mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
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:
parent
b8c1e34b3b
commit
74ab0759f1
41 changed files with 1672 additions and 80 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -66,6 +66,7 @@ pageLoadAssetSize:
|
|||
grokdebugger: 26779
|
||||
guidedOnboarding: 42965
|
||||
home: 30182
|
||||
imageEmbeddable: 12500
|
||||
indexLifecycleManagement: 107090
|
||||
indexManagement: 140608
|
||||
infra: 184320
|
||||
|
|
|
@ -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 })}
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}`,
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
4
src/plugins/image_embeddable/README.md
Normal file
4
src/plugins/image_embeddable/README.md
Normal 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.
|
18
src/plugins/image_embeddable/jest.config.js
Normal file
18
src/plugins/image_embeddable/jest.config.js
Normal 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}',
|
||||
],
|
||||
};
|
13
src/plugins/image_embeddable/kibana.json
Normal file
13
src/plugins/image_embeddable/kibana.json
Normal 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"]
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
});
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
|
@ -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'.`,
|
||||
})}
|
||||
/>
|
||||
));
|
|
@ -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;
|
||||
};
|
21
src/plugins/image_embeddable/public/image_viewer/index.tsx
Normal file
21
src/plugins/image_embeddable/public/image_viewer/index.tsx
Normal 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 |
28
src/plugins/image_embeddable/public/imports.ts
Normal file
28
src/plugins/image_embeddable/public/imports.ts
Normal 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;
|
14
src/plugins/image_embeddable/public/index.ts
Normal file
14
src/plugins/image_embeddable/public/index.ts
Normal 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);
|
||||
}
|
58
src/plugins/image_embeddable/public/plugin.ts
Normal file
58
src/plugins/image_embeddable/public/plugin.ts
Normal 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() {}
|
||||
}
|
31
src/plugins/image_embeddable/public/types.ts
Normal file
31
src/plugins/image_embeddable/public/types.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
63
src/plugins/image_embeddable/public/utils/validate_url.ts
Normal file
63
src/plugins/image_embeddable/public/utils/validate_url.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
17
src/plugins/image_embeddable/tsconfig.json
Normal file
17
src/plugins/image_embeddable/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
|
@ -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 |
|
@ -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`);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue