From b83e22833d5345d35f5297e28ef08dc44da80fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sepi=C3=B3=C5=82?= Date: Mon, 20 Jul 2026 13:55:38 +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/src/RepeatGrid/RepeatGrid.tsx | 40 +++ components/src/RepeatGrid/index.ts | 14 + components/src/index.ts | 1 + .../components/GridLayout/GridItemContent.tsx | 14 +- .../GridLayout/GridItemRenderer.tsx | 81 +++++ .../src/components/GridLayout/GridLayout.tsx | 11 +- .../GridLayout/RepeatGridItemContent.tsx | 107 +++++++ dashboards/src/components/GridLayout/Row.tsx | 62 ++-- 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 | 35 +-- .../PanelQueriesSharedControls.tsx | 46 ++- .../duplicate-panel-slice.ts | 3 +- .../DashboardProvider/panel-editor-slice.ts | 40 ++- .../DashboardProvider/panel-group-slice.ts | 1 + .../DashboardProvider/view-panel-slice.ts | 5 +- .../VariableProvider/VariableProvider.tsx | 41 +++ dashboards/src/context/useDashboard.tsx | 1 + dashboards/src/model/PanelGroupDefinition.ts | 11 +- dashboards/src/utils/index.ts | 1 + .../src/utils/repeatLayoutUtils.test.ts | 292 ++++++++++++++++++ dashboards/src/utils/repeatLayoutUtils.ts | 141 +++++++++ .../LayoutEditor/LayoutEditor.test.tsx | 160 ++++++++++ .../components/LayoutEditor/LayoutEditor.tsx | 114 +++++++ .../LayoutEditor/RepeatLayoutPreview.tsx | 78 +++++ .../LayoutEditor/RepeatVariableEditor.tsx | 146 +++++++++ .../src/components/LayoutEditor/index.ts | 15 + .../PanelSpecEditor/PanelSpecEditor.test.tsx | 2 + .../PanelSpecEditor/PanelSpecEditor.tsx | 22 +- plugin-system/src/constants/index.ts | 1 + plugin-system/src/constants/repeat.ts | 15 + plugin-system/src/model/panels.ts | 9 + plugin-system/src/schema/panel.ts | 16 + 36 files changed, 1479 insertions(+), 78 deletions(-) create mode 100644 components/src/RepeatGrid/RepeatGrid.tsx create mode 100644 components/src/RepeatGrid/index.ts create mode 100644 dashboards/src/components/GridLayout/GridItemRenderer.tsx create mode 100644 dashboards/src/components/GridLayout/RepeatGridItemContent.tsx create mode 100644 dashboards/src/utils/repeatLayoutUtils.test.ts create mode 100644 dashboards/src/utils/repeatLayoutUtils.ts create mode 100644 plugin-system/src/components/LayoutEditor/LayoutEditor.test.tsx create mode 100644 plugin-system/src/components/LayoutEditor/LayoutEditor.tsx create mode 100644 plugin-system/src/components/LayoutEditor/RepeatLayoutPreview.tsx create mode 100644 plugin-system/src/components/LayoutEditor/RepeatVariableEditor.tsx create mode 100644 plugin-system/src/components/LayoutEditor/index.ts create mode 100644 plugin-system/src/constants/repeat.ts diff --git a/components/src/RepeatGrid/RepeatGrid.tsx b/components/src/RepeatGrid/RepeatGrid.tsx new file mode 100644 index 00000000..015c91c1 --- /dev/null +++ b/components/src/RepeatGrid/RepeatGrid.tsx @@ -0,0 +1,40 @@ +// 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 { ReactNode } from 'react'; +import { Box, SxProps, Theme } from '@mui/material'; + +export interface RepeatGridProps { + rows: T[][]; + gap: number; + renderItem: (item: T, rowIndex: number, colIndex: number) => ReactNode; + containerSx?: SxProps; + rowSx?: SxProps; +} + +export function RepeatGrid({ rows, gap, renderItem, containerSx, rowSx }: RepeatGridProps): ReactNode { + return ( + + {rows.map((rowItems, rowIndex) => ( + + {rowItems.map((item, colIndex) => renderItem(item, rowIndex, colIndex))} + + ))} + + ); +} diff --git a/components/src/RepeatGrid/index.ts b/components/src/RepeatGrid/index.ts new file mode 100644 index 00000000..f6e7a4a3 --- /dev/null +++ b/components/src/RepeatGrid/index.ts @@ -0,0 +1,14 @@ +// 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. + +export * from './RepeatGrid'; diff --git a/components/src/index.ts b/components/src/index.ts index 72d7d93e..1c3199b3 100644 --- a/components/src/index.ts +++ b/components/src/index.ts @@ -48,4 +48,5 @@ export * from './test-utils'; export * from './theme'; export * from './TransformsEditor'; export * from './RefreshIntervalPicker'; +export * from './RepeatGrid'; export * from './ValueMappingEditor'; diff --git a/dashboards/src/components/GridLayout/GridItemContent.tsx b/dashboards/src/components/GridLayout/GridItemContent.tsx index b45db86c..2a98ff41 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, @@ -129,6 +134,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..1e3a71b4 --- /dev/null +++ b/dashboards/src/components/GridLayout/GridItemRenderer.tsx @@ -0,0 +1,81 @@ +// 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 { ReactElement } from 'react'; +import { ErrorAlert, ErrorBoundary } from '@perses-dev/components'; +import { PanelOptions } from '../Panel/Panel'; +import { useViewPanelGroup } from '../../context'; +import { PanelGroupItemId } from '../../model'; +import { getPerRowCount, 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 39784812..4b80cc85 100644 --- a/dashboards/src/components/GridLayout/GridLayout.tsx +++ b/dashboards/src/components/GridLayout/GridLayout.tsx @@ -140,7 +140,16 @@ export function RepeatGridLayout({ {variable.value.map((value) => ( { + 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 = useMemo(() => Math.floor((width - itemGap * (perRow - 1)) / perRow), [itemGap, perRow, width]); + + return ( + { + const isNotFirst = colIndex + rowIndex !== 0; + return ( + + + + + + ); + }} + /> + ); +} diff --git a/dashboards/src/components/GridLayout/Row.tsx b/dashboards/src/components/GridLayout/Row.tsx index 2c062b0f..3b36a227 100644 --- a/dashboards/src/components/GridLayout/Row.tsx +++ b/dashboards/src/components/GridLayout/Row.tsx @@ -12,19 +12,20 @@ // limitations under the License. import { Collapse, useTheme } from '@mui/material'; -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 { PanelGroupId } from '@perses-dev/plugin-system'; +import { useVariableValues, PanelGroupId } 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 { useViewPanelGroup } from '../../context'; +import { PanelOptions } from '../Panel/Panel'; import { GridContainer } from './GridContainer'; -import { GridItemContent } from './GridItemContent'; +import { GridItemRenderer } from './GridItemRenderer'; import { GridTitle } from './GridTitle'; -const DEFAULT_MARGIN = 10; -const ROW_HEIGHT = 30; +export const DEFAULT_MARGIN = 10; +export const ROW_HEIGHT = 30; export interface RowProps { panelGroupId: PanelGroupId; @@ -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', }} > - - - + ))} @@ -156,7 +176,7 @@ export function Row({ } const calculateGridItemWidth = (w: number, colWidth: number): number => { - // 0 * Infinity === NaN, which causes problems with resize contraints + // 0 * Infinity === NaN, which causes problems with resize constraints if (!Number.isFinite(w)) return w; return Math.round(colWidth * w + Math.max(0, w - 1) * DEFAULT_MARGIN); }; 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 4ed1b421..71079dfc 100644 --- a/dashboards/src/components/PanelDrawer/PanelDrawer.tsx +++ b/dashboards/src/components/PanelDrawer/PanelDrawer.tsx @@ -97,9 +97,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 a1cfdba8..bbe8ff52 100644 --- a/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx +++ b/dashboards/src/components/PanelDrawer/PanelEditorForm.tsx @@ -12,9 +12,8 @@ // limitations under the License. import { ReactElement, useCallback, useEffect, useState } from 'react'; -import { Box, Button, Grid, MenuItem, Stack, TextField, Typography } from '@mui/material'; +import { Box, Button, Grid, Stack, TextField, Typography } from '@mui/material'; import { PanelDefinition } from '@perses-dev/spec'; -import { PanelEditorValues, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; import { DiscardChangesConfirmationDialog, ErrorAlert, @@ -22,10 +21,10 @@ import { getSubmitText, getTitleAction, } from '@perses-dev/components'; +import { PanelEditorValues, PluginKindSelect, usePluginEditor, useValidationSchemas } from '@perses-dev/plugin-system'; import { Controller, FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { Action } from '@perses-dev/client'; -import { useListPanelGroups } from '../../context'; import { PanelEditorProvider } from '../../context/PanelEditorProvider/PanelEditorProvider'; import { usePanelEditor } from './usePanelEditor'; import { PanelQueriesSharedControls } from './PanelQueriesSharedControls'; @@ -40,7 +39,6 @@ export interface PanelEditorFormProps { export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { const { initialValues, initialAction, panelKey, onSave, onClose } = props; - const panelGroups = useListPanelGroups(); const { panelDefinition, setName, setDescription, setLinks, setQueries, setPlugin, setPanelDefinition } = usePanelEditor(initialValues.panelDefinition); const { plugin } = panelDefinition.spec; @@ -159,7 +157,7 @@ export function PanelEditorForm(props: PanelEditorFormProps): ReactElement { theme.spacing(2) }}> - + - - ( - { - field.onChange(event); - }} - > - {panelGroups.map((panelGroup, index) => ( - - {panelGroup.title ?? `Group ${index + 1}`} - - ))} - - )} - /> - - ; @@ -46,6 +50,10 @@ export function PanelQueriesSharedControls({ }: PanelQueriesSharedControlsProps): ReactElement { const { data: pluginPreview } = usePlugin('Panel', plugin.kind); const panelEditorContext = useContext(PanelEditorContext); + const variableValues = useVariableValues(); + const variableDefinitionGroups = useAllVariableDefinitions(); + const panelGroups = useListPanelGroups(); + const watchedRepeatVariable = useWatch({ control, name: 'layoutDefinition.repeatVariable' }); const suggestedStepMs = useSuggestedStepMs(panelEditorContext?.preview.previewPanelWidth); @@ -57,6 +65,16 @@ export function PanelQueriesSharedControls({ [panelDefinition.spec.plugin.spec, pluginPreview] ); + const repeatVariableValue = useMemo(() => { + if (watchedRepeatVariable && variableValues[watchedRepeatVariable.value]) { + return ( + variableValues[watchedRepeatVariable.value]?.options?.[0]?.value ?? + variableValues[watchedRepeatVariable.value]?.value + ); + } + return undefined; + }, [variableValues, watchedRepeatVariable]); + const [previewDefinition, setPreviewDefinition] = useState(panelDefinition.spec.queries ?? []); const handleOnQueriesChange = useCallback( @@ -79,21 +97,41 @@ export function PanelQueriesSharedControls({ }); }, []); + const preview = + watchedRepeatVariable && repeatVariableValue ? ( + + + + ) : ( + + ); + return ( Preview - - - + {preview} layout.i === panelGroupLayoutId); + if (layout === undefined) { + throw new Error(`Could not find layout for panel group item ${JSON.stringify(panelGroupItemId)}`); } // Find the panel to edit @@ -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.w; + currentLayout.h = next.layoutDefinition?.height ?? currentLayout.h; + // 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 ?? 12, + h: next.layoutDefinition?.height ?? 6, + 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 dcb971ac..322f59b3 100644 --- a/dashboards/src/context/DashboardProvider/panel-group-slice.ts +++ b/dashboards/src/context/DashboardProvider/panel-group-slice.ts @@ -104,6 +104,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 62747108..7f21c0c5 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 e8a6b79d..1d352424 100644 --- a/dashboards/src/context/VariableProvider/VariableProvider.tsx +++ b/dashboards/src/context/VariableProvider/VariableProvider.tsx @@ -155,6 +155,47 @@ export function useVariableDefinitionStates(variableNames?: string[]): VariableS ); } +/** + * A group of non-overridden variable definitions sharing a common source/scope. + * `source` is undefined for dashboard-scoped variables. + */ +export interface VariableDefinitionGroup { + source?: string; + definitions: VariableDefinition[]; +} + +/** + * Returns all non-overridden variable definitions grouped by source. + */ +export function useAllVariableDefinitions(): VariableDefinitionGroup[] { + const store = useVariableDefinitionStoreCtx(); + return useStoreWithEqualityFn( + store, + (s) => { + const groups: VariableDefinitionGroup[] = []; + + const dashboardDefinitions = s.variableDefinitions.filter( + (v) => !s.variableState.get({ name: v.spec.name })?.overridden + ); + if (dashboardDefinitions.length > 0) { + groups.push({ source: undefined, definitions: dashboardDefinitions }); + } + + [...s.externalVariableDefinitions].forEach((def) => { + const definitions = def.definitions.filter( + (v) => !s.variableState.get({ name: v.spec.name, source: def.source })?.overridden + ); + if (definitions.length > 0) { + groups.push({ source: def.source, definitions }); + } + }); + + return groups; + }, + 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 e140a45c..fd925084 100644 --- a/dashboards/src/context/useDashboard.tsx +++ b/dashboards/src/context/useDashboard.tsx @@ -160,6 +160,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 171da4ae..9add918b 100644 --- a/dashboards/src/model/PanelGroupDefinition.ts +++ b/dashboards/src/model/PanelGroupDefinition.ts @@ -11,7 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { PanelGroupId } from '@perses-dev/plugin-system'; +import { PanelEditorValues, PanelGroupId } from '@perses-dev/plugin-system'; + +export type RepeatVariable = NonNullable['repeatVariable']>; + /** * Panel Group Item Layout ID type. String identifier for items within a panel group. */ @@ -23,7 +26,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. } /** @@ -55,6 +61,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..9c59020d --- /dev/null +++ b/dashboards/src/utils/repeatLayoutUtils.test.ts @@ -0,0 +1,292 @@ +// 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 selected values when selection is non-empty', () => { + const repeatVariable: RepeatVariable = { value: 'region', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables)).toEqual(['us-east']); + }); + + test('falls back to options when selection is empty array', () => { + const variablesWithEmptySelection: VariableStateMap = { + env: { value: [], options: [{ value: 'prod', label: 'prod' }], loading: false }, + }; + const repeatVariable: RepeatVariable = { value: 'env', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variablesWithEmptySelection)).toEqual(['prod']); + }); + + test('falls back to options when all values are selected', () => { + 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', 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', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, emptyVariables)).toEqual([]); + }); + + test('returns single value from groupRepeatVariable when variable matches', () => { + const repeatVariable: RepeatVariable = { value: 'env', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables, ['env', 'staging'])).toEqual(['staging']); + }); + + test('ignores groupRepeatVariable when variable does not match', () => { + const repeatVariable: RepeatVariable = { value: 'env', alignment: 'horizontal' }; + expect(getRepeatVariableValues(repeatVariable, variables, ['region', 'us-east'])).toEqual([ + 'prod', + 'staging', + 'dev', + ]); + }); +}); + +describe('getPerRowCount', () => { + test('returns DEFAULT_MAX_PER_ROW when alignment is undefined and no maxPer set', () => { + const repeatVariable: RepeatVariable = { value: 'env' }; + expect(getPerRowCount(repeatVariable)).toBe(4); + }); + + test('returns 1 for vertical alignment', () => { + const repeatVariable: RepeatVariable = { value: 'env', alignment: 'vertical' }; + expect(getPerRowCount(repeatVariable)).toBe(1); + }); + + test('returns DEFAULT_MAX_PER_ROW when no maxPer set', () => { + const repeatVariable: RepeatVariable = { value: 'env', alignment: 'horizontal' }; + expect(getPerRowCount(repeatVariable)).toBe(4); + }); + + test('returns maxPer when set', () => { + const repeatVariable: RepeatVariable = { value: 'env', alignment: 'horizontal', maxPer: 3 }; + expect(getPerRowCount(repeatVariable)).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', 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', 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', 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', 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', 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', 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', 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..67464315 --- /dev/null +++ b/dashboards/src/utils/repeatLayoutUtils.ts @@ -0,0 +1,141 @@ +// 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 { DEFAULT_MAX_PER_ROW, DEFAULT_REPEAT_ALIGNMENT, VariableStateMap } from '@perses-dev/plugin-system'; +import { DEFAULT_MARGIN, ROW_HEIGHT } from '@perses-dev/dashboards'; +import { PanelGroupItemLayout, RepeatVariable } from '../model'; + +/** + * Resolves the list of string values for a repeat variable given the current variable state map. + * Returns the currently selected values, falling back to all options when nothing 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 (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): number { + if ((repeatVariable.alignment ?? DEFAULT_REPEAT_ALIGNMENT) === 'vertical') { + return 1; + } + return repeatVariable.maxPer ?? DEFAULT_MAX_PER_ROW; +} + +/** + * 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); + 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 }; +} diff --git a/plugin-system/src/components/LayoutEditor/LayoutEditor.test.tsx b/plugin-system/src/components/LayoutEditor/LayoutEditor.test.tsx new file mode 100644 index 00000000..5fd25323 --- /dev/null +++ b/plugin-system/src/components/LayoutEditor/LayoutEditor.test.tsx @@ -0,0 +1,160 @@ +// 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ReactElement } from 'react'; +import { PanelEditorValues } from '../../model'; +import { renderWithContext } from '../../test'; +import { VariableContext } from '../../runtime'; +import { LayoutEditor, LayoutEditorProps } from './LayoutEditor'; + +describe('LayoutEditor', () => { + const renderComponent = ( + props: Omit, + defaultValues?: Partial + ): void => { + const Component = (): ReactElement => { + const form = useForm({ defaultValues }); + return ( + + + + + + ); + }; + renderWithContext(); + }; + + it('should render repeat variable, alignment, and max per row fields', async () => { + renderComponent({ variableDefinitionGroups: [] }); + + expect(await screen.findByLabelText('Repeat Variable')).toBeInTheDocument(); + expect(screen.getByLabelText('Alignment')).toBeInTheDocument(); + expect(screen.getByLabelText('Max Per Row')).toBeInTheDocument(); + }); + + it('should only show list variables in the repeat variable dropdown', async () => { + renderComponent({ + variableDefinitionGroups: [ + { + definitions: [ + { + kind: 'ListVariable', + spec: { + name: 'env', + allowMultiple: false, + allowAllValue: false, + plugin: { kind: 'StaticListVariable', spec: { values: [] } }, + }, + }, + { kind: 'TextVariable', spec: { name: 'search', value: '' } }, + ], + }, + ], + }); + + await userEvent.click(await screen.findByLabelText('Repeat Variable')); + + expect(await screen.findByRole('option', { name: 'env' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'search' })).not.toBeInTheDocument(); + }); + + it('should render group selector when panelGroups are provided', async () => { + renderComponent({ + variableDefinitionGroups: [], + panelGroups: [ + { id: 1, title: 'CPU Stats' }, + { id: 2, title: 'Disk Stats' }, + ], + }); + + expect(await screen.findByLabelText(/Group/)).toBeInTheDocument(); + }); + + it('should not render group selector when panelGroups is not provided', async () => { + renderComponent({ variableDefinitionGroups: [] }); + + expect(await screen.findByLabelText('Repeat Variable')).toBeInTheDocument(); + expect(screen.queryByLabelText(/^Group$/)).not.toBeInTheDocument(); + }); + + it('should list all panel groups in the group selector and update the form value on selection', async () => { + let capturedGroupId: number | undefined; + const Component = (): ReactElement => { + const form = useForm({ defaultValues: { groupId: 1 } }); + capturedGroupId = form.watch('groupId'); + return ( + + + + + + ); + }; + renderWithContext(); + + await userEvent.click(await screen.findByLabelText(/Group/)); + + expect(await screen.findByRole('option', { name: 'CPU Stats' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Disk Stats' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Group 3' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('option', { name: 'Disk Stats' })); + + expect(capturedGroupId).toBe(2); + }); + + it('should show layout preview when a variable with options is selected', async () => { + const Component = (): ReactElement => { + const form = useForm({ + defaultValues: { + layoutDefinition: { + width: 12, + height: 6, + repeatVariable: { value: 'env', alignment: 'horizontal' }, + }, + }, + }); + return ( + + + + + + ); + }; + renderWithContext(); + + expect(await screen.findByText('Layout preview (2 panels)')).toBeInTheDocument(); + }); +}); diff --git a/plugin-system/src/components/LayoutEditor/LayoutEditor.tsx b/plugin-system/src/components/LayoutEditor/LayoutEditor.tsx new file mode 100644 index 00000000..5a004ca9 --- /dev/null +++ b/plugin-system/src/components/LayoutEditor/LayoutEditor.tsx @@ -0,0 +1,114 @@ +// 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, useMemo } from 'react'; +import { Grid2 as Grid, MenuItem, TextField, Typography } from '@mui/material'; +import { VariableDefinition } from '@perses-dev/spec'; +import { Control, Controller, useFormContext, useWatch } from 'react-hook-form'; +import { PanelEditorValues } from '../../model'; +import { useVariableValues } from '../../runtime'; +import { DEFAULT_MAX_PER_ROW, DEFAULT_REPEAT_ALIGNMENT } from '../../constants'; +import { RepeatLayoutPreview } from './RepeatLayoutPreview'; +import { RepeatVariableEditor } from './RepeatVariableEditor'; + +const DEFAULT_LAYOUT_WIDTH = 24; + +export interface VariableDefinitionGroup { + source?: string; + definitions: VariableDefinition[]; +} + +export interface PanelGroup { + id: number; + title?: string; +} + +export interface LayoutEditorProps { + control: Control; + variableDefinitionGroups: VariableDefinitionGroup[]; + panelGroups?: PanelGroup[]; +} + +export function LayoutEditor({ control, variableDefinitionGroups, panelGroups }: LayoutEditorProps): ReactElement { + const { formState, setValue } = useFormContext(); + const variableValues = useVariableValues(); + const watchedRepeatVariableValue = useWatch({ control, name: 'layoutDefinition.repeatVariable.value' }); + const watchedAlignment = useWatch({ control, name: 'layoutDefinition.repeatVariable.alignment' }); + const watchedMaxPer = useWatch({ control, name: 'layoutDefinition.repeatVariable.maxPer' }); + + const optionCount = useMemo(() => { + if (!watchedRepeatVariableValue) return 0; + return variableValues[watchedRepeatVariableValue]?.options?.length ?? 0; + }, [watchedRepeatVariableValue, variableValues]); + + const isVertical = (watchedAlignment ?? DEFAULT_REPEAT_ALIGNMENT) === 'vertical'; + const perRow = isVertical ? 1 : (watchedMaxPer ?? DEFAULT_MAX_PER_ROW); + + return ( + + {panelGroups && ( + <> + + Panel Group + + + ( + + {panelGroups.map((panelGroup, index) => ( + + {panelGroup.title ?? `Group ${index + 1}`} + + ))} + + )} + /> + + + + )} + + Repeat Options + + ( + setValue('layoutDefinition.width', DEFAULT_LAYOUT_WIDTH)} + /> + )} + /> + {watchedRepeatVariableValue && optionCount > 0 && ( + + + + )} + + ); +} diff --git a/plugin-system/src/components/LayoutEditor/RepeatLayoutPreview.tsx b/plugin-system/src/components/LayoutEditor/RepeatLayoutPreview.tsx new file mode 100644 index 00000000..713c15c2 --- /dev/null +++ b/plugin-system/src/components/LayoutEditor/RepeatLayoutPreview.tsx @@ -0,0 +1,78 @@ +// 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, useMemo } from 'react'; +import { Box, IconButton, Typography } from '@mui/material'; +import InformationOutlineIcon from 'mdi-material-ui/InformationOutline'; +import { InfoTooltip, RepeatGrid } from '@perses-dev/components'; + +export interface RepeatLayoutPreviewProps { + optionCount: number; + maxPer: number; +} + +const PREVIEW_GAP = 4; + +export function RepeatLayoutPreview({ optionCount, maxPer }: RepeatLayoutPreviewProps): ReactElement { + const { rows, perRow } = useMemo(() => { + const perRow = Math.max(1, maxPer); + const rows: number[][] = []; + for (let i = 0; i < optionCount; i += perRow) { + rows.push(Array.from({ length: Math.min(perRow, optionCount - i) }, (_, j) => i + j)); + } + + return { rows, perRow }; + }, [maxPer, optionCount]); + + return ( + + + + Layout preview ({optionCount} panel{optionCount !== 1 ? 's' : ''}) + + + ({ borderRadius: theme.shape.borderRadius, padding: '2px' })}> + theme.palette.grey[700] }} + /> + + + + + ( + + )} + /> + + + ); +} diff --git a/plugin-system/src/components/LayoutEditor/RepeatVariableEditor.tsx b/plugin-system/src/components/LayoutEditor/RepeatVariableEditor.tsx new file mode 100644 index 00000000..5e16697b --- /dev/null +++ b/plugin-system/src/components/LayoutEditor/RepeatVariableEditor.tsx @@ -0,0 +1,146 @@ +// 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, useCallback } from 'react'; +import { Grid2 as Grid, ListSubheader, MenuItem, TextField, Typography } from '@mui/material'; +import { ControllerRenderProps, FieldErrors } from 'react-hook-form'; +import { PanelEditorValues } from '../../model'; +import { DEFAULT_MAX_PER_ROW, DEFAULT_REPEAT_ALIGNMENT } from '../../constants'; +import { VariableDefinitionGroup } from './LayoutEditor'; + +type RepeatVariableValue = ControllerRenderProps['value']; +type RepeatVariableOnChange = ControllerRenderProps['onChange']; +type RepeatVariableErrors = FieldErrors< + NonNullable['repeatVariable']> +>; + +export interface RepeatVariableEditorProps { + value: RepeatVariableValue; + onChange: RepeatVariableOnChange; + errors: RepeatVariableErrors; + variableDefinitionGroups: VariableDefinitionGroup[]; + isVertical: boolean; + onRepeatVariableSet: () => void; +} + +export function RepeatVariableEditor({ + value: current, + onChange, + errors, + variableDefinitionGroups, + isVertical, + onRepeatVariableSet, +}: RepeatVariableEditorProps): ReactElement { + const handleVariableChange = useCallback( + (selected: string): void => { + if (!selected) { + onChange(undefined); + } else { + onChange(current ? { ...current, value: selected } : { value: selected, alignment: DEFAULT_REPEAT_ALIGNMENT }); + if (!current) { + onRepeatVariableSet(); + } + } + }, + [current, onChange, onRepeatVariableSet] + ); + + const handleAlignmentChange = useCallback( + (selected: string): void => { + if (!current) return; + onChange( + selected === 'vertical' + ? { ...current, alignment: selected, maxPer: undefined } + : { ...current, alignment: selected } + ); + }, + [current, onChange] + ); + + const handleMaxPerChange = useCallback( + (value: string): void => { + if (!current) return; + onChange({ ...current, maxPer: value === '' ? undefined : Number(value) }); + }, + [current, onChange] + ); + + return ( + <> + + handleVariableChange(event.target.value)} + slotProps={{ select: { MenuProps: { PaperProps: { sx: { maxHeight: 240 } } } } }} + > + + None + + {variableDefinitionGroups.flatMap(({ source, definitions }) => { + const listDefs = definitions.filter((def) => def.kind === 'ListVariable'); + if (listDefs.length === 0) return []; + return [ + source && {source}, + ...listDefs.map((def) => ( + + {def.spec.display?.name ?? def.spec.name} + + )), + ]; + })} + + + + + handleAlignmentChange(event.target.value)} + > + Horizontal + Vertical + + + + + handleMaxPerChange(event.target.value)} + slotProps={{ select: { MenuProps: { PaperProps: { sx: { maxHeight: 240 } } } } }} + > + {Array.from({ length: 12 }, (_, i) => i + 1).map((n) => ( + + {n} + + ))} + + + + ); +} diff --git a/plugin-system/src/components/LayoutEditor/index.ts b/plugin-system/src/components/LayoutEditor/index.ts new file mode 100644 index 00000000..f129f292 --- /dev/null +++ b/plugin-system/src/components/LayoutEditor/index.ts @@ -0,0 +1,15 @@ +// 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. + +export * from './LayoutEditor'; +export * from './RepeatLayoutPreview'; diff --git a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.test.tsx b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.test.tsx index 37963bec..68e35b60 100644 --- a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.test.tsx +++ b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.test.tsx @@ -60,6 +60,7 @@ describe('PanelSpecEditor', () => { queries: [], }, }, + variableDefinitionGroups: [], onQueriesChange: jest.fn(), onQueryRun: jest.fn(), onPluginSpecChange: jest.fn(), @@ -86,6 +87,7 @@ describe('PanelSpecEditor', () => { queries: [], }, }, + variableDefinitionGroups: [], onQueriesChange: jest.fn(), onQueryRun: jest.fn(), onPluginSpecChange: jest.fn(), diff --git a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx index eeb92e75..b8e17e5d 100644 --- a/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx +++ b/plugin-system/src/components/PanelSpecEditor/PanelSpecEditor.tsx @@ -21,10 +21,13 @@ import { useDataQueriesContext, usePlugin } from '../../runtime'; import { OptionsEditorTabs, OptionsEditorTabsProps } from '../OptionsEditorTabs'; import { MultiQueryEditor } from '../MultiQueryEditor'; import { PluginEditorRef } from '../PluginEditor'; +import { LayoutEditor, PanelGroup, VariableDefinitionGroup } from '../LayoutEditor'; export interface PanelSpecEditorProps { control: Control; panelDefinition: PanelDefinition; + variableDefinitionGroups: VariableDefinitionGroup[]; + panelGroups?: PanelGroup[]; onQueriesChange: (queries: QueryDefinition[]) => void; onQueryRun: (index: number, query: QueryDefinition) => void; onPluginSpecChange: (spec: UnknownSpec) => void; @@ -32,7 +35,16 @@ export interface PanelSpecEditorProps { } export const PanelSpecEditor = forwardRef((props, ref): ReactElement | null => { - const { control, panelDefinition, onQueriesChange, onQueryRun, onPluginSpecChange, onJSONChange } = props; + const { + control, + panelDefinition, + variableDefinitionGroups, + panelGroups, + onQueriesChange, + onQueryRun, + onPluginSpecChange, + onJSONChange, + } = props; const { kind } = panelDefinition.spec.plugin; const { data: plugin, isLoading, error } = usePlugin('Panel', kind); @@ -107,7 +119,7 @@ export const PanelSpecEditor = forwardRef ); } - // always show json editor and links editor by default + // Always show the JSON editor, Links editor, and Layout editor by default. tabs.push({ label: 'Links', content: , @@ -131,6 +143,12 @@ export const PanelSpecEditor = forwardRef /> ), }); + tabs.push({ + label: 'Layout', + content: ( + + ), + }); return ; }); diff --git a/plugin-system/src/constants/index.ts b/plugin-system/src/constants/index.ts index 9d79ffbb..617b4a43 100644 --- a/plugin-system/src/constants/index.ts +++ b/plugin-system/src/constants/index.ts @@ -11,4 +11,5 @@ // See the License for the specific language governing permissions and // limitations under the License. +export * from './repeat'; export * from './user-interface-text'; diff --git a/plugin-system/src/constants/repeat.ts b/plugin-system/src/constants/repeat.ts new file mode 100644 index 00000000..7e72689d --- /dev/null +++ b/plugin-system/src/constants/repeat.ts @@ -0,0 +1,15 @@ +// 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. + +export const DEFAULT_REPEAT_ALIGNMENT = 'horizontal' as const; +export const DEFAULT_MAX_PER_ROW = 4; diff --git a/plugin-system/src/model/panels.ts b/plugin-system/src/model/panels.ts index d75e1452..edc2a74d 100644 --- a/plugin-system/src/model/panels.ts +++ b/plugin-system/src/model/panels.ts @@ -88,4 +88,13 @@ export type PanelGroupId = number; export interface PanelEditorValues { groupId: PanelGroupId; panelDefinition: PanelDefinition; + layoutDefinition?: { + width: number; + height: number; + repeatVariable?: { + value: string; + maxPer?: number; + alignment?: 'horizontal' | 'vertical'; + }; + }; } diff --git a/plugin-system/src/schema/panel.ts b/plugin-system/src/schema/panel.ts index 6f68e10b..94e93110 100644 --- a/plugin-system/src/schema/panel.ts +++ b/plugin-system/src/schema/panel.ts @@ -15,14 +15,30 @@ import { z } from 'zod'; import { PluginSchema, panelDefinitionSchema, buildPanelDefinitionSchema } from '@perses-dev/spec'; import { PanelEditorValues } from '../model'; +const layoutDefinitionSchema = z + .object({ + width: z.number(), + height: z.number(), + repeatVariable: z + .object({ + value: z.string(), + maxPer: z.number().optional(), + alignment: z.enum(['horizontal', 'vertical']).optional(), + }) + .optional(), + }) + .optional(); + export const panelEditorSchema: z.ZodSchema = z.object({ groupId: z.number(), panelDefinition: panelDefinitionSchema, + layoutDefinition: layoutDefinitionSchema, }); export function buildPanelEditorSchema(pluginSchema: PluginSchema): z.ZodSchema { return z.object({ groupId: z.number(), panelDefinition: buildPanelDefinitionSchema(pluginSchema), + layoutDefinition: layoutDefinitionSchema, }); }