mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Drilldowns] <ActionWizard/> Component (#59032)
This commit is contained in:
parent
fbbb3f8091
commit
3c4cf56008
10 changed files with 543 additions and 1 deletions
|
@ -19,6 +19,7 @@
|
|||
|
||||
const { resolve } = require('path');
|
||||
const webpack = require('webpack');
|
||||
const { stringifyRequest } = require('loader-utils');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const { REPO_ROOT, DLL_DIST_DIR } = require('../lib/constants');
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
|
@ -72,6 +73,38 @@ module.exports = async ({ config }) => {
|
|||
],
|
||||
});
|
||||
|
||||
// Enable SASS
|
||||
config.module.rules.push({
|
||||
test: /\.scss$/,
|
||||
exclude: /\.module.(s(a|c)ss)$/,
|
||||
use: [
|
||||
{ loader: 'style-loader' },
|
||||
{ loader: 'css-loader', options: { importLoaders: 2 } },
|
||||
{
|
||||
loader: 'postcss-loader',
|
||||
options: {
|
||||
config: {
|
||||
path: resolve(REPO_ROOT, 'src/optimize/'),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: 'sass-loader',
|
||||
options: {
|
||||
prependData(loaderContext) {
|
||||
return `@import ${stringifyRequest(
|
||||
loaderContext,
|
||||
resolve(REPO_ROOT, 'src/legacy/ui/public/styles/_styling_constants.scss')
|
||||
)};\n`;
|
||||
},
|
||||
sassOptions: {
|
||||
includePaths: [resolve(REPO_ROOT, 'node_modules')],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Reference the built DLL file of static(ish) dependencies, which are removed
|
||||
// during kbn:bootstrap and rebuilt if missing.
|
||||
config.plugins.push(
|
||||
|
@ -96,7 +129,7 @@ module.exports = async ({ config }) => {
|
|||
);
|
||||
|
||||
// Tell Webpack about the ts/x extensions
|
||||
config.resolve.extensions.push('.ts', '.tsx');
|
||||
config.resolve.extensions.push('.ts', '.tsx', '.scss');
|
||||
|
||||
// Load custom Webpack config specified by a plugin.
|
||||
if (currentConfig.webpackHook) {
|
||||
|
|
|
@ -25,4 +25,5 @@ export const storybookAliases = {
|
|||
embeddable: 'src/plugins/embeddable/scripts/storybook.js',
|
||||
infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js',
|
||||
siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js',
|
||||
ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js',
|
||||
};
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
.auaActionWizard__selectedActionFactoryContainer {
|
||||
background-color: $euiColorLightestShade;
|
||||
padding: $euiSize;
|
||||
}
|
||||
|
||||
.auaActionWizard__actionFactoryItem {
|
||||
.euiKeyPadMenuItem__label {
|
||||
height: #{$euiSizeXL};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data';
|
||||
|
||||
storiesOf('components/ActionWizard', module)
|
||||
.add('default', () => (
|
||||
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
|
||||
))
|
||||
.add('Only one factory is available', () => (
|
||||
// to make sure layout doesn't break
|
||||
<Demo actionFactories={[dashboardDrilldownActionFactory]} />
|
||||
))
|
||||
.add('Long list of action factories', () => (
|
||||
// to make sure layout doesn't break
|
||||
<Demo
|
||||
actionFactories={[
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
]}
|
||||
/>
|
||||
));
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
||||
import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global
|
||||
import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard';
|
||||
import {
|
||||
dashboardDrilldownActionFactory,
|
||||
dashboards,
|
||||
Demo,
|
||||
urlDrilldownActionFactory,
|
||||
} from './test_data';
|
||||
|
||||
// TODO: afterEach is not available for it globally during setup
|
||||
// https://github.com/elastic/kibana/issues/59469
|
||||
afterEach(cleanup);
|
||||
|
||||
test('Pick and configure action', () => {
|
||||
const screen = render(
|
||||
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
|
||||
);
|
||||
|
||||
// check that all factories are displayed to pick
|
||||
expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2);
|
||||
|
||||
// select URL one
|
||||
fireEvent.click(screen.getByText(/Go to URL/i));
|
||||
|
||||
// Input url
|
||||
const URL = 'https://elastic.co';
|
||||
fireEvent.change(screen.getByLabelText(/url/i), {
|
||||
target: { value: URL },
|
||||
});
|
||||
|
||||
// change to dashboard
|
||||
fireEvent.click(screen.getByText(/change/i));
|
||||
fireEvent.click(screen.getByText(/Go to Dashboard/i));
|
||||
|
||||
// Select dashboard
|
||||
fireEvent.change(screen.getByLabelText(/Choose destination dashboard/i), {
|
||||
target: { value: dashboards[1].id },
|
||||
});
|
||||
});
|
||||
|
||||
test('If only one actions factory is available then actionFactory selection is emitted without user input', () => {
|
||||
const screen = render(<Demo actionFactories={[urlDrilldownActionFactory]} />);
|
||||
|
||||
// check that no factories are displayed to pick from
|
||||
expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(TEST_SUBJ_SELECTED_ACTION_FACTORY)).toBeInTheDocument();
|
||||
|
||||
// Input url
|
||||
const URL = 'https://elastic.co';
|
||||
fireEvent.change(screen.getByLabelText(/url/i), {
|
||||
target: { value: URL },
|
||||
});
|
||||
|
||||
// check that can't change to action factory type
|
||||
expect(screen.queryByTestId(/change/i)).not.toBeInTheDocument();
|
||||
});
|
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiKeyPadMenuItemButton,
|
||||
} from '@elastic/eui';
|
||||
import { txtChangeButton } from './i18n';
|
||||
import './action_wizard.scss';
|
||||
|
||||
// TODO: this interface is temporary for just moving forward with the component
|
||||
// and it will be imported from the ../ui_actions when implemented properly
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type ActionBaseConfig = {};
|
||||
export interface ActionFactory<Config extends ActionBaseConfig = ActionBaseConfig> {
|
||||
type: string; // TODO: type should be tied to Action and ActionByType
|
||||
displayName: string;
|
||||
iconType?: string;
|
||||
wizard: React.FC<ActionFactoryWizardProps<Config>>;
|
||||
createConfig: () => Config;
|
||||
isValid: (config: Config) => boolean;
|
||||
}
|
||||
|
||||
export interface ActionFactoryWizardProps<Config extends ActionBaseConfig> {
|
||||
config?: Config;
|
||||
|
||||
/**
|
||||
* Callback called when user updates the config in UI.
|
||||
*/
|
||||
onConfig: (config: Config) => void;
|
||||
}
|
||||
|
||||
export interface ActionWizardProps {
|
||||
/**
|
||||
* List of available action factories
|
||||
*/
|
||||
actionFactories: Array<ActionFactory<any>>; // any here to be able to pass array of ActionFactory<Config> with different configs
|
||||
|
||||
/**
|
||||
* Currently selected action factory
|
||||
* undefined - is allowed and means that non is selected
|
||||
*/
|
||||
currentActionFactory?: ActionFactory;
|
||||
/**
|
||||
* Action factory selected changed
|
||||
* null - means user click "change" and removed action factory selection
|
||||
*/
|
||||
onActionFactoryChange: (actionFactory: ActionFactory | null) => void;
|
||||
|
||||
/**
|
||||
* current config for currently selected action factory
|
||||
*/
|
||||
config?: ActionBaseConfig;
|
||||
|
||||
/**
|
||||
* config changed
|
||||
*/
|
||||
onConfigChange: (config: ActionBaseConfig) => void;
|
||||
}
|
||||
|
||||
export const ActionWizard: React.FC<ActionWizardProps> = ({
|
||||
currentActionFactory,
|
||||
actionFactories,
|
||||
onActionFactoryChange,
|
||||
onConfigChange,
|
||||
config,
|
||||
}) => {
|
||||
// auto pick action factory if there is only 1 available
|
||||
if (!currentActionFactory && actionFactories.length === 1) {
|
||||
onActionFactoryChange(actionFactories[0]);
|
||||
}
|
||||
|
||||
if (currentActionFactory && config) {
|
||||
return (
|
||||
<SelectedActionFactory
|
||||
actionFactory={currentActionFactory}
|
||||
showDeselect={actionFactories.length > 1}
|
||||
onDeselect={() => {
|
||||
onActionFactoryChange(null);
|
||||
}}
|
||||
config={config}
|
||||
onConfigChange={newConfig => {
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionFactorySelector
|
||||
actionFactories={actionFactories}
|
||||
onActionFactorySelected={actionFactory => {
|
||||
onActionFactoryChange(actionFactory);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectedActionFactoryProps<Config extends ActionBaseConfig = ActionBaseConfig> {
|
||||
actionFactory: ActionFactory<Config>;
|
||||
config: Config;
|
||||
onConfigChange: (config: Config) => void;
|
||||
showDeselect: boolean;
|
||||
onDeselect: () => void;
|
||||
}
|
||||
|
||||
export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selected-action-factory';
|
||||
|
||||
const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
|
||||
actionFactory,
|
||||
onDeselect,
|
||||
showDeselect,
|
||||
onConfigChange,
|
||||
config,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="auaActionWizard__selectedActionFactoryContainer"
|
||||
data-test-subj={TEST_SUBJ_SELECTED_ACTION_FACTORY}
|
||||
data-testid={TEST_SUBJ_SELECTED_ACTION_FACTORY}
|
||||
>
|
||||
<header>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
{actionFactory.iconType && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={actionFactory.iconType} size="m" />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiText>
|
||||
<h4>{actionFactory.displayName}</h4>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
{showDeselect && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty size="s" onClick={() => onDeselect()}>
|
||||
{txtChangeButton}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</header>
|
||||
<EuiSpacer size="m" />
|
||||
<div>
|
||||
{actionFactory.wizard({
|
||||
config,
|
||||
onConfig: onConfigChange,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ActionFactorySelectorProps {
|
||||
actionFactories: ActionFactory[];
|
||||
onActionFactorySelected: (actionFactory: ActionFactory) => void;
|
||||
}
|
||||
|
||||
export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item';
|
||||
|
||||
const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
|
||||
actionFactories,
|
||||
onActionFactorySelected,
|
||||
}) => {
|
||||
if (actionFactories.length === 0) {
|
||||
// this is not user facing, as it would be impossible to get into this state
|
||||
// just leaving for dev purposes for troubleshooting
|
||||
return <div>No action factories to pick from</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup wrap>
|
||||
{actionFactories.map(actionFactory => (
|
||||
<EuiKeyPadMenuItemButton
|
||||
className="auaActionWizard__actionFactoryItem"
|
||||
key={actionFactory.type}
|
||||
label={actionFactory.displayName}
|
||||
data-testid={TEST_SUBJ_ACTION_FACTORY_ITEM}
|
||||
data-test-subj={TEST_SUBJ_ACTION_FACTORY_ITEM}
|
||||
onClick={() => onActionFactorySelected(actionFactory)}
|
||||
>
|
||||
{actionFactory.iconType && <EuiIcon type={actionFactory.iconType} size="m" />}
|
||||
</EuiKeyPadMenuItemButton>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const txtChangeButton = i18n.translate(
|
||||
'xpack.advancedUiActions.components.actionWizard.changeButton',
|
||||
{
|
||||
defaultMessage: 'change',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { ActionFactory, ActionWizard } from './action_wizard';
|
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui';
|
||||
import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard';
|
||||
|
||||
export const dashboards = [
|
||||
{ id: 'dashboard1', title: 'Dashboard 1' },
|
||||
{ id: 'dashboard2', title: 'Dashboard 2' },
|
||||
];
|
||||
|
||||
export const dashboardDrilldownActionFactory: ActionFactory<{
|
||||
dashboardId?: string;
|
||||
useCurrentDashboardFilters: boolean;
|
||||
useCurrentDashboardDataRange: boolean;
|
||||
}> = {
|
||||
type: 'Dashboard',
|
||||
displayName: 'Go to Dashboard',
|
||||
iconType: 'dashboardApp',
|
||||
createConfig: () => {
|
||||
return {
|
||||
dashboardId: undefined,
|
||||
useCurrentDashboardDataRange: true,
|
||||
useCurrentDashboardFilters: true,
|
||||
};
|
||||
},
|
||||
isValid: config => {
|
||||
if (!config.dashboardId) return false;
|
||||
return true;
|
||||
},
|
||||
wizard: props => {
|
||||
const config = props.config ?? {
|
||||
dashboardId: undefined,
|
||||
useCurrentDashboardDataRange: true,
|
||||
useCurrentDashboardFilters: true,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow label="Choose destination dashboard:">
|
||||
<EuiSelect
|
||||
name="selectDashboard"
|
||||
hasNoInitialSelection={true}
|
||||
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
|
||||
value={config.dashboardId}
|
||||
onChange={e => {
|
||||
props.onConfig({ ...config, dashboardId: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="useCurrentFilters"
|
||||
label="Use current dashboard's filters"
|
||||
checked={config.useCurrentDashboardFilters}
|
||||
onChange={() =>
|
||||
props.onConfig({
|
||||
...config,
|
||||
useCurrentDashboardFilters: !config.useCurrentDashboardFilters,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="useCurrentDateRange"
|
||||
label="Use current dashboard's date range"
|
||||
checked={config.useCurrentDashboardDataRange}
|
||||
onChange={() =>
|
||||
props.onConfig({
|
||||
...config,
|
||||
useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = {
|
||||
type: 'Url',
|
||||
displayName: 'Go to URL',
|
||||
iconType: 'link',
|
||||
createConfig: () => {
|
||||
return {
|
||||
url: '',
|
||||
openInNewTab: false,
|
||||
};
|
||||
},
|
||||
isValid: config => {
|
||||
if (!config.url) return false;
|
||||
return true;
|
||||
},
|
||||
wizard: props => {
|
||||
const config = props.config ?? {
|
||||
url: '',
|
||||
openInNewTab: false,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow label="Enter target URL">
|
||||
<EuiFieldText
|
||||
placeholder="Enter URL"
|
||||
name="url"
|
||||
value={config.url}
|
||||
onChange={event => props.onConfig({ ...config, url: event.target.value })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="openInNewTab"
|
||||
label="Open in new tab?"
|
||||
checked={config.openInNewTab}
|
||||
onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) {
|
||||
const [state, setState] = useState<{
|
||||
currentActionFactory?: ActionFactory;
|
||||
config?: ActionBaseConfig;
|
||||
}>({});
|
||||
|
||||
function changeActionFactory(newActionFactory: ActionFactory | null) {
|
||||
if (!newActionFactory) {
|
||||
// removing action factory
|
||||
return setState({});
|
||||
}
|
||||
|
||||
setState({
|
||||
currentActionFactory: newActionFactory,
|
||||
config: newActionFactory.createConfig(),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionWizard
|
||||
actionFactories={actionFactories}
|
||||
config={state.config}
|
||||
onConfigChange={newConfig => {
|
||||
setState({
|
||||
...state,
|
||||
config: newConfig,
|
||||
});
|
||||
}}
|
||||
onActionFactoryChange={newActionFactory => {
|
||||
changeActionFactory(newActionFactory);
|
||||
}}
|
||||
currentActionFactory={state.currentActionFactory}
|
||||
/>
|
||||
<div style={{ marginTop: '44px' }} />
|
||||
<hr />
|
||||
<div>Action Factory Type: {state.currentActionFactory?.type}</div>
|
||||
<div>Action Factory Config: {JSON.stringify(state.config)}</div>
|
||||
<div>
|
||||
Is config valid:{' '}
|
||||
{JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
13
x-pack/plugins/advanced_ui_actions/scripts/storybook.js
Normal file
13
x-pack/plugins/advanced_ui_actions/scripts/storybook.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
|
||||
// eslint-disable-next-line
|
||||
require('@kbn/storybook').runStorybookCli({
|
||||
name: 'advanced_ui_actions',
|
||||
storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')],
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue