diff --git a/examples/embeddable_examples/common/index.ts b/examples/embeddable_examples/common/index.ts new file mode 100644 index 0000000000000..0199e75743972 --- /dev/null +++ b/examples/embeddable_examples/common/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { TodoSavedObjectAttributes } from './todo_saved_object_attributes'; +export { NoteSavedObjectAttributes, NOTE_SAVED_OBJECT } from './note_saved_object_attributes'; diff --git a/examples/embeddable_examples/common/note_saved_object_attributes.ts b/examples/embeddable_examples/common/note_saved_object_attributes.ts new file mode 100644 index 0000000000000..8e1b61a84d0b6 --- /dev/null +++ b/examples/embeddable_examples/common/note_saved_object_attributes.ts @@ -0,0 +1,28 @@ +/* + * 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 { SavedObjectAttributes } from '../../../src/core/types'; + +export const NOTE_SAVED_OBJECT = 'note'; + +export interface NoteSavedObjectAttributes extends SavedObjectAttributes { + to?: string; + from?: string; + message: string; +} diff --git a/examples/embeddable_examples/common/todo_saved_object_attributes.ts b/examples/embeddable_examples/common/todo_saved_object_attributes.ts new file mode 100644 index 0000000000000..21b6df20fea90 --- /dev/null +++ b/examples/embeddable_examples/common/todo_saved_object_attributes.ts @@ -0,0 +1,26 @@ +/* + * 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 { SavedObjectAttributes } from '../../../src/core/types'; + +export interface TodoSavedObjectAttributes extends SavedObjectAttributes { + task: string; + icon?: string; + title?: string; +} diff --git a/examples/embeddable_examples/kibana.json b/examples/embeddable_examples/kibana.json index c70bc7009ff51..aa1d6b014fa52 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -3,8 +3,8 @@ "version": "0.0.1", "kibanaVersion": "kibana", "configPath": ["embeddable_examples"], - "server": false, + "server": true, "ui": true, - "requiredPlugins": ["embeddable"], + "requiredPlugins": ["embeddable", "uiActions"], "optionalPlugins": [] } diff --git a/examples/embeddable_examples/public/create_sample_data.ts b/examples/embeddable_examples/public/create_sample_data.ts new file mode 100644 index 0000000000000..6216a3dbf93e0 --- /dev/null +++ b/examples/embeddable_examples/public/create_sample_data.ts @@ -0,0 +1,49 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/public'; +import { TodoSavedObjectAttributes, NOTE_SAVED_OBJECT, NoteSavedObjectAttributes } from '../common'; + +export async function createSampleData(client: SavedObjectsClientContract, overwrite = true) { + await client.create( + 'todo', + { + task: 'Take the garbage out', + title: 'Garbage', + icon: 'trash', + }, + { + id: 'sample-todo-saved-object', + overwrite, + } + ); + + await client.create( + NOTE_SAVED_OBJECT, + { + to: 'Sue', + from: 'Bob', + message: 'Remember to pick up more bleach.', + }, + { + id: 'sample-note-saved-object', + overwrite, + } + ); +} diff --git a/examples/embeddable_examples/public/index.ts b/examples/embeddable_examples/public/index.ts index 5fcd454b17a5c..f98a781cafaaf 100644 --- a/examples/embeddable_examples/public/index.ts +++ b/examples/embeddable_examples/public/index.ts @@ -17,7 +17,10 @@ * under the License. */ -import { PluginInitializer } from 'kibana/public'; +import { EmbeddableExamplesPlugin } from './plugin'; + +export const plugin = () => new EmbeddableExamplesPlugin(); + export { HELLO_WORLD_EMBEDDABLE, HelloWorldEmbeddable, @@ -26,18 +29,11 @@ export { export { ListContainer, LIST_CONTAINER } from './list_container'; export { TODO_EMBEDDABLE } from './todo'; -import { - EmbeddableExamplesPlugin, - EmbeddableExamplesSetupDependencies, - EmbeddableExamplesStartDependencies, -} from './plugin'; - -export { SearchableListContainer, SEARCHABLE_LIST_CONTAINER } from './searchable_list_container'; +export { EmbeddableExamplesStart } from './plugin'; +export { + SearchableListContainer, + SEARCHABLE_LIST_CONTAINER, + SearchableContainerInput, +} from './searchable_list_container'; export { MULTI_TASK_TODO_EMBEDDABLE } from './multi_task_todo'; - -export const plugin: PluginInitializer< - void, - void, - EmbeddableExamplesSetupDependencies, - EmbeddableExamplesStartDependencies -> = () => new EmbeddableExamplesPlugin(); +export { NOTE_EMBEDDABLE, NoteEmbeddableInput, NoteEmbeddableOutput, NoteEmbeddable } from './note'; diff --git a/examples/embeddable_examples/public/note/README.md b/examples/embeddable_examples/public/note/README.md new file mode 100644 index 0000000000000..37eb92252cccd --- /dev/null +++ b/examples/embeddable_examples/public/note/README.md @@ -0,0 +1,33 @@ +The `../todo` folder has two separate examples: a "by reference" and a "by value" todo embeddable example. +This folder combines both examples into a single embeddable, but since we can only have one embeddable factory +represent a single saved object type, this is built off a `note` saved object type. There is more complexity +invovled in making +it a single embeddable - it not only takes in an optional saved object id but can also accept edits to +the values. This is closer to the real world use case we aim for with the Visualize Library. A user +may have an embeddable on a dashboard that is "by value" but they would like to promote it to "by reference". + +Similarly they could break the link and convert back from by reference to by value. + +The input data is: + +```ts +{ + savedObjectId?: string; + attributes: NoteSavedObjectAttributes; +} +``` + +`attributes` represent either the "by value" data, or, edits on top of the saved object id. + +The output data is: + +```ts +{ + savedAttributes?: NoteSavedObjectAttributes; +} +``` + +There is also an action that represents how this setup can be used with a save/create/edit action. + +You can only have one embeddable factory representation for a single saved object, so rather than use the +`Todo` example, this is going to use a new embeddable - `note`. \ No newline at end of file diff --git a/examples/embeddable_examples/public/note/create_edit_note_component.tsx b/examples/embeddable_examples/public/note/create_edit_note_component.tsx new file mode 100644 index 0000000000000..4e46efbc0e9bc --- /dev/null +++ b/examples/embeddable_examples/public/note/create_edit_note_component.tsx @@ -0,0 +1,91 @@ +/* + * 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 } from 'react'; +import { EuiModalBody } from '@elastic/eui'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; +import { EuiModalFooter } from '@elastic/eui'; +import { EuiModalHeader } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; +import { NoteSavedObjectAttributes } from '../common'; + +export function CreateEditNoteComponent({ + savedObjectId, + attributes, + onSave, +}: { + savedObjectId?: string; + attributes?: NoteSavedObjectAttributes; + onSave: (attributes: NoteSavedObjectAttributes, saveToLibrary: boolean) => void; +}) { + const [to, setTo] = useState(attributes?.to ?? ''); + const [from, setFrom] = useState(attributes?.from ?? ''); + const [message, setMessage] = useState(attributes?.message ?? ''); + return ( + + +

{`${savedObjectId ? 'Create new ' : 'Edit '}`}

+
+ + + setTo(e.target.value)} + /> + + + setFrom(e.target.value)} + /> + + + setMessage(e.target.value)} + /> + + + + {savedObjectId === undefined ? ( + onSave({ message, to, from }, false)} + > + Save + + ) : null} + onSave({ message, to, from }, true)} + > + {savedObjectId ? 'Update library item' : 'Save to library'} + + +
+ ); +} diff --git a/examples/embeddable_examples/public/note/edit_note_action.tsx b/examples/embeddable_examples/public/note/edit_note_action.tsx new file mode 100644 index 0000000000000..c512f319dd44e --- /dev/null +++ b/examples/embeddable_examples/public/note/edit_note_action.tsx @@ -0,0 +1,83 @@ +/* + * 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 { OverlayStart, SavedObjectsClientContract } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { NoteSavedObjectAttributes, NOTE_SAVED_OBJECT } from '../common'; +import { createAction } from '../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { ViewMode } from '../../../../src/plugins/embeddable/public'; +import { CreateEditNoteComponent } from './create_edit_note_component'; +import { NoteEmbeddable, NOTE_EMBEDDABLE } from './note_embeddable'; + +interface StartServices { + openModal: OverlayStart['openModal']; + savedObjectsClient: SavedObjectsClientContract; +} + +interface ActionContext { + embeddable: NoteEmbeddable; +} + +export const ACTION_EDIT_NOTE = 'ACTION_EDIT_NOTE'; + +export const createEditNoteAction = (getStartServices: () => Promise) => + createAction({ + getDisplayName: () => + i18n.translate('embeddableExamples.note.edit', { defaultMessage: 'Edit' }), + type: ACTION_EDIT_NOTE, + isCompatible: async ({ embeddable }: ActionContext) => { + return ( + embeddable.type === NOTE_EMBEDDABLE && embeddable.getInput().viewMode === ViewMode.EDIT + ); + }, + execute: async ({ embeddable }: ActionContext) => { + const { openModal, savedObjectsClient } = await getStartServices(); + const onSave = async (attributes: NoteSavedObjectAttributes, includeInLibrary: boolean) => { + if (includeInLibrary) { + if (embeddable.getInput().savedObjectId) { + await savedObjectsClient.update( + NOTE_SAVED_OBJECT, + embeddable.getInput().savedObjectId!, + attributes + ); + embeddable.updateInput({ attributes: undefined }); + embeddable.reload(); + } else { + const savedItem = await savedObjectsClient.create(NOTE_SAVED_OBJECT, attributes); + embeddable.updateInput({ savedObjectId: savedItem.id }); + } + } else { + embeddable.updateInput({ attributes }); + } + }; + const overlay = openModal( + toMountPoint( + { + overlay.close(); + onSave(attributes, includeInLibrary); + }} + /> + ) + ); + }, + }); diff --git a/examples/embeddable_examples/public/note/index.ts b/examples/embeddable_examples/public/note/index.ts new file mode 100644 index 0000000000000..70076cc6b19af --- /dev/null +++ b/examples/embeddable_examples/public/note/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. + */ + +export * from './note_embeddable'; +export * from './note_embeddable_factory'; +export * from './edit_note_action'; diff --git a/examples/embeddable_examples/public/note/note_component.tsx b/examples/embeddable_examples/public/note/note_component.tsx new file mode 100644 index 0000000000000..8929cf8a8d35f --- /dev/null +++ b/examples/embeddable_examples/public/note/note_component.tsx @@ -0,0 +1,88 @@ +/* + * 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 { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import { EuiText } from '@elastic/eui'; +import { EuiFlexGrid } from '@elastic/eui'; +import { + withEmbeddableSubscription, + EmbeddableOutput, +} from '../../../../src/plugins/embeddable/public'; +import { NoteEmbeddable, NoteEmbeddableInput, NoteEmbeddableOutput } from './note_embeddable'; + +interface Props { + embeddable: NoteEmbeddable; + input: NoteEmbeddableInput; + output: EmbeddableOutput; +} + +function wrapSearchTerms(task?: string, search?: string) { + if (!search || !task) return task; + const parts = task.split(new RegExp(`(${search})`, 'g')); + return parts.map((part, i) => + part === search ? ( + + {part} + + ) : ( + part + ) + ); +} + +export function NoteEmbeddableComponentInner({ input: { search }, embeddable }: Props) { + const from = embeddable.getFrom(); + const to = embeddable.getTo(); + const message = embeddable.getMessage(); + return ( + + + + {to ? ( + + +

{`${wrapSearchTerms(to, search)},`}

+
+
+ ) : null} + + + {wrapSearchTerms(message ?? '', search)} + + + {from ? ( + + +

{`- ${wrapSearchTerms(from, search)}`}

+
+
+ ) : null} +
+
+
+ ); +} + +export const NoteEmbeddableComponent = withEmbeddableSubscription< + NoteEmbeddableInput, + NoteEmbeddableOutput, + NoteEmbeddable, + {} +>(NoteEmbeddableComponentInner); diff --git a/examples/embeddable_examples/public/note/note_embeddable.tsx b/examples/embeddable_examples/public/note/note_embeddable.tsx new file mode 100644 index 0000000000000..9f4772f2d677c --- /dev/null +++ b/examples/embeddable_examples/public/note/note_embeddable.tsx @@ -0,0 +1,160 @@ +/* + * 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 { Subscription } from 'rxjs'; +import * as Rx from 'rxjs'; +import { SavedObjectsClientContract } from 'kibana/public'; +import { NoteSavedObjectAttributes, NOTE_SAVED_OBJECT } from '../../common'; +import { + Embeddable, + IContainer, + EmbeddableOutput, + SavedObjectEmbeddableInput, +} from '../../../../src/plugins/embeddable/public'; +import { NoteEmbeddableComponent } from './note_component'; + +// Notice this is not the same value as the 'note' saved object type. Many of our +// cases in prod today use the same value, but this is unnecessary. +export const NOTE_EMBEDDABLE = 'NOTE_EMBEDDABLE'; + +export interface NoteEmbeddableInput extends SavedObjectEmbeddableInput { + /** + * Optional search string to highlight in the note. Will also dictate output.hasMatch. + */ + search?: string; + /** + * If undefined, then there are no local edits or overrides and a valid + * `savedObjectId` should be supplied. + */ + attributes?: NoteSavedObjectAttributes; +} + +export interface NoteEmbeddableOutput extends EmbeddableOutput { + /** + * Whether or not any values match the search string. Will check input.attributes first, + * otherwise will check output.savedAttributes. + */ + hasMatch: boolean; + /** + * If a valid `input.savedObjectId` was given, this will hold the last retrieved attributes + * from the saved object. + */ + savedAttributes?: NoteSavedObjectAttributes; +} + +function getHasMatch(note: NoteEmbeddable): boolean { + const to = note.getTo(); + const from = note.getFrom(); + const message = note.getMessage(); + const { search } = note.getInput(); + if (!search) return true; + if (!message) return false; + return Boolean(message.match(search) || to?.match(search) || from?.match(search)); +} + +/** + * This is an example of an embeddable that can optionally be backed by a saved object. + */ + +export class NoteEmbeddable extends Embeddable { + public readonly type = NOTE_EMBEDDABLE; + private subscription: Subscription; + private node?: HTMLElement; + private savedObjectsClient: SavedObjectsClientContract; + private savedObjectId?: string; + + constructor( + input: NoteEmbeddableInput, + { + parent, + savedObjectsClient, + }: { + parent?: IContainer; + savedObjectsClient: SavedObjectsClientContract; + } + ) { + super( + input, + { hasMatch: false, defaultTitle: input.to ? `A note to ${input.to}` : `A note` }, + parent + ); + this.savedObjectsClient = savedObjectsClient; + + this.subscription = Rx.merge(this.getOutput$(), this.getInput$()).subscribe(async () => { + const { savedObjectId } = this.getInput(); + if (this.savedObjectId !== savedObjectId) { + this.savedObjectId = savedObjectId; + if (savedObjectId !== undefined) { + this.reload(); + } + } + this.updateOutput({ + hasMatch: getHasMatch(this), + }); + }); + } + + public getTo() { + return this.input.attributes?.to ?? this.output.savedAttributes?.to; + } + + public getFrom() { + return this.input.attributes?.from ?? this.output.savedAttributes?.from; + } + + public getMessage() { + return this.input.attributes?.message ?? this.output.savedAttributes?.message; + } + + public render(node: HTMLElement) { + this.node = node; + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + ReactDOM.render(, node); + } + + public async reload() { + if (this.savedObjectId !== undefined) { + const savedObject = await this.savedObjectsClient.get( + NOTE_SAVED_OBJECT, + this.savedObjectId + ); + const defaultTitle = this.input.to ? `A note to ${this.input.to}` : `A note`; + this.updateOutput({ + hasMatch: getHasMatch(this), + title: this.input.title ?? defaultTitle, + defaultTitle, + savedAttributes: savedObject.attributes, + }); + if (this.node) { + this.render(this.node); + } + } + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/examples/embeddable_examples/public/note/note_embeddable_factory.tsx b/examples/embeddable_examples/public/note/note_embeddable_factory.tsx new file mode 100644 index 0000000000000..63d3d4c273d2b --- /dev/null +++ b/examples/embeddable_examples/public/note/note_embeddable_factory.tsx @@ -0,0 +1,127 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { SavedObjectsClientContract, OverlayStart } from 'kibana/public'; +import { NoteSavedObjectAttributes, NOTE_SAVED_OBJECT } from '../../common'; +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; +import { + IContainer, + SavedObjectEmbeddableFactoryDefinition, + EmbeddableStart, + ErrorEmbeddable, +} from '../../../../src/plugins/embeddable/public'; +import { + NoteEmbeddable, + NOTE_EMBEDDABLE, + NoteEmbeddableInput, + NoteEmbeddableOutput, +} from './note_embeddable'; +import { CreateEditNoteComponent } from './create_edit_note_component'; + +interface StartServices { + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; + savedObjectsClient: SavedObjectsClientContract; + openModal: OverlayStart['openModal']; +} + +export class NoteEmbeddableFactory + implements + SavedObjectEmbeddableFactoryDefinition< + NoteEmbeddableInput, + NoteEmbeddableOutput, + NoteEmbeddable, + NoteSavedObjectAttributes + > { + public readonly type = NOTE_EMBEDDABLE; + public savedObjectMetaData = { + name: 'Note', + includeFields: ['to', 'from', 'message'], + type: NOTE_SAVED_OBJECT, + getIconForSavedObject: () => 'pencil', + }; + + constructor(private getStartServices: () => Promise) {} + + public async isEditable() { + return true; + } + + public createFromSavedObject = async ( + savedObjectId: string, + input: Partial & { id: string }, + parent?: IContainer + ): Promise => { + const { savedObjectsClient } = await this.getStartServices(); + const todoSavedObject = await savedObjectsClient.get( + 'todo', + savedObjectId + ); + return this.create({ ...input, savedObjectId, attributes: todoSavedObject.attributes }, parent); + }; + + public async create(input: NoteEmbeddableInput, parent?: IContainer) { + const { savedObjectsClient } = await this.getStartServices(); + return new NoteEmbeddable(input, { + parent, + savedObjectsClient, + }); + } + + public getDisplayName() { + return i18n.translate('embeddableExamples.note.displayName', { + defaultMessage: 'Note', + }); + } + + /** + * This function is used when dynamically creating a new embeddable to add to a + * container that is not neccessarily backed by a saved object. + */ + public async getExplicitInput(): Promise<{ + savedObjectId?: string; + attributes?: NoteSavedObjectAttributes; + }> { + const { openModal, savedObjectsClient } = await this.getStartServices(); + return new Promise<{ + savedObjectId?: string; + attributes?: NoteSavedObjectAttributes; + }>(resolve => { + const onSave = async (attributes: NoteSavedObjectAttributes, includeInLibrary: boolean) => { + if (includeInLibrary) { + const savedItem = await savedObjectsClient.create(NOTE_SAVED_OBJECT, attributes); + resolve({ savedObjectId: savedItem.id }); + } else { + resolve({ attributes }); + } + }; + const overlay = openModal( + toMountPoint( + { + onSave(attributes, includeInLibrary); + overlay.close(); + }} + /> + ) + ); + }); + } +} diff --git a/examples/embeddable_examples/public/plugin.ts b/examples/embeddable_examples/public/plugin.ts index 31a3037332dda..28479dcf6f07c 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -17,28 +17,66 @@ * under the License. */ -import { EmbeddableSetup, EmbeddableStart } from '../../../src/plugins/embeddable/public'; +import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; +import { + EmbeddableSetup, + EmbeddableStart, + CONTEXT_MENU_TRIGGER, +} from '../../../src/plugins/embeddable/public'; import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public'; import { HelloWorldEmbeddableFactory, HELLO_WORLD_EMBEDDABLE } from './hello_world'; import { TODO_EMBEDDABLE, TodoEmbeddableFactory, TodoInput, TodoOutput } from './todo'; -import { MULTI_TASK_TODO_EMBEDDABLE, MultiTaskTodoEmbeddableFactory } from './multi_task_todo'; +import { + MULTI_TASK_TODO_EMBEDDABLE, + MultiTaskTodoEmbeddableFactory, + MultiTaskTodoInput, + MultiTaskTodoOutput, +} from './multi_task_todo'; import { SEARCHABLE_LIST_CONTAINER, SearchableListContainerFactory, } from './searchable_list_container'; import { LIST_CONTAINER, ListContainerFactory } from './list_container'; +import { createSampleData } from './create_sample_data'; +import { TodoRefInput, TodoRefOutput, TODO_REF_EMBEDDABLE } from './todo/todo_ref_embeddable'; +import { TodoRefEmbeddableFactory } from './todo/todo_ref_embeddable_factory'; +import { + ACTION_EDIT_NOTE, + NoteEmbeddable, + NOTE_EMBEDDABLE, + NoteEmbeddableInput, + NoteEmbeddableOutput, + NoteEmbeddableFactory, + createEditNoteAction, +} from './note'; export interface EmbeddableExamplesSetupDependencies { embeddable: EmbeddableSetup; + uiActions: UiActionsStart; } export interface EmbeddableExamplesStartDependencies { embeddable: EmbeddableStart; } +export interface EmbeddableExamplesStart { + createSampleData: (overwrite: boolean) => Promise; +} + +declare module '../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_EDIT_NOTE]: { embeddable: NoteEmbeddable }; + } +} + export class EmbeddableExamplesPlugin implements - Plugin { + Plugin< + void, + EmbeddableExamplesStart, + EmbeddableExamplesSetupDependencies, + EmbeddableExamplesStartDependencies + > { public setup( core: CoreSetup, deps: EmbeddableExamplesSetupDependencies @@ -48,7 +86,7 @@ export class EmbeddableExamplesPlugin new HelloWorldEmbeddableFactory() ); - deps.embeddable.registerEmbeddableFactory( + deps.embeddable.registerEmbeddableFactory( MULTI_TASK_TODO_EMBEDDABLE, new MultiTaskTodoEmbeddableFactory() ); @@ -73,9 +111,38 @@ export class EmbeddableExamplesPlugin openModal: (await core.getStartServices())[0].overlays.openModal, })) ); + + deps.embeddable.registerEmbeddableFactory( + TODO_REF_EMBEDDABLE, + new TodoRefEmbeddableFactory(async () => ({ + savedObjectsClient: (await core.getStartServices())[0].savedObjects.client, + getEmbeddableFactory: (await core.getStartServices())[1].embeddable.getEmbeddableFactory, + })) + ); + + deps.embeddable.registerEmbeddableFactory( + NOTE_EMBEDDABLE, + new NoteEmbeddableFactory(async () => ({ + savedObjectsClient: (await core.getStartServices())[0].savedObjects.client, + getEmbeddableFactory: (await core.getStartServices())[1].embeddable.getEmbeddableFactory, + openModal: (await core.getStartServices())[0].overlays.openModal, + })) + ); + + const editNoteAction = createEditNoteAction(async () => ({ + openModal: (await core.getStartServices())[0].overlays.openModal, + savedObjectsClient: (await core.getStartServices())[0].savedObjects.client, + getEmbeddableFactory: (await core.getStartServices())[1].embeddable.getEmbeddableFactory, + })); + deps.uiActions.registerAction(editNoteAction); + deps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, editNoteAction); } - public start(core: CoreStart, deps: EmbeddableExamplesStartDependencies) {} + public start(core: CoreStart, deps: EmbeddableExamplesStartDependencies) { + return { + createSampleData: () => createSampleData(core.savedObjects.client), + }; + } public stop() {} } diff --git a/examples/embeddable_examples/public/searchable_list_container/index.ts b/examples/embeddable_examples/public/searchable_list_container/index.ts index c422fdba5835d..4f34187736399 100644 --- a/examples/embeddable_examples/public/searchable_list_container/index.ts +++ b/examples/embeddable_examples/public/searchable_list_container/index.ts @@ -17,5 +17,9 @@ * under the License. */ -export { SearchableListContainer, SEARCHABLE_LIST_CONTAINER } from './searchable_list_container'; +export { + SearchableListContainer, + SEARCHABLE_LIST_CONTAINER, + SearchableContainerInput, +} from './searchable_list_container'; export { SearchableListContainerFactory } from './searchable_list_container_factory'; diff --git a/examples/embeddable_examples/public/todo/README.md b/examples/embeddable_examples/public/todo/README.md new file mode 100644 index 0000000000000..e782511f093b3 --- /dev/null +++ b/examples/embeddable_examples/public/todo/README.md @@ -0,0 +1,43 @@ +There are two examples in here: + - TodoEmbeddable + - TodoRefEmbeddable + + # TodoEmbeddable + + The first example you should review is the HelloWorldEmbeddable. That is as basic an embeddable as you can get. + This embeddable is the next step up - an embeddable that renders dynamic input data. The data is simple: + - a required task string + - an optional title + - an optional icon string + - an optional search string + +It also has output data, which is `hasMatch` - whether or not the search string has matched any input data. + +`hasMatch` is a better fit for output data than input data, because it's state that is _derived_ from input data. + +For example, if it was input data, you could create a TodoEmbeddable with input like this: + +```ts + todoEmbeddableFactory.create({ task: 'take out the garabage', search: 'garbage', hasMatch: false }); +``` + +That's wrong because there is actually a match from the search string inside the task. + +The TodoEmbeddable component itself doesn't do anything with the `hasMatch` variable other than set it, but +if you check out `SearchableListContainer`, you can see an example where this output data is being used. + +## TodoRefEmbeddable + +This is an example of an embeddable based off of a saved object. The input is just the `savedObjectId` and +the `search` string. It has even more output parameters, and this time, it does read it's own output parameters in +order to calculate `hasMatch`. + +Output: +```ts +{ + hasMatch: boolean, + savedAttributes?: TodoSavedAttributes +} +``` + +`savedAttributes` is optional because it's possible a TodoSavedObject could not be found with the given savedObjectId. diff --git a/examples/embeddable_examples/public/todo/todo_ref_component.tsx b/examples/embeddable_examples/public/todo/todo_ref_component.tsx new file mode 100644 index 0000000000000..8e0a17be1ec72 --- /dev/null +++ b/examples/embeddable_examples/public/todo/todo_ref_component.tsx @@ -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 React from 'react'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import { EuiText } from '@elastic/eui'; +import { EuiAvatar } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import { EuiFlexGrid } from '@elastic/eui'; +import { withEmbeddableSubscription } from '../../../../src/plugins/embeddable/public'; +import { TodoRefInput, TodoRefOutput, TodoRefEmbeddable } from './todo_ref_embeddable'; + +interface Props { + embeddable: TodoRefEmbeddable; + input: TodoRefInput; + output: TodoRefOutput; +} + +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 + ) + ); +} + +export function TodoRefEmbeddableComponentInner({ + input: { search }, + output: { savedAttributes }, +}: Props) { + const icon = savedAttributes?.icon; + const title = savedAttributes?.title; + const task = savedAttributes?.task; + return ( + + + {icon ? ( + + ) : ( + + )} + + + + + +

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

+
+
+ + {wrapSearchTerms(task, search)} + +
+
+
+ ); +} + +export const TodoRefEmbeddableComponent = withEmbeddableSubscription< + TodoRefInput, + TodoRefOutput, + TodoRefEmbeddable +>(TodoRefEmbeddableComponentInner); diff --git a/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx b/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx new file mode 100644 index 0000000000000..cf1b7c3455c0f --- /dev/null +++ b/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx @@ -0,0 +1,153 @@ +/* + * 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 { Subscription } from 'rxjs'; +import { SavedObjectsClientContract } from 'kibana/public'; +import { TodoSavedObjectAttributes } from '../../common'; +import { + Embeddable, + IContainer, + EmbeddableOutput, + SavedObjectEmbeddableInput, +} from '../../../../src/plugins/embeddable/public'; +import { TodoRefEmbeddableComponent } from './todo_ref_component'; + +// Notice this is not the same value as the 'todo' saved object type. Many of our +// cases in prod today use the same value, but this is unnecessary. +export const TODO_REF_EMBEDDABLE = 'TODO_REF_EMBEDDABLE'; + +export interface TodoRefInput extends SavedObjectEmbeddableInput { + /** + * Optional search string which will be used to highlight search terms as + * well as calculate `output.hasMatch`. + */ + search?: string; +} + +export interface TodoRefOutput 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 Todo Saved Object that matches + * `input.savedObjectId`. If the id is invalid, this may be undefined. + */ + savedAttributes?: TodoSavedObjectAttributes; +} + +/** + * 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?: TodoSavedObjectAttributes): boolean { + if (!search) return true; + if (!savedAttributes) return false; + return Boolean( + (savedAttributes.task && savedAttributes.task.match(search)) || + (savedAttributes.title && savedAttributes.title.match(search)) + ); +} + +/** + * This is an example of an embeddable that is backed by a saved object. It's essentially the + * same as `TodoEmbeddable` but that is "by value", while this is "by reference". + */ +export class TodoRefEmbeddable extends Embeddable { + public readonly type = TODO_REF_EMBEDDABLE; + private subscription: Subscription; + private node?: HTMLElement; + private savedObjectsClient: SavedObjectsClientContract; + private savedObjectId?: string; + + constructor( + initialInput: TodoRefInput, + { + parent, + savedObjectsClient, + }: { + parent?: IContainer; + savedObjectsClient: SavedObjectsClientContract; + } + ) { + super(initialInput, { hasMatch: false }, parent); + this.savedObjectsClient = savedObjectsClient; + + 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: TodoSavedObjectAttributes | 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 todoSavedObject = await this.savedObjectsClient.get( + 'todo', + this.input.savedObjectId + ); + savedAttributes = todoSavedObject?.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, + }); + }); + } + + public render(node: HTMLElement) { + this.node = node; + if (this.node) { + ReactDOM.unmountComponentAtNode(this.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 todoSavedObject = await this.savedObjectsClient.get( + 'todo', + this.input.savedObjectId + ); + const savedAttributes = todoSavedObject?.attributes; + this.updateOutput({ + hasMatch: getHasMatch(this.input.search, savedAttributes), + savedAttributes, + }); + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx b/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx new file mode 100644 index 0000000000000..1059c49a407a0 --- /dev/null +++ b/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx @@ -0,0 +1,83 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/public'; +import { TodoSavedObjectAttributes } from '../../common'; +import { + IContainer, + EmbeddableStart, + ErrorEmbeddable, + EmbeddableFactoryDefinition, +} from '../../../../src/plugins/embeddable/public'; +import { + TodoRefEmbeddable, + TODO_REF_EMBEDDABLE, + TodoRefInput, + TodoRefOutput, +} from './todo_ref_embeddable'; + +interface StartServices { + getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']; + savedObjectsClient: SavedObjectsClientContract; +} + +export class TodoRefEmbeddableFactory + implements + EmbeddableFactoryDefinition< + TodoRefInput, + TodoRefOutput, + TodoRefEmbeddable, + TodoSavedObjectAttributes + > { + public readonly type = TODO_REF_EMBEDDABLE; + public readonly savedObjectMetaData = { + name: 'Todo', + includeFields: ['task', 'icon', 'title'], + type: 'todo', + getIconForSavedObject: () => 'pencil', + }; + + constructor(private getStartServices: () => Promise) {} + + public async isEditable() { + return true; + } + + public createFromSavedObject = ( + savedObjectId: string, + input: Partial & { id: string }, + parent?: IContainer + ): Promise => { + return this.create({ ...input, savedObjectId }, parent); + }; + + public async create(input: TodoRefInput, parent?: IContainer) { + const { savedObjectsClient } = await this.getStartServices(); + return new TodoRefEmbeddable(input, { + parent, + savedObjectsClient, + }); + } + + public getDisplayName() { + return i18n.translate('embeddableExamples.todo.displayName', { + defaultMessage: 'Todo item', + }); + } +} diff --git a/examples/embeddable_examples/public/types.ts b/examples/embeddable_examples/public/types.ts new file mode 100644 index 0000000000000..22fddd900e646 --- /dev/null +++ b/examples/embeddable_examples/public/types.ts @@ -0,0 +1,26 @@ +/* + * 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 { SavedObjectAttributes } from 'kibana/public'; + +export interface TodoSavedObjectAttributes extends SavedObjectAttributes { + task: string; + icon?: string; + title?: string; +} diff --git a/examples/embeddable_examples/server/index.ts b/examples/embeddable_examples/server/index.ts new file mode 100644 index 0000000000000..9ddc3bc2cf715 --- /dev/null +++ b/examples/embeddable_examples/server/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { PluginInitializer } from 'kibana/server'; + +import { EmbeddableExamplesPlugin } from './plugin'; + +export const plugin: PluginInitializer = () => new EmbeddableExamplesPlugin(); diff --git a/examples/embeddable_examples/server/note_saved_object.ts b/examples/embeddable_examples/server/note_saved_object.ts new file mode 100644 index 0000000000000..0f4cb6348aed7 --- /dev/null +++ b/examples/embeddable_examples/server/note_saved_object.ts @@ -0,0 +1,41 @@ +/* + * 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 { SavedObjectsType } from 'kibana/server'; +import { NOTE_SAVED_OBJECT } from '../common'; + +export const noteSavedObject: SavedObjectsType = { + name: NOTE_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + message: { + type: 'text', + }, + to: { + type: 'keyword', + }, + from: { + type: 'keyword', + }, + }, + }, + migrations: {}, +}; diff --git a/examples/embeddable_examples/server/plugin.ts b/examples/embeddable_examples/server/plugin.ts new file mode 100644 index 0000000000000..d9f72f54d9e86 --- /dev/null +++ b/examples/embeddable_examples/server/plugin.ts @@ -0,0 +1,33 @@ +/* + * 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 { Plugin, CoreSetup, CoreStart } from 'kibana/server'; +import { todoSavedObject } from './todo_saved_object'; +import { noteSavedObject } from './note_saved_object'; + +export class EmbeddableExamplesPlugin implements Plugin { + public setup(core: CoreSetup) { + core.savedObjects.registerType(todoSavedObject); + core.savedObjects.registerType(noteSavedObject); + } + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/examples/embeddable_examples/server/todo_saved_object.ts b/examples/embeddable_examples/server/todo_saved_object.ts new file mode 100644 index 0000000000000..0f67c53cfa3e1 --- /dev/null +++ b/examples/embeddable_examples/server/todo_saved_object.ts @@ -0,0 +1,40 @@ +/* + * 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 { SavedObjectsType } from 'kibana/server'; + +export const todoSavedObject: SavedObjectsType = { + name: 'todo', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + title: { + type: 'keyword', + }, + task: { + type: 'text', + }, + icon: { + type: 'keyword', + }, + }, + }, + migrations: {}, +}; diff --git a/examples/embeddable_examples/tsconfig.json b/examples/embeddable_examples/tsconfig.json index 091130487791b..7fa03739119b4 100644 --- a/examples/embeddable_examples/tsconfig.json +++ b/examples/embeddable_examples/tsconfig.json @@ -6,6 +6,7 @@ }, "include": [ "index.ts", + "common/**/*.ts", "public/**/*.ts", "public/**/*.tsx", "server/**/*.ts", diff --git a/examples/embeddable_explorer/public/app.tsx b/examples/embeddable_explorer/public/app.tsx index e18012b4b3d80..1620e0c014f62 100644 --- a/examples/embeddable_explorer/public/app.tsx +++ b/examples/embeddable_explorer/public/app.tsx @@ -23,21 +23,14 @@ import { BrowserRouter as Router, Route, withRouter, RouteComponentProps } from import { EuiPage, EuiPageSideBar, EuiSideNav } from '@elastic/eui'; +import { EmbeddableExamplesStart } from 'examples/embeddable_examples/public/plugin'; import { EmbeddableStart } from '../../../src/plugins/embeddable/public'; -import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; -import { Start as InspectorStartContract } from '../../../src/plugins/inspector/public'; -import { - AppMountContext, - AppMountParameters, - CoreStart, - SavedObjectsStart, - IUiSettingsClient, - OverlayStart, -} from '../../../src/core/public'; +import { AppMountContext, AppMountParameters, CoreStart } from '../../../src/core/public'; import { HelloWorldEmbeddableExample } from './hello_world_embeddable_example'; import { TodoEmbeddableExample } from './todo_embeddable_example'; import { ListContainerExample } from './list_container_example'; import { EmbeddablePanelExample } from './embeddable_panel_example'; +import { SavedObjectEmbeddableExample } from './saved_object_embeddable_example'; interface PageDef { title: string; @@ -75,24 +68,14 @@ interface Props { basename: string; navigateToApp: CoreStart['application']['navigateToApp']; embeddableApi: EmbeddableStart; - uiActionsApi: UiActionsStart; - overlays: OverlayStart; - notifications: CoreStart['notifications']; - inspector: InspectorStartContract; - savedObject: SavedObjectsStart; - uiSettingsClient: IUiSettingsClient; + createSampleData: EmbeddableExamplesStart['createSampleData']; } const EmbeddableExplorerApp = ({ basename, navigateToApp, embeddableApi, - inspector, - uiSettingsClient, - savedObject, - overlays, - uiActionsApi, - notifications, + createSampleData, }: Props) => { const pages: PageDef[] = [ { @@ -119,6 +102,16 @@ const EmbeddableExplorerApp = ({ id: 'embeddablePanelExamplae', component: , }, + { + title: 'Embeddables backed by saved objects', + id: 'savedObjectSection', + component: ( + + ), + }, ]; const routes = pages.map((page, i) => ( diff --git a/examples/embeddable_explorer/public/plugin.tsx b/examples/embeddable_explorer/public/plugin.tsx index 7c75b108d9912..d3c9ec36a8c7b 100644 --- a/examples/embeddable_explorer/public/plugin.tsx +++ b/examples/embeddable_explorer/public/plugin.tsx @@ -18,6 +18,7 @@ */ import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public'; +import { EmbeddableExamplesStart } from 'examples/embeddable_examples/public/plugin'; import { UiActionsService } from '../../../src/plugins/ui_actions/public'; import { EmbeddableStart } from '../../../src/plugins/embeddable/public'; import { Start as InspectorStart } from '../../../src/plugins/inspector/public'; @@ -26,6 +27,7 @@ interface StartDeps { uiActions: UiActionsService; embeddable: EmbeddableStart; inspector: InspectorStart; + embeddableExamples: EmbeddableExamplesStart; } export class EmbeddableExplorerPlugin implements Plugin { @@ -38,14 +40,9 @@ export class EmbeddableExplorerPlugin implements Plugin(undefined); + const [embeddable, setEmbeddable] = useState(undefined); + const [loading, setLoading] = useState(false); + + const ref = useRef(false); + + useEffect(() => { + ref.current = true; + const loadData = async () => { + try { + await createSampleData(false); + } catch (e) { + // eslint-disable-next-line + console.log(e); + } + + if (!container) { + const factory = embeddableServices.getEmbeddableFactory< + SearchableContainerInput, + ContainerOutput, + SearchableListContainer + >(SEARCHABLE_LIST_CONTAINER); + const promise = factory?.create(searchableInput); + if (promise) { + promise.then(e => { + if (ref.current) { + setContainer(e); + } + }); + } + } + + if (!embeddable) { + const factory = embeddableServices.getEmbeddableFactory< + NoteEmbeddableInput, + NoteEmbeddableOutput, + NoteEmbeddable + >(NOTE_EMBEDDABLE); + const promise = factory?.create({ + savedObjectId: 'sample-note-saved-object', + id: '123', + }); + if (promise) { + promise.then(e => { + if (ref.current) { + setEmbeddable(e); + } + }); + } + } + }; + loadData(); + return () => { + ref.current = false; + }; + }); + + const onCreateSampleDataClick = async () => { + setLoading(true); + await createSampleData(true); + if (embeddable) embeddable.reload(); + if (container) container.reload(); + setLoading(false); + }; + + return ( + + + + +

Saved object embeddable example

+
+
+
+ + + + + {loading ? : 'Load sample data'} + +

Click load sample data first to get these examples to show up.

+

+ This example showcases an embeddable that is backed by a saved object. Click the + context menu and click Edit action in the context menu to see how to edit and update + the saved object. Refreshing the page after editing this embeddable will preserve your + edits. +

+
+ + + {embeddable ? ( + + ) : ( + Loading... + )} + + +

+ This next example showcases a container embeddable that has children that can + optionally be backed by a saved object. The first child is linked to a saved object. + The second child has input that does not include a saved object id, so it is by value. + Click the context menu and you can see how to turn the by value version into a saved + object. +

+
+ + {container ? ( + + ) : ( + Loading... + )} +
+
+
+ ); +} diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx index d182deb813e11..625779385094c 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_flyout.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; import { NotificationsStart, Toast } from 'src/core/public'; + import { DashboardPanelState } from '../embeddable'; import { IContainer, @@ -28,6 +29,7 @@ import { EmbeddableInput, EmbeddableOutput, EmbeddableStart, + SavedObjectEmbeddableInput, } from '../../embeddable_plugin'; interface Props { @@ -66,7 +68,7 @@ export class ReplacePanelFlyout extends React.Component { }); }; - public onReplacePanel = async (id: string, type: string, name: string) => { + public onReplacePanel = async (savedObjectId: string, type: string, name: string) => { const originalPanels = this.props.container.getInput().panels; const filteredPanels = { ...originalPanels }; @@ -76,7 +78,9 @@ export class ReplacePanelFlyout extends React.Component { const nny = (filteredPanels[this.props.panelToRemove.id] as DashboardPanelState).gridData.y; // add the new view - const newObj = await this.props.container.addSavedObjectEmbeddable(type, id); + const newObj = await this.props.container.addNewEmbeddable(type, { + savedObjectId, + }); const finalPanels = _.cloneDeep(this.props.container.getInput().panels); (finalPanels[newObj.id] as DashboardPanelState).gridData.w = nnw; diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index d1c7d2d9eba3e..71b98a984bb70 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -53,6 +53,7 @@ import { isErrorEmbeddable, openAddPanelFlyout, ViewMode, + SavedObjectEmbeddableInput, ContainerOutput, } from '../../../embeddable/public'; import { NavAction, SavedDashboardPanel } from '../types'; @@ -386,7 +387,7 @@ export class DashboardAppController { if ($routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]) { const type = $routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]; const id = $routeParams[DashboardConstants.ADD_EMBEDDABLE_ID]; - container.addSavedObjectEmbeddable(type, id); + container.addNewEmbeddable(type, { savedObjectId: id }); removeQueryParam(history, DashboardConstants.ADD_EMBEDDABLE_TYPE); removeQueryParam(history, DashboardConstants.ADD_EMBEDDABLE_ID); } diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index 7f3a2913daac3..50089f1f061f4 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -55,7 +55,7 @@ export interface DashboardContainerInput extends ContainerInput { description?: string; isFullScreenMode: boolean; panels: { - [panelId: string]: DashboardPanelState; + [panelId: string]: DashboardPanelState; }; isEmptyState?: boolean; } diff --git a/src/plugins/dashboard/public/application/embeddable/types.ts b/src/plugins/dashboard/public/application/embeddable/types.ts index 3df305b0d7f1b..843fcd2602cc1 100644 --- a/src/plugins/dashboard/public/application/embeddable/types.ts +++ b/src/plugins/dashboard/public/application/embeddable/types.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PanelState, EmbeddableInput } from '../../embeddable_plugin'; +import { PanelState, EmbeddableInput, SavedObjectEmbeddableInput } from '../../embeddable_plugin'; export type PanelId = string; export type SavedObjectId = string; @@ -28,7 +28,8 @@ export interface GridData { i: string; } -export interface DashboardPanelState - extends PanelState { +export interface DashboardPanelState< + TEmbeddableInput extends EmbeddableInput | SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +> extends PanelState { readonly gridData: GridData; } diff --git a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts index 447563bbfbcfa..25ce203332422 100644 --- a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts +++ b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.test.ts @@ -23,11 +23,6 @@ import { } from './embeddable_saved_object_converters'; import { SavedDashboardPanel } from '../../types'; import { DashboardPanelState } from '../embeddable'; -import { EmbeddableInput } from 'src/plugins/embeddable/public'; - -interface CustomInput extends EmbeddableInput { - something: string; -} test('convertSavedDashboardPanelToPanelState', () => { const savedDashboardPanel: SavedDashboardPanel = { @@ -58,8 +53,8 @@ test('convertSavedDashboardPanelToPanelState', () => { explicitInput: { something: 'hi!', id: '123', + savedObjectId: 'savedObjectId', }, - savedObjectId: 'savedObjectId', type: 'search', }); }); @@ -86,7 +81,7 @@ test('convertSavedDashboardPanelToPanelState does not include undefined id', () }); test('convertPanelStateToSavedDashboardPanel', () => { - const dashboardPanel: DashboardPanelState = { + const dashboardPanel: DashboardPanelState = { gridData: { x: 0, y: 0, @@ -94,10 +89,10 @@ test('convertPanelStateToSavedDashboardPanel', () => { w: 15, i: '123', }, - savedObjectId: 'savedObjectId', explicitInput: { something: 'hi!', id: '123', + savedObjectId: 'savedObjectId', }, type: 'search', }; @@ -121,7 +116,7 @@ test('convertPanelStateToSavedDashboardPanel', () => { }); test('convertPanelStateToSavedDashboardPanel will not add an undefined id when not needed', () => { - const dashboardPanel: DashboardPanelState = { + const dashboardPanel: DashboardPanelState = { gridData: { x: 0, y: 0, diff --git a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts index 01cd55df0d8e9..b19ef31ccb9ac 100644 --- a/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts +++ b/src/plugins/dashboard/public/application/lib/embeddable_saved_object_converters.ts @@ -19,6 +19,7 @@ import { omit } from 'lodash'; import { SavedDashboardPanel } from '../../types'; import { DashboardPanelState } from '../embeddable'; +import { SavedObjectEmbeddableInput } from '../../embeddable_plugin'; export function convertSavedDashboardPanelToPanelState( savedDashboardPanel: SavedDashboardPanel @@ -26,9 +27,9 @@ export function convertSavedDashboardPanelToPanelState( return { type: savedDashboardPanel.type, gridData: savedDashboardPanel.gridData, - ...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }), explicitInput: { id: savedDashboardPanel.panelIndex, + ...(savedDashboardPanel.id !== undefined && { savedObjectId: savedDashboardPanel.id }), ...(savedDashboardPanel.title !== undefined && { title: savedDashboardPanel.title }), ...savedDashboardPanel.embeddableConfig, }, @@ -42,13 +43,14 @@ export function convertPanelStateToSavedDashboardPanel( const customTitle: string | undefined = panelState.explicitInput.title ? (panelState.explicitInput.title as string) : undefined; + const savedObjectId = (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId; return { version, type: panelState.type, gridData: panelState.gridData, panelIndex: panelState.explicitInput.id, - embeddableConfig: omit(panelState.explicitInput, 'id'), + embeddableConfig: omit(panelState.explicitInput, ['id', 'savedObjectId']), ...(customTitle && { title: customTitle }), - ...(panelState.savedObjectId !== undefined && { id: panelState.savedObjectId }), + ...(savedObjectId !== undefined && { id: savedObjectId }), }; } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 23275fbe8e8f0..3bf0aba0836a6 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -61,6 +61,9 @@ export { PropertySpec, ViewMode, withEmbeddableSubscription, + SavedObjectEmbeddableInput, + isSavedObjectEmbeddableInput, + SavedObjectEmbeddableFactoryDefinition, } from './lib'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 4ab74e1883917..ffbe75f66b581 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -30,6 +30,7 @@ import { import { IContainer, ContainerInput, ContainerOutput, PanelState } from './i_container'; import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors'; import { EmbeddableStart } from '../../plugin'; +import { isSavedObjectEmbeddableInput } from '../embeddables/saved_object_embeddable'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -98,17 +99,6 @@ export abstract class Container< return this.createAndSaveEmbeddable(type, panelState); } - public async addSavedObjectEmbeddable< - TEmbeddableInput extends EmbeddableInput = EmbeddableInput, - TEmbeddable extends IEmbeddable = IEmbeddable - >(type: string, savedObjectId: string): Promise { - const factory = this.getFactory(type) as EmbeddableFactory; - const panelState = this.createNewPanelState(factory); - panelState.savedObjectId = savedObjectId; - - return this.createAndSaveEmbeddable(type, panelState); - } - public removeEmbeddable(embeddableId: string) { // Just a shortcut for removing the panel from input state, all internal state will get cleaned up naturally // by the listener. @@ -304,8 +294,10 @@ export abstract class Container< throw new EmbeddableFactoryNotFoundError(panel.type); } - embeddable = panel.savedObjectId - ? await factory.createFromSavedObject(panel.savedObjectId, inputForChild, this) + // TODO: lets get rid of this distinction with factories, I don't think it will be needed + // anymore after this change. + embeddable = isSavedObjectEmbeddableInput(inputForChild) + ? await factory.createFromSavedObject(inputForChild.savedObjectId, inputForChild, this) : await factory.create(inputForChild, this); } catch (e) { embeddable = new ErrorEmbeddable(e, { id: panel.explicitInput.id }, this); @@ -323,23 +315,6 @@ export abstract class Container< return; } - if (embeddable.getOutput().savedObjectId) { - this.updateInput({ - panels: { - ...this.input.panels, - [panel.explicitInput.id]: { - ...this.input.panels[panel.explicitInput.id], - ...(embeddable.getOutput().savedObjectId - ? { savedObjectId: embeddable.getOutput().savedObjectId } - : undefined), - explicitInput: { - ...this.input.panels[panel.explicitInput.id].explicitInput, - }, - }, - }, - } as Partial); - } - this.children[embeddable.id] = embeddable; this.updateOutput({ embeddableLoaded: { diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index 7da5f92ec92c1..31a7cd4f2e559 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -25,9 +25,7 @@ import { IEmbeddable, } from '../embeddables'; -export interface PanelState { - savedObjectId?: string; - +export interface PanelState { // The type of embeddable in this panel. Will be used to find the factory in which to // load the embeddable. type: string; @@ -89,17 +87,6 @@ export interface IContainer< */ removeEmbeddable(embeddableId: string): void; - /** - * Adds a new embeddable that is backed off of a saved object. - */ - addSavedObjectEmbeddable< - EEI extends EmbeddableInput = EmbeddableInput, - E extends Embeddable = Embeddable - >( - type: string, - savedObjectId: string - ): Promise; - /** * Adds a new embeddable to the container. `explicitInput` may partially specify the required embeddable input, * but the remainder must come from inherited container state. diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 6345c34b0dda2..c0fb98d2559db 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -26,6 +26,11 @@ import { TriggerContextMapping } from '../../../../ui_actions/public'; export interface EmbeddableInput { viewMode?: ViewMode; title?: string; + /** + * Note this is not a saved object id. It is used to uniquely identify this + * Embeddable instance from others (e.g. inside a container). It's possible to + * have two Embeddables where everything else is the same but the id. + */ id: string; lastReloadRequestTime?: number; hidePanelTitles?: boolean; @@ -44,6 +49,8 @@ export interface EmbeddableInput { * Whether this embeddable should not execute triggers. */ disableTriggers?: boolean; + + [key: string]: unknown; } export interface EmbeddableOutput { diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 4d6ab37a50c05..a9b1225de1c42 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -25,3 +25,5 @@ export { ErrorEmbeddable, isErrorEmbeddable } from './error_embeddable'; export { withEmbeddableSubscription } from './with_subscription'; export { EmbeddableFactoryRenderer } from './embeddable_factory_renderer'; export { EmbeddableRoot } from './embeddable_root'; +export * from './saved_object_embeddable'; +export * from './saved_object_embeddable_factory'; diff --git a/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts new file mode 100644 index 0000000000000..021a306878495 --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.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 { EmbeddableInput, EmbeddableOutput } from '..'; + +export interface SavedObjectEmbeddableInput extends EmbeddableInput { + savedObjectId: string; +} + +export type SavedObjectEmbeddableOutput = EmbeddableOutput; + +export function isSavedObjectEmbeddableInput( + input: EmbeddableInput | SavedObjectEmbeddableInput +): input is SavedObjectEmbeddableInput { + return (input as SavedObjectEmbeddableInput).savedObjectId !== undefined; +} diff --git a/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable_factory.ts new file mode 100644 index 0000000000000..7b81af3c194e1 --- /dev/null +++ b/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable_factory.ts @@ -0,0 +1,68 @@ +/* + * 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 { SavedObjectAttributes } from 'kibana/public'; +import { SavedObjectMetaData } from 'src/plugins/saved_objects/public'; +import { SavedObjectEmbeddableInput, SavedObjectEmbeddableOutput } from './saved_object_embeddable'; +import { EmbeddableFactory } from './embeddable_factory'; +import { IEmbeddable } from './i_embeddable'; +import { EmbeddableFactoryDefinition } from './embeddable_factory_definition'; +import { IContainer, ErrorEmbeddable } from '..'; + +export function isSavedObjectEmbeddableFactory( + factory: EmbeddableFactory | SavedObjectEmbeddableFactory +): factory is SavedObjectEmbeddableFactory { + return (factory as SavedObjectEmbeddableFactory).savedObjectMetaData !== undefined; +} + +export interface SavedObjectEmbeddableFactory< + I extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput, + O extends SavedObjectEmbeddableOutput = SavedObjectEmbeddableOutput, + E extends IEmbeddable = IEmbeddable, + SA extends SavedObjectAttributes = SavedObjectAttributes +> extends EmbeddableFactory { + /** + * Creates a new embeddable instance based off the saved object id. + * @param savedObjectId + * @param input - some input may come from a parent, or user, if it's not stored with the saved object. For example, the time + * range of the parent container. + * @param parent + */ + createFromSavedObject( + savedObjectId: string, + input: Partial, + parent?: IContainer + ): Promise; + + savedObjectMetaData: SavedObjectMetaData; +} + +export type SavedObjectEmbeddableFactoryDefinition< + I extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput, + O extends SavedObjectEmbeddableOutput = SavedObjectEmbeddableOutput, + E extends IEmbeddable = IEmbeddable, + SA extends SavedObjectAttributes = SavedObjectAttributes +> = + // Required parameters + Pick< + SavedObjectEmbeddableFactory, + | 'savedObjectMetaData' + // TODO: get rid of this function: + | 'createFromSavedObject' + > & + EmbeddableFactoryDefinition; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx index 50d8bcef8506c..5bf3f69a95c30 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx @@ -33,6 +33,7 @@ import { EmbeddableStart } from 'src/plugins/embeddable/public'; import { IContainer } from '../../../../containers'; import { EmbeddableFactoryNotFoundError } from '../../../../errors'; import { SavedObjectFinderCreateNew } from './saved_object_finder_create_new'; +import { SavedObjectEmbeddableInput } from '../../../../embeddables'; interface Props { onClose: () => void; @@ -98,8 +99,18 @@ export class AddPanelFlyout extends React.Component { } }; - public onAddPanel = async (id: string, type: string, name: string) => { - this.props.container.addSavedObjectEmbeddable(type, id); + public onAddPanel = async (savedObjectId: string, savedObjectType: string, name: string) => { + const factoryForSavedObjectType = [...this.props.getAllFactories()].find( + factory => factory.savedObjectMetaData && factory.savedObjectMetaData.type === savedObjectType + ); + if (!factoryForSavedObjectType) { + throw new EmbeddableFactoryNotFoundError(savedObjectType); + } + + this.props.container.addNewEmbeddable( + factoryForSavedObjectType.type, + { savedObjectId } + ); this.showToast(name); }; diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index 87076399465d3..1aae43550ec6f 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -641,8 +641,7 @@ test('container stores ErrorEmbeddables when a saved object cannot be found', as panels: { '123': { type: 'vis', - explicitInput: { id: '123' }, - savedObjectId: '456', + explicitInput: { id: '123', savedObjectId: '456' }, }, }, viewMode: ViewMode.EDIT, @@ -663,8 +662,7 @@ test('ErrorEmbeddables get updated when parent does', async done => { panels: { '123': { type: 'vis', - explicitInput: { id: '123' }, - savedObjectId: '456', + explicitInput: { id: '123', savedObjectId: '456' }, }, }, viewMode: ViewMode.EDIT, diff --git a/test/examples/embeddables/adding_children.ts b/test/examples/embeddables/adding_children.ts index 110b8ce573332..5fe88b5dd33f0 100644 --- a/test/examples/embeddables/adding_children.ts +++ b/test/examples/embeddables/adding_children.ts @@ -23,6 +23,7 @@ import { PluginFunctionalProviderContext } from 'test/plugin_functional/services // eslint-disable-next-line import/no-default-export export default function({ getService }: PluginFunctionalProviderContext) { const testSubjects = getService('testSubjects'); + const flyout = getService('flyout'); describe('creating and adding children', () => { before(async () => { @@ -39,5 +40,15 @@ export default function({ getService }: PluginFunctionalProviderContext) { const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task']); }); + + it('Can add a child backed off a saved object', async () => { + await testSubjects.click('embeddablePanelToggleMenuIcon'); + await testSubjects.click('embeddablePanelAction-ACTION_ADD_PANEL'); + await testSubjects.click('savedObjectTitleGarbage'); + await testSubjects.moveMouseTo('euiFlyoutCloseButton'); + await flyout.ensureClosed('dashboardAddPanel'); + const tasks = await testSubjects.getVisibleTextAll('todoEmbeddableTask'); + expect(tasks).to.eql(['Goes out on Wednesdays!', 'new task', 'Take the garbage out']); + }); }); } diff --git a/test/examples/embeddables/index.ts b/test/examples/embeddables/index.ts index 8ad0961fcc3b6..f3861cb0557ad 100644 --- a/test/examples/embeddables/index.ts +++ b/test/examples/embeddables/index.ts @@ -40,5 +40,6 @@ export default function({ loadTestFile(require.resolve('./todo_embeddable')); loadTestFile(require.resolve('./list_container')); loadTestFile(require.resolve('./adding_children')); + loadTestFile(require.resolve('./saved_object_embeddable')); }); } diff --git a/test/examples/embeddables/saved_object_embeddable.ts b/test/examples/embeddables/saved_object_embeddable.ts new file mode 100644 index 0000000000000..686063f9b009b --- /dev/null +++ b/test/examples/embeddables/saved_object_embeddable.ts @@ -0,0 +1,84 @@ +/* + * 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 expect from '@kbn/expect'; + +import { PluginFunctionalProviderContext } from 'test/plugin_functional/services'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: PluginFunctionalProviderContext) { + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const retry = getService('retry'); + const dashboardPanelActions = getService('dashboardPanelActions'); + + describe('saved object embeddable', () => { + before(async () => { + await testSubjects.click('savedObjectSection'); + await testSubjects.click('reset-sample-data'); + }); + + it('renders', async () => { + await retry.try(async () => { + const texts = await testSubjects.getVisibleTextAll('noteEmbeddableMessage'); + expect(texts).to.eql([ + 'Remember to pick up more bleach.', + 'Remember to pick up more bleach.', + 'How are you feeling today?', + ]); + }); + }); + + it('can be edited when backed by saved object ', async () => { + const header = await dashboardPanelActions.getPanelHeading('A note to Joe'); + await dashboardPanelActions.openContextMenu(header); + await testSubjects.click('embeddablePanelAction-ACTION_EDIT_NOTE'); + await testSubjects.setValue('titleInputField', 'Trash'); + await testSubjects.click('saveTodoEmbeddableByRef'); + + await retry.try(async () => { + const texts = await testSubjects.getVisibleTextAll('todoSoEmbeddableTitle'); + expect(texts).to.eql(['Trash', 'Garbage', 'Take out the trash (By value example)']); + }); + }); + + it('can be edited when not backed by saved object', async () => { + const header = await dashboardPanelActions.getPanelHeading( + 'Take out the trash (By value example)' + ); + await dashboardPanelActions.openContextMenu(header); + await testSubjects.click('embeddablePanelAction-EDIT_TODO_ACTION'); + await testSubjects.setValue('titleInputField', 'Junk'); + await testSubjects.click('saveTodoEmbeddableByValue'); + + await retry.try(async () => { + const texts = await testSubjects.getVisibleTextAll('todoSoEmbeddableTitle'); + expect(texts).to.eql(['Trash', 'Garbage', 'Junk']); + }); + + const url = await browser.getCurrentUrl(); + await browser.get(url.toString(), true); + + await retry.try(async () => { + const texts2 = await testSubjects.getVisibleTextAll('todoSoEmbeddableTitle'); + expect(texts2).to.eql(['Trash', 'Trash', 'Take out the trash (By value example)']); + }); + }); + }); +} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts index bb8951680be35..37ef8cad948cb 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts @@ -47,7 +47,7 @@ export const dashboardInput: DashboardContainerInput = { explicitInput: { id: '2', firstName: 'Sue', - } as any, + }, }, '822cd0f0-ce7c-419d-aeaa-1171cf452745': { gridData: { @@ -60,8 +60,8 @@ export const dashboardInput: DashboardContainerInput = { type: 'visualization', explicitInput: { id: '822cd0f0-ce7c-419d-aeaa-1171cf452745', + savedObjectId: '3fe22200-3dcb-11e8-8660-4d65aa086b3c', }, - savedObjectId: '3fe22200-3dcb-11e8-8660-4d65aa086b3c', }, '66f0a265-7b06-4974-accd-d05f74f7aa82': { gridData: { @@ -74,8 +74,8 @@ export const dashboardInput: DashboardContainerInput = { type: 'visualization', explicitInput: { id: '66f0a265-7b06-4974-accd-d05f74f7aa82', + savedObjectId: '4c0f47e0-3dcd-11e8-8660-4d65aa086b3c', }, - savedObjectId: '4c0f47e0-3dcd-11e8-8660-4d65aa086b3c', }, 'b2861741-40b9-4dc8-b82b-080c6e29a551': { gridData: { @@ -88,8 +88,8 @@ export const dashboardInput: DashboardContainerInput = { type: 'search', explicitInput: { id: 'b2861741-40b9-4dc8-b82b-080c6e29a551', + savedObjectId: 'be5accf0-3dca-11e8-8660-4d65aa086b3c', }, - savedObjectId: 'be5accf0-3dca-11e8-8660-4d65aa086b3c', }, }, isFullScreenMode: false,