mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-22 14:10:30 +01:00
Compare commits
8 Commits
fix/genera
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4db0cdb08 | ||
|
|
67d864740f | ||
|
|
80d6496375 | ||
|
|
be9ca1b4a3 | ||
|
|
fee93a81e6 | ||
|
|
635d6215a9 | ||
|
|
529126d66d | ||
|
|
0c83d5a3f2 |
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
11
frontend/src/components/TagBadge/TagBadge.module.scss
Normal file
11
frontend/src/components/TagBadge/TagBadge.module.scss
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.variableItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 160px;
|
||||
width: 180px;
|
||||
flex-direction: column;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user