Compare commits

...

3 Commits

53 changed files with 2104 additions and 1049 deletions

View File

@@ -1,28 +1,13 @@
import { Checkbox, Select } from 'antd';
import { Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import cx from 'classnames';
import type { UpdatedWindow } from '../../types';
import type { UpdatedWindow } from '../dsl/types';
import type { CreatorOption } from '../types';
import { UPDATED_OPTIONS } from './constants';
import styles from './FilterZone.module.scss';
export interface CreatorOption {
email: string;
label: string;
}
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
any: 'Any time',
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
};
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
value: w,
label: UPDATED_LABELS[w],
}));
interface Props {
createdBy: string[];
updated: UpdatedWindow;
@@ -33,6 +18,8 @@ interface Props {
onApply: () => void;
// Clear all Created-by selections and run immediately.
onClearCreatedBy: () => void;
// Ids: `${testIdPrefix}-filter-created-by`, `-filter-updated`.
testIdPrefix: string;
disabled?: boolean;
}
@@ -44,6 +31,7 @@ function FilterChips({
onUpdatedChange,
onApply,
onClearCreatedBy,
testIdPrefix,
disabled = false,
}: Props): JSX.Element {
const creatorOptionsData = creatorOptions.map((o) => ({
@@ -70,15 +58,17 @@ function FilterChips({
optionFilterProp="label"
maxTagCount={1}
menuItemSelectedIcon={null}
data-testid="dashboards-filter-created-by"
data-testid={`${testIdPrefix}-filter-created-by`}
disabled={disabled}
onClear={onClearCreatedBy}
optionRender={(option): JSX.Element => (
<div className={styles.creatorOption}>
<Checkbox
checked={createdBy.includes(option.value as string)}
className={styles.creatorCheck}
/>
<div className={styles.creatorCheck}>
<Checkbox
color="primary"
value={createdBy.includes(option.value as string)}
/>
</div>
<span className={styles.creatorLabel}>{option.label}</span>
</div>
)}
@@ -92,7 +82,7 @@ function FilterChips({
value={updated}
options={UPDATED_OPTIONS}
optionFilterProp="label"
data-testid="dashboards-filter-updated"
data-testid={`${testIdPrefix}-filter-updated`}
disabled={disabled}
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
/>

View File

@@ -103,3 +103,8 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.error {
font-size: var(--font-size-xs);
color: var(--danger-foreground);
}

View File

@@ -0,0 +1,166 @@
import type { ReactElement, ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import type { DslGrammar, SuggestionSource } from '../dsl/types';
import SearchBar from '../SearchBar/SearchBar';
import type {
CreatorOption,
FilterAppliedPayload,
FilterAppliedType,
} from '../types';
import FilterChips from './FilterChips';
import { useFilterZoneDraft } from './useFilterZoneDraft';
import styles from './FilterZone.module.scss';
interface Props {
// The last-run query (source of truth for fetching + the dirty baseline).
query: string;
grammar: DslGrammar;
// Ids: `${testIdPrefix}-search-zone`, `-list-search*`, `-filter-zone`,
// `-filter-clear`, `-filter-created-by`, `-filter-updated`, `-query-error`.
testIdPrefix: string;
creatorOptions: CreatorOption[];
source: SuggestionSource;
// Commit (run) the draft — the only path that triggers a fetch.
onQueryChange: (value: string) => void;
placeholder?: string;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
// Rendered in the filters row after the chips.
extraFilters?: ReactNode;
// Shows Clear even when the query is empty.
hasExtraFilters?: boolean;
// Called by Clear after it empties the query.
onClearAll?: () => void;
// Shown under the search row.
error?: string;
// Wraps the search box and the filter controls (e.g. in an authz tooltip).
wrapControl?: (node: ReactElement) => ReactElement;
onSubmitted?: (hasQuery: boolean) => void;
onFilterApplied?: (
filterType: FilterAppliedType,
payload: FilterAppliedPayload,
) => void;
onCleared?: () => void;
/** Filtering needs `list`: the box and chips edit a query that cannot be run. */
disabled?: boolean;
}
const identity = (node: ReactElement): ReactElement => node;
// The filter command zone: a draft DSL query box (run with Cmd/Ctrl+Enter or the
// Run button; a dirty dot signals unrun changes) plus Created-by / Updated chips.
function FilterZone({
query,
grammar,
testIdPrefix,
creatorOptions,
source,
onQueryChange,
placeholder,
rightSlot,
extraFilters,
hasExtraFilters = false,
onClearAll,
error,
wrapControl = identity,
onSubmitted,
onFilterApplied,
onCleared,
disabled = false,
}: Props): JSX.Element {
const {
draft,
setDraft,
reflected,
dirty,
isEmpty,
run,
handleCreatedByChange,
handleUpdatedChange,
handleClearCreatedBy,
handleClear,
} = useFilterZoneDraft({
query,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
});
return (
<div className={styles.filterZone}>
<div className={styles.searchRow}>
{wrapControl(
<div
className={styles.searchInput}
data-testid={`${testIdPrefix}-search-zone`}
>
<SearchBar
value={draft}
grammar={grammar}
testIdPrefix={`${testIdPrefix}-list-search`}
placeholder={placeholder}
source={source}
dirty={dirty}
disabled={disabled}
onChange={setDraft}
onSubmit={run}
onSubmitted={onSubmitted}
/>
</div>,
)}
{rightSlot}
</div>
{error && (
<Typography.Text
className={styles.error}
testId={`${testIdPrefix}-query-error`}
>
{error}
</Typography.Text>
)}
<div className={styles.filtersRow}>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
{wrapControl(
<div
className={styles.filterControls}
data-testid={`${testIdPrefix}-filter-zone`}
>
<FilterChips
createdBy={reflected.createdBy}
updated={reflected.updated}
creatorOptions={creatorOptions}
testIdPrefix={testIdPrefix}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onApply={run}
onClearCreatedBy={handleClearCreatedBy}
disabled={disabled}
/>
{extraFilters}
{(!isEmpty || hasExtraFilters) && (
<Button
disabledTooltip={undefined}
variant="outlined"
color="secondary"
size="md"
prefix={<X size={12} />}
disabled={disabled}
onClick={handleClear}
testId={`${testIdPrefix}-filter-clear`}
>
Clear
</Button>
)}
</div>,
)}
</div>
</div>
);
}
export default FilterZone;

View File

@@ -0,0 +1,131 @@
import { act, renderHook } from '@testing-library/react';
import { useFilterZoneDraft } from '../useFilterZoneDraft';
const BASE_QUERY = "name = 'x'";
function setup(query = BASE_QUERY): {
result: { current: ReturnType<typeof useFilterZoneDraft> };
rerender: (props: { query: string }) => void;
onQueryChange: jest.Mock;
onFilterApplied: jest.Mock;
onCleared: jest.Mock;
onClearAll: jest.Mock;
} {
const onQueryChange = jest.fn();
const onFilterApplied = jest.fn();
const onCleared = jest.fn();
const onClearAll = jest.fn();
const { result, rerender } = renderHook(
({ query: q }: { query: string }) =>
useFilterZoneDraft({
query: q,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
}),
{ initialProps: { query } },
);
return {
result,
rerender,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
};
}
describe('useFilterZoneDraft', () => {
it('edits a draft without running it until run is called', () => {
const { result, onQueryChange } = setup();
act(() => result.current.setDraft("name = 'y' "));
expect(result.current.dirty).toBe(true);
expect(onQueryChange).not.toHaveBeenCalled();
act(() => result.current.run());
expect(onQueryChange).toHaveBeenCalledWith("name = 'y'");
});
it('does not run a draft that matches the last-run query', () => {
const { result, onQueryChange } = setup();
act(() => result.current.run());
expect(result.current.dirty).toBe(false);
expect(onQueryChange).not.toHaveBeenCalled();
});
it('resyncs the draft when the query changes from outside', () => {
const { result, rerender } = setup();
act(() => result.current.setDraft("name = 'typing'"));
rerender({ query: "name = 'from-view'" });
expect(result.current.draft).toBe("name = 'from-view'");
expect(result.current.dirty).toBe(false);
});
it('stages a created-by pick into the draft without running it', () => {
const { result, onQueryChange, onFilterApplied } = setup();
act(() => result.current.handleCreatedByChange(['a@x.io', 'b@x.io']));
expect(result.current.draft).toBe(
"name = 'x' AND created_by IN ['a@x.io', 'b@x.io']",
);
expect(result.current.reflected.createdBy).toStrictEqual([
'a@x.io',
'b@x.io',
]);
expect(onFilterApplied).toHaveBeenCalledWith('createdBy', { valueCount: 2 });
expect(onQueryChange).not.toHaveBeenCalled();
});
it('runs an updated-window pick immediately', () => {
const { result, onQueryChange, onFilterApplied } = setup();
act(() => result.current.handleUpdatedChange('7d'));
expect(onFilterApplied).toHaveBeenCalledWith('updatedWindow', {});
expect(onQueryChange).toHaveBeenCalledTimes(1);
expect(onQueryChange.mock.calls[0][0]).toContain("name = 'x' AND updated_at");
expect(result.current.reflected.updated).toBe('7d');
});
it('drops the created-by clause and runs when created-by is cleared', () => {
const { result, onQueryChange } = setup(
"created_by = 'a@x.io' AND name = 'x'",
);
act(() => result.current.handleClearCreatedBy());
expect(result.current.draft).toBe("name = 'x'");
expect(onQueryChange).toHaveBeenCalledWith("name = 'x'");
});
it('empties the draft and runs the empty query on clear', () => {
const { result, onQueryChange, onCleared, onClearAll } = setup();
act(() => result.current.handleClear());
expect(result.current.draft).toBe('');
expect(result.current.isEmpty).toBe(true);
expect(onCleared).toHaveBeenCalledTimes(1);
expect(onQueryChange).toHaveBeenCalledWith('');
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it('skips re-running an already-empty query on clear', () => {
const { result, onQueryChange, onClearAll } = setup('');
act(() => result.current.handleClear());
expect(onQueryChange).not.toHaveBeenCalled();
expect(onClearAll).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,15 @@
import type { UpdatedWindow } from '../dsl/types';
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
any: 'Any time',
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
};
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
export const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
value: w,
label: UPDATED_LABELS[w],
}));

View File

@@ -0,0 +1,121 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createdByClause,
parseReflectedClauses,
type ReflectedClauses,
spliceClause,
updatedClause,
} from '../dsl/filterQuery';
import type { UpdatedWindow } from '../dsl/types';
import type { FilterAppliedPayload, FilterAppliedType } from '../types';
interface Args {
query: string;
onQueryChange: (value: string) => void;
onFilterApplied?: (
filterType: FilterAppliedType,
payload: FilterAppliedPayload,
) => void;
onCleared?: () => void;
onClearAll?: () => void;
}
interface FilterZoneDraft {
draft: string;
setDraft: (value: string) => void;
reflected: ReflectedClauses;
dirty: boolean;
isEmpty: boolean;
run: () => void;
handleCreatedByChange: (emails: string[]) => void;
handleUpdatedChange: (window: UpdatedWindow) => void;
handleClearCreatedBy: () => void;
handleClear: () => void;
}
// The query box is a DRAFT: typing and the Created-by / Updated dropdowns all edit
// the draft (dropdowns splice their clause in); nothing fetches until the draft is
// run. The draft re-syncs when the last-run query changes externally (view
// select, back/forward).
export function useFilterZoneDraft({
query,
onQueryChange,
onFilterApplied,
onCleared,
onClearAll,
}: Args): FilterZoneDraft {
const [draft, setDraft] = useState(query);
useEffect(() => {
setDraft(query);
}, [query]);
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
const dirty = draft.trim() !== query.trim();
const isEmpty = !draft.trim();
const commit = useCallback(
(next: string): void => {
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
},
[query, onQueryChange],
);
const run = useCallback((): void => commit(draft), [commit, draft]);
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
// when the dropdown closes (FilterChips fires onApply), not on each pick.
const handleCreatedByChange = useCallback(
(emails: string[]): void => {
onFilterApplied?.('createdBy', { valueCount: emails.length });
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
},
[onFilterApplied],
);
// Updated (single-select) is a definitive single choice, so it splices AND runs
// immediately (with the fresh value, avoiding the async draft-state lag).
const handleUpdatedChange = useCallback(
(window: UpdatedWindow): void => {
onFilterApplied?.('updatedWindow', {});
const next = spliceClause(draft, 'updated_at', updatedClause(window));
setDraft(next);
commit(next);
},
[draft, commit, onFilterApplied],
);
// Clear-all on the Created-by select removes its clause and runs immediately.
const handleClearCreatedBy = useCallback((): void => {
const next = spliceClause(draft, 'created_by', null);
setDraft(next);
commit(next);
}, [draft, commit]);
// Clear resets the draft and runs immediately (empty query).
const handleClear = useCallback((): void => {
onCleared?.();
setDraft('');
if (query !== '') {
onQueryChange('');
}
onClearAll?.();
}, [query, onQueryChange, onCleared, onClearAll]);
return {
draft,
setDraft,
reflected,
dirty,
isEmpty,
run,
handleCreatedByChange,
handleUpdatedChange,
handleClearCreatedBy,
handleClear,
};
}

View File

@@ -176,7 +176,8 @@
.submit {
position: absolute;
top: 5px;
top: 6px;
display: flex;
right: 6px;
z-index: 1;
}

View File

@@ -15,18 +15,20 @@ import CodeMirror, {
Prec,
type ReactCodeMirrorRef,
} from '@uiw/react-codemirror';
import logEvent from 'api/common/logEvent';
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import type { DslGrammar, SuggestionSource } from '../dsl/types';
import { dslCompletionSource } from './dslCompletions';
import { dslHighlight } from './dslHighlight';
import { BASIC_SETUP, EMPTY_SOURCE, editorTheme } from './editorConfig';
import styles from './SearchBar.module.scss';
interface Props {
value: string;
grammar: DslGrammar;
// Ids: `${testIdPrefix}`, `-submit`, `-dirty`.
testIdPrefix: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
@@ -35,53 +37,20 @@ interface Props {
dirty?: boolean;
/** Filtering needs `list`: the draft edits a query that cannot be run. */
disabled?: boolean;
onSubmitted?: (hasQuery: boolean) => void;
}
const EMPTY_SOURCE: SuggestionSource = {
tagKeys: [],
tagValuesByKey: {},
creatorEmails: [],
};
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
// handles layout — transparent background, monospace, single line.
const editorTheme = EditorView.theme({
'&': { fontSize: '13px', backgroundColor: 'transparent' },
'&.cm-focused': { outline: 'none' },
'.cm-content': {
// Leave room for the overlaid lead icon (left) and Run button (right).
padding: '8px 120px 8px 30px',
fontFamily: "'Space Mono', monospace",
color: 'var(--l1-foreground)',
caretColor: 'var(--l1-foreground)',
},
'.cm-line': { padding: '0' },
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
'.cm-placeholder': { color: 'var(--l3-foreground)' },
});
const BASIC_SETUP = {
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
highlightSelectionMatches: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
// styling and doesn't blink by default).
drawSelection: false,
} as const;
function SearchBar({
value,
grammar,
testIdPrefix,
onChange,
onSubmit,
placeholder = "Filter with DSL (e.g. name CONTAINS 'foo')",
source = EMPTY_SOURCE,
dirty = false,
disabled = false,
onSubmitted,
}: Props): JSX.Element {
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const editorRef = useRef<ReactCodeMirrorRef>(null);
@@ -89,12 +58,12 @@ function SearchBar({
// Refs so the (memoised, stable) extensions always see the latest values.
const sourceRef = useRef(source);
sourceRef.current = source;
const grammarRef = useRef(grammar);
grammarRef.current = grammar;
const handleSubmit = useCallback((): void => {
void logEvent(DashboardListEvents.SearchExecuted, {
hasQuery: !!value.trim(),
});
onSubmitted?.(!!value.trim());
onSubmit();
}, [onSubmit, value]);
}, [onSubmit, onSubmitted, value]);
const onSubmitRef = useRef(handleSubmit);
onSubmitRef.current = handleSubmit;
@@ -110,7 +79,12 @@ function SearchBar({
? []
: [
autocompletion({
override: [dslCompletionSource(() => sourceRef.current)],
override: [
dslCompletionSource(
() => sourceRef.current,
() => grammarRef.current,
),
],
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 100,
@@ -168,7 +142,7 @@ function SearchBar({
basicSetup={BASIC_SETUP}
indentWithTab={false}
editable={!disabled}
data-testid="dashboards-list-search"
data-testid={testIdPrefix}
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
/>
<span
@@ -182,13 +156,13 @@ function SearchBar({
size="sm"
aria-label="Run search"
disabled={disabled}
testId="dashboards-list-search-submit"
testId={`${testIdPrefix}-submit`}
onClick={handleSubmit}
prefix={
dirty ? (
<span
className={styles.dirtyDot}
data-testid="dashboards-list-search-dirty"
data-testid={`${testIdPrefix}-dirty`}
/>
) : undefined
}

View File

@@ -7,18 +7,21 @@ import {
} from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';
import {
getSuggestions,
type SuggestionSource,
} from '../../utils/dslSuggestions';
import { getSuggestions } from '../dsl/dslSuggestions';
import type { DslGrammar, SuggestionSource } from '../dsl/types';
// `getSource` is a getter so the freshest tags/users (which load async) are read
// on every keystroke.
// Getters so the freshest keys/users (which load async) are read on every
// keystroke.
export const dslCompletionSource =
(getSource: () => SuggestionSource) =>
(getSource: () => SuggestionSource, getGrammar: () => DslGrammar) =>
(context: CompletionContext): CompletionResult | null => {
const query = context.state.doc.toString();
const { items, ctx } = getSuggestions(query, context.pos, getSource());
const { items, ctx } = getSuggestions(
query,
context.pos,
getSource(),
getGrammar(),
);
if (items.length === 0) {
return null;
}

View File

@@ -1,4 +1,4 @@
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
// CodeMirror syntax highlighting for the list-filter DSL, driven by the same
// tokenizer used for autocomplete (dslTokenizer). Each key / operator / value /
// AND-OR span gets a `cm-dsl-*` class coloured in SearchBar.module.scss.
import { RangeSetBuilder } from '@codemirror/state';
@@ -10,7 +10,7 @@ import {
type ViewUpdate,
} from '@codemirror/view';
import { scanTerm, splitTopLevelTerms } from '../../utils/dslTokenizer';
import { scanTerm, splitTopLevelTerms } from '../dsl/dslTokenizer';
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
const OP = Decoration.mark({ class: 'cm-dsl-op' });

View File

@@ -0,0 +1,40 @@
import { EditorView } from '@uiw/react-codemirror';
import type { SuggestionSource } from '../dsl/types';
export const EMPTY_SOURCE: SuggestionSource = {
dynamicKeys: [],
valuesByKey: {},
creatorEmails: [],
};
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
// handles layout — transparent background, monospace, single line.
export const editorTheme = EditorView.theme({
'&': { fontSize: '13px', backgroundColor: 'transparent' },
'&.cm-focused': { outline: 'none' },
'.cm-content': {
// Leave room for the overlaid lead icon (left) and Run button (right).
padding: '8px 120px 8px 30px',
fontFamily: "'Space Mono', monospace",
color: 'var(--l1-foreground)',
caretColor: 'var(--l1-foreground)',
},
'.cm-line': { padding: '0' },
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
'.cm-placeholder': { color: 'var(--l3-foreground)' },
});
export const BASIC_SETUP = {
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
highlightSelectionMatches: false,
bracketMatching: false,
closeBrackets: false,
autocompletion: false,
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
// styling and doesn't blink by default).
drawSelection: false,
} as const;

View File

@@ -1,36 +1,15 @@
// Frontend mirror of the backend dashboards-list filter DSL grammar
// (pkg/types/dashboardtypes/list_filter.go). There is no keys/operators API, so
// the valid keys, per-field operators, and value shapes are encoded here. Keep in
// sync with the backend allow-lists.
// Grammar-independent operator tables for the list-filter DSL. Key sets and
// field types come from a DslGrammar config mirroring the backend allow-list
// (e.g. pkg/types/dashboardtypes/list_filter.go); keep those configs in sync.
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
// Reserved keys the backend recognises as dashboard columns. Any other key is a
// tag key. (`updated_by` and `source` are NOT queryable.)
export const RESERVED_KEYS = [
'name',
'description',
'created_by',
'created_at',
'updated_at',
'locked',
] as const;
import type { DslFieldType, DslGrammar } from './types';
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
// A reserved key maps to its column type; anything else is dynamic.
export const classifyField = (grammar: DslGrammar, key: string): DslFieldType =>
grammar.fieldTypes[key.toLowerCase()] ?? 'dynamic';
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
name: 'string',
description: 'string',
created_by: 'string',
created_at: 'timestamp',
updated_at: 'timestamp',
locked: 'bool',
};
// A reserved key maps to its column type; anything else is a tag.
export const classifyField = (key: string): DslFieldType =>
RESERVED_FIELD_TYPES[key.toLowerCase()] ?? 'tag';
// String/tag comparison operators, reusing the shared operator constants
// String/dynamic comparison operators, reusing the shared operator constants
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
const STRING_OPS = [
OPERATORS['='],
@@ -45,10 +24,10 @@ const STRING_OPS = [
negateOperator(OPERATORS.IN),
];
// Valid operators per field type. Tags additionally support existence checks.
// Valid operators per field type. Dynamic keys additionally support existence checks.
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
string: STRING_OPS,
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
dynamic: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
timestamp: [
OPERATORS['='],
OPERATORS['!='],
@@ -60,6 +39,12 @@ export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
negateOperator(OPERATORS.BETWEEN),
],
bool: [OPERATORS['='], OPERATORS['!=']],
enum: [
OPERATORS['='],
OPERATORS['!='],
OPERATORS.IN,
negateOperator(OPERATORS.IN),
],
};
// Operators that take no value (the clause is complete after the operator).

View File

@@ -1,16 +1,23 @@
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
import { DASHBOARDS_DSL_GRAMMAR } from 'pages/DashboardsListPage/utils/dashboardsDslGrammar';
import { getSuggestions } from './dslSuggestions';
import type { SuggestionSource } from './types';
const source: SuggestionSource = {
tagKeys: ['env', 'team'],
tagValuesByKey: { env: ['prod', 'dev'] },
dynamicKeys: ['env', 'team'],
valuesByKey: { env: ['prod', 'dev'] },
creatorEmails: ['alice@x.io', 'bob@x.io'],
currentUserEmail: 'me@x.io',
};
const labels = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source).items.map((i) => i.label);
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
(i) => i.label,
);
const inserts = (q: string, caret: number): string[] =>
getSuggestions(q, caret, source).items.map((i) => i.insertText);
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
(i) => i.insertText,
);
describe('getSuggestions — key stage', () => {
it('suggests reserved + tag keys matching the partial', () => {
@@ -61,7 +68,12 @@ describe('getSuggestions — operator stage', () => {
describe('getSuggestions — value stage', () => {
it('suggests creator emails (current user first, labelled) for created_by', () => {
const out = getSuggestions('created_by = ', 13, source);
const out = getSuggestions(
'created_by = ',
13,
source,
DASHBOARDS_DSL_GRAMMAR,
);
expect(out.items[0].label).toBe('me@x.io (me)');
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
});
@@ -86,7 +98,7 @@ describe('getSuggestions — value stage', () => {
describe('getSuggestions — IN list values', () => {
it('wraps a fresh IN value in a bracketed list, caret before the close', () => {
const q = 'created_by IN ';
const out = getSuggestions(q, q.length, source);
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
const alice = out.items.find((i) => i.label === 'alice@x.io');
expect(alice?.insertText).toBe("['alice@x.io']");
expect(alice?.caretOffset).toBe(1);
@@ -100,7 +112,7 @@ describe('getSuggestions — IN list values', () => {
it('appends to an open list and excludes already-entered values', () => {
const q = "env IN ['prod', ]";
const caret = q.indexOf(']'); // inside the brackets, after "'prod', "
const out = getSuggestions(q, caret, source);
const out = getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR);
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
const dev = out.items[0];
// No closing bracket (the existing `]` stays); caret lands at the end.
@@ -110,7 +122,7 @@ describe('getSuggestions — IN list values', () => {
it('treats a complete last literal (no trailing comma) as entered', () => {
const q = "env IN ['prod'";
const out = getSuggestions(q, q.length, source);
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
// 'prod' is committed; only 'dev' remains and it appends after it.
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
expect(out.items[0].insertText).toBe("['prod', 'dev'");

View File

@@ -1,48 +1,28 @@
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
// Stage-aware autocomplete for the list-filter DSL: as the caret moves, it
// suggests keys, then operators (valid for that key's field type), then values
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
// caret/stage detection in dslTokenizer.ts.
// (drawn from the dynamic keys/users the API reports). Operator tables live in
// dslGrammar.ts; caret/stage detection in dslTokenizer.ts.
import {
classifyField,
LIST_OPERATORS,
literal,
OPERATOR_MATRIX,
RESERVED_KEYS,
VALUELESS_OPERATORS,
} from './dslGrammar';
import { type CaretContext, getCaretContext } from './dslTokenizer';
import type {
DslGrammar,
Suggestion,
SuggestionSource,
SuggestionsResult,
} from './types';
// Show the full key set (the popup scrolls); values are capped a bit higher too.
const KEY_LIMIT = 100;
const VALUE_LIMIT = 50;
export interface SuggestionSource {
// Reserved column keys (from the list response, falling back to RESERVED_KEYS).
reservedKeys?: string[];
tagKeys: string[];
// Known values per tag key (lower-cased key → values).
tagValuesByKey: Record<string, string[]>;
// Creator emails for `created_by` value suggestions.
creatorEmails: string[];
currentUserEmail?: string;
}
export interface Suggestion {
label: string;
insertText: string;
kind: 'key' | 'operator' | 'value' | 'connector';
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
// are distinguishable from org tag keys.
detail?: string;
// Chars to move the caret back from the end of `insertText` after applying.
// Used to land the caret inside a `[...]` list so multi-select can continue.
caretOffset?: number;
}
export interface SuggestionsResult {
items: Suggestion[];
ctx: CaretContext;
}
// Reserved keys whose values are user emails.
const USER_KEYS = new Set(['created_by', 'updated_by']);
const includesInsensitive = (haystack: string, needle: string): boolean =>
haystack.toLowerCase().includes(needle.toLowerCase());
@@ -52,17 +32,22 @@ const dedupe = (values: string[]): string[] => Array.from(new Set(values));
const keySuggestions = (
partial: string,
source: SuggestionSource,
grammar: DslGrammar,
): Suggestion[] => {
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
const hidden = new Set(grammar.hiddenKeys ?? []);
const reserved = (source.reservedKeys ?? [...grammar.reservedKeys]).filter(
(k) => !hidden.has(k),
);
const reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
// Show reserved keys in our defined (purpose) order — name, description, … —
// rather than the API's alphabetical order; any extra API keys follow, then tags.
const known = RESERVED_KEYS as readonly string[];
// Show reserved keys in the grammar's (purpose) order — name, description, … —
// rather than the API's alphabetical order; any extra API keys follow, then
// dynamic keys.
const known = grammar.reservedKeys;
const orderedReserved = [
...known.filter((k) => reserved.includes(k)),
...reserved.filter((k) => !known.includes(k)),
];
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
const keys = dedupe([...orderedReserved, ...source.dynamicKeys]);
const lower = partial.toLowerCase();
return keys
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
@@ -71,16 +56,19 @@ const keySuggestions = (
label: k,
insertText: `${k} `,
kind: 'key',
// Distinguish built-in columns from org tag keys in the popup.
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
// Distinguish built-in columns from dynamic keys in the popup.
detail: reservedSet.has(k.toLowerCase())
? 'field'
: grammar.dynamicKey.detail,
}));
};
const operatorSuggestions = (
fieldKey: string,
partial: string,
grammar: DslGrammar,
): Suggestion[] => {
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
const ops = OPERATOR_MATRIX[classifyField(grammar, fieldKey)];
const upper = partial.toUpperCase();
return ops
.filter((op) => op.startsWith(upper))
@@ -155,15 +143,39 @@ const parseListPartial = (partial: string): ListPartial => {
};
};
// Known values for a key: user emails, enum values, or the dynamic key's values
// (a reserved dynamic key falls back to its prefixed alias, e.g. `labels.severity`).
const knownValues = (
key: string,
source: SuggestionSource,
grammar: DslGrammar,
): string[] | null => {
const type = classifyField(grammar, key);
if (USER_KEYS.has(key) && type === 'string') {
return source.currentUserEmail
? dedupe([source.currentUserEmail, ...source.creatorEmails])
: source.creatorEmails;
}
if (type === 'enum') {
return [...(grammar.enumValues?.[key] ?? [])];
}
if (type === 'dynamic') {
const alias = `${grammar.dynamicKey.prefix}${key}`.toLowerCase();
return source.valuesByKey[key] ?? source.valuesByKey[alias] ?? [];
}
return null;
};
const valueSuggestions = (
ctx: CaretContext,
source: SuggestionSource,
grammar: DslGrammar,
): Suggestion[] => {
if (VALUELESS_OPERATORS.has(ctx.operator)) {
return [];
}
const key = ctx.fieldKey.toLowerCase();
const type = classifyField(ctx.fieldKey);
const type = classifyField(grammar, ctx.fieldKey);
// List operators (`IN`/`NOT IN`) take a `[...]` list: parse what's already in
// the brackets so we exclude entered values and append rather than replace.
@@ -180,15 +192,8 @@ const valueSuggestions = (
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
}
let raw: string[] = [];
if (key === 'created_by') {
const emails = source.currentUserEmail
? dedupe([source.currentUserEmail, ...source.creatorEmails])
: source.creatorEmails;
raw = emails;
} else if (type === 'tag') {
raw = source.tagValuesByKey[key] ?? [];
} else {
const raw = knownValues(key, source, grammar);
if (raw === null) {
// name / description / timestamps have no known value set — free text.
return [];
}
@@ -199,7 +204,7 @@ const valueSuggestions = (
.slice(0, VALUE_LIMIT)
.map((v) => {
const label =
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
v === source.currentUserEmail && USER_KEYS.has(key) ? `${v} (me)` : v;
if (isList) {
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
return {
@@ -239,15 +244,16 @@ export const getSuggestions = (
query: string,
caret: number,
source: SuggestionSource,
grammar: DslGrammar,
): SuggestionsResult => {
const ctx = getCaretContext(query, caret);
let items: Suggestion[] = [];
if (ctx.stage === 'key') {
items = keySuggestions(ctx.partial, source);
items = keySuggestions(ctx.partial, source, grammar);
} else if (ctx.stage === 'operator') {
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
items = operatorSuggestions(ctx.fieldKey, ctx.partial, grammar);
} else if (ctx.stage === 'value') {
items = valueSuggestions(ctx, source);
items = valueSuggestions(ctx, source, grammar);
} else if (ctx.stage === 'connector') {
items = connectorSuggestions(ctx.partial);
}

View File

@@ -1,4 +1,4 @@
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
// Quote/paren/bracket-aware scanning of the list-filter DSL, shared by
// the autocomplete engine (dslSuggestions) and the clause splicer (filterQuery).
// The DSL is a boolean composition of `key OP value` terms joined by AND/OR.
import {

View File

@@ -1,5 +1,5 @@
// Pure, side-effect-free helpers over the backend list-filter DSL string
// (`GET /api/v2/dashboards?query=...`). The query string is the single source of
// (e.g. `GET /api/v2/dashboards?query=...`). The query string is the single source of
// truth; these build/parse/splice clauses for it. Free of React so the same
// logic backs live filtering, saved views, and built-in views.
//
@@ -7,11 +7,11 @@
// name CONTAINS 'text'
// created_by = 'a@b.com' | created_by IN ['a@b.com', 'c@d.com']
// updated_at >= '2026-06-01T00:00:00.000Z' (RFC3339)
// env = 'prod' (any non-reserved key is a tag filter)
// env = 'prod' (any non-reserved key is a dynamic filter, e.g. a tag)
// locked = true
import dayjs from 'dayjs';
import type { UpdatedWindow } from '../types';
import type { UpdatedWindow } from './types';
import { literal } from './dslGrammar';
import { scanTerm, splitTopLevelTerms } from './dslTokenizer';

View File

@@ -0,0 +1,50 @@
import type { CaretContext } from './dslTokenizer';
// `dynamic` keys (tags, labels) take string ops plus EXISTS / NOT EXISTS.
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'enum' | 'dynamic';
// A list-filter DSL grammar, mirroring a backend `list_filter.go` allow-list.
export interface DslGrammar {
// Reserved keys in suggestion order.
reservedKeys: readonly string[];
// Reserved key -> type. Any key not listed here is dynamic.
fieldTypes: Record<string, DslFieldType>;
// `detail` labels dynamic keys in the popup. A reserved dynamic key (e.g.
// rules' `severity`) aliases `${prefix}${key}` for value lookups.
dynamicKey: { detail: string; prefix: string };
enumValues?: Record<string, readonly string[]>;
// Advertised by the API but not filterable (e.g. `labels.<key>`).
hiddenKeys?: readonly string[];
}
export interface SuggestionSource {
// Reserved keys from the list response, falling back to the grammar's.
reservedKeys?: string[];
// Dynamic keys exactly as typed (e.g. `team`, `labels.team`).
dynamicKeys: string[];
// Known values per dynamic key (lower-cased full key -> values).
valuesByKey: Record<string, string[]>;
// Values for `created_by` / `updated_by`.
creatorEmails: string[];
currentUserEmail?: string;
}
export interface Suggestion {
label: string;
insertText: string;
kind: 'key' | 'operator' | 'value' | 'connector';
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
// are distinguishable from dynamic keys.
detail?: string;
// Chars to move the caret back from the end of `insertText` after applying.
// Used to land the caret inside a `[...]` list so multi-select can continue.
caretOffset?: number;
}
export interface SuggestionsResult {
items: Suggestion[];
ctx: CaretContext;
}
// Relative "updated within" windows offered by the Updated filter dropdown.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';

View File

@@ -0,0 +1,9 @@
export interface CreatorOption {
email: string;
label: string;
}
export type FilterAppliedType = 'createdBy' | 'updatedWindow';
// Analytics payload for a chip change (`valueCount` for Created by).
export type FilterAppliedPayload = Record<string, unknown>;

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useListUsers } from 'api/generated/services/users';
import type { CreatorOption } from '../components/FilterZone/FilterChips';
import type { CreatorOption } from './types';
interface Args {
currentUserEmail: string;

View File

@@ -0,0 +1,46 @@
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
}
.headingBlock {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.grow {
flex: 1;
}
.dirtyDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning-background);
flex: none;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,21 +1,20 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import NewDashboardButton from './NewDashboardButton';
import styles from './DashboardsList.module.scss';
import styles from './ListCommandHeader.module.scss';
interface Props {
label: string;
count: number;
isModified: boolean;
onCreate: () => void;
action?: ReactNode;
}
function CommandHeader({
function ListCommandHeader({
label,
count,
isModified,
onCreate,
action,
}: Props): JSX.Element {
return (
<div className={styles.commandHeader}>
@@ -25,9 +24,9 @@ function CommandHeader({
<span className={styles.countPill}>{count}</span>
</div>
<div className={styles.grow} />
<NewDashboardButton onClick={onCreate} />
{action}
</div>
);
}
export default CommandHeader;
export default ListCommandHeader;

View File

@@ -2,20 +2,25 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { PanelLeftClose, PanelLeftOpen } from '@signozhq/icons';
import styles from './StatusBar.module.scss';
import styles from './ListStatusBar.module.scss';
interface Props {
collapsed: boolean;
onToggleCollapse: () => void;
count: number;
total: number;
/** Plural noun for the counted items, e.g. "dashboards". */
noun: string;
testId: string;
}
function StatusBar({
function ListStatusBar({
collapsed,
onToggleCollapse,
count,
total,
noun,
testId,
}: Props): JSX.Element {
return (
<div className={styles.statusBar}>
@@ -27,15 +32,15 @@ function StatusBar({
collapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />
}
onClick={onToggleCollapse}
testId="dashboards-rail-toggle"
testId={testId}
>
{collapsed ? 'Expand' : 'Collapse'}
</Button>
<Typography.Text className={styles.count}>
{count} of {total} dashboards
{count} of {total} {noun}
</Typography.Text>
</div>
);
}
export default StatusBar;
export default ListStatusBar;

View File

@@ -9,10 +9,6 @@
transition: width 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
}
.denied {
padding: 16px 12px;
}
.collapsed {
width: 0;
border-right-color: transparent;
@@ -195,30 +191,3 @@
flex-wrap: wrap;
gap: 6px;
}
.savePopover {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
padding: 4px;
}
.saveTitle {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--l1-foreground);
}
.saveLabel {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
margin-top: 2px;
}
.saveActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,264 @@
import {
type ChangeEvent,
type ReactElement,
useCallback,
useState,
} from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Bookmark, Plus, Search } from '@signozhq/icons';
import cx from 'classnames';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
import type { BuiltinListView, ViewSaveMode } from '../types';
import ViewsRailDirtyPanel from './ViewsRailDirtyPanel';
import ViewsRailItem from './ViewsRailItem';
import styles from './ViewsRail.module.scss';
interface Props {
activeViewId: string;
builtinViews: BuiltinListView[];
customViews: { id: string; name: string }[];
customViewsLoading: boolean;
isCustomActive: boolean;
isModified: boolean;
collapsed?: boolean;
onSelect: (id: string) => void;
onSave: (name: string) => void;
onSaveChanges: () => void;
onReset: () => void;
onDelete: (id: string) => Promise<void>;
onRename: (id: string, name: string) => void;
/** Prefixes the rail's test ids, e.g. `<prefix>-view-<id>`. */
testIdPrefix: string;
saveTestIdPrefix?: string;
renameTestIdPrefix?: string;
customSectionLabel?: string;
deleteDescription?: string;
/** Wraps the rail body, e.g. in an authz guard. */
guard?: (content: ReactElement) => ReactElement;
onViewSaved?: (mode: ViewSaveMode) => void;
onViewRenamed?: () => void;
onViewDeleted?: () => void;
}
// Purely presentational — active view, dirty state, and handlers come from
// `useListViews`.
function ViewsRail({
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
collapsed = false,
onSelect,
onSave,
onSaveChanges,
onReset,
onDelete,
onRename,
testIdPrefix,
saveTestIdPrefix = 'save-view',
renameTestIdPrefix = 'rename-view',
customSectionLabel = 'My views',
deleteDescription = 'This removes the saved view.',
guard,
onViewSaved,
onViewRenamed,
onViewDeleted,
}: Props): JSX.Element {
const [saveOpen, setSaveOpen] = useState(false);
const [query, setQuery] = useState('');
const { contextHolder, confirmDelete } = useDeleteConfirm();
const q = query.trim().toLowerCase();
const matchesQuery = (label: string): boolean =>
!q || label.toLowerCase().includes(q);
const personal = builtinViews.filter(
(v) => v.section === 'personal' && matchesQuery(v.label),
);
const system = builtinViews.filter(
(v) => v.section === 'system' && matchesQuery(v.label),
);
const custom = customViews.filter((v) => matchesQuery(v.name));
const noMatches =
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
const onConfirmDelete = useCallback(
(id: string, label: string): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Delete the{' '}
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
view?
</Typography.Title>
),
content: deleteDescription,
// Return the delete promise so the modal's Delete button stays loading
// until the backend call settles, then closes — matching dashboard delete.
onConfirm: (): Promise<void> => {
onViewDeleted?.();
return onDelete(id);
},
});
},
[confirmDelete, deleteDescription, onDelete, onViewDeleted],
);
const handleRename = useCallback(
(id: string, name: string): void => {
onViewRenamed?.();
onRename(id, name);
},
[onRename, onViewRenamed],
);
const handleSaveAsView = (name: string): void => {
onViewSaved?.('new');
onSave(name);
};
const handleSaveViewChanges = (): void => {
onViewSaved?.('update');
onSaveChanges();
};
const renderBuiltin = (v: BuiltinListView): JSX.Element => (
<ViewsRailItem
key={v.id}
id={v.id}
label={v.label}
icon={v.icon}
active={v.id === activeViewId}
isModified={isModified}
testIdPrefix={testIdPrefix}
renameTestIdPrefix={renameTestIdPrefix}
onSelect={onSelect}
/>
);
const content = (
<>
<div className={styles.header}>
<h4 className={styles.headerTitle}>Views</h4>
<ViewNamePopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSubmit={handleSaveAsView}
title="Save as view"
confirmLabel="Save view"
testIdPrefix={saveTestIdPrefix}
tooltip="Save current filters as a view"
trigger={
<Button
aria-label="Save current filters as a view"
variant="ghost"
color="secondary"
size="sm"
icon
testId={`${testIdPrefix}-view-save-trigger`}
>
<Plus size={14} />
</Button>
}
/>
</div>
<div className={styles.search}>
<Input
value={query}
placeholder="Filter views by name"
prefix={<Search size={12} />}
testId={`${testIdPrefix}-view-search`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setQuery(e.target.value)
}
/>
</div>
<div className={styles.scroll}>
{personal.length > 0 && (
<>
<div className={styles.groupLabel}>Personal</div>
{personal.map(renderBuiltin)}
</>
)}
{system.length > 0 && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
System
</div>
{system.map(renderBuiltin)}
</>
)}
{(!q || custom.length > 0) && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
{customSectionLabel}
<Typography.Text className={styles.groupCount}>
{customViews.length}
</Typography.Text>
</div>
{customViewsLoading ? (
<div className={styles.empty}>Loading views…</div>
) : customViews.length === 0 ? (
<div className={styles.empty}>
No saved views yet. Filter the list, then save it as a view.
</div>
) : (
custom.map((v) => (
<ViewsRailItem
key={v.id}
id={v.id}
label={v.name}
icon={Bookmark}
active={v.id === activeViewId}
isModified={isModified}
testIdPrefix={testIdPrefix}
renameTestIdPrefix={renameTestIdPrefix}
onSelect={onSelect}
onRename={handleRename}
onDelete={onConfirmDelete}
/>
))
)}
</>
)}
{noMatches && (
<div className={styles.searchEmpty}>
No views match &ldquo;{query}&rdquo;
</div>
)}
</div>
{isModified && (
<ViewsRailDirtyPanel
isCustomActive={isCustomActive}
testIdPrefix={testIdPrefix}
onSaveChanges={handleSaveViewChanges}
onSaveAs={(): void => setSaveOpen(true)}
onReset={onReset}
/>
)}
{contextHolder}
</>
);
return (
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
{guard ? guard(content) : content}
</aside>
);
}
export default ViewsRail;

View File

@@ -0,0 +1,87 @@
import { Button } from '@signozhq/ui/button';
import { Plus } from '@signozhq/icons';
import cx from 'classnames';
import styles from './ViewsRail.module.scss';
interface Props {
isCustomActive: boolean;
testIdPrefix: string;
onSaveChanges: () => void;
onSaveAs: () => void;
onReset: () => void;
}
function ViewsRailDirtyPanel({
isCustomActive,
testIdPrefix,
onSaveChanges,
onSaveAs,
onReset,
}: Props): JSX.Element {
if (isCustomActive) {
return (
<div className={styles.dirtyPanel}>
<div className={styles.dirtyTitle}>Unsaved changes</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
onClick={onSaveChanges}
testId={`${testIdPrefix}-view-save-changes`}
>
Save
</Button>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={onSaveAs}
testId={`${testIdPrefix}-view-save-as`}
>
Save as…
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={onReset}
testId={`${testIdPrefix}-view-reset`}
>
Reset
</Button>
</div>
</div>
);
}
return (
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
<div className={styles.dirtyTitle}>Filters active</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={12} />}
onClick={onSaveAs}
testId={`${testIdPrefix}-view-save-as-new`}
>
Save as new view
</Button>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={onReset}
testId={`${testIdPrefix}-view-reset`}
>
Reset
</Button>
</div>
</div>
);
}
export default ViewsRailDirtyPanel;

View File

@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { PenLine, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
import type { ListViewIcon } from '../types';
import styles from './ViewsRail.module.scss';
interface Props {
id: string;
label: string;
icon: ListViewIcon;
active: boolean;
isModified: boolean;
testIdPrefix: string;
renameTestIdPrefix: string;
onSelect: (id: string) => void;
/** Present only for custom (editable) views. */
onRename?: (id: string, name: string) => void;
onDelete?: (id: string, label: string) => void;
}
function ViewsRailItem({
id,
label,
icon: Icon,
active,
isModified,
testIdPrefix,
renameTestIdPrefix,
onSelect,
onRename,
onDelete,
}: Props): JSX.Element {
const [renaming, setRenaming] = useState(false);
return (
<div className={cx(styles.row, { [styles.rowActive]: active })}>
<Button
size="md"
variant="ghost"
color="secondary"
width="100%"
onClick={(): void => onSelect(id)}
testId={`${testIdPrefix}-view-${id}`}
prefix={<Icon size={16} className={styles.itemIcon} />}
suffix={
active && isModified ? (
<div className={styles.dirtyDot} title="Unsaved changes" />
) : undefined
}
>
{label}
</Button>
{onRename && onDelete && (
<div className={styles.itemActions}>
<ViewNamePopover
open={renaming}
onOpenChange={setRenaming}
onSubmit={(name): void => onRename(id, name)}
title="Rename view"
confirmLabel="Rename"
initialName={label}
testIdPrefix={renameTestIdPrefix}
tooltip="Rename view"
trigger={
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Rename view"
testId={`${testIdPrefix}-view-rename-${id}`}
onClick={(e): void => e.stopPropagation()}
>
<PenLine size={12} />
</Button>
}
/>
<Tooltip title="Delete view">
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Delete view"
testId={`${testIdPrefix}-view-delete-${id}`}
onClick={(e): void => {
e.stopPropagation();
onDelete(id, label);
}}
>
<Trash2 size={12} />
</Button>
</Tooltip>
</div>
)}
</div>
);
}
export default ViewsRailItem;

View File

@@ -4,21 +4,21 @@ import { render, screen } from 'tests/test-utils';
import { setupAuthzAllow } from 'lib/authz/utils/authz-test-utils';
import { DashboardListPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import type { BuiltinView } from '../../../utils/views';
import ViewsRailGuard from 'pages/DashboardsListPage/components/DashboardsList/ViewsRailGuard';
import type { BuiltinListView } from '../../types';
import ViewsRail from '../ViewsRail';
const BUILTIN_VIEWS: BuiltinView[] = [
const BUILTIN_VIEWS: BuiltinListView[] = [
{
id: 'all',
label: 'All dashboards',
icon: Bookmark,
section: 'system',
} as BuiltinView,
},
];
const CUSTOM_VIEWS = [
{ id: 'view-1', name: 'My prod dashboards', query: '', userEmail: 'a@b.co' },
];
const CUSTOM_VIEWS = [{ id: 'view-1', name: 'My prod dashboards' }];
function renderRail(
props?: Partial<React.ComponentProps<typeof ViewsRail>>,
@@ -27,7 +27,7 @@ function renderRail(
<ViewsRail
activeViewId="view-1"
builtinViews={BUILTIN_VIEWS}
customViews={CUSTOM_VIEWS as never}
customViews={CUSTOM_VIEWS}
customViewsLoading={false}
isCustomActive
isModified
@@ -37,6 +37,8 @@ function renderRail(
onReset={jest.fn()}
onDelete={jest.fn()}
onRename={jest.fn()}
testIdPrefix="dashboards"
guard={(content): JSX.Element => <ViewsRailGuard>{content}</ViewsRailGuard>}
{...props}
/>,
);

View File

@@ -0,0 +1,147 @@
import { Bookmark, Pin } from '@signozhq/icons';
import { fireEvent, render, screen } from 'tests/test-utils';
import type { BuiltinListView } from '../../types';
import ViewsRail from '../ViewsRail';
const PREFIX = 'things';
const BUILTIN_VIEWS: BuiltinListView[] = [
{ id: 'mine', label: 'My things', icon: Pin, section: 'personal' },
{ id: 'all', label: 'All things', icon: Bookmark, section: 'system' },
];
const CUSTOM_VIEWS = [
{ id: 'view-1', name: 'Prod things' },
{ id: 'view-2', name: 'Staging things' },
];
type Props = React.ComponentProps<typeof ViewsRail>;
function renderRail(props?: Partial<Props>): Props {
const allProps: Props = {
activeViewId: 'all',
builtinViews: BUILTIN_VIEWS,
customViews: CUSTOM_VIEWS,
customViewsLoading: false,
isCustomActive: false,
isModified: false,
onSelect: jest.fn(),
onSave: jest.fn(),
onSaveChanges: jest.fn(),
onReset: jest.fn(),
onDelete: jest.fn(),
onRename: jest.fn(),
testIdPrefix: PREFIX,
...props,
};
render(<ViewsRail {...allProps} />);
return allProps;
}
describe('ViewsRail', () => {
it('groups built-in views by section and custom views under their label', () => {
renderRail({ customSectionLabel: 'Team views' });
expect(screen.getByText('Personal')).toBeInTheDocument();
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.getByText('Team views')).toBeInTheDocument();
['mine', 'all', 'view-1', 'view-2'].forEach((id) =>
expect(screen.getByTestId(`${PREFIX}-view-${id}`)).toBeInTheDocument(),
);
});
it('selects a view by id', () => {
const props = renderRail();
fireEvent.click(screen.getByTestId(`${PREFIX}-view-view-2`));
expect(props.onSelect).toHaveBeenCalledWith('view-2');
});
it('filters views by name and says when none match', () => {
renderRail();
const search = screen.getByTestId(`${PREFIX}-view-search`);
fireEvent.change(search, { target: { value: 'prod' } });
expect(screen.getByTestId(`${PREFIX}-view-view-1`)).toBeInTheDocument();
expect(screen.queryByTestId(`${PREFIX}-view-view-2`)).not.toBeInTheDocument();
expect(screen.queryByTestId(`${PREFIX}-view-all`)).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: 'nothing' } });
expect(screen.getByText(/No views match/)).toBeInTheDocument();
});
it('shows loading and empty states for custom views', () => {
const { unmount } = render(
<ViewsRail
activeViewId="all"
builtinViews={BUILTIN_VIEWS}
customViews={[]}
customViewsLoading
isCustomActive={false}
isModified={false}
onSelect={jest.fn()}
onSave={jest.fn()}
onSaveChanges={jest.fn()}
onReset={jest.fn()}
onDelete={jest.fn()}
onRename={jest.fn()}
testIdPrefix={PREFIX}
/>,
);
expect(screen.getByText('Loading views…')).toBeInTheDocument();
unmount();
renderRail({ customViews: [] });
expect(screen.getByText(/No saved views yet/)).toBeInTheDocument();
});
it('hides the unsaved-changes panel while filters match the view', () => {
renderRail();
expect(screen.queryByTestId(`${PREFIX}-view-reset`)).not.toBeInTheDocument();
});
it('offers save, save as and reset for a modified custom view', () => {
const onViewSaved = jest.fn();
const props = renderRail({
activeViewId: 'view-1',
isCustomActive: true,
isModified: true,
onViewSaved,
});
fireEvent.click(screen.getByTestId(`${PREFIX}-view-save-changes`));
expect(onViewSaved).toHaveBeenCalledWith('update');
expect(props.onSaveChanges).toHaveBeenCalledTimes(1);
expect(screen.getByTestId(`${PREFIX}-view-save-as`)).toBeInTheDocument();
fireEvent.click(screen.getByTestId(`${PREFIX}-view-reset`));
expect(props.onReset).toHaveBeenCalledTimes(1);
});
it('offers only save as new for a modified built-in view', () => {
renderRail({ isModified: true });
expect(screen.getByTestId(`${PREFIX}-view-save-as-new`)).toBeInTheDocument();
expect(
screen.queryByTestId(`${PREFIX}-view-save-changes`),
).not.toBeInTheDocument();
});
it('wraps its body in the guard it is given', () => {
renderRail({
guard: (content): JSX.Element => (
<div data-testid="rail-guard">{content}</div>
),
});
expect(screen.getByTestId('rail-guard')).toContainElement(
screen.getByTestId(`${PREFIX}-view-all`),
);
});
});

View File

@@ -0,0 +1,27 @@
import type { Pin } from '@signozhq/icons';
// All @signozhq icons share this component type.
export type ListViewIcon = typeof Pin;
export interface BuiltinListView {
id: string;
label: string;
icon: ListViewIcon;
section: 'personal' | 'system';
}
export interface SavedListView<F> {
id: string;
name: string;
filter: F;
}
export interface SavedListViewsApi<F> {
views: SavedListView<F>[];
isLoading: boolean;
createView: (name: string, filter: F) => Promise<SavedListView<F> | null>;
updateView: (id: string, name: string, filter: F) => void;
deleteView: (id: string) => Promise<void>;
}
export type ViewSaveMode = 'new' | 'update';

View File

@@ -0,0 +1,26 @@
.savePopover {
display: flex;
flex-direction: column;
gap: 8px;
width: 280px;
padding: 4px;
}
.saveTitle {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--l1-foreground);
}
.saveLabel {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
margin-top: 2px;
}
.saveActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from './ViewsRail.module.scss';
import styles from './ViewNamePopover.module.scss';
export const VIEW_NAME_MAX_LENGTH = 64;

View File

@@ -0,0 +1,175 @@
import { type ReactNode, useState } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import type {
SavedListView,
SavedListViewsApi,
} from 'components/ListViews/types';
import { useListViews } from '../useListViews';
import type { UseListViewsResult } from '../types';
interface Filter {
query: string;
sort: string;
}
const BUILTIN_QUERIES: Record<string, string> = { all: '', locked: 'locked' };
const builtinFilter = (id: string, current: Filter): Filter | null =>
id in BUILTIN_QUERIES ? { ...current, query: BUILTIN_QUERIES[id] } : null;
const isEqual = (a: Filter, b: Filter): boolean =>
a.query.trim() === b.query.trim();
const CUSTOM: SavedListView<Filter> = {
id: 'v1',
name: 'Prod',
filter: { query: 'env = prod', sort: 'name' },
};
function makeApi(): jest.Mocked<SavedListViewsApi<Filter>> {
return {
views: [CUSTOM],
isLoading: false,
createView: jest.fn(async (name: string, filter: Filter) => ({
id: 'new',
name,
filter,
})),
updateView: jest.fn(),
deleteView: jest.fn(async (_id: string): Promise<void> => undefined),
};
}
interface Harness extends UseListViewsResult<Filter> {
filter: Filter;
setFilter: (f: Filter) => void;
}
function setup(
api: SavedListViewsApi<Filter>,
searchParams = '',
initial: Filter = { query: '', sort: 'updated' },
): { result: { current: Harness } } {
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
{children}
</NuqsTestingAdapter>
);
return renderHook(
(): Harness => {
const [filter, setFilter] = useState<Filter>(initial);
const views = useListViews<Filter>({
filter,
applyFilter: setFilter,
isEqual,
builtinFilter,
defaultViewId: 'all',
api,
});
return { ...views, filter, setFilter };
},
{ wrapper },
);
}
describe('useListViews', () => {
it('defaults to the default view and is clean', () => {
const { result } = setup(makeApi());
expect(result.current.activeViewId).toBe('all');
expect(result.current.isCustomActive).toBe(false);
expect(result.current.isModified).toBe(false);
});
it('selecting a builtin applies its filter, keeping the rest of the current filter', () => {
const { result } = setup(makeApi());
act(() => result.current.selectView('locked'));
expect(result.current.activeViewId).toBe('locked');
expect(result.current.filter).toStrictEqual({
query: 'locked',
sort: 'updated',
});
expect(result.current.isModified).toBe(false);
});
it('selecting a custom view applies its stored filter', () => {
const { result } = setup(makeApi());
act(() => result.current.selectView('v1'));
expect(result.current.isCustomActive).toBe(true);
expect(result.current.activeView).toBe(CUSTOM);
expect(result.current.filter).toStrictEqual(CUSTOM.filter);
});
it('detects divergence via isEqual and resets to the canonical filter', () => {
const { result } = setup(makeApi(), '?view=v1', CUSTOM.filter);
act(() => result.current.setFilter({ query: 'env = prod ', sort: 'x' }));
expect(result.current.isModified).toBe(false);
act(() => result.current.setFilter({ query: 'env = dev', sort: 'name' }));
expect(result.current.isModified).toBe(true);
act(() => result.current.resetView());
expect(result.current.filter).toStrictEqual(CUSTOM.filter);
expect(result.current.isModified).toBe(false);
});
it('saveView creates the view from the current filter and selects it', async () => {
const api = makeApi();
const { result } = setup(api, '', { query: 'a', sort: 'name' });
act(() => result.current.saveView('Mine'));
expect(api.createView).toHaveBeenCalledWith('Mine', {
query: 'a',
sort: 'name',
});
await waitFor(() => expect(result.current.activeViewId).toBe('new'));
});
it('saveActiveView updates the active custom view with the current filter', () => {
const api = makeApi();
const { result } = setup(api, '?view=v1', CUSTOM.filter);
act(() => result.current.setFilter({ query: 'env = dev', sort: 'name' }));
act(() => result.current.saveActiveView());
expect(api.updateView).toHaveBeenCalledWith('v1', 'Prod', {
query: 'env = dev',
sort: 'name',
});
});
it('saveActiveView is a no-op on a builtin view', () => {
const api = makeApi();
const { result } = setup(api);
act(() => result.current.saveActiveView());
expect(api.updateView).not.toHaveBeenCalled();
});
it('renameView keeps the stored filter', () => {
const api = makeApi();
const { result } = setup(api, '', { query: 'other', sort: 'x' });
act(() => result.current.renameView('v1', 'Renamed'));
expect(api.updateView).toHaveBeenCalledWith('v1', 'Renamed', CUSTOM.filter);
});
it('removing the active view falls back to the default view', async () => {
const api = makeApi();
const { result } = setup(api, '?view=v1', CUSTOM.filter);
await act(() => result.current.removeView('v1'));
expect(api.deleteView).toHaveBeenCalledWith('v1');
expect(result.current.activeViewId).toBe('all');
expect(result.current.filter).toStrictEqual({ query: '', sort: 'name' });
});
it('removing an inactive view leaves the selection alone', async () => {
const api = makeApi();
const { result } = setup(api, '?view=locked', { query: 'locked', sort: 'x' });
await act(() => result.current.removeView('v1'));
expect(result.current.activeViewId).toBe('locked');
expect(result.current.filter.query).toBe('locked');
});
it('swallows delete failures without changing the selection', async () => {
const api = makeApi();
api.deleteView.mockRejectedValueOnce(new Error('boom'));
const { result } = setup(api, '?view=v1', CUSTOM.filter);
await act(() => result.current.removeView('v1'));
expect(result.current.activeViewId).toBe('v1');
});
});

View File

@@ -0,0 +1,32 @@
import type {
SavedListView,
SavedListViewsApi,
} from 'components/ListViews/types';
export interface UseListViewsArgs<F> {
/** The currently applied filter. */
filter: F;
applyFilter: (next: F) => void;
isEqual: (a: F, b: F) => boolean;
/** The filter a builtin view applies given the current one; `null` when `id` is not builtin. */
builtinFilter: (id: string, current: F) => F | null;
defaultViewId: string;
api: SavedListViewsApi<F>;
/** URL search param holding the active view id. */
urlParam?: string;
}
export interface UseListViewsResult<F> {
activeViewId: string;
activeView: SavedListView<F> | undefined;
customViews: SavedListView<F>[];
isCustomActive: boolean;
/** The current filter diverges from the active view's canonical filter. */
isModified: boolean;
selectView: (id: string) => void;
saveView: (name: string) => void;
saveActiveView: () => void;
resetView: () => void;
removeView: (id: string) => Promise<void>;
renameView: (id: string, name: string) => void;
}

View File

@@ -0,0 +1,139 @@
import { useCallback, useMemo } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import type { UseListViewsArgs, UseListViewsResult } from './types';
// Orchestrates a list page's active view: which view is selected (URL param),
// applying a view's filter on select, dirty detection, and save/reset/delete
// via the page's saved-views API.
export function useListViews<F>({
filter,
applyFilter,
isEqual,
builtinFilter,
defaultViewId,
api,
urlParam = 'view',
}: UseListViewsArgs<F>): UseListViewsResult<F> {
const [activeViewId, setActiveViewId] = useQueryState(
urlParam,
parseAsString.withDefault(defaultViewId).withOptions({ history: 'push' }),
);
const { views: customViews, createView, updateView, deleteView } = api;
const activeView = useMemo(
() => customViews.find((v) => v.id === activeViewId),
[customViews, activeViewId],
);
// The filter the active view "is" — used to detect divergence.
const canonicalFilter = useMemo<F | null>(
() => (activeView ? activeView.filter : builtinFilter(activeViewId, filter)),
[activeView, builtinFilter, activeViewId, filter],
);
const isModified =
canonicalFilter !== null && !isEqual(filter, canonicalFilter);
const selectView = useCallback(
(id: string): void => {
void setActiveViewId(id);
const custom = customViews.find((v) => v.id === id);
const next = custom
? custom.filter
: (builtinFilter(id, filter) ?? builtinFilter(defaultViewId, filter));
if (next !== null) {
applyFilter(next);
}
},
[
setActiveViewId,
customViews,
builtinFilter,
filter,
defaultViewId,
applyFilter,
],
);
const saveView = useCallback(
(name: string): void => {
void (async (): Promise<void> => {
const created = await createView(name, filter);
if (created) {
void setActiveViewId(created.id);
}
})();
},
[createView, filter, setActiveViewId],
);
const saveActiveView = useCallback((): void => {
if (!activeView) {
return;
}
updateView(activeView.id, activeView.name, filter);
}, [activeView, updateView, filter]);
const resetView = useCallback((): void => {
if (canonicalFilter === null) {
return;
}
applyFilter(canonicalFilter);
}, [canonicalFilter, applyFilter]);
const removeView = useCallback(
async (id: string): Promise<void> => {
try {
await deleteView(id);
} catch {
// Failure is surfaced by the delete mutation's error toast; still
// settle so the confirm modal stops loading and closes.
return;
}
if (activeViewId === id) {
void setActiveViewId(defaultViewId);
const next = builtinFilter(defaultViewId, filter);
if (next !== null) {
applyFilter(next);
}
}
},
[
deleteView,
activeViewId,
setActiveViewId,
defaultViewId,
builtinFilter,
filter,
applyFilter,
],
);
// Rename only touches the view's name; its stored filter is preserved.
const renameView = useCallback(
(id: string, name: string): void => {
const view = customViews.find((v) => v.id === id);
if (!view) {
return;
}
updateView(id, name, view.filter);
},
[customViews, updateView],
);
return {
activeViewId,
activeView,
customViews,
isCustomActive: !!activeView,
isModified,
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
};
}

View File

@@ -0,0 +1,74 @@
import { type ReactElement, useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import FilterZone from 'components/ListFilter/FilterZone/FilterZone';
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
import type {
CreatorOption,
FilterAppliedPayload,
FilterAppliedType,
} from 'components/ListFilter/types';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
import { DASHBOARDS_DSL_GRAMMAR } from '../../utils/dashboardsDslGrammar';
interface Props {
query: string;
creatorOptions: CreatorOption[];
source: SuggestionSource;
onQueryChange: (value: string) => void;
disabled?: boolean;
}
function DashboardsFilterZone({
query,
creatorOptions,
source,
onQueryChange,
disabled = false,
}: Props): JSX.Element {
const wrapControl = useCallback(
(node: ReactElement): ReactElement => (
<AuthZTooltip checks={LIST_CHECKS}>{node}</AuthZTooltip>
),
[],
);
const onSubmitted = useCallback((hasQuery: boolean): void => {
void logEvent(DashboardListEvents.SearchExecuted, { hasQuery });
}, []);
const onFilterApplied = useCallback(
(filterType: FilterAppliedType, payload: FilterAppliedPayload): void => {
void logEvent(DashboardListEvents.FilterApplied, {
filterType,
...payload,
});
},
[],
);
const onCleared = useCallback((): void => {
void logEvent(DashboardListEvents.FiltersCleared, {});
}, []);
return (
<FilterZone
query={query}
grammar={DASHBOARDS_DSL_GRAMMAR}
testIdPrefix="dashboards"
placeholder="DSL Filter — e.g. name CONTAINS 'api' AND env IN ['prod','staging']"
creatorOptions={creatorOptions}
source={source}
disabled={disabled}
wrapControl={wrapControl}
onQueryChange={onQueryChange}
onSubmitted={onSubmitted}
onFilterApplied={onFilterApplied}
onCleared={onCleared}
/>
);
}
export default DashboardsFilterZone;

View File

@@ -81,53 +81,6 @@
}
}
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
}
.headingBlock {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.grow {
flex: 1;
}
.dirtyDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning-background);
flex: none;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
color: var(--l2-foreground);
font-size: var(--font-size-sm);

View File

@@ -18,7 +18,7 @@ import {
useAccumulatedTags,
} from '../../hooks/useAccumulatedTags';
import { useActiveView } from '../../hooks/useActiveView';
import { useCreatorOptions } from '../../hooks/useCreatorOptions';
import { useCreatorOptions } from 'components/ListFilter/useCreatorOptions';
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
import {
usePage,
@@ -28,15 +28,19 @@ import {
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
import { BuiltinViewId } from '../../types';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
import type { DashboardListItem } from '../../utils/helpers';
import { applyClientView } from '../../utils/views';
import FilterZone from '../FilterZone/FilterZone';
import DashboardsFilterZone from '../DashboardsFilterZone/DashboardsFilterZone';
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
import StatusBar from '../StatusBar/StatusBar';
import ViewsRail from '../ViewsRail/ViewsRail';
import CommandHeader from './CommandHeader';
import type { ReactElement } from 'react';
import ListCommandHeader from 'components/ListViews/ListCommandHeader/ListCommandHeader';
import ListStatusBar from 'components/ListViews/ListStatusBar/ListStatusBar';
import type { ViewSaveMode } from 'components/ListViews/types';
import ViewsRail from 'components/ListViews/ViewsRail/ViewsRail';
import DashboardsResults from './DashboardsResults';
import NewDashboardButton from './NewDashboardButton';
import ViewsRailGuard from './ViewsRailGuard';
import WorkspaceEmptyState from './WorkspaceEmptyState';
import styles from './DashboardsList.module.scss';
@@ -125,6 +129,21 @@ function DashboardsList(): JSX.Element {
void logEvent(DashboardListEvents.RailToggled, { collapsed: !railCollapsed });
setRailCollapsed(!railCollapsed);
}, [setRailCollapsed, railCollapsed]);
const guardViewsRail = useCallback(
(content: ReactElement): ReactElement => (
<ViewsRailGuard>{content}</ViewsRailGuard>
),
[],
);
const logViewSaved = useCallback((mode: ViewSaveMode): void => {
void logEvent(DashboardListEvents.ViewSaved, { mode });
}, []);
const logViewRenamed = useCallback((): void => {
void logEvent(DashboardListEvents.ViewRenamed, {});
}, []);
const logViewDeleted = useCallback((): void => {
void logEvent(DashboardListEvents.ViewDeleted, {});
}, []);
const listParams = useMemo(
() => ({
@@ -207,17 +226,17 @@ function DashboardsList(): JSX.Element {
// Autocomplete data source: reserved keys from the response, tag keys/values
// accumulated across pages, and creator emails for `created_by` values.
const source = useMemo<SuggestionSource>(() => {
const tagValuesByKey: Record<string, string[]> = {};
const tagKeys = new Set<string>();
const valuesByKey: Record<string, string[]> = {};
const dynamicKeys = new Set<string>();
availableTags.forEach((t) => {
tagKeys.add(t.key);
dynamicKeys.add(t.key);
const lower = t.key.toLowerCase();
(tagValuesByKey[lower] ??= []).push(t.value);
(valuesByKey[lower] ??= []).push(t.value);
});
return {
reservedKeys: response?.data?.reservedKeywords,
tagKeys: Array.from(tagKeys),
tagValuesByKey,
dynamicKeys: Array.from(dynamicKeys),
valuesByKey,
creatorEmails: creatorOptions.map((o) => o.email),
currentUserEmail: user.email,
};
@@ -296,6 +315,12 @@ function DashboardsList(): JSX.Element {
onReset={handleResetView}
onDelete={handleRemoveView}
onRename={renameView}
testIdPrefix="dashboards"
deleteDescription="This removes the saved view. Your dashboards are not affected."
guard={guardViewsRail}
onViewSaved={logViewSaved}
onViewRenamed={logViewRenamed}
onViewDeleted={logViewDeleted}
/>
<div className={styles.main}>
<div className={styles.mainScroll}>
@@ -304,13 +329,13 @@ function DashboardsList(): JSX.Element {
) : (
<>
<div className={styles.headerZone}>
<CommandHeader
<ListCommandHeader
label={activeLabel}
count={total}
isModified={isModified}
onCreate={openCreate}
action={<NewDashboardButton onClick={openCreate} />}
/>
<FilterZone
<DashboardsFilterZone
query={query}
creatorOptions={creatorOptions}
source={source}
@@ -354,11 +379,13 @@ function DashboardsList(): JSX.Element {
</>
)}
</div>
<StatusBar
<ListStatusBar
collapsed={railCollapsed}
onToggleCollapse={toggleRail}
count={dashboards.length}
total={total}
noun="dashboards"
testId="dashboards-rail-toggle"
/>
</div>

View File

@@ -0,0 +1,3 @@
.denied {
padding: 16px 12px;
}

View File

@@ -0,0 +1,28 @@
import type { ReactElement } from 'react';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
import styles from './ViewsRailGuard.module.scss';
interface Props {
children: ReactElement;
}
function ViewsRailGuard({ children }: Props): JSX.Element {
return (
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): ReactElement => (
<div className={styles.denied} data-testid="views-rail-denied">
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
</div>
)}
>
{children}
</AuthZGuardContent>
);
}
export default ViewsRailGuard;

View File

@@ -1,175 +0,0 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import logEvent from 'api/common/logEvent';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
import type { SuggestionSource } from '../../utils/dslSuggestions';
import {
createdByClause,
parseReflectedClauses,
spliceClause,
updatedClause,
} from '../../utils/filterQuery';
import type { UpdatedWindow } from '../../types';
import SearchBar from '../SearchBar/SearchBar';
import FilterChips, { type CreatorOption } from './FilterChips';
import styles from './FilterZone.module.scss';
interface Props {
// The last-run query (source of truth for fetching + the dirty baseline).
query: string;
creatorOptions: CreatorOption[];
source: SuggestionSource;
// Commit (run) the draft — the only path that triggers a fetch.
onQueryChange: (value: string) => void;
// Rendered at the end of the search row (e.g. the New Dashboard action).
rightSlot?: ReactNode;
/** Filtering needs `list`: the box and chips edit a query that cannot be run. */
disabled?: boolean;
}
// The filter command zone. The query box is a DRAFT: typing and the Created-by /
// Updated dropdowns all edit the draft (dropdowns splice their clause in); nothing
// fetches until the draft is run (Cmd/Ctrl+Enter or the Run button). A dirty dot
// signals unrun changes. The draft re-syncs when the last-run query changes
// externally (view select, back/forward).
function FilterZone({
query,
creatorOptions,
source,
onQueryChange,
rightSlot,
disabled = false,
}: Props): JSX.Element {
const [draft, setDraft] = useState(query);
useEffect(() => {
setDraft(query);
}, [query]);
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
const dirty = draft.trim() !== query.trim();
const isEmpty = !draft.trim();
const run = useCallback((): void => {
const next = draft.trim();
if (next !== query) {
onQueryChange(next);
}
}, [draft, query, onQueryChange]);
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
// when the dropdown closes (FilterChips fires onApply), not on each pick.
const handleCreatedByChange = useCallback((emails: string[]): void => {
void logEvent(DashboardListEvents.FilterApplied, {
filterType: 'createdBy',
valueCount: emails.length,
});
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
}, []);
// Updated (single-select) is a definitive single choice, so it splices AND runs
// immediately (with the fresh value, avoiding the async draft-state lag).
const handleUpdatedChange = useCallback(
(window: UpdatedWindow): void => {
void logEvent(DashboardListEvents.FilterApplied, {
filterType: 'updatedWindow',
});
const next = spliceClause(draft, 'updated_at', updatedClause(window));
setDraft(next);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
},
[draft, query, onQueryChange],
);
// Clear-all on the Created-by select removes its clause and runs immediately.
const handleClearCreatedBy = useCallback((): void => {
const next = spliceClause(draft, 'created_by', null);
setDraft(next);
const trimmed = next.trim();
if (trimmed !== query) {
onQueryChange(trimmed);
}
}, [draft, query, onQueryChange]);
// Clear resets the draft and runs immediately (empty query).
const handleClear = useCallback((): void => {
void logEvent(DashboardListEvents.FiltersCleared, {});
setDraft('');
if (query !== '') {
onQueryChange('');
}
}, [query, onQueryChange]);
return (
<div className={styles.filterZone}>
<div className={styles.searchRow}>
<AuthZTooltip checks={LIST_CHECKS}>
<div className={styles.searchInput} data-testid="dashboards-search-zone">
<SearchBar
value={draft}
placeholder="DSL Filter — e.g. name CONTAINS 'api' AND env IN ['prod','staging']"
source={source}
dirty={dirty}
disabled={disabled}
onChange={setDraft}
onSubmit={run}
/>
</div>
</AuthZTooltip>
{rightSlot}
</div>
<div className={styles.filtersRow}>
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
<AuthZTooltip checks={LIST_CHECKS}>
<div
className={styles.filterControls}
data-testid="dashboards-filter-zone"
>
<FilterChips
createdBy={reflected.createdBy}
updated={reflected.updated}
creatorOptions={creatorOptions}
onCreatedByChange={handleCreatedByChange}
onUpdatedChange={handleUpdatedChange}
onApply={run}
onClearCreatedBy={handleClearCreatedBy}
disabled={disabled}
/>
{!isEmpty && (
<Button
disabledTooltip={undefined}
variant="outlined"
color="secondary"
size="md"
prefix={<X size={12} />}
disabled={disabled}
onClick={handleClear}
testId="dashboards-filter-clear"
>
Clear
</Button>
)}
</div>
</AuthZTooltip>
</div>
</div>
);
}
export default FilterZone;

View File

@@ -1,342 +0,0 @@
import {
type ChangeEvent,
type ReactElement,
useCallback,
useState,
} from 'react';
import logEvent from 'api/common/logEvent';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Bookmark, PenLine, Plus, Search, Trash2 } from '@signozhq/icons';
import cx from 'classnames';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
import type { SavedView } from '../../types';
import { type BuiltinView } from '../../utils/views';
import ViewNamePopover from './ViewNamePopover';
import styles from './ViewsRail.module.scss';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { LIST_CHECKS } from 'pages/DashboardsListPage/constants/permissions';
interface Props {
activeViewId: string;
builtinViews: BuiltinView[];
customViews: SavedView[];
customViewsLoading: boolean;
isCustomActive: boolean;
isModified: boolean;
collapsed?: boolean;
onSelect: (id: string) => void;
onSave: (name: string) => void;
onSaveChanges: () => void;
onReset: () => void;
onDelete: (id: string) => Promise<void>;
onRename: (id: string, name: string) => void;
}
interface ViewRow {
id: string;
label: string;
icon: BuiltinView['icon'];
deletable?: boolean;
}
// Purely presentational — active view, dirty state, and handlers come from
// `useActiveView`.
function ViewsRail({
activeViewId,
builtinViews,
customViews,
customViewsLoading,
isCustomActive,
isModified,
collapsed = false,
onSelect,
onSave,
onSaveChanges,
onReset,
onDelete,
onRename,
}: Props): JSX.Element {
const [saveOpen, setSaveOpen] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const { contextHolder, confirmDelete } = useDeleteConfirm();
const q = query.trim().toLowerCase();
const matchesQuery = (label: string): boolean =>
!q || label.toLowerCase().includes(q);
const personal = builtinViews.filter(
(v) => v.section === 'personal' && matchesQuery(v.label),
);
const system = builtinViews.filter(
(v) => v.section === 'system' && matchesQuery(v.label),
);
const custom = customViews.filter((v) => matchesQuery(v.name));
const noMatches =
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
const onConfirmDelete = useCallback(
(id: string, label: string): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Delete the{' '}
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
view?
</Typography.Title>
),
content: 'This removes the saved view. Your dashboards are not affected.',
// Return the delete promise so the modal's Delete button stays loading
// until the backend call settles, then closes — matching dashboard delete.
onConfirm: (): Promise<void> => {
void logEvent(DashboardListEvents.ViewDeleted, {});
return onDelete(id);
},
});
},
[confirmDelete, onDelete],
);
const handleSaveAsView = (name: string): void => {
void logEvent(DashboardListEvents.ViewSaved, { mode: 'new' });
onSave(name);
};
const handleSaveViewChanges = (): void => {
void logEvent(DashboardListEvents.ViewSaved, { mode: 'update' });
onSaveChanges();
};
const renderItem = (row: ViewRow): JSX.Element => {
const Icon = row.icon;
const active = row.id === activeViewId;
return (
<div key={row.id} className={cx(styles.row, { [styles.rowActive]: active })}>
<Button
size="md"
variant="ghost"
color="secondary"
width="100%"
onClick={(): void => onSelect(row.id)}
testId={`dashboards-view-${row.id}`}
prefix={<Icon size={16} className={styles.itemIcon} />}
suffix={
active && isModified ? (
<div className={styles.dirtyDot} title="Unsaved changes" />
) : undefined
}
>
{row.label}
</Button>
{row.deletable && (
<div className={styles.itemActions}>
<ViewNamePopover
open={renamingId === row.id}
onOpenChange={(open): void => setRenamingId(open ? row.id : null)}
onSubmit={(name): void => {
void logEvent(DashboardListEvents.ViewRenamed, {});
onRename(row.id, name);
}}
title="Rename view"
confirmLabel="Rename"
initialName={row.label}
testIdPrefix="rename-view"
tooltip="Rename view"
trigger={
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Rename view"
onClick={(e): void => e.stopPropagation()}
>
<PenLine size={12} />
</Button>
}
/>
<Tooltip title="Delete view">
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label="Delete view"
onClick={(e): void => {
e.stopPropagation();
onConfirmDelete(row.id, row.label);
}}
>
<Trash2 size={12} />
</Button>
</Tooltip>
</div>
)}
</div>
);
};
return (
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): ReactElement => (
<div className={styles.denied} data-testid="views-rail-denied">
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
</div>
)}
>
<>
<div className={styles.header}>
<h4 className={styles.headerTitle}>Views</h4>
<ViewNamePopover
open={saveOpen}
onOpenChange={setSaveOpen}
onSubmit={handleSaveAsView}
title="Save as view"
confirmLabel="Save view"
testIdPrefix="save-view"
tooltip="Save current filters as a view"
trigger={
<Button
aria-label="Save current filters as a view"
variant="ghost"
color="secondary"
size="sm"
icon
testId="dashboards-view-save-trigger"
>
<Plus size={14} />
</Button>
}
/>
</div>
<div className={styles.search}>
<Input
value={query}
placeholder="Filter views by name"
prefix={<Search size={12} />}
testId="dashboards-view-search"
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setQuery(e.target.value)
}
/>
</div>
<div className={styles.scroll}>
{personal.length > 0 && (
<>
<div className={styles.groupLabel}>Personal</div>
{personal.map((v) => renderItem(v))}
</>
)}
{system.length > 0 && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
System
</div>
{system.map((v) => renderItem(v))}
</>
)}
{(!q || custom.length > 0) && (
<>
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
My views
<Typography.Text className={styles.groupCount}>
{customViews.length}
</Typography.Text>
</div>
{customViewsLoading ? (
<div className={styles.empty}>Loading views…</div>
) : customViews.length === 0 ? (
<div className={styles.empty}>
No saved views yet. Filter the list, then save it as a view.
</div>
) : (
custom.map((v) =>
renderItem({
id: v.id,
label: v.name,
icon: Bookmark,
deletable: true,
}),
)
)}
</>
)}
{noMatches && (
<div className={styles.searchEmpty}>
No views match &ldquo;{query}&rdquo;
</div>
)}
</div>
{isCustomActive && isModified && (
<div className={styles.dirtyPanel}>
<div className={styles.dirtyTitle}>Unsaved changes</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSaveViewChanges}
testId="dashboards-view-save-changes"
>
Save
</Button>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => setSaveOpen(true)}
>
Save as…
</Button>
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
</Button>
</div>
</div>
)}
{!isCustomActive && isModified && (
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
<div className={styles.dirtyTitle}>Filters active</div>
<div className={styles.dirtyActions}>
<Button
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => setSaveOpen(true)}
testId="dashboards-view-save-as-new"
>
Save as new view
</Button>
<Button variant="ghost" color="secondary" size="sm" onClick={onReset}>
Reset
</Button>
</div>
</div>
)}
{contextHolder}
</>
</AuthZGuardContent>
</aside>
);
}
export default ViewsRail;

View File

@@ -1,23 +1,23 @@
import { useCallback, useMemo } from 'react';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import type {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
BuiltinListView,
SavedListView,
} from 'components/ListViews/types';
import { useListViews } from 'hooks/listViews/useListViews';
import { areQueriesEqual } from '../utils/filterQuery';
import { BuiltinViewId } from '../types';
import type { SavedView } from '../types';
import { BuiltinViewId, type DashboardViewFilter } from '../types';
import {
areViewFiltersEqual,
BUILTIN_VIEWS,
type BuiltinView,
builtinViewQuery,
builtinViewFilter,
isClientView,
} from '../utils/views';
import { useSavedViews } from './useSavedViews';
const opts: Options = { history: 'push' };
interface UseActiveViewArgs {
query: string;
setQuery: (next: string) => void;
@@ -32,8 +32,8 @@ interface UseActiveViewArgs {
export interface UseActiveViewResult {
activeViewId: string;
builtinViews: BuiltinView[];
customViews: SavedView[];
builtinViews: BuiltinListView[];
customViews: SavedListView<DashboardViewFilter>[];
customViewsLoading: boolean;
isCustomActive: boolean;
// The current query diverges from the active view's canonical query.
@@ -48,10 +48,9 @@ export interface UseActiveViewResult {
renameView: (id: string, name: string) => void;
}
// Orchestrates the active view: which view is selected (URL `view` param),
// merging built-in + org-shared saved views, applying a view's query on select,
// dirty detection, and save/reset/delete via the Views API. A view now simply
// "is" a query string, so dirty detection is a trimmed string compare.
// Dashboards' binding of `useListViews`: a view is a `{ query, sort, order }`
// filter, built-in views seed only the query, and dirty detection is a trimmed
// query compare.
export function useActiveView({
query,
setQuery,
@@ -62,145 +61,54 @@ export function useActiveView({
setSortOrder,
canListDashboards,
}: UseActiveViewArgs): UseActiveViewResult {
const [activeViewId, setActiveViewId] = useQueryState(
'view',
parseAsString.withDefault(BuiltinViewId.All).withOptions(opts),
const api = useSavedViews({ enabled: canListDashboards });
const filter = useMemo<DashboardViewFilter>(
() => ({ query, sort: sortColumn, order: sortOrder }),
[query, sortColumn, sortOrder],
);
const {
views: customViews,
isLoading: customViewsLoading,
createView,
updateView,
deleteView,
} = useSavedViews({ enabled: canListDashboards });
const activeCustom = useMemo(
() => customViews.find((v) => v.id === activeViewId),
[customViews, activeViewId],
);
// The query the active view "is" — used to detect divergence.
const canonicalQuery = useMemo<string | null>(
() =>
activeCustom
? activeCustom.query
: builtinViewQuery(activeViewId, userEmail),
[activeCustom, activeViewId, userEmail],
);
const isModified =
canonicalQuery !== null && !areQueriesEqual(query, canonicalQuery);
const selectView = useCallback(
(id: string): void => {
void setActiveViewId(id);
const custom = customViews.find((v) => v.id === id);
if (custom) {
setQuery(custom.query);
setSortColumn(custom.sort);
setSortOrder(custom.order);
return;
const applyFilter = useCallback(
(next: DashboardViewFilter): void => {
setQuery(next.query);
if (next.sort !== sortColumn) {
setSortColumn(next.sort);
}
setQuery(builtinViewQuery(id, userEmail) ?? '');
},
[
setActiveViewId,
customViews,
setQuery,
userEmail,
setSortColumn,
setSortOrder,
],
);
const saveView = useCallback(
(name: string): void => {
void (async (): Promise<void> => {
const created = await createView({
name,
query,
sort: sortColumn,
order: sortOrder,
});
if (created) {
void setActiveViewId(created.id);
}
})();
},
[query, createView, sortColumn, sortOrder, setActiveViewId],
);
const saveActiveView = useCallback((): void => {
if (!activeCustom) {
return;
}
updateView(activeCustom.id, {
name: activeCustom.name,
query,
sort: sortColumn,
order: sortOrder,
});
}, [activeCustom, query, updateView, sortColumn, sortOrder]);
const resetView = useCallback((): void => {
if (canonicalQuery === null) {
return;
}
setQuery(canonicalQuery);
if (activeCustom) {
setSortColumn(activeCustom.sort);
setSortOrder(activeCustom.order);
}
}, [canonicalQuery, setQuery, activeCustom, setSortColumn, setSortOrder]);
const removeView = useCallback(
async (id: string): Promise<void> => {
try {
await deleteView(id);
} catch {
// Failure is surfaced by the delete mutation's error toast; still
// settle so the confirm modal stops loading and closes.
return;
}
if (activeViewId === id) {
void setActiveViewId(BuiltinViewId.All);
setQuery('');
if (next.order !== sortOrder) {
setSortOrder(next.order);
}
},
[deleteView, activeViewId, setActiveViewId, setQuery],
[setQuery, sortColumn, sortOrder, setSortColumn, setSortOrder],
);
// Rename only touches the view's name; its stored query/sort/order are preserved.
const renameView = useCallback(
(id: string, name: string): void => {
const view = customViews.find((v) => v.id === id);
if (!view) {
return;
}
updateView(id, {
name,
query: view.query,
sort: view.sort,
order: view.order,
});
},
[customViews, updateView],
const builtinFilter = useCallback(
(id: string, current: DashboardViewFilter): DashboardViewFilter | null =>
builtinViewFilter(id, current, userEmail),
[userEmail],
);
const views = useListViews<DashboardViewFilter>({
filter,
applyFilter,
isEqual: areViewFiltersEqual,
builtinFilter,
defaultViewId: BuiltinViewId.All,
api,
});
return {
activeViewId,
activeViewId: views.activeViewId,
builtinViews: BUILTIN_VIEWS,
customViews,
customViewsLoading,
isCustomActive: !!activeCustom,
isModified,
clientView: isClientView(activeViewId),
selectView,
saveView,
saveActiveView,
resetView,
removeView,
renameView,
customViews: views.customViews,
customViewsLoading: api.isLoading,
isCustomActive: views.isCustomActive,
isModified: views.isModified,
clientView: isClientView(views.activeViewId),
selectView: views.selectView,
saveView: views.saveView,
saveActiveView: views.saveActiveView,
resetView: views.resetView,
removeView: views.removeView,
renameView: views.renameView,
};
}

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { type Options, parseAsString, useQueryState } from 'nuqs';
import { isQueryEmpty } from '../utils/filterQuery';
import { isQueryEmpty } from 'components/ListFilter/dsl/filterQuery';
const opts: Options = { history: 'push' };

View File

@@ -16,38 +16,42 @@ import {
type ListDashboardViews200,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedView, SavedViewInput } from '../types';
import type {
SavedListView,
SavedListViewsApi,
} from 'components/ListViews/types';
import type { DashboardViewFilter } from '../types';
// Schema version stamped on the view's data envelope (the backend requires it).
const VIEW_DATA_VERSION = 'v1';
const toSavedView = (dto: DashboardtypesDashboardViewDTO): SavedView => ({
type DashboardSavedView = SavedListView<DashboardViewFilter>;
const toSavedView = (
dto: DashboardtypesDashboardViewDTO,
): DashboardSavedView => ({
id: dto.id,
name: dto.name,
query: dto.data.query ?? '',
sort: dto.data.sort ?? DashboardtypesListSortDTO.updated_at,
order: dto.data.order ?? DashboardtypesListOrderDTO.desc,
});
const toPostable = (
input: SavedViewInput,
): { name: string; data: DashboardtypesDashboardViewDTO['data'] } => ({
name: input.name.trim(),
data: {
version: VIEW_DATA_VERSION,
query: input.query,
sort: input.sort,
order: input.order,
filter: {
query: dto.data.query ?? '',
sort: dto.data.sort ?? DashboardtypesListSortDTO.updated_at,
order: dto.data.order ?? DashboardtypesListOrderDTO.desc,
},
});
export interface UseSavedViewsResult {
views: SavedView[];
isLoading: boolean;
createView: (input: SavedViewInput) => Promise<SavedView | null>;
updateView: (id: string, input: SavedViewInput) => void;
deleteView: (id: string) => Promise<void>;
}
const toPostable = (
name: string,
filter: DashboardViewFilter,
): { name: string; data: DashboardtypesDashboardViewDTO['data'] } => ({
name: name.trim(),
data: {
version: VIEW_DATA_VERSION,
query: filter.query,
sort: filter.sort,
order: filter.order,
},
});
/**
* Org-shared saved views, backed by the Views API. `enabled` is the caller's
@@ -55,11 +59,11 @@ export interface UseSavedViewsResult {
*/
export function useSavedViews({
enabled = true,
}: { enabled?: boolean } = {}): UseSavedViewsResult {
}: { enabled?: boolean } = {}): SavedListViewsApi<DashboardViewFilter> {
const queryClient = useQueryClient();
const { data, isLoading } = useListDashboardViews({ query: { enabled } });
const views = useMemo<SavedView[]>(
const views = useMemo<DashboardSavedView[]>(
() => (data?.data?.views ?? []).map(toSavedView),
[data],
);
@@ -112,9 +116,14 @@ export function useSavedViews({
});
const createView = useCallback(
async (input: SavedViewInput): Promise<SavedView | null> => {
async (
name: string,
filter: DashboardViewFilter,
): Promise<DashboardSavedView | null> => {
try {
const res = await createMutation.mutateAsync({ data: toPostable(input) });
const res = await createMutation.mutateAsync({
data: toPostable(name, filter),
});
return res?.data ? toSavedView(res.data) : null;
} catch {
return null;
@@ -124,8 +133,11 @@ export function useSavedViews({
);
const updateView = useCallback(
(id: string, input: SavedViewInput): void => {
updateMutation.mutate({ pathParams: { id }, data: toPostable(input) });
(id: string, name: string, filter: DashboardViewFilter): void => {
updateMutation.mutate({
pathParams: { id },
data: toPostable(name, filter),
});
},
[updateMutation],
);

View File

@@ -29,19 +29,22 @@ import {
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
orgUsersResponse,
STORY_USER_EMAIL,
} from '@/storybook/msw/__story_mockdata__/orgUsers';
import { dashboardResponse } from '../../DashboardPage/stories/__story_mockdata__/dashboard';
import {
dashboardIdAt,
dashboardViewsResponse,
dashboardsListResponse,
orgUsersResponse,
PIN_WRITES,
recentDashboardIds,
ROW_MARKERS,
savedView,
seedPinnedDashboards,
setDashboardPinned,
STORY_USER_EMAIL,
TEMPLATE_REQUESTS,
TOOLTIP_TAGS,
WRITE_STATES,

View File

@@ -10,24 +10,12 @@ import {
type DashboardtypesListedDashboardForUserV2DTO,
type ListDashboardViews200,
type ListDashboardsForUserV2200,
type ListUsers200,
type TagtypesGettableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import { USER_ROLES } from 'types/roles';
/**
* "My dashboards" matches on the signed-in address, so the rows have to be
* created by the same user the providers mount.
*/
export const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user
.email;
const TEAMMATE_EMAILS = [
'ada@signoz.io',
'grace@signoz.io',
'linus@signoz.io',
] as const;
import {
STORY_USER_EMAIL,
TEAMMATE_EMAILS,
} from '@/storybook/msw/__story_mockdata__/orgUsers';
const HOUR = 60 * 60 * 1000;
@@ -324,21 +312,6 @@ export const dashboardViewsResponse = (
});
/** The org's users, which is where the Created-by dropdown gets its options. */
export const orgUsersResponse = (): ListUsers200 => ({
status: 'success',
data: [
{
id: 'storybook-user-me',
email: STORY_USER_EMAIL,
displayName: 'John Doe',
},
...TEAMMATE_EMAILS.map((email, index) => ({
id: `storybook-user-${index + 1}`,
email,
displayName: email.split('@')[0].replace(/^./, (c) => c.toUpperCase()),
})),
],
});
/** Ids the Recently-viewed rail entry reads out of local state. */
export const recentDashboardIds = (count: number): string[] =>

View File

@@ -3,23 +3,15 @@ import type {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
// Relative "updated within" windows offered by the Updated filter dropdown.
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
// A saved view: a named filter the org shares, persisted via the backend Views
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
// so a view captures the fully-combined DSL query plus the sort/order to apply.
export interface SavedView {
id: string;
name: string;
// The filter a saved view captures. The backend stores a flat `{ query, sort, order }`
// (no structured chips), so a view captures the fully-combined DSL query plus the
// sort/order to apply.
export interface DashboardViewFilter {
query: string;
sort: DashboardtypesListSortDTO;
order: DashboardtypesListOrderDTO;
}
// The payload for creating or updating a saved view (everything but the id).
export type SavedViewInput = Omit<SavedView, 'id'>;
// Built-in views rendered above the user's saved views. Their result set is
// derived (a fixed query fragment or a client-side id set), never persisted.
// String values double as the URL `view` param, so they must stay stable.
@@ -30,5 +22,3 @@ export enum BuiltinViewId {
All = 'all',
Locked = 'locked',
}
export type ViewSection = 'personal' | 'system' | 'custom';

View File

@@ -0,0 +1,23 @@
import type { DslGrammar } from 'components/ListFilter/dsl/types';
// Mirrors pkg/types/dashboardtypes/list_filter.go. Any other key is a tag key.
// (`updated_by` and `source` are NOT queryable.)
export const DASHBOARDS_DSL_GRAMMAR: DslGrammar = {
reservedKeys: [
'name',
'description',
'created_by',
'created_at',
'updated_at',
'locked',
],
fieldTypes: {
name: 'string',
description: 'string',
created_by: 'string',
created_at: 'timestamp',
updated_at: 'timestamp',
locked: 'bool',
},
dynamicKey: { detail: 'tag', prefix: '' },
};

View File

@@ -5,22 +5,16 @@
// - client: constrains by a client-side id set (Favorites, Recently viewed)
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
import { createdByClause } from './filterQuery';
import { BuiltinViewId } from '../types';
import type { ViewSection } from '../types';
import {
areQueriesEqual,
createdByClause,
} from 'components/ListFilter/dsl/filterQuery';
import type { BuiltinListView } from 'components/ListViews/types';
import { BuiltinViewId, type DashboardViewFilter } from '../types';
import type { DashboardListItem } from './helpers';
// All @signozhq icons share this component type.
export type ViewIcon = typeof Pin;
export interface BuiltinView {
id: BuiltinViewId;
label: string;
icon: ViewIcon;
section: Exclude<ViewSection, 'custom'>;
}
export const BUILTIN_VIEWS: BuiltinView[] = [
export const BUILTIN_VIEWS: BuiltinListView[] = [
{
id: BuiltinViewId.Mine,
label: 'My dashboards',
@@ -73,6 +67,22 @@ export const builtinViewQuery = (
}
};
// Built-in views only seed the query; the current sort/order is kept.
export const builtinViewFilter = (
id: string,
current: DashboardViewFilter,
userEmail: string,
): DashboardViewFilter | null => {
const query = builtinViewQuery(id, userEmail);
return query === null ? null : { ...current, query };
};
// A view is dirty only when its query diverges; sort/order changes don't count.
export const areViewFiltersEqual = (
a: DashboardViewFilter,
b: DashboardViewFilter,
): boolean => areQueriesEqual(a.query, b.query);
export interface EmptyStateCopy {
title: string;
description: string;

View File

@@ -0,0 +1,37 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { ListUsers200 } from 'api/generated/services/sigNoz.schemas';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import { USER_ROLES } from 'types/roles';
/**
* The address the providers sign in as, so a "mine" view matches rows created
* by it.
*/
export const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user
.email;
export const TEAMMATE_EMAILS = [
'ada@signoz.io',
'grace@signoz.io',
'linus@signoz.io',
] as const;
export const orgUsersResponse = (): ListUsers200 => ({
status: 'success',
data: [
{
id: 'storybook-user-me',
email: STORY_USER_EMAIL,
displayName: 'John Doe',
},
...TEAMMATE_EMAILS.map((email, index) => ({
id: `storybook-user-${index + 1}`,
email,
displayName: email.split('@')[0].replace(/^./, (c) => c.toUpperCase()),
})),
],
});