diff --git a/examples/embeddable_examples/common/index.ts b/examples/embeddable_examples/common/index.ts index 726420fb9bdc3..0199e75743972 100644 --- a/examples/embeddable_examples/common/index.ts +++ b/examples/embeddable_examples/common/index.ts @@ -18,3 +18,4 @@ */ 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/kibana.json b/examples/embeddable_examples/kibana.json index f446e7f31ac8e..aa1d6b014fa52 100644 --- a/examples/embeddable_examples/kibana.json +++ b/examples/embeddable_examples/kibana.json @@ -5,6 +5,6 @@ "configPath": ["embeddable_examples"], "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 index bd5ade18aa91e..6216a3dbf93e0 100644 --- a/examples/embeddable_examples/public/create_sample_data.ts +++ b/examples/embeddable_examples/public/create_sample_data.ts @@ -18,9 +18,9 @@ */ import { SavedObjectsClientContract } from 'kibana/public'; -import { TodoSavedObjectAttributes } from '../common'; +import { TodoSavedObjectAttributes, NOTE_SAVED_OBJECT, NoteSavedObjectAttributes } from '../common'; -export async function createSampleData(client: SavedObjectsClientContract) { +export async function createSampleData(client: SavedObjectsClientContract, overwrite = true) { await client.create( 'todo', { @@ -30,7 +30,20 @@ export async function createSampleData(client: SavedObjectsClientContract) { }, { id: 'sample-todo-saved-object', - overwrite: true, + 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 4aac63fb52e2b..f98a781cafaaf 100644 --- a/examples/embeddable_examples/public/index.ts +++ b/examples/embeddable_examples/public/index.ts @@ -17,6 +17,10 @@ * under the License. */ +import { EmbeddableExamplesPlugin } from './plugin'; + +export const plugin = () => new EmbeddableExamplesPlugin(); + export { HELLO_WORLD_EMBEDDABLE, HelloWorldEmbeddable, @@ -25,8 +29,11 @@ export { export { ListContainer, LIST_CONTAINER } from './list_container'; export { TODO_EMBEDDABLE } from './todo'; -import { EmbeddableExamplesPlugin } 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 = () => 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 75d34d2d6878f..28479dcf6f07c 100644 --- a/examples/embeddable_examples/public/plugin.ts +++ b/examples/embeddable_examples/public/plugin.ts @@ -17,7 +17,12 @@ * 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'; @@ -35,9 +40,19 @@ 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 { @@ -45,7 +60,13 @@ export interface EmbeddableExamplesStartDependencies { } export interface EmbeddableExamplesStart { - createSampleData: () => Promise; + createSampleData: (overwrite: boolean) => Promise; +} + +declare module '../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_EDIT_NOTE]: { embeddable: NoteEmbeddable }; + } } export class EmbeddableExamplesPlugin @@ -98,6 +119,23 @@ export class EmbeddableExamplesPlugin 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) { 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/todo_ref_component.tsx b/examples/embeddable_examples/public/todo/todo_ref_component.tsx index 47cb5f2a80db6..8e0a17be1ec72 100644 --- a/examples/embeddable_examples/public/todo/todo_ref_component.tsx +++ b/examples/embeddable_examples/public/todo/todo_ref_component.tsx @@ -79,6 +79,8 @@ export function TodoRefEmbeddableComponentInner({ ); } -export const TodoRefEmbeddableComponent = withEmbeddableSubscription( - TodoRefEmbeddableComponentInner -); +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 index 03f7e82de7d01..cf1b7c3455c0f 100644 --- a/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx +++ b/examples/embeddable_examples/public/todo/todo_ref_embeddable.tsx @@ -19,8 +19,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Subscription } from 'rxjs'; -import { TodoSavedObjectAttributes } from 'examples/embeddable_examples/common'; import { SavedObjectsClientContract } from 'kibana/public'; +import { TodoSavedObjectAttributes } from '../../common'; import { Embeddable, IContainer, diff --git a/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx b/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx index bdd8dee13df3f..1059c49a407a0 100644 --- a/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx +++ b/examples/embeddable_examples/public/todo/todo_ref_embeddable_factory.tsx @@ -18,7 +18,7 @@ */ import { i18n } from '@kbn/i18n'; import { SavedObjectsClientContract } from 'kibana/public'; -import { TodoSavedObjectAttributes } from 'examples/embeddable_examples/common'; +import { TodoSavedObjectAttributes } from '../../common'; import { IContainer, EmbeddableStart, 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/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 index d956b834d0d3c..d9f72f54d9e86 100644 --- a/examples/embeddable_examples/server/plugin.ts +++ b/examples/embeddable_examples/server/plugin.ts @@ -19,10 +19,12 @@ 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) {} 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 bba1b1748e207..d3c9ec36a8c7b 100644 --- a/examples/embeddable_explorer/public/plugin.tsx +++ b/examples/embeddable_explorer/public/plugin.tsx @@ -38,17 +38,11 @@ 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/embeddable/types.ts b/src/plugins/dashboard/public/application/embeddable/types.ts index 520fb88a6039f..843fcd2602cc1 100644 --- a/src/plugins/dashboard/public/application/embeddable/types.ts +++ b/src/plugins/dashboard/public/application/embeddable/types.ts @@ -16,8 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { SavedObjectEmbeddableInput } from 'src/plugins/embeddable/public'; -import { PanelState, EmbeddableInput } from '../embeddable_plugin'; +import { PanelState, EmbeddableInput, SavedObjectEmbeddableInput } from '../../embeddable_plugin'; export type PanelId = string; export type SavedObjectId = string; 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 5ec035200546b..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,7 +23,6 @@ import { } from './embeddable_saved_object_converters'; import { SavedDashboardPanel } from '../../types'; import { DashboardPanelState } from '../embeddable'; -import { EmbeddableInput } from 'src/plugins/embeddable/public'; test('convertSavedDashboardPanelToPanelState', () => { const savedDashboardPanel: SavedDashboardPanel = { diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index bdb7bfbddc308..3bf0aba0836a6 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -63,6 +63,7 @@ export { withEmbeddableSubscription, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, + SavedObjectEmbeddableFactoryDefinition, } from './lib'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index ad8b9c35e60be..a9b1225de1c42 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -26,3 +26,4 @@ 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 index 6ca1800b16de4..021a306878495 100644 --- a/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/saved_object_embeddable.ts @@ -17,12 +17,14 @@ * under the License. */ -import { EmbeddableInput } from '..'; +import { EmbeddableInput, EmbeddableOutput } from '..'; export interface SavedObjectEmbeddableInput extends EmbeddableInput { savedObjectId: string; } +export type SavedObjectEmbeddableOutput = EmbeddableOutput; + export function isSavedObjectEmbeddableInput( input: EmbeddableInput | SavedObjectEmbeddableInput ): input is SavedObjectEmbeddableInput { 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/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)']); + }); + }); + }); +}