Compare commits

...

8 Commits

21 changed files with 594 additions and 52 deletions

View File

@@ -0,0 +1,21 @@
import { prioritizeOrAddOptionForMultiSelect } from '../utils';
describe('prioritizeOrAddOptionForMultiSelect ordering', () => {
it('hoists selected then preserves the given (sorted) order in each group', () => {
const sorted = ['apple', 'banana', 'cherry', 'date', 'elderberry'].map(
(v) => ({ label: v, value: v }),
);
// selection given in a non-sorted order on purpose
const result = prioritizeOrAddOptionForMultiSelect(sorted, [
'date',
'banana',
]);
expect(result.map((o) => o.value)).toStrictEqual([
'banana',
'date',
'apple',
'cherry',
'elderberry',
]);
});
});

View File

@@ -0,0 +1,11 @@
.static {
// Tag chips are not interactive, but the @signozhq Badge darkens outline
// variants on hover — which reads as clickable. Pin the hover background to the
// resting background so hovering a plain tag produces no change.
--badge-outline-hover-background-color: var(
--badge-outline-background-color,
color-mix(in oklab, var(--badge-background) 10%, transparent)
);
cursor: default;
}

View File

@@ -1,5 +1,8 @@
import { type MouseEvent, type ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import styles from './TagBadge.module.scss';
interface TagBadgeProps {
children: ReactNode;
@@ -22,7 +25,7 @@ function TagBadge({
<Badge
color="sienna"
variant="outline"
className={className}
className={cx(styles.static, className)}
closable={closable}
onClose={onClose}
>

View File

@@ -10,6 +10,7 @@ import { toAPIError } from 'utils/errorUtils';
import { dashboardToUpdatable } from './dashboardToUpdatable';
import { findPanelLayoutIssues } from './danglingPanels';
import { compactSpecLayouts } from '../../layoutCompaction';
import { useDashboardStore } from '../../store/useDashboardStore';
export interface JsonValidity {
@@ -157,7 +158,18 @@ export function useJsonEditor({
setIsSaving(true);
// 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>;
const edited = JSON.parse(draft) as Pick<
DashboardtypesGettableDashboardV2DTO,
'tags' | 'spec'
>;
// Snap hand-edited panel geometry to a non-overlapping layout so a JSON edit
// can't be rejected by the backend's no-overlap check (matches drag/resize).
if (edited.spec?.layouts) {
edited.spec = {
...edited.spec,
layouts: compactSpecLayouts(edited.spec.layouts),
};
}
await updateDashboardV2(
{ id: dashboardId },
dashboardToUpdatable({ ...dashboard, ...edited }),

View File

@@ -130,6 +130,11 @@ function VariableForm({
value={selectedPanelIds}
onChange={(value): void => setSelectedPanelIds(value as string[])}
data-testid="variable-apply-panels"
// Resolve the closed-state tags to panel names (else they show the id).
showLabels
// Anchor the dropdown to the body instead of the scrolling form
// container, so opening it near the bottom doesn't shift the layout.
getPopupContainer={(): HTMLElement => document.body}
/>
</div>
</div>
@@ -215,7 +220,7 @@ function VariableForm({
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError || !!attributeError}
disabled={!!nameError || !!attributeError || isSaving}
loading={isSaving}
onClick={handleSave}
testId="variable-save"

View File

@@ -0,0 +1,142 @@
import { act, renderHook } from '@testing-library/react';
import {
DYNAMIC_SIGNALS,
emptyVariableFormModel,
VARIABLE_SORT,
type VariableFormModel,
} from '../variableFormModel';
import { useVariableForm } from './useVariableForm';
// Mock the store (its full slice graph is huge to transform and irrelevant here;
// the hook only reads dashboardId + variableValues for the Test-Run payload).
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({ dashboardId: undefined, variableValues: {} }),
}));
function initial(overrides?: Partial<VariableFormModel>): VariableFormModel {
return {
...emptyVariableFormModel(),
type: 'QUERY',
name: 'svc',
defaultValue: 'foo',
...overrides,
};
}
const args = (
init: VariableFormModel,
): Parameters<typeof useVariableForm>[0] => ({
initial: init,
siblings: [],
isNew: false,
onSave: jest.fn(),
});
describe('useVariableForm default reset — QUERY (on Test Run)', () => {
it('keeps the default when only the sort order changes', () => {
const { result } = renderHook(() => useVariableForm(args(initial())));
act(() => result.current.setRawPreview(['foo', 'bar']));
expect(result.current.defaultValue).toBe('foo');
// order-only change doesn't touch the preview values, so it must not reset
act(() => result.current.set({ sort: VARIABLE_SORT.DESC }));
expect(result.current.defaultValue).toBe('foo');
});
it('resets the default when a Test Run returns values that no longer contain it', () => {
const { result } = renderHook(() => useVariableForm(args(initial())));
act(() => result.current.setRawPreview(['foo', 'bar']));
expect(result.current.defaultValue).toBe('foo');
act(() => result.current.setRawPreview(['bar', 'baz']));
expect(result.current.defaultValue).toBe('');
});
it('keeps the default when a Test Run still contains it', () => {
const { result } = renderHook(() => useVariableForm(args(initial())));
act(() => result.current.setRawPreview(['foo', 'bar']));
act(() => result.current.setRawPreview(['foo', 'baz', 'qux']));
expect(result.current.defaultValue).toBe('foo');
});
it('keeps the default when a re-run yields the same values (no actual change)', () => {
const { result } = renderHook(() =>
useVariableForm(args(initial({ defaultValue: 'bar' }))),
);
act(() => result.current.setRawPreview(['foo', 'bar']));
// same set again — re-running an unchanged query must not disturb the default
act(() => result.current.setRawPreview(['foo', 'bar']));
expect(result.current.defaultValue).toBe('bar');
});
});
describe('useVariableForm default reset — DYNAMIC (on attribute/signal change)', () => {
const dynamic = (overrides?: Partial<VariableFormModel>): VariableFormModel =>
initial({
type: 'DYNAMIC',
dynamicAttribute: 'service.name',
dynamicSignal: DYNAMIC_SIGNALS[1],
...overrides,
});
it('does not reset on the passive edit-open auto-fetch', () => {
// The auto-fetch populates the preview without going through onDynamicChange,
// so opening an existing variable must never clear its saved default.
const { result } = renderHook(() =>
useVariableForm(args(dynamic({ defaultValue: 'zzz' }))),
);
act(() => result.current.setRawPreview(['foo', 'bar']));
expect(result.current.defaultValue).toBe('zzz');
});
it('resets when the attribute changes', () => {
const { result } = renderHook(() => useVariableForm(args(dynamic())));
act(() => result.current.onDynamicChange({ dynamicAttribute: 'host.name' }));
expect(result.current.defaultValue).toBe('');
});
it('resets when the signal changes', () => {
const { result } = renderHook(() => useVariableForm(args(dynamic())));
act(() =>
result.current.onDynamicChange({ dynamicSignal: DYNAMIC_SIGNALS[2] }),
);
expect(result.current.defaultValue).toBe('');
});
it('keeps the default when the attribute is set to the same value', () => {
const { result } = renderHook(() => useVariableForm(args(dynamic())));
act(() =>
result.current.onDynamicChange({ dynamicAttribute: 'service.name' }),
);
expect(result.current.defaultValue).toBe('foo');
});
});
describe('useVariableForm default reset — CUSTOM (on options edit)', () => {
const custom = (overrides?: Partial<VariableFormModel>): VariableFormModel =>
initial({ type: 'CUSTOM', ...overrides });
it('resets when the edited options no longer contain the default', () => {
const { result } = renderHook(() => useVariableForm(args(custom())));
act(() => result.current.onCustomChange('bar, baz'));
expect(result.current.defaultValue).toBe('');
});
it('keeps the default when the edited options still contain it', () => {
const { result } = renderHook(() => useVariableForm(args(custom())));
act(() => result.current.onCustomChange('foo, bar, baz'));
expect(result.current.defaultValue).toBe('foo');
});
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
@@ -89,6 +89,31 @@ export function useVariableForm({
[rawPreview, model.sort],
);
// QUERY: drop a now-invalid default when the user re-runs the query and the
// returned values actually change. The query preview is populated only by the
// manual "Test Run" (never on edit-open), so keying off `rawPreview` here is
// effectively "on run"; the signature guard skips a re-run that yields the same
// values so a still-valid default is left untouched. DYNAMIC/CUSTOM resets are
// handled in their change handlers instead (see below).
const lastQueryPreviewRef = useRef<string | null>(null);
useEffect(() => {
lastQueryPreviewRef.current = null;
}, [initial]);
useEffect(() => {
if (model.type !== 'QUERY' || rawPreview.length === 0) {
return;
}
const optionValues = rawPreview.map(String);
const signature = JSON.stringify(optionValues);
if (signature === lastQueryPreviewRef.current) {
return;
}
lastQueryPreviewRef.current = signature;
setDefaultValue((current) =>
current && !optionValues.includes(current) ? '' : current,
);
}, [rawPreview, model.type]);
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
const existingDynamicAttributes = useMemo(
@@ -137,12 +162,31 @@ export function useVariableForm({
const onCustomChange = (value: string): void => {
set({ customValue: value });
setRawPreview(commaValuesParser(value));
const parsed = commaValuesParser(value);
setRawPreview(parsed);
// Editing the options list drops a default that is no longer present.
const optionValues = parsed.map(String);
setDefaultValue((current) =>
current && !optionValues.includes(current) ? '' : current,
);
};
// In add mode, mirror the selected attribute into the name until the user
// edits the name themselves (matches the V1 dynamic-variable behaviour).
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
// Changing the attribute or signal changes the value space, so drop the
// default — but only on an actual change, and never on the passive
// edit-open auto-fetch (which doesn't go through this handler).
const attributeChanged =
patch.dynamicAttribute !== undefined &&
patch.dynamicAttribute !== model.dynamicAttribute;
const signalChanged =
patch.dynamicSignal !== undefined &&
patch.dynamicSignal !== model.dynamicSignal;
if (attributeChanged || signalChanged) {
setDefaultValue('');
}
if (isNew && !nameTouched && patch.dynamicAttribute) {
set({ ...patch, name: patch.dynamicAttribute });
} else {

View File

@@ -122,11 +122,18 @@ export function useVariableListActions({
}
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
// Persist FIRST; only close the form and apply the list change once the
// backend accepts it. On failure the form stays open (save() has already
// surfaced the exact backend error) so the invalid variable is never
// silently kept.
const saved = await save(next);
if (!saved || formModel.type !== 'DYNAMIC') {
if (!saved) {
return;
}
setIsEditing(null);
setVariables(next);
if (formModel.type !== 'DYNAMIC') {
return;
}
const ops = buildSyncVariableToPanelsPatch(

View File

@@ -38,6 +38,12 @@ function SectionTitleModal({
}, [open, initialValue]);
const submit = (): void => {
// Guard against a second submit (notably the Enter key, which bypasses the OK
// button's disabled state) while the create/rename request is in flight —
// otherwise a double Enter creates two sections.
if (isSaving) {
return;
}
const trimmed = value.trim();
if (trimmed) {
onSubmit(trimmed);

View File

@@ -5,6 +5,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useOptimisticPatch } from '../../../hooks/useOptimisticPatch';
import { compactGridItems } from '../../../layoutCompaction';
import { replaceSectionItemsOp } from '../../../patchOps';
import { useDashboardStore } from '../../../store/useDashboardStore';
import type { GridItem } from '../../../utils';
@@ -74,7 +75,9 @@ export function usePersistLayout({ layoutIndex, items }: Params): Result {
if (!dashboardId) {
return;
}
const nextItems = mergeRglLayout(rglLayout, items);
// Compact to the snapped, non-overlapping layout RGL shows on-screen, so
// the persisted geometry can never trip the backend's no-overlap check.
const nextItems = compactGridItems(mergeRglLayout(rglLayout, items));
if (!hasGeometryChanged(nextItems, items)) {
return;
}

View File

@@ -107,6 +107,18 @@ describe('reconcileWithOptions', () => {
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('preserves a user-entered single value not in the options (freeform)', () => {
// A typed value that isn't among the fetched options must survive a refetch
// (e.g. a time-range change) rather than being reset to the default.
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: 'typed-value', allSelected: false },
['a', 'b'],
),
).toBeNull();
});
it('falls back to the configured default (else first) when invalid', () => {
expect(
reconcileWithOptions(

View File

@@ -1,7 +1,7 @@
.variableItem {
position: relative;
display: flex;
width: 160px;
width: 180px;
flex-direction: column;
padding-top: 7px;
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
@@ -12,6 +12,12 @@ interface ValueSelectorProps {
loading?: boolean;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/**
* Selection to commit when the multi-select closes with nothing selected —
* the variable's default (resolved against the current options), so an empty
* pick falls back to the default exactly like the first load.
*/
emptyFallback: VariableSelection;
testId?: string;
/** Option-fetch error surfaced in the dropdown, with a retry action. */
errorMessage?: string | null;
@@ -19,9 +25,11 @@ interface ValueSelectorProps {
}
/**
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the
* shared NewSelect components, which provide search, the "ALL" option and
* apply-on-close batching (so multi-select edits don't cascade per toggle).
* Single/multi value picker for Custom/Query/Dynamic variables. Reuses the shared
* NewSelect components (search + "ALL"). Multi-select edits are buffered locally
* while the dropdown is open and committed once on close, so toggles don't cascade
* to dependent variables/panels per click; closing with an empty selection commits
* {@link ValueSelectorProps.emptyFallback} instead.
*/
function ValueSelector({
options,
@@ -30,6 +38,7 @@ function ValueSelector({
loading,
selection,
onChange,
emptyFallback,
testId,
errorMessage,
onRetry,
@@ -39,46 +48,79 @@ function ValueSelector({
[options],
);
// All-selected → the full option set so CustomMultiSelect engages its "all"
// path (overlay when closed, every option checked when open). The scalar
// sentinel would instead render a literal `__ALL__` row.
const committedValues = useMemo<string[]>(
() =>
selection.allSelected
? options
: (Array.isArray(selection.value) ? selection.value : []).map(String),
[selection, options],
);
// Buffer edits while the dropdown is open; the committed selection is shown
// when closed. This defers the dependent cascade to a single commit-on-close.
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(committedValues);
const commit = (values: string[]): void => {
if (values.length === 0) {
onChange(emptyFallback);
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
};
if (multiSelect) {
// All-selected → hand CustomMultiSelect the full option set so it engages its
// "all" path (overlay when closed, every option checked when open). Passing the
// scalar sentinel instead makes it render a literal `__ALL__` row.
const value = selection.allSelected
? options
: (Array.isArray(selection.value) ? selection.value : []).map(String);
return (
<CustomMultiSelect
className={styles.control}
data-testid={testId}
options={optionData}
value={value}
value={isOpen ? draft : committedValues}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
placeholder="Select value"
maxTagCount={2}
maxTagTextLength={20}
// Show one truncated tag + a bare "+N" for the rest so the chips never
// crowd out the caret in the fixed-width control (ALL shows "ALL").
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
// Offer ALL only once options load, else a concrete value reads as "all".
enableAllSelection={showAllOption && options.length > 0}
onDropdownVisibleChange={(open): void => {
if (open) {
setDraft(committedValues);
setIsOpen(true);
return;
}
setIsOpen(false);
commit(draft);
}}
onChange={(next): void => {
const values = Array.isArray(next)
? next.map(String)
: next
? [String(next)]
: [];
if (values.length === 0) {
onChange({ value: [], allSelected: false });
return;
}
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
onChange({ value: values, allSelected: isAll });
setDraft(values);
}}
onClear={(): void => {
setDraft([]);
// A clear on the closed control falls back to the default immediately;
// while open it just empties the draft (committed on close).
if (!isOpen) {
onChange(emptyFallback);
}
}}
onClear={(): void => onChange({ value: [], allSelected: false })}
/>
);
}

View File

@@ -1,8 +1,14 @@
import { useMemo } from 'react';
import type { VariableFormModel } from '../../../DashboardSettings/Variables/variableFormModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../../selectionTypes';
import {
reconcileWithOptions,
resolveDefaultSelection,
} from '../../utils/resolveVariableSelection';
import { useAutoSelect } from '../../hooks/useAutoSelect';
import ValueSelector from './ValueSelector';
import { useVariableOptions } from '../../hooks/useVariableOptions';
@@ -41,6 +47,15 @@ function VariableValueControl({
useAutoSelect(variable, options, selection, onAutoSelect);
// The selection to fall back to when the multi-select closes empty: the
// configured default reconciled against the current options (ALL, the
// configured default, or the first option) — the same resolver the seed and
// auto-select use, so an empty pick lands exactly where the first load would.
const emptyFallback = useMemo<VariableSelection>(() => {
const seed = resolveDefaultSelection(variable);
return reconcileWithOptions(variable, seed, options) ?? seed;
}, [variable, options]);
return (
<ValueSelector
options={options}
@@ -51,6 +66,7 @@ function VariableValueControl({
onRetry={onRetry}
selection={selection}
onChange={onChange}
emptyFallback={emptyFallback}
testId={`variable-select-${variable.name}`}
/>
);

View File

@@ -53,19 +53,6 @@ function isAllDefault(
);
}
function isValidSingle(
value: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(value) &&
value !== '' &&
value !== null &&
value !== undefined &&
options.includes(String(value))
);
}
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
@@ -167,8 +154,17 @@ export function reconcileWithOptions(
: fillDefault(model, options);
}
if (!model.multiSelect && isValidSingle(current.value, options)) {
return null;
if (!model.multiSelect) {
// Preserve any non-empty single value across a refetch — including a user-typed
// value that isn't in the fetched options (freeform). Only fall back to the
// default/first option when there is no value yet, so e.g. a time-range change
// (which refetches options) never wipes a value the user didn't change.
const hasValue =
!Array.isArray(current.value) &&
current.value !== '' &&
current.value !== null &&
current.value !== undefined;
return hasValue ? null : fillDefault(model, options);
}
return fillDefault(model, options);
}

View File

@@ -0,0 +1,73 @@
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { compactGridItems, compactSpecLayouts } from '../layoutCompaction';
import type { GridItem } from '../utils';
const item = (
id: string,
x: number,
y: number,
width = 6,
height = 2,
): GridItem => ({ id, x, y, width, height, panel: undefined });
describe('compactGridItems', () => {
it('pulls a floating item up to the top', () => {
const [a] = compactGridItems([item('a', 0, 5)]);
expect(a.y).toBe(0);
});
it('resolves an overlap by pushing the colliding item down', () => {
// Order is preserved, so [0] is 'a' and [1] is 'b'.
const result = compactGridItems([item('a', 0, 0), item('b', 0, 1)]);
// a occupies rows 0-1 (height 2), so b must sit at row 2 — no overlap.
expect(result[0].y).toBe(0);
expect(result[1].y).toBe(2);
});
it('preserves item order and the panel reference', () => {
const panel = { kind: 'panel' } as unknown as GridItem['panel'];
const result = compactGridItems([
{ ...item('a', 0, 0), panel },
item('b', 6, 0),
]);
expect(result.map((i) => i.id)).toStrictEqual(['a', 'b']);
expect(result[0].panel).toBe(panel);
});
});
describe('compactSpecLayouts', () => {
const grid = (
items: { x: number; y: number; width: number; height: number; ref: string }[],
): DashboardtypesLayoutDTO =>
({
kind: 'Grid',
spec: {
items: items.map((i) => ({
x: i.x,
y: i.y,
width: i.width,
height: i.height,
content: { $ref: i.ref },
})),
},
}) as unknown as DashboardtypesLayoutDTO;
it('compacts overlapping items and keeps their panel refs', () => {
const [layout] = compactSpecLayouts([
grid([
{ x: 0, y: 0, width: 6, height: 2, ref: '#/spec/panels/a' },
{ x: 0, y: 1, width: 6, height: 2, ref: '#/spec/panels/b' },
]),
]);
const items = layout.spec?.items ?? [];
expect(items[0]?.y).toBe(0);
expect(items[1]?.y).toBe(2);
expect(items[1]?.content?.$ref).toBe('#/spec/panels/b');
});
it('passes a non-Grid layout through untouched', () => {
const other = { kind: 'Other' } as unknown as DashboardtypesLayoutDTO;
expect(compactSpecLayouts([other])[0]).toBe(other);
});
});

View File

@@ -0,0 +1,84 @@
import * as ReactGridLayout from 'react-grid-layout';
import type { Layout } from 'react-grid-layout';
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import { GRID_COLS } from './patchOps';
import type { GridItem } from './utils';
// `utils.compact` is exported by react-grid-layout at runtime — it is the exact
// vertical compaction the grid applies on-screen while dragging — but it is absent
// from the package's TypeScript types, so it is reached through a typed cast.
const { compact } = (
ReactGridLayout as unknown as {
utils: {
compact: (
layout: Layout[],
compactType: 'vertical' | 'horizontal',
cols: number,
) => Layout[];
};
}
).utils;
/** Vertically compact geometry so no two items overlap (mirrors on-screen RGL). */
function compactVertically(layout: Layout[]): Layout[] {
return compact(layout, 'vertical', GRID_COLS);
}
/**
* Snap a section's grid items to a non-overlapping, vertically-compacted layout —
* the same normalization RGL applies mid-drag — so a persisted layout can never be
* rejected by the backend's no-overlap validation. Panel refs and every other item
* field are preserved; item order is unchanged (only geometry updates).
*/
export function compactGridItems(items: GridItem[]): GridItem[] {
const compacted = compactVertically(
items.map((item) => ({
i: item.id,
x: item.x,
y: item.y,
w: item.width,
h: item.height,
})),
);
const byId = new Map(compacted.map((entry) => [entry.i, entry]));
return items.map((item) => {
const entry = byId.get(item.id);
return entry
? { ...item, x: entry.x, y: entry.y, width: entry.w, height: entry.h }
: item;
});
}
/**
* Compact every Grid section in a `spec.layouts` array — used on JSON-editor save,
* where hand-edited items can overlap. Items are keyed by index (spec items carry
* no id of their own); non-Grid or empty layouts pass through untouched.
*/
export function compactSpecLayouts(
layouts: DashboardtypesLayoutDTO[],
): DashboardtypesLayoutDTO[] {
return layouts.map((layout) => {
const items = layout?.kind === 'Grid' ? (layout.spec?.items ?? []) : [];
if (items.length === 0) {
return layout;
}
const compacted = compactVertically(
items.map((item, index) => ({
i: String(index),
x: item.x ?? 0,
y: item.y ?? 0,
w: item.width ?? 6,
h: item.height ?? 6,
})),
);
const byIndex = new Map(compacted.map((entry) => [entry.i, entry]));
const nextItems = items.map((item, index) => {
const entry = byIndex.get(String(index));
return entry
? { ...item, x: entry.x, y: entry.y, width: entry.w, height: entry.h }
: item;
});
return { ...layout, spec: { ...layout.spec, items: nextItems } };
});
}

View File

@@ -166,7 +166,7 @@ interface CreatePanelOpsArgs {
const NEW_PANEL_SIZE = { width: 6, height: 6 };
/** Columns in the section grid — mirrors `cols` on SectionGrid's GridLayout. */
const GRID_COLS = 12;
export const GRID_COLS = 12;
/** Minimal placement fields shared by grid-item DTOs and flattened `GridItem`s. */
type PlacedItem = Pick<DashboardGridItemDTO, 'x' | 'y' | 'width' | 'height'>;

View File

@@ -17,10 +17,12 @@ import {
import { useCopyToClipboard } from 'react-use';
import {
cloneDashboardV2,
getGetDashboardV2QueryKey,
invalidateListDashboardsForUserV2,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppContext } from 'providers/App/App';
@@ -99,6 +101,17 @@ function ActionsPopover({
: lockDashboardV2({ id: dashboardId }),
onSuccess: async () => {
toast.success(isLocked ? 'Dashboard unlocked' : 'Dashboard locked');
// Patch the detail-page cache too: it uses staleTime:Infinity +
// refetchOnMount:false, so without this, returning to the dashboard would
// still show the stale (pre-toggle) lock state.
const key = getGetDashboardV2QueryKey({ id: dashboardId });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: !isLocked },
});
}
await invalidateListDashboardsForUserV2(queryClient);
},
onError: (error: APIError) => {

View File

@@ -145,6 +145,28 @@
justify-content: flex-end;
}
// Cap the name tooltip so a long name wraps instead of spanning the page.
.nameTooltip {
max-width: 480px;
overflow-wrap: break-word;
}
// The "+N" tags trigger: a plain button reset so only the chip shows (clickable).
.moreTags {
padding: 0;
border: none;
background: none;
cursor: pointer;
display: inline-flex;
}
.extraTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 320px;
}
.details {
margin-top: 12px;
display: flex;

View File

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Popover } from 'antd';
import TagBadge from 'components/TagBadge/TagBadge';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
@@ -124,7 +125,12 @@ function DashboardRow({
<div className={styles.titleWithAction}>
<div className={styles.titleBlock}>
{name.length > 50 ? (
<TooltipSimple title={name} side="bottom" disableHoverableContent>
<TooltipSimple
title={name}
side="bottom"
disableHoverableContent
tooltipContentProps={{ className: styles.nameTooltip }}
>
{titleLink}
</TooltipSimple>
) : (
@@ -149,7 +155,31 @@ function DashboardRow({
<TagBadge key={tag}>{tag}</TagBadge>
))}
{tags.length > 3 && (
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
<Popover
trigger="click"
placement="bottomRight"
content={
<div
className={styles.extraTags}
onClick={(e): void => e.stopPropagation()}
role="presentation"
>
{tags.slice(3).map((tag) => (
<TagBadge key={tag}>{tag}</TagBadge>
))}
</div>
}
>
{/* Reveal the remaining tags on click — stop propagation so the
row's onClick doesn't navigate to the dashboard. */}
<button
type="button"
className={styles.moreTags}
onClick={(e): void => e.stopPropagation()}
>
<TagBadge>+{tags.length - 3}</TagBadge>
</button>
</Popover>
)}
</div>
)}