From 4a28ba4e15ee8915dc662e57dade9844884022c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 1 Jun 2026 16:01:48 +0200 Subject: [PATCH] [FEATURE] Add panel-level repeat variable support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Sepiół --- .../components/GridLayout/GridItemContent.tsx | 14 +- .../GridLayout/GridItemRenderer.tsx | 80 +++++ .../src/components/GridLayout/GridLayout.tsx | 11 +- .../GridLayout/RepeatGridItemContent.tsx | 120 +++++++ dashboards/src/components/GridLayout/Row.tsx | 52 ++- dashboards/src/components/GridLayout/index.ts | 1 + dashboards/src/components/Panel/Panel.tsx | 3 + .../src/components/Panel/PanelActions.tsx | 20 +- .../src/components/Panel/PanelHeader.tsx | 4 + .../components/PanelDrawer/PanelDrawer.tsx | 4 +- .../PanelDrawer/PanelEditorForm.tsx | 78 ++++- .../PanelDrawer/RepeatVariableOptions.tsx | 168 ++++++++++ .../duplicate-panel-slice.ts | 3 +- .../DashboardProvider/panel-editor-slice.ts | 38 ++- .../DashboardProvider/panel-group-slice.ts | 1 + .../DashboardProvider/view-panel-slice.ts | 5 +- .../VariableProvider/VariableProvider.tsx | 31 ++ dashboards/src/context/useDashboard.tsx | 1 + dashboards/src/model/PanelGroupDefinition.ts | 13 +- dashboards/src/utils/index.ts | 1 + .../src/utils/repeatLayoutUtils.test.ts | 302 ++++++++++++++++++ dashboards/src/utils/repeatLayoutUtils.ts | 150 +++++++++ 22 files changed, 1052 insertions(+), 48 deletions(-) create mode 100644 dashboards/src/components/GridLayout/GridItemRenderer.tsx create mode 100644 dashboards/src/components/GridLayout/RepeatGridItemContent.tsx create mode 100644 dashboards/src/components/PanelDrawer/RepeatVariableOptions.tsx create mode 100644 dashboards/src/utils/repeatLayoutUtils.test.ts create mode 100644 dashboards/src/utils/repeatLayoutUtils.ts diff --git a/dashboards/src/components/GridLayout/GridItemContent.tsx b/dashboards/src/components/GridLayout/GridItemContent.tsx index 49af090e..faf2f36a 100644 --- a/dashboards/src/components/GridLayout/GridItemContent.tsx +++ b/dashboards/src/components/GridLayout/GridItemContent.tsx @@ -25,13 +25,15 @@ export interface GridItemContentProps { panelGroupItemId: PanelGroupItemId; width: number; // necessary for determining the suggested step ms panelOptions?: PanelOptions; + readonly?: boolean; + informationTooltip?: string; } /** * Resolves the reference to panel content in a GridItemDefinition and renders the panel. */ export function GridItemContent(props: GridItemContentProps): ReactElement { - const { panelGroupItemId, width } = props; + const { readonly, panelGroupItemId, width, informationTooltip } = props; const panelDefinition = usePanel(panelGroupItemId); const { @@ -39,6 +41,9 @@ export function GridItemContent(props: GridItemContentProps): ReactElement { } = panelDefinition; const { isEditMode } = useEditMode(); + const canModify = useMemo(() => { + return isEditMode && !readonly; + }, [isEditMode, readonly]); const { openEditPanel, openDeletePanelDialog, duplicatePanel, viewPanel } = usePanelActions(panelGroupItemId); const viewPanelGroupItemId = useViewPanelGroup(); @@ -64,14 +69,14 @@ export function GridItemContent(props: GridItemContentProps): ReactElement { const [openQueryViewer, setOpenQueryViewer] = useState(false); const viewQueriesHandler = useMemo(() => { - return isEditMode || !queries?.length + return canModify || !queries?.length ? undefined : { onClick: (): void => { setOpenQueryViewer(true); }, }; - }, [isEditMode, queries]); + }, [canModify, queries]); const readHandlers = { isPanelViewed: isPanelGroupItemIdEqual(viewPanelGroupItemId, panelGroupItemId), @@ -86,7 +91,7 @@ export function GridItemContent(props: GridItemContentProps): ReactElement { // Provide actions to the panel when in edit mode let editHandlers: PanelProps['editHandlers'] = undefined; - if (isEditMode) { + if (canModify && !readonly) { editHandlers = { onEditPanelClick: openEditPanel, onDuplicatePanelClick: duplicatePanel, @@ -137,6 +142,7 @@ export function GridItemContent(props: GridItemContentProps): ReactElement { viewQueriesHandler={viewQueriesHandler} panelOptions={props.panelOptions} panelGroupItemId={panelGroupItemId} + informationTooltip={informationTooltip} /> )} diff --git a/dashboards/src/components/GridLayout/GridItemRenderer.tsx b/dashboards/src/components/GridLayout/GridItemRenderer.tsx new file mode 100644 index 00000000..5ac32cf4 --- /dev/null +++ b/dashboards/src/components/GridLayout/GridItemRenderer.tsx @@ -0,0 +1,80 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PanelGroupId } from '@perses-dev/spec'; +import { PanelOptions, useViewPanelGroup } from '@perses-dev/dashboards'; +import { ReactElement } from 'react'; +import { ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import { PanelGroupItemId } from '../../model'; +import { RepeatItemMeta } from '../../utils'; +import { GridItemContent } from './GridItemContent'; +import { RepeatGridItemContent } from './RepeatGridItemContent'; + +const DEFAULT_MARGIN = 10; + +interface GridItemRendererProps { + panelGroupId: PanelGroupId; + panelGroupItemLayoutId: string; + width: number; + repeatItemMeta?: RepeatItemMeta; + groupRepeatVariable?: [string, string]; + panelOptions?: PanelOptions; + isEditMode: boolean; +} + +export function GridItemRenderer({ + panelGroupId, + panelGroupItemLayoutId, + width, + repeatItemMeta, + groupRepeatVariable, + panelOptions, + isEditMode, +}: GridItemRendererProps): ReactElement { + const viewPanelItemId = useViewPanelGroup(); + + const panelRepeatVariable = repeatItemMeta?.itemRepeatVariable; + const panelVariableValues = repeatItemMeta?.values; + const effectiveValues = viewPanelItemId?.repeatVariable?.panel + ? [viewPanelItemId.repeatVariable.panel[1]] + : panelVariableValues; + + const panelGroupItemId: PanelGroupItemId = { + panelGroupId, + panelGroupItemLayoutId, + repeatVariable: { group: groupRepeatVariable }, + }; + + return ( + + {panelRepeatVariable && effectiveValues?.length ? ( + + ) : ( + + )} + + ); +} diff --git a/dashboards/src/components/GridLayout/GridLayout.tsx b/dashboards/src/components/GridLayout/GridLayout.tsx index 85126fb5..26a682d5 100644 --- a/dashboards/src/components/GridLayout/GridLayout.tsx +++ b/dashboards/src/components/GridLayout/GridLayout.tsx @@ -141,7 +141,16 @@ export function RepeatGridLayout({ {variable.value.map((value) => ( { + const maxPerRow = maxPer ?? variableValues.length; + if (variableValues.length < maxPerRow) { + return variableValues.length; + } + return maxPerRow; + }, [maxPer, variableValues.length]); + const rows: string[][] = useMemo(() => { + const result: string[][] = []; + for (let i = 0; i < variableValues.length; i += perRow) { + result.push(variableValues.slice(i, i + perRow)); + } + return result; + }, [variableValues, perRow]); + const perPanelWidth = Math.floor((width - itemGap * (perRow - 1)) / perRow); + + return ( + + {rows.map((rowValues, rowIndex) => ( + + {rowValues.map((value, index) => { + const isNotFirst = index + rowIndex !== 0; + return ( + + + + + + ); + })} + + ))} + + ); +} diff --git a/dashboards/src/components/GridLayout/Row.tsx b/dashboards/src/components/GridLayout/Row.tsx index 7b5fa36a..6b4a92d4 100644 --- a/dashboards/src/components/GridLayout/Row.tsx +++ b/dashboards/src/components/GridLayout/Row.tsx @@ -16,11 +16,12 @@ import { PanelGroupId } from '@perses-dev/spec'; import { PanelOptions, useViewPanelGroup } from '@perses-dev/dashboards'; import { ReactElement, useEffect, useMemo, useState } from 'react'; import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout'; -import { ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import { useVariableValues } from '@perses-dev/plugin-system'; import { GRID_LAYOUT_COLS, GRID_LAYOUT_SMALL_BREAKPOINT } from '../../constants'; import { PanelGroupDefinition, PanelGroupItemLayout } from '../../model'; +import { buildRepeatMeta, restoreRepeatLayouts } from '../../utils'; import { GridContainer } from './GridContainer'; -import { GridItemContent } from './GridItemContent'; +import { GridItemRenderer } from './GridItemRenderer'; import { GridTitle } from './GridTitle'; const DEFAULT_MARGIN = 10; @@ -57,14 +58,20 @@ export function Row({ const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const theme = useTheme(); const viewPanelItemId = useViewPanelGroup(); + const variableValues = useVariableValues(); const [isOpen, setIsOpen] = useState(!groupDefinition.isCollapsed); + const { expandedItemLayouts, repeatMeta } = useMemo( + () => buildRepeatMeta(groupDefinition.itemLayouts, variableValues, repeatVariable), + [groupDefinition.itemLayouts, repeatVariable, variableValues] + ); + const hasViewPanel = viewPanelItemId?.panelGroupId === panelGroupId && // Check for repeatVariable panels - viewPanelItemId.repeatVariable?.[0] === repeatVariable?.[0] && - viewPanelItemId.repeatVariable?.[1] === repeatVariable?.[1]; + viewPanelItemId.repeatVariable?.group?.[0] === repeatVariable?.[0] && + viewPanelItemId.repeatVariable?.group?.[1] === repeatVariable?.[1]; const itemLayoutViewed = viewPanelItemId?.panelGroupItemLayoutId; // If there is a panel in view mode, we should hide the grid if the panel is not in the current group. @@ -80,10 +87,11 @@ export function Row({ // Item layout is override if there is a panel in view mode const itemLayouts: PanelGroupItemLayout[] = useMemo(() => { if (itemLayoutViewed) { - return groupDefinition.itemLayouts.map((itemLayout) => { + return expandedItemLayouts.map((itemLayout) => { if (itemLayout.i === itemLayoutViewed) { const rowTitleHeight = 40 + 8; // 40 is the height of the row title and 8 is the margin height return { + ...itemLayout, h: Math.round(((panelFullHeight ?? window.innerHeight) - rowTitleHeight) / (ROW_HEIGHT + DEFAULT_MARGIN)), // Viewed panel should take the full height remaining i: itemLayoutViewed, w: 48, @@ -94,8 +102,18 @@ export function Row({ return itemLayout; }); } - return groupDefinition.itemLayouts; - }, [groupDefinition.itemLayouts, itemLayoutViewed, panelFullHeight]); + return expandedItemLayouts; + }, [expandedItemLayouts, itemLayoutViewed, panelFullHeight]); + + const handleLayoutChange = useMemo(() => { + if (!onLayoutChange) { + return undefined; + } + return (currentLayout: Layout[], allLayouts: Layouts): void => { + const restored = restoreRepeatLayouts(currentLayout, allLayouts, repeatMeta); + onLayoutChange(restored.currentLayout, restored.allLayouts); + }; + }, [onLayoutChange, repeatMeta]); return ( {itemLayouts.map(({ i, w }) => ( @@ -140,13 +158,15 @@ export function Row({ display: itemLayoutViewed ? (itemLayoutViewed === i ? 'unset' : 'none') : 'unset', }} > - - - + ))} diff --git a/dashboards/src/components/GridLayout/index.ts b/dashboards/src/components/GridLayout/index.ts index 11028d14..fe8bae7f 100644 --- a/dashboards/src/components/GridLayout/index.ts +++ b/dashboards/src/components/GridLayout/index.ts @@ -16,3 +16,4 @@ export * from './GridItemContent'; export * from './GridLayout'; export * from './GridTitle'; export * from './Row'; +export * from './RepeatGridItemContent'; diff --git a/dashboards/src/components/Panel/Panel.tsx b/dashboards/src/components/Panel/Panel.tsx index 436c72c7..2b73a6bc 100644 --- a/dashboards/src/components/Panel/Panel.tsx +++ b/dashboards/src/components/Panel/Panel.tsx @@ -35,6 +35,7 @@ export interface PanelProps extends CardProps<'section'> { panelOptions?: PanelOptions; panelGroupItemId?: PanelGroupItemId; viewQueriesHandler?: PanelHeaderProps['viewQueriesHandler']; + informationTooltip?: string; } export type PanelOptions = { @@ -85,6 +86,7 @@ export const Panel = memo(function Panel(props: PanelProps) { panelOptions, panelGroupItemId, viewQueriesHandler, + informationTooltip, ...others } = props; @@ -213,6 +215,7 @@ export const Panel = memo(function Panel(props: PanelProps) { id={headerId} title={definition.spec.display?.name ?? ''} description={definition.spec.display?.description} + informationTooltip={informationTooltip} queryResults={queryResults} readHandlers={readHandlers} editHandlers={editHandlers} diff --git a/dashboards/src/components/Panel/PanelActions.tsx b/dashboards/src/components/Panel/PanelActions.tsx index d4a1c885..478b5b40 100644 --- a/dashboards/src/components/Panel/PanelActions.tsx +++ b/dashboards/src/components/Panel/PanelActions.tsx @@ -49,6 +49,7 @@ export interface PanelActionsProps { title?: string; description?: string; descriptionTooltipId: string; + informationTooltip?: string; links?: Link[]; extra?: React.ReactNode; editHandlers?: { @@ -85,6 +86,7 @@ export const PanelActions: React.FC = ({ title, description, descriptionTooltipId, + informationTooltip, links, queryResults, pluginActions = [], @@ -179,6 +181,18 @@ export const PanelActions: React.FC = ({ return undefined; }, [readHandlers, title]); + const informationTooltipIcon = useMemo((): ReactNode | undefined => { + return ( + informationTooltip && ( + + + + + + ) + ); + }, [informationTooltip]); + const viewQueryAction = useMemo(() => { if (!viewQueriesHandler?.onClick) return null; return ( @@ -271,7 +285,8 @@ export const PanelActions: React.FC = ({ {divider} - {descriptionAction} {linksAction} {queryStateIndicator} {noticesIndicator} {extraActions} {viewQueryAction} + {descriptionAction} {linksAction} {queryStateIndicator} {noticesIndicator} + {informationTooltipIcon} {extraActions} {viewQueryAction} {readActions} {pluginActions} {itemActions} {editActions} @@ -295,6 +310,7 @@ export const PanelActions: React.FC = ({ {extraActions} {readActions} + {informationTooltipIcon} {editActions} {viewQueryAction} {pluginActions} {itemActions} @@ -318,7 +334,7 @@ export const PanelActions: React.FC = ({ {extraActions} {viewQueryAction} - {readActions} {editActions} + {readActions} {informationTooltipIcon} {editActions} {/* Show plugin actions inside a menu if it gets crowded */} {pluginActions.length <= 1 ? pluginActions : {pluginActions}} {itemActions.length <= 1 ? ( diff --git a/dashboards/src/components/Panel/PanelHeader.tsx b/dashboards/src/components/Panel/PanelHeader.tsx index dc4293e3..3f9a1d5e 100644 --- a/dashboards/src/components/Panel/PanelHeader.tsx +++ b/dashboards/src/components/Panel/PanelHeader.tsx @@ -37,6 +37,7 @@ export interface PanelHeaderProps extends Omit { itemActionsListConfig?: ItemAction[]; showIcons: PanelOptions['showIcons']; dimension?: { width: number }; + informationTooltip?: string; } export function PanelHeader({ @@ -54,6 +55,7 @@ export function PanelHeader({ showIcons, viewQueriesHandler, dimension, + informationTooltip, ...rest }: PanelHeaderProps): ReactElement { const titleElementId = `${id}-title`; @@ -107,6 +109,7 @@ export function PanelHeader({ title={title} description={description} descriptionTooltipId={descriptionTooltipId} + informationTooltip={informationTooltip} links={links} readHandlers={readHandlers} editHandlers={editHandlers} @@ -155,6 +158,7 @@ export function PanelHeader({ title={title} description={description} descriptionTooltipId={descriptionTooltipId} + informationTooltip={informationTooltip} links={links} readHandlers={readHandlers} editHandlers={editHandlers} diff --git a/dashboards/src/components/PanelDrawer/PanelDrawer.tsx b/dashboards/src/components/PanelDrawer/PanelDrawer.tsx index eec9b1ab..94a3a21e 100644 --- a/dashboards/src/components/PanelDrawer/PanelDrawer.tsx +++ b/dashboards/src/components/PanelDrawer/PanelDrawer.tsx @@ -98,9 +98,9 @@ export const PanelDrawer = (): ReactElement => { }, [handleExited, handleSave, isOpen, panelEditor, panelKey]); // If the panel editor is using a repeat variable, we need to wrap the drawer in a VariableContext.Provider - if (panelEditor?.panelGroupItemId?.repeatVariable) { + if (panelEditor?.panelGroupItemId?.repeatVariable?.group) { return ( - + {drawer} ); diff --git a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx index 1ff26f0b..f3f63ce1 100644 --- a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx +++ b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx @@ -11,24 +11,31 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ReactElement, useCallback, useEffect, useState } from 'react'; +import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Button, Grid, MenuItem, Stack, TextField, Typography } from '@mui/material'; import { PanelDefinition, PanelEditorValues } from '@perses-dev/spec'; import { + Action, DiscardChangesConfirmationDialog, ErrorAlert, ErrorBoundary, - Action, - getTitleAction, getSubmitText, + getTitleAction, } from '@perses-dev/components'; -import { PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; +import { + PluginKindSelect, + usePluginEditor, + useValidationSchemas, + useVariableValues, + VariableContext, +} from '@perses-dev/plugin-system'; import { Controller, FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useListPanelGroups } from '../../context'; import { PanelEditorProvider } from '../../context/PanelEditorProvider/PanelEditorProvider'; import { usePanelEditor } from './usePanelEditor'; import { PanelQueriesSharedControls } from './PanelQueriesSharedControls'; +import { RepeatVariableOptions } from './RepeatVariableOptions'; export interface PanelEditorFormProps { initialValues: PanelEditorValues; @@ -41,6 +48,8 @@ export interface PanelEditorFormProps { export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { const { initialValues, initialAction, panelKey, onSave, onClose } = props; const panelGroups = useListPanelGroups(); + const variableValues = useVariableValues(); + const { panelDefinition, setName, setDescription, setLinks, setQueries, setPlugin, setPanelDefinition } = usePanelEditor(initialValues.panelDefinition); const { plugin } = panelDefinition.spec; @@ -128,6 +137,17 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { const watchedName = useWatch({ control: form.control, name: 'panelDefinition.spec.display.name' }); const watchedDescription = useWatch({ control: form.control, name: 'panelDefinition.spec.display.description' }); const watchedPluginKind = useWatch({ control: form.control, name: 'panelDefinition.spec.plugin.kind' }); + const watchedRepeatVariable = useWatch({ control: form.control, name: 'layoutDefinition.repeatVariable' }); + + const repeatVariableValue = useMemo(() => { + if (watchedRepeatVariable && variableValues[watchedRepeatVariable.value]) { + return ( + variableValues[watchedRepeatVariable.value]?.options?.[0]?.value ?? + variableValues[watchedRepeatVariable.value]?.value + ); + } + return undefined; + }, [variableValues, watchedRepeatVariable]); const handleSubmit = useCallback(() => { form.handleSubmit(processForm)(); @@ -248,18 +268,46 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { )} /> - + + + - setQueries(q)} - onPluginSpecChange={(spec) => { - pluginEditor.onSpecChange(spec); - }} - onJSONChange={handlePanelDefinitionChange} - /> + {watchedRepeatVariable && repeatVariableValue ? ( + + setQueries(q)} + onPluginSpecChange={(spec) => { + pluginEditor.onSpecChange(spec); + }} + onJSONChange={handlePanelDefinitionChange} + /> + + ) : ( + setQueries(q)} + onPluginSpecChange={(spec) => { + pluginEditor.onSpecChange(spec); + }} + onJSONChange={handlePanelDefinitionChange} + /> + )} diff --git a/dashboards/src/components/PanelDrawer/RepeatVariableOptions.tsx b/dashboards/src/components/PanelDrawer/RepeatVariableOptions.tsx new file mode 100644 index 00000000..07c0d33b --- /dev/null +++ b/dashboards/src/components/PanelDrawer/RepeatVariableOptions.tsx @@ -0,0 +1,168 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { Grid2 as Grid, MenuItem, TextField, Typography } from '@mui/material'; +import { PanelEditorValues, VariableDefinition } from '@perses-dev/spec'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useAllVariableDefinitions } from '../../context/VariableProvider'; +import { DEFAULT_REPEAT_ALIGNMENT, DEFAULT_REPEAT_MODE } from '../../utils'; +import { GRID_LAYOUT_COLS, GRID_LAYOUT_SMALL_BREAKPOINT } from '../../constants'; + +export function RepeatVariableOptions(): ReactElement { + const variableDefinitions: VariableDefinition[] = useAllVariableDefinitions(); + const { control, formState, setValue } = useFormContext(); + + const watchedRepeatVariable = useWatch({ control, name: 'layoutDefinition.repeatVariable' }); + + const isVertical = (watchedRepeatVariable?.alignment ?? DEFAULT_REPEAT_ALIGNMENT) === 'vertical'; + + return ( + + + Repeat Options + + + ( + { + const selected = event.target.value; + if (!selected) { + field.onChange(undefined); + } else { + field.onChange( + watchedRepeatVariable + ? { ...watchedRepeatVariable, value: selected } + : { value: selected, mode: DEFAULT_REPEAT_MODE, alignment: DEFAULT_REPEAT_ALIGNMENT } + ); + if (!watchedRepeatVariable) { + setValue('layoutDefinition.width', GRID_LAYOUT_COLS[GRID_LAYOUT_SMALL_BREAKPOINT]); + } + } + }} + > + + None + + {variableDefinitions.map((def) => ( + + {def.spec.display?.name ?? def.spec.name} + + ))} + + )} + /> + + + + ( + { + const selected = event.target.value; + field.onChange({ ...field.value, mode: selected }); + }} + > + All + Selected + + )} + /> + + + + ( + { + const selected = event.target.value; + if (selected === 'vertical') { + field.onChange({ ...field.value, alignment: selected, maxPer: undefined }); + } else { + field.onChange({ ...field.value, alignment: selected }); + } + }} + > + Horizontal + Vertical + + )} + /> + + + + ( + { + const value = event.target.value; + if (value === undefined || value === '') { + field.onChange({ ...field.value, maxPer: undefined }); + } else { + const maxPer = parseInt(value, 10); + field.onChange({ ...field.value, maxPer: Number.isFinite(maxPer) ? maxPer : undefined }); + } + }} + disabled={!watchedRepeatVariable || isVertical} + /> + )} + /> + + + ); +} diff --git a/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts b/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts index abbbd918..6836a4e8 100644 --- a/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts +++ b/dashboards/src/context/DashboardProvider/duplicate-panel-slice.ts @@ -13,7 +13,7 @@ import { StateCreator } from 'zustand'; import { PanelGroupItemId } from '../../model'; -import { generatePanelKey, insertPanelInLayout, UnpositionedPanelGroupItemLayout } from '../../utils/panelUtils'; +import { generatePanelKey, insertPanelInLayout, UnpositionedPanelGroupItemLayout } from '../../utils'; import { generateId, Middleware } from './common'; import { PanelGroupSlice } from './panel-group-slice'; import { PanelSlice } from './panel-slice'; @@ -77,6 +77,7 @@ export function createDuplicatePanelSlice(): StateCreator< i: generateId().toString(), w: matchingLayout.w, h: matchingLayout.h, + repeatVariable: matchingLayout.repeatVariable, }; group.itemLayouts = insertPanelInLayout(duplicateLayout, matchingLayout, group.itemLayouts); diff --git a/dashboards/src/context/DashboardProvider/panel-editor-slice.ts b/dashboards/src/context/DashboardProvider/panel-editor-slice.ts index cbbf1d0a..caf3facf 100644 --- a/dashboards/src/context/DashboardProvider/panel-editor-slice.ts +++ b/dashboards/src/context/DashboardProvider/panel-editor-slice.ts @@ -16,8 +16,8 @@ import { PanelEditorValues, PanelGroupId } from '@perses-dev/spec'; import { StateCreator } from 'zustand'; import { generatePanelKey, getYForNewRow } from '../../utils'; import { PanelGroupDefinition, PanelGroupItemId, PanelGroupItemLayout } from '../../model'; -import { generateId, Middleware, createPanelDefinition } from './common'; -import { PanelGroupSlice, addPanelGroup, createEmptyPanelGroup } from './panel-group-slice'; +import { createPanelDefinition, generateId, Middleware } from './common'; +import { addPanelGroup, createEmptyPanelGroup, PanelGroupSlice } from './panel-group-slice'; import { PanelSlice } from './panel-slice'; /** @@ -91,11 +91,17 @@ export function createPanelEditorSlice(): StateCreator< // Figure out the panel key at that location const { panelGroupId, panelGroupItemLayoutId: panelGroupLayoutId } = panelGroupItemId; - const panelKey = panelGroups[panelGroupId]?.itemPanelKeys[panelGroupLayoutId]; + const panelGroup = panelGroups[panelGroupId]; + const panelKey = panelGroup?.itemPanelKeys[panelGroupLayoutId]; if (panelKey === undefined) { throw new Error(`Could not find Panel Group item ${panelGroupItemId}`); } + const layout = panelGroup?.itemLayouts.find((layout) => layout.i === panelGroupLayoutId); + if (layout === undefined) { + throw new Error(`Could not find layout for panel group item ${panelGroupItemId}`); + } + // Find the panel to edit const panelToEdit = panels[panelKey]; if (panelToEdit === undefined) { @@ -108,11 +114,30 @@ export function createPanelEditorSlice(): StateCreator< initialValues: { groupId: panelGroupItemId.panelGroupId, panelDefinition: panelToEdit, + layoutDefinition: { + width: layout.w, + height: layout.h, + repeatVariable: layout.repeatVariable, + }, }, applyChanges: (next) => { set((state) => { state.panels[panelKey] = next.panelDefinition; + // Update the repeat variable on the current group item + const currentGroup = state.panelGroups[panelGroupId]; + const layoutIndex = currentGroup?.itemLayouts.findIndex((layout) => layout.i === panelGroupLayoutId); + if (currentGroup === undefined || layoutIndex === undefined || layoutIndex === -1) { + throw new Error(`Could not find layout for panel group item ${panelGroupItemId}`); + } + const currentLayout = currentGroup.itemLayouts[layoutIndex]; + if (currentLayout === undefined) { + throw new Error(`Could not find layout for panel group item ${panelGroupItemId}`); + } + currentLayout.repeatVariable = next.layoutDefinition.repeatVariable; + currentLayout.w = next.layoutDefinition.width; + currentLayout.h = next.layoutDefinition.height; + // If the panel didn't change groups, nothing else to do if (next.groupId === panelGroupId) { return; @@ -147,6 +172,7 @@ export function createPanelEditorSlice(): StateCreator< y: getYForNewRow(newGroup), w: existingLayout.w, h: existingLayout.h, + repeatVariable: existingLayout.repeatVariable, }); newGroup.itemPanelKeys[existingLayout.i] = existingPanelKey; }); @@ -179,6 +205,7 @@ export function createPanelEditorSlice(): StateCreator< initialValues: { groupId: panelGroupId, panelDefinition: get().initialValues?.panelDefinition ?? createPanelDefinition(), + layoutDefinition: { width: 12, height: 6 }, }, applyChanges: (next) => { const panelKey = generatePanelKey(); @@ -195,8 +222,9 @@ export function createPanelEditorSlice(): StateCreator< i: generateId().toString(), x: 0, y: getYForNewRow(group), - w: 12, - h: 6, + w: next.layoutDefinition.width, + h: next.layoutDefinition.height, + repeatVariable: next.layoutDefinition.repeatVariable, }; group.itemLayouts.push(layout); group.itemPanelKeys[layout.i] = panelKey; diff --git a/dashboards/src/context/DashboardProvider/panel-group-slice.ts b/dashboards/src/context/DashboardProvider/panel-group-slice.ts index a89de23e..a187872f 100644 --- a/dashboards/src/context/DashboardProvider/panel-group-slice.ts +++ b/dashboards/src/context/DashboardProvider/panel-group-slice.ts @@ -102,6 +102,7 @@ export function convertLayoutsToPanelGroups( h: item.height, x: item.x, y: item.y, + repeatVariable: item.repeatVariable, }); itemPanelKeys[panelGroupLayoutId] = getPanelKeyFromRef(item.content); } diff --git a/dashboards/src/context/DashboardProvider/view-panel-slice.ts b/dashboards/src/context/DashboardProvider/view-panel-slice.ts index aa030bde..c14e2ec7 100644 --- a/dashboards/src/context/DashboardProvider/view-panel-slice.ts +++ b/dashboards/src/context/DashboardProvider/view-panel-slice.ts @@ -22,7 +22,10 @@ import { PanelGroupSlice } from './panel-group-slice'; */ export interface VirtualPanelRef { ref: string; - repeatVariable?: [string, string]; + repeatVariable?: { + group?: [string, string]; + panel?: [string, string]; + }; } /** diff --git a/dashboards/src/context/VariableProvider/VariableProvider.tsx b/dashboards/src/context/VariableProvider/VariableProvider.tsx index f82dcc7c..9c0ffd31 100644 --- a/dashboards/src/context/VariableProvider/VariableProvider.tsx +++ b/dashboards/src/context/VariableProvider/VariableProvider.tsx @@ -155,6 +155,37 @@ export function useVariableDefinitionStates(variableNames?: string[]): VariableS ); } +/** + * Returns all nonoverridden variable definitions (local and external) + */ +export function useAllVariableDefinitions(): VariableDefinition[] { + const store = useVariableDefinitionStoreCtx(); + return useStoreWithEqualityFn( + store, + (s) => { + const result: VariableDefinition[] = []; + + // External variables (reversed so most-prioritized comes first) + [...s.externalVariableDefinitions].reverse().forEach((def) => { + def.definitions.forEach((v) => { + if (!s.variableState.get({ name: v.spec.name, source: def.source })?.overridden) { + result.push(v); + } + }); + }); + + s.variableDefinitions.forEach((v) => { + if (!s.variableState.get({ name: v.spec.name })?.overridden) { + result.push(v); + } + }); + + return result; + }, + shallow + ); +} + /** * Get the state and definition of a variable from the variables context. * @param name name of the variable diff --git a/dashboards/src/context/useDashboard.tsx b/dashboards/src/context/useDashboard.tsx index 7a02e8d0..6326bc60 100644 --- a/dashboards/src/context/useDashboard.tsx +++ b/dashboards/src/context/useDashboard.tsx @@ -146,6 +146,7 @@ function convertPanelGroupsToLayouts( width: layout.w, height: layout.h, content: createPanelRef(panelKey), + repeatVariable: layout.repeatVariable, }; }), repeatVariable: repeatVariable, diff --git a/dashboards/src/model/PanelGroupDefinition.ts b/dashboards/src/model/PanelGroupDefinition.ts index 82adffe7..37f4670e 100644 --- a/dashboards/src/model/PanelGroupDefinition.ts +++ b/dashboards/src/model/PanelGroupDefinition.ts @@ -13,6 +13,13 @@ export type PanelGroupId = number; +export interface RepeatVariable { + value: string; + maxPer?: number; + mode?: 'all' | 'selected'; + alignment?: 'horizontal' | 'vertical'; +} + /** * Panel Group Item Layout ID type. String identifier for items within a panel group. */ @@ -24,7 +31,10 @@ export type PanelGroupItemLayoutId = string; export interface PanelGroupItemId { panelGroupId: PanelGroupId; panelGroupItemLayoutId: PanelGroupItemLayoutId; - repeatVariable?: [string, string]; // Optional, used for repeated panel groups. Variable name and value. + repeatVariable?: { + group?: [string, string]; + panel?: [string, string]; + }; // Optional, used for repeated panels and panel groups. } /** @@ -56,6 +66,7 @@ export interface BaseLayout { export interface PanelGroupItemLayout extends BaseLayout { i: PanelGroupItemLayoutId; + repeatVariable?: RepeatVariable; } /** diff --git a/dashboards/src/utils/index.ts b/dashboards/src/utils/index.ts index 538e931b..a370c5cd 100644 --- a/dashboards/src/utils/index.ts +++ b/dashboards/src/utils/index.ts @@ -12,3 +12,4 @@ // limitations under the License. export * from './panelUtils'; +export * from './repeatLayoutUtils'; diff --git a/dashboards/src/utils/repeatLayoutUtils.test.ts b/dashboards/src/utils/repeatLayoutUtils.test.ts new file mode 100644 index 00000000..1b7803f1 --- /dev/null +++ b/dashboards/src/utils/repeatLayoutUtils.test.ts @@ -0,0 +1,302 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { VariableStateMap } from '@perses-dev/plugin-system'; +import { PanelGroupItemLayout, RepeatVariable } from '../model'; +import { + buildRepeatMeta, + calculateExpandedHeight, + calculateSingleItemHeight, + getPerRowCount, + getRepeatVariableValues, + restoreRepeatItemLayout, + restoreRepeatLayouts, +} from './repeatLayoutUtils'; + +const makeVariableState = (options: string[], selected?: string[]): VariableStateMap[string] => ({ + value: selected ?? options, + options: options.map((value) => ({ value, label: value })), + loading: false, +}); + +describe('getRepeatVariableValues', () => { + const variables: VariableStateMap = { + env: makeVariableState(['prod', 'staging', 'dev']), + region: makeVariableState(['us-east', 'eu-west'], ['us-east']), + }; + + test('returns all option values when mode is all', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables)).toEqual(['prod', 'staging', 'dev']); + }); + + test('returns selected values when mode is selected and selection is non-empty', () => { + const repeatVariable: RepeatVariable = { value: 'region', mode: 'selected', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables)).toEqual(['us-east']); + }); + + test('falls back to options when mode is selected but selection is empty array', () => { + const variablesWithEmptySelection: VariableStateMap = { + env: { value: [], options: [{ value: 'prod', label: 'prod' }], loading: false }, + }; + const repeatVariable: RepeatVariable = { value: 'env', mode: 'selected', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variablesWithEmptySelection)).toEqual(['prod']); + }); + + test('returns all option values when mode is undefined (defaults to all)', () => { + const repeatVariable: RepeatVariable = { value: 'env', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables)).toEqual(['prod', 'staging', 'dev']); + }); + + test('returns empty array when variable does not exist', () => { + const repeatVariable: RepeatVariable = { value: 'missing', mode: 'all', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables)).toEqual([]); + }); + + test('returns empty array when variable has no options', () => { + const emptyVariables: VariableStateMap = { env: { value: [], loading: false } }; + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, emptyVariables)).toEqual([]); + }); + + test('returns single value from groupRepeatVariable when variable matches', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables, ['env', 'staging'])).toEqual(['staging']); + }); + + test('ignores groupRepeatVariable when variable does not match', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables, ['region', 'us-east'])).toEqual([ + 'prod', + 'staging', + 'dev', + ]); + }); +}); + +describe('getPerRowCount', () => { + test('returns total values when alignment is undefined (defaults to horizontal)', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all' }; + expect(getPerRowCount(repeatVariable, 4)).toBe(4); + }); + + test('returns 1 for vertical alignment', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'vertical' }; + expect(getPerRowCount(repeatVariable, 5)).toBe(1); + }); + + test('returns total values when no maxPer set', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal' }; + expect(getPerRowCount(repeatVariable, 4)).toBe(4); + }); + + test('caps at maxPer when maxPer is less than total values', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal', maxPer: 3 }; + expect(getPerRowCount(repeatVariable, 5)).toBe(3); + }); + + test('caps at total values when maxPer exceeds total values', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal', maxPer: 10 }; + expect(getPerRowCount(repeatVariable, 3)).toBe(3); + }); +}); + +describe('calculateExpandedHeight', () => { + test('returns singleItemHeight unchanged for 1 row', () => { + expect(calculateExpandedHeight(6, 1)).toBe(6); + }); + + test('returns singleItemHeight unchanged for 0 rows', () => { + expect(calculateExpandedHeight(6, 0)).toBe(6); + }); + + test('adds row height with gap for 2 rows', () => { + expect(calculateExpandedHeight(6, 2)).toBe(13); + }); + + test('adds row heights with gaps for 3 rows', () => { + expect(calculateExpandedHeight(6, 3)).toBe(19); + }); + + test('handles large numbers of rows', () => { + expect(calculateExpandedHeight(4, 10)).toBe(43); + }); +}); + +describe('calculateSingleItemHeight', () => { + test('returns totalHeight unchanged for 1 row', () => { + expect(calculateSingleItemHeight(6, 1)).toBe(6); + }); + + test('inverts calculateExpandedHeight for 2 rows', () => { + const singleItemHeight = 6; + const expanded = calculateExpandedHeight(singleItemHeight, 2); + expect(calculateSingleItemHeight(expanded, 2)).toBe(singleItemHeight); + }); + + test('inverts calculateExpandedHeight for 3 rows', () => { + const singleItemHeight = 6; + const expanded = calculateExpandedHeight(singleItemHeight, 3); + expect(calculateSingleItemHeight(expanded, 3)).toBe(singleItemHeight); + }); + + test('inverts calculateExpandedHeight for 10 rows', () => { + const singleItemHeight = 4; + const expanded = calculateExpandedHeight(singleItemHeight, 10); + expect(calculateSingleItemHeight(expanded, 10)).toBe(singleItemHeight); + }); + + test('returns at least 1 when total height is very small', () => { + expect(calculateSingleItemHeight(1, 5)).toBe(1); + }); +}); + +describe('restoreRepeatItemLayout', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal', maxPer: 2 }; + const baseLayout: PanelGroupItemLayout = { i: 'panel-1', x: 0, y: 0, w: 12, h: 13 }; + + test('restores single-item height from expanded height', () => { + const meta = { itemRepeatVariable: repeatVariable, values: ['prod', 'staging', 'dev'], numberOfRows: 2 }; + expect(restoreRepeatItemLayout(baseLayout, meta).h).toBe(6); + }); + + test('re-attaches repeatVariable from meta', () => { + const meta = { itemRepeatVariable: repeatVariable, values: ['prod'], numberOfRows: 1 }; + expect(restoreRepeatItemLayout(baseLayout, meta).repeatVariable).toBe(repeatVariable); + }); + + test('preserves other layout properties unchanged', () => { + const meta = { itemRepeatVariable: repeatVariable, values: ['prod'], numberOfRows: 1 }; + const restored = restoreRepeatItemLayout(baseLayout, meta); + expect(restored.i).toBe('panel-1'); + expect(restored.x).toBe(0); + expect(restored.y).toBe(0); + expect(restored.w).toBe(12); + }); + + test('round-trips with calculateExpandedHeight', () => { + const singleItemHeight = 8; + const numberOfRows = 3; + const expandedHeight = calculateExpandedHeight(singleItemHeight, numberOfRows); + const meta = { itemRepeatVariable: repeatVariable, values: ['a', 'b', 'c'], numberOfRows }; + const restored = restoreRepeatItemLayout({ ...baseLayout, h: expandedHeight }, meta); + expect(restored.h).toBe(singleItemHeight); + }); +}); + +describe('restoreRepeatLayouts', () => { + const repeatVariable: RepeatVariable = { value: 'env', mode: 'all', alignment: 'horizontal', maxPer: 2 }; + const meta = new Map([ + ['repeat-panel', { itemRepeatVariable: repeatVariable, values: ['prod', 'staging', 'dev'], numberOfRows: 2 }], + ]); + + const expandedLayout = { i: 'repeat-panel', x: 0, y: 0, w: 12, h: 13 }; + const plainLayout = { i: 'plain-panel', x: 12, y: 0, w: 12, h: 4 }; + + test('restores h for repeat items in currentLayout', () => { + const { currentLayout } = restoreRepeatLayouts([expandedLayout, plainLayout], {}, meta); + expect(currentLayout.find((l) => l.i === 'repeat-panel')?.h).toBe(6); + expect(currentLayout.find((l) => l.i === 'plain-panel')?.h).toBe(4); + }); + + test('restores h for repeat items in allLayouts', () => { + const { allLayouts } = restoreRepeatLayouts([], { sm: [expandedLayout, plainLayout] }, meta); + expect(allLayouts['sm']?.find((l) => l.i === 'repeat-panel')?.h).toBe(6); + expect(allLayouts['sm']?.find((l) => l.i === 'plain-panel')?.h).toBe(4); + }); + + test('restores all breakpoints in allLayouts', () => { + const { allLayouts } = restoreRepeatLayouts([], { sm: [expandedLayout], xxs: [expandedLayout] }, meta); + expect(allLayouts['sm']?.[0]?.h).toBe(6); + expect(allLayouts['xxs']?.[0]?.h).toBe(6); + }); + + test('leaves allLayouts empty when no breakpoints provided', () => { + const { allLayouts } = restoreRepeatLayouts([expandedLayout], {}, meta); + expect(Object.keys(allLayouts)).toHaveLength(0); + }); +}); + +describe('buildRepeatMeta', () => { + const variables: VariableStateMap = { + env: makeVariableState(['prod', 'staging', 'dev']), + }; + + const baseLayout: PanelGroupItemLayout = { i: 'panel-1', x: 0, y: 0, w: 12, h: 6 }; + + test('returns layout unchanged when no repeatVariable', () => { + const { expandedItemLayouts, repeatMeta } = buildRepeatMeta([baseLayout], variables); + expect(expandedItemLayouts).toEqual([baseLayout]); + expect(repeatMeta.size).toBe(0); + }); + + test('expands height for horizontal repeat with multiple rows', () => { + const layout: PanelGroupItemLayout = { + ...baseLayout, + repeatVariable: { value: 'env', mode: 'all', alignment: 'horizontal', maxPer: 2 }, + }; + const { expandedItemLayouts, repeatMeta } = buildRepeatMeta([layout], variables); + expect(expandedItemLayouts[0]?.h).toBe(13); + expect(repeatMeta.get('panel-1')?.numberOfRows).toBe(2); + expect(repeatMeta.get('panel-1')?.values).toEqual(['prod', 'staging', 'dev']); + }); + + test('does not expand height when all values fit in one row', () => { + const layout: PanelGroupItemLayout = { + ...baseLayout, + repeatVariable: { value: 'env', mode: 'all', alignment: 'horizontal' }, + }; + const { expandedItemLayouts, repeatMeta } = buildRepeatMeta([layout], variables); + expect(expandedItemLayouts[0]?.h).toBe(6); + expect(repeatMeta.get('panel-1')?.numberOfRows).toBe(1); + }); + + test('uses numberOfRows 1 and keeps original height when variable has no values', () => { + const layout: PanelGroupItemLayout = { + ...baseLayout, + repeatVariable: { value: 'missing', mode: 'all', alignment: 'horizontal' }, + }; + const { expandedItemLayouts, repeatMeta } = buildRepeatMeta([layout], variables); + expect(expandedItemLayouts[0]?.h).toBe(6); + expect(repeatMeta.get('panel-1')?.numberOfRows).toBe(1); + expect(repeatMeta.get('panel-1')?.values).toEqual([]); + }); + + test('uses numberOfRows equal to value count for vertical alignment', () => { + const layout: PanelGroupItemLayout = { + ...baseLayout, + repeatVariable: { value: 'env', mode: 'all', alignment: 'vertical' }, + }; + const { expandedItemLayouts, repeatMeta } = buildRepeatMeta([layout], variables); + expect(expandedItemLayouts[0]?.h).toBe(19); + expect(repeatMeta.get('panel-1')?.numberOfRows).toBe(3); + }); + + test('handles mixed repeat and non-repeat layouts', () => { + const repeatLayout: PanelGroupItemLayout = { + i: 'repeat-panel', + x: 0, + y: 0, + w: 12, + h: 6, + repeatVariable: { value: 'env', mode: 'all', alignment: 'vertical' }, + }; + const plainLayout: PanelGroupItemLayout = { i: 'plain-panel', x: 12, y: 0, w: 12, h: 4 }; + const { expandedItemLayouts, repeatMeta } = buildRepeatMeta([repeatLayout, plainLayout], variables); + expect(expandedItemLayouts[0]?.h).toBe(19); + expect(expandedItemLayouts[1]?.h).toBe(4); + expect(repeatMeta.size).toBe(1); + expect(repeatMeta.has('repeat-panel')).toBe(true); + expect(repeatMeta.has('plain-panel')).toBe(false); + }); +}); diff --git a/dashboards/src/utils/repeatLayoutUtils.ts b/dashboards/src/utils/repeatLayoutUtils.ts new file mode 100644 index 00000000..38c07cb0 --- /dev/null +++ b/dashboards/src/utils/repeatLayoutUtils.ts @@ -0,0 +1,150 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Layout, Layouts } from 'react-grid-layout'; +import { VariableStateMap } from '@perses-dev/plugin-system'; +import { PanelGroupItemLayout, RepeatVariable } from '../model'; + +const DEFAULT_MARGIN = 10; +const ROW_HEIGHT = 30; + +export const DEFAULT_REPEAT_MODE = 'all' as const; +export const DEFAULT_REPEAT_ALIGNMENT = 'horizontal' as const; + +/** + * Resolves the list of string values for a repeat variable given the current variable state map. + * Returns all option values when mode is 'all', or the selected values when mode is 'selected'. + */ +export function getRepeatVariableValues( + repeatVariable: RepeatVariable, + variableValues: VariableStateMap, + groupRepeatVariable?: [string, string] +): string[] { + const variableState = variableValues[repeatVariable.value]; + if (!variableState) { + return []; + } + if (groupRepeatVariable && repeatVariable.value === groupRepeatVariable[0]) { + return [groupRepeatVariable[1]]; + } + if ( + (repeatVariable.mode ?? DEFAULT_REPEAT_MODE) === 'selected' && + Array.isArray(variableState.value) && + variableState.value.length > 0 + ) { + return variableState.value; + } + return variableState.options?.map((option) => option.value) ?? []; +} + +/** + * Returns how many items will be rendered per row for a repeat variable + */ +export function getPerRowCount(repeatVariable: RepeatVariable, totalValues: number): number { + if ((repeatVariable.alignment ?? DEFAULT_REPEAT_ALIGNMENT) === 'vertical') { + return 1; + } + return Math.min(repeatVariable.maxPer ?? totalValues, totalValues); +} + +/** + * Calculates the total expanded grid height for a repeat panel item given the single-item height. + * Each row of repeated sub-panels occupies singleItemHeight grid rows, with margins between rows. + */ +export function calculateExpandedHeight(singleItemHeight: number, numberOfRows: number): number { + if (numberOfRows <= 1) { + return singleItemHeight; + } + return numberOfRows * singleItemHeight + Math.ceil(((numberOfRows - 1) * DEFAULT_MARGIN) / ROW_HEIGHT); +} + +/** + * Calculates the single-item grid height from a total expanded height. + * This is the inverse of calculateExpandedHeight and is used when persisting + * a resize performed by the user in edit mode. + */ +export function calculateSingleItemHeight(totalHeight: number, numberOfRows: number): number { + if (numberOfRows <= 1) { + return totalHeight; + } + const gapHeight = Math.ceil(((numberOfRows - 1) * DEFAULT_MARGIN) / ROW_HEIGHT); + return Math.max(1, Math.round((totalHeight - gapHeight) / numberOfRows)); +} + +export interface RepeatItemMeta { + itemRepeatVariable: RepeatVariable; + values: string[]; + numberOfRows: number; +} + +/** + * Restores a layout item to its single-item height and re-attaches repeatVariable after + * react-grid-layout reports back an expanded (total) height. Used when persisting layouts, + * including after a user resize in edit mode. + */ +export function restoreRepeatItemLayout(layout: PanelGroupItemLayout, meta: RepeatItemMeta): PanelGroupItemLayout { + return { + ...layout, + h: calculateSingleItemHeight(layout.h, meta.numberOfRows), + repeatVariable: meta.itemRepeatVariable, + }; +} + +/** + * Applies restoreRepeatItemLayout to all repeat items in currentLayout and allLayouts using + * the provided meta map. Non-repeat items are returned unchanged. + */ +export function restoreRepeatLayouts( + currentLayout: Layout[], + allLayouts: Layouts, + repeatMeta: Map +): { currentLayout: PanelGroupItemLayout[]; allLayouts: Layouts } { + const restore = (layout: Layout): PanelGroupItemLayout => { + const meta = repeatMeta.get(layout.i); + return meta ? restoreRepeatItemLayout(layout, meta) : layout; + }; + const restoredAllLayouts: Layouts = {}; + for (const [breakpoint, layouts] of Object.entries(allLayouts)) { + restoredAllLayouts[breakpoint] = layouts.map(restore); + } + return { currentLayout: currentLayout.map(restore), allLayouts: restoredAllLayouts }; +} + +/** + * Builds a map from layout item id to repeat metadata and a list of layouts with + * expanded heights for repeat-variable items. Non-repeat items are returned unchanged. + */ +export function buildRepeatMeta( + itemLayouts: PanelGroupItemLayout[], + variableValues: VariableStateMap, + groupRepeatVariable?: [string, string] +): { expandedItemLayouts: PanelGroupItemLayout[]; repeatMeta: Map } { + const repeatMeta = new Map(); + const expandedItemLayouts = itemLayouts.map((itemLayout) => { + const itemRepeatVariable = itemLayout.repeatVariable; + if (!itemRepeatVariable) { + return itemLayout; + } + + const values = getRepeatVariableValues(itemRepeatVariable, variableValues, groupRepeatVariable); + const perRowCount = getPerRowCount(itemRepeatVariable, values.length); + const numberOfRows = values.length > 0 ? Math.ceil(values.length / perRowCount) : 1; + repeatMeta.set(itemLayout.i, { itemRepeatVariable, values, numberOfRows }); + + if (values.length === 0 || numberOfRows <= 1) { + return itemLayout; + } + return { ...itemLayout, h: calculateExpandedHeight(itemLayout.h, numberOfRows) }; + }); + return { expandedItemLayouts, repeatMeta }; +}