Skip to content

Commit

Permalink
[Embeddables rebuild] Add new registry (elastic#176018)
Browse files Browse the repository at this point in the history
Creates a new registry for `React Embeddables` and allows the Dashboard to render them.
  • Loading branch information
ThomThomson authored and CoenWarmer committed Feb 15, 2024
1 parent a09bd99 commit 4f2d2d4
Show file tree
Hide file tree
Showing 69 changed files with 1,871 additions and 463 deletions.
3 changes: 3 additions & 0 deletions examples/embeddable_examples/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
FilterDebuggerEmbeddableFactory,
FilterDebuggerEmbeddableFactoryDefinition,
} from './filter_debugger';
import { registerMarkdownEditorEmbeddable } from './react_embeddables/eui_markdown_react_embeddable';

export interface EmbeddableExamplesSetupDependencies {
embeddable: EmbeddableSetup;
Expand All @@ -53,6 +54,8 @@ export interface EmbeddableExamplesStart {
factories: ExampleEmbeddableFactories;
}

registerMarkdownEditorEmbeddable();

export class EmbeddableExamplesPlugin
implements
Plugin<
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { EuiMarkdownEditor, EuiMarkdownFormat } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import {
ReactEmbeddableFactory,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
initializeReactEmbeddableUuid,
initializeReactEmbeddableTitles,
SerializedReactEmbeddableTitles,
DefaultEmbeddableApi,
useReactEmbeddableUnsavedChanges,
} from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { useInheritedViewMode, useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import React from 'react';
import { BehaviorSubject } from 'rxjs';

// -----------------------------------------------------------------------------
// Types for this embeddable
// -----------------------------------------------------------------------------
type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & {
content: string;
};

type MarkdownEditorApi = DefaultEmbeddableApi;

const type = 'euiMarkdown';

// -----------------------------------------------------------------------------
// Define the Embeddable Factory
// -----------------------------------------------------------------------------
const markdownEmbeddableFactory: ReactEmbeddableFactory<
MarkdownEditorSerializedState,
MarkdownEditorApi
> = {
// -----------------------------------------------------------------------------
// Deserialize function
// -----------------------------------------------------------------------------
deserializeState: (state) => {
// We could run migrations here.
// We should inject references here. References are given as state.references

return state.rawState as MarkdownEditorSerializedState;
},

// -----------------------------------------------------------------------------
// Register the Embeddable component
// -----------------------------------------------------------------------------
getComponent: async (state, maybeId) => {
/**
* initialize state (source of truth)
*/
const uuid = initializeReactEmbeddableUuid(maybeId);
const { titlesApi, titleComparators, serializeTitles } = initializeReactEmbeddableTitles(state);
const contentSubject = new BehaviorSubject(state.content);

/**
* getComponent is async so you can async import the component or load a saved object here.
* the loading will be handed gracefully by the Presentation Container.
*/

return RegisterReactEmbeddable((apiRef) => {
/**
* Unsaved changes logic is handled automatically by this hook. You only need to provide
* a subject, setter, and optional state comparator for each key in your state type.
*/
const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges(
uuid,
markdownEmbeddableFactory,
{
content: [contentSubject, (value) => contentSubject.next(value)],
...titleComparators,
}
);

/**
* Publish the API. This is what gets forwarded to the Actions framework, and to whatever the
* parent of this embeddable is.
*/
const thisApi = useReactEmbeddableApiHandle(
{
...titlesApi,
unsavedChanges,
resetUnsavedChanges,
serializeState: async () => {
return {
rawState: {
...serializeTitles(),
content: contentSubject.getValue(),
},
};
},
},
apiRef,
uuid
);

// get state for rendering
const content = useStateFromPublishingSubject(contentSubject);
const viewMode = useInheritedViewMode(thisApi) ?? 'view';

return viewMode === 'edit' ? (
<EuiMarkdownEditor
css={css`
width: 100%;
`}
value={content ?? ''}
onChange={(value) => contentSubject.next(value)}
aria-label={i18n.translate('dashboard.test.markdownEditor.ariaLabel', {
defaultMessage: 'Dashboard markdown editor',
})}
height="full"
/>
) : (
<EuiMarkdownFormat
css={css`
padding: ${euiThemeVars.euiSizeS};
`}
>
{content ?? ''}
</EuiMarkdownFormat>
);
});
},
};

// -----------------------------------------------------------------------------
// Register the defined Embeddable Factory - notice that this isn't defined
// on the plugin. Instead, it's a simple imported function. I.E to register an
// Embeddable, you only need the embeddable plugin in your requiredBundles
// -----------------------------------------------------------------------------
export const registerMarkdownEditorEmbeddable = () =>
registerReactEmbeddableFactory(type, markdownEmbeddableFactory);
8 changes: 4 additions & 4 deletions examples/embeddable_examples/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": [
"target/**/*",
],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/kibana-utils-plugin",
"@kbn/ui-actions-plugin",
"@kbn/embeddable-plugin",
"@kbn/presentation-publishing",
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/es-query",
"@kbn/es-query"
]
}
6 changes: 6 additions & 0 deletions packages/presentation/presentation_containers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ export {
type PresentationContainer,
} from './interfaces/presentation_container';
export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays';
export { type SerializedPanelState } from './interfaces/serialized_state';
export {
type PublishesLastSavedState,
apiPublishesLastSavedState,
getLastSavedStateSubjectForChild,
} from './interfaces/last_saved_state';
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { PublishingSubject } from '@kbn/presentation-publishing';
import { BehaviorSubject, Subject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { SerializedPanelState } from './serialized_state';

export interface PublishesLastSavedState {
lastSavedState: Subject<void>; // a notification that the last saved state has changed
getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined;
}

export const apiPublishesLastSavedState = (api: unknown): api is PublishesLastSavedState => {
return Boolean(
api &&
(api as PublishesLastSavedState).lastSavedState &&
(api as PublishesLastSavedState).getLastSavedStateForChild
);
};

export const getLastSavedStateSubjectForChild = <StateType extends unknown = unknown>(
parentApi: unknown,
childId: string,
deserializer?: (state: SerializedPanelState) => StateType
): PublishingSubject<StateType | undefined> | undefined => {
if (!parentApi) return;
const fetchUnsavedChanges = (): StateType | undefined => {
if (!apiPublishesLastSavedState(parentApi)) return;
const rawLastSavedState = parentApi.getLastSavedStateForChild(childId);
if (rawLastSavedState === undefined) return;
return deserializer
? deserializer(rawLastSavedState)
: (rawLastSavedState.rawState as StateType);
};

const lastSavedStateForChild = new BehaviorSubject<StateType | undefined>(fetchUnsavedChanges());
if (!apiPublishesLastSavedState(parentApi)) return;
parentApi.lastSavedState
.pipe(
map(() => fetchUnsavedChanges()),
filter((rawLastSavedState) => rawLastSavedState !== undefined)
)
.subscribe(lastSavedStateForChild);
return lastSavedStateForChild;
};
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,4 @@ export const apiCanExpandPanels = (unknownApi: unknown | null): unknownApi is Ca
* Gets this API's expanded panel state as a reactive variable which will cause re-renders on change.
*/
export const useExpandedPanelId = (api: Partial<CanExpandPanels> | undefined) =>
useStateFromPublishingSubject<string | undefined, CanExpandPanels['expandedPanelId']>(
apiCanExpandPanels(api) ? api.expandedPanelId : undefined
);
useStateFromPublishingSubject(apiCanExpandPanels(api) ? api.expandedPanelId : undefined);
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,32 @@
*/

import { apiHasParentApi, PublishesViewMode } from '@kbn/presentation-publishing';
import { PublishesLastSavedState } from './last_saved_state';

export interface PanelPackage {
panelType: string;
initialState: unknown;
}
export interface PresentationContainer extends Partial<PublishesViewMode> {
removePanel: (panelId: string) => void;
canRemovePanels?: () => boolean;
replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise<string>;
}

export type PresentationContainer = Partial<PublishesViewMode> &
PublishesLastSavedState & {
registerPanelApi: <ApiType extends unknown = unknown>(
panelId: string,
panelApi: ApiType
) => void;
removePanel: (panelId: string) => void;
canRemovePanels?: () => boolean;
replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise<string>;
};

export const apiIsPresentationContainer = (
unknownApi: unknown | null
): unknownApi is PresentationContainer => {
return Boolean((unknownApi as PresentationContainer)?.removePanel !== undefined);
return Boolean(
(unknownApi as PresentationContainer)?.removePanel !== undefined &&
(unknownApi as PresentationContainer)?.registerPanelApi !== undefined &&
(unknownApi as PresentationContainer)?.replacePanel !== undefined
);
};

export const getContainerParentFromAPI = (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server';

/**
* A package containing the serialized Embeddable state, with references extracted. When saving Embeddables using any
* strategy, this is the format that should be used.
*/
export interface SerializedPanelState<RawStateType extends object = object> {
references?: SavedObjectReference[];
rawState: RawStateType;
version?: string;
}
20 changes: 20 additions & 0 deletions packages/presentation/presentation_containers/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { Subject } from 'rxjs';
import { PresentationContainer } from './interfaces/presentation_container';

export const getMockPresentationContainer = (): PresentationContainer => {
return {
registerPanelApi: jest.fn(),
removePanel: jest.fn(),
replacePanel: jest.fn(),
lastSavedState: new Subject<void>(),
getLastSavedStateForChild: jest.fn(),
};
};
6 changes: 5 additions & 1 deletion packages/presentation/presentation_containers/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": ["@kbn/presentation-publishing", "@kbn/core-mount-utils-browser"]
"kbn_references": [
"@kbn/presentation-publishing",
"@kbn/core-mount-utils-browser",
"@kbn/core-saved-objects-api-server",
]
}
5 changes: 5 additions & 0 deletions packages/presentation/presentation_publishing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export {
type PublishesWritableViewMode,
type ViewMode,
} from './interfaces/publishes_view_mode';
export {
type PublishesUnsavedChanges,
apiPublishesUnsavedChanges,
useUnsavedChanges,
} from './interfaces/publishes_unsaved_changes';
export {
useBatchedPublishingSubjects,
useStateFromPublishingSubject,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { useStateFromPublishingSubject } from '../publishing_subject';
import { apiHasParentApi, HasParentApi } from './has_parent_api';
import { apiPublishesViewMode, PublishesViewMode, ViewMode } from './publishes_view_mode';
import { apiPublishesViewMode, PublishesViewMode } from './publishes_view_mode';

/**
* This API can access a view mode, either its own or from its parent API.
Expand Down Expand Up @@ -49,6 +49,5 @@ export const getViewModeSubject = (api?: CanAccessViewMode) => {
export const useInheritedViewMode = <ApiType extends CanAccessViewMode = CanAccessViewMode>(
api: ApiType | undefined
) => {
const subject = getViewModeSubject(api);
useStateFromPublishingSubject<ViewMode, typeof subject>(subject);
return useStateFromPublishingSubject(getViewModeSubject(api));
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,4 @@ export const apiPublishesBlockingError = (
* Gets this API's fatal error as a reactive variable which will cause re-renders on change.
*/
export const useBlockingError = (api: Partial<PublishesBlockingError> | undefined) =>
useStateFromPublishingSubject<Error | undefined, PublishesBlockingError['blockingError']>(
api?.blockingError
);
useStateFromPublishingSubject(api?.blockingError);
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,4 @@ export const apiPublishesDataLoading = (
* Gets this API's data loading state as a reactive variable which will cause re-renders on change.
*/
export const useDataLoading = (api: Partial<PublishesDataLoading> | undefined) =>
useStateFromPublishingSubject<boolean | undefined, PublishesDataLoading['dataLoading']>(
apiPublishesDataLoading(api) ? api.dataLoading : undefined
);
useStateFromPublishingSubject(apiPublishesDataLoading(api) ? api.dataLoading : undefined);
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,4 @@ export const apiPublishesDataViews = (
* Gets this API's data views as a reactive variable which will cause re-renders on change.
*/
export const useDataViews = (api: Partial<PublishesDataViews> | undefined) =>
useStateFromPublishingSubject<DataView[] | undefined, PublishesDataViews['dataViews']>(
apiPublishesDataViews(api) ? api.dataViews : undefined
);
useStateFromPublishingSubject(apiPublishesDataViews(api) ? api.dataViews : undefined);
Loading

0 comments on commit 4f2d2d4

Please sign in to comment.