Image Embeddable (#146421)

close https://github.com/elastic/kibana/issues/81345

Adds an image embeddable - a new embeddable type that allows to
insert images into dashboard using the new file service
This commit is contained in:
Anton Dosov 2022-12-19 14:50:29 +01:00 committed by GitHub
parent b8c1e34b3b
commit 74ab0759f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1672 additions and 80 deletions

1
.github/CODEOWNERS vendored
View file

@ -73,6 +73,7 @@
/src/plugins/ui_actions/ @elastic/kibana-global-experience
/src/plugins/ui_actions_enhanced/ @elastic/kibana-global-experience
/src/plugins/navigation/ @elastic/kibana-global-experience
/src/plugins/image_embeddable/ @elastic/kibana-global-experience
/x-pack/plugins/notifications/ @elastic/kibana-global-experience
## Examples

View file

@ -53,6 +53,7 @@
"inspectorViews": "src/legacy/core_plugins/inspector_views",
"interactiveSetup": "src/plugins/interactive_setup",
"interpreter": "src/legacy/core_plugins/interpreter",
"imageEmbeddable": "src/plugins/image_embeddable",
"kbn": "src/legacy/core_plugins/kibana",
"kbnConfig": "packages/kbn-config/src",
"kbnDocViews": "src/legacy/core_plugins/kbn_doc_views",

View file

@ -192,6 +192,11 @@ for use in their own application.
|Moves the legacy ui/registry/feature_catalogue module for registering "features" that should be shown in the home page's feature catalogue to a service within a "home" plugin. The feature catalogue refered to here should not be confused with the "feature" plugin for registering features used to derive UI capabilities for feature controls.
|{kib-repo}blob/{branch}/src/plugins/image_embeddable/README.md[imageEmbeddable]
|This plugin contains image embeddable. Image embeddable allows to embed images into the dashboard.
Images can be added either by URL or by uploading the image file via file service.
|{kib-repo}blob/{branch}/src/plugins/input_control_vis/README.md[inputControlVis]
|Contains the input control visualization allowing to place custom filter controls on a dashboard.

View file

@ -66,6 +66,7 @@ pageLoadAssetSize:
grokdebugger: 26779
guidedOnboarding: 42965
home: 30182
imageEmbeddable: 12500
indexLifecycleManagement: 107090
indexManagement: 140608
infra: 184320

View file

@ -49,7 +49,7 @@ export const Image = ({ src, url, alt, onLoad, onError, meta, ...rest }: Props)
return (
<EuiImage
alt=""
alt={alt ?? ''}
loading={'lazy'}
{...rest}
className={classNames(rest.className, { blurhash: currentSrc === blurhashSrc })}

View file

@ -117,13 +117,17 @@ export const dashboardReplacePanelActionStrings = {
i18n.translate('dashboard.panel.removePanel.replacePanel', {
defaultMessage: 'Replace panel',
}),
getSuccessMessage: (savedObjectName: string) =>
i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', {
defaultMessage: '{savedObjectName} was added',
values: {
savedObjectName,
},
}),
getSuccessMessage: (savedObjectName?: string) =>
savedObjectName
? i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', {
defaultMessage: '{savedObjectName} was added',
values: {
savedObjectName: `'${savedObjectName}'`,
},
})
: i18n.translate('dashboard.addPanel.panelAddedToContainerSuccessMessageTitle', {
defaultMessage: 'A panel was added',
}),
getNoMatchingObjectsMessage: () =>
i18n.translate('dashboard.addPanel.noMatchingObjectsMessage', {
defaultMessage: 'No matching objects found.',

View file

@ -8,6 +8,7 @@
import { EuiHorizontalRule } from '@elastic/eui';
import { METRIC_TYPE } from '@kbn/analytics';
import { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import {
AddFromLibraryButton,
PrimaryActionButton,
@ -18,6 +19,7 @@ import {
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import React from 'react';
import { useCallback } from 'react';
import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants';
import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_renderer';
import { pluginServices } from '../../services/plugin_services';
@ -28,8 +30,9 @@ export function DashboardEditingToolbar() {
const {
usageCollection,
data: { search },
notifications: { toasts },
settings: { uiSettings },
embeddable: { getStateTransfer },
embeddable: { getStateTransfer, getEmbeddableFactory },
visualizations: { get: getVisualization, getAliases: getVisTypeAliases },
} = pluginServices.getServices();
@ -39,7 +42,13 @@ export function DashboardEditingToolbar() {
const IS_DARK_THEME = uiSettings.get('theme:darkMode');
const lensAlias = getVisTypeAliases().find(({ name }) => name === 'lens');
const quickButtonVisTypes = ['markdown', 'maps'];
const quickButtonVisTypes: Array<
{ type: 'vis'; visType: string } | { type: 'embeddable'; embeddableType: string }
> = [
{ type: 'vis', visType: 'markdown' },
{ type: 'embeddable', embeddableType: 'image' },
{ type: 'vis', visType: 'maps' },
];
const trackUiMetric = usageCollection.reportUiCounter?.bind(
usageCollection,
@ -79,32 +88,77 @@ export function DashboardEditingToolbar() {
[stateTransferService, search.session, trackUiMetric]
);
const getVisTypeQuickButton = (visTypeName: string) => {
const visType =
getVisualization(visTypeName) || getVisTypeAliases().find(({ name }) => name === visTypeName);
if (visType) {
if ('aliasPath' in visType) {
const { name, icon, title } = visType as VisTypeAlias;
return {
iconType: icon,
createType: title,
onClick: createNewVisType(visType as VisTypeAlias),
'data-test-subj': `dashboardQuickButton${name}`,
};
} else {
const { name, icon, title, titleInWizard } = visType as BaseVisType;
return {
iconType: icon,
createType: titleInWizard || title,
onClick: createNewVisType(visType as BaseVisType),
'data-test-subj': `dashboardQuickButton${name}`,
};
const createNewEmbeddable = useCallback(
async (embeddableFactory: EmbeddableFactory) => {
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, embeddableFactory.type);
}
let explicitInput: Awaited<ReturnType<typeof embeddableFactory.getExplicitInput>>;
try {
explicitInput = await embeddableFactory.getExplicitInput();
} catch (e) {
// error likely means user canceled embeddable creation
return;
}
const newEmbeddable = await dashboardContainer.addNewEmbeddable(
embeddableFactory.type,
explicitInput
);
if (newEmbeddable) {
toasts.addSuccess({
title: dashboardReplacePanelActionStrings.getSuccessMessage(newEmbeddable.getTitle()),
'data-test-subj': 'addEmbeddableToDashboardSuccess',
});
}
},
[trackUiMetric, dashboardContainer, toasts]
);
const getVisTypeQuickButton = (quickButtonForType: typeof quickButtonVisTypes[0]) => {
if (quickButtonForType.type === 'vis') {
const visTypeName = quickButtonForType.visType;
const visType =
getVisualization(visTypeName) ||
getVisTypeAliases().find(({ name }) => name === visTypeName);
if (visType) {
if ('aliasPath' in visType) {
const { name, icon, title } = visType as VisTypeAlias;
return {
iconType: icon,
createType: title,
onClick: createNewVisType(visType as VisTypeAlias),
'data-test-subj': `dashboardQuickButton${name}`,
};
} else {
const { name, icon, title, titleInWizard } = visType as BaseVisType;
return {
iconType: icon,
createType: titleInWizard || title,
onClick: createNewVisType(visType as BaseVisType),
'data-test-subj': `dashboardQuickButton${name}`,
};
}
}
} else {
const embeddableType = quickButtonForType.embeddableType;
const embeddableFactory = getEmbeddableFactory(embeddableType);
return {
iconType: embeddableFactory?.getIconType(),
createType: embeddableFactory?.getDisplayName(),
onClick: () => {
if (embeddableFactory) {
createNewEmbeddable(embeddableFactory);
}
},
'data-test-subj': `dashboardQuickButton${embeddableType}`,
};
}
return;
};
const quickButtons = quickButtonVisTypes
@ -127,7 +181,10 @@ export function DashboardEditingToolbar() {
),
quickButtonGroup: <QuickButtonGroup buttons={quickButtons} />,
extraButtons: [
<EditorMenu createNewVisType={createNewVisType} />,
<EditorMenu
createNewVisType={createNewVisType}
createNewEmbeddable={createNewEmbeddable}
/>,
<AddFromLibraryButton
onClick={() => dashboardContainer.addFromLibrary()}
data-test-subj="dashboardAddPanelButton"

View file

@ -8,31 +8,25 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiBadge,
EuiContextMenu,
EuiContextMenuPanelItemDescriptor,
EuiContextMenuItemIcon,
EuiContextMenuPanelItemDescriptor,
EuiFlexGroup,
EuiFlexItem,
EuiBadge,
} from '@elastic/eui';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import { type BaseVisType, VisGroups, type VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { SolutionToolbarPopover } from '@kbn/presentation-util-plugin/public';
import type {
EmbeddableFactory,
EmbeddableFactoryDefinition,
EmbeddableInput,
} from '@kbn/embeddable-plugin/public';
import type { EmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { pluginServices } from '../../services/plugin_services';
import { getPanelAddedSuccessString } from '../_dashboard_app_strings';
import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants';
import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_renderer';
import { DASHBOARD_APP_ID } from '../../dashboard_constants';
interface Props {
/** Handler for creating new visualization of a specified type */
createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void;
/** Handler for creating a new embeddable of a specified type */
createNewEmbeddable: (embeddableFactory: EmbeddableFactory) => void;
}
interface FactoryGroup {
@ -40,7 +34,7 @@ interface FactoryGroup {
appName: string;
icon: EuiContextMenuItemIcon;
panelId: number;
factories: EmbeddableFactoryDefinition[];
factories: EmbeddableFactory[];
}
interface UnwrappedEmbeddableFactory {
@ -48,12 +42,10 @@ interface UnwrappedEmbeddableFactory {
isEditable: boolean;
}
export const EditorMenu = ({ createNewVisType }: Props) => {
export const EditorMenu = ({ createNewVisType, createNewEmbeddable }: Props) => {
const {
embeddable,
notifications: { toasts },
settings: { uiSettings },
usageCollection,
visualizations: {
getAliases: getVisTypeAliases,
getByGroup: getVisTypesByGroup,
@ -61,8 +53,6 @@ export const EditorMenu = ({ createNewVisType }: Props) => {
},
} = pluginServices.getServices();
const { embeddableInstance: dashboardContainer } = useDashboardContainerContext();
const embeddableFactories = useMemo(
() => Array.from(embeddable.getEmbeddableFactories()),
[embeddable]
@ -84,11 +74,6 @@ export const EditorMenu = ({ createNewVisType }: Props) => {
const LABS_ENABLED = uiSettings.get('visualize:enableLabs');
const trackUiMetric = usageCollection.reportUiCounter?.bind(
usageCollection,
DASHBOARD_UI_METRIC_ID
);
const createNewAggsBasedVis = useCallback(
(visType?: BaseVisType) => () =>
showNewVisModal({
@ -129,7 +114,7 @@ export const EditorMenu = ({ createNewVisType }: Props) => {
);
const factoryGroupMap: Record<string, FactoryGroup> = {};
const ungroupedFactories: EmbeddableFactoryDefinition[] = [];
const ungroupedFactories: EmbeddableFactory[] = [];
const aggBasedPanelID = 1;
let panelCount = 1 + aggBasedPanelID;
@ -211,7 +196,7 @@ export const EditorMenu = ({ createNewVisType }: Props) => {
};
const getEmbeddableFactoryMenuItem = (
factory: EmbeddableFactoryDefinition,
factory: EmbeddableFactory,
closePopover: () => void
): EuiContextMenuPanelItemDescriptor => {
const icon = factory?.getIconType ? factory.getIconType() : 'empty';
@ -224,23 +209,7 @@ export const EditorMenu = ({ createNewVisType }: Props) => {
toolTipContent,
onClick: async () => {
closePopover();
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, factory.type);
}
let newEmbeddable;
if (factory.getExplicitInput) {
const explicitInput = await factory.getExplicitInput();
newEmbeddable = await dashboardContainer.addNewEmbeddable(factory.type, explicitInput);
} else {
newEmbeddable = await factory.create({} as EmbeddableInput, dashboardContainer);
}
if (newEmbeddable) {
toasts.addSuccess({
title: getPanelAddedSuccessString(`'${newEmbeddable.getInput().title}'` || ''),
'data-test-subj': 'addEmbeddableToDashboardSuccess',
});
}
createNewEmbeddable(factory);
},
'data-test-subj': `createNew-${factory.type}`,
};

View file

@ -75,13 +75,29 @@ export class EditPanelAction implements Action<ActionContext> {
embeddable &&
embeddable.getOutput().editable &&
(embeddable.getOutput().editUrl ||
(embeddable.getOutput().editApp && embeddable.getOutput().editPath))
(embeddable.getOutput().editApp && embeddable.getOutput().editPath) ||
embeddable.getOutput().editableWithExplicitInput)
);
const inDashboardEditMode = embeddable.getInput().viewMode === ViewMode.EDIT;
return Boolean(canEditEmbeddable && inDashboardEditMode);
}
public async execute(context: ActionContext) {
const embeddable = context.embeddable;
const { editableWithExplicitInput } = embeddable.getOutput();
if (editableWithExplicitInput) {
const factory = this.getEmbeddableFactory(embeddable.type);
if (!factory) {
throw new EmbeddableFactoryNotFoundError(embeddable.type);
}
const oldExplicitInput = embeddable.getExplicitInput();
const newExplicitInput = await factory.getExplicitInput(oldExplicitInput);
embeddable.parent?.replaceEmbeddable(embeddable.id, newExplicitInput);
return;
}
const appTarget = this.getAppTarget(context);
if (appTarget) {
if (this.stateTransfer && appTarget.state) {

View file

@ -96,8 +96,10 @@ export interface EmbeddableFactory<
* Can be used to request explicit input from the user, to be passed in to `EmbeddableFactory:create`.
* Explicit input is stored on the parent container for this embeddable. It overrides all inherited
* input passed down from the parent container.
*
* Can be used to edit an embeddable by re-requesting explicit input. Initial input can be provided to allow the editor to show the current state.
*/
getExplicitInput(): Promise<Partial<TEmbeddableInput>>;
getExplicitInput(initialInput?: Partial<TEmbeddableInput>): Promise<Partial<TEmbeddableInput>>;
/**
* Creates a new embeddable instance based off the saved object id.

View file

@ -28,6 +28,8 @@ export interface EmbeddableOutput {
defaultTitle?: string;
title?: string;
editable?: boolean;
// Whether the embeddable can be edited inline by re-requesting the explicit input from the user
editableWithExplicitInput?: boolean;
savedObjectId?: string;
}

View file

@ -9,6 +9,7 @@
"description": "File upload, download, sharing, and serving over HTTP implementation in Kibana.",
"server": true,
"ui": true,
"extraPublicDirs": ["common"],
"requiredBundles": ["kibanaUtils"],
"optionalPlugins": ["security", "usageCollection"]
}

View file

@ -0,0 +1,4 @@
## Image Embeddable
This plugin contains image embeddable. Image embeddable allows to embed images into the dashboard.
Images can be added either by URL or by uploading the image file via file service.

View file

@ -0,0 +1,18 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/image_embeddable'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/image_embeddable',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/src/plugins/image_embeddable/{__packages_do_not_import__,common,public,server,static}/**/*.{ts,tsx}',
],
};

View file

@ -0,0 +1,13 @@
{
"id": "imageEmbeddable",
"version": "kibana",
"server": false,
"ui": true,
"owner": {
"name": "@elastic/kibana-global-experience",
"githubTeam": "@elastic/kibana-global-experience"
},
"description": "Image embeddable",
"requiredPlugins": ["embeddable", "files"],
"requiredBundles": ["kibanaUtils", "kibanaReact"]
}

View file

@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import 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/operators';
import { Subject } from 'rxjs';
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;
},
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}
/>
</ImageViewerContext.Provider>
</FilesContext>,
{ theme$: deps.theme.theme$ }
),
{
ownFocus: true,
'data-test-subj': 'createImageEmbeddableFlyout',
}
);
handle.onClose.then(() => {
closed$.next(true);
});
});
}

View file

@ -0,0 +1,136 @@
/*
* 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 { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nProvider } from '@kbn/i18n-react';
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';
const validateUrl = jest.fn(() => ({ isValid: true }));
beforeEach(() => {
validateUrl.mockImplementation(() => ({ isValid: true }));
});
const filesClient = createMockFilesClient();
filesClient.getFileKind.mockImplementation(() => imageEmbeddableFileKind);
const ImageEditor = (props: Partial<ImageEditorFlyoutProps>) => {
return (
<I18nProvider>
<FilesContext client={filesClient}>
<ImageViewerContext.Provider
value={{
getImageDownloadHref: (fileId: string) => `https://elastic.co/${fileId}`,
validateUrl,
}}
>
<ImageEditorFlyout
validateUrl={validateUrl}
onCancel={() => {}}
onSave={() => {}}
{...props}
/>
</ImageViewerContext.Provider>
</FilesContext>
</I18nProvider>
);
};
test('should call onCancel when "Close" clicked', async () => {
const onCancel = jest.fn();
const { getByText } = render(<ImageEditor onCancel={onCancel} />);
expect(getByText('Close')).toBeVisible();
await userEvent.click(getByText('Close'));
expect(onCancel).toBeCalled();
});
test('should call onSave when "Save" clicked (url)', async () => {
const onSave = jest.fn();
const { getByText, getByTestId } = render(<ImageEditor onSave={onSave} />);
await userEvent.click(getByText('Use link'));
await userEvent.type(getByTestId(`imageEmbeddableEditorUrlInput`), `https://elastic.co/image`);
await userEvent.type(getByTestId(`imageEmbeddableEditorAltInput`), `alt text`);
expect(getByTestId(`imageEmbeddableEditorSave`)).toBeVisible();
await userEvent.click(getByTestId(`imageEmbeddableEditorSave`));
expect(onSave).toBeCalledWith({
altText: 'alt text',
backgroundColor: '',
sizing: {
objectFit: 'contain',
},
src: {
type: 'url',
url: 'https://elastic.co/image',
},
});
});
test('should be able to edit', async () => {
const initialImageConfig = {
altText: 'alt text',
backgroundColor: '',
sizing: {
objectFit: 'contain' as const,
},
src: {
type: 'url' as const,
url: 'https://elastic.co/image',
},
};
const onSave = jest.fn();
const { getByTestId } = render(
<ImageEditor onSave={onSave} initialImageConfig={initialImageConfig} />
);
expect(getByTestId(`imageEmbeddableEditorUrlInput`)).toHaveValue('https://elastic.co/image');
await userEvent.type(getByTestId(`imageEmbeddableEditorUrlInput`), `-changed`);
await userEvent.type(getByTestId(`imageEmbeddableEditorAltInput`), ` changed`);
expect(getByTestId(`imageEmbeddableEditorSave`)).toBeVisible();
await userEvent.click(getByTestId(`imageEmbeddableEditorSave`));
expect(onSave).toBeCalledWith({
altText: 'alt text changed',
backgroundColor: '',
sizing: {
objectFit: 'contain',
},
src: {
type: 'url',
url: 'https://elastic.co/image-changed',
},
});
});
test(`shouldn't be able to save if url is invalid`, async () => {
const initialImageConfig = {
altText: 'alt text',
backgroundColor: '',
sizing: {
objectFit: 'contain' as const,
},
src: {
type: 'url' as const,
url: 'https://elastic.co/image',
},
};
validateUrl.mockImplementation(() => ({ isValid: false, error: 'error' }));
const { getByTestId } = render(<ImageEditor initialImageConfig={initialImageConfig} />);
expect(getByTestId(`imageEmbeddableEditorSave`)).toBeDisabled();
});

View file

@ -0,0 +1,445 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiTab,
EuiTabs,
EuiTitle,
EuiSpacer,
EuiLink,
EuiEmptyPrompt,
EuiTextArea,
EuiFormRow,
EuiSelect,
EuiColorPicker,
useColorPickerState,
EuiLoadingSpinner,
useEuiTheme,
} from '@elastic/eui';
import React, { useState } from 'react';
import { css } from '@emotion/react';
import { FileUpload } from '@kbn/shared-ux-file-upload';
import { FilePicker } from '@kbn/shared-ux-file-picker';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
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';
/**
* Shared sizing css for image, upload placeholder, empty and not found state
* Makes sure the container has not too large height to preserve vertical space for the image configuration in the flyout
*/
const CONTAINER_SIZING_CSS = css({
aspectRatio: `21 / 9`,
width: `100%`,
height: `auto`,
maxHeight: `max(20vh, 180px)`,
});
export interface ImageEditorFlyoutProps {
onCancel: () => void;
onSave: (imageConfig: ImageConfig) => void;
initialImageConfig?: ImageConfig;
validateUrl: ValidateUrlFn;
}
export function ImageEditorFlyout(props: ImageEditorFlyoutProps) {
const isEditing = !!props.initialImageConfig;
const { euiTheme } = useEuiTheme();
const [fileId, setFileId] = useState<undefined | string>(() =>
props.initialImageConfig?.src?.type === 'file' ? props.initialImageConfig.src.fileId : undefined
);
const [fileImageMeta, setFileImageMeta] = useState<undefined | FileImageMetadata>(() =>
props.initialImageConfig?.src?.type === 'file'
? props.initialImageConfig.src.fileImageMeta
: undefined
);
const [srcType, setSrcType] = useState<ImageConfig['src']['type']>(
() => props.initialImageConfig?.src?.type ?? 'file'
);
const [srcUrl, setSrcUrl] = useState<string>(() =>
props.initialImageConfig?.src?.type === 'url' ? props.initialImageConfig.src.url : ''
);
const [srcUrlError, setSrcUrlError] = useState<string | null>(() => {
if (srcUrl) return props.validateUrl(srcUrl)?.error ?? null;
return null;
});
const [isFilePickerOpen, setIsFilePickerOpen] = useState<boolean>(false);
const [sizingObjectFit, setSizingObjectFit] = useState<ImageConfig['sizing']['objectFit']>(
() => props.initialImageConfig?.sizing?.objectFit ?? 'contain'
);
const [altText, setAltText] = useState<string>(() => props.initialImageConfig?.altText ?? '');
const [color, setColor, colorErrors] = useColorPickerState(
props?.initialImageConfig?.backgroundColor
);
const isColorInvalid = !!color && !!colorErrors;
const draftImageConfig: DraftImageConfig = {
...props.initialImageConfig,
src:
srcType === 'url'
? {
type: 'url',
url: srcUrl,
}
: { type: 'file', fileId, fileImageMeta },
altText,
backgroundColor: colorErrors ? undefined : color,
sizing: {
objectFit: sizingObjectFit,
},
};
const isDraftImageConfigValid = validateImageConfig(draftImageConfig, {
validateUrl: props.validateUrl,
});
const onSave = () => {
if (!isDraftImageConfigValid) return;
props.onSave(draftImageConfig);
};
return (
<>
<EuiFlyoutHeader hasBorder={true}>
<EuiTitle size="m">
<h2>
{isEditing ? (
<FormattedMessage
id="imageEmbeddable.imageEditor.editImagetitle"
defaultMessage="Edit image"
/>
) : (
<FormattedMessage
id="imageEmbeddable.imageEditor.addImagetitle"
defaultMessage="Add image"
/>
)}
</h2>
</EuiTitle>
<EuiSpacer size={'s'} />
<EuiTabs style={{ marginBottom: '-25px' }}>
<EuiTab onClick={() => setSrcType('file')} isSelected={srcType === 'file'}>
<FormattedMessage
id="imageEmbeddable.imageEditor.uploadTabLabel"
defaultMessage="Upload"
/>
</EuiTab>
<EuiTab onClick={() => setSrcType('url')} isSelected={srcType === 'url'}>
<FormattedMessage
id="imageEmbeddable.imageEditor.useLinkTabLabel"
defaultMessage="Use link"
/>
</EuiTab>
</EuiTabs>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{srcType === 'file' && (
<>
{isDraftImageConfigValid ? (
<ImageViewer
css={CONTAINER_SIZING_CSS}
imageConfig={draftImageConfig}
onChange={() => setIsFilePickerOpen(true)}
onClear={() => {
setFileId(undefined);
setFileImageMeta(undefined);
}}
containerCSS={css`
border: ${euiTheme.border.thin};
background-color: ${euiTheme.colors.lightestShade};
`}
/>
) : (
<EuiFormRow
fullWidth={true}
css={css`
.lazy-load-fallback,
.euiFilePicker__prompt {
// increase upload image prompt size and lazy load fallback container to look nicer with large flyout and reduce layout shift
height: auto;
${CONTAINER_SIZING_CSS};
}
.lazy-load-fallback {
display: flex;
justify-content: center;
align-items: center;
}
`}
>
<>
<FileUpload
kind={imageEmbeddableFileKind.id}
onDone={(files) => setFileId(files[0]?.id)}
immediate={true}
initialPromptText={i18n.translate(
'imageEmbeddable.imageEditor.uploadImagePromptText',
{
defaultMessage: 'Select or drag and drop an image',
}
)}
fullWidth={true}
lazyLoadFallback={
<div className={`lazy-load-fallback`}>
<EuiLoadingSpinner size={'xl'} />
</div>
}
/>
<p style={{ textAlign: 'center' }}>
<EuiLink
onClick={() => setIsFilePickerOpen(true)}
data-test-subj="imageEmbeddableEditorSelectFiles"
>
<FormattedMessage
id="imageEmbeddable.imageEditor.selectImagePromptText"
defaultMessage="Use a previously uploaded image"
/>
</EuiLink>
</p>
</>
</EuiFormRow>
)}
</>
)}
{srcType === 'url' && (
<>
{!isDraftImageConfigValid ? (
<EuiEmptyPrompt
css={css`
max-width: none;
${CONTAINER_SIZING_CSS}
.euiEmptyPrompt__main {
height: 100%;
}
`}
iconType="image"
color="subdued"
title={
<p>
<FormattedMessage
id="imageEmbeddable.imageEditor.byURLNoImageTitle"
defaultMessage="No Image"
/>
</p>
}
titleSize={'s'}
/>
) : (
<ImageViewer
css={CONTAINER_SIZING_CSS}
imageConfig={draftImageConfig}
onError={() => {
setSrcUrlError(
i18n.translate('imageEmbeddable.imageEditor.urlFailedToLoadImageErrorMessage', {
defaultMessage: 'Unable to load image.',
})
);
}}
containerCSS={css`
border: ${euiTheme.border.thin};
background-color: ${euiTheme.colors.lightestShade};
`}
/>
)}
<EuiSpacer />
<EuiFormRow
label={
<FormattedMessage
id="imageEmbeddable.imageEditor.imageURLInputLabel"
defaultMessage="Link to image"
/>
}
helpText={
<FormattedMessage
id="imageEmbeddable.imageEditor.imageURLHelpText"
defaultMessage="Supported file types: png, jpeg, webp, and avif."
/>
}
fullWidth={true}
isInvalid={!!srcUrlError}
error={srcUrlError}
>
<EuiTextArea
data-test-subj={'imageEmbeddableEditorUrlInput'}
fullWidth
compressed={true}
placeholder={i18n.translate('imageEmbeddable.imageEditor.imageURLPlaceholderText', {
defaultMessage: 'Example: https://elastic.co/my-image.png',
})}
value={srcUrl}
onChange={(e) => {
const url = e.target.value;
const { isValid, error } = props.validateUrl(url);
if (!isValid) {
setSrcUrlError(error!);
} else {
setSrcUrlError(null);
}
setSrcUrl(e.target.value);
}}
/>
</EuiFormRow>
</>
)}
<EuiSpacer />
<EuiFormRow
label={
<FormattedMessage
id="imageEmbeddable.imageEditor.imageFillModeLabel"
defaultMessage="Fill mode"
/>
}
fullWidth
>
<EuiSelect
fullWidth
options={[
{
value: 'contain',
text: i18n.translate('imageEmbeddable.imageEditor.imageFillModeContainOptionText', {
defaultMessage: 'Fit maintaining aspect ratio',
}),
},
{
value: 'cover',
text: i18n.translate('imageEmbeddable.imageEditor.imageFillModeCoverOptionText', {
defaultMessage: 'Fill maintaining aspect ratio',
}),
},
{
value: 'fill',
text: i18n.translate('imageEmbeddable.imageEditor.imageFillModeFillOptionText', {
defaultMessage: 'Stretch to fill',
}),
},
{
value: 'none',
text: i18n.translate('imageEmbeddable.imageEditor.imageFillModeNoneOptionText', {
defaultMessage: "Don't resize",
}),
},
]}
value={sizingObjectFit}
onChange={(e) =>
setSizingObjectFit(e.target.value as ImageConfig['sizing']['objectFit'])
}
/>
</EuiFormRow>
<EuiSpacer />
<EuiFormRow
label={
<FormattedMessage
id="imageEmbeddable.imageEditor.imageBackgroundColorLabel"
defaultMessage="Background color"
/>
}
fullWidth
isInvalid={isColorInvalid}
error={colorErrors}
>
<EuiColorPicker
fullWidth
onChange={setColor}
color={color}
isInvalid={isColorInvalid}
isClearable={true}
placeholder={i18n.translate(
'imageEmbeddable.imageEditor.imageBackgroundColorPlaceholderText',
{
defaultMessage: 'Transparent',
}
)}
/>
</EuiFormRow>
<EuiSpacer />
<EuiFormRow
label={
<FormattedMessage
id="imageEmbeddable.imageEditor.imageBackgroundDescriptionLabel"
defaultMessage="Description"
/>
}
fullWidth
>
<EuiTextArea
data-test-subj={'imageEmbeddableEditorAltInput'}
fullWidth
compressed={true}
value={altText}
maxLength={1000}
placeholder={i18n.translate(
'imageEmbeddable.imageEditor.imageAltInputPlaceholderText',
{
defaultMessage: `Alt text that describes the image`,
}
)}
onChange={(e) => {
setAltText(e.target.value);
}}
/>
</EuiFormRow>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={props.onCancel} flush="left">
<FormattedMessage
id="imageEmbeddable.imageEditor.imageBackgroundCloseButtonText"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={onSave}
fill
isDisabled={!isDraftImageConfigValid}
data-test-subj="imageEmbeddableEditorSave"
>
<FormattedMessage
id="imageEmbeddable.imageEditor.imageBackgroundSaveImageButtonText"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
{isFilePickerOpen && (
<FilePicker
kind={imageEmbeddableFileKind.id}
multiple={false}
onClose={() => {
setIsFilePickerOpen(false);
}}
onDone={([file]) => {
setFileId(file.id);
setFileImageMeta(file.meta as FileImageMetadata);
setIsFilePickerOpen(false);
}}
/>
)}
</>
);
}

View file

@ -0,0 +1,9 @@
/*
* 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 './configure_image';

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import 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';
export const IMAGE_EMBEDDABLE_TYPE = 'image';
export class ImageEmbeddable extends Embeddable<ImageEmbeddableInput> {
public readonly type = IMAGE_EMBEDDABLE_TYPE;
constructor(
private deps: {
getImageDownloadHref: (fileId: string) => string;
validateUrl: ReturnType<typeof createValidateUrl>;
},
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());
return (
<ImageViewerContext.Provider
value={{
getImageDownloadHref: this.deps.getImageDownloadHref,
validateUrl: this.deps.validateUrl,
}}
>
<ImageViewer
imageConfig={input.imageConfig}
onLoad={() => {
this.renderComplete.dispatchComplete();
}}
onError={() => {
this.renderComplete.dispatchError();
}}
/>
</ImageViewerContext.Provider>
);
};
}

View file

@ -0,0 +1,92 @@
/*
* 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 {
IContainer,
EmbeddableInput,
EmbeddableFactoryDefinition,
ApplicationStart,
OverlayStart,
FilesClient,
FileImageMetadata,
imageEmbeddableFileKind,
ThemeServiceStart,
} from '../imports';
import { ImageEmbeddable, IMAGE_EMBEDDABLE_TYPE } from './image_embeddable';
import { ImageConfig } from '../types';
import { createValidateUrl } from '../utils/validate_url';
export interface ImageEmbeddableFactoryDeps {
start: () => {
application: ApplicationStart;
overlays: OverlayStart;
files: FilesClient<FileImageMetadata>;
externalUrl: IExternalUrl;
theme: ThemeServiceStart;
};
}
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),
},
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 imageConfig = await configureImage(
{
files: this.deps.start().files,
overlays: this.deps.start().overlays,
currentAppId$: this.deps.start().application.currentAppId$,
validateUrl: createValidateUrl(this.deps.start().externalUrl),
getImageDownloadHref: this.getImageDownloadHref,
theme: this.deps.start().theme,
},
initialInput ? initialInput.imageConfig : undefined
);
return { imageConfig };
}
private getImageDownloadHref = (fileId: string) =>
this.deps.start().files.getDownloadHref({ id: fileId, fileKind: imageEmbeddableFileKind.id });
}

View file

@ -0,0 +1,10 @@
/*
* 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,77 @@
/*
* 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 { render } from '@testing-library/react';
import { ImageViewer } from './image_viewer';
import { ImageViewerContext } from './image_viewer_context';
import { ImageConfig } from '../types';
const validateUrl = jest.fn(() => ({ isValid: true }));
beforeEach(() => {
validateUrl.mockImplementation(() => ({ isValid: true }));
});
const DefaultImageViewer = (props: { imageConfig: ImageConfig }) => {
return (
<ImageViewerContext.Provider
value={{
getImageDownloadHref: (fileId: string) => `https://elastic.co/${fileId}`,
validateUrl,
}}
>
<ImageViewer imageConfig={props.imageConfig} />
</ImageViewerContext.Provider>
);
};
test('should display an image by a valid url', () => {
const { getByAltText } = render(
<DefaultImageViewer
imageConfig={{
src: { type: 'url', url: 'https://elastic.co/image' },
sizing: { objectFit: 'fill' },
altText: 'alt text',
}}
/>
);
expect(getByAltText(`alt text`)).toBeVisible();
});
test('should display a 404 if url is invalid', () => {
validateUrl.mockImplementation(() => ({ isValid: false }));
const { queryByAltText, getByTestId } = render(
<DefaultImageViewer
imageConfig={{
src: { type: 'url', url: 'https://elastic.co/image' },
sizing: { objectFit: 'fill' },
altText: 'alt text',
}}
/>
);
expect(queryByAltText(`alt text`)).toBeNull();
expect(getByTestId(`imageNotFound`)).toBeVisible();
});
test('should display an image by file id', () => {
const { getByAltText } = render(
<DefaultImageViewer
imageConfig={{
src: { type: 'file', fileId: 'imageId', fileImageMeta: { width: 300, height: 300 } },
sizing: { objectFit: 'fill' },
altText: 'alt text',
}}
/>
);
expect(getByAltText(`alt text`)).toBeVisible();
expect(getByAltText(`alt text`)).toHaveAttribute('src', 'https://elastic.co/imageId');
});

View file

@ -0,0 +1,212 @@
/*
* 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 { css, SerializedStyles } from '@emotion/react';
import { FileImage } from '@kbn/shared-ux-file-image';
import classNames from 'classnames';
import {
EuiButtonIcon,
EuiEmptyPrompt,
EuiImage,
useEuiTheme,
useResizeObserver,
useIsWithinBreakpoints,
EuiImageProps,
} from '@elastic/eui';
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 { useImageViewerContext } from './image_viewer_context';
export interface ImageViewerProps {
imageConfig: ImageConfig;
className?: string;
onChange?: () => void;
onClear?: () => void;
onError?: () => void;
onLoad?: () => void;
containerCSS?: SerializedStyles;
}
export function ImageViewer({
imageConfig,
onChange,
onClear,
onError,
onLoad,
className,
containerCSS,
}: ImageViewerProps) {
const { euiTheme } = useEuiTheme();
const { getImageDownloadHref, validateUrl } = useImageViewerContext();
const isImageConfigValid = validateImageConfig(imageConfig, { validateUrl });
const src =
imageConfig.src.type === 'url'
? imageConfig.src.url
: getImageDownloadHref(imageConfig.src.fileId);
const [hasFailedToLoad, setFailedToLoad] = useState<boolean>(false);
useEffect(() => {
setFailedToLoad(false);
}, [src]);
return (
<div
css={[
css`
position: relative;
width: 100%;
height: 100%;
border-radius: ${euiTheme.border.radius.medium};
.visually-hidden {
visibility: hidden;
}
`,
containerCSS,
]}
>
{(hasFailedToLoad || !isImageConfigValid) && <NotFound />}
{isImageConfigValid && (
<FileImage
src={src}
// uncomment to enable blurhash when it's ready
// https://github.com/elastic/kibana/issues/145567
// meta={imageConfig.src.type === 'file' ? imageConfig.src.fileImageMeta : undefined}
alt={imageConfig.altText ?? ''}
className={classNames(className, { 'visually-hidden': hasFailedToLoad })}
title={onChange ? 'Click to select a different image' : undefined}
style={{
width: '100%',
height: '100%',
objectFit: imageConfig?.sizing?.objectFit ?? 'contain',
cursor: onChange ? 'pointer' : 'initial',
display: 'block', // needed to remove gap under the image
backgroundColor: imageConfig.backgroundColor,
}}
wrapperProps={{
style: { display: 'block', height: '100%', width: '100%' },
}}
onClick={() => {
if (onChange) onChange();
}}
onLoad={() => {
if (onLoad) onLoad();
}}
onError={() => {
setFailedToLoad(true);
if (onError) onError();
}}
/>
)}
{onClear && (
<EuiButtonIcon
style={{ position: 'absolute', top: '-4px', right: '-4px' }}
display="fill"
iconType="cross"
aria-label="Clear"
color="danger"
onClick={() => {
if (onClear) onClear();
}}
/>
)}
</div>
);
}
function NotFound() {
const [resizeRef, setRef] = React.useState<HTMLDivElement | null>(null);
const isLargeScreen = useIsWithinBreakpoints(['l', 'xl'], true);
const dimensions = useResizeObserver(resizeRef);
let mode: 'none' | 'only-image' | 'image-and-text' = 'none';
if (!resizeRef) {
mode = 'none';
} else if (dimensions.height > 200 && dimensions.width > 320 && isLargeScreen) {
mode = 'image-and-text';
} else {
mode = 'only-image';
}
return (
<div
ref={(node) => setRef(node)}
css={css`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.euiPanel,
.euiEmptyPrompt__main {
height: 100%;
width: 100%;
max-width: none;
}
`}
>
{mode === 'only-image' && (
<NotFoundImage
css={css`
object-fit: contain;
height: 100%;
width: 100%;
`}
wrapperProps={{
css: css`
height: 100%;
width: 100%;
`,
}}
/>
)}
{mode === 'image-and-text' && (
<EuiEmptyPrompt
color="transparent"
icon={<NotFoundImage />}
title={
<p>
<FormattedMessage
id="imageEmbeddable.imageViewer.notFoundTitle"
defaultMessage="Image not found"
/>
</p>
}
layout="horizontal"
body={
<p>
<FormattedMessage
id="imageEmbeddable.imageViewer.notFoundMessage"
defaultMessage="We can't find the image you're looking for. It might have been removed, renamed, or it didn't exist in the first place."
/>
</p>
}
/>
)}
</div>
);
}
const NotFoundImage = React.memo((props: Partial<Omit<EuiImageProps, 'url'>>) => (
<EuiImage
{...props}
data-test-subj={`imageNotFound`}
srcSet={`${notFound} 1x, ${notFound2x} 2x`}
src={notFound}
alt={i18n.translate('imageEmbeddable.imageViewer.notFoundImageAltText', {
defaultMessage: `An outer space illustration. In the background is a large moon and two planets. In the foreground is an astronaut floating in space and the numbers '404'.`,
})}
/>
));

View file

@ -0,0 +1,27 @@
/*
* 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 { createContext, useContext } from 'react';
import type { createValidateUrl } from '../utils/validate_url';
export interface ImageViewerContextValue {
getImageDownloadHref: (fileId: string) => string;
validateUrl: ReturnType<typeof createValidateUrl>;
}
export const ImageViewerContext = createContext<ImageViewerContextValue>(
null as unknown as ImageViewerContextValue
);
export const useImageViewerContext = () => {
const ctx = useContext(ImageViewerContext);
if (!ctx) {
throw new Error('ImageViewerContext is not found!');
}
return ctx;
};

View file

@ -0,0 +1,21 @@
/*
* 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';
export { ImageViewerContext, type ImageViewerContextValue } from './image_viewer_context';
import type { ImageViewerProps } from './image_viewer';
const LazyImageViewer = React.lazy(() =>
import('./image_viewer').then((m) => ({ default: m.ImageViewer }))
);
export const ImageViewer = (props: ImageViewerProps) => (
<React.Suspense fallback={<></>}>
<LazyImageViewer {...props} />
</React.Suspense>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FileKind } from '@kbn/shared-ux-file-types';
import { defaultImageFileKind } from '@kbn/files-plugin/common';
export type {
FilesClient,
FilesSetup,
FilesStart,
ScopedFilesClient,
} from '@kbn/files-plugin/public';
export type { FileImageMetadata } from '@kbn/shared-ux-file-types/';
export type {
IContainer,
EmbeddableInput,
EmbeddableFactoryDefinition,
} from '@kbn/embeddable-plugin/public';
export type { ApplicationStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
export const imageEmbeddableFileKind: FileKind = defaultImageFileKind;

View file

@ -0,0 +1,14 @@
/*
* 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 { PluginInitializerContext } from '@kbn/core/public';
import { ImageEmbeddablePlugin } from './plugin';
export function plugin(context: PluginInitializerContext) {
return new ImageEmbeddablePlugin(context);
}

View file

@ -0,0 +1,58 @@
/*
* 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 { 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 { FilesSetup, FilesStart } from '@kbn/files-plugin/public';
import { IMAGE_EMBEDDABLE_TYPE, ImageEmbeddableFactoryDefinition } from './image_embeddable';
export interface SetupDependencies {
embeddable: EmbeddableSetup;
files: FilesSetup;
}
export interface StartDependencies {
embeddable: EmbeddableStart;
files: FilesStart;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SetupContract {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface StartContract {}
export class ImageEmbeddablePlugin
implements Plugin<SetupContract, StartContract, SetupDependencies, StartDependencies>
{
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,
}),
})
);
return {};
}
public start(core: CoreStart, plugins: StartDependencies): StartContract {
return {};
}
public stop() {}
}

View file

@ -0,0 +1,31 @@
/*
* 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 interface ImageConfig {
src: ImageFileSrc | ImageUrlSrc;
altText?: string;
sizing: {
objectFit: `fill` | `contain` | `cover` | `none`;
};
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

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { RecursivePartial } from '@elastic/eui';
import { ImageConfig } from '../types';
import { ValidateUrlFn } from './validate_url';
export type DraftImageConfig = RecursivePartial<ImageConfig>;
export function validateImageConfig(
draftConfig: DraftImageConfig,
{ validateUrl }: { validateUrl: ValidateUrlFn }
): draftConfig is ImageConfig {
if (!draftConfig.src) return false;
if (draftConfig.src.type === 'file') {
if (!draftConfig.src.fileId) return false;
} else if (draftConfig.src.type === 'url') {
if (!draftConfig.src.url) return false;
if (!validateUrl(draftConfig.src.url).isValid) return false;
}
return true;
}

View file

@ -0,0 +1,63 @@
/*
* 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';
const SAFE_URL_PATTERN = /^(?:(?:https?):|[^&:/?#]*(?:[/?#]|$))/gi;
const generalFormatError = i18n.translate(
'imageEmbeddable.imageEditor.urlFormatGeneralErrorMessage',
{
defaultMessage: 'Invalid format. Example: {exampleUrl}',
values: {
exampleUrl: 'https://elastic.co/my-image.png',
},
}
);
const externalUrlError = i18n.translate(
'imageEmbeddable.imageEditor.urlFormatExternalErrorMessage',
{
defaultMessage:
'This URL is not allowed by your administrator. Refer to "externalUrl.policy" configuration.',
}
);
export type ValidateUrlFn = ReturnType<typeof createValidateUrl>;
export function createValidateUrl(
externalUrl: IExternalUrl
): (url: string) => { isValid: boolean; error?: string } {
return (url: string) => {
if (!url)
return {
isValid: false,
error: generalFormatError,
};
try {
new URL(url);
if (!url.match(SAFE_URL_PATTERN)) throw new Error();
const isExternalUrlValid = !!externalUrl.validateUrl(url);
if (!isExternalUrlValid) {
return {
isValid: false,
error: externalUrlError,
};
}
return { isValid: true };
} catch (e) {
return {
isValid: false,
error: generalFormatError,
};
}
};
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true
},
"include": ["public/**/*", "common/**/*", "server/**/*"],
"kbn_references": [
{ "path": "../../core/tsconfig.json" },
{ "path": "../embeddable/tsconfig.json" },
{ "path": "../kibana_utils/tsconfig.json" },
{ "path": "../kibana_react/tsconfig.json" },
{ "path": "../files/tsconfig.json" }
]
}

View file

@ -84,8 +84,16 @@ export class CustomTimeRangeAction implements Action<TimeRangeActionContext> {
const isMarkdown =
isVisualizeEmbeddable(embeddable) &&
(embeddable as VisualizeEmbeddable).getOutput().visTypeName === 'markdown';
const isImage = embeddable.type === 'image';
return Boolean(
embeddable && embeddable.parent && hasTimeRange(embeddable) && !isInputControl && !isMarkdown
embeddable &&
embeddable.parent &&
hasTimeRange(embeddable) &&
!isInputControl &&
!isMarkdown &&
!isImage
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -0,0 +1,48 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'dashboard']);
const testSubjects = getService('testSubjects');
const kibanaServer = getService('kibanaServer');
describe('image embeddable', function () {
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load(
'test/functional/fixtures/kbn_archiver/dashboard/current/kibana'
);
await kibanaServer.uiSettings.replace({
defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c',
});
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await PageObjects.dashboard.switchToEditMode();
});
it('should create an image embeddable', async () => {
// create an image embeddable
await testSubjects.click(`dashboardQuickButtonimage`);
await testSubjects.exists(`createImageEmbeddableFlyout`);
await PageObjects.common.setFileInputPath(require.resolve('./elastic_logo.png'));
await testSubjects.clickWhenNotDisabled(`imageEmbeddableEditorSave`);
// check that it is added on the dashboard
expect(await PageObjects.dashboard.getSharedItemsCount()).to.be('1');
await PageObjects.dashboard.waitForRenderComplete();
const panel = (await PageObjects.dashboard.getDashboardPanels())[0];
const img = await panel.findByCssSelector('img.euiImage');
const imgSrc = await img.getAttribute('src');
expect(imgSrc).to.contain(`files/defaultImage`);
});
});
}

View file

@ -0,0 +1,15 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('image embeddable', function () {
loadTestFile(require.resolve('./image_embeddable'));
});
}

View file

@ -34,6 +34,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./input_control_vis'));
loadTestFile(require.resolve('./controls'));
loadTestFile(require.resolve('./_markdown_vis'));
loadTestFile(require.resolve('./image_embeddable'));
});
});
}

View file

@ -868,6 +868,8 @@
"@kbn/guided-onboarding-plugin/*": ["src/plugins/guided_onboarding/*"],
"@kbn/home-plugin": ["src/plugins/home"],
"@kbn/home-plugin/*": ["src/plugins/home/*"],
"@kbn/image-embeddable-plugin": ["src/plugins/image_embeddable"],
"@kbn/image-embeddable-plugin/*": ["src/plugins/image_embeddable/*"],
"@kbn/input-control-vis-plugin": ["src/plugins/input_control_vis"],
"@kbn/input-control-vis-plugin/*": ["src/plugins/input_control_vis/*"],
"@kbn/inspector-plugin": ["src/plugins/inspector"],