mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 00:20:40 +01:00
Compare commits
2 Commits
worktree-j
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28806970b | ||
|
|
6a851c5030 |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
20
frontend/src/typings/react-grid-layout-build-utils.d.ts
vendored
Normal file
20
frontend/src/typings/react-grid-layout-build-utils.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user