From dda555a2fa5656651ea7dbf2d5c7163c43e941fc Mon Sep 17 00:00:00 2001 From: Josh Romero Date: Thu, 21 Jul 2022 12:12:22 -0700 Subject: [PATCH] [D&D] Enable basic embeddable panels (#1911) - add embeddable, embeddable component, embeddable factory - update `toExpression` to allow passing services - register embeddable factory in plugin setup fixes #1908 Signed-off-by: Josh Romero --- .../saved_objects_management/README.md | 2 +- src/plugins/wizard/public/embeddable/index.ts | 7 + .../public/embeddable/wizard_component.tsx | 133 +++++++++++++++ .../public/embeddable/wizard_embeddable.tsx | 160 ++++++++++++++++++ .../embeddable/wizard_embeddable_factory.tsx | 100 +++++++++++ src/plugins/wizard/public/plugin.ts | 21 ++- .../type_service/visualization_type.tsx | 6 +- src/plugins/wizard/public/types.ts | 3 +- .../visualizations/metric/to_expression.ts | 11 +- 9 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 src/plugins/wizard/public/embeddable/index.ts create mode 100644 src/plugins/wizard/public/embeddable/wizard_component.tsx create mode 100644 src/plugins/wizard/public/embeddable/wizard_embeddable.tsx create mode 100644 src/plugins/wizard/public/embeddable/wizard_embeddable_factory.tsx diff --git a/src/plugins/saved_objects_management/README.md b/src/plugins/saved_objects_management/README.md index 748ce5b8992..3e2a67dd0b4 100644 --- a/src/plugins/saved_objects_management/README.md +++ b/src/plugins/saved_objects_management/README.md @@ -34,7 +34,7 @@ You'll notice that when clicking on the "Inspect" button from the saved objects ### Registering -Ideally, we'd allow plugins to self-register their `savedObjectLoader` and (declare a dependency on this plugin). However, as currently implemented, any plugins that want this plugin to handle their inpect routes need to be added as optional dependencies and registered here. +Ideally, we'd allow plugins to self-register their `savedObjectLoader` and (declare a dependency on this plugin). However, as currently implemented, any plugins that want this plugin to handle their inspect routes need to be added as optional dependencies and registered here. 1. Add your plugin to the `optionalPlugins` array in `./opensearch_dashboards.json` 2. Update the `StartDependencies` interface of this plugin to include the public plugin start type diff --git a/src/plugins/wizard/public/embeddable/index.ts b/src/plugins/wizard/public/embeddable/index.ts new file mode 100644 index 00000000000..d0137757e0a --- /dev/null +++ b/src/plugins/wizard/public/embeddable/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './wizard_embeddable'; +export * from './wizard_embeddable_factory'; diff --git a/src/plugins/wizard/public/embeddable/wizard_component.tsx b/src/plugins/wizard/public/embeddable/wizard_component.tsx new file mode 100644 index 00000000000..79a768c754c --- /dev/null +++ b/src/plugins/wizard/public/embeddable/wizard_component.tsx @@ -0,0 +1,133 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiText, + EuiAvatar, + EuiFlexGrid, + EuiCodeBlock, +} from '@elastic/eui'; + +import { withEmbeddableSubscription } from '../../../embeddable/public'; +import { WizardEmbeddable, WizardInput, WizardOutput } from './wizard_embeddable'; +import { validateSchemaState } from '../application/utils/validate_schema_state'; + +interface Props { + embeddable: WizardEmbeddable; + input: WizardInput; + output: WizardOutput; +} + +function wrapSearchTerms(task?: string, search?: string) { + if (!search) return task; + if (!task) return task; + const parts = task.split(new RegExp(`(${search})`, 'g')); + return parts.map((part, i) => + part === search ? ( + + {part} + + ) : ( + part + ) + ); +} + +function WizardEmbeddableComponentInner({ + embeddable, + input: { search }, + output: { savedAttributes }, +}: Props) { + const { ReactExpressionRenderer, toasts, types, indexPatterns, aggs } = embeddable; + const [expression, setExpression] = useState(); + const { title, description, visualizationState, styleState } = savedAttributes || {}; + + useEffect(() => { + const { visualizationState: visualization, styleState: style } = savedAttributes || {}; + if (savedAttributes === undefined || visualization === undefined || style === undefined) { + return; + } + + const rootState = { + visualization: JSON.parse(visualization), + style: JSON.parse(style), + }; + + const visualizationType = types.get(rootState.visualization?.activeVisualization?.name ?? ''); + if (!visualizationType) { + throw new Error(`Invalid visualization type ${visualizationType}`); + } + const { toExpression, ui } = visualizationType; + + async function loadExpression() { + const schemas = ui.containerConfig.data.schemas; + const [valid, errorMsg] = validateSchemaState(schemas, rootState); + + if (!valid) { + if (errorMsg) { + toasts.addWarning(errorMsg); + } + setExpression(undefined); + return; + } + const exp = await toExpression(rootState, indexPatterns, aggs); + setExpression(exp); + } + + if (savedAttributes !== undefined) { + loadExpression(); + } + }, [aggs, indexPatterns, savedAttributes, toasts, types]); + + // TODO: add correct loading and error states, remove debugging mode + return ( + <> + {expression ? ( + + + + ) : ( + + + + + + + + +

{wrapSearchTerms(title || '', search)}

+
+
+ + + {wrapSearchTerms(description, search)} + + + + + {wrapSearchTerms(visualizationState, search)} + + + + + {wrapSearchTerms(styleState, search)} + + +
+
+
+ )} + + ); +} + +export const WizardEmbeddableComponent = withEmbeddableSubscription< + WizardInput, + WizardOutput, + WizardEmbeddable +>(WizardEmbeddableComponentInner); diff --git a/src/plugins/wizard/public/embeddable/wizard_embeddable.tsx b/src/plugins/wizard/public/embeddable/wizard_embeddable.tsx new file mode 100644 index 00000000000..2e4a137d368 --- /dev/null +++ b/src/plugins/wizard/public/embeddable/wizard_embeddable.tsx @@ -0,0 +1,160 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Subscription } from 'rxjs'; + +import { WizardSavedObjectAttributes } from '../../common'; +import { + Embeddable, + EmbeddableOutput, + IContainer, + SavedObjectEmbeddableInput, +} from '../../../embeddable/public'; +import { IToasts, SavedObjectsClientContract } from '../../../../core/public'; +import { WizardEmbeddableComponent } from './wizard_component'; +import { ReactExpressionRendererType } from '../../../expressions/public'; +import { TypeServiceStart } from '../services/type_service'; +import { DataPublicPluginStart } from '../../../data/public'; + +export const WIZARD_EMBEDDABLE = 'WIZARD_EMBEDDABLE'; + +// TODO: remove search, hasMatch or update as appropriate +export interface WizardInput extends SavedObjectEmbeddableInput { + /** + * Optional search string which will be used to highlight search terms as + * well as calculate `output.hasMatch`. + */ + search?: string; +} + +export interface WizardOutput extends EmbeddableOutput { + /** + * Should be true if input.search is defined and the task or title contain + * search as a substring. + */ + hasMatch: boolean; + /** + * Will contain the saved object attributes of the Wizard Saved Object that matches + * `input.savedObjectId`. If the id is invalid, this may be undefined. + */ + savedAttributes?: WizardSavedObjectAttributes; +} + +/** + * Returns whether any attributes contain the search string. If search is empty, true is returned. If + * there are no savedAttributes, false is returned. + * @param search - the search string + * @param savedAttributes - the saved object attributes for the saved object with id `input.savedObjectId` + */ +function getHasMatch(search?: string, savedAttributes?: WizardSavedObjectAttributes): boolean { + if (!search) return true; + if (!savedAttributes) return false; + return Boolean( + (savedAttributes.description && savedAttributes.description.match(search)) || + (savedAttributes.title && savedAttributes.title.match(search)) + ); +} + +export class WizardEmbeddable extends Embeddable { + public readonly type = WIZARD_EMBEDDABLE; + private subscription: Subscription; + private node?: HTMLElement; + private savedObjectsClient: SavedObjectsClientContract; + public ReactExpressionRenderer: ReactExpressionRendererType; + public toasts: IToasts; + public types: TypeServiceStart; + public indexPatterns: DataPublicPluginStart['indexPatterns']; + public aggs: DataPublicPluginStart['search']['aggs']; + private savedObjectId?: string; + + constructor( + initialInput: WizardInput, + { + parent, + savedObjectsClient, + data, + ReactExpressionRenderer, + toasts, + types, + }: { + parent?: IContainer; + data: DataPublicPluginStart; + savedObjectsClient: SavedObjectsClientContract; + ReactExpressionRenderer: ReactExpressionRendererType; + toasts: IToasts; + types: TypeServiceStart; + } + ) { + // TODO: can default title come from saved object? + super(initialInput, { defaultTitle: 'wizard', hasMatch: false }, parent); + this.savedObjectsClient = savedObjectsClient; + this.ReactExpressionRenderer = ReactExpressionRenderer; + this.toasts = toasts; + this.types = types; + this.indexPatterns = data.indexPatterns; + this.aggs = data.search.aggs; + + this.subscription = this.getInput$().subscribe(async () => { + // There is a little more work today for this embeddable because it has + // more output it needs to update in response to input state changes. + let savedAttributes: WizardSavedObjectAttributes | undefined; + + // Since this is an expensive task, we save a local copy of the previous + // savedObjectId locally and only retrieve the new saved object if the id + // actually changed. + if (this.savedObjectId !== this.input.savedObjectId) { + this.savedObjectId = this.input.savedObjectId; + const wizardSavedObject = await this.savedObjectsClient.get( + 'wizard', + this.input.savedObjectId + ); + savedAttributes = wizardSavedObject?.attributes; + } + + // The search string might have changed as well so we need to make sure we recalculate + // hasMatch. + this.updateOutput({ + hasMatch: getHasMatch(this.input.search, savedAttributes), + savedAttributes, + title: savedAttributes?.title, + }); + }); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + ReactDOM.render(, node); + } + + /** + * Lets re-sync our saved object to make sure it's up to date! + */ + public async reload() { + this.savedObjectId = this.input.savedObjectId; + const wizardSavedObject = await this.savedObjectsClient.get( + 'wizard', + this.input.savedObjectId + ); + const savedAttributes = wizardSavedObject?.attributes; + this.updateOutput({ + hasMatch: getHasMatch(this.input.search, savedAttributes), + savedAttributes, + title: wizardSavedObject?.attributes?.title, + }); + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/src/plugins/wizard/public/embeddable/wizard_embeddable_factory.tsx b/src/plugins/wizard/public/embeddable/wizard_embeddable_factory.tsx new file mode 100644 index 00000000000..ce1780d0066 --- /dev/null +++ b/src/plugins/wizard/public/embeddable/wizard_embeddable_factory.tsx @@ -0,0 +1,100 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { NotificationsStart, SavedObjectsClientContract } from '../../../../core/public'; +import { DataPublicPluginStart } from '../../../data/public'; +import { + EmbeddableFactory, + EmbeddableFactoryDefinition, + EmbeddableStart, + ErrorEmbeddable, + IContainer, +} from '../../../embeddable/public'; +import { ExpressionsStart } from '../../../expressions/public'; +import { WizardSavedObjectAttributes } from '../../common'; +import { TypeServiceStart } from '../services/type_service'; +import { + WizardEmbeddable, + WizardInput, + WizardOutput, + WIZARD_EMBEDDABLE, +} from './wizard_embeddable'; + +interface StartServices { + data: DataPublicPluginStart; + expressions: ExpressionsStart; + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; + savedObjectsClient: SavedObjectsClientContract; + notifications: NotificationsStart; + types: TypeServiceStart; +} + +// TODO: use or remove? +export type WizardEmbeddableFactory = EmbeddableFactory< + WizardInput, + WizardOutput, + WizardEmbeddable, + WizardSavedObjectAttributes +>; + +export class WizardEmbeddableFactoryDefinition + implements + EmbeddableFactoryDefinition< + WizardInput, + WizardOutput, + WizardEmbeddable, + WizardSavedObjectAttributes + > { + public readonly type = WIZARD_EMBEDDABLE; + public readonly savedObjectMetaData = { + // TODO: Update to include most vis functionality + name: 'Wizard', + includeFields: ['visualizationState'], + type: 'wizard', + getIconForSavedObject: () => 'pencil', + }; + + constructor(private getStartServices: () => Promise) {} + + public async isEditable() { + // TODO: Add proper access controls + // return getCapabilities().visualize.save as boolean; + return true; + } + + public createFromSavedObject = ( + savedObjectId: string, + input: Partial & { id: string }, + parent?: IContainer + ): Promise => { + return this.create({ ...input, savedObjectId }, parent); + }; + + public async create(input: WizardInput, parent?: IContainer) { + // TODO: Use savedWizardLoader here instead + const { + data, + expressions: { ReactExpressionRenderer }, + notifications: { toasts }, + savedObjectsClient, + types, + } = await this.getStartServices(); + return new WizardEmbeddable(input, { + parent, + data, + savedObjectsClient, + ReactExpressionRenderer, + toasts, + types, + }); + } + + public getDisplayName() { + return i18n.translate('wizard.displayName', { + defaultMessage: 'Wizard', + }); + } +} diff --git a/src/plugins/wizard/public/plugin.ts b/src/plugins/wizard/public/plugin.ts index 7a3fe5aa204..4f4e7016044 100644 --- a/src/plugins/wizard/public/plugin.ts +++ b/src/plugins/wizard/public/plugin.ts @@ -19,6 +19,7 @@ import { WizardSetup, WizardStart, } from './types'; +import { WizardEmbeddableFactoryDefinition, WIZARD_EMBEDDABLE } from './embeddable'; import wizardIcon from './assets/wizard_icon.svg'; import { PLUGIN_ID, PLUGIN_NAME } from '../common'; import { TypeService } from './services/type_service'; @@ -37,7 +38,7 @@ export class WizardPlugin public setup( core: CoreSetup, - { visualizations }: WizardPluginSetupDependencies + { embeddable, visualizations }: WizardPluginSetupDependencies ) { const typeService = this.typeService; registerDefaultTypes(typeService.setup()); @@ -89,6 +90,24 @@ export class WizardPlugin }, }); + // Register embeddable + // TODO: investigate simplification via getter a la visualizations: + // const start = createStartServicesGetter(core.getStartServices)); + // const embeddableFactory = new WizardEmbeddableFactoryDefinition({ start }); + const embeddableFactory = new WizardEmbeddableFactoryDefinition(async () => { + const [coreStart, pluginsStart, _wizardStart] = await core.getStartServices(); + // TODO: refactor to pass minimal service methods? + return { + savedObjectsClient: coreStart.savedObjects.client, + data: pluginsStart.data, + getEmbeddableFactory: pluginsStart.embeddable.getEmbeddableFactory, + expressions: pluginsStart.expressions, + notifications: coreStart.notifications, + types: this.typeService.start(), + }; + }); + embeddable.registerEmbeddableFactory(WIZARD_EMBEDDABLE, embeddableFactory); + // Register the plugin as an alias to create visualization visualizations.registerAlias({ name: PLUGIN_ID, diff --git a/src/plugins/wizard/public/services/type_service/visualization_type.tsx b/src/plugins/wizard/public/services/type_service/visualization_type.tsx index 90f30d8f8a9..62fddfff85b 100644 --- a/src/plugins/wizard/public/services/type_service/visualization_type.tsx +++ b/src/plugins/wizard/public/services/type_service/visualization_type.tsx @@ -15,7 +15,11 @@ export class VisualizationType implements IVisualizationType { public readonly icon: IconType; public readonly stage: 'beta' | 'production'; public readonly ui: IVisualizationType['ui']; - public readonly toExpression: (state: RootState) => Promise; + public readonly toExpression: ( + state: RootState, + indexPatterns?, + aggs? + ) => Promise; constructor(options: VisualizationTypeOptions) { this.name = options.name; diff --git a/src/plugins/wizard/public/types.ts b/src/plugins/wizard/public/types.ts index 55b3f1b4134..f8371b832bd 100644 --- a/src/plugins/wizard/public/types.ts +++ b/src/plugins/wizard/public/types.ts @@ -5,7 +5,7 @@ import { History } from 'history'; import { SavedObject, SavedObjectsStart } from '../../saved_objects/public'; -import { EmbeddableSetup } from '../../embeddable/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; import { DashboardStart } from '../../dashboard/public'; import { VisualizationsSetup } from '../../visualizations/public'; import { ExpressionsStart } from '../../expressions/public'; @@ -25,6 +25,7 @@ export interface WizardPluginSetupDependencies { visualizations: VisualizationsSetup; } export interface WizardPluginStartDependencies { + embeddable: EmbeddableStart; navigation: NavigationPublicPluginStart; data: DataPublicPluginStart; savedObjects: SavedObjectsStart; diff --git a/src/plugins/wizard/public/visualizations/metric/to_expression.ts b/src/plugins/wizard/public/visualizations/metric/to_expression.ts index ce930d9b8e4..0a459c23a7a 100644 --- a/src/plugins/wizard/public/visualizations/metric/to_expression.ts +++ b/src/plugins/wizard/public/visualizations/metric/to_expression.ts @@ -91,13 +91,18 @@ export interface MetricRootState extends RootState { style: MetricOptionsDefaults; } -export const toExpression = async ({ style: styleState, visualization }: MetricRootState) => { +export const toExpression = async ( + { style: styleState, visualization }: MetricRootState, + indexPatterns, + aggs +) => { const { activeVisualization, indexPattern: indexId = '' } = visualization; const { aggConfigParams } = activeVisualization || {}; - const indexPatternsService = getIndexPatterns(); + const indexPatternsService = indexPatterns ?? getIndexPatterns(); const indexPattern = await indexPatternsService.get(indexId); - const aggConfigs = getAggService().createAggConfigs(indexPattern, cloneDeep(aggConfigParams)); + const aggService = aggs ?? getAggService(); + const aggConfigs = aggService.createAggConfigs(indexPattern, cloneDeep(aggConfigParams)); // soon this becomes: const opensearchaggs = vis.data.aggs!.toExpressionAst(); const opensearchaggs = buildExpressionFunction(