mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[Dashboard] Add collapsible sections (#220877)
Closes https://github.com/elastic/kibana/issues/1547 Closes https://github.com/elastic/kibana/issues/190342 Closes https://github.com/elastic/kibana/issues/197716 ## Summary This PR adds the ability for collapsible sections to be created and managed on Dashboards. https://github.com/user-attachments/assets/c5c046d0-58f1-45e1-88b3-33421f3ec002 > [!NOTE] > Most of the work for developing collapsible sections occurred in PRs contained to the `kbn-grid-layout` package (see [this meta issue](https://github.com/elastic/kibana/issues/190342) to track this work) - this PR simply makes them available on Dashboards by adding them as a widget that can be added through the "Add panel" menu. As a result of this, most work is contained in the Dashboard plugin - changes made to the `kbn-grid-layout` package only include adding IDs for additional tests that were added for the Dashboard integration. ### Technical Details #### Content Management Schema The content management schema allows for panels and sections to be mixed within the single `panels` key for a dashboard **without** worrying about section IDs; for example: ``` { "panels": [ { // this is a simplified panel "gridData": { "x": 0, "y": 0, "w": 12, "h": 8, }, "panelConfig": { ... }, }, { // this is a section "gridData": { "y": 9, }, "collapsed": false, "title": "Section title", "panels": [ { // this is a simplified panel "gridData": { "x": 0, "y": 0, "w": 24, "h": 16, }, "panelConfig": { ... }, }, ], }, ] } ``` #### Saved Object Schema The dashboard saved object schema, on the other hand, separates out sections and panels under different keys - this is because, while we are stuck with panels being stored as `panelJSON`, I didn't want to add much to this. So, under grid data for each panel, they have an optional `sectionId` which then links to a section in the `sections` array in the saved object: ``` { "panelsJSON": "<...> \"gridData\":{\"i\":\"panelId\",\"y\":0,\"x\":0,\"w\":12,\"h\":8,\"sectionId\":\"someSectionId\"} <...>" "sections": [ { "collapsed": false, "title": "Section title", "gridData": { "i": "someSectionId", "y": 8. } } ], } ``` This allows sections to be serialized **without** being stringified. This storage also matches how we store this data in runtime using `layout`. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ## Release note Adds collapsible sections to Dashboard, which allow panels to grouped into sections that will not load their contents when their assigned section is collapsed. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b3f79c809f
commit
74ee116780
78 changed files with 5615 additions and 1982 deletions
1517
oas_docs/bundle.json
1517
oas_docs/bundle.json
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -285,6 +285,7 @@
|
|||
"refreshInterval.pause",
|
||||
"refreshInterval.section",
|
||||
"refreshInterval.value",
|
||||
"sections",
|
||||
"timeFrom",
|
||||
"timeRestore",
|
||||
"timeTo",
|
||||
|
|
|
@ -982,6 +982,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"dynamic": false,
|
||||
"properties": {}
|
||||
},
|
||||
"timeFrom": {
|
||||
"doc_values": false,
|
||||
"index": false,
|
||||
|
|
|
@ -90,7 +90,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
|
|||
"connector_token": "79977ea2cb1530ba7e315b95c1b5a524b622a6b3",
|
||||
"core-usage-stats": "b3c04da317c957741ebcdedfea4524049fdc79ff",
|
||||
"csp-rule-template": "c151324d5f85178169395eecb12bac6b96064654",
|
||||
"dashboard": "211e9ca30f5a95d5f3c27b1bf2b58e6cfa0c9ae9",
|
||||
"dashboard": "7fea2b6f8f860ac4f665fd0d5c91645ac248fd56",
|
||||
"dynamic-config-overrides": "eb3ec7d96a42991068eda5421eecba9349c82d2b",
|
||||
"endpoint:unified-user-artifact-manifest": "71c7fcb52c658b21ea2800a6b6a76972ae1c776e",
|
||||
"endpoint:user-artifact-manifest": "1c3533161811a58772e30cdc77bac4631da3ef2b",
|
||||
|
|
|
@ -38,6 +38,11 @@ const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
|
|||
|
||||
const { rerender, ...rtlRest } = render(<GridLayout {...props} />, { wrapper: EuiThemeProvider });
|
||||
|
||||
const gridLayout = screen.getByTestId('kbnGridLayout');
|
||||
jest.spyOn(gridLayout, 'getBoundingClientRect').mockImplementation(() => {
|
||||
return { top: 0, bottom: 500 } as DOMRect;
|
||||
});
|
||||
|
||||
return {
|
||||
...rtlRest,
|
||||
rerender: (overrides: Partial<GridLayoutProps>) => {
|
||||
|
|
|
@ -198,6 +198,7 @@ export const GridLayout = ({
|
|||
<GridLayoutContext.Provider value={memoizedContext}>
|
||||
<GridHeightSmoother>
|
||||
<div
|
||||
data-test-subj="kbnGridLayout"
|
||||
ref={(divElement) => {
|
||||
layoutRef.current = divElement;
|
||||
setDimensionsRef(divElement);
|
||||
|
|
|
@ -35,6 +35,7 @@ export const DeleteGridSectionModal = ({
|
|||
|
||||
return (
|
||||
<EuiModal
|
||||
data-test-subj={`kbnGridLayoutDeleteSectionModal-${sectionId}`}
|
||||
onClose={() => {
|
||||
setDeleteModalVisible(false);
|
||||
}}
|
||||
|
|
|
@ -174,6 +174,10 @@ export const GridSectionHeader = React.memo(({ sectionId }: GridSectionHeaderPro
|
|||
|
||||
section.isCollapsed = !section.isCollapsed;
|
||||
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||
|
||||
const buttonRef = collapseButtonRef.current;
|
||||
if (!buttonRef) return;
|
||||
buttonRef.setAttribute('aria-expanded', `${!section.isCollapsed}`);
|
||||
}, [gridLayoutStateManager, sectionId]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -110,6 +110,7 @@ export const GridSectionTitle = React.memo(
|
|||
size="m"
|
||||
id={`kbnGridSectionTitle-${sectionId}`}
|
||||
aria-controls={`kbnGridSection-${sectionId}`}
|
||||
aria-expanded={!currentSection?.isCollapsed}
|
||||
data-test-subj={`kbnGridSectionTitle-${sectionId}`}
|
||||
textProps={false}
|
||||
className={'kbnGridSectionTitle--button'}
|
||||
|
|
|
@ -86,6 +86,7 @@ export const GridSectionWrapper = React.memo(({ sectionId }: GridSectionProps) =
|
|||
gridLayoutStateManager.sectionRefs.current[sectionId] = rowRef;
|
||||
}}
|
||||
className={'kbnGridSection'}
|
||||
data-test-subj={`kbnGridSectionWrapper-${sectionId}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -88,9 +88,17 @@ export const moveAction = (
|
|||
let previousSection;
|
||||
let targetSectionId: string | undefined = (() => {
|
||||
if (isResize) return lastSectionId;
|
||||
// early return - target the first "main" section if the panel is dragged above the layout element
|
||||
if (previewRect.top < (gridLayoutElement?.getBoundingClientRect().top ?? 0)) {
|
||||
const layoutRect = gridLayoutElement?.getBoundingClientRect();
|
||||
// early returns for edge cases
|
||||
if (previewRect.top < (layoutRect?.top ?? 0)) {
|
||||
// target the first "main" section if the panel is dragged above the layout element
|
||||
return `main-0`;
|
||||
} else if (previewRect.top > (layoutRect?.bottom ?? Infinity)) {
|
||||
// target the last "main" section if the panel is dragged below the layout element
|
||||
const sections = Object.values(currentLayout);
|
||||
const maxOrder = sections.length - 1;
|
||||
previousSection = sections.filter(({ order }) => order === maxOrder)[0].id;
|
||||
return `main-${maxOrder}`;
|
||||
}
|
||||
|
||||
const previewBottom = previewRect.top + rowHeight;
|
||||
|
|
|
@ -62,6 +62,7 @@ export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
|
|||
for (const key of keys) {
|
||||
const widgetA = a[key];
|
||||
const widgetB = b[key];
|
||||
if (!widgetA || !widgetB) return widgetA === widgetB;
|
||||
|
||||
if (widgetA.type === 'panel' && widgetB.type === 'panel') {
|
||||
isEqual = isGridDataEqual(widgetA, widgetB);
|
||||
|
|
|
@ -17,6 +17,7 @@ export {
|
|||
type CanDuplicatePanels,
|
||||
type CanExpandPanels,
|
||||
} from './interfaces/panel_management';
|
||||
export { type CanAddNewSection, apiCanAddNewSection } from './interfaces/can_add_new_section';
|
||||
export {
|
||||
canTrackContentfulRender,
|
||||
type TrackContentfulRender,
|
||||
|
|
|
@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export interface CanAddNewSection {
|
||||
addNewSection: () => void;
|
||||
}
|
||||
|
||||
export const apiCanAddNewSection = (api: unknown): api is CanAddNewSection => {
|
||||
return typeof (api as CanAddNewSection)?.addNewSection === 'function';
|
||||
};
|
|
@ -7,15 +7,14 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { combineLatest, debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import {
|
||||
apiHasUniqueId,
|
||||
apiPublishesUnsavedChanges,
|
||||
HasUniqueId,
|
||||
PublishesUnsavedChanges,
|
||||
PublishingSubject,
|
||||
apiHasUniqueId,
|
||||
apiPublishesUnsavedChanges,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { combineLatest, debounceTime, map, of, switchMap } from 'rxjs';
|
||||
|
||||
export const DEBOUNCE_TIME = 100;
|
||||
|
||||
|
@ -27,8 +26,6 @@ export function childrenUnsavedChanges$<Api extends unknown = unknown>(
|
|||
) {
|
||||
return children$.pipe(
|
||||
map((children) => Object.keys(children)),
|
||||
distinctUntilChanged(deepEqual),
|
||||
|
||||
// children may change, so make sure we subscribe/unsubscribe with switchMap
|
||||
switchMap((newChildIds: string[]) => {
|
||||
if (newChildIds.length === 0) return of([]);
|
||||
|
|
|
@ -7,5 +7,11 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type { GridData, DashboardItem, SavedDashboardPanel } from '../v1/types'; // no changes made to types from v1 to v2
|
||||
export type { ControlGroupAttributes, DashboardCrudTypes, DashboardAttributes } from './types';
|
||||
export type { DashboardItem } from '../v1/types'; // no changes made to types from v1 to v2
|
||||
export type {
|
||||
ControlGroupAttributes,
|
||||
DashboardCrudTypes,
|
||||
DashboardAttributes,
|
||||
GridData,
|
||||
SavedDashboardPanel,
|
||||
} from './types';
|
||||
|
|
|
@ -16,7 +16,18 @@ import { DashboardContentType } from '../types';
|
|||
import {
|
||||
ControlGroupAttributesV1,
|
||||
DashboardAttributes as DashboardAttributesV1,
|
||||
GridData as GridDataV1,
|
||||
SavedDashboardPanel as SavedDashboardPanelV1,
|
||||
} from '../v1/types';
|
||||
import { DashboardSectionState } from '../..';
|
||||
|
||||
export type GridData = GridDataV1 & {
|
||||
sectionId?: string;
|
||||
};
|
||||
|
||||
export type SavedDashboardPanel = Omit<SavedDashboardPanelV1, 'gridData'> & {
|
||||
gridData: GridData;
|
||||
};
|
||||
|
||||
export type ControlGroupAttributes = ControlGroupAttributesV1 & {
|
||||
showApplySelections?: boolean;
|
||||
|
@ -24,6 +35,7 @@ export type ControlGroupAttributes = ControlGroupAttributesV1 & {
|
|||
|
||||
export type DashboardAttributes = Omit<DashboardAttributesV1, 'controlGroupInput'> & {
|
||||
controlGroupInput?: ControlGroupAttributes;
|
||||
sections?: DashboardSectionState[];
|
||||
};
|
||||
|
||||
export type DashboardCrudTypes = ContentManagementCrudTypes<
|
||||
|
|
|
@ -26,6 +26,7 @@ const dashboardWithExtractedPanel: ParsedDashboardAttributesWithType = {
|
|||
},
|
||||
},
|
||||
},
|
||||
sections: {},
|
||||
};
|
||||
|
||||
const extractedSavedObjectPanelRef = {
|
||||
|
@ -47,6 +48,7 @@ const unextractedDashboardState: ParsedDashboardAttributesWithType = {
|
|||
},
|
||||
},
|
||||
},
|
||||
sections: {},
|
||||
};
|
||||
|
||||
describe('inject/extract by reference panel', () => {
|
||||
|
@ -85,6 +87,7 @@ const dashboardWithExtractedByValuePanel: ParsedDashboardAttributesWithType = {
|
|||
},
|
||||
},
|
||||
},
|
||||
sections: {},
|
||||
};
|
||||
|
||||
const extractedByValueRef = {
|
||||
|
@ -106,6 +109,7 @@ const unextractedDashboardByValueState: ParsedDashboardAttributesWithType = {
|
|||
},
|
||||
},
|
||||
},
|
||||
sections: {},
|
||||
};
|
||||
|
||||
describe('inject/extract by value panels', () => {
|
||||
|
|
|
@ -11,6 +11,17 @@ import type { Reference } from '@kbn/content-management-utils';
|
|||
|
||||
import type { GridData } from '../../server/content_management';
|
||||
|
||||
export interface DashboardSectionMap {
|
||||
[id: string]: DashboardSectionState;
|
||||
}
|
||||
|
||||
export interface DashboardSectionState {
|
||||
title: string;
|
||||
collapsed?: boolean; // if undefined, then collapsed is false
|
||||
readonly gridData: Pick<GridData, 'i' | 'y'>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DashboardPanelMap {
|
||||
[key: string]: DashboardPanelState;
|
||||
}
|
||||
|
@ -18,7 +29,7 @@ export interface DashboardPanelMap {
|
|||
export interface DashboardPanelState<PanelState = object> {
|
||||
type: string;
|
||||
explicitInput: PanelState;
|
||||
readonly gridData: GridData;
|
||||
readonly gridData: GridData & { sectionId?: string };
|
||||
panelRefName?: string;
|
||||
|
||||
/**
|
||||
|
|
|
@ -11,8 +11,8 @@ import type { Reference } from '@kbn/content-management-utils';
|
|||
import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common/types';
|
||||
|
||||
import {
|
||||
convertPanelMapToPanelsArray,
|
||||
convertPanelsArrayToPanelMap,
|
||||
convertPanelSectionMapsToPanelsArray,
|
||||
convertPanelsArrayToPanelSectionMaps,
|
||||
} from '../../lib/dashboard_panel_converters';
|
||||
import { DashboardAttributesAndReferences, ParsedDashboardAttributesWithType } from '../../types';
|
||||
import type { DashboardAttributes } from '../../../server/content_management';
|
||||
|
@ -28,9 +28,11 @@ export interface InjectExtractDeps {
|
|||
function parseDashboardAttributesWithType({
|
||||
panels,
|
||||
}: DashboardAttributes): ParsedDashboardAttributesWithType {
|
||||
const { panels: panelsMap, sections } = convertPanelsArrayToPanelSectionMaps(panels); // drop sections
|
||||
return {
|
||||
type: 'dashboard',
|
||||
panels: convertPanelsArrayToPanelMap(panels),
|
||||
panels: panelsMap,
|
||||
sections,
|
||||
} as ParsedDashboardAttributesWithType;
|
||||
}
|
||||
|
||||
|
@ -43,7 +45,10 @@ export function injectReferences(
|
|||
// inject references back into panels via the Embeddable persistable state service.
|
||||
const inject = createInject(deps.embeddablePersistableStateService);
|
||||
const injectedState = inject(parsedAttributes, references) as ParsedDashboardAttributesWithType;
|
||||
const injectedPanels = convertPanelMapToPanelsArray(injectedState.panels);
|
||||
const injectedPanels = convertPanelSectionMapsToPanelsArray(
|
||||
injectedState.panels,
|
||||
parsedAttributes.sections
|
||||
); // sections don't have references
|
||||
|
||||
const newAttributes = {
|
||||
...attributes,
|
||||
|
@ -58,7 +63,6 @@ export function extractReferences(
|
|||
deps: InjectExtractDeps
|
||||
): DashboardAttributesAndReferences {
|
||||
const parsedAttributes = parseDashboardAttributesWithType(attributes);
|
||||
|
||||
const panels = parsedAttributes.panels;
|
||||
|
||||
const panelMissingType = Object.entries(panels).find(
|
||||
|
@ -73,8 +77,10 @@ export function extractReferences(
|
|||
references: Reference[];
|
||||
state: ParsedDashboardAttributesWithType;
|
||||
};
|
||||
const extractedPanels = convertPanelMapToPanelsArray(extractedState.panels);
|
||||
|
||||
const extractedPanels = convertPanelSectionMapsToPanelsArray(
|
||||
extractedState.panels,
|
||||
parsedAttributes.sections
|
||||
); // sections don't have references
|
||||
const newAttributes = {
|
||||
...attributes,
|
||||
panels: extractedPanels,
|
||||
|
|
|
@ -14,6 +14,12 @@ export type {
|
|||
DashboardState,
|
||||
} from './types';
|
||||
|
||||
export type { DashboardPanelMap, DashboardPanelState } from './dashboard_container/types';
|
||||
export type {
|
||||
DashboardPanelMap,
|
||||
DashboardPanelState,
|
||||
DashboardSectionMap,
|
||||
DashboardSectionState,
|
||||
} from './dashboard_container/types';
|
||||
|
||||
export { type InjectExtractDeps } from './dashboard_saved_object/persistable_state/dashboard_saved_object_references';
|
||||
export { isDashboardSection } from './lib/dashboard_panel_converters';
|
||||
|
|
|
@ -7,44 +7,97 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { v4 } from 'uuid';
|
||||
import { omit } from 'lodash';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import type { Reference } from '@kbn/content-management-utils';
|
||||
import type { DashboardPanelMap } from '..';
|
||||
import type { DashboardPanel } from '../../server/content_management';
|
||||
import type { DashboardPanelMap, DashboardSectionMap } from '..';
|
||||
import type {
|
||||
DashboardAttributes,
|
||||
DashboardPanel,
|
||||
DashboardSection,
|
||||
} from '../../server/content_management';
|
||||
|
||||
import {
|
||||
getReferencesForPanelId,
|
||||
prefixReferencesFromPanel,
|
||||
} from '../dashboard_container/persistable_state/dashboard_container_references';
|
||||
|
||||
export const convertPanelsArrayToPanelMap = (panels?: DashboardPanel[]): DashboardPanelMap => {
|
||||
const panelsMap: DashboardPanelMap = {};
|
||||
panels?.forEach((panel, idx) => {
|
||||
panelsMap![panel.panelIndex ?? String(idx)] = {
|
||||
type: panel.type,
|
||||
gridData: panel.gridData,
|
||||
panelRefName: panel.panelRefName,
|
||||
explicitInput: {
|
||||
...(panel.id !== undefined && { savedObjectId: panel.id }),
|
||||
...(panel.title !== undefined && { title: panel.title }),
|
||||
...panel.panelConfig,
|
||||
},
|
||||
version: panel.version,
|
||||
};
|
||||
});
|
||||
return panelsMap;
|
||||
export const isDashboardSection = (
|
||||
widget: DashboardAttributes['panels'][number]
|
||||
): widget is DashboardSection => {
|
||||
return 'panels' in widget;
|
||||
};
|
||||
|
||||
export const convertPanelMapToPanelsArray = (
|
||||
export const convertPanelsArrayToPanelSectionMaps = (
|
||||
panels?: DashboardAttributes['panels']
|
||||
): { panels: DashboardPanelMap; sections: DashboardSectionMap } => {
|
||||
const panelsMap: DashboardPanelMap = {};
|
||||
const sectionsMap: DashboardSectionMap = {};
|
||||
|
||||
/**
|
||||
* panels and sections are mixed in the DashboardAttributes 'panels' key, so we need
|
||||
* to separate them out into separate maps for the dashboard client side code
|
||||
*/
|
||||
panels?.forEach((widget, i) => {
|
||||
if (isDashboardSection(widget)) {
|
||||
const sectionId = widget.gridData.i ?? String(i);
|
||||
const { panels: sectionPanels, ...restOfSection } = widget;
|
||||
sectionsMap[sectionId] = {
|
||||
...restOfSection,
|
||||
gridData: {
|
||||
...widget.gridData,
|
||||
i: sectionId,
|
||||
},
|
||||
id: sectionId,
|
||||
};
|
||||
(sectionPanels as DashboardPanel[]).forEach((panel, j) => {
|
||||
const panelId = panel.panelIndex ?? String(j);
|
||||
const transformed = transformPanel(panel);
|
||||
panelsMap[panelId] = {
|
||||
...transformed,
|
||||
gridData: { ...transformed.gridData, sectionId, i: panelId },
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// if not a section, then this widget is a panel
|
||||
panelsMap[widget.panelIndex ?? String(i)] = transformPanel(widget);
|
||||
}
|
||||
});
|
||||
|
||||
return { panels: panelsMap, sections: sectionsMap };
|
||||
};
|
||||
|
||||
const transformPanel = (panel: DashboardPanel): DashboardPanelMap[string] => {
|
||||
return {
|
||||
type: panel.type,
|
||||
gridData: panel.gridData,
|
||||
panelRefName: panel.panelRefName,
|
||||
explicitInput: {
|
||||
...(panel.id !== undefined && { savedObjectId: panel.id }),
|
||||
...(panel.title !== undefined && { title: panel.title }),
|
||||
...panel.panelConfig,
|
||||
},
|
||||
version: panel.version,
|
||||
};
|
||||
};
|
||||
|
||||
export const convertPanelSectionMapsToPanelsArray = (
|
||||
panels: DashboardPanelMap,
|
||||
sections: DashboardSectionMap,
|
||||
removeLegacyVersion?: boolean
|
||||
) => {
|
||||
return Object.entries(panels).map(([panelId, panelState]) => {
|
||||
): DashboardAttributes['panels'] => {
|
||||
const combined: DashboardAttributes['panels'] = [];
|
||||
|
||||
const panelsInSections: { [sectionId: string]: DashboardSection } = {};
|
||||
Object.entries(sections).forEach(([sectionId, sectionState]) => {
|
||||
panelsInSections[sectionId] = { ...omit(sectionState, 'id'), panels: [] };
|
||||
});
|
||||
Object.entries(panels).forEach(([panelId, panelState]) => {
|
||||
const savedObjectId = (panelState.explicitInput as { savedObjectId?: string }).savedObjectId;
|
||||
const title = (panelState.explicitInput as { title?: string }).title;
|
||||
return {
|
||||
const { sectionId, ...gridData } = panelState.gridData; // drop section ID
|
||||
const convertedPanelState = {
|
||||
/**
|
||||
* Version information used to be stored in the panel until 8.11 when it was moved to live inside the
|
||||
* explicit Embeddable Input. If removeLegacyVersion is not passed, we'd like to keep this information for
|
||||
|
@ -53,14 +106,22 @@ export const convertPanelMapToPanelsArray = (
|
|||
...(!removeLegacyVersion ? { version: panelState.version } : {}),
|
||||
|
||||
type: panelState.type,
|
||||
gridData: panelState.gridData,
|
||||
gridData,
|
||||
panelIndex: panelId,
|
||||
panelConfig: omit(panelState.explicitInput, ['id', 'savedObjectId', 'title']),
|
||||
...(title !== undefined && { title }),
|
||||
...(savedObjectId !== undefined && { id: savedObjectId }),
|
||||
...(panelState.panelRefName !== undefined && { panelRefName: panelState.panelRefName }),
|
||||
};
|
||||
|
||||
if (sectionId) {
|
||||
panelsInSections[sectionId].panels.push(convertedPanelState);
|
||||
} else {
|
||||
combined.push(convertedPanelState);
|
||||
}
|
||||
});
|
||||
|
||||
return [...combined, ...Object.values(panelsInSections)];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { ScopedHistory } from '@kbn/core-application-browser';
|
|||
|
||||
import { ForwardedDashboardState } from './locator';
|
||||
import type { DashboardState } from '../types';
|
||||
import { convertPanelsArrayToPanelMap } from '../lib/dashboard_panel_converters';
|
||||
import { convertPanelsArrayToPanelSectionMaps } from '../lib/dashboard_panel_converters';
|
||||
|
||||
export const loadDashboardHistoryLocationState = (
|
||||
getScopedHistory: () => ScopedHistory
|
||||
|
@ -29,6 +29,6 @@ export const loadDashboardHistoryLocationState = (
|
|||
|
||||
return {
|
||||
...restOfState,
|
||||
...{ panels: convertPanelsArrayToPanelMap(panels) },
|
||||
...convertPanelsArrayToPanelSectionMaps(panels),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -17,11 +17,12 @@ import type {
|
|||
ControlGroupSerializedState,
|
||||
} from '@kbn/controls-plugin/common';
|
||||
|
||||
import type { DashboardPanelMap } from './dashboard_container/types';
|
||||
import type { DashboardPanelMap, DashboardSectionMap } from './dashboard_container/types';
|
||||
import type {
|
||||
DashboardAttributes,
|
||||
DashboardOptions,
|
||||
DashboardPanel,
|
||||
DashboardSection,
|
||||
} from '../server/content_management';
|
||||
|
||||
export interface DashboardCapabilities {
|
||||
|
@ -37,6 +38,7 @@ export interface DashboardCapabilities {
|
|||
export interface ParsedDashboardAttributesWithType {
|
||||
id: string;
|
||||
panels: DashboardPanelMap;
|
||||
sections: DashboardSectionMap;
|
||||
type: 'dashboard';
|
||||
}
|
||||
|
||||
|
@ -59,6 +61,7 @@ export interface DashboardState extends DashboardSettings {
|
|||
refreshInterval?: RefreshInterval;
|
||||
viewMode: ViewMode;
|
||||
panels: DashboardPanelMap;
|
||||
sections: DashboardSectionMap;
|
||||
|
||||
/**
|
||||
* Temporary. Currently Dashboards are in charge of providing references to all of their children.
|
||||
|
@ -78,7 +81,7 @@ export interface DashboardState extends DashboardSettings {
|
|||
* Do not change type without considering BWC of stored URLs
|
||||
*/
|
||||
export type SharedDashboardState = Partial<
|
||||
Omit<DashboardState, 'panels'> & {
|
||||
Omit<DashboardState, 'panels' | 'sections'> & {
|
||||
controlGroupInput?: DashboardState['controlGroupInput'] & SerializableRecord;
|
||||
|
||||
/**
|
||||
|
@ -87,7 +90,7 @@ export type SharedDashboardState = Partial<
|
|||
*/
|
||||
controlGroupState?: Partial<ControlGroupRuntimeState> & SerializableRecord;
|
||||
|
||||
panels: DashboardPanel[];
|
||||
panels: Array<DashboardPanel | DashboardSection>;
|
||||
|
||||
references?: DashboardState['references'] & SerializableRecord;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ADD_PANEL_ANNOTATION_GROUP } from '@kbn/embeddable-plugin/public';
|
||||
import { apiCanAddNewSection, CanAddNewSection } from '@kbn/presentation-containers';
|
||||
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
|
||||
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
import { ACTION_ADD_SECTION } from './constants';
|
||||
|
||||
type AddSectionActionApi = CanAddNewSection;
|
||||
|
||||
const isApiCompatible = (api: unknown | null): api is AddSectionActionApi =>
|
||||
Boolean(apiCanAddNewSection(api));
|
||||
|
||||
export class AddSectionAction implements Action<EmbeddableApiContext> {
|
||||
public readonly type = ACTION_ADD_SECTION;
|
||||
public readonly id = ACTION_ADD_SECTION;
|
||||
public order = 40;
|
||||
public grouping = [ADD_PANEL_ANNOTATION_GROUP];
|
||||
|
||||
public getDisplayName() {
|
||||
return i18n.translate('dashboard.collapsibleSection.displayName', {
|
||||
defaultMessage: 'Collapsible section',
|
||||
});
|
||||
}
|
||||
|
||||
public getIconType() {
|
||||
return 'section';
|
||||
}
|
||||
|
||||
public async isCompatible({ embeddable }: EmbeddableApiContext) {
|
||||
return isApiCompatible(embeddable);
|
||||
}
|
||||
|
||||
public async execute({ embeddable }: EmbeddableApiContext) {
|
||||
if (!isApiCompatible(embeddable)) throw new IncompatibleActionError();
|
||||
embeddable.addNewSection();
|
||||
}
|
||||
}
|
|
@ -15,4 +15,5 @@ export const ACTION_COPY_TO_DASHBOARD = 'copyToDashboard';
|
|||
export const ACTION_EXPAND_PANEL = 'togglePanel';
|
||||
export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV';
|
||||
export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary';
|
||||
export const ACTION_ADD_SECTION = 'addCollapsibleSection';
|
||||
export const BADGE_FILTERS_NOTIFICATION = 'ACTION_FILTERS_NOTIFICATION';
|
||||
|
|
|
@ -8,8 +8,10 @@
|
|||
*/
|
||||
|
||||
import { CONTEXT_MENU_TRIGGER, PANEL_NOTIFICATION_TRIGGER } from '@kbn/embeddable-plugin/public';
|
||||
import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
|
||||
import { DashboardStartDependencies } from '../plugin';
|
||||
import {
|
||||
ACTION_ADD_SECTION,
|
||||
ACTION_ADD_TO_LIBRARY,
|
||||
ACTION_CLONE_PANEL,
|
||||
ACTION_COPY_TO_DASHBOARD,
|
||||
|
@ -40,6 +42,12 @@ export const registerActions = async (plugins: DashboardStartDependencies) => {
|
|||
});
|
||||
uiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, BADGE_FILTERS_NOTIFICATION);
|
||||
|
||||
uiActions.registerActionAsync(ACTION_ADD_SECTION, async () => {
|
||||
const { AddSectionAction } = await import('../dashboard_renderer/dashboard_module');
|
||||
return new AddSectionAction();
|
||||
});
|
||||
uiActions.attachAction(ADD_PANEL_TRIGGER, ACTION_ADD_SECTION);
|
||||
|
||||
if (share) {
|
||||
uiActions.registerActionAsync(ACTION_EXPORT_CSV, async () => {
|
||||
const { ExportCSVAction } = await import('../dashboard_renderer/dashboard_module');
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { xor } from 'lodash';
|
||||
import { DashboardLayout } from './types';
|
||||
|
||||
/**
|
||||
* Checks whether the layouts have the same keys, and if they do, checks whether every layout item in the
|
||||
* original layout is deep equal to the layout item at the same ID in the new layout
|
||||
*/
|
||||
export const areLayoutsEqual = (originalLayout?: DashboardLayout, newLayout?: DashboardLayout) => {
|
||||
/**
|
||||
* It is safe to assume that there are **usually** more panels than sections, so do cheaper section ID comparison first
|
||||
*/
|
||||
const newSectionUuids = Object.keys(newLayout?.sections ?? {});
|
||||
const sectionIdDiff = xor(Object.keys(originalLayout?.sections ?? {}), newSectionUuids);
|
||||
if (sectionIdDiff.length > 0) return false;
|
||||
|
||||
/**
|
||||
* Since section IDs are equal, check for more expensive panel ID equality
|
||||
*/
|
||||
const newPanelUuids = Object.keys(newLayout?.panels ?? {});
|
||||
const panelIdDiff = xor(Object.keys(originalLayout?.panels ?? {}), newPanelUuids);
|
||||
if (panelIdDiff.length > 0) return false;
|
||||
|
||||
/**
|
||||
* IDs of all widgets are equal, so now actually compare contents - this is the most expensive equality comparison step
|
||||
*/
|
||||
// again, start with section comparison since it is most likely cheaper
|
||||
for (const sectionId of newSectionUuids) {
|
||||
if (!deepEqual(originalLayout?.sections[sectionId], newLayout?.sections[sectionId])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// then compare panel grid data
|
||||
for (const embeddableId of newPanelUuids) {
|
||||
if (
|
||||
!deepEqual(
|
||||
originalLayout?.panels[embeddableId]?.gridData,
|
||||
newLayout?.panels[embeddableId]?.gridData
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
|
@ -1,37 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { xor } from 'lodash';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { DashboardLayout } from './types';
|
||||
|
||||
/**
|
||||
* Checks whether the panel maps have the same keys, and if they do, whether the grid data and types of each panel
|
||||
* are equal.
|
||||
*/
|
||||
export const arePanelLayoutsEqual = (
|
||||
originalPanels?: DashboardLayout,
|
||||
newPanels?: DashboardLayout
|
||||
) => {
|
||||
const originalUuids = Object.keys(originalPanels ?? {});
|
||||
const newUuids = Object.keys(newPanels ?? {});
|
||||
|
||||
const idDiff = xor(originalUuids, newUuids);
|
||||
if (idDiff.length > 0) return false;
|
||||
|
||||
for (const embeddableId of newUuids) {
|
||||
if (originalPanels?.[embeddableId]?.type !== newPanels?.[embeddableId]?.type) {
|
||||
return false;
|
||||
}
|
||||
if (!deepEqual(originalPanels?.[embeddableId]?.gridData, newPanels?.[embeddableId]?.gridData)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
|
@ -16,6 +16,7 @@ export const DEFAULT_DASHBOARD_STATE: DashboardState = {
|
|||
description: '',
|
||||
filters: [],
|
||||
panels: {},
|
||||
sections: {},
|
||||
title: '',
|
||||
tags: [],
|
||||
|
||||
|
|
|
@ -11,18 +11,23 @@ import type { Reference } from '@kbn/content-management-utils';
|
|||
import { EmbeddablePackageState } from '@kbn/embeddable-plugin/public';
|
||||
import { BehaviorSubject, debounceTime, merge } from 'rxjs';
|
||||
import { v4 } from 'uuid';
|
||||
import { DASHBOARD_APP_ID } from '../../common/constants';
|
||||
import {
|
||||
getReferencesForControls,
|
||||
getReferencesForPanelId,
|
||||
} from '../../common/dashboard_container/persistable_state/dashboard_container_references';
|
||||
import { DASHBOARD_APP_ID } from '../../common/constants';
|
||||
import type { DashboardState } from '../../common/types';
|
||||
import { getDashboardContentManagementService } from '../services/dashboard_content_management_service';
|
||||
import { LoadDashboardReturn } from '../services/dashboard_content_management_service/types';
|
||||
import {
|
||||
CONTROL_GROUP_EMBEDDABLE_ID,
|
||||
initializeControlGroupManager,
|
||||
} from './control_group_manager';
|
||||
import { initializeDataLoadingManager } from './data_loading_manager';
|
||||
import { initializeDataViewsManager } from './data_views_manager';
|
||||
import { DEFAULT_DASHBOARD_STATE } from './default_dashboard_state';
|
||||
import { getSerializedState } from './get_serialized_state';
|
||||
import { initializePanelsManager } from './panels_manager';
|
||||
import { initializeLayoutManager } from './layout_manager';
|
||||
import { openSaveModal } from './save_modal/open_save_modal';
|
||||
import { initializeSearchSessionManager } from './search_sessions/search_session_manager';
|
||||
import { initializeSettingsManager } from './settings_manager';
|
||||
|
@ -35,14 +40,9 @@ import {
|
|||
DashboardCreationOptions,
|
||||
DashboardInternalApi,
|
||||
} from './types';
|
||||
import type { DashboardState } from '../../common/types';
|
||||
import { initializeUnifiedSearchManager } from './unified_search_manager';
|
||||
import { initializeUnsavedChangesManager } from './unsaved_changes_manager';
|
||||
import { initializeViewModeManager } from './view_mode_manager';
|
||||
import {
|
||||
CONTROL_GROUP_EMBEDDABLE_ID,
|
||||
initializeControlGroupManager,
|
||||
} from './control_group_manager';
|
||||
|
||||
export function getDashboardApi({
|
||||
creationOptions,
|
||||
|
@ -63,7 +63,7 @@ export function getDashboardApi({
|
|||
|
||||
const viewModeManager = initializeViewModeManager(incomingEmbeddable, savedObjectResult);
|
||||
const trackPanel = initializeTrackPanel(async (id: string) => {
|
||||
await panelsManager.api.getChildApi(id);
|
||||
await layoutManager.api.getChildApi(id);
|
||||
});
|
||||
|
||||
const references$ = new BehaviorSubject<Reference[] | undefined>(initialState.references);
|
||||
|
@ -78,9 +78,10 @@ export function getDashboardApi({
|
|||
return panelReferences.length > 0 ? panelReferences : references$.value ?? [];
|
||||
};
|
||||
|
||||
const panelsManager = initializePanelsManager(
|
||||
const layoutManager = initializeLayoutManager(
|
||||
incomingEmbeddable,
|
||||
initialState.panels,
|
||||
initialState.sections,
|
||||
trackPanel,
|
||||
getReferences
|
||||
);
|
||||
|
@ -88,10 +89,10 @@ export function getDashboardApi({
|
|||
initialState.controlGroupInput,
|
||||
getReferences
|
||||
);
|
||||
const dataLoadingManager = initializeDataLoadingManager(panelsManager.api.children$);
|
||||
const dataLoadingManager = initializeDataLoadingManager(layoutManager.api.children$);
|
||||
const dataViewsManager = initializeDataViewsManager(
|
||||
controlGroupManager.api.controlGroupApi$,
|
||||
panelsManager.api.children$
|
||||
layoutManager.api.children$
|
||||
);
|
||||
const settingsManager = initializeSettingsManager(initialState);
|
||||
const unifiedSearchManager = initializeUnifiedSearchManager(
|
||||
|
@ -107,7 +108,7 @@ export function getDashboardApi({
|
|||
creationOptions,
|
||||
controlGroupManager,
|
||||
lastSavedState: savedObjectResult?.dashboardInput ?? DEFAULT_DASHBOARD_STATE,
|
||||
panelsManager,
|
||||
layoutManager,
|
||||
savedObjectId$,
|
||||
settingsManager,
|
||||
unifiedSearchManager,
|
||||
|
@ -115,13 +116,18 @@ export function getDashboardApi({
|
|||
});
|
||||
|
||||
function getState() {
|
||||
const { panels, references: panelReferences } = panelsManager.internalApi.serializePanels();
|
||||
const {
|
||||
panels,
|
||||
sections,
|
||||
references: panelReferences,
|
||||
} = layoutManager.internalApi.serializeLayout();
|
||||
const { state: unifiedSearchState, references: searchSourceReferences } =
|
||||
unifiedSearchManager.internalApi.getState();
|
||||
const dashboardState: DashboardState = {
|
||||
...settingsManager.api.getSettings(),
|
||||
...unifiedSearchState,
|
||||
panels,
|
||||
sections,
|
||||
viewMode: viewModeManager.api.viewMode$.value,
|
||||
};
|
||||
|
||||
|
@ -143,7 +149,7 @@ export function getDashboardApi({
|
|||
...viewModeManager.api,
|
||||
...dataLoadingManager.api,
|
||||
...dataViewsManager.api,
|
||||
...panelsManager.api,
|
||||
...layoutManager.api,
|
||||
...settingsManager.api,
|
||||
...trackPanel,
|
||||
...unifiedSearchManager.api,
|
||||
|
@ -223,7 +229,7 @@ export function getDashboardApi({
|
|||
getSerializedStateForChild: (childId: string) => {
|
||||
return childId === CONTROL_GROUP_EMBEDDABLE_ID
|
||||
? controlGroupManager.internalApi.getStateForControlGroup()
|
||||
: panelsManager.internalApi.getSerializedStateForPanel(childId);
|
||||
: layoutManager.internalApi.getSerializedStateForPanel(childId);
|
||||
},
|
||||
setSavedObjectId: (id: string | undefined) => savedObjectId$.next(id),
|
||||
type: DASHBOARD_API_TYPE as 'dashboard',
|
||||
|
@ -231,7 +237,7 @@ export function getDashboardApi({
|
|||
} as Omit<DashboardApi, 'searchSessionId$'>;
|
||||
|
||||
const internalApi: DashboardInternalApi = {
|
||||
...panelsManager.internalApi,
|
||||
...layoutManager.internalApi,
|
||||
...unifiedSearchManager.internalApi,
|
||||
setControlGroupApi: controlGroupManager.internalApi.setControlGroupApi,
|
||||
};
|
||||
|
|
|
@ -167,4 +167,70 @@ describe('getSerializedState', () => {
|
|||
|
||||
expect(result.references).toEqual(panelReferences);
|
||||
});
|
||||
|
||||
it('should serialize sections', () => {
|
||||
const dashboardState = {
|
||||
...getSampleDashboardState(),
|
||||
panels: {
|
||||
oldPanelId: {
|
||||
type: 'visualization',
|
||||
gridData: { sectionId: 'section1' },
|
||||
} as unknown as DashboardPanelState,
|
||||
},
|
||||
sections: {
|
||||
section1: {
|
||||
id: 'section1',
|
||||
title: 'Section One',
|
||||
collapsed: false,
|
||||
gridData: { y: 1, i: 'section1' },
|
||||
},
|
||||
section2: {
|
||||
id: 'section2',
|
||||
title: 'Section Two',
|
||||
collapsed: true,
|
||||
gridData: { y: 2, i: 'section2' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = getSerializedState({
|
||||
controlGroupReferences: [],
|
||||
generateNewIds: true,
|
||||
dashboardState,
|
||||
panelReferences: [],
|
||||
searchSourceReferences: [],
|
||||
});
|
||||
|
||||
expect(result.attributes.panels).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"collapsed": false,
|
||||
"gridData": Object {
|
||||
"i": "section1",
|
||||
"y": 1,
|
||||
},
|
||||
"panels": Array [
|
||||
Object {
|
||||
"gridData": Object {
|
||||
"i": "54321",
|
||||
},
|
||||
"panelConfig": Object {},
|
||||
"panelIndex": "54321",
|
||||
"type": "visualization",
|
||||
"version": undefined,
|
||||
},
|
||||
],
|
||||
"title": "Section One",
|
||||
},
|
||||
Object {
|
||||
"collapsed": true,
|
||||
"gridData": Object {
|
||||
"i": "section2",
|
||||
"y": 2,
|
||||
},
|
||||
"panels": Array [],
|
||||
"title": "Section Two",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,27 +7,30 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { RefreshInterval } from '@kbn/data-plugin/public';
|
||||
import { pick } from 'lodash';
|
||||
import moment, { Moment } from 'moment';
|
||||
import { RefreshInterval } from '@kbn/data-plugin/public';
|
||||
|
||||
import type { Reference } from '@kbn/content-management-utils';
|
||||
import { extractReferences } from '../../common/dashboard_saved_object/persistable_state/dashboard_saved_object_references';
|
||||
import {
|
||||
convertPanelMapToPanelsArray,
|
||||
convertPanelSectionMapsToPanelsArray,
|
||||
generateNewPanelIds,
|
||||
} from '../../common/lib/dashboard_panel_converters';
|
||||
import { extractReferences } from '../../common/dashboard_saved_object/persistable_state/dashboard_saved_object_references';
|
||||
import type { DashboardAttributes } from '../../server';
|
||||
|
||||
import { convertDashboardVersionToNumber } from '../services/dashboard_content_management_service/lib/dashboard_versioning';
|
||||
import type { DashboardState } from '../../common';
|
||||
import { LATEST_VERSION } from '../../common/content_management';
|
||||
import {
|
||||
convertDashboardVersionToNumber,
|
||||
convertNumberToDashboardVersion,
|
||||
} from '../services/dashboard_content_management_service/lib/dashboard_versioning';
|
||||
import {
|
||||
dataService,
|
||||
embeddableService,
|
||||
savedObjectsTaggingService,
|
||||
} from '../services/kibana_services';
|
||||
import type { DashboardState } from '../../common';
|
||||
import { LATEST_VERSION } from '../../common/content_management';
|
||||
import { convertNumberToDashboardVersion } from '../services/dashboard_content_management_service/lib/dashboard_versioning';
|
||||
import { DashboardApi } from './types';
|
||||
|
||||
const LATEST_DASHBOARD_CONTAINER_VERSION = convertNumberToDashboardVersion(LATEST_VERSION);
|
||||
|
||||
|
@ -53,13 +56,12 @@ export const getSerializedState = ({
|
|||
dashboardState: DashboardState;
|
||||
panelReferences?: Reference[];
|
||||
searchSourceReferences?: Reference[];
|
||||
}) => {
|
||||
}): ReturnType<DashboardApi['getSerializedState']> => {
|
||||
const {
|
||||
query: {
|
||||
timefilter: { timefilter },
|
||||
},
|
||||
} = dataService;
|
||||
|
||||
const {
|
||||
tags,
|
||||
query,
|
||||
|
@ -67,6 +69,7 @@ export const getSerializedState = ({
|
|||
filters,
|
||||
timeRestore,
|
||||
description,
|
||||
sections,
|
||||
|
||||
// Dashboard options
|
||||
useMargins,
|
||||
|
@ -100,7 +103,7 @@ export const getSerializedState = ({
|
|||
syncTooltips,
|
||||
hidePanelTitles,
|
||||
};
|
||||
const savedPanels = convertPanelMapToPanelsArray(panels, true);
|
||||
const savedPanels = convertPanelSectionMapsToPanelsArray(panels, sections, true);
|
||||
|
||||
/**
|
||||
* Parse global time filter settings
|
||||
|
|
|
@ -7,6 +7,10 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { filter, map as lodashMap, max } from 'lodash';
|
||||
import { BehaviorSubject, Observable, combineLatestWith, debounceTime, map, merge } from 'rxjs';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
import type { Reference } from '@kbn/content-management-utils';
|
||||
import {
|
||||
|
@ -14,12 +18,8 @@ import {
|
|||
EmbeddablePackageState,
|
||||
PanelNotFoundError,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import {
|
||||
CanDuplicatePanels,
|
||||
HasSerializedChildState,
|
||||
PanelPackage,
|
||||
PresentationContainer,
|
||||
} from '@kbn/presentation-containers';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { PanelPackage } from '@kbn/presentation-containers';
|
||||
import {
|
||||
SerializedPanelState,
|
||||
SerializedTitles,
|
||||
|
@ -32,10 +32,8 @@ import {
|
|||
shouldLogStateDiff,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import { asyncForEach } from '@kbn/std';
|
||||
import { filter, map as lodashMap, max } from 'lodash';
|
||||
import { BehaviorSubject, Observable, combineLatestWith, debounceTime, map, merge } from 'rxjs';
|
||||
import { v4 } from 'uuid';
|
||||
import type { DashboardState } from '../../common';
|
||||
|
||||
import type { DashboardSectionMap, DashboardState } from '../../common';
|
||||
import { DashboardPanelMap } from '../../common';
|
||||
import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../../common/content_management';
|
||||
import { prefixReferencesFromPanel } from '../../common/dashboard_container/persistable_state/dashboard_container_references';
|
||||
|
@ -47,83 +45,84 @@ import { runPanelPlacementStrategy } from '../panel_placement/place_new_panel_st
|
|||
import { PanelPlacementStrategy } from '../plugin_constants';
|
||||
import { coreServices, usageCollectionService } from '../services/kibana_services';
|
||||
import { DASHBOARD_UI_METRIC_ID } from '../utils/telemetry_constants';
|
||||
import { arePanelLayoutsEqual } from './are_panel_layouts_equal';
|
||||
import { areLayoutsEqual } from './are_layouts_equal';
|
||||
import type { initializeTrackPanel } from './track_panel';
|
||||
import {
|
||||
DashboardApi,
|
||||
DashboardChildState,
|
||||
DashboardChildren,
|
||||
DashboardLayout,
|
||||
DashboardLayoutItem,
|
||||
} from './types';
|
||||
import { DashboardChildState, DashboardChildren, DashboardLayout, DashboardPanel } from './types';
|
||||
|
||||
export function initializePanelsManager(
|
||||
export function initializeLayoutManager(
|
||||
incomingEmbeddable: EmbeddablePackageState | undefined,
|
||||
initialPanels: DashboardPanelMap, // SERIALIZED STATE ONLY TODO Remove the DashboardPanelMap layer. We could take the Saved Dashboard Panels array here directly.
|
||||
initialSections: DashboardSectionMap,
|
||||
trackPanel: ReturnType<typeof initializeTrackPanel>,
|
||||
getReferences: (id: string) => Reference[]
|
||||
): {
|
||||
internalApi: {
|
||||
startComparing$: (
|
||||
lastSavedState$: BehaviorSubject<DashboardState>
|
||||
) => Observable<{ panels?: DashboardPanelMap }>;
|
||||
getSerializedStateForPanel: HasSerializedChildState['getSerializedStateForChild'];
|
||||
layout$: BehaviorSubject<DashboardLayout>;
|
||||
registerChildApi: (api: DefaultEmbeddableApi) => void;
|
||||
resetPanels: (lastSavedPanels: DashboardPanelMap) => void;
|
||||
setChildState: (uuid: string, state: SerializedPanelState<object>) => void;
|
||||
serializePanels: () => { panels: DashboardPanelMap; references: Reference[] };
|
||||
};
|
||||
api: PresentationContainer<DefaultEmbeddableApi> &
|
||||
CanDuplicatePanels & { getDashboardPanelFromId: DashboardApi['getDashboardPanelFromId'] };
|
||||
} {
|
||||
) {
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Set up panel state manager
|
||||
// --------------------------------------------------------------------------------------
|
||||
const children$ = new BehaviorSubject<DashboardChildren>({});
|
||||
const { layout: initialLayout, childState: initialChildState } = deserializePanels(initialPanels);
|
||||
const { layout: initialLayout, childState: initialChildState } = deserializeLayout(
|
||||
initialPanels,
|
||||
initialSections
|
||||
);
|
||||
const layout$ = new BehaviorSubject<DashboardLayout>(initialLayout); // layout is the source of truth for which panels are in the dashboard.
|
||||
let currentChildState = initialChildState; // childState is the source of truth for the state of each panel.
|
||||
|
||||
function deserializePanels(panelMap: DashboardPanelMap) {
|
||||
const layout: DashboardLayout = {};
|
||||
function deserializeLayout(panelMap: DashboardPanelMap, sectionMap: DashboardSectionMap) {
|
||||
const layout: DashboardLayout = {
|
||||
panels: {},
|
||||
sections: {},
|
||||
};
|
||||
const childState: DashboardChildState = {};
|
||||
Object.keys(panelMap).forEach((uuid) => {
|
||||
const { gridData, explicitInput, type } = panelMap[uuid];
|
||||
layout[uuid] = { type, gridData };
|
||||
childState[uuid] = {
|
||||
Object.keys(sectionMap).forEach((sectionId) => {
|
||||
layout.sections[sectionId] = { collapsed: false, ...sectionMap[sectionId] };
|
||||
});
|
||||
Object.keys(panelMap).forEach((panelId) => {
|
||||
const { gridData, explicitInput, type } = panelMap[panelId];
|
||||
layout.panels[panelId] = { type, gridData } as DashboardPanel;
|
||||
childState[panelId] = {
|
||||
rawState: explicitInput,
|
||||
references: getReferences(uuid),
|
||||
references: getReferences(panelId),
|
||||
};
|
||||
});
|
||||
return { layout, childState };
|
||||
}
|
||||
|
||||
const serializePanels = (): { references: Reference[]; panels: DashboardPanelMap } => {
|
||||
const serializeLayout = (): {
|
||||
references: Reference[];
|
||||
panels: DashboardPanelMap;
|
||||
sections: DashboardSectionMap;
|
||||
} => {
|
||||
const references: Reference[] = [];
|
||||
const layout = layout$.value;
|
||||
const panels: DashboardPanelMap = {};
|
||||
for (const uuid of Object.keys(layout$.value)) {
|
||||
|
||||
for (const panelId of Object.keys(layout.panels)) {
|
||||
references.push(
|
||||
...prefixReferencesFromPanel(uuid, currentChildState[uuid]?.references ?? [])
|
||||
...prefixReferencesFromPanel(panelId, currentChildState[panelId]?.references ?? [])
|
||||
);
|
||||
panels[uuid] = {
|
||||
...layout$.value[uuid],
|
||||
explicitInput: currentChildState[uuid]?.rawState ?? {},
|
||||
panels[panelId] = {
|
||||
...layout.panels[panelId],
|
||||
explicitInput: currentChildState[panelId]?.rawState ?? {},
|
||||
};
|
||||
}
|
||||
return { panels, references };
|
||||
return { panels, sections: { ...layout.sections }, references };
|
||||
};
|
||||
|
||||
const resetPanels = (lastSavedPanels: DashboardPanelMap) => {
|
||||
const { layout: lastSavedLayout, childState: lstSavedChildState } =
|
||||
deserializePanels(lastSavedPanels);
|
||||
const resetLayout = ({
|
||||
panels: lastSavedPanels,
|
||||
sections: lastSavedSections,
|
||||
}: DashboardState) => {
|
||||
const { layout: lastSavedLayout, childState: lastSavedChildState } = deserializeLayout(
|
||||
lastSavedPanels,
|
||||
lastSavedSections
|
||||
);
|
||||
|
||||
layout$.next(lastSavedLayout);
|
||||
currentChildState = lstSavedChildState;
|
||||
currentChildState = lastSavedChildState;
|
||||
let childrenModified = false;
|
||||
const currentChildren = { ...children$.value };
|
||||
for (const uuid of Object.keys(currentChildren)) {
|
||||
if (lastSavedLayout[uuid]) {
|
||||
if (lastSavedLayout.panels[uuid]) {
|
||||
const child = currentChildren[uuid];
|
||||
if (apiPublishesUnsavedChanges(child)) child.resetUnsavedChanges();
|
||||
} else {
|
||||
|
@ -145,7 +144,7 @@ export function initializePanelsManager(
|
|||
{
|
||||
width: size?.width ?? DEFAULT_PANEL_WIDTH,
|
||||
height: size?.height ?? DEFAULT_PANEL_HEIGHT,
|
||||
currentPanels: layout$.value,
|
||||
currentPanels: layout$.value.panels,
|
||||
}
|
||||
);
|
||||
return { ...newPanelPlacement, i: uuid };
|
||||
|
@ -154,11 +153,17 @@ export function initializePanelsManager(
|
|||
const placeNewPanel = async (
|
||||
uuid: string,
|
||||
panelPackage: PanelPackage,
|
||||
gridData?: DashboardLayoutItem['gridData']
|
||||
gridData?: DashboardPanel['gridData']
|
||||
): Promise<DashboardLayout> => {
|
||||
const { panelType: type, serializedState } = panelPackage;
|
||||
if (gridData) {
|
||||
return { ...layout$.value, [uuid]: { gridData: { ...gridData, i: uuid }, type } };
|
||||
return {
|
||||
...layout$.value,
|
||||
panels: {
|
||||
...layout$.value.panels,
|
||||
[uuid]: { gridData: { ...gridData, i: uuid }, type } as DashboardPanel,
|
||||
},
|
||||
};
|
||||
}
|
||||
const getCustomPlacementSettingFunc = getDashboardPanelPlacementSetting(type);
|
||||
const customPlacementSettings = getCustomPlacementSettingFunc
|
||||
|
@ -167,12 +172,18 @@ export function initializePanelsManager(
|
|||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
customPlacementSettings?.strategy ?? PanelPlacementStrategy.findTopLeftMostOpenSpace,
|
||||
{
|
||||
currentPanels: layout$.value,
|
||||
currentPanels: layout$.value.panels,
|
||||
height: customPlacementSettings?.height ?? DEFAULT_PANEL_HEIGHT,
|
||||
width: customPlacementSettings?.width ?? DEFAULT_PANEL_WIDTH,
|
||||
}
|
||||
);
|
||||
return { ...otherPanels, [uuid]: { gridData: { ...newPanelPlacement, i: uuid }, type } };
|
||||
return {
|
||||
...layout$.value,
|
||||
panels: {
|
||||
...otherPanels,
|
||||
[uuid]: { gridData: { ...newPanelPlacement, i: uuid }, type } as DashboardPanel,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
@ -181,7 +192,7 @@ export function initializePanelsManager(
|
|||
if (incomingEmbeddable) {
|
||||
const { serializedState, size, type } = incomingEmbeddable;
|
||||
const uuid = incomingEmbeddable.embeddableId ?? v4();
|
||||
const existingPanel: DashboardLayoutItem | undefined = layout$.value[uuid];
|
||||
const existingPanel: DashboardPanel | undefined = layout$.value.panels[uuid];
|
||||
const sameType = existingPanel?.type === type;
|
||||
|
||||
const gridData = existingPanel ? existingPanel.gridData : placeIncomingPanel(uuid, size);
|
||||
|
@ -195,14 +206,17 @@ export function initializePanelsManager(
|
|||
|
||||
layout$.next({
|
||||
...layout$.value,
|
||||
[uuid]: { gridData, type },
|
||||
panels: {
|
||||
...layout$.value.panels,
|
||||
[uuid]: { gridData, type } as DashboardPanel,
|
||||
},
|
||||
});
|
||||
trackPanel.setScrollToPanelId(uuid);
|
||||
trackPanel.setHighlightPanelId(uuid);
|
||||
}
|
||||
|
||||
function getDashboardPanelFromId(panelId: string) {
|
||||
const childLayout = layout$.value[panelId];
|
||||
const childLayout = layout$.value.panels[panelId];
|
||||
const childApi = children$.value[panelId];
|
||||
if (!childApi || !childLayout) throw new PanelNotFoundError();
|
||||
return {
|
||||
|
@ -216,7 +230,7 @@ export function initializePanelsManager(
|
|||
|
||||
async function getPanelTitles(): Promise<string[]> {
|
||||
const titles: string[] = [];
|
||||
await asyncForEach(Object.keys(layout$.value), async (id) => {
|
||||
await asyncForEach(Object.keys(layout$.value.panels), async (id) => {
|
||||
const childApi = await getChildApi(id);
|
||||
const title = apiPublishesTitle(childApi) ? getTitle(childApi) : '';
|
||||
if (title) titles.push(title);
|
||||
|
@ -230,7 +244,7 @@ export function initializePanelsManager(
|
|||
const addNewPanel = async <ApiType>(
|
||||
panelPackage: PanelPackage,
|
||||
displaySuccessMessage?: boolean,
|
||||
gridData?: DashboardLayoutItem['gridData']
|
||||
gridData?: DashboardPanel['gridData']
|
||||
) => {
|
||||
const uuid = v4();
|
||||
const { panelType: type, serializedState } = panelPackage;
|
||||
|
@ -246,17 +260,17 @@ export function initializePanelsManager(
|
|||
title: getPanelAddedSuccessString(title),
|
||||
'data-test-subj': 'addEmbeddableToDashboardSuccess',
|
||||
});
|
||||
trackPanel.setScrollToPanelId(uuid);
|
||||
trackPanel.setHighlightPanelId(uuid);
|
||||
}
|
||||
trackPanel.setScrollToPanelId(uuid);
|
||||
trackPanel.setHighlightPanelId(uuid);
|
||||
return (await getChildApi(uuid)) as ApiType;
|
||||
};
|
||||
|
||||
const removePanel = (uuid: string) => {
|
||||
const layout = { ...layout$.value };
|
||||
if (layout[uuid]) {
|
||||
delete layout[uuid];
|
||||
layout$.next(layout);
|
||||
const panels = { ...layout$.value.panels };
|
||||
if (panels[uuid]) {
|
||||
delete panels[uuid];
|
||||
layout$.next({ ...layout$.value, panels });
|
||||
}
|
||||
const children = { ...children$.value };
|
||||
if (children[uuid]) {
|
||||
|
@ -269,7 +283,7 @@ export function initializePanelsManager(
|
|||
};
|
||||
|
||||
const replacePanel = async (idToRemove: string, panelPackage: PanelPackage) => {
|
||||
const existingGridData = layout$.value[idToRemove]?.gridData;
|
||||
const existingGridData = layout$.value.panels[idToRemove]?.gridData;
|
||||
if (!existingGridData) throw new PanelNotFoundError();
|
||||
|
||||
removePanel(idToRemove);
|
||||
|
@ -278,7 +292,7 @@ export function initializePanelsManager(
|
|||
};
|
||||
|
||||
const duplicatePanel = async (uuidToDuplicate: string) => {
|
||||
const layoutItemToDuplicate = layout$.value[uuidToDuplicate];
|
||||
const layoutItemToDuplicate = layout$.value.panels[uuidToDuplicate];
|
||||
const apiToDuplicate = children$.value[uuidToDuplicate];
|
||||
if (!apiToDuplicate || !layoutItemToDuplicate) throw new PanelNotFoundError();
|
||||
|
||||
|
@ -297,14 +311,22 @@ export function initializePanelsManager(
|
|||
const { newPanelPlacement, otherPanels } = placeClonePanel({
|
||||
width: layoutItemToDuplicate.gridData.w,
|
||||
height: layoutItemToDuplicate.gridData.h,
|
||||
currentPanels: layout$.value,
|
||||
sectionId: layoutItemToDuplicate.gridData.sectionId,
|
||||
currentPanels: layout$.value.panels,
|
||||
placeBesideId: uuidToDuplicate,
|
||||
});
|
||||
layout$.next({
|
||||
...otherPanels,
|
||||
[uuidOfDuplicate]: {
|
||||
gridData: { ...newPanelPlacement, i: uuidOfDuplicate },
|
||||
type: layoutItemToDuplicate.type,
|
||||
...layout$.value,
|
||||
panels: {
|
||||
...otherPanels,
|
||||
[uuidOfDuplicate]: {
|
||||
gridData: {
|
||||
...newPanelPlacement,
|
||||
i: uuidOfDuplicate,
|
||||
sectionId: layoutItemToDuplicate.gridData.sectionId,
|
||||
},
|
||||
type: layoutItemToDuplicate.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -315,7 +337,7 @@ export function initializePanelsManager(
|
|||
};
|
||||
|
||||
const getChildApi = async (uuid: string): Promise<DefaultEmbeddableApi | undefined> => {
|
||||
if (!layout$.value[uuid]) throw new PanelNotFoundError();
|
||||
if (!layout$.value.panels[uuid]) throw new PanelNotFoundError();
|
||||
if (children$.value[uuid]) return children$.value[uuid];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
@ -326,7 +348,7 @@ export function initializePanelsManager(
|
|||
}
|
||||
|
||||
// If we hit this, the panel was removed before the embeddable finished loading.
|
||||
if (layout$.value[uuid] === undefined) {
|
||||
if (layout$.value.panels[uuid] === undefined) {
|
||||
subscription.unsubscribe();
|
||||
resolve(undefined);
|
||||
}
|
||||
|
@ -338,25 +360,34 @@ export function initializePanelsManager(
|
|||
internalApi: {
|
||||
getSerializedStateForPanel: (uuid: string) => currentChildState[uuid],
|
||||
layout$,
|
||||
resetPanels,
|
||||
serializePanels,
|
||||
reset: resetLayout,
|
||||
serializeLayout,
|
||||
startComparing$: (
|
||||
lastSavedState$: BehaviorSubject<DashboardState>
|
||||
): Observable<{ panels?: DashboardPanelMap }> => {
|
||||
): Observable<{ panels?: DashboardPanelMap; sections?: DashboardSectionMap }> => {
|
||||
return layout$.pipe(
|
||||
debounceTime(100),
|
||||
combineLatestWith(lastSavedState$.pipe(map((lastSaved) => lastSaved.panels))),
|
||||
map(([, lastSavedPanels]) => {
|
||||
const panels = serializePanels().panels;
|
||||
if (!arePanelLayoutsEqual(lastSavedPanels, panels)) {
|
||||
combineLatestWith(
|
||||
lastSavedState$.pipe(
|
||||
map((lastSaved) => ({ panels: lastSaved.panels, sections: lastSaved.sections }))
|
||||
)
|
||||
),
|
||||
map(([, { panels: lastSavedPanels, sections: lastSavedSections }]) => {
|
||||
const { panels, sections } = serializeLayout();
|
||||
if (
|
||||
!areLayoutsEqual(
|
||||
{ panels: lastSavedPanels, sections: lastSavedSections },
|
||||
{ panels, sections }
|
||||
)
|
||||
) {
|
||||
if (shouldLogStateDiff()) {
|
||||
logStateDiff(
|
||||
'dashboard layout',
|
||||
deserializePanels(lastSavedPanels).layout,
|
||||
deserializePanels(panels).layout
|
||||
deserializeLayout(lastSavedPanels, lastSavedSections).layout,
|
||||
deserializeLayout(panels, sections).layout
|
||||
);
|
||||
}
|
||||
return { panels };
|
||||
return { panels, sections };
|
||||
}
|
||||
return {};
|
||||
})
|
||||
|
@ -371,8 +402,13 @@ export function initializePanelsManager(
|
|||
setChildState: (uuid: string, state: SerializedPanelState<object>) => {
|
||||
currentChildState[uuid] = state;
|
||||
},
|
||||
isSectionCollapsed: (sectionId?: string): boolean => {
|
||||
const { sections } = layout$.getValue();
|
||||
return Boolean(sectionId && sections[sectionId].collapsed);
|
||||
},
|
||||
},
|
||||
api: {
|
||||
/** Panels */
|
||||
children$,
|
||||
getChildApi,
|
||||
addNewPanel,
|
||||
|
@ -380,8 +416,39 @@ export function initializePanelsManager(
|
|||
replacePanel,
|
||||
duplicatePanel,
|
||||
getDashboardPanelFromId,
|
||||
getPanelCount: () => Object.keys(layout$.value).length,
|
||||
getPanelCount: () => Object.keys(layout$.value.panels).length,
|
||||
canRemovePanels: () => trackPanel.expandedPanelId$.value === undefined,
|
||||
|
||||
/** Sections */
|
||||
addNewSection: () => {
|
||||
const currentLayout = layout$.getValue();
|
||||
|
||||
// find the max y so we know where to add the section
|
||||
let maxY = 0;
|
||||
[...Object.values(currentLayout.panels), ...Object.values(currentLayout.sections)].forEach(
|
||||
(widget) => {
|
||||
const { y, h } = { h: 1, ...widget.gridData };
|
||||
maxY = Math.max(maxY, y + h);
|
||||
}
|
||||
);
|
||||
|
||||
// add the new section
|
||||
const sections = { ...currentLayout.sections };
|
||||
const newId = v4();
|
||||
sections[newId] = {
|
||||
id: newId,
|
||||
gridData: { i: newId, y: maxY },
|
||||
title: i18n.translate('dashboard.defaultSectionTitle', {
|
||||
defaultMessage: 'New collapsible section',
|
||||
}),
|
||||
collapsed: false,
|
||||
};
|
||||
layout$.next({
|
||||
...currentLayout,
|
||||
sections,
|
||||
});
|
||||
trackPanel.scrollToBottom$.next();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -7,13 +7,14 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
|
||||
export function initializeTrackPanel(untilLoaded: (id: string) => Promise<undefined>) {
|
||||
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
const focusedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
const highlightPanelId$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
const scrollToPanelId$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
const scrollToBottom$ = new Subject<void>();
|
||||
let scrollPosition: number | undefined;
|
||||
|
||||
function setScrollToPanelId(id: string | undefined) {
|
||||
|
@ -62,15 +63,19 @@ export function initializeTrackPanel(untilLoaded: (id: string) => Promise<undefi
|
|||
untilLoaded(id).then(() => {
|
||||
setScrollToPanelId(undefined);
|
||||
if (scrollPosition !== undefined) {
|
||||
window.scrollTo({ top: scrollPosition });
|
||||
window.scrollTo({ top: scrollPosition, behavior: 'smooth' });
|
||||
scrollPosition = undefined;
|
||||
} else {
|
||||
panelRef.scrollIntoView({ block: 'start' });
|
||||
panelRef.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
},
|
||||
scrollToTop: () => {
|
||||
window.scroll(0, 0);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
scrollToBottom$,
|
||||
scrollToBottom: () => {
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
},
|
||||
setFocusedPanelId: (id: string | undefined) => {
|
||||
if (focusedPanelId$.value !== id) focusedPanelId$.next(id);
|
||||
|
|
|
@ -15,6 +15,7 @@ import { Filter, Query, TimeRange } from '@kbn/es-query';
|
|||
import { PublishesESQLVariables } from '@kbn/esql-types';
|
||||
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||
import {
|
||||
CanAddNewSection,
|
||||
CanExpandPanels,
|
||||
HasLastSavedChildState,
|
||||
HasSerializedChildState,
|
||||
|
@ -47,6 +48,8 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
|||
import {
|
||||
DashboardLocatorParams,
|
||||
DashboardPanelMap,
|
||||
DashboardPanelState,
|
||||
DashboardSectionMap,
|
||||
DashboardSettings,
|
||||
DashboardState,
|
||||
} from '../../common';
|
||||
|
@ -58,9 +61,12 @@ import {
|
|||
|
||||
export const DASHBOARD_API_TYPE = 'dashboard';
|
||||
|
||||
export type DashboardLayoutItem = { gridData: GridData } & HasType;
|
||||
export const ReservedLayoutItemTypes: readonly string[] = ['section'] as const;
|
||||
|
||||
export type DashboardPanel = Pick<DashboardPanelState, 'gridData'> & HasType;
|
||||
export interface DashboardLayout {
|
||||
[uuid: string]: DashboardLayoutItem;
|
||||
panels: { [uuid: string]: DashboardPanel }; // partial of DashboardPanelState
|
||||
sections: DashboardSectionMap;
|
||||
}
|
||||
|
||||
export interface DashboardChildState {
|
||||
|
@ -102,6 +108,7 @@ export interface DashboardCreationOptions {
|
|||
}
|
||||
|
||||
export type DashboardApi = CanExpandPanels &
|
||||
CanAddNewSection &
|
||||
HasAppContext &
|
||||
HasExecutionContext &
|
||||
HasLastSavedChildState &
|
||||
|
@ -151,6 +158,8 @@ export type DashboardApi = CanExpandPanels &
|
|||
scrollToPanel: (panelRef: HTMLDivElement) => void;
|
||||
scrollToPanelId$: PublishingSubject<string | undefined>;
|
||||
scrollToTop: () => void;
|
||||
scrollToBottom: () => void;
|
||||
scrollToBottom$: Subject<void>;
|
||||
setFilters: (filters?: Filter[] | undefined) => void;
|
||||
setFullScreenMode: (fullScreenMode: boolean) => void;
|
||||
setHighlightPanelId: (id: string | undefined) => void;
|
||||
|
@ -168,5 +177,10 @@ export interface DashboardInternalApi {
|
|||
layout$: BehaviorSubject<DashboardLayout>;
|
||||
registerChildApi: (api: DefaultEmbeddableApi) => void;
|
||||
setControlGroupApi: (controlGroupApi: ControlGroupApi) => void;
|
||||
serializePanels: () => { references: Reference[]; panels: DashboardPanelMap };
|
||||
serializeLayout: () => {
|
||||
references: Reference[];
|
||||
panels: DashboardPanelMap;
|
||||
sections: DashboardSectionMap;
|
||||
};
|
||||
isSectionCollapsed: (sectionId?: string) => boolean;
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ import {
|
|||
tap,
|
||||
} from 'rxjs';
|
||||
import { getDashboardBackupService } from '../services/dashboard_backup_service';
|
||||
import { initializePanelsManager } from './panels_manager';
|
||||
import { initializeLayoutManager } from './layout_manager';
|
||||
import { initializeSettingsManager } from './settings_manager';
|
||||
import { DashboardCreationOptions } from './types';
|
||||
import { DashboardState } from '../../common';
|
||||
|
@ -40,7 +40,7 @@ import {
|
|||
const DEBOUNCE_TIME = 100;
|
||||
|
||||
export function initializeUnsavedChangesManager({
|
||||
panelsManager,
|
||||
layoutManager,
|
||||
savedObjectId$,
|
||||
lastSavedState,
|
||||
settingsManager,
|
||||
|
@ -55,7 +55,7 @@ export function initializeUnsavedChangesManager({
|
|||
getReferences: (id: string) => Reference[];
|
||||
savedObjectId$: PublishesSavedObjectId['savedObjectId$'];
|
||||
controlGroupManager: ReturnType<typeof initializeControlGroupManager>;
|
||||
panelsManager: ReturnType<typeof initializePanelsManager>;
|
||||
layoutManager: ReturnType<typeof initializeLayoutManager>;
|
||||
viewModeManager: ReturnType<typeof initializeViewModeManager>;
|
||||
settingsManager: ReturnType<typeof initializeSettingsManager>;
|
||||
unifiedSearchManager: ReturnType<typeof initializeUnifiedSearchManager>;
|
||||
|
@ -75,14 +75,14 @@ export function initializeUnsavedChangesManager({
|
|||
// references injected while loading dashboard saved object in loadDashboardState
|
||||
const lastSavedState$ = new BehaviorSubject<DashboardState>(lastSavedState);
|
||||
|
||||
const hasPanelChanges$ = childrenUnsavedChanges$(panelsManager.api.children$).pipe(
|
||||
const hasPanelChanges$ = childrenUnsavedChanges$(layoutManager.api.children$).pipe(
|
||||
tap((childrenWithChanges) => {
|
||||
// propagate the latest serialized state back to the panels manager.
|
||||
// propagate the latest serialized state back to the layout manager.
|
||||
for (const { uuid, hasUnsavedChanges } of childrenWithChanges) {
|
||||
const childApi = panelsManager.api.children$.value[uuid];
|
||||
const childApi = layoutManager.api.children$.value[uuid];
|
||||
if (!hasUnsavedChanges || !childApi || !apiHasSerializableState(childApi)) continue;
|
||||
|
||||
panelsManager.internalApi.setChildState(uuid, childApi.serializeState());
|
||||
layoutManager.internalApi.setChildState(uuid, childApi.serializeState());
|
||||
}
|
||||
}),
|
||||
map((childrenWithChanges) => {
|
||||
|
@ -93,7 +93,7 @@ export function initializeUnsavedChangesManager({
|
|||
const dashboardStateChanges$: Observable<Partial<DashboardState>> = combineLatest([
|
||||
settingsManager.internalApi.startComparing$(lastSavedState$),
|
||||
unifiedSearchManager.internalApi.startComparing$(lastSavedState$),
|
||||
panelsManager.internalApi.startComparing$(lastSavedState$),
|
||||
layoutManager.internalApi.startComparing$(lastSavedState$),
|
||||
]).pipe(
|
||||
map(([settings, unifiedSearch, panels]) => {
|
||||
return { ...settings, ...unifiedSearch, ...panels };
|
||||
|
@ -129,10 +129,9 @@ export function initializeUnsavedChangesManager({
|
|||
|
||||
// always back up view mode. This allows us to know which Dashboards were last changed while in edit mode.
|
||||
dashboardStateToBackup.viewMode = viewMode;
|
||||
|
||||
// Backup latest state from children that have unsaved changes
|
||||
if (hasPanelChanges || hasControlGroupChanges) {
|
||||
const { panels, references } = panelsManager.internalApi.serializePanels();
|
||||
const { panels, references } = layoutManager.internalApi.serializeLayout();
|
||||
const { controlGroupInput, controlGroupReferences } =
|
||||
controlGroupManager.internalApi.serializeControlGroup();
|
||||
// dashboardStateToBackup.references will be used instead of savedObjectResult.references
|
||||
|
@ -169,9 +168,10 @@ export function initializeUnsavedChangesManager({
|
|||
return {
|
||||
api: {
|
||||
asyncResetToLastSavedState: async () => {
|
||||
panelsManager.internalApi.resetPanels(lastSavedState$.value.panels);
|
||||
unifiedSearchManager.internalApi.reset(lastSavedState$.value);
|
||||
settingsManager.internalApi.reset(lastSavedState$.value);
|
||||
const savedState = lastSavedState$.value;
|
||||
layoutManager.internalApi.reset(savedState);
|
||||
unifiedSearchManager.internalApi.reset(savedState);
|
||||
settingsManager.internalApi.reset(savedState);
|
||||
|
||||
await controlGroupManager.api.controlGroupApi$.value?.resetUnsavedChanges();
|
||||
},
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import { Capabilities } from '@kbn/core/public';
|
||||
import { convertPanelMapToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
|
||||
import { convertPanelSectionMapsToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
|
||||
import { DashboardLocatorParams } from '../../../../common/types';
|
||||
import { getDashboardBackupService } from '../../../services/dashboard_backup_service';
|
||||
import { shareService } from '../../../services/kibana_services';
|
||||
|
@ -124,7 +124,7 @@ describe('ShowShareModal', () => {
|
|||
).locatorParams.params;
|
||||
const rawDashboardState = {
|
||||
...unsavedDashboardState,
|
||||
panels: convertPanelMapToPanelsArray(unsavedDashboardState.panels),
|
||||
panels: convertPanelSectionMapsToPanelsArray(unsavedDashboardState.panels, {}),
|
||||
};
|
||||
unsavedStateKeys.forEach((key) => {
|
||||
expect(shareLocatorParams[key]).toStrictEqual(
|
||||
|
|
|
@ -7,6 +7,10 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import React, { ReactElement, useState } from 'react';
|
||||
|
||||
import { EuiCallOut, EuiCheckboxGroup } from '@elastic/eui';
|
||||
import type { Capabilities } from '@kbn/core/public';
|
||||
import { QueryState } from '@kbn/data-plugin/common';
|
||||
|
@ -14,12 +18,10 @@ import { DASHBOARD_APP_LOCATOR } from '@kbn/deeplinks-analytics';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { getStateFromKbnUrl, setStateToKbnUrl, unhashUrl } from '@kbn/kibana-utils-plugin/public';
|
||||
import { omit } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import React, { ReactElement, useState } from 'react';
|
||||
import { LocatorPublic } from '@kbn/share-plugin/common';
|
||||
|
||||
import { DashboardLocatorParams } from '../../../../common';
|
||||
import { convertPanelMapToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
|
||||
import { convertPanelSectionMapsToPanelsArray } from '../../../../common/lib/dashboard_panel_converters';
|
||||
import { SharedDashboardState } from '../../../../common/types';
|
||||
import { getDashboardBackupService } from '../../../services/dashboard_backup_service';
|
||||
import { coreServices, dataService, shareService } from '../../../services/kibana_services';
|
||||
|
@ -110,8 +112,11 @@ export function ShowShareModal({
|
|||
);
|
||||
};
|
||||
|
||||
const { panels: allUnsavedPanelsMap, ...unsavedDashboardState } =
|
||||
getDashboardBackupService().getState(savedObjectId) ?? {};
|
||||
const {
|
||||
panels: allUnsavedPanelsMap,
|
||||
sections: allUnsavedSectionsMap,
|
||||
...unsavedDashboardState
|
||||
} = getDashboardBackupService().getState(savedObjectId) ?? {};
|
||||
|
||||
const hasPanelChanges = allUnsavedPanelsMap !== undefined;
|
||||
|
||||
|
@ -121,8 +126,11 @@ export function ShowShareModal({
|
|||
unsavedDashboardState.controlGroupInput as SharedDashboardState['controlGroupInput'],
|
||||
references: unsavedDashboardState.references as SharedDashboardState['references'],
|
||||
};
|
||||
if (allUnsavedPanelsMap) {
|
||||
unsavedDashboardStateForLocator.panels = convertPanelMapToPanelsArray(allUnsavedPanelsMap);
|
||||
if (allUnsavedPanelsMap || allUnsavedSectionsMap) {
|
||||
unsavedDashboardStateForLocator.panels = convertPanelSectionMapsToPanelsArray(
|
||||
allUnsavedPanelsMap ?? {},
|
||||
allUnsavedSectionsMap ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
const locatorParams: DashboardLocatorParams = {
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
import { History } from 'history';
|
||||
import { map } from 'rxjs';
|
||||
import { SEARCH_SESSION_ID } from '../../../common/constants';
|
||||
import { convertPanelMapToPanelsArray } from '../../../common/lib/dashboard_panel_converters';
|
||||
import { convertPanelSectionMapsToPanelsArray } from '../../../common/lib/dashboard_panel_converters';
|
||||
import { DashboardLocatorParams } from '../../../common/types';
|
||||
import { DashboardApi, DashboardInternalApi } from '../../dashboard_api/types';
|
||||
import { dataService } from '../../services/kibana_services';
|
||||
|
@ -81,7 +81,7 @@ function getLocatorParams({
|
|||
shouldRestoreSearchSession: boolean;
|
||||
}): DashboardLocatorParams {
|
||||
const savedObjectId = dashboardApi.savedObjectId$.value;
|
||||
const { panels, references } = dashboardInternalApi.serializePanels();
|
||||
const { panels, sections, references } = dashboardInternalApi.serializeLayout();
|
||||
return {
|
||||
viewMode: dashboardApi.viewMode$.value ?? 'view',
|
||||
useHash: false,
|
||||
|
@ -104,7 +104,10 @@ function getLocatorParams({
|
|||
...(savedObjectId
|
||||
? {}
|
||||
: {
|
||||
panels: convertPanelMapToPanelsArray(panels) as DashboardLocatorParams['panels'],
|
||||
panels: convertPanelSectionMapsToPanelsArray(
|
||||
panels,
|
||||
sections
|
||||
) as DashboardLocatorParams['panels'],
|
||||
references: references as DashboardLocatorParams['references'],
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -7,17 +7,22 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { serializeRuntimeState } from '@kbn/controls-plugin/public';
|
||||
import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common';
|
||||
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { History } from 'history';
|
||||
import _ from 'lodash';
|
||||
import { skip } from 'rxjs';
|
||||
import semverSatisfies from 'semver/functions/satisfies';
|
||||
import type { DashboardPanelMap } from '../../../common/dashboard_container/types';
|
||||
import { convertPanelsArrayToPanelMap } from '../../../common/lib/dashboard_panel_converters';
|
||||
|
||||
import { serializeRuntimeState } from '@kbn/controls-plugin/public';
|
||||
import { replaceUrlHashQuery } from '@kbn/kibana-utils-plugin/common';
|
||||
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
import type {
|
||||
DashboardPanelMap,
|
||||
DashboardSectionMap,
|
||||
} from '../../../common/dashboard_container/types';
|
||||
import { convertPanelsArrayToPanelSectionMaps } from '../../../common/lib/dashboard_panel_converters';
|
||||
import type { DashboardState, SharedDashboardState } from '../../../common/types';
|
||||
import type { DashboardPanel } from '../../../server/content_management';
|
||||
import type { DashboardPanel, DashboardSection } from '../../../server/content_management';
|
||||
import type { SavedDashboardPanel } from '../../../server/dashboard_saved_object';
|
||||
import { DashboardApi } from '../../dashboard_api/types';
|
||||
import { migrateLegacyQuery } from '../../services/dashboard_content_management_service/lib/load_dashboard_state';
|
||||
|
@ -33,8 +38,14 @@ const panelIsLegacy = (panel: unknown): panel is SavedDashboardPanel => {
|
|||
* We no longer support loading panels from a version older than 7.3 in the URL.
|
||||
* @returns whether or not there is a panel in the URL state saved with a version before 7.3
|
||||
*/
|
||||
export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPanel[]) => {
|
||||
export const isPanelVersionTooOld = (
|
||||
panels: Array<DashboardPanel | DashboardSection> | SavedDashboardPanel[]
|
||||
) => {
|
||||
for (const panel of panels) {
|
||||
if ('panels' in panel) {
|
||||
// can't use isDashboardSection type guard because of SavedDashboardPanel type
|
||||
continue; // ignore sections
|
||||
}
|
||||
if (
|
||||
!panel.gridData ||
|
||||
!((panel as DashboardPanel).panelConfig || (panel as SavedDashboardPanel).embeddableConfig) ||
|
||||
|
@ -45,13 +56,15 @@ export const isPanelVersionTooOld = (panels: DashboardPanel[] | SavedDashboardPa
|
|||
return false;
|
||||
};
|
||||
|
||||
function getPanelsMap(panels?: DashboardPanel[]): DashboardPanelMap | undefined {
|
||||
function getPanelSectionMaps(
|
||||
panels?: Array<DashboardPanel | DashboardSection>
|
||||
): { panels: DashboardPanelMap; sections: DashboardSectionMap } | undefined {
|
||||
if (!panels) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (panels.length === 0) {
|
||||
return {};
|
||||
return { panels: {}, sections: {} };
|
||||
}
|
||||
|
||||
if (isPanelVersionTooOld(panels)) {
|
||||
|
@ -71,7 +84,7 @@ function getPanelsMap(panels?: DashboardPanel[]): DashboardPanelMap | undefined
|
|||
return panel;
|
||||
});
|
||||
|
||||
return convertPanelsArrayToPanelMap(standardizedPanels);
|
||||
return convertPanelsArrayToPanelSectionMaps(standardizedPanels);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -85,8 +98,7 @@ export const loadAndRemoveDashboardState = (
|
|||
);
|
||||
|
||||
if (!rawAppStateInUrl) return {};
|
||||
|
||||
const panelsMap = getPanelsMap(rawAppStateInUrl.panels);
|
||||
const converted = getPanelSectionMaps(rawAppStateInUrl.panels);
|
||||
|
||||
const nextUrl = replaceUrlHashQuery(window.location.href, (hashQuery) => {
|
||||
delete hashQuery[DASHBOARD_STATE_STORAGE_KEY];
|
||||
|
@ -100,7 +112,8 @@ export const loadAndRemoveDashboardState = (
|
|||
controlGroupInput: serializeRuntimeState(rawAppStateInUrl.controlGroupState).rawState,
|
||||
}
|
||||
: {}),
|
||||
...(panelsMap ? { panels: panelsMap } : {}),
|
||||
...(converted?.panels ? { panels: converted.panels } : {}),
|
||||
...(converted?.sections ? { sections: converted.sections } : {}),
|
||||
...(rawAppStateInUrl.query ? { query: migrateLegacyQuery(rawAppStateInUrl.query) } : {}),
|
||||
};
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
.dshEmptyPromptParent {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dshEmptyPromptPageTemplate {
|
||||
|
|
|
@ -17,3 +17,4 @@ export { ExportCSVAction } from '../dashboard_actions/export_csv_action';
|
|||
export { AddToLibraryAction } from '../dashboard_actions/library_add_action';
|
||||
export { UnlinkFromLibraryAction } from '../dashboard_actions/library_unlink_action';
|
||||
export { CopyToDashboardAction } from '../dashboard_actions/copy_to_dashboard_action';
|
||||
export { AddSectionAction } from '../dashboard_actions/add_section_action';
|
||||
|
|
|
@ -8,19 +8,21 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiThemeProvider } from '@elastic/eui';
|
||||
|
||||
import { EuiThemeProvider } from '@elastic/eui';
|
||||
import { useBatchedPublishingSubjects as mockUseBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { DashboardPanelMap } from '../../../common';
|
||||
import { RenderResult, act, getByLabelText, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { DashboardPanelMap, DashboardSectionMap } from '../../../common';
|
||||
import {
|
||||
DashboardContext,
|
||||
useDashboardApi as mockUseDashboardApi,
|
||||
} from '../../dashboard_api/use_dashboard_api';
|
||||
import { DashboardInternalContext } from '../../dashboard_api/use_dashboard_internal_api';
|
||||
import { buildMockDashboardApi } from '../../mocks';
|
||||
import { buildMockDashboardApi, getMockDashboardPanels } from '../../mocks';
|
||||
import { DashboardGrid } from './dashboard_grid';
|
||||
import type { Props as DashboardGridItemProps } from './dashboard_grid_item';
|
||||
import { RenderResult, act, render, waitFor } from '@testing-library/react';
|
||||
|
||||
jest.mock('./dashboard_grid_item', () => {
|
||||
return {
|
||||
|
@ -56,19 +58,6 @@ jest.mock('./dashboard_grid_item', () => {
|
|||
};
|
||||
});
|
||||
|
||||
const PANELS = {
|
||||
'1': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
'2': {
|
||||
gridData: { x: 6, y: 6, w: 6, h: 6, i: '2' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '2' },
|
||||
},
|
||||
};
|
||||
|
||||
const verifyElementHasClass = (
|
||||
component: RenderResult,
|
||||
elementSelector: string,
|
||||
|
@ -79,10 +68,16 @@ const verifyElementHasClass = (
|
|||
expect(itemToCheck!.classList.contains(className)).toBe(true);
|
||||
};
|
||||
|
||||
const createAndMountDashboardGrid = async (panels: DashboardPanelMap = PANELS) => {
|
||||
const createAndMountDashboardGrid = async (overrides?: {
|
||||
panels?: DashboardPanelMap;
|
||||
sections?: DashboardSectionMap;
|
||||
}) => {
|
||||
const panels = overrides?.panels ?? getMockDashboardPanels().panels;
|
||||
const sections = overrides?.sections;
|
||||
const { api, internalApi } = buildMockDashboardApi({
|
||||
overrides: {
|
||||
panels,
|
||||
...(sections && { sections }),
|
||||
},
|
||||
});
|
||||
const component = render(
|
||||
|
@ -95,84 +90,225 @@ const createAndMountDashboardGrid = async (panels: DashboardPanelMap = PANELS) =
|
|||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
// panels in collapsed sections should not render
|
||||
const panelRenderCount = sections
|
||||
? Object.values(panels).filter((value) => {
|
||||
const sectionId = value.gridData.sectionId;
|
||||
return sectionId ? !sections[sectionId].collapsed : true;
|
||||
}).length
|
||||
: Object.keys(panels).length;
|
||||
|
||||
// wait for first render
|
||||
await waitFor(() => {
|
||||
expect(component.queryAllByTestId('dashboardGridItem').length).toBe(Object.keys(panels).length);
|
||||
expect(component.queryAllByTestId('dashboardGridItem').length).toBe(panelRenderCount);
|
||||
});
|
||||
|
||||
return { dashboardApi: api, component };
|
||||
return { dashboardApi: api, internalApi, component };
|
||||
};
|
||||
|
||||
test('renders DashboardGrid', async () => {
|
||||
await createAndMountDashboardGrid(PANELS);
|
||||
});
|
||||
|
||||
test('renders DashboardGrid with no visualizations', async () => {
|
||||
await createAndMountDashboardGrid({});
|
||||
});
|
||||
|
||||
test('DashboardGrid removes panel when removed from container', async () => {
|
||||
const { dashboardApi, component } = await createAndMountDashboardGrid(PANELS);
|
||||
|
||||
// remove panel
|
||||
await act(async () => {
|
||||
dashboardApi.removePanel('1');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
describe('DashboardGrid', () => {
|
||||
test('renders', async () => {
|
||||
await createAndMountDashboardGrid();
|
||||
});
|
||||
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(1);
|
||||
});
|
||||
|
||||
test('DashboardGrid renders expanded panel', async () => {
|
||||
const { dashboardApi, component } = await createAndMountDashboardGrid();
|
||||
|
||||
// maximize panel
|
||||
await act(async () => {
|
||||
dashboardApi.expandPanel('1');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'expandedPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'hiddenPanel');
|
||||
|
||||
// minimize panel
|
||||
await act(async () => {
|
||||
dashboardApi.expandPanel('1');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
|
||||
});
|
||||
|
||||
test('DashboardGrid renders focused panel', async () => {
|
||||
const { dashboardApi, component } = await createAndMountDashboardGrid();
|
||||
const overlayMock = {
|
||||
onClose: new Promise<void>((resolve) => {
|
||||
resolve();
|
||||
}),
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
dashboardApi.openOverlay(overlayMock, { focusedPanelId: '2' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
// Both panels should still exist in the dom, so nothing needs to be re-fetched once focused/blurred.
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'blurredPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'focusedPanel');
|
||||
|
||||
await act(async () => {
|
||||
dashboardApi.clearOverlays();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
|
||||
describe('panels', () => {
|
||||
test('renders with no visualizations', async () => {
|
||||
await createAndMountDashboardGrid();
|
||||
});
|
||||
|
||||
test('removes panel when removed from container', async () => {
|
||||
const { dashboardApi, component } = await createAndMountDashboardGrid();
|
||||
|
||||
// remove panel
|
||||
await act(async () => {
|
||||
dashboardApi.removePanel('2');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(1);
|
||||
});
|
||||
|
||||
test('renders expanded panel', async () => {
|
||||
const { dashboardApi, component } = await createAndMountDashboardGrid();
|
||||
|
||||
// maximize panel
|
||||
await act(async () => {
|
||||
dashboardApi.expandPanel('1');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
// Both panels should still exist in the dom, so nothing needs to be re-fetched once minimized.
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'expandedPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'hiddenPanel');
|
||||
|
||||
// minimize panel
|
||||
await act(async () => {
|
||||
dashboardApi.expandPanel('1');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
|
||||
});
|
||||
|
||||
test('renders focused panel', async () => {
|
||||
const { dashboardApi, component } = await createAndMountDashboardGrid();
|
||||
const overlayMock = {
|
||||
onClose: new Promise<void>((resolve) => {
|
||||
resolve();
|
||||
}),
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
dashboardApi.openOverlay(overlayMock, { focusedPanelId: '2' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
// Both panels should still exist in the dom, so nothing needs to be re-fetched once focused/blurred.
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'blurredPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'focusedPanel');
|
||||
|
||||
await act(async () => {
|
||||
dashboardApi.clearOverlays();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
});
|
||||
expect(component.getAllByTestId('dashboardGridItem').length).toBe(2);
|
||||
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_1', 'regularPanel');
|
||||
verifyElementHasClass(component, '#mockDashboardGridItem_2', 'regularPanel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sections', () => {
|
||||
test('renders sections', async () => {
|
||||
const { panels, sections } = getMockDashboardPanels(true);
|
||||
await createAndMountDashboardGrid({
|
||||
panels,
|
||||
sections,
|
||||
});
|
||||
|
||||
const header1 = screen.getByTestId('kbnGridSectionHeader-section1');
|
||||
expect(header1).toBeInTheDocument();
|
||||
expect(header1.classList).toContain('kbnGridSectionHeader--collapsed');
|
||||
const header2 = screen.getByTestId('kbnGridSectionHeader-section2');
|
||||
expect(header2).toBeInTheDocument();
|
||||
expect(header2.classList).not.toContain('kbnGridSectionHeader--collapsed');
|
||||
});
|
||||
|
||||
test('can add new section', async () => {
|
||||
const { panels, sections } = getMockDashboardPanels(true);
|
||||
const { dashboardApi, internalApi } = await createAndMountDashboardGrid({
|
||||
panels,
|
||||
sections,
|
||||
});
|
||||
dashboardApi.addNewSection();
|
||||
await waitFor(() => {
|
||||
const headers = screen.getAllByLabelText('Edit section title'); // aria-label
|
||||
expect(headers.length).toEqual(3);
|
||||
});
|
||||
|
||||
const newHeader = Object.values(internalApi.layout$.getValue().sections).filter(
|
||||
({ gridData: { y } }) => y === 8
|
||||
)[0];
|
||||
|
||||
expect(newHeader.title).toEqual('New collapsible section');
|
||||
expect(screen.getByText(newHeader.title)).toBeInTheDocument();
|
||||
expect(newHeader.collapsed).toBe(false);
|
||||
expect(screen.getByTestId(`kbnGridSectionHeader-${newHeader.id}`).classList).not.toContain(
|
||||
'kbnGridSectionHeader--collapsed'
|
||||
);
|
||||
});
|
||||
|
||||
test('dashboard state updates on collapse', async () => {
|
||||
const { panels, sections } = getMockDashboardPanels(true);
|
||||
const { internalApi } = await createAndMountDashboardGrid({
|
||||
panels,
|
||||
sections,
|
||||
});
|
||||
|
||||
const headerButton = screen.getByTestId(`kbnGridSectionTitle-section2`);
|
||||
expect(headerButton.nodeName.toLowerCase()).toBe('button');
|
||||
userEvent.click(headerButton);
|
||||
await waitFor(() => {
|
||||
expect(internalApi.layout$.getValue().sections.section2.collapsed).toBe(true);
|
||||
});
|
||||
expect(headerButton.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
test('dashboard state updates on section deletion', async () => {
|
||||
const { panels, sections } = getMockDashboardPanels(true, {
|
||||
sections: {
|
||||
emptySection: {
|
||||
id: 'emptySection',
|
||||
title: 'Empty section',
|
||||
collapsed: false,
|
||||
gridData: { i: 'emptySection', y: 8 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { internalApi } = await createAndMountDashboardGrid({
|
||||
panels,
|
||||
sections,
|
||||
});
|
||||
|
||||
// can delete empty section
|
||||
const deleteEmptySectionButton = getByLabelText(
|
||||
screen.getByTestId('kbnGridSectionHeader-emptySection'),
|
||||
'Delete section'
|
||||
);
|
||||
await act(async () => {
|
||||
await userEvent.click(deleteEmptySectionButton);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(Object.keys(internalApi.layout$.getValue().sections)).not.toContain('emptySection');
|
||||
});
|
||||
|
||||
// can delete non-empty section
|
||||
const deleteSection1Button = getByLabelText(
|
||||
screen.getByTestId('kbnGridSectionHeader-section1'),
|
||||
'Delete section'
|
||||
);
|
||||
await userEvent.click(deleteSection1Button);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('kbnGridLayoutDeleteSectionModal-section1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const confirmDeleteButton = screen.getByText('Delete section and 1 panel');
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
await waitFor(() => {
|
||||
expect(Object.keys(internalApi.layout$.getValue().sections)).not.toContain('section1');
|
||||
expect(Object.keys(internalApi.layout$.getValue().panels)).not.toContain('3'); // this is the panel in section1
|
||||
});
|
||||
});
|
||||
|
||||
test('layout responds to dashboard state update', async () => {
|
||||
const withoutSections = getMockDashboardPanels();
|
||||
const withSections = getMockDashboardPanels(true);
|
||||
|
||||
const { internalApi } = await createAndMountDashboardGrid({
|
||||
panels: withoutSections.panels,
|
||||
sections: {},
|
||||
});
|
||||
|
||||
let sectionContainers = screen.getAllByTestId(`kbnGridSectionWrapper-`, {
|
||||
exact: false,
|
||||
});
|
||||
expect(sectionContainers.length).toBe(1); // only the first top section is rendered
|
||||
|
||||
internalApi.layout$.next(withSections);
|
||||
|
||||
await waitFor(() => {
|
||||
sectionContainers = screen.getAllByTestId(`kbnGridSectionWrapper-`, {
|
||||
exact: false,
|
||||
});
|
||||
expect(sectionContainers.length).toBe(2); // section wrappers are not rendered for collapsed sections
|
||||
expect(screen.getAllByTestId('dashboardGridItem').length).toBe(3); // one panel is in a collapsed section
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,19 +10,20 @@
|
|||
import { useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { useAppFixedViewport } from '@kbn/core-rendering-browser';
|
||||
import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
|
||||
import { GridLayout, GridPanelData, GridSectionData, type GridLayoutData } from '@kbn/grid-layout';
|
||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { default as React, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../common/content_management/constants';
|
||||
import { arePanelLayoutsEqual } from '../../dashboard_api/are_panel_layouts_equal';
|
||||
import { GridData } from '../../../common/content_management/v2/types';
|
||||
import { areLayoutsEqual } from '../../dashboard_api/are_layouts_equal';
|
||||
import { DashboardLayout } from '../../dashboard_api/types';
|
||||
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
|
||||
import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
|
||||
DASHBOARD_GRID_HEIGHT,
|
||||
DASHBOARD_MARGIN_SIZE,
|
||||
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
|
||||
} from './constants';
|
||||
import { DashboardGridItem } from './dashboard_grid_item';
|
||||
import { useLayoutStyles } from './use_layout_styles';
|
||||
|
@ -34,11 +35,13 @@ export const DashboardGrid = ({
|
|||
}) => {
|
||||
const dashboardApi = useDashboardApi();
|
||||
const dashboardInternalApi = useDashboardInternalApi();
|
||||
const layoutRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const layoutStyles = useLayoutStyles();
|
||||
const panelRefs = useRef<{ [panelId: string]: React.Ref<HTMLDivElement> }>({});
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const [topOffset, setTopOffset] = useState(DEFAULT_DASHBOARD_DRAG_TOP_OFFSET);
|
||||
const [expandedPanelId, layout, useMargins, viewMode] = useBatchedPublishingSubjects(
|
||||
dashboardApi.expandedPanelId$,
|
||||
dashboardInternalApi.layout$,
|
||||
|
@ -46,57 +49,93 @@ export const DashboardGrid = ({
|
|||
dashboardApi.viewMode$
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTopOffset(
|
||||
dashboardContainerRef?.current?.getBoundingClientRect().top ??
|
||||
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET
|
||||
);
|
||||
}, [dashboardContainerRef]);
|
||||
|
||||
const appFixedViewport = useAppFixedViewport();
|
||||
|
||||
const currentLayout: GridLayoutData = useMemo(() => {
|
||||
const singleRow: GridLayoutData = {};
|
||||
|
||||
Object.keys(layout).forEach((panelId) => {
|
||||
const gridData = layout[panelId].gridData;
|
||||
singleRow[panelId] = {
|
||||
const newLayout: GridLayoutData = {};
|
||||
Object.keys(layout.sections).forEach((sectionId) => {
|
||||
const section = layout.sections[sectionId];
|
||||
newLayout[sectionId] = {
|
||||
id: sectionId,
|
||||
type: 'section',
|
||||
row: section.gridData.y,
|
||||
isCollapsed: Boolean(section.collapsed),
|
||||
title: section.title,
|
||||
panels: {},
|
||||
};
|
||||
});
|
||||
Object.keys(layout.panels).forEach((panelId) => {
|
||||
const gridData = layout.panels[panelId].gridData;
|
||||
const basePanel = {
|
||||
id: panelId,
|
||||
row: gridData.y,
|
||||
column: gridData.x,
|
||||
width: gridData.w,
|
||||
height: gridData.h,
|
||||
type: 'panel',
|
||||
};
|
||||
} as GridPanelData;
|
||||
if (gridData.sectionId) {
|
||||
(newLayout[gridData.sectionId] as GridSectionData).panels[panelId] = basePanel;
|
||||
} else {
|
||||
newLayout[panelId] = {
|
||||
...basePanel,
|
||||
type: 'panel',
|
||||
};
|
||||
}
|
||||
// update `data-grid-row` attribute for all panels because it is used for some styling
|
||||
const panelRef = panelRefs.current[panelId];
|
||||
if (typeof panelRef !== 'function' && panelRef?.current) {
|
||||
panelRef.current.setAttribute('data-grid-row', `${gridData.y}`);
|
||||
}
|
||||
});
|
||||
|
||||
return singleRow;
|
||||
return newLayout;
|
||||
}, [layout]);
|
||||
|
||||
const onLayoutChange = useCallback(
|
||||
(newLayout: GridLayoutData) => {
|
||||
if (viewMode !== 'edit') return;
|
||||
|
||||
const currentPanels = dashboardInternalApi.layout$.getValue();
|
||||
const updatedPanels: DashboardLayout = Object.values(newLayout).reduce(
|
||||
(updatedPanelsAcc, widget) => {
|
||||
if (widget.type === 'section') {
|
||||
return updatedPanelsAcc; // sections currently aren't supported
|
||||
}
|
||||
updatedPanelsAcc[widget.id] = {
|
||||
...currentPanels[widget.id],
|
||||
const currLayout = dashboardInternalApi.layout$.getValue();
|
||||
const updatedLayout: DashboardLayout = {
|
||||
sections: {},
|
||||
panels: {},
|
||||
};
|
||||
Object.values(newLayout).forEach((widget) => {
|
||||
if (widget.type === 'section') {
|
||||
updatedLayout.sections[widget.id] = {
|
||||
collapsed: widget.isCollapsed,
|
||||
title: widget.title,
|
||||
id: widget.id,
|
||||
gridData: {
|
||||
i: widget.id,
|
||||
y: widget.row,
|
||||
x: widget.column,
|
||||
w: widget.width,
|
||||
h: widget.height,
|
||||
},
|
||||
};
|
||||
return updatedPanelsAcc;
|
||||
},
|
||||
{} as DashboardLayout
|
||||
);
|
||||
if (!arePanelLayoutsEqual(currentPanels, updatedPanels)) {
|
||||
dashboardInternalApi.layout$.next(updatedPanels);
|
||||
Object.values(widget.panels).forEach((panel) => {
|
||||
updatedLayout.panels[panel.id] = {
|
||||
...currLayout.panels[panel.id],
|
||||
gridData: {
|
||||
...convertGridPanelToDashboardGridData(panel),
|
||||
sectionId: widget.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// widget is a panel
|
||||
updatedLayout.panels[widget.id] = {
|
||||
...currLayout.panels[widget.id],
|
||||
gridData: convertGridPanelToDashboardGridData(widget),
|
||||
};
|
||||
}
|
||||
});
|
||||
if (!areLayoutsEqual(currLayout, updatedLayout)) {
|
||||
dashboardInternalApi.layout$.next(updatedLayout);
|
||||
}
|
||||
},
|
||||
[dashboardInternalApi.layout$, viewMode]
|
||||
|
@ -104,13 +143,14 @@ export const DashboardGrid = ({
|
|||
|
||||
const renderPanelContents = useCallback(
|
||||
(id: string, setDragHandles: (refs: Array<HTMLElement | null>) => void) => {
|
||||
const currentPanels = dashboardInternalApi.layout$.getValue();
|
||||
if (!currentPanels[id]) return;
|
||||
const panels = dashboardInternalApi.layout$.getValue().panels;
|
||||
if (!panels[id]) return;
|
||||
|
||||
if (!panelRefs.current[id]) {
|
||||
panelRefs.current[id] = React.createRef();
|
||||
}
|
||||
const type = currentPanels[id].type;
|
||||
|
||||
const type = panels[id].type;
|
||||
return (
|
||||
<DashboardGridItem
|
||||
ref={panelRefs.current[id]}
|
||||
|
@ -120,13 +160,45 @@ export const DashboardGrid = ({
|
|||
setDragHandles={setDragHandles}
|
||||
appFixedViewport={appFixedViewport}
|
||||
dashboardContainerRef={dashboardContainerRef}
|
||||
data-grid-row={currentPanels[id].gridData.y} // initialize data-grid-row
|
||||
data-grid-row={panels[id].gridData.y} // initialize data-grid-row
|
||||
/>
|
||||
);
|
||||
},
|
||||
[appFixedViewport, dashboardContainerRef, dashboardInternalApi.layout$]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* ResizeObserver fires the callback on `.observe()` with the initial size of the observed
|
||||
* element; we want to ignore this first call and scroll to the bottom on the **second**
|
||||
* callback - i.e. after the row is actually added to the DOM
|
||||
*/
|
||||
let first = false;
|
||||
const scrollToBottomOnResize = new ResizeObserver(() => {
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
dashboardApi.scrollToBottom();
|
||||
scrollToBottomOnResize.disconnect(); // once scrolled, stop observing resize events
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* When `scrollToBottom$` emits, wait for the `layoutRef` size to change then scroll
|
||||
* to the bottom of the screen
|
||||
*/
|
||||
const scrollToBottomSubscription = dashboardApi.scrollToBottom$.subscribe(() => {
|
||||
if (!layoutRef.current) return;
|
||||
first = true; // ensure that only the second resize callback actually triggers scrolling
|
||||
scrollToBottomOnResize.observe(layoutRef.current);
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollToBottomOnResize.disconnect();
|
||||
scrollToBottomSubscription.unsubscribe();
|
||||
};
|
||||
}, [dashboardApi]);
|
||||
|
||||
const memoizedgridLayout = useMemo(() => {
|
||||
// memoizing this component reduces the number of times it gets re-rendered to a minimum
|
||||
return (
|
||||
|
@ -137,9 +209,7 @@ export const DashboardGrid = ({
|
|||
gutterSize: useMargins ? DASHBOARD_MARGIN_SIZE : 0,
|
||||
rowHeight: DASHBOARD_GRID_HEIGHT,
|
||||
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
|
||||
keyboardDragTopLimit:
|
||||
dashboardContainerRef?.current?.getBoundingClientRect().top ||
|
||||
DEFAULT_DASHBOARD_DRAG_TOP_OFFSET,
|
||||
keyboardDragTopLimit: topOffset,
|
||||
}}
|
||||
useCustomDragHandle={true}
|
||||
renderPanelContents={renderPanelContents}
|
||||
|
@ -156,7 +226,7 @@ export const DashboardGrid = ({
|
|||
onLayoutChange,
|
||||
expandedPanelId,
|
||||
viewMode,
|
||||
dashboardContainerRef,
|
||||
topOffset,
|
||||
]);
|
||||
|
||||
const { dashboardClasses, dashboardStyles } = useMemo(() => {
|
||||
|
@ -183,8 +253,18 @@ export const DashboardGrid = ({
|
|||
}, [useMargins, viewMode, expandedPanelId, euiTheme.levels.toast]);
|
||||
|
||||
return (
|
||||
<div className={dashboardClasses} css={dashboardStyles}>
|
||||
<div ref={layoutRef} className={dashboardClasses} css={dashboardStyles}>
|
||||
{memoizedgridLayout}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const convertGridPanelToDashboardGridData = (panel: GridPanelData): GridData => {
|
||||
return {
|
||||
i: panel.id,
|
||||
y: panel.row,
|
||||
x: panel.column,
|
||||
w: panel.width,
|
||||
h: panel.height,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -50,7 +50,11 @@ export const useLayoutStyles = () => {
|
|||
background-origin: content-box;
|
||||
}
|
||||
|
||||
.kbnGridPanel--dragPreview {
|
||||
// styles for the area where the panel and/or section header will be dropped
|
||||
.kbnGridPanel--dragPreview,
|
||||
.kbnGridSection--dragPreview {
|
||||
border-radius: ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium};
|
||||
|
||||
background-color: ${transparentize(euiTheme.colors.vis.euiColorVis0, 0.2)};
|
||||
}
|
||||
|
||||
|
@ -91,6 +95,50 @@ export const useLayoutStyles = () => {
|
|||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// styling for what the grid section header looks like when being dragged
|
||||
.kbnGridSectionHeader--active {
|
||||
background-color: ${euiTheme.colors.backgroundBasePlain};
|
||||
outline: var(--dashboardActivePanelBorderStyle);
|
||||
border-radius: ${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium};
|
||||
padding-left: 8px;
|
||||
// hide accordian arrow + panel count text when row is being dragged
|
||||
& .kbnGridSectionTitle--button svg,
|
||||
& .kbnGridLayout--panelCount {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// styling for the section footer
|
||||
.kbnGridSectionFooter {
|
||||
height: ${euiTheme.size.s};
|
||||
display: block;
|
||||
border-top: ${euiTheme.border.thin};
|
||||
// highlight the footer of a targeted section to make it clear where the section ends
|
||||
&--targeted {
|
||||
border-top: ${euiTheme.border.width.thick} solid
|
||||
${transparentize(euiTheme.colors.vis.euiColorVis0, 0.5)};
|
||||
}
|
||||
}
|
||||
// hide footer border when section is being dragged
|
||||
&:has(.kbnGridSectionHeader--active) .kbnGridSectionHeader--active + .kbnGridSectionFooter {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
// apply a "fade out" effect when dragging a section header over another section, indicating that dropping is not allowed
|
||||
.kbnGridSection--blocked {
|
||||
z-index: 1;
|
||||
background-color: ${transparentize(euiTheme.colors.backgroundBaseSubdued, 0.5)};
|
||||
// the oulines of panels extend past 100% by 1px on each side, so adjust for that
|
||||
margin-left: -1px;
|
||||
margin-top: -1px;
|
||||
width: calc(100% + 2px);
|
||||
height: calc(100% + 2px);
|
||||
}
|
||||
|
||||
&:has(.kbnGridSection--blocked) .kbnGridSection--dragHandle {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
`;
|
||||
}, [euiTheme]);
|
||||
|
||||
|
|
|
@ -12,6 +12,10 @@
|
|||
.dshDashboardViewport {
|
||||
width: 100%;
|
||||
|
||||
&.dshDashboardViewport--empty {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&--panelExpanded {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
@ -14,14 +14,14 @@ import { EuiPortal } from '@elastic/eui';
|
|||
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||
import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen';
|
||||
|
||||
import { ControlGroupApi } from '@kbn/controls-plugin/public';
|
||||
import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common';
|
||||
import { ControlGroupApi } from '@kbn/controls-plugin/public';
|
||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||
import { DashboardGrid } from '../grid';
|
||||
import { CONTROL_GROUP_EMBEDDABLE_ID } from '../../dashboard_api/control_group_manager';
|
||||
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
|
||||
import { useDashboardInternalApi } from '../../dashboard_api/use_dashboard_internal_api';
|
||||
import { DashboardGrid } from '../grid';
|
||||
import { DashboardEmptyScreen } from './empty_screen/dashboard_empty_screen';
|
||||
import { CONTROL_GROUP_EMBEDDABLE_ID } from '../../dashboard_api/control_group_manager';
|
||||
|
||||
export const DashboardViewport = ({
|
||||
dashboardContainerRef,
|
||||
|
@ -54,12 +54,21 @@ export const DashboardViewport = ({
|
|||
dashboardApi.setFullScreenMode(false);
|
||||
}, [dashboardApi]);
|
||||
|
||||
const panelCount = useMemo(() => {
|
||||
return Object.keys(layout).length;
|
||||
}, [layout]);
|
||||
const { panelCount, visiblePanelCount, sectionCount } = useMemo(() => {
|
||||
const panels = Object.values(layout.panels);
|
||||
const visiblePanels = panels.filter(({ gridData }) => {
|
||||
return !dashboardInternalApi.isSectionCollapsed(gridData.sectionId);
|
||||
});
|
||||
return {
|
||||
panelCount: panels.length,
|
||||
visiblePanelCount: visiblePanels.length,
|
||||
sectionCount: Object.keys(layout.sections).length,
|
||||
};
|
||||
}, [layout, dashboardInternalApi]);
|
||||
|
||||
const classes = classNames({
|
||||
dshDashboardViewport: true,
|
||||
'dshDashboardViewport--empty': panelCount === 0 && sectionCount === 0,
|
||||
'dshDashboardViewport--print': viewMode === 'print',
|
||||
'dshDashboardViewport--panelExpanded': Boolean(expandedPanelId),
|
||||
});
|
||||
|
@ -124,15 +133,18 @@ export const DashboardViewport = ({
|
|||
<ExitFullScreenButton onExit={onExit} toggleChrome={!dashboardApi.isEmbeddedExternally} />
|
||||
</EuiPortal>
|
||||
)}
|
||||
{panelCount === 0 && <DashboardEmptyScreen />}
|
||||
<div
|
||||
className={classes}
|
||||
data-shared-items-container
|
||||
data-title={dashboardTitle}
|
||||
data-description={description}
|
||||
data-shared-items-count={panelCount}
|
||||
data-shared-items-count={visiblePanelCount}
|
||||
>
|
||||
<DashboardGrid dashboardContainerRef={dashboardContainerRef} />
|
||||
{panelCount === 0 && sectionCount === 0 ? (
|
||||
<DashboardEmptyScreen />
|
||||
) : (
|
||||
<DashboardGrid dashboardContainerRef={dashboardContainerRef} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -12,7 +12,7 @@ import { BehaviorSubject } from 'rxjs';
|
|||
import { DashboardStart } from './plugin';
|
||||
import { DashboardState } from '../common/types';
|
||||
import { getDashboardApi } from './dashboard_api/get_dashboard_api';
|
||||
import { DashboardPanelState } from '../common/dashboard_container/types';
|
||||
import { DashboardPanelMap, DashboardSectionMap } from '../common';
|
||||
|
||||
export type Start = jest.Mocked<DashboardStart>;
|
||||
|
||||
|
@ -126,24 +126,67 @@ export function getSampleDashboardState(overrides?: Partial<DashboardState>): Da
|
|||
timeRestore: false,
|
||||
viewMode: 'view',
|
||||
panels: {},
|
||||
sections: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSampleDashboardPanel(
|
||||
overrides: Partial<DashboardPanelState> & {
|
||||
explicitInput: { id: string };
|
||||
type: string;
|
||||
export function getMockDashboardPanels(
|
||||
withSections: boolean = false,
|
||||
overrides?: {
|
||||
panels?: DashboardPanelMap;
|
||||
sections?: DashboardSectionMap;
|
||||
}
|
||||
): DashboardPanelState {
|
||||
return {
|
||||
gridData: {
|
||||
h: 15,
|
||||
w: 15,
|
||||
x: 0,
|
||||
y: 0,
|
||||
i: overrides.explicitInput.id,
|
||||
): { panels: DashboardPanelMap; sections: DashboardSectionMap } {
|
||||
const panels = {
|
||||
'1': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
...overrides,
|
||||
'2': {
|
||||
gridData: { x: 6, y: 0, w: 6, h: 6, i: '2' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '2' },
|
||||
},
|
||||
...overrides?.panels,
|
||||
};
|
||||
if (!withSections) return { panels, sections: {} };
|
||||
|
||||
return {
|
||||
panels: {
|
||||
...panels,
|
||||
'3': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: '3', sectionId: 'section1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '3' },
|
||||
},
|
||||
'4': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: '4', sectionId: 'section2' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '4' },
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
section1: {
|
||||
id: 'section1',
|
||||
title: 'Section One',
|
||||
collapsed: true,
|
||||
gridData: {
|
||||
y: 6,
|
||||
i: 'section1',
|
||||
},
|
||||
},
|
||||
section2: {
|
||||
id: 'section2',
|
||||
title: 'Section Two',
|
||||
collapsed: false,
|
||||
gridData: {
|
||||
y: 7,
|
||||
i: 'section2',
|
||||
},
|
||||
},
|
||||
...overrides?.sections,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { getMockDashboardPanels } from '../mocks';
|
||||
import { placeClonePanel } from './place_clone_panel_strategy';
|
||||
|
||||
describe('Clone panel placement strategies', () => {
|
||||
it('no other panels', () => {
|
||||
const currentPanels = {
|
||||
'1': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: '1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
};
|
||||
const { newPanelPlacement, otherPanels } = placeClonePanel({
|
||||
width: 6,
|
||||
height: 6,
|
||||
currentPanels,
|
||||
placeBesideId: '1',
|
||||
});
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 6, // placed right beside the other panel
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(currentPanels);
|
||||
});
|
||||
|
||||
it('panel collision at desired clone location', () => {
|
||||
const { panels } = getMockDashboardPanels();
|
||||
const { newPanelPlacement, otherPanels } = placeClonePanel({
|
||||
width: 6,
|
||||
height: 6,
|
||||
currentPanels: panels,
|
||||
placeBesideId: '1',
|
||||
});
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0,
|
||||
y: 6, // instead of being placed beside the cloned panel, it is placed right below
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(panels);
|
||||
});
|
||||
|
||||
it('ignores panels in other sections', () => {
|
||||
const { panels } = getMockDashboardPanels(true);
|
||||
|
||||
const { newPanelPlacement, otherPanels } = placeClonePanel({
|
||||
width: 6,
|
||||
height: 6,
|
||||
currentPanels: panels,
|
||||
placeBesideId: '3',
|
||||
sectionId: 'section1',
|
||||
});
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 6, // placed beside panel 3, since is has space beside it in section1
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(panels);
|
||||
});
|
||||
});
|
|
@ -9,10 +9,11 @@
|
|||
|
||||
import { PanelNotFoundError } from '@kbn/embeddable-plugin/public';
|
||||
import { cloneDeep, forOwn } from 'lodash';
|
||||
|
||||
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../common/content_management';
|
||||
import type { GridData } from '../../server/content_management';
|
||||
import { DashboardLayoutItem } from '../dashboard_api/types';
|
||||
import { PanelPlacementProps, PanelPlacementReturn } from './types';
|
||||
import { DashboardPanel } from '../dashboard_api/types';
|
||||
|
||||
interface IplacementDirection {
|
||||
grid: Omit<GridData, 'i'>;
|
||||
|
@ -42,6 +43,7 @@ function comparePanels(a: GridData, b: GridData): number {
|
|||
export function placeClonePanel({
|
||||
width,
|
||||
height,
|
||||
sectionId,
|
||||
currentPanels,
|
||||
placeBesideId,
|
||||
}: PanelPlacementProps & { placeBesideId: string }): PanelPlacementReturn {
|
||||
|
@ -51,8 +53,11 @@ export function placeClonePanel({
|
|||
}
|
||||
const beside = panelToPlaceBeside.gridData;
|
||||
const otherPanelGridData: GridData[] = [];
|
||||
forOwn(currentPanels, (panel: DashboardLayoutItem, key: string | undefined) => {
|
||||
otherPanelGridData.push(panel.gridData);
|
||||
forOwn(currentPanels, (panel: DashboardPanel) => {
|
||||
if (panel.gridData.sectionId === sectionId) {
|
||||
// only check against panels that are in the same section as the cloned panel
|
||||
otherPanelGridData.push(panel.gridData);
|
||||
}
|
||||
});
|
||||
|
||||
const possiblePlacementDirections: IplacementDirection[] = [
|
||||
|
@ -109,8 +114,11 @@ export function placeClonePanel({
|
|||
for (let j = position + 1; j < grid.length; j++) {
|
||||
originalPositionInTheGrid = grid[j].i;
|
||||
const { gridData, ...movedPanel } = cloneDeep(otherPanels[originalPositionInTheGrid]);
|
||||
const newGridData = { ...gridData, y: gridData.y + diff };
|
||||
otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData };
|
||||
if (gridData.sectionId === sectionId) {
|
||||
// only move panels in the cloned panel's section
|
||||
const newGridData = { ...gridData, y: gridData.y + diff };
|
||||
otherPanels[originalPositionInTheGrid] = { ...movedPanel, gridData: newGridData };
|
||||
}
|
||||
}
|
||||
return { newPanelPlacement: bottomPlacement.grid, otherPanels };
|
||||
}
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { getMockDashboardPanels } from '../mocks';
|
||||
import { PanelPlacementStrategy } from '../plugin_constants';
|
||||
import { runPanelPlacementStrategy } from './place_new_panel_strategies';
|
||||
|
||||
describe('new panel placement strategies', () => {
|
||||
describe('place at top', () => {
|
||||
it('no other panels', () => {
|
||||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
PanelPlacementStrategy.placeAtTop,
|
||||
{ width: 6, height: 6, currentPanels: {} }
|
||||
);
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual({});
|
||||
});
|
||||
|
||||
it('push other panels down', () => {
|
||||
const { panels } = getMockDashboardPanels();
|
||||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
PanelPlacementStrategy.placeAtTop,
|
||||
{ width: 6, height: 6, currentPanels: panels }
|
||||
);
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(
|
||||
Object.keys(panels).reduce((prev, panelId) => {
|
||||
const originalGridData = panels[panelId].gridData;
|
||||
return {
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...panels[panelId],
|
||||
gridData: {
|
||||
...originalGridData,
|
||||
y: originalGridData.y + 6, // panel was pushed down by height of new panel
|
||||
},
|
||||
},
|
||||
};
|
||||
}, {})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores panels in other sections', () => {
|
||||
const { panels } = getMockDashboardPanels(true);
|
||||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
PanelPlacementStrategy.placeAtTop,
|
||||
{ width: 6, height: 6, currentPanels: panels, sectionId: 'section1' }
|
||||
);
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(
|
||||
Object.keys(panels).reduce((prev, panelId) => {
|
||||
const originalGridData = panels[panelId].gridData;
|
||||
return {
|
||||
...prev,
|
||||
[panelId]: {
|
||||
...panels[panelId],
|
||||
gridData: {
|
||||
...originalGridData,
|
||||
// only panels in the targetted section should get pushed down
|
||||
...(originalGridData.sectionId === 'section1' && {
|
||||
y: originalGridData.y + 6,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}, {})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Find top left most open space', () => {
|
||||
it('no other panels', () => {
|
||||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
PanelPlacementStrategy.findTopLeftMostOpenSpace,
|
||||
{ width: 6, height: 6, currentPanels: {} }
|
||||
);
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual({});
|
||||
});
|
||||
|
||||
it('top left most space is available', () => {
|
||||
const { panels } = getMockDashboardPanels(false, {
|
||||
panels: {
|
||||
'1': {
|
||||
gridData: { x: 6, y: 0, w: 6, h: 6, i: '1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
PanelPlacementStrategy.findTopLeftMostOpenSpace,
|
||||
{ width: 6, height: 6, currentPanels: panels }
|
||||
);
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0, // placed in the first available spot
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(panels); // other panels don't move with this strategy
|
||||
});
|
||||
|
||||
it('panel must be pushed down', () => {
|
||||
const { panels } = getMockDashboardPanels(true, {
|
||||
panels: {
|
||||
'5': {
|
||||
gridData: { x: 6, y: 0, w: 42, h: 6, i: '5' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
PanelPlacementStrategy.findTopLeftMostOpenSpace,
|
||||
{ width: 6, height: 6, currentPanels: panels }
|
||||
);
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0,
|
||||
y: 6,
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(panels); // other panels don't move with this strategy
|
||||
});
|
||||
|
||||
it('ignores panels in other sections', () => {
|
||||
const { panels } = getMockDashboardPanels(true, {
|
||||
panels: {
|
||||
'1': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 100, i: '1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
'2': {
|
||||
gridData: { x: 6, y: 6, w: 42, h: 100, i: '2' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '2' },
|
||||
},
|
||||
'6': {
|
||||
gridData: { x: 0, y: 6, w: 6, h: 6, i: '6', sectionId: 'section1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
'7': {
|
||||
gridData: { x: 6, y: 0, w: 42, h: 12, i: '7', sectionId: 'section1' },
|
||||
type: 'lens',
|
||||
explicitInput: { id: '1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const { newPanelPlacement, otherPanels } = runPanelPlacementStrategy(
|
||||
PanelPlacementStrategy.findTopLeftMostOpenSpace,
|
||||
{ width: 6, height: 6, currentPanels: panels, sectionId: 'section1' }
|
||||
);
|
||||
expect(newPanelPlacement).toEqual({
|
||||
x: 0,
|
||||
y: 12, // maxY is 12 for section1; maxY of 100 in section 0 is ignored
|
||||
w: 6,
|
||||
h: 6,
|
||||
});
|
||||
expect(otherPanels).toEqual(panels); // other panels don't move with this strategy
|
||||
});
|
||||
});
|
||||
});
|
|
@ -15,15 +15,18 @@ import { PanelPlacementProps, PanelPlacementReturn } from './types';
|
|||
|
||||
export const runPanelPlacementStrategy = (
|
||||
strategy: PanelPlacementStrategy,
|
||||
{ width, height, currentPanels }: PanelPlacementProps
|
||||
{ width, height, currentPanels, sectionId }: PanelPlacementProps
|
||||
): PanelPlacementReturn => {
|
||||
switch (strategy) {
|
||||
case PanelPlacementStrategy.placeAtTop:
|
||||
const otherPanels = { ...currentPanels };
|
||||
for (const [id, panel] of Object.entries(currentPanels)) {
|
||||
const { gridData, ...currentPanel } = cloneDeep(panel);
|
||||
const newGridData = { ...gridData, y: gridData.y + height };
|
||||
otherPanels[id] = { ...currentPanel, gridData: newGridData };
|
||||
// only consider collisions with panels in the same section
|
||||
if (!sectionId || panel.gridData.sectionId === sectionId) {
|
||||
const { gridData, ...currentPanel } = cloneDeep(panel);
|
||||
const newGridData = { ...gridData, y: gridData.y + height };
|
||||
otherPanels[id] = { ...currentPanel, gridData: newGridData };
|
||||
}
|
||||
}
|
||||
return {
|
||||
newPanelPlacement: { x: 0, y: 0, w: width, h: height },
|
||||
|
@ -35,7 +38,10 @@ export const runPanelPlacementStrategy = (
|
|||
|
||||
const currentPanelsArray = Object.values(currentPanels);
|
||||
currentPanelsArray.forEach((panel) => {
|
||||
maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
|
||||
// only consider panels in the same section when calculating maxY
|
||||
if (panel.gridData.sectionId === sectionId) {
|
||||
maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle case of empty grid.
|
||||
|
@ -52,17 +58,19 @@ export const runPanelPlacementStrategy = (
|
|||
}
|
||||
|
||||
currentPanelsArray.forEach((panel) => {
|
||||
for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) {
|
||||
for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) {
|
||||
const row = grid[y];
|
||||
if (row === undefined) {
|
||||
throw new Error(
|
||||
`Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify(
|
||||
panel
|
||||
)}`
|
||||
);
|
||||
if (panel.gridData.sectionId === sectionId) {
|
||||
for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) {
|
||||
for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) {
|
||||
const row = grid[y];
|
||||
if (row === undefined) {
|
||||
throw new Error(
|
||||
`Attempted to access a row that doesn't exist at ${y} for panel ${JSON.stringify(
|
||||
panel
|
||||
)}`
|
||||
);
|
||||
}
|
||||
grid[y][x] = 1;
|
||||
}
|
||||
grid[y][x] = 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -21,13 +21,14 @@ export interface PanelPlacementSettings {
|
|||
|
||||
export interface PanelPlacementReturn {
|
||||
newPanelPlacement: Omit<GridData, 'i'>;
|
||||
otherPanels: DashboardLayout;
|
||||
otherPanels: DashboardLayout['panels'];
|
||||
}
|
||||
|
||||
export interface PanelPlacementProps {
|
||||
width: number;
|
||||
height: number;
|
||||
currentPanels: DashboardLayout;
|
||||
currentPanels: DashboardLayout['panels'];
|
||||
sectionId?: string; // section where panel is being placed
|
||||
}
|
||||
|
||||
export type GetPanelPlacementSettings<SerializedState extends object = object> = (
|
||||
|
|
|
@ -7,18 +7,18 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { has } from 'lodash';
|
||||
import { injectSearchSourceReferences } from '@kbn/data-plugin/public';
|
||||
import { Filter, Query } from '@kbn/es-query';
|
||||
import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
|
||||
import { has } from 'lodash';
|
||||
|
||||
import { cleanFiltersForSerialize } from '../../../utils/clean_filters_for_serialize';
|
||||
import { getDashboardContentManagementCache } from '..';
|
||||
import { convertPanelsArrayToPanelMap } from '../../../../common/lib/dashboard_panel_converters';
|
||||
import { injectReferences } from '../../../../common/dashboard_saved_object/persistable_state/dashboard_saved_object_references';
|
||||
import { convertPanelsArrayToPanelSectionMaps } from '../../../../common/lib/dashboard_panel_converters';
|
||||
import type { DashboardGetIn, DashboardGetOut } from '../../../../server/content_management';
|
||||
import { DASHBOARD_CONTENT_ID } from '../../../utils/telemetry_constants';
|
||||
import { DEFAULT_DASHBOARD_STATE } from '../../../dashboard_api/default_dashboard_state';
|
||||
import { cleanFiltersForSerialize } from '../../../utils/clean_filters_for_serialize';
|
||||
import { DASHBOARD_CONTENT_ID } from '../../../utils/telemetry_constants';
|
||||
import {
|
||||
contentManagementService,
|
||||
dataService,
|
||||
|
@ -73,6 +73,7 @@ export const loadDashboardState = async ({
|
|||
let resolveMeta: DashboardGetOut['meta'];
|
||||
|
||||
const cachedDashboard = dashboardContentManagementCache.fetchDashboard(id);
|
||||
|
||||
if (cachedDashboard) {
|
||||
/** If the dashboard exists in the cache, use the cached version to load the dashboard */
|
||||
({ item: rawDashboardContent, meta: resolveMeta } = cachedDashboard);
|
||||
|
@ -149,7 +150,6 @@ export const loadDashboardState = async ({
|
|||
const query = migrateLegacyQuery(
|
||||
searchSource?.getOwnField('query') || queryString.getDefaultQuery() // TODO SAVED DASHBOARDS determine if migrateLegacyQuery is still needed
|
||||
);
|
||||
|
||||
const {
|
||||
refreshInterval,
|
||||
description,
|
||||
|
@ -170,7 +170,9 @@ export const loadDashboardState = async ({
|
|||
}
|
||||
: undefined;
|
||||
|
||||
const panelMap = convertPanelsArrayToPanelMap(panels ?? []);
|
||||
const { panels: panelMap, sections: sectionsMap } = convertPanelsArrayToPanelSectionMaps(
|
||||
panels ?? []
|
||||
);
|
||||
|
||||
return {
|
||||
managed,
|
||||
|
@ -187,6 +189,7 @@ export const loadDashboardState = async ({
|
|||
panels: panelMap,
|
||||
query,
|
||||
title,
|
||||
sections: sectionsMap,
|
||||
|
||||
viewMode: 'view', // dashboards loaded from saved object default to view mode. If it was edited recently, the view mode from session storage will override this.
|
||||
tags:
|
||||
|
|
|
@ -11,6 +11,7 @@ export type {
|
|||
ControlGroupAttributes,
|
||||
GridData,
|
||||
DashboardPanel,
|
||||
DashboardSection,
|
||||
DashboardAttributes,
|
||||
DashboardItem,
|
||||
DashboardGetIn,
|
||||
|
|
|
@ -229,7 +229,16 @@ const searchSourceSchema = schema.object(
|
|||
{ defaultValue: {}, unknowns: 'allow' }
|
||||
);
|
||||
|
||||
export const gridDataSchema = schema.object({
|
||||
const sectionGridDataSchema = schema.object({
|
||||
y: schema.number({ meta: { description: 'The y coordinate of the section in grid units' } }),
|
||||
i: schema.maybe(
|
||||
schema.string({
|
||||
meta: { description: 'The unique identifier of the section' },
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const panelGridDataSchema = schema.object({
|
||||
x: schema.number({ meta: { description: 'The x coordinate of the panel in grid units' } }),
|
||||
y: schema.number({ meta: { description: 'The y coordinate of the panel in grid units' } }),
|
||||
w: schema.number({
|
||||
|
@ -284,7 +293,7 @@ export const panelSchema = schema.object({
|
|||
),
|
||||
type: schema.string({ meta: { description: 'The embeddable type' } }),
|
||||
panelRefName: schema.maybe(schema.string()),
|
||||
gridData: gridDataSchema,
|
||||
gridData: panelGridDataSchema,
|
||||
panelIndex: schema.maybe(
|
||||
schema.string({
|
||||
meta: { description: 'The unique ID of the panel.' },
|
||||
|
@ -302,6 +311,23 @@ export const panelSchema = schema.object({
|
|||
),
|
||||
});
|
||||
|
||||
export const sectionSchema = schema.object({
|
||||
title: schema.string({
|
||||
meta: { description: 'The title of the section.' },
|
||||
}),
|
||||
collapsed: schema.maybe(
|
||||
schema.boolean({
|
||||
meta: { description: 'The collapsed state of the section.' },
|
||||
defaultValue: false,
|
||||
})
|
||||
),
|
||||
gridData: sectionGridDataSchema,
|
||||
panels: schema.arrayOf(panelSchema, {
|
||||
meta: { description: 'The panels that belong to the section.' },
|
||||
defaultValue: [],
|
||||
}),
|
||||
});
|
||||
|
||||
export const optionsSchema = schema.object({
|
||||
hidePanelTitles: schema.boolean({
|
||||
defaultValue: DEFAULT_DASHBOARD_OPTIONS.hidePanelTitles,
|
||||
|
@ -402,7 +428,7 @@ export const dashboardAttributesSchema = searchResultsAttributesSchema.extends({
|
|||
|
||||
// Dashboard Content
|
||||
controlGroupInput: schema.maybe(controlGroupInputSchema),
|
||||
panels: schema.arrayOf(panelSchema, { defaultValue: [] }),
|
||||
panels: schema.arrayOf(schema.oneOf([panelSchema, sectionSchema]), { defaultValue: [] }),
|
||||
options: optionsSchema,
|
||||
version: schema.maybe(schema.number({ meta: { deprecated: true } })),
|
||||
});
|
||||
|
@ -417,14 +443,29 @@ export const referenceSchema = schema.object(
|
|||
);
|
||||
|
||||
const dashboardAttributesSchemaResponse = dashboardAttributesSchema.extends({
|
||||
// Responses always include the panel index (for panels) and gridData.i (for panels + sections)
|
||||
panels: schema.arrayOf(
|
||||
panelSchema.extends({
|
||||
// Responses always include the panel index and gridData.i
|
||||
panelIndex: schema.string(),
|
||||
gridData: gridDataSchema.extends({
|
||||
i: schema.string(),
|
||||
schema.oneOf([
|
||||
panelSchema.extends({
|
||||
panelIndex: schema.string(),
|
||||
gridData: panelGridDataSchema.extends({
|
||||
i: schema.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
sectionSchema.extends({
|
||||
gridData: sectionGridDataSchema.extends({
|
||||
i: schema.string(),
|
||||
}),
|
||||
panels: schema.arrayOf(
|
||||
panelSchema.extends({
|
||||
panelIndex: schema.string(),
|
||||
gridData: panelGridDataSchema.extends({
|
||||
i: schema.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
]),
|
||||
{ defaultValue: [] }
|
||||
),
|
||||
});
|
||||
|
|
|
@ -11,6 +11,7 @@ export type {
|
|||
ControlGroupAttributes,
|
||||
GridData,
|
||||
DashboardPanel,
|
||||
DashboardSection,
|
||||
DashboardAttributes,
|
||||
DashboardItem,
|
||||
DashboardGetIn,
|
||||
|
|
|
@ -10,21 +10,11 @@
|
|||
import { pick } from 'lodash';
|
||||
|
||||
import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-api-server';
|
||||
import type {
|
||||
DashboardAttributes,
|
||||
DashboardGetOut,
|
||||
DashboardItem,
|
||||
ItemAttrsToSavedObjectParams,
|
||||
ItemAttrsToSavedObjectReturn,
|
||||
ItemAttrsToSavedObjectWithTagsParams,
|
||||
PartialDashboardItem,
|
||||
SavedObjectToItemReturn,
|
||||
} from './types';
|
||||
import type { DashboardSavedObjectAttributes } from '../../dashboard_saved_object';
|
||||
import type {
|
||||
ControlGroupAttributes as ControlGroupAttributesV2,
|
||||
DashboardCrudTypes as DashboardCrudTypesV2,
|
||||
} from '../../../common/content_management/v2';
|
||||
import type { DashboardSavedObjectAttributes } from '../../dashboard_saved_object';
|
||||
import {
|
||||
transformControlGroupIn,
|
||||
transformControlGroupOut,
|
||||
|
@ -34,6 +24,16 @@ import {
|
|||
transformSearchSourceIn,
|
||||
transformSearchSourceOut,
|
||||
} from './transforms';
|
||||
import type {
|
||||
DashboardAttributes,
|
||||
DashboardGetOut,
|
||||
DashboardItem,
|
||||
ItemAttrsToSavedObjectParams,
|
||||
ItemAttrsToSavedObjectReturn,
|
||||
ItemAttrsToSavedObjectWithTagsParams,
|
||||
PartialDashboardItem,
|
||||
SavedObjectToItemReturn,
|
||||
} from './types';
|
||||
|
||||
export function dashboardAttributesOut(
|
||||
attributes: DashboardSavedObjectAttributes | Partial<DashboardSavedObjectAttributes>,
|
||||
|
@ -46,6 +46,7 @@ export function dashboardAttributesOut(
|
|||
kibanaSavedObjectMeta,
|
||||
optionsJSON,
|
||||
panelsJSON,
|
||||
sections,
|
||||
refreshInterval,
|
||||
timeFrom,
|
||||
timeRestore,
|
||||
|
@ -53,7 +54,6 @@ export function dashboardAttributesOut(
|
|||
title,
|
||||
version,
|
||||
} = attributes;
|
||||
|
||||
// Inject any tag names from references into the attributes
|
||||
let tags: string[] | undefined;
|
||||
if (getTagNamesFromReferences && references && references.length) {
|
||||
|
@ -68,7 +68,7 @@ export function dashboardAttributesOut(
|
|||
kibanaSavedObjectMeta: transformSearchSourceOut(kibanaSavedObjectMeta),
|
||||
}),
|
||||
...(optionsJSON && { options: transformOptionsOut(optionsJSON) }),
|
||||
...(panelsJSON && { panels: transformPanelsOut(panelsJSON) }),
|
||||
...((panelsJSON || sections) && { panels: transformPanelsOut(panelsJSON, sections) }),
|
||||
...(refreshInterval && {
|
||||
refreshInterval: { pause: refreshInterval.pause, value: refreshInterval.value },
|
||||
}),
|
||||
|
@ -107,7 +107,7 @@ export const getResultV3ToV2 = (result: DashboardGetOut): DashboardCrudTypesV2['
|
|||
kibanaSavedObjectMeta: transformSearchSourceIn(kibanaSavedObjectMeta),
|
||||
}),
|
||||
...(options && { optionsJSON: JSON.stringify(options) }),
|
||||
panelsJSON: panels ? transformPanelsIn(panels) : '[]',
|
||||
panelsJSON: panels ? transformPanelsIn(panels, true).panelsJSON : '[]',
|
||||
refreshInterval,
|
||||
...(timeFrom && { timeFrom }),
|
||||
timeRestore,
|
||||
|
@ -130,6 +130,8 @@ export const itemAttrsToSavedObject = ({
|
|||
}: ItemAttrsToSavedObjectParams): ItemAttrsToSavedObjectReturn => {
|
||||
try {
|
||||
const { controlGroupInput, kibanaSavedObjectMeta, options, panels, tags, ...rest } = attributes;
|
||||
const { panelsJSON, sections } = transformPanelsIn(panels);
|
||||
|
||||
const soAttributes = {
|
||||
...rest,
|
||||
...(controlGroupInput && {
|
||||
|
@ -139,8 +141,9 @@ export const itemAttrsToSavedObject = ({
|
|||
optionsJSON: JSON.stringify(options),
|
||||
}),
|
||||
...(panels && {
|
||||
panelsJSON: transformPanelsIn(panels),
|
||||
panelsJSON,
|
||||
}),
|
||||
...(sections?.length && { sections }),
|
||||
...(kibanaSavedObjectMeta && {
|
||||
kibanaSavedObjectMeta: transformSearchSourceIn(kibanaSavedObjectMeta),
|
||||
}),
|
||||
|
@ -217,7 +220,6 @@ export function savedObjectToItem(
|
|||
version,
|
||||
managed,
|
||||
} = savedObject;
|
||||
|
||||
try {
|
||||
const attributesOut = allowedAttributes
|
||||
? pick(
|
||||
|
|
|
@ -30,7 +30,7 @@ describe('transformPanelsIn', () => {
|
|||
},
|
||||
];
|
||||
const result = transformPanelsIn(panels as DashboardPanel[]);
|
||||
expect(result).toEqual(
|
||||
expect(result.panelsJSON).toEqual(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: 'foo',
|
||||
|
|
|
@ -9,24 +9,54 @@
|
|||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { DashboardSavedObjectAttributes } from '../../../../dashboard_saved_object';
|
||||
import { DashboardAttributes } from '../../types';
|
||||
import { isDashboardSection } from '../../../../../common/lib/dashboard_panel_converters';
|
||||
import {
|
||||
DashboardSavedObjectAttributes,
|
||||
SavedDashboardPanel,
|
||||
SavedDashboardSection,
|
||||
} from '../../../../dashboard_saved_object';
|
||||
import { DashboardAttributes, DashboardPanel, DashboardSection } from '../../types';
|
||||
|
||||
export function transformPanelsIn(
|
||||
panels: DashboardAttributes['panels']
|
||||
): DashboardSavedObjectAttributes['panelsJSON'] {
|
||||
const updatedPanels = panels.map(({ panelIndex, gridData, panelConfig, ...restPanel }) => {
|
||||
const idx = panelIndex ?? uuidv4();
|
||||
return {
|
||||
...restPanel,
|
||||
embeddableConfig: panelConfig,
|
||||
panelIndex: idx,
|
||||
gridData: {
|
||||
...gridData,
|
||||
i: idx,
|
||||
},
|
||||
};
|
||||
});
|
||||
widgets: DashboardAttributes['panels'] | undefined,
|
||||
dropSections: boolean = false
|
||||
): {
|
||||
panelsJSON: DashboardSavedObjectAttributes['panelsJSON'];
|
||||
sections: DashboardSavedObjectAttributes['sections'];
|
||||
} {
|
||||
const panels: SavedDashboardPanel[] = [];
|
||||
const sections: SavedDashboardSection[] = [];
|
||||
|
||||
return JSON.stringify(updatedPanels);
|
||||
widgets?.forEach((widget) => {
|
||||
if (isDashboardSection(widget)) {
|
||||
const { panels: sectionPanels, gridData, ...restOfSection } = widget as DashboardSection;
|
||||
const idx = gridData.i ?? uuidv4();
|
||||
sections.push({ ...restOfSection, gridData: { ...gridData, i: idx } });
|
||||
(sectionPanels as DashboardPanel[]).forEach((panel) => {
|
||||
const transformed = transformPanel(panel);
|
||||
panels.push({
|
||||
...transformed,
|
||||
gridData: { ...transformed.gridData, ...(!dropSections && { sectionId: idx }) },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// widget is a panel
|
||||
panels.push(transformPanel(widget));
|
||||
}
|
||||
});
|
||||
return { panelsJSON: JSON.stringify(panels), sections };
|
||||
}
|
||||
|
||||
function transformPanel(panel: DashboardPanel): SavedDashboardPanel {
|
||||
const { panelIndex, gridData, panelConfig, ...restPanel } = panel as DashboardPanel;
|
||||
const idx = panelIndex ?? uuidv4();
|
||||
return {
|
||||
...restPanel,
|
||||
embeddableConfig: panelConfig,
|
||||
panelIndex: idx,
|
||||
gridData: {
|
||||
...gridData,
|
||||
i: idx,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -7,25 +7,51 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { flow } from 'lodash';
|
||||
import { SavedDashboardPanel } from '../../../../dashboard_saved_object';
|
||||
import { DashboardAttributes } from '../../types';
|
||||
import { SavedDashboardPanel, SavedDashboardSection } from '../../../../dashboard_saved_object';
|
||||
import { DashboardAttributes, DashboardPanel, DashboardSection } from '../../types';
|
||||
|
||||
export function transformPanelsOut(panelsJSON: string): DashboardAttributes['panels'] {
|
||||
return flow(JSON.parse, transformPanelsProperties)(panelsJSON);
|
||||
}
|
||||
|
||||
function transformPanelsProperties(panels: SavedDashboardPanel[]) {
|
||||
return panels.map(
|
||||
({ embeddableConfig, gridData, id, panelIndex, panelRefName, title, type, version }) => ({
|
||||
gridData,
|
||||
id,
|
||||
panelConfig: embeddableConfig,
|
||||
panelIndex,
|
||||
panelRefName,
|
||||
title,
|
||||
type,
|
||||
version,
|
||||
})
|
||||
export function transformPanelsOut(
|
||||
panelsJSON: string = '{}',
|
||||
sections: SavedDashboardSection[] = []
|
||||
): DashboardAttributes['panels'] {
|
||||
const panels = JSON.parse(panelsJSON);
|
||||
const sectionsMap: { [uuid: string]: DashboardPanel | DashboardSection } = sections.reduce(
|
||||
(prev, section) => {
|
||||
const sectionId = section.gridData.i;
|
||||
return { ...prev, [sectionId]: { ...section, panels: [] } };
|
||||
},
|
||||
{}
|
||||
);
|
||||
panels.forEach((panel: SavedDashboardPanel) => {
|
||||
const { sectionId } = panel.gridData;
|
||||
if (sectionId) {
|
||||
(sectionsMap[sectionId] as DashboardSection).panels.push(transformPanelProperties(panel));
|
||||
} else {
|
||||
sectionsMap[panel.panelIndex] = transformPanelProperties(panel);
|
||||
}
|
||||
});
|
||||
return Object.values(sectionsMap);
|
||||
}
|
||||
|
||||
function transformPanelProperties({
|
||||
embeddableConfig,
|
||||
gridData,
|
||||
id,
|
||||
panelIndex,
|
||||
panelRefName,
|
||||
title,
|
||||
type,
|
||||
version,
|
||||
}: SavedDashboardPanel) {
|
||||
const { sectionId, ...rest } = gridData; // drop section ID, if it exists
|
||||
return {
|
||||
gridData: rest,
|
||||
id,
|
||||
panelConfig: embeddableConfig,
|
||||
panelIndex,
|
||||
panelRefName,
|
||||
title,
|
||||
type,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -20,8 +20,9 @@ import { WithRequiredProperty } from '@kbn/utility-types';
|
|||
import {
|
||||
dashboardItemSchema,
|
||||
controlGroupInputSchema,
|
||||
gridDataSchema,
|
||||
panelGridDataSchema,
|
||||
panelSchema,
|
||||
sectionSchema,
|
||||
dashboardAttributesSchema,
|
||||
dashboardCreateOptionsSchema,
|
||||
dashboardCreateResultSchema,
|
||||
|
@ -43,8 +44,9 @@ export type DashboardPanel = Omit<TypeOf<typeof panelSchema>, 'panelConfig'> & {
|
|||
panelConfig: TypeOf<typeof panelSchema>['panelConfig'] & { [key: string]: any };
|
||||
gridData: GridData;
|
||||
};
|
||||
export type DashboardSection = TypeOf<typeof sectionSchema>;
|
||||
export type DashboardAttributes = Omit<TypeOf<typeof dashboardAttributesSchema>, 'panels'> & {
|
||||
panels: DashboardPanel[];
|
||||
panels: Array<DashboardPanel | DashboardSection>;
|
||||
};
|
||||
|
||||
export type DashboardItem = TypeOf<typeof dashboardItemSchema>;
|
||||
|
@ -54,7 +56,7 @@ export type PartialDashboardItem = Omit<DashboardItem, 'attributes' | 'reference
|
|||
};
|
||||
|
||||
export type ControlGroupAttributes = TypeOf<typeof controlGroupInputSchema>;
|
||||
export type GridData = WithRequiredProperty<TypeOf<typeof gridDataSchema>, 'i'>;
|
||||
export type GridData = WithRequiredProperty<TypeOf<typeof panelGridDataSchema>, 'i'>;
|
||||
|
||||
export type DashboardGetIn = GetIn<typeof CONTENT_ID>;
|
||||
export type DashboardGetOut = TypeOf<typeof dashboardGetResultSchema>;
|
||||
|
|
|
@ -79,6 +79,10 @@ export const createDashboardSavedObjectType = ({
|
|||
},
|
||||
optionsJSON: { type: 'text', index: false },
|
||||
panelsJSON: { type: 'text', index: false },
|
||||
sections: {
|
||||
properties: {},
|
||||
dynamic: false,
|
||||
},
|
||||
refreshInterval: {
|
||||
properties: {
|
||||
display: { type: 'keyword', index: false, doc_values: false },
|
||||
|
|
|
@ -11,4 +11,9 @@ export {
|
|||
createDashboardSavedObjectType,
|
||||
DASHBOARD_SAVED_OBJECT_TYPE,
|
||||
} from './dashboard_saved_object';
|
||||
export type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './schema';
|
||||
export type {
|
||||
DashboardSavedObjectAttributes,
|
||||
GridData,
|
||||
SavedDashboardPanel,
|
||||
SavedDashboardSection,
|
||||
} from './schema';
|
||||
|
|
|
@ -7,5 +7,10 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type { DashboardSavedObjectAttributes, GridData, SavedDashboardPanel } from './latest';
|
||||
export type {
|
||||
DashboardSavedObjectAttributes,
|
||||
GridData,
|
||||
SavedDashboardPanel,
|
||||
SavedDashboardSection,
|
||||
} from './latest';
|
||||
export { dashboardSavedObjectSchema } from './latest';
|
||||
|
|
|
@ -13,4 +13,5 @@ export {
|
|||
type DashboardAttributes as DashboardSavedObjectAttributes,
|
||||
type GridData,
|
||||
type SavedDashboardPanel,
|
||||
type SavedDashboardSection,
|
||||
} from './v2';
|
||||
|
|
|
@ -7,5 +7,10 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export type { DashboardAttributes, GridData, SavedDashboardPanel } from './types';
|
||||
export type {
|
||||
DashboardAttributes,
|
||||
GridData,
|
||||
SavedDashboardPanel,
|
||||
SavedDashboardSection,
|
||||
} from './types';
|
||||
export { controlGroupInputSchema, dashboardAttributesSchema } from './v2';
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { Serializable } from '@kbn/utility-types';
|
||||
import { TypeOf } from '@kbn/config-schema';
|
||||
import { dashboardAttributesSchema, gridDataSchema } from './v2';
|
||||
import { dashboardAttributesSchema, gridDataSchema, sectionSchema } from './v2';
|
||||
|
||||
export type DashboardAttributes = TypeOf<typeof dashboardAttributesSchema>;
|
||||
export type GridData = TypeOf<typeof gridDataSchema>;
|
||||
|
@ -33,3 +33,8 @@ export interface SavedDashboardPanel {
|
|||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved dashboard section parsed directly from the Dashboard Attributes
|
||||
*/
|
||||
export type SavedDashboardSection = TypeOf<typeof sectionSchema>;
|
||||
|
|
|
@ -13,6 +13,26 @@ import {
|
|||
dashboardAttributesSchema as dashboardAttributesSchemaV1,
|
||||
} from '../v1';
|
||||
|
||||
// sections only include y + i for grid data
|
||||
export const sectionGridDataSchema = schema.object({
|
||||
y: schema.number(),
|
||||
i: schema.string(),
|
||||
});
|
||||
|
||||
// panels include all grid data keys, including those that sections use
|
||||
export const gridDataSchema = sectionGridDataSchema.extends({
|
||||
x: schema.number(),
|
||||
w: schema.number(),
|
||||
h: schema.number(),
|
||||
sectionId: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
export const sectionSchema = schema.object({
|
||||
title: schema.string(),
|
||||
collapsed: schema.maybe(schema.boolean()),
|
||||
gridData: sectionGridDataSchema,
|
||||
});
|
||||
|
||||
export const controlGroupInputSchema = controlGroupInputSchemaV1.extends(
|
||||
{
|
||||
showApplySelections: schema.maybe(schema.boolean()),
|
||||
|
@ -23,14 +43,7 @@ export const controlGroupInputSchema = controlGroupInputSchemaV1.extends(
|
|||
export const dashboardAttributesSchema = dashboardAttributesSchemaV1.extends(
|
||||
{
|
||||
controlGroupInput: schema.maybe(controlGroupInputSchema),
|
||||
sections: schema.maybe(schema.arrayOf(sectionSchema)),
|
||||
},
|
||||
{ unknowns: 'ignore' }
|
||||
);
|
||||
|
||||
export const gridDataSchema = schema.object({
|
||||
x: schema.number(),
|
||||
y: schema.number(),
|
||||
w: schema.number(),
|
||||
h: schema.number(),
|
||||
i: schema.string(),
|
||||
});
|
||||
|
|
|
@ -39,7 +39,7 @@ export async function plugin(initializerContext: PluginInitializerContext) {
|
|||
}
|
||||
|
||||
export type { DashboardPluginSetup, DashboardPluginStart } from './types';
|
||||
export type { DashboardAttributes, DashboardPanel } from './content_management';
|
||||
export type { DashboardAttributes, DashboardPanel, DashboardSection } from './content_management';
|
||||
export type { DashboardSavedObjectAttributes } from './dashboard_saved_object';
|
||||
|
||||
export { PUBLIC_API_PATH } from './api/constants';
|
||||
|
|
|
@ -12,6 +12,7 @@ import chroma from 'chroma-js';
|
|||
import rison from '@kbn/rison';
|
||||
import { DEFAULT_PANEL_WIDTH } from '@kbn/dashboard-plugin/common/content_management/constants';
|
||||
import { SharedDashboardState } from '@kbn/dashboard-plugin/common/types';
|
||||
import { DashboardPanel } from '@kbn/dashboard-plugin/server';
|
||||
import { PIE_CHART_VIS_NAME, AREA_CHART_VIS_NAME } from '../../../page_objects/dashboard_page';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
|
@ -231,7 +232,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
(appState: Partial<SharedDashboardState>) => {
|
||||
log.debug(JSON.stringify(appState, null, ' '));
|
||||
return {
|
||||
panels: (appState.panels ?? []).map((panel) => {
|
||||
panels: (appState.panels ?? []).map((widget) => {
|
||||
const panel = widget as DashboardPanel;
|
||||
return {
|
||||
...panel,
|
||||
gridData: {
|
||||
|
@ -306,7 +308,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
currentUrl,
|
||||
(appState: Partial<SharedDashboardState>) => {
|
||||
return {
|
||||
panels: (appState.panels ?? []).map((panel) => {
|
||||
panels: (appState.panels ?? []).map((widget) => {
|
||||
const panel = widget as DashboardPanel;
|
||||
return {
|
||||
...panel,
|
||||
panelConfig: {
|
||||
|
@ -350,7 +353,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
currentUrl,
|
||||
(appState: Partial<SharedDashboardState>) => {
|
||||
return {
|
||||
panels: (appState.panels ?? []).map((panel) => {
|
||||
panels: (appState.panels ?? []).map((widget) => {
|
||||
const panel = widget as DashboardPanel;
|
||||
return {
|
||||
...panel,
|
||||
panelConfig: {
|
||||
|
|
|
@ -70,7 +70,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
]);
|
||||
|
||||
// Any changes to the number of panels needs to be audited by @elastic/kibana-presentation
|
||||
expect(panelTypes.length).to.eql(10);
|
||||
expect(panelTypes.length).to.eql(11);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -5,19 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { SavedObjectsFindResult } from '@kbn/core/server';
|
||||
import { IContentClient } from '@kbn/content-management-plugin/server/types';
|
||||
import type { Logger, SavedObjectsFindResult } from '@kbn/core/server';
|
||||
import { isDashboardSection } from '@kbn/dashboard-plugin/common';
|
||||
import type { DashboardAttributes, DashboardPanel } from '@kbn/dashboard-plugin/server';
|
||||
import type { LensAttributes } from '@kbn/lens-embeddable-utils';
|
||||
import type {
|
||||
FieldBasedIndexPatternColumn,
|
||||
GenericIndexPatternColumn,
|
||||
} from '@kbn/lens-plugin/public';
|
||||
import type { Logger } from '@kbn/core/server';
|
||||
import type { LensAttributes } from '@kbn/lens-embeddable-utils';
|
||||
import type { RelevantPanel, RelatedDashboard } from '@kbn/observability-schema';
|
||||
import type { DashboardAttributes, DashboardPanel } from '@kbn/dashboard-plugin/server';
|
||||
import type { InvestigateAlertsClient } from './investigate_alerts_client';
|
||||
import type { RelatedDashboard, RelevantPanel } from '@kbn/observability-schema';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { AlertData } from './alert_data';
|
||||
import type { InvestigateAlertsClient } from './investigate_alerts_client';
|
||||
|
||||
type Dashboard = SavedObjectsFindResult<DashboardAttributes>;
|
||||
export class RelatedDashboardsClient {
|
||||
|
@ -177,19 +177,21 @@ export class RelatedDashboardsClient {
|
|||
return { dashboards: relevantDashboards };
|
||||
}
|
||||
|
||||
getPanelsByIndex(index: string, panels: DashboardPanel[]): DashboardPanel[] {
|
||||
getPanelsByIndex(index: string, panels: DashboardAttributes['panels']): DashboardPanel[] {
|
||||
const panelsByIndex = panels.filter((p) => {
|
||||
if (isDashboardSection(p)) return false; // filter out sections
|
||||
const panelIndices = this.getPanelIndices(p);
|
||||
return panelIndices.has(index);
|
||||
});
|
||||
}) as DashboardPanel[]; // filtering with type guard doesn't actually limit type, so need to cast
|
||||
return panelsByIndex;
|
||||
}
|
||||
|
||||
getPanelsByField(
|
||||
fields: string[],
|
||||
panels: DashboardPanel[]
|
||||
panels: DashboardAttributes['panels']
|
||||
): Array<{ matchingFields: Set<string>; panel: DashboardPanel }> {
|
||||
const panelsByField = panels.reduce((acc, p) => {
|
||||
if (isDashboardSection(p)) return acc; // filter out sections
|
||||
const panelFields = this.getPanelFields(p);
|
||||
const matchingFields = fields.filter((f) => panelFields.has(f));
|
||||
if (matchingFields.length) {
|
||||
|
|
|
@ -69,7 +69,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
]);
|
||||
|
||||
// Any changes to the number of panels needs to be audited by @elastic/kibana-presentation
|
||||
expect(panelTypes.length).to.eql(20);
|
||||
expect(panelTypes.length).to.eql(21);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue