Skip to content

Commit

Permalink
New core plugin for dynamic content rendering (#7201)
Browse files Browse the repository at this point in the history
* feat: add content management plugin

Signed-off-by: Yulong Ruan <[email protected]>

* fix: add license header

Signed-off-by: Yulong Ruan <[email protected]>

* add card container for rendering content as cards

Signed-off-by: Yulong Ruan <[email protected]>

* rename GetStartedCard -> CardList

Signed-off-by: Yulong Ruan <[email protected]>

* content management supporting embedding dashboards

Signed-off-by: Yulong Ruan <[email protected]>

* rename interface VisualizationInput -> SavedObjectInput

Signed-off-by: Yulong Ruan <[email protected]>

* cleanup and refactor

Signed-off-by: Yulong Ruan <[email protected]>

* fix license header

Signed-off-by: Yulong Ruan <[email protected]>

* add unit test for content management plugin

Signed-off-by: Yulong Ruan <[email protected]>

* Add a todo to cleanup demo code

Signed-off-by: Yulong Ruan <[email protected]>

* refactor: decouple content and page with content provider

Signed-off-by: Yulong Ruan <[email protected]>

* fix linter

Signed-off-by: Yulong Ruan <[email protected]>

* fix lint

Signed-off-by: Yulong Ruan <[email protected]>

* Changeset file for PR #7201 created/updated

* pr feedback updates

Signed-off-by: Yulong Ruan <[email protected]>

* fix tests

Signed-off-by: Yulong Ruan <[email protected]>

* fix license header

Signed-off-by: Yulong Ruan <[email protected]>

* cleanup unused variable

Signed-off-by: Yulong Ruan <[email protected]>

* mark registerContentProvider API as experimental

Signed-off-by: Yulong Ruan <[email protected]>

---------

Signed-off-by: Yulong Ruan <[email protected]>
Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
(cherry picked from commit cc6949b)
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
1 parent acbfcaf commit 375e82b
Show file tree
Hide file tree
Showing 44 changed files with 1,953 additions and 8 deletions.
2 changes: 2 additions & 0 deletions changelogs/fragments/7201.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Introduced an new plugin contentManagement for dynamic content rendering ([#7201](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7201))
9 changes: 9 additions & 0 deletions src/plugins/content_management/opensearch_dashboards.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "contentManagement",
"version": "opensearchDashboards",
"server": false,
"ui": true,
"requiredPlugins": ["embeddable"],
"optionalPlugins": [],
"requiredBundles": []
}
23 changes: 23 additions & 0 deletions src/plugins/content_management/public/app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';

import { SavedObjectsClientContract } from 'opensearch-dashboards/public';
import { PageRender } from './components/page_render';
import { Page } from './services';
import { EmbeddableStart } from '../../embeddable/public';

export const renderPage = ({
page,
embeddable,
savedObjectsClient,
}: {
page: Page;
embeddable: EmbeddableStart;
savedObjectsClient: SavedObjectsClientContract;
}) => {
return <PageRender page={page} embeddable={embeddable} savedObjectsClient={savedObjectsClient} />;

Check warning on line 22 in src/plugins/content_management/public/app.tsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/content_management/public/app.tsx#L22

Added line #L22 was not covered by tests
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { embeddablePluginMock } from '../../../../embeddable/public/mocks';
import { CardContainer } from './card_container';

jest.mock('./card_list', () => {
return {
CardList: jest.fn().mockReturnValue(<span id="mockCardList" />),
};
});

test('CardContainer should render CardList', () => {
const container = new CardContainer(
{ id: 'container-id', panels: {} },
embeddablePluginMock.createStartContract()
);
const node = document.createElement('div');
container.render(node);
expect(node.querySelector('#mockCardList')).toBeTruthy();

container.destroy();
expect(node.querySelector('#mockCardList')).toBeFalsy();
});
Original file line number Diff line number Diff line change
@@ -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<{}, CardContainerInput> {
public readonly type = CARD_CONTAINER;
private node?: HTMLElement;

constructor(input: CardContainerInput, 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);

Check warning on line 31 in src/plugins/content_management/public/components/card_container/card_container.tsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/content_management/public/components/card_container/card_container.tsx#L31

Added line #L31 was not covered by tests
}
this.node = node;
ReactDOM.render(
<CardList embeddable={this} embeddableServices={this.embeddableServices} />,
node
);
}

public destroy() {
super.destroy();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { embeddablePluginMock } from '../../../../embeddable/public/mocks';
import { CARD_CONTAINER, CardContainer } from './card_container';
import { CardContainerFactoryDefinition } from './card_container_factory';

test('CardContainerFactoryDefinition', async () => {
const getStartServices = jest
.fn()
.mockResolvedValue({ embeddableServices: embeddablePluginMock.createStartContract() });
const factory = new CardContainerFactoryDefinition(getStartServices);
expect(factory.type).toBe(CARD_CONTAINER);
expect(factory.isContainerType).toBe(true);
expect(await factory.isEditable()).toBe(false);
expect(factory.getDisplayName()).toBe('Card container');
expect(await factory.create({ id: 'card-id', panels: {} })).toBeInstanceOf(CardContainer);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { i18n } from '@osd/i18n';

import {
EmbeddableFactoryDefinition,
EmbeddableStart,
EmbeddableFactory,
ContainerOutput,
} from '../../../../embeddable/public';
import { CARD_CONTAINER, CardContainer, CardContainerInput } from './card_container';

interface StartServices {
embeddableServices: EmbeddableStart;
}

export type CardContainerFactory = EmbeddableFactory<CardContainerInput, ContainerOutput>;
export class CardContainerFactoryDefinition
implements EmbeddableFactoryDefinition<CardContainerInput, ContainerOutput> {
public readonly type = CARD_CONTAINER;
public readonly isContainerType = true;

constructor(private getStartServices: () => Promise<StartServices>) {}

public async isEditable() {
return false;
}

public create = async (initialInput: CardContainerInput) => {
const { embeddableServices } = await this.getStartServices();
return new CardContainer(initialInput, embeddableServices);
};

public getDisplayName() {
return i18n.translate('contentManagement.cardContainer.displayName', {
defaultMessage: 'Card container',
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { CardEmbeddable } from './card_embeddable';

test('CardEmbeddable should render a card with the title', () => {
const embeddable = new CardEmbeddable({ id: 'card-id', title: 'card title', description: '' });

const node = document.createElement('div');
embeddable.render(node);

// it should render the card with title specified
expect(
Array.from(node.querySelectorAll('*')).find((ele) => ele.textContent?.trim() === 'card title')
).toBeTruthy();

embeddable.destroy();
expect(
Array.from(node.querySelectorAll('*')).find((ele) => ele.textContent?.trim() === 'card title')
).toBeFalsy();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import ReactDOM from 'react-dom';
import { EuiCard } from '@elastic/eui';

import { Embeddable, EmbeddableInput, IContainer } from '../../../../embeddable/public';

export const CARD_EMBEDDABLE = 'card_embeddable';
export type CardEmbeddableInput = EmbeddableInput & { description: string; onClick?: () => void };

export class CardEmbeddable extends Embeddable<CardEmbeddableInput> {
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);

Check warning on line 25 in src/plugins/content_management/public/components/card_container/card_embeddable.tsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/content_management/public/components/card_container/card_embeddable.tsx#L25

Added line #L25 was not covered by tests
}
this.node = node;
ReactDOM.render(
<EuiCard
title={this.input.title ?? ''}
description={this.input.description}
display="plain"
onClick={this.input.onClick}
/>,
node
);
}

public destroy() {
super.destroy();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}

public reload() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { CARD_EMBEDDABLE, CardEmbeddable } from './card_embeddable';
import { CardEmbeddableFactoryDefinition } from './card_embeddable_factory';

test('create CardEmbeddableFactoryDefinition', async () => {
const factory = new CardEmbeddableFactoryDefinition();
expect(factory.type).toBe(CARD_EMBEDDABLE);
expect(factory.getDisplayName()).toBe('Card');
expect(await factory.isEditable()).toBe(false);
expect(await factory.create({ id: 'card-id', title: 'title', description: '' })).toBeInstanceOf(
CardEmbeddable
);
});
Original file line number Diff line number Diff line change
@@ -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',
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { render, screen } from '@testing-library/react';

import { CardList } from './card_list';
import { CardContainer } from './card_container';
import { embeddablePluginMock } from '../../../../embeddable/public/mocks';
import { CARD_EMBEDDABLE } from './card_embeddable';

beforeEach(() => {
jest.restoreAllMocks();
});

test('render list of cards', () => {
const embeddableStart = embeddablePluginMock.createStartContract();
jest
.spyOn(embeddableStart, 'EmbeddablePanel')
.mockImplementation(() => <span>CardEmbeddablePanel</span>);
render(
<CardList
embeddableServices={embeddableStart}
embeddable={
new CardContainer(
{
id: 'card',
panels: {
'card-id-1': { type: CARD_EMBEDDABLE, explicitInput: { id: 'card-id-1' } },
'card-id-2': { type: CARD_EMBEDDABLE, explicitInput: { id: 'card-id-2' } },
},
},
embeddableStart
)
}
/>
);
expect(screen.queryAllByText('CardEmbeddablePanel')).toHaveLength(2);
});
Original file line number Diff line number Diff line change
@@ -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 (
<EuiFlexItem key={panel.explicitInput.id}>
<embeddableServices.EmbeddablePanel embeddable={child} />
</EuiFlexItem>
);
});
return (
<EuiFlexGrid gutterSize="s" columns={4}>
{cards}
</EuiFlexGrid>
);
};

export const CardList = withEmbeddableSubscription<
ContainerInput,
ContainerOutput,
IContainer,
{ embeddableServices: EmbeddableStart }
>(CardListInner);
Loading

0 comments on commit 375e82b

Please sign in to comment.