[Embeddable Rebuild] [Image] Migrate image embeddable to new embeddable framework (#178544)

Closes https://github.com/elastic/kibana/issues/174962
Closes https://github.com/elastic/kibana/issues/165848
Closes https://github.com/elastic/kibana/issues/179521

## Summary

This PR converts the Image embeddable to the new React embeddable
framework. There should not be **any** changes in user-facing behaviour
(unless they were made intentionally, such as the small change described
[here](https://github.com/elastic/kibana/pull/178544#discussion_r1539376300))
- therefore, testing of this PR should be focused on ensuring that no
behaviour is changed and/or broken with this refactor.

Since I was already doing a major overhaul, I took the opportunity to
clean up some of the image embeddable code, such as the small change
described
[here](https://github.com/elastic/kibana/pull/178544#discussion_r1538201565).
Some of my changes are heavily influenced by the Presentation team style
(such as my changes to the file organization) so, if there are any
disagreements, I am 100% open to make changes - after all, this code
does not belong to us and we are not responsible for maintenance. Since
this is the first embeddable to be officially refactored (🎉), I expect
there to be lots of questions + feedback and that is okay!

### Small Style Changes
In order to close https://github.com/elastic/kibana/issues/165848, I did
two things:
1. I fixed the contrast of the `optionsMenuButton` as described in
https://github.com/elastic/kibana/pull/178544#discussion_r1538162779
2. I ensured that the `PresentationPanel` enforces rounded corners in
view mode while keeping appearances consistent in edit mode (i.e. the
upper corners remain square so that it looks consistent with the title
bar):
    
    | | Before | After |
    |-----|--------|--------|
| **View mode** |
![image](5ecda06f-a47d-41c5-9370-cf51af1489e4)
|
![image](b3503016-63e2-4f70-9600-aa12fd650a67)
|
| **Edit mode** |
![image](bf014f11-8e77-4814-8df3-b1d4cd780bf4)
|
![image](3d8f4606-3d61-48b7-a2d0-f8e4787b8315)
|

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
([FTR](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5559))

![image](2041b786-adfd-4f6e-b885-bc348a4a9e20)
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2024-04-08 12:08:18 -06:00 committed by GitHub
parent d1e792a5a0
commit 826f7cb42b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 614 additions and 471 deletions

View file

@ -73,9 +73,9 @@ export const useBatchedPublishingSubjects = <SubjectsType extends [...AnyPublish
if (definedSubjects.length === 0) return;
const subscription = combineLatest(definedSubjects)
.pipe(
debounceTime(0),
// When a new observer subscribes to a BehaviorSubject, it immediately receives the current value. Skip this emit.
skip(1)
skip(1),
debounceTime(0)
)
.subscribe((values) => {
setLatestPublishedValues((lastPublishedValues) => {

View file

@ -7,18 +7,8 @@
"id": "imageEmbeddable",
"server": false,
"browser": true,
"requiredPlugins": [
"embeddable",
"files",
"uiActions"
],
"optionalPlugins": [
"security",
"screenshotMode",
],
"requiredBundles": [
"kibanaUtils",
"kibanaReact"
]
"requiredPlugins": ["embeddable", "files", "uiActions", "kibanaReact"],
"optionalPlugins": ["security", "screenshotMode", "embeddableEnhanced"],
"requiredBundles": []
}
}

View file

@ -0,0 +1,59 @@
/*
* 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 { CanAddNewPanel } from '@kbn/presentation-containers';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import {
ADD_IMAGE_EMBEDDABLE_ACTION_ID,
IMAGE_EMBEDDABLE_TYPE,
} from '../image_embeddable/constants';
import { uiActionsService } from '../services/kibana_services';
const parentApiIsCompatible = async (parentApi: unknown): Promise<CanAddNewPanel | undefined> => {
const { apiCanAddNewPanel } = await import('@kbn/presentation-containers');
// we cannot have an async type check, so return the casted parentApi rather than a boolean
return apiCanAddNewPanel(parentApi) ? (parentApi as CanAddNewPanel) : undefined;
};
export const registerCreateImageAction = () => {
uiActionsService.registerAction<EmbeddableApiContext>({
id: ADD_IMAGE_EMBEDDABLE_ACTION_ID,
getIconType: () => 'image',
isCompatible: async ({ embeddable: parentApi }) => {
return Boolean(await parentApiIsCompatible(parentApi));
},
execute: async ({ embeddable: parentApi }) => {
const canAddNewPanelParent = await parentApiIsCompatible(parentApi);
if (!canAddNewPanelParent) throw new IncompatibleActionError();
const { openImageEditor } = await import('../components/image_editor/open_image_editor');
try {
const imageConfig = await openImageEditor({ parentApi: canAddNewPanelParent });
canAddNewPanelParent.addNewPanel({
panelType: IMAGE_EMBEDDABLE_TYPE,
initialState: { imageConfig },
});
} catch {
// swallow the rejection, since this just means the user closed without saving
}
},
getDisplayName: () =>
i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName', {
defaultMessage: 'Image',
}),
});
uiActionsService.attachAction('ADD_PANEL_TRIGGER', ADD_IMAGE_EMBEDDABLE_ACTION_ID);
if (uiActionsService.hasTrigger('ADD_CANVAS_ELEMENT_TRIGGER')) {
// Because Canvas is not enabled in Serverless, this trigger might not be registered - only attach
// the create action if the Canvas-specific trigger does indeed exist.
uiActionsService.attachAction('ADD_CANVAS_ELEMENT_TRIGGER', ADD_IMAGE_EMBEDDABLE_ACTION_ID);
}
};

View file

@ -8,7 +8,6 @@
import { i18n } from '@kbn/i18n';
import type { Trigger } from '@kbn/ui-actions-plugin/public';
import type { ImageEmbeddable } from '../image_embeddable';
export const IMAGE_CLICK_TRIGGER = 'IMAGE_CLICK_TRIGGER';
@ -21,7 +20,3 @@ export const imageClickTrigger: Trigger = {
defaultMessage: 'Clicking the image will trigger the action',
}),
};
export interface ImageClickContext {
embeddable: ImageEmbeddable;
}

View file

@ -14,7 +14,7 @@ import { FilesContext } from '@kbn/shared-ux-file-context';
import { createMockFilesClient } from '@kbn/shared-ux-file-mocks';
import { ImageViewerContext } from '../image_viewer';
import { ImageEditorFlyout, ImageEditorFlyoutProps } from './image_editor_flyout';
import { imageEmbeddableFileKind } from '../imports';
import { imageEmbeddableFileKind } from '../../imports';
const validateUrl = jest.fn(() => ({ isValid: true }));
@ -35,12 +35,7 @@ const ImageEditor = (props: Partial<ImageEditorFlyoutProps>) => {
validateUrl,
}}
>
<ImageEditorFlyout
validateUrl={validateUrl}
onCancel={() => {}}
onSave={() => {}}
{...props}
/>
<ImageEditorFlyout onCancel={() => {}} onSave={() => {}} {...props} />
</ImageViewerContext.Provider>
</FilesContext>
</I18nProvider>

View file

@ -35,11 +35,11 @@ import { FilePicker } from '@kbn/shared-ux-file-picker';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { FileImageMetadata, imageEmbeddableFileKind } from '../imports';
import { ImageConfig } from '../types';
import { FileImageMetadata, imageEmbeddableFileKind } from '../../imports';
import { ImageConfig } from '../../types';
import { ImageViewer } from '../image_viewer/image_viewer'; // use eager version to avoid flickering
import { ValidateUrlFn } from '../utils/validate_url';
import { validateImageConfig, DraftImageConfig } from '../utils/validate_image_config';
import { validateImageConfig, DraftImageConfig } from '../../utils/validate_image_config';
import { useImageViewerContext } from '../image_viewer/image_viewer_context';
/**
* Shared sizing css for image, upload placeholder, empty and not found state
@ -56,13 +56,14 @@ export interface ImageEditorFlyoutProps {
onCancel: () => void;
onSave: (imageConfig: ImageConfig) => void;
initialImageConfig?: ImageConfig;
validateUrl: ValidateUrlFn;
user?: AuthenticatedUser;
}
export function ImageEditorFlyout(props: ImageEditorFlyoutProps) {
const isEditing = !!props.initialImageConfig;
const { euiTheme } = useEuiTheme();
const { validateUrl } = useImageViewerContext();
const [fileId, setFileId] = useState<undefined | string>(() =>
props.initialImageConfig?.src?.type === 'file' ? props.initialImageConfig.src.fileId : undefined
);
@ -78,7 +79,7 @@ export function ImageEditorFlyout(props: ImageEditorFlyoutProps) {
props.initialImageConfig?.src?.type === 'url' ? props.initialImageConfig.src.url : ''
);
const [srcUrlError, setSrcUrlError] = useState<string | null>(() => {
if (srcUrl) return props.validateUrl(srcUrl)?.error ?? null;
if (srcUrl) return validateUrl(srcUrl)?.error ?? null;
return null;
});
const [isFilePickerOpen, setIsFilePickerOpen] = useState<boolean>(false);
@ -108,7 +109,7 @@ export function ImageEditorFlyout(props: ImageEditorFlyoutProps) {
};
const isDraftImageConfigValid = validateImageConfig(draftImageConfig, {
validateUrl: props.validateUrl,
validateUrl,
});
const onSave = () => {
@ -224,11 +225,7 @@ export function ImageEditorFlyout(props: ImageEditorFlyoutProps) {
{!isDraftImageConfigValid ? (
<EuiEmptyPrompt
css={css`
max-width: none;
${CONTAINER_SIZING_CSS}
.euiEmptyPrompt__main {
height: 100%;
}
max-inline-size: none !important;
`}
iconType="image"
color="subdued"
@ -289,7 +286,7 @@ export function ImageEditorFlyout(props: ImageEditorFlyoutProps) {
onChange={(e) => {
const url = e.target.value;
const { isValid, error } = props.validateUrl(url);
const { isValid, error } = validateUrl(url);
if (!isValid) {
setSrcUrlError(error!);
} else {

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export * from './configure_image';
export { openImageEditor } from './open_image_editor';

View file

@ -0,0 +1,88 @@
/*
* 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 { tracksOverlays, CanAddNewPanel } from '@kbn/presentation-containers';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { FilesContext } from '@kbn/shared-ux-file-context';
import { ImageConfig } from '../../image_embeddable/types';
import { FileImageMetadata, imageEmbeddableFileKind } from '../../imports';
import { coreServices, filesService, securityService } from '../../services/kibana_services';
import { createValidateUrl } from '../../utils/validate_url';
import { ImageViewerContext } from '../image_viewer/image_viewer_context';
export const openImageEditor = async ({
parentApi,
initialImageConfig,
}: {
parentApi: CanAddNewPanel;
initialImageConfig?: ImageConfig;
}): Promise<ImageConfig> => {
const { ImageEditorFlyout } = await import('./image_editor_flyout');
const { overlays, theme, i18n, http } = coreServices;
const user = securityService ? await securityService.authc.getCurrentUser() : undefined;
const filesClient = filesService.filesClientFactory.asUnscoped<FileImageMetadata>();
/**
* If available, the parent API will keep track of which flyout is open and close it
* if the app changes, disable certain actions when the flyout is open, etc.
*/
const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined;
return new Promise((resolve, reject) => {
const onSave = (imageConfig: ImageConfig) => {
resolve(imageConfig);
flyoutSession.close();
overlayTracker?.clearOverlays();
};
const onCancel = () => {
reject();
flyoutSession.close();
overlayTracker?.clearOverlays();
};
const flyoutSession = overlays.openFlyout(
toMountPoint(
<FilesContext client={filesClient}>
<ImageViewerContext.Provider
value={{
getImageDownloadHref: (fileId: string) => {
return filesClient.getDownloadHref({
id: fileId,
fileKind: imageEmbeddableFileKind.id,
});
},
validateUrl: createValidateUrl(http.externalUrl),
}}
>
<ImageEditorFlyout
user={user}
onCancel={onCancel}
onSave={onSave}
initialImageConfig={initialImageConfig}
/>
</ImageViewerContext.Provider>
</FilesContext>,
{ theme, i18n }
),
{
onClose: () => {
onCancel();
},
ownFocus: true,
'data-test-subj': 'createImageEmbeddableFlyout',
}
);
overlayTracker?.openOverlay(flyoutSession);
});
};

View file

@ -0,0 +1,88 @@
/*
* 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, { useEffect, useState } from 'react';
import { PublishingSubject, useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { imageClickTrigger } from '../actions';
import { ImageEmbeddableApi } from '../image_embeddable/types';
import { FileImageMetadata, FilesClient, imageEmbeddableFileKind } from '../imports';
import { coreServices, screenshotModeService, uiActionsService } from '../services/kibana_services';
import { ImageConfig } from '../types';
import { createValidateUrl } from '../utils/validate_url';
import { ImageViewer } from './image_viewer';
import { ImageViewerContext } from './image_viewer/image_viewer_context';
import './image_embeddable.scss';
interface ImageEmbeddableProps {
api: ImageEmbeddableApi & {
setDataLoading: (loading: boolean | undefined) => void;
imageConfig$: PublishingSubject<ImageConfig>;
};
filesClient: FilesClient<FileImageMetadata>;
}
export const ImageEmbeddable = ({ api, filesClient }: ImageEmbeddableProps) => {
const [imageConfig, dynamicActionsState] = useBatchedPublishingSubjects(
api.imageConfig$,
api.dynamicActionsState$
);
const [hasTriggerActions, setHasTriggerActions] = useState(false);
useEffect(() => {
/**
* set the loading to `true` any time the image changes; the ImageViewer component
* is responsible for setting loading to `false` again once the image loads
*/
api.setDataLoading(true);
}, [api, imageConfig]);
useEffect(() => {
// set `hasTriggerActions` depending on whether or not the image has at least one drilldown
setHasTriggerActions((dynamicActionsState?.dynamicActions.events ?? []).length > 0);
}, [dynamicActionsState]);
return (
<ImageViewerContext.Provider
value={{
getImageDownloadHref: (fileId: string) => {
return filesClient.getDownloadHref({
id: fileId,
fileKind: imageEmbeddableFileKind.id,
});
},
validateUrl: createValidateUrl(coreServices.http.externalUrl),
}}
>
<ImageViewer
data-rendering-count={1} // TODO: Remove this as part of https://github.com/elastic/kibana/issues/179376
className="imageEmbeddableImage"
imageConfig={imageConfig}
isScreenshotMode={screenshotModeService?.isScreenshotMode()}
onLoad={() => {
api.setDataLoading(false);
}}
onError={() => {
api.setDataLoading(false);
}}
onClick={
// note: passing onClick enables the cursor pointer style, so we only pass it if there are compatible actions
hasTriggerActions
? () => {
uiActionsService.executeTriggerActions(imageClickTrigger.id, {
embeddable: api,
});
}
: undefined
}
/>
</ImageViewerContext.Provider>
);
};

View file

@ -10,7 +10,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { ImageViewer } from './image_viewer';
import { ImageViewerContext } from './image_viewer_context';
import { ImageConfig } from '../types';
import { ImageConfig } from '../../types';
const validateUrl = jest.fn(() => ({ isValid: true }));

View file

@ -6,25 +6,26 @@
* Side Public License, v 1.
*/
import React, { useEffect, useState } from 'react';
import { css, SerializedStyles } from '@emotion/react';
import { FileImage } from '@kbn/shared-ux-file-image';
import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import {
EuiButtonIcon,
EuiEmptyPrompt,
EuiImage,
useEuiTheme,
useResizeObserver,
useIsWithinBreakpoints,
EuiImageProps,
useIsWithinBreakpoints,
useResizeObserver,
} from '@elastic/eui';
import { css, SerializedStyles } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { ImageConfig } from '../types';
import notFound from './not_found/not_found_light.png';
import notFound2x from './not_found/not_found_light@2x.png';
import { validateImageConfig } from '../utils/validate_image_config';
import { FileImage } from '@kbn/shared-ux-file-image';
import { ImageConfig } from '../../types';
import { validateImageConfig } from '../../utils/validate_image_config';
import notFound from './assets/not_found_light.png';
import notFound2x from './assets/not_found_light@2x.png';
import { useImageViewerContext } from './image_viewer_context';
export interface ImageViewerProps {
@ -50,7 +51,6 @@ export function ImageViewer({
containerCSS,
isScreenshotMode,
}: ImageViewerProps) {
const { euiTheme } = useEuiTheme();
const { getImageDownloadHref, validateUrl } = useImageViewerContext();
const isImageConfigValid = validateImageConfig(imageConfig, { validateUrl });
@ -73,8 +73,6 @@ export function ImageViewer({
position: relative;
width: 100%;
height: 100%;
border-radius: ${euiTheme.border.radius.medium};
.visually-hidden {
visibility: hidden;
}

View file

@ -7,7 +7,7 @@
*/
import { createContext, useContext } from 'react';
import type { createValidateUrl } from '../utils/validate_url';
import type { createValidateUrl } from '../../utils/validate_url';
export interface ImageViewerContextValue {
getImageDownloadHref: (fileId: string) => string;

View file

@ -1,90 +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 { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { FilesContext } from '@kbn/shared-ux-file-context';
import { skip, take, takeUntil } from 'rxjs';
import { Subject } from 'rxjs';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import { ImageConfig } from '../types';
import { ImageEditorFlyout } from './image_editor_flyout';
import { ImageViewerContext } from '../image_viewer';
import {
OverlayStart,
ApplicationStart,
FilesClient,
FileImageMetadata,
ThemeServiceStart,
} from '../imports';
import { ValidateUrlFn } from '../utils/validate_url';
/**
* @throws in case user cancels
*/
export async function configureImage(
deps: {
files: FilesClient<FileImageMetadata>;
overlays: OverlayStart;
theme: ThemeServiceStart;
currentAppId$: ApplicationStart['currentAppId$'];
validateUrl: ValidateUrlFn;
getImageDownloadHref: (fileId: string) => string;
user?: AuthenticatedUser;
},
initialImageConfig?: ImageConfig
): Promise<ImageConfig> {
return new Promise((resolve, reject) => {
const closed$ = new Subject<true>();
const onSave = (imageConfig: ImageConfig) => {
resolve(imageConfig);
handle.close();
};
const onCancel = () => {
reject();
handle.close();
};
// Close the flyout on application change.
deps.currentAppId$.pipe(takeUntil(closed$), skip(1), take(1)).subscribe(() => {
handle.close();
});
const handle = deps.overlays.openFlyout(
toMountPoint(
<FilesContext client={deps.files}>
<ImageViewerContext.Provider
value={{
getImageDownloadHref: deps.getImageDownloadHref,
validateUrl: deps.validateUrl,
}}
>
<ImageEditorFlyout
onCancel={onCancel}
onSave={onSave}
initialImageConfig={initialImageConfig}
validateUrl={deps.validateUrl}
user={deps.user}
/>
</ImageViewerContext.Provider>
</FilesContext>,
{ theme$: deps.theme.theme$ }
),
{
ownFocus: true,
'data-test-subj': 'createImageEmbeddableFlyout',
}
);
handle.onClose.then(() => {
closed$.next(true);
});
});
}

View file

@ -6,4 +6,5 @@
* Side Public License, v 1.
*/
import './image_embeddable.scss';
export const IMAGE_EMBEDDABLE_TYPE = 'image';
export const ADD_IMAGE_EMBEDDABLE_ACTION_ID = 'create_image_embeddable';

View file

@ -0,0 +1,125 @@
/*
* 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, { useEffect, useMemo } from 'react';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject } from 'rxjs';
import { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { PresentationContainer } from '@kbn/presentation-containers';
import { initializeTitles } from '@kbn/presentation-publishing';
import { IMAGE_CLICK_TRIGGER } from '../actions';
import { openImageEditor } from '../components/image_editor/open_image_editor';
import { ImageEmbeddable as ImageEmbeddableComponent } from '../components/image_embeddable';
import { FileImageMetadata } from '../imports';
import { filesService } from '../services/kibana_services';
import { IMAGE_EMBEDDABLE_TYPE } from './constants';
import { ImageConfig, ImageEmbeddableApi, ImageEmbeddableSerializedState } from './types';
export const getImageEmbeddableFactory = ({
embeddableEnhanced,
}: {
embeddableEnhanced?: EmbeddableEnhancedPluginStart;
}) => {
const imageEmbeddableFactory: ReactEmbeddableFactory<
ImageEmbeddableSerializedState,
ImageEmbeddableApi
> = {
type: IMAGE_EMBEDDABLE_TYPE,
deserializeState: (state) => {
return state.rawState as ImageEmbeddableSerializedState;
},
buildEmbeddable: async (initialState, buildApi, uuid) => {
const { titlesApi, titleComparators, serializeTitles } = initializeTitles(initialState);
const dynamicActionsApi = embeddableEnhanced?.initializeReactEmbeddableDynamicActions(
uuid,
() => titlesApi.panelTitle.getValue(),
initialState
);
// if it is provided, start the dynamic actions manager
const maybeStopDynamicActions = dynamicActionsApi?.startDynamicActions();
const filesClient = filesService.filesClientFactory.asUnscoped<FileImageMetadata>();
const imageConfig$ = new BehaviorSubject<ImageConfig>(initialState.imageConfig);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
const embeddable = buildApi(
{
...titlesApi,
...(dynamicActionsApi?.dynamicActionsApi ?? {}),
dataLoading: dataLoading$,
supportedTriggers: () => [IMAGE_CLICK_TRIGGER],
onEdit: async () => {
try {
const newImageConfig = await openImageEditor({
parentApi: embeddable.parentApi as PresentationContainer,
initialImageConfig: imageConfig$.getValue(),
});
imageConfig$.next(newImageConfig);
} catch {
// swallow the rejection, since this just means the user closed without saving
}
},
isEditingEnabled: () => true,
getTypeDisplayName: () =>
i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName.edit', {
defaultMessage: 'image',
}),
serializeState: () => {
return {
rawState: {
...serializeTitles(),
...(dynamicActionsApi?.serializeDynamicActions() ?? {}),
imageConfig: imageConfig$.getValue(),
},
};
},
},
{
...titleComparators,
...(dynamicActionsApi?.dynamicActionsComparator ?? {}),
imageConfig: [
imageConfig$,
(value) => imageConfig$.next(value),
(a, b) => deepEqual(a, b),
],
}
);
return {
api: embeddable,
Component: () => {
const privateImageEmbeddableApi = useMemo(() => {
/** Memoize the API so that the reference stays consistent and it can be used as a dependency */
return {
...embeddable,
imageConfig$,
setDataLoading: (loading: boolean | undefined) => dataLoading$.next(loading),
};
}, []);
useEffect(() => {
return () => {
// if it was started, stop the dynamic actions manager on unmount
maybeStopDynamicActions?.stopDynamicActions();
};
}, []);
return (
<ImageEmbeddableComponent api={privateImageEmbeddableApi} filesClient={filesClient} />
);
},
};
},
};
return imageEmbeddableFactory;
};

View file

@ -1,113 +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 useObservable from 'react-use/lib/useObservable';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import { ImageEmbeddableInput } from './image_embeddable_factory';
import { ImageViewer, ImageViewerContext } from '../image_viewer';
import { createValidateUrl } from '../utils/validate_url';
import { imageClickTrigger, ImageClickContext } from '../actions';
export const IMAGE_EMBEDDABLE_TYPE = 'image';
export class ImageEmbeddable extends Embeddable<ImageEmbeddableInput> {
public readonly type = IMAGE_EMBEDDABLE_TYPE;
supportedTriggers(): string[] {
return [imageClickTrigger.id];
}
constructor(
private deps: {
getImageDownloadHref: (fileId: string) => string;
validateUrl: ReturnType<typeof createValidateUrl>;
actions: {
executeTriggerActions: (triggerId: string, context: ImageClickContext) => void;
hasTriggerActions: (triggerId: string, context: ImageClickContext) => Promise<boolean>;
};
isScreenshotMode: () => boolean;
},
initialInput: ImageEmbeddableInput,
parent?: IContainer
) {
super(
initialInput,
{
editable: true,
editableWithExplicitInput: true,
},
parent
);
}
public render(el: HTMLElement) {
super.render(el); // calling super.render initializes renderComplete and setTitle
el.setAttribute('data-shared-item', '');
const ImageEmbeddableViewer = this.ImageEmbeddableViewer;
return <ImageEmbeddableViewer embeddable={this} />;
}
public reload() {}
private ImageEmbeddableViewer = (props: { embeddable: ImageEmbeddable }) => {
const input = useObservable(props.embeddable.getInput$(), props.embeddable.getInput());
React.useLayoutEffect(() => {
import('./image_embeddable_lazy');
}, []);
const [hasTriggerActions, setHasTriggerActions] = React.useState(false);
React.useEffect(() => {
let cancel = false;
// hack: timeout to give a chance for a drilldown action to be registered just after it is created by user
setTimeout(() => {
if (cancel) return;
this.deps.actions
.hasTriggerActions(imageClickTrigger.id, { embeddable: this })
.catch(() => false)
.then((hasActions) => !cancel && setHasTriggerActions(hasActions));
}, 0);
return () => {
cancel = true;
};
});
return (
<ImageViewerContext.Provider
value={{
getImageDownloadHref: this.deps.getImageDownloadHref,
validateUrl: this.deps.validateUrl,
}}
>
<ImageViewer
className="imageEmbeddableImage"
imageConfig={input.imageConfig}
isScreenshotMode={this.deps.isScreenshotMode()}
onLoad={() => {
this.renderComplete.dispatchComplete();
}}
onError={() => {
this.renderComplete.dispatchError();
}}
onClick={
// note: passing onClick enables the cursor pointer style, so we only pass it if there are compatible actions
hasTriggerActions
? () => {
this.deps.actions.executeTriggerActions(imageClickTrigger.id, {
embeddable: this,
});
}
: undefined
}
/>
</ImageViewerContext.Provider>
);
};
}

View file

@ -1,113 +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 { i18n } from '@kbn/i18n';
import { IExternalUrl } from '@kbn/core-http-browser';
import type { AuthenticatedUser } from '@kbn/security-plugin/common';
import {
IContainer,
EmbeddableInput,
EmbeddableFactoryDefinition,
ApplicationStart,
OverlayStart,
FilesClient,
FileImageMetadata,
imageEmbeddableFileKind,
ThemeServiceStart,
UiActionsStart,
} from '../imports';
import { ImageEmbeddable, IMAGE_EMBEDDABLE_TYPE } from './image_embeddable';
import { ImageConfig } from '../types';
import { createValidateUrl } from '../utils/validate_url';
import { ImageClickContext } from '../actions';
export interface ImageEmbeddableFactoryDeps {
start: () => {
application: ApplicationStart;
overlays: OverlayStart;
files: FilesClient<FileImageMetadata>;
externalUrl: IExternalUrl;
theme: ThemeServiceStart;
getUser: () => Promise<AuthenticatedUser | undefined>;
uiActions: UiActionsStart;
isScreenshotMode: () => boolean;
};
}
export interface ImageEmbeddableInput extends EmbeddableInput {
imageConfig: ImageConfig;
}
export class ImageEmbeddableFactoryDefinition
implements EmbeddableFactoryDefinition<ImageEmbeddableInput>
{
public readonly type = IMAGE_EMBEDDABLE_TYPE;
constructor(private deps: ImageEmbeddableFactoryDeps) {}
public async isEditable() {
return Boolean(this.deps.start().application.capabilities.dashboard?.showWriteControls);
}
public async create(initialInput: ImageEmbeddableInput, parent?: IContainer) {
return new ImageEmbeddable(
{
getImageDownloadHref: this.getImageDownloadHref,
validateUrl: createValidateUrl(this.deps.start().externalUrl),
actions: {
executeTriggerActions: (triggerId: string, context: ImageClickContext) =>
this.deps.start().uiActions.executeTriggerActions(triggerId, context),
hasTriggerActions: (triggerId: string, context: ImageClickContext) =>
this.deps
.start()
.uiActions.getTriggerCompatibleActions(triggerId, context)
.catch(() => [])
.then((actions) => actions.length > 0),
},
isScreenshotMode: () => this.deps.start().isScreenshotMode(),
},
initialInput,
parent
);
}
public getDisplayName() {
return i18n.translate('imageEmbeddable.imageEmbeddableFactory.displayName', {
defaultMessage: 'Image',
});
}
public getIconType() {
return `image`;
}
public async getExplicitInput(initialInput: ImageEmbeddableInput) {
const { configureImage } = await import('../image_editor');
const start = this.deps.start();
const { files, overlays, theme, application, externalUrl, getUser } = start;
const user = await getUser();
const imageConfig = await configureImage(
{
files,
overlays,
theme,
user,
currentAppId$: application.currentAppId$,
validateUrl: createValidateUrl(externalUrl),
getImageDownloadHref: this.getImageDownloadHref,
},
initialInput ? initialInput.imageConfig : undefined
);
return { imageConfig };
}
private getImageDownloadHref = (fileId: string) =>
this.deps.start().files.getDownloadHref({ id: fileId, fileKind: imageEmbeddableFileKind.id });
}

View file

@ -1,10 +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 * from './image_embeddable';
export * from './image_embeddable_factory';

View file

@ -0,0 +1,52 @@
/*
* 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 { HasDynamicActions } from '@kbn/embeddable-enhanced-plugin/public';
import { DynamicActionsSerializedState } from '@kbn/embeddable-enhanced-plugin/public/plugin';
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import {
HasEditCapabilities,
HasSupportedTriggers,
SerializedTitles,
} from '@kbn/presentation-publishing';
export type ImageEmbeddableSerializedState = SerializedTitles &
Partial<DynamicActionsSerializedState> & {
imageConfig: ImageConfig;
};
export type ImageEmbeddableApi = DefaultEmbeddableApi<ImageEmbeddableSerializedState> &
HasEditCapabilities &
HasSupportedTriggers &
HasDynamicActions;
export type ImageSizing = 'fill' | 'contain' | 'cover' | 'none';
export interface ImageConfig {
src: ImageFileSrc | ImageUrlSrc;
altText?: string;
sizing: {
objectFit: ImageSizing;
};
backgroundColor?: string;
}
export interface ImageFileSrc {
type: 'file';
fileId: string;
fileImageMeta: {
blurHash?: string;
width: number;
height: number;
};
}
export interface ImageUrlSrc {
type: 'url';
url: string;
}

View file

@ -6,11 +6,10 @@
* Side Public License, v 1.
*/
import { PluginInitializerContext } from '@kbn/core/public';
import { ImageEmbeddablePlugin } from './plugin';
export { type ImageClickContext, IMAGE_CLICK_TRIGGER } from './actions';
export { IMAGE_CLICK_TRIGGER } from './actions';
export function plugin(context: PluginInitializerContext) {
return new ImageEmbeddablePlugin(context);
export function plugin() {
return new ImageEmbeddablePlugin();
}

View file

@ -6,20 +6,22 @@
* Side Public License, v 1.
*/
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public';
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { EmbeddableSetup, registerReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { FilesSetup, FilesStart } from '@kbn/files-plugin/public';
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import {
ScreenshotModePluginSetup,
ScreenshotModePluginStart,
} from '@kbn/screenshot-mode-plugin/public';
import { IMAGE_EMBEDDABLE_TYPE, ImageEmbeddableFactoryDefinition } from './image_embeddable';
import { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public';
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { imageClickTrigger } from './actions';
import { setKibanaServices, untilPluginStartServicesReady } from './services/kibana_services';
import { IMAGE_EMBEDDABLE_TYPE } from './image_embeddable/constants';
import { registerCreateImageAction } from './actions/create_image_action';
export interface SetupDependencies {
export interface ImageEmbeddableSetupDependencies {
embeddable: EmbeddableSetup;
files: FilesSetup;
security?: SecurityPluginSetup;
@ -27,12 +29,12 @@ export interface SetupDependencies {
screenshotMode?: ScreenshotModePluginSetup;
}
export interface StartDependencies {
embeddable: EmbeddableStart;
export interface ImageEmbeddableStartDependencies {
files: FilesStart;
security?: SecurityPluginStart;
uiActions: UiActionsStart;
screenshotMode?: ScreenshotModePluginStart;
embeddableEnhanced?: EmbeddableEnhancedPluginStart;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -42,36 +44,38 @@ export interface SetupContract {}
export interface StartContract {}
export class ImageEmbeddablePlugin
implements Plugin<SetupContract, StartContract, SetupDependencies, StartDependencies>
implements
Plugin<
SetupContract,
StartContract,
ImageEmbeddableSetupDependencies,
ImageEmbeddableStartDependencies
>
{
constructor(protected readonly context: PluginInitializerContext) {}
public setup(core: CoreSetup<StartDependencies>, plugins: SetupDependencies): SetupContract {
const start = createStartServicesGetter(core.getStartServices);
plugins.embeddable.registerEmbeddableFactory(
IMAGE_EMBEDDABLE_TYPE,
new ImageEmbeddableFactoryDefinition({
start: () => ({
application: start().core.application,
overlays: start().core.overlays,
files: start().plugins.files.filesClientFactory.asUnscoped(),
externalUrl: start().core.http.externalUrl,
theme: start().core.theme,
getUser: async () => {
const security = start().plugins.security;
return security ? await security.authc.getCurrentUser() : undefined;
},
uiActions: start().plugins.uiActions,
isScreenshotMode: () => plugins.screenshotMode?.isScreenshotMode() ?? false,
}),
})
);
constructor() {}
public setup(
core: CoreSetup<ImageEmbeddableStartDependencies>,
plugins: ImageEmbeddableSetupDependencies
): SetupContract {
plugins.uiActions.registerTrigger(imageClickTrigger);
return {};
}
public start(core: CoreStart, plugins: StartDependencies): StartContract {
public start(core: CoreStart, plugins: ImageEmbeddableStartDependencies): StartContract {
setKibanaServices(core, plugins);
untilPluginStartServicesReady().then(() => {
registerCreateImageAction();
});
registerReactEmbeddableFactory(IMAGE_EMBEDDABLE_TYPE, async () => {
const [_, { getImageEmbeddableFactory }] = await Promise.all([
untilPluginStartServicesReady(),
import('./image_embeddable/get_image_embeddable_factory'),
]);
return getImageEmbeddableFactory({ embeddableEnhanced: plugins.embeddableEnhanced });
});
return {};
}

View file

@ -0,0 +1,56 @@
/*
* 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';
import { CoreStart } from '@kbn/core/public';
import { FilesStart } from '@kbn/files-plugin/public';
import { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public';
import { SecurityPluginStart } from '@kbn/security-plugin-types-public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { ImageEmbeddableStartDependencies } from '../plugin';
export let coreServices: CoreStart;
export let filesService: FilesStart;
export let uiActionsService: UiActionsStart;
export let screenshotModeService: ScreenshotModePluginStart | undefined;
export let securityService: SecurityPluginStart | undefined;
export let trackUiMetric: (
type: string,
eventNames: string | string[],
count?: number
) => void | undefined;
const servicesReady$ = new BehaviorSubject(false);
export const untilPluginStartServicesReady = () => {
if (servicesReady$.value) return Promise.resolve();
return new Promise<void>((resolve) => {
const subscription = servicesReady$.subscribe((isInitialized) => {
if (isInitialized) {
subscription.unsubscribe();
resolve();
}
});
});
};
export const setKibanaServices = (
kibanaCore: CoreStart,
deps: ImageEmbeddableStartDependencies
) => {
coreServices = kibanaCore;
filesService = deps.files;
securityService = deps.security;
uiActionsService = deps.uiActions;
screenshotModeService = deps.screenshotMode;
servicesReady$.next(true);
};

View file

@ -1,29 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": ["public/**/*", "common/**/*", "server/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/embeddable-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/kibana-react-plugin",
"@kbn/files-plugin",
"@kbn/security-plugin",
"@kbn/shared-ux-file-context",
"@kbn/shared-ux-file-upload",
"@kbn/shared-ux-file-picker",
"@kbn/shared-ux-file-types",
"@kbn/i18n-react",
"@kbn/shared-ux-file-mocks",
"@kbn/i18n",
"@kbn/core-http-browser",
"@kbn/shared-ux-file-image",
"@kbn/i18n",
"@kbn/i18n-react",
"@kbn/core-http-browser",
"@kbn/ui-actions-plugin",
"@kbn/screenshot-mode-plugin"
"@kbn/screenshot-mode-plugin",
"@kbn/presentation-containers",
"@kbn/presentation-publishing",
"@kbn/react-kibana-mount",
"@kbn/security-plugin-types-public",
"@kbn/embeddable-enhanced-plugin"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -19,8 +19,7 @@
flex: 1 1 100%;
z-index: 1;
min-height: 0; // Absolute must for Firefox to scroll contents
border-bottom-left-radius: $euiBorderRadius;
border-bottom-right-radius: $euiBorderRadius;
border-radius: $euiBorderRadius;
overflow: hidden;
&[data-error] {
@ -106,12 +105,12 @@
*/
.embPanel__optionsMenuButton {
background-color: transparentize($euiColorDarkestShade, .9);
background-color: transparentize($euiColorLightShade, .5);
border-bottom-right-radius: 0;
border-top-left-radius: 0;
&:focus {
background-color: $euiFocusBackgroundColor;
background-color: transparentize($euiColorLightestShade, .5);
}
}
@ -158,6 +157,12 @@
}
}
.embPanel__content {
border-radius: 0;
border-bottom-left-radius: $euiBorderRadius;
border-bottom-right-radius: $euiBorderRadius;
}
.embPanel__optionsMenuButton {
opacity: 1; /* 3 */
}

View file

@ -91,7 +91,11 @@ export const PresentationPanelInternal = <
const contentAttrs = useMemo(() => {
const attrs: { [key: string]: boolean } = {};
if (dataLoading) attrs['data-loading'] = true;
if (dataLoading) {
attrs['data-loading'] = true;
} else {
attrs['data-render-complete'] = true;
}
if (blockingError) attrs['data-error'] = true;
return attrs;
}, [dataLoading, blockingError]);
@ -108,6 +112,7 @@ export const PresentationPanelInternal = <
aria-labelledby={headerId}
data-test-embeddable-id={api?.uuid}
data-test-subj="embeddablePanel"
{...contentAttrs}
>
{!hideHeader && api && (
<PresentationPanelHeader
@ -139,7 +144,6 @@ export const PresentationPanelInternal = <
<EuiErrorBoundary>
<Component
{...(componentProps as React.ComponentProps<typeof Component>)}
{...contentAttrs}
ref={(newApi) => {
if (newApi && !api) setApi(newApi);
}}

View file

@ -6,15 +6,16 @@
* Side Public License, v 1.
*/
import { v4 as uuidv4 } from 'uuid';
import { Subscription } from 'rxjs';
import { createStateContainer, StateContainer } from '@kbn/kibana-utils-plugin/common';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { UiActionsActionDefinition as ActionDefinition } from '@kbn/ui-actions-plugin/public';
import { StateContainer, createStateContainer } from '@kbn/kibana-utils-plugin/common';
import { ActionStorage } from './dynamic_action_storage';
import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state';
import { Subscription } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { StartContract } from '../plugin';
import { SerializedAction, SerializedEvent } from './types';
import { dynamicActionGrouping } from './dynamic_action_grouping';
import { defaultState, selectors, State, transitions } from './dynamic_action_manager_state';
import { ActionStorage } from './dynamic_action_storage';
import { SerializedAction, SerializedEvent } from './types';
const compareEvents = (
a: ReadonlyArray<{ eventId: string }>,
@ -39,7 +40,7 @@ export interface DynamicActionManagerParams {
| 'getActionFactory'
| 'hasActionFactory'
>;
isCompatible: <C = unknown>(context: C) => Promise<boolean>;
isCompatible: (context: EmbeddableApiContext) => Promise<boolean>;
}
export class DynamicActionManager {
@ -91,7 +92,7 @@ export class DynamicActionManager {
...actionDefinition,
id: actionId,
grouping: dynamicActionGrouping,
isCompatible: async (context) => {
isCompatible: async (context: EmbeddableApiContext) => {
if (!(await isCompatible(context))) return false;
if (!actionDefinition.isCompatible) return true;
return actionDefinition.isCompatible(context);

View file

@ -24,6 +24,7 @@
"@kbn/rison",
"@kbn/datemath",
"@kbn/monaco",
"@kbn/presentation-publishing",
],
"exclude": [
"target/**/*",

View file

@ -38,7 +38,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should create an image embeddable', async () => {
// create an image embeddable
await dashboardAddPanel.clickEditorMenuButton();
await dashboardAddPanel.clickAddNewEmbeddableLink('image');
await dashboardAddPanel.clickAddNewPanelFromUIActionLink('Image');
await testSubjects.exists(`createImageEmbeddableFlyout`);
await PageObjects.common.setFileInputPath(require.resolve('./elastic_logo.png'));
await testSubjects.clickWhenNotDisabled(`imageEmbeddableEditorSave`);

View file

@ -43,7 +43,7 @@ export interface OpenFlyoutAddDrilldownParams {
}
export type FlyoutCreateDrilldownActionApi = CanAccessViewMode &
HasDynamicActions &
Required<HasDynamicActions> &
HasParentApi<HasType & Partial<PresentationContainer & TracksOverlays>> &
HasSupportedTriggers &
Partial<HasUniqueId>;

View file

@ -41,7 +41,7 @@ export interface FlyoutEditDrilldownParams {
}
export type FlyoutEditDrilldownActionApi = CanAccessViewMode &
HasDynamicActions &
Required<HasDynamicActions> &
HasParentApi<Partial<PresentationContainer & TracksOverlays>> &
HasSupportedTriggers &
Partial<HasUniqueId>;

View file

@ -12,7 +12,6 @@ import {
SerializedEvent,
} from '@kbn/ui-actions-enhanced-plugin/public/dynamic_actions';
import { BehaviorSubject } from 'rxjs';
import { HasDynamicActions } from '../embeddables/interfaces/has_dynamic_actions';
import { DynamicActionsSerializedState } from '../plugin';
import {
PanelNotificationsAction,
@ -62,7 +61,7 @@ describe('PanelNotificationsAction', () => {
const context = createContext([{}, {}] as SerializedEvent[]);
const action = new PanelNotificationsAction();
(context.embeddable as HasDynamicActions).setDynamicActions({
(context.embeddable as PanelNotificationsActionApi).setDynamicActions({
dynamicActions: { events: [{}, {}, {}] as SerializedEvent[] },
});
@ -108,7 +107,7 @@ describe('PanelNotificationsAction', () => {
const context = createContext([{}, {}, {}] as SerializedEvent[]);
const action = new PanelNotificationsAction();
(context.embeddable as HasDynamicActions).setDynamicActions({
(context.embeddable as PanelNotificationsActionApi).setDynamicActions({
dynamicActions: { events: [{}, {}] as SerializedEvent[] },
});

View file

@ -39,7 +39,7 @@ export const txtManyDrilldowns = (count: number) =>
export const ACTION_PANEL_NOTIFICATIONS = 'ACTION_PANEL_NOTIFICATIONS';
export type PanelNotificationsActionApi = CanAccessViewMode & HasDynamicActions;
export type PanelNotificationsActionApi = CanAccessViewMode & Required<HasDynamicActions>;
const isApiCompatible = (api: unknown | null): api is PanelNotificationsActionApi =>
apiHasDynamicActions(api) && apiCanAccessViewMode(api);
@ -73,11 +73,8 @@ export class PanelNotificationsAction implements ActionDefinition<EmbeddableApiC
onChange: (isCompatible: boolean, action: PanelNotificationsAction) => void
) => {
if (!isApiCompatible(embeddable)) return;
return merge(
getViewModeSubject(embeddable) ?? new BehaviorSubject(ViewMode.VIEW),
embeddable.dynamicActionsState$
).subscribe(() => {
const viewModeSubject = getViewModeSubject(embeddable) ?? new BehaviorSubject(ViewMode.VIEW);
return merge(embeddable.dynamicActionsState$, viewModeSubject).subscribe(() => {
onChange(
getInheritedViewMode(embeddable) === ViewMode.EDIT &&
this.getEventCount({ embeddable }) > 0,

View file

@ -14,7 +14,7 @@ import {
import { HasDynamicActions } from './interfaces/has_dynamic_actions';
export type DynamicActionStorageApi = Pick<
HasDynamicActions,
Required<HasDynamicActions>,
'setDynamicActions' | 'dynamicActionsState$'
>;
export class DynamicActionStorage extends AbstractActionStorage {

View file

@ -9,14 +9,14 @@ import { PublishingSubject } from '@kbn/presentation-publishing';
import { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '@kbn/ui-actions-enhanced-plugin/public';
import { DynamicActionsSerializedState } from '../../plugin';
export interface HasDynamicActions {
export type HasDynamicActions = Partial<{
enhancements: { dynamicActions: DynamicActionManager };
setDynamicActions: (newState: DynamicActionsSerializedState['enhancements']) => void;
dynamicActionsState$: PublishingSubject<DynamicActionsSerializedState['enhancements']>;
}
}>;
export const apiHasDynamicActions = (api: unknown): api is HasDynamicActions => {
const apiMaybeHasDynamicActions = api as HasDynamicActions;
export const apiHasDynamicActions = (api: unknown): api is Required<HasDynamicActions> => {
const apiMaybeHasDynamicActions = api as Required<HasDynamicActions>;
return Boolean(
apiMaybeHasDynamicActions &&
apiMaybeHasDynamicActions.enhancements &&

View file

@ -18,7 +18,11 @@ import {
IEmbeddable,
PANEL_NOTIFICATION_TRIGGER,
} from '@kbn/embeddable-plugin/public';
import { apiHasUniqueId, StateComparators } from '@kbn/presentation-publishing';
import {
apiHasUniqueId,
EmbeddableApiContext,
StateComparators,
} from '@kbn/presentation-publishing';
import type { FinderAttributes } from '@kbn/saved-objects-finder-plugin/common';
import {
AdvancedUiActionsSetup,
@ -49,16 +53,19 @@ export interface StartDependencies {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SetupContract {}
export interface ReactEmbeddableDynamicActionsApi {
dynamicActionsApi: HasDynamicActions;
dynamicActionsComparator: StateComparators<DynamicActionsSerializedState>;
serializeDynamicActions: () => DynamicActionsSerializedState;
startDynamicActions: () => { stopDynamicActions: () => void };
}
export interface StartContract {
initializeReactEmbeddableDynamicActions: (
uuid: string,
getTitle: () => string | undefined,
state: DynamicActionsSerializedState
) => {
dynamicActionsApi: HasDynamicActions;
dynamicActionsComparator: StateComparators<DynamicActionsSerializedState>;
serializeDynamicActions: () => DynamicActionsSerializedState;
};
) => ReactEmbeddableDynamicActionsApi;
}
export interface DynamicActionsSerializedState {
@ -138,23 +145,26 @@ export class EmbeddableEnhancedPlugin
dynamicActionsApi: HasDynamicActions;
dynamicActionsComparator: StateComparators<DynamicActionsSerializedState>;
serializeDynamicActions: () => DynamicActionsSerializedState;
startDynamicActions: () => { stopDynamicActions: () => void };
} {
const dynamicActionsState$ = new BehaviorSubject<DynamicActionsSerializedState['enhancements']>(
{ dynamicActions: { events: [] }, ...(state.enhancements ?? {}) }
);
const api: DynamicActionStorageApi = {
dynamicActionsState$,
setDynamicActions: (newState) => dynamicActionsState$.next(newState),
setDynamicActions: (newState) => {
dynamicActionsState$.next(newState);
},
};
const storage = new DynamicActionStorage(uuid, getTitle, api);
const dynamicActions = new DynamicActionManager({
isCompatible: async (context: unknown) => {
return apiHasUniqueId(context) && context.uuid === uuid;
isCompatible: async (context: EmbeddableApiContext) => {
const { embeddable } = context;
return apiHasUniqueId(embeddable) && embeddable.uuid === uuid;
},
storage,
uiActions: this.uiActions!,
});
this.startDynamicActions(dynamicActions);
return {
dynamicActionsApi: { ...api, enhancements: { dynamicActions } },
@ -170,6 +180,10 @@ export class EmbeddableEnhancedPlugin
serializeDynamicActions: () => {
return { enhancements: dynamicActionsState$.getValue() };
},
startDynamicActions: () => {
const stop = this.startDynamicActions(dynamicActions);
return { stopDynamicActions: stop };
},
};
}