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]);
+ }
+ }
+ }}
+ >
+
+ {variableDefinitions.map((def) => (
+
+ ))}
+
+ )}
+ />
+
+
+
+ (
+ {
+ const selected = event.target.value;
+ field.onChange({ ...field.value, mode: selected });
+ }}
+ >
+
+
+
+ )}
+ />
+
+
+
+ (
+ {
+ const selected = event.target.value;
+ if (selected === 'vertical') {
+ field.onChange({ ...field.value, alignment: selected, maxPer: undefined });
+ } else {
+ field.onChange({ ...field.value, alignment: selected });
+ }
+ }}
+ >
+
+
+
+ )}
+ />
+
+
+
+ (
+ {
+ 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 };
+}