mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
c312baeec1
commit
fa799d7a08
22 changed files with 533 additions and 598 deletions
|
@ -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);
|
||||
|
|
|
@ -15,4 +15,4 @@ export type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & {
|
|||
content: string;
|
||||
};
|
||||
|
||||
export type MarkdownEditorApi = DefaultEmbeddableApi;
|
||||
export type MarkdownEditorApi = DefaultEmbeddableApi<MarkdownEditorSerializedState>;
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue