Skip to content
6 changes: 3 additions & 3 deletions client/src/model/datasource-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ describe('hasHTTPProxy', () => {
});

describe('createTestDatasourceConnection', () => {
const mockFetch = jest.fn();
const mockFetch = vi.fn();

beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
delete window.PERSES_APP_CONFIG;
globalThis.fetch = mockFetch;
});
Expand Down Expand Up @@ -212,7 +212,7 @@ describe('createTestDatasourceConnection', () => {

await testConnection(spec, '/api/v1/query');

const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
const callBody = JSON.parse(mockFetch.mock.calls[0]?.[1].body);
expect(callBody).toMatchObject({ method: 'GET', body: null, spec });
});
});
Expand Down
165 changes: 165 additions & 0 deletions components/src/RepeatGrid/RepeatGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// 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 { render, RenderResult, screen } from '@testing-library/react';
import { ReactNode } from 'react';

import { RepeatGrid, RepeatGridProps } from './RepeatGrid';

type TestItem = { id: string; label: string };

const ITEMS: TestItem[] = [
{ id: 'a', label: 'Item A' },
{ id: 'b', label: 'Item B' },
{ id: 'c', label: 'Item C' },
{ id: 'd', label: 'Item D' },
{ id: 'e', label: 'Item E' },
];

type RenderRepeatGridOpts = Partial<RepeatGridProps<TestItem>>;

const renderRepeatGrid = ({
repeatItems = ITEMS,
maxPer = 3,
gap = 8,
renderItem = (item): ReactNode => <div data-testid={`item-${item.id}`}>{item.label}</div>,
containerSx,
rowSx,
}: RenderRepeatGridOpts = {}): RenderResult => {
return render(
<RepeatGrid
repeatItems={repeatItems}
maxPer={maxPer}
gap={gap}
renderItem={renderItem}
containerSx={containerSx}
rowSx={rowSx}
/>,
);
};

describe('RepeatGrid', () => {
describe('rendering items', () => {
it('renders all items', () => {
renderRepeatGrid();
for (const item of ITEMS) {
expect(screen.getByTestId(`item-${item.id}`)).toBeInTheDocument();
}
});

it('renders nothing when repeatItems is empty', () => {
const { container } = renderRepeatGrid({ repeatItems: [] });
expect(container.querySelectorAll('[data-testid^="item-"]')).toHaveLength(0);
});

it('renders a single item', () => {
renderRepeatGrid({ repeatItems: [ITEMS[0]!] });
expect(screen.getByTestId('item-a')).toBeInTheDocument();
});
});

describe('row and column indices', () => {
it('passes correct rowIndex and colIndex to renderItem', () => {
const positions: Array<{ id: string; rowIndex: number; colIndex: number }> = [];
renderRepeatGrid({
repeatItems: ITEMS.slice(0, 4),
maxPer: 2,
renderItem: (item, { rowIndex, colIndex }) => {
positions.push({ id: item.id, rowIndex, colIndex });
return <div key={item.id}>{item.label}</div>;
},
});

expect(positions).toEqual([
{ id: 'a', rowIndex: 0, colIndex: 0 },
{ id: 'b', rowIndex: 0, colIndex: 1 },
{ id: 'c', rowIndex: 1, colIndex: 0 },
{ id: 'd', rowIndex: 1, colIndex: 1 },
]);
});

it('puts all items in one row when maxPer exceeds item count', () => {
const positions: Array<{ rowIndex: number; colIndex: number }> = [];
renderRepeatGrid({
repeatItems: ITEMS.slice(0, 3),
maxPer: 10,
renderItem: (item, { rowIndex, colIndex }) => {
positions.push({ rowIndex, colIndex });
return <div key={item.id}>{item.label}</div>;
},
});

expect(positions.every((p) => p.rowIndex === 0)).toBe(true);
expect(positions.map((p) => p.colIndex)).toEqual([0, 1, 2]);
});
});

describe('maxPer behaviour', () => {
it('distributes 5 items into rows of 3 (2 rows)', () => {
const rowIndices: number[] = [];
renderRepeatGrid({
repeatItems: ITEMS,
maxPer: 3,
renderItem: (item, { rowIndex }) => {
rowIndices.push(rowIndex);
return <div key={item.id}>{item.label}</div>;
},
});

// Items a,b,c → row 0; items d,e → row 1
expect(rowIndices).toEqual([0, 0, 0, 1, 1]);
});

it('clamps maxPer of 0 to 1 item per row', () => {
const colIndices: number[] = [];
renderRepeatGrid({
repeatItems: ITEMS.slice(0, 3),
maxPer: 0,
renderItem: (item, { colIndex }) => {
colIndices.push(colIndex);
return <div key={item.id}>{item.label}</div>;
},
});

expect(colIndices).toEqual([0, 0, 0]);
});

it('clamps negative maxPer to 1 item per row', () => {
const colIndices: number[] = [];
renderRepeatGrid({
repeatItems: ITEMS.slice(0, 2),
maxPer: -5,
renderItem: (item, { colIndex }) => {
colIndices.push(colIndex);
return <div key={item.id}>{item.label}</div>;
},
});

expect(colIndices).toEqual([0, 0]);
});

it('places all items in a single row when maxPer equals item count', () => {
const rowIndices: number[] = [];
renderRepeatGrid({
repeatItems: ITEMS,
maxPer: ITEMS.length,
renderItem: (item, { rowIndex }) => {
rowIndices.push(rowIndex);
return <div key={item.id}>{item.label}</div>;
},
});

expect(rowIndices.every((r) => r === 0)).toBe(true);
});
});
});
68 changes: 68 additions & 0 deletions components/src/RepeatGrid/RepeatGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// 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 { Box, SxProps, Theme } from '@mui/material';
import { ReactNode, useMemo } from 'react';

export interface RepeatGridProps<T> {
repeatItems: T[];
maxPer: number;
gap: number;
renderItem: (item: T, options: { rowIndex: number; colIndex: number }) => ReactNode;
containerSx?: SxProps<Theme>;
rowSx?: SxProps<Theme>;
}

export function RepeatGrid<T>({
repeatItems,
maxPer,
gap,
renderItem,
containerSx,
rowSx,
}: RepeatGridProps<T>): ReactNode {
const { rows, perRow } = useMemo(() => {
const perRow = Math.max(1, maxPer);
const rows: T[][] = [];
for (let i = 0; i < repeatItems.length; i += perRow) {
rows.push(repeatItems.slice(i, i + perRow));
}

return { rows, perRow };
}, [maxPer, repeatItems]);

return (
<Box
sx={[
{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', gap: `${gap}px` },
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
]}
>
{rows.map((rowItems, rowIndex) => (
<Box key={rowIndex} sx={[{ display: 'flex', gap: `${gap}px` }, ...(Array.isArray(rowSx) ? rowSx : [rowSx])]}>
{rowItems.map((item, colIndex) => (
<Box
key={`${rowIndex}-${colIndex}`}
sx={{
width: `calc((100% - ${gap * (perRow - 1)}px) / ${perRow})`,
overflow: 'hidden',
}}
>
{renderItem(item, { rowIndex, colIndex })}
</Box>
))}
</Box>
))}
</Box>
);
}
14 changes: 14 additions & 0 deletions components/src/RepeatGrid/index.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ export * from './test-utils';
export * from './theme';
export * from './TransformsEditor';
export * from './RefreshIntervalPicker';
export * from './RepeatGrid';
export * from './ValueMappingEditor';
14 changes: 10 additions & 4 deletions dashboards/src/components/GridLayout/GridItemContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,25 @@ 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 {
spec: { queries },
} = panelDefinition;

const { isEditMode } = useEditMode();
const canModify = useMemo(() => {
return isEditMode && !readonly;
}, [isEditMode, readonly]);
const { openEditPanel, openDeletePanelDialog, duplicatePanel, viewPanel } = usePanelActions(panelGroupItemId);
const viewPanelGroupItemId = useViewPanelGroup();

Expand All @@ -65,14 +70,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),
Expand All @@ -87,7 +92,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) {
editHandlers = {
onEditPanelClick: openEditPanel,
onDuplicatePanelClick: duplicatePanel,
Expand Down Expand Up @@ -130,6 +135,7 @@ export function GridItemContent(props: GridItemContentProps): ReactElement {
viewQueriesHandler={viewQueriesHandler}
panelOptions={props.panelOptions}
panelGroupItemId={panelGroupItemId}
informationTooltip={informationTooltip}
/>
)}
</DataQueriesProvider>
Expand Down
Loading
Loading