mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-03 15:00:53 +00:00
Compare commits
3 Commits
main
...
frontend/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a8259032 | ||
|
|
bdec1330af | ||
|
|
c766f20c9a |
@@ -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)}
|
||||
/>
|
||||
@@ -103,3 +103,8 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
166
frontend/src/components/ListFilter/FilterZone/FilterZone.tsx
Normal file
166
frontend/src/components/ListFilter/FilterZone/FilterZone.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
15
frontend/src/components/ListFilter/FilterZone/constants.ts
Normal file
15
frontend/src/components/ListFilter/FilterZone/constants.ts
Normal 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],
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -176,7 +176,8 @@
|
||||
|
||||
.submit {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
top: 6px;
|
||||
display: flex;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
40
frontend/src/components/ListFilter/SearchBar/editorConfig.ts
Normal file
40
frontend/src/components/ListFilter/SearchBar/editorConfig.ts
Normal 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;
|
||||
@@ -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).
|
||||
@@ -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'");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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';
|
||||
|
||||
50
frontend/src/components/ListFilter/dsl/types.ts
Normal file
50
frontend/src/components/ListFilter/dsl/types.ts
Normal 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';
|
||||
9
frontend/src/components/ListFilter/types.ts
Normal file
9
frontend/src/components/ListFilter/types.ts
Normal 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>;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
264
frontend/src/components/ListViews/ViewsRail/ViewsRail.tsx
Normal file
264
frontend/src/components/ListViews/ViewsRail/ViewsRail.tsx
Normal 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 “{query}”
|
||||
</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;
|
||||
@@ -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;
|
||||
105
frontend/src/components/ListViews/ViewsRail/ViewsRailItem.tsx
Normal file
105
frontend/src/components/ListViews/ViewsRail/ViewsRailItem.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
@@ -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`),
|
||||
);
|
||||
});
|
||||
});
|
||||
27
frontend/src/components/ListViews/types.ts
Normal file
27
frontend/src/components/ListViews/types.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
175
frontend/src/hooks/listViews/__tests__/useListViews.test.tsx
Normal file
175
frontend/src/hooks/listViews/__tests__/useListViews.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
32
frontend/src/hooks/listViews/types.ts
Normal file
32
frontend/src/hooks/listViews/types.ts
Normal 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;
|
||||
}
|
||||
139
frontend/src/hooks/listViews/useListViews.ts
Normal file
139
frontend/src/hooks/listViews/useListViews.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.denied {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 “{query}”
|
||||
</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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: '' },
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
37
frontend/src/storybook/msw/__story_mockdata__/orgUsers.ts
generated
Normal file
37
frontend/src/storybook/msw/__story_mockdata__/orgUsers.ts
generated
Normal 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()),
|
||||
})),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user