diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a3d884c01b43..5948b9672e6d4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,7 +5,6 @@ # App /x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app -/src/plugins/share/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app /src/legacy/server/sample_data/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-app @@ -27,6 +26,7 @@ /src/plugins/kibana_legacy/ @elastic/kibana-app /src/plugins/timelion/ @elastic/kibana-app /src/plugins/dev_tools/ @elastic/kibana-app +/src/plugins/dashboard_embeddable_container/ @elastic/kibana-app # App Architecture /packages/kbn-interpreter/ @elastic/kibana-app-arch @@ -42,7 +42,6 @@ /src/legacy/core_plugins/visualizations/ @elastic/kibana-app-arch /src/legacy/server/index_patterns/ @elastic/kibana-app-arch /src/plugins/bfetch/ @elastic/kibana-app-arch -/src/plugins/dashboard_embeddable_container/ @elastic/kibana-app-arch /src/plugins/data/ @elastic/kibana-app-arch /src/plugins/embeddable/ @elastic/kibana-app-arch /src/plugins/expressions/ @elastic/kibana-app-arch @@ -53,6 +52,9 @@ /src/plugins/navigation/ @elastic/kibana-app-arch /src/plugins/ui_actions/ @elastic/kibana-app-arch /src/plugins/visualizations/ @elastic/kibana-app-arch +/src/plugins/share/ @elastic/kibana-app-arch +/examples/url_generators_examples/ @elastic/kibana-app-arch +/examples/url_generators_explorer/ @elastic/kibana-app-arch /x-pack/plugins/advanced_ui_actions/ @elastic/kibana-app-arch /x-pack/plugins/drilldowns/ @elastic/kibana-app-arch diff --git a/examples/ui_action_examples/public/hello_world_action.tsx b/examples/ui_action_examples/public/hello_world_action.tsx index f4c3bfeee6a6d..da20f40464516 100644 --- a/examples/ui_action_examples/public/hello_world_action.tsx +++ b/examples/ui_action_examples/public/hello_world_action.tsx @@ -22,7 +22,7 @@ import { OverlayStart } from '../../../src/core/public'; import { createAction } from '../../../src/plugins/ui_actions/public'; import { toMountPoint } from '../../../src/plugins/kibana_react/public'; -export const HELLO_WORLD_ACTION_TYPE = 'HELLO_WORLD_ACTION_TYPE'; +export const ACTION_HELLO_WORLD = 'ACTION_HELLO_WORLD'; interface StartServices { openModal: OverlayStart['openModal']; @@ -30,7 +30,7 @@ interface StartServices { export const createHelloWorldAction = (getStartServices: () => Promise) => createAction({ - type: HELLO_WORLD_ACTION_TYPE, + type: ACTION_HELLO_WORLD, getDisplayName: () => 'Hello World!', execute: async () => { const { openModal } = await getStartServices(); diff --git a/examples/ui_action_examples/public/index.ts b/examples/ui_action_examples/public/index.ts index 9dce2191d2670..88a36d278e256 100644 --- a/examples/ui_action_examples/public/index.ts +++ b/examples/ui_action_examples/public/index.ts @@ -23,4 +23,4 @@ import { PluginInitializer } from '../../../src/core/public'; export const plugin: PluginInitializer = () => new UiActionExamplesPlugin(); export { HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger'; -export { HELLO_WORLD_ACTION_TYPE } from './hello_world_action'; +export { ACTION_HELLO_WORLD } from './hello_world_action'; diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index 08b65714dbf66..c47746d4b3fd6 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -19,7 +19,7 @@ import { Plugin, CoreSetup } from '../../../src/core/public'; import { UiActionsSetup } from '../../../src/plugins/ui_actions/public'; -import { createHelloWorldAction } from './hello_world_action'; +import { createHelloWorldAction, ACTION_HELLO_WORLD } from './hello_world_action'; import { helloWorldTrigger, HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger'; interface UiActionExamplesSetupDependencies { @@ -28,7 +28,11 @@ interface UiActionExamplesSetupDependencies { declare module '../../../src/plugins/ui_actions/public' { export interface TriggerContextMapping { - [HELLO_WORLD_TRIGGER_ID]: undefined; + [HELLO_WORLD_TRIGGER_ID]: {}; + } + + export interface ActionContextMapping { + [ACTION_HELLO_WORLD]: {}; } } @@ -42,7 +46,7 @@ export class UiActionExamplesPlugin })); uiActions.registerAction(helloWorldAction); - uiActions.attachAction(helloWorldTrigger.id, helloWorldAction.id); + uiActions.attachAction(helloWorldTrigger.id, helloWorldAction); } public start() {} diff --git a/examples/ui_actions_explorer/public/actions/actions.tsx b/examples/ui_actions_explorer/public/actions/actions.tsx index 2770b0e3bd5ff..64a820ab6d194 100644 --- a/examples/ui_actions_explorer/public/actions/actions.tsx +++ b/examples/ui_actions_explorer/public/actions/actions.tsx @@ -27,44 +27,48 @@ export const USER_TRIGGER = 'USER_TRIGGER'; export const COUNTRY_TRIGGER = 'COUNTRY_TRIGGER'; export const PHONE_TRIGGER = 'PHONE_TRIGGER'; -export const VIEW_IN_MAPS_ACTION = 'VIEW_IN_MAPS_ACTION'; -export const TRAVEL_GUIDE_ACTION = 'TRAVEL_GUIDE_ACTION'; -export const CALL_PHONE_NUMBER_ACTION = 'CALL_PHONE_NUMBER_ACTION'; -export const EDIT_USER_ACTION = 'EDIT_USER_ACTION'; -export const PHONE_USER_ACTION = 'PHONE_USER_ACTION'; -export const SHOWCASE_PLUGGABILITY_ACTION = 'SHOWCASE_PLUGGABILITY_ACTION'; +export const ACTION_VIEW_IN_MAPS = 'ACTION_VIEW_IN_MAPS'; +export const ACTION_TRAVEL_GUIDE = 'ACTION_TRAVEL_GUIDE'; +export const ACTION_CALL_PHONE_NUMBER = 'ACTION_CALL_PHONE_NUMBER'; +export const ACTION_EDIT_USER = 'ACTION_EDIT_USER'; +export const ACTION_PHONE_USER = 'ACTION_PHONE_USER'; +export const ACTION_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY'; -export const showcasePluggability = createAction({ - type: SHOWCASE_PLUGGABILITY_ACTION, +export const showcasePluggability = createAction({ + type: ACTION_SHOWCASE_PLUGGABILITY, getDisplayName: () => 'This is pluggable! Any plugin can inject their actions here.', execute: async () => alert("Isn't that cool?!"), }); -export type PhoneContext = string; +export interface PhoneContext { + phone: string; +} -export const makePhoneCallAction = createAction({ - type: CALL_PHONE_NUMBER_ACTION, +export const makePhoneCallAction = createAction({ + type: ACTION_CALL_PHONE_NUMBER, getDisplayName: () => 'Call phone number', - execute: async phone => alert(`Pretend calling ${phone}...`), + execute: async context => alert(`Pretend calling ${context.phone}...`), }); -export const lookUpWeatherAction = createAction<{ country: string }>({ - type: TRAVEL_GUIDE_ACTION, +export const lookUpWeatherAction = createAction({ + type: ACTION_TRAVEL_GUIDE, getIconType: () => 'popout', getDisplayName: () => 'View travel guide', - execute: async ({ country }) => { - window.open(`https://www.worldtravelguide.net/?s=${country},`, '_blank'); + execute: async context => { + window.open(`https://www.worldtravelguide.net/?s=${context.country}`, '_blank'); }, }); -export type CountryContext = string; +export interface CountryContext { + country: string; +} -export const viewInMapsAction = createAction({ - type: VIEW_IN_MAPS_ACTION, +export const viewInMapsAction = createAction({ + type: ACTION_VIEW_IN_MAPS, getIconType: () => 'popout', getDisplayName: () => 'View in maps', - execute: async country => { - window.open(`https://www.google.com/maps/place/${country}`, '_blank'); + execute: async context => { + window.open(`https://www.google.com/maps/place/${context.country}`, '_blank'); }, }); @@ -100,11 +104,8 @@ function EditUserModal({ } export const createEditUserAction = (getOpenModal: () => Promise) => - createAction<{ - user: User; - update: (user: User) => void; - }>({ - type: EDIT_USER_ACTION, + createAction({ + type: ACTION_EDIT_USER, getIconType: () => 'pencil', getDisplayName: () => 'Edit user', execute: async ({ user, update }) => { @@ -120,8 +121,8 @@ export interface UserContext { } export const createPhoneUserAction = (getUiActionsApi: () => Promise) => - createAction({ - type: PHONE_USER_ACTION, + createAction({ + type: ACTION_PHONE_USER, getDisplayName: () => 'Call phone number', isCompatible: async ({ user }) => user.phone !== undefined, execute: async ({ user }) => { @@ -133,7 +134,7 @@ export const createPhoneUserAction = (getUiActionsApi: () => Promise { uiActionsApi.executeTriggerActions(HELLO_WORLD_TRIGGER_ID, undefined)} + onClick={() => uiActionsApi.executeTriggerActions(HELLO_WORLD_TRIGGER_ID, {})} > Say hello world! @@ -76,8 +76,9 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { { - const dynamicAction = createAction<{}>({ - type: `${HELLO_WORLD_ACTION_TYPE}-${name}`, + const dynamicAction = createAction({ + id: `${ACTION_HELLO_WORLD}-${name}`, + type: ACTION_HELLO_WORLD, getDisplayName: () => `Say hello to ${name}`, execute: async () => { const overlay = openModal( @@ -95,7 +96,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { }, }); uiActionsApi.registerAction(dynamicAction); - uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction.type); + uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( `You've successfully added a new action: ${dynamicAction.getDisplayName( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index fecada71099e8..f1895905a45e1 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -27,17 +27,17 @@ import { lookUpWeatherAction, viewInMapsAction, createEditUserAction, - CALL_PHONE_NUMBER_ACTION, - VIEW_IN_MAPS_ACTION, - TRAVEL_GUIDE_ACTION, - PHONE_USER_ACTION, - EDIT_USER_ACTION, makePhoneCallAction, showcasePluggability, - SHOWCASE_PLUGGABILITY_ACTION, UserContext, CountryContext, PhoneContext, + ACTION_EDIT_USER, + ACTION_SHOWCASE_PLUGGABILITY, + ACTION_CALL_PHONE_NUMBER, + ACTION_TRAVEL_GUIDE, + ACTION_VIEW_IN_MAPS, + ACTION_PHONE_USER, } from './actions/actions'; interface StartDeps { @@ -54,6 +54,15 @@ declare module '../../../src/plugins/ui_actions/public' { [COUNTRY_TRIGGER]: CountryContext; [PHONE_TRIGGER]: PhoneContext; } + + export interface ActionContextMapping { + [ACTION_EDIT_USER]: UserContext; + [ACTION_SHOWCASE_PLUGGABILITY]: {}; + [ACTION_CALL_PHONE_NUMBER]: PhoneContext; + [ACTION_TRAVEL_GUIDE]: CountryContext; + [ACTION_VIEW_IN_MAPS]: CountryContext; + [ACTION_PHONE_USER]: UserContext; + } } export class UiActionsExplorerPlugin implements Plugin { @@ -67,29 +76,24 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) ); - deps.uiActions.registerAction( + deps.uiActions.attachAction( + USER_TRIGGER, createEditUserAction(async () => (await startServices)[0].overlays.openModal) ); - deps.uiActions.attachAction(USER_TRIGGER, PHONE_USER_ACTION); - deps.uiActions.attachAction(USER_TRIGGER, EDIT_USER_ACTION); - // What's missing here is type analysis to ensure the context emitted by the trigger - // is the same context that the action requires. - deps.uiActions.attachAction(COUNTRY_TRIGGER, VIEW_IN_MAPS_ACTION); - deps.uiActions.attachAction(COUNTRY_TRIGGER, TRAVEL_GUIDE_ACTION); - deps.uiActions.attachAction(COUNTRY_TRIGGER, SHOWCASE_PLUGGABILITY_ACTION); - deps.uiActions.attachAction(PHONE_TRIGGER, CALL_PHONE_NUMBER_ACTION); - deps.uiActions.attachAction(PHONE_TRIGGER, SHOWCASE_PLUGGABILITY_ACTION); - deps.uiActions.attachAction(USER_TRIGGER, SHOWCASE_PLUGGABILITY_ACTION); + deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction); + deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction); + deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction); + deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability); core.application.register({ id: 'uiActionsExplorer', diff --git a/examples/ui_actions_explorer/public/trigger_context_example.tsx b/examples/ui_actions_explorer/public/trigger_context_example.tsx index 00d974e938138..4b88652103966 100644 --- a/examples/ui_actions_explorer/public/trigger_context_example.tsx +++ b/examples/ui_actions_explorer/public/trigger_context_example.tsx @@ -47,7 +47,7 @@ const createRowData = ( { - uiActionsApi.executeTriggerActions(COUNTRY_TRIGGER, user.countryOfResidence); + uiActionsApi.executeTriggerActions(COUNTRY_TRIGGER, { country: user.countryOfResidence }); }} > {user.countryOfResidence} @@ -59,7 +59,7 @@ const createRowData = ( { - uiActionsApi.executeTriggerActions(PHONE_TRIGGER, user.phone!); + uiActionsApi.executeTriggerActions(PHONE_TRIGGER, { phone: user.phone! }); }} > {user.phone} diff --git a/examples/url_generators_examples/README.md b/examples/url_generators_examples/README.md new file mode 100644 index 0000000000000..facd5c90c8c96 --- /dev/null +++ b/examples/url_generators_examples/README.md @@ -0,0 +1,7 @@ +## Access links examples + +This example app shows how to: + - Register a direct access link generator. + - Handle migration of legacy generators into a new one. + +To run this example, use the command `yarn start --run-examples`. Navigate to the access links explorer app \ No newline at end of file diff --git a/examples/url_generators_examples/kibana.json b/examples/url_generators_examples/kibana.json new file mode 100644 index 0000000000000..0767018e3bb98 --- /dev/null +++ b/examples/url_generators_examples/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "urlGeneratorsExamples", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["url_generators_examples"], + "server": false, + "ui": true, + "requiredPlugins": ["share"], + "optionalPlugins": [] +} diff --git a/examples/url_generators_examples/package.json b/examples/url_generators_examples/package.json new file mode 100644 index 0000000000000..e07482db25f43 --- /dev/null +++ b/examples/url_generators_examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "url_generators_examples", + "version": "1.0.0", + "main": "target/examples/url_generators_examples", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/examples/url_generators_examples/public/app.tsx b/examples/url_generators_examples/public/app.tsx new file mode 100644 index 0000000000000..c39cd876ea9b1 --- /dev/null +++ b/examples/url_generators_examples/public/app.tsx @@ -0,0 +1,89 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { EuiPageBody } from '@elastic/eui'; +import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody } from '@elastic/eui'; +import { Route, Switch, Redirect, Router, useLocation } from 'react-router-dom'; +import { createBrowserHistory } from 'history'; +import { EuiText } from '@elastic/eui'; +import { AppMountParameters } from '../../../src/core/public'; + +function useQuery() { + const { search } = useLocation(); + const params = React.useMemo(() => new URLSearchParams(search), [search]); + return params; +} + +interface HelloPageProps { + firstName: string; + lastName: string; +} + +const HelloPage = ({ firstName, lastName }: HelloPageProps) => ( + {`Hello ${firstName} ${lastName}`} +); + +export const Routes: React.FC<{}> = () => { + const query = useQuery(); + + return ( + + + + + + + + + + + + + ); +}; + +export const LinksExample: React.FC<{ + appBasePath: string; +}> = props => { + const history = React.useMemo( + () => + createBrowserHistory({ + basename: props.appBasePath, + }), + [props.appBasePath] + ); + return ( + + + + ); +}; + +export const renderApp = (props: { appBasePath: string }, { element }: AppMountParameters) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/url_generators_examples/public/index.ts b/examples/url_generators_examples/public/index.ts new file mode 100644 index 0000000000000..e87f9237bff38 --- /dev/null +++ b/examples/url_generators_examples/public/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AccessLinksExamplesPlugin } from './plugin'; + +export const plugin = () => new AccessLinksExamplesPlugin(); diff --git a/examples/url_generators_examples/public/plugin.tsx b/examples/url_generators_examples/public/plugin.tsx new file mode 100644 index 0000000000000..016494037ec05 --- /dev/null +++ b/examples/url_generators_examples/public/plugin.tsx @@ -0,0 +1,76 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SharePluginStart, SharePluginSetup } from '../../../src/plugins/share/public'; +import { Plugin, CoreSetup, AppMountParameters } from '../../../src/core/public'; +import { + HelloLinkGeneratorState, + createHelloPageLinkGenerator, + LegacyHelloLinkGeneratorState, + HELLO_URL_GENERATOR_V1, + HELLO_URL_GENERATOR, + helloPageLinkGeneratorV1, +} from './url_generator'; + +declare module '../../../src/plugins/share/public' { + export interface UrlGeneratorStateMapping { + [HELLO_URL_GENERATOR_V1]: LegacyHelloLinkGeneratorState; + [HELLO_URL_GENERATOR]: HelloLinkGeneratorState; + } +} + +interface StartDeps { + share: SharePluginStart; +} + +interface SetupDeps { + share: SharePluginSetup; +} + +const APP_ID = 'urlGeneratorsExamples'; + +export class AccessLinksExamplesPlugin implements Plugin { + public setup(core: CoreSetup, { share: { urlGenerators } }: SetupDeps) { + urlGenerators.registerUrlGenerator( + createHelloPageLinkGenerator(async () => ({ + appBasePath: (await core.getStartServices())[0].application.getUrlForApp(APP_ID), + })) + ); + + urlGenerators.registerUrlGenerator(helloPageLinkGeneratorV1); + + core.application.register({ + id: APP_ID, + title: 'Access links examples', + async mount(params: AppMountParameters) { + const { renderApp } = await import('./app'); + return renderApp( + { + appBasePath: params.appBasePath, + }, + params + ); + }, + }); + } + + public start() {} + + public stop() {} +} diff --git a/examples/url_generators_examples/public/url_generator.ts b/examples/url_generators_examples/public/url_generator.ts new file mode 100644 index 0000000000000..f21b1c9295e66 --- /dev/null +++ b/examples/url_generators_examples/public/url_generator.ts @@ -0,0 +1,78 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import url from 'url'; +import { UrlGeneratorState, UrlGeneratorsDefinition } from '../../../src/plugins/share/public'; + +/** + * The name of the latest variable can always stay the same so code that + * uses this link generator statically will switch to the latest version. + * Typescript will warn the developer if incorrect state is being passed + * down. + */ +export const HELLO_URL_GENERATOR = 'HELLO_URL_GENERATOR_V2'; + +export interface HelloLinkState { + firstName: string; + lastName: string; +} + +export type HelloLinkGeneratorState = UrlGeneratorState; + +export const createHelloPageLinkGenerator = ( + getStartServices: () => Promise<{ appBasePath: string }> +): UrlGeneratorsDefinition => ({ + id: HELLO_URL_GENERATOR, + createUrl: async state => { + const startServices = await getStartServices(); + const appBasePath = startServices.appBasePath; + const parsedUrl = url.parse(window.location.href); + + return url.format({ + protocol: parsedUrl.protocol, + host: parsedUrl.host, + pathname: `${appBasePath}/hello`, + query: { + ...state, + }, + }); + }, +}); + +/** + * The name of this legacy generator id changes, but the *value* stays the same. + */ +export const HELLO_URL_GENERATOR_V1 = 'HELLO_URL_GENERATOR'; + +export interface HelloLinkStateV1 { + name: string; +} + +export type LegacyHelloLinkGeneratorState = UrlGeneratorState< + HelloLinkStateV1, + typeof HELLO_URL_GENERATOR, + HelloLinkState +>; + +export const helloPageLinkGeneratorV1: UrlGeneratorsDefinition = { + id: HELLO_URL_GENERATOR_V1, + isDeprecated: true, + migrate: async state => { + return { id: HELLO_URL_GENERATOR, state: { firstName: state.name, lastName: '' } }; + }, +}; diff --git a/examples/url_generators_examples/tsconfig.json b/examples/url_generators_examples/tsconfig.json new file mode 100644 index 0000000000000..091130487791b --- /dev/null +++ b/examples/url_generators_examples/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../typings/**/*" + ], + "exclude": [] +} diff --git a/examples/url_generators_explorer/README.md b/examples/url_generators_explorer/README.md new file mode 100644 index 0000000000000..922cf37aff847 --- /dev/null +++ b/examples/url_generators_explorer/README.md @@ -0,0 +1,8 @@ +## Access links explorer + +This example app shows how to: + - Generate links to other applications + - Generate dynamic links, when the target application is not known + - Handle backward compatibility of urls + +To run this example, use the command `yarn start --run-examples`. \ No newline at end of file diff --git a/examples/url_generators_explorer/kibana.json b/examples/url_generators_explorer/kibana.json new file mode 100644 index 0000000000000..94ab75b338889 --- /dev/null +++ b/examples/url_generators_explorer/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "urlGeneratorsExplorer", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["url_generators_explorer"], + "server": false, + "ui": true, + "requiredPlugins": ["share", "urlGeneratorsExamples"], + "optionalPlugins": [] +} diff --git a/examples/url_generators_explorer/package.json b/examples/url_generators_explorer/package.json new file mode 100644 index 0000000000000..52da533dc0c05 --- /dev/null +++ b/examples/url_generators_explorer/package.json @@ -0,0 +1,17 @@ +{ + "name": "url_generators_explorer", + "version": "1.0.0", + "main": "target/examples/url_generators_explorer", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/examples/url_generators_explorer/public/app.tsx b/examples/url_generators_explorer/public/app.tsx new file mode 100644 index 0000000000000..77e804ae08c5f --- /dev/null +++ b/examples/url_generators_explorer/public/app.tsx @@ -0,0 +1,170 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; + +import { EuiPage } from '@elastic/eui'; + +import { EuiButton } from '@elastic/eui'; +import { EuiPageBody } from '@elastic/eui'; +import { EuiPageContent } from '@elastic/eui'; +import { EuiPageContentBody } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiPageHeader } from '@elastic/eui'; +import { EuiLink } from '@elastic/eui'; +import { AppMountParameters } from '../../../src/core/public'; +import { UrlGeneratorsService } from '../../../src/plugins/share/public'; +import { + HELLO_URL_GENERATOR, + HELLO_URL_GENERATOR_V1, +} from '../../url_generators_examples/public/url_generator'; + +interface Props { + getLinkGenerator: UrlGeneratorsService['getUrlGenerator']; +} + +interface MigratedLink { + isDeprecated: boolean; + linkText: string; + link: string; +} + +const ActionsExplorer = ({ getLinkGenerator }: Props) => { + const [migratedLinks, setMigratedLinks] = useState([] as MigratedLink[]); + const [buildingLinks, setBuildingLinks] = useState(false); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + /** + * Lets pretend we grabbed these links from a persistent store, like a saved object. + * Some of these links were created with older versions of the hello link generator. + * They use deprecated generator ids. + */ + const [persistedLinks, setPersistedLinks] = useState([ + { + id: HELLO_URL_GENERATOR_V1, + linkText: 'Say hello to Mary', + state: { + name: 'Mary', + }, + }, + { + id: HELLO_URL_GENERATOR, + linkText: 'Say hello to George', + state: { + firstName: 'George', + lastName: 'Washington', + }, + }, + ]); + + useEffect(() => { + setBuildingLinks(true); + + const updateLinks = async () => { + const updatedLinks = await Promise.all( + persistedLinks.map(async savedLink => { + const generator = getLinkGenerator(savedLink.id); + const link = await generator.createUrl(savedLink.state); + return { + isDeprecated: generator.isDeprecated, + linkText: savedLink.linkText, + link, + }; + }) + ); + setMigratedLinks(updatedLinks); + setBuildingLinks(false); + }; + + updateLinks(); + }, [getLinkGenerator, persistedLinks]); + + return ( + + + Access links explorer + + + +

Create new links using the most recent version of a url generator.

+
+ { + setFirstName(e.target.value); + }} + /> + setLastName(e.target.value)} /> + + setPersistedLinks([ + ...persistedLinks, + { + id: HELLO_URL_GENERATOR, + state: { firstName, lastName }, + linkText: `Say hello to ${firstName} ${lastName}`, + }, + ]) + } + > + Add new link + + + + +

+ Existing links retrieved from storage. The links that were generated from legacy + generators are in red. This can be useful for developers to know they will have to + migrate persisted state or in a future version of Kibana, these links may no longer + work. They still work now because legacy url generators must provide a state + migration function. +

+
+ {buildingLinks ? ( +
loading...
+ ) : ( + migratedLinks.map(link => ( + + + {link.linkText} + +
+
+ )) + )} +
+
+
+
+ ); +}; + +export const renderApp = (props: Props, { element }: AppMountParameters) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/url_generators_explorer/public/index.ts b/examples/url_generators_explorer/public/index.ts new file mode 100644 index 0000000000000..30ff481dbe3a5 --- /dev/null +++ b/examples/url_generators_explorer/public/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AccessLinksExplorerPlugin } from './plugin'; + +export const plugin = () => new AccessLinksExplorerPlugin(); diff --git a/examples/url_generators_explorer/public/page.tsx b/examples/url_generators_explorer/public/page.tsx new file mode 100644 index 0000000000000..90bea35804822 --- /dev/null +++ b/examples/url_generators_explorer/public/page.tsx @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; + +import { + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +interface PageProps { + title: string; + children: React.ReactNode; +} + +export function Page({ title, children }: PageProps) { + return ( + + + + +

{title}

+
+
+
+ + {children} + +
+ ); +} diff --git a/examples/url_generators_explorer/public/plugin.tsx b/examples/url_generators_explorer/public/plugin.tsx new file mode 100644 index 0000000000000..1fe70476b8e79 --- /dev/null +++ b/examples/url_generators_explorer/public/plugin.tsx @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SharePluginStart } from 'src/plugins/share/public'; +import { Plugin, CoreSetup, AppMountParameters } from '../../../src/core/public'; + +interface StartDeps { + share: SharePluginStart; +} + +export class AccessLinksExplorerPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'urlGeneratorsExplorer', + title: 'Access links explorer', + async mount(params: AppMountParameters) { + const depsStart = (await core.getStartServices())[1]; + const { renderApp } = await import('./app'); + return renderApp( + { + getLinkGenerator: depsStart.share.urlGenerators.getUrlGenerator, + }, + params + ); + }, + }); + } + + public start() {} + + public stop() {} +} diff --git a/examples/url_generators_explorer/tsconfig.json b/examples/url_generators_explorer/tsconfig.json new file mode 100644 index 0000000000000..091130487791b --- /dev/null +++ b/examples/url_generators_explorer/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../typings/**/*" + ], + "exclude": [] +} diff --git a/src/legacy/core_plugins/data/public/actions/select_range_action.ts b/src/legacy/core_plugins/data/public/actions/select_range_action.ts index 7f1c5d78ab800..21046f8bb834f 100644 --- a/src/legacy/core_plugins/data/public/actions/select_range_action.ts +++ b/src/legacy/core_plugins/data/public/actions/select_range_action.ts @@ -19,21 +19,21 @@ import { i18n } from '@kbn/i18n'; import { - Action, createAction, IncompatibleActionError, + ActionByType, } from '../../../../../plugins/ui_actions/public'; import { onBrushEvent } from './filters/brush_event'; import { FilterManager, TimefilterContract, esFilters } from '../../../../../plugins/data/public'; -export const SELECT_RANGE_ACTION = 'SELECT_RANGE_ACTION'; +export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE'; -interface ActionContext { +export interface SelectRangeActionContext { data: any; timeFieldName: string; } -async function isCompatible(context: ActionContext) { +async function isCompatible(context: SelectRangeActionContext) { try { return Boolean(await onBrushEvent(context.data)); } catch { @@ -44,17 +44,17 @@ async function isCompatible(context: ActionContext) { export function selectRangeAction( filterManager: FilterManager, timeFilter: TimefilterContract -): Action { - return createAction({ - type: SELECT_RANGE_ACTION, - id: SELECT_RANGE_ACTION, +): ActionByType { + return createAction({ + type: ACTION_SELECT_RANGE, + id: ACTION_SELECT_RANGE, getDisplayName: () => { return i18n.translate('data.filter.applyFilterActionTitle', { defaultMessage: 'Apply filter to current view', }); }, isCompatible, - execute: async ({ timeFieldName, data }: ActionContext) => { + execute: async ({ timeFieldName, data }: SelectRangeActionContext) => { if (!(await isCompatible({ timeFieldName, data }))) { throw new IncompatibleActionError(); } diff --git a/src/legacy/core_plugins/data/public/actions/value_click_action.ts b/src/legacy/core_plugins/data/public/actions/value_click_action.ts index 26933cc8ddb82..4c69bc8262922 100644 --- a/src/legacy/core_plugins/data/public/actions/value_click_action.ts +++ b/src/legacy/core_plugins/data/public/actions/value_click_action.ts @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import { toMountPoint } from '../../../../../plugins/kibana_react/public'; import { - Action, + ActionByType, createAction, IncompatibleActionError, } from '../../../../../plugins/ui_actions/public'; @@ -37,14 +37,14 @@ import { esFilters, } from '../../../../../plugins/data/public'; -export const VALUE_CLICK_ACTION = 'VALUE_CLICK_ACTION'; +export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK'; -interface ActionContext { +export interface ValueClickActionContext { data: any; timeFieldName: string; } -async function isCompatible(context: ActionContext) { +async function isCompatible(context: ValueClickActionContext) { try { const filters: Filter[] = (await createFiltersFromEvent(context.data.data || [context.data], context.data.negate)) || @@ -58,17 +58,17 @@ async function isCompatible(context: ActionContext) { export function valueClickAction( filterManager: FilterManager, timeFilter: TimefilterContract -): Action { - return createAction({ - type: VALUE_CLICK_ACTION, - id: VALUE_CLICK_ACTION, +): ActionByType { + return createAction({ + type: ACTION_VALUE_CLICK, + id: ACTION_VALUE_CLICK, getDisplayName: () => { return i18n.translate('data.filter.applyFilterActionTitle', { defaultMessage: 'Apply filter to current view', }); }, isCompatible, - execute: async ({ timeFieldName, data }: ActionContext) => { + execute: async ({ timeFieldName, data }: ValueClickActionContext) => { if (!(await isCompatible({ timeFieldName, data }))) { throw new IncompatibleActionError(); } diff --git a/src/legacy/core_plugins/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts index e2b8ca5dda78c..18230646ab412 100644 --- a/src/legacy/core_plugins/data/public/plugin.ts +++ b/src/legacy/core_plugins/data/public/plugin.ts @@ -37,8 +37,16 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../plugins/data/public/services'; import { setSearchServiceShim } from './services'; -import { SELECT_RANGE_ACTION, selectRangeAction } from './actions/select_range_action'; -import { VALUE_CLICK_ACTION, valueClickAction } from './actions/value_click_action'; +import { + selectRangeAction, + SelectRangeActionContext, + ACTION_SELECT_RANGE, +} from './actions/select_range_action'; +import { + valueClickAction, + ACTION_VALUE_CLICK, + ValueClickActionContext, +} from './actions/value_click_action'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, @@ -76,6 +84,12 @@ export interface DataSetup { export interface DataStart { search: SearchStart; } +declare module '../../../../plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_SELECT_RANGE]: SelectRangeActionContext; + [ACTION_VALUE_CLICK]: ValueClickActionContext; + } +} /** * Data Plugin - public @@ -100,10 +114,13 @@ export class DataPlugin // This is to be deprecated once we switch to the new search service fully addSearchStrategy(defaultSearchStrategy); - uiActions.registerAction( + uiActions.attachAction( + SELECT_RANGE_TRIGGER, selectRangeAction(data.query.filterManager, data.query.timefilter.timefilter) ); - uiActions.registerAction( + + uiActions.attachAction( + VALUE_CLICK_TRIGGER, valueClickAction(data.query.filterManager, data.query.timefilter.timefilter) ); @@ -123,9 +140,6 @@ export class DataPlugin setSearchService(data.search); setOverlays(core.overlays); - uiActions.attachAction(SELECT_RANGE_TRIGGER, SELECT_RANGE_ACTION); - uiActions.attachAction(VALUE_CLICK_TRIGGER, VALUE_CLICK_ACTION); - return { search, }; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.js index d649777b56438..9257fc18fd75e 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.js @@ -46,7 +46,7 @@ class ColorRulesUI extends Component { const part = {}; part[name] = cast(_.get(e, '[0].value', _.get(e, 'target.value'))); if (part[name] === 'undefined') part[name] = undefined; - if (isNaN(part[name])) part[name] = undefined; + if (cast === Number && isNaN(part[name])) part[name] = undefined; handleChange(_.assign({}, item, part)); }; } @@ -170,6 +170,7 @@ class ColorRulesUI extends Component { selectedOptions={selectedOperatorOption ? [selectedOperatorOption] : []} onChange={this.handleChange(model, 'operator')} singleSelection={{ asPlainText: true }} + data-test-subj="colorRuleOperator" fullWidth /> @@ -182,6 +183,7 @@ class ColorRulesUI extends Component { })} value={model.value} onChange={this.handleChange(model, 'value', Number)} + data-test-subj="colorRuleValue" fullWidth /> diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.test.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.test.js index a05ff06627145..63af98db57e8b 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.test.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/color_rules.test.js @@ -18,7 +18,10 @@ */ import React from 'react'; +import { collectionActions } from './lib/collection_actions'; import { ColorRules } from './color_rules'; +import { keyCodes } from '@elastic/eui'; +import { findTestSubject } from '@elastic/eui/lib/test'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; describe('src/legacy/core_plugins/metrics/public/components/color_rules.test.js', () => { @@ -59,5 +62,18 @@ describe('src/legacy/core_plugins/metrics/public/components/color_rules.test.js' expect(isNode).toBeTruthy(); }); + it('should handle change of operator and value correctly', () => { + collectionActions.handleChange = jest.fn(); + const wrapper = mountWithIntl(); + const operatorInput = findTestSubject(wrapper, 'colorRuleOperator'); + operatorInput.simulate('keyDown', { keyCode: keyCodes.DOWN }); + operatorInput.simulate('keyDown', { keyCode: keyCodes.DOWN }); + operatorInput.simulate('keyDown', { keyCode: keyCodes.ENTER }); + expect(collectionActions.handleChange.mock.calls[0][1].operator).toEqual('gt'); + + const numberInput = findTestSubject(wrapper, 'colorRuleValue'); + numberInput.simulate('change', { target: { value: '123' } }); + expect(collectionActions.handleChange.mock.calls[1][1].value).toEqual(123); + }); }); }); diff --git a/src/plugins/dashboard_embeddable_container/kibana.json b/src/plugins/dashboard_embeddable_container/kibana.json index aab23316f606c..70e37ea6a6d7d 100644 --- a/src/plugins/dashboard_embeddable_container/kibana.json +++ b/src/plugins/dashboard_embeddable_container/kibana.json @@ -2,10 +2,14 @@ "id": "dashboard_embeddable_container", "version": "kibana", "requiredPlugins": [ + "data", "embeddable", "inspector", "uiActions" ], + "optionalPlugins": [ + "share" + ], "server": false, "ui": true } diff --git a/src/plugins/dashboard_embeddable_container/public/actions/expand_panel_action.tsx b/src/plugins/dashboard_embeddable_container/public/actions/expand_panel_action.tsx index edfba153b2b0b..cf245178306d5 100644 --- a/src/plugins/dashboard_embeddable_container/public/actions/expand_panel_action.tsx +++ b/src/plugins/dashboard_embeddable_container/public/actions/expand_panel_action.tsx @@ -19,10 +19,10 @@ import { i18n } from '@kbn/i18n'; import { IEmbeddable } from '../embeddable_plugin'; -import { Action, IncompatibleActionError } from '../ui_actions_plugin'; +import { ActionByType, IncompatibleActionError } from '../ui_actions_plugin'; import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable'; -export const EXPAND_PANEL_ACTION = 'togglePanel'; +export const ACTION_EXPAND_PANEL = 'togglePanel'; function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer { return embeddable.type === DASHBOARD_CONTAINER_TYPE; @@ -36,18 +36,18 @@ function isExpanded(embeddable: IEmbeddable) { return embeddable.id === embeddable.parent.getInput().expandedPanelId; } -interface ActionContext { +export interface ExpandPanelActionContext { embeddable: IEmbeddable; } -export class ExpandPanelAction implements Action { - public readonly type = EXPAND_PANEL_ACTION; - public readonly id = EXPAND_PANEL_ACTION; +export class ExpandPanelAction implements ActionByType { + public readonly type = ACTION_EXPAND_PANEL; + public readonly id = ACTION_EXPAND_PANEL; public order = 7; constructor() {} - public getDisplayName({ embeddable }: ActionContext) { + public getDisplayName({ embeddable }: ExpandPanelActionContext) { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } @@ -67,7 +67,7 @@ export class ExpandPanelAction implements Action { ); } - public getIconType({ embeddable }: ActionContext) { + public getIconType({ embeddable }: ExpandPanelActionContext) { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } @@ -75,11 +75,11 @@ export class ExpandPanelAction implements Action { return isExpanded(embeddable) ? 'expand' : 'expand'; } - public async isCompatible({ embeddable }: ActionContext) { + public async isCompatible({ embeddable }: ExpandPanelActionContext) { return Boolean(embeddable.parent && isDashboard(embeddable.parent)); } - public async execute({ embeddable }: ActionContext) { + public async execute({ embeddable }: ExpandPanelActionContext) { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } diff --git a/src/plugins/dashboard_embeddable_container/public/actions/index.ts b/src/plugins/dashboard_embeddable_container/public/actions/index.ts index 6c0db82fbbc5b..304fb98b4f842 100644 --- a/src/plugins/dashboard_embeddable_container/public/actions/index.ts +++ b/src/plugins/dashboard_embeddable_container/public/actions/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { ExpandPanelAction, EXPAND_PANEL_ACTION } from './expand_panel_action'; -export { ReplacePanelAction, REPLACE_PANEL_ACTION } from './replace_panel_action'; +export { ExpandPanelAction, ACTION_EXPAND_PANEL } from './expand_panel_action'; +export { ReplacePanelAction, ACTION_REPLACE_PANEL } from './replace_panel_action'; diff --git a/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_action.tsx b/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_action.tsx index 16f611a2f1ff2..1d59fe6bcb30f 100644 --- a/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard_embeddable_container/public/actions/replace_panel_action.tsx @@ -21,22 +21,22 @@ import { i18n } from '@kbn/i18n'; import { CoreStart } from '../../../../core/public'; import { IEmbeddable, ViewMode, IEmbeddableStart } from '../embeddable_plugin'; import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable'; -import { Action, IncompatibleActionError } from '../ui_actions_plugin'; +import { ActionByType, IncompatibleActionError } from '../ui_actions_plugin'; import { openReplacePanelFlyout } from './open_replace_panel_flyout'; -export const REPLACE_PANEL_ACTION = 'replacePanel'; +export const ACTION_REPLACE_PANEL = 'replacePanel'; function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer { return embeddable.type === DASHBOARD_CONTAINER_TYPE; } -interface ActionContext { +export interface ReplacePanelActionContext { embeddable: IEmbeddable; } -export class ReplacePanelAction implements Action { - public readonly type = REPLACE_PANEL_ACTION; - public readonly id = REPLACE_PANEL_ACTION; +export class ReplacePanelAction implements ActionByType { + public readonly type = ACTION_REPLACE_PANEL; + public readonly id = ACTION_REPLACE_PANEL; public order = 11; constructor( @@ -46,7 +46,7 @@ export class ReplacePanelAction implements Action { private getEmbeddableFactories: IEmbeddableStart['getEmbeddableFactories'] ) {} - public getDisplayName({ embeddable }: ActionContext) { + public getDisplayName({ embeddable }: ReplacePanelActionContext) { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } @@ -55,14 +55,14 @@ export class ReplacePanelAction implements Action { }); } - public getIconType({ embeddable }: ActionContext) { + public getIconType({ embeddable }: ReplacePanelActionContext) { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } return 'kqlOperand'; } - public async isCompatible({ embeddable }: ActionContext) { + public async isCompatible({ embeddable }: ReplacePanelActionContext) { if (embeddable.getInput().viewMode) { if (embeddable.getInput().viewMode === ViewMode.VIEW) { return false; @@ -72,7 +72,7 @@ export class ReplacePanelAction implements Action { return Boolean(embeddable.parent && isDashboard(embeddable.parent)); } - public async execute({ embeddable }: ActionContext) { + public async execute({ embeddable }: ReplacePanelActionContext) { if (!embeddable.parent || !isDashboard(embeddable.parent)) { throw new IncompatibleActionError(); } diff --git a/src/plugins/dashboard_embeddable_container/public/index.ts b/src/plugins/dashboard_embeddable_container/public/index.ts index e5f55c06b290c..c6846346b64ef 100644 --- a/src/plugins/dashboard_embeddable_container/public/index.ts +++ b/src/plugins/dashboard_embeddable_container/public/index.ts @@ -31,3 +31,5 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { DashboardEmbeddableContainerPublicPlugin as Plugin }; + +export { DASHBOARD_APP_URL_GENERATOR } from './url_generator'; diff --git a/src/plugins/dashboard_embeddable_container/public/plugin.tsx b/src/plugins/dashboard_embeddable_container/public/plugin.tsx index 44c9dbf2dcc4b..6f78829af19f1 100644 --- a/src/plugins/dashboard_embeddable_container/public/plugin.tsx +++ b/src/plugins/dashboard_embeddable_container/public/plugin.tsx @@ -21,6 +21,7 @@ import * as React from 'react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { SharePluginSetup } from 'src/plugins/share/public'; import { UiActionsSetup, UiActionsStart } from '../../../plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, IEmbeddableSetup, IEmbeddableStart } from './embeddable_plugin'; import { ExpandPanelAction, ReplacePanelAction } from '.'; @@ -31,10 +32,24 @@ import { ExitFullScreenButton as ExitFullScreenButtonUi, ExitFullScreenButtonProps, } from '../../../plugins/kibana_react/public'; +import { ExpandPanelActionContext, ACTION_EXPAND_PANEL } from './actions/expand_panel_action'; +import { ReplacePanelActionContext, ACTION_REPLACE_PANEL } from './actions/replace_panel_action'; +import { + DashboardAppLinkGeneratorState, + DASHBOARD_APP_URL_GENERATOR, + createDirectAccessDashboardLinkGenerator, +} from './url_generator'; + +declare module '../../share/public' { + export interface UrlGeneratorStateMapping { + [DASHBOARD_APP_URL_GENERATOR]: DashboardAppLinkGeneratorState; + } +} interface SetupDependencies { embeddable: IEmbeddableSetup; uiActions: UiActionsSetup; + share?: SharePluginSetup; } interface StartDependencies { @@ -46,14 +61,31 @@ interface StartDependencies { export type Setup = void; export type Start = void; +declare module '../../../plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_EXPAND_PANEL]: ExpandPanelActionContext; + [ACTION_REPLACE_PANEL]: ReplacePanelActionContext; + } +} + export class DashboardEmbeddableContainerPublicPlugin implements Plugin { constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { embeddable, uiActions }: SetupDependencies): Setup { + public setup(core: CoreSetup, { share, uiActions }: SetupDependencies): Setup { const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction); + const startServices = core.getStartServices(); + + if (share) { + share.urlGenerators.registerUrlGenerator( + createDirectAccessDashboardLinkGenerator(async () => ({ + appBasePath: (await startServices)[0].application.getUrlForApp('dashboard'), + useHashedUrl: (await startServices)[0].uiSettings.get('state:storeInSessionStorage'), + })) + ); + } } public start(core: CoreStart, plugins: StartDependencies): Start { @@ -81,7 +113,7 @@ export class DashboardEmbeddableContainerPublicPlugin plugins.embeddable.getEmbeddableFactories ); uiActions.registerAction(changeViewAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction); const factory = new DashboardContainerFactory({ application, diff --git a/src/plugins/dashboard_embeddable_container/public/tests/dashboard_container.test.tsx b/src/plugins/dashboard_embeddable_container/public/tests/dashboard_container.test.tsx index 6a3b69af60d6b..a81d80b440e04 100644 --- a/src/plugins/dashboard_embeddable_container/public/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard_embeddable_container/public/tests/dashboard_container.test.tsx @@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const editModeAction = createEditModeAction(); uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction.id); + uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any) diff --git a/src/plugins/dashboard_embeddable_container/public/url_generator.test.ts b/src/plugins/dashboard_embeddable_container/public/url_generator.test.ts new file mode 100644 index 0000000000000..5dfc47b694f60 --- /dev/null +++ b/src/plugins/dashboard_embeddable_container/public/url_generator.test.ts @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createDirectAccessDashboardLinkGenerator } from './url_generator'; +import { hashedItemStore } from '../../kibana_utils/public'; +// eslint-disable-next-line +import { mockStorage } from '../../kibana_utils/public/storage/hashed_item_store/mock'; + +const APP_BASE_PATH: string = 'xyz/app/kibana'; + +describe('dashboard url generator', () => { + beforeEach(() => { + // @ts-ignore + hashedItemStore.storage = mockStorage; + }); + + test('creates a link to a saved dashboard', async () => { + const generator = createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false }) + ); + const url = await generator.createUrl!({}); + expect(url).toMatchInlineSnapshot(`"xyz/app/kibana#/dashboard?_a=()&_g=()"`); + }); + + test('creates a link with global time range set up', async () => { + const generator = createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + expect(url).toMatchInlineSnapshot( + `"xyz/app/kibana#/dashboard?_a=()&_g=(time:(from:now-15m,mode:relative,to:now))"` + ); + }); + + test('creates a link with filters, time range and query to a saved object', async () => { + const generator = createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + dashboardId: '123', + filters: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'hi' }, + }, + ], + query: { query: 'bye', language: 'kuery' }, + }); + expect(url).toMatchInlineSnapshot( + `"xyz/app/kibana#/dashboard/123?_a=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:hi))),query:(language:kuery,query:bye))&_g=(time:(from:now-15m,mode:relative,to:now))"` + ); + }); + + test('if no useHash setting is given, uses the one was start services', async () => { + const generator = createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: true }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + expect(url.indexOf('relative')).toBe(-1); + }); + + test('can override a false useHash ui setting', async () => { + const generator = createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: false }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + useHash: true, + }); + expect(url.indexOf('relative')).toBe(-1); + }); + + test('can override a true useHash ui setting', async () => { + const generator = createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ appBasePath: APP_BASE_PATH, useHashedUrl: true }) + ); + const url = await generator.createUrl!({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + useHash: false, + }); + expect(url.indexOf('relative')).toBeGreaterThan(1); + }); +}); diff --git a/src/plugins/dashboard_embeddable_container/public/url_generator.ts b/src/plugins/dashboard_embeddable_container/public/url_generator.ts new file mode 100644 index 0000000000000..5f1255bc9d45f --- /dev/null +++ b/src/plugins/dashboard_embeddable_container/public/url_generator.ts @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TimeRange, Filter, Query } from '../../data/public'; +import { setStateToKbnUrl } from '../../kibana_utils/public'; +import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public'; + +export const STATE_STORAGE_KEY = '_a'; +export const GLOBAL_STATE_STORAGE_KEY = '_g'; + +export const DASHBOARD_APP_URL_GENERATOR = 'DASHBOARD_APP_URL_GENERATOR'; + +export type DashboardAppLinkGeneratorState = UrlGeneratorState<{ + /** + * If given, the dashboard saved object with this id will be loaded. If not given, + * a new, unsaved dashboard will be loaded up. + */ + dashboardId?: string; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + /** + * Optionally apply filers. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has filters saved with it, this will _replace_ those filters. This will set + * app filters, not global filters. + */ + filters?: Filter[]; + /** + * Optionally set a query. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has a query saved with it, this will _replace_ that query. + */ + query?: Query; + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; +}>; + +export const createDirectAccessDashboardLinkGenerator = ( + getStartServices: () => Promise<{ appBasePath: string; useHashedUrl: boolean }> +): UrlGeneratorsDefinition => ({ + id: DASHBOARD_APP_URL_GENERATOR, + createUrl: async state => { + const startServices = await getStartServices(); + const useHash = state.useHash ?? startServices.useHashedUrl; + const appBasePath = startServices.appBasePath; + const hash = state.dashboardId ? `dashboard/${state.dashboardId}` : `dashboard`; + + const appStateUrl = setStateToKbnUrl( + STATE_STORAGE_KEY, + { + query: state.query, + filters: state.filters, + }, + { useHash }, + `${appBasePath}#/${hash}` + ); + + return setStateToKbnUrl( + GLOBAL_STATE_STORAGE_KEY, + { + time: state.timeRange, + }, + { useHash }, + appStateUrl + ); + }, +}); diff --git a/src/plugins/data/common/timefilter/types.ts b/src/plugins/data/common/timefilter/types.ts index 1fc606a57d22d..b197b16e67dd1 100644 --- a/src/plugins/data/common/timefilter/types.ts +++ b/src/plugins/data/common/timefilter/types.ts @@ -25,4 +25,5 @@ export interface RefreshInterval { export interface TimeRange { from: string; to: string; + mode?: 'absolute' | 'relative'; } diff --git a/src/plugins/data/public/actions/apply_filter_action.ts b/src/plugins/data/public/actions/apply_filter_action.ts index 6edb3237987fa..bd20c6f632a3a 100644 --- a/src/plugins/data/public/actions/apply_filter_action.ts +++ b/src/plugins/data/public/actions/apply_filter_action.ts @@ -19,36 +19,36 @@ import { i18n } from '@kbn/i18n'; import { toMountPoint } from '../../../kibana_react/public'; -import { Action, createAction, IncompatibleActionError } from '../../../ui_actions/public'; +import { ActionByType, createAction, IncompatibleActionError } from '../../../ui_actions/public'; import { getOverlays, getIndexPatterns } from '../services'; import { applyFiltersPopover } from '../ui/apply_filters'; import { Filter, FilterManager, TimefilterContract, esFilters } from '..'; -export const GLOBAL_APPLY_FILTER_ACTION = 'GLOBAL_APPLY_FILTER_ACTION'; +export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER'; -interface ActionContext { +export interface ApplyGlobalFilterActionContext { filters: Filter[]; timeFieldName?: string; } -async function isCompatible(context: ActionContext) { +async function isCompatible(context: ApplyGlobalFilterActionContext) { return context.filters !== undefined; } export function createFilterAction( filterManager: FilterManager, timeFilter: TimefilterContract -): Action { - return createAction({ - type: GLOBAL_APPLY_FILTER_ACTION, - id: GLOBAL_APPLY_FILTER_ACTION, +): ActionByType { + return createAction({ + type: ACTION_GLOBAL_APPLY_FILTER, + id: ACTION_GLOBAL_APPLY_FILTER, getDisplayName: () => { return i18n.translate('data.filter.applyFilterActionTitle', { defaultMessage: 'Apply filter to current view', }); }, isCompatible, - execute: async ({ filters, timeFieldName }: ActionContext) => { + execute: async ({ filters, timeFieldName }: ApplyGlobalFilterActionContext) => { if (!filters) { throw new Error('Applying a filter requires a filter'); } diff --git a/src/plugins/data/public/actions/index.ts b/src/plugins/data/public/actions/index.ts index 5d469606944a1..e3dc9760aa8b8 100644 --- a/src/plugins/data/public/actions/index.ts +++ b/src/plugins/data/public/actions/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { GLOBAL_APPLY_FILTER_ACTION, createFilterAction } from './apply_filter_action'; +export { ACTION_GLOBAL_APPLY_FILTER, createFilterAction } from './apply_filter_action'; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 8ce379547ead5..a199a0419aea6 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -44,9 +44,16 @@ import { setIndexPatterns, setUiSettings, } from './services'; -import { createFilterAction, GLOBAL_APPLY_FILTER_ACTION } from './actions'; +import { createFilterAction, ACTION_GLOBAL_APPLY_FILTER } from './actions'; import { APPLY_FILTER_TRIGGER } from '../../embeddable/public'; import { createSearchBar } from './ui/search_bar/create_search_bar'; +import { ApplyGlobalFilterActionContext } from './actions/apply_filter_action'; + +declare module '../../ui_actions/public' { + export interface ActionContextMapping { + [ACTION_GLOBAL_APPLY_FILTER]: ApplyGlobalFilterActionContext; + } +} export class DataPublicPlugin implements Plugin { private readonly autocomplete = new AutocompleteService(); @@ -93,7 +100,7 @@ export class DataPublicPlugin implements Plugin {prefix} - {JSON.stringify(filter.query)} + {JSON.stringify(filter.query) || filter.meta.value}
); } diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 93a15aab7a0dd..e69361178eeba 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -33,6 +33,13 @@ import { SELECT_RANGE_TRIGGER, CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, + ACTION_ADD_PANEL, + ACTION_CUSTOMIZE_PANEL, + ACTION_INSPECT_PANEL, + REMOVE_PANEL_ACTION, + ACTION_EDIT_PANEL, + FilterActionContext, + ACTION_APPLY_FILTER, } from './lib'; declare module '../../ui_actions/public' { @@ -46,6 +53,15 @@ declare module '../../ui_actions/public' { [CONTEXT_MENU_TRIGGER]: EmbeddableContext; [PANEL_BADGE_TRIGGER]: EmbeddableContext; } + + export interface ActionContextMapping { + [ACTION_CUSTOMIZE_PANEL]: EmbeddableContext; + [ACTION_ADD_PANEL]: EmbeddableContext; + [ACTION_INSPECT_PANEL]: EmbeddableContext; + [REMOVE_PANEL_ACTION]: EmbeddableContext; + [ACTION_EDIT_PANEL]: EmbeddableContext; + [ACTION_APPLY_FILTER]: FilterActionContext; + } } /** diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 2eafe16442e18..34abd57eeacdd 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -24,9 +24,9 @@ import { EmbeddablePublicPlugin } from './plugin'; export { Adapters, - ADD_PANEL_ACTION_ID, + ACTION_ADD_PANEL, AddPanelAction, - APPLY_FILTER_ACTION, + ACTION_APPLY_FILTER, APPLY_FILTER_TRIGGER, applyFilterTrigger, Container, @@ -34,7 +34,7 @@ export { ContainerOutput, CONTEXT_MENU_TRIGGER, contextMenuTrigger, - EDIT_PANEL_ACTION_ID, + ACTION_EDIT_PANEL, EditPanelAction, Embeddable, EmbeddableChildPanel, diff --git a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts b/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts index 6b8f2223a14a5..5297cf6cd365c 100644 --- a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts +++ b/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts @@ -20,10 +20,10 @@ import { createFilterAction } from './apply_filter_action'; import { expectErrorAsync } from '../../tests/helpers'; -test('has APPLY_FILTER_ACTION type and id', () => { +test('has ACTION_APPLY_FILTER type and id', () => { const action = createFilterAction(); - expect(action.id).toBe('APPLY_FILTER_ACTION'); - expect(action.type).toBe('APPLY_FILTER_ACTION'); + expect(action.id).toBe('ACTION_APPLY_FILTER'); + expect(action.type).toBe('ACTION_APPLY_FILTER'); }); test('has expected display name', () => { diff --git a/src/plugins/embeddable/public/lib/actions/apply_filter_action.ts b/src/plugins/embeddable/public/lib/actions/apply_filter_action.ts index 9aeb7e1c84d7e..4680512fb81c8 100644 --- a/src/plugins/embeddable/public/lib/actions/apply_filter_action.ts +++ b/src/plugins/embeddable/public/lib/actions/apply_filter_action.ts @@ -18,19 +18,19 @@ */ import { i18n } from '@kbn/i18n'; -import { Action, createAction, IncompatibleActionError } from '../ui_actions'; +import { ActionByType, createAction, IncompatibleActionError } from '../ui_actions'; import { IEmbeddable, EmbeddableInput } from '../embeddables'; import { Filter } from '../../../../../plugins/data/public'; -export const APPLY_FILTER_ACTION = 'APPLY_FILTER_ACTION'; +export const ACTION_APPLY_FILTER = 'ACTION_APPLY_FILTER'; type RootEmbeddable = IEmbeddable; -interface ActionContext { +export interface FilterActionContext { embeddable: IEmbeddable; filters: Filter[]; } -async function isCompatible(context: ActionContext) { +async function isCompatible(context: FilterActionContext) { if (context.embeddable === undefined) { return false; } @@ -38,10 +38,10 @@ async function isCompatible(context: ActionContext) { return Boolean(root.getInput().filters !== undefined && context.filters !== undefined); } -export function createFilterAction(): Action { - return createAction({ - type: APPLY_FILTER_ACTION, - id: APPLY_FILTER_ACTION, +export function createFilterAction(): ActionByType { + return createAction({ + type: ACTION_APPLY_FILTER, + id: ACTION_APPLY_FILTER, getDisplayName: () => { return i18n.translate('embeddableApi.actions.applyFilterActionTitle', { defaultMessage: 'Apply filter to current view', diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 767def76348c8..82f8e33b7ae2f 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -23,15 +23,15 @@ import { GetEmbeddableFactory, ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; import { IEmbeddable } from '../embeddables'; -export const EDIT_PANEL_ACTION_ID = 'editPanel'; +export const ACTION_EDIT_PANEL = 'editPanel'; interface ActionContext { embeddable: IEmbeddable; } export class EditPanelAction implements Action { - public readonly type = EDIT_PANEL_ACTION_ID; - public readonly id = EDIT_PANEL_ACTION_ID; + public readonly type = ACTION_EDIT_PANEL; + public readonly id = ACTION_EDIT_PANEL; public order = 15; constructor(private readonly getEmbeddableFactory: GetEmbeddableFactory) {} diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 218660462b4ef..fdff82e63faec 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -25,7 +25,7 @@ import { nextTick } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { I18nProvider } from '@kbn/i18n/react'; import { CONTEXT_MENU_TRIGGER } from '../triggers'; -import { Action, UiActionsStart } from 'src/plugins/ui_actions/public'; +import { Action, UiActionsStart, ActionType } from 'src/plugins/ui_actions/public'; import { Trigger, GetEmbeddableFactory, ViewMode } from '../types'; import { EmbeddableFactory, isErrorEmbeddable } from '../embeddables'; import { EmbeddablePanel } from './embeddable_panel'; @@ -213,9 +213,9 @@ const renderInEditModeAndOpenContextMenu = async ( }; test('HelloWorldContainer in edit mode hides disabledActions', async () => { - const action = { + const action: Action = { id: 'FOO', - type: 'FOO', + type: 'FOO' as ActionType, getIconType: () => undefined, getDisplayName: () => 'foo', isCompatible: async () => true, @@ -245,9 +245,9 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => { }); test('HelloWorldContainer hides disabled badges', async () => { - const action = { + const action: Action = { id: 'BAR', - type: 'BAR', + type: 'BAR' as ActionType, getIconType: () => undefined, getDisplayName: () => 'bar', isCompatible: async () => true, diff --git a/src/plugins/embeddable/public/lib/panel/index.ts b/src/plugins/embeddable/public/lib/panel/index.ts index dee52bc5bec50..f5ef8d9e20edb 100644 --- a/src/plugins/embeddable/public/lib/panel/index.ts +++ b/src/plugins/embeddable/public/lib/panel/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { EmbeddablePanel } from './embeddable_panel'; -export { ADD_PANEL_ACTION_ID, AddPanelAction, openAddPanelFlyout } from './panel_header'; +export * from './embeddable_panel'; +export * from './panel_header'; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/index.ts b/src/plugins/embeddable/public/lib/panel/panel_header/index.ts index e5975b06ba1e9..d64094f2d5e24 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/index.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/index.ts @@ -17,9 +17,4 @@ * under the License. */ -export { - ADD_PANEL_ACTION_ID, - AddPanelAction, - RemovePanelAction, - openAddPanelFlyout, -} from './panel_actions'; +export * from './panel_actions'; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts index 2759d4575da19..36bb742040ccc 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts @@ -23,15 +23,15 @@ import { ViewMode, GetEmbeddableFactory, GetEmbeddableFactories } from '../../.. import { openAddPanelFlyout } from './open_add_panel_flyout'; import { IContainer } from '../../../../containers'; -export const ADD_PANEL_ACTION_ID = 'ADD_PANEL_ACTION_ID'; +export const ACTION_ADD_PANEL = 'ACTION_ADD_PANEL'; interface ActionContext { embeddable: IContainer; } export class AddPanelAction implements Action { - public readonly type = ADD_PANEL_ACTION_ID; - public readonly id = ADD_PANEL_ACTION_ID; + public readonly type = ACTION_ADD_PANEL; + public readonly id = ACTION_ADD_PANEL; constructor( private readonly getFactory: GetEmbeddableFactory, diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index e0d34fc1f4b04..c0e43c0538833 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -22,7 +22,7 @@ import { Action } from 'src/plugins/ui_actions/public'; import { ViewMode } from '../../../../types'; import { IEmbeddable } from '../../../../embeddables'; -const CUSTOMIZE_PANEL_ACTION_ID = 'CUSTOMIZE_PANEL_ACTION_ID'; +export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL'; type GetUserData = (context: ActionContext) => Promise<{ title: string | undefined }>; @@ -31,8 +31,8 @@ interface ActionContext { } export class CustomizePanelTitleAction implements Action { - public readonly type = CUSTOMIZE_PANEL_ACTION_ID; - public id = CUSTOMIZE_PANEL_ACTION_ID; + public readonly type = ACTION_CUSTOMIZE_PANEL; + public id = ACTION_CUSTOMIZE_PANEL; public order = 10; constructor(private readonly getDataFromUser: GetUserData) { diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/index.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/index.ts new file mode 100644 index 0000000000000..2aa4253e988d9 --- /dev/null +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './customize_panel_action'; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/index.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/index.ts index 7810e0095b632..27e9dd903848d 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/index.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/index.ts @@ -17,6 +17,7 @@ * under the License. */ -export { InspectPanelAction } from './inspect_panel_action'; -export { ADD_PANEL_ACTION_ID, AddPanelAction, openAddPanelFlyout } from './add_panel'; -export { RemovePanelAction } from './remove_panel_action'; +export * from './inspect_panel_action'; +export * from './add_panel'; +export * from './remove_panel_action'; +export * from './customize_title'; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index 1433bb6f78280..d04f35715537c 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -22,15 +22,15 @@ import { Action } from 'src/plugins/ui_actions/public'; import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { IEmbeddable } from '../../../embeddables'; -export const INSPECT_PANEL_ACTION_ID = 'openInspector'; +export const ACTION_INSPECT_PANEL = 'openInspector'; interface ActionContext { embeddable: IEmbeddable; } export class InspectPanelAction implements Action { - public readonly type = INSPECT_PANEL_ACTION_ID; - public readonly id = INSPECT_PANEL_ACTION_ID; + public readonly type = ACTION_INSPECT_PANEL; + public readonly id = ACTION_INSPECT_PANEL; public order = 10; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/test_samples/actions/edit_mode_action.ts b/src/plugins/embeddable/public/lib/test_samples/actions/edit_mode_action.ts index b5ceae0c15a24..bb34b474efda0 100644 --- a/src/plugins/embeddable/public/lib/test_samples/actions/edit_mode_action.ts +++ b/src/plugins/embeddable/public/lib/test_samples/actions/edit_mode_action.ts @@ -17,17 +17,20 @@ * under the License. */ -import { createAction } from '../../ui_actions'; +import { createAction, ActionType } from '../../ui_actions'; import { ViewMode } from '../../types'; -import { EmbeddableContext } from '../../triggers'; +import { IEmbeddable } from '../..'; -export const EDIT_MODE_ACTION = 'EDIT_MODE_ACTION'; +// Casting to ActionType is a hack - in a real situation use +// declare module and add this id to ActionContextMapping. +export const EDIT_MODE_ACTION = 'EDIT_MODE_ACTION' as ActionType; export function createEditModeAction() { - return createAction({ + return createAction({ type: EDIT_MODE_ACTION, getDisplayName: () => 'I only show up in edit mode', - isCompatible: async context => context.embeddable.getInput().viewMode === ViewMode.EDIT, + isCompatible: async (context: { embeddable: IEmbeddable }) => + context.embeddable.getInput().viewMode === ViewMode.EDIT, execute: async () => {}, }); } diff --git a/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx b/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx index 55615875528a4..0612b838a6ce7 100644 --- a/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx @@ -17,10 +17,12 @@ * under the License. */ -import { Action, IncompatibleActionError } from '../../ui_actions'; +import { ActionByType, IncompatibleActionError, ActionType } from '../../ui_actions'; import { EmbeddableInput, Embeddable, EmbeddableOutput, IEmbeddable } from '../../embeddables'; -export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION'; +// Casting to ActionType is a hack - in a real situation use +// declare module and add this id to ActionContextMapping. +export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION' as ActionType; export interface FullNameEmbeddableOutput extends EmbeddableOutput { fullName: string; @@ -35,12 +37,12 @@ export function hasFullNameOutput( ); } -interface ActionContext { +export interface SayHelloActionContext { embeddable: Embeddable; message?: string; } -export class SayHelloAction implements Action { +export class SayHelloAction implements ActionByType { public readonly type = SAY_HELLO_ACTION; public readonly id = SAY_HELLO_ACTION; @@ -62,7 +64,7 @@ export class SayHelloAction implements Action { // Can use typescript generics to get compiler time warnings for immediate feedback if // the context is not compatible. - async isCompatible(context: ActionContext) { + async isCompatible(context: SayHelloActionContext) { // Option 1: only compatible with Greeting Embeddables. // return context.embeddable.type === CONTACT_CARD_EMBEDDABLE; @@ -70,7 +72,7 @@ export class SayHelloAction implements Action { return hasFullNameOutput(context.embeddable); } - async execute(context: ActionContext) { + async execute(context: SayHelloActionContext) { if (!(await this.isCompatible(context))) { throw new IncompatibleActionError(); } diff --git a/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx b/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx index 502269d7ac193..222fe1f6ed870 100644 --- a/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/actions/send_message_action.tsx @@ -18,14 +18,16 @@ */ import React from 'react'; import { EuiFlyoutBody } from '@elastic/eui'; -import { createAction, IncompatibleActionError } from '../../ui_actions'; +import { createAction, IncompatibleActionError, ActionType } from '../../ui_actions'; import { CoreStart } from '../../../../../../core/public'; import { toMountPoint } from '../../../../../kibana_react/public'; import { Embeddable, EmbeddableInput } from '../../embeddables'; import { GetMessageModal } from './get_message_modal'; import { FullNameEmbeddableOutput, hasFullNameOutput } from './say_hello_action'; -export const SEND_MESSAGE_ACTION = 'SEND_MESSAGE_ACTION'; +// Casting to ActionType is a hack - in a real situation use +// declare module and add this id to ActionContextMapping. +export const ACTION_SEND_MESSAGE = 'ACTION_SEND_MESSAGE' as ActionType; interface ActionContext { embeddable: Embeddable; @@ -42,11 +44,11 @@ export function createSendMessageAction(overlays: CoreStart['overlays']) { overlays.openFlyout(toMountPoint({content})); }; - return createAction({ - type: SEND_MESSAGE_ACTION, + return createAction({ + type: ACTION_SEND_MESSAGE, getDisplayName: () => 'Send message', isCompatible, - execute: async context => { + execute: async (context: ActionContext) => { if (!(await isCompatible(context))) { throw new IncompatibleActionError(); } diff --git a/src/plugins/share/public/index.ts b/src/plugins/share/public/index.ts index fe5822c79366b..183219645467e 100644 --- a/src/plugins/share/public/index.ts +++ b/src/plugins/share/public/index.ts @@ -17,6 +17,8 @@ * under the License. */ +export { UrlGeneratorStateMapping } from './url_generators/url_generator_definition'; + export { SharePluginSetup, SharePluginStart } from './plugin'; export { ShareContext, @@ -25,6 +27,15 @@ export { ShowShareMenuOptions, ShareContextMenuPanelItem, } from './types'; + +export { + UrlGeneratorId, + UrlGeneratorState, + UrlGeneratorsDefinition, + UrlGeneratorContract, + UrlGeneratorsService, +} from './url_generators'; + import { SharePlugin } from './plugin'; export const plugin = () => new SharePlugin(); diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 01c248624950a..5b638174b4dfb 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -21,27 +21,39 @@ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services'; import { createShortUrlRedirectApp } from './services/short_url_redirect_app'; +import { + UrlGeneratorsService, + UrlGeneratorsSetup, + UrlGeneratorsStart, +} from './url_generators/url_generator_service'; export class SharePlugin implements Plugin { private readonly shareMenuRegistry = new ShareMenuRegistry(); private readonly shareContextMenu = new ShareMenuManager(); + private readonly urlGeneratorsService = new UrlGeneratorsService(); - public async setup(core: CoreSetup) { + public setup(core: CoreSetup): SharePluginSetup { core.application.register(createShortUrlRedirectApp(core, window.location)); return { ...this.shareMenuRegistry.setup(), + urlGenerators: this.urlGeneratorsService.setup(core), }; } - public async start(core: CoreStart) { + public start(core: CoreStart): SharePluginStart { return { ...this.shareContextMenu.start(core, this.shareMenuRegistry.start()), + urlGenerators: this.urlGeneratorsService.start(core), }; } } /** @public */ -export type SharePluginSetup = ShareMenuRegistrySetup; +export type SharePluginSetup = ShareMenuRegistrySetup & { + urlGenerators: UrlGeneratorsSetup; +}; /** @public */ -export type SharePluginStart = ShareMenuManagerStart; +export type SharePluginStart = ShareMenuManagerStart & { + urlGenerators: UrlGeneratorsStart; +}; diff --git a/src/plugins/share/public/url_generators/README.md b/src/plugins/share/public/url_generators/README.md new file mode 100644 index 0000000000000..39ee5f2901e91 --- /dev/null +++ b/src/plugins/share/public/url_generators/README.md @@ -0,0 +1,114 @@ +## URL Generator Services + +Developers who maintain pages in Kibana that other developers may want to link to +can register a direct access link generator. This provides backward compatibility support +so the developer of the app/page has a way to change their url structure without +breaking users of this system. If users were to generate the urls on their own, +using string concatenation, those links may break often. + +Owners: Kibana App Arch team. + +## Producer Usage + +If you are registering a new generator, don't forget to add a mapping of id to state + +```ts +declare module '../../share/public' { + export interface UrlGeneratorStateMapping { + [MY_GENERATOR]: MyState; + } +} +``` + +### Migration + +Once your generator is released, you should *never* change the `MyState` type, nor the value of `MY_GENERATOR`. +Instead, register a new generator id, with the new state type, and add a migration function to convert to it. + +To avoid having to refactor many run time usages of the old id, change the _value_ of the generator id, but not +the name itself. For example: + +Initial release: +```ts +export const MY_GENERATOR = 'MY_GENERATOR'; +export const MyState { + foo: string; +} +export interface UrlGeneratorStateMapping { + [MY_GENERATOR]: UrlGeneratorState; +} +``` + +Second release: +```ts + // Value stays the same here! This is important. + export const MY_LEGACY_GENERATOR_V1 = 'MY_GENERATOR'; + // Always point the const `MY_GENERATOR` to the most + // recent version of the state to avoid a large refactor. + export const MY_GENERATOR = 'MY_GENERATOR_V2'; + + // Same here, the mapping stays the same, but the names change. + export const MyLegacyState { + foo: string; + } + // New type, old name! + export const MyState { + bar: string; + } + export interface UrlGeneratorStateMapping { + [MY_LEGACY_GENERATOR_V1]: UrlGeneratorState; + [MY_GENERATOR]: UrlGeneratorState; + } +``` + +### Examples + +Working examples of registered link generators can be found in `examples/url_generator_examples` folder. Run these +examples via + +``` +yarn start --run-examples +``` + +## Consumer Usage + +Consumers of this service can use the ids and state to create URL strings: + +```ts + const { id, state } = getLinkData(); + const generator = urlGeneratorPluginStart.getLinkGenerator(id); + if (generator.isDeprecated) { + // Consumers have a few options here. + + // If the consumer constrols the persisted data, they can migrate this data and + // update it. Something like this: + const { id: newId, state: newState } = await generator.migrate(state); + replaceLegacyData({ oldId: id, newId, newState }); + + // If the consumer does not control the persisted data store, they can warn the + // user that they are using a deprecated id and should update the data on their + // own. + alert(`This data is deprecated, please generate new URL data.`); + + // They can also choose to do nothing. Calling `createUrl` will internally migrate this + // data. Depending on the cost, we may choose to keep support for deprecated generators + // along for a long time, using telemetry to make this decision. However another + // consideration is how many migrations are taking place and whether this is creating a + // performance issue. + } + const link = await generator.createUrl(savedLink.state); +``` + +**As a consumer, you should not persist the url string!** + +As soon as you do, you have lost your migration options. Instead you should store the id +and the state object. This will let you recreate the migrated url later. + +### Examples + +Working examples of consuming registered link generators can be found in `examples/url_generator_explorer` folder. Run these +via + +``` +yarn start --run-examples +``` diff --git a/src/plugins/ui_actions/public/tests/test_samples/restricted_action.ts b/src/plugins/share/public/url_generators/index.ts similarity index 69% rename from src/plugins/ui_actions/public/tests/test_samples/restricted_action.ts rename to src/plugins/share/public/url_generators/index.ts index aa65d3af98163..4d45dc4fee54f 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/restricted_action.ts +++ b/src/plugins/share/public/url_generators/index.ts @@ -17,14 +17,8 @@ * under the License. */ -import { Action, createAction } from '../../actions'; +export * from './url_generator_service'; -export const RESTRICTED_ACTION = 'RESTRICTED_ACTION'; +export * from './url_generator_definition'; -export function createRestrictedAction(isCompatibleIn: (context: C) => boolean): Action { - return createAction({ - type: RESTRICTED_ACTION, - isCompatible: async context => isCompatibleIn(context), - execute: async () => {}, - }); -} +export * from './url_generator_contract'; diff --git a/src/plugins/share/public/url_generators/url_generator_contract.ts b/src/plugins/share/public/url_generators/url_generator_contract.ts new file mode 100644 index 0000000000000..993428ebe1f64 --- /dev/null +++ b/src/plugins/share/public/url_generators/url_generator_contract.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UrlGeneratorId, UrlGeneratorStateMapping } from './url_generator_definition'; + +export interface UrlGeneratorContract { + id: Id; + createUrl(state: UrlGeneratorStateMapping[Id]['State']): Promise; + isDeprecated: boolean; + migrate( + state: UrlGeneratorStateMapping[Id]['State'] + ): Promise<{ + state: UrlGeneratorStateMapping[Id]['MigratedState']; + id: UrlGeneratorStateMapping[Id]['MigratedId']; + }>; +} diff --git a/src/plugins/share/public/url_generators/url_generator_definition.ts b/src/plugins/share/public/url_generators/url_generator_definition.ts new file mode 100644 index 0000000000000..51994c203907f --- /dev/null +++ b/src/plugins/share/public/url_generators/url_generator_definition.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type UrlGeneratorId = string; + +export interface UrlGeneratorState< + S extends {}, + I extends string | undefined = undefined, + MS extends {} | undefined = undefined +> { + State: S; + MigratedId?: I; + MigratedState?: MS; +} + +export interface UrlGeneratorStateMapping { + // The `any` here is quite unfortunate. Using `object` actually gives no type errors in my IDE + // but running `node scripts/type_check` will cause an error: + // examples/url_generators_examples/public/url_generator.ts:77:66 - + // error TS2339: Property 'name' does not exist on type 'object'. However it's correctly + // typed when I edit that file. + [key: string]: UrlGeneratorState; +} + +export interface UrlGeneratorsDefinition { + id: Id; + createUrl?: (state: UrlGeneratorStateMapping[Id]['State']) => Promise; + isDeprecated?: boolean; + migrate?: ( + state: UrlGeneratorStateMapping[Id]['State'] + ) => Promise<{ + state: UrlGeneratorStateMapping[Id]['MigratedState']; + id: UrlGeneratorStateMapping[Id]['MigratedId']; + }>; +} diff --git a/src/plugins/share/public/url_generators/url_generator_internal.ts b/src/plugins/share/public/url_generators/url_generator_internal.ts new file mode 100644 index 0000000000000..19ee83059e017 --- /dev/null +++ b/src/plugins/share/public/url_generators/url_generator_internal.ts @@ -0,0 +1,99 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { UrlGeneratorsStart } from './url_generator_service'; +import { + UrlGeneratorStateMapping, + UrlGeneratorId, + UrlGeneratorsDefinition, +} from './url_generator_definition'; +import { UrlGeneratorContract } from './url_generator_contract'; + +export class UrlGeneratorInternal { + constructor( + private spec: UrlGeneratorsDefinition, + private getGenerator: UrlGeneratorsStart['getUrlGenerator'] + ) { + if (spec.isDeprecated && !spec.migrate) { + throw new Error( + i18n.translate('share.urlGenerators.error.noMigrationFnProvided', { + defaultMessage: + 'If the access link generator is marked as deprecated, you must provide a migration function.', + }) + ); + } + + if (!spec.isDeprecated && spec.migrate) { + throw new Error( + i18n.translate('share.urlGenerators.error.migrationFnGivenNotDeprecated', { + defaultMessage: + 'If you provide a migration function, you must mark this generator as deprecated', + }) + ); + } + + if (!spec.createUrl && !spec.isDeprecated) { + throw new Error( + i18n.translate('share.urlGenerators.error.noCreateUrlFnProvided', { + defaultMessage: + 'This generator is not marked as deprecated. Please provide a createUrl fn.', + }) + ); + } + + if (spec.createUrl && spec.isDeprecated) { + throw new Error( + i18n.translate('share.urlGenerators.error.createUrlFnProvided', { + defaultMessage: 'This generator is marked as deprecated. Do not supply a createUrl fn.', + }) + ); + } + } + + getPublicContract(): UrlGeneratorContract { + return { + id: this.spec.id, + createUrl: async (state: UrlGeneratorStateMapping[Id]['State']) => { + if (this.spec.migrate && !this.spec.createUrl) { + const { id, state: newState } = await this.spec.migrate(state); + + // eslint-disable-next-line + console.warn(`URL generator is deprecated and may not work in future versions. Please migrate your data.`); + + return this.getGenerator(id!).createUrl(newState!); + } + + return this.spec.createUrl!(state); + }, + isDeprecated: !!this.spec.isDeprecated, + migrate: async (state: UrlGeneratorStateMapping[Id]['State']) => { + if (!this.spec.isDeprecated) { + throw new Error( + i18n.translate('share.urlGenerators.error.migrateCalledNotDeprecated', { + defaultMessage: 'You cannot call migrate on a non-deprecated generator.', + }) + ); + } + + return this.spec.migrate!(state); + }, + }; + } +} diff --git a/src/plugins/share/public/url_generators/url_generator_service.test.ts b/src/plugins/share/public/url_generators/url_generator_service.test.ts new file mode 100644 index 0000000000000..4a377db033762 --- /dev/null +++ b/src/plugins/share/public/url_generators/url_generator_service.test.ts @@ -0,0 +1,126 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UrlGeneratorsService } from './url_generator_service'; +import { coreMock } from '../../../../core/public/mocks'; + +const service = new UrlGeneratorsService(); + +const setup = service.setup(coreMock.createSetup()); +const start = service.start(coreMock.createStart()); + +test('Asking for a generator that does not exist throws an error', () => { + expect(() => start.getUrlGenerator('noexist')).toThrowError(); +}); + +test('Registering and retrieving a generator', async () => { + setup.registerUrlGenerator({ + id: 'TEST_GENERATOR', + createUrl: () => Promise.resolve('myurl'), + }); + const generator = start.getUrlGenerator('TEST_GENERATOR'); + expect(generator).toMatchInlineSnapshot(` + Object { + "createUrl": [Function], + "id": "TEST_GENERATOR", + "isDeprecated": false, + "migrate": [Function], + } + `); + await expect(generator.migrate({})).rejects.toEqual( + new Error('You cannot call migrate on a non-deprecated generator.') + ); + expect(await generator.createUrl({})).toBe('myurl'); +}); + +test('Registering a generator with a createUrl function that is deprecated throws an error', () => { + expect(() => + setup.registerUrlGenerator({ + id: 'TEST_GENERATOR', + migrate: () => Promise.resolve({ id: '', state: {} }), + createUrl: () => Promise.resolve('myurl'), + isDeprecated: true, + }) + ).toThrowError( + new Error('This generator is marked as deprecated. Do not supply a createUrl fn.') + ); +}); + +test('Registering a deprecated generator with no migration function throws an error', () => { + expect(() => + setup.registerUrlGenerator({ + id: 'TEST_GENERATOR', + isDeprecated: true, + }) + ).toThrowError( + new Error( + 'If the access link generator is marked as deprecated, you must provide a migration function.' + ) + ); +}); + +test('Registering a generator with no functions throws an error', () => { + expect(() => + setup.registerUrlGenerator({ + id: 'TEST_GENERATOR', + }) + ).toThrowError( + new Error('This generator is not marked as deprecated. Please provide a createUrl fn.') + ); +}); + +test('Registering a generator with a migrate function that is not deprecated throws an error', () => { + expect(() => + setup.registerUrlGenerator({ + id: 'TEST_GENERATOR', + migrate: () => Promise.resolve({ id: '', state: {} }), + isDeprecated: false, + }) + ).toThrowError( + new Error('If you provide a migration function, you must mark this generator as deprecated') + ); +}); + +test('Registering a generator with a migrate function and a createUrl fn throws an error', () => { + expect(() => + setup.registerUrlGenerator({ + id: 'TEST_GENERATOR', + createUrl: () => Promise.resolve('myurl'), + migrate: () => Promise.resolve({ id: '', state: {} }), + }) + ).toThrowError(); +}); + +test('Generator returns migrated url', async () => { + setup.registerUrlGenerator({ + id: 'v1', + migrate: (state: { bar: string }) => Promise.resolve({ id: 'v2', state: { foo: state.bar } }), + isDeprecated: true, + }); + setup.registerUrlGenerator({ + id: 'v2', + createUrl: (state: { foo: string }) => Promise.resolve(`www.${state.foo}.com`), + isDeprecated: false, + }); + + const generator = start.getUrlGenerator('v1'); + expect(generator.isDeprecated).toBe(true); + expect(await generator.migrate({ bar: 'hi' })).toEqual({ id: 'v2', state: { foo: 'hi' } }); + expect(await generator.createUrl({ bar: 'hi' })).toEqual('www.hi.com'); +}); diff --git a/src/plugins/share/public/url_generators/url_generator_service.ts b/src/plugins/share/public/url_generators/url_generator_service.ts new file mode 100644 index 0000000000000..332750671cee3 --- /dev/null +++ b/src/plugins/share/public/url_generators/url_generator_service.ts @@ -0,0 +1,76 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { i18n } from '@kbn/i18n'; +import { UrlGeneratorId, UrlGeneratorsDefinition } from './url_generator_definition'; +import { UrlGeneratorInternal } from './url_generator_internal'; +import { UrlGeneratorContract } from './url_generator_contract'; + +export interface UrlGeneratorsStart { + getUrlGenerator: (urlGeneratorId: UrlGeneratorId) => UrlGeneratorContract; +} + +export interface UrlGeneratorsSetup { + registerUrlGenerator: (generator: UrlGeneratorsDefinition) => void; +} + +export class UrlGeneratorsService implements Plugin { + // Unfortunate use of any here, but I haven't figured out how to type this any better without + // getting warnings. + private urlGenerators: Map> = new Map(); + + constructor() {} + + public setup(core: CoreSetup) { + const setup: UrlGeneratorsSetup = { + registerUrlGenerator: ( + generatorOptions: UrlGeneratorsDefinition + ) => { + this.urlGenerators.set( + generatorOptions.id, + new UrlGeneratorInternal(generatorOptions, this.getUrlGenerator) + ); + }, + }; + return setup; + } + + public start(core: CoreStart) { + const start: UrlGeneratorsStart = { + getUrlGenerator: this.getUrlGenerator, + }; + return start; + } + + public stop() {} + + private readonly getUrlGenerator = (id: UrlGeneratorId) => { + const generator = this.urlGenerators.get(id); + if (!generator) { + throw new Error( + i18n.translate('share.urlGenerators.errors.noGeneratorWithId', { + defaultMessage: 'No generator found with id {id}', + values: { id }, + }) + ); + } + return generator.getPublicContract(); + }; +} diff --git a/src/plugins/ui_actions/public/actions/action.test.ts b/src/plugins/ui_actions/public/actions/action.test.ts index e1a789ae1cc45..f9d696d3ddb5f 100644 --- a/src/plugins/ui_actions/public/actions/action.test.ts +++ b/src/plugins/ui_actions/public/actions/action.test.ts @@ -17,17 +17,23 @@ * under the License. */ -import { createSayHelloAction } from '../tests/test_samples/say_hello_action'; +import { createAction } from '../../../ui_actions/public'; +import { ActionType } from '../types'; -test('SayHelloAction is not compatible with not matching context', async () => { - const sayHelloAction = createSayHelloAction((() => {}) as any); +const sayHelloAction = createAction({ + // Casting to ActionType is a hack - in a real situation use + // declare module and add this id to ActionContextMapping. + type: 'test' as ActionType, + isCompatible: ({ amICompatible }: { amICompatible: boolean }) => Promise.resolve(amICompatible), + execute: () => Promise.resolve(), +}); - const isCompatible = await sayHelloAction.isCompatible({} as any); +test('action is not compatible based on context', async () => { + const isCompatible = await sayHelloAction.isCompatible({ amICompatible: false }); expect(isCompatible).toBe(false); }); -test('HelloWorldAction inherits isCompatible from base action', async () => { - const helloWorldAction = createSayHelloAction({} as any); - const isCompatible = await helloWorldAction.isCompatible({ name: 'Sue' }); +test('action is compatible based on context', async () => { + const isCompatible = await sayHelloAction.isCompatible({ amICompatible: true }); expect(isCompatible).toBe(true); }); diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index 854e2c8c1cb09..2b2fc004a84c6 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -18,17 +18,26 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/common'; +import { ActionType, ActionContextMapping } from '../types'; -export interface Action { +export type ActionByType = Action; + +export interface Action { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. */ order?: number; + /** + * A unique identifier for this action instance. + */ id: string; - readonly type: string; + /** + * The action type is what determines the context shape. + */ + readonly type: T; /** * Optional EUI icon type that can be displayed along with the title. diff --git a/src/plugins/ui_actions/public/actions/action_definition.ts b/src/plugins/ui_actions/public/actions/action_definition.ts new file mode 100644 index 0000000000000..c590cf8f34ee0 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_definition.ts @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UiComponent } from 'src/plugins/kibana_utils/common'; +import { ActionType, ActionContextMapping } from '../types'; + +export interface ActionDefinition { + /** + * Determined the order when there is more than one action matched to a trigger. + * Higher numbers are displayed first. + */ + order?: number; + + /** + * A unique identifier for this action instance. + */ + id?: string; + + /** + * The action type is what determines the context shape. + */ + readonly type: T; + + /** + * Optional EUI icon type that can be displayed along with the title. + */ + getIconType?(context: ActionContextMapping[T]): string; + + /** + * Returns a title to be displayed to the user. + * @param context + */ + getDisplayName?(context: ActionContextMapping[T]): string; + + /** + * `UiComponent` to render when displaying this action as a context menu item. + * If not provided, `getDisplayName` will be used instead. + */ + MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>; + + /** + * Returns a promise that resolves to true if this action is compatible given the context, + * otherwise resolves to false. + */ + isCompatible?(context: ActionContextMapping[T]): Promise; + + /** + * If this returns something truthy, this is used in addition to the `execute` method when clicked. + */ + getHref?(context: ActionContextMapping[T]): string | undefined; + + /** + * Executes the action. + */ + execute(context: ActionContextMapping[T]): Promise; +} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index 4077cf1081021..90a9415c0b497 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -17,11 +17,11 @@ * under the License. */ -import { Action } from './action'; +import { ActionByType } from './action'; +import { ActionType } from '../types'; +import { ActionDefinition } from './action_definition'; -export function createAction( - action: { type: string; execute: Action['execute'] } & Partial> -): Action { +export function createAction(action: ActionDefinition): ActionByType { return { getIconType: () => undefined, order: 0, diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index eb69aefdbb50e..79b8e1474f6c2 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -29,4 +29,5 @@ export { UiActionsServiceParams, UiActionsService } from './service'; export { Action, createAction, IncompatibleActionError } from './actions'; export { buildContextMenuForActions } from './context_menu'; export { Trigger, TriggerContext } from './triggers'; -export { TriggerContextMapping, TriggerId } from './types'; +export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; +export { ActionByType } from './actions'; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index 948450495384a..c1be6b2626525 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -41,6 +41,7 @@ const createStartContract = (): Start => { attachAction: jest.fn(), registerAction: jest.fn(), registerTrigger: jest.fn(), + getAction: jest.fn(), detachAction: jest.fn(), executeTriggerActions: jest.fn(), getTrigger: jest.fn(), diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index c52b975358610..bdf71a25e6dbc 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -18,14 +18,13 @@ */ import { UiActionsService } from './ui_actions_service'; -import { Action } from '../actions'; -import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples'; -import { ActionRegistry, TriggerRegistry, TriggerId } from '../types'; +import { Action, createAction } from '../actions'; +import { createHelloWorldAction } from '../tests/test_samples'; +import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { Trigger } from '../triggers'; -// I tried redeclaring the module in here to extend the `TriggerContextMapping` but -// that seems to overwrite all other plugins extending it, I suspect because it's inside -// the main plugin. +// Casting to ActionType or TriggerId is a hack - in a real situation use +// declare module and add this id to the appropriate context mapping. const FOO_TRIGGER: TriggerId = 'FOO_TRIGGER' as TriggerId; const BAR_TRIGGER: TriggerId = 'BAR_TRIGGER' as TriggerId; const MY_TRIGGER: TriggerId = 'MY_TRIGGER' as TriggerId; @@ -33,7 +32,7 @@ const MY_TRIGGER: TriggerId = 'MY_TRIGGER' as TriggerId; const testAction1: Action = { id: 'action1', order: 1, - type: 'type1', + type: 'type1' as ActionType, execute: async () => {}, getDisplayName: () => 'test1', getIconType: () => '', @@ -43,7 +42,7 @@ const testAction1: Action = { const testAction2: Action = { id: 'action2', order: 2, - type: 'type2', + type: 'type2' as ActionType, execute: async () => {}, getDisplayName: () => 'test2', getIconType: () => '', @@ -100,7 +99,7 @@ describe('UiActionsService', () => { getDisplayName: () => 'test', getIconType: () => '', isCompatible: async () => true, - type: 'test', + type: 'test' as ActionType, }); }); }); @@ -109,7 +108,7 @@ describe('UiActionsService', () => { const action1: Action = { id: 'action1', order: 1, - type: 'type1', + type: 'type1' as ActionType, execute: async () => {}, getDisplayName: () => 'test', getIconType: () => '', @@ -118,7 +117,7 @@ describe('UiActionsService', () => { const action2: Action = { id: 'action2', order: 2, - type: 'type2', + type: 'type2' as ActionType, execute: async () => {}, getDisplayName: () => 'test', getIconType: () => '', @@ -140,13 +139,13 @@ describe('UiActionsService', () => { expect(list0).toHaveLength(0); - service.attachAction(FOO_TRIGGER, 'action1'); + service.attachAction(FOO_TRIGGER, action1); const list1 = service.getTriggerActions(FOO_TRIGGER); expect(list1).toHaveLength(1); expect(list1).toEqual([action1]); - service.attachAction(FOO_TRIGGER, 'action2'); + service.attachAction(FOO_TRIGGER, action2); const list2 = service.getTriggerActions(FOO_TRIGGER); expect(list2).toHaveLength(2); @@ -179,7 +178,7 @@ describe('UiActionsService', () => { title: 'My trigger', }; service.registerTrigger(testTrigger); - service.attachAction(MY_TRIGGER, helloWorldAction.id); + service.attachAction(MY_TRIGGER, helloWorldAction); const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { hi: 'there', @@ -191,11 +190,13 @@ describe('UiActionsService', () => { test('filters out actions not applicable based on the context', async () => { const service = new UiActionsService(); - const restrictedAction = createRestrictedAction<{ accept: boolean }>(context => { - return context.accept; + const action = createAction({ + type: 'test' as ActionType, + isCompatible: ({ accept }: { accept: boolean }) => Promise.resolve(accept), + execute: () => Promise.resolve(), }); - service.registerAction(restrictedAction); + service.registerAction(action); const testTrigger: Trigger = { id: MY_TRIGGER, @@ -203,7 +204,7 @@ describe('UiActionsService', () => { }; service.registerTrigger(testTrigger); - service.attachAction(testTrigger.id, restrictedAction.id); + service.attachAction(testTrigger.id, action); const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { accept: true, @@ -287,7 +288,7 @@ describe('UiActionsService', () => { id: FOO_TRIGGER, }); service1.registerAction(testAction1); - service1.attachAction(FOO_TRIGGER, testAction1.id); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); @@ -308,14 +309,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1.id); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service2.attachAction(FOO_TRIGGER, testAction2.id); + service2.attachAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); @@ -329,14 +330,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1.id); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service1.attachAction(FOO_TRIGGER, testAction2.id); + service1.attachAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); @@ -344,7 +345,7 @@ describe('UiActionsService', () => { }); describe('registries', () => { - const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID'; + const ACTION_HELLO_WORLD = 'ACTION_HELLO_WORLD'; test('can register trigger', () => { const triggers: TriggerRegistry = new Map(); @@ -369,12 +370,12 @@ describe('UiActionsService', () => { const service = new UiActionsService({ actions }); service.registerAction({ - id: HELLO_WORLD_ACTION_ID, + id: ACTION_HELLO_WORLD, order: 13, } as any); - expect(actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({ - id: HELLO_WORLD_ACTION_ID, + expect(actions.get(ACTION_HELLO_WORLD)).toMatchObject({ + id: ACTION_HELLO_WORLD, order: 13, }); }); @@ -386,18 +387,17 @@ describe('UiActionsService', () => { id: MY_TRIGGER, }; const action = { - id: HELLO_WORLD_ACTION_ID, + id: ACTION_HELLO_WORLD, order: 25, } as any; service.registerTrigger(trigger); - service.registerAction(action); - service.attachAction(MY_TRIGGER, HELLO_WORLD_ACTION_ID); + service.attachAction(MY_TRIGGER, action); const actions = service.getTriggerActions(trigger.id); expect(actions.length).toBe(1); - expect(actions[0].id).toBe(HELLO_WORLD_ACTION_ID); + expect(actions[0].id).toBe(ACTION_HELLO_WORLD); }); test('can detach an action to a trigger', () => { @@ -407,14 +407,14 @@ describe('UiActionsService', () => { id: MY_TRIGGER, }; const action = { - id: HELLO_WORLD_ACTION_ID, + id: ACTION_HELLO_WORLD, order: 25, } as any; service.registerTrigger(trigger); service.registerAction(action); - service.attachAction(trigger.id, HELLO_WORLD_ACTION_ID); - service.detachAction(trigger.id, HELLO_WORLD_ACTION_ID); + service.attachAction(trigger.id, action); + service.detachAction(trigger.id, action.id); const actions2 = service.getTriggerActions(trigger.id); expect(actions2).toEqual([]); @@ -424,15 +424,15 @@ describe('UiActionsService', () => { const service = new UiActionsService(); const action = { - id: HELLO_WORLD_ACTION_ID, + id: ACTION_HELLO_WORLD, order: 25, } as any; service.registerAction(action); expect(() => - service.detachAction('i do not exist' as TriggerId, HELLO_WORLD_ACTION_ID) + service.detachAction('i do not exist' as TriggerId, ACTION_HELLO_WORLD) ).toThrowError( - 'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].' + 'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = ACTION_HELLO_WORLD].' ); }); @@ -440,15 +440,13 @@ describe('UiActionsService', () => { const service = new UiActionsService(); const action = { - id: HELLO_WORLD_ACTION_ID, + id: ACTION_HELLO_WORLD, order: 25, } as any; service.registerAction(action); - expect(() => - service.attachAction('i do not exist' as TriggerId, HELLO_WORLD_ACTION_ID) - ).toThrowError( - 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].' + expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError( + 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].' ); }); @@ -456,13 +454,13 @@ describe('UiActionsService', () => { const service = new UiActionsService(); const action = { - id: HELLO_WORLD_ACTION_ID, + id: ACTION_HELLO_WORLD, order: 25, } as any; service.registerAction(action); expect(() => service.registerAction(action)).toThrowError( - 'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.' + 'Action [action.id = ACTION_HELLO_WORLD] already registered.' ); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 66f038f05a4ac..f7718e63773f5 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -23,8 +23,9 @@ import { TriggerToActionsRegistry, TriggerId, TriggerContextMapping, + ActionType, } from '../types'; -import { Action } from '../actions'; +import { Action, ActionByType } from '../actions'; import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; @@ -75,7 +76,7 @@ export class UiActionsService { return trigger.contract; }; - public readonly registerAction = (action: Action) => { + public readonly registerAction = (action: ActionByType) => { if (this.actions.has(action.id)) { throw new Error(`Action [action.id = ${action.id}] already registered.`); } @@ -83,22 +84,41 @@ export class UiActionsService { this.actions.set(action.id, action); }; - // TODO: make this - // (triggerId: T, action: Action): \ - // to get type checks here! - public readonly attachAction = (triggerId: T, actionId: string): void => { + public readonly getAction = (id: string): ActionByType => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); + } + + return this.actions.get(id) as ActionByType; + }; + + public readonly attachAction = ( + triggerId: TType, + // The action can accept partial or no context, but if it needs context not provided + // by this type of trigger, typescript will complain. yay! + action: ActionByType & Action + ): void => { + if (!this.actions.has(action.id)) { + this.registerAction(action); + } else { + const registeredAction = this.actions.get(action.id); + if (registeredAction !== action) { + throw new Error(`A different action instance with this id is already registered.`); + } + } + const trigger = this.triggers.get(triggerId); if (!trigger) { throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].` ); } const actionIds = this.triggerToActions.get(triggerId); - if (!actionIds!.find(id => id === actionId)) { - this.triggerToActions.set(triggerId, [...actionIds!, actionId]); + if (!actionIds!.find(id => id === action.id)) { + this.triggerToActions.set(triggerId, [...actionIds!, action.id]); } }; diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 450bfbfc6c959..5b427f918c173 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -21,7 +21,7 @@ import { Action, createAction } from '../actions'; import { openContextMenu } from '../context_menu'; import { uiActionsPluginMock } from '../mocks'; import { Trigger } from '../triggers'; -import { TriggerId } from '../types'; +import { TriggerId, ActionType } from '../types'; jest.mock('../context_menu'); @@ -30,11 +30,18 @@ const openContextMenuSpy = (openContextMenu as any) as jest.SpyInstance; const CONTACT_USER_TRIGGER = 'CONTACT_USER_TRIGGER'; -function createTestAction(id: string, checkCompatibility: (context: A) => boolean): Action { - return createAction({ - type: 'testAction', - id, - isCompatible: context => Promise.resolve(checkCompatibility(context)), +// Casting to ActionType is a hack - in a real situation use +// declare module and add this id to ActionContextMapping. +const TEST_ACTION_TYPE = 'TEST_ACTION_TYPE' as ActionType; + +function createTestAction( + type: string, + checkCompatibility: (context: C) => boolean +): Action { + return createAction({ + type: type as ActionType, + id: type, + isCompatible: (context: C) => Promise.resolve(checkCompatibility(context)), execute: context => executeFn(context), }); } @@ -46,7 +53,7 @@ const reset = () => { uiActions.setup.registerTrigger({ id: CONTACT_USER_TRIGGER, }); - uiActions.setup.attachAction(CONTACT_USER_TRIGGER, 'SEND_MESSAGE_ACTION'); + // uiActions.setup.attachAction(CONTACT_USER_TRIGGER, 'ACTION_SEND_MESSAGE'); executeFn.mockReset(); openContextMenuSpy.mockReset(); @@ -62,8 +69,7 @@ test('executes a single action mapped to a trigger', async () => { const action = createTestAction('test1', () => true); setup.registerTrigger(trigger); - setup.registerAction(action); - setup.attachAction(trigger.id, 'test1'); + setup.attachAction(trigger.id, action); const context = {}; const start = doStart(); @@ -81,7 +87,6 @@ test('throws an error if there are no compatible actions to execute', async () = }; setup.registerTrigger(trigger); - setup.attachAction(trigger.id, 'testaction'); const context = {}; const start = doStart(); @@ -98,11 +103,13 @@ test('does not execute an incompatible action', async () => { id: 'MY-TRIGGER' as TriggerId, title: 'My trigger', }; - const action = createTestAction<{ name: string }>('test1', ({ name }) => name === 'executeme'); + const action = createTestAction<{ name: string }>( + 'test1', + ({ name }: { name: string }) => name === 'executeme' + ); setup.registerTrigger(trigger); - setup.registerAction(action); - setup.attachAction(trigger.id, 'test1'); + setup.attachAction(trigger.id, action); const start = doStart(); const context = { @@ -123,10 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as const action2 = createTestAction('test2', () => true); setup.registerTrigger(trigger); - setup.registerAction(action1); - setup.registerAction(action2); - setup.attachAction(trigger.id, 'test1'); - setup.attachAction(trigger.id, 'test2'); + setup.attachAction(trigger.id, action1); + setup.attachAction(trigger.id, action2); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -150,8 +155,7 @@ test('passes whole action context to isCompatible()', async () => { }); setup.registerTrigger(trigger); - setup.registerAction(action); - setup.attachAction(trigger.id, 'test'); + setup.attachAction(trigger.id, action); const start = doStart(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index ae335de4b3deb..f5a6a96fb41a4 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -19,17 +19,17 @@ import { Action } from '../actions'; import { uiActionsPluginMock } from '../mocks'; -import { TriggerId } from '../types'; +import { TriggerId, ActionType } from '../types'; const action1: Action = { id: 'action1', order: 1, - type: 'type1', + type: 'type1' as ActionType, } as any; const action2: Action = { id: 'action2', order: 2, - type: 'type2', + type: 'type2' as ActionType, } as any; test('returns actions set on trigger', () => { @@ -47,13 +47,13 @@ test('returns actions set on trigger', () => { expect(list0).toHaveLength(0); - setup.attachAction('trigger' as TriggerId, 'action1'); + setup.attachAction('trigger' as TriggerId, action1); const list1 = start.getTriggerActions('trigger' as TriggerId); expect(list1).toHaveLength(1); expect(list1).toEqual([action1]); - setup.attachAction('trigger' as TriggerId, 'action2'); + setup.attachAction('trigger' as TriggerId, action2); const list2 = start.getTriggerActions('trigger' as TriggerId); expect(list2).toHaveLength(2); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index dfb55e42b9443..c5e68e5d5ca5a 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -17,25 +17,27 @@ * under the License. */ -import { createSayHelloAction } from '../tests/test_samples/say_hello_action'; import { uiActionsPluginMock } from '../mocks'; -import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples'; -import { Action } from '../actions'; +import { createHelloWorldAction } from '../tests/test_samples'; +import { Action, createAction } from '../actions'; import { Trigger } from '../triggers'; -import { TriggerId } from '../types'; +import { TriggerId, ActionType } from '../types'; -let action: Action<{ name: string }>; +let action: Action<{ name: string }, ActionType>; let uiActions: ReturnType; beforeEach(() => { uiActions = uiActionsPluginMock.createPlugin(); - action = createSayHelloAction({} as any); + action = createAction({ + type: 'test' as ActionType, + execute: () => Promise.resolve(), + }); uiActions.setup.registerAction(action); uiActions.setup.registerTrigger({ id: 'trigger' as TriggerId, title: 'trigger', }); - uiActions.setup.attachAction('trigger' as TriggerId, action.id); + uiActions.setup.attachAction('trigger' as TriggerId, action); }); test('can register action', async () => { @@ -56,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => { title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction.id); + setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction); const start = doStart(); const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); @@ -67,19 +69,22 @@ test('getTriggerCompatibleActions returns attached actions', async () => { test('filters out actions not applicable based on the context', async () => { const { setup, doStart } = uiActions; - const restrictedAction = createRestrictedAction<{ accept: boolean }>(context => { - return context.accept; + const action1 = createAction({ + type: 'test1' as ActionType, + isCompatible: async (context: { accept: boolean }) => { + return Promise.resolve(context.accept); + }, + execute: () => Promise.resolve(), }); - setup.registerAction(restrictedAction); - const testTrigger: Trigger = { - id: 'MY-TRIGGER' as TriggerId, + id: 'MY-TRIGGER2' as TriggerId, title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.attachAction(testTrigger.id, restrictedAction.id); + setup.registerAction(action1); + setup.attachAction(testTrigger.id, action1); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx b/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx index 196f3e2d5cdc1..8fff231a867bf 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx +++ b/src/plugins/ui_actions/public/tests/test_samples/hello_world_action.tsx @@ -20,8 +20,9 @@ import React from 'react'; import { EuiFlyout, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; import { CoreStart } from 'src/core/public'; -import { createAction, Action } from '../../actions'; +import { createAction, ActionByType } from '../../actions'; import { toMountPoint, reactToUiComponent } from '../../../../kibana_react/public'; +import { ActionType } from '../../types'; const ReactMenuItem: React.FC = () => { return ( @@ -36,11 +37,15 @@ const ReactMenuItem: React.FC = () => { const UiMenuItem = reactToUiComponent(ReactMenuItem); -export const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID'; +// Casting to ActionType is a hack - in a real situation use +// declare module and add this id to ActionContextMapping. +export const ACTION_HELLO_WORLD = 'ACTION_HELLO_WORLD' as ActionType; -export function createHelloWorldAction(overlays: CoreStart['overlays']): Action { - return createAction({ - type: HELLO_WORLD_ACTION_ID, +export function createHelloWorldAction( + overlays: CoreStart['overlays'] +): ActionByType { + return createAction({ + type: ACTION_HELLO_WORLD, getIconType: () => 'lock', MenuItem: UiMenuItem, execute: async () => { diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index 40301d629aa41..7d63b1b6d5669 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,6 +16,4 @@ * specific language governing permissions and limitations * under the License. */ -export { createRestrictedAction } from './restricted_action'; -export { createSayHelloAction } from './say_hello_action'; export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/tests/test_samples/say_hello_action.tsx b/src/plugins/ui_actions/public/tests/test_samples/say_hello_action.tsx deleted file mode 100644 index f1265fed54b38..0000000000000 --- a/src/plugins/ui_actions/public/tests/test_samples/say_hello_action.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { CoreStart } from 'src/core/public'; -import { Action, createAction } from '../../actions'; -import { toMountPoint } from '../../../../kibana_react/public'; - -export const SAY_HELLO_ACTION = 'SAY_HELLO_ACTION'; - -export function createSayHelloAction(overlays: CoreStart['overlays']): Action<{ name: string }> { - return createAction<{ name: string }>({ - type: SAY_HELLO_ACTION, - getDisplayName: ({ name }) => `Hello, ${name}`, - isCompatible: async ({ name }) => name !== undefined, - execute: async context => { - const flyoutSession = overlays.openFlyout( - toMountPoint( - flyoutSession && flyoutSession.close()}> - this.getDisplayName(context) - - ), - { - 'data-test-subj': 'sayHelloAction', - } - ); - }, - }); -} diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index d78d3c8951222..d443ce0e592cb 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,20 +17,27 @@ * under the License. */ -import { Action } from './actions/action'; +import { ActionByType } from './actions/action'; import { TriggerInternal } from './triggers/trigger_internal'; export type TriggerRegistry = Map>; -export type ActionRegistry = Map>; +export type ActionRegistry = Map>; export type TriggerToActionsRegistry = Map; const DEFAULT_TRIGGER = ''; export type TriggerId = keyof TriggerContextMapping; +export type BaseContext = object; export type TriggerContext = BaseContext; -export type BaseContext = object | undefined | string | number; export interface TriggerContextMapping { [DEFAULT_TRIGGER]: TriggerContext; } + +const DEFAULT_ACTION = ''; +export type ActionType = keyof ActionContextMapping; + +export interface ActionContextMapping { + [DEFAULT_ACTION]: BaseContext; +} diff --git a/test/examples/embeddables/adding_children.ts b/test/examples/embeddables/adding_children.ts index 8f4951b0e22fe..110b8ce573332 100644 --- a/test/examples/embeddables/adding_children.ts +++ b/test/examples/embeddables/adding_children.ts @@ -31,7 +31,7 @@ export default function({ getService }: PluginFunctionalProviderContext) { it('Can create a new child', async () => { await testSubjects.click('embeddablePanelToggleMenuIcon'); - await testSubjects.click('embeddablePanelAction-ADD_PANEL_ACTION_ID'); + await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); await testSubjects.click('createNew'); await testSubjects.click('createNew-TODO_EMBEDDABLE'); await testSubjects.setValue('taskInputField', 'new task'); diff --git a/test/examples/ui_actions/ui_actions.ts b/test/examples/ui_actions/ui_actions.ts index f047bfa333d88..8fe599a907070 100644 --- a/test/examples/ui_actions/ui_actions.ts +++ b/test/examples/ui_actions/ui_actions.ts @@ -41,7 +41,7 @@ export default function({ getService }: PluginFunctionalProviderContext) { await testSubjects.click('addDynamicAction'); await retry.try(async () => { await testSubjects.click('emitHelloWorldTrigger'); - await testSubjects.click('embeddablePanelAction-HELLO_WORLD_ACTION_TYPE-Waldo'); + await testSubjects.click('embeddablePanelAction-ACTION_HELLO_WORLD-Waldo'); }); await retry.try(async () => { const text = await testSubjects.getVisibleText('dynamicHelloWorldActionText'); diff --git a/test/functional/apps/dashboard/full_screen_mode.js b/test/functional/apps/dashboard/full_screen_mode.js index 69c0a05b8413b..df00f64530ca0 100644 --- a/test/functional/apps/dashboard/full_screen_mode.js +++ b/test/functional/apps/dashboard/full_screen_mode.js @@ -75,9 +75,7 @@ export default function({ getService, getPageObjects }) { }); it('exits when the text button is clicked on', async () => { - const logoButton = await PageObjects.dashboard.getExitFullScreenLogoButton(); - await logoButton.moveMouseTo(); - await PageObjects.dashboard.clickExitFullScreenTextButton(); + await PageObjects.dashboard.exitFullScreenMode(); await retry.try(async () => { const isChromeVisible = await PageObjects.common.isChromeVisible(); expect(isChromeVisible).to.be(true); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index ff340c6b0abcd..a0f503eb27e68 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -87,6 +87,8 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clearAdvancedSettings(propertyName: string) { await testSubjects.click(`advancedSetting-resetField-${propertyName}`); await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.click(`advancedSetting-saveButton`); + await PageObjects.header.waitUntilLoadingHasFinished(); } async setAdvancedSettingsSelect(propertyName: string, propertyValue: string) { diff --git a/test/functional/services/dashboard/panel_actions.js b/test/functional/services/dashboard/panel_actions.js index fafefaefc2cee..baea2a52208c1 100644 --- a/test/functional/services/dashboard/panel_actions.js +++ b/test/functional/services/dashboard/panel_actions.js @@ -21,7 +21,7 @@ const REMOVE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-deletePanel'; const EDIT_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-editPanel'; const REPLACE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-replacePanel'; const TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-togglePanel'; -const CUSTOMIZE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-CUSTOMIZE_PANEL_ACTION_ID'; +const CUSTOMIZE_PANEL_DATA_TEST_SUBJ = 'embeddablePanelAction-ACTION_CUSTOMIZE_PANEL'; const OPEN_CONTEXT_MENU_ICON_DATA_TEST_SUBJ = 'embeddablePanelToggleMenuIcon'; const OPEN_INSPECTOR_TEST_SUBJ = 'embeddablePanelAction-openInspector'; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index 2c58abba60558..25666dc0359d9 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -85,7 +85,7 @@ export class EmbeddableExplorerPublicPlugin plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sendMessageAction); - plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction.id); + plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.embeddable.registerEmbeddableFactory( helloWorldEmbeddableFactory.type, diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx index 4ce748e2c7118..8395fddece2a4 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx @@ -21,18 +21,22 @@ import React from 'react'; import { npStart, npSetup } from 'ui/new_platform'; import { CONTEXT_MENU_TRIGGER, IEmbeddable } from '../../../../../src/plugins/embeddable/public'; -import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { createAction, ActionType } from '../../../../../src/plugins/ui_actions/public'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -interface ActionContext { +// Casting to ActionType is a hack - in a real situation use +// declare module and add this id to ActionContextMapping. +export const SAMPLE_PANEL_ACTION = 'SAMPLE_PANEL_ACTION' as ActionType; + +export interface SamplePanelActionContext { embeddable: IEmbeddable; } function createSamplePanelAction() { - return createAction({ - type: 'samplePanelAction', + return createAction({ + type: SAMPLE_PANEL_ACTION, getDisplayName: () => 'Sample Panel Action', - execute: async ({ embeddable }) => { + execute: async ({ embeddable }: SamplePanelActionContext) => { if (!embeddable) { return; } @@ -59,4 +63,4 @@ function createSamplePanelAction() { const action = createSamplePanelAction(); npSetup.plugins.uiActions.registerAction(action); -npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action.id); +npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts index 7a3fb7fa85546..4b09be4db8a60 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts @@ -17,12 +17,16 @@ * under the License. */ import { npStart } from 'ui/new_platform'; -import { Action, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { Action, createAction, ActionType } from '../../../../../src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; +// Casting to ActionType is a hack - in a real situation use +// declare module and add this id to ActionContextMapping. +export const SAMPLE_PANEL_LINK = 'samplePanelLink' as ActionType; + export const createSamplePanelLink = (): Action => - createAction({ - type: 'samplePanelLink', + createAction({ + type: SAMPLE_PANEL_LINK, getDisplayName: () => 'Sample panel Link', execute: async () => {}, getHref: () => 'https://example.com/kibana/test', @@ -30,4 +34,4 @@ export const createSamplePanelLink = (): Action => const action = createSamplePanelLink(); npStart.plugins.uiActions.registerAction(action); -npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action.id); +npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); diff --git a/x-pack/legacy/plugins/maps/check_license.js b/x-pack/legacy/plugins/maps/check_license.js deleted file mode 100644 index 9e5397ee5dc75..0000000000000 --- a/x-pack/legacy/plugins/maps/check_license.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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. - */ - -/** - * on the license information extracted from the xPackInfo. - * @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from. - * @returns {LicenseCheckResult} - */ -export function checkLicense(xPackInfo) { - if (!xPackInfo.isAvailable()) { - return { - maps: false, - }; - } - - const isAnyXpackLicense = xPackInfo.license.isOneOf([ - 'basic', - 'standard', - 'gold', - 'platinum', - 'enterprise', - 'trial', - ]); - - if (!isAnyXpackLicense) { - return { - maps: false, - }; - } - - return { - maps: true, - uid: xPackInfo.license.getUid(), - }; -} diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js index ec3a588d3627f..73f222615493b 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable_factory.js @@ -23,6 +23,8 @@ import { getQueryableUniqueIndexPatternIds } from '../selectors/map_selectors'; import { getInitialLayers } from '../angular/get_initial_layers'; import { mergeInputWithSavedMap } from './merge_input_with_saved_map'; import '../angular/services/gis_map_saved_object_loader'; +import { bindSetupCoreAndPlugins } from '../plugin'; +import { npSetup } from 'ui/new_platform'; export class MapEmbeddableFactory extends EmbeddableFactory { type = MAP_SAVED_OBJECT_TYPE; @@ -37,6 +39,7 @@ export class MapEmbeddableFactory extends EmbeddableFactory { getIconForSavedObject: () => APP_ICON, }, }); + bindSetupCoreAndPlugins(npSetup.core, npSetup.plugins); } isEditable() { return capabilities.get().maps.save; diff --git a/x-pack/legacy/plugins/maps/public/plugin.ts b/x-pack/legacy/plugins/maps/public/plugin.ts index 51bec872a1c9b..c3f90d815239c 100644 --- a/x-pack/legacy/plugins/maps/public/plugin.ts +++ b/x-pack/legacy/plugins/maps/public/plugin.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Plugin, CoreStart } from 'src/core/public'; +import { Plugin, CoreStart, CoreSetup } from 'src/core/public'; // @ts-ignore import { wrapInI18nContext } from 'ui/i18n'; // @ts-ignore @@ -31,23 +31,25 @@ interface MapsPluginSetupDependencies { }; } +export const bindSetupCoreAndPlugins = (core: CoreSetup, plugins: any) => { + const { licensing } = plugins; + if (licensing) { + licensing.license$.subscribe(({ uid }: { uid: string }) => setLicenseId(uid)); + } +}; + /** @internal */ export class MapsPlugin implements Plugin { - public setup( - core: any, - { __LEGACY: { uiModules }, np: { licensing, home } }: MapsPluginSetupDependencies - ) { + public setup(core: CoreSetup, { __LEGACY: { uiModules }, np }: MapsPluginSetupDependencies) { uiModules .get('app/maps', ['ngRoute', 'react']) .directive('mapListing', function(reactDirective: any) { return reactDirective(wrapInI18nContext(MapListing)); }); - if (licensing) { - licensing.license$.subscribe(({ uid }) => setLicenseId(uid)); - } + bindSetupCoreAndPlugins(core, np); - home.featureCatalogue.register(featureCatalogueEntry); + np.home.featureCatalogue.register(featureCatalogueEntry); } public start(core: CoreStart, plugins: any) { diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js b/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js index cb7c9478244aa..da95ff1ac17fd 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js +++ b/x-pack/legacy/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js @@ -7,6 +7,10 @@ import { TIME_RANGE_TYPE, URL_TYPE } from './constants'; import rison from 'rison-node'; +import url from 'url'; + +import { npStart } from 'ui/new_platform'; +import { DASHBOARD_APP_URL_GENERATOR } from '../../../../../../../../../src/plugins/dashboard_embeddable_container/public'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { getPartitioningFieldNames } from '../../../../../common/util/job_utils'; @@ -152,52 +156,42 @@ function buildDashboardUrlFromSettings(settings) { query = searchSourceData.query; } - // Add time settings to the global state URL parameter with $earliest$ and - // $latest$ tokens which get substituted for times around the time of the - // anomaly on which the URL will be run against. - const _g = rison.encode({ - time: { - from: '$earliest$', - to: '$latest$', - mode: 'absolute', - }, - }); - - const appState = { - filters, - }; - - // To put entities in filters section would involve creating parameters of the form - // filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:b30fd340-efb4-11e7-a600-0f58b1422b87, - // key:airline,negate:!f,params:(query:AAL,type:phrase),type:phrase,value:AAL),query:(match:(airline:(query:AAL,type:phrase))))) - // which includes the ID of the index holding the field used in the filter. - - // So for simplicity, put entities in the query, replacing any query which is there already. - // e.g. query:(language:kuery,query:'region:us-east-1%20and%20instance:i-20d061fa') const queryFromEntityFieldNames = buildAppStateQueryParam(queryFieldNames); if (queryFromEntityFieldNames !== undefined) { query = queryFromEntityFieldNames; } - if (query !== undefined) { - appState.query = query; - } - - const _a = rison.encode(appState); - - const urlValue = `kibana#/dashboard/${dashboardId}?_g=${_g}&_a=${_a}`; - - const urlToAdd = { - url_name: settings.label, - url_value: urlValue, - time_range: TIME_RANGE_TYPE.AUTO, - }; - - if (settings.timeRange.type === TIME_RANGE_TYPE.INTERVAL) { - urlToAdd.time_range = settings.timeRange.interval; - } + const generator = npStart.plugins.share.urlGenerators.getUrlGenerator( + DASHBOARD_APP_URL_GENERATOR + ); + + return generator + .createUrl({ + dashboardId, + timeRange: { + from: '$earliest$', + to: '$latest$', + mode: 'absolute', + }, + filters, + query, + // Don't hash the URL since this string will be 1. shown to the user and 2. used as a + // template to inject the time parameters. + useHash: false, + }) + .then(urlValue => { + const urlToAdd = { + url_name: settings.label, + url_value: decodeURIComponent(`kibana${url.parse(urlValue).hash}`), + time_range: TIME_RANGE_TYPE.AUTO, + }; + + if (settings.timeRange.type === TIME_RANGE_TYPE.INTERVAL) { + urlToAdd.time_range = settings.timeRange.interval; + } - resolve(urlToAdd); + resolve(urlToAdd); + }); }) .catch(resp => { reject(resp); diff --git a/x-pack/legacy/plugins/monitoring/server/es_client/instantiate_client.js b/x-pack/legacy/plugins/monitoring/server/es_client/instantiate_client.js index 9aed1ac145617..671c6cdaaed70 100644 --- a/x-pack/legacy/plugins/monitoring/server/es_client/instantiate_client.js +++ b/x-pack/legacy/plugins/monitoring/server/es_client/instantiate_client.js @@ -25,6 +25,7 @@ export function exposeClient({ elasticsearchConfig, events, log, elasticsearchPl events.on('stop', bindKey(cluster, 'close')); const configSource = isMonitoringCluster ? 'monitoring' : 'production'; log([LOGGING_TAG, 'es-client'], `config sourced from: ${configSource} cluster`); + return cluster; } export function hasMonitoringCluster(config) { diff --git a/x-pack/legacy/plugins/monitoring/server/init_monitoring_xpack_info.js b/x-pack/legacy/plugins/monitoring/server/init_monitoring_xpack_info.js index ba07f512de896..7a6ab37798db6 100644 --- a/x-pack/legacy/plugins/monitoring/server/init_monitoring_xpack_info.js +++ b/x-pack/legacy/plugins/monitoring/server/init_monitoring_xpack_info.js @@ -7,15 +7,26 @@ import { checkLicenseGenerator } from './cluster_alerts/check_license'; import { hasMonitoringCluster } from './es_client/instantiate_client'; import { LOGGING_TAG } from '../common/constants'; +import { XPackInfo } from '../../xpack_main/server/lib/xpack_info'; /* * Expose xpackInfo for the Monitoring cluster as server.plugins.monitoring.info */ -export const initMonitoringXpackInfo = async ({ config, xpackMainPlugin, expose, log }) => { +export const initMonitoringXpackInfo = async ({ + config, + server, + client, + xpackMainPlugin, + licensing, + expose, + log, +}) => { const xpackInfo = hasMonitoringCluster(config) - ? xpackMainPlugin.createXPackInfo({ - clusterSource: 'monitoring', - pollFrequencyInMillis: config.get('monitoring.xpack_api_polling_frequency_millis'), + ? new XPackInfo(server, { + licensing: licensing.createLicensePoller( + client, + config.get('monitoring.xpack_api_polling_frequency_millis') + ), }) : xpackMainPlugin.info; diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.js index 8362ebec0206b..96a0354556093 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.js @@ -38,19 +38,29 @@ export async function verifyMonitoringAuth(req) { async function verifyHasPrivileges(req) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); - const response = await callWithRequest(req, 'transport.request', { - method: 'POST', - path: '/_security/user/_has_privileges', - body: { - index: [ - { - names: [INDEX_PATTERN], // uses wildcard - privileges: ['read'], - }, - ], - }, - ignoreUnavailable: true, // we allow 404 incase the user shutdown security in-between the check and now - }); + let response; + try { + response = await callWithRequest(req, 'transport.request', { + method: 'POST', + path: '/_security/user/_has_privileges', + body: { + index: [ + { + names: [INDEX_PATTERN], // uses wildcard + privileges: ['read'], + }, + ], + }, + ignoreUnavailable: true, // we allow 404 incase the user shutdown security in-between the check and now + }); + } catch (err) { + if ( + err.message === 'no handler found for uri [/_security/user/_has_privileges] and method [POST]' + ) { + return; + } + throw err; + } // we assume true because, if the response 404ed, then it will not exist but we should try to continue const hasAllRequestedPrivileges = get(response, 'has_all_requested', true); diff --git a/x-pack/legacy/plugins/monitoring/server/plugin.js b/x-pack/legacy/plugins/monitoring/server/plugin.js index 3d6d110a01949..fa9f1ae699919 100644 --- a/x-pack/legacy/plugins/monitoring/server/plugin.js +++ b/x-pack/legacy/plugins/monitoring/server/plugin.js @@ -60,7 +60,7 @@ export class Plugin { const elasticsearchConfig = parseElasticsearchConfig(config); // Create the dedicated client - await instantiateClient({ + const client = await instantiateClient({ log, events, elasticsearchConfig, @@ -77,6 +77,8 @@ export class Plugin { if (uiEnabled) { await initMonitoringXpackInfo({ config, + server: hapiServer, + client, log, xpackMainPlugin: plugins.xpack_main, expose, diff --git a/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index f8d8fdf481dd6..4c9cd890ee75b 100644 --- a/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/legacy/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -8,7 +8,10 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import { npSetup, npStart } from 'ui/new_platform'; -import { Action, IncompatibleActionError } from '../../../../../../src/plugins/ui_actions/public'; +import { + ActionByType, + IncompatibleActionError, +} from '../../../../../../src/plugins/ui_actions/public'; import { ViewMode, @@ -28,11 +31,17 @@ function isSavedSearchEmbeddable( return embeddable.type === SEARCH_EMBEDDABLE_TYPE; } -interface ActionContext { +export interface CSVActionContext { embeddable: ISearchEmbeddable; } -class GetCsvReportPanelAction implements Action { +declare module '../../../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [CSV_REPORTING_ACTION]: CSVActionContext; + } +} + +class GetCsvReportPanelAction implements ActionByType { private isDownloading: boolean; public readonly type = CSV_REPORTING_ACTION; public readonly id = CSV_REPORTING_ACTION; @@ -64,13 +73,13 @@ class GetCsvReportPanelAction implements Action { return searchEmbeddable.getSavedSearch().searchSource.getSearchRequestBody(); } - public isCompatible = async (context: ActionContext) => { + public isCompatible = async (context: CSVActionContext) => { const { embeddable } = context; return embeddable.getInput().viewMode !== ViewMode.EDIT && embeddable.type === 'search'; }; - public execute = async (context: ActionContext) => { + public execute = async (context: CSVActionContext) => { const { embeddable } = context; if (!isSavedSearchEmbeddable(embeddable)) { @@ -166,4 +175,4 @@ class GetCsvReportPanelAction implements Action { const action = new GetCsvReportPanelAction(); npSetup.plugins.uiActions.registerAction(action); -npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action.id); +npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.tsx new file mode 100644 index 0000000000000..3a2ef3bc21721 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options.tsx @@ -0,0 +1,27 @@ +/* + * 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 { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import * as i18n from './translations'; +import { ClosureOptionsRadio } from './closure_options_radio'; + +const ClosureOptionsComponent: React.FC = () => { + return ( + {i18n.CASE_CLOSURE_OPTIONS_TITLE}} + description={i18n.CASE_CLOSURE_OPTIONS_DESC} + > + + + + + ); +}; + +export const ClosureOptions = React.memo(ClosureOptionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.tsx new file mode 100644 index 0000000000000..5d1476acee5b1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/closure_options_radio.tsx @@ -0,0 +1,43 @@ +/* + * 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 { EuiRadioGroup } from '@elastic/eui'; + +import * as i18n from './translations'; + +const ID_PREFIX = 'closure_options'; +const DEFAULT_RADIO = `${ID_PREFIX}_manual`; + +const radios = [ + { + id: DEFAULT_RADIO, + label: i18n.CASE_CLOSURE_OPTIONS_MANUAL, + }, + { + id: `${ID_PREFIX}_new_incident`, + label: i18n.CASE_CLOSURE_OPTIONS_NEW_INCIDENT, + }, + { + id: `${ID_PREFIX}_closed_incident`, + label: i18n.CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT, + }, +]; + +const ClosureOptionsRadioComponent: React.FC = () => { + const [selectedClosure, setSelectedClosure] = useState(DEFAULT_RADIO); + + return ( + + ); +}; + +export const ClosureOptionsRadio = React.memo(ClosureOptionsRadioComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx similarity index 81% rename from x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown/index.tsx rename to x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx index c00baa04d78a0..d43935deda395 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { EuiSuperSelect, EuiIcon, EuiSuperSelectOption } from '@elastic/eui'; import styled from 'styled-components'; -import * as i18n from '../translations'; +import * as i18n from './translations'; const ICON_SIZE = 'm'; @@ -40,15 +40,14 @@ const connectors: Array> = [ ]; const ConnectorsDropdownComponent: React.FC = () => { - const [selectedConnector, selectConnector] = useState(connectors[0].value); - const onChange = useCallback(connector => selectConnector(connector), [selectedConnector]); + const [selectedConnector, setSelectedConnector] = useState(connectors[0].value); return ( ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx new file mode 100644 index 0000000000000..814f1bfd75ae4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EuiDescribedFormGroup, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import styled from 'styled-components'; + +import * as i18n from './translations'; +import { FieldMappingRow } from './field_mapping_row'; + +const FieldRowWrapper = styled.div` + margin-top: 8px; + font-size: 14px; +`; + +const supportedThirdPartyFields = [ + { + value: 'short_description', + inputDisplay: {'Short Description'}, + }, + { + value: 'comment', + inputDisplay: {'Comment'}, + }, + { + value: 'tags', + inputDisplay: {'Tags'}, + }, + { + value: 'description', + inputDisplay: {'Description'}, + }, +]; + +const FieldMappingComponent: React.FC = () => ( + {i18n.FIELD_MAPPING_TITLE}} + description={i18n.FIELD_MAPPING_DESC} + > + + + + {i18n.FIELD_MAPPING_FIRST_COL} + + + {i18n.FIELD_MAPPING_SECOND_COL} + + + {i18n.FIELD_MAPPING_THIRD_COL} + + + + + + + + + + +); + +export const FieldMapping = React.memo(FieldMappingComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx new file mode 100644 index 0000000000000..0e446ad9bbe89 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/field_mapping_row.tsx @@ -0,0 +1,73 @@ +/* + * 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 { EuiFlexItem, EuiFlexGroup, EuiSuperSelect, EuiIcon } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface ThirdPartyField { + value: string; + inputDisplay: JSX.Element; +} +interface RowProps { + siemField: string; + thirdPartyOptions: ThirdPartyField[]; +} + +const editUpdateOptions = [ + { + value: 'nothing', + inputDisplay: {i18n.FIELD_MAPPING_EDIT_NOTHING}, + 'data-test-subj': 'edit-update-option-nothing', + }, + { + value: 'overwrite', + inputDisplay: {i18n.FIELD_MAPPING_EDIT_OVERWRITE}, + 'data-test-subj': 'edit-update-option-overwrite', + }, + { + value: 'append', + inputDisplay: {i18n.FIELD_MAPPING_EDIT_APPEND}, + 'data-test-subj': 'edit-update-option-append', + }, +]; + +const FieldMappingRowComponent: React.FC = ({ siemField, thirdPartyOptions }) => { + const [selectedEditUpdate, setSelectedEditUpdate] = useState(editUpdateOptions[0].value); + const [selectedThirdParty, setSelectedThirdParty] = useState(thirdPartyOptions[0].value); + + return ( + + + + + {siemField} + + + + + + + + + + + + + + ); +}; + +export const FieldMappingRow = React.memo(FieldMappingRowComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts index 54d256b143f60..ca2d878c58ee3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/translations.ts @@ -35,3 +35,103 @@ export const NO_CONNECTOR = i18n.translate('xpack.siem.case.configureCases.noCon export const ADD_NEW_CONNECTOR = i18n.translate('xpack.siem.case.configureCases.addNewConnector', { defaultMessage: 'Add new connector option', }); + +export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( + 'xpack.siem.case.configureCases.caseClosureOptionsTitle', + { + defaultMessage: 'Cases Closures', + } +); + +export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( + 'xpack.siem.case.configureCases.caseClosureOptionsDesc', + { + defaultMessage: + 'Define how you wish SIEM cases to be closed. Automated case closures require an established connection to a third-party incident management system.', + } +); + +export const CASE_CLOSURE_OPTIONS_LABEL = i18n.translate( + 'xpack.siem.case.configureCases.caseClosureOptionsLabel', + { + defaultMessage: 'Case closure options', + } +); + +export const CASE_CLOSURE_OPTIONS_MANUAL = i18n.translate( + 'xpack.siem.case.configureCases.caseClosureOptionsManual', + { + defaultMessage: 'Manually close SIEM cases', + } +); + +export const CASE_CLOSURE_OPTIONS_NEW_INCIDENT = i18n.translate( + 'xpack.siem.case.configureCases.caseClosureOptionsNewIncident', + { + defaultMessage: 'Automatically close SIEM cases when pushing new incident to third-party', + } +); + +export const CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT = i18n.translate( + 'xpack.siem.case.configureCases.caseClosureOptionsClosedIncident', + { + defaultMessage: 'Automatically close SIEM cases when incident is closed in third-party', + } +); + +export const FIELD_MAPPING_TITLE = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingTitle', + { + defaultMessage: 'Field mappings', + } +); + +export const FIELD_MAPPING_DESC = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingDesc', + { + defaultMessage: + 'Map SIEM case fields when pushing data to a third-party. Field mappings require an established connection to a third-party incident management system.', + } +); + +export const FIELD_MAPPING_FIRST_COL = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingFirstCol', + { + defaultMessage: 'SIEM case field', + } +); + +export const FIELD_MAPPING_SECOND_COL = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingSecondCol', + { + defaultMessage: 'Third-party incident field', + } +); + +export const FIELD_MAPPING_THIRD_COL = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingThirdCol', + { + defaultMessage: 'On edit and update', + } +); + +export const FIELD_MAPPING_EDIT_NOTHING = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingEditNothing', + { + defaultMessage: 'Nothing', + } +); + +export const FIELD_MAPPING_EDIT_OVERWRITE = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingEditOverwrite', + { + defaultMessage: 'Overwrite', + } +); + +export const FIELD_MAPPING_EDIT_APPEND = i18n.translate( + 'xpack.siem.case.configureCases.fieldMappingEditAppend', + { + defaultMessage: 'Append', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx index 018f9dc9ade52..556d7779c664f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx @@ -14,6 +14,8 @@ import { getCaseUrl } from '../../components/link_to'; import { WhitePageWrapper, SectionWrapper } from './components/wrappers'; import { Connectors } from './components/configure_cases/connectors'; import * as i18n from './translations'; +import { ClosureOptions } from './components/configure_cases/closure_options'; +import { FieldMapping } from './components/configure_cases/field_mapping'; const backOptions = { href: getCaseUrl(), @@ -26,8 +28,12 @@ const wrapperPageStyle: Record = { paddingBottom: '0', }; -export const FormWrapper = styled.div` +const FormWrapper = styled.div` ${({ theme }) => css` + & > * { + margin-top 40px; + } + padding-top: ${theme.eui.paddingSizes.l}; padding-bottom: ${theme.eui.paddingSizes.l}; `} @@ -44,6 +50,12 @@ const ConfigureCasesPageComponent: React.FC = () => ( + + + + + + diff --git a/x-pack/legacy/plugins/transform/public/app/common/request.ts b/x-pack/legacy/plugins/transform/public/app/common/request.ts index 3b740de177ef8..31089b86a2c2d 100644 --- a/x-pack/legacy/plugins/transform/public/app/common/request.ts +++ b/x-pack/legacy/plugins/transform/public/app/common/request.ts @@ -7,7 +7,7 @@ import { DefaultOperator } from 'elasticsearch'; import { dictionaryToArray } from '../../../common/types/common'; -import { SavedSearchQuery } from '../lib/kibana'; +import { SavedSearchQuery } from '../hooks/use_search_items'; import { StepDefineExposedState } from '../sections/create_transform/components/step_define/step_define_form'; import { StepDetailsExposedState } from '../sections/create_transform/components/step_details/step_details_form'; diff --git a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx b/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx index 81af5c974fe04..095b57de97d9a 100644 --- a/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/components/toast_notification_text.test.tsx @@ -7,13 +7,13 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { KibanaContext } from '../lib/kibana'; import { createPublicShim } from '../../shim'; import { getAppProviders } from '../app_dependencies'; import { ToastNotificationText } from './toast_notification_text'; jest.mock('../../shared_imports'); +jest.mock('ui/new_platform'); describe('ToastNotificationText', () => { test('should render the text as plain text', () => { @@ -23,9 +23,7 @@ describe('ToastNotificationText', () => { }; const { container } = render( - - - + ); expect(container.textContent).toBe('a short text message'); @@ -39,9 +37,7 @@ describe('ToastNotificationText', () => { }; const { container } = render( - - - + ); expect(container.textContent).toBe( diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts similarity index 96% rename from x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts rename to x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts index aa4cd21281e22..2258f8f33f01d 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/common.ts +++ b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -14,6 +14,8 @@ import { import { matchAllQuery } from '../../common'; +export type SavedSearchQuery = object; + type IndexPatternId = string; type SavedSearchId = string; @@ -60,7 +62,7 @@ export function getIndexPatternIdByTitle(indexPatternTitle: string): string | un return indexPatternCache.find(d => d?.attributes?.title === indexPatternTitle)?.id; } -type CombinedQuery = Record<'bool', any> | unknown; +type CombinedQuery = Record<'bool', any> | object; export function loadCurrentIndexPattern( indexPatterns: IndexPatternsContract, @@ -79,17 +81,20 @@ export function loadCurrentSavedSearch(savedSearches: any, savedSearchId: SavedS function isIndexPattern(arg: any): arg is IndexPattern { return arg !== undefined; } + +export interface SearchItems { + indexPattern: IndexPattern; + savedSearch: any; + query: any; + combinedQuery: CombinedQuery; +} + // Helper for creating the items used for searching and job creation. export function createSearchItems( indexPattern: IndexPattern | undefined, savedSearch: any, config: IUiSettingsClient -): { - indexPattern: IndexPattern; - savedSearch: any; - query: any; - combinedQuery: CombinedQuery; -} { +): SearchItems { // query is only used by the data visualizer as it needs // a lucene query_string. // Using a blank query will cause match_all:{} to be used diff --git a/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts new file mode 100644 index 0000000000000..aa4f04f43b335 --- /dev/null +++ b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { SavedSearchQuery, SearchItems } from './common'; +export { useSearchItems } from './use_search_items'; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_provider.tsx b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts similarity index 53% rename from x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_provider.tsx rename to x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index f2574a4a85f29..12fc75c20ffa4 100644 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_provider.tsx +++ b/x-pack/legacy/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -4,30 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, FC } from 'react'; +import { useEffect, useState } from 'react'; + +import { createSavedSearchesLoader } from '../../../shared_imports'; import { useAppDependencies } from '../../app_dependencies'; import { createSearchItems, + getIndexPatternIdByTitle, loadCurrentIndexPattern, loadIndexPatterns, loadCurrentSavedSearch, + SearchItems, } from './common'; -import { InitializedKibanaContextValue, KibanaContext, KibanaContextValue } from './kibana_context'; - -interface Props { - savedObjectId: string; -} +export const useSearchItems = (defaultSavedObjectId: string | undefined) => { + const [savedObjectId, setSavedObjectId] = useState(defaultSavedObjectId); -export const KibanaProvider: FC = ({ savedObjectId, children }) => { const appDeps = useAppDependencies(); const indexPatterns = appDeps.plugins.data.indexPatterns; + const uiSettings = appDeps.core.uiSettings; const savedObjectsClient = appDeps.core.savedObjects.client; - const savedSearches = appDeps.plugins.savedSearches.getClient(); + const savedSearches = createSavedSearchesLoader({ + savedObjectsClient, + indexPatterns, + chrome: appDeps.core.chrome, + overlays: appDeps.core.overlays, + }); - const [contextValue, setContextValue] = useState({ initialized: false }); + const [searchItems, setSearchItems] = useState(undefined); async function fetchSavedObject(id: string) { await loadIndexPatterns(savedObjectsClient, indexPatterns); @@ -47,31 +53,21 @@ export const KibanaProvider: FC = ({ savedObjectId, children }) => { // Just let fetchedSavedSearch stay undefined in case it doesn't exist. } - const kibanaConfig = appDeps.core.uiSettings; - - const { - indexPattern: currentIndexPattern, - savedSearch: currentSavedSearch, - combinedQuery, - } = createSearchItems(fetchedIndexPattern, fetchedSavedSearch, kibanaConfig); - - const kibanaContext: InitializedKibanaContextValue = { - indexPatterns, - initialized: true, - kibanaConfig, - combinedQuery, - currentIndexPattern, - currentSavedSearch, - }; - - setContextValue(kibanaContext); + setSearchItems(createSearchItems(fetchedIndexPattern, fetchedSavedSearch, uiSettings)); } useEffect(() => { - fetchSavedObject(savedObjectId); - // fetchSavedObject should not be tracked. + if (savedObjectId !== undefined) { + fetchSavedObject(savedObjectId); + } + // Run this only when savedObjectId changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [savedObjectId]); - return {children}; + return { + getIndexPatternIdByTitle, + loadIndexPatterns, + searchItems, + setSavedObjectId, + }; }; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/index.ts b/x-pack/legacy/plugins/transform/public/app/lib/kibana/index.ts deleted file mode 100644 index 62107cb37ff2c..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { getIndexPatternIdByTitle, loadIndexPatterns } from './common'; -export { - useKibanaContext, - InitializedKibanaContextValue, - KibanaContext, - KibanaContextValue, - SavedSearchQuery, - RenderOnlyWithInitializedKibanaContext, -} from './kibana_context'; -export { KibanaProvider } from './kibana_provider'; -export { useCurrentIndexPattern } from './use_current_index_pattern'; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_context.tsx b/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_context.tsx deleted file mode 100644 index 7677c491a7a59..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/kibana_context.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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, { createContext, useContext, FC } from 'react'; - -import { IUiSettingsClient } from 'kibana/public'; - -import { - IndexPattern, - IndexPatternsContract, -} from '../../../../../../../../src/plugins/data/public'; -import { SavedSearch } from '../../../../../../../../src/plugins/discover/public/'; - -interface UninitializedKibanaContextValue { - initialized: false; -} - -export interface InitializedKibanaContextValue { - combinedQuery: any; - indexPatterns: IndexPatternsContract; - initialized: true; - kibanaConfig: IUiSettingsClient; - currentIndexPattern: IndexPattern; - currentSavedSearch?: SavedSearch; -} - -export type KibanaContextValue = UninitializedKibanaContextValue | InitializedKibanaContextValue; - -export function isKibanaContextInitialized(arg: any): arg is InitializedKibanaContextValue { - return arg.initialized; -} - -export type SavedSearchQuery = object; - -export const KibanaContext = createContext({ initialized: false }); - -/** - * Custom hook to get the current kibanaContext. - * - * @remarks - * This hook should only be used in components wrapped in `RenderOnlyWithInitializedKibanaContext`, - * otherwise it will throw an error when KibanaContext hasn't been initialized yet. - * In return you get the benefit of not having to check if it's been initialized in the component - * where it's used. - * - * @returns `kibanaContext` - */ -export const useKibanaContext = () => { - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContextInitialized(kibanaContext)) { - throw new Error('useKibanaContext: kibanaContext not initialized'); - } - - return kibanaContext; -}; - -/** - * Wrapper component to render children only if `kibanaContext` has been initialized. - * In combination with `useKibanaContext` this avoids having to check for the initialization - * in consuming components. - * - * @returns `children` or `null` depending on whether `kibanaContext` is initialized or not. - */ -export const RenderOnlyWithInitializedKibanaContext: FC = ({ children }) => { - const kibanaContext = useContext(KibanaContext); - - return isKibanaContextInitialized(kibanaContext) ? <>{children} : null; -}; diff --git a/x-pack/legacy/plugins/transform/public/app/lib/kibana/use_current_index_pattern.ts b/x-pack/legacy/plugins/transform/public/app/lib/kibana/use_current_index_pattern.ts deleted file mode 100644 index 12c5bde171b8b..0000000000000 --- a/x-pack/legacy/plugins/transform/public/app/lib/kibana/use_current_index_pattern.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 { useContext } from 'react'; - -import { isKibanaContextInitialized, KibanaContext } from './kibana_context'; - -export const useCurrentIndexPattern = () => { - const context = useContext(KibanaContext); - - if (!isKibanaContextInitialized(context)) { - throw new Error('useCurrentIndexPattern: kibanaContext not initialized'); - } - - return context.currentIndexPattern; -}; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index c5c46dcac6c95..4618e96cbfd6e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -22,6 +22,7 @@ import { } from '@elastic/eui'; import { useApi } from '../../hooks/use_api'; +import { useSearchItems } from '../../hooks/use_search_items'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; @@ -29,12 +30,6 @@ import { useAppDependencies, useDocumentationLinks } from '../../app_dependencie import { TransformPivotConfig } from '../../common'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; -import { - getIndexPatternIdByTitle, - loadIndexPatterns, - KibanaProvider, - RenderOnlyWithInitializedKibanaContext, -} from '../../lib/kibana'; import { Wizard } from '../create_transform/components/wizard'; @@ -80,7 +75,12 @@ export const CloneTransformSection: FC = ({ match }) => { const [transformConfig, setTransformConfig] = useState(); const [errorMessage, setErrorMessage] = useState(); const [isInitialized, setIsInitialized] = useState(false); - const [savedObjectId, setSavedObjectId] = useState(undefined); + const { + getIndexPatternIdByTitle, + loadIndexPatterns, + searchItems, + setSavedObjectId, + } = useSearchItems(undefined); const fetchTransformConfig = async () => { try { @@ -169,12 +169,8 @@ export const CloneTransformSection: FC = ({ match }) => {
{JSON.stringify(errorMessage)}
)} - {savedObjectId !== undefined && isInitialized === true && transformConfig !== undefined && ( - - - - - + {searchItems !== undefined && isInitialized === true && transformConfig !== undefined && ( + )} diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap index e43f2e37bb416..6d2d3d5c4a6a5 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap @@ -1,24 +1,584 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Minimal initialization 1`] = ` -
- - + + + - -
+ > + + + + + `; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx index bfde8f171874e..ddd1a1482fd35 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/expanded_row.test.tsx @@ -39,8 +39,6 @@ describe('Transform: ', () => { }, }; - // Using a wrapping
element because shallow() would fail - // with the Provider being the outer most component. const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx index 16949425284fd..ec79735741427 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.test.tsx @@ -7,8 +7,10 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaContext } from '../../../../lib/kibana'; +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { getPivotQuery } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { SourceIndexPreview } from './source_index_preview'; @@ -18,22 +20,24 @@ jest.mock('react', () => { return { ...r, memo: (x: any) => x }; }); +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: ', () => { test('Minimal initialization', () => { const props = { + indexPattern: { + title: 'the-index-pattern-title', + fields: [] as any[], + } as SearchItems['indexPattern'], query: getPivotQuery('the-query'), }; - // Using a wrapping
element because shallow() would fail - // with the Provider being the outer most component. + const Providers = getAppProviders(createPublicShim()); const wrapper = shallow( -
- - - -
+ + + ); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx index 0c9dcfb9b1c04..76ed12ff772f5 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/source_index_preview.tsx @@ -22,14 +22,13 @@ import { import { getNestedProperty } from '../../../../../../common/utils/object_utils'; -import { useCurrentIndexPattern } from '../../../../lib/kibana'; - import { euiDataGridStyle, euiDataGridToolbarSettings, EsFieldName, PivotQuery, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { getSourceIndexDevConsoleStatement } from './common'; import { SOURCE_INDEX_STATUS, useSourceIndexData } from './use_source_index_data'; @@ -49,13 +48,13 @@ const SourceIndexPreviewTitle: React.FC = ({ indexPatte ); interface Props { + indexPattern: SearchItems['indexPattern']; query: PivotQuery; } const defaultPagination = { pageIndex: 0, pageSize: 5 }; -export const SourceIndexPreview: React.FC = React.memo(({ query }) => { - const indexPattern = useCurrentIndexPattern(); +export const SourceIndexPreview: React.FC = React.memo(({ indexPattern, query }) => { const allFields = indexPattern.fields.map(f => f.name); const indexPatternFields: string[] = allFields.filter(f => { if (indexPattern.metaFields.includes(f)) { diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap index e034badea9b11..db4ff0c1a99ae 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap @@ -1,27 +1,581 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Minimal initialization 1`] = ` -
- - + + + - -
+ > + + + + + `; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx index 625c545ee8c46..80968fd6e2887 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.test.tsx @@ -7,7 +7,8 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaContext } from '../../../../lib/kibana'; +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { StepCreateForm } from './step_create_form'; @@ -17,6 +18,7 @@ jest.mock('react', () => { return { ...r, memo: (x: any) => x }; }); +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: ', () => { @@ -29,14 +31,11 @@ describe('Transform: ', () => { onChange() {}, }; - // Using a wrapping
element because shallow() would fail - // with the Provider being the outer most component. + const Providers = getAppProviders(createPublicShim()); const wrapper = shallow( -
- - - -
+ + + ); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index bbeb97b6b8113..4198c2ea0260d 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -34,7 +34,6 @@ import { PROGRESS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants import { getTransformProgress, getDiscoverUrl } from '../../../../common'; import { useApi } from '../../../../hooks/use_api'; -import { useKibanaContext } from '../../../../lib/kibana'; import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; import { RedirectToTransformManagement } from '../../../../common/navigation'; import { ToastNotificationText } from '../../../../components'; @@ -76,7 +75,8 @@ export const StepCreateForm: FC = React.memo( ); const deps = useAppDependencies(); - const kibanaContext = useKibanaContext(); + const indexPatterns = deps.plugins.data.indexPatterns; + const uiSettings = deps.core.uiSettings; const toastNotifications = useToastNotifications(); useEffect(() => { @@ -176,7 +176,7 @@ export const StepCreateForm: FC = React.memo( const indexPatternName = transformConfig.dest.index; try { - const newIndexPattern = await kibanaContext.indexPatterns.make(); + const newIndexPattern = await indexPatterns.make(); Object.assign(newIndexPattern, { id: '', @@ -200,8 +200,8 @@ export const StepCreateForm: FC = React.memo( // check if there's a default index pattern, if not, // set the newly created one as the default index pattern. - if (!kibanaContext.kibanaConfig.get('defaultIndex')) { - await kibanaContext.kibanaConfig.set('defaultIndex', id); + if (!uiSettings.get('defaultIndex')) { + await uiSettings.set('defaultIndex', id); } toastNotifications.addSuccess( diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap index a7da172a67b8a..bc0d983c6e022 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap @@ -1,44 +1,604 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Minimal initialization 1`] = ` -
- - + + + - -
+ > + + + + + `; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap index 70a0bfc12b208..30c57a9f3f4ae 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap @@ -1,17 +1,572 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Minimal initialization 1`] = ` -
- - - -
+ + + + + + + + + `; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_summary.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_summary.test.tsx.snap index b18233e5c53e3..4955a0a95b7e9 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_summary.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_summary.test.tsx.snap @@ -1,42 +1,99 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Transform: Minimal initialization 1`] = ` -
- + - + + + + + + + + +
+ + + + - -
+ query={ + Object { + "query_string": Object { + "default_operator": "AND", + "query": "the-search-query", + }, + } + } + /> + + + `; diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx index 2ac4295da1eed..6b49a305e515b 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.test.tsx @@ -7,8 +7,8 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaContext } from '../../../../lib/kibana'; - +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { getPivotQuery, PivotAggsConfig, @@ -16,6 +16,7 @@ import { PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { PivotPreview } from './pivot_preview'; @@ -25,6 +26,7 @@ jest.mock('react', () => { return { ...r, memo: (x: any) => x }; }); +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: ', () => { @@ -44,17 +46,18 @@ describe('Transform: ', () => { const props = { aggs: { 'the-agg-name': agg }, groupBy: { 'the-group-by-name': groupBy }, + indexPattern: { + title: 'the-index-pattern-title', + fields: [] as any[], + } as SearchItems['indexPattern'], query: getPivotQuery('the-query'), }; - // Using a wrapping
element because shallow() would fail - // with the Provider being the outer most component. + const Providers = getAppProviders(createPublicShim()); const wrapper = shallow( -
- - - -
+ + + ); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx index b755956eae24e..9b32bbbae839e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/pivot_preview.tsx @@ -24,8 +24,6 @@ import { import { dictionaryToArray } from '../../../../../../common/types/common'; import { getNestedProperty } from '../../../../../../common/utils/object_utils'; -import { useCurrentIndexPattern } from '../../../../lib/kibana'; - import { euiDataGridStyle, euiDataGridToolbarSettings, @@ -36,6 +34,7 @@ import { PivotGroupByConfigDict, PivotQuery, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { getPivotPreviewDevConsoleStatement, multiColumnSortFactory } from './common'; import { PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data'; @@ -103,184 +102,186 @@ const ErrorMessage: FC = ({ message }) => ( interface PivotPreviewProps { aggs: PivotAggsConfigDict; groupBy: PivotGroupByConfigDict; + indexPattern: SearchItems['indexPattern']; query: PivotQuery; } const defaultPagination = { pageIndex: 0, pageSize: 5 }; -export const PivotPreview: FC = React.memo(({ aggs, groupBy, query }) => { - const indexPattern = useCurrentIndexPattern(); - - const { - previewData: data, - previewMappings, - errorMessage, - previewRequest, - status, - } = usePivotPreviewData(indexPattern, query, aggs, groupBy); - const groupByArr = dictionaryToArray(groupBy); - - // Filters mapping properties of type `object`, which get returned for nested field parents. - const columnKeys = Object.keys(previewMappings.properties).filter( - key => previewMappings.properties[key].type !== 'object' - ); - columnKeys.sort(sortColumns(groupByArr)); - - // Column visibility - const [visibleColumns, setVisibleColumns] = useState(columnKeys); - - useEffect(() => { - setVisibleColumns(columnKeys); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(columnKeys)]); - - const [pagination, setPagination] = useState(defaultPagination); - - // Reset pagination if data changes. This is to avoid ending up with an empty table - // when for example the user selected a page that is not available with the updated data. - useEffect(() => { - setPagination(defaultPagination); - }, [data.length]); - - // EuiDataGrid State - const dataGridColumns = columnKeys.map(id => ({ id })); - - const onChangeItemsPerPage = useCallback( - pageSize => { - setPagination(p => { - const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); - return { pageIndex, pageSize }; - }); - }, - [setPagination] - ); +export const PivotPreview: FC = React.memo( + ({ aggs, groupBy, indexPattern, query }) => { + const { + previewData: data, + previewMappings, + errorMessage, + previewRequest, + status, + } = usePivotPreviewData(indexPattern, query, aggs, groupBy); + const groupByArr = dictionaryToArray(groupBy); + + // Filters mapping properties of type `object`, which get returned for nested field parents. + const columnKeys = Object.keys(previewMappings.properties).filter( + key => previewMappings.properties[key].type !== 'object' + ); + columnKeys.sort(sortColumns(groupByArr)); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState(columnKeys); + + useEffect(() => { + setVisibleColumns(columnKeys); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(columnKeys)]); + + const [pagination, setPagination] = useState(defaultPagination); + + // Reset pagination if data changes. This is to avoid ending up with an empty table + // when for example the user selected a page that is not available with the updated data. + useEffect(() => { + setPagination(defaultPagination); + }, [data.length]); + + // EuiDataGrid State + const dataGridColumns = columnKeys.map(id => ({ id })); + + const onChangeItemsPerPage = useCallback( + pageSize => { + setPagination(p => { + const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); + return { pageIndex, pageSize }; + }); + }, + [setPagination] + ); - const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ - setPagination, - ]); + const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ + setPagination, + ]); - // Sorting config - const [sortingColumns, setSortingColumns] = useState([]); - const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); + // Sorting config + const [sortingColumns, setSortingColumns] = useState([]); + const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); - if (sortingColumns.length > 0) { - data.sort(multiColumnSortFactory(sortingColumns)); - } + if (sortingColumns.length > 0) { + data.sort(multiColumnSortFactory(sortingColumns)); + } - const pageData = data.slice( - pagination.pageIndex * pagination.pageSize, - (pagination.pageIndex + 1) * pagination.pageSize - ); + const pageData = data.slice( + pagination.pageIndex * pagination.pageSize, + (pagination.pageIndex + 1) * pagination.pageSize + ); - const renderCellValue = useMemo(() => { - return ({ - rowIndex, - columnId, - setCellProps, - }: { - rowIndex: number; - columnId: string; - setCellProps: any; - }) => { - const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; - - const cellValue = pageData.hasOwnProperty(adjustedRowIndex) - ? getNestedProperty(pageData[adjustedRowIndex], columnId, null) - : null; - - if (typeof cellValue === 'object' && cellValue !== null) { - return JSON.stringify(cellValue); - } + const renderCellValue = useMemo(() => { + return ({ + rowIndex, + columnId, + setCellProps, + }: { + rowIndex: number; + columnId: string; + setCellProps: any; + }) => { + const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + const cellValue = pageData.hasOwnProperty(adjustedRowIndex) + ? getNestedProperty(pageData[adjustedRowIndex], columnId, null) + : null; + + if (typeof cellValue === 'object' && cellValue !== null) { + return JSON.stringify(cellValue); + } - if (cellValue === undefined) { - return null; - } + if (cellValue === undefined) { + return null; + } - return cellValue; - }; - }, [pageData, pagination.pageIndex, pagination.pageSize]); + return cellValue; + }; + }, [pageData, pagination.pageIndex, pagination.pageSize]); + + if (status === PIVOT_PREVIEW_STATUS.ERROR) { + return ( +
+ + + + +
+ ); + } - if (status === PIVOT_PREVIEW_STATUS.ERROR) { - return ( -
- - - - -
- ); - } + if (data.length === 0) { + let noDataMessage = i18n.translate( + 'xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', + { + defaultMessage: + 'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.', + } + ); - if (data.length === 0) { - let noDataMessage = i18n.translate( - 'xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', - { - defaultMessage: - 'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.', + const aggsArr = dictionaryToArray(aggs); + if (aggsArr.length === 0 || groupByArr.length === 0) { + noDataMessage = i18n.translate( + 'xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody', + { + defaultMessage: 'Please choose at least one group-by field and aggregation.', + } + ); } - ); - const aggsArr = dictionaryToArray(aggs); - if (aggsArr.length === 0 || groupByArr.length === 0) { - noDataMessage = i18n.translate( - 'xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody', - { - defaultMessage: 'Please choose at least one group-by field and aggregation.', - } + return ( +
+ + +

{noDataMessage}

+
+
); } + + if (columnKeys.length === 0) { + return null; + } + return ( -
+
- -

{noDataMessage}

-
+
+ {status === PIVOT_PREVIEW_STATUS.LOADING && } + {status !== PIVOT_PREVIEW_STATUS.LOADING && ( + + )} +
+ {dataGridColumns.length > 0 && data.length > 0 && ( + + )}
); } - - if (columnKeys.length === 0) { - return null; - } - - return ( -
- -
- {status === PIVOT_PREVIEW_STATUS.LOADING && } - {status !== PIVOT_PREVIEW_STATUS.LOADING && ( - - )} -
- {dataGridColumns.length > 0 && data.length > 0 && ( - - )} -
- ); -}); +); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index 44edd1340e8d6..f31af733fa3ee 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -7,14 +7,16 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaContext } from '../../../../lib/kibana'; - +import { createPublicShim } from '../../../../../shim'; +import { getAppProviders } from '../../../../app_dependencies'; import { PivotAggsConfigDict, PivotGroupByConfigDict, PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; + import { StepDefineForm, getAggNameConflictToastMessages } from './step_define_form'; // workaround to make React.memo() work with enzyme @@ -23,18 +25,16 @@ jest.mock('react', () => { return { ...r, memo: (x: any) => x }; }); +jest.mock('ui/new_platform'); jest.mock('../../../../../shared_imports'); describe('Transform: ', () => { test('Minimal initialization', () => { - // Using a wrapping
element because shallow() would fail - // with the Provider being the outer most component. + const Providers = getAppProviders(createPublicShim()); const wrapper = shallow( -
- - {}} /> - -
+ + {}} searchItems={{} as SearchItems} /> + ); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 9b96e4b1ee758..f61f54c38680e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -26,6 +26,7 @@ import { EuiSwitch, } from '@elastic/eui'; +import { SavedSearchQuery, SearchItems } from '../../../../hooks/use_search_items'; import { useXJsonMode, xJsonMode } from '../../../../hooks/use_x_json_mode'; import { useDocumentationLinks, useToastNotifications } from '../../../../app_dependencies'; import { TransformPivotConfig } from '../../../../common'; @@ -38,12 +39,6 @@ import { PivotPreview } from './pivot_preview'; import { KqlFilterBar } from '../../../../../shared_imports'; import { SwitchModal } from './switch_modal'; -import { - useKibanaContext, - InitializedKibanaContextValue, - SavedSearchQuery, -} from '../../../../lib/kibana'; - import { getPivotQuery, getPreviewRequestBody, @@ -78,18 +73,14 @@ export interface StepDefineExposedState { const defaultSearch = '*'; const emptySearch = ''; -export function getDefaultStepDefineState( - kibanaContext: InitializedKibanaContextValue -): StepDefineExposedState { +export function getDefaultStepDefineState(searchItems: SearchItems): StepDefineExposedState { return { aggList: {} as PivotAggsConfigDict, groupByList: {} as PivotGroupByConfigDict, isAdvancedPivotEditorEnabled: false, isAdvancedSourceEditorEnabled: false, - searchString: - kibanaContext.currentSavedSearch !== undefined ? kibanaContext.combinedQuery : defaultSearch, - searchQuery: - kibanaContext.currentSavedSearch !== undefined ? kibanaContext.combinedQuery : defaultSearch, + searchString: searchItems.savedSearch !== undefined ? searchItems.combinedQuery : defaultSearch, + searchQuery: searchItems.savedSearch !== undefined ? searchItems.combinedQuery : defaultSearch, sourceConfigUpdated: false, valid: false, }; @@ -242,14 +233,14 @@ export function getAggNameConflictToastMessages( interface Props { overrides?: StepDefineExposedState; onChange(s: StepDefineExposedState): void; + searchItems: SearchItems; } -export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange }) => { - const kibanaContext = useKibanaContext(); +export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange, searchItems }) => { const toastNotifications = useToastNotifications(); const { esQueryDsl, esTransformPivot } = useDocumentationLinks(); - const defaults = { ...getDefaultStepDefineState(kibanaContext), ...overrides }; + const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; // The search filter const [searchString, setSearchString] = useState(defaults.searchString); @@ -267,7 +258,7 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange // The list of selected group by fields const [groupByList, setGroupByList] = useState(defaults.groupByList); - const indexPattern = kibanaContext.currentIndexPattern; + const { indexPattern } = searchItems; const { groupByOptions, @@ -568,7 +559,7 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange
- {kibanaContext.currentSavedSearch === undefined && typeof searchString === 'string' && ( + {searchItems.savedSearch === undefined && typeof searchString === 'string' && ( = React.memo(({ overrides = {}, onChange )} - {kibanaContext.currentSavedSearch === undefined && ( + {searchItems.savedSearch === undefined && ( @@ -720,16 +711,15 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange )} - {kibanaContext.currentSavedSearch !== undefined && - kibanaContext.currentSavedSearch.id !== undefined && ( - - {kibanaContext.currentSavedSearch.title} - - )} + {searchItems.savedSearch !== undefined && searchItems.savedSearch.id !== undefined && ( + + {searchItems.savedSearch.title} + + )} {!isAdvancedPivotEditorEnabled && ( @@ -903,9 +893,14 @@ export const StepDefineForm: FC = React.memo(({ overrides = {}, onChange - + - + ); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 78f6fc30f9191..e3a9830ea1904 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -7,14 +7,14 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaContext } from '../../../../lib/kibana'; - import { PivotAggsConfig, PivotGroupByConfig, PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; + import { StepDefineExposedState } from './step_define_form'; import { StepDefineSummary } from './step_define_summary'; @@ -40,7 +40,7 @@ describe('Transform: ', () => { aggName: 'the-group-by-agg-name', dropDownName: 'the-group-by-drop-down-name', }; - const props: StepDefineExposedState = { + const formState: StepDefineExposedState = { aggList: { 'the-agg-name': agg }, groupByList: { 'the-group-by-name': groupBy }, isAdvancedPivotEditorEnabled: false, @@ -51,14 +51,8 @@ describe('Transform: ', () => { valid: true, }; - // Using a wrapping
element because shallow() would fail - // with the Provider being the outer most component. const wrapper = shallow( -
- - - -
+ ); expect(wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index 30c447f62c760..f8fb9db9bd686 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -17,26 +17,27 @@ import { EuiText, } from '@elastic/eui'; -import { useKibanaContext } from '../../../../lib/kibana'; +import { getPivotQuery } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { AggListSummary } from '../aggregation_list'; import { GroupByListSummary } from '../group_by_list'; -import { PivotPreview } from './pivot_preview'; -import { getPivotQuery } from '../../../../common'; +import { PivotPreview } from './pivot_preview'; import { StepDefineExposedState } from './step_define_form'; const defaultSearch = '*'; const emptySearch = ''; -export const StepDefineSummary: FC = ({ - searchString, - searchQuery, - groupByList, - aggList, -}) => { - const kibanaContext = useKibanaContext(); +interface Props { + formState: StepDefineExposedState; + searchItems: SearchItems; +} +export const StepDefineSummary: FC = ({ + formState: { searchString, searchQuery, groupByList, aggList }, + searchItems, +}) => { const pivotQuery = getPivotQuery(searchQuery); let useCodeBlock = false; let displaySearch; @@ -55,8 +56,8 @@ export const StepDefineSummary: FC = ({
- {kibanaContext.currentSavedSearch !== undefined && - kibanaContext.currentSavedSearch.id === undefined && + {searchItems.savedSearch !== undefined && + searchItems.savedSearch.id === undefined && typeof searchString === 'string' && ( = ({ defaultMessage: 'Index pattern', })} > - {kibanaContext.currentIndexPattern.title} + {searchItems.indexPattern.title} {useCodeBlock === false && displaySearch !== emptySearch && ( = ({ )} - {kibanaContext.currentSavedSearch !== undefined && - kibanaContext.currentSavedSearch.id !== undefined && ( - - {kibanaContext.currentSavedSearch.title} - - )} + {searchItems.savedSearch !== undefined && searchItems.savedSearch.id !== undefined && ( + + {searchItems.savedSearch.title} + + )} = ({ - + diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 5ae2180bfe779..ea9483af49302 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -11,11 +11,15 @@ import { i18n } from '@kbn/i18n'; import { EuiLink, EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui'; import { toMountPoint } from '../../../../../../../../../../src/plugins/kibana_react/public'; -import { useKibanaContext } from '../../../../lib/kibana'; import { isValidIndexName } from '../../../../../../common/utils/es_utils'; -import { useDocumentationLinks, useToastNotifications } from '../../../../app_dependencies'; +import { + useAppDependencies, + useDocumentationLinks, + useToastNotifications, +} from '../../../../app_dependencies'; import { ToastNotificationText } from '../../../../components'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { useApi } from '../../../../hooks/use_api'; import { isTransformIdValid, TransformId, TransformPivotConfig } from '../../../../common'; @@ -67,109 +71,129 @@ export function applyTransformConfigToDetailsState( interface Props { overrides?: StepDetailsExposedState; onChange(s: StepDetailsExposedState): void; + searchItems: SearchItems; } -export const StepDetailsForm: FC = React.memo(({ overrides = {}, onChange }) => { - const kibanaContext = useKibanaContext(); - const toastNotifications = useToastNotifications(); - const { esIndicesCreateIndex } = useDocumentationLinks(); +export const StepDetailsForm: FC = React.memo( + ({ overrides = {}, onChange, searchItems }) => { + const deps = useAppDependencies(); + const toastNotifications = useToastNotifications(); + const { esIndicesCreateIndex } = useDocumentationLinks(); - const defaults = { ...getDefaultStepDetailsState(), ...overrides }; + const defaults = { ...getDefaultStepDetailsState(), ...overrides }; - const [transformId, setTransformId] = useState(defaults.transformId); - const [transformDescription, setTransformDescription] = useState( - defaults.transformDescription - ); - const [destinationIndex, setDestinationIndex] = useState(defaults.destinationIndex); - const [transformIds, setTransformIds] = useState([]); - const [indexNames, setIndexNames] = useState([]); - const [indexPatternTitles, setIndexPatternTitles] = useState([]); - const [createIndexPattern, setCreateIndexPattern] = useState(defaults.createIndexPattern); + const [transformId, setTransformId] = useState(defaults.transformId); + const [transformDescription, setTransformDescription] = useState( + defaults.transformDescription + ); + const [destinationIndex, setDestinationIndex] = useState( + defaults.destinationIndex + ); + const [transformIds, setTransformIds] = useState([]); + const [indexNames, setIndexNames] = useState([]); + const [indexPatternTitles, setIndexPatternTitles] = useState([]); + const [createIndexPattern, setCreateIndexPattern] = useState(defaults.createIndexPattern); - // Continuous mode state - const [isContinuousModeEnabled, setContinuousModeEnabled] = useState( - defaults.isContinuousModeEnabled - ); + // Continuous mode state + const [isContinuousModeEnabled, setContinuousModeEnabled] = useState( + defaults.isContinuousModeEnabled + ); - const api = useApi(); + const api = useApi(); - // fetch existing transform IDs and indices once for form validation - useEffect(() => { - // use an IIFE to avoid returning a Promise to useEffect. - (async function() { - try { - setTransformIds( - (await api.getTransforms()).transforms.map( - (transform: TransformPivotConfig) => transform.id - ) - ); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformList', { - defaultMessage: 'An error occurred getting the existing transform IDs:', - }), - text: toMountPoint(), - }); - } + // fetch existing transform IDs and indices once for form validation + useEffect(() => { + // use an IIFE to avoid returning a Promise to useEffect. + (async function() { + try { + setTransformIds( + (await api.getTransforms()).transforms.map( + (transform: TransformPivotConfig) => transform.id + ) + ); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformList', { + defaultMessage: 'An error occurred getting the existing transform IDs:', + }), + text: toMountPoint(), + }); + } - try { - setIndexNames((await api.getIndices()).map(index => index.name)); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexNames', { - defaultMessage: 'An error occurred getting the existing index names:', - }), - text: toMountPoint(), - }); - } + try { + setIndexNames((await api.getIndices()).map(index => index.name)); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexNames', { + defaultMessage: 'An error occurred getting the existing index names:', + }), + text: toMountPoint(), + }); + } - try { - setIndexPatternTitles(await kibanaContext.indexPatterns.getTitles()); - } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles', { - defaultMessage: 'An error occurred getting the existing index pattern titles:', - }), - text: toMountPoint(), - }); - } - })(); - // custom comparison - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kibanaContext.initialized]); + try { + setIndexPatternTitles(await deps.plugins.data.indexPatterns.getTitles()); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.transform.stepDetailsForm.errorGettingIndexPatternTitles', + { + defaultMessage: 'An error occurred getting the existing index pattern titles:', + } + ), + text: toMountPoint(), + }); + } + })(); + // run once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const dateFieldNames = kibanaContext.currentIndexPattern.fields - .filter(f => f.type === 'date') - .map(f => f.name) - .sort(); - const isContinuousModeAvailable = dateFieldNames.length > 0; - const [continuousModeDateField, setContinuousModeDateField] = useState( - isContinuousModeAvailable ? dateFieldNames[0] : '' - ); - const [continuousModeDelay, setContinuousModeDelay] = useState(defaults.continuousModeDelay); - const isContinuousModeDelayValid = delayValidator(continuousModeDelay); + const dateFieldNames = searchItems.indexPattern.fields + .filter(f => f.type === 'date') + .map(f => f.name) + .sort(); + const isContinuousModeAvailable = dateFieldNames.length > 0; + const [continuousModeDateField, setContinuousModeDateField] = useState( + isContinuousModeAvailable ? dateFieldNames[0] : '' + ); + const [continuousModeDelay, setContinuousModeDelay] = useState(defaults.continuousModeDelay); + const isContinuousModeDelayValid = delayValidator(continuousModeDelay); - const transformIdExists = transformIds.some(id => transformId === id); - const transformIdEmpty = transformId === ''; - const transformIdValid = isTransformIdValid(transformId); + const transformIdExists = transformIds.some(id => transformId === id); + const transformIdEmpty = transformId === ''; + const transformIdValid = isTransformIdValid(transformId); - const indexNameExists = indexNames.some(name => destinationIndex === name); - const indexNameEmpty = destinationIndex === ''; - const indexNameValid = isValidIndexName(destinationIndex); - const indexPatternTitleExists = indexPatternTitles.some(name => destinationIndex === name); + const indexNameExists = indexNames.some(name => destinationIndex === name); + const indexNameEmpty = destinationIndex === ''; + const indexNameValid = isValidIndexName(destinationIndex); + const indexPatternTitleExists = indexPatternTitles.some(name => destinationIndex === name); - const valid = - !transformIdEmpty && - transformIdValid && - !transformIdExists && - !indexNameEmpty && - indexNameValid && - (!indexPatternTitleExists || !createIndexPattern) && - (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)); + const valid = + !transformIdEmpty && + transformIdValid && + !transformIdExists && + !indexNameEmpty && + indexNameValid && + (!indexPatternTitleExists || !createIndexPattern) && + (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)); - // expose state to wizard - useEffect(() => { - onChange({ + // expose state to wizard + useEffect(() => { + onChange({ + continuousModeDateField, + continuousModeDelay, + createIndexPattern, + isContinuousModeEnabled, + transformId, + transformDescription, + destinationIndex, + touched: true, + valid, + }); + // custom comparison + /* eslint-disable react-hooks/exhaustive-deps */ + }, [ continuousModeDateField, continuousModeDelay, createIndexPattern, @@ -177,232 +201,223 @@ export const StepDetailsForm: FC = React.memo(({ overrides = {}, onChange transformId, transformDescription, destinationIndex, - touched: true, valid, - }); - // custom comparison - /* eslint-disable react-hooks/exhaustive-deps */ - }, [ - continuousModeDateField, - continuousModeDelay, - createIndexPattern, - isContinuousModeEnabled, - transformId, - transformDescription, - destinationIndex, - valid, - /* eslint-enable react-hooks/exhaustive-deps */ - ]); + /* eslint-enable react-hooks/exhaustive-deps */ + ]); - return ( -
- - - setTransformId(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.transformIdInputAriaLabel', - { - defaultMessage: 'Choose a unique transform ID.', - } - )} + return ( +
+ + - - - setTransformDescription(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel', - { - defaultMessage: 'Choose an optional transform description.', - } - )} - data-test-subj="transformDescriptionInput" - /> - - - {i18n.translate('xpack.transform.stepDetailsForm.destinationIndexInvalidError', { - defaultMessage: 'Invalid destination index name.', - })} -
- - {i18n.translate( - 'xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink', - { - defaultMessage: 'Learn more about index name limitations.', - } - )} - - , - ] - } - > - setDestinationIndex(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel', + error={[ + ...(!transformIdEmpty && !transformIdValid + ? [ + i18n.translate('xpack.transform.stepDetailsForm.transformIdInvalidError', { + defaultMessage: + 'Must contain lowercase alphanumeric characters (a-z and 0-9), hyphens, and underscores only and must start and end with alphanumeric characters.', + }), + ] + : []), + ...(transformIdExists + ? [ + i18n.translate('xpack.transform.stepDetailsForm.transformIdExistsError', { + defaultMessage: 'A transform with this ID already exists.', + }), + ] + : []), + ]} + > + setTransformId(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.transformIdInputAriaLabel', + { + defaultMessage: 'Choose a unique transform ID.', + } + )} + isInvalid={(!transformIdEmpty && !transformIdValid) || transformIdExists} + data-test-subj="transformIdInput" + /> +
+ - - - setCreateIndexPattern(!createIndexPattern)} - data-test-subj="transformCreateIndexPatternSwitch" - /> - - - setContinuousModeEnabled(!isContinuousModeEnabled)} - disabled={isContinuousModeAvailable === false} - data-test-subj="transformContinuousModeSwitch" - /> - - {isContinuousModeEnabled && ( - - + setTransformDescription(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.transformDescriptionInputAriaLabel', { - defaultMessage: 'Date field', + defaultMessage: 'Choose an optional transform description.', } )} - helpText={i18n.translate( - 'xpack.transform.stepDetailsForm.continuousModeDateFieldHelpText', + data-test-subj="transformDescriptionInput" + /> + + + {i18n.translate('xpack.transform.stepDetailsForm.destinationIndexInvalidError', { + defaultMessage: 'Invalid destination index name.', + })} +
+ + {i18n.translate( + 'xpack.transform.stepDetailsForm.destinationIndexInvalidErrorLink', + { + defaultMessage: 'Learn more about index name limitations.', + } + )} + +
, + ] + } + > + setDestinationIndex(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.destinationIndexInputAriaLabel', { - defaultMessage: - 'Select the date field that can be used to identify new documents.', + defaultMessage: 'Choose a unique destination index name.', } )} - > - ({ text }))} - value={continuousModeDateField} - onChange={e => setContinuousModeDateField(e.target.value)} - data-test-subj="transformContinuousDateFieldSelect" - /> - - + + + - setContinuousModeDelay(e.target.value)} - aria-label={i18n.translate( - 'xpack.transform.stepDetailsForm.continuousModeAriaLabel', + checked={createIndexPattern === true} + onChange={() => setCreateIndexPattern(!createIndexPattern)} + data-test-subj="transformCreateIndexPatternSwitch" + /> + + + setContinuousModeEnabled(!isContinuousModeEnabled)} + disabled={isContinuousModeAvailable === false} + data-test-subj="transformContinuousModeSwitch" + /> + + {isContinuousModeEnabled && ( + + + ({ text }))} + value={continuousModeDateField} + onChange={e => setContinuousModeDateField(e.target.value)} + data-test-subj="transformContinuousDateFieldSelect" + /> + + - - - )} -
-
- ); -}); + error={ + !isContinuousModeDelayValid && [ + i18n.translate('xpack.transform.stepDetailsForm.continuousModeDelayError', { + defaultMessage: 'Invalid delay format', + }), + ] + } + helpText={i18n.translate( + 'xpack.transform.stepDetailsForm.continuousModeDelayHelpText', + { + defaultMessage: 'Time delay between current time and latest input data time.', + } + )} + > + setContinuousModeDelay(e.target.value)} + aria-label={i18n.translate( + 'xpack.transform.stepDetailsForm.continuousModeAriaLabel', + { + defaultMessage: 'Choose a delay.', + } + )} + isInvalid={!isContinuousModeDelayValid} + data-test-subj="transformContinuousDelayInput" + /> +
+ + )} +
+
+ ); + } +); diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index f1861755d9742..0773ecbb1d8d3 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -10,9 +10,8 @@ import { i18n } from '@kbn/i18n'; import { EuiSteps, EuiStepStatus } from '@elastic/eui'; -import { useKibanaContext } from '../../../../lib/kibana'; - import { getCreateRequestBody, TransformPivotConfig } from '../../../../common'; +import { SearchItems } from '../../../../hooks/use_search_items'; import { applyTransformConfigToDefineState, @@ -46,6 +45,7 @@ interface DefinePivotStepProps { stepDefineState: StepDefineExposedState; setCurrentStep: React.Dispatch>; setStepDefineState: React.Dispatch>; + searchItems: SearchItems; } const StepDefine: FC = ({ @@ -53,6 +53,7 @@ const StepDefine: FC = ({ stepDefineState, setCurrentStep, setStepDefineState, + searchItems, }) => { const definePivotRef = useRef(null); @@ -61,31 +62,36 @@ const StepDefine: FC = ({
{isCurrentStep && ( - + setCurrentStep(WIZARD_STEPS.DETAILS)} nextActive={stepDefineState.valid} /> )} - {!isCurrentStep && } + {!isCurrentStep && ( + + )} ); }; interface WizardProps { cloneConfig?: TransformPivotConfig; + searchItems: SearchItems; } -export const Wizard: FC = React.memo(({ cloneConfig }) => { - const kibanaContext = useKibanaContext(); - +export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) => { // The current WIZARD_STEP const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE); // The DEFINE state const [stepDefineState, setStepDefineState] = useState( - applyTransformConfigToDefineState(getDefaultStepDefineState(kibanaContext), cloneConfig) + applyTransformConfigToDefineState(getDefaultStepDefineState(searchItems), cloneConfig) ); // The DETAILS state @@ -95,7 +101,11 @@ export const Wizard: FC = React.memo(({ cloneConfig }) => { const stepDetails = currentStep === WIZARD_STEPS.DETAILS ? ( - + ) : ( ); @@ -122,7 +132,7 @@ export const Wizard: FC = React.memo(({ cloneConfig }) => { } }, []); - const indexPattern = kibanaContext.currentIndexPattern; + const { indexPattern } = searchItems; const transformConfig = getCreateRequestBody( indexPattern.title, @@ -154,6 +164,7 @@ export const Wizard: FC = React.memo(({ cloneConfig }) => { stepDefineState={stepDefineState} setCurrentStep={setCurrentStep} setStepDefineState={setStepDefineState} + searchItems={searchItems} /> ), }, diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx index 5196f281adf0a..d09fc0913590e 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx @@ -22,9 +22,9 @@ import { import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; import { useDocumentationLinks } from '../../app_dependencies'; +import { useSearchItems } from '../../hooks/use_search_items'; import { breadcrumbService, docTitleService, BREADCRUMB_SECTION } from '../../services/navigation'; import { PrivilegesWrapper } from '../../lib/authorization'; -import { KibanaProvider, RenderOnlyWithInitializedKibanaContext } from '../../lib/kibana'; import { Wizard } from './components/wizard'; @@ -38,43 +38,41 @@ export const CreateTransformSection: FC = ({ match }) => { const { esTransform } = useDocumentationLinks(); + const { searchItems } = useSearchItems(match.params.savedObjectId); + return ( - - - - - -

- -

-
- - - - - -
-
- - - - - - -
-
+ + + + +

+ +

+
+ + + + + +
+
+ + + {searchItems !== undefined && } + +
); }; diff --git a/x-pack/legacy/plugins/transform/public/plugin.ts b/x-pack/legacy/plugins/transform/public/plugin.ts index 23fad00fb0786..7b5fbbb4a2151 100644 --- a/x-pack/legacy/plugins/transform/public/plugin.ts +++ b/x-pack/legacy/plugins/transform/public/plugin.ts @@ -11,7 +11,6 @@ import { breadcrumbService } from './app/services/navigation'; import { docTitleService } from './app/services/navigation'; import { textService } from './app/services/text'; import { uiMetricService } from './app/services/ui_metric'; -import { createSavedSearchesLoader } from '../../../../../src/plugins/discover/public'; export class Plugin { public start(core: ShimCore, plugins: ShimPlugins): void { @@ -27,7 +26,7 @@ export class Plugin { savedObjects, overlays, } = core; - const { data, management, savedSearches: coreSavedSearches, uiMetric, xsrfToken } = plugins; + const { data, management, uiMetric, xsrfToken } = plugins; // AppCore/AppPlugins to be passed on as React context const appDependencies = { @@ -46,7 +45,6 @@ export class Plugin { plugins: { data, management, - savedSearches: coreSavedSearches, xsrfToken, }, }; @@ -61,14 +59,6 @@ export class Plugin { }), order: 3, mount(params) { - const savedSearches = createSavedSearchesLoader({ - savedObjectsClient: core.savedObjects.client, - indexPatterns: plugins.data.indexPatterns, - chrome: core.chrome, - overlays: core.overlays, - }); - coreSavedSearches.setClient(savedSearches); - breadcrumbService.setup(params.setBreadcrumbs); params.setBreadcrumbs([ { diff --git a/x-pack/legacy/plugins/transform/public/shared_imports.ts b/x-pack/legacy/plugins/transform/public/shared_imports.ts index b077cd8836c4b..1ca71f8c4aa77 100644 --- a/x-pack/legacy/plugins/transform/public/shared_imports.ts +++ b/x-pack/legacy/plugins/transform/public/shared_imports.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { createSavedSearchesLoader } from '../../../../../src/plugins/discover/public'; export { XJsonMode } from '../../../../plugins/es_ui_shared/console_lang/ace/modes/x_json'; export { collapseLiteralStrings, diff --git a/x-pack/legacy/plugins/transform/public/shim.ts b/x-pack/legacy/plugins/transform/public/shim.ts index 05f7626e25e9d..9941aabcf3255 100644 --- a/x-pack/legacy/plugins/transform/public/shim.ts +++ b/x-pack/legacy/plugins/transform/public/shim.ts @@ -13,7 +13,6 @@ import { docTitle } from 'ui/doc_title/doc_title'; import { createUiStatsReporter } from '../../../../../src/legacy/core_plugins/ui_metric/public'; import { TRANSFORM_DOC_PATHS } from './app/constants'; -import { SavedSearchLoader } from '../../../../../src/plugins/discover/public'; export type NpCore = typeof npStart.core; export type NpPlugins = typeof npStart.plugins; @@ -33,7 +32,7 @@ export type AppCore = Pick< | 'overlays' | 'notifications' >; -export type AppPlugins = Pick; +export type AppPlugins = Pick; export interface AppDependencies { core: AppCore; @@ -61,18 +60,10 @@ export interface ShimPlugins extends NpPlugins { uiMetric: { createUiStatsReporter: typeof createUiStatsReporter; }; - savedSearches: { - getClient(): any; - setClient(client: any): void; - }; xsrfToken: string; } export function createPublicShim(): { core: ShimCore; plugins: ShimPlugins } { - // This is an Angular service, which is why we use this provider pattern - // to access it within our React app. - let savedSearches: SavedSearchLoader; - const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = npStart.core.docLinks; return { @@ -94,12 +85,6 @@ export function createPublicShim(): { core: ShimCore; plugins: ShimPlugins } { }, plugins: { ...npStart.plugins, - savedSearches: { - setClient: (client: any): void => { - savedSearches = client; - }, - getClient: (): any => savedSearches, - }, uiMetric: { createUiStatsReporter, }, diff --git a/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js b/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js index 21b781423531e..2707858a5fec8 100644 --- a/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js +++ b/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js @@ -19,15 +19,6 @@ export function setupXPackMain(server) { const info = new XPackInfo(server, { licensing: server.newPlatform.setup.plugins.licensing }); server.expose('info', info); - server.expose('createXPackInfo', options => { - const client = server.newPlatform.setup.core.elasticsearch.createClient(options.clusterSource); - const monitoringLicensing = server.newPlatform.setup.plugins.licensing.createLicensePoller( - client, - options.pollFrequencyInMillis - ); - - return new XPackInfo(server, { licensing: monitoringLicensing }); - }); server.ext('onPreResponse', (request, h) => injectXPackInfoSignature(info, request, h)); diff --git a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts index 05cb97663e1af..a9abc733775d2 100644 --- a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts +++ b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts @@ -11,7 +11,6 @@ export { XPackFeature } from './lib/xpack_info'; export interface XPackMainPlugin { info: XPackInfo; - createXPackInfo(options: XPackInfoOptions): XPackInfo; getFeatures(): Feature[]; registerFeature(feature: FeatureWithAllOrReadPrivileges): void; } diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index aa31b035cda58..325a5ddc10179 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -7,12 +7,12 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public'; -import { Action, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { ActionByType, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { TimeRange } from '../../../../src/plugins/data/public'; import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { OpenModal, CommonlyUsedRange } from './types'; -const CUSTOM_TIME_RANGE = 'CUSTOM_TIME_RANGE'; +export const CUSTOM_TIME_RANGE = 'CUSTOM_TIME_RANGE'; const SEARCH_EMBEDDABLE_TYPE = 'search'; export interface TimeRangeInput extends EmbeddableInput { @@ -34,11 +34,11 @@ function isVisualizeEmbeddable( return embeddable.type === VISUALIZE_EMBEDDABLE_TYPE; } -interface ActionContext { +export interface TimeRangeActionContext { embeddable: Embeddable; } -export class CustomTimeRangeAction implements Action { +export class CustomTimeRangeAction implements ActionByType { public readonly type = CUSTOM_TIME_RANGE; private openModal: OpenModal; private dateFormat?: string; @@ -70,7 +70,7 @@ export class CustomTimeRangeAction implements Action { return 'calendar'; } - public async isCompatible({ embeddable }: ActionContext) { + public async isCompatible({ embeddable }: TimeRangeActionContext) { const isInputControl = isVisualizeEmbeddable(embeddable) && (embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'input_control_vis'; @@ -89,7 +89,7 @@ export class CustomTimeRangeAction implements Action { ); } - public async execute({ embeddable }: ActionContext) { + public async execute({ embeddable }: TimeRangeActionContext) { const isCompatible = await this.isCompatible({ embeddable }); if (!isCompatible) { throw new IncompatibleActionError(); diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.tsx index 4ee8c91ff2a32..59a2fc27267b0 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_badge.tsx @@ -7,13 +7,13 @@ import React from 'react'; import { prettyDuration, commonDurationRanges } from '@elastic/eui'; import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public'; -import { Action, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { ActionByType, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { TimeRange } from '../../../../src/plugins/data/public'; import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { doesInheritTimeRange } from './does_inherit_time_range'; import { OpenModal, CommonlyUsedRange } from './types'; -const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE'; +export const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE'; export interface TimeRangeInput extends EmbeddableInput { timeRange: TimeRange; @@ -25,11 +25,11 @@ function hasTimeRange( return (embeddable as Embeddable).getInput().timeRange !== undefined; } -interface ActionContext { +export interface TimeBadgeActionContext { embeddable: Embeddable; } -export class CustomTimeRangeBadge implements Action { +export class CustomTimeRangeBadge implements ActionByType { public readonly type = CUSTOM_TIME_RANGE_BADGE; public readonly id = CUSTOM_TIME_RANGE_BADGE; public order = 7; @@ -51,7 +51,7 @@ export class CustomTimeRangeBadge implements Action { this.commonlyUsedRanges = commonlyUsedRanges; } - public getDisplayName({ embeddable }: ActionContext) { + public getDisplayName({ embeddable }: TimeBadgeActionContext) { return prettyDuration( embeddable.getInput().timeRange.from, embeddable.getInput().timeRange.to, @@ -64,11 +64,11 @@ export class CustomTimeRangeBadge implements Action { return 'calendar'; } - public async isCompatible({ embeddable }: ActionContext) { + public async isCompatible({ embeddable }: TimeBadgeActionContext) { return Boolean(embeddable && hasTimeRange(embeddable) && !doesInheritTimeRange(embeddable)); } - public async execute({ embeddable }: ActionContext) { + public async execute({ embeddable }: TimeBadgeActionContext) { const isCompatible = await this.isCompatible({ embeddable }); if (!isCompatible) { throw new IncompatibleActionError(); diff --git a/x-pack/plugins/advanced_ui_actions/public/plugin.ts b/x-pack/plugins/advanced_ui_actions/public/plugin.ts index 5c5d2d38da15e..2f6935cdf1961 100644 --- a/x-pack/plugins/advanced_ui_actions/public/plugin.ts +++ b/x-pack/plugins/advanced_ui_actions/public/plugin.ts @@ -18,9 +18,17 @@ import { IEmbeddableSetup, IEmbeddableStart, } from '../../../../src/plugins/embeddable/public'; -import { CustomTimeRangeAction } from './custom_time_range_action'; +import { + CustomTimeRangeAction, + CUSTOM_TIME_RANGE, + TimeRangeActionContext, +} from './custom_time_range_action'; -import { CustomTimeRangeBadge } from './custom_time_range_badge'; +import { + CustomTimeRangeBadge, + CUSTOM_TIME_RANGE_BADGE, + TimeBadgeActionContext, +} from './custom_time_range_badge'; import { CommonlyUsedRange } from './types'; interface SetupDependencies { @@ -36,6 +44,13 @@ interface StartDependencies { export type Setup = void; export type Start = void; +declare module '../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [CUSTOM_TIME_RANGE]: TimeRangeActionContext; + [CUSTOM_TIME_RANGE_BADGE]: TimeBadgeActionContext; + } +} + export class AdvancedUiActionsPublicPlugin implements Plugin { constructor(initializerContext: PluginInitializerContext) {} @@ -52,7 +67,7 @@ export class AdvancedUiActionsPublicPlugin commonlyUsedRanges, }); uiActions.registerAction(timeRangeAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, @@ -60,7 +75,7 @@ export class AdvancedUiActionsPublicPlugin commonlyUsedRanges, }); uiActions.registerAction(timeRangeBadge); - uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge.id); + uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx index 0b9f54f51f61e..1db57eb3d0b28 100644 --- a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx +++ b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { CoreStart } from 'src/core/public'; -import { Action } from '../../../../../../src/plugins/ui_actions/public'; +import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; @@ -22,7 +22,7 @@ export interface OpenFlyoutAddDrilldownParams { overlays: () => Promise; } -export class FlyoutCreateDrilldownAction implements Action { +export class FlyoutCreateDrilldownAction implements ActionByType { public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; public order = 5; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index 6c8555fa55a11..1761e17d55986 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -7,6 +7,7 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { DrilldownService } from './service'; +import { FlyoutCreateDrilldownActionContext, OPEN_FLYOUT_ADD_DRILLDOWN } from './actions'; export interface DrilldownsSetupDependencies { uiActions: UiActionsSetup; @@ -21,6 +22,12 @@ export type DrilldownsSetupContract = Pick = React.memo( - ({ basename, store, coreStart: { http } }) => ( + ({ basename, store, coreStart: { http, notifications } }) => ( - - + + @@ -72,8 +72,8 @@ const AppRoot: React.FunctionComponent = React.memo( - - + + ) ); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/action.ts index e916dc66c59f0..a42e23e57d107 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/action.ts @@ -4,14 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ManagementListPagination } from '../../types'; -import { EndpointResultList } from '../../../../../common/types'; +import { ManagementListPagination, ServerApiError } from '../../types'; +import { EndpointResultList, EndpointMetadata } from '../../../../../common/types'; interface ServerReturnedManagementList { type: 'serverReturnedManagementList'; payload: EndpointResultList; } +interface ServerReturnedManagementDetails { + type: 'serverReturnedManagementDetails'; + payload: EndpointMetadata; +} + +interface ServerFailedToReturnManagementDetails { + type: 'serverFailedToReturnManagementDetails'; + payload: ServerApiError; +} + interface UserExitedManagementList { type: 'userExitedManagementList'; } @@ -23,5 +33,7 @@ interface UserPaginatedManagementList { export type ManagementAction = | ServerReturnedManagementList + | ServerReturnedManagementDetails + | ServerFailedToReturnManagementDetails | UserExitedManagementList | UserPaginatedManagementList; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/index.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/index.test.ts index 56a606f430d9e..6903c37d4684d 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/index.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/index.test.ts @@ -19,6 +19,7 @@ describe('endpoint_list store concerns', () => { }; const generateEndpoint = (): EndpointMetadata => { return { + '@timestamp': new Date(1582231151055).toString(), event: { created: new Date(0), }, @@ -30,7 +31,6 @@ describe('endpoint_list store concerns', () => { agent: { version: '', id: '', - name: '', }, host: { id: '', diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.test.ts index 9fb12b77e7252..f29e90509785d 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.test.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.test.ts @@ -6,6 +6,7 @@ import { CoreStart, HttpSetup } from 'kibana/public'; import { applyMiddleware, createStore, Dispatch, Store } from 'redux'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { History, createBrowserHistory } from 'history'; import { managementListReducer, managementMiddlewareFactory } from './index'; import { EndpointMetadata, EndpointResultList } from '../../../../../common/types'; import { ManagementListState } from '../../types'; @@ -18,9 +19,12 @@ describe('endpoint list saga', () => { let store: Store; let getState: typeof store['getState']; let dispatch: Dispatch; + let history: History; + // https://github.com/elastic/endpoint-app-team/issues/131 const generateEndpoint = (): EndpointMetadata => { return { + '@timestamp': new Date(1582231151055).toString(), event: { created: new Date(0), }, @@ -32,7 +36,6 @@ describe('endpoint list saga', () => { agent: { version: '', id: '', - name: '', }, host: { id: '', @@ -65,12 +68,20 @@ describe('endpoint list saga', () => { ); getState = store.getState; dispatch = store.dispatch; + history = createBrowserHistory(); }); - test('it handles `userNavigatedToPage`', async () => { + test('it handles `userChangedUrl`', async () => { const apiResponse = getEndpointListApiResponse(); fakeHttpServices.post.mockResolvedValue(apiResponse); expect(fakeHttpServices.post).not.toHaveBeenCalled(); - dispatch({ type: 'userNavigatedToPage', payload: 'managementPage' }); + + dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/management', + }, + }); await sleep(); expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', { body: JSON.stringify({ diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.ts index 754a855c171ad..1131e8d769fcf 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/middleware.ts @@ -5,19 +5,28 @@ */ import { MiddlewareFactory } from '../../types'; -import { pageIndex, pageSize } from './selectors'; +import { + pageIndex, + pageSize, + isOnManagementPage, + hasSelectedHost, + uiQueryParams, +} from './selectors'; import { ManagementListState } from '../../types'; import { AppAction } from '../action'; export const managementMiddlewareFactory: MiddlewareFactory = coreStart => { return ({ getState, dispatch }) => next => async (action: AppAction) => { next(action); + const state = getState(); if ( - (action.type === 'userNavigatedToPage' && action.payload === 'managementPage') || + (action.type === 'userChangedUrl' && + isOnManagementPage(state) && + hasSelectedHost(state) !== true) || action.type === 'userPaginatedManagementList' ) { - const managementPageIndex = pageIndex(getState()); - const managementPageSize = pageSize(getState()); + const managementPageIndex = pageIndex(state); + const managementPageSize = pageSize(state); const response = await coreStart.http.post('/api/endpoint/metadata', { body: JSON.stringify({ paging_properties: [ @@ -32,5 +41,20 @@ export const managementMiddlewareFactory: MiddlewareFactory payload: response, }); } + if (action.type === 'userChangedUrl' && hasSelectedHost(state) !== false) { + const { selected_host: selectedHost } = uiQueryParams(state); + try { + const response = await coreStart.http.get(`/api/endpoint/metadata/${selectedHost}`); + dispatch({ + type: 'serverReturnedManagementDetails', + payload: response, + }); + } catch (error) { + dispatch({ + type: 'serverFailedToReturnManagementDetails', + payload: error, + }); + } + } }; }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/mock_host_result_list.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/mock_host_result_list.ts new file mode 100644 index 0000000000000..866e5c59329e6 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/mock_host_result_list.ts @@ -0,0 +1,63 @@ +/* + * 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 { EndpointResultList } from '../../../../../common/types'; + +export const mockHostResultList: (options?: { + total?: number; + request_page_size?: number; + request_page_index?: number; +}) => EndpointResultList = (options = {}) => { + const { + total = 1, + request_page_size: requestPageSize = 10, + request_page_index: requestPageIndex = 0, + } = options; + + // Skip any that are before the page we're on + const numberToSkip = requestPageSize * requestPageIndex; + + // total - numberToSkip is the count of non-skipped ones, but return no more than a pageSize, and no less than 0 + const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0); + + const endpoints = []; + for (let index = 0; index < actualCountToReturn; index++) { + endpoints.push({ + '@timestamp': new Date(1582231151055).toString(), + event: { + created: new Date('2020-02-20T20:39:11.055Z'), + }, + endpoint: { + policy: { + id: '00000000-0000-0000-0000-000000000000', + }, + }, + agent: { + version: '6.9.2', + id: '9a87fdac-e6c0-4f27-a25c-e349e7093cb1', + }, + host: { + id: '3ca26fe5-1c7d-42b8-8763-98256d161c9f', + hostname: 'bea-0.example.com', + ip: ['10.154.150.114', '10.43.37.62', '10.217.73.149'], + mac: ['ea-5a-a8-c0-5-95', '7e-d8-fe-7f-b6-4e', '23-31-5d-af-e6-2b'], + os: { + name: 'windows 6.2', + full: 'Windows Server 2012', + version: '6.2', + variant: 'Windows Server Release 2', + }, + }, + }); + } + const mock: EndpointResultList = { + endpoints, + total, + request_page_size: requestPageSize, + request_page_index: requestPageIndex, + }; + return mock; +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/reducer.ts index bbbbdc4d17ce6..582aa6b7138c9 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/reducer.ts @@ -15,6 +15,9 @@ const initialState = (): ManagementListState => { pageIndex: 0, total: 0, loading: false, + detailsError: undefined, + details: undefined, + location: undefined, }; }; @@ -37,18 +40,30 @@ export const managementListReducer: Reducer = ( pageIndex, loading: false, }; - } - - if (action.type === 'userExitedManagementList') { + } else if (action.type === 'serverReturnedManagementDetails') { + return { + ...state, + details: action.payload, + }; + } else if (action.type === 'serverFailedToReturnManagementDetails') { + return { + ...state, + detailsError: action.payload, + }; + } else if (action.type === 'userExitedManagementList') { return initialState(); - } - - if (action.type === 'userPaginatedManagementList') { + } else if (action.type === 'userPaginatedManagementList') { return { ...state, ...action.payload, loading: true, }; + } else if (action.type === 'userChangedUrl') { + return { + ...state, + location: action.payload, + detailsError: undefined, + }; } return state; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/selectors.ts index 3dcb144c2bade..a7776f09fe2b8 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/managing/selectors.ts @@ -3,8 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { ManagementListState } from '../../types'; +import querystring from 'querystring'; +import { createSelector } from 'reselect'; +import { Immutable } from '../../../../../common/types'; +import { ManagementListState, ManagingIndexUIQueryParams } from '../../types'; export const listData = (state: ManagementListState) => state.endpoints; @@ -15,3 +17,44 @@ export const pageSize = (state: ManagementListState) => state.pageSize; export const totalHits = (state: ManagementListState) => state.total; export const isLoading = (state: ManagementListState) => state.loading; + +export const detailsError = (state: ManagementListState) => state.detailsError; + +export const detailsData = (state: ManagementListState) => { + return state.details; +}; + +export const isOnManagementPage = (state: ManagementListState) => + state.location ? state.location.pathname === '/management' : false; + +export const uiQueryParams: ( + state: ManagementListState +) => Immutable = createSelector( + (state: ManagementListState) => state.location, + (location: ManagementListState['location']) => { + const data: ManagingIndexUIQueryParams = {}; + if (location) { + // Removes the `?` from the beginning of query string if it exists + const query = querystring.parse(location.search.slice(1)); + + const keys: Array = ['selected_host']; + + for (const key of keys) { + const value = query[key]; + if (typeof value === 'string') { + data[key] = value; + } else if (Array.isArray(value)) { + data[key] = value[value.length - 1]; + } + } + } + return data; + } +); + +export const hasSelectedHost: (state: ManagementListState) => boolean = createSelector( + uiQueryParams, + ({ selected_host: selectedHost }) => { + return selectedHost !== undefined; + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index b46785d3190e5..6adb3d6adc260 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -28,12 +28,24 @@ export interface ManagementListState { pageSize: number; pageIndex: number; loading: boolean; + detailsError?: ServerApiError; + details?: Immutable; + location?: Immutable; } export interface ManagementListPagination { pageIndex: number; pageSize: number; } +export interface ManagingIndexUIQueryParams { + selected_host?: string; +} + +export interface ServerApiError { + statusCode: number; + error: string; + message: string; +} // REFACTOR to use Types from Ingest Manager - see: https://github.com/elastic/endpoint-app-team/issues/150 export interface PolicyData { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/details.tsx new file mode 100644 index 0000000000000..9f2a732042719 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/details.tsx @@ -0,0 +1,159 @@ +/* + * 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, { useCallback, useMemo, memo, useEffect } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiDescriptionList, + EuiLoadingContent, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { useManagementListSelector } from './hooks'; +import { urlFromQueryParams } from './url_from_query_params'; +import { uiQueryParams, detailsData, detailsError } from './../../store/managing/selectors'; + +const HostDetails = memo(() => { + const details = useManagementListSelector(detailsData); + if (details === undefined) { + return null; + } + + const detailsResultsUpper = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.management.details.os', { + defaultMessage: 'OS', + }), + description: details.host.os.full, + }, + { + title: i18n.translate('xpack.endpoint.management.details.lastSeen', { + defaultMessage: 'Last Seen', + }), + description: details['@timestamp'], + }, + { + title: i18n.translate('xpack.endpoint.management.details.alerts', { + defaultMessage: 'Alerts', + }), + description: '0', + }, + ]; + }, [details]); + + const detailsResultsLower = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.management.details.policy', { + defaultMessage: 'Policy', + }), + description: details.endpoint.policy.id, + }, + { + title: i18n.translate('xpack.endpoint.management.details.policyStatus', { + defaultMessage: 'Policy Status', + }), + description: 'active', + }, + { + title: i18n.translate('xpack.endpoint.management.details.ipAddress', { + defaultMessage: 'IP Address', + }), + description: details.host.ip, + }, + { + title: i18n.translate('xpack.endpoint.management.details.hostname', { + defaultMessage: 'Hostname', + }), + description: details.host.hostname, + }, + { + title: i18n.translate('xpack.endpoint.management.details.sensorVersion', { + defaultMessage: 'Sensor Version', + }), + description: details.agent.version, + }, + ]; + }, [details.agent.version, details.endpoint.policy.id, details.host.hostname, details.host.ip]); + + return ( + <> + + + + + ); +}); + +export const ManagementDetails = () => { + const history = useHistory(); + const { notifications } = useKibana(); + const queryParams = useManagementListSelector(uiQueryParams); + const { selected_host: selectedHost, ...queryParamsWithoutSelectedHost } = queryParams; + const details = useManagementListSelector(detailsData); + const error = useManagementListSelector(detailsError); + + const handleFlyoutClose = useCallback(() => { + history.push(urlFromQueryParams(queryParamsWithoutSelectedHost)); + }, [history, queryParamsWithoutSelectedHost]); + + useEffect(() => { + if (error !== undefined) { + notifications.toasts.danger({ + title: ( + + ), + body: ( + + ), + toastLifeTimeMs: 10000, + }); + } + }, [error, notifications.toasts]); + + return ( + + + +

+ {details === undefined ? : details.host.hostname} +

+
+
+ + {details === undefined ? ( + <> + + + ) : ( + + )} + +
+ ); +}; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/index.test.tsx new file mode 100644 index 0000000000000..216e4df61b0dd --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/index.test.tsx @@ -0,0 +1,123 @@ +/* + * 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 * as reactTestingLibrary from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { I18nProvider } from '@kbn/i18n/react'; +import { appStoreFactory } from '../../store'; +import { coreMock } from 'src/core/public/mocks'; +import { RouteCapture } from '../route_capture'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { AppAction } from '../../types'; +import { ManagementList } from './index'; +import { mockHostResultList } from '../../store/managing/mock_host_result_list'; + +describe('when on the managing page', () => { + let render: () => reactTestingLibrary.RenderResult; + let history: MemoryHistory; + let store: ReturnType; + + let queryByTestSubjId: ( + renderResult: reactTestingLibrary.RenderResult, + testSubjId: string + ) => Promise; + + beforeEach(async () => { + history = createMemoryHistory(); + store = appStoreFactory(coreMock.createStart(), true); + render = () => { + return reactTestingLibrary.render( + + + + + + + + + + ); + }; + + queryByTestSubjId = async (renderResult, testSubjId) => { + return await reactTestingLibrary.waitForElement( + () => document.body.querySelector(`[data-test-subj="${testSubjId}"]`), + { + container: renderResult.container, + } + ); + }; + }); + + it('should show a table', async () => { + const renderResult = render(); + const table = await queryByTestSubjId(renderResult, 'managementListTable'); + expect(table).not.toBeNull(); + }); + + describe('when there is no selected host in the url', () => { + it('should not show the flyout', () => { + const renderResult = render(); + expect.assertions(1); + return queryByTestSubjId(renderResult, 'managementDetailsFlyout').catch(e => { + expect(e).not.toBeNull(); + }); + }); + describe('when data loads', () => { + beforeEach(() => { + reactTestingLibrary.act(() => { + const action: AppAction = { + type: 'serverReturnedManagementList', + payload: mockHostResultList(), + }; + store.dispatch(action); + }); + }); + + it('should render the management summary row in the table', async () => { + const renderResult = render(); + const rows = await renderResult.findAllByRole('row'); + expect(rows).toHaveLength(2); + }); + + describe('when the user clicks the hostname in the table', () => { + let renderResult: reactTestingLibrary.RenderResult; + beforeEach(async () => { + renderResult = render(); + const detailsLink = await queryByTestSubjId(renderResult, 'hostnameCellLink'); + if (detailsLink) { + reactTestingLibrary.fireEvent.click(detailsLink); + } + }); + + it('should show the flyout', () => { + return queryByTestSubjId(renderResult, 'managementDetailsFlyout').then(flyout => { + expect(flyout).not.toBeNull(); + }); + }); + }); + }); + }); + + describe('when there is a selected host in the url', () => { + beforeEach(() => { + reactTestingLibrary.act(() => { + history.push({ + ...history.location, + search: '?selected_host=1', + }); + }); + }); + it('should show the flyout', () => { + const renderResult = render(); + return queryByTestSubjId(renderResult, 'managementDetailsFlyout').then(flyout => { + expect(flyout).not.toBeNull(); + }); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/index.tsx index 44b08f25c7653..ba9a931a233b2 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/index.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { EuiPage, EuiPageBody, @@ -16,26 +17,30 @@ import { EuiTitle, EuiBasicTable, EuiTextColor, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { createStructuredSelector } from 'reselect'; +import { ManagementDetails } from './details'; import * as selectors from '../../store/managing/selectors'; import { ManagementAction } from '../../store/managing/action'; import { useManagementListSelector } from './hooks'; -import { usePageId } from '../use_page_id'; import { CreateStructuredSelector } from '../../types'; +import { urlFromQueryParams } from './url_from_query_params'; const selector = (createStructuredSelector as CreateStructuredSelector)(selectors); export const ManagementList = () => { - usePageId('managementPage'); const dispatch = useDispatch<(a: ManagementAction) => void>(); + const history = useHistory(); const { listData, pageIndex, pageSize, totalHits: totalItemCount, isLoading, + uiQueryParams: queryParams, + hasSelectedHost, } = useManagementListSelector(selector); const paginationSetup = useMemo(() => { @@ -59,109 +64,129 @@ export const ManagementList = () => { [dispatch] ); - const columns = [ - { - field: 'host.hostname', - name: i18n.translate('xpack.endpoint.management.list.host', { - defaultMessage: 'Hostname', - }), - }, - { - field: '', - name: i18n.translate('xpack.endpoint.management.list.policy', { - defaultMessage: 'Policy', - }), - render: () => { - return 'Policy Name'; + const columns = useMemo(() => { + return [ + { + field: '', + name: i18n.translate('xpack.endpoint.management.list.host', { + defaultMessage: 'Hostname', + }), + render: ({ host: { hostname, id } }: { host: { hostname: string; id: string } }) => { + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + { + ev.preventDefault(); + history.push(urlFromQueryParams({ ...queryParams, selected_host: id })); + }} + > + {hostname} + + ); + }, }, - }, - { - field: '', - name: i18n.translate('xpack.endpoint.management.list.policyStatus', { - defaultMessage: 'Policy Status', - }), - render: () => { - return 'Policy Status'; + { + field: '', + name: i18n.translate('xpack.endpoint.management.list.policy', { + defaultMessage: 'Policy', + }), + render: () => { + return 'Policy Name'; + }, }, - }, - { - field: '', - name: i18n.translate('xpack.endpoint.management.list.alerts', { - defaultMessage: 'Alerts', - }), - render: () => { - return '0'; + { + field: '', + name: i18n.translate('xpack.endpoint.management.list.policyStatus', { + defaultMessage: 'Policy Status', + }), + render: () => { + return 'Policy Status'; + }, }, - }, - { - field: 'host.os.name', - name: i18n.translate('xpack.endpoint.management.list.os', { - defaultMessage: 'Operating System', - }), - }, - { - field: 'host.ip', - name: i18n.translate('xpack.endpoint.management.list.ip', { - defaultMessage: 'IP Address', - }), - }, - { - field: '', - name: i18n.translate('xpack.endpoint.management.list.sensorVersion', { - defaultMessage: 'Sensor Version', - }), - render: () => { - return 'version'; + { + field: '', + name: i18n.translate('xpack.endpoint.management.list.alerts', { + defaultMessage: 'Alerts', + }), + render: () => { + return '0'; + }, }, - }, - { - field: '', - name: i18n.translate('xpack.endpoint.management.list.lastActive', { - defaultMessage: 'Last Active', - }), - render: () => { - return 'xxxx'; + { + field: 'host.os.name', + name: i18n.translate('xpack.endpoint.management.list.os', { + defaultMessage: 'Operating System', + }), }, - }, - ]; + { + field: 'host.ip', + name: i18n.translate('xpack.endpoint.management.list.ip', { + defaultMessage: 'IP Address', + }), + }, + { + field: '', + name: i18n.translate('xpack.endpoint.management.list.sensorVersion', { + defaultMessage: 'Sensor Version', + }), + render: () => { + return 'version'; + }, + }, + { + field: '', + name: i18n.translate('xpack.endpoint.management.list.lastActive', { + defaultMessage: 'Last Active', + }), + render: () => { + return 'xxxx'; + }, + }, + ]; + }, [queryParams, history]); return ( - - - - - - -

- -

-
-

- - - -

-
-
- - - -
-
-
+ <> + {hasSelectedHost && } + + + + + + +

+ +

+
+

+ + + +

+
+
+ + + +
+
+
+ ); }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/url_from_query_params.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/url_from_query_params.ts new file mode 100644 index 0000000000000..ea6a4c6f684ad --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/managing/url_from_query_params.ts @@ -0,0 +1,17 @@ +/* + * 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 querystring from 'querystring'; +import { EndpointAppLocation, ManagingIndexUIQueryParams } from '../../types'; + +export function urlFromQueryParams( + queryParams: ManagingIndexUIQueryParams +): Partial { + const search = querystring.stringify(queryParams); + return { + search, + }; +} diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index 5e1ca72a7200d..c6bb62aa34916 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -398,8 +398,12 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { desc: schema.maybe(schema.boolean()), end: schema.maybe(schema.string()), exclude_interim: schema.maybe(schema.boolean()), - 'page.from': schema.maybe(schema.number()), - 'page.size': schema.maybe(schema.number()), + page: schema.maybe( + schema.object({ + from: schema.maybe(schema.number()), + size: schema.maybe(schema.number()), + }) + ), record_score: schema.maybe(schema.number()), sort: schema.maybe(schema.string()), start: schema.maybe(schema.string()), @@ -410,7 +414,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { try { const results = await context.ml!.mlClient.callAsCurrentUser('ml.records', { jobId: request.params.jobId, - ...request.body, + body: request.body, }); return response.ok({ body: results, @@ -448,8 +452,12 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { end: schema.maybe(schema.string()), exclude_interim: schema.maybe(schema.boolean()), expand: schema.maybe(schema.boolean()), - 'page.from': schema.maybe(schema.number()), - 'page.size': schema.maybe(schema.number()), + page: schema.maybe( + schema.object({ + from: schema.maybe(schema.number()), + size: schema.maybe(schema.number()), + }) + ), sort: schema.maybe(schema.string()), start: schema.maybe(schema.string()), }), @@ -460,7 +468,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { const results = await context.ml!.mlClient.callAsCurrentUser('ml.buckets', { jobId: request.params.jobId, timestamp: request.params.timestamp, - ...request.body, + body: request.body, }); return response.ok({ body: results,