Skip to content

Commit b611f9c

Browse files
committed
[FEATURE] Add panel-level repeat variable support
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
1 parent 94dae1e commit b611f9c

22 files changed

Lines changed: 1013 additions & 49 deletions

dashboards/src/components/GridLayout/GridItemContent.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,25 @@ export interface GridItemContentProps {
2525
panelGroupItemId: PanelGroupItemId;
2626
width: number; // necessary for determining the suggested step ms
2727
panelOptions?: PanelOptions;
28+
readonly?: boolean;
29+
informationTooltip?: string;
2830
}
2931

3032
/**
3133
* Resolves the reference to panel content in a GridItemDefinition and renders the panel.
3234
*/
3335
export function GridItemContent(props: GridItemContentProps): ReactElement {
34-
const { panelGroupItemId, width } = props;
36+
const { readonly, panelGroupItemId, width, informationTooltip } = props;
3537
const panelDefinition = usePanel(panelGroupItemId);
3638

3739
const {
3840
spec: { queries },
3941
} = panelDefinition;
4042

4143
const { isEditMode } = useEditMode();
44+
const canModify = useMemo(() => {
45+
return isEditMode && !readonly;
46+
}, [isEditMode, readonly]);
4247
const { openEditPanel, openDeletePanelDialog, duplicatePanel, viewPanel } = usePanelActions(panelGroupItemId);
4348
const viewPanelGroupItemId = useViewPanelGroup();
4449

@@ -64,14 +69,14 @@ export function GridItemContent(props: GridItemContentProps): ReactElement {
6469
const [openQueryViewer, setOpenQueryViewer] = useState(false);
6570

6671
const viewQueriesHandler = useMemo(() => {
67-
return isEditMode || !queries?.length
72+
return canModify || !queries?.length
6873
? undefined
6974
: {
7075
onClick: (): void => {
7176
setOpenQueryViewer(true);
7277
},
7378
};
74-
}, [isEditMode, queries]);
79+
}, [canModify, queries]);
7580

7681
const readHandlers = {
7782
isPanelViewed: isPanelGroupItemIdEqual(viewPanelGroupItemId, panelGroupItemId),
@@ -86,7 +91,7 @@ export function GridItemContent(props: GridItemContentProps): ReactElement {
8691

8792
// Provide actions to the panel when in edit mode
8893
let editHandlers: PanelProps['editHandlers'] = undefined;
89-
if (isEditMode) {
94+
if (canModify && !readonly) {
9095
editHandlers = {
9196
onEditPanelClick: openEditPanel,
9297
onDuplicatePanelClick: duplicatePanel,
@@ -137,6 +142,7 @@ export function GridItemContent(props: GridItemContentProps): ReactElement {
137142
viewQueriesHandler={viewQueriesHandler}
138143
panelOptions={props.panelOptions}
139144
panelGroupItemId={panelGroupItemId}
145+
informationTooltip={informationTooltip}
140146
/>
141147
)}
142148
</DataQueriesProvider>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { PanelGroupId } from '@perses-dev/spec';
15+
import { PanelOptions, useViewPanelGroup } from '@perses-dev/dashboards';
16+
import { ReactElement } from 'react';
17+
import { ErrorAlert, ErrorBoundary } from '@perses-dev/components';
18+
import { PanelGroupItemId } from '../../model';
19+
import { RepeatItemMeta } from '../../utils';
20+
import { GridItemContent } from './GridItemContent';
21+
import { RepeatGridItemContent } from './RepeatGridItemContent';
22+
23+
const DEFAULT_MARGIN = 10;
24+
25+
interface GridItemRendererProps {
26+
panelGroupId: PanelGroupId;
27+
panelGroupItemLayoutId: string;
28+
width: number;
29+
repeatItemMeta?: RepeatItemMeta;
30+
groupRepeatVariable?: [string, string];
31+
panelOptions?: PanelOptions;
32+
isEditMode: boolean;
33+
}
34+
35+
export function GridItemRenderer({
36+
panelGroupId,
37+
panelGroupItemLayoutId,
38+
width,
39+
repeatItemMeta,
40+
groupRepeatVariable,
41+
panelOptions,
42+
isEditMode,
43+
}: GridItemRendererProps): ReactElement {
44+
const viewPanelItemId = useViewPanelGroup();
45+
46+
const panelRepeatVariable = repeatItemMeta?.itemRepeatVariable;
47+
const panelVariableValues = repeatItemMeta?.values;
48+
const effectiveValues = viewPanelItemId?.repeatVariable?.panel
49+
? [viewPanelItemId.repeatVariable.panel[1]]
50+
: panelVariableValues;
51+
52+
const panelGroupItemId: PanelGroupItemId = {
53+
panelGroupId,
54+
panelGroupItemLayoutId,
55+
repeatVariable: { group: groupRepeatVariable },
56+
};
57+
58+
return (
59+
<ErrorBoundary FallbackComponent={ErrorAlert}>
60+
{panelRepeatVariable && effectiveValues?.length ? (
61+
<RepeatGridItemContent
62+
panelGroupId={panelGroupId}
63+
panelGroupItemLayoutId={panelGroupItemLayoutId}
64+
panelRepeatVariable={{
65+
name: panelRepeatVariable.value,
66+
values: effectiveValues,
67+
maxPer: panelRepeatVariable.alignment === 'vertical' ? 1 : panelRepeatVariable.maxPer,
68+
}}
69+
groupRepeatVariable={groupRepeatVariable}
70+
width={width}
71+
itemGap={DEFAULT_MARGIN}
72+
panelOptions={panelOptions}
73+
isEditMode={isEditMode}
74+
/>
75+
) : (
76+
<GridItemContent panelOptions={panelOptions} panelGroupItemId={panelGroupItemId} width={width} />
77+
)}
78+
</ErrorBoundary>
79+
);
80+
}

dashboards/src/components/GridLayout/GridLayout.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,16 @@ export function RepeatGridLayout({
141141
{variable.value.map((value) => (
142142
<VariableContext.Provider
143143
key={`${repeatVariableName}-${value}`}
144-
value={{ state: { ...variables, [repeatVariableName]: { value, loading: false } } }}
144+
value={{
145+
state: {
146+
...variables,
147+
[repeatVariableName]: {
148+
...variables[repeatVariableName],
149+
value: value,
150+
loading: false,
151+
},
152+
},
153+
}}
145154
>
146155
<Row
147156
panelGroupId={panelGroupId}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { ReactElement, useMemo } from 'react';
15+
import { useVariableValues, VariableContext } from '@perses-dev/plugin-system';
16+
import { GridItemContent, PanelOptions } from '@perses-dev/dashboards';
17+
import { PanelGroupId } from '@perses-dev/spec';
18+
import { Box } from '@mui/material';
19+
20+
interface RepeatPanelItemProps {
21+
panelGroupId: PanelGroupId;
22+
panelGroupItemLayoutId: string;
23+
panelRepeatVariable: {
24+
name: string;
25+
values: string[];
26+
maxPer?: number;
27+
};
28+
groupRepeatVariable?: [string, string];
29+
width: number;
30+
itemGap: number;
31+
panelOptions?: PanelOptions;
32+
isEditMode: boolean;
33+
}
34+
35+
/**
36+
* Renders a grid item that repeats based on a variable.
37+
* It calculates the number of items per row and the width of each item,
38+
* then renders the appropriate number of GridItemContent components with the correct variable context.
39+
*/
40+
export function RepeatGridItemContent({
41+
panelGroupId,
42+
panelGroupItemLayoutId,
43+
panelRepeatVariable,
44+
groupRepeatVariable,
45+
width,
46+
itemGap,
47+
panelOptions,
48+
isEditMode,
49+
}: RepeatPanelItemProps): ReactElement {
50+
const { name: repeatVariableName, values: variableValues, maxPer } = panelRepeatVariable;
51+
const variables = useVariableValues();
52+
const perRow = useMemo(() => {
53+
const maxPerRow = maxPer ?? variableValues.length;
54+
if (variableValues.length < maxPerRow) {
55+
return variableValues.length;
56+
}
57+
return maxPerRow;
58+
}, [maxPer, variableValues.length]);
59+
const rows: string[][] = useMemo(() => {
60+
const result: string[][] = [];
61+
for (let i = 0; i < variableValues.length; i += perRow) {
62+
result.push(variableValues.slice(i, i + perRow));
63+
}
64+
return result;
65+
}, [variableValues, perRow]);
66+
const perPanelWidth = Math.floor((width - itemGap * (perRow - 1)) / perRow);
67+
68+
return (
69+
<Box
70+
sx={{
71+
display: 'flex',
72+
flexDirection: 'column',
73+
width: '100%',
74+
height: '100%',
75+
gap: `${itemGap}px`,
76+
overflow: 'hidden',
77+
}}
78+
>
79+
{rows.map((rowValues, rowIndex) => (
80+
<Box key={rowIndex} sx={{ display: 'flex', flex: 1, gap: `${itemGap}px`, overflow: 'hidden' }}>
81+
{rowValues.map((value, index) => {
82+
const isNotFirst = index + rowIndex !== 0;
83+
return (
84+
<VariableContext.Provider
85+
key={`${repeatVariableName}-${value}`}
86+
value={{
87+
state: {
88+
...variables,
89+
[repeatVariableName]: { ...variables[repeatVariableName], value, loading: false },
90+
},
91+
}}
92+
>
93+
<Box sx={{ width: perPanelWidth, overflow: 'hidden' }}>
94+
<GridItemContent
95+
panelOptions={panelOptions}
96+
panelGroupItemId={{
97+
panelGroupId,
98+
panelGroupItemLayoutId,
99+
repeatVariable: {
100+
panel: [repeatVariableName, value],
101+
group: groupRepeatVariable,
102+
},
103+
}}
104+
width={perPanelWidth}
105+
readonly={isNotFirst}
106+
informationTooltip={
107+
isNotFirst && isEditMode
108+
? `This panel is generated from the variable "${repeatVariableName}" with the value "${value}". To change panel definition, please edit the first panel.`
109+
: undefined
110+
}
111+
/>
112+
</Box>
113+
</VariableContext.Provider>
114+
);
115+
})}
116+
</Box>
117+
))}
118+
</Box>
119+
);
120+
}

dashboards/src/components/GridLayout/Row.tsx

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ import { PanelGroupId } from '@perses-dev/spec';
1616
import { PanelOptions, useViewPanelGroup } from '@perses-dev/dashboards';
1717
import { ReactElement, useEffect, useMemo, useState } from 'react';
1818
import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout';
19-
import { ErrorAlert, ErrorBoundary } from '@perses-dev/components';
19+
import { useVariableValues } from '@perses-dev/plugin-system';
2020
import { GRID_LAYOUT_COLS, GRID_LAYOUT_SMALL_BREAKPOINT } from '../../constants';
2121
import { PanelGroupDefinition, PanelGroupItemLayout } from '../../model';
22+
import { buildRepeatMeta, restoreRepeatLayouts } from '../../utils';
2223
import { GridContainer } from './GridContainer';
23-
import { GridItemContent } from './GridItemContent';
24+
import { GridItemRenderer } from './GridItemRenderer';
2425
import { GridTitle } from './GridTitle';
2526

2627
const DEFAULT_MARGIN = 10;
@@ -57,14 +58,20 @@ export function Row({
5758
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
5859
const theme = useTheme();
5960
const viewPanelItemId = useViewPanelGroup();
61+
const variableValues = useVariableValues();
6062

6163
const [isOpen, setIsOpen] = useState(!groupDefinition.isCollapsed);
6264

65+
const { expandedItemLayouts, repeatMeta } = useMemo(
66+
() => buildRepeatMeta(groupDefinition.itemLayouts, variableValues, repeatVariable),
67+
[groupDefinition.itemLayouts, repeatVariable, variableValues]
68+
);
69+
6370
const hasViewPanel =
6471
viewPanelItemId?.panelGroupId === panelGroupId &&
6572
// Check for repeatVariable panels
66-
viewPanelItemId.repeatVariable?.[0] === repeatVariable?.[0] &&
67-
viewPanelItemId.repeatVariable?.[1] === repeatVariable?.[1];
73+
viewPanelItemId.repeatVariable?.group?.[0] === repeatVariable?.[0] &&
74+
viewPanelItemId.repeatVariable?.group?.[1] === repeatVariable?.[1];
6875
const itemLayoutViewed = viewPanelItemId?.panelGroupItemLayoutId;
6976

7077
// 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({
8087
// Item layout is override if there is a panel in view mode
8188
const itemLayouts: PanelGroupItemLayout[] = useMemo(() => {
8289
if (itemLayoutViewed) {
83-
return groupDefinition.itemLayouts.map((itemLayout) => {
90+
return expandedItemLayouts.map((itemLayout) => {
8491
if (itemLayout.i === itemLayoutViewed) {
8592
const rowTitleHeight = 40 + 8; // 40 is the height of the row title and 8 is the margin height
8693
return {
94+
...itemLayout,
8795
h: Math.round(((panelFullHeight ?? window.innerHeight) - rowTitleHeight) / (ROW_HEIGHT + DEFAULT_MARGIN)), // Viewed panel should take the full height remaining
8896
i: itemLayoutViewed,
8997
w: 48,
@@ -94,8 +102,18 @@ export function Row({
94102
return itemLayout;
95103
});
96104
}
97-
return groupDefinition.itemLayouts;
98-
}, [groupDefinition.itemLayouts, itemLayoutViewed, panelFullHeight]);
105+
return expandedItemLayouts;
106+
}, [expandedItemLayouts, itemLayoutViewed, panelFullHeight]);
107+
108+
const handleLayoutChange = useMemo(() => {
109+
if (!onLayoutChange) {
110+
return undefined;
111+
}
112+
return (currentLayout: Layout[], allLayouts: Layouts): void => {
113+
const restored = restoreRepeatLayouts(currentLayout, allLayouts, repeatMeta);
114+
onLayoutChange(restored.currentLayout, restored.allLayouts);
115+
};
116+
}, [onLayoutChange, repeatMeta]);
99117

100118
return (
101119
<GridContainer
@@ -129,8 +147,8 @@ export function Row({
129147
margin={[DEFAULT_MARGIN, DEFAULT_MARGIN]}
130148
containerPadding={[0, 10]}
131149
layouts={{ sm: itemLayouts }}
132-
onLayoutChange={onLayoutChange}
133-
onWidthChange={onWidthChange}
150+
onLayoutChange={handleLayoutChange}
151+
onWidthChange={isGridDisplayed ? onWidthChange : undefined}
134152
allowOverlap={hasViewPanel} // Enabling overlap when viewing a specific panel because panel in front of the viewed panel will add empty spaces (empty row height)
135153
>
136154
{itemLayouts.map(({ i, w }) => (
@@ -140,13 +158,15 @@ export function Row({
140158
display: itemLayoutViewed ? (itemLayoutViewed === i ? 'unset' : 'none') : 'unset',
141159
}}
142160
>
143-
<ErrorBoundary FallbackComponent={ErrorAlert}>
144-
<GridItemContent
145-
panelOptions={panelOptions}
146-
panelGroupItemId={{ panelGroupId, panelGroupItemLayoutId: i, repeatVariable }}
147-
width={calculateGridItemWidth(w, gridColWidth)}
148-
/>
149-
</ErrorBoundary>
161+
<GridItemRenderer
162+
panelGroupId={panelGroupId}
163+
panelGroupItemLayoutId={i}
164+
width={calculateGridItemWidth(w, gridColWidth)}
165+
repeatItemMeta={repeatMeta.get(i)}
166+
groupRepeatVariable={repeatVariable}
167+
panelOptions={panelOptions}
168+
isEditMode={isEditMode}
169+
/>
150170
</div>
151171
))}
152172
</ResponsiveGridLayout>
@@ -156,7 +176,7 @@ export function Row({
156176
}
157177

158178
const calculateGridItemWidth = (w: number, colWidth: number): number => {
159-
// 0 * Infinity === NaN, which causes problems with resize contraints
179+
// 0 * Infinity === NaN, which causes problems with resize constraints
160180
if (!Number.isFinite(w)) return w;
161181
return Math.round(colWidth * w + Math.max(0, w - 1) * DEFAULT_MARGIN);
162182
};

0 commit comments

Comments
 (0)