Skip to content

Commit

Permalink
[Drilldowns] <ActionWizard/> Component (#59032) (#59579)
Browse files Browse the repository at this point in the history
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
Dosant and elasticmachine authored Mar 9, 2020
1 parent 0c57aa8 commit 84a7642
Show file tree
Hide file tree
Showing 10 changed files with 543 additions and 1 deletion.
35 changes: 34 additions & 1 deletion packages/kbn-storybook/storybook_config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/dev/storybook/aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.auaActionWizard__selectedActionFactoryContainer {
background-color: $euiColorLightestShade;
padding: $euiSize;
}

.auaActionWizard__actionFactoryItem {
.euiKeyPadMenuItem__label {
height: #{$euiSizeXL};
}
}
Original file line number Diff line number Diff line change
@@ -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,
]}
/>
));
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -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',
}
);
Original file line number Diff line number Diff line 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';
Loading

0 comments on commit 84a7642

Please sign in to comment.