mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
[kbn-grid-layout] Add ability to create, edit, and delete rows (#209193)
Closes https://github.com/elastic/kibana/issues/204849 ## Summary This PR adds the ability to create, edit, and delete sections / rows to `kbn-grid-layout`: https://github.com/user-attachments/assets/4831b289-2c71-42fb-851d-0925560e233a Note that sections are still statically placed - dragging rows around will be added in a follow-up PR, because it's a larger undertaking. Since this feature is not available to users yet, it is okay to implement this in stages like this. ### 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) ### Identify risks Collapsible sections are not available on Dashboard yet and so there is no user-facing risk to this PR. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com>
This commit is contained in:
parent
4a8928d5d4
commit
e587187ffc
17 changed files with 1004 additions and 218 deletions
|
@ -10,11 +10,22 @@
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||||
import { ADD_PANEL_TRIGGER, UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
import { ADD_PANEL_TRIGGER, UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||||
|
import {
|
||||||
|
PublishingSubject,
|
||||||
|
ViewMode,
|
||||||
|
apiPublishesViewMode,
|
||||||
|
useStateFromPublishingSubject,
|
||||||
|
} from '@kbn/presentation-publishing';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions: UiActionsStart }) {
|
export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions: UiActionsStart }) {
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
const [items, setItems] = useState<ReactElement[]>([]);
|
const [items, setItems] = useState<ReactElement[]>([]);
|
||||||
|
|
||||||
|
const viewMode = useStateFromPublishingSubject(
|
||||||
|
apiPublishesViewMode(pageApi) ? pageApi?.viewMode$ : (of('edit') as PublishingSubject<ViewMode>)
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
@ -59,6 +70,7 @@ export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions:
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPopoverOpen(!isPopoverOpen);
|
setIsPopoverOpen(!isPopoverOpen);
|
||||||
}}
|
}}
|
||||||
|
disabled={viewMode !== 'edit'}
|
||||||
>
|
>
|
||||||
Add panel
|
Add panel
|
||||||
</EuiButton>
|
</EuiButton>
|
||||||
|
|
|
@ -10,20 +10,16 @@
|
||||||
import deepEqual from 'fast-deep-equal';
|
import deepEqual from 'fast-deep-equal';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { combineLatest, debounceTime } from 'rxjs';
|
import { Subject, combineLatest, debounceTime, map, skip, take } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EuiBadge,
|
EuiBadge,
|
||||||
EuiButton,
|
EuiButton,
|
||||||
EuiButtonEmpty,
|
EuiButtonEmpty,
|
||||||
EuiButtonGroup,
|
|
||||||
EuiCallOut,
|
EuiCallOut,
|
||||||
EuiFlexGroup,
|
EuiFlexGroup,
|
||||||
EuiFlexItem,
|
EuiFlexItem,
|
||||||
EuiFormRow,
|
|
||||||
EuiPageTemplate,
|
EuiPageTemplate,
|
||||||
EuiPopover,
|
|
||||||
EuiRange,
|
|
||||||
EuiSpacer,
|
EuiSpacer,
|
||||||
transparentize,
|
transparentize,
|
||||||
useEuiTheme,
|
useEuiTheme,
|
||||||
|
@ -33,12 +29,13 @@ import { AppMountParameters } from '@kbn/core-application-browser';
|
||||||
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
import { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||||
import { AddEmbeddableButton } from '@kbn/embeddable-examples-plugin/public';
|
import { AddEmbeddableButton } from '@kbn/embeddable-examples-plugin/public';
|
||||||
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
|
||||||
import { GridLayout, GridLayoutData } from '@kbn/grid-layout';
|
import { GridLayout, GridLayoutData, GridSettings } from '@kbn/grid-layout';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
|
||||||
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
||||||
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||||
|
|
||||||
|
import { GridLayoutOptions } from './grid_layout_options';
|
||||||
import {
|
import {
|
||||||
clearSerializedDashboardState,
|
clearSerializedDashboardState,
|
||||||
getSerializedDashboardState,
|
getSerializedDashboardState,
|
||||||
|
@ -66,20 +63,24 @@ export const GridExample = ({
|
||||||
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
|
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
|
||||||
dashboardInputToGridLayout(savedState.current)
|
dashboardInputToGridLayout(savedState.current)
|
||||||
);
|
);
|
||||||
const [isSettingsPopoverOpen, setIsSettingsPopoverOpen] = useState(false);
|
const [gridSettings, setGridSettings] = useState<GridSettings>({
|
||||||
const [gutterSize, setGutterSize] = useState<number>(DASHBOARD_MARGIN_SIZE);
|
gutterSize: DASHBOARD_MARGIN_SIZE,
|
||||||
const [rowHeight, setRowHeight] = useState<number>(DASHBOARD_GRID_HEIGHT);
|
rowHeight: DASHBOARD_GRID_HEIGHT,
|
||||||
|
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
|
||||||
|
});
|
||||||
|
|
||||||
const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current });
|
const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current });
|
||||||
const [viewMode, expandedPanelId] = useBatchedPublishingSubjects(
|
const [viewMode, expandedPanelId] = useBatchedPublishingSubjects(
|
||||||
mockDashboardApi.viewMode$,
|
mockDashboardApi.viewMode$,
|
||||||
mockDashboardApi.expandedPanelId$
|
mockDashboardApi.expandedPanelId$
|
||||||
);
|
);
|
||||||
|
const layoutUpdated$ = useMemo(() => new Subject<void>(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$])
|
combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$])
|
||||||
.pipe(debounceTime(0)) // debounce to avoid subscribe being called twice when both panels$ and rows$ publish
|
.pipe(
|
||||||
.subscribe(([panels, rows]) => {
|
debounceTime(0), // debounce to avoid subscribe being called twice when both panels$ and rows$ publish
|
||||||
|
map(([panels, rows]) => {
|
||||||
const panelIds = Object.keys(panels);
|
const panelIds = Object.keys(panels);
|
||||||
let panelsAreEqual = true;
|
let panelsAreEqual = true;
|
||||||
for (const panelId of panelIds) {
|
for (const panelId of panelIds) {
|
||||||
|
@ -91,14 +92,25 @@ export const GridExample = ({
|
||||||
{ row: 0, ...savedPanel.gridData }
|
{ row: 0, ...savedPanel.gridData }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows));
|
const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows));
|
||||||
|
return { hasChanges, updatedLayout: dashboardInputToGridLayout({ panels, rows }) };
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(({ hasChanges, updatedLayout }) => {
|
||||||
setHasUnsavedChanges(hasChanges);
|
setHasUnsavedChanges(hasChanges);
|
||||||
setCurrentLayout(dashboardInputToGridLayout({ panels, rows }));
|
setCurrentLayout(updatedLayout);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On layout update, emit `layoutUpdated$` so that side effects to layout updates can
|
||||||
|
* happen (such as scrolling to the bottom of the screen after adding a new section)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
layoutUpdated$.next();
|
||||||
|
}, [currentLayout, layoutUpdated$]);
|
||||||
|
|
||||||
const renderPanelContents = useCallback(
|
const renderPanelContents = useCallback(
|
||||||
(id: string, setDragHandles?: (refs: Array<HTMLElement | null>) => void) => {
|
(id: string, setDragHandles?: (refs: Array<HTMLElement | null>) => void) => {
|
||||||
const currentPanels = mockDashboardApi.panels$.getValue();
|
const currentPanels = mockDashboardApi.panels$.getValue();
|
||||||
|
@ -122,6 +134,41 @@ export const GridExample = ({
|
||||||
[mockDashboardApi]
|
[mockDashboardApi]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onLayoutChange = useCallback(
|
||||||
|
(newLayout: GridLayoutData) => {
|
||||||
|
const { panels, rows } = gridLayoutToDashboardPanelMap(
|
||||||
|
mockDashboardApi.panels$.getValue(),
|
||||||
|
newLayout
|
||||||
|
);
|
||||||
|
mockDashboardApi.panels$.next(panels);
|
||||||
|
mockDashboardApi.rows$.next(rows);
|
||||||
|
},
|
||||||
|
[mockDashboardApi.panels$, mockDashboardApi.rows$]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNewSection = useCallback(() => {
|
||||||
|
mockDashboardApi.rows$.next([
|
||||||
|
...mockDashboardApi.rows$.getValue(),
|
||||||
|
{
|
||||||
|
title: i18n.translate('examples.gridExample.defaultSectionTitle', {
|
||||||
|
defaultMessage: 'New collapsible section',
|
||||||
|
}),
|
||||||
|
collapsed: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// scroll to bottom after row is added
|
||||||
|
layoutUpdated$.pipe(skip(1), take(1)).subscribe(() => {
|
||||||
|
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}, [mockDashboardApi.rows$, layoutUpdated$]);
|
||||||
|
|
||||||
|
const resetUnsavedChanges = useCallback(() => {
|
||||||
|
const { panels, rows } = savedState.current;
|
||||||
|
mockDashboardApi.panels$.next(panels);
|
||||||
|
mockDashboardApi.rows$.next(rows);
|
||||||
|
}, [mockDashboardApi.panels$, mockDashboardApi.rows$]);
|
||||||
|
|
||||||
const customLayoutCss = useMemo(() => {
|
const customLayoutCss = useMemo(() => {
|
||||||
const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2);
|
const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2);
|
||||||
return css`
|
return css`
|
||||||
|
@ -196,89 +243,22 @@ export const GridExample = ({
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
<AddEmbeddableButton pageApi={mockDashboardApi} uiActions={uiActions} />{' '}
|
<AddEmbeddableButton pageApi={mockDashboardApi} uiActions={uiActions} />
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
<EuiPopover
|
<EuiButton onClick={addNewSection} disabled={viewMode !== 'edit'}>
|
||||||
button={
|
{i18n.translate('examples.gridExample.addRowButton', {
|
||||||
<EuiButton
|
defaultMessage: 'Add collapsible section',
|
||||||
iconType="arrowDown"
|
|
||||||
iconSide="right"
|
|
||||||
onClick={() => setIsSettingsPopoverOpen(!isSettingsPopoverOpen)}
|
|
||||||
>
|
|
||||||
{i18n.translate('examples.gridExample.settingsPopover.title', {
|
|
||||||
defaultMessage: 'Layout settings',
|
|
||||||
})}
|
})}
|
||||||
</EuiButton>
|
</EuiButton>
|
||||||
}
|
</EuiFlexItem>
|
||||||
isOpen={isSettingsPopoverOpen}
|
<EuiFlexItem grow={false}>
|
||||||
closePopover={() => setIsSettingsPopoverOpen(false)}
|
<GridLayoutOptions
|
||||||
>
|
mockDashboardApi={mockDashboardApi}
|
||||||
<>
|
gridSettings={gridSettings}
|
||||||
<EuiFormRow
|
setGridSettings={setGridSettings}
|
||||||
label={i18n.translate('examples.gridExample.settingsPopover.viewMode', {
|
viewMode={viewMode}
|
||||||
defaultMessage: 'View mode',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<EuiButtonGroup
|
|
||||||
legend={i18n.translate('examples.gridExample.layoutOptionsLegend', {
|
|
||||||
defaultMessage: 'Layout options',
|
|
||||||
})}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
id: 'view',
|
|
||||||
label: i18n.translate('examples.gridExample.viewOption', {
|
|
||||||
defaultMessage: 'View',
|
|
||||||
}),
|
|
||||||
toolTipContent:
|
|
||||||
'The layout adjusts when the window is resized. Panel interactivity, such as moving and resizing within the grid, is disabled.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edit',
|
|
||||||
label: i18n.translate('examples.gridExample.editOption', {
|
|
||||||
defaultMessage: 'Edit',
|
|
||||||
}),
|
|
||||||
toolTipContent:
|
|
||||||
'The layout does not adjust when the window is resized.',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
idSelected={viewMode}
|
|
||||||
onChange={(id) => {
|
|
||||||
mockDashboardApi.viewMode$.next(id);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</EuiFormRow>
|
|
||||||
<EuiFormRow
|
|
||||||
label={i18n.translate('examples.gridExample.settingsPopover.gutterSize', {
|
|
||||||
defaultMessage: 'Gutter size',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<EuiRange
|
|
||||||
min={1}
|
|
||||||
max={30}
|
|
||||||
value={gutterSize}
|
|
||||||
onChange={(e) => setGutterSize(parseInt(e.currentTarget.value, 10))}
|
|
||||||
showLabels
|
|
||||||
showValue
|
|
||||||
/>
|
|
||||||
</EuiFormRow>
|
|
||||||
<EuiFormRow
|
|
||||||
label={i18n.translate('examples.gridExample.settingsPopover.rowHeight', {
|
|
||||||
defaultMessage: 'Row height',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<EuiRange
|
|
||||||
min={5}
|
|
||||||
max={30}
|
|
||||||
step={5}
|
|
||||||
value={rowHeight}
|
|
||||||
onChange={(e) => setRowHeight(parseInt(e.currentTarget.value, 10))}
|
|
||||||
showLabels
|
|
||||||
showValue
|
|
||||||
/>
|
|
||||||
</EuiFormRow>
|
|
||||||
</>
|
|
||||||
</EuiPopover>
|
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
|
@ -294,13 +274,7 @@ export const GridExample = ({
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
)}
|
)}
|
||||||
<EuiFlexItem grow={false}>
|
<EuiFlexItem grow={false}>
|
||||||
<EuiButtonEmpty
|
<EuiButtonEmpty onClick={resetUnsavedChanges}>
|
||||||
onClick={() => {
|
|
||||||
const { panels, rows } = savedState.current;
|
|
||||||
mockDashboardApi.panels$.next(panels);
|
|
||||||
mockDashboardApi.rows$.next(rows);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{i18n.translate('examples.gridExample.resetLayoutButton', {
|
{i18n.translate('examples.gridExample.resetLayoutButton', {
|
||||||
defaultMessage: 'Reset',
|
defaultMessage: 'Reset',
|
||||||
})}
|
})}
|
||||||
|
@ -332,21 +306,10 @@ export const GridExample = ({
|
||||||
accessMode={viewMode === 'view' ? 'VIEW' : 'EDIT'}
|
accessMode={viewMode === 'view' ? 'VIEW' : 'EDIT'}
|
||||||
expandedPanelId={expandedPanelId}
|
expandedPanelId={expandedPanelId}
|
||||||
layout={currentLayout}
|
layout={currentLayout}
|
||||||
gridSettings={{
|
gridSettings={gridSettings}
|
||||||
gutterSize,
|
|
||||||
rowHeight,
|
|
||||||
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
|
|
||||||
}}
|
|
||||||
useCustomDragHandle={true}
|
useCustomDragHandle={true}
|
||||||
renderPanelContents={renderPanelContents}
|
renderPanelContents={renderPanelContents}
|
||||||
onLayoutChange={(newLayout) => {
|
onLayoutChange={onLayoutChange}
|
||||||
const { panels, rows } = gridLayoutToDashboardPanelMap(
|
|
||||||
mockDashboardApi.panels$.getValue(),
|
|
||||||
newLayout
|
|
||||||
);
|
|
||||||
mockDashboardApi.panels$.next(panels);
|
|
||||||
mockDashboardApi.rows$.next(rows);
|
|
||||||
}}
|
|
||||||
css={customLayoutCss}
|
css={customLayoutCss}
|
||||||
/>
|
/>
|
||||||
</EuiPageTemplate.Section>
|
</EuiPageTemplate.Section>
|
||||||
|
|
116
examples/grid_example/public/grid_layout_options.tsx
Normal file
116
examples/grid_example/public/grid_layout_options.tsx
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
/*
|
||||||
|
* 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 React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { EuiButton, EuiButtonGroup, EuiFormRow, EuiPopover, EuiRange } from '@elastic/eui';
|
||||||
|
import { GridSettings } from '@kbn/grid-layout';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import { ViewMode } from '@kbn/presentation-publishing';
|
||||||
|
import { MockDashboardApi } from './types';
|
||||||
|
|
||||||
|
export const GridLayoutOptions = ({
|
||||||
|
viewMode,
|
||||||
|
mockDashboardApi,
|
||||||
|
gridSettings,
|
||||||
|
setGridSettings,
|
||||||
|
}: {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
mockDashboardApi: MockDashboardApi;
|
||||||
|
gridSettings: GridSettings;
|
||||||
|
setGridSettings: (settings: GridSettings) => void;
|
||||||
|
}) => {
|
||||||
|
const [isSettingsPopoverOpen, setIsSettingsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiPopover
|
||||||
|
button={
|
||||||
|
<EuiButton
|
||||||
|
iconType="arrowDown"
|
||||||
|
iconSide="right"
|
||||||
|
onClick={() => setIsSettingsPopoverOpen(!isSettingsPopoverOpen)}
|
||||||
|
>
|
||||||
|
{i18n.translate('examples.gridExample.settingsPopover.title', {
|
||||||
|
defaultMessage: 'Layout settings',
|
||||||
|
})}
|
||||||
|
</EuiButton>
|
||||||
|
}
|
||||||
|
isOpen={isSettingsPopoverOpen}
|
||||||
|
closePopover={() => setIsSettingsPopoverOpen(false)}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<EuiFormRow
|
||||||
|
label={i18n.translate('examples.gridExample.settingsPopover.viewMode', {
|
||||||
|
defaultMessage: 'View mode',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<EuiButtonGroup
|
||||||
|
legend={i18n.translate('examples.gridExample.layoutOptionsLegend', {
|
||||||
|
defaultMessage: 'Layout options',
|
||||||
|
})}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
id: 'view',
|
||||||
|
label: i18n.translate('examples.gridExample.viewOption', {
|
||||||
|
defaultMessage: 'View',
|
||||||
|
}),
|
||||||
|
toolTipContent:
|
||||||
|
'The layout adjusts when the window is resized. Panel interactivity, such as moving and resizing within the grid, is disabled.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
label: i18n.translate('examples.gridExample.editOption', {
|
||||||
|
defaultMessage: 'Edit',
|
||||||
|
}),
|
||||||
|
toolTipContent: 'The layout does not adjust when the window is resized.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
idSelected={viewMode}
|
||||||
|
onChange={(id) => {
|
||||||
|
mockDashboardApi.setViewMode(id as ViewMode);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
<EuiFormRow
|
||||||
|
label={i18n.translate('examples.gridExample.settingsPopover.gutterSize', {
|
||||||
|
defaultMessage: 'Gutter size',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<EuiRange
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
value={gridSettings.gutterSize}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGridSettings({ ...gridSettings, gutterSize: parseInt(e.currentTarget.value, 10) })
|
||||||
|
}
|
||||||
|
showLabels
|
||||||
|
showValue
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
<EuiFormRow
|
||||||
|
label={i18n.translate('examples.gridExample.settingsPopover.rowHeight', {
|
||||||
|
defaultMessage: 'Row height',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<EuiRange
|
||||||
|
min={5}
|
||||||
|
max={30}
|
||||||
|
step={5}
|
||||||
|
value={gridSettings.rowHeight}
|
||||||
|
onChange={(e) =>
|
||||||
|
setGridSettings({ ...gridSettings, rowHeight: parseInt(e.currentTarget.value, 10) })
|
||||||
|
}
|
||||||
|
showLabels
|
||||||
|
showValue
|
||||||
|
/>
|
||||||
|
</EuiFormRow>
|
||||||
|
</>
|
||||||
|
</EuiPopover>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,6 +7,15 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CanAddNewPanel,
|
||||||
|
CanExpandPanels,
|
||||||
|
HasSerializedChildState,
|
||||||
|
PresentationContainer,
|
||||||
|
} from '@kbn/presentation-containers';
|
||||||
|
import { PublishesWritableViewMode } from '@kbn/presentation-publishing';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
export interface DashboardGridData {
|
export interface DashboardGridData {
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
|
@ -32,3 +41,12 @@ export interface MockSerializedDashboardState {
|
||||||
panels: MockedDashboardPanelMap;
|
panels: MockedDashboardPanelMap;
|
||||||
rows: MockedDashboardRowMap;
|
rows: MockedDashboardRowMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MockDashboardApi = PresentationContainer &
|
||||||
|
CanAddNewPanel &
|
||||||
|
HasSerializedChildState &
|
||||||
|
PublishesWritableViewMode &
|
||||||
|
CanExpandPanels & {
|
||||||
|
panels$: BehaviorSubject<MockedDashboardPanelMap>;
|
||||||
|
rows$: BehaviorSubject<MockedDashboardRowMap>;
|
||||||
|
};
|
||||||
|
|
|
@ -15,7 +15,9 @@ import { v4 } from 'uuid';
|
||||||
import { TimeRange } from '@kbn/es-query';
|
import { TimeRange } from '@kbn/es-query';
|
||||||
import { PanelPackage } from '@kbn/presentation-containers';
|
import { PanelPackage } from '@kbn/presentation-containers';
|
||||||
|
|
||||||
|
import { ViewMode } from '@kbn/presentation-publishing';
|
||||||
import {
|
import {
|
||||||
|
MockDashboardApi,
|
||||||
MockSerializedDashboardState,
|
MockSerializedDashboardState,
|
||||||
MockedDashboardPanelMap,
|
MockedDashboardPanelMap,
|
||||||
MockedDashboardRowMap,
|
MockedDashboardRowMap,
|
||||||
|
@ -29,10 +31,11 @@ export const useMockDashboardApi = ({
|
||||||
savedState,
|
savedState,
|
||||||
}: {
|
}: {
|
||||||
savedState: MockSerializedDashboardState;
|
savedState: MockSerializedDashboardState;
|
||||||
}) => {
|
}): MockDashboardApi => {
|
||||||
const mockDashboardApi = useMemo(() => {
|
const mockDashboardApi = useMemo(() => {
|
||||||
const panels$ = new BehaviorSubject<MockedDashboardPanelMap>(savedState.panels);
|
const panels$ = new BehaviorSubject<MockedDashboardPanelMap>(savedState.panels);
|
||||||
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
|
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
|
||||||
|
const viewMode$ = new BehaviorSubject<ViewMode>('edit');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getSerializedStateForChild: (id: string) => {
|
getSerializedStateForChild: (id: string) => {
|
||||||
|
@ -48,8 +51,12 @@ export const useMockDashboardApi = ({
|
||||||
}),
|
}),
|
||||||
filters$: new BehaviorSubject([]),
|
filters$: new BehaviorSubject([]),
|
||||||
query$: new BehaviorSubject(''),
|
query$: new BehaviorSubject(''),
|
||||||
viewMode$: new BehaviorSubject('edit'),
|
viewMode$,
|
||||||
|
setViewMode: (viewMode: ViewMode) => viewMode$.next(viewMode),
|
||||||
panels$,
|
panels$,
|
||||||
|
getPanelCount: () => {
|
||||||
|
return Object.keys(panels$.getValue()).length;
|
||||||
|
},
|
||||||
rows$: new BehaviorSubject<MockedDashboardRowMap>(savedState.rows),
|
rows$: new BehaviorSubject<MockedDashboardRowMap>(savedState.rows),
|
||||||
expandedPanelId$,
|
expandedPanelId$,
|
||||||
expandPanel: (id: string) => {
|
expandPanel: (id: string) => {
|
||||||
|
@ -64,7 +71,7 @@ export const useMockDashboardApi = ({
|
||||||
delete panels[id]; // the grid layout component will handle compacting, if necessary
|
delete panels[id]; // the grid layout component will handle compacting, if necessary
|
||||||
mockDashboardApi.panels$.next(panels);
|
mockDashboardApi.panels$.next(panels);
|
||||||
},
|
},
|
||||||
replacePanel: (id: string, newPanel: PanelPackage) => {
|
replacePanel: async (id: string, newPanel: PanelPackage): Promise<string> => {
|
||||||
const currentPanels = mockDashboardApi.panels$.getValue();
|
const currentPanels = mockDashboardApi.panels$.getValue();
|
||||||
const otherPanels = { ...currentPanels };
|
const otherPanels = { ...currentPanels };
|
||||||
const oldPanel = currentPanels[id];
|
const oldPanel = currentPanels[id];
|
||||||
|
@ -75,8 +82,9 @@ export const useMockDashboardApi = ({
|
||||||
explicitInput: { ...newPanel.initialState, id: newId },
|
explicitInput: { ...newPanel.initialState, id: newId },
|
||||||
};
|
};
|
||||||
mockDashboardApi.panels$.next(otherPanels);
|
mockDashboardApi.panels$.next(otherPanels);
|
||||||
|
return newId;
|
||||||
},
|
},
|
||||||
addNewPanel: async (panelPackage: PanelPackage) => {
|
addNewPanel: async (panelPackage: PanelPackage): Promise<undefined> => {
|
||||||
// we are only implementing "place at top" here, for demo purposes
|
// we are only implementing "place at top" here, for demo purposes
|
||||||
const currentPanels = mockDashboardApi.panels$.getValue();
|
const currentPanels = mockDashboardApi.panels$.getValue();
|
||||||
const otherPanels = { ...currentPanels };
|
const otherPanels = { ...currentPanels };
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
touchMoveTo,
|
touchMoveTo,
|
||||||
touchStart,
|
touchStart,
|
||||||
} from './test_utils/events';
|
} from './test_utils/events';
|
||||||
|
import { EuiThemeProvider } from '@elastic/eui';
|
||||||
|
|
||||||
const onLayoutChange = jest.fn();
|
const onLayoutChange = jest.fn();
|
||||||
|
|
||||||
|
@ -34,7 +35,7 @@ const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
|
||||||
...propsOverrides,
|
...propsOverrides,
|
||||||
} as GridLayoutProps;
|
} as GridLayoutProps;
|
||||||
|
|
||||||
const { rerender, ...rtlRest } = render(<GridLayout {...props} />);
|
const { rerender, ...rtlRest } = render(<GridLayout {...props} />, { wrapper: EuiThemeProvider });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...rtlRest,
|
...rtlRest,
|
||||||
|
|
|
@ -198,6 +198,9 @@ const styles = {
|
||||||
'& .kbnGridRowContainer:has(.kbnGridPanel--expanded)': {
|
'& .kbnGridRowContainer:has(.kbnGridPanel--expanded)': {
|
||||||
'.kbnGridRowHeader': {
|
'.kbnGridRowHeader': {
|
||||||
height: '0px', // used instead of 'display: none' due to a11y concerns
|
height: '0px', // used instead of 'display: none' due to a11y concerns
|
||||||
|
padding: '0px',
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
'.kbnGridRow': {
|
'.kbnGridRow': {
|
||||||
display: 'block !important', // overwrite grid display
|
display: 'block !important', // overwrite grid display
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { GridPanel, type GridPanelProps } from './grid_panel';
|
import { GridPanel, type GridPanelProps } from './grid_panel';
|
||||||
import { gridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
|
import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
|
||||||
import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context';
|
import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context';
|
||||||
|
|
||||||
describe('GridPanel', () => {
|
describe('GridPanel', () => {
|
||||||
|
@ -20,7 +20,7 @@ describe('GridPanel', () => {
|
||||||
}) => {
|
}) => {
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
renderPanelContents: mockRenderPanelContents,
|
renderPanelContents: mockRenderPanelContents,
|
||||||
gridLayoutStateManager: gridLayoutStateManagerMock,
|
gridLayoutStateManager: getGridLayoutStateManagerMock(),
|
||||||
...(overrides?.contextOverrides ?? {}),
|
...(overrides?.contextOverrides ?? {}),
|
||||||
} as GridLayoutContextType;
|
} as GridLayoutContextType;
|
||||||
const panelProps = {
|
const panelProps = {
|
||||||
|
|
|
@ -0,0 +1,102 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the "Elastic License
|
||||||
|
* 2.0", 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 React from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EuiButton,
|
||||||
|
EuiButtonEmpty,
|
||||||
|
EuiModal,
|
||||||
|
EuiModalBody,
|
||||||
|
EuiModalFooter,
|
||||||
|
EuiModalHeader,
|
||||||
|
EuiModalHeaderTitle,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
|
||||||
|
import { deleteRow, movePanelsToRow } from '../utils/row_management';
|
||||||
|
import { useGridLayoutContext } from '../use_grid_layout_context';
|
||||||
|
|
||||||
|
export const DeleteGridRowModal = ({
|
||||||
|
rowIndex,
|
||||||
|
setDeleteModalVisible,
|
||||||
|
}: {
|
||||||
|
rowIndex: number;
|
||||||
|
setDeleteModalVisible: (visible: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiModal
|
||||||
|
onClose={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EuiModalHeader>
|
||||||
|
<EuiModalHeaderTitle>
|
||||||
|
{i18n.translate('kbnGridLayout.deleteGridRowModal.title', {
|
||||||
|
defaultMessage: 'Delete section',
|
||||||
|
})}
|
||||||
|
</EuiModalHeaderTitle>
|
||||||
|
</EuiModalHeader>
|
||||||
|
<EuiModalBody>
|
||||||
|
{i18n.translate('kbnGridLayout.deleteGridRowModal.body', {
|
||||||
|
defaultMessage:
|
||||||
|
'Choose to remove the section, including its contents, or only the section.',
|
||||||
|
})}
|
||||||
|
</EuiModalBody>
|
||||||
|
<EuiModalFooter>
|
||||||
|
<EuiButtonEmpty
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n.translate('kbnGridLayout.deleteGridRowModal.cancelButton', {
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
})}
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
<EuiButton
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
let newLayout = movePanelsToRow(
|
||||||
|
gridLayoutStateManager.gridLayout$.getValue(),
|
||||||
|
rowIndex,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
newLayout = deleteRow(newLayout, rowIndex);
|
||||||
|
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||||
|
}}
|
||||||
|
color="danger"
|
||||||
|
>
|
||||||
|
{i18n.translate('kbnGridLayout.deleteGridRowModal.confirmDeleteSection', {
|
||||||
|
defaultMessage: 'Delete section only',
|
||||||
|
})}
|
||||||
|
</EuiButton>
|
||||||
|
<EuiButton
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowIndex);
|
||||||
|
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||||
|
}}
|
||||||
|
fill
|
||||||
|
color="danger"
|
||||||
|
>
|
||||||
|
{i18n.translate('kbnGridLayout.deleteGridRowModal.confirmDeleteAllPanels', {
|
||||||
|
defaultMessage:
|
||||||
|
'Delete section and {panelCount} {panelCount, plural, one {panel} other {panels}}',
|
||||||
|
values: {
|
||||||
|
panelCount: Object.keys(
|
||||||
|
gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels
|
||||||
|
).length,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</EuiButton>
|
||||||
|
</EuiModalFooter>
|
||||||
|
</EuiModal>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7,12 +7,15 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { EuiThemeProvider } from '@elastic/eui';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { GridRow, type GridRowProps } from './grid_row';
|
|
||||||
import { gridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
|
import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
|
||||||
import { getSampleLayout } from '../test_utils/sample_layout';
|
import { getSampleLayout } from '../test_utils/sample_layout';
|
||||||
import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context';
|
import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context';
|
||||||
|
import { GridRow, GridRowProps } from './grid_row';
|
||||||
|
|
||||||
describe('GridRow', () => {
|
describe('GridRow', () => {
|
||||||
const renderGridRow = (
|
const renderGridRow = (
|
||||||
|
@ -24,13 +27,14 @@ describe('GridRow', () => {
|
||||||
value={
|
value={
|
||||||
{
|
{
|
||||||
renderPanelContents: mockRenderPanelContents,
|
renderPanelContents: mockRenderPanelContents,
|
||||||
gridLayoutStateManager: gridLayoutStateManagerMock,
|
gridLayoutStateManager: getGridLayoutStateManagerMock(),
|
||||||
...contextOverrides,
|
...contextOverrides,
|
||||||
} as GridLayoutContextType
|
} as GridLayoutContextType
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<GridRow rowIndex={0} {...propsOverrides} />
|
<GridRow rowIndex={0} {...propsOverrides} />
|
||||||
</GridLayoutContext.Provider>
|
</GridLayoutContext.Provider>,
|
||||||
|
{ wrapper: EuiThemeProvider }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -45,11 +49,13 @@ describe('GridRow', () => {
|
||||||
it('does not show the panels in a row that is collapsed', async () => {
|
it('does not show the panels in a row that is collapsed', async () => {
|
||||||
renderGridRow({ rowIndex: 1 });
|
renderGridRow({ rowIndex: 1 });
|
||||||
|
|
||||||
|
expect(screen.getByTestId('kbnGridRowTitle-1').ariaExpanded).toBe('true');
|
||||||
expect(screen.getAllByText(/panel content/)).toHaveLength(1);
|
expect(screen.getAllByText(/panel content/)).toHaveLength(1);
|
||||||
|
|
||||||
const collapseButton = screen.getByRole('button', { name: /toggle collapse/i });
|
const collapseButton = screen.getByRole('button', { name: /toggle collapse/i });
|
||||||
await userEvent.click(collapseButton);
|
await userEvent.click(collapseButton);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('kbnGridRowTitle-1').ariaExpanded).toBe('false');
|
||||||
expect(screen.queryAllByText(/panel content/)).toHaveLength(0);
|
expect(screen.queryAllByText(/panel content/)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,9 +7,10 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { combineLatest, distinctUntilChanged, map, pairwise, skip } from 'rxjs';
|
import { combineLatest, map, pairwise, skip } from 'rxjs';
|
||||||
|
|
||||||
import { css } from '@emotion/react';
|
import { css } from '@emotion/react';
|
||||||
|
|
||||||
|
@ -25,13 +26,12 @@ export interface GridRowProps {
|
||||||
|
|
||||||
export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
||||||
const { gridLayoutStateManager } = useGridLayoutContext();
|
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||||
|
const collapseButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
|
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
|
||||||
|
|
||||||
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
|
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
|
||||||
getKeysInOrder(currentRow.panels)
|
getKeysInOrder(currentRow.panels)
|
||||||
);
|
);
|
||||||
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
|
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
@ -42,7 +42,6 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
||||||
.subscribe((interactionEvent) => {
|
.subscribe((interactionEvent) => {
|
||||||
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
|
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
|
||||||
if (!rowRef) return;
|
if (!rowRef) return;
|
||||||
|
|
||||||
const targetRow = interactionEvent?.targetRowIndex;
|
const targetRow = interactionEvent?.targetRowIndex;
|
||||||
if (rowIndex === targetRow && interactionEvent) {
|
if (rowIndex === targetRow && interactionEvent) {
|
||||||
rowRef.classList.add('kbnGridRow--targeted');
|
rowRef.classList.add('kbnGridRow--targeted');
|
||||||
|
@ -53,7 +52,6 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This subscription ensures that the row will re-render when one of the following changes:
|
* This subscription ensures that the row will re-render when one of the following changes:
|
||||||
* - Title
|
|
||||||
* - Collapsed state
|
* - Collapsed state
|
||||||
* - Panel IDs (adding/removing/replacing, but not reordering)
|
* - Panel IDs (adding/removing/replacing, but not reordering)
|
||||||
*/
|
*/
|
||||||
|
@ -65,17 +63,16 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
||||||
map(([proposedGridLayout, gridLayout]) => {
|
map(([proposedGridLayout, gridLayout]) => {
|
||||||
const displayedGridLayout = proposedGridLayout ?? gridLayout;
|
const displayedGridLayout = proposedGridLayout ?? gridLayout;
|
||||||
return {
|
return {
|
||||||
title: displayedGridLayout[rowIndex].title,
|
isCollapsed: displayedGridLayout[rowIndex]?.isCollapsed ?? false,
|
||||||
isCollapsed: displayedGridLayout[rowIndex].isCollapsed,
|
panelIds: Object.keys(displayedGridLayout[rowIndex]?.panels ?? {}),
|
||||||
panelIds: Object.keys(displayedGridLayout[rowIndex].panels),
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
pairwise()
|
pairwise()
|
||||||
)
|
)
|
||||||
.subscribe(([oldRowData, newRowData]) => {
|
.subscribe(([oldRowData, newRowData]) => {
|
||||||
if (oldRowData.title !== newRowData.title) setRowTitle(newRowData.title);
|
if (oldRowData.isCollapsed !== newRowData.isCollapsed) {
|
||||||
if (oldRowData.isCollapsed !== newRowData.isCollapsed)
|
|
||||||
setIsCollapsed(newRowData.isCollapsed);
|
setIsCollapsed(newRowData.isCollapsed);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
oldRowData.panelIds.length !== newRowData.panelIds.length ||
|
oldRowData.panelIds.length !== newRowData.panelIds.length ||
|
||||||
!(
|
!(
|
||||||
|
@ -86,7 +83,7 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
||||||
setPanelIdsInOrder(
|
setPanelIdsInOrder(
|
||||||
getKeysInOrder(
|
getKeysInOrder(
|
||||||
(gridLayoutStateManager.proposedGridLayout$.getValue() ??
|
(gridLayoutStateManager.proposedGridLayout$.getValue() ??
|
||||||
gridLayoutStateManager.gridLayout$.getValue())[rowIndex].panels
|
gridLayoutStateManager.gridLayout$.getValue())[rowIndex]?.panels ?? {}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -98,54 +95,62 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
||||||
* reasons (screen readers and focus management).
|
* reasons (screen readers and focus management).
|
||||||
*/
|
*/
|
||||||
const gridLayoutSubscription = gridLayoutStateManager.gridLayout$.subscribe((gridLayout) => {
|
const gridLayoutSubscription = gridLayoutStateManager.gridLayout$.subscribe((gridLayout) => {
|
||||||
|
if (!gridLayout[rowIndex]) return;
|
||||||
const newPanelIdsInOrder = getKeysInOrder(gridLayout[rowIndex].panels);
|
const newPanelIdsInOrder = getKeysInOrder(gridLayout[rowIndex].panels);
|
||||||
if (panelIdsInOrder.join() !== newPanelIdsInOrder.join()) {
|
if (panelIdsInOrder.join() !== newPanelIdsInOrder.join()) {
|
||||||
setPanelIdsInOrder(newPanelIdsInOrder);
|
setPanelIdsInOrder(newPanelIdsInOrder);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const columnCountSubscription = gridLayoutStateManager.runtimeSettings$
|
|
||||||
.pipe(
|
|
||||||
map(({ columnCount }) => columnCount),
|
|
||||||
distinctUntilChanged()
|
|
||||||
)
|
|
||||||
.subscribe((columnCount) => {
|
|
||||||
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
|
|
||||||
if (!rowRef) return;
|
|
||||||
rowRef.style.setProperty('--kbnGridRowColumnCount', `${columnCount}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
interactionStyleSubscription.unsubscribe();
|
interactionStyleSubscription.unsubscribe();
|
||||||
gridLayoutSubscription.unsubscribe();
|
gridLayoutSubscription.unsubscribe();
|
||||||
rowStateSubscription.unsubscribe();
|
rowStateSubscription.unsubscribe();
|
||||||
columnCountSubscription.unsubscribe();
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[rowIndex]
|
[rowIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const toggleIsCollapsed = useCallback(() => {
|
||||||
<div css={styles.fullHeight} className="kbnGridRowContainer">
|
|
||||||
{rowIndex !== 0 && (
|
|
||||||
<GridRowHeader
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
toggleIsCollapsed={() => {
|
|
||||||
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
|
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
|
||||||
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
|
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
|
||||||
gridLayoutStateManager.gridLayout$.next(newLayout);
|
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||||
}}
|
}, [rowIndex, gridLayoutStateManager.gridLayout$]);
|
||||||
rowTitle={rowTitle}
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* Set `aria-expanded` without passing the expanded state as a prop to `GridRowHeader` in order
|
||||||
|
* to prevent `GridRowHeader` from rerendering when this state changes
|
||||||
|
*/
|
||||||
|
if (!collapseButtonRef.current) return;
|
||||||
|
collapseButtonRef.current.ariaExpanded = `${!isCollapsed}`;
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
css={styles.fullHeight}
|
||||||
|
className={classNames('kbnGridRowContainer', {
|
||||||
|
'kbnGridRowContainer--collapsed': isCollapsed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{rowIndex !== 0 && (
|
||||||
|
<GridRowHeader
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
toggleIsCollapsed={toggleIsCollapsed}
|
||||||
|
collapseButtonRef={collapseButtonRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div
|
<div
|
||||||
|
id={`kbnGridRow-${rowIndex}`}
|
||||||
className={'kbnGridRow'}
|
className={'kbnGridRow'}
|
||||||
ref={(element: HTMLDivElement | null) =>
|
ref={(element: HTMLDivElement | null) =>
|
||||||
(gridLayoutStateManager.rowRefs.current[rowIndex] = element)
|
(gridLayoutStateManager.rowRefs.current[rowIndex] = element)
|
||||||
}
|
}
|
||||||
css={[styles.fullHeight, styles.grid]}
|
css={[styles.fullHeight, styles.grid]}
|
||||||
|
role="region"
|
||||||
|
aria-labelledby={`kbnGridRowTile-${rowIndex}`}
|
||||||
>
|
>
|
||||||
{/* render the panels **in order** for accessibility, using the memoized panel components */}
|
{/* render the panels **in order** for accessibility, using the memoized panel components */}
|
||||||
{panelIdsInOrder.map((panelId) => (
|
{panelIdsInOrder.map((panelId) => (
|
||||||
|
@ -169,10 +174,10 @@ const styles = {
|
||||||
gap: 'calc(var(--kbnGridGutterSize) * 1px)',
|
gap: 'calc(var(--kbnGridGutterSize) * 1px)',
|
||||||
gridAutoRows: 'calc(var(--kbnGridRowHeight) * 1px)',
|
gridAutoRows: 'calc(var(--kbnGridRowHeight) * 1px)',
|
||||||
gridTemplateColumns: `repeat(
|
gridTemplateColumns: `repeat(
|
||||||
var(--kbnGridRowColumnCount),
|
var(--kbnGridColumnCount),
|
||||||
calc(
|
calc(
|
||||||
(100% - (var(--kbnGridGutterSize) * (var(--kbnGridRowColumnCount) - 1) * 1px)) /
|
(100% - (var(--kbnGridGutterSize) * (var(--kbnGridColumnCount) - 1) * 1px)) /
|
||||||
var(--kbnGridRowColumnCount)
|
var(--kbnGridColumnCount)
|
||||||
)
|
)
|
||||||
)`,
|
)`,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
/*
|
||||||
|
* 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 { cloneDeep } from 'lodash';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { EuiThemeProvider } from '@elastic/eui';
|
||||||
|
import { RenderResult, act, render, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
|
||||||
|
import { GridLayoutStateManager } from '../types';
|
||||||
|
import { GridRowHeader, GridRowHeaderProps } from './grid_row_header';
|
||||||
|
import { GridLayoutContext, GridLayoutContextType } from '../use_grid_layout_context';
|
||||||
|
|
||||||
|
const toggleIsCollapsed = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((rowIndex: number, gridLayoutStateManager: GridLayoutStateManager) => {
|
||||||
|
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
|
||||||
|
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
|
||||||
|
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GridRowHeader', () => {
|
||||||
|
const renderGridRowHeader = (
|
||||||
|
propsOverrides: Partial<GridRowHeaderProps> = {},
|
||||||
|
contextOverrides: Partial<GridLayoutContextType> = {}
|
||||||
|
) => {
|
||||||
|
const stateManagerMock = getGridLayoutStateManagerMock();
|
||||||
|
return {
|
||||||
|
component: render(
|
||||||
|
<GridLayoutContext.Provider
|
||||||
|
value={
|
||||||
|
{
|
||||||
|
renderPanelContents: mockRenderPanelContents,
|
||||||
|
gridLayoutStateManager: stateManagerMock,
|
||||||
|
...contextOverrides,
|
||||||
|
} as GridLayoutContextType
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GridRowHeader
|
||||||
|
rowIndex={0}
|
||||||
|
toggleIsCollapsed={() => toggleIsCollapsed(0, stateManagerMock)}
|
||||||
|
collapseButtonRef={React.createRef()}
|
||||||
|
{...propsOverrides}
|
||||||
|
/>
|
||||||
|
</GridLayoutContext.Provider>,
|
||||||
|
{ wrapper: EuiThemeProvider }
|
||||||
|
),
|
||||||
|
gridLayoutStateManager: stateManagerMock,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
toggleIsCollapsed.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the panel count', async () => {
|
||||||
|
const { component, gridLayoutStateManager } = renderGridRowHeader();
|
||||||
|
const initialCount = component.getByTestId('kbnGridRowHeader-0--panelCount');
|
||||||
|
expect(initialCount.textContent).toBe('(8 panels)');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const currentRow = gridLayoutStateManager.gridLayout$.getValue()[0];
|
||||||
|
gridLayoutStateManager.gridLayout$.next([
|
||||||
|
{
|
||||||
|
...currentRow,
|
||||||
|
panels: {
|
||||||
|
panel1: currentRow.panels.panel1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const updatedCount = component.getByTestId('kbnGridRowHeader-0--panelCount');
|
||||||
|
expect(updatedCount.textContent).toBe('(1 panel)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking title calls `toggleIsCollapsed`', async () => {
|
||||||
|
const { component, gridLayoutStateManager } = renderGridRowHeader();
|
||||||
|
const title = component.getByTestId('kbnGridRowTitle-0');
|
||||||
|
|
||||||
|
expect(toggleIsCollapsed).toBeCalledTimes(0);
|
||||||
|
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false);
|
||||||
|
await userEvent.click(title);
|
||||||
|
expect(toggleIsCollapsed).toBeCalledTimes(1);
|
||||||
|
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('title editor', () => {
|
||||||
|
const setTitle = async (component: RenderResult) => {
|
||||||
|
const input = component.getByTestId('euiInlineEditModeInput');
|
||||||
|
expect(input.getAttribute('value')).toBe('Large section');
|
||||||
|
await userEvent.click(input);
|
||||||
|
await userEvent.keyboard(' 123');
|
||||||
|
expect(input.getAttribute('value')).toBe('Large section 123');
|
||||||
|
};
|
||||||
|
|
||||||
|
it('clicking on edit icon triggers inline title editor and does not toggle collapsed', async () => {
|
||||||
|
const { component, gridLayoutStateManager } = renderGridRowHeader();
|
||||||
|
const editIcon = component.getByTestId('kbnGridRowTitle-0--edit');
|
||||||
|
|
||||||
|
expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument();
|
||||||
|
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false);
|
||||||
|
await userEvent.click(editIcon);
|
||||||
|
expect(component.getByTestId('kbnGridRowTitle-0--editor')).toBeInTheDocument();
|
||||||
|
expect(toggleIsCollapsed).toBeCalledTimes(0);
|
||||||
|
expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can update the title', async () => {
|
||||||
|
const { component, gridLayoutStateManager } = renderGridRowHeader();
|
||||||
|
expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section');
|
||||||
|
expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section');
|
||||||
|
|
||||||
|
const editIcon = component.getByTestId('kbnGridRowTitle-0--edit');
|
||||||
|
await userEvent.click(editIcon);
|
||||||
|
await setTitle(component);
|
||||||
|
const saveButton = component.getByTestId('euiInlineEditModeSaveButton');
|
||||||
|
await userEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument();
|
||||||
|
expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section 123');
|
||||||
|
expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section 123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking on cancel closes the inline title editor without updating title', async () => {
|
||||||
|
const { component, gridLayoutStateManager } = renderGridRowHeader();
|
||||||
|
const editIcon = component.getByTestId('kbnGridRowTitle-0--edit');
|
||||||
|
await userEvent.click(editIcon);
|
||||||
|
|
||||||
|
await setTitle(component);
|
||||||
|
const cancelButton = component.getByTestId('euiInlineEditModeCancelButton');
|
||||||
|
await userEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument();
|
||||||
|
expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section');
|
||||||
|
expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -6,40 +6,212 @@
|
||||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui';
|
import { distinctUntilChanged, map } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EuiButtonIcon,
|
||||||
|
EuiFlexGroup,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiText,
|
||||||
|
UseEuiTheme,
|
||||||
|
euiCanAnimate,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { css } from '@emotion/react';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
|
|
||||||
export const GridRowHeader = React.memo(
|
import { useGridLayoutContext } from '../use_grid_layout_context';
|
||||||
({
|
import { deleteRow } from '../utils/row_management';
|
||||||
isCollapsed,
|
import { DeleteGridRowModal } from './delete_grid_row_modal';
|
||||||
toggleIsCollapsed,
|
import { GridRowTitle } from './grid_row_title';
|
||||||
rowTitle,
|
|
||||||
}: {
|
export interface GridRowHeaderProps {
|
||||||
isCollapsed: boolean;
|
rowIndex: number;
|
||||||
toggleIsCollapsed: () => void;
|
toggleIsCollapsed: () => void;
|
||||||
rowTitle?: string;
|
collapseButtonRef: React.MutableRefObject<HTMLButtonElement | null>;
|
||||||
}) => {
|
}
|
||||||
|
|
||||||
|
export const GridRowHeader = React.memo(
|
||||||
|
({ rowIndex, toggleIsCollapsed, collapseButtonRef }: GridRowHeaderProps) => {
|
||||||
|
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||||
|
|
||||||
|
const [editTitleOpen, setEditTitleOpen] = useState<boolean>(false);
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState<boolean>(false);
|
||||||
|
const [readOnly, setReadOnly] = useState<boolean>(
|
||||||
|
gridLayoutStateManager.accessMode$.getValue() === 'VIEW'
|
||||||
|
);
|
||||||
|
const [panelCount, setPanelCount] = useState<number>(
|
||||||
|
Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels).length
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* This subscription is responsible for controlling whether or not the section title is
|
||||||
|
* editable and hiding all other "edit mode" actions (delete section, move section, etc)
|
||||||
|
*/
|
||||||
|
const accessModeSubscription = gridLayoutStateManager.accessMode$
|
||||||
|
.pipe(distinctUntilChanged())
|
||||||
|
.subscribe((accessMode) => {
|
||||||
|
setReadOnly(accessMode === 'VIEW');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This subscription is responsible for keeping the panel count in sync
|
||||||
|
*/
|
||||||
|
const panelCountSubscription = gridLayoutStateManager.gridLayout$
|
||||||
|
.pipe(
|
||||||
|
map((layout) => Object.keys(layout[rowIndex]?.panels ?? {}).length),
|
||||||
|
distinctUntilChanged()
|
||||||
|
)
|
||||||
|
.subscribe((count) => {
|
||||||
|
setPanelCount(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
accessModeSubscription.unsubscribe();
|
||||||
|
panelCountSubscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [gridLayoutStateManager, rowIndex]);
|
||||||
|
|
||||||
|
const confirmDeleteRow = useCallback(() => {
|
||||||
|
/**
|
||||||
|
* Memoization of this callback does not need to be dependant on the React panel count
|
||||||
|
* state, so just grab the panel count via gridLayoutStateManager instead
|
||||||
|
*/
|
||||||
|
const count = Object.keys(
|
||||||
|
gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels
|
||||||
|
).length;
|
||||||
|
if (!Boolean(count)) {
|
||||||
|
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowIndex);
|
||||||
|
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||||
|
} else {
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
}
|
||||||
|
}, [gridLayoutStateManager.gridLayout$, rowIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kbnGridRowHeader">
|
<>
|
||||||
<EuiSpacer size="s" />
|
<EuiFlexGroup
|
||||||
<EuiFlexGroup gutterSize="s">
|
gutterSize="xs"
|
||||||
<EuiButtonIcon
|
responsive={false}
|
||||||
color="text"
|
alignItems="center"
|
||||||
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
|
css={styles.headerStyles}
|
||||||
defaultMessage: 'Toggle collapse',
|
className="kbnGridRowHeader"
|
||||||
})}
|
data-test-subj={`kbnGridRowHeader-${rowIndex}`}
|
||||||
iconType={isCollapsed ? 'arrowRight' : 'arrowDown'}
|
>
|
||||||
onClick={toggleIsCollapsed}
|
<GridRowTitle
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
readOnly={readOnly}
|
||||||
|
toggleIsCollapsed={toggleIsCollapsed}
|
||||||
|
editTitleOpen={editTitleOpen}
|
||||||
|
setEditTitleOpen={setEditTitleOpen}
|
||||||
|
collapseButtonRef={collapseButtonRef}
|
||||||
/>
|
/>
|
||||||
<EuiTitle size="xs">
|
{
|
||||||
<h2>{rowTitle}</h2>
|
/**
|
||||||
</EuiTitle>
|
* Add actions at the end of the header section when the layout is editable + the section title
|
||||||
|
* is not in edit mode
|
||||||
|
*/
|
||||||
|
!editTitleOpen && (
|
||||||
|
<>
|
||||||
|
<EuiFlexItem grow={false} css={styles.hiddenOnCollapsed}>
|
||||||
|
<EuiText
|
||||||
|
color="subdued"
|
||||||
|
size="s"
|
||||||
|
data-test-subj={`kbnGridRowHeader-${rowIndex}--panelCount`}
|
||||||
|
className={'kbnGridLayout--panelCount'}
|
||||||
|
>
|
||||||
|
{i18n.translate('kbnGridLayout.rowHeader.panelCount', {
|
||||||
|
defaultMessage:
|
||||||
|
'({panelCount} {panelCount, plural, one {panel} other {panels}})',
|
||||||
|
values: {
|
||||||
|
panelCount,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</EuiText>
|
||||||
|
</EuiFlexItem>
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiButtonIcon
|
||||||
|
iconType="trash"
|
||||||
|
color="danger"
|
||||||
|
className="kbnGridLayout--deleteRowIcon"
|
||||||
|
onClick={confirmDeleteRow}
|
||||||
|
aria-label={i18n.translate('kbnGridLayout.row.deleteRow', {
|
||||||
|
defaultMessage: 'Delete section',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
<EuiFlexItem grow={false} css={[styles.hiddenOnCollapsed, styles.floatToRight]}>
|
||||||
|
{/*
|
||||||
|
This was added as a placeholder to get the desired UI here; however, since the
|
||||||
|
functionality will be implemented in https://github.com/elastic/kibana/issues/190381
|
||||||
|
and this button doesn't do anything yet, I'm choosing to hide it for now. I am keeping
|
||||||
|
the `FlexItem` wrapper so that the UI still looks correct.
|
||||||
|
*/}
|
||||||
|
{/* <EuiButtonIcon
|
||||||
|
iconType="move"
|
||||||
|
color="text"
|
||||||
|
className="kbnGridLayout--moveRowIcon"
|
||||||
|
aria-label={i18n.translate('kbnGridLayout.row.moveRow', {
|
||||||
|
defaultMessage: 'Move section',
|
||||||
|
})}
|
||||||
|
/> */}
|
||||||
|
</EuiFlexItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
<EuiSpacer size="s" />
|
{deleteModalVisible && (
|
||||||
</div>
|
<DeleteGridRowModal rowIndex={rowIndex} setDeleteModalVisible={setDeleteModalVisible} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
GridRowHeader.displayName = 'KbnGridLayoutRowHeader';
|
const styles = {
|
||||||
|
hiddenOnCollapsed: css({
|
||||||
|
display: 'none',
|
||||||
|
'.kbnGridRowContainer--collapsed &': {
|
||||||
|
display: 'block',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
floatToRight: css({
|
||||||
|
marginLeft: 'auto',
|
||||||
|
}),
|
||||||
|
headerStyles: ({ euiTheme }: UseEuiTheme) =>
|
||||||
|
css({
|
||||||
|
height: `calc(${euiTheme.size.xl} + (2 * ${euiTheme.size.s}))`,
|
||||||
|
padding: `${euiTheme.size.s} 0px`,
|
||||||
|
borderBottom: '1px solid transparent', // prevents layout shift
|
||||||
|
'.kbnGridRowContainer--collapsed &': {
|
||||||
|
borderBottom: euiTheme.border.thin,
|
||||||
|
},
|
||||||
|
'.kbnGridLayout--deleteRowIcon': {
|
||||||
|
marginLeft: euiTheme.size.xs,
|
||||||
|
},
|
||||||
|
'.kbnGridLayout--panelCount': {
|
||||||
|
textWrapMode: 'nowrap', // prevent panel count from wrapping
|
||||||
|
},
|
||||||
|
// these styles hide the delete + move actions by default and only show them on hover
|
||||||
|
[`.kbnGridLayout--deleteRowIcon,
|
||||||
|
.kbnGridLayout--moveRowIcon`]: {
|
||||||
|
opacity: '0',
|
||||||
|
[`${euiCanAnimate}`]: {
|
||||||
|
transition: `opacity ${euiTheme.animation.extraFast} ease-in`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[`&:hover .kbnGridLayout--deleteRowIcon,
|
||||||
|
&:hover .kbnGridLayout--moveRowIcon,
|
||||||
|
&:has(:focus-visible) .kbnGridLayout--deleteRowIcon,
|
||||||
|
&:has(:focus-visible) .kbnGridLayout--moveRowIcon`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
GridRowHeader.displayName = 'GridRowHeader';
|
||||||
|
|
|
@ -0,0 +1,187 @@
|
||||||
|
/*
|
||||||
|
* 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 { cloneDeep } from 'lodash';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { distinctUntilChanged, map } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EuiButtonEmpty,
|
||||||
|
EuiButtonIcon,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiInlineEditTitle,
|
||||||
|
EuiTitle,
|
||||||
|
UseEuiTheme,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import { css } from '@emotion/react';
|
||||||
|
|
||||||
|
import { useGridLayoutContext } from '../use_grid_layout_context';
|
||||||
|
|
||||||
|
export const GridRowTitle = React.memo(
|
||||||
|
({
|
||||||
|
readOnly,
|
||||||
|
rowIndex,
|
||||||
|
editTitleOpen,
|
||||||
|
setEditTitleOpen,
|
||||||
|
toggleIsCollapsed,
|
||||||
|
collapseButtonRef,
|
||||||
|
}: {
|
||||||
|
readOnly: boolean;
|
||||||
|
rowIndex: number;
|
||||||
|
editTitleOpen: boolean;
|
||||||
|
setEditTitleOpen: (value: boolean) => void;
|
||||||
|
toggleIsCollapsed: () => void;
|
||||||
|
collapseButtonRef: React.MutableRefObject<HTMLButtonElement | null>;
|
||||||
|
}) => {
|
||||||
|
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const currentRow = gridLayoutStateManager.gridLayout$.getValue()[rowIndex];
|
||||||
|
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* This subscription ensures that this component will re-render when the title changes
|
||||||
|
*/
|
||||||
|
const titleSubscription = gridLayoutStateManager.gridLayout$
|
||||||
|
.pipe(
|
||||||
|
map((gridLayout) => gridLayout[rowIndex]?.title ?? ''),
|
||||||
|
distinctUntilChanged()
|
||||||
|
)
|
||||||
|
.subscribe((title) => {
|
||||||
|
setRowTitle(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
titleSubscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [rowIndex, gridLayoutStateManager]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* Set focus on title input when edit mode is open
|
||||||
|
*/
|
||||||
|
if (editTitleOpen && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [editTitleOpen]);
|
||||||
|
|
||||||
|
const updateTitle = useCallback(
|
||||||
|
(title: string) => {
|
||||||
|
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.getValue());
|
||||||
|
newLayout[rowIndex].title = title;
|
||||||
|
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||||
|
setEditTitleOpen(false);
|
||||||
|
},
|
||||||
|
[rowIndex, setEditTitleOpen, gridLayoutStateManager.gridLayout$]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EuiFlexItem grow={false} css={styles.titleButton}>
|
||||||
|
<EuiButtonEmpty
|
||||||
|
buttonRef={collapseButtonRef}
|
||||||
|
color="text"
|
||||||
|
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
|
||||||
|
defaultMessage: 'Toggle collapse',
|
||||||
|
})}
|
||||||
|
iconType={'arrowDown'}
|
||||||
|
onClick={toggleIsCollapsed}
|
||||||
|
size="m"
|
||||||
|
id={`kbnGridRowTitle-${rowIndex}`}
|
||||||
|
aria-controls={`kbnGridRow-${rowIndex}`}
|
||||||
|
data-test-subj={`kbnGridRowTitle-${rowIndex}`}
|
||||||
|
textProps={false}
|
||||||
|
flush="both"
|
||||||
|
>
|
||||||
|
{editTitleOpen ? null : (
|
||||||
|
<EuiTitle size="xs">
|
||||||
|
<h2>{rowTitle}</h2>
|
||||||
|
</EuiTitle>
|
||||||
|
)}
|
||||||
|
</EuiButtonEmpty>
|
||||||
|
</EuiFlexItem>
|
||||||
|
{!readOnly && editTitleOpen ? (
|
||||||
|
<EuiFlexItem grow={true} css={styles.editTitleInput}>
|
||||||
|
{/* @ts-ignore - EUI typing issue that will be resolved with https://github.com/elastic/eui/pull/8307 */}
|
||||||
|
<EuiInlineEditTitle
|
||||||
|
size="xs"
|
||||||
|
heading="h2"
|
||||||
|
defaultValue={rowTitle}
|
||||||
|
onSave={updateTitle}
|
||||||
|
onCancel={() => setEditTitleOpen(false)}
|
||||||
|
startWithEditOpen
|
||||||
|
editModeProps={{
|
||||||
|
inputProps: { inputRef },
|
||||||
|
}}
|
||||||
|
inputAriaLabel={i18n.translate('kbnGridLayout.row.editTitleAriaLabel', {
|
||||||
|
defaultMessage: 'Edit section title',
|
||||||
|
})}
|
||||||
|
data-test-subj={`kbnGridRowTitle-${rowIndex}--editor`}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!readOnly && (
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiButtonIcon
|
||||||
|
iconType="pencil"
|
||||||
|
onClick={() => setEditTitleOpen(true)}
|
||||||
|
color="text"
|
||||||
|
aria-label={i18n.translate('kbnGridLayout.row.editRowTitle', {
|
||||||
|
defaultMessage: 'Edit section title',
|
||||||
|
})}
|
||||||
|
data-test-subj={`kbnGridRowTitle-${rowIndex}--edit`}
|
||||||
|
/>
|
||||||
|
</EuiFlexItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
titleButton: ({ euiTheme }: UseEuiTheme) =>
|
||||||
|
css({
|
||||||
|
minWidth: 0,
|
||||||
|
button: {
|
||||||
|
'&:focus': {
|
||||||
|
backgroundColor: 'unset',
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
},
|
||||||
|
svg: {
|
||||||
|
transition: `transform ${euiTheme.animation.fast} ease`,
|
||||||
|
transform: 'rotate(0deg)',
|
||||||
|
'.kbnGridRowContainer--collapsed &': {
|
||||||
|
transform: 'rotate(-90deg) !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
editTitleInput: css({
|
||||||
|
// if field-sizing is supported, grow width to text; otherwise, fill available space
|
||||||
|
'@supports (field-sizing: content)': {
|
||||||
|
minWidth: 0,
|
||||||
|
'.euiFlexItem:has(input)': {
|
||||||
|
flexGrow: 0,
|
||||||
|
maxWidth: 'calc(100% - 80px)', // don't extend past parent
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
fieldSizing: 'content',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
GridRowTitle.displayName = 'GridRowTitle';
|
|
@ -29,26 +29,26 @@ export const gridSettings = {
|
||||||
rowHeight: DASHBOARD_GRID_HEIGHT,
|
rowHeight: DASHBOARD_GRID_HEIGHT,
|
||||||
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
|
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockRenderPanelContents = jest.fn((panelId) => (
|
export const mockRenderPanelContents = jest.fn((panelId) => (
|
||||||
<button aria-label={`panelId:${panelId}`}>panel content {panelId}</button>
|
<button aria-label={`panelId:${panelId}`}>panel content {panelId}</button>
|
||||||
));
|
));
|
||||||
|
|
||||||
const runtimeSettings$ = new BehaviorSubject<RuntimeGridSettings>({
|
export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStateManager>) => {
|
||||||
...gridSettings,
|
return {
|
||||||
columnPixelWidth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const gridLayoutStateManagerMock: GridLayoutStateManager = {
|
|
||||||
expandedPanelId$: new BehaviorSubject<string | undefined>(undefined),
|
expandedPanelId$: new BehaviorSubject<string | undefined>(undefined),
|
||||||
isMobileView$: new BehaviorSubject<boolean>(false),
|
isMobileView$: new BehaviorSubject<boolean>(false),
|
||||||
gridLayout$: new BehaviorSubject<GridLayoutData>(getSampleLayout()),
|
gridLayout$: new BehaviorSubject<GridLayoutData>(getSampleLayout()),
|
||||||
proposedGridLayout$: new BehaviorSubject<GridLayoutData | undefined>(undefined),
|
proposedGridLayout$: new BehaviorSubject<GridLayoutData | undefined>(undefined),
|
||||||
runtimeSettings$,
|
runtimeSettings$: new BehaviorSubject<RuntimeGridSettings>({
|
||||||
|
...gridSettings,
|
||||||
|
columnPixelWidth: 0,
|
||||||
|
}),
|
||||||
panelRefs: { current: [] },
|
panelRefs: { current: [] },
|
||||||
rowRefs: { current: [] },
|
rowRefs: { current: [] },
|
||||||
accessMode$: new BehaviorSubject<GridAccessMode>('EDIT'),
|
accessMode$: new BehaviorSubject<GridAccessMode>('EDIT'),
|
||||||
interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined),
|
interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined),
|
||||||
activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined),
|
activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined),
|
||||||
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
|
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -146,11 +146,12 @@ export const useGridLayoutState = ({
|
||||||
*/
|
*/
|
||||||
const cssVariableSubscription = gridLayoutStateManager.runtimeSettings$
|
const cssVariableSubscription = gridLayoutStateManager.runtimeSettings$
|
||||||
.pipe(distinctUntilChanged(deepEqual))
|
.pipe(distinctUntilChanged(deepEqual))
|
||||||
.subscribe(({ gutterSize, columnPixelWidth, rowHeight }) => {
|
.subscribe(({ gutterSize, columnPixelWidth, rowHeight, columnCount }) => {
|
||||||
if (!layoutRef.current) return;
|
if (!layoutRef.current) return;
|
||||||
layoutRef.current.style.setProperty('--kbnGridGutterSize', `${gutterSize}`);
|
layoutRef.current.style.setProperty('--kbnGridGutterSize', `${gutterSize}`);
|
||||||
layoutRef.current.style.setProperty('--kbnGridRowHeight', `${rowHeight}`);
|
layoutRef.current.style.setProperty('--kbnGridRowHeight', `${rowHeight}`);
|
||||||
layoutRef.current.style.setProperty('--kbnGridColumnWidth', `${columnPixelWidth}`);
|
layoutRef.current.style.setProperty('--kbnGridColumnWidth', `${columnPixelWidth}`);
|
||||||
|
layoutRef.current.style.setProperty('--kbnGridColumnCount', `${columnCount}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* 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 { cloneDeep } from 'lodash';
|
||||||
|
|
||||||
|
import { GridLayoutData } from '../types';
|
||||||
|
import { resolveGridRow } from './resolve_grid_row';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the panels in the `startingRow` to the bottom of the `newRow` and resolve the resulting layout
|
||||||
|
* @param layout Starting layout
|
||||||
|
* @param startingRow The source row for the panels
|
||||||
|
* @param newRow The destination row for the panels
|
||||||
|
* @returns Updated layout with panels moved from `startingRow` to `newRow`
|
||||||
|
*/
|
||||||
|
export const movePanelsToRow = (layout: GridLayoutData, startingRow: number, newRow: number) => {
|
||||||
|
const newLayout = cloneDeep(layout);
|
||||||
|
const panelsToMove = newLayout[startingRow].panels;
|
||||||
|
const maxRow = Math.max(
|
||||||
|
...Object.values(newLayout[newRow].panels).map(({ row, height }) => row + height)
|
||||||
|
);
|
||||||
|
Object.keys(panelsToMove).forEach((index) => (panelsToMove[index].row += maxRow));
|
||||||
|
newLayout[newRow].panels = { ...newLayout[newRow].panels, ...panelsToMove };
|
||||||
|
newLayout[newRow] = resolveGridRow(newLayout[newRow]);
|
||||||
|
newLayout[startingRow] = { ...newLayout[startingRow], panels: {} };
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an entire row from the layout, including all of its panels
|
||||||
|
* @param layout Starting layout
|
||||||
|
* @param rowIndex The row to be deleted
|
||||||
|
* @returns Updated layout with the row at `rowIndex` deleted
|
||||||
|
*/
|
||||||
|
export const deleteRow = (layout: GridLayoutData, rowIndex: number) => {
|
||||||
|
const newLayout = cloneDeep(layout);
|
||||||
|
newLayout.splice(rowIndex, 1);
|
||||||
|
return newLayout;
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue