mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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** |  |  | | **Edit mode** |  |  | ### 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))  - [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:
parent
d1e792a5a0
commit
826f7cb42b
39 changed files with 614 additions and 471 deletions
|
@ -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) => {
|
||||
|
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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 {
|
|
@ -6,4 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './configure_image';
|
||||
export { openImageEditor } from './open_image_editor';
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
@ -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 }));
|
||||
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
"@kbn/rison",
|
||||
"@kbn/datemath",
|
||||
"@kbn/monaco",
|
||||
"@kbn/presentation-publishing",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -43,7 +43,7 @@ export interface OpenFlyoutAddDrilldownParams {
|
|||
}
|
||||
|
||||
export type FlyoutCreateDrilldownActionApi = CanAccessViewMode &
|
||||
HasDynamicActions &
|
||||
Required<HasDynamicActions> &
|
||||
HasParentApi<HasType & Partial<PresentationContainer & TracksOverlays>> &
|
||||
HasSupportedTriggers &
|
||||
Partial<HasUniqueId>;
|
||||
|
|
|
@ -41,7 +41,7 @@ export interface FlyoutEditDrilldownParams {
|
|||
}
|
||||
|
||||
export type FlyoutEditDrilldownActionApi = CanAccessViewMode &
|
||||
HasDynamicActions &
|
||||
Required<HasDynamicActions> &
|
||||
HasParentApi<Partial<PresentationContainer & TracksOverlays>> &
|
||||
HasSupportedTriggers &
|
||||
Partial<HasUniqueId>;
|
||||
|
|
|
@ -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[] },
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue