[Drilldowns] <ActionWizard/> Component (#59032)

This commit is contained in:
Anton Dosov 2020-03-06 19:46:47 +01:00 committed by GitHub
parent fbbb3f8091
commit 3c4cf56008
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 543 additions and 1 deletions

View file

@ -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) {

View file

@ -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',
};

View file

@ -0,0 +1,10 @@
.auaActionWizard__selectedActionFactoryContainer {
background-color: $euiColorLightestShade;
padding: $euiSize;
}
.auaActionWizard__actionFactoryItem {
.euiKeyPadMenuItem__label {
height: #{$euiSizeXL};
}
}

View file

@ -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,
]}
/>
));

View file

@ -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();
});

View file

@ -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>
);
};

View file

@ -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',
}
);

View file

@ -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';

View file

@ -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>
</>
);
}

View 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')],
});