[Embeddable Rebuild] Change Parenting Structure (#178426)

Changes the structure of the React Embeddable system's factories to reduce boilerplate and potential for bugs.
This commit is contained in:
Devon Thomson 2024-03-13 12:38:08 -04:00 committed by GitHub
parent c312baeec1
commit fa799d7a08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 533 additions and 598 deletions

View file

@ -10,12 +10,8 @@ import { EuiMarkdownEditor, EuiMarkdownFormat } from '@elastic/eui';
import { css } from '@emotion/react';
import {
initializeReactEmbeddableTitles,
initializeReactEmbeddableUuid,
ReactEmbeddableFactory,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
useReactEmbeddableUnsavedChanges,
} from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { useInheritedViewMode, useStateFromPublishingSubject } from '@kbn/presentation-publishing';
@ -25,70 +21,64 @@ import { BehaviorSubject } from 'rxjs';
import { EUI_MARKDOWN_ID } from './constants';
import { MarkdownEditorSerializedState, MarkdownEditorApi } from './types';
export const registerMarkdownEditorEmbeddable = () => {
const markdownEmbeddableFactory: ReactEmbeddableFactory<
MarkdownEditorSerializedState,
MarkdownEditorApi
> = {
deserializeState: (state) => {
/**
* Here we can run migrations and inject references.
*/
return state.rawState as MarkdownEditorSerializedState;
},
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);
const markdownEmbeddableFactory: ReactEmbeddableFactory<
MarkdownEditorSerializedState,
MarkdownEditorApi
> = {
type: EUI_MARKDOWN_ID,
deserializeState: (state) => {
/**
* Here we can run clientside migrations and inject references.
*/
return state.rawState as MarkdownEditorSerializedState;
},
/**
* The buildEmbeddable function 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.
*/
buildEmbeddable: async (state, buildApi) => {
/**
* initialize state (source of truth)
*/
const { titlesApi, titleComparators, serializeTitles } = initializeReactEmbeddableTitles(state);
const content$ = 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(),
},
};
/**
* Register the API for this embeddable. This API will be published into the imperative handle
* of the React component. Methods on this API will be exposed to siblings, to registered actions
* and to the parent api.
*/
const api = buildApi(
{
...titlesApi,
serializeState: () => {
return {
rawState: {
...serializeTitles(),
content: content$.getValue(),
},
},
apiRef,
uuid
);
};
},
},
/**
* Provide state comparators. Each comparator is 3 element tuple:
* 1) current value (publishing subject)
* 2) setter, allowing parent to reset value
* 3) optional comparator which provides logic to diff lasted stored value and current value
*/
{
content: [content$, (value) => content$.next(value)],
...titleComparators,
}
);
return {
api,
Component: () => {
// get state for rendering
const content = useStateFromPublishingSubject(contentSubject);
const viewMode = useInheritedViewMode(thisApi) ?? 'view';
const content = useStateFromPublishingSubject(content$);
const viewMode = useInheritedViewMode(api) ?? 'view';
return viewMode === 'edit' ? (
<EuiMarkdownEditor
@ -96,7 +86,7 @@ export const registerMarkdownEditorEmbeddable = () => {
width: 100%;
`}
value={content ?? ''}
onChange={(value) => contentSubject.next(value)}
onChange={(value) => content$.next(value)}
aria-label={i18n.translate('embeddableExamples.euiMarkdownEditor.ariaLabel', {
defaultMessage: 'Dashboard markdown editor',
})}
@ -105,20 +95,21 @@ export const registerMarkdownEditorEmbeddable = () => {
) : (
<EuiMarkdownFormat
css={css`
padding: ${euiThemeVars.euiSizeS};
padding: ${euiThemeVars.euiSizeM};
`}
>
{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
*/
registerReactEmbeddableFactory(EUI_MARKDOWN_ID, markdownEmbeddableFactory);
},
};
},
};
/**
* 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(markdownEmbeddableFactory);

View file

@ -15,4 +15,4 @@ export type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & {
content: string;
};
export type MarkdownEditorApi = DefaultEmbeddableApi;
export type MarkdownEditorApi = DefaultEmbeddableApi<MarkdownEditorSerializedState>;

View file

@ -12,19 +12,15 @@ import { ChartsPluginStart } from '@kbn/charts-plugin/public';
import { Reference } from '@kbn/content-management-utils';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataView } from '@kbn/data-views-plugin/common';
import {
DataViewsPublicPluginStart,
DATA_VIEW_SAVED_OBJECT_TYPE,
type DataView,
} from '@kbn/data-views-plugin/public';
import {
initializeReactEmbeddableTitles,
initializeReactEmbeddableUuid,
ReactEmbeddableFactory,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
useReactEmbeddableUnsavedChanges,
} from '@kbn/embeddable-plugin/public';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { i18n } from '@kbn/i18n';
@ -37,7 +33,7 @@ import {
} from '@kbn/unified-field-list';
import { cloneDeep } from 'lodash';
import React, { useEffect, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Subscription } from 'rxjs';
import { FIELD_LIST_DATA_VIEW_REF_NAME, FIELD_LIST_ID } from './constants';
import { FieldListApi, FieldListSerializedStateState } from './types';
@ -72,150 +68,164 @@ export const registerFieldListFactory = (
FieldListSerializedStateState,
FieldListApi
> = {
type: FIELD_LIST_ID,
deserializeState: (state) => {
const serializedState = cloneDeep(state.rawState) as FieldListSerializedStateState;
// inject the reference
const dataViewIdRef = state.references?.find(
(ref) => ref.name === FIELD_LIST_DATA_VIEW_REF_NAME
);
if (dataViewIdRef && serializedState) {
// if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this)
if (dataViewIdRef && serializedState && !serializedState.dataViewId) {
serializedState.dataViewId = dataViewIdRef?.id;
}
return serializedState;
},
getComponent: async (initialState, maybeId) => {
const uuid = initializeReactEmbeddableUuid(maybeId);
buildEmbeddable: async (initialState, buildApi) => {
const subscriptions = new Subscription();
const { titlesApi, titleComparators, serializeTitles } =
initializeReactEmbeddableTitles(initialState);
const allDataViews = await dataViews.getIdsWithTitle();
const selectedDataViewId$ = new BehaviorSubject<string | undefined>(
initialState.dataViewId ?? (await dataViews.getDefaultDataView())?.id
);
// transform data view ID into data views array.
const getDataViews = async (id?: string) => {
return id ? [await dataViews.get(id)] : undefined;
};
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(
await getDataViews(initialState.dataViewId)
);
subscriptions.add(
selectedDataViewId$.subscribe(async (id) => dataViews$.next(await getDataViews(id)))
);
const selectedFieldNames$ = new BehaviorSubject<string[] | undefined>(
initialState.selectedFieldNames
);
return RegisterReactEmbeddable((apiRef) => {
const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges(
uuid,
fieldListEmbeddableFactory,
{
dataViewId: [selectedDataViewId$, (value) => selectedDataViewId$.next(value)],
selectedFieldNames: [
selectedFieldNames$,
(value) => selectedFieldNames$.next(value),
(a, b) => {
return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? '');
const api = buildApi(
{
...titlesApi,
serializeState: () => {
const dataViewId = selectedDataViewId$.getValue();
const references: Reference[] = dataViewId
? [
{
type: DATA_VIEW_SAVED_OBJECT_TYPE,
name: FIELD_LIST_DATA_VIEW_REF_NAME,
id: dataViewId,
},
]
: [];
return {
rawState: {
...serializeTitles(),
// here we skip serializing the dataViewId, because the reference contains that information.
selectedFieldNames: selectedFieldNames$.getValue(),
},
],
...titleComparators,
}
);
useReactEmbeddableApiHandle(
{
...titlesApi,
unsavedChanges,
resetUnsavedChanges,
serializeState: async () => {
const dataViewId = selectedDataViewId$.getValue();
const references: Reference[] = dataViewId
? [
{
type: DATA_VIEW_SAVED_OBJECT_TYPE,
name: FIELD_LIST_DATA_VIEW_REF_NAME,
id: dataViewId,
},
]
: [];
return {
rawState: {
...serializeTitles(),
// here we skip serializing the dataViewId, because the reference contains that information.
selectedFieldNames: selectedFieldNames$.getValue(),
},
references,
};
},
references,
};
},
apiRef,
uuid
);
},
{
...titleComparators,
dataViewId: [selectedDataViewId$, (value) => selectedDataViewId$.next(value)],
selectedFieldNames: [
selectedFieldNames$,
(value) => selectedFieldNames$.next(value),
(a, b) => {
return (a?.slice().sort().join(',') ?? '') === (b?.slice().sort().join(',') ?? '');
},
],
}
);
const [selectedDataViewId, selectedFieldNames] = useBatchedPublishingSubjects(
selectedDataViewId$,
selectedFieldNames$
);
return {
api,
Component: () => {
const [selectedDataViewId, selectedFieldNames] = useBatchedPublishingSubjects(
selectedDataViewId$,
selectedFieldNames$
);
const [selectedDataView, setSelectedDataView] = useState<DataView | undefined>(undefined);
const [selectedDataView, setSelectedDataView] = useState<DataView | undefined>(undefined);
useEffect(() => {
if (!selectedDataViewId) return;
let mounted = true;
(async () => {
const dataView = await dataViews.get(selectedDataViewId);
if (!mounted) return;
setSelectedDataView(dataView);
})();
return () => {
mounted = false;
};
}, [selectedDataViewId]);
useEffect(() => {
if (!selectedDataViewId) return;
let mounted = true;
(async () => {
const dataView = await dataViews.get(selectedDataViewId);
if (!mounted) return;
setSelectedDataView(dataView);
})();
return () => {
mounted = false;
};
}, [selectedDataViewId]);
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem
grow={false}
css={css`
padding: ${euiThemeVars.euiSizeS};
`}
>
<DataViewPicker
dataViews={allDataViews}
selectedDataViewId={selectedDataViewId}
onChangeDataViewId={(nextSelection) => {
selectedDataViewId$.next(nextSelection);
}}
trigger={{
label:
selectedDataView?.getName() ??
i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', {
defaultMessage: 'Please select a data view',
}),
}}
/>
</EuiFlexItem>
<EuiFlexItem>
{selectedDataView ? (
<UnifiedFieldListSidebarContainer
fullWidth={true}
variant="list-always"
dataView={selectedDataView}
allFields={selectedDataView.fields}
getCreationOptions={getCreationOptions}
workspaceSelectedFieldNames={selectedFieldNames}
services={{ dataViews, data, fieldFormats, charts, core }}
onAddFieldToWorkspace={(field) =>
selectedFieldNames$.next([
...(selectedFieldNames$.getValue() ?? []),
field.name,
])
}
onRemoveFieldFromWorkspace={(field) => {
selectedFieldNames$.next(
(selectedFieldNames$.getValue() ?? []).filter((name) => name !== field.name)
);
// On destroy
useEffect(() => {
return () => {
subscriptions.unsubscribe();
};
}, []);
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem
grow={false}
css={css`
padding: ${euiThemeVars.euiSizeS};
`}
>
<DataViewPicker
dataViews={allDataViews}
selectedDataViewId={selectedDataViewId}
onChangeDataViewId={(nextSelection) => {
selectedDataViewId$.next(nextSelection);
}}
trigger={{
label:
selectedDataView?.getName() ??
i18n.translate('embeddableExamples.unifiedFieldList.selectDataViewMessage', {
defaultMessage: 'Please select a data view',
}),
}}
/>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
);
});
</EuiFlexItem>
<EuiFlexItem>
{selectedDataView ? (
<UnifiedFieldListSidebarContainer
fullWidth={true}
variant="list-always"
dataView={selectedDataView}
allFields={selectedDataView.fields}
getCreationOptions={getCreationOptions}
workspaceSelectedFieldNames={selectedFieldNames}
services={{ dataViews, data, fieldFormats, charts, core }}
onAddFieldToWorkspace={(field) =>
selectedFieldNames$.next([
...(selectedFieldNames$.getValue() ?? []),
field.name,
])
}
onRemoveFieldFromWorkspace={(field) => {
selectedFieldNames$.next(
(selectedFieldNames$.getValue() ?? []).filter((name) => name !== field.name)
);
}}
/>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
);
},
};
},
};
registerReactEmbeddableFactory(FIELD_LIST_ID, fieldListEmbeddableFactory);
registerReactEmbeddableFactory(fieldListEmbeddableFactory);
};

View file

@ -20,7 +20,11 @@ export {
type PresentationContainer,
} from './interfaces/presentation_container';
export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays';
export { type SerializedPanelState } from './interfaces/serialized_state';
export {
type SerializedPanelState,
type HasSerializableState,
apiHasSerializableState,
} from './interfaces/serialized_state';
export {
type PublishesLastSavedState,
apiPublishesLastSavedState,

View file

@ -17,3 +17,11 @@ export interface SerializedPanelState<RawStateType extends object = object> {
rawState: RawStateType;
version?: string;
}
export interface HasSerializableState<StateType extends object = object> {
serializeState: () => SerializedPanelState<StateType>;
}
export const apiHasSerializableState = (api: unknown | null): api is HasSerializableState => {
return Boolean((api as HasSerializableState)?.serializeState);
};

View file

@ -105,9 +105,10 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
if (reactEmbeddableRegistryHasKey(type)) {
return (
<ReactEmbeddableRenderer
uuid={id}
key={`${type}_${id}`}
type={type}
maybeId={id}
parentApi={container}
key={`${type}_${id}`}
state={{ rawState: panel.explicitInput, version: panel.version, references }}
/>
);

View file

@ -14,7 +14,6 @@ import {
isReferenceOrValueEmbeddable,
reactEmbeddableRegistryHasKey,
} from '@kbn/embeddable-plugin/public';
import { SerializedPanelState } from '@kbn/presentation-containers';
import { showSaveModal } from '@kbn/saved-objects-plugin/public';
import { cloneDeep } from 'lodash';
import React from 'react';
@ -36,28 +35,16 @@ const serializeAllPanelState = async (
dashboard: DashboardContainer
): Promise<{ panels: DashboardContainerInput['panels']; references: Reference[] }> => {
const references: Reference[] = [];
const reactEmbeddableSavePromises: Array<
Promise<{ serializedState: SerializedPanelState; uuid: string }>
> = [];
const panels = cloneDeep(dashboard.getInput().panels);
for (const [uuid, panel] of Object.entries(panels)) {
if (!reactEmbeddableRegistryHasKey(panel.type)) continue;
const api = dashboard.reactEmbeddableChildren.value[uuid];
if (api) {
reactEmbeddableSavePromises.push(
new Promise((resolve) => {
api.serializeState().then((serializedState) => {
resolve({ serializedState, uuid });
});
})
);
const serializedState = api.serializeState();
panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid };
references.push(...prefixReferencesFromPanel(uuid, serializedState.references ?? []));
}
}
const saveResults = await Promise.all(reactEmbeddableSavePromises);
for (const { serializedState, uuid } of saveResults) {
panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid };
references.push(...prefixReferencesFromPanel(uuid, serializedState.references ?? []));
}
return { panels, references };
};

View file

@ -20,7 +20,6 @@ import {
EmbeddableFactoryNotFoundError,
isExplicitInputWithAttributes,
PanelNotFoundError,
ReactEmbeddableParentContext,
reactEmbeddableRegistryHasKey,
ViewMode,
type EmbeddableFactory,
@ -304,9 +303,7 @@ export class DashboardContainer
>
<KibanaThemeProvider theme$={this.theme$}>
<DashboardContainerContext.Provider value={this}>
<ReactEmbeddableParentContext.Provider value={{ parentApi: this }}>
<DashboardViewport />
</ReactEmbeddableParentContext.Provider>
<DashboardViewport />
</DashboardContainerContext.Provider>
</KibanaThemeProvider>
</ExitFullScreenButtonKibanaProvider>

View file

@ -96,21 +96,15 @@ export type { EnhancementRegistryDefinition } from './types';
export {
ReactEmbeddableRenderer,
reactEmbeddableRegistryHasKey,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
type DefaultEmbeddableApi,
type ReactEmbeddable,
type ReactEmbeddableFactory,
type ReactEmbeddableRegistration,
type ReactEmbeddableTitlesApi,
type SerializedReactEmbeddableTitles,
ReactEmbeddableParentContext,
useReactEmbeddableParentApi,
useReactEmbeddableUnsavedChanges,
initializeReactEmbeddableUuid,
initializeReactEmbeddableTitles,
serializeReactEmbeddableTitles,
startTrackingEmbeddableUnsavedChanges,
} from './react_embeddable_system';
export { registerSavedObjectToPanelMethod } from './registry/saved_object_to_panel_methods';

View file

@ -6,16 +6,8 @@
* Side Public License, v 1.
*/
export {
useReactEmbeddableApiHandle,
initializeReactEmbeddableUuid,
ReactEmbeddableParentContext,
useReactEmbeddableParentApi,
} from './react_embeddable_api';
export { useReactEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
export {
reactEmbeddableRegistryHasKey,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
} from './react_embeddable_registry';
export { ReactEmbeddableRenderer } from './react_embeddable_renderer';
@ -25,9 +17,9 @@ export {
type ReactEmbeddableTitlesApi,
type SerializedReactEmbeddableTitles,
} from './react_embeddable_titles';
export { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
export type {
DefaultEmbeddableApi,
ReactEmbeddable,
ReactEmbeddableFactory,
ReactEmbeddableRegistration,
} from './types';

View file

@ -1,87 +0,0 @@
/*
* 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 { render, waitFor } from '@testing-library/react';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { useReactEmbeddableApiHandle, ReactEmbeddableParentContext } from './react_embeddable_api';
import { DefaultEmbeddableApi } from './types';
describe('react embeddable api', () => {
const defaultApi = {
unsavedChanges: new BehaviorSubject<object | undefined>(undefined),
resetUnsavedChanges: jest.fn(),
serializeState: jest.fn().mockReturnValue({ bork: 'borkbork' }),
};
const parentApi = getMockPresentationContainer();
const TestComponent = React.forwardRef<DefaultEmbeddableApi>((_, ref) => {
useReactEmbeddableApiHandle(defaultApi, ref, '123');
return <div />;
});
it('returns the given API', () => {
const { result } = renderHook(() =>
useReactEmbeddableApiHandle<DefaultEmbeddableApi & { bork: () => 'bork' }>(
{
...defaultApi,
bork: jest.fn().mockReturnValue('bork'),
},
{} as any,
'superBork'
)
);
expect(result.current.bork()).toEqual('bork');
expect(result.current.serializeState()).toEqual({ bork: 'borkbork' });
});
it('publishes the API into the provided ref', async () => {
const ref = React.createRef<DefaultEmbeddableApi>();
renderHook(() => useReactEmbeddableApiHandle(defaultApi, ref, '123'));
await waitFor(() => expect(ref.current).toBeDefined());
expect(ref.current?.serializeState);
expect(ref.current?.serializeState()).toEqual({ bork: 'borkbork' });
});
it('publishes the API into an imperative handle', async () => {
const ref = React.createRef<DefaultEmbeddableApi>();
render(<TestComponent ref={ref} />);
await waitFor(() => expect(ref.current).toBeDefined());
expect(ref.current?.serializeState);
expect(ref.current?.serializeState()).toEqual({ bork: 'borkbork' });
});
it('returns an API with a parent when rendered inside a parent context', async () => {
const ref = React.createRef<DefaultEmbeddableApi>();
render(
<ReactEmbeddableParentContext.Provider value={{ parentApi }}>
<TestComponent ref={ref} />
</ReactEmbeddableParentContext.Provider>
);
await waitFor(() => expect(ref.current).toBeDefined());
expect(ref.current?.serializeState);
expect(ref.current?.serializeState()).toEqual({ bork: 'borkbork' });
expect(ref.current?.parentApi?.getLastSavedStateForChild).toBeDefined();
expect(ref.current?.parentApi?.registerPanelApi).toBeDefined();
});
it('calls registerPanelApi on its parent', async () => {
const ref = React.createRef<DefaultEmbeddableApi>();
render(
<ReactEmbeddableParentContext.Provider value={{ parentApi }}>
<TestComponent ref={ref} />
</ReactEmbeddableParentContext.Provider>
);
expect(parentApi?.registerPanelApi).toHaveBeenCalledWith('123', expect.any(Object));
});
});

View file

@ -1,74 +0,0 @@
/*
* 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 { apiIsPresentationContainer, PresentationContainer } from '@kbn/presentation-containers';
import { createContext, useContext, useImperativeHandle, useMemo } from 'react';
import { v4 as generateId } from 'uuid';
import { DefaultEmbeddableApi } from './types';
/**
* Pushes any API to the passed in ref. Note that any API passed in will not be rebuilt on
* subsequent renders, so it does not support reactive variables. Instead, pass in setter functions
* and publishing subjects to allow other components to listen to changes.
*/
export const useReactEmbeddableApiHandle = <
ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi
>(
apiToRegister: Omit<ApiType, 'parent'>,
ref: React.ForwardedRef<ApiType>,
uuid: string
) => {
const { parentApi } = useReactEmbeddableParentContext() ?? {};
/**
* Publish the api for this embeddable.
*/
const thisApi = useMemo(
() => {
const api = {
...apiToRegister,
uuid,
// allow this embeddable access to its parent
parentApi,
} as ApiType;
// register this api with its parent
if (parentApi && apiIsPresentationContainer(parentApi))
parentApi.registerPanelApi<DefaultEmbeddableApi>(uuid, api);
return api;
},
// disabling exhaustive deps because the API should only be rebuilt when the uuid changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[uuid]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
useImperativeHandle(ref, () => thisApi, [uuid]);
return thisApi;
};
export const initializeReactEmbeddableUuid = (maybeId?: string) => maybeId ?? generateId();
/**
* Parenting
*/
interface ReactEmbeddableParentContext {
parentApi?: PresentationContainer;
}
export const ReactEmbeddableParentContext = createContext<ReactEmbeddableParentContext | null>(
null
);
export const useReactEmbeddableParentApi = (): unknown | null => {
return useContext<ReactEmbeddableParentContext | null>(ReactEmbeddableParentContext)?.parentApi;
};
export const useReactEmbeddableParentContext = (): ReactEmbeddableParentContext | null => {
return useContext<ReactEmbeddableParentContext | null>(ReactEmbeddableParentContext);
};

View file

@ -15,8 +15,9 @@ import { ReactEmbeddableFactory } from './types';
describe('react embeddable registry', () => {
const testEmbeddableFactory: ReactEmbeddableFactory = {
type: 'test',
deserializeState: jest.fn(),
getComponent: jest.fn(),
buildEmbeddable: jest.fn(),
};
it('throws an error if requested embeddable factory type is not registered', () => {
@ -26,7 +27,7 @@ describe('react embeddable registry', () => {
});
it('can register and get an embeddable factory', () => {
registerReactEmbeddableFactory('test', testEmbeddableFactory);
registerReactEmbeddableFactory(testEmbeddableFactory);
expect(getReactEmbeddableFactory('test')).toBe(testEmbeddableFactory);
});

View file

@ -7,38 +7,31 @@
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import {
DefaultEmbeddableApi,
ReactEmbeddable,
ReactEmbeddableFactory,
ReactEmbeddableRegistration,
} from './types';
import { DefaultEmbeddableApi, ReactEmbeddableFactory } from './types';
const registry: { [key: string]: ReactEmbeddableFactory<any, any> } = {};
export const registerReactEmbeddableFactory = <
StateType extends unknown = unknown,
APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi
StateType extends object = object,
APIType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
>(
key: string,
factory: ReactEmbeddableFactory<StateType, APIType>
) => {
if (registry[key] !== undefined)
if (registry[factory.type] !== undefined)
throw new Error(
i18n.translate('embeddableApi.reactEmbeddable.factoryAlreadyExistsError', {
defaultMessage: 'An embeddable factory for for type: {key} is already registered.',
values: { key },
values: { key: factory.type },
})
);
registry[key] = factory;
registry[factory.type] = factory;
};
export const reactEmbeddableRegistryHasKey = (key: string) => registry[key] !== undefined;
export const getReactEmbeddableFactory = <
StateType extends unknown = unknown,
ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi
StateType extends object = object,
ApiType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
>(
key: string
): ReactEmbeddableFactory<StateType, ApiType> => {
@ -51,11 +44,3 @@ export const getReactEmbeddableFactory = <
);
return registry[key];
};
/**
* A helper function which transforms a component into an Embeddable component by forwarding a ref which
* should be used with `useEmbeddableApiHandle` to expose an API for your component.
*/
export const RegisterReactEmbeddable: <ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi>(
component: ReactEmbeddableRegistration<ApiType>
) => ReactEmbeddable<ApiType> = (component) => React.forwardRef((_, apiRef) => component(apiRef));

View file

@ -5,36 +5,122 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { render, waitFor, screen } from '@testing-library/react';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { registerReactEmbeddableFactory } from './react_embeddable_registry';
import { ReactEmbeddableRenderer } from './react_embeddable_renderer';
import { ReactEmbeddableFactory } from './types';
describe('react embeddable renderer', () => {
const testEmbeddableFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = {
deserializeState: jest.fn(),
getComponent: jest.fn().mockResolvedValue(() => {
return <div>SUPER TEST COMPONENT</div>;
}),
type: 'test',
deserializeState: jest.fn().mockImplementation((state) => state.rawState),
buildEmbeddable: async (state, registerApi) => {
const api = registerApi(
{
serializeState: () => ({
rawState: {
name: state.name,
bork: state.bork,
},
}),
},
{
name: [new BehaviorSubject<string>(state.name), () => {}],
bork: [new BehaviorSubject<string>(state.bork), () => {}],
}
);
return {
Component: () => (
<div>
SUPER TEST COMPONENT, name: {state.name} bork: {state.bork}
</div>
),
api,
};
},
};
beforeAll(() => {
registerReactEmbeddableFactory('test', testEmbeddableFactory);
registerReactEmbeddableFactory(testEmbeddableFactory);
});
it('deserializes given state', () => {
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { blorp: 'blorp?' } }} />);
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { bork: 'blorp?' } }} />);
expect(testEmbeddableFactory.deserializeState).toHaveBeenCalledWith({
rawState: { blorp: 'blorp?' },
rawState: { bork: 'blorp?' },
});
});
it('builds the embeddable', () => {
const buildEmbeddableSpy = jest.spyOn(testEmbeddableFactory, 'buildEmbeddable');
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { bork: 'blorp?' } }} />);
expect(buildEmbeddableSpy).toHaveBeenCalledWith({ bork: 'blorp?' }, expect.any(Function));
});
it('renders the given component once it resolves', () => {
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { blorp: 'blorp?' } }} />);
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { name: 'Kuni Garu' } }} />);
waitFor(() => {
expect(screen.findByText('SUPER TEST COMPONENT')).toBeInTheDocument();
expect(screen.findByText('SUPER TEST COMPONENT, name: Kuni Garu')).toBeInTheDocument();
});
});
it('publishes the API into the provided callback', async () => {
const onApiAvailable = jest.fn();
render(
<ReactEmbeddableRenderer
type={'test'}
maybeId={'12345'}
onApiAvailable={onApiAvailable}
state={{ rawState: { name: 'Kuni Garu' } }}
/>
);
await waitFor(() =>
expect(onApiAvailable).toHaveBeenCalledWith({
type: 'test',
uuid: '12345',
parentApi: undefined,
unsavedChanges: expect.any(Object),
serializeState: expect.any(Function),
resetUnsavedChanges: expect.any(Function),
})
);
});
it('initializes a new ID when one is not given', async () => {
const onApiAvailable = jest.fn();
render(
<ReactEmbeddableRenderer
type={'test'}
onApiAvailable={onApiAvailable}
state={{ rawState: { name: 'Kuni Garu' } }}
/>
);
await waitFor(() =>
expect(onApiAvailable).toHaveBeenCalledWith(
expect.objectContaining({ uuid: expect.any(String) })
)
);
});
it('registers the API with the parent API', async () => {
const onApiAvailable = jest.fn();
const parentApi = getMockPresentationContainer();
render(
<ReactEmbeddableRenderer
type={'test'}
maybeId={'12345'}
parentApi={parentApi}
onApiAvailable={onApiAvailable}
state={{ rawState: { name: 'Kuni Garu' } }}
/>
);
await waitFor(() => {
expect(onApiAvailable).toHaveBeenCalledWith(expect.objectContaining({ parentApi }));
expect(parentApi.registerPanelApi).toHaveBeenCalledWith('12345', expect.any(Object));
});
});
});

View file

@ -6,30 +6,92 @@
* Side Public License, v 1.
*/
import { SerializedPanelState } from '@kbn/presentation-containers';
import {
apiIsPresentationContainer,
PresentationContainer,
SerializedPanelState,
} from '@kbn/presentation-containers';
import { PresentationPanel } from '@kbn/presentation-panel-plugin/public';
import React, { useMemo } from 'react';
import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import { v4 as generateId } from 'uuid';
import { getReactEmbeddableFactory } from './react_embeddable_registry';
import { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
import {
DefaultEmbeddableApi,
EmbeddableStateComparators,
ReactEmbeddableApiRegistration,
ReactEmbeddableFactory,
} from './types';
/**
* Renders a component from the React Embeddable registry into a Presentation Panel.
*
* TODO: Rename this to simply `Embeddable` when the legacy Embeddable system is removed.
*/
export const ReactEmbeddableRenderer = ({
uuid,
export const ReactEmbeddableRenderer = <
StateType extends object = object,
ApiType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
>({
maybeId,
type,
state,
parentApi,
onApiAvailable,
}: {
uuid?: string;
maybeId?: string;
type: string;
state: SerializedPanelState;
state: SerializedPanelState<StateType>;
parentApi?: PresentationContainer;
onApiAvailable?: (api: ApiType) => void;
}) => {
const cleanupFunction = useRef<(() => void) | null>(null);
const componentPromise = useMemo(
() =>
(async () => {
const factory = getReactEmbeddableFactory(type);
return await factory.getComponent(factory.deserializeState(state), uuid);
const factory = getReactEmbeddableFactory(type) as ReactEmbeddableFactory<
StateType,
ApiType
>;
const registerApi = (
apiRegistration: ReactEmbeddableApiRegistration<StateType, ApiType>,
comparators: EmbeddableStateComparators<StateType>
) => {
const uuid = maybeId ?? generateId();
const { unsavedChanges, resetUnsavedChanges, cleanup } =
startTrackingEmbeddableUnsavedChanges(
uuid,
parentApi,
comparators,
factory.deserializeState
);
const fullApi = {
...apiRegistration,
uuid,
parentApi,
unsavedChanges,
resetUnsavedChanges,
type: factory.type,
} as unknown as ApiType;
if (parentApi && apiIsPresentationContainer(parentApi)) {
parentApi.registerPanelApi(uuid, fullApi);
}
cleanupFunction.current = () => cleanup();
onApiAvailable?.(fullApi);
return fullApi;
};
const { api, Component } = await factory.buildEmbeddable(
factory.deserializeState(state),
registerApi
);
return React.forwardRef<typeof api>((_, ref) => {
// expose the api into the imperative handle
useImperativeHandle(ref, () => api, []);
return <Component />;
});
})(),
/**
* Disabling exhaustive deps because we do not want to re-fetch the component
@ -38,5 +100,12 @@ export const ReactEmbeddableRenderer = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[type]
);
useEffect(() => {
return () => {
cleanupFunction.current?.();
};
}, []);
return <PresentationPanel Component={componentPromise} />;
};

View file

@ -12,13 +12,10 @@ import {
SerializedPanelState,
} from '@kbn/presentation-containers';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { PublishesUnsavedChanges } from '@kbn/presentation-publishing';
import { render, waitFor } from '@testing-library/react';
import React, { useImperativeHandle } from 'react';
import { waitFor } from '@testing-library/react';
import { BehaviorSubject, Subject } from 'rxjs';
import { ReactEmbeddableParentContext } from './react_embeddable_api';
import { useReactEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
import { EmbeddableStateComparators, ReactEmbeddableFactory } from './types';
import { startTrackingEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
import { EmbeddableStateComparators } from './types';
interface SuperTestStateType {
name: string;
@ -58,7 +55,7 @@ describe('react embeddable unsaved changes', () => {
return defaultComparators;
};
const renderTestComponent = async (
const startTrackingUnsavedChanges = (
customComparators?: EmbeddableStateComparators<SuperTestStateType>
) => {
comparators = customComparators ?? initializeDefaultComparators();
@ -69,70 +66,36 @@ describe('react embeddable unsaved changes', () => {
getLastSavedStateForChild: () => ({ rawState: lastSavedState }),
lastSavedState: new Subject<void>(),
};
let apiToReturn: PublishesUnsavedChanges | null = null;
const TestComponent = React.forwardRef<PublishesUnsavedChanges>((props, ref) => {
const unsavedChangesApi = useReactEmbeddableUnsavedChanges(
'someId',
{ deserializeState } as ReactEmbeddableFactory<SuperTestStateType>,
comparators
);
useImperativeHandle(ref, () => unsavedChangesApi);
return <div>A Test Component</div>;
});
const componentElement = (
<TestComponent
ref={(outApi) => {
apiToReturn = outApi;
}}
/>
);
if (parentApi) {
render(
<ReactEmbeddableParentContext.Provider value={{ parentApi }}>
{componentElement}
</ReactEmbeddableParentContext.Provider>
);
} else {
render(componentElement);
}
await waitFor(() => {
expect(apiToReturn).toBeDefined();
});
return apiToReturn as unknown as PublishesUnsavedChanges;
return startTrackingEmbeddableUnsavedChanges('id', parentApi, comparators, deserializeState);
};
it('should return undefined unsaved changes when used without a parent context to provide the last saved state', async () => {
parentApi = null;
const unsavedChangesApi = await renderTestComponent();
const unsavedChangesApi = startTrackingUnsavedChanges();
expect(unsavedChangesApi).toBeDefined();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
it('runs factory deserialize function on last saved state', async () => {
await renderTestComponent();
startTrackingUnsavedChanges();
expect(deserializeState).toHaveBeenCalledWith({ rawState: lastSavedState });
});
it('should return unsaved changes subject initialized to undefined when no unsaved changes are detected', async () => {
const unsavedChangesApi = await renderTestComponent();
const unsavedChangesApi = startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
it('should return unsaved changes subject initialized with diff when unsaved changes are detected', async () => {
initialState.tagline = 'Testing is my speciality!';
const unsavedChangesApi = await renderTestComponent();
const unsavedChangesApi = startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
tagline: 'Testing is my speciality!',
});
});
it('should detect unsaved changes when state changes during the lifetime of the component', async () => {
const unsavedChangesApi = await renderTestComponent();
const unsavedChangesApi = startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.tagline[1]('Testing is my speciality!');
@ -144,7 +107,7 @@ describe('react embeddable unsaved changes', () => {
});
it('should detect unsaved changes when last saved state changes during the lifetime of the component', async () => {
const unsavedChangesApi = await renderTestComponent();
const unsavedChangesApi = startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
lastSavedState.tagline = 'Some other tagline';
@ -158,7 +121,7 @@ describe('react embeddable unsaved changes', () => {
});
it('should reset unsaved changes, calling given setters with last saved values. This should remove all unsaved state', async () => {
const unsavedChangesApi = await renderTestComponent();
const unsavedChangesApi = startTrackingUnsavedChanges();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.tagline[1]('Testing is my speciality!');
@ -188,7 +151,7 @@ describe('react embeddable unsaved changes', () => {
],
};
const unsavedChangesApi = await renderTestComponent(customComparators);
const unsavedChangesApi = startTrackingUnsavedChanges(customComparators);
// here we expect there to be no unsaved changes, both unsaved state and last saved state have two digits.
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);

View file

@ -6,13 +6,15 @@
* Side Public License, v 1.
*/
import { getLastSavedStateSubjectForChild } from '@kbn/presentation-containers';
import {
getLastSavedStateSubjectForChild,
PresentationContainer,
SerializedPanelState,
} from '@kbn/presentation-containers';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { useCallback, useEffect, useMemo } from 'react';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { combineLatestWith, debounceTime, map } from 'rxjs/operators';
import { useReactEmbeddableParentContext } from './react_embeddable_api';
import { EmbeddableStateComparators, ReactEmbeddableFactory } from './types';
import { EmbeddableStateComparators } from './types';
const defaultComparator = <T>(a: T, b: T) => a === b;
@ -28,6 +30,14 @@ const getInitialValuesFromComparators = <StateType extends object = object>(
return initialValues;
};
const getDefaultDiffingApi = () => {
return {
unsavedChanges: new BehaviorSubject<object | undefined>(undefined),
resetUnsavedChanges: () => {},
cleanup: () => {},
};
};
const runComparators = <StateType extends object = object>(
comparators: EmbeddableStateComparators<StateType>,
comparatorKeys: Array<keyof StateType>,
@ -49,85 +59,64 @@ const runComparators = <StateType extends object = object>(
return Object.keys(latestChanges).length > 0 ? latestChanges : undefined;
};
export const useReactEmbeddableUnsavedChanges = <StateType extends object = object>(
export const startTrackingEmbeddableUnsavedChanges = <StateType extends object = object>(
uuid: string,
factory: ReactEmbeddableFactory<StateType>,
comparators: EmbeddableStateComparators<StateType>
parentApi: PresentationContainer | undefined,
comparators: EmbeddableStateComparators<StateType>,
deserializeState: (state: SerializedPanelState<object>) => StateType
) => {
const { parentApi } = useReactEmbeddableParentContext() ?? {};
const lastSavedStateSubject = useMemo(
() => getLastSavedStateSubjectForChild<StateType>(parentApi, uuid, factory.deserializeState),
[factory.deserializeState, parentApi, uuid]
if (Object.keys(comparators).length === 0) return getDefaultDiffingApi();
const lastSavedStateSubject = getLastSavedStateSubjectForChild<StateType>(
parentApi,
uuid,
deserializeState
);
if (!lastSavedStateSubject) return getDefaultDiffingApi();
const comparatorSubjects: Array<PublishingSubject<unknown>> = [];
const comparatorKeys: Array<keyof StateType> = [];
for (const key of Object.keys(comparators) as Array<keyof StateType>) {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
comparatorSubjects.push(comparatorSubject as PublishingSubject<unknown>);
comparatorKeys.push(key);
}
const unsavedChanges = new BehaviorSubject<Partial<StateType> | undefined>(
runComparators(
comparators,
comparatorKeys,
lastSavedStateSubject?.getValue(),
getInitialValuesFromComparators(comparators, comparatorKeys)
)
);
const { comparatorSubjects, comparatorKeys } = useMemo(() => {
const subjects: Array<PublishingSubject<unknown>> = [];
const keys: Array<keyof StateType> = [];
for (const key of Object.keys(comparators) as Array<keyof StateType>) {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
subjects.push(comparatorSubject as PublishingSubject<unknown>);
keys.push(key);
}
return { comparatorKeys: keys, comparatorSubjects: subjects };
// disable exhaustive deps because the comparators must be static
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* set up unsaved changes subject, running an initial diff. If the parent API cannot provide
* last saved state, we return undefined.
*/
const unsavedChanges = useMemo(
() =>
new BehaviorSubject<Partial<StateType> | undefined>(
lastSavedStateSubject
? runComparators(
comparators,
comparatorKeys,
lastSavedStateSubject?.getValue(),
getInitialValuesFromComparators(comparators, comparatorKeys)
)
: undefined
const subscription = combineLatest(comparatorSubjects)
.pipe(
debounceTime(100),
map((latestStates) =>
comparatorKeys.reduce((acc, key, index) => {
acc[key] = latestStates[index] as StateType[typeof key];
return acc;
}, {} as Partial<StateType>)
),
// disable exhaustive deps because the comparators must be static
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
combineLatestWith(lastSavedStateSubject)
)
.subscribe(([latestStates, lastSavedState]) => {
unsavedChanges.next(
runComparators(comparators, comparatorKeys, lastSavedState, latestStates)
);
});
useEffect(() => {
if (!lastSavedStateSubject) return;
// subscribe to last saved state subject and all state comparators
const subscription = combineLatest(comparatorSubjects)
.pipe(
debounceTime(100),
map((latestStates) =>
comparatorKeys.reduce((acc, key, index) => {
acc[key] = latestStates[index] as StateType[typeof key];
return acc;
}, {} as Partial<StateType>)
),
combineLatestWith(lastSavedStateSubject)
)
.subscribe(([latestStates, lastSavedState]) => {
unsavedChanges.next(
runComparators(comparators, comparatorKeys, lastSavedState, latestStates)
);
});
return () => subscription.unsubscribe();
// disable exhaustive deps because the comparators must be static
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const resetUnsavedChanges = useCallback(() => {
const lastSaved = lastSavedStateSubject?.getValue();
for (const key of comparatorKeys) {
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
setter(lastSaved?.[key] as StateType[typeof key]);
}
// disable exhaustive deps because the comparators must be static
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { unsavedChanges, resetUnsavedChanges };
return {
unsavedChanges,
resetUnsavedChanges: () => {
const lastSaved = lastSavedStateSubject?.getValue();
for (const key of comparatorKeys) {
const setter = comparators[key][1]; // setter function is the 1st element of the tuple
setter(lastSaved?.[key] as StateType[typeof key]);
}
},
cleanup: () => subscription.unsubscribe(),
};
};

View file

@ -5,10 +5,10 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { SerializedPanelState } from '@kbn/presentation-containers';
import { HasSerializableState, SerializedPanelState } from '@kbn/presentation-containers';
import { DefaultPresentationPanelApi } from '@kbn/presentation-panel-plugin/public/panel_component/types';
import { PublishesUnsavedChanges, PublishingSubject } from '@kbn/presentation-publishing';
import { ReactElement } from 'react';
import { HasType, PublishesUnsavedChanges, PublishingSubject } from '@kbn/presentation-publishing';
import React, { ReactElement } from 'react';
export type ReactEmbeddableRegistration<
ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi
@ -19,26 +19,33 @@ export type ReactEmbeddableRegistration<
*
* Before adding anything to this interface, please be certain that it belongs in *every* embeddable.
*/
export type DefaultEmbeddableApi = DefaultPresentationPanelApi &
PublishesUnsavedChanges & {
serializeState: () => Promise<SerializedPanelState>;
};
export interface DefaultEmbeddableApi<StateType extends object = object>
extends DefaultPresentationPanelApi,
HasType,
PublishesUnsavedChanges,
HasSerializableState<StateType> {}
export type ReactEmbeddable<ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi> =
React.ForwardRefExoticComponent<React.RefAttributes<ApiType>>;
export type ReactEmbeddableApiRegistration<
StateType extends object = object,
ApiType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
> = Omit<ApiType, 'uuid' | 'parent' | 'type' | 'unsavedChanges' | 'resetUnsavedChanges'>;
export interface ReactEmbeddableFactory<
StateType extends unknown = unknown,
APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi
StateType extends object = object,
ApiType extends DefaultEmbeddableApi<StateType> = DefaultEmbeddableApi<StateType>
> {
getComponent: (initialState: StateType, maybeId?: string) => Promise<ReactEmbeddable<APIType>>;
deserializeState: (state: SerializedPanelState) => StateType;
type: string;
latestVersion?: string;
deserializeState: (state: SerializedPanelState) => StateType;
buildEmbeddable: (
initialState: StateType,
buildApi: (
apiRegistration: ReactEmbeddableApiRegistration<StateType, ApiType>,
comparators: EmbeddableStateComparators<StateType>
) => ApiType
) => Promise<{ Component: React.FC<{}>; api: ApiType }>;
}
export type StateTypeFromFactory<F extends ReactEmbeddableFactory<any>> =
F extends ReactEmbeddableFactory<infer S> ? S : never;
/**
* State comparators
*/

View file

@ -38,7 +38,7 @@ export const getMockPresentationPanelCompatibleComponent = <
): Promise<PanelCompatibleComponent> =>
Promise.resolve(
React.forwardRef((_, apiRef) => {
useImperativeHandle(apiRef, () => api ?? {});
useImperativeHandle(apiRef, () => api ?? { uuid: 'test' });
return (
<div data-test-subj="testPresentationPanelInternalComponent">This is a test component</div>
);

View file

@ -49,6 +49,7 @@ describe('Presentation panel', () => {
it('renders a blocking error when one is present', async () => {
const api: DefaultPresentationPanelApi = {
uuid: 'test',
blockingError: new BehaviorSubject<Error | undefined>(new Error('UH OH')),
};
render(<PresentationPanel Component={getMockPresentationPanelCompatibleComponent(api)} />);
@ -64,7 +65,7 @@ describe('Presentation panel', () => {
function getComponent(api?: DefaultPresentationPanelApi): Promise<PanelCompatibleComponent> {
return Promise.resolve(
React.forwardRef((_, apiRef) => {
useImperativeHandle(apiRef, () => api ?? {});
useImperativeHandle(apiRef, () => api ?? { uuid: 'test' });
return <ComponentThatThrows />;
})
);
@ -88,6 +89,7 @@ describe('Presentation panel', () => {
it('gets compatible actions for the given API', async () => {
const api: DefaultPresentationPanelApi = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('superTest'),
};
await renderPresentationPanel({ api });
@ -112,6 +114,7 @@ describe('Presentation panel', () => {
it('does not show actions which are disabled by the API', async () => {
const api: DefaultPresentationPanelApi = {
uuid: 'test',
disabledActionIds: new BehaviorSubject<string[] | undefined>(['actionA']),
};
const getActions = jest.fn().mockReturnValue([mockAction('actionA'), mockAction('actionB')]);
@ -156,6 +159,7 @@ describe('Presentation panel', () => {
describe('titles', () => {
it('renders the panel title from the api', async () => {
const api: DefaultPresentationPanelApi = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
};
await renderPresentationPanel({ api });
@ -166,6 +170,7 @@ describe('Presentation panel', () => {
it('renders an info icon when the api provides a panel description', async () => {
const api: DefaultPresentationPanelApi = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
panelDescription: new BehaviorSubject<string | undefined>('SUPER DESCRIPTION'),
};
@ -177,6 +182,7 @@ describe('Presentation panel', () => {
it('does not render a title when in view mode when the provided title is blank', async () => {
const api: DefaultPresentationPanelApi & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>(''),
viewMode: new BehaviorSubject<ViewMode>('view'),
};
@ -188,6 +194,7 @@ describe('Presentation panel', () => {
it('renders a placeholder title when in edit mode and the provided title is blank', async () => {
const api: DefaultPresentationPanelApi & PublishesDataViews & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>(''),
viewMode: new BehaviorSubject<ViewMode>('edit'),
dataViews: new BehaviorSubject<DataView[] | undefined>([]),
@ -202,6 +209,7 @@ describe('Presentation panel', () => {
const spy = jest.spyOn(openCustomizePanel, 'openCustomizePanelFlyout');
const api: DefaultPresentationPanelApi & PublishesDataViews & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('TITLE'),
viewMode: new BehaviorSubject<ViewMode>('edit'),
dataViews: new BehaviorSubject<DataView[] | undefined>([]),
@ -218,6 +226,7 @@ describe('Presentation panel', () => {
it('does not show title customize link in view mode', async () => {
const api: DefaultPresentationPanelApi & PublishesDataViews & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
viewMode: new BehaviorSubject<ViewMode>('view'),
dataViews: new BehaviorSubject<DataView[] | undefined>([]),
@ -231,6 +240,7 @@ describe('Presentation panel', () => {
it('hides title when API hide title option is true', async () => {
const api: DefaultPresentationPanelApi & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
hidePanelTitle: new BehaviorSubject<boolean | undefined>(true),
viewMode: new BehaviorSubject<ViewMode>('view'),
@ -241,6 +251,7 @@ describe('Presentation panel', () => {
it('hides title when parent hide title option is true', async () => {
const api: DefaultPresentationPanelApi & PublishesViewMode = {
uuid: 'test',
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
viewMode: new BehaviorSubject<ViewMode>('view'),
parentApi: {

View file

@ -60,18 +60,19 @@ export interface PresentationPanelInternalProps<
* The API that any component passed to the `Component` prop of `PresentationPanel` should implement.
* Everything in this API is Partial because it is valid for a component to implement none of these methods.
*/
export type DefaultPresentationPanelApi = Partial<
HasUniqueId &
PublishesPanelTitle &
PublishesDataLoading &
PublishesBlockingError &
PublishesPanelDescription &
PublishesDisabledActionIds &
HasParentApi<
PresentationContainer &
Partial<Pick<PublishesPanelTitle, 'hidePanelTitle'> & PublishesViewMode>
>
>;
export interface DefaultPresentationPanelApi
extends HasUniqueId,
Partial<
PublishesPanelTitle &
PublishesDataLoading &
PublishesBlockingError &
PublishesPanelDescription &
PublishesDisabledActionIds &
HasParentApi<
PresentationContainer &
Partial<Pick<PublishesPanelTitle, 'hidePanelTitle'> & PublishesViewMode>
>
> {}
export type PresentationPanelProps<
ApiType extends DefaultPresentationPanelApi = DefaultPresentationPanelApi,