Compare commits

...

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
e28806970b fix(dashboard-v2): compact section layouts on JSON-editor save
The JSON editor let a panel's x/y be set to arbitrary coordinates (e.g. a lone
panel at y: 10), but react-grid-layout renders each section compacted, so the
stored coordinates and what's shown drifted apart. Saving now compacts every
Grid section's items with react-grid-layout's own helpers (12 cols, vertical),
so stored state matches the rendered layout.
2026-07-10 02:12:09 +05:30
Ashwin Bhatkal
6a851c5030 fix(dashboard-v2): surface validation errors when editing a tag chip inline
Editing an existing tag chip to an invalid (non key:value) or duplicate value
silently reverted with no feedback, unlike the new-tag input which shows an
inline error. Inline edit now shows the same error on Enter and keeps the edit
box open to fix it, while still reverting on blur so a click-away is never
trapped.
2026-07-10 02:12:09 +05:30
6 changed files with 259 additions and 13 deletions

View File

@@ -0,0 +1,72 @@
import { fireEvent, render, screen } from '@testing-library/react';
import TagKeyValueInput from './TagKeyValueInput';
const TID = 'tag-key-value-input';
const startEditingFirstChip = (): HTMLElement => {
fireEvent.doubleClick(screen.getAllByTestId(`${TID}-chip`)[0]);
return screen.getByTestId(`${TID}-edit`);
};
describe('TagKeyValueInput — inline chip edit', () => {
it('shows an error and stays in edit mode when Enter commits an invalid value', () => {
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'novalue' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'key:value format',
);
// Still editing (input present), and no change committed.
expect(screen.getByTestId(`${TID}-edit`)).toBeInTheDocument();
expect(onTagsChange).not.toHaveBeenCalled();
});
it('shows a duplicate error when Enter commits an existing tag', () => {
const onTagsChange = jest.fn();
render(
<TagKeyValueInput
tags={['env:prod', 'team:core']}
onTagsChange={onTagsChange}
/>,
);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'team:core' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByTestId(`${TID}-error`)).toHaveTextContent(
'already exists',
);
expect(onTagsChange).not.toHaveBeenCalled();
});
it('commits a valid edit on Enter', () => {
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'env:staging' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onTagsChange).toHaveBeenCalledWith(['env:staging']);
expect(screen.queryByTestId(`${TID}-error`)).not.toBeInTheDocument();
});
it('reverts silently (no error) when blurring an invalid edit', () => {
const onTagsChange = jest.fn();
render(<TagKeyValueInput tags={['env:prod']} onTagsChange={onTagsChange} />);
const input = startEditingFirstChip();
fireEvent.change(input, { target: { value: 'novalue' } });
fireEvent.blur(input);
expect(screen.queryByTestId(`${TID}-error`)).not.toBeInTheDocument();
expect(screen.queryByTestId(`${TID}-edit`)).not.toBeInTheDocument();
expect(onTagsChange).not.toHaveBeenCalled();
});
});

View File

@@ -82,15 +82,32 @@ function TagKeyValueInput({
const cancelEdit = (): void => {
setEditIndex(-1);
setEditValue('');
setError('');
};
const commitEdit = (): void => {
// Commit an inline chip edit. On Enter (`revertOnInvalid` false) an invalid or
// duplicate value shows the error and keeps the edit box open so the user can
// fix it — matching the new-tag input. On blur we revert instead, so a click
// away never strands the user in an un-exitable edit box.
const commitEdit = (revertOnInvalid = false): void => {
const normalized = parseKeyValueTag(editValue);
// Drop into a no-op (revert) on invalid or duplicate edits rather than
// stranding the user in an un-exitable edit box.
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
if (!normalized) {
if (revertOnInvalid) {
cancelEdit();
return;
}
setError('Tags must be in key:value format (both sides required).');
return;
}
if (tags.some((t, i) => t === normalized && i !== editIndex)) {
if (revertOnInvalid) {
cancelEdit();
return;
}
setError('This tag already exists.');
return;
}
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
cancelEdit();
};
@@ -121,11 +138,14 @@ function TagKeyValueInput({
value={editValue}
autoFocus
testId={`${testId}-edit`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setEditValue(e.target.value)
}
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
setEditValue(e.target.value);
if (error) {
setError('');
}
}}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
onBlur={(): void => commitEdit(true)}
/>
) : (
<TagBadge

View File

@@ -0,0 +1,65 @@
import type {
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
import { normalizeSpecLayouts } from '../normalizeSpecLayouts';
const gridLayout = (
items: DashboardtypesLayoutDTO['spec']['items'],
): DashboardtypesLayoutDTO => ({
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
spec: { display: { title: 'S' }, items },
});
// Only `layouts` matters here; the rest of the spec is irrelevant to compaction.
const makeSpec = (
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesDashboardSpecDTO =>
({ layouts }) as DashboardtypesDashboardSpecDTO;
const ref = (id: string): { $ref: string } => ({
$ref: `#/spec/panels/${id}`,
});
describe('normalizeSpecLayouts', () => {
it('pulls a lone panel with a non-zero y up to the top', () => {
const spec = makeSpec([
gridLayout([{ x: 0, y: 10, width: 6, height: 6, content: ref('a') }]),
]);
const out = normalizeSpecLayouts(spec);
expect(out.layouts?.[0].spec.items?.[0]).toMatchObject({ x: 0, y: 0 });
});
it('collapses a vertical gap between stacked panels', () => {
const spec = makeSpec([
gridLayout([
{ x: 0, y: 0, width: 6, height: 6, content: ref('a') },
{ x: 0, y: 20, width: 6, height: 6, content: ref('b') },
]),
]);
const items = normalizeSpecLayouts(spec).layouts?.[0].spec.items ?? [];
expect(items[0]).toMatchObject({ y: 0 });
expect(items[1]).toMatchObject({ y: 6 });
});
it('preserves side-by-side panels and their panel refs', () => {
const spec = makeSpec([
gridLayout([
{ x: 0, y: 5, width: 6, height: 6, content: ref('a') },
{ x: 6, y: 5, width: 6, height: 6, content: ref('b') },
]),
]);
const items = normalizeSpecLayouts(spec).layouts?.[0].spec.items ?? [];
expect(items[0]).toMatchObject({ x: 0, y: 0, content: ref('a') });
expect(items[1]).toMatchObject({ x: 6, y: 0, content: ref('b') });
});
it('leaves empty sections and specs without layouts untouched', () => {
expect(
normalizeSpecLayouts({} as DashboardtypesDashboardSpecDTO).layouts,
).toBeUndefined();
const empty = makeSpec([gridLayout([])]);
expect(normalizeSpecLayouts(empty).layouts?.[0].spec.items).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,67 @@
import type { Layout } from 'react-grid-layout';
// react-grid-layout has no top-level export for these; the built utils are the
// same functions GridLayout uses internally to lay items out on render.
import { compact, correctBounds } from 'react-grid-layout/build/utils';
import type {
DashboardGridItemDTO,
DashboardtypesDashboardSpecDTO,
DashboardtypesLayoutDTO,
} from 'api/generated/services/sigNoz.schemas';
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
const GRID_COLS = 12;
/**
* Compact one Grid section's items exactly as SectionGrid's react-grid-layout
* does on render (12 cols, vertical compaction): pull items up to remove gaps
* and clamp them within bounds. Item→panel refs and sizes are preserved; only
* x/y are normalized. Keyed by array index so a reorder from `compact` maps back
* cleanly.
*/
function compactItems(items: DashboardGridItemDTO[]): DashboardGridItemDTO[] {
if (items.length === 0) {
return items;
}
const layout: Layout[] = items.map((item, index) => ({
i: String(index),
x: item.x ?? 0,
y: item.y ?? 0,
w: item.width ?? 0,
h: item.height ?? 0,
}));
const compacted = compact(
correctBounds(layout, { cols: GRID_COLS }),
'vertical',
GRID_COLS,
);
const byIndex = new Map(compacted.map((entry) => [entry.i, entry]));
return items.map((item, index) => {
const entry = byIndex.get(String(index));
return entry ? { ...item, x: entry.x, y: entry.y } : item;
});
}
/**
* Normalize every Grid section's layout so the stored coordinates match what the
* grid renders. The JSON editor lets a user set arbitrary `x`/`y` (e.g. a lone
* panel at `y: 10`); react-grid-layout would still render it compacted at the
* top, leaving stored and rendered state out of sync. Compacting on save keeps
* them in agreement. Non-Grid layouts and empty sections pass through untouched.
*/
export function normalizeSpecLayouts(
spec: DashboardtypesDashboardSpecDTO,
): DashboardtypesDashboardSpecDTO {
if (!spec.layouts || spec.layouts.length === 0) {
return spec;
}
const layouts: DashboardtypesLayoutDTO[] = spec.layouts.map((layout) => {
if (layout.kind !== 'Grid' || !layout.spec.items) {
return layout;
}
return {
...layout,
spec: { ...layout.spec, items: compactItems(layout.spec.items) },
};
});
return { ...spec, layouts };
}

View File

@@ -10,6 +10,7 @@ import { toAPIError } from 'utils/errorUtils';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { normalizeSpecLayouts } from './normalizeSpecLayouts';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -158,10 +159,11 @@ export function useJsonEditor({
// The draft only carries name/tags/spec; overlay it on the current dashboard
// so the redacted fields (schemaVersion, image, …) are preserved on save.
const edited = JSON.parse(draft) as Record<string, unknown>;
await updateDashboardV2(
{ id: dashboardId },
dashboardToUpdatable({ ...dashboard, ...edited }),
);
const merged = { ...dashboard, ...edited };
// Compact each section's layout so hand-edited coordinates (e.g. a lone
// panel left at y: 10) are stored the way the grid actually renders them.
merged.spec = normalizeSpecLayouts(merged.spec);
await updateDashboardV2({ id: dashboardId }, dashboardToUpdatable(merged));
toast.success('Dashboard updated');
refetch();
onApplied();

View File

@@ -0,0 +1,20 @@
// react-grid-layout ships these layout helpers without type declarations. They
// are the same functions GridLayout uses internally, so the V2 dashboard reuses
// them to compact a section's items exactly the way they render.
declare module 'react-grid-layout/build/utils' {
import type { Layout } from 'react-grid-layout';
export type CompactType = 'horizontal' | 'vertical' | null;
export function compact(
layout: Layout[],
compactType: CompactType,
cols: number,
allowOverlap?: boolean,
): Layout[];
export function correctBounds(
layout: Layout[],
bounds: { cols: number },
): Layout[];
}