[Embeddables rebuild] Add new registry (#176018)

Creates a new registry for `React Embeddables` and allows the Dashboard to render them.
This commit is contained in:
Devon Thomson 2024-02-02 15:10:19 -05:00 committed by GitHub
parent c8bc299815
commit 435680612e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 1874 additions and 466 deletions

View file

@ -31,6 +31,7 @@ import {
FilterDebuggerEmbeddableFactory,
FilterDebuggerEmbeddableFactoryDefinition,
} from './filter_debugger';
import { registerMarkdownEditorEmbeddable } from './react_embeddables/eui_markdown_react_embeddable';
export interface EmbeddableExamplesSetupDependencies {
embeddable: EmbeddableSetup;
@ -53,6 +54,8 @@ export interface EmbeddableExamplesStart {
factories: ExampleEmbeddableFactories;
}
registerMarkdownEditorEmbeddable();
export class EmbeddableExamplesPlugin
implements
Plugin<

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiMarkdownEditor, EuiMarkdownFormat } from '@elastic/eui';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import {
ReactEmbeddableFactory,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
initializeReactEmbeddableUuid,
initializeReactEmbeddableTitles,
SerializedReactEmbeddableTitles,
DefaultEmbeddableApi,
useReactEmbeddableUnsavedChanges,
} from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { useInheritedViewMode, useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
// -----------------------------------------------------------------------------
// Types for this embeddable
// -----------------------------------------------------------------------------
type MarkdownEditorSerializedState = SerializedReactEmbeddableTitles & {
content: string;
};
type MarkdownEditorApi = DefaultEmbeddableApi;
const type = 'euiMarkdown';
// -----------------------------------------------------------------------------
// Define the Embeddable Factory
// -----------------------------------------------------------------------------
const markdownEmbeddableFactory: ReactEmbeddableFactory<
MarkdownEditorSerializedState,
MarkdownEditorApi
> = {
// -----------------------------------------------------------------------------
// Deserialize function
// -----------------------------------------------------------------------------
deserializeState: (state) => {
// We could run migrations here.
// We should inject references here. References are given as state.references
return state.rawState as MarkdownEditorSerializedState;
},
// -----------------------------------------------------------------------------
// Register the Embeddable component
// -----------------------------------------------------------------------------
getComponent: async (state, maybeId) => {
/**
* initialize state (source of truth)
*/
const uuid = initializeReactEmbeddableUuid(maybeId);
const { titlesApi, titleComparators, serializeTitles } = initializeReactEmbeddableTitles(state);
const contentSubject = new BehaviorSubject(state.content);
/**
* getComponent is async so you can async import the component or load a saved object here.
* the loading will be handed gracefully by the Presentation Container.
*/
return RegisterReactEmbeddable((apiRef) => {
/**
* Unsaved changes logic is handled automatically by this hook. You only need to provide
* a subject, setter, and optional state comparator for each key in your state type.
*/
const { unsavedChanges, resetUnsavedChanges } = useReactEmbeddableUnsavedChanges(
uuid,
markdownEmbeddableFactory,
{
content: [contentSubject, (value) => contentSubject.next(value)],
...titleComparators,
}
);
/**
* Publish the API. This is what gets forwarded to the Actions framework, and to whatever the
* parent of this embeddable is.
*/
const thisApi = useReactEmbeddableApiHandle(
{
...titlesApi,
unsavedChanges,
resetUnsavedChanges,
serializeState: async () => {
return {
rawState: {
...serializeTitles(),
content: contentSubject.getValue(),
},
};
},
},
apiRef,
uuid
);
// get state for rendering
const content = useStateFromPublishingSubject(contentSubject);
const viewMode = useInheritedViewMode(thisApi) ?? 'view';
return viewMode === 'edit' ? (
<EuiMarkdownEditor
css={css`
width: 100%;
`}
value={content ?? ''}
onChange={(value) => contentSubject.next(value)}
aria-label={i18n.translate('dashboard.test.markdownEditor.ariaLabel', {
defaultMessage: 'Dashboard markdown editor',
})}
height="full"
/>
) : (
<EuiMarkdownFormat
css={css`
padding: ${euiThemeVars.euiSizeS};
`}
>
{content ?? ''}
</EuiMarkdownFormat>
);
});
},
};
// -----------------------------------------------------------------------------
// Register the defined Embeddable Factory - notice that this isn't defined
// on the plugin. Instead, it's a simple imported function. I.E to register an
// Embeddable, you only need the embeddable plugin in your requiredBundles
// -----------------------------------------------------------------------------
export const registerMarkdownEditorEmbeddable = () =>
registerReactEmbeddableFactory(type, markdownEmbeddableFactory);

View file

@ -11,15 +11,15 @@
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": [
"target/**/*",
],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/kibana-utils-plugin",
"@kbn/ui-actions-plugin",
"@kbn/embeddable-plugin",
"@kbn/presentation-publishing",
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/es-query",
"@kbn/es-query"
]
}

View file

@ -20,3 +20,9 @@ export {
type PresentationContainer,
} from './interfaces/presentation_container';
export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays';
export { type SerializedPanelState } from './interfaces/serialized_state';
export {
type PublishesLastSavedState,
apiPublishesLastSavedState,
getLastSavedStateSubjectForChild,
} from './interfaces/last_saved_state';

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PublishingSubject } from '@kbn/presentation-publishing';
import { BehaviorSubject, Subject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { SerializedPanelState } from './serialized_state';
export interface PublishesLastSavedState {
lastSavedState: Subject<void>; // a notification that the last saved state has changed
getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined;
}
export const apiPublishesLastSavedState = (api: unknown): api is PublishesLastSavedState => {
return Boolean(
api &&
(api as PublishesLastSavedState).lastSavedState &&
(api as PublishesLastSavedState).getLastSavedStateForChild
);
};
export const getLastSavedStateSubjectForChild = <StateType extends unknown = unknown>(
parentApi: unknown,
childId: string,
deserializer?: (state: SerializedPanelState) => StateType
): PublishingSubject<StateType | undefined> | undefined => {
if (!parentApi) return;
const fetchUnsavedChanges = (): StateType | undefined => {
if (!apiPublishesLastSavedState(parentApi)) return;
const rawLastSavedState = parentApi.getLastSavedStateForChild(childId);
if (rawLastSavedState === undefined) return;
return deserializer
? deserializer(rawLastSavedState)
: (rawLastSavedState.rawState as StateType);
};
const lastSavedStateForChild = new BehaviorSubject<StateType | undefined>(fetchUnsavedChanges());
if (!apiPublishesLastSavedState(parentApi)) return;
parentApi.lastSavedState
.pipe(
map(() => fetchUnsavedChanges()),
filter((rawLastSavedState) => rawLastSavedState !== undefined)
)
.subscribe(lastSavedStateForChild);
return lastSavedStateForChild;
};

View file

@ -34,6 +34,4 @@ export const apiCanExpandPanels = (unknownApi: unknown | null): unknownApi is Ca
* Gets this API's expanded panel state as a reactive variable which will cause re-renders on change.
*/
export const useExpandedPanelId = (api: Partial<CanExpandPanels> | undefined) =>
useStateFromPublishingSubject<string | undefined, CanExpandPanels['expandedPanelId']>(
apiCanExpandPanels(api) ? api.expandedPanelId : undefined
);
useStateFromPublishingSubject(apiCanExpandPanels(api) ? api.expandedPanelId : undefined);

View file

@ -7,21 +7,32 @@
*/
import { apiHasParentApi, PublishesViewMode } from '@kbn/presentation-publishing';
import { PublishesLastSavedState } from './last_saved_state';
export interface PanelPackage {
panelType: string;
initialState: unknown;
}
export interface PresentationContainer extends Partial<PublishesViewMode> {
removePanel: (panelId: string) => void;
canRemovePanels?: () => boolean;
replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise<string>;
}
export type PresentationContainer = Partial<PublishesViewMode> &
PublishesLastSavedState & {
registerPanelApi: <ApiType extends unknown = unknown>(
panelId: string,
panelApi: ApiType
) => void;
removePanel: (panelId: string) => void;
canRemovePanels?: () => boolean;
replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise<string>;
};
export const apiIsPresentationContainer = (
unknownApi: unknown | null
): unknownApi is PresentationContainer => {
return Boolean((unknownApi as PresentationContainer)?.removePanel !== undefined);
return Boolean(
(unknownApi as PresentationContainer)?.removePanel !== undefined &&
(unknownApi as PresentationContainer)?.registerPanelApi !== undefined &&
(unknownApi as PresentationContainer)?.replacePanel !== undefined
);
};
export const getContainerParentFromAPI = (

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server';
/**
* A package containing the serialized Embeddable state, with references extracted. When saving Embeddables using any
* strategy, this is the format that should be used.
*/
export interface SerializedPanelState<RawStateType extends object = object> {
references?: SavedObjectReference[];
rawState: RawStateType;
version?: string;
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Subject } from 'rxjs';
import { PresentationContainer } from './interfaces/presentation_container';
export const getMockPresentationContainer = (): PresentationContainer => {
return {
registerPanelApi: jest.fn(),
removePanel: jest.fn(),
replacePanel: jest.fn(),
lastSavedState: new Subject<void>(),
getLastSavedStateForChild: jest.fn(),
};
};

View file

@ -6,5 +6,9 @@
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": ["@kbn/presentation-publishing", "@kbn/core-mount-utils-browser"]
"kbn_references": [
"@kbn/presentation-publishing",
"@kbn/core-mount-utils-browser",
"@kbn/core-saved-objects-api-server",
]
}

View file

@ -93,6 +93,11 @@ export {
type PublishesWritableViewMode,
type ViewMode,
} from './interfaces/publishes_view_mode';
export {
type PublishesUnsavedChanges,
apiPublishesUnsavedChanges,
useUnsavedChanges,
} from './interfaces/publishes_unsaved_changes';
export {
useBatchedPublishingSubjects,
useStateFromPublishingSubject,

View file

@ -8,7 +8,7 @@
import { useStateFromPublishingSubject } from '../publishing_subject';
import { apiHasParentApi, HasParentApi } from './has_parent_api';
import { apiPublishesViewMode, PublishesViewMode, ViewMode } from './publishes_view_mode';
import { apiPublishesViewMode, PublishesViewMode } from './publishes_view_mode';
/**
* This API can access a view mode, either its own or from its parent API.
@ -49,6 +49,5 @@ export const getViewModeSubject = (api?: CanAccessViewMode) => {
export const useInheritedViewMode = <ApiType extends CanAccessViewMode = CanAccessViewMode>(
api: ApiType | undefined
) => {
const subject = getViewModeSubject(api);
useStateFromPublishingSubject<ViewMode, typeof subject>(subject);
return useStateFromPublishingSubject(getViewModeSubject(api));
};

View file

@ -22,6 +22,4 @@ export const apiPublishesBlockingError = (
* Gets this API's fatal error as a reactive variable which will cause re-renders on change.
*/
export const useBlockingError = (api: Partial<PublishesBlockingError> | undefined) =>
useStateFromPublishingSubject<Error | undefined, PublishesBlockingError['blockingError']>(
api?.blockingError
);
useStateFromPublishingSubject(api?.blockingError);

View file

@ -22,6 +22,4 @@ export const apiPublishesDataLoading = (
* Gets this API's data loading state as a reactive variable which will cause re-renders on change.
*/
export const useDataLoading = (api: Partial<PublishesDataLoading> | undefined) =>
useStateFromPublishingSubject<boolean | undefined, PublishesDataLoading['dataLoading']>(
apiPublishesDataLoading(api) ? api.dataLoading : undefined
);
useStateFromPublishingSubject(apiPublishesDataLoading(api) ? api.dataLoading : undefined);

View file

@ -23,6 +23,4 @@ export const apiPublishesDataViews = (
* Gets this API's data views as a reactive variable which will cause re-renders on change.
*/
export const useDataViews = (api: Partial<PublishesDataViews> | undefined) =>
useStateFromPublishingSubject<DataView[] | undefined, PublishesDataViews['dataViews']>(
apiPublishesDataViews(api) ? api.dataViews : undefined
);
useStateFromPublishingSubject(apiPublishesDataViews(api) ? api.dataViews : undefined);

View file

@ -29,7 +29,4 @@ export const apiPublishesDisabledActionIds = (
* Gets this API's disabled action IDs as a reactive variable which will cause re-renders on change.
*/
export const useDisabledActionIds = (api: Partial<PublishesDisabledActionIds> | undefined) =>
useStateFromPublishingSubject<
string[] | undefined,
PublishesDisabledActionIds['disabledActionIds']
>(api?.disabledActionIds);
useStateFromPublishingSubject(api?.disabledActionIds);

View file

@ -62,16 +62,16 @@ export const apiPublishesWritableLocalUnifiedSearch = (
* A hook that gets this API's local time range as a reactive variable which will cause re-renders on change.
*/
export const useLocalTimeRange = (api: Partial<PublishesLocalUnifiedSearch> | undefined) =>
useStateFromPublishingSubject<TimeRange | undefined>(api?.localTimeRange);
useStateFromPublishingSubject(api?.localTimeRange);
/**
* A hook that gets this API's local filters as a reactive variable which will cause re-renders on change.
*/
export const useLocalFilters = (api: Partial<PublishesLocalUnifiedSearch> | undefined) =>
useStateFromPublishingSubject<Filter[] | undefined>(api?.localFilters);
useStateFromPublishingSubject(api?.localFilters);
/**
* A hook that gets this API's local query as a reactive variable which will cause re-renders on change.
*/
export const useLocalQuery = (api: Partial<PublishesLocalUnifiedSearch> | undefined) =>
useStateFromPublishingSubject<Query | AggregateQuery | undefined>(api?.localQuery);
useStateFromPublishingSubject(api?.localQuery);

View file

@ -39,15 +39,10 @@ export const apiPublishesWritablePanelDescription = (
* A hook that gets this API's panel description as a reactive variable which will cause re-renders on change.
*/
export const usePanelDescription = (api: Partial<PublishesPanelDescription> | undefined) =>
useStateFromPublishingSubject<string | undefined, PublishesPanelDescription['panelDescription']>(
api?.panelDescription
);
useStateFromPublishingSubject(api?.panelDescription);
/**
* A hook that gets this API's default panel description as a reactive variable which will cause re-renders on change.
*/
export const useDefaultPanelDescription = (api: Partial<PublishesPanelDescription> | undefined) =>
useStateFromPublishingSubject<
string | undefined,
PublishesPanelDescription['defaultPanelDescription']
>(api?.defaultPanelDescription);
useStateFromPublishingSubject(api?.defaultPanelDescription);

View file

@ -17,7 +17,6 @@ export interface PublishesPanelTitle {
export type PublishesWritablePanelTitle = PublishesPanelTitle & {
setPanelTitle: (newTitle: string | undefined) => void;
setHidePanelTitle: (hide: boolean | undefined) => void;
setDefaultPanelTitle?: (newDefaultTitle: string | undefined) => void;
};
export const apiPublishesPanelTitle = (
@ -46,16 +45,16 @@ export const apiPublishesWritablePanelTitle = (
* A hook that gets this API's panel title as a reactive variable which will cause re-renders on change.
*/
export const usePanelTitle = (api: Partial<PublishesPanelTitle> | undefined) =>
useStateFromPublishingSubject<string | undefined>(api?.panelTitle);
useStateFromPublishingSubject(api?.panelTitle);
/**
* A hook that gets this API's hide panel title setting as a reactive variable which will cause re-renders on change.
*/
export const useHidePanelTitle = (api: Partial<PublishesPanelTitle> | undefined) =>
useStateFromPublishingSubject<boolean | undefined>(api?.hidePanelTitle);
useStateFromPublishingSubject(api?.hidePanelTitle);
/**
* A hook that gets this API's default title as a reactive variable which will cause re-renders on change.
*/
export const useDefaultPanelTitle = (api: Partial<PublishesPanelTitle> | undefined) =>
useStateFromPublishingSubject<string | undefined>(api?.defaultPanelTitle);
useStateFromPublishingSubject(api?.defaultPanelTitle);

View file

@ -28,4 +28,4 @@ export const apiPublishesSavedObjectId = (
* A hook that gets this API's saved object ID as a reactive variable which will cause re-renders on change.
*/
export const useSavedObjectId = (api: PublishesSavedObjectId | undefined) =>
useStateFromPublishingSubject<string | undefined>(api?.savedObjectId);
useStateFromPublishingSubject(api?.savedObjectId);

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
export interface PublishesUnsavedChanges {
unsavedChanges: PublishingSubject<object | undefined>;
resetUnsavedChanges: () => void;
}
export const apiPublishesUnsavedChanges = (api: unknown): api is PublishesUnsavedChanges => {
return Boolean(
api &&
(api as PublishesUnsavedChanges).unsavedChanges &&
(api as PublishesUnsavedChanges).resetUnsavedChanges
);
};
/**
* A hook that gets this API's unsaved changes as a reactive variable which will cause re-renders on change.
*/
export const useUnsavedChanges = (api: PublishesUnsavedChanges | undefined) =>
useStateFromPublishingSubject(api?.unsavedChanges);

View file

@ -52,4 +52,4 @@ export const useViewMode = <
ApiType extends Partial<PublishesViewMode> = Partial<PublishesViewMode>
>(
api: ApiType | undefined
) => useStateFromPublishingSubject<ViewMode, ApiType['viewMode']>(api?.viewMode);
) => useStateFromPublishingSubject(api?.viewMode);

View file

@ -7,8 +7,9 @@
*/
export { useBatchedPublishingSubjects } from './publishing_batcher';
export {
useStateFromPublishingSubject,
usePublishingSubject,
type PublishingSubject,
} from './publishing_subject';
export { useStateFromPublishingSubject, usePublishingSubject } from './publishing_subject';
export type {
PublishingSubject,
ValueFromPublishingSubject,
UnwrapPublishingSubjectTuple,
} from './types';

View file

@ -8,39 +8,18 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { combineLatest } from 'rxjs';
import { debounceTime, filter } from 'rxjs/operators';
import { PublishingSubject } from './publishing_subject';
import { debounceTime } from 'rxjs/operators';
import { AnyPublishingSubject, PublishingSubject, UnwrapPublishingSubjectTuple } from './types';
// Usage of any required here. We want to subscribe to the subject no matter the type.
type AnyValue = any;
type AnyPublishingSubject = PublishingSubject<AnyValue>;
interface PublishingSubjectCollection {
[key: string]: AnyPublishingSubject | undefined;
}
interface RequiredPublishingSubjectCollection {
[key: string]: AnyPublishingSubject;
}
type PublishingSubjectBatchResult<SubjectsType extends PublishingSubjectCollection> = {
[SubjectKey in keyof SubjectsType]?: SubjectsType[SubjectKey] extends
| PublishingSubject<infer ValueType>
| undefined
? ValueType
: never;
};
const hasSubjectsObjectChanged = (
subjectsA: PublishingSubjectCollection,
subjectsB: PublishingSubjectCollection
const hasSubjectsArrayChanged = (
subjectsA: AnyPublishingSubject[],
subjectsB: AnyPublishingSubject[]
) => {
const subjectKeysA = Object.keys(subjectsA);
const subjectKeysB = Object.keys(subjectsB);
if (subjectKeysA.length !== subjectKeysB.length) return true;
if (subjectsA.length !== subjectsB.length) return true;
for (const key of subjectKeysA) {
if (Boolean(subjectsA[key]) !== Boolean(subjectsB[key])) return true;
for (let i = 0; i < subjectsA.length; i++) {
// here we only compare if the subjects are both either defined or undefined.
if (Boolean(subjectsA[i]) !== Boolean(subjectsB[i])) return true;
}
return false;
};
@ -49,21 +28,15 @@ const hasSubjectsObjectChanged = (
* Batches the latest values of multiple publishing subjects into a single object. Use this to avoid unnecessary re-renders.
* You should avoid using this hook with subjects that your component pushes values to on user interaction, as it can cause a slight delay.
*/
export const useBatchedPublishingSubjects = <SubjectsType extends PublishingSubjectCollection>(
subjects: SubjectsType
): PublishingSubjectBatchResult<SubjectsType> => {
export const useBatchedPublishingSubjects = <SubjectsType extends [...AnyPublishingSubject[]]>(
...subjects: [...SubjectsType]
): UnwrapPublishingSubjectTuple<SubjectsType> => {
/**
* memoize and deep diff subjects to avoid rebuilding the subscription when the subjects are the same.
*/
const previousSubjects = useRef<SubjectsType | null>(null);
const previousSubjects = useRef<SubjectsType>(subjects);
const subjectsToUse = useMemo(() => {
if (!previousSubjects.current && !Object.values(subjects).some((subject) => Boolean(subject))) {
// if the previous subjects were null and none of the new subjects are defined, return null to avoid building the subscription.
return null;
}
if (!hasSubjectsObjectChanged(previousSubjects.current ?? {}, subjects)) {
if (!hasSubjectsArrayChanged(previousSubjects.current ?? [], subjects)) {
return previousSubjects.current;
}
previousSubjects.current = subjects;
@ -71,54 +44,51 @@ export const useBatchedPublishingSubjects = <SubjectsType extends PublishingSubj
}, [subjects]);
/**
* Extract only defined subjects from any subjects passed in.
* Set up latest published values state, initialized with the current values of the subjects.
*/
const { definedKeys, definedSubjects } = useMemo(() => {
if (!subjectsToUse) return {};
const definedSubjectsMap: RequiredPublishingSubjectCollection =
Object.keys(subjectsToUse).reduce((acc, key) => {
if (Boolean(subjectsToUse[key])) acc[key] = subjectsToUse[key] as AnyPublishingSubject;
return acc;
}, {} as RequiredPublishingSubjectCollection) ?? {};
return {
definedKeys: Object.keys(definedSubjectsMap ?? {}) as Array<keyof SubjectsType>,
definedSubjects: Object.values(definedSubjectsMap) ?? [],
};
}, [subjectsToUse]);
const [latestPublishedValues, setLatestPublishedValues] = useState<
PublishingSubjectBatchResult<SubjectsType>
>(() => {
if (!definedKeys?.length || !definedSubjects?.length) return {};
const nextResult: PublishingSubjectBatchResult<SubjectsType> = {};
for (let keyIndex = 0; keyIndex < definedKeys.length; keyIndex++) {
nextResult[definedKeys[keyIndex]] = definedSubjects[keyIndex].value ?? undefined;
}
return nextResult;
});
const initialSubjectValues = useMemo(
() => unwrapPublishingSubjectArray(subjectsToUse),
[subjectsToUse]
);
const [latestPublishedValues, setLatestPublishedValues] =
useState<UnwrapPublishingSubjectTuple<SubjectsType>>(initialSubjectValues);
/**
* Subscribe to all subjects and update the latest values when any of them change.
*/
useEffect(() => {
if (!definedSubjects?.length || !definedKeys?.length) return;
const subscription = combineLatest(definedSubjects)
.pipe(
// debounce latest state for 0ms to flush all in-flight changes
debounceTime(0),
filter((changes) => changes.length > 0)
)
.subscribe((latestValues) => {
const nextResult: PublishingSubjectBatchResult<SubjectsType> = {};
for (let keyIndex = 0; keyIndex < definedKeys.length; keyIndex++) {
nextResult[definedKeys[keyIndex]] = latestValues[keyIndex] ?? undefined;
}
setLatestPublishedValues(nextResult);
});
const definedSubjects: Array<PublishingSubject<unknown>> = [];
const definedSubjectIndices: number[] = [];
for (let i = 0; i < subjectsToUse.length; i++) {
if (!subjectsToUse[i]) continue;
definedSubjects.push(subjectsToUse[i] as PublishingSubject<unknown>);
definedSubjectIndices.push(i);
}
if (definedSubjects.length === 0) return;
const subscription = combineLatest(definedSubjects)
.pipe(debounceTime(0))
.subscribe((values) => {
setLatestPublishedValues((lastPublishedValues) => {
const newLatestPublishedValues: UnwrapPublishingSubjectTuple<SubjectsType> = [
...lastPublishedValues,
] as UnwrapPublishingSubjectTuple<SubjectsType>;
for (let i = 0; i < values.length; i++) {
newLatestPublishedValues[definedSubjectIndices[i]] = values[i] as never;
}
return newLatestPublishedValues;
});
});
return () => subscription.unsubscribe();
}, [definedKeys, definedSubjects]);
}, [subjectsToUse]);
return latestPublishedValues;
};
const unwrapPublishingSubjectArray = <T extends AnyPublishingSubject[]>(
subjects: T
): UnwrapPublishingSubjectTuple<T> => {
return subjects.map(
(subject) => subject?.getValue?.() ?? undefined
) as UnwrapPublishingSubjectTuple<T>;
};

View file

@ -42,12 +42,12 @@ describe('useBatchedPublishingSubjects', () => {
test('should render once when all state changes are in click handler (react batch)', async () => {
let renderCount = 0;
function Component() {
const value1 = useStateFromPublishingSubject<number>(subject1);
const value2 = useStateFromPublishingSubject<number>(subject2);
const value3 = useStateFromPublishingSubject<number>(subject3);
const value4 = useStateFromPublishingSubject<number>(subject4);
const value5 = useStateFromPublishingSubject<number>(subject5);
const value6 = useStateFromPublishingSubject<number>(subject6);
const value1 = useStateFromPublishingSubject(subject1);
const value2 = useStateFromPublishingSubject(subject2);
const value3 = useStateFromPublishingSubject(subject3);
const value4 = useStateFromPublishingSubject(subject4);
const value5 = useStateFromPublishingSubject(subject5);
const value6 = useStateFromPublishingSubject(subject6);
renderCount++;
return (
@ -76,14 +76,14 @@ describe('useBatchedPublishingSubjects', () => {
test('should batch state updates when using useBatchedPublishingSubjects', async () => {
let renderCount = 0;
function Component() {
const { value1, value2, value3, value4, value5, value6 } = useBatchedPublishingSubjects({
value1: subject1,
value2: subject2,
value3: subject3,
value4: subject4,
value5: subject5,
value6: subject6,
});
const [value1, value2, value3, value4, value5, value6] = useBatchedPublishingSubjects(
subject1,
subject2,
subject3,
subject4,
subject5,
subject6
);
renderCount++;
return (
@ -117,12 +117,12 @@ describe('useBatchedPublishingSubjects', () => {
test('should render for each state update outside of click handler', async () => {
let renderCount = 0;
function Component() {
const value1 = useStateFromPublishingSubject<number>(subject1);
const value2 = useStateFromPublishingSubject<number>(subject2);
const value3 = useStateFromPublishingSubject<number>(subject3);
const value4 = useStateFromPublishingSubject<number>(subject4);
const value5 = useStateFromPublishingSubject<number>(subject5);
const value6 = useStateFromPublishingSubject<number>(subject6);
const value1 = useStateFromPublishingSubject(subject1);
const value2 = useStateFromPublishingSubject(subject2);
const value3 = useStateFromPublishingSubject(subject3);
const value4 = useStateFromPublishingSubject(subject4);
const value5 = useStateFromPublishingSubject(subject5);
const value6 = useStateFromPublishingSubject(subject6);
renderCount++;
return (

View file

@ -8,16 +8,7 @@
import { useEffect, useMemo, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
/**
* A publishing subject is a RxJS subject that can be used to listen to value changes, but does not allow pushing values via the Next method.
*/
export type PublishingSubject<T extends unknown = unknown> = Omit<BehaviorSubject<T>, 'next'>;
/**
* A utility type that makes a type optional if another passed in type is optional.
*/
type OptionalIfOptional<TestType, Type> = undefined extends TestType ? Type | undefined : Type;
import { PublishingSubject, ValueFromPublishingSubject } from './types';
/**
* Declares a publishing subject, allowing external code to subscribe to react state changes.
@ -41,18 +32,15 @@ export const usePublishingSubject = <T extends unknown = unknown>(
* @param subject Publishing subject.
*/
export const useStateFromPublishingSubject = <
ValueType extends unknown = unknown,
SubjectType extends PublishingSubject<ValueType> | undefined =
| PublishingSubject<ValueType>
| undefined
SubjectType extends PublishingSubject<any> | undefined = PublishingSubject<any> | undefined
>(
subject?: SubjectType
): OptionalIfOptional<SubjectType, ValueType> => {
const [value, setValue] = useState<ValueType | undefined>(subject?.getValue());
subject: SubjectType
): ValueFromPublishingSubject<SubjectType> => {
const [value, setValue] = useState<ValueFromPublishingSubject<SubjectType>>(subject?.getValue());
useEffect(() => {
if (!subject) return;
const subscription = subject.subscribe((newValue) => setValue(newValue));
return () => subscription.unsubscribe();
}, [subject]);
return value as OptionalIfOptional<SubjectType, ValueType>;
return value;
};

View file

@ -0,0 +1,34 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
/**
* A publishing subject is a RxJS subject that can be used to listen to value changes, but does not allow pushing values via the Next method.
*/
export type PublishingSubject<T extends unknown = unknown> = Omit<BehaviorSubject<T>, 'next'>;
// Usage of any required here. We want to build functionalities that can work with a publishing subject of any type.
type AnyValue = any;
export type AnyPublishingSubject = PublishingSubject<AnyValue> | undefined;
export type ValueFromPublishingSubject<
T extends PublishingSubject<AnyValue> | undefined = PublishingSubject<AnyValue> | undefined
> = T extends PublishingSubject<infer ValueType>
? ValueType
: T extends undefined
? undefined
: never;
export type UnwrapPublishingSubjectTuple<T extends [...any[]]> = T extends [
infer Head extends AnyPublishingSubject,
...infer Tail extends AnyPublishingSubject[]
]
? [ValueFromPublishingSubject<Head>, ...UnwrapPublishingSubjectTuple<Tail>]
: [];

View file

@ -1,27 +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 { useImperativeHandle, useMemo } from 'react';
/**
* Publishes 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 useApiPublisher = <ApiType extends unknown = unknown>(
api: ApiType,
ref: React.ForwardedRef<ApiType>
) => {
const publishApi = useMemo(
() => api,
// disabling exhaustive deps because the API should be created once and never change.
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useImperativeHandle(ref, () => publishApi);
};

View file

@ -48,9 +48,9 @@ export function CopyToDashboardModal({ api, closeModal }: CopyToDashboardModalPr
const dashboardId = api.parentApi.savedObjectId.value;
const onSubmit = useCallback(() => {
const onSubmit = useCallback(async () => {
const dashboard = api.parentApi;
const panelToCopy = dashboard.getDashboardPanelFromId(api.uuid);
const panelToCopy = await dashboard.getDashboardPanelFromId(api.uuid);
if (!panelToCopy) {
throw new PanelNotFoundError();

View file

@ -7,6 +7,7 @@
*/
import { PublishesViewMode, ViewMode } from '@kbn/presentation-publishing';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { BehaviorSubject } from 'rxjs';
import { ReplacePanelSOFinder } from '.';
import { ReplacePanelAction, ReplacePanelActionApi } from './replace_panel_action';
@ -28,10 +29,7 @@ describe('replace panel action', () => {
embeddable: {
uuid: 'superId',
viewMode: new BehaviorSubject<ViewMode>('edit'),
parentApi: {
removePanel: jest.fn(),
replacePanel: jest.fn(),
},
parentApi: getMockPresentationContainer(),
},
};
});

View file

@ -6,12 +6,17 @@
* Side Public License, v 1.
*/
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
import React, { useState, useRef, useEffect, useLayoutEffect, useMemo } from 'react';
import { EuiLoadingChart } from '@elastic/eui';
import classNames from 'classnames';
import { PhaseEvent } from '@kbn/presentation-publishing';
import { EmbeddablePanel, ViewMode } from '@kbn/embeddable-plugin/public';
import {
ReactEmbeddableRenderer,
EmbeddablePanel,
reactEmbeddableRegistryHasKey,
ViewMode,
} from '@kbn/embeddable-plugin/public';
import { css } from '@emotion/react';
import { DashboardPanelState } from '../../../../common';
@ -52,6 +57,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
const container = useDashboardContainer();
const scrollToPanelId = container.select((state) => state.componentState.scrollToPanelId);
const highlightPanelId = container.select((state) => state.componentState.highlightPanelId);
const panel = container.select((state) => state.explicitInput.panels[id]);
const expandPanel = expandedPanelId !== undefined && expandedPanelId === id;
const hidePanel = expandedPanelId !== undefined && expandedPanelId !== id;
@ -94,6 +100,33 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
`
: css``;
const renderedEmbeddable = useMemo(() => {
if (reactEmbeddableRegistryHasKey(type)) {
return (
<ReactEmbeddableRenderer
uuid={id}
key={`${type}_${id}`}
type={type}
// TODO Embeddable refactor. References here
state={{ rawState: panel.explicitInput, version: panel.version, references: [] }}
/>
);
}
return (
<EmbeddablePanel
key={type}
index={index}
showBadges={true}
showShadow={true}
showNotifications={true}
onPanelStatusChange={onPanelStatusChange}
embeddable={() => container.untilEmbeddableLoaded(id)}
/>
);
}, [container, id, index, onPanelStatusChange, type, panel]);
// render legacy embeddable
return (
<div
css={focusStyles}
@ -105,15 +138,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
>
{isRenderable ? (
<>
<EmbeddablePanel
key={type}
index={index}
showBadges={true}
showShadow={true}
showNotifications={true}
onPanelStatusChange={onPanelStatusChange}
embeddable={() => container.untilEmbeddableLoaded(id)}
/>
{renderedEmbeddable}
{children}
</>
) : (

View file

@ -143,15 +143,13 @@ test('Duplicates a non RefOrVal embeddable by value', async () => {
});
test('Gets a unique title from the dashboard', async () => {
expect(await incrementPanelTitle(byRefOrValEmbeddable, '')).toEqual('');
expect(await incrementPanelTitle(container, '')).toEqual('');
container.getPanelTitles = jest.fn().mockImplementation(() => {
return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle'];
});
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testUniqueTitle')).toEqual(
'testUniqueTitle (copy)'
);
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
expect(await incrementPanelTitle(container, 'testUniqueTitle')).toEqual('testUniqueTitle (copy)');
expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual(
'testDuplicateTitle (copy 1)'
);
@ -160,20 +158,20 @@ test('Gets a unique title from the dashboard', async () => {
Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`)
);
});
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual(
'testDuplicateTitle (copy 40)'
);
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual(
'testDuplicateTitle (copy 40)'
);
container.getPanelTitles = jest.fn().mockImplementation(() => {
return ['testDuplicateTitle (copy 100)'];
});
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
expect(await incrementPanelTitle(container, 'testDuplicateTitle')).toEqual(
'testDuplicateTitle (copy 101)'
);
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
expect(await incrementPanelTitle(container, 'testDuplicateTitle (copy 100)')).toEqual(
'testDuplicateTitle (copy 101)'
);
});

View file

@ -7,12 +7,11 @@
*/
import {
EmbeddableInput,
IEmbeddable,
isReferenceOrValueEmbeddable,
PanelNotFoundError,
PanelState,
reactEmbeddableRegistryHasKey,
} from '@kbn/embeddable-plugin/public';
import { apiPublishesPanelTitle } from '@kbn/presentation-publishing';
import { filter, map, max } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { DashboardPanelState } from '../../../../common';
@ -21,37 +20,69 @@ import { pluginServices } from '../../../services/plugin_services';
import { placeClonePanel } from '../../component/panel_placement';
import { DashboardContainer } from '../dashboard_container';
export async function duplicateDashboardPanel(this: DashboardContainer, idToDuplicate: string) {
const panelToClone = this.getInput().panels[idToDuplicate] as DashboardPanelState;
const embeddable = this.getChild(idToDuplicate);
if (!panelToClone || !embeddable) {
throw new PanelNotFoundError();
}
const duplicateLegacyInput = async (
dashboard: DashboardContainer,
panelToClone: DashboardPanelState,
idToDuplicate: string
) => {
const embeddable = dashboard.getChild(idToDuplicate);
if (!panelToClone || !embeddable) throw new PanelNotFoundError();
// duplicate panel input
const duplicatedPanelState: PanelState<EmbeddableInput> = await (async () => {
const newTitle = await incrementPanelTitle(embeddable, embeddable.getTitle() || '');
const id = uuidv4();
if (isReferenceOrValueEmbeddable(embeddable)) {
return {
type: embeddable.type,
explicitInput: {
...(await embeddable.getInputAsValueType()),
hidePanelTitles: panelToClone.explicitInput.hidePanelTitles,
...(newTitle ? { title: newTitle } : {}),
id,
},
};
}
const newTitle = await incrementPanelTitle(dashboard, embeddable.getTitle() || '');
const id = uuidv4();
if (isReferenceOrValueEmbeddable(embeddable)) {
return {
type: embeddable.type,
explicitInput: {
...panelToClone.explicitInput,
title: newTitle,
...(await embeddable.getInputAsValueType()),
hidePanelTitles: panelToClone.explicitInput.hidePanelTitles,
...(newTitle ? { title: newTitle } : {}),
id,
},
};
})();
}
return {
type: embeddable.type,
explicitInput: {
...panelToClone.explicitInput,
title: newTitle,
id,
},
};
};
const duplicateReactEmbeddableInput = async (
dashboard: DashboardContainer,
panelToClone: DashboardPanelState,
idToDuplicate: string
) => {
const child = dashboard.reactEmbeddableChildren.value[idToDuplicate];
if (!child) throw new PanelNotFoundError();
const lastTitle = apiPublishesPanelTitle(child)
? child.panelTitle.value ?? child.defaultPanelTitle?.value ?? ''
: '';
const newTitle = await incrementPanelTitle(dashboard, lastTitle);
const id = uuidv4();
const serializedState = await child.serializeState();
return {
type: panelToClone.type,
explicitInput: {
...panelToClone.explicitInput,
...serializedState.rawState,
title: newTitle,
id,
},
};
};
export async function duplicateDashboardPanel(this: DashboardContainer, idToDuplicate: string) {
const panelToClone = this.getInput().panels[idToDuplicate] as DashboardPanelState;
const duplicatedPanelState = reactEmbeddableRegistryHasKey(panelToClone.type)
? await duplicateReactEmbeddableInput(this, panelToClone, idToDuplicate)
: await duplicateLegacyInput(this, panelToClone, idToDuplicate);
pluginServices.getServices().notifications.toasts.addSuccess({
title: dashboardClonePanelActionStrings.getSuccessMessage(),
'data-test-subj': 'addObjectToContainerSuccess',
@ -80,14 +111,13 @@ export async function duplicateDashboardPanel(this: DashboardContainer, idToDupl
});
}
export const incrementPanelTitle = async (embeddable: IEmbeddable, rawTitle: string) => {
export const incrementPanelTitle = async (dashboard: DashboardContainer, rawTitle: string) => {
if (rawTitle === '') return '';
const clonedTag = dashboardClonePanelActionStrings.getClonedTag();
const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g');
const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g');
const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim();
const dashboard: DashboardContainer = embeddable.getRoot() as DashboardContainer;
const similarTitles = filter(await dashboard.getPanelTitles(), (title: string) => {
return title.startsWith(baseTitle);
});

View file

@ -6,13 +6,14 @@
* Side Public License, v 1.
*/
import type { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { 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';
import { batch } from 'react-redux';
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { DashboardContainerInput } from '../../../../common';
import { DASHBOARD_CONTENT_ID, SAVED_OBJECT_POST_TIME } from '../../../dashboard_constants';
import {
@ -25,6 +26,33 @@ import { DashboardContainer } from '../dashboard_container';
import { extractTitleAndCount } from './lib/extract_title_and_count';
import { DashboardSaveModal } from './overlays/save_modal';
const serializeAllPanelState = async (
dashboard: DashboardContainer
): Promise<DashboardContainerInput['panels']> => {
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 saveResults = await Promise.all(reactEmbeddableSavePromises);
for (const { serializedState, uuid } of saveResults) {
panels[uuid].explicitInput = { ...serializedState.rawState, id: uuid };
}
return panels;
};
export function runSaveAs(this: DashboardContainer) {
const {
data: {
@ -82,18 +110,20 @@ export function runSaveAs(this: DashboardContainer) {
// do not save if title is duplicate and is unconfirmed
return {};
}
const lastSavedInput: DashboardContainerInput = {
const nextPanels = await serializeAllPanelState(this);
const dashboardStateToSave: DashboardContainerInput = {
...currentState,
panels: nextPanels,
...stateFromSaveModal,
};
let stateToSave: SavedDashboardInput = lastSavedInput;
let stateToSave: SavedDashboardInput = dashboardStateToSave;
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
if (this.controlGroup) {
persistableControlGroupInput = this.controlGroup.getPersistableInput();
stateToSave = { ...stateToSave, controlGroupInput: persistableControlGroupInput };
}
const beforeAddTime = window.performance.now();
const saveResult = await saveDashboardState({
currentState: stateToSave,
saveOptions,
@ -112,7 +142,8 @@ export function runSaveAs(this: DashboardContainer) {
if (saveResult.id) {
batch(() => {
this.dispatch.setStateFromSaveModal(stateFromSaveModal);
this.dispatch.setLastSavedInput(lastSavedInput);
this.dispatch.setLastSavedInput(dashboardStateToSave);
this.lastSavedState.next();
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput);
}
@ -153,7 +184,8 @@ export async function runQuickSave(this: DashboardContainer) {
if (managed) return;
let stateToSave: SavedDashboardInput = currentState;
const nextPanels = await serializeAllPanelState(this);
let stateToSave: SavedDashboardInput = { ...currentState, panels: nextPanels };
let persistableControlGroupInput: PersistableControlGroupInput | undefined;
if (this.controlGroup) {
persistableControlGroupInput = this.controlGroup.getPersistableInput();
@ -167,6 +199,7 @@ export async function runQuickSave(this: DashboardContainer) {
});
this.dispatch.setLastSavedInput(currentState);
this.lastSavedState.next();
if (this.controlGroup && persistableControlGroupInput) {
this.controlGroup.dispatch.setLastSavedInput(persistableControlGroupInput);
}

View file

@ -5,10 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { cloneDeep, identity, omit, pickBy } from 'lodash';
import { BehaviorSubject, combineLatestWith, distinctUntilChanged, map, Subject } from 'rxjs';
import { v4 } from 'uuid';
import {
ControlGroupInput,
CONTROL_GROUP_TYPE,
@ -21,10 +17,17 @@ import {
type ControlGroupContainer,
} from '@kbn/controls-plugin/public';
import { GlobalQueryStateFromUrl, syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
import { EmbeddableFactory, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
import {
EmbeddableFactory,
isErrorEmbeddable,
reactEmbeddableRegistryHasKey,
ViewMode,
} from '@kbn/embeddable-plugin/public';
import { TimeRange } from '@kbn/es-query';
import { lazyLoadReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
import { cloneDeep, identity, omit, pickBy } from 'lodash';
import { Subject } from 'rxjs';
import { v4 } from 'uuid';
import { DashboardContainerInput, DashboardPanelState } from '../../../../common';
import {
DEFAULT_DASHBOARD_INPUT,
@ -38,6 +41,7 @@ import {
} from '../../../services/dashboard_content_management/types';
import { pluginServices } from '../../../services/plugin_services';
import { panelPlacementStrategies } from '../../component/panel_placement/place_new_panel_strategies';
import { startDiffingDashboardState } from '../../state/diffing/dashboard_diffing_integration';
import { DashboardPublicState } from '../../types';
import { DashboardContainer } from '../dashboard_container';
import { DashboardCreationOptions } from '../dashboard_container_factory';
@ -100,7 +104,7 @@ export const createDashboard = async (
const { input, searchSessionId } = initializeResult;
// --------------------------------------------------------------------------------------
// Build and return the dashboard container.
// Build the dashboard container.
// --------------------------------------------------------------------------------------
const initialComponentState: DashboardPublicState = {
lastSavedInput: omit(savedObjectResult?.dashboardInput, 'controlGroupInput') ?? {
@ -124,6 +128,14 @@ export const createDashboard = async (
creationOptions,
initialComponentState
);
// --------------------------------------------------------------------------------------
// Start the diffing integration after all other integrations are set up.
// --------------------------------------------------------------------------------------
untilDashboardReady().then((container) => {
startDiffingDashboardState.bind(container)(creationOptions);
});
dashboardContainerReady$.next(dashboardContainer);
return dashboardContainer;
};
@ -353,6 +365,9 @@ export const initializeDashboard = async ({
[newPanelState.explicitInput.id]: newPanelState,
},
});
if (reactEmbeddableRegistryHasKey(incomingEmbeddable.type)) {
return { id: embeddableId };
}
return await container.untilEmbeddableLoaded(embeddableId);
})();
@ -451,49 +466,5 @@ export const initializeDashboard = async ({
setTimeout(() => dashboard.dispatch.setAnimatePanelTransforms(true), 500)
);
// --------------------------------------------------------------------------------------
// Start diffing subscription to keep track of unsaved changes
// --------------------------------------------------------------------------------------
untilDashboardReady().then((dashboard) => {
// subscription that handles the unsaved changes badge
dashboard.integrationSubscriptions.add(
dashboard.hasUnsavedChanges
.pipe(
combineLatestWith(
dashboard.controlGroup?.unsavedChanges.pipe(
map((unsavedControlchanges) => Boolean(unsavedControlchanges))
) ?? new BehaviorSubject(false)
),
distinctUntilChanged(
(
[dashboardHasChanges1, controlHasChanges1],
[dashboardHasChanges2, controlHasChanges2]
) =>
(dashboardHasChanges1 || controlHasChanges1) ===
(dashboardHasChanges2 || controlHasChanges2)
)
)
.subscribe(([dashboardHasChanges, controlGroupHasChanges]) => {
dashboard.dispatch.setHasUnsavedChanges(dashboardHasChanges || controlGroupHasChanges);
})
);
// subscription that handles backing up the unsaved changes to the session storage
dashboard.integrationSubscriptions.add(
dashboard.backupUnsavedChanges
.pipe(
combineLatestWith(
dashboard.controlGroup?.unsavedChanges ?? new BehaviorSubject(undefined)
)
)
.subscribe(([dashboardChanges, controlGroupChanges]) => {
dashboardBackup.setState(dashboard.getDashboardSavedObjectId(), {
...dashboardChanges,
controlGroupInput: controlGroupChanges,
});
})
);
});
return { input: initialDashboardInput, searchSessionId: initialSearchSessionId };
};

View file

@ -11,7 +11,8 @@ import React, { createContext, useContext } from 'react';
import ReactDOM from 'react-dom';
import { batch } from 'react-redux';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';
import deepEqual from 'fast-deep-equal';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
import { RefreshInterval } from '@kbn/data-plugin/public';
@ -19,6 +20,10 @@ import type { DataView } from '@kbn/data-views-plugin/public';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import {
Container,
DefaultEmbeddableApi,
PanelNotFoundError,
ReactEmbeddableParentContext,
reactEmbeddableRegistryHasKey,
ViewMode,
type EmbeddableFactory,
type EmbeddableInput,
@ -43,7 +48,7 @@ import { placePanel } from '../component/panel_placement';
import { DashboardViewport } from '../component/viewport/dashboard_viewport';
import { DashboardExternallyAccessibleApi } from '../external_api/dashboard_api';
import { dashboardContainerReducers } from '../state/dashboard_container_reducers';
import { startDiffingDashboardState } from '../state/diffing/dashboard_diffing_integration';
import { getDiffingMiddleware } from '../state/diffing/dashboard_diffing_integration';
import {
DashboardPublicState,
DashboardReduxState,
@ -65,7 +70,6 @@ import {
dashboardTypeDisplayLowercase,
dashboardTypeDisplayName,
} from './dashboard_container_factory';
import { SavedDashboardInput } from '../../services/dashboard_content_management/types';
export interface InheritedChildInput {
filters: Filter[];
@ -108,15 +112,13 @@ export class DashboardContainer
public getState: DashboardReduxEmbeddableTools['getState'];
public dispatch: DashboardReduxEmbeddableTools['dispatch'];
public onStateChange: DashboardReduxEmbeddableTools['onStateChange'];
public anyReducerRun: Subject<null> = new Subject();
public integrationSubscriptions: Subscription = new Subscription();
public publishingSubscription: Subscription = new Subscription();
public diffingSubscription: Subscription = new Subscription();
public controlGroup?: ControlGroupContainer;
public hasUnsavedChanges: BehaviorSubject<boolean>;
public backupUnsavedChanges: BehaviorSubject<Partial<SavedDashboardInput> | undefined>;
public searchSessionId?: string;
public locator?: Pick<LocatorPublic<DashboardLocatorParams>, 'navigate' | 'getRedirectUrl'>;
@ -140,6 +142,10 @@ export class DashboardContainer
private chrome;
private customBranding;
// new embeddable framework
public reactEmbeddableChildren: BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }> =
new BehaviorSubject<{ [key: string]: DefaultEmbeddableApi }>({});
constructor(
initialInput: DashboardContainerInput,
reduxToolsPackage: ReduxToolsPackage,
@ -177,11 +183,7 @@ export class DashboardContainer
this.dashboardCreationStartTime = dashboardCreationStartTime;
// start diffing dashboard state
this.hasUnsavedChanges = new BehaviorSubject(false);
this.backupUnsavedChanges = new BehaviorSubject<Partial<DashboardContainerInput> | undefined>(
undefined
);
const diffingMiddleware = startDiffingDashboardState.bind(this)(creationOptions);
const diffingMiddleware = getDiffingMiddleware.bind(this)();
// build redux embeddable tools
const reduxTools = reduxToolsPackage.createReduxEmbeddableTools<
@ -214,6 +216,7 @@ export class DashboardContainer
this.expandedPanelId.next(this.getExpandedPanelId());
})
);
this.startAuditingReactEmbeddableChildren();
}
public getAppContext() {
@ -278,7 +281,9 @@ export class DashboardContainer
>
<KibanaThemeProvider theme$={this.theme$}>
<DashboardContainerContext.Provider value={this}>
<DashboardViewport />
<ReactEmbeddableParentContext.Provider value={{ parentApi: this }}>
<DashboardViewport />
</ReactEmbeddableParentContext.Provider>
</DashboardContainerContext.Provider>
</KibanaThemeProvider>
</ExitFullScreenButtonKibanaProvider>
@ -391,7 +396,21 @@ export class DashboardContainer
return newId;
}
public getDashboardPanelFromId = (panelId: string) => this.getInput().panels[panelId];
public getDashboardPanelFromId = async (panelId: string) => {
const panel = this.getInput().panels[panelId];
if (reactEmbeddableRegistryHasKey(panel.type)) {
const child = this.reactEmbeddableChildren.value[panelId];
if (!child) throw new PanelNotFoundError();
const serialized = await child.serializeState();
return {
type: panel.type,
explicitInput: { ...panel.explicitInput, ...serialized.rawState },
gridData: panel.gridData,
version: serialized.version,
};
}
return panel;
};
public expandPanel = (panelId?: string) => {
this.setExpandedPanelId(panelId);
@ -435,6 +454,7 @@ export class DashboardContainer
if (timeRange) timeFilterService.setTime(timeRange);
if (refreshInterval) timeFilterService.setRefreshInterval(refreshInterval);
}
this.resetAllReactEmbeddables();
}
public navigateToDashboard = async (
@ -537,14 +557,20 @@ export class DashboardContainer
public async getPanelTitles(): Promise<string[]> {
const titles: string[] = [];
const ids: string[] = Object.keys(this.getInput().panels);
for (const panelId of ids) {
await this.untilEmbeddableLoaded(panelId);
const child: IEmbeddable<EmbeddableInput, EmbeddableOutput> = this.getChild(panelId);
const title = child.getTitle();
if (title) {
titles.push(title);
}
for (const [id, panel] of Object.entries(this.getInput().panels)) {
const title = await (async () => {
if (reactEmbeddableRegistryHasKey(panel.type)) {
return (
this.reactEmbeddableChildren.value[id]?.panelTitle?.value ??
this.reactEmbeddableChildren.value[id]?.defaultPanelTitle?.value
);
}
await this.untilEmbeddableLoaded(id);
const child: IEmbeddable<EmbeddableInput, EmbeddableOutput> = this.getChild(id);
if (!child) return undefined;
return child.getTitle();
})();
if (title) titles.push(title);
}
return titles;
}
@ -589,4 +615,75 @@ export class DashboardContainer
public setFocusedPanelId = (id: string | undefined) => {
this.dispatch.setFocusedPanelId(id);
};
// ------------------------------------------------------------------------------------------------------
// React Embeddable system
// ------------------------------------------------------------------------------------------------------
public registerPanelApi = <ApiType extends unknown = unknown>(id: string, api: ApiType) => {
this.reactEmbeddableChildren.next({
...this.reactEmbeddableChildren.value,
[id]: api as DefaultEmbeddableApi,
});
};
public getLastSavedStateForChild = (childId: string) => {
const {
componentState: {
lastSavedInput: { panels },
},
} = this.getState();
const panel: DashboardPanelState | undefined = panels[childId];
// TODO Embeddable refactor. References here
return { rawState: panel?.explicitInput, version: panel?.version, references: [] };
};
public removePanel(id: string) {
const type = this.getInput().panels[id]?.type;
this.removeEmbeddable(id);
if (reactEmbeddableRegistryHasKey(type)) {
const { [id]: childToRemove, ...otherChildren } = this.reactEmbeddableChildren.value;
this.reactEmbeddableChildren.next(otherChildren);
}
}
public startAuditingReactEmbeddableChildren = () => {
const auditChildren = () => {
const currentChildren = this.reactEmbeddableChildren.value;
let panelsChanged = false;
for (const panelId of Object.keys(currentChildren)) {
if (!this.getInput().panels[panelId]) {
delete currentChildren[panelId];
panelsChanged = true;
}
}
if (panelsChanged) this.reactEmbeddableChildren.next(currentChildren);
};
// audit children when panels change
this.publishingSubscription.add(
this.getInput$()
.pipe(
map(() => Object.keys(this.getInput().panels)),
distinctUntilChanged(deepEqual)
)
.subscribe(() => auditChildren())
);
auditChildren();
};
public resetAllReactEmbeddables = () => {
let resetChangedPanelCount = false;
const currentChildren = this.reactEmbeddableChildren.value;
for (const panelId of Object.keys(currentChildren)) {
if (this.getInput().panels[panelId]) {
currentChildren[panelId].resetUnsavedChanges();
} else {
// if reset resulted in panel removal, we need to update the list of children
delete currentChildren[panelId];
resetChangedPanelCount = true;
}
}
if (resetChangedPanelCount) this.reactEmbeddableChildren.next(currentChildren);
};
}

View file

@ -34,7 +34,7 @@ export interface DashboardPluginInternalFunctions {
* A temporary backdoor to allow some actions access to the Dashboard panels. This should eventually be replaced with a generic version
* on the PresentationContainer interface.
*/
getDashboardPanelFromId: (id: string) => DashboardPanelState;
getDashboardPanelFromId: (id: string) => Promise<DashboardPanelState>;
/**
* A temporary backdoor to allow the filters notification popover to get the data views directly from the dashboard container

View file

@ -6,16 +6,17 @@
* Side Public License, v 1.
*/
import fastIsEqual from 'fast-deep-equal';
import { shouldRefreshFilterCompareOptions } from '@kbn/embeddable-plugin/public';
import {
reactEmbeddableRegistryHasKey,
shouldRefreshFilterCompareOptions,
} from '@kbn/embeddable-plugin/public';
import {
compareFilters,
COMPARE_ALL_OPTIONS,
isFilterPinned,
onlyDisabledFiltersChanged,
} from '@kbn/es-query';
import fastIsEqual from 'fast-deep-equal';
import { DashboardContainerInput } from '../../../../common';
import { DashboardContainer } from '../../embeddable/dashboard_container';
import { DashboardContainerInputWithoutId } from '../../types';
@ -82,7 +83,11 @@ export const unsavedChangesDiffingFunctions: DashboardDiffFunctions = {
(panel) =>
new Promise<boolean>((resolve, reject) => {
const embeddableId = panel.explicitInput.id;
if (!embeddableId) reject();
if (!embeddableId || reactEmbeddableRegistryHasKey(panel.type)) {
// if this is a new style embeddable, it will handle its own diffing.
reject();
return;
}
try {
container.untilEmbeddableLoaded(embeddableId).then((embeddable) =>
embeddable

View file

@ -5,20 +5,23 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { omit } from 'lodash';
import { PersistableControlGroupInput } from '@kbn/controls-plugin/common';
import deepEqual from 'fast-deep-equal';
import { cloneDeep, omit } from 'lodash';
import { AnyAction, Middleware } from 'redux';
import { debounceTime, Observable, startWith, Subject, switchMap } from 'rxjs';
import { combineLatest, debounceTime, Observable, of, startWith, switchMap } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { DashboardContainer, DashboardCreationOptions } from '../..';
import { DashboardContainerInput } from '../../../../common';
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
import { pluginServices } from '../../../services/plugin_services';
import { dashboardContainerReducers } from '../dashboard_container_reducers';
import {
isKeyEqual,
isKeyEqualAsync,
shouldRefreshDiffingFunctions,
unsavedChangesDiffingFunctions,
} from './dashboard_diffing_functions';
import { DashboardContainerInput } from '../../../../common';
import { DashboardContainer, DashboardCreationOptions } from '../..';
import { CHANGE_CHECK_DEBOUNCE } from '../../../dashboard_constants';
import { dashboardContainerReducers } from '../dashboard_container_reducers';
/**
* An array of reducers which cannot cause unsaved changes. Unsaved changes only compares the explicit input
@ -74,6 +77,24 @@ const sessionChangeKeys: Array<keyof Omit<DashboardContainerInput, 'panels'>> =
'syncTooltips',
];
/**
* build middleware that fires an event any time a reducer that could cause unsaved changes is run
*/
export function getDiffingMiddleware(this: DashboardContainer) {
const diffingMiddleware: Middleware<AnyAction> = (store) => (next) => (action) => {
const dispatchedActionName = action.type.split('/')?.[1];
if (
dispatchedActionName &&
dispatchedActionName !== 'updateEmbeddableReduxOutput' && // ignore any generic output updates.
!reducersToIgnore.includes(dispatchedActionName)
) {
this.anyReducerRun.next(null);
}
next(action);
};
return diffingMiddleware;
}
/**
* Does an initial diff between @param initialInput and @param initialLastSavedInput, and creates a middleware
* which listens to the redux store and pushes updates to the `hasUnsavedChanges` and `backupUnsavedChanges` behaviour
@ -83,56 +104,89 @@ export function startDiffingDashboardState(
this: DashboardContainer,
creationOptions?: DashboardCreationOptions
) {
const checkForUnsavedChangesSubject$ = new Subject<null>();
this.diffingSubscription.add(
checkForUnsavedChangesSubject$
.pipe(
startWith(null),
debounceTime(CHANGE_CHECK_DEBOUNCE),
switchMap(() => {
return new Observable((observer) => {
const {
explicitInput: currentInput,
componentState: { lastSavedInput },
} = this.getState();
getUnsavedChanges
.bind(this)(lastSavedInput, currentInput)
.then((unsavedChanges) => {
if (observer.closed) return;
const validUnsavedChanges = omit(unsavedChanges, keysNotConsideredUnsavedChanges);
const hasChanges = Object.keys(validUnsavedChanges).length > 0;
this.hasUnsavedChanges.next(hasChanges);
/**
* Create an observable stream of unsaved changes from all react embeddable children
*/
const reactEmbeddableUnsavedChanges = this.reactEmbeddableChildren.pipe(
map((children) => Object.keys(children)),
distinctUntilChanged(deepEqual),
debounceTime(CHANGE_CHECK_DEBOUNCE),
if (creationOptions?.useSessionStorageIntegration) {
this.backupUnsavedChanges.next(
omit(unsavedChanges, keysToOmitFromSessionStorage)
);
}
});
});
})
)
.subscribe()
// children may change, so make sure we subscribe/unsubscribe with switchMap
switchMap((newChildIds: string[]) => {
if (newChildIds.length === 0) return of([]);
return combineLatest(
newChildIds.map((childId) =>
this.reactEmbeddableChildren.value[childId].unsavedChanges.pipe(
map((unsavedChanges) => {
return { childId, unsavedChanges };
})
)
)
);
}),
map((children) => children.filter((child) => Boolean(child.unsavedChanges)))
);
/**
* Create an observable stream that checks for unsaved changes in the Dashboard state
* and the state of all of its legacy embeddable children.
*/
const dashboardUnsavedChanges = this.anyReducerRun.pipe(
startWith(null),
debounceTime(CHANGE_CHECK_DEBOUNCE),
switchMap(() => {
return new Observable<Partial<DashboardContainerInput>>((observer) => {
const {
explicitInput: currentInput,
componentState: { lastSavedInput },
} = this.getState();
getDashboardUnsavedChanges
.bind(this)(lastSavedInput, currentInput)
.then((unsavedChanges) => {
if (observer.closed) return;
observer.next(unsavedChanges);
});
});
})
);
/**
* Combine unsaved changes from all sources together. Set unsaved changes state and backup unsaved changes when any of the sources emit.
*/
this.diffingSubscription.add(
combineLatest([
dashboardUnsavedChanges,
reactEmbeddableUnsavedChanges,
this.controlGroup?.unsavedChanges ??
(of(undefined) as Observable<PersistableControlGroupInput | undefined>),
]).subscribe(([dashboardChanges, reactEmbeddableChanges, controlGroupChanges]) => {
// calculate unsaved changes
const hasUnsavedChanges =
Object.keys(omit(dashboardChanges, keysNotConsideredUnsavedChanges)).length > 0 ||
reactEmbeddableChanges.length > 0 ||
controlGroupChanges !== undefined;
if (hasUnsavedChanges !== this.getState().componentState.hasUnsavedChanges) {
this.dispatch.setHasUnsavedChanges(hasUnsavedChanges);
}
// backup unsaved changes if configured to do so
if (creationOptions?.useSessionStorageIntegration) {
backupUnsavedChanges.bind(this)(
dashboardChanges,
reactEmbeddableChanges,
controlGroupChanges
);
}
})
);
const diffingMiddleware: Middleware<AnyAction> = (store) => (next) => (action) => {
const dispatchedActionName = action.type.split('/')?.[1];
if (
dispatchedActionName &&
dispatchedActionName !== 'updateEmbeddableReduxOutput' && // ignore any generic output updates.
!reducersToIgnore.includes(dispatchedActionName)
) {
checkForUnsavedChangesSubject$.next(null);
}
next(action);
};
return diffingMiddleware;
}
/**
* Does a shallow diff between @param lastInput and @param input and
* @returns an object out of the keys which are different.
*/
export async function getUnsavedChanges(
export async function getDashboardUnsavedChanges(
this: DashboardContainer,
lastInput: DashboardContainerInput,
input: DashboardContainerInput
@ -198,3 +252,40 @@ export function getShouldRefresh(
}
return false;
}
function backupUnsavedChanges(
this: DashboardContainer,
dashboardChanges: Partial<DashboardContainerInput>,
reactEmbeddableChanges: Array<{
childId: string;
unsavedChanges: object | undefined;
}>,
controlGroupChanges: PersistableControlGroupInput | undefined
) {
const { dashboardBackup } = pluginServices.getServices();
// apply all unsaved state from react embeddables to the unsaved changes object.
let hasAnyReactEmbeddableUnsavedChanges = false;
const currentPanels = cloneDeep(dashboardChanges.panels ?? this.getInput().panels);
for (const { childId, unsavedChanges: childUnsavedChanges } of reactEmbeddableChanges) {
if (!childUnsavedChanges) continue;
const panelStateToBackup = {
...currentPanels[childId],
...(dashboardChanges.panels?.[childId] ?? {}),
explicitInput: {
...currentPanels[childId]?.explicitInput,
...(dashboardChanges.panels?.[childId]?.explicitInput ?? {}),
...childUnsavedChanges,
},
};
hasAnyReactEmbeddableUnsavedChanges = true;
currentPanels[childId] = panelStateToBackup;
}
const dashboardStateToBackup = omit(dashboardChanges, keysToOmitFromSessionStorage);
dashboardBackup.setState(this.getDashboardSavedObjectId(), {
...dashboardStateToBackup,
panels: hasAnyReactEmbeddableUnsavedChanges ? currentPanels : dashboardChanges.panels,
controlGroupInput: controlGroupChanges,
});
}

View file

@ -9,9 +9,9 @@
import { ControlGroupInput } from '@kbn/controls-plugin/common';
import {
EmbeddableFactoryNotFoundError,
reactEmbeddableRegistryHasKey,
runEmbeddableFactoryMigrations,
} from '@kbn/embeddable-plugin/public';
import { DashboardContainerInput, DashboardPanelState } from '../../../../common';
import { type DashboardEmbeddableService } from '../../embeddable/types';
import { SavedDashboardInput } from '../types';
@ -51,7 +51,13 @@ export const migrateDashboardInput = (
});
}
const migratedPanels: DashboardContainerInput['panels'] = {};
Object.entries(dashboardInput.panels).forEach(([id, panel]) => {
for (const [id, panel] of Object.entries(dashboardInput.panels)) {
// if the panel type is registered in the new embeddable system, we do not need to run migrations for it.
if (reactEmbeddableRegistryHasKey(panel.type)) {
migratedPanels[id] = panel;
continue;
}
const factory = embeddable.getEmbeddableFactory(panel.type);
if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type);
// run last saved migrations for by value panels only.
@ -67,7 +73,7 @@ export const migrateDashboardInput = (
panel.explicitInput.version = factory.latestVersion;
}
migratedPanels[id] = panel;
});
}
dashboardInput.panels = migratedPanels;
return { dashboardInput, anyMigrationRun };
};

View file

@ -75,7 +75,7 @@
"@kbn/presentation-panel-plugin",
"@kbn/content-management-table-list-view-common",
"@kbn/shared-ux-utility",
"@kbn/managed-content-badge"
"@kbn/managed-content-badge",
],
"exclude": ["target/**/*"]
}

View file

@ -10,8 +10,6 @@ import { PresentationPanelProps } from '@kbn/presentation-panel-plugin/public';
import { MaybePromise } from '@kbn/utility-types';
import { ReactNode } from 'react';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../lib';
import { LegacyEmbeddableAPI } from '../lib/embeddables/i_embeddable';
import { EmbeddableComponent } from '../registry/types';
export type LegacyCompatibleEmbeddable = IEmbeddable<
EmbeddableInput,
@ -26,5 +24,3 @@ export type EmbeddablePanelProps = Omit<PresentationPanelProps, 'Component'> & {
export type UnwrappedEmbeddablePanelProps = Omit<EmbeddablePanelProps, 'embeddable'> & {
embeddable: LegacyCompatibleEmbeddable;
};
export type LegacyEmbeddableCompatibilityComponent = EmbeddableComponent<LegacyEmbeddableAPI>;

View file

@ -92,8 +92,26 @@ export type {
EmbeddableStartDependencies,
} from './plugin';
export type { EnhancementRegistryDefinition } from './types';
export type { EmbeddableComponentFactory } from './registry/types';
export { CreateEmbeddableComponent } from './registry/create_embeddable_component';
export {
ReactEmbeddableRenderer,
reactEmbeddableRegistryHasKey,
RegisterReactEmbeddable,
registerReactEmbeddableFactory,
useReactEmbeddableApiHandle,
type DefaultEmbeddableApi,
type ReactEmbeddable,
type ReactEmbeddableFactory,
type ReactEmbeddableRegistration,
type ReactEmbeddableTitlesApi,
type SerializedReactEmbeddableTitles,
ReactEmbeddableParentContext,
useReactEmbeddableParentApi,
useReactEmbeddableUnsavedChanges,
initializeReactEmbeddableUuid,
initializeReactEmbeddableTitles,
serializeReactEmbeddableTitles,
} from './react_embeddable_system';
export function plugin(initializerContext: PluginInitializerContext) {
return new EmbeddablePublicPlugin(initializerContext);

View file

@ -8,7 +8,7 @@
import deepEqual from 'fast-deep-equal';
import { isEqual, xor } from 'lodash';
import { EMPTY, merge, Subscription } from 'rxjs';
import { EMPTY, merge, Subject, Subscription } from 'rxjs';
import {
catchError,
combineLatestWith,
@ -21,7 +21,11 @@ import {
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { PresentationContainer, PanelPackage } from '@kbn/presentation-containers';
import {
PresentationContainer,
PanelPackage,
SerializedPanelState,
} from '@kbn/presentation-containers';
import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable';
import { EmbeddableStart } from '../../plugin';
@ -42,6 +46,7 @@ import {
IContainer,
PanelState,
} from './i_container';
import { reactEmbeddableRegistryHasKey } from '../../react_embeddable_system';
const getKeys = <T extends {}>(o: T): Array<keyof T> => Object.keys(o) as Array<keyof T>;
@ -61,6 +66,12 @@ export abstract class Container<
private subscription: Subscription | undefined;
private readonly anyChildOutputChange$;
public lastSavedState: Subject<void> = new Subject();
public getLastSavedStateForChild: (childId: string) => SerializedPanelState | undefined = () =>
undefined;
public registerPanelApi = <ApiType extends unknown = unknown>(id: string, api: ApiType) => {};
constructor(
input: TContainerInput,
output: TContainerOutput,
@ -492,6 +503,17 @@ export abstract class Container<
}
private async onPanelAdded(panel: PanelState) {
// do nothing if this panel's type is in the new Embeddable registry.
if (reactEmbeddableRegistryHasKey(panel.type)) {
this.updateOutput({
embeddableLoaded: {
...this.output.embeddableLoaded,
[panel.explicitInput.id]: true,
},
} as Partial<TContainerOutput>);
return;
}
this.updateOutput({
embeddableLoaded: {
...this.output.embeddableLoaded,

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
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';
export {
initializeReactEmbeddableTitles,
serializeReactEmbeddableTitles,
type ReactEmbeddableTitlesApi,
type SerializedReactEmbeddableTitles,
} from './react_embeddable_titles';
export type {
DefaultEmbeddableApi,
ReactEmbeddable,
ReactEmbeddableFactory,
ReactEmbeddableRegistration,
} from './types';

View file

@ -0,0 +1,87 @@
/*
* 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

@ -0,0 +1,74 @@
/*
* 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

@ -0,0 +1,37 @@
/*
* 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 {
registerReactEmbeddableFactory,
reactEmbeddableRegistryHasKey,
getReactEmbeddableFactory,
} from './react_embeddable_registry';
import { ReactEmbeddableFactory } from './types';
describe('react embeddable registry', () => {
const testEmbeddableFactory: ReactEmbeddableFactory = {
deserializeState: jest.fn(),
getComponent: jest.fn(),
};
it('throws an error if requested embeddable factory type is not registered', () => {
expect(() => getReactEmbeddableFactory('notRegistered')).toThrowErrorMatchingInlineSnapshot(
`"No embeddable factory found for type: notRegistered"`
);
});
it('can register and get an embeddable factory', () => {
registerReactEmbeddableFactory('test', testEmbeddableFactory);
expect(getReactEmbeddableFactory('test')).toBe(testEmbeddableFactory);
});
it('can check if a factory is registered', () => {
expect(reactEmbeddableRegistryHasKey('test')).toBe(true);
expect(reactEmbeddableRegistryHasKey('notRegistered')).toBe(false);
});
});

View file

@ -0,0 +1,61 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React from 'react';
import {
DefaultEmbeddableApi,
ReactEmbeddable,
ReactEmbeddableFactory,
ReactEmbeddableRegistration,
} from './types';
const registry: { [key: string]: ReactEmbeddableFactory<any, any> } = {};
export const registerReactEmbeddableFactory = <
StateType extends unknown = unknown,
APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi
>(
key: string,
factory: ReactEmbeddableFactory<StateType, APIType>
) => {
if (registry[key] !== undefined)
throw new Error(
i18n.translate('embeddableApi.reactEmbeddable.factoryAlreadyExistsError', {
defaultMessage: 'An embeddable factory for for type: {key} is already registered.',
values: { key },
})
);
registry[key] = factory;
};
export const reactEmbeddableRegistryHasKey = (key: string) => registry[key] !== undefined;
export const getReactEmbeddableFactory = <
StateType extends unknown = unknown,
ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi
>(
key: string
): ReactEmbeddableFactory<StateType, ApiType> => {
if (registry[key] === undefined)
throw new Error(
i18n.translate('embeddableApi.reactEmbeddable.factoryNotFoundError', {
defaultMessage: 'No embeddable factory found for type: {key}',
values: { key },
})
);
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

@ -0,0 +1,40 @@
/*
* 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, screen } from '@testing-library/react';
import React from 'react';
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>;
}),
};
beforeAll(() => {
registerReactEmbeddableFactory('test', testEmbeddableFactory);
});
it('deserializes given state', () => {
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { blorp: 'blorp?' } }} />);
expect(testEmbeddableFactory.deserializeState).toHaveBeenCalledWith({
rawState: { blorp: 'blorp?' },
});
});
it('renders the given component once it resolves', () => {
render(<ReactEmbeddableRenderer type={'test'} state={{ rawState: { blorp: 'blorp?' } }} />);
waitFor(() => {
expect(screen.findByText('SUPER TEST COMPONENT')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { SerializedPanelState } from '@kbn/presentation-containers';
import { PresentationPanel } from '@kbn/presentation-panel-plugin/public';
import React, { useMemo } from 'react';
import { getReactEmbeddableFactory } from './react_embeddable_registry';
/**
* 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,
type,
state,
}: {
uuid?: string;
type: string;
state: SerializedPanelState;
}) => {
const componentPromise = useMemo(
() =>
(async () => {
const factory = getReactEmbeddableFactory(type);
return await factory.getComponent(factory.deserializeState(state), uuid);
})(),
/**
* Disabling exhaustive deps because we do not want to re-fetch the component
* from the embeddable registry unless the type changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
[type]
);
return <PresentationPanel Component={componentPromise} />;
};

View file

@ -0,0 +1,69 @@
/*
* 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 {
initializeReactEmbeddableTitles,
SerializedReactEmbeddableTitles,
} from './react_embeddable_titles';
describe('react embeddable titles', () => {
const rawState: SerializedReactEmbeddableTitles = {
title: 'very cool title',
description: 'less cool description',
hidePanelTitles: false,
};
it('should initialize publishing subjects with the provided rawState', () => {
const { titlesApi } = initializeReactEmbeddableTitles(rawState);
expect(titlesApi.panelTitle.value).toBe(rawState.title);
expect(titlesApi.panelDescription.value).toBe(rawState.description);
expect(titlesApi.hidePanelTitle.value).toBe(rawState.hidePanelTitles);
});
it('should update publishing subject values when set functions are called', () => {
const { titlesApi } = initializeReactEmbeddableTitles(rawState);
titlesApi.setPanelTitle('even cooler title');
titlesApi.setPanelDescription('super uncool description');
titlesApi.setHidePanelTitle(true);
expect(titlesApi.panelTitle.value).toEqual('even cooler title');
expect(titlesApi.panelDescription.value).toEqual('super uncool description');
expect(titlesApi.hidePanelTitle.value).toBe(true);
});
it('should correctly serialize current state', () => {
const { serializeTitles, titlesApi } = initializeReactEmbeddableTitles(rawState);
titlesApi.setPanelTitle('UH OH, A TITLE');
const serializedTitles = serializeTitles();
expect(serializedTitles).toMatchInlineSnapshot(`
Object {
"description": "less cool description",
"hidePanelTitles": false,
"title": "UH OH, A TITLE",
}
`);
});
it('should return the correct set of comparators', () => {
const { titleComparators } = initializeReactEmbeddableTitles(rawState);
expect(titleComparators.title).toBeDefined();
expect(titleComparators.description).toBeDefined();
expect(titleComparators.hidePanelTitles).toBeDefined();
});
it('should correctly compare hidePanelTitles with custom comparator', () => {
const { titleComparators } = initializeReactEmbeddableTitles(rawState);
expect(titleComparators.hidePanelTitles![2]!(true, false)).toBe(false);
expect(titleComparators.hidePanelTitles![2]!(undefined, false)).toBe(true);
expect(titleComparators.hidePanelTitles![2]!(true, undefined)).toBe(false);
});
});

View file

@ -0,0 +1,70 @@
/*
* 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 {
PublishesWritablePanelDescription,
PublishesWritablePanelTitle,
} from '@kbn/presentation-publishing';
import { BehaviorSubject } from 'rxjs';
import { EmbeddableStateComparators } from './types';
export interface SerializedReactEmbeddableTitles {
title?: string;
description?: string;
hidePanelTitles?: boolean;
}
export type ReactEmbeddableTitlesApi = PublishesWritablePanelTitle &
PublishesWritablePanelDescription;
export const initializeReactEmbeddableTitles = (
rawState: SerializedReactEmbeddableTitles
): {
titlesApi: ReactEmbeddableTitlesApi;
titleComparators: EmbeddableStateComparators<SerializedReactEmbeddableTitles>;
serializeTitles: () => SerializedReactEmbeddableTitles;
} => {
const panelTitle = new BehaviorSubject<string | undefined>(rawState.title);
const panelDescription = new BehaviorSubject<string | undefined>(rawState.description);
const hidePanelTitle = new BehaviorSubject<boolean | undefined>(rawState.hidePanelTitles);
const setPanelTitle = (value: string | undefined) => panelTitle.next(value);
const setHidePanelTitle = (value: boolean | undefined) => hidePanelTitle.next(value);
const setPanelDescription = (value: string | undefined) => panelDescription.next(value);
const titleComparators: EmbeddableStateComparators<SerializedReactEmbeddableTitles> = {
title: [panelTitle, setPanelTitle],
description: [panelDescription, setPanelDescription],
hidePanelTitles: [hidePanelTitle, setHidePanelTitle, (a, b) => Boolean(a) === Boolean(b)],
};
const titlesApi = {
panelTitle,
hidePanelTitle,
setPanelTitle,
setHidePanelTitle,
panelDescription,
setPanelDescription,
};
return {
serializeTitles: () => serializeReactEmbeddableTitles(titlesApi),
titleComparators,
titlesApi,
};
};
export const serializeReactEmbeddableTitles = (
titlesApi: ReactEmbeddableTitlesApi
): SerializedReactEmbeddableTitles => {
return {
title: titlesApi.panelTitle.value,
hidePanelTitles: titlesApi.hidePanelTitle.value,
description: titlesApi.panelDescription.value,
};
};

View file

@ -0,0 +1,205 @@
/*
* 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 {
PresentationContainer,
PublishesLastSavedState,
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 { BehaviorSubject, Subject } from 'rxjs';
import { ReactEmbeddableParentContext } from './react_embeddable_api';
import { useReactEmbeddableUnsavedChanges } from './react_embeddable_unsaved_changes';
import { EmbeddableStateComparators, ReactEmbeddableFactory } from './types';
interface SuperTestStateType {
name: string;
age: number;
tagline: string;
}
describe('react embeddable unsaved changes', () => {
let initialState: SuperTestStateType;
let lastSavedState: SuperTestStateType;
let comparators: EmbeddableStateComparators<SuperTestStateType>;
let deserializeState: (state: SerializedPanelState) => SuperTestStateType;
let parentApi: (PresentationContainer & PublishesLastSavedState) | null;
beforeEach(() => {
initialState = {
name: 'Sir Testsalot',
age: 42,
tagline: 'A glutton for testing!',
};
lastSavedState = {
name: 'Sir Testsalot',
age: 42,
tagline: 'A glutton for testing!',
};
});
const initializeDefaultComparators = () => {
const nameSubject = new BehaviorSubject<string>(initialState.name);
const ageSubject = new BehaviorSubject<number>(initialState.age);
const taglineSubject = new BehaviorSubject<string>(initialState.tagline);
const defaultComparators: EmbeddableStateComparators<SuperTestStateType> = {
name: [nameSubject, jest.fn((nextName) => nameSubject.next(nextName))],
age: [ageSubject, jest.fn((nextAge) => ageSubject.next(nextAge))],
tagline: [taglineSubject, jest.fn((nextTagline) => taglineSubject.next(nextTagline))],
};
return defaultComparators;
};
const renderTestComponent = async (
customComparators?: EmbeddableStateComparators<SuperTestStateType>
) => {
comparators = customComparators ?? initializeDefaultComparators();
deserializeState = jest.fn((state) => state.rawState as SuperTestStateType);
parentApi = {
...getMockPresentationContainer(),
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;
};
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();
expect(unsavedChangesApi).toBeDefined();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
it('runs factory deserialize function on last saved state', async () => {
await renderTestComponent();
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();
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();
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();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.tagline[1]('Testing is my speciality!');
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
tagline: 'Testing is my speciality!',
});
});
});
it('should detect unsaved changes when last saved state changes during the lifetime of the component', async () => {
const unsavedChangesApi = await renderTestComponent();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
lastSavedState.tagline = 'Some other tagline';
parentApi?.lastSavedState.next();
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
// we expect `A glutton for testing!` here because that is the current state of the component.
tagline: 'A glutton for testing!',
});
});
});
it('should reset unsaved changes, calling given setters with last saved values. This should remove all unsaved state', async () => {
const unsavedChangesApi = await renderTestComponent();
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
comparators.tagline[1]('Testing is my speciality!');
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
tagline: 'Testing is my speciality!',
});
});
unsavedChangesApi.resetUnsavedChanges();
expect(comparators.tagline[1]).toHaveBeenCalledWith('A glutton for testing!');
await waitFor(() => {
expect(unsavedChangesApi.unsavedChanges.value).toBe(undefined);
});
});
it('uses a custom comparator when supplied', async () => {
lastSavedState.age = 20;
initialState.age = 50;
const ageSubject = new BehaviorSubject(initialState.age);
const customComparators: EmbeddableStateComparators<SuperTestStateType> = {
...initializeDefaultComparators(),
age: [
ageSubject,
jest.fn((nextAge) => ageSubject.next(nextAge)),
(lastAge, currentAge) => lastAge?.toString().length === currentAge?.toString().length,
],
};
const unsavedChangesApi = await renderTestComponent(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);
comparators.age[1](101);
await waitFor(() => {
// here we expect there to be unsaved changes, because now the latest state has three digits.
expect(unsavedChangesApi.unsavedChanges.value).toEqual({
age: 101,
});
});
});
});

View file

@ -0,0 +1,133 @@
/*
* 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 { getLastSavedStateSubjectForChild } 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';
const defaultComparator = <T>(a: T, b: T) => a === b;
const getInitialValuesFromComparators = <StateType extends object = object>(
comparators: EmbeddableStateComparators<StateType>,
comparatorKeys: Array<keyof StateType>
) => {
const initialValues: Partial<StateType> = {};
for (const key of comparatorKeys) {
const comparatorSubject = comparators[key][0]; // 0th element of tuple is the subject
initialValues[key] = comparatorSubject?.value;
}
return initialValues;
};
const runComparators = <StateType extends object = object>(
comparators: EmbeddableStateComparators<StateType>,
comparatorKeys: Array<keyof StateType>,
lastSavedState: StateType | undefined,
latestState: Partial<StateType>
) => {
if (!lastSavedState) {
// if the parent API provides last saved state, but it's empty for this panel, all of our latest state is unsaved.
return latestState;
}
const latestChanges: Partial<StateType> = {};
for (const key of comparatorKeys) {
const customComparator = comparators[key]?.[2]; // 2nd element of the tuple is the custom comparator
const comparator = customComparator ?? defaultComparator;
if (!comparator(lastSavedState?.[key], latestState[key], lastSavedState, latestState)) {
latestChanges[key] = latestState[key];
}
}
return Object.keys(latestChanges).length > 0 ? latestChanges : undefined;
};
export const useReactEmbeddableUnsavedChanges = <StateType extends object = object>(
uuid: string,
factory: ReactEmbeddableFactory<StateType>,
comparators: EmbeddableStateComparators<StateType>
) => {
const { parentApi } = useReactEmbeddableParentContext() ?? {};
const lastSavedStateSubject = useMemo(
() => getLastSavedStateSubjectForChild<StateType>(parentApi, uuid, factory.deserializeState),
[factory.deserializeState, parentApi, uuid]
);
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
),
// disable exhaustive deps because the comparators must be static
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
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 };
};

View file

@ -0,0 +1,60 @@
/*
* 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 { 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';
export type ReactEmbeddableRegistration<
ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi
> = (ref: React.ForwardedRef<ApiType>) => ReactElement | null;
/**
* The default embeddable API that all Embeddables must implement.
*
* Before adding anything to this interface, please be certain that it belongs in *every* embeddable.
*/
export type DefaultEmbeddableApi = DefaultPresentationPanelApi &
PublishesUnsavedChanges & {
serializeState: () => Promise<SerializedPanelState>;
};
export type ReactEmbeddable<ApiType extends DefaultEmbeddableApi = DefaultEmbeddableApi> =
React.ForwardRefExoticComponent<React.RefAttributes<ApiType>>;
export interface ReactEmbeddableFactory<
StateType extends unknown = unknown,
APIType extends DefaultEmbeddableApi = DefaultEmbeddableApi
> {
getComponent: (initialState: StateType, maybeId?: string) => Promise<ReactEmbeddable<APIType>>;
deserializeState: (state: SerializedPanelState) => StateType;
latestVersion?: string;
}
export type StateTypeFromFactory<F extends ReactEmbeddableFactory<any>> =
F extends ReactEmbeddableFactory<infer S> ? S : never;
/**
* State comparators
*/
export type EmbeddableComparatorFunction<StateType, KeyType extends keyof StateType> = (
last: StateType[KeyType] | undefined,
current: StateType[KeyType] | undefined,
lastState?: Partial<StateType>,
currentState?: Partial<StateType>
) => boolean;
export type EmbeddableComparatorDefinition<StateType, KeyType extends keyof StateType> = [
PublishingSubject<StateType[KeyType]>,
(value: StateType[KeyType]) => void,
EmbeddableComparatorFunction<StateType, KeyType>?
];
export type EmbeddableStateComparators<StateType> = {
[KeyType in keyof StateType]: EmbeddableComparatorDefinition<StateType, KeyType>;
};

View file

@ -1,17 +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 React from 'react';
import { EmbeddableComponent } from './types';
export const CreateEmbeddableComponent: <ApiType extends unknown = unknown>(
component: (
ref: React.ForwardedRef<ApiType>
) => React.ReactElement<any, string | React.JSXElementConstructor<any>> | null
) => EmbeddableComponent<ApiType> = (component) =>
React.forwardRef((_, apiRef) => component(apiRef));

View file

@ -1,18 +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.
*/
export type EmbeddableComponent<ApiType extends unknown = unknown> =
React.ForwardRefExoticComponent<React.RefAttributes<ApiType>>;
export interface EmbeddableComponentFactory<
StateType extends unknown = unknown,
APIType extends unknown = unknown
> {
getComponent: (initialState: StateType) => Promise<EmbeddableComponent<APIType>>;
deserializeState: (state: unknown) => StateType;
}

View file

@ -11,6 +11,7 @@ import {
apiCanAccessViewMode,
apiPublishesDataViews,
apiPublishesLocalUnifiedSearch,
apiPublishesPanelTitle,
CanAccessViewMode,
EmbeddableApiContext,
getInheritedViewMode,
@ -26,9 +27,9 @@ import { openCustomizePanelFlyout } from './open_customize_panel';
export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL';
export type CustomizePanelActionApi = CanAccessViewMode &
PublishesDataViews &
Partial<
PublishesWritableLocalUnifiedSearch &
PublishesDataViews &
PublishesWritableLocalUnifiedSearch &
PublishesWritablePanelDescription &
PublishesWritablePanelTitle &
HasParentApi
@ -37,7 +38,7 @@ export type CustomizePanelActionApi = CanAccessViewMode &
export const isApiCompatibleWithCustomizePanelAction = (
api: unknown | null
): api is CustomizePanelActionApi =>
Boolean(apiCanAccessViewMode(api) && apiPublishesDataViews(api));
apiCanAccessViewMode(api) && (apiPublishesDataViews(api) || apiPublishesPanelTitle(api));
export class CustomizePanelAction implements Action<EmbeddableApiContext> {
public type = ACTION_CUSTOMIZE_PANEL;

View file

@ -37,7 +37,7 @@ interface FiltersDetailsProps {
export function FiltersDetails({ editMode, api }: FiltersDetailsProps) {
const [queryString, setQueryString] = useState<string>('');
const [queryLanguage, setQueryLanguage] = useState<'sql' | 'esql' | undefined>();
const dataViews = api.dataViews.value ?? [];
const dataViews = api.dataViews?.value ?? [];
const filters = useMemo(() => api.localFilters?.value ?? [], [api]);

View file

@ -9,6 +9,7 @@
import { ViewMode } from '@kbn/presentation-publishing';
import { BehaviorSubject } from 'rxjs';
import { RemovePanelAction, RemovePanelActionApi } from './remove_panel_action';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
describe('Remove panel action', () => {
let action: RemovePanelAction;
@ -20,11 +21,7 @@ describe('Remove panel action', () => {
embeddable: {
uuid: 'superId',
viewMode: new BehaviorSubject<ViewMode>('edit'),
parentApi: {
removePanel: jest.fn(),
canRemovePanels: jest.fn().mockReturnValue(true),
replacePanel: jest.fn(),
},
parentApi: getMockPresentationContainer(),
},
};
});

View file

@ -43,8 +43,8 @@ export const PresentationPanelContextMenu = ({
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean | undefined>(undefined);
const [contextMenuPanels, setContextMenuPanels] = useState<EuiContextMenuPanelDescriptor[]>([]);
const { title, parentViewMode } = useBatchedPublishingSubjects({
title: api.panelTitle,
const [title, parentViewMode] = useBatchedPublishingSubjects(
api.panelTitle,
/**
* View mode changes often have the biggest influence over which actions will be compatible,
@ -52,8 +52,8 @@ export const PresentationPanelContextMenu = ({
* actions should eventually all be Frequent Compatibility Change Actions which can track their
* own dependencies.
*/
parentViewMode: getViewModeSubject(api),
});
getViewModeSubject(api)
);
useEffect(() => {
/**

View file

@ -8,6 +8,7 @@
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { DataView } from '@kbn/data-views-plugin/common';
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { PublishesDataViews, PublishesViewMode, ViewMode } from '@kbn/presentation-publishing';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@ -221,9 +222,8 @@ describe('Presentation panel', () => {
panelTitle: new BehaviorSubject<string | undefined>('SUPER TITLE'),
viewMode: new BehaviorSubject<ViewMode>('view'),
parentApi: {
removePanel: jest.fn(),
replacePanel: jest.fn(),
viewMode: new BehaviorSubject<ViewMode>('view'),
...getMockPresentationContainer(),
},
};
await renderPresentationPanel({ api });

View file

@ -47,27 +47,25 @@ export const PresentationPanelInternal = <
if (apiHasParentApi(api) && apiPublishesViewMode(api.parentApi)) return api.parentApi.viewMode;
})();
const {
rawViewMode,
const [
dataLoading,
blockingError,
panelTitle,
dataLoading,
hidePanelTitle,
panelDescription,
defaultPanelTitle,
rawViewMode,
parentHidePanelTitle,
} = useBatchedPublishingSubjects({
dataLoading: api?.dataLoading,
blockingError: api?.blockingError,
panelTitle: api?.panelTitle,
hidePanelTitle: api?.hidePanelTitle,
panelDescription: api?.panelDescription,
defaultPanelTitle: api?.defaultPanelTitle,
rawViewMode: viewModeSubject,
parentHidePanelTitle: api?.parentApi?.hidePanelTitle,
});
] = useBatchedPublishingSubjects(
api?.dataLoading,
api?.blockingError,
api?.panelTitle,
api?.hidePanelTitle,
api?.panelDescription,
api?.defaultPanelTitle,
viewModeSubject,
api?.parentApi?.hidePanelTitle
);
const viewMode = rawViewMode ?? 'view';
const [initialLoadComplete, setInitialLoadComplete] = useState(!dataLoading);

View file

@ -56,6 +56,10 @@ export interface PresentationPanelInternalProps<
index?: number;
}
/**
* 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 &

View file

@ -68,6 +68,7 @@ export class FlyoutCreateDrilldownAction implements Action<EmbeddableContext> {
}
public async isCompatible(context: EmbeddableContext) {
if (!context.embeddable?.getInput) return false;
const isEditMode = context.embeddable.getInput().viewMode === 'edit';
return isEditMode && this.isEmbeddableCompatible(context);
}

View file

@ -46,6 +46,7 @@ export class FlyoutEditDrilldownAction implements Action<EmbeddableContext> {
public readonly MenuItem = MenuItem as any;
public async isCompatible({ embeddable }: EmbeddableContext) {
if (!embeddable?.getInput) return false;
if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false;
if (!isEnhancedEmbeddable(embeddable)) return false;
return embeddable.enhancements.dynamicActions.state.get().events.length > 0;

View file

@ -46,7 +46,7 @@ export class PanelNotificationsAction implements ActionDefinition<EnhancedEmbedd
};
public couldBecomeCompatible({ embeddable }: EnhancedEmbeddableContext) {
return true;
return Boolean(!!embeddable.getInput && embeddable.getRoot);
}
public subscribeToCompatibilityChanges = (
@ -71,6 +71,7 @@ export class PanelNotificationsAction implements ActionDefinition<EnhancedEmbedd
};
public readonly isCompatible = async ({ embeddable }: EnhancedEmbeddableContext) => {
if (!embeddable?.getInput) return false;
if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false;
return this.getEventCount(embeddable) > 0;
};

View file

@ -24,6 +24,7 @@ interface Context {
}
export async function isEditActionCompatible(embeddable: IEmbeddable) {
if (!embeddable?.getInput) return false;
// display the action only if dashboard is on editable mode
const inDashboardEditMode = embeddable.getInput().viewMode === 'edit';
return Boolean(isLensEmbeddable(embeddable) && embeddable.getIsEditable() && inDashboardEditMode);