mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Embeddable Refactor] Create Decoupled Presentation Panel (#172017)
Closes https://github.com/elastic/kibana/issues/167426 Closes https://github.com/elastic/kibana/issues/167890 Contains much of the prep work required to decouple the Embeddables system from Kibana in anticipation of its deprecation and removal. Co-authored-by: Nathan Reese <reese.nathan@gmail.com>
This commit is contained in:
parent
b4fa81814c
commit
64ebaffd89
217 changed files with 6947 additions and 6079 deletions
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
|
@ -591,6 +591,10 @@ packages/kbn-plugin-generator @elastic/kibana-operations
|
|||
packages/kbn-plugin-helpers @elastic/kibana-operations
|
||||
examples/portable_dashboards_example @elastic/kibana-presentation
|
||||
examples/preboot_example @elastic/kibana-security @elastic/kibana-core
|
||||
packages/presentation/presentation_containers @elastic/kibana-presentation
|
||||
packages/presentation/presentation_library @elastic/kibana-presentation
|
||||
src/plugins/presentation_panel @elastic/kibana-presentation
|
||||
packages/presentation/presentation_publishing @elastic/kibana-presentation
|
||||
src/plugins/presentation_util @elastic/kibana-presentation
|
||||
x-pack/plugins/profiling_data_access @elastic/obs-ux-infra_services-team
|
||||
x-pack/plugins/profiling @elastic/obs-ux-infra_services-team
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
"discover": ["src/plugins/discover", "packages/kbn-discover-utils"],
|
||||
"savedSearch": "src/plugins/saved_search",
|
||||
"embeddableApi": "src/plugins/embeddable",
|
||||
"presentationPanel": "src/plugins/presentation_panel",
|
||||
"embeddableExamples": "examples/embeddable_examples",
|
||||
"esQuery": "packages/kbn-es-query/src",
|
||||
"esUi": "src/plugins/es_ui_shared",
|
||||
|
|
|
@ -274,6 +274,10 @@ Content is fetched from the remote (https://feeds.elastic.co) once a day, with p
|
|||
|Helps to globally configure the no data page components
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/presentation_panel/README.md[presentationPanel]
|
||||
|The Presentation Panel is the point of contact between any React component and any registered UI actions. Components provided to the Presentation Panel should use an imperative handle to expose methods and state.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/presentation_util/README.mdx[presentationUtil]
|
||||
|The Presentation Utility Plugin is a set of common, shared components and toolkits for solutions within the Presentation space, (e.g. Dashboards, Canvas).
|
||||
|
||||
|
|
|
@ -596,6 +596,10 @@
|
|||
"@kbn/panel-loader": "link:packages/kbn-panel-loader",
|
||||
"@kbn/portable-dashboards-example": "link:examples/portable_dashboards_example",
|
||||
"@kbn/preboot-example-plugin": "link:examples/preboot_example",
|
||||
"@kbn/presentation-containers": "link:packages/presentation/presentation_containers",
|
||||
"@kbn/presentation-library": "link:packages/presentation/presentation_library",
|
||||
"@kbn/presentation-panel-plugin": "link:src/plugins/presentation_panel",
|
||||
"@kbn/presentation-publishing": "link:packages/presentation/presentation_publishing",
|
||||
"@kbn/presentation-util-plugin": "link:src/plugins/presentation_util",
|
||||
"@kbn/profiling-data-access-plugin": "link:x-pack/plugins/profiling_data_access",
|
||||
"@kbn/profiling-plugin": "link:x-pack/plugins/profiling",
|
||||
|
|
|
@ -110,6 +110,7 @@ pageLoadAssetSize:
|
|||
observabilityShared: 52256
|
||||
osquery: 107090
|
||||
painlessLab: 179748
|
||||
presentationPanel: 55463
|
||||
presentationUtil: 58834
|
||||
profiling: 36694
|
||||
remoteClusters: 51327
|
||||
|
|
|
@ -8,14 +8,25 @@
|
|||
|
||||
import React from 'react';
|
||||
import { EuiLoadingChart, EuiPanel } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
export const PanelLoader = (props: { showShadow?: boolean; dataTestSubj?: string }) => {
|
||||
return (
|
||||
<EuiPanel
|
||||
css={css`
|
||||
z-index: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: $euiSizeL + 2px;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`}
|
||||
role="figure"
|
||||
paddingSize="none"
|
||||
hasShadow={props.showShadow ?? false}
|
||||
className={'embPanel embPanel--loading embPanel-isLoading'}
|
||||
data-test-subj={props.dataTestSubj}
|
||||
>
|
||||
<EuiLoadingChart size="l" mono />
|
||||
|
|
3
packages/presentation/presentation_containers/README.md
Normal file
3
packages/presentation/presentation_containers/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/presentation-containers
|
||||
|
||||
Contains interfaces and type guards which can be used to define and consume "containers" which are pages or elements which render multiple panels.
|
22
packages/presentation/presentation_containers/index.ts
Normal file
22
packages/presentation/presentation_containers/index.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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 {
|
||||
apiCanDuplicatePanels,
|
||||
apiCanExpandPanels,
|
||||
useExpandedPanelId,
|
||||
type CanDuplicatePanels,
|
||||
type CanExpandPanels,
|
||||
} from './interfaces/panel_management';
|
||||
export {
|
||||
apiIsPresentationContainer,
|
||||
getContainerParentFromAPI,
|
||||
type PanelPackage,
|
||||
type PresentationContainer,
|
||||
} from './interfaces/presentation_container';
|
||||
export { tracksOverlays, type TracksOverlays } from './interfaces/tracks_overlays';
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
PublishingSubject,
|
||||
useStateFromPublishingSubject,
|
||||
} from '@kbn/presentation-publishing/publishing_subject';
|
||||
|
||||
export interface CanDuplicatePanels {
|
||||
duplicatePanel: (panelId: string) => void;
|
||||
}
|
||||
|
||||
export const apiCanDuplicatePanels = (
|
||||
unknownApi: unknown | null
|
||||
): unknownApi is CanDuplicatePanels => {
|
||||
return Boolean((unknownApi as CanDuplicatePanels)?.duplicatePanel !== undefined);
|
||||
};
|
||||
|
||||
export interface CanExpandPanels {
|
||||
expandPanel: (panelId?: string) => void;
|
||||
expandedPanelId: PublishingSubject<string | undefined>;
|
||||
}
|
||||
|
||||
export const apiCanExpandPanels = (unknownApi: unknown | null): unknownApi is CanExpandPanels => {
|
||||
return Boolean((unknownApi as CanExpandPanels)?.expandPanel !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets this API's expanded panel state as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useExpandedPanelId = (api: Partial<CanExpandPanels> | undefined) =>
|
||||
useStateFromPublishingSubject<string | undefined, CanExpandPanels['expandedPanelId']>(
|
||||
apiCanExpandPanels(api) ? api.expandedPanelId : undefined
|
||||
);
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { apiPublishesParentApi } from '@kbn/presentation-publishing';
|
||||
|
||||
export interface PanelPackage {
|
||||
panelType: string;
|
||||
initialState: unknown;
|
||||
}
|
||||
export interface PresentationContainer {
|
||||
removePanel: (panelId: string) => void;
|
||||
canRemovePanels?: () => boolean;
|
||||
replacePanel: (idToRemove: string, newPanel: PanelPackage) => Promise<string>;
|
||||
}
|
||||
|
||||
export const apiIsPresentationContainer = (
|
||||
unknownApi: unknown | null
|
||||
): unknownApi is PresentationContainer => {
|
||||
return Boolean((unknownApi as PresentationContainer)?.removePanel !== undefined);
|
||||
};
|
||||
|
||||
export const getContainerParentFromAPI = (
|
||||
api: null | unknown
|
||||
): PresentationContainer | undefined => {
|
||||
const apiParent = apiPublishesParentApi(api) ? api.parentApi.value : null;
|
||||
if (!apiParent) return undefined;
|
||||
return apiIsPresentationContainer(apiParent) ? apiParent : undefined;
|
||||
};
|
|
@ -9,14 +9,20 @@
|
|||
import { OverlayRef } from '@kbn/core-mount-utils-browser';
|
||||
|
||||
interface TracksOverlaysOptions {
|
||||
/**
|
||||
* If present, the panel with this ID will be focused when the overlay is opened. This can be used in tandem with a push
|
||||
* flyout to edit a panel's settings in context
|
||||
*/
|
||||
focusedPanelId?: string;
|
||||
}
|
||||
|
||||
interface TracksOverlays {
|
||||
export interface TracksOverlays {
|
||||
openOverlay: (ref: OverlayRef, options?: TracksOverlaysOptions) => void;
|
||||
clearOverlays: () => void;
|
||||
}
|
||||
|
||||
export const tracksOverlays = (root: unknown): root is TracksOverlays => {
|
||||
return Boolean((root as TracksOverlays).openOverlay && (root as TracksOverlays).clearOverlays);
|
||||
return Boolean(
|
||||
root && (root as TracksOverlays).openOverlay && (root as TracksOverlays).clearOverlays
|
||||
);
|
||||
};
|
13
packages/presentation/presentation_containers/jest.config.js
Normal file
13
packages/presentation/presentation_containers/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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>/packages/presentation/presentation_containers'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/presentation-containers",
|
||||
"owner": "@elastic/kibana-presentation"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/presentation-containers",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
10
packages/presentation/presentation_containers/tsconfig.json
Normal file
10
packages/presentation/presentation_containers/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": ["jest", "node", "react"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": ["@kbn/presentation-publishing", "@kbn/core-mount-utils-browser"]
|
||||
}
|
3
packages/presentation/presentation_library/README.md
Normal file
3
packages/presentation/presentation_library/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/presentation-library
|
||||
|
||||
Contains interfaces and type guards to be used to mediate the relationship between panels / charts, and a content library.
|
13
packages/presentation/presentation_library/index.ts
Normal file
13
packages/presentation/presentation_library/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { type CanLinkToLibrary, apiCanLinkToLibrary } from './interfaces/can_link_to_library';
|
||||
export {
|
||||
type CanUnlinkFromLibrary,
|
||||
apiCanUnlinkFromLibrary,
|
||||
} from './interfaces/can_unlink_from_library';
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 CanLinkToLibrary {
|
||||
canLinkToLibrary: () => Promise<boolean>;
|
||||
linkToLibrary: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const apiCanLinkToLibrary = (api: unknown): api is CanLinkToLibrary =>
|
||||
typeof (api as CanLinkToLibrary).canLinkToLibrary === 'function' &&
|
||||
typeof (api as CanLinkToLibrary).linkToLibrary === 'function';
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 CanUnlinkFromLibrary {
|
||||
canUnlinkFromLibrary: () => Promise<boolean>;
|
||||
unlinkFromLibrary: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const apiCanUnlinkFromLibrary = (api: unknown): api is CanUnlinkFromLibrary =>
|
||||
typeof (api as CanUnlinkFromLibrary).canUnlinkFromLibrary === 'function' &&
|
||||
typeof (api as CanUnlinkFromLibrary).unlinkFromLibrary === 'function';
|
13
packages/presentation/presentation_library/jest.config.js
Normal file
13
packages/presentation/presentation_library/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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>/packages/presentation/presentation_library'],
|
||||
};
|
5
packages/presentation/presentation_library/kibana.jsonc
Normal file
5
packages/presentation/presentation_library/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/presentation-library",
|
||||
"owner": "@elastic/kibana-presentation"
|
||||
}
|
6
packages/presentation/presentation_library/package.json
Normal file
6
packages/presentation/presentation_library/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/presentation-library",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
10
packages/presentation/presentation_library/tsconfig.json
Normal file
10
packages/presentation/presentation_library/tsconfig.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": ["jest", "node", "react"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": []
|
||||
}
|
3
packages/presentation/presentation_publishing/README.md
Normal file
3
packages/presentation/presentation_publishing/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/presentation-publishing
|
||||
|
||||
Contains interfaces and type guards to be used to publish and consume state of specific types.
|
102
packages/presentation/presentation_publishing/index.ts
Normal file
102
packages/presentation/presentation_publishing/index.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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 EmbeddableApiContext {
|
||||
embeddable: unknown;
|
||||
}
|
||||
|
||||
export {
|
||||
apiFiresPhaseEvents,
|
||||
type FiresPhaseEvents,
|
||||
type PhaseEvent,
|
||||
type PhaseEventType,
|
||||
} from './interfaces/fires_phase_events';
|
||||
export { hasEditCapabilities, type HasEditCapabilities } from './interfaces/has_edit_capabilities';
|
||||
export {
|
||||
apiHasType,
|
||||
apiIsOfType,
|
||||
type HasType,
|
||||
type HasTypeDisplayName,
|
||||
} from './interfaces/has_type';
|
||||
export {
|
||||
apiPublishesDataLoading,
|
||||
useDataLoading,
|
||||
type PublishesDataLoading,
|
||||
} from './interfaces/publishes_data_loading';
|
||||
export {
|
||||
apiPublishesDataViews,
|
||||
useDataViews,
|
||||
type PublishesDataViews,
|
||||
} from './interfaces/publishes_data_views';
|
||||
export {
|
||||
apiPublishesDisabledActionIds,
|
||||
useDisabledActionIds,
|
||||
type PublishesDisabledActionIds,
|
||||
} from './interfaces/publishes_disabled_action_ids';
|
||||
export {
|
||||
apiPublishesBlockingError,
|
||||
useBlockingError,
|
||||
type PublishesBlockingError,
|
||||
} from './interfaces/publishes_blocking_error';
|
||||
export {
|
||||
apiPublishesUniqueId,
|
||||
useUniqueId,
|
||||
type PublishesUniqueId,
|
||||
} from './interfaces/publishes_uuid';
|
||||
export {
|
||||
apiPublishesLocalUnifiedSearch,
|
||||
apiPublishesPartialLocalUnifiedSearch,
|
||||
apiPublishesWritableLocalUnifiedSearch,
|
||||
useLocalFilters,
|
||||
useLocalQuery,
|
||||
useLocalTimeRange,
|
||||
type PublishesLocalUnifiedSearch,
|
||||
type PublishesWritableLocalUnifiedSearch,
|
||||
} from './interfaces/publishes_local_unified_search';
|
||||
export {
|
||||
apiPublishesPanelDescription,
|
||||
apiPublishesWritablePanelDescription,
|
||||
useDefaultPanelDescription,
|
||||
usePanelDescription,
|
||||
type PublishesPanelDescription,
|
||||
type PublishesWritablePanelDescription,
|
||||
} from './interfaces/publishes_panel_description';
|
||||
export {
|
||||
apiPublishesPanelTitle,
|
||||
apiPublishesWritablePanelTitle,
|
||||
useDefaultPanelTitle,
|
||||
useHidePanelTitle,
|
||||
usePanelTitle,
|
||||
type PublishesPanelTitle,
|
||||
type PublishesWritablePanelTitle,
|
||||
} from './interfaces/publishes_panel_title';
|
||||
export {
|
||||
apiPublishesParentApi,
|
||||
useParentApi,
|
||||
type PublishesParentApi,
|
||||
} from './interfaces/publishes_parent_api';
|
||||
export {
|
||||
apiPublishesSavedObjectId,
|
||||
useSavedObjectId,
|
||||
type PublishesSavedObjectId,
|
||||
} from './interfaces/publishes_saved_object_id';
|
||||
export {
|
||||
apiPublishesViewMode,
|
||||
apiPublishesWritableViewMode,
|
||||
useViewMode,
|
||||
type PublishesViewMode,
|
||||
type PublishesWritableViewMode,
|
||||
type ViewMode,
|
||||
} from './interfaces/publishes_view_mode';
|
||||
export {
|
||||
useBatchedPublishingSubjects,
|
||||
useStateFromPublishingSubject,
|
||||
usePublishingSubject,
|
||||
type PublishingSubject,
|
||||
} from './publishing_subject';
|
||||
export { useApiPublisher } from './publishing_utils';
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { ErrorLike } from '@kbn/expressions-plugin/common';
|
||||
import { PublishingSubject } from '../publishing_subject';
|
||||
|
||||
/** ------------------------------------------------------------------------------------------
|
||||
* Performance Tracking Types
|
||||
* ------------------------------------------------------------------------------------------ */
|
||||
export type PhaseEventType = 'loading' | 'loaded' | 'rendered' | 'error';
|
||||
export interface PhaseEvent {
|
||||
id: string;
|
||||
status: PhaseEventType;
|
||||
error?: ErrorLike;
|
||||
timeToEvent: number;
|
||||
}
|
||||
|
||||
export interface FiresPhaseEvents {
|
||||
onPhaseChange: PublishingSubject<PhaseEvent>;
|
||||
}
|
||||
|
||||
export const apiFiresPhaseEvents = (unknownApi: null | unknown): unknownApi is FiresPhaseEvents => {
|
||||
return Boolean(unknownApi && (unknownApi as FiresPhaseEvents)?.onPhaseChange !== undefined);
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 { HasTypeDisplayName } from './has_type';
|
||||
|
||||
/**
|
||||
* An interface which determines whether or not a given API is editable.
|
||||
* In order to be editable, the api requires an edit function to execute the action
|
||||
* a getTypeDisplayName function to display to the user which type of chart is being
|
||||
* edited, and an isEditingEnabled function.
|
||||
*/
|
||||
export interface HasEditCapabilities extends HasTypeDisplayName {
|
||||
onEdit: () => void;
|
||||
isEditingEnabled: () => boolean;
|
||||
getEditHref?: () => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard which determines whether or not a given API is editable.
|
||||
*/
|
||||
export const hasEditCapabilities = (root: unknown): root is HasEditCapabilities => {
|
||||
return Boolean(
|
||||
root &&
|
||||
(root as HasEditCapabilities).onEdit &&
|
||||
typeof (root as HasEditCapabilities).onEdit === 'function' &&
|
||||
(root as HasEditCapabilities).getTypeDisplayName &&
|
||||
typeof (root as HasEditCapabilities).getTypeDisplayName === 'function' &&
|
||||
(root as HasEditCapabilities).isEditingEnabled &&
|
||||
typeof (root as HasEditCapabilities).isEditingEnabled === 'function'
|
||||
);
|
||||
};
|
|
@ -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.
|
||||
*/
|
||||
|
||||
export interface HasType<T extends string = string> {
|
||||
type: T;
|
||||
}
|
||||
|
||||
export interface HasTypeDisplayName {
|
||||
getTypeDisplayName: () => string;
|
||||
getTypeDisplayNameLowerCase?: () => string;
|
||||
}
|
||||
|
||||
export const apiHasType = (api: unknown | null): api is HasType => {
|
||||
return Boolean(api && (api as HasType).type);
|
||||
};
|
||||
|
||||
export const apiIsOfType = <T extends string = string>(
|
||||
api: unknown | null,
|
||||
typeToCheck: string
|
||||
): api is HasType<T> => {
|
||||
return Boolean(api && (api as HasType).type) && (api as HasType).type === typeToCheck;
|
||||
};
|
|
@ -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 { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesBlockingError {
|
||||
blockingError: PublishingSubject<Error | undefined>;
|
||||
}
|
||||
|
||||
export const apiPublishesBlockingError = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesBlockingError => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesBlockingError)?.blockingError !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets this API's fatal error as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useBlockingError = (api: Partial<PublishesBlockingError> | undefined) =>
|
||||
useStateFromPublishingSubject<Error | undefined, PublishesBlockingError['blockingError']>(
|
||||
api?.blockingError
|
||||
);
|
|
@ -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 { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesDataLoading {
|
||||
dataLoading: PublishingSubject<boolean | undefined>;
|
||||
}
|
||||
|
||||
export const apiPublishesDataLoading = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesDataLoading => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesDataLoading)?.dataLoading !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets this API's data loading state as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useDataLoading = (api: Partial<PublishesDataLoading> | undefined) =>
|
||||
useStateFromPublishingSubject<boolean | undefined, PublishesDataLoading['dataLoading']>(
|
||||
apiPublishesDataLoading(api) ? api.dataLoading : undefined
|
||||
);
|
|
@ -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 { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesDataViews {
|
||||
dataViews: PublishingSubject<DataView[] | undefined>;
|
||||
}
|
||||
|
||||
export const apiPublishesDataViews = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesDataViews => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesDataViews)?.dataViews !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets this API's data views as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useDataViews = (api: Partial<PublishesDataViews> | undefined) =>
|
||||
useStateFromPublishingSubject<DataView[] | undefined, PublishesDataViews['dataViews']>(
|
||||
apiPublishesDataViews(api) ? api.dataViews : undefined
|
||||
);
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesDisabledActionIds {
|
||||
disabledActionIds: PublishingSubject<string[] | undefined>;
|
||||
getAllTriggersDisabled?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard which checks whether or not a given API publishes Disabled Action IDs. This can be used
|
||||
* to programatically limit which actions are available on a per-API basis.
|
||||
*/
|
||||
export const apiPublishesDisabledActionIds = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesDisabledActionIds => {
|
||||
return Boolean(
|
||||
unknownApi && (unknownApi as PublishesDisabledActionIds)?.disabledActionIds !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets this API's disabled action IDs as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useDisabledActionIds = (api: Partial<PublishesDisabledActionIds> | undefined) =>
|
||||
useStateFromPublishingSubject<
|
||||
string[] | undefined,
|
||||
PublishesDisabledActionIds['disabledActionIds']
|
||||
>(api?.disabledActionIds);
|
|
@ -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 { TimeRange, Filter, Query, AggregateQuery } from '@kbn/es-query';
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesLocalUnifiedSearch {
|
||||
isCompatibleWithLocalUnifiedSearch?: () => boolean;
|
||||
localTimeRange: PublishingSubject<TimeRange | undefined>;
|
||||
getFallbackTimeRange?: () => TimeRange | undefined;
|
||||
localFilters: PublishingSubject<Filter[] | undefined>;
|
||||
localQuery: PublishingSubject<Query | AggregateQuery | undefined>;
|
||||
}
|
||||
|
||||
export type PublishesWritableLocalUnifiedSearch = PublishesLocalUnifiedSearch & {
|
||||
setLocalTimeRange: (timeRange: TimeRange | undefined) => void;
|
||||
setLocalFilters: (filters: Filter[] | undefined) => void;
|
||||
setLocalQuery: (query: Query | undefined) => void;
|
||||
};
|
||||
|
||||
export const apiPublishesLocalUnifiedSearch = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesLocalUnifiedSearch => {
|
||||
return Boolean(
|
||||
unknownApi &&
|
||||
(unknownApi as PublishesLocalUnifiedSearch)?.localTimeRange !== undefined &&
|
||||
(unknownApi as PublishesLocalUnifiedSearch)?.localFilters !== undefined &&
|
||||
(unknownApi as PublishesLocalUnifiedSearch)?.localQuery !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const apiPublishesPartialLocalUnifiedSearch = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is Partial<PublishesLocalUnifiedSearch> => {
|
||||
return Boolean(
|
||||
(unknownApi && (unknownApi as PublishesLocalUnifiedSearch)?.localTimeRange !== undefined) ||
|
||||
(unknownApi as PublishesLocalUnifiedSearch)?.localFilters !== undefined ||
|
||||
(unknownApi as PublishesLocalUnifiedSearch)?.localQuery !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const apiPublishesWritableLocalUnifiedSearch = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesWritableLocalUnifiedSearch => {
|
||||
return (
|
||||
apiPublishesLocalUnifiedSearch(unknownApi) &&
|
||||
(unknownApi as PublishesWritableLocalUnifiedSearch).setLocalTimeRange !== undefined &&
|
||||
typeof (unknownApi as PublishesWritableLocalUnifiedSearch).setLocalTimeRange === 'function' &&
|
||||
(unknownApi as PublishesWritableLocalUnifiedSearch).setLocalFilters !== undefined &&
|
||||
typeof (unknownApi as PublishesWritableLocalUnifiedSearch).setLocalFilters === 'function' &&
|
||||
(unknownApi as PublishesWritableLocalUnifiedSearch).setLocalQuery !== undefined &&
|
||||
typeof (unknownApi as PublishesWritableLocalUnifiedSearch).setLocalQuery === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that gets this API's local time range as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useLocalTimeRange = (api: Partial<PublishesLocalUnifiedSearch> | undefined) =>
|
||||
useStateFromPublishingSubject<TimeRange | undefined>(api?.localTimeRange);
|
||||
|
||||
/**
|
||||
* A hook that gets this API's local filters as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useLocalFilters = (api: Partial<PublishesLocalUnifiedSearch> | undefined) =>
|
||||
useStateFromPublishingSubject<Filter[] | undefined>(api?.localFilters);
|
||||
|
||||
/**
|
||||
* A hook that gets this API's local query as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useLocalQuery = (api: Partial<PublishesLocalUnifiedSearch> | undefined) =>
|
||||
useStateFromPublishingSubject<Query | AggregateQuery | undefined>(api?.localQuery);
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesPanelDescription {
|
||||
panelDescription: PublishingSubject<string | undefined>;
|
||||
defaultPanelDescription?: PublishingSubject<string | undefined>;
|
||||
}
|
||||
|
||||
export type PublishesWritablePanelDescription = PublishesPanelDescription & {
|
||||
setPanelDescription: (newTitle: string | undefined) => void;
|
||||
};
|
||||
|
||||
export const apiPublishesPanelDescription = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesPanelDescription => {
|
||||
return Boolean(
|
||||
unknownApi && (unknownApi as PublishesPanelDescription)?.panelDescription !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const apiPublishesWritablePanelDescription = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesWritablePanelDescription => {
|
||||
return (
|
||||
apiPublishesPanelDescription(unknownApi) &&
|
||||
(unknownApi as PublishesWritablePanelDescription).setPanelDescription !== undefined &&
|
||||
typeof (unknownApi as PublishesWritablePanelDescription).setPanelDescription === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that gets this API's panel description as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const usePanelDescription = (api: Partial<PublishesPanelDescription> | undefined) =>
|
||||
useStateFromPublishingSubject<string | undefined, PublishesPanelDescription['panelDescription']>(
|
||||
api?.panelDescription
|
||||
);
|
||||
|
||||
/**
|
||||
* A hook that gets this API's default panel description as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useDefaultPanelDescription = (api: Partial<PublishesPanelDescription> | undefined) =>
|
||||
useStateFromPublishingSubject<
|
||||
string | undefined,
|
||||
PublishesPanelDescription['defaultPanelDescription']
|
||||
>(api?.defaultPanelDescription);
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesPanelTitle {
|
||||
panelTitle: PublishingSubject<string | undefined>;
|
||||
hidePanelTitle: PublishingSubject<boolean | undefined>;
|
||||
defaultPanelTitle?: PublishingSubject<string | undefined>;
|
||||
}
|
||||
|
||||
export type PublishesWritablePanelTitle = PublishesPanelTitle & {
|
||||
setPanelTitle: (newTitle: string | undefined) => void;
|
||||
setHidePanelTitle: (hide: boolean | undefined) => void;
|
||||
setDefaultPanelTitle?: (newDefaultTitle: string | undefined) => void;
|
||||
};
|
||||
|
||||
export const apiPublishesPanelTitle = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesPanelTitle => {
|
||||
return Boolean(
|
||||
unknownApi &&
|
||||
(unknownApi as PublishesPanelTitle)?.panelTitle !== undefined &&
|
||||
(unknownApi as PublishesPanelTitle)?.hidePanelTitle !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const apiPublishesWritablePanelTitle = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesWritablePanelTitle => {
|
||||
return (
|
||||
apiPublishesPanelTitle(unknownApi) &&
|
||||
(unknownApi as PublishesWritablePanelTitle).setPanelTitle !== undefined &&
|
||||
(typeof (unknownApi as PublishesWritablePanelTitle).setPanelTitle === 'function' &&
|
||||
(unknownApi as PublishesWritablePanelTitle).setHidePanelTitle) !== undefined &&
|
||||
typeof (unknownApi as PublishesWritablePanelTitle).setHidePanelTitle === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that gets this API's panel title as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const usePanelTitle = (api: Partial<PublishesPanelTitle> | undefined) =>
|
||||
useStateFromPublishingSubject<string | undefined>(api?.panelTitle);
|
||||
|
||||
/**
|
||||
* A hook that gets this API's hide panel title setting as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useHidePanelTitle = (api: Partial<PublishesPanelTitle> | undefined) =>
|
||||
useStateFromPublishingSubject<boolean | undefined>(api?.hidePanelTitle);
|
||||
|
||||
/**
|
||||
* A hook that gets this API's default title as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useDefaultPanelTitle = (api: Partial<PublishesPanelTitle> | undefined) =>
|
||||
useStateFromPublishingSubject<string | undefined>(api?.defaultPanelTitle);
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesParentApi<ParentApiType extends unknown = unknown> {
|
||||
parentApi: PublishingSubject<ParentApiType>;
|
||||
}
|
||||
|
||||
type UnwrapParent<ApiType extends unknown> = ApiType extends PublishesParentApi<infer ParentType>
|
||||
? ParentType
|
||||
: unknown;
|
||||
|
||||
/**
|
||||
* A type guard which checks whether or not a given API publishes its parent API.
|
||||
*/
|
||||
export const apiPublishesParentApi = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesParentApi => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesParentApi)?.parentApi !== undefined);
|
||||
};
|
||||
|
||||
export const useParentApi = <
|
||||
ApiType extends Partial<PublishesParentApi> = Partial<PublishesParentApi>
|
||||
>(
|
||||
api: ApiType
|
||||
): UnwrapParent<ApiType> =>
|
||||
useStateFromPublishingSubject<unknown, ApiType['parentApi']>(
|
||||
apiPublishesParentApi(api) ? api.parentApi : undefined
|
||||
) as UnwrapParent<ApiType>;
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
/**
|
||||
* This API publishes a saved object id which can be used to determine which saved object this API is linked to.
|
||||
*/
|
||||
export interface PublishesSavedObjectId {
|
||||
savedObjectId: PublishingSubject<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type guard which can be used to determine if a given API publishes a saved object id.
|
||||
*/
|
||||
export const apiPublishesSavedObjectId = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesSavedObjectId => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesSavedObjectId)?.savedObjectId !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that gets this API's saved object ID as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useSavedObjectId = (api: PublishesSavedObjectId | undefined) =>
|
||||
useStateFromPublishingSubject<string | undefined>(api?.savedObjectId);
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export interface PublishesUniqueId {
|
||||
uuid: PublishingSubject<string>;
|
||||
}
|
||||
|
||||
export const apiPublishesUniqueId = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesUniqueId => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesUniqueId)?.uuid !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets this API's UUID as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useUniqueId = <
|
||||
ApiType extends Partial<PublishesUniqueId> = Partial<PublishesUniqueId>
|
||||
>(
|
||||
api: ApiType
|
||||
) =>
|
||||
useStateFromPublishingSubject<string, ApiType['uuid']>(
|
||||
apiPublishesUniqueId(api) ? api.uuid : undefined
|
||||
);
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PublishingSubject, useStateFromPublishingSubject } from '../publishing_subject';
|
||||
|
||||
export type ViewMode = 'view' | 'edit' | 'print' | 'preview';
|
||||
|
||||
/**
|
||||
* This API publishes a universal view mode which can change compatibility of actions and the
|
||||
* visibility of components.
|
||||
*/
|
||||
export interface PublishesViewMode {
|
||||
viewMode: PublishingSubject<ViewMode>;
|
||||
}
|
||||
|
||||
export type PublishesWritableViewMode = PublishesViewMode & {
|
||||
setViewMode: (viewMode: ViewMode) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A type guard which can be used to determine if a given API publishes a view mode.
|
||||
*/
|
||||
export const apiPublishesViewMode = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesViewMode => {
|
||||
return Boolean(unknownApi && (unknownApi as PublishesViewMode)?.viewMode !== undefined);
|
||||
};
|
||||
|
||||
export const apiPublishesWritableViewMode = (
|
||||
unknownApi: null | unknown
|
||||
): unknownApi is PublishesWritableViewMode => {
|
||||
return (
|
||||
apiPublishesViewMode(unknownApi) &&
|
||||
(unknownApi as PublishesWritableViewMode).setViewMode !== undefined &&
|
||||
typeof (unknownApi as PublishesWritableViewMode).setViewMode === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that gets this API's view mode as a reactive variable which will cause re-renders on change.
|
||||
*/
|
||||
export const useViewMode = <
|
||||
ApiType extends Partial<PublishesViewMode> = Partial<PublishesViewMode>
|
||||
>(
|
||||
api: ApiType | undefined
|
||||
) => useStateFromPublishingSubject<ViewMode, ApiType['viewMode']>(api?.viewMode);
|
13
packages/presentation/presentation_publishing/jest.config.js
Normal file
13
packages/presentation/presentation_publishing/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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>/packages/presentation/presentation_publishing'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/presentation-publishing",
|
||||
"owner": "@elastic/kibana-presentation"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/presentation-publishing",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
||||
export { useBatchedPublishingSubjects } from './publishing_batcher';
|
||||
export {
|
||||
useStateFromPublishingSubject,
|
||||
usePublishingSubject,
|
||||
type PublishingSubject,
|
||||
} from './publishing_subject';
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { debounceTime, filter } from 'rxjs/operators';
|
||||
import { PublishingSubject } from './publishing_subject';
|
||||
|
||||
// Usage of any required here. We want to subscribe to the subject no matter the type.
|
||||
type AnyValue = any;
|
||||
type AnyPublishingSubject = PublishingSubject<AnyValue>;
|
||||
|
||||
interface PublishingSubjectCollection {
|
||||
[key: string]: AnyPublishingSubject | undefined;
|
||||
}
|
||||
|
||||
interface RequiredPublishingSubjectCollection {
|
||||
[key: string]: AnyPublishingSubject;
|
||||
}
|
||||
|
||||
type PublishingSubjectBatchResult<SubjectsType extends PublishingSubjectCollection> = {
|
||||
[SubjectKey in keyof SubjectsType]?: SubjectsType[SubjectKey] extends
|
||||
| PublishingSubject<infer ValueType>
|
||||
| undefined
|
||||
? ValueType
|
||||
: never;
|
||||
};
|
||||
|
||||
const hasSubjectsObjectChanged = (
|
||||
subjectsA: PublishingSubjectCollection,
|
||||
subjectsB: PublishingSubjectCollection
|
||||
) => {
|
||||
const subjectKeysA = Object.keys(subjectsA);
|
||||
const subjectKeysB = Object.keys(subjectsB);
|
||||
if (subjectKeysA.length !== subjectKeysB.length) return true;
|
||||
|
||||
for (const key of subjectKeysA) {
|
||||
if (Boolean(subjectsA[key]) !== Boolean(subjectsB[key])) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Batches the latest values of multiple publishing subjects into a single object. Use this to avoid unnecessary re-renders.
|
||||
* You should avoid using this hook with subjects that your component pushes values to on user interaction, as it can cause a slight delay.
|
||||
*/
|
||||
export const useBatchedPublishingSubjects = <SubjectsType extends PublishingSubjectCollection>(
|
||||
subjects: SubjectsType
|
||||
): PublishingSubjectBatchResult<SubjectsType> => {
|
||||
/**
|
||||
* memoize and deep diff subjects to avoid rebuilding the subscription when the subjects are the same.
|
||||
*/
|
||||
const previousSubjects = useRef<SubjectsType | null>(null);
|
||||
|
||||
const subjectsToUse = useMemo(() => {
|
||||
if (!previousSubjects.current && !Object.values(subjects).some((subject) => Boolean(subject))) {
|
||||
// if the previous subjects were null and none of the new subjects are defined, return null to avoid building the subscription.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasSubjectsObjectChanged(previousSubjects.current ?? {}, subjects)) {
|
||||
return previousSubjects.current;
|
||||
}
|
||||
previousSubjects.current = subjects;
|
||||
return subjects;
|
||||
}, [subjects]);
|
||||
|
||||
/**
|
||||
* Extract only defined subjects from any subjects passed in.
|
||||
*/
|
||||
const { definedKeys, definedSubjects } = useMemo(() => {
|
||||
if (!subjectsToUse) return {};
|
||||
const definedSubjectsMap: RequiredPublishingSubjectCollection =
|
||||
Object.keys(subjectsToUse).reduce((acc, key) => {
|
||||
if (Boolean(subjectsToUse[key])) acc[key] = subjectsToUse[key] as AnyPublishingSubject;
|
||||
return acc;
|
||||
}, {} as RequiredPublishingSubjectCollection) ?? {};
|
||||
|
||||
return {
|
||||
definedKeys: Object.keys(definedSubjectsMap ?? {}) as Array<keyof SubjectsType>,
|
||||
definedSubjects: Object.values(definedSubjectsMap) ?? [],
|
||||
};
|
||||
}, [subjectsToUse]);
|
||||
|
||||
const [latestPublishedValues, setLatestPublishedValues] = useState<
|
||||
PublishingSubjectBatchResult<SubjectsType>
|
||||
>(() => {
|
||||
if (!definedKeys?.length || !definedSubjects?.length) return {};
|
||||
const nextResult: PublishingSubjectBatchResult<SubjectsType> = {};
|
||||
for (let keyIndex = 0; keyIndex < definedKeys.length; keyIndex++) {
|
||||
nextResult[definedKeys[keyIndex]] = definedSubjects[keyIndex].value ?? undefined;
|
||||
}
|
||||
return nextResult;
|
||||
});
|
||||
|
||||
/**
|
||||
* Subscribe to all subjects and update the latest values when any of them change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!definedSubjects?.length || !definedKeys?.length) return;
|
||||
const subscription = combineLatest(definedSubjects)
|
||||
.pipe(
|
||||
// debounce latest state for 0ms to flush all in-flight changes
|
||||
debounceTime(0),
|
||||
filter((changes) => changes.length > 0)
|
||||
)
|
||||
.subscribe((latestValues) => {
|
||||
const nextResult: PublishingSubjectBatchResult<SubjectsType> = {};
|
||||
for (let keyIndex = 0; keyIndex < definedKeys.length; keyIndex++) {
|
||||
nextResult[definedKeys[keyIndex]] = latestValues[keyIndex] ?? undefined;
|
||||
}
|
||||
setLatestPublishedValues(nextResult);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [definedKeys, definedSubjects]);
|
||||
|
||||
return latestPublishedValues;
|
||||
};
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* 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 { BehaviorSubject } from 'rxjs';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useBatchedPublishingSubjects } from './publishing_batcher';
|
||||
import { useStateFromPublishingSubject } from './publishing_subject';
|
||||
|
||||
describe('useBatchedPublishingSubjects', () => {
|
||||
let subject1: BehaviorSubject<number>;
|
||||
let subject2: BehaviorSubject<number>;
|
||||
let subject3: BehaviorSubject<number>;
|
||||
let subject4: BehaviorSubject<number>;
|
||||
let subject5: BehaviorSubject<number>;
|
||||
let subject6: BehaviorSubject<number>;
|
||||
beforeEach(() => {
|
||||
subject1 = new BehaviorSubject<number>(0);
|
||||
subject2 = new BehaviorSubject<number>(0);
|
||||
subject3 = new BehaviorSubject<number>(0);
|
||||
subject4 = new BehaviorSubject<number>(0);
|
||||
subject5 = new BehaviorSubject<number>(0);
|
||||
subject6 = new BehaviorSubject<number>(0);
|
||||
});
|
||||
|
||||
function incrementAll() {
|
||||
subject1.next(subject1.getValue() + 1);
|
||||
subject2.next(subject2.getValue() + 1);
|
||||
subject3.next(subject3.getValue() + 1);
|
||||
subject4.next(subject4.getValue() + 1);
|
||||
subject5.next(subject5.getValue() + 1);
|
||||
subject6.next(subject6.getValue() + 1);
|
||||
}
|
||||
|
||||
test('should render once when all state changes are in click handler (react batch)', async () => {
|
||||
let renderCount = 0;
|
||||
function Component() {
|
||||
const value1 = useStateFromPublishingSubject<number>(subject1);
|
||||
const value2 = useStateFromPublishingSubject<number>(subject2);
|
||||
const value3 = useStateFromPublishingSubject<number>(subject3);
|
||||
const value4 = useStateFromPublishingSubject<number>(subject4);
|
||||
const value5 = useStateFromPublishingSubject<number>(subject5);
|
||||
const value6 = useStateFromPublishingSubject<number>(subject6);
|
||||
|
||||
renderCount++;
|
||||
return (
|
||||
<>
|
||||
<button onClick={incrementAll} />
|
||||
<span>{`value1: ${value1}, value2: ${value2}, value3: ${value3}, value4: ${value4}, value5: ${value5}, value6: ${value6}`}</span>
|
||||
<div data-test-subj="renderCount">{renderCount}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Component />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('value1: 0, value2: 0, value3: 0, value4: 0, value5: 0, value6: 0')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
userEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('value1: 1, value2: 1, value3: 1, value4: 1, value5: 1, value6: 1')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('renderCount')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
test('should batch state updates when using useBatchedPublishingSubjects', async () => {
|
||||
let renderCount = 0;
|
||||
function Component() {
|
||||
const { value1, value2, value3, value4, value5, value6 } = useBatchedPublishingSubjects({
|
||||
value1: subject1,
|
||||
value2: subject2,
|
||||
value3: subject3,
|
||||
value4: subject4,
|
||||
value5: subject5,
|
||||
value6: subject6,
|
||||
});
|
||||
|
||||
renderCount++;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
// using setTimeout to move next calls outside of callstack from onClick
|
||||
setTimeout(incrementAll, 0);
|
||||
}}
|
||||
/>
|
||||
<span>{`value1: ${value1}, value2: ${value2}, value3: ${value3}, value4: ${value4}, value5: ${value5}, value6: ${value6}`}</span>
|
||||
<div data-test-subj="renderCount">{renderCount}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Component />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('value1: 0, value2: 0, value3: 0, value4: 0, value5: 0, value6: 0')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
userEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('value1: 1, value2: 1, value3: 1, value4: 1, value5: 1, value6: 1')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('renderCount')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
test('should render for each state update outside of click handler', async () => {
|
||||
let renderCount = 0;
|
||||
function Component() {
|
||||
const value1 = useStateFromPublishingSubject<number>(subject1);
|
||||
const value2 = useStateFromPublishingSubject<number>(subject2);
|
||||
const value3 = useStateFromPublishingSubject<number>(subject3);
|
||||
const value4 = useStateFromPublishingSubject<number>(subject4);
|
||||
const value5 = useStateFromPublishingSubject<number>(subject5);
|
||||
const value6 = useStateFromPublishingSubject<number>(subject6);
|
||||
|
||||
renderCount++;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
// using setTimeout to move next calls outside of callstack from onClick
|
||||
setTimeout(incrementAll, 0);
|
||||
}}
|
||||
/>
|
||||
<span>{`value1: ${value1}, value2: ${value2}, value3: ${value3}, value4: ${value4}, value5: ${value5}, value6: ${value6}`}</span>
|
||||
<div data-test-subj="renderCount">{renderCount}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Component />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('value1: 0, value2: 0, value3: 0, value4: 0, value5: 0, value6: 0')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
userEvent.click(screen.getByRole('button'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('value1: 1, value2: 1, value3: 1, value4: 1, value5: 1, value6: 1')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('renderCount')).toHaveTextContent('7');
|
||||
});
|
||||
});
|
|
@ -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 { useEffect, useMemo, useState } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* A publishing subject is a RxJS subject that can be used to listen to value changes, but does not allow pushing values via the Next method.
|
||||
*/
|
||||
export type PublishingSubject<T extends unknown = unknown> = Omit<BehaviorSubject<T>, 'next'>;
|
||||
|
||||
/**
|
||||
* A utility type that makes a type optional if another passed in type is optional.
|
||||
*/
|
||||
type OptionalIfOptional<TestType, Type> = undefined extends TestType ? Type | undefined : Type;
|
||||
|
||||
/**
|
||||
* Declares a publishing subject, allowing external code to subscribe to react state changes.
|
||||
* Changes to state fire subject.next
|
||||
* @param state React state from useState hook.
|
||||
*/
|
||||
export const usePublishingSubject = <T extends unknown = unknown>(
|
||||
state: T
|
||||
): PublishingSubject<T> => {
|
||||
const subject = useMemo<BehaviorSubject<T>>(
|
||||
() => new BehaviorSubject<T>(state),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
useEffect(() => subject.next(state), [subject, state]);
|
||||
return subject;
|
||||
};
|
||||
|
||||
/**
|
||||
* Declares a state variable that is synced with a publishing subject value.
|
||||
* @param subject Publishing subject.
|
||||
*/
|
||||
export const useStateFromPublishingSubject = <
|
||||
ValueType extends unknown = unknown,
|
||||
SubjectType extends PublishingSubject<ValueType> | undefined =
|
||||
| PublishingSubject<ValueType>
|
||||
| undefined
|
||||
>(
|
||||
subject?: SubjectType
|
||||
): OptionalIfOptional<SubjectType, ValueType> => {
|
||||
const [value, setValue] = useState<ValueType | undefined>(subject?.getValue());
|
||||
useEffect(() => {
|
||||
if (!subject) return;
|
||||
const subscription = subject.subscribe((newValue) => setValue(newValue));
|
||||
return () => subscription.unsubscribe();
|
||||
}, [subject]);
|
||||
return value as OptionalIfOptional<SubjectType, ValueType>;
|
||||
};
|
|
@ -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 { useImperativeHandle, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Publishes any API to the passed in ref. Note that any API passed in will not be rebuilt on
|
||||
* subsequent renders, so it does not support reactive variables. Instead, pass in setter functions
|
||||
* and publishing subjects to allow other components to listen to changes.
|
||||
*/
|
||||
export const useApiPublisher = <ApiType extends unknown = unknown>(
|
||||
api: ApiType,
|
||||
ref: React.ForwardedRef<ApiType>
|
||||
) => {
|
||||
const publishApi = useMemo(
|
||||
() => api,
|
||||
// disabling exhaustive deps because the API should be created once and never change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
useImperativeHandle(ref, () => publishApi);
|
||||
};
|
14
packages/presentation/presentation_publishing/tsconfig.json
Normal file
14
packages/presentation/presentation_publishing/tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": ["jest", "node", "react"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/es-query",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/expressions-plugin",
|
||||
]
|
||||
}
|
|
@ -36,6 +36,12 @@
|
|||
"serverless",
|
||||
"noDataPage"
|
||||
],
|
||||
"requiredBundles": ["kibanaReact", "kibanaUtils", "presentationUtil", "savedObjects"]
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"kibanaUtils",
|
||||
"presentationUtil",
|
||||
"presentationPanel",
|
||||
"savedObjects"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,11 @@ export const dashboardAddToLibraryActionStrings = {
|
|||
defaultMessage: `Panel {panelTitle} was added to the library`,
|
||||
values: { panelTitle },
|
||||
}),
|
||||
getErrorMessage: (panelTitle?: string) =>
|
||||
i18n.translate('dashboard.panel.addToLibrary.errorMessage', {
|
||||
defaultMessage: `An error was encountered adding panel {panelTitle} to the library`,
|
||||
values: { panelTitle },
|
||||
}),
|
||||
};
|
||||
|
||||
export const dashboardClonePanelActionStrings = {
|
||||
|
@ -87,7 +92,12 @@ export const dashboardUnlinkFromLibraryActionStrings = {
|
|||
}),
|
||||
getSuccessMessage: (panelTitle: string) =>
|
||||
i18n.translate('dashboard.panel.unlinkFromLibrary.successMessage', {
|
||||
defaultMessage: `Panel {panelTitle} is no longer connected to the library`,
|
||||
defaultMessage: `Panel {panelTitle} is no longer connected to the library.`,
|
||||
values: { panelTitle },
|
||||
}),
|
||||
getFailureMessage: (panelTitle: string) =>
|
||||
i18n.translate('dashboard.panel.unlinkFromLibrary.failureMessage', {
|
||||
defaultMessage: `An error occured while unlinking {panelTitle} from the library.`,
|
||||
values: { panelTitle },
|
||||
}),
|
||||
};
|
||||
|
@ -113,6 +123,13 @@ export const dashboardReplacePanelActionStrings = {
|
|||
i18n.translate('dashboard.panel.removePanel.replacePanel', {
|
||||
defaultMessage: 'Replace panel',
|
||||
}),
|
||||
getFlyoutHeader: (panelName?: string) =>
|
||||
i18n.translate('dashboard.panel.replacePanel.flyoutHeader', {
|
||||
defaultMessage: 'Replace panel {panelName} with:',
|
||||
values: {
|
||||
panelName: `'${panelName}'`,
|
||||
},
|
||||
}),
|
||||
getSuccessMessage: (savedObjectName?: string) =>
|
||||
savedObjectName
|
||||
? i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', {
|
||||
|
|
|
@ -5,182 +5,69 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import {
|
||||
EmbeddableInput,
|
||||
ErrorEmbeddable,
|
||||
IContainer,
|
||||
isErrorEmbeddable,
|
||||
ReferenceOrValueEmbeddable,
|
||||
ViewMode,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
import { type Query, type AggregateQuery, Filter } from '@kbn/es-query';
|
||||
|
||||
import { buildMockDashboard } from '../mocks';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { AddToLibraryAction } from './add_to_library_action';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
import { AddToLibraryAction, AddPanelToLibraryActionApi } from './add_to_library_action';
|
||||
|
||||
const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(embeddableFactory);
|
||||
let container: DashboardContainer;
|
||||
let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
|
||||
describe('Add to library action', () => {
|
||||
let action: AddToLibraryAction;
|
||||
let context: { embeddable: AddPanelToLibraryActionApi };
|
||||
|
||||
const defaultCapabilities = {
|
||||
advancedSettings: {},
|
||||
visualize: { save: true },
|
||||
maps: { save: true },
|
||||
navLinks: {},
|
||||
};
|
||||
beforeEach(() => {
|
||||
action = new AddToLibraryAction();
|
||||
context = {
|
||||
embeddable: {
|
||||
linkToLibrary: jest.fn(),
|
||||
canLinkToLibrary: jest.fn().mockResolvedValue(true),
|
||||
|
||||
Object.defineProperty(pluginServices.getServices().application, 'capabilities', {
|
||||
value: defaultCapabilities,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
pluginServices.getServices().application.capabilities = defaultCapabilities;
|
||||
|
||||
container = buildMockDashboard();
|
||||
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibanana',
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
panelTitle: new BehaviorSubject<string | undefined>('A very compatible API'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
} else {
|
||||
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput
|
||||
>(contactCardEmbeddable, {
|
||||
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id },
|
||||
mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id },
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('is incompatible when context lacks necessary functions', async () => {
|
||||
const emptyContext = {
|
||||
embeddable: {},
|
||||
};
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('is incompatible when view mode is view', async () => {
|
||||
context.embeddable.viewMode = new BehaviorSubject<ViewMode>('view');
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('is incompatible when canLinkToLibrary returns false', async () => {
|
||||
context.embeddable.canLinkToLibrary = jest.fn().mockResolvedValue(false);
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('calls the linkToLibrary method on execute', async () => {
|
||||
action.execute(context);
|
||||
expect(context.embeddable.linkToLibrary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a toast with a title from the API when successful', async () => {
|
||||
await action.execute(context);
|
||||
expect(pluginServices.getServices().notifications.toasts.addSuccess).toHaveBeenCalledWith({
|
||||
'data-test-subj': 'addPanelToLibrarySuccess',
|
||||
title: "Panel 'A very compatible API' was added to the library",
|
||||
});
|
||||
embeddable.updateInput({ viewMode: ViewMode.EDIT });
|
||||
}
|
||||
});
|
||||
|
||||
test('Add to library is incompatible with Error Embeddables', async () => {
|
||||
const action = new AddToLibraryAction();
|
||||
const errorEmbeddable = new ErrorEmbeddable(
|
||||
'Wow what an awful error',
|
||||
{ id: ' 404' },
|
||||
embeddable.getRoot() as IContainer
|
||||
);
|
||||
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Add to library is incompatible with ES|QL Embeddables', async () => {
|
||||
const action = new AddToLibraryAction();
|
||||
const mockGetFilters = jest.fn(async () => [] as Filter[]);
|
||||
const mockGetQuery = jest.fn(async () => undefined as Query | AggregateQuery | undefined);
|
||||
const filterableEmbeddable = embeddablePluginMock.mockFilterableEmbeddable(embeddable, {
|
||||
getFilters: () => mockGetFilters(),
|
||||
getQuery: () => mockGetQuery(),
|
||||
});
|
||||
mockGetQuery.mockResolvedValue({ esql: 'from logstash-* | limit 10' } as AggregateQuery);
|
||||
expect(await action.isCompatible({ embeddable: filterableEmbeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Add to library is incompatible on visualize embeddable without visualize save permissions', async () => {
|
||||
pluginServices.getServices().application.capabilities = {
|
||||
...defaultCapabilities,
|
||||
visualize: { save: false },
|
||||
};
|
||||
const action = new AddToLibraryAction();
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Add to library is compatible when embeddable on dashboard has value type input', async () => {
|
||||
const action = new AddToLibraryAction();
|
||||
embeddable.updateInput(await embeddable.getInputAsValueType());
|
||||
expect(await action.isCompatible({ embeddable })).toBe(true);
|
||||
});
|
||||
|
||||
test('Add to library is not compatible when embeddable input is by reference', async () => {
|
||||
const action = new AddToLibraryAction();
|
||||
embeddable.updateInput(await embeddable.getInputAsRefType());
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Add to library is not compatible when view mode is set to view', async () => {
|
||||
const action = new AddToLibraryAction();
|
||||
embeddable.updateInput(await embeddable.getInputAsRefType());
|
||||
embeddable.updateInput({ viewMode: ViewMode.VIEW });
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Add to library is not compatible when embeddable is not in a dashboard container', async () => {
|
||||
let orphanContactCard = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Orphan',
|
||||
it('shows a danger toast when the link operation is unsuccessful', async () => {
|
||||
context.embeddable.linkToLibrary = jest.fn().mockRejectedValue(new Error('Oh dang'));
|
||||
await action.execute(context);
|
||||
expect(pluginServices.getServices().notifications.toasts.addDanger).toHaveBeenCalledWith({
|
||||
'data-test-subj': 'addPanelToLibraryError',
|
||||
title: 'An error was encountered adding panel A very compatible API to the library',
|
||||
});
|
||||
});
|
||||
orphanContactCard = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput
|
||||
>(orphanContactCard, {
|
||||
mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id },
|
||||
mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id },
|
||||
});
|
||||
const action = new AddToLibraryAction();
|
||||
expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false);
|
||||
});
|
||||
|
||||
test('Add to library replaces embeddableId and retains panel count', async () => {
|
||||
const dashboard = embeddable.getRoot() as IContainer;
|
||||
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
|
||||
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
|
||||
|
||||
const action = new AddToLibraryAction();
|
||||
await action.execute({ embeddable });
|
||||
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount);
|
||||
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
expect(newPanelId).toBeDefined();
|
||||
const newPanel = container.getInput().panels[newPanelId!];
|
||||
expect(newPanel.type).toEqual(embeddable.type);
|
||||
});
|
||||
|
||||
test('Add to library returns reference type input', async () => {
|
||||
const complicatedAttributes = {
|
||||
attribute1: 'The best attribute',
|
||||
attribute2: 22,
|
||||
attribute3: ['array', 'of', 'strings'],
|
||||
attribute4: { nestedattribute: 'hello from the nest' },
|
||||
};
|
||||
|
||||
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<ContactCardEmbeddable>(embeddable, {
|
||||
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id },
|
||||
mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id } as EmbeddableInput,
|
||||
});
|
||||
const dashboard = embeddable.getRoot() as IContainer;
|
||||
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
|
||||
const action = new AddToLibraryAction();
|
||||
await action.execute({ embeddable });
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
expect(newPanelId).toBeDefined();
|
||||
const newPanel = container.getInput().panels[newPanelId!];
|
||||
expect(newPanel.type).toEqual(embeddable.type);
|
||||
expect((newPanel.explicitInput as unknown as { attributes: unknown }).attributes).toBeUndefined();
|
||||
expect(newPanel.explicitInput.savedObjectId).toBe('testSavedObjectId');
|
||||
});
|
||||
|
|
|
@ -6,110 +6,70 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { apiCanLinkToLibrary, CanLinkToLibrary } from '@kbn/presentation-library';
|
||||
import {
|
||||
ViewMode,
|
||||
type PanelState,
|
||||
type IEmbeddable,
|
||||
isErrorEmbeddable,
|
||||
PanelNotFoundError,
|
||||
type EmbeddableInput,
|
||||
isReferenceOrValueEmbeddable,
|
||||
isFilterableEmbeddable,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
apiPublishesViewMode,
|
||||
EmbeddableApiContext,
|
||||
PublishesPanelTitle,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
import { type AggregateQuery } from '@kbn/es-query';
|
||||
import { DashboardPanelState } from '../../common';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { dashboardAddToLibraryActionStrings } from './_dashboard_actions_strings';
|
||||
import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container';
|
||||
|
||||
export const ACTION_ADD_TO_LIBRARY = 'saveToLibrary';
|
||||
|
||||
export interface AddToLibraryActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
export type AddPanelToLibraryActionApi = PublishesViewMode &
|
||||
CanLinkToLibrary &
|
||||
Partial<PublishesPanelTitle>;
|
||||
|
||||
export class AddToLibraryAction implements Action<AddToLibraryActionContext> {
|
||||
const isApiCompatible = (api: unknown | null): api is AddPanelToLibraryActionApi =>
|
||||
Boolean(apiPublishesViewMode(api) && apiCanLinkToLibrary(api));
|
||||
|
||||
export class AddToLibraryAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_ADD_TO_LIBRARY;
|
||||
public readonly id = ACTION_ADD_TO_LIBRARY;
|
||||
public order = 15;
|
||||
|
||||
private applicationCapabilities;
|
||||
private toastsService;
|
||||
|
||||
constructor() {
|
||||
({
|
||||
application: { capabilities: this.applicationCapabilities },
|
||||
notifications: { toasts: this.toastsService },
|
||||
} = pluginServices.getServices());
|
||||
}
|
||||
|
||||
public getDisplayName({ embeddable }: AddToLibraryActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return dashboardAddToLibraryActionStrings.getDisplayName();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: AddToLibraryActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'folderCheck';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: AddToLibraryActionContext) {
|
||||
// TODO: Fix this, potentially by adding a 'canSave' function to embeddable interface
|
||||
const { maps, visualize } = this.applicationCapabilities;
|
||||
const canSave = embeddable.type === 'map' ? maps.save : visualize.save;
|
||||
const { isOfAggregateQueryType } = await import('@kbn/es-query');
|
||||
const query = isFilterableEmbeddable(embeddable) && (await embeddable.getQuery());
|
||||
// Textbased panels (i.e. ES|QL, SQL) should not save to library
|
||||
const isTextBasedEmbeddable = isOfAggregateQueryType(query as AggregateQuery);
|
||||
return Boolean(
|
||||
canSave &&
|
||||
!isErrorEmbeddable(embeddable) &&
|
||||
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
|
||||
embeddable.getRoot() &&
|
||||
embeddable.getRoot().isContainer &&
|
||||
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE &&
|
||||
isReferenceOrValueEmbeddable(embeddable) &&
|
||||
!embeddable.inputIsRefType(embeddable.getInput()) &&
|
||||
!isTextBasedEmbeddable
|
||||
);
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) return false;
|
||||
return embeddable.viewMode.value === 'edit' && (await embeddable.canLinkToLibrary());
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: AddToLibraryActionContext) {
|
||||
if (!isReferenceOrValueEmbeddable(embeddable)) {
|
||||
throw new IncompatibleActionError();
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
const panelTitle = embeddable.panelTitle?.value ?? embeddable.defaultPanelTitle?.value;
|
||||
try {
|
||||
await embeddable.linkToLibrary();
|
||||
this.toastsService.addSuccess({
|
||||
title: dashboardAddToLibraryActionStrings.getSuccessMessage(
|
||||
panelTitle ? `'${panelTitle}'` : ''
|
||||
),
|
||||
'data-test-subj': 'addPanelToLibrarySuccess',
|
||||
});
|
||||
} catch (e) {
|
||||
this.toastsService.addDanger({
|
||||
title: dashboardAddToLibraryActionStrings.getErrorMessage(panelTitle),
|
||||
'data-test-subj': 'addPanelToLibraryError',
|
||||
});
|
||||
}
|
||||
const newInput = await embeddable.getInputAsRefType();
|
||||
|
||||
embeddable.updateInput(newInput);
|
||||
|
||||
const dashboard = embeddable.getRoot() as DashboardContainer;
|
||||
const panelToReplace = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
|
||||
if (!panelToReplace) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
|
||||
const newPanel: PanelState<EmbeddableInput> = {
|
||||
type: embeddable.type,
|
||||
explicitInput: { ...newInput },
|
||||
};
|
||||
const replacedPanelId = await dashboard.replacePanel(panelToReplace, newPanel, true);
|
||||
|
||||
const title = dashboardAddToLibraryActionStrings.getSuccessMessage(
|
||||
embeddable.getTitle() ? `'${embeddable.getTitle()}'` : ''
|
||||
);
|
||||
|
||||
if (dashboard.getExpandedPanelId() !== undefined) {
|
||||
dashboard.setExpandedPanelId(replacedPanelId);
|
||||
}
|
||||
|
||||
this.toastsService.addSuccess({
|
||||
title,
|
||||
'data-test-subj': 'addPanelToLibrarySuccess',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,202 +6,46 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
import {
|
||||
ErrorEmbeddable,
|
||||
IContainer,
|
||||
isErrorEmbeddable,
|
||||
ReferenceOrValueEmbeddable,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { CanDuplicatePanels } from '@kbn/presentation-containers';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ClonePanelAction, ClonePanelActionApi } from './clone_panel_action';
|
||||
|
||||
import { ClonePanelAction } from './clone_panel_action';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
describe('Clone panel action', () => {
|
||||
let action: ClonePanelAction;
|
||||
let context: { embeddable: ClonePanelActionApi };
|
||||
|
||||
let container: DashboardContainer;
|
||||
let genericEmbeddable: ContactCardEmbeddable;
|
||||
let byRefOrValEmbeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
|
||||
let coreStart: CoreStart;
|
||||
beforeEach(async () => {
|
||||
coreStart = coreMock.createStart();
|
||||
coreStart.savedObjects.client = {
|
||||
...coreStart.savedObjects.client,
|
||||
get: jest.fn().mockImplementation(() => ({ attributes: { title: 'Holy moly' } })),
|
||||
find: jest.fn().mockImplementation(() => ({ total: 15 })),
|
||||
create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })),
|
||||
};
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
container = buildMockDashboard({
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Kibanana', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
beforeEach(() => {
|
||||
action = new ClonePanelAction();
|
||||
context = {
|
||||
embeddable: {
|
||||
uuid: new BehaviorSubject<string>('superId'),
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
parentApi: new BehaviorSubject<CanDuplicatePanels>({
|
||||
duplicatePanel: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const refOrValContactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'RefOrValEmbeddable',
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
const nonRefOrValueContactCard = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Not a refOrValEmbeddable',
|
||||
it('is incompatible when context lacks necessary functions', async () => {
|
||||
const emptyContext = {
|
||||
embeddable: {},
|
||||
};
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
if (
|
||||
isErrorEmbeddable(refOrValContactCardEmbeddable) ||
|
||||
isErrorEmbeddable(nonRefOrValueContactCard)
|
||||
) {
|
||||
throw new Error('Failed to create embeddables');
|
||||
} else {
|
||||
genericEmbeddable = nonRefOrValueContactCard;
|
||||
byRefOrValEmbeddable = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput
|
||||
>(refOrValContactCardEmbeddable, {
|
||||
mockedByReferenceInput: {
|
||||
savedObjectId: 'testSavedObjectId',
|
||||
id: refOrValContactCardEmbeddable.id,
|
||||
},
|
||||
mockedByValueInput: { firstName: 'RefOrValEmbeddable', id: refOrValContactCardEmbeddable.id },
|
||||
});
|
||||
jest.spyOn(byRefOrValEmbeddable, 'getInputAsValueType');
|
||||
}
|
||||
});
|
||||
|
||||
test('Clone is incompatible with Error Embeddables', async () => {
|
||||
const action = new ClonePanelAction();
|
||||
const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' }, container);
|
||||
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Clone adds a new embeddable', async () => {
|
||||
const dashboard = byRefOrValEmbeddable.getRoot() as IContainer;
|
||||
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
|
||||
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
|
||||
const action = new ClonePanelAction();
|
||||
await action.execute({ embeddable: byRefOrValEmbeddable });
|
||||
|
||||
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1);
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
expect(newPanelId).toBeDefined();
|
||||
const newPanel = container.getInput().panels[newPanelId!];
|
||||
expect(newPanel.type).toEqual(byRefOrValEmbeddable.type);
|
||||
});
|
||||
|
||||
test('Clones a RefOrVal embeddable by value', async () => {
|
||||
const dashboard = byRefOrValEmbeddable.getRoot() as IContainer;
|
||||
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
|
||||
const action = new ClonePanelAction();
|
||||
await action.execute({ embeddable: byRefOrValEmbeddable });
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
|
||||
const originalFirstName = (
|
||||
container.getInput().panels[byRefOrValEmbeddable.id].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
const newFirstName = (
|
||||
container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
expect(byRefOrValEmbeddable.getInputAsValueType).toHaveBeenCalled();
|
||||
|
||||
expect(originalFirstName).toEqual(newFirstName);
|
||||
expect(container.getInput().panels[newPanelId!].type).toEqual(byRefOrValEmbeddable.type);
|
||||
});
|
||||
|
||||
test('Clones a non RefOrVal embeddable by value', async () => {
|
||||
const dashboard = genericEmbeddable.getRoot() as IContainer;
|
||||
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
|
||||
const action = new ClonePanelAction();
|
||||
await action.execute({ embeddable: genericEmbeddable });
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
|
||||
const originalFirstName = (
|
||||
container.getInput().panels[genericEmbeddable.id].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
const newFirstName = (
|
||||
container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
expect(originalFirstName).toEqual(newFirstName);
|
||||
expect(container.getInput().panels[newPanelId!].type).toEqual(genericEmbeddable.type);
|
||||
});
|
||||
|
||||
test('Gets a unique title from the dashboard', async () => {
|
||||
const dashboard = byRefOrValEmbeddable.getRoot() as DashboardContainer;
|
||||
const action = new ClonePanelAction();
|
||||
|
||||
// @ts-ignore
|
||||
expect(await action.getCloneTitle(byRefOrValEmbeddable, '')).toEqual('');
|
||||
|
||||
dashboard.getPanelTitles = jest.fn().mockImplementation(() => {
|
||||
return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle'];
|
||||
it('is incompatible when view mode is view', async () => {
|
||||
context.embeddable.viewMode = new BehaviorSubject<ViewMode>('view');
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testUniqueTitle')).toEqual(
|
||||
'testUniqueTitle (copy)'
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
|
||||
'testDuplicateTitle (copy 1)'
|
||||
);
|
||||
|
||||
dashboard.getPanelTitles = jest.fn().mockImplementation(() => {
|
||||
return ['testDuplicateTitle', 'testDuplicateTitle (copy)'].concat(
|
||||
Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`)
|
||||
);
|
||||
it('calls the parent duplicatePanel method on execute', async () => {
|
||||
action.execute(context);
|
||||
expect(context.embeddable.parentApi.value.duplicatePanel).toHaveBeenCalled();
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
|
||||
'testDuplicateTitle (copy 40)'
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
|
||||
'testDuplicateTitle (copy 40)'
|
||||
);
|
||||
|
||||
dashboard.getPanelTitles = jest.fn().mockImplementation(() => {
|
||||
return ['testDuplicateTitle (copy 100)'];
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
|
||||
'testDuplicateTitle (copy 101)'
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(await action.getCloneTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
|
||||
'testDuplicateTitle (copy 101)'
|
||||
);
|
||||
});
|
||||
|
|
|
@ -6,154 +6,60 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { filter, map, max } from 'lodash';
|
||||
|
||||
import {
|
||||
ViewMode,
|
||||
PanelState,
|
||||
IEmbeddable,
|
||||
PanelNotFoundError,
|
||||
EmbeddableInput,
|
||||
isErrorEmbeddable,
|
||||
isReferenceOrValueEmbeddable,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { type DashboardPanelState } from '../../common';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { apiCanDuplicatePanels, CanDuplicatePanels } from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiPublishesUniqueId,
|
||||
apiPublishesParentApi,
|
||||
apiPublishesViewMode,
|
||||
EmbeddableApiContext,
|
||||
PublishesBlockingError,
|
||||
PublishesUniqueId,
|
||||
PublishesParentApi,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { dashboardClonePanelActionStrings } from './_dashboard_actions_strings';
|
||||
import { placeClonePanel } from '../dashboard_container/component/panel_placement';
|
||||
import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container';
|
||||
|
||||
export const ACTION_CLONE_PANEL = 'clonePanel';
|
||||
|
||||
export interface ClonePanelActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
export type ClonePanelActionApi = PublishesViewMode &
|
||||
PublishesUniqueId &
|
||||
PublishesParentApi<CanDuplicatePanels> &
|
||||
Partial<PublishesBlockingError>;
|
||||
|
||||
export class ClonePanelAction implements Action<ClonePanelActionContext> {
|
||||
const isApiCompatible = (api: unknown | null): api is ClonePanelActionApi =>
|
||||
Boolean(
|
||||
apiPublishesUniqueId(api) &&
|
||||
apiPublishesViewMode(api) &&
|
||||
apiPublishesParentApi(api) &&
|
||||
apiCanDuplicatePanels(api.parentApi.value)
|
||||
);
|
||||
|
||||
export class ClonePanelAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_CLONE_PANEL;
|
||||
public readonly id = ACTION_CLONE_PANEL;
|
||||
public order = 45;
|
||||
|
||||
private toastsService;
|
||||
constructor() {}
|
||||
|
||||
constructor() {
|
||||
({
|
||||
notifications: { toasts: this.toastsService },
|
||||
} = pluginServices.getServices());
|
||||
}
|
||||
|
||||
public getDisplayName({ embeddable }: ClonePanelActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return dashboardClonePanelActionStrings.getDisplayName();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: ClonePanelActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'copy';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: ClonePanelActionContext) {
|
||||
return Boolean(
|
||||
!isErrorEmbeddable(embeddable) &&
|
||||
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
|
||||
embeddable.getRoot() &&
|
||||
embeddable.getRoot().isContainer &&
|
||||
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE &&
|
||||
embeddable.getOutput().editable
|
||||
);
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) return false;
|
||||
return Boolean(!embeddable.blockingError?.value && embeddable.viewMode.value === 'edit');
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: ClonePanelActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
|
||||
const dashboard = embeddable.getRoot() as DashboardContainer;
|
||||
const panelToClone = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
|
||||
if (!panelToClone) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
|
||||
// Clone panel input
|
||||
const clonedPanelState: PanelState<EmbeddableInput> = await (async () => {
|
||||
const newTitle = await this.getCloneTitle(embeddable, embeddable.getTitle() || '');
|
||||
const id = uuidv4();
|
||||
if (isReferenceOrValueEmbeddable(embeddable)) {
|
||||
return {
|
||||
type: embeddable.type,
|
||||
explicitInput: {
|
||||
...(await embeddable.getInputAsValueType()),
|
||||
hidePanelTitles: panelToClone.explicitInput.hidePanelTitles,
|
||||
...(newTitle ? { title: newTitle } : {}),
|
||||
id,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: embeddable.type,
|
||||
explicitInput: {
|
||||
...panelToClone.explicitInput,
|
||||
title: newTitle,
|
||||
id,
|
||||
},
|
||||
};
|
||||
})();
|
||||
this.toastsService.addSuccess({
|
||||
title: dashboardClonePanelActionStrings.getSuccessMessage(),
|
||||
'data-test-subj': 'addObjectToContainerSuccess',
|
||||
});
|
||||
|
||||
const { newPanelPlacement, otherPanels } = placeClonePanel({
|
||||
width: panelToClone.gridData.w,
|
||||
height: panelToClone.gridData.h,
|
||||
currentPanels: dashboard.getInput().panels,
|
||||
placeBesideId: panelToClone.explicitInput.id,
|
||||
});
|
||||
|
||||
const newPanel = {
|
||||
...clonedPanelState,
|
||||
gridData: {
|
||||
...newPanelPlacement,
|
||||
i: clonedPanelState.explicitInput.id,
|
||||
},
|
||||
};
|
||||
|
||||
dashboard.updateInput({
|
||||
panels: {
|
||||
...otherPanels,
|
||||
[newPanel.explicitInput.id]: newPanel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getCloneTitle(embeddable: IEmbeddable, rawTitle: string) {
|
||||
if (rawTitle === '') return ''; // If
|
||||
|
||||
const clonedTag = dashboardClonePanelActionStrings.getClonedTag();
|
||||
const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g');
|
||||
const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g');
|
||||
const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim();
|
||||
const dashboard: DashboardContainer = embeddable.getRoot() as DashboardContainer;
|
||||
const similarTitles = filter(await dashboard.getPanelTitles(), (title: string) => {
|
||||
return title.startsWith(baseTitle);
|
||||
});
|
||||
|
||||
const cloneNumbers = map(similarTitles, (title: string) => {
|
||||
if (title.match(cloneRegex)) return 0;
|
||||
const cloneTag = title.match(cloneNumberRegex);
|
||||
return cloneTag ? parseInt(cloneTag[0].replace(/[^0-9.]/g, ''), 10) : -1;
|
||||
});
|
||||
const similarBaseTitlesCount = max(cloneNumbers) || 0;
|
||||
|
||||
return similarBaseTitlesCount < 0
|
||||
? baseTitle + ` (${clonedTag})`
|
||||
: baseTitle + ` (${clonedTag} ${similarBaseTitlesCount + 1})`;
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
embeddable.parentApi.value.duplicatePanel(embeddable.uuid.value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,32 +8,51 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import type { IEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
apiIsOfType,
|
||||
apiPublishesUniqueId,
|
||||
apiPublishesParentApi,
|
||||
apiPublishesSavedObjectId,
|
||||
HasType,
|
||||
EmbeddableApiContext,
|
||||
PublishesUniqueId,
|
||||
PublishesParentApi,
|
||||
PublishesSavedObjectId,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { DASHBOARD_CONTAINER_TYPE } from '../dashboard_container';
|
||||
import { DashboardPluginInternalFunctions } from '../dashboard_container/external_api/dashboard_api';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { CopyToDashboardModal } from './copy_to_dashboard_modal';
|
||||
import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_strings';
|
||||
import { DashboardContainer, DASHBOARD_CONTAINER_TYPE } from '../dashboard_container';
|
||||
|
||||
export const ACTION_COPY_TO_DASHBOARD = 'copyToDashboard';
|
||||
|
||||
export interface CopyToDashboardActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
|
||||
export interface DashboardCopyToCapabilities {
|
||||
canCreateNew: boolean;
|
||||
canEditExisting: boolean;
|
||||
}
|
||||
|
||||
function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer {
|
||||
return embeddable.type === DASHBOARD_CONTAINER_TYPE;
|
||||
}
|
||||
export type CopyToDashboardAPI = HasType &
|
||||
PublishesUniqueId &
|
||||
PublishesParentApi<
|
||||
{ type: typeof DASHBOARD_CONTAINER_TYPE } & PublishesSavedObjectId &
|
||||
DashboardPluginInternalFunctions
|
||||
>;
|
||||
|
||||
export class CopyToDashboardAction implements Action<CopyToDashboardActionContext> {
|
||||
const apiIsCompatible = (api: unknown): api is CopyToDashboardAPI => {
|
||||
return (
|
||||
apiPublishesUniqueId(api) &&
|
||||
apiPublishesParentApi(api) &&
|
||||
apiIsOfType(api.parentApi.value, DASHBOARD_CONTAINER_TYPE) &&
|
||||
apiPublishesSavedObjectId(api.parentApi.value)
|
||||
);
|
||||
};
|
||||
|
||||
export class CopyToDashboardAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_COPY_TO_DASHBOARD;
|
||||
public readonly id = ACTION_COPY_TO_DASHBOARD;
|
||||
public order = 1;
|
||||
|
@ -48,45 +67,33 @@ export class CopyToDashboardAction implements Action<CopyToDashboardActionContex
|
|||
} = pluginServices.getServices());
|
||||
}
|
||||
|
||||
public getDisplayName({ embeddable }: CopyToDashboardActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!apiIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
|
||||
return dashboardCopyToDashboardActionStrings.getDisplayName();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: CopyToDashboardActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!apiIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'exit';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: CopyToDashboardActionContext) {
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!apiIsCompatible(embeddable)) return false;
|
||||
const { createNew: canCreateNew, showWriteControls: canEditExisting } =
|
||||
this.dashboardCapabilities;
|
||||
|
||||
return Boolean(
|
||||
embeddable.parent && isDashboard(embeddable.parent) && (canCreateNew || canEditExisting)
|
||||
);
|
||||
return Boolean(canCreateNew || canEditExisting);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: CopyToDashboardActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!apiIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
|
||||
const { theme, i18n } = this.core;
|
||||
const session = this.openModal(
|
||||
toMountPoint(
|
||||
<CopyToDashboardModal
|
||||
closeModal={() => session.close()}
|
||||
dashboardId={(embeddable.parent as DashboardContainer).getDashboardSavedObjectId()}
|
||||
embeddable={embeddable}
|
||||
/>,
|
||||
{ theme, i18n }
|
||||
),
|
||||
toMountPoint(<CopyToDashboardModal closeModal={() => session.close()} api={embeddable} />, {
|
||||
theme,
|
||||
i18n,
|
||||
}),
|
||||
{
|
||||
maxWidth: 400,
|
||||
'data-test-subj': 'copyToDashboardPanel',
|
||||
|
|
|
@ -5,46 +5,36 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { omit } from 'lodash';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiRadio,
|
||||
EuiButton,
|
||||
EuiSpacer,
|
||||
EuiButtonEmpty,
|
||||
EuiFormRow,
|
||||
EuiModalBody,
|
||||
EuiButtonEmpty,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiRadio,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import {
|
||||
EmbeddablePackageState,
|
||||
IEmbeddable,
|
||||
PanelNotFoundError,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddablePackageState, PanelNotFoundError } from '@kbn/embeddable-plugin/public';
|
||||
import { LazyDashboardPicker, withSuspense } from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import { DashboardPanelState } from '../../common';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { type DashboardContainer } from '../dashboard_container';
|
||||
import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_strings';
|
||||
import { createDashboardEditUrl, CREATE_NEW_DASHBOARD_URL } from '../dashboard_constants';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { CopyToDashboardAPI } from './copy_to_dashboard_action';
|
||||
import { dashboardCopyToDashboardActionStrings } from './_dashboard_actions_strings';
|
||||
|
||||
interface CopyToDashboardModalProps {
|
||||
embeddable: IEmbeddable;
|
||||
dashboardId?: string;
|
||||
api: CopyToDashboardAPI;
|
||||
closeModal: () => void;
|
||||
}
|
||||
|
||||
const DashboardPicker = withSuspense(LazyDashboardPicker);
|
||||
|
||||
export function CopyToDashboardModal({
|
||||
dashboardId,
|
||||
embeddable,
|
||||
closeModal,
|
||||
}: CopyToDashboardModalProps) {
|
||||
export function CopyToDashboardModal({ api, closeModal }: CopyToDashboardModalProps) {
|
||||
const {
|
||||
embeddable: { getStateTransfer },
|
||||
dashboardCapabilities: { createNew: canCreateNew, showWriteControls: canEditExisting },
|
||||
|
@ -56,15 +46,18 @@ export function CopyToDashboardModal({
|
|||
null
|
||||
);
|
||||
|
||||
const dashboardId = api.parentApi.value.savedObjectId.value;
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
const dashboard = embeddable.getRoot() as DashboardContainer;
|
||||
const panelToCopy = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
|
||||
const dashboard = api.parentApi.value;
|
||||
const panelToCopy = dashboard.getDashboardPanelFromId(api.uuid.value);
|
||||
|
||||
if (!panelToCopy) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
|
||||
const state: EmbeddablePackageState = {
|
||||
type: embeddable.type,
|
||||
type: panelToCopy.type,
|
||||
input: {
|
||||
...omit(panelToCopy.explicitInput, 'id'),
|
||||
},
|
||||
|
@ -84,7 +77,7 @@ export function CopyToDashboardModal({
|
|||
state,
|
||||
path,
|
||||
});
|
||||
}, [dashboardOption, embeddable, selectedDashboard, stateTransfer, closeModal]);
|
||||
}, [api, dashboardOption, selectedDashboard, closeModal, stateTransfer]);
|
||||
|
||||
const titleId = 'copyToDashboardTitle';
|
||||
const descriptionId = 'copyToDashboardDescription';
|
||||
|
|
|
@ -6,92 +6,58 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { ExpandPanelAction } from './expand_panel_action';
|
||||
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
import { CanExpandPanels } from '@kbn/presentation-containers';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ExpandPanelActionApi, ExpandPanelAction } from './expand_panel_action';
|
||||
|
||||
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
describe('Expand panel action', () => {
|
||||
let action: ExpandPanelAction;
|
||||
let context: { embeddable: ExpandPanelActionApi };
|
||||
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
|
||||
let container: DashboardContainer;
|
||||
let embeddable: ContactCardEmbeddable;
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
|
||||
beforeEach(async () => {
|
||||
container = buildMockDashboard({
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
beforeEach(() => {
|
||||
action = new ExpandPanelAction();
|
||||
context = {
|
||||
embeddable: {
|
||||
uuid: new BehaviorSubject<string>('superId'),
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
parentApi: new BehaviorSubject<CanExpandPanels>({
|
||||
expandPanel: jest.fn(),
|
||||
expandedPanelId: new BehaviorSubject<string | undefined>(undefined),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibana',
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
} else {
|
||||
embeddable = contactCardEmbeddable;
|
||||
}
|
||||
});
|
||||
|
||||
test('Sets the embeddable expanded panel id on the parent', async () => {
|
||||
const expandPanelAction = new ExpandPanelAction();
|
||||
|
||||
expect(container.getExpandedPanelId()).toBeUndefined();
|
||||
|
||||
expandPanelAction.execute({ embeddable });
|
||||
|
||||
expect(container.getExpandedPanelId()).toBe(embeddable.id);
|
||||
});
|
||||
|
||||
test('Is not compatible when embeddable is not in a dashboard container', async () => {
|
||||
const action = new ExpandPanelAction();
|
||||
expect(
|
||||
await action.isCompatible({
|
||||
embeddable: new ContactCardEmbeddable(
|
||||
{ firstName: 'sue', id: '123' },
|
||||
{ execAction: (() => null) as any }
|
||||
),
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('Execute throws an error when called with an embeddable not in a parent', async () => {
|
||||
const action = new ExpandPanelAction();
|
||||
async function check() {
|
||||
await action.execute({ embeddable: container });
|
||||
}
|
||||
await expect(check()).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
test('Returns title', async () => {
|
||||
const action = new ExpandPanelAction();
|
||||
expect(action.getDisplayName({ embeddable })).toBeDefined();
|
||||
});
|
||||
|
||||
test('Returns an icon', async () => {
|
||||
const action = new ExpandPanelAction();
|
||||
expect(action.getIconType({ embeddable })).toBeDefined();
|
||||
it('is incompatible when context lacks necessary functions', async () => {
|
||||
const emptyContext = {
|
||||
embeddable: {},
|
||||
};
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the correct icon based on expanded panel id', async () => {
|
||||
expect(await action.getIconType(context)).toBe('expand');
|
||||
context.embeddable.parentApi.value.expandedPanelId = new BehaviorSubject<string | undefined>(
|
||||
'superPanelId'
|
||||
);
|
||||
expect(await action.getIconType(context)).toBe('minimize');
|
||||
});
|
||||
|
||||
it('returns the correct display name based on expanded panel id', async () => {
|
||||
expect(await action.getDisplayName(context)).toBe('Maximize panel');
|
||||
context.embeddable.parentApi.value.expandedPanelId = new BehaviorSubject<string | undefined>(
|
||||
'superPanelId'
|
||||
);
|
||||
expect(await action.getDisplayName(context)).toBe('Minimize');
|
||||
});
|
||||
|
||||
it('calls the parent expandPanel method on execute', async () => {
|
||||
action.execute(context);
|
||||
expect(context.embeddable.parentApi.value.expandPanel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,67 +6,61 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { IEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { apiCanExpandPanels, CanExpandPanels } from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiPublishesUniqueId,
|
||||
apiPublishesParentApi,
|
||||
apiPublishesViewMode,
|
||||
EmbeddableApiContext,
|
||||
PublishesUniqueId,
|
||||
PublishesParentApi,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { DASHBOARD_CONTAINER_TYPE, type DashboardContainer } from '../dashboard_container';
|
||||
import { dashboardExpandPanelActionStrings } from './_dashboard_actions_strings';
|
||||
|
||||
export const ACTION_EXPAND_PANEL = 'togglePanel';
|
||||
|
||||
function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer {
|
||||
return embeddable.type === DASHBOARD_CONTAINER_TYPE;
|
||||
}
|
||||
export type ExpandPanelActionApi = PublishesViewMode &
|
||||
PublishesUniqueId &
|
||||
PublishesParentApi<CanExpandPanels>;
|
||||
|
||||
function isExpanded(embeddable: IEmbeddable) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
const isApiCompatible = (api: unknown | null): api is ExpandPanelActionApi =>
|
||||
Boolean(
|
||||
apiPublishesUniqueId(api) &&
|
||||
apiPublishesViewMode(api) &&
|
||||
apiPublishesParentApi(api) &&
|
||||
apiCanExpandPanels(api.parentApi.value)
|
||||
);
|
||||
|
||||
return embeddable.id === (embeddable.parent as DashboardContainer).getExpandedPanelId();
|
||||
}
|
||||
|
||||
export interface ExpandPanelActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
|
||||
export class ExpandPanelAction implements Action<ExpandPanelActionContext> {
|
||||
export class ExpandPanelAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_EXPAND_PANEL;
|
||||
public readonly id = ACTION_EXPAND_PANEL;
|
||||
public order = 7;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public getDisplayName({ embeddable }: ExpandPanelActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
|
||||
return isExpanded(embeddable)
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return embeddable.parentApi.value.expandedPanelId.value
|
||||
? dashboardExpandPanelActionStrings.getMinimizeTitle()
|
||||
: dashboardExpandPanelActionStrings.getMaximizeTitle();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: ExpandPanelActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
return isExpanded(embeddable) ? 'minimize' : 'expand';
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return embeddable.parentApi.value.expandedPanelId.value ? 'minimize' : 'expand';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: ExpandPanelActionContext) {
|
||||
return Boolean(embeddable.parent && isDashboard(embeddable.parent));
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return isApiCompatible(embeddable);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: ExpandPanelActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
const newValue = isExpanded(embeddable) ? undefined : embeddable.id;
|
||||
(embeddable.parent as DashboardContainer).setExpandedPanelId(newValue);
|
||||
|
||||
if (!newValue) {
|
||||
(embeddable.parent as DashboardContainer).setScrollToPanelId(embeddable.id);
|
||||
}
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
embeddable.parentApi.value.expandPanel(
|
||||
embeddable.parentApi.value.expandedPanelId.value ? undefined : embeddable.uuid.value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,105 +6,62 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardExportableEmbeddableFactory,
|
||||
CONTACT_CARD_EXPORTABLE_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { LINE_FEED_CHARACTER } from '@kbn/data-plugin/common/exports/export_csv';
|
||||
import { isErrorEmbeddable, IContainer, ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { ExportCSVAction } from './export_csv_action';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
import { ExportCSVAction, ExportCsvActionApi } from './export_csv_action';
|
||||
|
||||
describe('Export CSV action', () => {
|
||||
let container: DashboardContainer;
|
||||
let embeddable: ContactCardEmbeddable;
|
||||
let coreStart: CoreStart;
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardExportableEmbeddableFactory(
|
||||
(() => null) as any,
|
||||
{} as any
|
||||
);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
let action: ExportCSVAction;
|
||||
let context: { embeddable: ExportCsvActionApi };
|
||||
|
||||
beforeEach(async () => {
|
||||
coreStart = coreMock.createStart();
|
||||
coreStart.savedObjects.client = {
|
||||
...coreStart.savedObjects.client,
|
||||
get: jest.fn().mockImplementation(() => ({ attributes: { title: 'Holy moly' } })),
|
||||
find: jest.fn().mockImplementation(() => ({ total: 15 })),
|
||||
create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })),
|
||||
};
|
||||
|
||||
container = buildMockDashboard({
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Kibanana', id: '123' },
|
||||
type: CONTACT_CARD_EXPORTABLE_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
action = new ExportCSVAction();
|
||||
context = {
|
||||
embeddable: {
|
||||
getInspectorAdapters: () => ({
|
||||
tables: {
|
||||
allowCsvExport: true,
|
||||
tables: {
|
||||
layer1: {
|
||||
type: 'datatable',
|
||||
columns: [
|
||||
{ id: 'firstName', name: 'First Name' },
|
||||
{ id: 'originalLastName', name: 'Last Name' },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
firstName: 'Kibanana',
|
||||
orignialLastName: 'Kiwi',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EXPORTABLE_EMBEDDABLE, {
|
||||
firstName: 'Kibana',
|
||||
});
|
||||
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
} else {
|
||||
embeddable = contactCardEmbeddable;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
test('Download is incompatible with embeddables without getInspectorAdapters implementation', async () => {
|
||||
const action = new ExportCSVAction();
|
||||
const errorEmbeddable = new ErrorEmbeddable(
|
||||
'Wow what an awful error',
|
||||
{ id: ' 404' },
|
||||
embeddable.getRoot() as IContainer
|
||||
);
|
||||
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
test('Should download a compatible Embeddable', async () => {
|
||||
const action = new ExportCSVAction();
|
||||
const result = (await action.execute({ embeddable, asString: true })) as unknown as
|
||||
| undefined
|
||||
| Record<string, { content: string; type: string }>;
|
||||
it('is incompatible with APIs without a getInspectorAdapters implementation', async () => {
|
||||
const emptyContext = {
|
||||
embeddable: {},
|
||||
};
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
it('Should download if the API is compatible', async () => {
|
||||
const result = (await action.execute({
|
||||
embeddable: context.embeddable,
|
||||
asString: true,
|
||||
})) as unknown as undefined | Record<string, { content: string; type: string }>;
|
||||
expect(result).toEqual({
|
||||
'Hello Kibana.csv': {
|
||||
content: `First Name,Last Name${LINE_FEED_CHARACTER}Kibana,${LINE_FEED_CHARACTER}`,
|
||||
'untitled.csv': {
|
||||
content: `First Name,Last Name${LINE_FEED_CHARACTER}Kibanana,${LINE_FEED_CHARACTER}`,
|
||||
type: 'text/plain;charset=utf-8',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('Should not download incompatible Embeddable', async () => {
|
||||
const action = new ExportCSVAction();
|
||||
const errorEmbeddable = new ErrorEmbeddable(
|
||||
'Wow what an awful error',
|
||||
{ id: ' 404' },
|
||||
embeddable.getRoot() as IContainer
|
||||
);
|
||||
const result = (await action.execute({
|
||||
embeddable: errorEmbeddable,
|
||||
asString: true,
|
||||
})) as unknown as undefined | Record<string, string>;
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,27 +7,32 @@
|
|||
*/
|
||||
|
||||
import { exporters } from '@kbn/data-plugin/public';
|
||||
import { Action } from '@kbn/ui-actions-plugin/public';
|
||||
import { Datatable } from '@kbn/expressions-plugin/public';
|
||||
import { downloadMultipleAs } from '@kbn/share-plugin/public';
|
||||
import { FormatFactory } from '@kbn/field-formats-plugin/common';
|
||||
import type { Adapters, IEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { downloadMultipleAs } from '@kbn/share-plugin/public';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { dashboardExportCsvActionStrings } from './_dashboard_actions_strings';
|
||||
import {
|
||||
apiHasInspectorAdapters,
|
||||
HasInspectorAdapters,
|
||||
type Adapters,
|
||||
} from '@kbn/inspector-plugin/public';
|
||||
import { EmbeddableApiContext, PublishesPanelTitle } from '@kbn/presentation-publishing';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { dashboardExportCsvActionStrings } from './_dashboard_actions_strings';
|
||||
|
||||
export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV';
|
||||
|
||||
export interface ExportContext {
|
||||
embeddable?: IEmbeddable;
|
||||
export type ExportContext = EmbeddableApiContext & {
|
||||
// used for testing
|
||||
asString?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export type ExportCsvActionApi = HasInspectorAdapters & Partial<PublishesPanelTitle>;
|
||||
|
||||
const isApiCompatible = (api: unknown | null): api is ExportCsvActionApi =>
|
||||
Boolean(apiHasInspectorAdapters(api));
|
||||
|
||||
/**
|
||||
* This is "Export CSV" action which appears in the context
|
||||
* menu of a dashboard panel.
|
||||
*/
|
||||
export class ExportCSVAction implements Action<ExportContext> {
|
||||
public readonly id = ACTION_EXPORT_CSV;
|
||||
public readonly type = ACTION_EXPORT_CSV;
|
||||
|
@ -50,8 +55,9 @@ export class ExportCSVAction implements Action<ExportContext> {
|
|||
public readonly getDisplayName = (context: ExportContext): string =>
|
||||
dashboardExportCsvActionStrings.getDisplayName();
|
||||
|
||||
public async isCompatible(context: ExportContext): Promise<boolean> {
|
||||
return !!this.hasDatatableContent(context.embeddable?.getInspectorAdapters?.());
|
||||
public async isCompatible({ embeddable }: ExportContext): Promise<boolean> {
|
||||
if (!isApiCompatible(embeddable)) return false;
|
||||
return Boolean(this.hasDatatableContent(embeddable?.getInspectorAdapters?.()));
|
||||
}
|
||||
|
||||
private hasDatatableContent = (adapters: Adapters | undefined) => {
|
||||
|
@ -71,16 +77,17 @@ export class ExportCSVAction implements Action<ExportContext> {
|
|||
return;
|
||||
};
|
||||
|
||||
private exportCSV = async (context: ExportContext) => {
|
||||
private exportCSV = async (embeddable: ExportCsvActionApi, asString = false) => {
|
||||
const formatFactory = this.getFormatter();
|
||||
// early exit if not formatter is available
|
||||
if (!formatFactory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableAdapters = this.getDataTableContent(
|
||||
context?.embeddable?.getInspectorAdapters()
|
||||
) as Record<string, Datatable>;
|
||||
const tableAdapters = this.getDataTableContent(embeddable?.getInspectorAdapters()) as Record<
|
||||
string,
|
||||
Datatable
|
||||
>;
|
||||
|
||||
if (tableAdapters) {
|
||||
const datatables = Object.values(tableAdapters);
|
||||
|
@ -91,7 +98,7 @@ export class ExportCSVAction implements Action<ExportContext> {
|
|||
const postFix = datatables.length > 1 ? `-${i + 1}` : '';
|
||||
const untitledFilename = dashboardExportCsvActionStrings.getUntitledFilename();
|
||||
|
||||
memo[`${context!.embeddable!.getTitle() || untitledFilename}${postFix}.csv`] = {
|
||||
memo[`${embeddable.panelTitle?.value || untitledFilename}${postFix}.csv`] = {
|
||||
content: exporters.datatableToCSV(datatable, {
|
||||
csvSeparator: this.uiSettings.get('csv:separator', ','),
|
||||
quoteValues: this.uiSettings.get('csv:quoteValues', true),
|
||||
|
@ -107,7 +114,7 @@ export class ExportCSVAction implements Action<ExportContext> {
|
|||
);
|
||||
|
||||
// useful for testing
|
||||
if (context.asString) {
|
||||
if (asString) {
|
||||
return content as unknown as Promise<void>;
|
||||
}
|
||||
|
||||
|
@ -117,8 +124,8 @@ export class ExportCSVAction implements Action<ExportContext> {
|
|||
}
|
||||
};
|
||||
|
||||
public async execute(context: ExportContext): Promise<void> {
|
||||
// make it testable: type here will be forced
|
||||
return await this.exportCSV(context);
|
||||
public async execute({ embeddable, asString }: ExportContext): Promise<void> {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return await this.exportCSV(embeddable, asString);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,30 +6,17 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { ErrorEmbeddable, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { Filter, FilterStateStore, type AggregateQuery, type Query } from '@kbn/es-query';
|
||||
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { DashboardPluginInternalFunctions } from '../dashboard_container/external_api/dashboard_api';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddableFactory,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { type Query, type AggregateQuery, Filter } from '@kbn/es-query';
|
||||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
FiltersNotificationAction,
|
||||
FiltersNotificationActionApi,
|
||||
} from './filters_notification_action';
|
||||
|
||||
import { buildMockDashboard } from '../mocks';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { FiltersNotificationAction } from './filters_notification_action';
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
|
||||
const mockGetFilters = jest.fn(async () => [] as Filter[]);
|
||||
const mockGetQuery = jest.fn(async () => undefined as Query | AggregateQuery | undefined);
|
||||
|
||||
const getMockPhraseFilter = (key: string, value: string) => {
|
||||
const getMockPhraseFilter = (key: string, value: string): Filter => {
|
||||
return {
|
||||
meta: {
|
||||
type: 'phrase',
|
||||
|
@ -44,59 +31,89 @@ const getMockPhraseFilter = (key: string, value: string) => {
|
|||
},
|
||||
},
|
||||
$state: {
|
||||
store: 'appState',
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const buildEmbeddable = async (input?: Partial<ContactCardEmbeddableInput>) => {
|
||||
const container = buildMockDashboard();
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibanana',
|
||||
viewMode: ViewMode.EDIT,
|
||||
...input,
|
||||
});
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
}
|
||||
describe('filters notification action', () => {
|
||||
let action: FiltersNotificationAction;
|
||||
let context: { embeddable: FiltersNotificationActionApi };
|
||||
|
||||
const embeddable = embeddablePluginMock.mockFilterableEmbeddable(contactCardEmbeddable, {
|
||||
getFilters: () => mockGetFilters(),
|
||||
getQuery: () => mockGetQuery(),
|
||||
let updateFilters: (filters: Filter[]) => void;
|
||||
let updateQuery: (query: Query | AggregateQuery | undefined) => void;
|
||||
let updateViewMode: (viewMode: ViewMode) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
const filtersSubject = new BehaviorSubject<Filter[] | undefined>(undefined);
|
||||
updateFilters = (filters) => filtersSubject.next(filters);
|
||||
const querySubject = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
|
||||
updateQuery = (query) => querySubject.next(query);
|
||||
|
||||
const viewModeSubject = new BehaviorSubject<ViewMode>('edit');
|
||||
updateViewMode = (viewMode) => viewModeSubject.next(viewMode);
|
||||
|
||||
action = new FiltersNotificationAction();
|
||||
context = {
|
||||
embeddable: {
|
||||
uuid: new BehaviorSubject<string>('testId'),
|
||||
viewMode: viewModeSubject,
|
||||
parentApi: new BehaviorSubject<DashboardPluginInternalFunctions>({
|
||||
getAllDataViews: jest.fn(),
|
||||
getDashboardPanelFromId: jest.fn(),
|
||||
}),
|
||||
localFilters: filtersSubject,
|
||||
localQuery: querySubject,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return embeddable;
|
||||
};
|
||||
it('is incompatible when api is missing required functions', async () => {
|
||||
const emptyContext = { embeddable: {} };
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
const action = new FiltersNotificationAction();
|
||||
it('is incompatible when api has no local filters or queries', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
test('Badge is incompatible with Error Embeddables', async () => {
|
||||
const errorEmbeddable = new ErrorEmbeddable('Wow what an awful error', { id: ' 404' });
|
||||
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Badge is not shown when panel has no app-level filters or queries', async () => {
|
||||
const embeddable = await buildEmbeddable();
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Badge is shown when panel has at least one app-level filter', async () => {
|
||||
const embeddable = await buildEmbeddable();
|
||||
mockGetFilters.mockResolvedValue([getMockPhraseFilter('fieldName', 'someValue')] as Filter[]);
|
||||
expect(await action.isCompatible({ embeddable })).toBe(true);
|
||||
});
|
||||
|
||||
test('Badge is shown when panel has at least one app-level query', async () => {
|
||||
const embeddable = await buildEmbeddable();
|
||||
mockGetQuery.mockResolvedValue({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery);
|
||||
expect(await action.isCompatible({ embeddable })).toBe(true);
|
||||
});
|
||||
|
||||
test('Badge is not shown in view mode', async () => {
|
||||
const embeddable = await buildEmbeddable({ viewMode: ViewMode.VIEW });
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
it('is compatible when api has at least one local filter', async () => {
|
||||
updateFilters([getMockPhraseFilter('SuperField', 'SuperValue')]);
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('is compatible when api has at least one local query', async () => {
|
||||
updateQuery({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery);
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('is incompatible when api is in view mode', async () => {
|
||||
updateFilters([getMockPhraseFilter('SuperField', 'SuperValue')]);
|
||||
updateQuery({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery);
|
||||
updateViewMode('view');
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onChange when view mode changes', () => {
|
||||
const onChange = jest.fn();
|
||||
updateFilters([getMockPhraseFilter('SuperField', 'SuperValue')]);
|
||||
updateQuery({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery);
|
||||
action.subscribeToCompatibilityChanges(context, onChange);
|
||||
updateViewMode('view');
|
||||
expect(onChange).toHaveBeenCalledWith(false, action);
|
||||
});
|
||||
|
||||
it('calls onChange when filters change', async () => {
|
||||
const onChange = jest.fn();
|
||||
action.subscribeToCompatibilityChanges(context, onChange);
|
||||
updateFilters([getMockPhraseFilter('SuperField', 'SuperValue')]);
|
||||
expect(onChange).toHaveBeenCalledWith(true, action);
|
||||
});
|
||||
|
||||
it('calls onChange when query changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
action.subscribeToCompatibilityChanges(context, onChange);
|
||||
updateQuery({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery);
|
||||
expect(onChange).toHaveBeenCalledWith(true, action);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,50 +8,64 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { EditPanelAction, isFilterableEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { type IEmbeddable, isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
import { isOfAggregateQueryType, isOfQueryType } from '@kbn/es-query';
|
||||
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
|
||||
import type { ApplicationStart } from '@kbn/core/public';
|
||||
import { type AggregateQuery } from '@kbn/es-query';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import {
|
||||
apiPublishesPartialLocalUnifiedSearch,
|
||||
apiPublishesUniqueId,
|
||||
apiPublishesViewMode,
|
||||
EmbeddableApiContext,
|
||||
PublishesLocalUnifiedSearch,
|
||||
PublishesParentApi,
|
||||
PublishesUniqueId,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { merge } from 'rxjs';
|
||||
import { DashboardPluginInternalFunctions } from '../dashboard_container/external_api/dashboard_api';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { FiltersNotificationPopover } from './filters_notification_popover';
|
||||
import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
|
||||
export const BADGE_FILTERS_NOTIFICATION = 'ACTION_FILTERS_NOTIFICATION';
|
||||
|
||||
export interface FiltersNotificationActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
export type FiltersNotificationActionApi = PublishesUniqueId &
|
||||
PublishesViewMode &
|
||||
Partial<PublishesLocalUnifiedSearch> &
|
||||
PublishesParentApi<DashboardPluginInternalFunctions>;
|
||||
|
||||
export class FiltersNotificationAction implements Action<FiltersNotificationActionContext> {
|
||||
const isApiCompatible = (api: unknown | null): api is FiltersNotificationActionApi =>
|
||||
Boolean(
|
||||
apiPublishesUniqueId(api) &&
|
||||
apiPublishesViewMode(api) &&
|
||||
apiPublishesPartialLocalUnifiedSearch(api)
|
||||
);
|
||||
|
||||
const compatibilityCheck = (api: EmbeddableApiContext['embeddable']) => {
|
||||
if (!isApiCompatible(api) || api.viewMode.value !== 'edit') return false;
|
||||
const query = api.localQuery?.value;
|
||||
return (
|
||||
(api.localFilters?.value ?? []).length > 0 ||
|
||||
(isOfQueryType(query) && query.query !== '') ||
|
||||
isOfAggregateQueryType(query)
|
||||
);
|
||||
};
|
||||
|
||||
export class FiltersNotificationAction implements Action<EmbeddableApiContext> {
|
||||
public readonly id = BADGE_FILTERS_NOTIFICATION;
|
||||
public readonly type = BADGE_FILTERS_NOTIFICATION;
|
||||
public readonly order = 2;
|
||||
|
||||
private displayName = dashboardFilterNotificationActionStrings.getDisplayName();
|
||||
private icon = 'filter';
|
||||
private applicationService;
|
||||
private embeddableService;
|
||||
private settingsService;
|
||||
|
||||
constructor() {
|
||||
({
|
||||
application: this.applicationService,
|
||||
embeddable: this.embeddableService,
|
||||
settings: this.settingsService,
|
||||
} = pluginServices.getServices());
|
||||
({ settings: this.settingsService } = pluginServices.getServices());
|
||||
}
|
||||
|
||||
public readonly MenuItem = ({ context }: { context: FiltersNotificationActionContext }) => {
|
||||
public readonly MenuItem = ({ context }: { context: EmbeddableApiContext }) => {
|
||||
const { embeddable } = context;
|
||||
|
||||
const editPanelAction = new EditPanelAction(
|
||||
this.embeddableService.getEmbeddableFactory,
|
||||
this.applicationService as unknown as ApplicationStart,
|
||||
this.embeddableService.getStateTransfer()
|
||||
);
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
|
||||
const { Provider: KibanaReactContextProvider } = createKibanaReactContext({
|
||||
uiSettings: this.settingsService.uiSettings,
|
||||
|
@ -59,51 +73,42 @@ export class FiltersNotificationAction implements Action<FiltersNotificationActi
|
|||
|
||||
return (
|
||||
<KibanaReactContextProvider>
|
||||
<FiltersNotificationPopover
|
||||
editPanelAction={editPanelAction}
|
||||
displayName={this.displayName}
|
||||
context={context}
|
||||
icon={this.getIconType({ embeddable })}
|
||||
id={this.id}
|
||||
/>
|
||||
<FiltersNotificationPopover api={embeddable} />
|
||||
</KibanaReactContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
public getDisplayName({ embeddable }: FiltersNotificationActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
return this.displayName;
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return dashboardFilterNotificationActionStrings.getDisplayName();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: FiltersNotificationActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
return this.icon;
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'filter';
|
||||
}
|
||||
|
||||
public isCompatible = async ({ embeddable }: FiltersNotificationActionContext) => {
|
||||
// add all possible early returns to avoid the async import unless absolutely necessary
|
||||
if (
|
||||
isErrorEmbeddable(embeddable) ||
|
||||
!embeddable.getRoot().isContainer ||
|
||||
embeddable.getInput()?.viewMode !== ViewMode.EDIT ||
|
||||
!isFilterableEmbeddable(embeddable)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ((await embeddable.getFilters()).length > 0) return true;
|
||||
|
||||
// all early returns failed, so go ahead and check the query now
|
||||
const { isOfQueryType, isOfAggregateQueryType } = await import('@kbn/es-query');
|
||||
const query = await embeddable.getQuery();
|
||||
return (
|
||||
(isOfQueryType(query) && query.query !== '') ||
|
||||
isOfAggregateQueryType(query as AggregateQuery)
|
||||
);
|
||||
public isCompatible = async ({ embeddable }: EmbeddableApiContext) => {
|
||||
return compatibilityCheck(embeddable);
|
||||
};
|
||||
|
||||
public couldBecomeCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return apiPublishesPartialLocalUnifiedSearch(embeddable);
|
||||
}
|
||||
|
||||
public subscribeToCompatibilityChanges(
|
||||
{ embeddable }: EmbeddableApiContext,
|
||||
onChange: (isCompatible: boolean, action: FiltersNotificationAction) => void
|
||||
) {
|
||||
if (!isApiCompatible(embeddable)) return;
|
||||
return merge(
|
||||
...[embeddable.localQuery, embeddable.localFilters, embeddable.viewMode].filter((value) =>
|
||||
Boolean(value)
|
||||
)
|
||||
).subscribe(() => {
|
||||
onChange(compatibilityCheck(embeddable), this);
|
||||
});
|
||||
}
|
||||
|
||||
public execute = async () => {};
|
||||
}
|
||||
|
|
|
@ -6,87 +6,119 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { AggregateQuery, Filter, FilterStateStore, Query } from '@kbn/es-query';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { FilterableEmbeddable, isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { DashboardPluginInternalFunctions } from '../dashboard_container/external_api/dashboard_api';
|
||||
import { FiltersNotificationActionApi } from './filters_notification_action';
|
||||
import { FiltersNotificationPopover } from './filters_notification_popover';
|
||||
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
import { buildMockDashboard } from '../mocks';
|
||||
import { EuiPopover } from '@elastic/eui';
|
||||
import {
|
||||
FiltersNotificationPopover,
|
||||
FiltersNotificationProps,
|
||||
} from './filters_notification_popover';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
const getMockPhraseFilter = (key: string, value: string): Filter => {
|
||||
return {
|
||||
meta: {
|
||||
type: 'phrase',
|
||||
key,
|
||||
params: {
|
||||
query: value,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
$state: {
|
||||
store: FilterStateStore.APP_STATE,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockedEditPanelAction = {
|
||||
execute: jest.fn(),
|
||||
isCompatible: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
jest.mock('@kbn/presentation-panel-plugin/public', () => ({
|
||||
getEditPanelAction: () => mockedEditPanelAction,
|
||||
}));
|
||||
|
||||
describe('filters notification popover', () => {
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
|
||||
let container: DashboardContainer;
|
||||
let embeddable: ContactCardEmbeddable & FilterableEmbeddable;
|
||||
let defaultProps: FiltersNotificationProps;
|
||||
let api: FiltersNotificationActionApi;
|
||||
let updateFilters: (filters: Filter[]) => void;
|
||||
let updateQuery: (query: Query | AggregateQuery | undefined) => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
container = buildMockDashboard();
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibanana',
|
||||
});
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
}
|
||||
embeddable = embeddablePluginMock.mockFilterableEmbeddable(contactCardEmbeddable, {
|
||||
getFilters: jest.fn(),
|
||||
getQuery: jest.fn(),
|
||||
});
|
||||
const filtersSubject = new BehaviorSubject<Filter[] | undefined>(undefined);
|
||||
updateFilters = (filters) => filtersSubject.next(filters);
|
||||
const querySubject = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
|
||||
updateQuery = (query) => querySubject.next(query);
|
||||
|
||||
defaultProps = {
|
||||
icon: 'test',
|
||||
context: { embeddable: contactCardEmbeddable },
|
||||
displayName: 'test display',
|
||||
id: 'testId',
|
||||
editPanelAction: {
|
||||
execute: jest.fn(),
|
||||
} as unknown as FiltersNotificationProps['editPanelAction'],
|
||||
api = {
|
||||
uuid: new BehaviorSubject<string>('testId'),
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
parentApi: new BehaviorSubject<DashboardPluginInternalFunctions>({
|
||||
getAllDataViews: jest.fn(),
|
||||
getDashboardPanelFromId: jest.fn(),
|
||||
}),
|
||||
localFilters: filtersSubject,
|
||||
localQuery: querySubject,
|
||||
};
|
||||
});
|
||||
|
||||
function mountComponent(props?: Partial<FiltersNotificationProps>) {
|
||||
return mountWithIntl(<FiltersNotificationPopover {...{ ...defaultProps, ...props }} />);
|
||||
}
|
||||
const renderAndOpenPopover = async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<FiltersNotificationPopover api={api} />
|
||||
</I18nProvider>
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(`embeddablePanelNotification-${api.uuid.value}`)
|
||||
);
|
||||
await waitForEuiPopoverOpen();
|
||||
};
|
||||
|
||||
test('clicking edit button executes edit panel action', async () => {
|
||||
embeddable.updateInput({ viewMode: ViewMode.EDIT });
|
||||
const component = mountComponent();
|
||||
it('calls get all dataviews from the parent', async () => {
|
||||
render(<FiltersNotificationPopover api={api} />);
|
||||
expect(api.parentApi.value?.getAllDataViews).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`).simulate(
|
||||
'click'
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
component.update();
|
||||
});
|
||||
it('renders the filter section when given filters', async () => {
|
||||
updateFilters([getMockPhraseFilter('ay', 'oh')]);
|
||||
await renderAndOpenPopover();
|
||||
expect(await screen.findByTestId('filtersNotificationModal__filterItems')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const popover = component.find(EuiPopover);
|
||||
const editButton = findTestSubject(popover, 'filtersNotificationModal__editButton');
|
||||
editButton.simulate('click');
|
||||
expect(defaultProps.editPanelAction.execute).toHaveBeenCalled();
|
||||
it('renders the query section when given a query', async () => {
|
||||
updateQuery({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery);
|
||||
await renderAndOpenPopover();
|
||||
expect(await screen.findByTestId('filtersNotificationModal__query')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an edit button when the edit panel action is compatible', async () => {
|
||||
updateFilters([getMockPhraseFilter('ay', 'oh')]);
|
||||
await renderAndOpenPopover();
|
||||
expect(await screen.findByTestId('filtersNotificationModal__editButton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render an edit button when the query is ESQL', async () => {
|
||||
updateFilters([getMockPhraseFilter('ay', 'oh')]);
|
||||
updateQuery({ sql: 'SELECT * FROM test_dataview' } as AggregateQuery);
|
||||
updateFilters([getMockPhraseFilter('ay', 'oh')]);
|
||||
await renderAndOpenPopover();
|
||||
expect(
|
||||
await screen.queryByTestId('filtersNotificationModal__editButton')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls edit action execute when edit button is clicked', async () => {
|
||||
updateFilters([getMockPhraseFilter('ay', 'oh')]);
|
||||
await renderAndOpenPopover();
|
||||
const editButton = await screen.findByTestId('filtersNotificationModal__editButton');
|
||||
await userEvent.click(editButton);
|
||||
expect(mockedEditPanelAction.execute).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,50 +6,66 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiPopover,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiButtonIcon,
|
||||
EuiPopoverTitle,
|
||||
EuiCodeBlock,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiPopover,
|
||||
EuiPopoverFooter,
|
||||
EuiPopoverTitle,
|
||||
} from '@elastic/eui';
|
||||
import { EditPanelAction } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import { AggregateQuery, getAggregateQueryMode, isOfQueryType } from '@kbn/es-query';
|
||||
import { getEditPanelAction } from '@kbn/presentation-panel-plugin/public';
|
||||
import { FilterItems } from '@kbn/unified-search-plugin/public';
|
||||
import { FiltersNotificationActionApi } from './filters_notification_action';
|
||||
import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings';
|
||||
import { FiltersNotificationActionContext } from './filters_notification_action';
|
||||
import { FiltersNotificationPopoverContents } from './filters_notification_popover_contents';
|
||||
|
||||
export interface FiltersNotificationProps {
|
||||
context: FiltersNotificationActionContext;
|
||||
editPanelAction: EditPanelAction;
|
||||
displayName: string;
|
||||
icon: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function FiltersNotificationPopover({
|
||||
editPanelAction,
|
||||
displayName,
|
||||
context,
|
||||
icon,
|
||||
id,
|
||||
}: FiltersNotificationProps) {
|
||||
const { embeddable } = context;
|
||||
export function FiltersNotificationPopover({ api }: { api: FiltersNotificationActionApi }) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [disableEditbutton, setDisableEditButton] = useState(false);
|
||||
|
||||
const editPanelAction = getEditPanelAction();
|
||||
|
||||
const filters = useMemo(() => api.localFilters?.value, [api]);
|
||||
const displayName = dashboardFilterNotificationActionStrings.getDisplayName();
|
||||
|
||||
const { queryString, queryLanguage } = useMemo(() => {
|
||||
const localQuery = api.localQuery?.value;
|
||||
if (!localQuery) return {};
|
||||
if (isOfQueryType(localQuery)) {
|
||||
if (typeof localQuery.query === 'string') {
|
||||
return { queryString: localQuery.query };
|
||||
} else {
|
||||
return { queryString: JSON.stringify(localQuery.query, null, 2) };
|
||||
}
|
||||
} else {
|
||||
setDisableEditButton(true);
|
||||
const language: 'sql' | 'esql' | undefined = getAggregateQueryMode(localQuery);
|
||||
return {
|
||||
queryString: localQuery[language as keyof AggregateQuery],
|
||||
queryLanguage: language,
|
||||
};
|
||||
}
|
||||
}, [api, setDisableEditButton]);
|
||||
|
||||
const dataViews = useMemo(() => api.parentApi.value?.getAllDataViews(), [api]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
color="text"
|
||||
iconType={icon}
|
||||
iconType={'filter'}
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
data-test-subj={`embeddablePanelNotification-${id}`}
|
||||
data-test-subj={`embeddablePanelNotification-${api.uuid.value}`}
|
||||
aria-label={displayName}
|
||||
/>
|
||||
}
|
||||
|
@ -58,10 +74,39 @@ export function FiltersNotificationPopover({
|
|||
anchorPosition="upCenter"
|
||||
>
|
||||
<EuiPopoverTitle>{displayName}</EuiPopoverTitle>
|
||||
<FiltersNotificationPopoverContents
|
||||
context={context}
|
||||
setDisableEditButton={setDisableEditButton}
|
||||
/>
|
||||
<EuiForm
|
||||
component="div"
|
||||
css={css`
|
||||
min-width: 300px;
|
||||
`}
|
||||
>
|
||||
{Boolean(queryString) && (
|
||||
<EuiFormRow
|
||||
label={dashboardFilterNotificationActionStrings.getQueryTitle()}
|
||||
data-test-subj={'filtersNotificationModal__query'}
|
||||
display="rowCompressed"
|
||||
>
|
||||
<EuiCodeBlock
|
||||
language={queryLanguage}
|
||||
paddingSize="s"
|
||||
aria-labelledby={`${dashboardFilterNotificationActionStrings.getQueryTitle()}: ${queryString}`}
|
||||
tabIndex={0} // focus so that keyboard controls will not skip over the code block
|
||||
>
|
||||
{queryString}
|
||||
</EuiCodeBlock>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
{filters && filters.length > 0 && (
|
||||
<EuiFormRow
|
||||
label={dashboardFilterNotificationActionStrings.getFiltersTitle()}
|
||||
data-test-subj={'filtersNotificationModal__filterItems'}
|
||||
>
|
||||
<EuiFlexGroup wrap={true} gutterSize="xs">
|
||||
<FilterItems filters={filters} indexPatterns={dataViews} readOnly={true} />
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</EuiForm>
|
||||
<EuiPopoverFooter>
|
||||
{!disableEditbutton && (
|
||||
<EuiFlexGroup
|
||||
|
@ -76,7 +121,7 @@ export function FiltersNotificationPopover({
|
|||
data-test-subj={'filtersNotificationModal__editButton'}
|
||||
size="s"
|
||||
fill
|
||||
onClick={() => editPanelAction.execute({ embeddable })}
|
||||
onClick={() => editPanelAction.execute({ embeddable: api })}
|
||||
>
|
||||
{dashboardFilterNotificationActionStrings.getEditButtonTitle()}
|
||||
</EuiButton>
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import useMount from 'react-use/lib/useMount';
|
||||
|
||||
import { EuiCodeBlock, EuiFlexGroup, EuiForm, EuiFormRow, EuiSkeletonText } from '@elastic/eui';
|
||||
import { FilterableEmbeddable, IEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { FilterItems } from '@kbn/unified-search-plugin/public';
|
||||
import { css } from '@emotion/react';
|
||||
import {
|
||||
type AggregateQuery,
|
||||
type Filter,
|
||||
getAggregateQueryMode,
|
||||
isOfQueryType,
|
||||
} from '@kbn/es-query';
|
||||
|
||||
import { FiltersNotificationActionContext } from './filters_notification_action';
|
||||
import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
|
||||
export interface FiltersNotificationProps {
|
||||
context: FiltersNotificationActionContext;
|
||||
setDisableEditButton: (flag: boolean) => void;
|
||||
}
|
||||
|
||||
export function FiltersNotificationPopoverContents({
|
||||
context,
|
||||
setDisableEditButton,
|
||||
}: FiltersNotificationProps) {
|
||||
const { embeddable } = context;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const [queryString, setQueryString] = useState<string>('');
|
||||
const [queryLanguage, setQueryLanguage] = useState<'sql' | 'esql' | undefined>();
|
||||
|
||||
const dataViews = useMemo(
|
||||
() => (embeddable.getRoot() as DashboardContainer)?.getAllDataViews(),
|
||||
[embeddable]
|
||||
);
|
||||
|
||||
useMount(() => {
|
||||
Promise.all([
|
||||
(embeddable as IEmbeddable & FilterableEmbeddable).getFilters(),
|
||||
(embeddable as IEmbeddable & FilterableEmbeddable).getQuery(),
|
||||
]).then(([embeddableFilters, embeddableQuery]) => {
|
||||
setFilters(embeddableFilters);
|
||||
if (embeddableQuery) {
|
||||
if (isOfQueryType(embeddableQuery)) {
|
||||
if (typeof embeddableQuery.query === 'string') {
|
||||
setQueryString(embeddableQuery.query);
|
||||
} else {
|
||||
setQueryString(JSON.stringify(embeddableQuery.query, null, 2));
|
||||
}
|
||||
} else {
|
||||
const language = getAggregateQueryMode(embeddableQuery);
|
||||
setQueryLanguage(language);
|
||||
setQueryString(embeddableQuery[language as keyof AggregateQuery]);
|
||||
setDisableEditButton(true);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiForm
|
||||
component="div"
|
||||
css={css`
|
||||
min-width: 300px;
|
||||
`}
|
||||
>
|
||||
<EuiSkeletonText isLoading={isLoading} lines={3}>
|
||||
{queryString !== '' && (
|
||||
<EuiFormRow
|
||||
label={dashboardFilterNotificationActionStrings.getQueryTitle()}
|
||||
display="rowCompressed"
|
||||
fullWidth
|
||||
>
|
||||
<EuiCodeBlock
|
||||
language={queryLanguage}
|
||||
paddingSize="s"
|
||||
aria-labelledby={`${dashboardFilterNotificationActionStrings.getQueryTitle()}: ${queryString}`}
|
||||
tabIndex={0} // focus so that keyboard controls will not skip over the code block
|
||||
>
|
||||
{queryString}
|
||||
</EuiCodeBlock>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
{filters && filters.length > 0 && (
|
||||
<EuiFormRow label={dashboardFilterNotificationActionStrings.getFiltersTitle()} fullWidth>
|
||||
<EuiFlexGroup wrap={true} gutterSize="xs">
|
||||
<FilterItems filters={filters} indexPatterns={dataViews} readOnly={true} />
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</EuiSkeletonText>
|
||||
</EuiForm>
|
||||
);
|
||||
}
|
|
@ -6,20 +6,23 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { CONTEXT_MENU_TRIGGER, PANEL_NOTIFICATION_TRIGGER } from '@kbn/embeddable-plugin/public';
|
||||
import { CoreStart } from '@kbn/core/public';
|
||||
import { getSavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public';
|
||||
import { CONTEXT_MENU_TRIGGER, PANEL_NOTIFICATION_TRIGGER } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
getSavedObjectFinder,
|
||||
SavedObjectFinderProps,
|
||||
} from '@kbn/saved-objects-finder-plugin/public';
|
||||
|
||||
import { ExportCSVAction } from './export_csv_action';
|
||||
import { ClonePanelAction } from './clone_panel_action';
|
||||
import { DashboardStartDependencies } from '../plugin';
|
||||
import { ExpandPanelAction } from './expand_panel_action';
|
||||
import { ReplacePanelAction } from './replace_panel_action';
|
||||
import { AddToLibraryAction } from './add_to_library_action';
|
||||
import { ClonePanelAction } from './clone_panel_action';
|
||||
import { CopyToDashboardAction } from './copy_to_dashboard_action';
|
||||
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
import { ExpandPanelAction } from './expand_panel_action';
|
||||
import { ExportCSVAction } from './export_csv_action';
|
||||
import { FiltersNotificationAction } from './filters_notification_action';
|
||||
import { LibraryNotificationAction } from './library_notification_action';
|
||||
import { ReplacePanelAction } from './replace_panel_action';
|
||||
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
|
||||
interface BuildAllDashboardActionsProps {
|
||||
core: CoreStart;
|
||||
|
@ -27,6 +30,8 @@ interface BuildAllDashboardActionsProps {
|
|||
plugins: DashboardStartDependencies;
|
||||
}
|
||||
|
||||
export type ReplacePanelSOFinder = (props: Omit<SavedObjectFinderProps, 'services'>) => JSX.Element;
|
||||
|
||||
export const buildAllDashboardActions = async ({
|
||||
core,
|
||||
plugins,
|
||||
|
@ -43,7 +48,7 @@ export const buildAllDashboardActions = async ({
|
|||
core.uiSettings,
|
||||
savedObjectsTaggingOss?.getTaggingApi()
|
||||
);
|
||||
const changeViewAction = new ReplacePanelAction(SavedObjectFinder);
|
||||
const changeViewAction = new ReplacePanelAction(SavedObjectFinder as ReplacePanelSOFinder);
|
||||
uiActions.registerAction(changeViewAction);
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction.id);
|
||||
|
||||
|
|
|
@ -6,91 +6,55 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ErrorEmbeddable,
|
||||
IContainer,
|
||||
isErrorEmbeddable,
|
||||
ReferenceOrValueEmbeddable,
|
||||
ViewMode,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { LibraryNotificationAction } from './library_notification_action';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
import { buildMockDashboard } from '../mocks';
|
||||
import {
|
||||
UnlinkFromLibraryAction,
|
||||
UnlinkPanelFromLibraryActionApi,
|
||||
} from './unlink_from_library_action';
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
describe('library notification action', () => {
|
||||
let action: LibraryNotificationAction;
|
||||
let unlinkAction: UnlinkFromLibraryAction;
|
||||
let context: { embeddable: UnlinkPanelFromLibraryActionApi };
|
||||
|
||||
let container: DashboardContainer;
|
||||
let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
|
||||
let unlinkAction: UnlinkFromLibraryAction;
|
||||
let updateViewMode: (viewMode: ViewMode) => void;
|
||||
|
||||
beforeEach(async () => {
|
||||
unlinkAction = {
|
||||
getDisplayName: () => 'unlink from dat library',
|
||||
execute: jest.fn(),
|
||||
} as unknown as UnlinkFromLibraryAction;
|
||||
beforeEach(() => {
|
||||
const viewModeSubject = new BehaviorSubject<ViewMode>('edit');
|
||||
updateViewMode = (viewMode) => viewModeSubject.next(viewMode);
|
||||
|
||||
container = buildMockDashboard();
|
||||
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibanana',
|
||||
unlinkAction = new UnlinkFromLibraryAction();
|
||||
action = new LibraryNotificationAction(unlinkAction);
|
||||
context = {
|
||||
embeddable: {
|
||||
viewMode: viewModeSubject,
|
||||
canUnlinkFromLibrary: jest.fn().mockResolvedValue(true),
|
||||
unlinkFromLibrary: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
}
|
||||
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput
|
||||
>(contactCardEmbeddable, {
|
||||
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id },
|
||||
mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id },
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
embeddable.updateInput({ viewMode: ViewMode.EDIT });
|
||||
});
|
||||
|
||||
test('Notification is incompatible with Error Embeddables', async () => {
|
||||
const action = new LibraryNotificationAction(unlinkAction);
|
||||
const errorEmbeddable = new ErrorEmbeddable(
|
||||
'Wow what an awful error',
|
||||
{ id: ' 404' },
|
||||
embeddable.getRoot() as IContainer
|
||||
);
|
||||
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
|
||||
});
|
||||
it('is incompatible when api is missing required functions', async () => {
|
||||
const emptyContext = { embeddable: {} };
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
test('Notification is shown when embeddable on dashboard has reference type input', async () => {
|
||||
const action = new LibraryNotificationAction(unlinkAction);
|
||||
embeddable.updateInput(await embeddable.getInputAsRefType());
|
||||
expect(await action.isCompatible({ embeddable })).toBe(true);
|
||||
});
|
||||
it('is incompatible when can unlink from library resolves to false', async () => {
|
||||
context.embeddable.canUnlinkFromLibrary = jest.fn().mockResolvedValue(false);
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
test('Notification is not shown when embeddable input is by value', async () => {
|
||||
const action = new LibraryNotificationAction(unlinkAction);
|
||||
embeddable.updateInput(await embeddable.getInputAsValueType());
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Notification is not shown when view mode is set to view', async () => {
|
||||
const action = new LibraryNotificationAction(unlinkAction);
|
||||
embeddable.updateInput(await embeddable.getInputAsRefType());
|
||||
embeddable.updateInput({ viewMode: ViewMode.VIEW });
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
it('calls onChange when view mode changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
action.subscribeToCompatibilityChanges(context, onChange);
|
||||
updateViewMode('view');
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith(false, action));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,70 +8,61 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
ViewMode,
|
||||
type IEmbeddable,
|
||||
isErrorEmbeddable,
|
||||
isReferenceOrValueEmbeddable,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
import { LibraryNotificationPopover } from './library_notification_popover';
|
||||
import { unlinkActionIsCompatible, UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
import { dashboardLibraryNotificationStrings } from './_dashboard_actions_strings';
|
||||
|
||||
export const ACTION_LIBRARY_NOTIFICATION = 'ACTION_LIBRARY_NOTIFICATION';
|
||||
|
||||
export interface LibraryNotificationActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
|
||||
export class LibraryNotificationAction implements Action<LibraryNotificationActionContext> {
|
||||
export class LibraryNotificationAction implements Action<EmbeddableApiContext> {
|
||||
public readonly id = ACTION_LIBRARY_NOTIFICATION;
|
||||
public readonly type = ACTION_LIBRARY_NOTIFICATION;
|
||||
public readonly order = 1;
|
||||
|
||||
constructor(private unlinkAction: UnlinkFromLibraryAction) {}
|
||||
|
||||
private displayName = dashboardLibraryNotificationStrings.getDisplayName();
|
||||
|
||||
private icon = 'folderCheck';
|
||||
|
||||
public readonly MenuItem = ({ context }: { context: LibraryNotificationActionContext }) => {
|
||||
public readonly MenuItem = ({ context }: { context: EmbeddableApiContext }) => {
|
||||
const { embeddable } = context;
|
||||
return (
|
||||
<LibraryNotificationPopover
|
||||
unlinkAction={this.unlinkAction}
|
||||
displayName={this.displayName}
|
||||
context={context}
|
||||
icon={this.getIconType({ embeddable })}
|
||||
id={this.id}
|
||||
/>
|
||||
);
|
||||
if (!unlinkActionIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return <LibraryNotificationPopover unlinkAction={this.unlinkAction} api={embeddable} />;
|
||||
};
|
||||
|
||||
public getDisplayName({ embeddable }: LibraryNotificationActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
return this.displayName;
|
||||
public couldBecomeCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return unlinkActionIsCompatible(embeddable);
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: LibraryNotificationActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
return this.icon;
|
||||
public subscribeToCompatibilityChanges(
|
||||
{ embeddable }: EmbeddableApiContext,
|
||||
onChange: (isCompatible: boolean, action: LibraryNotificationAction) => void
|
||||
) {
|
||||
if (!unlinkActionIsCompatible(embeddable)) return;
|
||||
|
||||
/**
|
||||
* TODO: Upgrade this action by subscribing to changes in the existance of a saved object id. Currently,
|
||||
* this is unnecessary because a link or unlink operation will cause the panel to unmount and remount.
|
||||
*/
|
||||
return embeddable.viewMode.subscribe((viewMode) => {
|
||||
embeddable.canUnlinkFromLibrary().then((canUnlink) => {
|
||||
onChange(viewMode === 'edit' && canUnlink, this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public isCompatible = async ({ embeddable }: LibraryNotificationActionContext) => {
|
||||
return (
|
||||
!isErrorEmbeddable(embeddable) &&
|
||||
embeddable.getRoot().isContainer &&
|
||||
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
|
||||
isReferenceOrValueEmbeddable(embeddable) &&
|
||||
embeddable.inputIsRefType(embeddable.getInput())
|
||||
);
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!unlinkActionIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return dashboardLibraryNotificationStrings.getDisplayName();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!unlinkActionIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'folderCheck';
|
||||
}
|
||||
|
||||
public isCompatible = async ({ embeddable }: EmbeddableApiContext) => {
|
||||
if (!unlinkActionIsCompatible(embeddable)) return false;
|
||||
return embeddable.viewMode.value === 'edit' && embeddable.canUnlinkFromLibrary();
|
||||
};
|
||||
|
||||
public execute = async () => {};
|
||||
|
|
|
@ -6,95 +6,57 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { EuiPopover } from '@elastic/eui';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { LibraryNotificationPopover } from './library_notification_popover';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
UnlinkFromLibraryAction,
|
||||
UnlinkPanelFromLibraryActionApi,
|
||||
} from './unlink_from_library_action';
|
||||
|
||||
import {
|
||||
LibraryNotificationPopover,
|
||||
LibraryNotificationProps,
|
||||
} from './library_notification_popover';
|
||||
import { buildMockDashboard } from '../mocks';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
const mockUnlinkFromLibraryAction = {
|
||||
execute: jest.fn(),
|
||||
isCompatible: jest.fn().mockResolvedValue(true),
|
||||
getDisplayName: jest.fn().mockReturnValue('Test Unlink'),
|
||||
} as unknown as UnlinkFromLibraryAction;
|
||||
|
||||
describe('LibraryNotificationPopover', () => {
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
|
||||
let container: DashboardContainer;
|
||||
let defaultProps: LibraryNotificationProps;
|
||||
describe('library notification popover', () => {
|
||||
let api: UnlinkPanelFromLibraryActionApi;
|
||||
|
||||
beforeEach(async () => {
|
||||
container = buildMockDashboard();
|
||||
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibanana',
|
||||
});
|
||||
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
}
|
||||
|
||||
defaultProps = {
|
||||
unlinkAction: {
|
||||
execute: jest.fn(),
|
||||
getDisplayName: () => 'test unlink',
|
||||
} as unknown as LibraryNotificationProps['unlinkAction'],
|
||||
displayName: 'test display',
|
||||
context: { embeddable: contactCardEmbeddable },
|
||||
icon: 'testIcon',
|
||||
id: 'testId',
|
||||
api = {
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
canUnlinkFromLibrary: jest.fn().mockResolvedValue(true),
|
||||
unlinkFromLibrary: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function mountComponent(props?: Partial<LibraryNotificationProps>) {
|
||||
return mountWithIntl(<LibraryNotificationPopover {...{ ...defaultProps, ...props }} />);
|
||||
}
|
||||
const renderAndOpenPopover = async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<LibraryNotificationPopover api={api} unlinkAction={mockUnlinkFromLibraryAction} />
|
||||
</I18nProvider>
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION')
|
||||
);
|
||||
await waitForEuiPopoverOpen();
|
||||
};
|
||||
|
||||
test('click library notification badge should open and close popover', () => {
|
||||
const component = mountComponent();
|
||||
const btn = findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`);
|
||||
btn.simulate('click');
|
||||
let popover = component.find(EuiPopover);
|
||||
expect(popover.prop('isOpen')).toBe(true);
|
||||
btn.simulate('click');
|
||||
popover = component.find(EuiPopover);
|
||||
expect(popover.prop('isOpen')).toBe(false);
|
||||
it('renders the unlink button', async () => {
|
||||
await renderAndOpenPopover();
|
||||
expect(await screen.findByText('Test Unlink')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('popover should contain button with unlink action display name', () => {
|
||||
const component = mountComponent();
|
||||
const btn = findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`);
|
||||
btn.simulate('click');
|
||||
const popover = component.find(EuiPopover);
|
||||
const unlinkButton = findTestSubject(popover, 'libraryNotificationUnlinkButton');
|
||||
expect(unlinkButton.text()).toEqual('test unlink');
|
||||
});
|
||||
|
||||
test('clicking unlink executes unlink action', () => {
|
||||
const component = mountComponent();
|
||||
const btn = findTestSubject(component, `embeddablePanelNotification-${defaultProps.id}`);
|
||||
btn.simulate('click');
|
||||
const popover = component.find(EuiPopover);
|
||||
const unlinkButton = findTestSubject(popover, 'libraryNotificationUnlinkButton');
|
||||
unlinkButton.simulate('click');
|
||||
expect(defaultProps.unlinkAction.execute).toHaveBeenCalled();
|
||||
it('calls the unlink action execute method on click', async () => {
|
||||
await renderAndOpenPopover();
|
||||
const button = await screen.findByTestId('libraryNotificationUnlinkButton');
|
||||
await userEvent.click(button);
|
||||
expect(mockUnlinkFromLibraryAction.execute).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,36 +18,28 @@ import {
|
|||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
import { LibraryNotificationActionContext } from './library_notification_action';
|
||||
import {
|
||||
UnlinkFromLibraryAction,
|
||||
UnlinkPanelFromLibraryActionApi,
|
||||
} from './unlink_from_library_action';
|
||||
import { dashboardLibraryNotificationStrings } from './_dashboard_actions_strings';
|
||||
|
||||
export interface LibraryNotificationProps {
|
||||
context: LibraryNotificationActionContext;
|
||||
api: UnlinkPanelFromLibraryActionApi;
|
||||
unlinkAction: UnlinkFromLibraryAction;
|
||||
displayName: string;
|
||||
icon: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function LibraryNotificationPopover({
|
||||
unlinkAction,
|
||||
displayName,
|
||||
context,
|
||||
icon,
|
||||
id,
|
||||
}: LibraryNotificationProps) {
|
||||
export function LibraryNotificationPopover({ unlinkAction, api }: LibraryNotificationProps) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const { embeddable } = context;
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
color="text"
|
||||
iconType={icon}
|
||||
iconType={'folderCheck'}
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
data-test-subj={`embeddablePanelNotification-${id}`}
|
||||
data-test-subj={'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION'}
|
||||
aria-label={dashboardLibraryNotificationStrings.getPopoverAriaLabel()}
|
||||
/>
|
||||
}
|
||||
|
@ -55,7 +47,7 @@ export function LibraryNotificationPopover({
|
|||
closePopover={() => setIsPopoverOpen(false)}
|
||||
anchorPosition="upCenter"
|
||||
>
|
||||
<EuiPopoverTitle>{displayName}</EuiPopoverTitle>
|
||||
<EuiPopoverTitle>{dashboardLibraryNotificationStrings.getDisplayName()}</EuiPopoverTitle>
|
||||
<div style={{ width: '300px' }}>
|
||||
<EuiText>
|
||||
<p>{dashboardLibraryNotificationStrings.getTooltip()}</p>
|
||||
|
@ -74,9 +66,9 @@ export function LibraryNotificationPopover({
|
|||
data-test-subj={'libraryNotificationUnlinkButton'}
|
||||
size="s"
|
||||
fill
|
||||
onClick={() => unlinkAction.execute({ embeddable })}
|
||||
onClick={() => unlinkAction.execute({ embeddable: api })}
|
||||
>
|
||||
{unlinkAction.getDisplayName({ embeddable })}
|
||||
{unlinkAction.getDisplayName({ embeddable: api })}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -8,25 +8,21 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
IContainer,
|
||||
IEmbeddable,
|
||||
EmbeddableInput,
|
||||
EmbeddableOutput,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { tracksOverlays } from '@kbn/embeddable-plugin/public';
|
||||
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
|
||||
|
||||
import { ReplacePanelFlyout } from './replace_panel_flyout';
|
||||
import { tracksOverlays } from '@kbn/presentation-containers';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { ReplacePanelActionApi } from './replace_panel_action';
|
||||
import { ReplacePanelFlyout } from './replace_panel_flyout';
|
||||
import { ReplacePanelSOFinder } from '.';
|
||||
|
||||
export async function openReplacePanelFlyout(options: {
|
||||
embeddable: IContainer;
|
||||
savedObjectFinder: React.ComponentType<any>;
|
||||
panelToRemove: IEmbeddable<EmbeddableInput, EmbeddableOutput>;
|
||||
}) {
|
||||
const { embeddable, panelToRemove, savedObjectFinder } = options;
|
||||
|
||||
export const openReplacePanelFlyout = async ({
|
||||
savedObjectFinder,
|
||||
api,
|
||||
}: {
|
||||
savedObjectFinder: ReplacePanelSOFinder;
|
||||
api: ReplacePanelActionApi;
|
||||
}) => {
|
||||
const {
|
||||
settings: {
|
||||
theme: { theme$ },
|
||||
|
@ -34,22 +30,19 @@ export async function openReplacePanelFlyout(options: {
|
|||
overlays: { openFlyout },
|
||||
} = pluginServices.getServices();
|
||||
|
||||
// send the overlay ref to the root embeddable if it is capable of tracking overlays
|
||||
const rootEmbeddable = embeddable.getRoot();
|
||||
const overlayTracker = tracksOverlays(rootEmbeddable) ? rootEmbeddable : undefined;
|
||||
// send the overlay ref to the parent if it is capable of tracking overlays
|
||||
const overlayTracker = tracksOverlays(api.parentApi.value) ? api.parentApi.value : undefined;
|
||||
|
||||
const flyoutSession = openFlyout(
|
||||
toMountPoint(
|
||||
<ReplacePanelFlyout
|
||||
container={embeddable}
|
||||
api={api}
|
||||
onClose={() => {
|
||||
if (flyoutSession) {
|
||||
if (overlayTracker) overlayTracker.clearOverlays();
|
||||
|
||||
flyoutSession.close();
|
||||
}
|
||||
}}
|
||||
panelToRemove={panelToRemove}
|
||||
savedObjectsFinder={savedObjectFinder}
|
||||
/>,
|
||||
{ theme$ }
|
||||
|
@ -65,4 +58,4 @@ export async function openReplacePanelFlyout(options: {
|
|||
);
|
||||
|
||||
overlayTracker?.openOverlay(flyoutSession);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -6,90 +6,55 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { PresentationContainer } from '@kbn/presentation-containers';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ReplacePanelSOFinder } from '.';
|
||||
import { ReplacePanelAction, ReplacePanelActionApi } from './replace_panel_action';
|
||||
|
||||
import { ReplacePanelAction } from './replace_panel_action';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { buildMockDashboard, getSampleDashboardPanel } from '../mocks';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
const mockOpenReplacePanelFlyout = jest.fn();
|
||||
jest.mock('./open_replace_panel_flyout', () => ({
|
||||
openReplacePanelFlyout: () => mockOpenReplacePanelFlyout(),
|
||||
}));
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
describe('replace panel action', () => {
|
||||
let action: ReplacePanelAction;
|
||||
let context: { embeddable: ReplacePanelActionApi };
|
||||
|
||||
let container: DashboardContainer;
|
||||
let embeddable: ContactCardEmbeddable;
|
||||
beforeEach(async () => {
|
||||
container = buildMockDashboard({
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Sam', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
const savedObjectFinder = {} as unknown as ReplacePanelSOFinder;
|
||||
|
||||
beforeEach(() => {
|
||||
action = new ReplacePanelAction(savedObjectFinder);
|
||||
context = {
|
||||
embeddable: {
|
||||
uuid: new BehaviorSubject<string>('superId'),
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
parentApi: new BehaviorSubject<PresentationContainer>({
|
||||
removePanel: jest.fn(),
|
||||
replacePanel: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibana',
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
} else {
|
||||
embeddable = contactCardEmbeddable;
|
||||
}
|
||||
});
|
||||
it('is incompatible when context lacks necessary functions', async () => {
|
||||
const emptyContext = {
|
||||
embeddable: {},
|
||||
};
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
|
||||
test('Executes the replace panel action', () => {
|
||||
let SavedObjectFinder: any;
|
||||
const action = new ReplacePanelAction(SavedObjectFinder);
|
||||
action.execute({ embeddable });
|
||||
});
|
||||
it('is incompatible when view mode is view', async () => {
|
||||
context.embeddable.viewMode = new BehaviorSubject<ViewMode>('view');
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
test('Is not compatible when embeddable is not in a dashboard container', async () => {
|
||||
let SavedObjectFinder: any;
|
||||
const action = new ReplacePanelAction(SavedObjectFinder);
|
||||
expect(
|
||||
await action.isCompatible({
|
||||
embeddable: new ContactCardEmbeddable(
|
||||
{ firstName: 'sue', id: '123' },
|
||||
{ execAction: (() => null) as any }
|
||||
),
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('Execute throws an error when called with an embeddable not in a parent', async () => {
|
||||
let SavedObjectFinder: any;
|
||||
const action = new ReplacePanelAction(SavedObjectFinder);
|
||||
async function check() {
|
||||
await action.execute({ embeddable: container });
|
||||
}
|
||||
await expect(check()).rejects.toThrow(Error);
|
||||
});
|
||||
|
||||
test('Returns title', () => {
|
||||
let SavedObjectFinder: any;
|
||||
const action = new ReplacePanelAction(SavedObjectFinder);
|
||||
expect(action.getDisplayName({ embeddable })).toBeDefined();
|
||||
});
|
||||
|
||||
test('Returns an icon', () => {
|
||||
let SavedObjectFinder: any;
|
||||
const action = new ReplacePanelAction(SavedObjectFinder);
|
||||
expect(action.getIconType({ embeddable })).toBeDefined();
|
||||
it('calls open replace panel flyout on execute', async () => {
|
||||
action.execute(context);
|
||||
expect(mockOpenReplacePanelFlyout).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,65 +6,69 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { type IEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
apiIsPresentationContainer,
|
||||
PresentationContainer,
|
||||
TracksOverlays,
|
||||
} from '@kbn/presentation-containers';
|
||||
import {
|
||||
apiPublishesUniqueId,
|
||||
apiPublishesParentApi,
|
||||
apiPublishesViewMode,
|
||||
EmbeddableApiContext,
|
||||
PublishesUniqueId,
|
||||
PublishesPanelTitle,
|
||||
PublishesParentApi,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { ReplacePanelSOFinder } from '.';
|
||||
import { openReplacePanelFlyout } from './open_replace_panel_flyout';
|
||||
import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings';
|
||||
import { type DashboardContainer, DASHBOARD_CONTAINER_TYPE } from '../dashboard_container';
|
||||
|
||||
export const ACTION_REPLACE_PANEL = 'replacePanel';
|
||||
|
||||
function isDashboard(embeddable: IEmbeddable): embeddable is DashboardContainer {
|
||||
return embeddable.type === DASHBOARD_CONTAINER_TYPE;
|
||||
}
|
||||
export type ReplacePanelActionApi = PublishesViewMode &
|
||||
PublishesUniqueId &
|
||||
Partial<PublishesPanelTitle> &
|
||||
PublishesParentApi<PresentationContainer & Partial<TracksOverlays>>;
|
||||
|
||||
export interface ReplacePanelActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
const isApiCompatible = (api: unknown | null): api is ReplacePanelActionApi =>
|
||||
Boolean(
|
||||
apiPublishesUniqueId(api) &&
|
||||
apiPublishesViewMode(api) &&
|
||||
apiPublishesParentApi(api) &&
|
||||
apiIsPresentationContainer(api.parentApi.value)
|
||||
);
|
||||
|
||||
export class ReplacePanelAction implements Action<ReplacePanelActionContext> {
|
||||
export class ReplacePanelAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_REPLACE_PANEL;
|
||||
public readonly id = ACTION_REPLACE_PANEL;
|
||||
public order = 3;
|
||||
|
||||
constructor(private savedobjectfinder: React.ComponentType<any>) {}
|
||||
constructor(private savedObjectFinder: ReplacePanelSOFinder) {}
|
||||
|
||||
public getDisplayName({ embeddable }: ReplacePanelActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return dashboardReplacePanelActionStrings.getDisplayName();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: ReplacePanelActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'kqlOperand';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: ReplacePanelActionContext) {
|
||||
if (embeddable.getInput().viewMode) {
|
||||
if (embeddable.getInput().viewMode === ViewMode.VIEW) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return Boolean(embeddable.parent && isDashboard(embeddable.parent));
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) return false;
|
||||
return embeddable.viewMode.value === 'edit';
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: ReplacePanelActionContext) {
|
||||
if (!embeddable.parent || !isDashboard(embeddable.parent)) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
|
||||
const view = embeddable;
|
||||
const dash = embeddable.parent;
|
||||
openReplacePanelFlyout({
|
||||
embeddable: dash,
|
||||
savedObjectFinder: this.savedobjectfinder,
|
||||
panelToRemove: view,
|
||||
api: embeddable,
|
||||
savedObjectFinder: this.savedObjectFinder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,27 +6,20 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EmbeddableInput,
|
||||
EmbeddableOutput,
|
||||
IContainer,
|
||||
IEmbeddable,
|
||||
SavedObjectEmbeddableInput,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { Toast } from '@kbn/core/public';
|
||||
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { ReplacePanelActionApi } from './replace_panel_action';
|
||||
import { dashboardReplacePanelActionStrings } from './_dashboard_actions_strings';
|
||||
import { DashboardContainer } from '../dashboard_container';
|
||||
import { ReplacePanelSOFinder } from '.';
|
||||
|
||||
interface Props {
|
||||
container: IContainer;
|
||||
savedObjectsFinder: React.ComponentType<any>;
|
||||
api: ReplacePanelActionApi;
|
||||
savedObjectsFinder: ReplacePanelSOFinder;
|
||||
onClose: () => void;
|
||||
panelToRemove: IEmbeddable<EmbeddableInput, EmbeddableOutput>;
|
||||
}
|
||||
|
||||
export class ReplacePanelFlyout extends React.Component<Props> {
|
||||
|
@ -56,18 +49,10 @@ export class ReplacePanelFlyout extends React.Component<Props> {
|
|||
};
|
||||
|
||||
public onReplacePanel = async (savedObjectId: string, type: string, name: string) => {
|
||||
const { panelToRemove, container } = this.props;
|
||||
|
||||
const id = await container.replaceEmbeddable<SavedObjectEmbeddableInput>(
|
||||
panelToRemove.id,
|
||||
{
|
||||
savedObjectId,
|
||||
},
|
||||
type,
|
||||
true
|
||||
);
|
||||
|
||||
(container as DashboardContainer).setHighlightPanelId(id);
|
||||
this.props.api.parentApi.value.replacePanel(this.props.api.uuid.value, {
|
||||
panelType: type,
|
||||
initialState: { savedObjectId },
|
||||
});
|
||||
this.showToast(name);
|
||||
this.props.onClose();
|
||||
};
|
||||
|
@ -92,14 +77,16 @@ export class ReplacePanelFlyout extends React.Component<Props> {
|
|||
/>
|
||||
);
|
||||
|
||||
const panelToReplace = 'Replace panel ' + this.props.panelToRemove.getTitle() + ' with:';
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>
|
||||
<span>{panelToReplace}</span>
|
||||
<span>
|
||||
{dashboardReplacePanelActionStrings.getFlyoutHeader(
|
||||
this.props.api.panelTitle?.value ?? this.props.api.defaultPanelTitle?.value
|
||||
)}
|
||||
</span>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
|
|
|
@ -6,154 +6,71 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ViewMode,
|
||||
IContainer,
|
||||
ErrorEmbeddable,
|
||||
isErrorEmbeddable,
|
||||
ReferenceOrValueEmbeddable,
|
||||
SavedObjectEmbeddableInput,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
|
||||
import { buildMockDashboard } from '../mocks';
|
||||
import { DashboardPanelState } from '../../common';
|
||||
import { ViewMode } from '@kbn/presentation-publishing';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { UnlinkFromLibraryAction } from './unlink_from_library_action';
|
||||
import { DashboardContainer } from '../dashboard_container/embeddable/dashboard_container';
|
||||
import {
|
||||
UnlinkFromLibraryAction,
|
||||
UnlinkPanelFromLibraryActionApi,
|
||||
} from './unlink_from_library_action';
|
||||
|
||||
let container: DashboardContainer;
|
||||
let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
|
||||
describe('Unlink from library action', () => {
|
||||
let action: UnlinkFromLibraryAction;
|
||||
let context: { embeddable: UnlinkPanelFromLibraryActionApi };
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
beforeEach(() => {
|
||||
action = new UnlinkFromLibraryAction();
|
||||
context = {
|
||||
embeddable: {
|
||||
unlinkFromLibrary: jest.fn(),
|
||||
canUnlinkFromLibrary: jest.fn().mockResolvedValue(true),
|
||||
|
||||
beforeEach(async () => {
|
||||
container = buildMockDashboard();
|
||||
|
||||
const contactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Kibanana',
|
||||
viewMode: new BehaviorSubject<ViewMode>('edit'),
|
||||
panelTitle: new BehaviorSubject<string | undefined>('A very compatible API'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (isErrorEmbeddable(contactCardEmbeddable)) {
|
||||
throw new Error('Failed to create embeddable');
|
||||
}
|
||||
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput
|
||||
>(contactCardEmbeddable, {
|
||||
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id },
|
||||
mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id },
|
||||
it('is compatible when api meets all conditions', async () => {
|
||||
expect(await action.isCompatible(context)).toBe(true);
|
||||
});
|
||||
embeddable.updateInput({ viewMode: ViewMode.EDIT });
|
||||
});
|
||||
|
||||
test('Unlink is incompatible with Error Embeddables', async () => {
|
||||
const action = new UnlinkFromLibraryAction();
|
||||
const errorEmbeddable = new ErrorEmbeddable(
|
||||
'Wow what an awful error',
|
||||
{ id: ' 404' },
|
||||
embeddable.getRoot() as IContainer
|
||||
);
|
||||
expect(await action.isCompatible({ embeddable: errorEmbeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Unlink is compatible when embeddable on dashboard has reference type input', async () => {
|
||||
const action = new UnlinkFromLibraryAction();
|
||||
embeddable.updateInput(await embeddable.getInputAsRefType());
|
||||
expect(await action.isCompatible({ embeddable })).toBe(true);
|
||||
});
|
||||
|
||||
test('Unlink is not compatible when embeddable input is by value', async () => {
|
||||
const action = new UnlinkFromLibraryAction();
|
||||
embeddable.updateInput(await embeddable.getInputAsValueType());
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Unlink is not compatible when view mode is set to view', async () => {
|
||||
const action = new UnlinkFromLibraryAction();
|
||||
embeddable.updateInput(await embeddable.getInputAsRefType());
|
||||
embeddable.updateInput({ viewMode: ViewMode.VIEW });
|
||||
expect(await action.isCompatible({ embeddable })).toBe(false);
|
||||
});
|
||||
|
||||
test('Unlink is not compatible when embeddable is not in a dashboard container', async () => {
|
||||
let orphanContactCard = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Orphan',
|
||||
it('is incompatible when context lacks necessary functions', async () => {
|
||||
const emptyContext = {
|
||||
embeddable: {},
|
||||
};
|
||||
expect(await action.isCompatible(emptyContext)).toBe(false);
|
||||
});
|
||||
orphanContactCard = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput
|
||||
>(orphanContactCard, {
|
||||
mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id },
|
||||
mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id },
|
||||
|
||||
it('is incompatible when view mode is view', async () => {
|
||||
context.embeddable.viewMode = new BehaviorSubject<ViewMode>('view');
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
const action = new UnlinkFromLibraryAction();
|
||||
expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false);
|
||||
});
|
||||
|
||||
test('Unlink replaces embeddableId and retains panel count', async () => {
|
||||
const dashboard = embeddable.getRoot() as IContainer;
|
||||
const originalPanelCount = Object.keys(dashboard.getInput().panels).length;
|
||||
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
|
||||
const action = new UnlinkFromLibraryAction();
|
||||
await action.execute({ embeddable });
|
||||
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount);
|
||||
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
expect(newPanelId).toBeDefined();
|
||||
const newPanel = container.getInput().panels[newPanelId!];
|
||||
expect(newPanel.type).toEqual(embeddable.type);
|
||||
});
|
||||
|
||||
test('Unlink unwraps all attributes from savedObject', async () => {
|
||||
const complicatedAttributes = {
|
||||
attribute1: 'The best attribute',
|
||||
attribute2: 22,
|
||||
attribute3: ['array', 'of', 'strings'],
|
||||
attribute4: { nestedattribute: 'hello from the nest' },
|
||||
};
|
||||
|
||||
embeddable = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
{ attributes: unknown; id: string },
|
||||
SavedObjectEmbeddableInput
|
||||
>(embeddable, {
|
||||
mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id },
|
||||
mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id },
|
||||
it('is incompatible when canUnlinkFromLibrary returns false', async () => {
|
||||
context.embeddable.canUnlinkFromLibrary = jest.fn().mockResolvedValue(false);
|
||||
expect(await action.isCompatible(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('calls the unlinkFromLibrary method on execute', async () => {
|
||||
action.execute(context);
|
||||
expect(context.embeddable.unlinkFromLibrary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a toast with a title from the API when successful', async () => {
|
||||
await action.execute(context);
|
||||
expect(pluginServices.getServices().notifications.toasts.addSuccess).toHaveBeenCalledWith({
|
||||
'data-test-subj': 'unlinkPanelSuccess',
|
||||
title: "Panel 'A very compatible API' is no longer connected to the library.",
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a danger toast when the link operation is unsuccessful', async () => {
|
||||
context.embeddable.unlinkFromLibrary = jest.fn().mockRejectedValue(new Error('Oh dang'));
|
||||
await action.execute(context);
|
||||
expect(pluginServices.getServices().notifications.toasts.addDanger).toHaveBeenCalledWith({
|
||||
'data-test-subj': 'unlinkPanelFailure',
|
||||
title: "An error occured while unlinking 'A very compatible API' from the library.",
|
||||
});
|
||||
});
|
||||
const dashboard = embeddable.getRoot() as IContainer;
|
||||
const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels));
|
||||
const action = new UnlinkFromLibraryAction();
|
||||
await action.execute({ embeddable });
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
expect(newPanelId).toBeDefined();
|
||||
const newPanel = container.getInput().panels[newPanelId!] as DashboardPanelState & {
|
||||
explicitInput: { attributes: unknown };
|
||||
};
|
||||
expect(newPanel.type).toEqual(embeddable.type);
|
||||
expect((newPanel.explicitInput as { attributes: unknown }).attributes).toEqual(
|
||||
complicatedAttributes
|
||||
);
|
||||
});
|
||||
|
|
|
@ -6,29 +6,30 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ViewMode,
|
||||
type PanelState,
|
||||
type IEmbeddable,
|
||||
isErrorEmbeddable,
|
||||
PanelNotFoundError,
|
||||
type EmbeddableInput,
|
||||
isReferenceOrValueEmbeddable,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { apiCanUnlinkFromLibrary, CanUnlinkFromLibrary } from '@kbn/presentation-library';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { DashboardPanelState } from '../../common';
|
||||
import {
|
||||
apiPublishesViewMode,
|
||||
EmbeddableApiContext,
|
||||
PublishesPanelTitle,
|
||||
PublishesViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { pluginServices } from '../services/plugin_services';
|
||||
import { dashboardUnlinkFromLibraryActionStrings } from './_dashboard_actions_strings';
|
||||
import { type DashboardContainer, DASHBOARD_CONTAINER_TYPE } from '../dashboard_container';
|
||||
|
||||
export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
|
||||
|
||||
export interface UnlinkFromLibraryActionContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
export type UnlinkPanelFromLibraryActionApi = PublishesViewMode &
|
||||
CanUnlinkFromLibrary &
|
||||
Partial<PublishesPanelTitle>;
|
||||
|
||||
export class UnlinkFromLibraryAction implements Action<UnlinkFromLibraryActionContext> {
|
||||
export const unlinkActionIsCompatible = (
|
||||
api: unknown | null
|
||||
): api is UnlinkPanelFromLibraryActionApi =>
|
||||
Boolean(apiPublishesViewMode(api) && apiCanUnlinkFromLibrary(api));
|
||||
|
||||
export class UnlinkFromLibraryAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_UNLINK_FROM_LIBRARY;
|
||||
public readonly id = ACTION_UNLINK_FROM_LIBRARY;
|
||||
public order = 15;
|
||||
|
@ -41,64 +42,35 @@ export class UnlinkFromLibraryAction implements Action<UnlinkFromLibraryActionCo
|
|||
} = pluginServices.getServices());
|
||||
}
|
||||
|
||||
public getDisplayName({ embeddable }: UnlinkFromLibraryActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getDisplayName({ embeddable }: EmbeddableApiContext) {
|
||||
if (!unlinkActionIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return dashboardUnlinkFromLibraryActionStrings.getDisplayName();
|
||||
}
|
||||
|
||||
public getIconType({ embeddable }: UnlinkFromLibraryActionContext) {
|
||||
if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) {
|
||||
throw new IncompatibleActionError();
|
||||
}
|
||||
public getIconType({ embeddable }: EmbeddableApiContext) {
|
||||
if (!unlinkActionIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
return 'folderExclamation';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: UnlinkFromLibraryActionContext) {
|
||||
return Boolean(
|
||||
!isErrorEmbeddable(embeddable) &&
|
||||
embeddable.getInput()?.viewMode !== ViewMode.VIEW &&
|
||||
embeddable.getRoot() &&
|
||||
embeddable.getRoot().isContainer &&
|
||||
embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE &&
|
||||
isReferenceOrValueEmbeddable(embeddable) &&
|
||||
embeddable.inputIsRefType(embeddable.getInput())
|
||||
);
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
if (!unlinkActionIsCompatible(embeddable)) return false;
|
||||
return embeddable.viewMode.value === 'edit' && (await embeddable.canUnlinkFromLibrary());
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: UnlinkFromLibraryActionContext) {
|
||||
if (!isReferenceOrValueEmbeddable(embeddable)) {
|
||||
throw new IncompatibleActionError();
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!unlinkActionIsCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
const title = embeddable.panelTitle?.value ?? embeddable.defaultPanelTitle?.value;
|
||||
try {
|
||||
await embeddable.unlinkFromLibrary();
|
||||
this.toastsService.addSuccess({
|
||||
title: dashboardUnlinkFromLibraryActionStrings.getSuccessMessage(title ? `'${title}'` : ''),
|
||||
'data-test-subj': 'unlinkPanelSuccess',
|
||||
});
|
||||
} catch (e) {
|
||||
this.toastsService.addDanger({
|
||||
title: dashboardUnlinkFromLibraryActionStrings.getFailureMessage(title ? `'${title}'` : ''),
|
||||
'data-test-subj': 'unlinkPanelFailure',
|
||||
});
|
||||
}
|
||||
|
||||
const newInput = await embeddable.getInputAsValueType();
|
||||
embeddable.updateInput(newInput);
|
||||
|
||||
const dashboard = embeddable.getRoot() as DashboardContainer;
|
||||
const panelToReplace = dashboard.getInput().panels[embeddable.id] as DashboardPanelState;
|
||||
|
||||
if (!panelToReplace) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
|
||||
const newPanel: PanelState<EmbeddableInput> = {
|
||||
type: embeddable.type,
|
||||
explicitInput: { ...newInput, title: embeddable.getTitle() },
|
||||
};
|
||||
const replacedPanelId = await dashboard.replacePanel(panelToReplace, newPanel, true);
|
||||
|
||||
const title = dashboardUnlinkFromLibraryActionStrings.getSuccessMessage(
|
||||
embeddable.getTitle() ? `'${embeddable.getTitle()}'` : ''
|
||||
);
|
||||
|
||||
if (dashboard.getExpandedPanelId() !== undefined) {
|
||||
dashboard.setExpandedPanelId(replacedPanelId);
|
||||
}
|
||||
|
||||
this.toastsService.addSuccess({
|
||||
title,
|
||||
'data-test-subj': 'unlinkPanelSuccess',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
@import '../../../embeddable/public/variables';
|
||||
|
||||
@import './component/grid/index';
|
||||
@import './component/viewport/index';
|
||||
|
||||
|
|
|
@ -89,7 +89,7 @@
|
|||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: $embEditingModeHoverColor;
|
||||
background-color: transparentize($euiColorWarning, lightOrDarkTheme(.9, .7));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
|||
import { EuiLoadingChart } from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { EmbeddablePhaseEvent, EmbeddablePanel, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { PhaseEvent } from '@kbn/presentation-publishing';
|
||||
import { EmbeddablePanel, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
|
@ -27,7 +28,7 @@ export interface Props extends DivProps {
|
|||
focusedPanelId?: string;
|
||||
key: string;
|
||||
isRenderable?: boolean;
|
||||
onPanelStatusChange?: (info: EmbeddablePhaseEvent) => void;
|
||||
onPanelStatusChange?: (info: PhaseEvent) => void;
|
||||
}
|
||||
|
||||
export const Item = React.forwardRef<HTMLDivElement, Props>(
|
||||
|
@ -116,7 +117,7 @@ export const Item = React.forwardRef<HTMLDivElement, Props>(
|
|||
{children}
|
||||
</>
|
||||
) : (
|
||||
<div className="embPanel embPanel-isLoading">
|
||||
<div>
|
||||
<EuiLoadingChart size="l" mono />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -6,10 +6,9 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PhaseEvent } from '@kbn/presentation-publishing';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { EmbeddablePhaseEvent } from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { useDashboardContainer } from '../../embeddable/dashboard_container';
|
||||
import { DashboardLoadedEventStatus, DashboardRenderPerformanceStats } from '../../types';
|
||||
|
||||
|
@ -37,7 +36,7 @@ export const useDashboardPerformanceTracker = ({ panelCount }: { panelCount: num
|
|||
performanceRefs.current = getDefaultPerformanceTracker();
|
||||
|
||||
const onPanelStatusChange = useCallback(
|
||||
(info: EmbeddablePhaseEvent) => {
|
||||
(info: PhaseEvent) => {
|
||||
if (performanceRefs.current.panelIds[info.id] === undefined || info.status === 'loading') {
|
||||
performanceRefs.current.panelIds[info.id] = {};
|
||||
} else if (info.status === 'error') {
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* 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 { CoreStart } from '@kbn/core/public';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { isErrorEmbeddable, ReferenceOrValueEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
|
||||
import { duplicateDashboardPanel, incrementPanelTitle } from './duplicate_dashboard_panel';
|
||||
import { buildMockDashboard, getSampleDashboardPanel } from '../../../mocks';
|
||||
import { pluginServices } from '../../../services/plugin_services';
|
||||
import { DashboardContainer } from '../dashboard_container';
|
||||
|
||||
let container: DashboardContainer;
|
||||
let genericEmbeddable: ContactCardEmbeddable;
|
||||
let byRefOrValEmbeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable;
|
||||
let coreStart: CoreStart;
|
||||
beforeEach(async () => {
|
||||
coreStart = coreMock.createStart();
|
||||
coreStart.savedObjects.client = {
|
||||
...coreStart.savedObjects.client,
|
||||
get: jest.fn().mockImplementation(() => ({ attributes: { title: 'Holy moly' } })),
|
||||
find: jest.fn().mockImplementation(() => ({ total: 15 })),
|
||||
create: jest.fn().mockImplementation(() => ({ id: 'brandNewSavedObject' })),
|
||||
};
|
||||
|
||||
const mockEmbeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockEmbeddableFactory);
|
||||
container = buildMockDashboard({
|
||||
overrides: {
|
||||
panels: {
|
||||
'123': getSampleDashboardPanel<ContactCardEmbeddableInput>({
|
||||
explicitInput: { firstName: 'Kibanana', id: '123' },
|
||||
type: CONTACT_CARD_EMBEDDABLE,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const refOrValContactCardEmbeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'RefOrValEmbeddable',
|
||||
});
|
||||
|
||||
const nonRefOrValueContactCard = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Not a refOrValEmbeddable',
|
||||
});
|
||||
|
||||
if (
|
||||
isErrorEmbeddable(refOrValContactCardEmbeddable) ||
|
||||
isErrorEmbeddable(nonRefOrValueContactCard)
|
||||
) {
|
||||
throw new Error('Failed to create embeddables');
|
||||
} else {
|
||||
genericEmbeddable = nonRefOrValueContactCard;
|
||||
byRefOrValEmbeddable = embeddablePluginMock.mockRefOrValEmbeddable<
|
||||
ContactCardEmbeddable,
|
||||
ContactCardEmbeddableInput
|
||||
>(refOrValContactCardEmbeddable, {
|
||||
mockedByReferenceInput: {
|
||||
savedObjectId: 'testSavedObjectId',
|
||||
id: refOrValContactCardEmbeddable.id,
|
||||
},
|
||||
mockedByValueInput: { firstName: 'RefOrValEmbeddable', id: refOrValContactCardEmbeddable.id },
|
||||
});
|
||||
jest.spyOn(byRefOrValEmbeddable, 'getInputAsValueType');
|
||||
}
|
||||
});
|
||||
|
||||
test('Duplication adds a new embeddable', async () => {
|
||||
const originalPanelCount = Object.keys(container.getInput().panels).length;
|
||||
const originalPanelKeySet = new Set(Object.keys(container.getInput().panels));
|
||||
await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id);
|
||||
|
||||
expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount + 1);
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
expect(newPanelId).toBeDefined();
|
||||
const newPanel = container.getInput().panels[newPanelId!];
|
||||
expect(newPanel.type).toEqual(byRefOrValEmbeddable.type);
|
||||
});
|
||||
|
||||
test('Duplicates a RefOrVal embeddable by value', async () => {
|
||||
const originalPanelKeySet = new Set(Object.keys(container.getInput().panels));
|
||||
await duplicateDashboardPanel.bind(container)(byRefOrValEmbeddable.id);
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
|
||||
const originalFirstName = (
|
||||
container.getInput().panels[byRefOrValEmbeddable.id].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
const newFirstName = (
|
||||
container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
expect(byRefOrValEmbeddable.getInputAsValueType).toHaveBeenCalled();
|
||||
|
||||
expect(originalFirstName).toEqual(newFirstName);
|
||||
expect(container.getInput().panels[newPanelId!].type).toEqual(byRefOrValEmbeddable.type);
|
||||
});
|
||||
|
||||
test('Duplicates a non RefOrVal embeddable by value', async () => {
|
||||
const originalPanelKeySet = new Set(Object.keys(container.getInput().panels));
|
||||
await duplicateDashboardPanel.bind(container)(genericEmbeddable.id);
|
||||
const newPanelId = Object.keys(container.getInput().panels).find(
|
||||
(key) => !originalPanelKeySet.has(key)
|
||||
);
|
||||
|
||||
const originalFirstName = (
|
||||
container.getInput().panels[genericEmbeddable.id].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
const newFirstName = (
|
||||
container.getInput().panels[newPanelId!].explicitInput as ContactCardEmbeddableInput
|
||||
).firstName;
|
||||
|
||||
expect(originalFirstName).toEqual(newFirstName);
|
||||
expect(container.getInput().panels[newPanelId!].type).toEqual(genericEmbeddable.type);
|
||||
});
|
||||
|
||||
test('Gets a unique title from the dashboard', async () => {
|
||||
expect(await incrementPanelTitle(byRefOrValEmbeddable, '')).toEqual('');
|
||||
|
||||
container.getPanelTitles = jest.fn().mockImplementation(() => {
|
||||
return ['testDuplicateTitle', 'testDuplicateTitle (copy)', 'testUniqueTitle'];
|
||||
});
|
||||
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testUniqueTitle')).toEqual(
|
||||
'testUniqueTitle (copy)'
|
||||
);
|
||||
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
|
||||
'testDuplicateTitle (copy 1)'
|
||||
);
|
||||
|
||||
container.getPanelTitles = jest.fn().mockImplementation(() => {
|
||||
return ['testDuplicateTitle', 'testDuplicateTitle (copy)'].concat(
|
||||
Array.from([...Array(39)], (_, index) => `testDuplicateTitle (copy ${index + 1})`)
|
||||
);
|
||||
});
|
||||
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
|
||||
'testDuplicateTitle (copy 40)'
|
||||
);
|
||||
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
|
||||
'testDuplicateTitle (copy 40)'
|
||||
);
|
||||
|
||||
container.getPanelTitles = jest.fn().mockImplementation(() => {
|
||||
return ['testDuplicateTitle (copy 100)'];
|
||||
});
|
||||
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle')).toEqual(
|
||||
'testDuplicateTitle (copy 101)'
|
||||
);
|
||||
expect(await incrementPanelTitle(byRefOrValEmbeddable, 'testDuplicateTitle (copy 100)')).toEqual(
|
||||
'testDuplicateTitle (copy 101)'
|
||||
);
|
||||
});
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* 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 {
|
||||
EmbeddableInput,
|
||||
IEmbeddable,
|
||||
isReferenceOrValueEmbeddable,
|
||||
PanelNotFoundError,
|
||||
PanelState,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { filter, map, max } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { dashboardClonePanelActionStrings } from '../../../dashboard_actions/_dashboard_actions_strings';
|
||||
import { pluginServices } from '../../../services/plugin_services';
|
||||
import { placeClonePanel } from '../../component/panel_placement';
|
||||
import { DashboardContainer } from '../dashboard_container';
|
||||
|
||||
export async function duplicateDashboardPanel(this: DashboardContainer, idToDuplicate: string) {
|
||||
const panelToClone = this.getInput().panels[idToDuplicate] as DashboardPanelState;
|
||||
const embeddable = this.getChild(idToDuplicate);
|
||||
if (!panelToClone || !embeddable) {
|
||||
throw new PanelNotFoundError();
|
||||
}
|
||||
|
||||
// duplicate panel input
|
||||
const duplicatedPanelState: PanelState<EmbeddableInput> = await (async () => {
|
||||
const newTitle = await incrementPanelTitle(embeddable, embeddable.getTitle() || '');
|
||||
const id = uuidv4();
|
||||
if (isReferenceOrValueEmbeddable(embeddable)) {
|
||||
return {
|
||||
type: embeddable.type,
|
||||
explicitInput: {
|
||||
...(await embeddable.getInputAsValueType()),
|
||||
hidePanelTitles: panelToClone.explicitInput.hidePanelTitles,
|
||||
...(newTitle ? { title: newTitle } : {}),
|
||||
id,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: embeddable.type,
|
||||
explicitInput: {
|
||||
...panelToClone.explicitInput,
|
||||
title: newTitle,
|
||||
id,
|
||||
},
|
||||
};
|
||||
})();
|
||||
pluginServices.getServices().notifications.toasts.addSuccess({
|
||||
title: dashboardClonePanelActionStrings.getSuccessMessage(),
|
||||
'data-test-subj': 'addObjectToContainerSuccess',
|
||||
});
|
||||
|
||||
const { newPanelPlacement, otherPanels } = placeClonePanel({
|
||||
width: panelToClone.gridData.w,
|
||||
height: panelToClone.gridData.h,
|
||||
currentPanels: this.getInput().panels,
|
||||
placeBesideId: panelToClone.explicitInput.id,
|
||||
});
|
||||
|
||||
const newPanel = {
|
||||
...duplicatedPanelState,
|
||||
gridData: {
|
||||
...newPanelPlacement,
|
||||
i: duplicatedPanelState.explicitInput.id,
|
||||
},
|
||||
};
|
||||
|
||||
this.updateInput({
|
||||
panels: {
|
||||
...otherPanels,
|
||||
[newPanel.explicitInput.id]: newPanel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const incrementPanelTitle = async (embeddable: IEmbeddable, rawTitle: string) => {
|
||||
if (rawTitle === '') return '';
|
||||
|
||||
const clonedTag = dashboardClonePanelActionStrings.getClonedTag();
|
||||
const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g');
|
||||
const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g');
|
||||
const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim();
|
||||
const dashboard: DashboardContainer = embeddable.getRoot() as DashboardContainer;
|
||||
const similarTitles = filter(await dashboard.getPanelTitles(), (title: string) => {
|
||||
return title.startsWith(baseTitle);
|
||||
});
|
||||
|
||||
const cloneNumbers = map(similarTitles, (title: string) => {
|
||||
if (title.match(cloneRegex)) return 0;
|
||||
const cloneTag = title.match(cloneNumberRegex);
|
||||
return cloneTag ? parseInt(cloneTag[0].replace(/[^0-9.]/g, ''), 10) : -1;
|
||||
});
|
||||
const similarBaseTitlesCount = max(cloneNumbers) || 0;
|
||||
|
||||
return similarBaseTitlesCount < 0
|
||||
? baseTitle + ` (${clonedTag})`
|
||||
: baseTitle + ` (${clonedTag} ${similarBaseTitlesCount + 1})`;
|
||||
};
|
|
@ -8,5 +8,5 @@
|
|||
|
||||
export { showSettings } from './show_settings';
|
||||
export { addFromLibrary } from './add_panel_from_library';
|
||||
export { addOrUpdateEmbeddable } from './panel_management';
|
||||
export { runSaveAs, runQuickSave, runClone } from './run_save_functions';
|
||||
export { addOrUpdateEmbeddable, replacePanel } from './panel_management';
|
||||
|
|
|
@ -6,14 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EmbeddableInput,
|
||||
EmbeddableOutput,
|
||||
IEmbeddable,
|
||||
PanelState,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
|
||||
import { DashboardPanelState } from '../../../../common';
|
||||
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '@kbn/embeddable-plugin/public';
|
||||
import { DashboardContainer } from '../dashboard_container';
|
||||
|
||||
export async function addOrUpdateEmbeddable<
|
||||
|
@ -23,31 +16,24 @@ export async function addOrUpdateEmbeddable<
|
|||
>(this: DashboardContainer, type: string, explicitInput: Partial<EEI>, embeddableId?: string) {
|
||||
const idToReplace = embeddableId || explicitInput.id;
|
||||
if (idToReplace && this.input.panels[idToReplace]) {
|
||||
return this.replacePanel(this.input.panels[idToReplace], {
|
||||
const previousPanelState = this.input.panels[idToReplace];
|
||||
const newPanelState = {
|
||||
type,
|
||||
explicitInput: {
|
||||
...explicitInput,
|
||||
id: idToReplace,
|
||||
},
|
||||
});
|
||||
};
|
||||
const panelId = await this.replaceEmbeddable(
|
||||
previousPanelState.explicitInput.id,
|
||||
{
|
||||
...newPanelState.explicitInput,
|
||||
id: previousPanelState.explicitInput.id,
|
||||
},
|
||||
newPanelState.type,
|
||||
true
|
||||
);
|
||||
return panelId;
|
||||
}
|
||||
return this.addNewEmbeddable<EEI, EEO, E>(type, explicitInput);
|
||||
}
|
||||
|
||||
export async function replacePanel(
|
||||
this: DashboardContainer,
|
||||
previousPanelState: DashboardPanelState<EmbeddableInput>,
|
||||
newPanelState: Partial<PanelState>,
|
||||
generateNewId?: boolean
|
||||
): Promise<string> {
|
||||
const panelId = await this.replaceEmbeddable(
|
||||
previousPanelState.explicitInput.id,
|
||||
{
|
||||
...newPanelState.explicitInput,
|
||||
id: previousPanelState.explicitInput.id,
|
||||
},
|
||||
newPanelState.type,
|
||||
generateNewId
|
||||
);
|
||||
return panelId;
|
||||
}
|
||||
|
|
|
@ -6,35 +6,21 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
|
||||
import { isErrorEmbeddable, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
ViewMode,
|
||||
EmbeddablePanel,
|
||||
isErrorEmbeddable,
|
||||
CONTEXT_MENU_TRIGGER,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
EMPTY_EMBEDDABLE,
|
||||
ContactCardEmbeddable,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ContactCardEmbeddableFactory,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddableFactory,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
EMPTY_EMBEDDABLE,
|
||||
} from '@kbn/embeddable-plugin/public/lib/test_samples/embeddables';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import type { TimeRange } from '@kbn/es-query';
|
||||
import { findTestSubject, nextTick } from '@kbn/test-jest-helpers';
|
||||
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
|
||||
import { setStubKibanaServices } from '@kbn/embeddable-plugin/public/mocks';
|
||||
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
|
||||
import { createEditModeActionDefinition } from '@kbn/embeddable-plugin/public/lib/test_samples';
|
||||
|
||||
import { DashboardContainer } from './dashboard_container';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { buildMockDashboard, getSampleDashboardInput, getSampleDashboardPanel } from '../../mocks';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { DashboardContainer } from './dashboard_container';
|
||||
|
||||
const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
|
@ -130,10 +116,11 @@ test('DashboardContainer.replacePanel', (done) => {
|
|||
);
|
||||
|
||||
// replace the panel now
|
||||
container.replacePanel(container.getInput().panels[ID], {
|
||||
type: EMPTY_EMBEDDABLE,
|
||||
explicitInput: { id: ID },
|
||||
});
|
||||
container.replaceEmbeddable(
|
||||
container.getInput().panels[ID].explicitInput.id,
|
||||
{ id: ID },
|
||||
EMPTY_EMBEDDABLE
|
||||
);
|
||||
});
|
||||
|
||||
test('Container view mode change propagates to existing children', async () => {
|
||||
|
@ -194,82 +181,6 @@ test('searchSessionId propagates to children', async () => {
|
|||
expect(embeddable.getInput().searchSessionId).toBe(searchSessionId1);
|
||||
});
|
||||
|
||||
test('DashboardContainer in edit mode shows edit mode actions', async () => {
|
||||
// mock embeddable dependencies so that the embeddable panel renders
|
||||
setStubKibanaServices();
|
||||
const uiActionsSetup = uiActionsPluginMock.createSetupContract();
|
||||
|
||||
const editModeAction = createEditModeActionDefinition();
|
||||
uiActionsSetup.registerAction(editModeAction);
|
||||
uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction);
|
||||
|
||||
const container = buildMockDashboard({ overrides: { viewMode: ViewMode.VIEW } });
|
||||
|
||||
const embeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {
|
||||
firstName: 'Bob',
|
||||
});
|
||||
|
||||
let wrapper: ReactWrapper;
|
||||
await act(async () => {
|
||||
wrapper = await mount(
|
||||
<I18nProvider>
|
||||
<EmbeddablePanel embeddable={embeddable} />
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
const component = wrapper!;
|
||||
await component.update();
|
||||
await nextTick();
|
||||
|
||||
const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon');
|
||||
|
||||
expect(button.length).toBe(1);
|
||||
act(() => {
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
});
|
||||
await nextTick();
|
||||
await component.update();
|
||||
|
||||
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
|
||||
|
||||
const editAction = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`);
|
||||
|
||||
expect(editAction.length).toBe(0);
|
||||
|
||||
act(() => {
|
||||
container.updateInput({ viewMode: ViewMode.EDIT });
|
||||
});
|
||||
await nextTick();
|
||||
await component.update();
|
||||
|
||||
act(() => {
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
});
|
||||
await nextTick();
|
||||
component.update();
|
||||
|
||||
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(0);
|
||||
|
||||
act(() => {
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
});
|
||||
await nextTick();
|
||||
component.update();
|
||||
|
||||
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1);
|
||||
|
||||
await nextTick();
|
||||
component.update();
|
||||
|
||||
// TODO: Address this.
|
||||
// const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`);
|
||||
// expect(action.length).toBe(1);
|
||||
});
|
||||
|
||||
describe('getInheritedInput', () => {
|
||||
const dashboardTimeRange = {
|
||||
to: 'now',
|
||||
|
|
|
@ -6,63 +6,68 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { batch } from 'react-redux';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
|
||||
|
||||
import {
|
||||
ViewMode,
|
||||
Container,
|
||||
type IEmbeddable,
|
||||
type EmbeddableInput,
|
||||
type EmbeddableOutput,
|
||||
type EmbeddableFactory,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
getDefaultControlGroupInput,
|
||||
persistableControlGroupInputIsEqual,
|
||||
} from '@kbn/controls-plugin/common';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { RefreshInterval } from '@kbn/data-plugin/public';
|
||||
import type { Filter, TimeRange, Query } from '@kbn/es-query';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
|
||||
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
|
||||
import type { KibanaExecutionContext, OverlayRef } from '@kbn/core/public';
|
||||
import { RefreshInterval } from '@kbn/data-plugin/public';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
|
||||
import {
|
||||
Container,
|
||||
ViewMode,
|
||||
type EmbeddableFactory,
|
||||
type EmbeddableInput,
|
||||
type EmbeddableOutput,
|
||||
type IEmbeddable,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import type { Filter, Query, TimeRange } from '@kbn/es-query';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { ReduxEmbeddableTools, ReduxToolsPackage } from '@kbn/presentation-util-plugin/public';
|
||||
import { LocatorPublic } from '@kbn/share-plugin/common';
|
||||
import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen';
|
||||
import { ReduxToolsPackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import {
|
||||
runClone,
|
||||
runSaveAs,
|
||||
showSettings,
|
||||
runQuickSave,
|
||||
replacePanel,
|
||||
addFromLibrary,
|
||||
addOrUpdateEmbeddable,
|
||||
} from './api';
|
||||
|
||||
import { PanelPackage } from '@kbn/presentation-containers';
|
||||
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..';
|
||||
import { DashboardContainerInput, DashboardPanelState } from '../../../common';
|
||||
import { DASHBOARD_APP_ID, DASHBOARD_LOADED_EVENT } from '../../dashboard_constants';
|
||||
import { DashboardAnalyticsService } from '../../services/analytics/types';
|
||||
import { DashboardCapabilitiesService } from '../../services/dashboard_capabilities/types';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { placePanel } from '../component/panel_placement';
|
||||
import { DashboardViewport } from '../component/viewport/dashboard_viewport';
|
||||
import { DashboardExternallyAccessibleApi } from '../external_api/dashboard_api';
|
||||
import { dashboardContainerReducers } from '../state/dashboard_container_reducers';
|
||||
import { startDiffingDashboardState } from '../state/diffing/dashboard_diffing_integration';
|
||||
import {
|
||||
DashboardPublicState,
|
||||
DashboardReduxState,
|
||||
DashboardRenderPerformanceStats,
|
||||
} from '../types';
|
||||
import { placePanel } from '../component/panel_placement';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { initializeDashboard } from './create/create_dashboard';
|
||||
import { DASHBOARD_APP_ID, DASHBOARD_LOADED_EVENT } from '../../dashboard_constants';
|
||||
import { DashboardCreationOptions } from './dashboard_container_factory';
|
||||
import { DashboardAnalyticsService } from '../../services/analytics/types';
|
||||
import { DashboardLocatorParams, DASHBOARD_CONTAINER_TYPE } from '../..';
|
||||
import { DashboardViewport } from '../component/viewport/dashboard_viewport';
|
||||
import { DashboardPanelState, DashboardContainerInput } from '../../../common';
|
||||
import { dashboardContainerReducers } from '../state/dashboard_container_reducers';
|
||||
import { startDiffingDashboardState } from '../state/diffing/dashboard_diffing_integration';
|
||||
import {
|
||||
addFromLibrary,
|
||||
addOrUpdateEmbeddable,
|
||||
runClone,
|
||||
runQuickSave,
|
||||
runSaveAs,
|
||||
showSettings,
|
||||
} from './api';
|
||||
import { duplicateDashboardPanel } from './api/duplicate_dashboard_panel';
|
||||
import { combineDashboardFiltersWithControlGroupFilters } from './create/controls/dashboard_control_group_integration';
|
||||
import { DashboardCapabilitiesService } from '../../services/dashboard_capabilities/types';
|
||||
import { initializeDashboard } from './create/create_dashboard';
|
||||
import {
|
||||
DashboardCreationOptions,
|
||||
dashboardTypeDisplayLowercase,
|
||||
dashboardTypeDisplayName,
|
||||
} from './dashboard_container_factory';
|
||||
|
||||
export interface InheritedChildInput {
|
||||
filters: Filter[];
|
||||
|
@ -94,7 +99,10 @@ export const useDashboardContainer = (): DashboardContainer => {
|
|||
return dashboard!;
|
||||
};
|
||||
|
||||
export class DashboardContainer extends Container<InheritedChildInput, DashboardContainerInput> {
|
||||
export class DashboardContainer
|
||||
extends Container<InheritedChildInput, DashboardContainerInput>
|
||||
implements DashboardExternallyAccessibleApi
|
||||
{
|
||||
public readonly type = DASHBOARD_CONTAINER_TYPE;
|
||||
|
||||
// state management
|
||||
|
@ -104,6 +112,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
public onStateChange: DashboardReduxEmbeddableTools['onStateChange'];
|
||||
|
||||
public integrationSubscriptions: Subscription = new Subscription();
|
||||
public publishingSubscription: Subscription = new Subscription();
|
||||
public diffingSubscription: Subscription = new Subscription();
|
||||
public controlGroup?: ControlGroupContainer;
|
||||
|
||||
|
@ -185,6 +194,22 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
this.getState = reduxTools.getState;
|
||||
this.dispatch = reduxTools.dispatch;
|
||||
this.select = reduxTools.select;
|
||||
|
||||
this.savedObjectId = new BehaviorSubject(this.getDashboardSavedObjectId());
|
||||
this.publishingSubscription.add(
|
||||
this.onStateChange(() => {
|
||||
if (this.savedObjectId.value === this.getDashboardSavedObjectId()) return;
|
||||
this.savedObjectId.next(this.getDashboardSavedObjectId());
|
||||
})
|
||||
);
|
||||
|
||||
this.expandedPanelId = new BehaviorSubject(this.getDashboardSavedObjectId());
|
||||
this.publishingSubscription.add(
|
||||
this.onStateChange(() => {
|
||||
if (this.expandedPanelId.value === this.getExpandedPanelId()) return;
|
||||
this.expandedPanelId.next(this.getExpandedPanelId());
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public getAppContext() {
|
||||
|
@ -319,6 +344,7 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
this.cleanupStateTools();
|
||||
this.controlGroup?.destroy();
|
||||
this.diffingSubscription.unsubscribe();
|
||||
this.publishingSubscription.unsubscribe();
|
||||
this.integrationSubscriptions.unsubscribe();
|
||||
this.stopSyncingWithUnifiedSearch?.();
|
||||
if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode);
|
||||
|
@ -335,7 +361,42 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
|
|||
public showSettings = showSettings;
|
||||
public addFromLibrary = addFromLibrary;
|
||||
|
||||
public replacePanel = replacePanel;
|
||||
public duplicatePanel(id: string) {
|
||||
duplicateDashboardPanel.bind(this)(id);
|
||||
}
|
||||
|
||||
public canRemovePanels = () => !this.getExpandedPanelId();
|
||||
|
||||
public getTypeDisplayName = () => dashboardTypeDisplayName;
|
||||
public getTypeDisplayNameLowerCase = () => dashboardTypeDisplayLowercase;
|
||||
|
||||
public savedObjectId: BehaviorSubject<string | undefined>;
|
||||
public expandedPanelId: BehaviorSubject<string | undefined>;
|
||||
|
||||
public async replacePanel(idToRemove: string, { panelType, initialState }: PanelPackage) {
|
||||
const newId = await this.replaceEmbeddable(
|
||||
idToRemove,
|
||||
initialState as Partial<EmbeddableInput>,
|
||||
panelType,
|
||||
true
|
||||
);
|
||||
if (this.getExpandedPanelId() !== undefined) {
|
||||
this.setExpandedPanelId(newId);
|
||||
}
|
||||
this.setHighlightPanelId(newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
public getDashboardPanelFromId = (panelId: string) => this.getInput().panels[panelId];
|
||||
|
||||
public expandPanel = (panelId?: string) => {
|
||||
this.setExpandedPanelId(panelId);
|
||||
|
||||
if (!panelId) {
|
||||
this.setScrollToPanelId(panelId);
|
||||
}
|
||||
};
|
||||
|
||||
public addOrUpdateEmbeddable = addOrUpdateEmbeddable;
|
||||
|
||||
public forceRefresh(refreshControlGroup: boolean = true) {
|
||||
|
|
|
@ -63,6 +63,17 @@ export interface DashboardCreationOptions {
|
|||
getEmbeddableAppContext?: (dashboardId?: string) => EmbeddableAppContext;
|
||||
}
|
||||
|
||||
export const dashboardTypeDisplayName = i18n.translate('dashboard.factory.displayName', {
|
||||
defaultMessage: 'Dashboard',
|
||||
});
|
||||
|
||||
export const dashboardTypeDisplayLowercase = i18n.translate(
|
||||
'dashboard.factory.displayNameLowercase',
|
||||
{
|
||||
defaultMessage: 'dashboard',
|
||||
}
|
||||
);
|
||||
|
||||
export class DashboardContainerFactoryDefinition
|
||||
implements
|
||||
EmbeddableFactoryDefinition<DashboardContainerInput, ContainerOutput, DashboardContainer>
|
||||
|
@ -83,11 +94,7 @@ export class DashboardContainerFactoryDefinition
|
|||
return false;
|
||||
};
|
||||
|
||||
public readonly getDisplayName = () => {
|
||||
return i18n.translate('dashboard.factory.displayName', {
|
||||
defaultMessage: 'Dashboard',
|
||||
});
|
||||
};
|
||||
public readonly getDisplayName = () => dashboardTypeDisplayName;
|
||||
|
||||
public getDefaultInput(): Partial<DashboardContainerInput> {
|
||||
return DEFAULT_DASHBOARD_INPUT;
|
||||
|
|
|
@ -6,6 +6,10 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { CanDuplicatePanels, CanExpandPanels, TracksOverlays } from '@kbn/presentation-containers';
|
||||
import { HasTypeDisplayName, PublishesSavedObjectId } from '@kbn/presentation-publishing';
|
||||
import { DashboardPanelState } from '../../../common';
|
||||
import { DashboardContainer } from '../embeddable/dashboard_container';
|
||||
|
||||
// TODO lock down DashboardAPI
|
||||
|
@ -13,3 +17,27 @@ export type DashboardAPI = DashboardContainer;
|
|||
export type AwaitingDashboardAPI = DashboardAPI | null;
|
||||
|
||||
export const buildApiFromDashboardContainer = (container?: DashboardContainer) => container ?? null;
|
||||
|
||||
export type DashboardExternallyAccessibleApi = HasTypeDisplayName &
|
||||
CanDuplicatePanels &
|
||||
TracksOverlays &
|
||||
PublishesSavedObjectId &
|
||||
DashboardPluginInternalFunctions &
|
||||
CanExpandPanels;
|
||||
|
||||
/**
|
||||
* An interface that holds types for the methods that Dashboard publishes which should not be used
|
||||
* outside of the Dashboard plugin. This is necessary for some actions which reside in the Dashboard plugin.
|
||||
*/
|
||||
export interface DashboardPluginInternalFunctions {
|
||||
/**
|
||||
* A temporary backdoor to allow some actions access to the Dashboard panels. This should eventually be replaced with a generic version
|
||||
* on the PresentationContainer interface.
|
||||
*/
|
||||
getDashboardPanelFromId: (id: string) => DashboardPanelState;
|
||||
|
||||
/**
|
||||
* A temporary backdoor to allow the filters notification popover to get the data views directly from the dashboard container
|
||||
*/
|
||||
getAllDataViews: () => DataView[];
|
||||
}
|
||||
|
|
|
@ -17,9 +17,10 @@ import { DashboardContainerFactory } from '..';
|
|||
import { DASHBOARD_CONTAINER_TYPE } from '../..';
|
||||
import { DashboardRenderer } from './dashboard_renderer';
|
||||
import { pluginServices } from '../../services/plugin_services';
|
||||
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common';
|
||||
import { DashboardContainer } from '../embeddable/dashboard_container';
|
||||
import { DashboardCreationOptions } from '../embeddable/dashboard_container_factory';
|
||||
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common';
|
||||
import { setStubKibanaServices as setPresentationPanelMocks } from '@kbn/presentation-panel-plugin/public/mocks';
|
||||
|
||||
describe('dashboard renderer', () => {
|
||||
let mockDashboardContainer: DashboardContainer;
|
||||
|
@ -38,6 +39,7 @@ describe('dashboard renderer', () => {
|
|||
pluginServices.getServices().embeddable.getEmbeddableFactory = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockDashboardFactory);
|
||||
setPresentationPanelMocks();
|
||||
});
|
||||
|
||||
test('calls create method on the Dashboard embeddable factory', async () => {
|
||||
|
|
|
@ -9,9 +9,15 @@
|
|||
import { EmbeddableInput, ViewMode } from '@kbn/embeddable-plugin/public';
|
||||
import { mockedReduxEmbeddablePackage } from '@kbn/presentation-util-plugin/public/mocks';
|
||||
|
||||
import { DashboardStart } from './plugin';
|
||||
import { DashboardContainerInput, DashboardPanelState } from '../common';
|
||||
import { DashboardContainer } from './dashboard_container/embeddable/dashboard_container';
|
||||
import { DashboardStart } from './plugin';
|
||||
import { pluginServices } from './services/plugin_services';
|
||||
export { setStubDashboardServices } from './services/mocks';
|
||||
|
||||
export const getMockedDashboardServices = () => {
|
||||
return pluginServices.getServices();
|
||||
};
|
||||
|
||||
export type Start = jest.Mocked<DashboardStart>;
|
||||
|
||||
|
|
|
@ -69,6 +69,10 @@
|
|||
"@kbn/react-kibana-mount",
|
||||
"@kbn/core-lifecycle-browser",
|
||||
"@kbn/logging",
|
||||
"@kbn/presentation-library",
|
||||
"@kbn/presentation-publishing",
|
||||
"@kbn/presentation-containers",
|
||||
"@kbn/presentation-panel-plugin",
|
||||
"@kbn/content-management-table-list-view-common",
|
||||
"@kbn/shared-ux-utility"
|
||||
],
|
||||
|
|
|
@ -117,7 +117,7 @@ export class SavedSearchEmbeddable
|
|||
|
||||
private abortController?: AbortController;
|
||||
private savedSearch: SavedSearch | undefined;
|
||||
private panelTitle: string = '';
|
||||
private panelTitleInternal: string = '';
|
||||
private filtersSearchSource!: ISearchSource;
|
||||
private prevTimeRange?: TimeRange;
|
||||
private prevFilters?: Filter[];
|
||||
|
@ -144,9 +144,9 @@ export class SavedSearchEmbeddable
|
|||
};
|
||||
|
||||
this.subscription = this.getUpdated$().subscribe(() => {
|
||||
const titleChanged = this.output.title && this.panelTitle !== this.output.title;
|
||||
const titleChanged = this.output.title && this.panelTitleInternal !== this.output.title;
|
||||
if (titleChanged) {
|
||||
this.panelTitle = this.output.title || '';
|
||||
this.panelTitleInternal = this.output.title || '';
|
||||
}
|
||||
if (!this.searchProps) {
|
||||
return;
|
||||
|
@ -180,7 +180,7 @@ export class SavedSearchEmbeddable
|
|||
unwrapResult
|
||||
);
|
||||
|
||||
this.panelTitle = this.getCurrentTitle();
|
||||
this.panelTitleInternal = this.getCurrentTitle();
|
||||
|
||||
await this.initializeOutput();
|
||||
|
||||
|
@ -592,8 +592,8 @@ export class SavedSearchEmbeddable
|
|||
|
||||
searchProps.columns = columnState.columns;
|
||||
searchProps.sort = this.getSort(this.input.sort || savedSearch.sort, searchProps?.dataView);
|
||||
searchProps.sharedItemTitle = this.panelTitle;
|
||||
searchProps.searchTitle = this.panelTitle;
|
||||
searchProps.sharedItemTitle = this.panelTitleInternal;
|
||||
searchProps.searchTitle = this.panelTitleInternal;
|
||||
searchProps.rowHeightState = this.input.rowHeight || savedSearch.rowHeight;
|
||||
searchProps.rowsPerPageState =
|
||||
this.input.rowsPerPage ||
|
||||
|
@ -758,7 +758,7 @@ export class SavedSearchEmbeddable
|
|||
/**
|
||||
* @returns Local/panel-level array of filters for Saved Search embeddable
|
||||
*/
|
||||
public async getFilters() {
|
||||
public getFilters() {
|
||||
return mapAndFlattenFilters(
|
||||
(this.savedSearch?.searchSource.getFields().filter as Filter[]) ?? []
|
||||
);
|
||||
|
@ -767,7 +767,7 @@ export class SavedSearchEmbeddable
|
|||
/**
|
||||
* @returns Local/panel-level query for Saved Search embeddable
|
||||
*/
|
||||
public async getQuery() {
|
||||
public getQuery() {
|
||||
return this.savedSearch?.searchSource.getFields().query;
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
"contentManagement"
|
||||
],
|
||||
"optionalPlugins": ["savedObjectsTaggingOss", "usageCollection"],
|
||||
"requiredBundles": ["savedObjects", "kibanaReact", "kibanaUtils", "unifiedSearch"],
|
||||
"requiredBundles": ["savedObjects", "kibanaReact", "kibanaUtils", "presentationPanel"],
|
||||
"extraPublicDirs": ["common"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
$embEditingModeHoverColor: transparentize($euiColorWarning, lightOrDarkTheme(.9, .7));
|
|
@ -1,633 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ReactWrapper, mount } from 'enzyme';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { nextTick } from '@kbn/test-jest-helpers';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { Action, UiActionsStart, ActionInternal, Trigger } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import {
|
||||
ContactCardEmbeddable,
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddableFactory,
|
||||
CONTACT_CARD_EMBEDDABLE_REACT,
|
||||
createEditModeActionDefinition,
|
||||
ContactCardEmbeddableReactFactory,
|
||||
HelloWorldContainer,
|
||||
} from '../lib/test_samples';
|
||||
import { EuiBadge, EuiNotificationBadge } from '@elastic/eui';
|
||||
import { embeddablePluginMock } from '../mocks';
|
||||
import { EmbeddablePanel } from './embeddable_panel';
|
||||
import { core, inspector } from '../kibana_services';
|
||||
import { CONTEXT_MENU_TRIGGER, ViewMode } from '..';
|
||||
import { UnwrappedEmbeddablePanelProps } from './types';
|
||||
import {
|
||||
DESCRIPTIVE_CONTACT_CARD_EMBEDDABLE,
|
||||
DescriptiveContactCardEmbeddableFactory,
|
||||
} from '../lib/test_samples/embeddables/contact_card/descriptive_contact_card_embeddable_factory';
|
||||
|
||||
const actionRegistry = new Map<string, Action>();
|
||||
const triggerRegistry = new Map<string, Trigger>();
|
||||
|
||||
const { setup, doStart } = embeddablePluginMock.createInstance();
|
||||
|
||||
const editModeAction = createEditModeActionDefinition();
|
||||
const trigger: Trigger = {
|
||||
id: CONTEXT_MENU_TRIGGER,
|
||||
};
|
||||
const embeddableFactory = new ContactCardEmbeddableFactory((() => null) as any, {} as any);
|
||||
const embeddableReactFactory = new ContactCardEmbeddableReactFactory(
|
||||
(() => null) as any,
|
||||
{} as any
|
||||
);
|
||||
const descriptiveEmbeddableFactory = new DescriptiveContactCardEmbeddableFactory(
|
||||
(() => null) as any
|
||||
);
|
||||
|
||||
actionRegistry.set(editModeAction.id, new ActionInternal(editModeAction));
|
||||
triggerRegistry.set(trigger.id, trigger);
|
||||
setup.registerEmbeddableFactory(embeddableFactory.type, embeddableFactory);
|
||||
setup.registerEmbeddableFactory(embeddableReactFactory.type, embeddableReactFactory);
|
||||
setup.registerEmbeddableFactory(descriptiveEmbeddableFactory.type, descriptiveEmbeddableFactory);
|
||||
|
||||
const start = doStart();
|
||||
const getEmbeddableFactory = start.getEmbeddableFactory;
|
||||
|
||||
const renderEmbeddableInPanel = async (
|
||||
props: UnwrappedEmbeddablePanelProps
|
||||
): Promise<ReactWrapper> => {
|
||||
let wrapper: ReactWrapper;
|
||||
await act(async () => {
|
||||
wrapper = mount(
|
||||
<I18nProvider>
|
||||
<EmbeddablePanel {...props} />
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
return wrapper!;
|
||||
};
|
||||
|
||||
const setupContainerAndEmbeddable = async (
|
||||
embeddableType: string,
|
||||
viewMode: ViewMode = ViewMode.VIEW,
|
||||
hidePanelTitles?: boolean
|
||||
) => {
|
||||
const container = new HelloWorldContainer(
|
||||
{ id: '123', panels: {}, viewMode: viewMode ?? ViewMode.VIEW, hidePanelTitles },
|
||||
{
|
||||
getEmbeddableFactory,
|
||||
} as any
|
||||
);
|
||||
|
||||
const embeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(embeddableType, {
|
||||
firstName: 'Jack',
|
||||
lastName: 'Orange',
|
||||
});
|
||||
|
||||
return { container, embeddable };
|
||||
};
|
||||
|
||||
const renderInEditModeAndOpenContextMenu = async ({
|
||||
embeddableInputs,
|
||||
getActions = () => Promise.resolve([]),
|
||||
showNotifications = true,
|
||||
showBadges = true,
|
||||
}: {
|
||||
embeddableInputs: any;
|
||||
getActions?: UiActionsStart['getTriggerCompatibleActions'];
|
||||
showNotifications?: boolean;
|
||||
showBadges?: boolean;
|
||||
}) => {
|
||||
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
|
||||
getEmbeddableFactory,
|
||||
} as any);
|
||||
|
||||
const embeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, embeddableInputs);
|
||||
|
||||
let component: ReactWrapper;
|
||||
await act(async () => {
|
||||
component = mount(
|
||||
<I18nProvider>
|
||||
<EmbeddablePanel
|
||||
embeddable={embeddable}
|
||||
getActions={getActions}
|
||||
showNotifications={showNotifications}
|
||||
showBadges={showBadges}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
findTestSubject(component!, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
await nextTick();
|
||||
component!.update();
|
||||
|
||||
return { component: component! };
|
||||
};
|
||||
|
||||
describe('Error states', () => {
|
||||
let component: ReactWrapper<unknown>;
|
||||
let embeddable: ContactCardEmbeddable;
|
||||
|
||||
beforeEach(async () => {
|
||||
const container = new HelloWorldContainer({ id: '123', panels: {}, viewMode: ViewMode.VIEW }, {
|
||||
getEmbeddableFactory,
|
||||
} as any);
|
||||
|
||||
embeddable = (await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE, {})) as ContactCardEmbeddable;
|
||||
|
||||
await act(async () => {
|
||||
component = mount(
|
||||
<I18nProvider>
|
||||
<EmbeddablePanel embeddable={embeddable} />
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
|
||||
jest.spyOn(embeddable, 'catchError');
|
||||
});
|
||||
|
||||
test('renders a custom error', () => {
|
||||
act(() => {
|
||||
embeddable.triggerError(new Error('something'));
|
||||
component.update();
|
||||
component.mount();
|
||||
});
|
||||
|
||||
const embeddableError = findTestSubject(component, 'embeddableError');
|
||||
|
||||
expect(embeddable.catchError).toHaveBeenCalledWith(
|
||||
new Error('something'),
|
||||
expect.any(HTMLElement)
|
||||
);
|
||||
expect(embeddableError).toHaveProperty('length', 1);
|
||||
expect(embeddableError.text()).toBe('something');
|
||||
});
|
||||
|
||||
test('renders a custom fatal error', () => {
|
||||
act(() => {
|
||||
embeddable.triggerError(new Error('something'));
|
||||
component.update();
|
||||
component.mount();
|
||||
});
|
||||
|
||||
const embeddableError = findTestSubject(component, 'embeddableError');
|
||||
|
||||
expect(embeddable.catchError).toHaveBeenCalledWith(
|
||||
new Error('something'),
|
||||
expect.any(HTMLElement)
|
||||
);
|
||||
expect(embeddableError).toHaveProperty('length', 1);
|
||||
expect(embeddableError.text()).toBe('something');
|
||||
});
|
||||
|
||||
test('destroys previous error', () => {
|
||||
const { catchError } = embeddable as Required<typeof embeddable>;
|
||||
let destroyError: jest.MockedFunction<ReturnType<typeof catchError>>;
|
||||
|
||||
(embeddable.catchError as jest.MockedFunction<typeof catchError>).mockImplementationOnce(
|
||||
(...args) => {
|
||||
destroyError = jest.fn(catchError(...args));
|
||||
|
||||
return destroyError;
|
||||
}
|
||||
);
|
||||
act(() => {
|
||||
embeddable.triggerError(new Error('something'));
|
||||
component.update();
|
||||
component.mount();
|
||||
});
|
||||
act(() => {
|
||||
embeddable.triggerError(new Error('another error'));
|
||||
component.update();
|
||||
component.mount();
|
||||
});
|
||||
|
||||
const embeddableError = findTestSubject(component, 'embeddableError');
|
||||
|
||||
expect(embeddableError).toHaveProperty('length', 1);
|
||||
expect(embeddableError.text()).toBe('another error');
|
||||
expect(destroyError!).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders a default error', async () => {
|
||||
embeddable.catchError = undefined;
|
||||
act(() => {
|
||||
embeddable.triggerError(new Error('something'));
|
||||
component.update();
|
||||
component.mount();
|
||||
});
|
||||
|
||||
const embeddableError = findTestSubject(component, 'embeddableError');
|
||||
|
||||
expect(embeddableError).toHaveProperty('length', 1);
|
||||
expect(embeddableError.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('renders a React node', () => {
|
||||
(embeddable.catchError as jest.Mock).mockReturnValueOnce(<div>Something</div>);
|
||||
act(() => {
|
||||
embeddable.triggerError(new Error('something'));
|
||||
component.update();
|
||||
component.mount();
|
||||
});
|
||||
|
||||
const embeddableError = findTestSubject(component, 'embeddableError');
|
||||
|
||||
expect(embeddableError).toHaveProperty('length', 1);
|
||||
expect(embeddableError.text()).toBe('Something');
|
||||
});
|
||||
});
|
||||
|
||||
test('Render method is called on Embeddable', async () => {
|
||||
const { embeddable } = await setupContainerAndEmbeddable(CONTACT_CARD_EMBEDDABLE);
|
||||
jest.spyOn(embeddable, 'render');
|
||||
await renderEmbeddableInPanel({ embeddable });
|
||||
expect(embeddable.render).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Actions which are disabled via disabledActions are hidden', async () => {
|
||||
const action = {
|
||||
id: 'FOO',
|
||||
type: 'FOO',
|
||||
getIconType: () => undefined,
|
||||
getDisplayName: () => 'foo',
|
||||
isCompatible: async () => true,
|
||||
execute: async () => {},
|
||||
order: 10,
|
||||
getHref: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
const getActions = () => Promise.resolve([action]);
|
||||
|
||||
const { component: component1 } = await renderInEditModeAndOpenContextMenu({
|
||||
embeddableInputs: {
|
||||
firstName: 'Bob',
|
||||
},
|
||||
getActions,
|
||||
});
|
||||
const { component: component2 } = await renderInEditModeAndOpenContextMenu({
|
||||
embeddableInputs: {
|
||||
firstName: 'Bob',
|
||||
disabledActions: ['FOO'],
|
||||
},
|
||||
getActions,
|
||||
});
|
||||
|
||||
const fooContextMenuActionItem1 = findTestSubject(component1, 'embeddablePanelAction-FOO');
|
||||
const fooContextMenuActionItem2 = findTestSubject(component2, 'embeddablePanelAction-FOO');
|
||||
|
||||
expect(fooContextMenuActionItem1.length).toBe(1);
|
||||
expect(fooContextMenuActionItem2.length).toBe(0);
|
||||
});
|
||||
|
||||
test('Badges which are disabled via disabledActions are hidden', async () => {
|
||||
const action = {
|
||||
id: 'BAR',
|
||||
type: 'BAR',
|
||||
getIconType: () => undefined,
|
||||
getDisplayName: () => 'bar',
|
||||
isCompatible: async () => true,
|
||||
execute: async () => {},
|
||||
order: 10,
|
||||
getHref: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
const getActions = () => Promise.resolve([action]);
|
||||
|
||||
const { component: component1 } = await renderInEditModeAndOpenContextMenu({
|
||||
embeddableInputs: {
|
||||
firstName: 'Bob',
|
||||
},
|
||||
getActions,
|
||||
});
|
||||
const { component: component2 } = await renderInEditModeAndOpenContextMenu({
|
||||
embeddableInputs: {
|
||||
firstName: 'Bob',
|
||||
disabledActions: ['BAR'],
|
||||
},
|
||||
getActions,
|
||||
});
|
||||
|
||||
expect(component1.find(EuiBadge).length).toBe(1);
|
||||
expect(component2.find(EuiBadge).length).toBe(0);
|
||||
});
|
||||
|
||||
test('Badges are not shown when hideBadges is true', async () => {
|
||||
const action = {
|
||||
id: 'BAR',
|
||||
type: 'BAR',
|
||||
getIconType: () => undefined,
|
||||
getDisplayName: () => 'bar',
|
||||
isCompatible: async () => true,
|
||||
execute: async () => {},
|
||||
order: 10,
|
||||
getHref: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
const getActions = () => Promise.resolve([action]);
|
||||
|
||||
const { component } = await renderInEditModeAndOpenContextMenu({
|
||||
embeddableInputs: {
|
||||
firstName: 'Bob',
|
||||
},
|
||||
getActions,
|
||||
showBadges: false,
|
||||
});
|
||||
expect(component.find(EuiBadge).length).toBe(0);
|
||||
expect(component.find(EuiNotificationBadge).length).toBe(1);
|
||||
});
|
||||
|
||||
test('Notifications are not shown when hideNotifications is true', async () => {
|
||||
const action = {
|
||||
id: 'BAR',
|
||||
type: 'BAR',
|
||||
getIconType: () => undefined,
|
||||
getDisplayName: () => 'bar',
|
||||
isCompatible: async () => true,
|
||||
execute: async () => {},
|
||||
order: 10,
|
||||
getHref: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
const getActions = () => Promise.resolve([action]);
|
||||
|
||||
const { component } = await renderInEditModeAndOpenContextMenu({
|
||||
embeddableInputs: {
|
||||
firstName: 'Bob',
|
||||
},
|
||||
getActions,
|
||||
showNotifications: false,
|
||||
});
|
||||
|
||||
expect(component.find(EuiBadge).length).toBe(1);
|
||||
expect(component.find(EuiNotificationBadge).length).toBe(0);
|
||||
});
|
||||
|
||||
test('Edit mode actions are hidden if parent is in view mode', async () => {
|
||||
const { embeddable } = await setupContainerAndEmbeddable(CONTACT_CARD_EMBEDDABLE);
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
|
||||
await nextTick();
|
||||
component.update();
|
||||
expect(findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`).length).toBe(0);
|
||||
});
|
||||
|
||||
test('Edit mode actions are shown in edit mode', async () => {
|
||||
const { container, embeddable } = await setupContainerAndEmbeddable(CONTACT_CARD_EMBEDDABLE);
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon');
|
||||
|
||||
expect(button.length).toBe(1);
|
||||
await act(async () => {
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
|
||||
await nextTick();
|
||||
act(() => {
|
||||
component.update();
|
||||
});
|
||||
expect(findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`).length).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
container.updateInput({ viewMode: ViewMode.EDIT });
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
|
||||
// Need to close and re-open to refresh. It doesn't update automatically.
|
||||
await act(async () => {
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
await nextTick();
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
container.updateInput({ viewMode: ViewMode.VIEW });
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
|
||||
// TODO: Fix this.
|
||||
// const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`);
|
||||
// expect(action.length).toBe(1);
|
||||
});
|
||||
|
||||
test('Panel title customize link does not exist in view mode', async () => {
|
||||
const { embeddable } = await setupContainerAndEmbeddable(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.VIEW,
|
||||
false
|
||||
);
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
const titleLink = findTestSubject(component, 'embeddablePanelTitleLink');
|
||||
expect(titleLink.length).toBe(0);
|
||||
});
|
||||
|
||||
test('Runs customize panel action on title click when in edit mode', async () => {
|
||||
// spy on core openFlyout to check that the flyout is opened correctly.
|
||||
core.overlays.openFlyout = jest.fn();
|
||||
|
||||
const { embeddable } = await setupContainerAndEmbeddable(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.EDIT,
|
||||
false
|
||||
);
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
const titleLink = findTestSubject(component, 'embeddablePanelTitleLink');
|
||||
expect(titleLink.length).toBe(1);
|
||||
act(() => {
|
||||
titleLink.simulate('click');
|
||||
});
|
||||
await nextTick();
|
||||
expect(core.overlays.openFlyout).toHaveBeenCalledTimes(1);
|
||||
expect(core.overlays.openFlyout).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ 'data-test-subj': 'customizePanel' })
|
||||
);
|
||||
});
|
||||
|
||||
test('Updates when hidePanelTitles is toggled', async () => {
|
||||
const { container, embeddable } = await setupContainerAndEmbeddable(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.VIEW,
|
||||
false
|
||||
);
|
||||
/**
|
||||
* panel title will always show if a description is set so we explictily set the panel
|
||||
* description so the embeddable description is not used
|
||||
*/
|
||||
embeddable.updateInput({ description: '' });
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
await component.update();
|
||||
let title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
|
||||
expect(title.length).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
await container.updateInput({ hidePanelTitles: true });
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
await component.update();
|
||||
title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
|
||||
expect(title.length).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
await container.updateInput({ hidePanelTitles: false });
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
|
||||
title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
|
||||
expect(title.length).toBe(1);
|
||||
});
|
||||
|
||||
test('Respects options from SelfStyledEmbeddable', async () => {
|
||||
const { container, embeddable } = await setupContainerAndEmbeddable(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.VIEW,
|
||||
false
|
||||
);
|
||||
|
||||
const selfStyledEmbeddable = embeddablePluginMock.mockSelfStyledEmbeddable(embeddable, {
|
||||
hideTitle: true,
|
||||
});
|
||||
|
||||
// make sure the title is being hidden because of the self styling, not the container
|
||||
container.updateInput({ hidePanelTitles: false });
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable: selfStyledEmbeddable });
|
||||
|
||||
const title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
|
||||
expect(title.length).toBe(0);
|
||||
});
|
||||
|
||||
test('Shows icon in panel title when the embeddable has a description', async () => {
|
||||
const { embeddable } = await setupContainerAndEmbeddable(
|
||||
DESCRIPTIVE_CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.VIEW,
|
||||
false
|
||||
);
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
const descriptionIcon = findTestSubject(component, 'embeddablePanelTitleDescriptionIcon');
|
||||
expect(descriptionIcon.length).toBe(1);
|
||||
});
|
||||
|
||||
test('Does not hide header when parent hide header option is false', async () => {
|
||||
const { embeddable } = await setupContainerAndEmbeddable(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.VIEW,
|
||||
false
|
||||
);
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
const title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
|
||||
expect(title.length).toBe(1);
|
||||
});
|
||||
|
||||
test('Hides title when parent hide header option is true', async () => {
|
||||
const { embeddable } = await setupContainerAndEmbeddable(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.VIEW,
|
||||
true
|
||||
);
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
const title = findTestSubject(component, `embeddablePanelHeading-HelloJackOrange`);
|
||||
expect(title.length).toBe(0);
|
||||
});
|
||||
|
||||
test('Should work in minimal way rendering only the inspector action', async () => {
|
||||
inspector.isAvailable = jest.fn(() => true);
|
||||
|
||||
const { embeddable } = await setupContainerAndEmbeddable(
|
||||
CONTACT_CARD_EMBEDDABLE,
|
||||
ViewMode.VIEW,
|
||||
true
|
||||
);
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
await act(async () => {
|
||||
findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click');
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
|
||||
expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1);
|
||||
await act(async () => {
|
||||
await nextTick();
|
||||
component.update();
|
||||
});
|
||||
expect(findTestSubject(component, `embeddablePanelAction-openInspector`).length).toBe(1);
|
||||
const action = findTestSubject(component, `embeddablePanelAction-ACTION_CUSTOMIZE_PANEL`);
|
||||
expect(action.length).toBe(0);
|
||||
});
|
||||
|
||||
test('Renders an embeddable returning a React node', async () => {
|
||||
const container = new HelloWorldContainer(
|
||||
{ id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelTitles: false },
|
||||
{ getEmbeddableFactory } as any
|
||||
);
|
||||
|
||||
const embeddable = await container.addNewEmbeddable<
|
||||
ContactCardEmbeddableInput,
|
||||
ContactCardEmbeddableOutput,
|
||||
ContactCardEmbeddable
|
||||
>(CONTACT_CARD_EMBEDDABLE_REACT, {
|
||||
firstName: 'Bran',
|
||||
lastName: 'Stark',
|
||||
});
|
||||
|
||||
const component = await renderEmbeddableInPanel({ embeddable });
|
||||
|
||||
expect(component.find('.embPanel__titleText').text()).toBe('Hello Bran Stark');
|
||||
});
|
|
@ -6,222 +6,62 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPanel, htmlIdGenerator } from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import { isNil } from 'lodash';
|
||||
import { css } from '@emotion/react';
|
||||
import { PresentationPanel } from '@kbn/presentation-panel-plugin/public';
|
||||
import { useApiPublisher } from '@kbn/presentation-publishing';
|
||||
import { isPromise } from '@kbn/std';
|
||||
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { distinct, map } from 'rxjs';
|
||||
import { untilPluginStartServicesReady } from '../kibana_services';
|
||||
import { LegacyEmbeddableAPI } from '../lib/embeddables/i_embeddable';
|
||||
import { CreateEmbeddableComponent } from '../registry/create_embeddable_component';
|
||||
import { EmbeddablePanelProps, LegacyEmbeddableCompatibilityComponent } from './types';
|
||||
|
||||
import { PanelLoader } from '@kbn/panel-loader';
|
||||
import { core, embeddableStart, inspector } from '../kibana_services';
|
||||
import { EmbeddableErrorHandler, EmbeddableOutput, ViewMode } from '../lib';
|
||||
import { EmbeddablePanelError } from './embeddable_panel_error';
|
||||
import {
|
||||
CustomizePanelAction,
|
||||
EditPanelAction,
|
||||
InspectPanelAction,
|
||||
RemovePanelAction,
|
||||
} from './panel_actions';
|
||||
import { EmbeddablePanelHeader } from './panel_header/embeddable_panel_header';
|
||||
import {
|
||||
EmbeddablePhase,
|
||||
EmbeddablePhaseEvent,
|
||||
PanelUniversalActions,
|
||||
UnwrappedEmbeddablePanelProps,
|
||||
} from './types';
|
||||
import {
|
||||
useSelectFromEmbeddableInput,
|
||||
useSelectFromEmbeddableOutput,
|
||||
} from './use_select_from_embeddable';
|
||||
|
||||
const getEventStatus = (output: EmbeddableOutput): EmbeddablePhase => {
|
||||
if (!isNil(output.error)) {
|
||||
return 'error';
|
||||
} else if (output.rendered === true) {
|
||||
return 'rendered';
|
||||
} else if (output.loading === false) {
|
||||
return 'loaded';
|
||||
} else {
|
||||
return 'loading';
|
||||
}
|
||||
};
|
||||
|
||||
export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => {
|
||||
const { hideHeader, showShadow, embeddable, hideInspector, onPanelStatusChange } = panelProps;
|
||||
const [node, setNode] = useState<ReactNode | undefined>();
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
|
||||
|
||||
const headerId = useMemo(() => htmlIdGenerator()(), []);
|
||||
const [outputError, setOutputError] = useState<Error>();
|
||||
|
||||
/**
|
||||
* Universal actions are exposed on the context menu for every embeddable, they
|
||||
* bypass the trigger registry.
|
||||
*/
|
||||
const universalActions = useMemo<PanelUniversalActions>(() => {
|
||||
const stateTransfer = embeddableStart.getStateTransfer();
|
||||
const editPanel = new EditPanelAction(
|
||||
embeddableStart.getEmbeddableFactory,
|
||||
core.application,
|
||||
stateTransfer
|
||||
);
|
||||
|
||||
const actions: PanelUniversalActions = {
|
||||
customizePanel: new CustomizePanelAction(editPanel),
|
||||
removePanel: new RemovePanelAction(),
|
||||
editPanel,
|
||||
};
|
||||
if (!hideInspector) actions.inspectPanel = new InspectPanelAction(inspector);
|
||||
return actions;
|
||||
}, [hideInspector]);
|
||||
|
||||
/**
|
||||
* Track panel status changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!onPanelStatusChange) return;
|
||||
let loadingStartTime = 0;
|
||||
|
||||
const subscription = embeddable
|
||||
.getOutput$()
|
||||
.pipe(
|
||||
// Map loaded event properties
|
||||
map((output) => {
|
||||
if (output.loading === true) {
|
||||
loadingStartTime = performance.now();
|
||||
}
|
||||
return {
|
||||
id: embeddable.id,
|
||||
status: getEventStatus(output),
|
||||
error: output.error,
|
||||
};
|
||||
}),
|
||||
// Dedupe
|
||||
distinct((output) => loadingStartTime + output.id + output.status + !!output.error),
|
||||
// Map loaded event properties
|
||||
map((output): EmbeddablePhaseEvent => {
|
||||
return {
|
||||
...output,
|
||||
timeToEvent: performance.now() - loadingStartTime,
|
||||
};
|
||||
})
|
||||
)
|
||||
.subscribe((statusOutput) => {
|
||||
onPanelStatusChange(statusOutput);
|
||||
});
|
||||
return () => subscription?.unsubscribe();
|
||||
|
||||
// Panel status change subscription should only be run on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Select state from the embeddable
|
||||
*/
|
||||
const loading = useSelectFromEmbeddableOutput('loading', embeddable);
|
||||
const rendered = useSelectFromEmbeddableOutput('rendered', embeddable);
|
||||
|
||||
if ((loading === false || rendered === true || outputError) && !initialLoadComplete) {
|
||||
setInitialLoadComplete(true);
|
||||
const getComponentFromEmbeddable = async (
|
||||
embeddable: EmbeddablePanelProps['embeddable']
|
||||
): Promise<LegacyEmbeddableCompatibilityComponent> => {
|
||||
const startServicesPromise = untilPluginStartServicesReady();
|
||||
const embeddablePromise =
|
||||
typeof embeddable === 'function' ? embeddable() : Promise.resolve(embeddable);
|
||||
const [, unwrappedEmbeddable] = await Promise.all([startServicesPromise, embeddablePromise]);
|
||||
if (unwrappedEmbeddable.parent) {
|
||||
await unwrappedEmbeddable.parent.untilEmbeddableLoaded(unwrappedEmbeddable.id);
|
||||
}
|
||||
|
||||
const viewMode = useSelectFromEmbeddableInput('viewMode', embeddable);
|
||||
return CreateEmbeddableComponent((apiRef) => {
|
||||
const [node, setNode] = useState<ReactNode | undefined>();
|
||||
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
|
||||
|
||||
/**
|
||||
* Render embeddable into ref, set up error subscription
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!embeddableRoot.current) return;
|
||||
// Render legacy embeddable into ref, and destroy on unmount.
|
||||
useEffect(() => {
|
||||
if (!embeddableRoot.current) return;
|
||||
const nextNode = unwrappedEmbeddable.render(embeddableRoot.current) ?? undefined;
|
||||
if (isPromise(nextNode)) {
|
||||
nextNode.then((resolved) => setNode(resolved));
|
||||
} else {
|
||||
setNode(nextNode);
|
||||
}
|
||||
return () => {
|
||||
unwrappedEmbeddable.destroy();
|
||||
};
|
||||
}, [embeddableRoot]);
|
||||
|
||||
let cancelled = false;
|
||||
useApiPublisher(unwrappedEmbeddable, apiRef);
|
||||
|
||||
const render = async (root: HTMLDivElement) => {
|
||||
const nextNode = (await embeddable.render(root)) ?? undefined;
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setNode(nextNode);
|
||||
};
|
||||
|
||||
render(embeddableRoot.current);
|
||||
|
||||
const errorSubscription = embeddable.getOutput$().subscribe({
|
||||
next: (output) => {
|
||||
setOutputError(output.error);
|
||||
},
|
||||
error: (error) => setOutputError(error),
|
||||
});
|
||||
|
||||
return () => {
|
||||
embeddable?.destroy();
|
||||
errorSubscription?.unsubscribe();
|
||||
cancelled = true;
|
||||
};
|
||||
}, [embeddable, embeddableRoot]);
|
||||
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
classNames('embPanel', {
|
||||
'embPanel--editing': viewMode !== ViewMode.VIEW,
|
||||
'embPanel--loading': loading,
|
||||
}),
|
||||
[viewMode, loading]
|
||||
);
|
||||
|
||||
const contentAttrs = useMemo(() => {
|
||||
const attrs: { [key: string]: boolean } = {};
|
||||
if (loading) attrs['data-loading'] = true;
|
||||
if (outputError) attrs['data-error'] = true;
|
||||
return attrs;
|
||||
}, [loading, outputError]);
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
role="figure"
|
||||
paddingSize="none"
|
||||
className={classes}
|
||||
hasShadow={showShadow}
|
||||
aria-labelledby={headerId}
|
||||
data-test-subj="embeddablePanel"
|
||||
data-test-embeddable-id={embeddable.id}
|
||||
>
|
||||
{!hideHeader && (
|
||||
<EmbeddablePanelHeader
|
||||
{...panelProps}
|
||||
headerId={headerId}
|
||||
universalActions={universalActions}
|
||||
/>
|
||||
)}
|
||||
{outputError && (
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
className="eui-fullHeight embPanel__error"
|
||||
data-test-subj="embeddableError"
|
||||
justifyContent="center"
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EmbeddableErrorHandler embeddable={embeddable} error={outputError}>
|
||||
{(error) => (
|
||||
<EmbeddablePanelError
|
||||
editPanelAction={universalActions.editPanel}
|
||||
embeddable={embeddable}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</EmbeddableErrorHandler>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{!initialLoadComplete && <PanelLoader />}
|
||||
<div
|
||||
css={initialLoadComplete ? undefined : { display: 'none !important' }}
|
||||
className="embPanel__content"
|
||||
ref={embeddableRoot}
|
||||
{...contentAttrs}
|
||||
>
|
||||
return (
|
||||
<div css={css(`width: 100%; height: 100%; display:flex`)} ref={embeddableRoot}>
|
||||
{node}
|
||||
</div>
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads and renders an embeddable.
|
||||
*/
|
||||
export const EmbeddablePanel = (props: EmbeddablePanelProps) => {
|
||||
const { embeddable, ...passThroughProps } = props;
|
||||
const componentPromise = useMemo(() => getComponentFromEmbeddable(embeddable), [embeddable]);
|
||||
return (
|
||||
<PresentationPanel<LegacyEmbeddableAPI> {...passThroughProps} Component={componentPromise} />
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,6 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export * from './edit_mode_action';
|
||||
export * from './say_hello_action';
|
||||
export * from './send_message_action';
|
||||
export { EmbeddablePanel } from './embeddable_panel';
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { PanelLoader } from '@kbn/panel-loader';
|
||||
import { EmbeddablePanelProps } from './types';
|
||||
import { useEmbeddablePanel } from './use_embeddable_panel';
|
||||
|
||||
/**
|
||||
* Loads and renders an embeddable.
|
||||
*/
|
||||
export const EmbeddablePanel = (props: EmbeddablePanelProps) => {
|
||||
const result = useEmbeddablePanel({ embeddable: props.embeddable });
|
||||
if (!result)
|
||||
return (
|
||||
<PanelLoader showShadow={props.showShadow} dataTestSubj="embeddablePanelLoadingIndicator" />
|
||||
);
|
||||
const { embeddable, ...passThroughProps } = props;
|
||||
return <result.Panel embeddable={result.unwrappedEmbeddable} {...passThroughProps} />;
|
||||
};
|
|
@ -1,44 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { canInheritTimeRange } from './can_inherit_time_range';
|
||||
import {
|
||||
HelloWorldContainer,
|
||||
TimeRangeContainer,
|
||||
TimeRangeEmbeddable,
|
||||
} from '../../../lib/test_samples';
|
||||
import { HelloWorldEmbeddable } from '../../../tests/fixtures';
|
||||
|
||||
test('canInheritTimeRange returns false if embeddable is inside container without a time range', () => {
|
||||
const embeddable = new TimeRangeEmbeddable(
|
||||
{ id: '1234', timeRange: { from: 'noxw-15m', to: 'now' } },
|
||||
new HelloWorldContainer({ id: '123', panels: {} }, {})
|
||||
);
|
||||
|
||||
expect(canInheritTimeRange(embeddable)).toBe(false);
|
||||
});
|
||||
|
||||
test('canInheritTimeRange returns false if embeddable is without a time range', () => {
|
||||
const embeddable = new HelloWorldEmbeddable(
|
||||
{ id: '1234' },
|
||||
new HelloWorldContainer({ id: '123', panels: {} }, {})
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(canInheritTimeRange(embeddable)).toBe(false);
|
||||
});
|
||||
|
||||
test('canInheritTimeRange returns true if embeddable is inside a container with a time range', () => {
|
||||
const embeddable = new TimeRangeEmbeddable(
|
||||
{ id: '1234', timeRange: { from: 'noxw-15m', to: 'now' } },
|
||||
new TimeRangeContainer(
|
||||
{ id: '123', panels: {}, timeRange: { from: 'noxw-15m', to: 'now' } },
|
||||
() => undefined
|
||||
)
|
||||
);
|
||||
expect(canInheritTimeRange(embeddable)).toBe(true);
|
||||
});
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { TimeRange } from '@kbn/es-query';
|
||||
|
||||
import { Embeddable, IContainer, ContainerInput } from '../../..';
|
||||
import { TimeRangeInput } from './time_range_helpers';
|
||||
|
||||
interface ContainerTimeRangeInput extends ContainerInput<TimeRangeInput> {
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
export function canInheritTimeRange(embeddable: Embeddable<TimeRangeInput>) {
|
||||
if (!embeddable.parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parent = embeddable.parent as IContainer<TimeRangeInput, ContainerTimeRangeInput>;
|
||||
|
||||
return parent.getInput().timeRange !== undefined;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue