diff --git a/src/plugins/content_management/opensearch_dashboards.json b/src/plugins/content_management/opensearch_dashboards.json new file mode 100644 index 000000000000..f396ef925cf1 --- /dev/null +++ b/src/plugins/content_management/opensearch_dashboards.json @@ -0,0 +1,9 @@ +{ + "id": "contentManagement", + "version": "opensearchDashboards", + "server": false, + "ui": true, + "requiredPlugins": ["embeddable"], + "optionalPlugins": [], + "requiredBundles": ["embeddable"] +} diff --git a/src/plugins/content_management/public/app.tsx b/src/plugins/content_management/public/app.tsx new file mode 100644 index 000000000000..cd60f8047b8e --- /dev/null +++ b/src/plugins/content_management/public/app.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Route, Router, Switch } from 'react-router-dom'; +import { I18nProvider } from '@osd/i18n/react'; + +import { + AppMountParameters, + CoreStart, + SavedObjectsClientContract, +} from 'opensearch-dashboards/public'; +import { PageRender } from './components/page_render'; +import { Page } from './services'; +import { ContentManagementPluginStartDependencies } from './types'; +import { EmbeddableStart } from '../../embeddable/public'; + +interface Props { + params: AppMountParameters; + pages: Page[]; + coreStart: CoreStart; + depsStart: ContentManagementPluginStartDependencies; +} + +export const renderPage = ({ + page, + embeddable, + savedObjectsClient, +}: { + page: Page; + embeddable: EmbeddableStart; + savedObjectsClient: SavedObjectsClientContract; +}) => { + return ; +}; + +export const renderApp = ( + { params, pages, coreStart, depsStart }: Props, + element: AppMountParameters['element'] +) => { + ReactDOM.render( + + + + {pages.map((page) => ( + + {renderPage({ + page, + embeddable: depsStart.embeddable, + savedObjectsClient: coreStart.savedObjects.client, + })} + + ))} + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/content_management/public/components/card_container/card_container.tsx b/src/plugins/content_management/public/components/card_container/card_container.tsx new file mode 100644 index 000000000000..ed181f32eaa5 --- /dev/null +++ b/src/plugins/content_management/public/components/card_container/card_container.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Container, ContainerInput, EmbeddableStart } from '../../../../embeddable/public'; +import { CardList } from './card_list'; + +export const CARD_CONTAINER = 'CARD_CONTAINER'; + +export type CardContainerInput = ContainerInput<{ description: string; onClick?: () => void }>; + +export class CardContainer extends Container<{}, ContainerInput> { + public readonly type = CARD_CONTAINER; + private node?: HTMLElement; + + constructor(input: ContainerInput, private embeddableServices: EmbeddableStart) { + super(input, { embeddableLoaded: {} }, embeddableServices.getEmbeddableFactory); + } + + getInheritedInput() { + return { + viewMode: this.input.viewMode, + }; + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + ReactDOM.render( + , + node + ); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/src/plugins/content_management/public/components/card_container/card_container_factory.ts b/src/plugins/content_management/public/components/card_container/card_container_factory.ts new file mode 100644 index 000000000000..11adc7cf9950 --- /dev/null +++ b/src/plugins/content_management/public/components/card_container/card_container_factory.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import { + EmbeddableFactoryDefinition, + ContainerInput, + EmbeddableStart, + EmbeddableFactory, + ContainerOutput, +} from '../../../../embeddable/public'; +import { CARD_CONTAINER, CardContainer } from './card_container'; + +interface StartServices { + embeddableServices: EmbeddableStart; +} + +export type CardContainerFactory = EmbeddableFactory; +export class CardContainerFactoryDefinition + implements EmbeddableFactoryDefinition { + public readonly type = CARD_CONTAINER; + public readonly isContainerType = true; + + constructor(private getStartServices: () => Promise) {} + + public async isEditable() { + return true; + } + + public create = async (initialInput: ContainerInput) => { + const { embeddableServices } = await this.getStartServices(); + return new CardContainer(initialInput, embeddableServices); + }; + + public getDisplayName() { + return i18n.translate('contentManagement.cardContainer.displayName', { + defaultMessage: 'Card container', + }); + } +} diff --git a/src/plugins/content_management/public/components/card_container/card_embeddable.tsx b/src/plugins/content_management/public/components/card_container/card_embeddable.tsx new file mode 100644 index 000000000000..6787f161f28d --- /dev/null +++ b/src/plugins/content_management/public/components/card_container/card_embeddable.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Embeddable, EmbeddableInput, IContainer } from '../../../../embeddable/public'; +import { EuiCard } from '@elastic/eui'; + +export const CARD_EMBEDDABLE = 'card_embeddable'; +export type CardEmbeddableInput = EmbeddableInput & { description: string; onClick?: () => void }; + +export class CardEmbeddable extends Embeddable { + public readonly type = CARD_EMBEDDABLE; + private node: HTMLElement | null = null; + + constructor(initialInput: CardEmbeddableInput, parent?: IContainer) { + super(initialInput, {}, parent); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + ReactDOM.render( + , + node + ); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public reload() {} +} diff --git a/src/plugins/content_management/public/components/card_container/card_embeddable_factory.ts b/src/plugins/content_management/public/components/card_container/card_embeddable_factory.ts new file mode 100644 index 000000000000..01ab709bfd79 --- /dev/null +++ b/src/plugins/content_management/public/components/card_container/card_embeddable_factory.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { EmbeddableFactoryDefinition, IContainer } from '../../../../embeddable/public'; +import { CARD_EMBEDDABLE, CardEmbeddable, CardEmbeddableInput } from './card_embeddable'; + +export class CardEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { + public readonly type = CARD_EMBEDDABLE; + + public async isEditable() { + return false; + } + + public async create(initialInput: CardEmbeddableInput, parent?: IContainer) { + return new CardEmbeddable(initialInput, parent); + } + + public getDisplayName() { + return i18n.translate('contentManagement.embeddable.card', { + defaultMessage: 'Card', + }); + } +} diff --git a/src/plugins/content_management/public/components/card_container/card_list.tsx b/src/plugins/content_management/public/components/card_container/card_list.tsx new file mode 100644 index 000000000000..b9619d39e0d1 --- /dev/null +++ b/src/plugins/content_management/public/components/card_container/card_list.tsx @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; + +import { + IContainer, + withEmbeddableSubscription, + ContainerInput, + ContainerOutput, + EmbeddableStart, +} from '../../../../embeddable/public'; + +interface Props { + embeddable: IContainer; + input: ContainerInput; + embeddableServices: EmbeddableStart; +} + +const CardListInner = ({ embeddable, input, embeddableServices }: Props) => { + const cards = Object.values(input.panels).map((panel) => { + const child = embeddable.getChild(panel.explicitInput.id); + return ( + + + + ); + }); + return ( + + {cards} + + ); +}; + +export const CardList = withEmbeddableSubscription< + ContainerInput, + ContainerOutput, + IContainer, + { embeddableServices: EmbeddableStart } +>(CardListInner); diff --git a/src/plugins/content_management/public/components/custom_content_embeddable.tsx b/src/plugins/content_management/public/components/custom_content_embeddable.tsx new file mode 100644 index 000000000000..df67b340a4ba --- /dev/null +++ b/src/plugins/content_management/public/components/custom_content_embeddable.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { Embeddable, EmbeddableInput, IContainer } from '../../../embeddable/public'; + +export const CUSTOM_CONTENT_EMBEDDABLE = 'custom_content_embeddable'; +export type CustomContentEmbeddableInput = EmbeddableInput & { render: () => React.ReactElement }; + +export class CustomContentEmbeddable extends Embeddable { + public readonly type = CUSTOM_CONTENT_EMBEDDABLE; + private node: HTMLElement | null = null; + + constructor(initialInput: CustomContentEmbeddableInput, parent?: IContainer) { + super(initialInput, {}, parent); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + ReactDOM.render(this.input.render(), node); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public reload() {} +} diff --git a/src/plugins/content_management/public/components/custom_content_embeddable_factory.ts b/src/plugins/content_management/public/components/custom_content_embeddable_factory.ts new file mode 100644 index 000000000000..fb543f071c5e --- /dev/null +++ b/src/plugins/content_management/public/components/custom_content_embeddable_factory.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import { EmbeddableFactoryDefinition, IContainer } from '../../../embeddable/public'; +import { + CUSTOM_CONTENT_EMBEDDABLE, + CustomContentEmbeddable, + CustomContentEmbeddableInput, +} from './custom_content_embeddable'; + +export class CustomContentEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { + public readonly type = CUSTOM_CONTENT_EMBEDDABLE; + + public async isEditable() { + return false; + } + + public async create(initialInput: CustomContentEmbeddableInput, parent?: IContainer) { + return new CustomContentEmbeddable(initialInput, parent); + } + + public getDisplayName() { + return i18n.translate('contentManagement.embeddable.customContent', { + defaultMessage: 'Content', + }); + } +} diff --git a/src/plugins/content_management/public/components/index.ts b/src/plugins/content_management/public/components/index.ts new file mode 100644 index 000000000000..b83d733fc0f8 --- /dev/null +++ b/src/plugins/content_management/public/components/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './page_render'; diff --git a/src/plugins/content_management/public/components/page_render.tsx b/src/plugins/content_management/public/components/page_render.tsx new file mode 100644 index 000000000000..541e8713c926 --- /dev/null +++ b/src/plugins/content_management/public/components/page_render.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { useObservable } from 'react-use'; + +import { Page } from '../services'; +import { SectionRender } from './section_render'; +import { EmbeddableStart } from '../../../embeddable/public'; +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; + +export interface Props { + page: Page; + embeddable: EmbeddableStart; + savedObjectsClient: SavedObjectsClientContract; +} + +export const PageRender = ({ page, embeddable, savedObjectsClient }: Props) => { + const sections = useObservable(page.getSections$()) || []; + + return ( +
+ {sections.map((section) => ( + + ))} +
+ ); +}; diff --git a/src/plugins/content_management/public/components/section_render.tsx b/src/plugins/content_management/public/components/section_render.tsx new file mode 100644 index 000000000000..0d713bc30e19 --- /dev/null +++ b/src/plugins/content_management/public/components/section_render.tsx @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { useObservable } from 'react-use'; +import { BehaviorSubject } from 'rxjs'; + +import { Content, Section } from '../services'; +import { EmbeddableInput, EmbeddableRenderer, EmbeddableStart } from '../../../embeddable/public'; +import { DashboardContainerInput } from '../../../dashboard/public'; +import { createCardSection, createDashboardSection } from './utils'; +import { CARD_CONTAINER } from './card_container/card_container'; +import { EuiTitle } from '@elastic/eui'; +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; + +interface Props { + section: Section; + contents$: BehaviorSubject; + embeddable: EmbeddableStart; + savedObjectsClient: SavedObjectsClientContract; +} + +export interface CardInput extends EmbeddableInput { + description: string; +} + +const DashboardSection = ({ section, embeddable, contents$, savedObjectsClient }: Props) => { + const contents = useObservable(contents$); + const [input, setInput] = useState(); + + useEffect(() => { + if (section.kind === 'dashboard') { + createDashboardSection(section, contents ?? [], { savedObjectsClient }).then((ds) => + setInput(ds) + ); + } + }, [section, contents]); + + const factory = embeddable.getEmbeddableFactory('dashboard'); + + if (section.kind === 'dashboard' && factory && input) { + // const input = createDashboardSection(section, contents ?? []); + return ; + } + + return null; +}; + +const CardSection = ({ section, embeddable, contents$ }: Props) => { + const contents = useObservable(contents$); + const input = useMemo(() => { + return createCardSection(section, contents ?? []); + }, [section, contents]); + + const factory = embeddable.getEmbeddableFactory(CARD_CONTAINER); + + if (section.kind === 'card' && factory && input) { + return ( +
+ +

{section.title}

+
+ +
+ ); + } + + return null; +}; + +export const SectionRender = (props: Props) => { + if (props.section.kind === 'dashboard') { + return ; + } + + if (props.section.kind === 'card') { + return ; + } + + return null; +}; diff --git a/src/plugins/content_management/public/components/utils.ts b/src/plugins/content_management/public/components/utils.ts new file mode 100644 index 000000000000..750a6ca2b601 --- /dev/null +++ b/src/plugins/content_management/public/components/utils.ts @@ -0,0 +1,194 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; + +import { Content, Section } from '../services'; +import { ViewMode } from '../../../embeddable/public'; +import { DashboardContainerInput, SavedObjectDashboard } from '../../../dashboard/public'; +import { CUSTOM_CONTENT_EMBEDDABLE } from './custom_content_embeddable'; +import { CardContainerInput } from './card_container/card_container'; +import { CARD_EMBEDDABLE } from './card_container/card_embeddable'; +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; + +const DASHBOARD_GRID_COLUMN_COUNT = 48; + +export const createCardSection = ( + section: Section, + contents: Content[] +): CardContainerInput | null => { + if (section.kind !== 'card') { + throw new Error(`function does not support section.type: ${section.kind}`); + } + + const panels: CardContainerInput['panels'] = {}; + + const input: CardContainerInput = { + id: section.id, + title: section.title ?? '', + hidePanelTitles: true, + viewMode: ViewMode.VIEW, + panels: panels, + }; + + contents.forEach((content) => { + if (content.kind === 'card') { + panels[content.id] = { + type: CARD_EMBEDDABLE, + explicitInput: { + id: content.id, + title: content.title, + description: content.description, + onClick: content.onClick, + }, + }; + } + }); + + if (Object.keys(panels).length === 0) { + return null; + } + + return input; +}; + +export const createDashboardSection = async ( + section: Section, + contents: Content[], + services: { savedObjectsClient: SavedObjectsClientContract } +) => { + if (section.kind !== 'dashboard') { + throw new Error(`function does not support section.type: ${section.kind}`); + } + + const panels: DashboardContainerInput['panels'] = {}; + let x = 0; + let y = 0; + const w = 12; + const h = 15; + const counter = new BehaviorSubject(0); + + contents.forEach(async (content, i) => { + counter.next(counter.value + 1); + try { + if (content.kind === 'dashboard') { + let dashboardId = ''; + if (content.input.kind === 'dynamic') { + dashboardId = await content.input.get(); + } + + if (content.input.kind === 'static') { + dashboardId = content.input.id; + } + + if (dashboardId) { + const dashboardObject = await services.savedObjectsClient.get( + 'dashboard', + dashboardId + ); + const references = dashboardObject.references; + const savedObject = dashboardObject.attributes; + if (savedObject.panelsJSON && typeof savedObject.panelsJSON === 'string') { + const dashboardPanels = JSON.parse(savedObject.panelsJSON); + if (Array.isArray(dashboardPanels)) { + dashboardPanels.forEach((panel) => { + if (!panel.panelRefName) { + return; + } + const reference = references.find((ref) => ref.name === panel.panelRefName); + if (reference) { + panels[panel.panelIndex] = { + gridData: panel.gridData, + type: reference.type, + explicitInput: { + id: panel.panelIndex, + savedObjectId: reference.id, + }, + }; + } + }); + } + } + } + return; + } + + const config: DashboardContainerInput['panels'][string] = { + gridData: { + w, + h, + x, + y, + i: content.id, + }, + type: '', + explicitInput: { + id: content.id, + disabledActions: ['togglePanel'], + }, + }; + + x = x + w; + if (x >= DASHBOARD_GRID_COLUMN_COUNT) { + x = 0; + y = y + h; + } + + if (content.kind === 'visualization') { + config.type = 'visualization'; + if (content.input.kind === 'dynamic') { + config.explicitInput.savedObjectId = await content.input.get(); + } + if (content.input.kind === 'static') { + config.explicitInput.savedObjectId = content.input.id; + } + } + + if (content.kind === 'custom') { + config.type = CUSTOM_CONTENT_EMBEDDABLE; + config.explicitInput.render = content.render; + } + + panels[content.id] = config; + } catch (e) { + console.log(e); + } finally { + counter.next(counter.value - 1); + } + }); + + /** + * TODO: the input should be hooked with query input + */ + const input: DashboardContainerInput = { + viewMode: ViewMode.VIEW, + panels: panels, + isFullScreenMode: false, + filters: [], + useMargins: true, + id: section.id, + timeRange: { + to: 'now', + from: 'now-7d', + }, + title: section.title ?? 'test', + query: { + query: '', + language: 'lucene', + }, + refreshConfig: { + pause: true, + value: 15, + }, + }; + + return new Promise((resolve) => { + counter.subscribe((n) => { + if (n === 0) { + resolve(input); + } + }); + }); +}; diff --git a/src/plugins/content_management/public/index.ts b/src/plugins/content_management/public/index.ts new file mode 100644 index 000000000000..5dcd8cf0fcbc --- /dev/null +++ b/src/plugins/content_management/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from 'opensearch-dashboards/public'; + +export { ContentManagementPluginSetup, ContentManagementPluginStart } from './types'; +import { ContentManagementPublicPlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new ContentManagementPublicPlugin(initializerContext); + +export * from './components'; diff --git a/src/plugins/content_management/public/plugin.ts b/src/plugins/content_management/public/plugin.ts new file mode 100644 index 000000000000..04cf2ca86c54 --- /dev/null +++ b/src/plugins/content_management/public/plugin.ts @@ -0,0 +1,104 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AppMountParameters, + AppNavLinkStatus, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from '../../../core/public'; + +import { ContentManagementService, Page } from './services'; +import { + ContentManagementPluginSetup, + ContentManagementPluginSetupDependencies, + ContentManagementPluginStart, + ContentManagementPluginStartDependencies, +} from './types'; +import { CUSTOM_CONTENT_EMBEDDABLE } from './components/custom_content_embeddable'; +import { CustomContentEmbeddableFactoryDefinition } from './components/custom_content_embeddable_factory'; +import { CARD_CONTAINER } from './components/card_container/card_container'; +import { CardContainerFactoryDefinition } from './components/card_container/card_container_factory'; +import { CARD_EMBEDDABLE } from './components/card_container/card_embeddable'; +import { CardEmbeddableFactoryDefinition } from './components/card_container/card_embeddable_factory'; +import { renderPage } from './app'; + +export class ContentManagementPublicPlugin + implements + Plugin< + ContentManagementPluginSetup, + ContentManagementPluginStart, + {}, + ContentManagementPluginStartDependencies + > { + private readonly contentManagementService = new ContentManagementService(); + + constructor(private readonly initializerContext: PluginInitializerContext) {} + + public setup( + core: CoreSetup, + deps: ContentManagementPluginSetupDependencies + ) { + this.contentManagementService.setup(); + + deps.embeddable.registerEmbeddableFactory( + CUSTOM_CONTENT_EMBEDDABLE, + new CustomContentEmbeddableFactoryDefinition() + ); + + deps.embeddable.registerEmbeddableFactory( + CARD_EMBEDDABLE, + new CardEmbeddableFactoryDefinition() + ); + + deps.embeddable.registerEmbeddableFactory( + CARD_CONTAINER, + new CardContainerFactoryDefinition(async () => ({ + embeddableServices: (await core.getStartServices())[1].embeddable, + })) + ); + + core.application.register({ + id: 'contents', + title: 'Contents', + navLinkStatus: AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + const [coreStart, depsStart] = await core.getStartServices(); + const { renderApp } = await import('./app'); + const pages = [...this.contentManagementService.pages.values()]; + + return renderApp( + { + pages, + coreStart, + depsStart, + params, + }, + params.element + ); + }, + }); + + return { + registerPage: this.contentManagementService.registerPage, + getPage: this.contentManagementService.getPage, + }; + } + + public start(core: CoreStart, depsStart: ContentManagementPluginStartDependencies) { + this.contentManagementService.start(); + return { + getPage: this.contentManagementService.getPage, + renderPage: (page: Page) => + renderPage({ + page, + embeddable: depsStart.embeddable, + savedObjectsClient: core.savedObjects.client, + }), + }; + } +} diff --git a/src/plugins/content_management/public/services/content_management/content_management_service.ts b/src/plugins/content_management/public/services/content_management/content_management_service.ts new file mode 100644 index 000000000000..24168ed4c426 --- /dev/null +++ b/src/plugins/content_management/public/services/content_management/content_management_service.ts @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Page } from './page'; +import { PageConfig } from './types'; + +export class ContentManagementService { + pages: Map = new Map(); + constructor() {} + + registerPage = (pageConfig: PageConfig) => { + if (this.pages.has(pageConfig.id)) { + throw new Error(`Page id exists: ${pageConfig.id}`); + } + const page = new Page(pageConfig); + this.pages.set(pageConfig.id, page); + return page; + }; + + getPage = (id: string) => { + return this.pages.get(id); + }; + + setup() { + return {}; + } + + start() { + return {}; + } +} diff --git a/src/plugins/content_management/public/services/content_management/index.ts b/src/plugins/content_management/public/services/content_management/index.ts new file mode 100644 index 000000000000..ab6d8357d9b5 --- /dev/null +++ b/src/plugins/content_management/public/services/content_management/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './content_management_service'; +export * from './page'; +export * from './types'; diff --git a/src/plugins/content_management/public/services/content_management/page.ts b/src/plugins/content_management/public/services/content_management/page.ts new file mode 100644 index 000000000000..639cd57f9941 --- /dev/null +++ b/src/plugins/content_management/public/services/content_management/page.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { Content, PageConfig, Section } from './types'; + +export class Page { + config: PageConfig; + private sections: Map = new Map(); + private contents: Map = new Map(); + private sections$ = new BehaviorSubject([]); + private contentObservables: Map> = new Map(); + private NO_CONTENT$ = new BehaviorSubject([]); + + constructor(pageConfig: PageConfig) { + this.config = pageConfig; + } + + createSection(section: Section) { + if (this.sections.has(section.id)) { + throw new Error(`Section id exists: ${section.id}`); + } + this.sections.set(section.id, section); + this.sections$.next(this.getSections()); + } + + getSections() { + return [...this.sections.values()].sort((a, b) => a.order - b.order); + } + + getSections$() { + return this.sections$; + } + + addContent(sectionId: string, content: Content) { + const sectionContents = this.contents.get(sectionId); + if (sectionContents) { + if (content.kind === 'dashboard' && sectionContents.length > 0) { + throw new Error('Section type "dashboard" can only have one content type of "dashboard"'); + } + sectionContents.push(content); + // sort content by order + sectionContents.sort((a, b) => a.order - b.order); + } else { + this.contents.set(sectionId, [content]); + } + + if (this.contentObservables.get(sectionId)) { + this.contentObservables.get(sectionId)?.next(this.contents.get(sectionId) ?? []); + } else { + this.contentObservables.set( + sectionId, + new BehaviorSubject(this.contents.get(sectionId) ?? []) + ); + } + } + + getContents(sectionId: string) { + return this.contents.get(sectionId); + } + + getContents$(sectionId: string) { + return this.contentObservables.get(sectionId) ?? this.NO_CONTENT$; + } +} diff --git a/src/plugins/content_management/public/services/content_management/types.ts b/src/plugins/content_management/public/services/content_management/types.ts new file mode 100644 index 000000000000..39ca2c58d83e --- /dev/null +++ b/src/plugins/content_management/public/services/content_management/types.ts @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface PageConfig { + id: string; + title?: string; + description?: string; +} + +export type Section = + | { + kind: 'custom'; + id: string; + order: number; + title?: string; + description?: string; + render?: (contents: Map) => React.ReactNode; + } + | { + kind: 'dashboard'; + id: string; + order: number; + title?: string; + description?: string; + } + | { + kind: 'card'; + id: string; + order: number; + title?: string; + }; + +export type Content = + | { + kind: 'visualization'; + id: string; + order: number; + input: SavedObjectInput; + } + | { + kind: 'dashboard'; + id: string; + order: number; + input: SavedObjectInput; + } + | { + kind: 'custom'; + id: string; + order: number; + render: () => React.ReactElement; + } + | { + kind: 'card'; + id: string; + order: number; + title: string; + description: string; + onClick?: () => void; + }; + +export type SavedObjectInput = + | { + kind: 'static'; + /** + * The visualization id + */ + id: string; + } + | { + kind: 'dynamic'; + /** + * A promise that returns a visualization id + */ + get: () => Promise; + }; diff --git a/src/plugins/content_management/public/services/index.ts b/src/plugins/content_management/public/services/index.ts new file mode 100644 index 000000000000..55bc487558c8 --- /dev/null +++ b/src/plugins/content_management/public/services/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './content_management'; diff --git a/src/plugins/content_management/public/types.ts b/src/plugins/content_management/public/types.ts new file mode 100644 index 000000000000..7d8dce059943 --- /dev/null +++ b/src/plugins/content_management/public/types.ts @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { CoreStart } from 'opensearch-dashboards/public'; + +import { ContentManagementService, Page } from './services'; +import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; + +export interface ContentManagementPluginSetup { + registerPage: ContentManagementService['registerPage']; + getPage: ContentManagementService['getPage']; +} +export interface ContentManagementPluginStart { + getPage: ContentManagementService['getPage']; + renderPage: (page: Page) => React.ReactNode; +} + +export type ContentManagementPluginStartDependencies = { + embeddable: EmbeddableStart; +}; + +export type ContentManagementPluginSetupDependencies = { + embeddable: EmbeddableSetup; +}; + +export type ContentServices = CoreStart & ContentManagementPluginStartDependencies; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx index be047f8a9698..17b9ca8f6a10 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.tsx @@ -43,6 +43,7 @@ interface Props { export class EmbeddableRoot extends React.Component { private root?: React.RefObject; private alreadyMounted: boolean = false; + private lastUsedInput?: EmbeddableInput; constructor(props: Props) { super(props); @@ -54,6 +55,7 @@ export class EmbeddableRoot extends React.Component { if (this.root && this.root.current && this.props.embeddable) { this.alreadyMounted = true; this.props.embeddable.render(this.root.current); + this.lastUsedInput = this.props.input; } } @@ -62,6 +64,16 @@ export class EmbeddableRoot extends React.Component { if (this.root && this.root.current && this.props.embeddable && !this.alreadyMounted) { this.alreadyMounted = true; this.props.embeddable.render(this.root.current); + /** + * When an embeddable was created with an value asynchronously, at the time when calling embeddable.render() + * The current input value might already changed, so it needs to call embeddable.updateInput() with the + * latest input here. It cannot simply compare prevProps.input and this.props.input because input may already + * be changed before embeddable was created. + */ + if (this.lastUsedInput !== this.props.input && this.props.input) { + this.props.embeddable.updateInput(this.props.input); + this.lastUsedInput = this.props.input; + } justRendered = true; } @@ -72,9 +84,10 @@ export class EmbeddableRoot extends React.Component { this.props.embeddable && this.alreadyMounted && this.props.input && - prevProps?.input !== this.props.input + this.lastUsedInput !== this.props.input ) { this.props.embeddable.updateInput(this.props.input); + this.lastUsedInput = this.props.input; } } diff --git a/src/plugins/home/opensearch_dashboards.json b/src/plugins/home/opensearch_dashboards.json index 7dc3adf0d2c7..280a6cb23b20 100644 --- a/src/plugins/home/opensearch_dashboards.json +++ b/src/plugins/home/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["data", "urlForwarding", "savedObjects"], + "requiredPlugins": ["data", "urlForwarding", "savedObjects", "contentManagement", "embeddable"], "optionalPlugins": ["usageCollection", "telemetry", "dataSource"], - "requiredBundles": ["opensearchDashboardsReact", "dataSourceManagement"] + "requiredBundles": ["opensearchDashboardsReact", "dataSourceManagement", "contentManagement"] } diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 4febddb9148d..66746ca746f7 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -42,6 +42,7 @@ import { replaceTemplateStrings } from './tutorial/replace_template_strings'; import { getServices } from '../opensearch_dashboards_services'; import { useMount } from 'react-use'; import { USE_NEW_HOME_PAGE } from '../../../common/constants'; +import { PageRender } from '../../../../content_management/public'; const RedirectToDefaultApp = () => { useMount(() => { @@ -88,9 +89,11 @@ export function HomeApp({ directories, solutions }) { environmentService, telemetry, uiSettings, + contentManagement, } = getServices(); const environment = environmentService.getEnvironment(); const isCloudEnabled = environment.cloud; + const page = contentManagement.getPage('home'); const renderTutorial = (props) => { return ( @@ -119,6 +122,8 @@ export function HomeApp({ directories, solutions }) { const homepage = ; + const nextHome = contentManagement.renderPage(page); + return ( @@ -134,13 +139,13 @@ export function HomeApp({ directories, solutions }) { {legacyHome} - {homepage} + {nextHome} ) : ( <> - {homepage} + {nextHome} {legacyHome} diff --git a/src/plugins/home/public/application/home_render.tsx b/src/plugins/home/public/application/home_render.tsx new file mode 100644 index 000000000000..2f974d281099 --- /dev/null +++ b/src/plugins/home/public/application/home_render.tsx @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { CoreStart } from 'opensearch-dashboards/public'; +import { Page } from '../../../content_management/public/services'; +import { toMountPoint } from '../../../opensearch_dashboards_react/public'; + +export const GET_STARTED_SECTION_ID = 'homepage_get_started'; + +/** + * Example: render a arbitrary component + */ +const renderHomeCard = () =>
Hello World!
; + +export const initHome = (page: Page, core: CoreStart) => { + /** + * init get started section + */ + page.addContent('get_started', { + id: 'get_started_1', + kind: 'card', + order: 5000, + description: 'description 1', + title: 'title 1', + onClick: () => { + const modal = core.overlays.openModal( + toMountPoint( +
+ test +
+ ) + ); + }, + }); + page.addContent('get_started', { + id: 'get_started_2', + kind: 'card', + order: 2000, + description: 'description 2', + title: 'title 2', + onClick: () => { + const modal = core.overlays.openModal( + toMountPoint( +
+ test +
+ ) + ); + }, + }); + page.addContent('get_started', { + id: 'get_started_3', + kind: 'card', + order: 3000, + description: 'description 3', + title: 'title 3', + onClick: () => { + const modal = core.overlays.openModal( + toMountPoint( +
+ test +
+ ) + ); + }, + }); + page.addContent('get_started', { + id: 'get_started_4', + kind: 'card', + order: 4000, + description: 'description 4', + title: 'title 4', + onClick: () => { + const modal = core.overlays.openModal( + toMountPoint( +
+ test +
+ ) + ); + }, + }); + + /** + * Example: embed a dashboard to homepage + */ + page.addContent('some_dashboard', { + id: 'dashboard_1', + kind: 'dashboard', + order: 0, + input: { + kind: 'static', + id: '722b74f0-b882-11e8-a6d9-e546fe2bba5f', + }, + }); + + /** + * Example: embed visualization to homepage + */ + page.addContent('service_cards', { + id: 'vis_1', + order: 0, + kind: 'visualization', + input: { + kind: 'dynamic', + get: () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve('4b3ec120-b892-11e8-a6d9-e546fe2bba5f'); + }, 500); + }); + }, + }, + }); + page.addContent('service_cards', { + id: 'vis_2', + order: 10, + kind: 'visualization', + input: { + kind: 'dynamic', + get: () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve('4b3ec120-b892-11e8-a6d9-e546fe2bba5f'); + }, 500); + }); + }, + }, + }); + page.addContent('service_cards', { + id: 'vis_3', + order: 20, + kind: 'visualization', + input: { + kind: 'static', + id: '4b3ec120-b892-11e8-a6d9-e546fe2bba5f', + }, + }); + page.addContent('service_cards', { + id: 'vis_4', + order: 30, + kind: 'custom', + render: renderHomeCard, + }); +}; diff --git a/src/plugins/home/public/application/opensearch_dashboards_services.ts b/src/plugins/home/public/application/opensearch_dashboards_services.ts index 727cb03c10ab..b7bf14f1f3ab 100644 --- a/src/plugins/home/public/application/opensearch_dashboards_services.ts +++ b/src/plugins/home/public/application/opensearch_dashboards_services.ts @@ -48,6 +48,8 @@ import { SectionTypeService } from '../services/section_type'; import { ConfigSchema } from '../../config'; import { HomePluginBranding } from '..'; import { DataSourcePluginStart } from '../../../data_source/public'; +import { EmbeddableStart } from '../../../embeddable/public'; +import { ContentManagementPluginStart } from '../../../content_management/public'; export interface HomeOpenSearchDashboardsServices { indexPatternService: any; @@ -56,6 +58,8 @@ export interface HomeOpenSearchDashboardsServices { application: ApplicationStart; uiSettings: IUiSettingsClient; urlForwarding: UrlForwardingStart; + contentManagement: ContentManagementPluginStart; + embeddable: EmbeddableStart; homeConfig: ConfigSchema; featureCatalogue: FeatureCatalogueRegistry; http: HttpStart; diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index a9e4cb263e88..d5fd8b55e1dc 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -64,17 +64,27 @@ import { PLUGIN_ID, HOME_APP_BASE_PATH, IMPORT_SAMPLE_DATA_APP_ID } from '../com import { DataSourcePluginStart } from '../../data_source/public'; import { workWithDataSection } from './application/components/homepage/sections/work_with_data'; import { learnBasicsSection } from './application/components/homepage/sections/learn_basics'; +import { + ContentManagementPluginSetup, + ContentManagementPluginStart, +} from '../../content_management/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; +import { initHome } from './application/home_render'; export interface HomePluginStartDependencies { data: DataPublicPluginStart; telemetry?: TelemetryPluginStart; urlForwarding: UrlForwardingStart; dataSource?: DataSourcePluginStart; + contentManagement: ContentManagementPluginStart; + embeddable: EmbeddableStart; } export interface HomePluginSetupDependencies { usageCollection?: UsageCollectionSetup; urlForwarding: UrlForwardingSetup; + contentManagement: ContentManagementPluginSetup; + embeddable: EmbeddableSetup; } export class HomePublicPlugin @@ -94,7 +104,7 @@ export class HomePublicPlugin public setup( core: CoreSetup, - { urlForwarding, usageCollection }: HomePluginSetupDependencies + { urlForwarding, usageCollection, contentManagement }: HomePluginSetupDependencies ): HomePublicPluginSetup { const setCommonService = async ( homeOpenSearchDashboardsServices?: Partial @@ -104,7 +114,14 @@ export class HomePublicPlugin : () => {}; const [ coreStart, - { telemetry, data, urlForwarding: urlForwardingStart, dataSource }, + { + telemetry, + data, + urlForwarding: urlForwardingStart, + dataSource, + embeddable, + contentManagement: contentManagementStart, + }, ] = await core.getStartServices(); setServices({ trackUiMetric, @@ -123,6 +140,8 @@ export class HomePublicPlugin indexPatternService: data.indexPatterns, environmentService: this.environmentService, urlForwarding: urlForwardingStart, + contentManagement: contentManagementStart, + embeddable: embeddable, homeConfig: this.initializerContext.config.get(), tutorialService: this.tutorialService, featureCatalogue: this.featuresCatalogueRegistry, @@ -132,6 +151,7 @@ export class HomePublicPlugin ...homeOpenSearchDashboardsServices, }); }; + core.application.register({ id: PLUGIN_ID, title: 'Home', @@ -191,6 +211,25 @@ export class HomePublicPlugin sectionTypes.registerSection(workWithDataSection); sectionTypes.registerSection(learnBasicsSection); + const page = contentManagement.registerPage({ id: 'home', title: 'Home' }); + page.createSection({ + id: 'service_cards', + order: 3000, + kind: 'dashboard', + }); + page.createSection({ + id: 'some_dashboard', + order: 2000, + title: 'test dashboard', + kind: 'dashboard', + }); + page.createSection({ + id: 'get_started', + order: 1000, + title: 'Define your path forward with OpenSearch', + kind: 'card', + }); + return { featureCatalogue, environment: { ...this.environmentService.setup() }, @@ -199,12 +238,20 @@ export class HomePublicPlugin }; } - public start(core: CoreStart, { data, urlForwarding }: HomePluginStartDependencies) { + public start( + core: CoreStart, + { data, urlForwarding, contentManagement }: HomePluginStartDependencies + ) { const { application: { capabilities, currentAppId$ }, http, } = core; + const page = contentManagement.getPage('home'); + if (page) { + initHome(page, core); + } + this.featuresCatalogueRegistry.start({ capabilities }); this.sectionTypeService.start({ core, data });