mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
c8bc299815
commit
435680612e
69 changed files with 1874 additions and 466 deletions
|
@ -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<
|
||||
|
|
|
@ -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);
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-api-server';
|
||||
|
||||
/**
|
||||
* A package containing the serialized Embeddable state, with references extracted. When saving Embeddables using any
|
||||
* strategy, this is the format that should be used.
|
||||
*/
|
||||
export interface SerializedPanelState<RawStateType extends object = object> {
|
||||
references?: SavedObjectReference[];
|
||||
rawState: RawStateType;
|
||||
version?: string;
|
||||
}
|
20
packages/presentation/presentation_containers/mocks.ts
Normal file
20
packages/presentation/presentation_containers/mocks.ts
Normal 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(),
|
||||
};
|
||||
};
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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>]
|
||||
: [];
|
|
@ -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);
|
||||
};
|
|
@ -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();
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
@ -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)'
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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));
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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} />;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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>;
|
||||
};
|
|
@ -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));
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
/**
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 &
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue