Compare commits

..

10 Commits

Author SHA1 Message Date
Vinícius Lourenço
62cc95c0e7 test(storybook): assert the in-flight create key button is disabled 2026-09-28 19:32:50 -03:00
Vinícius Lourenço
7302674647 test(storybook): cover ingestion create key validation, expiry and submit states 2026-09-28 19:32:49 -03:00
Vinícius Lourenço
03b70c4356 test(storybook): cover ingestion key tag adding and added states 2026-09-28 19:32:49 -03:00
Vinícius Lourenço
9ef7aeb5e3 test(storybook): click exceptions filter suggestions as they are found 2026-09-28 16:57:53 -03:00
Vinícius Lourenço
618e99bf4e test(storybook): hold the pod filter stories at their opening height 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
748c5141a5 test(storybook): wait for the filter's scrollbars before focusing it 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
d7bacbe0b1 test(storybook): keep filter focused while scrollbars initialise 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
c4164aa3c6 test(storybook): add query builder filter interaction stories 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
b1901f6804 test(storybook): fix interaction stories against current main 2026-09-28 15:29:43 -03:00
Vinícius Lourenço
d90196daba test(storybook): add interaction stories for menus, drawers and selects 2026-09-28 15:29:43 -03:00
73 changed files with 1678 additions and 3013 deletions

View File

@@ -23,6 +23,10 @@ const IGNORED_MESSAGES = [
// (YouTube embeds, the docs pane) so they hit the real network instead of
// an unanswered msw request; the block is the point, not a bug.
/violates the following Content Security Policy directive/,
// The filter editor's ANTLR parser reports every syntax error through
// `console.error` (`line 1:14 missing ...`), so each partial expression
// typed into it logs one; the editor shows the same errors on screen.
/^line \d+:\d+ /,
];
interface CapturedMessage {

View File

@@ -0,0 +1,241 @@
import { EditorView } from '@codemirror/view';
import { userEvent, waitFor, within } from 'storybook/test';
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The editor is controlled: each change round-trips through React state before
* the next one is applied on top of it. People type slower than this.
*/
const KEYSTROKE_MS = 50;
/** Throws until `found` holds something, which is what `waitFor` retries on. */
const present = <TValue>(
found: TValue | null | undefined,
what: string,
): TValue => {
if (found === null || found === undefined) {
throw new Error(`${what} not found`);
}
return found;
};
const pause = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
const suggestionRow = (
canvasElement: HTMLElement,
text: string,
): HTMLElement | undefined => {
const list = suggestionList(canvasElement);
return list
? within(list)
.queryAllByRole('option')
.find((option) => option.textContent?.includes(text))
: undefined;
};
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
const requestSuggestions = (editor: HTMLElement): void => {
editor.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
code: 'Space',
ctrlKey: true,
bubbles: true,
}),
);
};
const currentView = (
canvasElement: HTMLElement,
): { editor: HTMLElement; view: EditorView } => {
// An explorer renders one editor per query; the first is the one on screen.
const editor = present(
canvasElement.querySelector<HTMLElement>(
'.code-mirror-where-clause .cm-content',
),
'filter editor',
);
return {
editor,
view: present(EditorView.findFromDOM(editor), 'editor view'),
};
};
/**
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
* the browser is idle. Initialising moves the content, the editor with it, and
* focuses the editor again through the DOM, which puts the caret back at the
* start and swaps the suggestions for the key list. Throws until every wrapper
* around the filter has initialised.
*/
const assertScrollbarsReady = (editor: HTMLElement): void => {
for (
let wrapper = editor.closest('.overlay-scrollbar');
wrapper;
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
) {
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
throw new Error('scrollbars around the filter still initialising');
}
}
};
/**
* Waits until `text` shows in the suggestion list, asking for suggestions
* whenever the list is shut. Focus and typing only open it once the keys have
* loaded, and moving the caret never does.
*/
const waitForSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> =>
waitFor(
() => {
const { editor } = currentView(canvasElement);
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
},
{ ...untilLoaded, interval: 250 },
);
/**
* Focuses the filter once the scrollbars around it have initialised, and waits
* for its suggestion list.
*/
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
await waitFor(
() => {
const { editor, view } = currentView(canvasElement);
assertScrollbarsReady(editor);
if (!view.hasFocus) {
view.focus();
}
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionList(canvasElement), 'suggestion list');
},
{ ...untilLoaded, interval: 250 },
);
return currentView(canvasElement).view;
};
/** Waits for a row of the suggestion list. */
export const findSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
/** Focuses the empty filter: every key, with any recent filters above them. */
export const openKeySuggestions = async (
canvasElement: HTMLElement,
row: string,
): Promise<void> => {
await focusFilter(canvasElement);
await waitForSuggestion(canvasElement, row);
};
/**
* Focuses the filter and types onto the end of it one character at a time,
* each as the transaction a keystroke makes, leaving the caret at the end so
* the suggestion list follows what was typed. Quotes and brackets are not
* closed for it: type both.
*
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
* highlighted, which strands the caret `userEvent` tracks.
*/
export const typeFilter = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
const view = await focusFilter(canvasElement);
for (const character of text) {
const at = view.state.doc.length;
view.dispatch({
changes: { from: at, insert: character },
selection: { anchor: at + character.length },
userEvent: 'input.type',
});
await pause(KEYSTROKE_MS);
}
};
/**
* Types an expression, then steps the caret back inside it, before a closing
* bracket or parenthesis, where the suggestions are about what goes in there.
*/
export const typeFilterWithCaretBack = async (
canvasElement: HTMLElement,
text: string,
stepsBack: number,
row: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
const { view } = currentView(canvasElement);
view.dispatch({
selection: { anchor: view.state.doc.length - stepsBack },
userEvent: 'select',
});
await waitForSuggestion(canvasElement, row);
};
/**
* Moves focus off the filter, which is when the expression is validated and
* the error marker can show.
*/
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.keyboard('{Escape}');
await userEvent.click(canvasElement.ownerDocument.body);
};
/** Types an expression, leaves the filter and opens its validation errors. */
export const showFilterErrors = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
await blurFilter(canvasElement);
const marker = await waitFor(
() =>
present(
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
'error marker',
),
untilLoaded,
);
await userEvent.hover(marker);
await waitFor(
() =>
present(
canvasElement.ownerDocument.querySelector('.query-validation-error'),
'validation error',
),
untilLoaded,
);
};

View File

@@ -12,7 +12,6 @@
overflow: visible;
position: relative;
align-self: stretch;
clip-path: inset(0 -100vw 0 0);
}
}
@@ -35,7 +34,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) 10.5px;
padding: 10.5px;
border-bottom: 1px solid var(--l1-border);
.left-actions {
@@ -46,9 +45,6 @@
justify-content: flex-start;
.text {
padding-left: var(--spacing-4);
white-space: nowrap;
flex-shrink: 0;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
@@ -81,8 +77,6 @@
}
.select-box {
--combobox-trigger-width: auto;
display: flex;
padding: 2px 5px;
flex-direction: row;
@@ -100,9 +94,10 @@
.right-actions {
display: flex;
align-items: center;
gap: var(--spacing-1);
flex-shrink: 0;
gap: 10px;
width: 100%;
justify-content: flex-end;
--button-variant-link-color: var(--l1-foreground);
.divider-filter {
width: 1px;
@@ -110,8 +105,26 @@
background: var(--l1-background);
}
.active {
background: var(--l2-background);
.sync-icon {
cursor: pointer;
}
.right-action-icon-container {
position: relative;
display: flex;
padding: 2px;
background-color: var(--l1-background);
.settings-icon {
height: 14px;
width: 14px;
cursor: pointer;
}
&.active,
&:hover {
background: var(--l2-background);
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import {
ArrowUpToLine,
Filter,
Frown,
RefreshCw,
Settings2 as SettingsIcon,
@@ -13,9 +14,8 @@ import {
ComboboxList,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Skeleton } from 'antd';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -57,7 +57,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
savedViewsHeader,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [params, setParams] = useApiMonitoringParams();
@@ -180,6 +179,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Helpers to reduce cognitive complexity in main render
const renderLeftActions = (): JSX.Element => (
<section className="left-actions">
<Filter size="md" />
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
@@ -216,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</Combobox>
) : (
displayedQueryName && (
<TooltipSimple
<Tooltip
title={`Filter currently in sync with query ${displayedQueryName}`}
>
<Typography.Text className="sync-tag">
{displayedQueryName}
</Typography.Text>
</TooltipSimple>
</Tooltip>
)
)}
</section>
@@ -230,54 +230,51 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderRightActions = (): JSX.Element => (
<section className="right-actions">
<TooltipSimple title="Reset All">
<Tooltip title="Reset All">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
aria-label="Reset All"
className="right-action-icon-container"
onClick={handleReset}
prefix={<RefreshCw size={14} />}
prefix={<RefreshCw className="sync-icon" size="md" />}
/>
</TooltipSimple>
</Tooltip>
{showFilterCollapse && (
<TooltipSimple title="Collapse Filters">
<Tooltip title="Collapse Filters">
<Button
variant="ghost"
variant="link"
color="secondary"
size="icon"
aria-label="Collapse Filters"
className="right-action-icon-container"
onClick={handleFilterVisibilityChange}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
/>
</TooltipSimple>
</Tooltip>
)}
{isDynamicFilters && (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="link"
color="secondary"
aria-label="Settings"
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
/>
</Tooltip>
}
/>
)}
{isDynamicFilters &&
(isSettingsDisabled ? (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
) : (
<TooltipSimple title="Settings">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
className={classNames({ active: isSettingsOpen })}
onClick={(): void => setIsSettingsOpen(true)}
data-testid="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
</TooltipSimple>
))}
</section>
);
@@ -382,7 +379,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}

View File

@@ -8,7 +8,9 @@
overflow: hidden;
}
.sidebar {
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
width: 280px;
flex-shrink: 0;
display: flex;
@@ -19,44 +21,6 @@
z-index: 2;
}
.savedViews {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
}
.quickFilters {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--l1-background);
transition: transform 0.2s ease-in-out;
z-index: 1;
&.isOpen {
transform: translateX(100%);
}
}
// No quick filters: the header sits in flow and the list stays under it
.isStatic {
border-right: 1px solid var(--l1-border);
.quickFilters {
position: static;
flex-shrink: 0;
}
.savedViews {
border-right: 0;
}
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
@@ -67,7 +31,3 @@
flex-direction: column;
overflow: hidden;
}
.contentInner {
height: 100%;
}

View File

@@ -1,9 +1,6 @@
import { ComponentProps, ReactNode, useState } from 'react';
import { ComponentProps, ReactNode } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -15,15 +12,8 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
ComponentProps<typeof QuickFilters>
>;
// What the page configures; the layout adds the open / close wiring.
type SavedViewsElementProps = Omit<
ComponentProps<typeof SavedViewsPanel>,
'onClose'
>;
export interface QuickFiltersLayoutProps {
quickFilterProps?: QuickFiltersElementProps;
savedViewProps?: SavedViewsElementProps;
quickFilterProps: QuickFiltersElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
@@ -33,65 +23,20 @@ export interface QuickFiltersLayoutProps {
function QuickFiltersLayout({
quickFilterProps,
savedViewProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
const isSliding = hasQuickFilters && isViewsListOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showSidebar && (
{showFilters && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
className={styles.filters}
data-testid="quick-filters-layout-filters"
>
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
/>
)}
</div>
<QuickFilters {...quickFilterProps} />
</aside>
)}
<section
@@ -99,7 +44,7 @@ function QuickFiltersLayout({
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div className={styles.contentInner}>{children}</div>
<div>{children}</div>
</OverlayScrollbar>
</section>
</div>

View File

@@ -1,6 +1,4 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -9,33 +7,15 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
sidebar: 'sidebar',
isStatic: 'isStatic',
savedViews: 'savedViews',
quickFilters: 'quickFilters',
isOpen: 'isOpen',
filters: 'filters',
content: 'content',
contentInner: 'contentInner',
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({
source,
savedViewsHeader,
}: {
source: string;
savedViewsHeader?: React.ReactNode;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
</div>
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
),
}));
@@ -43,16 +23,8 @@ const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -68,16 +40,11 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout
showFilters={false}
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
@@ -89,140 +56,24 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByText('content')).toBeInTheDocument();
});
it('renders no sidebar at all without quick filters or saved views', () => {
render(
<QuickFiltersLayout showFilters>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="root-extra"
contentClassName="content-extra"
testId="layout"
className="page-root"
contentClassName="page-content"
testId="page"
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'content-extra',
'page-content',
);
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const quickFilters = screen.getByTestId('quick-filters');
expect(quickFilters).toContainElement(
screen.getByTestId('saved-views-header'),
);
expect(drawer()).toContainElement(quickFilters);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const sidebar = screen.getByTestId('quick-filters-layout-filters');
expect(sidebar).toHaveClass('isStatic');
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
});
});

View File

@@ -8,20 +8,12 @@
width: 342px;
height: 100%;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
transition: width 0.05s ease-in-out;
overflow: hidden;
color: var(--l1-foreground);
transition:
transform 0.2s ease-in-out,
visibility 0s;
&.hidden {
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition:
transform 0.2s ease-in-out,
visibility 0s 0.2s;
width: 0;
}
.qf-header {
display: flex;

View File

@@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -62,7 +61,6 @@ export interface IQuickFiltersProps {
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
export enum QuickFiltersSource {

View File

@@ -5,10 +5,12 @@ import {
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { SavedViewSourcePage } from 'container/SavedViews/types';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import {
findSavedView,
getSavedViewQuery,
SavedViewSourcePage,
toSavedViewSource,
} from 'container/SavedViews/utils';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -8,7 +8,7 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { getSavedViewQuery } from 'container/SavedViews/utils';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import Card from 'periscope/components/Card/Card';

View File

@@ -10,8 +10,6 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
@@ -349,61 +347,56 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<QuickFiltersLayout
showFilters
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</div>
</Tooltip>
</div>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
</div>
</div>
</QuickFiltersLayout>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
/>
</div>
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}

View File

@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
aggregateOperator?: string;
aggregateOperator: string;
initialOptions?: InitialOptions;
}

View File

@@ -1,34 +0,0 @@
import { useMemo } from 'react';
import { Check } from '@signozhq/icons';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import SavedViewsIconButton from './SavedViewsIconButton';
import { SaveChangesMenuProps } from './types';
function SaveChangesMenu({
disabled,
onSaveAsNew,
onUpdate,
}: SaveChangesMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(
() => [
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
],
[onSaveAsNew, onUpdate],
);
return (
<DropdownMenuSimple menu={{ items }} align="end">
<SavedViewsIconButton
title="Save changes"
icon={<Check size={14} />}
color="warning"
disabled={disabled}
testId="saved-views-save-changes"
/>
</DropdownMenuSimple>
);
}
export default SaveChangesMenu;

View File

@@ -1,18 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.label {
font-size: var(--font-size-xs);
color: var(--l1-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-4);
width: 100%;
}

View File

@@ -1,94 +0,0 @@
import { FormEvent, useState } from 'react';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { SAVE_VIEW_MODAL_TITLE } from './constants';
import { SaveViewModalProps } from './types';
import styles from './SaveViewModal.module.scss';
const NAME_INPUT_ID = 'save-view-name';
function SaveViewModal({
mode,
isSaving,
onClose,
onSave,
}: SaveViewModalProps): JSX.Element {
const [name, setName] = useState('');
const displayName = name.trim();
const handleSave = async (): Promise<void> => {
if (!displayName || isSaving) {
return;
}
if (await onSave(displayName)) {
onClose();
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void handleSave();
};
return (
<DialogWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={SAVE_VIEW_MODAL_TITLE[mode]}
testId="save-view-modal"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<X size={14} />}
onClick={onClose}
testId="save-view-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
prefix={<Check size={14} />}
loading={isSaving}
disabled={!displayName}
onClick={(): void => {
void handleSave();
}}
testId="save-view-submit"
>
Save view
</Button>
</div>
}
>
<form className={styles.field} onSubmit={handleSubmit}>
<label htmlFor={NAME_INPUT_ID}>
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
</label>
<Input
id={NAME_INPUT_ID}
placeholder="Eg. payments-view"
value={name}
onChange={(event): void => setName(event.target.value)}
autoFocus
autoComplete="off"
testId="save-view-name"
/>
</form>
</DialogWrapper>
);
}
export default SaveViewModal;

View File

@@ -1,61 +0,0 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-5);
border-bottom: 1px solid var(--l1-border);
color: var(--l1-foreground);
flex-shrink: 0;
&.isDirty {
background: linear-gradient(
to right,
var(--callout-warning-background),
transparent
);
}
}
.name {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
padding: var(--spacing-1) var(--spacing-4);
border-radius: var(--spacing-1);
background: var(--l3-background);
color: var(--l1-foreground);
font-size: var(--font-size-sm);
&.isDirty {
background: var(--callout-warning-background);
color: var(--callout-warning-title);
}
}
.nameText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
width: var(--spacing-3);
height: var(--spacing-3);
flex-shrink: 0;
border-radius: 50%;
background: var(--callout-warning-icon);
}
.skeleton {
flex: 1;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
}

View File

@@ -1,146 +0,0 @@
import { useCallback, useState } from 'react';
import cx from 'classnames';
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
import { Skeleton } from '@signozhq/ui/skeleton';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useRestoreLastUsedView } from './hooks/useRestoreLastUsedView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
import SaveViewModal from './SaveViewModal';
import { SaveViewModalMode } from './types';
import styles from './SavedViewsHeader.module.scss';
function SavedViewsHeader({
source,
onOpenViews,
}: {
source: SavedviewtypesSourceDTO;
// Absent while the list is on screen.
onOpenViews?: () => void;
}): JSX.Element {
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
const { selectView, revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
useRestoreLastUsedView({ source, selectView });
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useCallback(
async (displayName: string): Promise<boolean> => {
const id = await createView(displayName);
if (!id) {
return false;
}
toast.success('You have created a new view.', {
position: SAVED_VIEW_TOAST_POSITION,
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
});
return true;
},
[createView, onOpenViews],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
}
}, [view, updateView]);
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
const closeModal = useCallback((): void => setModalMode(null), []);
const name = view?.spec.displayName ?? MY_VIEW_NAME;
const isDirty = !!view && hasUnsavedChanges;
return (
<div
className={cx(styles.header, { [styles.isDirty]: isDirty })}
data-testid="saved-views-header"
data-source={source}
>
{isLoading ? (
<Skeleton.Input
active
size="small"
className={styles.skeleton}
testId="saved-views-loading"
/>
) : (
<TooltipSimple title={name}>
<span
className={cx(styles.name, { [styles.isDirty]: isDirty })}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</TooltipSimple>
)}
<div className={styles.actions}>
{!view && (
<SavedViewsIconButton
title="Create new view"
icon={<Plus size={14} />}
disabled={isLoading}
onClick={(): void => setModalMode('create')}
testId="saved-views-create"
/>
)}
{view && !isDirty && (
<SavedViewsIconButton
title="Clear view"
icon={<Undo2 size={14} />}
onClick={clearView}
testId="saved-views-clear"
/>
)}
{view && isDirty && (
<>
<SaveChangesMenu
disabled={isSaving}
onSaveAsNew={openSaveAsNew}
onUpdate={(): void => {
void handleUpdate();
}}
/>
<SavedViewsIconButton
title="Discard changes"
icon={<X size={14} />}
color="warning"
disabled={isSaving}
onClick={(): void => revertView(view)}
testId="saved-views-discard"
/>
</>
)}
{!isDirty && onOpenViews && (
<SavedViewsIconButton
title="All views"
icon={<Menu size={14} />}
onClick={onOpenViews}
testId="saved-views-open"
/>
)}
</div>
{modalMode && (
<SaveViewModal
mode={modalMode}
isSaving={isSaving}
onClose={closeModal}
onSave={handleSave}
/>
)}
</div>
);
}
export default SavedViewsHeader;

View File

@@ -1,41 +0,0 @@
import { forwardRef } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedViewsIconButtonProps } from './types';
// forwardRef and rest props so a menu can use it as its trigger.
const SavedViewsIconButton = forwardRef<
HTMLButtonElement,
SavedViewsIconButtonProps
>(function SavedViewsIconButton(
{
title,
icon,
testId,
color = 'secondary',
disabled,
onClick,
...triggerProps
},
ref,
): JSX.Element {
return (
<TooltipSimple title={title}>
<Button
ref={ref}
variant="ghost"
color={color}
size="icon"
aria-label={title}
prefix={icon}
disabled={disabled}
onClick={onClick}
{...triggerProps}
data-testid={testId}
/>
</TooltipSimple>
);
});
export default SavedViewsIconButton;

View File

@@ -1,21 +0,0 @@
.panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
color: var(--l1-foreground);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -1,40 +0,0 @@
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsPanel.module.scss';
function SavedViewsPanel({
source,
onClose,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onClose?: () => void;
}): JSX.Element {
return (
<div
className={styles.panel}
data-testid="saved-views-panel"
data-source={source}
>
<div className={styles.header}>
<Typography.Text className={styles.title}>All views</Typography.Text>
{onClose && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Close"
prefix={<X size={14} />}
onClick={onClose}
data-testid="saved-views-close"
/>
)}
</div>
</div>
);
}
export default SavedViewsPanel;

View File

@@ -1,211 +0,0 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const PATH = ROUTES.TRACES_EXPLORER;
function renderHeader(
url: string,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>,
url,
);
}
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
async function openDirtyView(
view: ReturnType<typeof makeView>,
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
const rendered = renderHeader(viewUrl(PATH, view));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
view.spec.displayName,
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
act(() => {
rendered.history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: view.id,
}),
);
});
await waitFor(() =>
expect(screen.getByTestId('saved-views-discard')).toBeInTheDocument(),
);
return rendered;
}
async function chooseFromSaveMenu(item: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
describe('SavedViewsHeader saving', () => {
beforeEach(() => {
localStorage.clear();
});
describe('create', () => {
it('saves the current state under a generated name and opens it', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
expect(screen.getByTestId('save-view-submit')).toBeDisabled();
await submitName('Slow checkout');
await waitFor(() => expect(requests.created).toHaveLength(1));
expect(requests.created[0]).toStrictEqual(
expect.objectContaining({
generateName: true,
source: SavedviewtypesSourceDTO.traces,
schemaVersion: 'v2',
spec: expect.objectContaining({
displayName: 'Slow checkout',
panelType: 'list',
}),
}),
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-new'),
),
);
expect(toast.success).toHaveBeenCalledWith(
'You have created a new view.',
expect.objectContaining({ position: 'top-right' }),
);
await waitFor(() => expect(isModalOpen()).toBe(false));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'Slow checkout',
),
);
});
it('keeps the modal open and reports a failed save', async () => {
const requests = mockSavedViewsApi([], { createStatus: 500 });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(requests.created).toHaveLength(1);
expect(isModalOpen()).toBe(true);
expect(screen.getByTestId('save-view-name')).toHaveValue('Slow checkout');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(toast.success).not.toHaveBeenCalled();
});
it('cancel closes the modal without saving', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
await userEvent.click(screen.getByTestId('save-view-cancel'));
await waitFor(() => expect(isModalOpen()).toBe(false));
expect(requests.created).toHaveLength(0);
});
});
it('save as new creates another view from the changed state', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors], { createdId: 'view-copy' });
const { history } = await openDirtyView(errors);
await chooseFromSaveMenu('Save as new view');
expect(within(modal()).getByText('Save as new view')).toBeInTheDocument();
await submitName('Errors copy');
await waitFor(() => expect(requests.created).toHaveLength(1));
const body = requests.created[0] as {
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
expect(body.spec.displayName).toBe('Errors copy');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
expect(requests.updated).toHaveLength(0);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-copy'),
),
);
});
it('update stores the changed state on the open view, keeping its name', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors]);
await openDirtyView(errors);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(requests.updated).toHaveLength(1));
const { id, body } = requests.updated[0] as {
id: string;
body: {
source: string;
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
};
expect(id).toBe('view-1');
expect(body.source).toBe(SavedviewtypesSourceDTO.traces);
expect(body.spec.displayName).toBe('Errors');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
'View updated',
expect.objectContaining({ position: 'top-right' }),
),
);
expect(requests.created).toHaveLength(0);
});
});

View File

@@ -1,325 +0,0 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { useActiveSavedView } from '../hooks/useActiveSavedView';
import SavedViewsHeader from '../SavedViewsHeader';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
id: 'view-2',
displayName: 'Slow spans',
expression: 'duration_nano > 1000',
panelType: PANEL_TYPES.TIME_SERIES,
});
function renderHeader(
url: string,
onOpenViews: (() => void) | undefined = jest.fn(),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={onOpenViews}
/>
<RenderRecorder />
</>,
url,
);
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
const saveChanges = (): HTMLElement =>
screen.getByTestId('saved-views-save-changes');
async function waitForView(name: string): Promise<void> {
await waitFor(() => expect(chip()).toHaveTextContent(name));
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
// Each render's answer from the hook the header reads, so a state that lasts a
// single render is seen too.
interface RenderRecord {
viewId?: string;
dirty: boolean;
// The staged query had not caught up with the url yet.
isStaging: boolean;
}
let renders: RenderRecord[] = [];
function RenderRecorder(): null {
const { view, hasUnsavedChanges } = useActiveSavedView(
SavedviewtypesSourceDTO.traces,
);
const { stagedQuery } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
renders.push({
viewId: view?.id,
dirty: hasUnsavedChanges,
isStaging: stagedQuery?.id !== compositeQuery?.id,
});
return null;
}
async function recordRenders(change: () => void): Promise<RenderRecord[]> {
renders = [];
act(change);
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
return renders;
}
describe('SavedViewsHeader', () => {
beforeEach(() => {
localStorage.clear();
});
it('shows My view with create and all views when no view is open', async () => {
mockSavedViewsApi([errors]);
renderHeader(explorerUrl(PATH, {}));
expect(chip()).toHaveTextContent('My view');
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-save-changes')).toBeNull();
expect(screen.queryByTestId('saved-views-discard')).toBeNull();
});
it('shows a skeleton while the view in the url loads', async () => {
mockSavedViewsApi([errors], { getDelay: 150 });
renderHeader(viewUrl(PATH, errors));
expect(screen.getByTestId('saved-views-loading')).toBeInTheDocument();
await waitForView('Errors');
expect(screen.queryByTestId('saved-views-loading')).toBeNull();
});
it('shows the open view clean, with clear and all views', async () => {
mockSavedViewsApi([errors]);
renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-create')).toBeNull();
expect(isDirty()).toBe(false);
});
it('falls back to My view when the view in the url does not load', async () => {
mockSavedViewsApi([]);
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
expect(chip()).toHaveTextContent('My view');
});
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
expect(saveChanges()).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
it('marks the view dirty when only the tab changes', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
const params = new URLSearchParams(history.location.search);
params.set(QueryParams.panelTypes, JSON.stringify(PANEL_TYPES.TABLE));
history.push(`${PATH}?${params.toString()}`);
});
await waitFor(() => expect(isDirty()).toBe(true));
});
it('discard puts the saved query and tab back', async () => {
mockSavedViewsApi([slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = true'),
panelType: PANEL_TYPES.LIST,
viewKey: slow.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(isDirty()).toBe(false));
expect(urlParam(history, QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TIME_SERIES),
);
expect(urlParam(history, QueryParams.viewKey)).toBe(JSON.stringify(slow.id));
expect(urlParam(history, QueryParams.viewName)).toBeNull();
const query = JSON.parse(
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
);
expect(query.builder.queryData[0].filter.expression).toBe(
'duration_nano > 1000',
);
});
it('clear drops the view from the url but keeps the time range', async () => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors, '1h'));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(urlParam(history, QueryParams.panelTypes)).toBeNull();
expect(urlParam(history, QueryParams.relativeTime)).toBe('1h');
expect(
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}'),
).toStrictEqual({});
});
describe('last used view', () => {
beforeEach(() => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
});
it('reopens on a bare explorer', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
});
it('leaves a url with its own query alone', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
}),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
});
describe('unsaved changes indicator', () => {
it('never flashes while switching between views already loaded', async () => {
mockSavedViewsApi([errors, slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => history.push(viewUrl(PATH, errors)));
await waitForView('Errors');
const states = await recordRenders(() => history.push(viewUrl(PATH, slow)));
expect(chip()).toHaveTextContent('Slow spans');
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => render.dirty)).toStrictEqual([]);
});
it('holds while the query keeps changing on a dirty view', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
const states = await recordRenders(() =>
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'duration_nano > 5'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
),
);
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => !render.dirty)).toStrictEqual([]);
});
});
it('on metrics compares no columns and has no all views button', async () => {
const cpu = makeView({
id: 'view-3',
displayName: 'CPU',
source: SavedviewtypesSourceDTO.metrics,
dataSource: DataSource.METRICS,
expression: '',
panelType: PANEL_TYPES.TIME_SERIES,
});
mockSavedViewsApi([cpu]);
renderWithExplorerProviders(
<SavedViewsHeader source={SavedviewtypesSourceDTO.metrics} />,
viewUrl(ROUTES.METRICS_EXPLORER_EXPLORER, cpu),
DataSource.METRICS,
);
await waitForView('CPU');
expect(isDirty()).toBe(false);
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
});

View File

@@ -1,304 +0,0 @@
import { ReactElement, useEffect, useMemo } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { render, RenderResult } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { createMemoryHistory, MemoryHistory } from 'history';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppContext } from 'providers/App/App';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import store from 'store';
import { getAppContextMock } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
export const API = 'http://localhost/api/v2/saved_views';
// The server stores queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined),
);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
export function queryWith(dataSource: DataSource, expression: string): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
} as Query;
}
export function makeView({
id,
displayName,
source = SavedviewtypesSourceDTO.traces,
dataSource = DataSource.TRACES,
expression = 'has_error = true',
panelType = PANEL_TYPES.LIST,
}: {
id: string;
displayName: string;
source?: SavedviewtypesSourceDTO;
dataSource?: DataSource;
expression?: string;
panelType?: PANEL_TYPES;
}): SavedviewtypesSavedViewDTO {
const spec = toSavedViewSpec({
query: queryWith(dataSource, expression),
panelType,
displayName,
});
return {
id,
name: `${displayName.toLowerCase()}-abc`,
source,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdBy: 'test@signoz.io',
updatedBy: 'test@signoz.io',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// Params written the way the query builder redirect writes them.
export function explorerUrl(
path: string,
{
query,
panelType,
viewKey,
relativeTime = '15m',
}: {
query?: Query;
panelType?: PANEL_TYPES;
viewKey?: string;
relativeTime?: string;
},
): string {
const params = new URLSearchParams();
if (query) {
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify({ ...query, id: `url-${Math.random()}` })),
);
}
if (panelType) {
params.set(QueryParams.panelTypes, JSON.stringify(panelType));
}
if (viewKey) {
params.set(QueryParams.viewKey, JSON.stringify(viewKey));
}
params.set(QueryParams.relativeTime, relativeTime);
return `${path}?${params.toString()}`;
}
export function viewUrl(
path: string,
view: SavedviewtypesSavedViewDTO,
relativeTime?: string,
): string {
return explorerUrl(path, {
query: getSavedViewQuery(view),
panelType: view.spec.panelType as unknown as PANEL_TYPES,
viewKey: view.id,
relativeTime,
});
}
export interface Requests {
created: unknown[];
updated: { id: string; body: unknown }[];
}
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
createdId = 'view-new',
getDelay = 0,
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
): Requests {
const requests: Requests = { created: [], updated: [] };
server.use(
rest.get(API, (req, res, ctx) => {
const source = req.url.searchParams.get('source');
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: views.filter((view) => !source || view.source === source),
}),
);
}),
rest.get(`${API}/:id`, (req, res, ctx) => {
const view = views.find((v) => v.id === req.params.id);
if (!view) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.delay(getDelay),
ctx.status(200),
ctx.json({ status: 'success', data: view }),
);
}),
rest.post(API, async (req, res, ctx) => {
const body = await req.json();
requests.created.push(body);
if (createStatus >= 400) {
return res(
ctx.status(createStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
return res(
ctx.status(createStatus),
ctx.json({ status: 'success', data: { id: createdId } }),
);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = await req.json();
requests.updated.push({ id: String(req.params.id), body });
return res(ctx.status(204));
}),
);
return requests;
}
const mockStore = configureStore([thunk]);
// What the explorer page does around the header: writes its default query to a
// bare url, and sets the provider's tab from the url (the provider alone seeds
// it from the raw, still JSON-quoted param).
function ExplorerShell({ dataSource }: { dataSource: DataSource }): null {
const panelType = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const { handleSetConfig, updateAllQueriesOperators } = useQueryBuilder();
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap[dataSource],
PANEL_TYPES.LIST,
dataSource,
),
[updateAllQueriesOperators, dataSource],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
useEffect(() => {
handleSetConfig(panelType, dataSource);
}, [panelType, dataSource, handleSetConfig]);
return null;
}
export function renderWithExplorerProviders(
ui: ReactElement,
initialUrl: string,
dataSource: DataSource = DataSource.TRACES,
): RenderResult & { history: MemoryHistory } {
const history = createMemoryHistory({ initialEntries: [initialUrl] });
// Parts of the explorer read window.location directly, so it follows the
// router's history.
const syncWindowLocation = (): void =>
window.history.replaceState(
null,
'',
`${history.location.pathname}${history.location.search}`,
);
syncWindowLocation();
history.listen(syncWindowLocation);
__setSearchParamsGetterForTest(
() => new URLSearchParams(history.location.search),
);
safeNavigateMock.mockImplementation((to) => {
history.push(
typeof to === 'string'
? to
: `${to.pathname ?? history.location.pathname}${to.search ?? ''}`,
);
});
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
mutations: { retry: false },
},
});
const result = render(
<Router history={history}>
<CompatRouter>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<Provider store={mockStore(store.getState())}>
<AppContext.Provider value={getAppContextMock('ADMIN')}>
<TooltipProvider>
<PreferenceContextProvider>
<QueryBuilderProvider>
<ExplorerShell dataSource={dataSource} />
{ui}
</QueryBuilderProvider>
</PreferenceContextProvider>
</TooltipProvider>
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</NuqsAdapter>
</CompatRouter>
</Router>,
);
return { ...result, history };
}
export function urlParam(history: MemoryHistory, key: string): string | null {
return new URLSearchParams(history.location.search).get(key);
}

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewQuery } from '../getSavedViewQuery';
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
@@ -91,3 +91,36 @@ describe('getSavedViewQuery', () => {
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
});
});
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});
describe('findSavedView', () => {
const views = [
{ ...makeView(), id: 'a' },
{ ...makeView(), id: 'b' },
];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -1,35 +0,0 @@
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { DataSource } from 'types/common/queryBuilder';
import { SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,
QueryParams.viewName,
QueryParams.compositeQuery,
QueryParams.panelTypes,
];
export const MY_VIEW_NAME = 'My view';
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
create: 'Create new view',
saveAsNew: 'Save as new view',
};
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;

View File

@@ -1,76 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
export function useActiveSavedView(
source: SavedviewtypesSourceDTO,
): UseActiveSavedViewResult {
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { data, isLoading, isError } = useGetSavedView(
{ id: viewKey },
{ query: { enabled: !!viewKey } },
);
const view = viewKey ? data?.data : undefined;
// The query builder stages the url query a render later. Until the ids
// match, keep the last answer for this view; another view starts clean.
const compositeQuery = useGetCompositeQueryParam();
const isStagedQueryCurrent =
!!stagedQuery && stagedQuery.id === compositeQuery?.id;
const lastResult = useRef<{ viewId?: string; hasUnsavedChanges: boolean }>({
hasUnsavedChanges: false,
});
const hasUnsavedChanges = useMemo((): boolean => {
if (!view) {
return false;
}
if (!isStagedQueryCurrent) {
return (
lastResult.current.viewId === view.id &&
lastResult.current.hasUnsavedChanges
);
}
return hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
});
}, [
view,
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
options,
]);
useEffect(() => {
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
}, [view?.id, hasUnsavedChanges]);
return {
view,
isLoading: !!viewKey && isLoading,
isError: !!viewKey && isError,
hasUnsavedChanges,
};
}

View File

@@ -1,55 +0,0 @@
import { useCallback } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
PreservedViewsInLocalStorage,
PreservedViewType,
} from 'container/ExplorerOptions/types';
import { UseLastUsedViewResult } from '../types';
function readLastUsedViews(): PreservedViewsInLocalStorage {
try {
return JSON.parse(
getLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) || '{}',
) as PreservedViewsInLocalStorage;
} catch {
return {};
}
}
// Read at call time, not held in state: the hook is used from more than one
// place and a cached copy would bring back a view another caller just cleared.
export function useLastUsedView(
savedViewSource: SavedviewtypesSourceDTO,
): UseLastUsedViewResult {
// The bar's enum carries the same four values as the v2 source.
const source = savedViewSource as unknown as PreservedViewType;
const getLastUsedViewKey = useCallback(
(): string | undefined => readLastUsedViews()[source]?.key,
[source],
);
const setLastUsedView = useCallback(
(id: string, displayName: string): void => {
setLocalStorageKey(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({
...readLastUsedViews(),
[source]: { key: id, value: displayName },
}),
);
},
[source],
);
const clearLastUsedView = useCallback((): void => {
const { [source]: _removed, ...rest } = readLastUsedViews();
setLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, JSON.stringify(rest));
}, [source]);
return { getLastUsedViewKey, setLastUsedView, clearLastUsedView };
}

View File

@@ -1,56 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { QueryParams } from 'constants/query';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { UseRestoreLastUsedViewArgs } from '../types';
import { useLastUsedView } from './useLastUsedView';
export function useRestoreLastUsedView({
source,
selectView,
}: UseRestoreLastUsedViewArgs): void {
const { viewKey } = useGetSavedViewParams();
const { getLastUsedViewKey, clearLastUsedView } = useLastUsedView(source);
// Decided on the first render, before the explorer writes its default query
// to the url; later an empty url means the view was cleared.
const [lastUsedViewKey] = useState(() => {
const params = getUnstableCurrentSearchParams();
const isBare =
!params.get(QueryParams.viewKey) && !params.get(QueryParams.compositeQuery);
return isBare ? getLastUsedViewKey() : undefined;
});
const hasRestored = useRef(false);
const shouldRestore = !!lastUsedViewKey && !viewKey && !hasRestored.current;
const { data, isError } = useGetSavedView(
{ id: lastUsedViewKey ?? '' },
{ query: { enabled: shouldRestore } },
);
const view = data?.data;
useEffect(() => {
if (!shouldRestore) {
return undefined;
}
if (isError) {
hasRestored.current = true;
clearLastUsedView();
return undefined;
}
if (!view) {
return undefined;
}
// Deferred like the bar's restore, so it lands after the explorer has
// written its default query to the url.
const timeoutId = setTimeout(() => {
hasRestored.current = true;
selectView(view);
}, 0);
return (): void => clearTimeout(timeoutId);
}, [shouldRestore, isError, view, clearLastUsedView, selectView]);
}

View File

@@ -1,193 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateGetSavedView,
invalidateListSavedViews,
useCreateSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
import {
RenderErrorResponseDTO,
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
// to the user's own.
export function useSavedViewActions(
source: SavedviewtypesSourceDTO,
): UseSavedViewActionsResult {
const queryClient = useQueryClient();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const { currentQuery, stagedQuery, panelType, redirectWithQueryBuilderData } =
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
const { mutateAsync: updateSavedView, isLoading: isUpdating } =
useUpdateSavedView();
const invalidateViews = useCallback(
async (id?: string): Promise<void> => {
await Promise.all([
invalidateListSavedViews(queryClient),
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
// The saved views bar still lists through v1.
queryClient.invalidateQueries([{ sourcepage: source }]),
]);
},
[queryClient, source],
);
const toCurrentSpec = useCallback(
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
toSavedViewSpec({
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
);
const selectView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
handleExplorerTabChange(view.spec.panelType, {
query: getSavedViewQuery(view),
viewKey: view.id,
});
setLastUsedView(view.id, view.spec.displayName);
},
[handleExplorerTabChange, setLastUsedView],
);
// The preference provider re-reads a view's columns only when its list
// changes, so the list is refetched as the view is applied again.
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
void invalidateListSavedViews(queryClient);
selectView(view);
},
[queryClient, selectView],
);
// The query builder redirects only ever add params, so the url is rebuilt
// here; the time range and everything else a view does not set stays.
const clearView = useCallback((): void => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
SAVED_VIEW_URL_PARAMS.forEach((param) => params.delete(param));
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const createView = useCallback(
async (displayName: string): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
generateName: true,
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(displayName),
},
});
await invalidateViews();
redirectWithQueryBuilderData(stagedQuery ?? currentQuery, {
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
return data.id;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not save the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return undefined;
}
},
[
createSavedView,
source,
toCurrentSpec,
invalidateViews,
redirectWithQueryBuilderData,
stagedQuery,
currentQuery,
setLastUsedView,
],
);
const updateView = useCallback(
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
try {
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(view.spec.displayName, view.spec.display?.color),
},
});
await invalidateViews(view.id);
return true;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not update the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews],
);
return {
selectView,
revertView,
clearView,
createView,
updateView,
isSaving: isCreating || isUpdating,
};
}

View File

@@ -1,83 +0,0 @@
import { ReactElement } from 'react';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
OptionsQuery,
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
>;
export interface ToSavedViewSpecArgs {
query: Query;
panelType: PANEL_TYPES;
displayName: string;
options?: SavedViewOptions;
color?: string;
}
export interface HasUnsavedViewChangesArgs {
view: SavedviewtypesSavedViewDTO;
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
}
export interface UseLastUsedViewResult {
getLastUsedViewKey: () => string | undefined;
setLastUsedView: (id: string, displayName: string) => void;
clearLastUsedView: () => void;
}
export interface UseActiveSavedViewResult {
view: SavedviewtypesSavedViewDTO | undefined;
isLoading: boolean;
isError: boolean;
hasUnsavedChanges: boolean;
}
export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (displayName: string) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}
export interface UseRestoreLastUsedViewArgs {
source: SavedviewtypesSourceDTO;
selectView: UseSavedViewActionsResult['selectView'];
}
export type SaveViewModalMode = 'create' | 'saveAsNew';
export interface SaveViewModalProps {
mode: SaveViewModalMode;
isSaving: boolean;
onClose: () => void;
// Resolves true when saved; the modal stays open otherwise.
onSave: (displayName: string) => Promise<boolean>;
}
export interface SaveChangesMenuProps {
disabled: boolean;
onSaveAsNew: () => void;
onUpdate: () => void;
}
export interface SavedViewsIconButtonProps {
title: string;
icon: ReactElement;
testId: string;
color?: 'secondary' | 'warning';
disabled?: boolean;
onClick?: () => void;
}

View File

@@ -0,0 +1,49 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -1,20 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { findSavedView } from '../findSavedView';
describe('findSavedView', () => {
const views = [{ id: 'a' }, { id: 'b' }] as SavedviewtypesSavedViewDTO[];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -1,217 +0,0 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewQuery } from '../getSavedViewQuery';
import { hasUnsavedViewChanges } from '../hasUnsavedViewChanges';
import { toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function query(dataSource: DataSource, expression = 'has_error = true'): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
// The server stores the queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined);
return Object.fromEntries(entries);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
function viewFrom(
spec: SavedviewtypesSavedViewSpecDTO,
): SavedviewtypesSavedViewDTO {
return {
id: 'view-1',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// What the query builder stages after the view is opened: the stored query,
// with its defaults filled in.
function stagedFrom(
view: SavedviewtypesSavedViewDTO,
dataSource: DataSource,
): Query {
const saved = getSavedViewQuery(view);
return {
...saved,
builder: {
...saved.builder,
queryData: saved.builder.queryData.map((queryData) => ({
...initialQueryBuilderFormValuesMap[dataSource],
...queryData,
})),
},
};
}
const columns = [
{ name: 'service.name', key: 'service.name' },
{ name: 'name', key: 'name' },
] as TelemetryFieldKey[];
const formatting = {
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
};
describe('hasUnsavedViewChanges', () => {
const options = { selectColumns: columns, ...formatting };
const view = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options,
}),
);
const staged = stagedFrom(view, DataSource.TRACES);
it('is dirty when the columns change', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: [columns[0]] },
}),
).toBe(true);
});
it('reads a traces view without columns as the default columns', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
}),
).toBe(false);
});
describe('on logs', () => {
const logsView = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: { selectColumns: columns, ...formatting },
}),
);
const logsStaged = stagedFrom(logsView, DataSource.LOGS);
// The explorer always adds the required logs columns.
const shownColumns = ensureLogsRequiredColumns(columns);
it('is clean with the required columns the explorer adds', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting },
}),
).toBe(false);
});
it('is dirty when the formatting changes', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
}),
).toBe(true);
});
it('reads a view without formatting as the default formatting', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
options: {
selectColumns: defaultLogsSelectedColumns,
format: 'table',
maxLines: 1,
fontSize: FontSize.SMALL,
},
}),
).toBe(false);
});
});
it('ignores formatting on traces', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, ...formatting, maxLines: 5 },
}),
).toBe(false);
});
});

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { toSavedViewSource } from '../toSavedViewSource';
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});

View File

@@ -1,95 +0,0 @@
import {
Querybuildertypesv5RequestTypeDTO,
SavedviewtypesPanelTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { toSavedViewPanelType, toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function tracesQuery(): Query {
const base = initialQueriesMap.traces;
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression: 'has_error = true' },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
const columns = [
{
name: 'service.name',
key: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
isIndexed: true,
},
{ key: 'name' },
] as unknown as TelemetryFieldKey[];
describe('toSavedViewPanelType', () => {
it('folds a dashboard only panel type to graph', () => {
expect(toSavedViewPanelType(PANEL_TYPES.PIE)).toBe(
SavedviewtypesPanelTypeDTO.graph,
);
});
});
describe('toSavedViewSpec', () => {
it('stores only the field identity of the columns, naming key only ones', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: {
selectColumns: columns,
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
},
});
expect(spec.selectedFields).toStrictEqual([
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{ name: 'name' },
]);
expect(spec.display).toStrictEqual({
format: 'raw',
maxLines: 2,
fontSize: FontSize.SMALL,
});
});
it('keeps the colour in display', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'Errors',
color: 'red',
});
expect(spec.panelType).toBe(SavedviewtypesPanelTypeDTO.graph);
expect(spec.requestType).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
expect(spec.display).toStrictEqual({ color: 'red' });
});
});

View File

@@ -1,8 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -1,17 +0,0 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}

View File

@@ -1,49 +0,0 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
columns: TelemetryFieldKey[] | undefined,
): TelemetryFieldKey[] | null {
if (!columns) {
return null;
}
return columns.map((column) => ({
...column,
name: column.name ?? column.key,
}));
}
// The columns and formatting the explorer shows for a saved view, with the
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {
return {
columns: ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
),
formatting: {
maxLines: spec?.display?.maxLines || 1,
format: (spec?.display?.format as LogViewMode) || 'table',
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
version: 1,
},
};
}
if (dataSource === DataSource.TRACES) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
return { columns: [] };
}

View File

@@ -1,81 +0,0 @@
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
// The server drops empty values when it stores a query, while the query
// builder fills them with defaults, so both sides lose them before comparing.
function withoutEmptyValues(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value
.map(withoutEmptyValues)
.filter((item) => item !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
.filter(([, item]) => item !== undefined);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
if (value === null || value === '' || value === 0) {
return undefined;
}
return value;
}
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
// side gets different defaults.
export function hasUnsavedViewChanges({
view,
stagedQuery,
panelType,
options,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
return false;
}
if (view.spec.panelType !== toSavedViewPanelType(panelType)) {
return true;
}
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
stagedQuery,
panelType,
);
if (
!isEqual(
withoutEmptyValues(view.spec.queries),
withoutEmptyValues(stagedQueries),
)
) {
return true;
}
if (!options) {
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
return false;
}
return (
formatting.format !== options.format ||
formatting.maxLines !== options.maxLines ||
formatting.fontSize !== options.fontSize
);
}

View File

@@ -1,23 +0,0 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}

View File

@@ -1,80 +0,0 @@
import {
Querybuildertypesv5QueryEnvelopeDTO,
SavedviewtypesDisplayDTO,
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewSpecDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { panelTypeToRequestType } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ToSavedViewSpecArgs } from '../types';
const SAVED_VIEW_PANEL_TYPES = new Set<string>(
Object.values(SavedviewtypesPanelTypeDTO),
);
// Explorers only produce list / trace / graph / table; anything else a caller
// hands over (a dashboard panel type) is stored as a time series view.
export function toSavedViewPanelType(
panelType: PANEL_TYPES,
): SavedviewtypesPanelTypeDTO {
return SAVED_VIEW_PANEL_TYPES.has(panelType)
? (panelType as unknown as SavedviewtypesPanelTypeDTO)
: SavedviewtypesPanelTypeDTO.graph;
}
// The api stores the field identity only; `key` and the column flags are ui state.
function toFieldKey(
column: TelemetryFieldKey,
): TelemetrytypesTelemetryFieldKeyDTO {
return {
name: column.name ?? column.key ?? '',
...(column.signal && {
signal: column.signal as unknown as TelemetrytypesSignalDTO,
}),
...(column.fieldContext && {
fieldContext:
column.fieldContext as unknown as TelemetrytypesFieldContextDTO,
}),
...(column.fieldDataType && {
fieldDataType:
column.fieldDataType as unknown as TelemetrytypesFieldDataTypeDTO,
}),
...(column.description && { description: column.description }),
...(column.unit && { unit: column.unit }),
};
}
export function toSavedViewSpec({
query,
panelType,
displayName,
options,
color,
}: ToSavedViewSpecArgs): SavedviewtypesSavedViewSpecDTO {
const { queries } = mapCompositeQueryFromQuery(query, panelType);
const display: SavedviewtypesDisplayDTO = {
...(color && { color }),
...(options?.format && { format: options.format }),
...(options?.maxLines && { maxLines: options.maxLines }),
...(options?.fontSize && { fontSize: options.fontSize }),
};
return {
displayName,
panelType: toSavedViewPanelType(panelType),
requestType: panelTypeToRequestType(panelType),
queries: (queries ?? []) as unknown as Querybuildertypesv5QueryEnvelopeDTO[],
...(options?.selectColumns?.length && {
selectedFields: options.selectColumns.map(toFieldKey),
}),
...(Object.keys(display).length > 0 && { display }),
};
}

View File

@@ -73,20 +73,32 @@ export const useHandleExplorerTabChange = (): {
}
const query = currentQueryData?.query || getUpdateQuery(newPanelType);
const nextViewKey = currentQueryData?.viewKey || viewKey;
const nextViewName = currentQueryData?.viewName || viewName;
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
...(nextViewName ? { [QueryParams.viewName]: nextViewName } : {}),
},
redirectToUrl,
undefined,
newTab,
);
if (redirectToUrl) {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
redirectToUrl,
undefined,
newTab,
);
} else {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
undefined,
undefined,
newTab,
);
}
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
);

View File

@@ -8,6 +8,7 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { encode } from 'js-base64';
import type { Tags } from 'hooks/useResourceAttribute/types';
import { fireEvent, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -31,6 +32,9 @@ import {
type ServiceHealth,
} from './__story_mockdata__/serviceMap';
/** The keys are only fetched once the select opens, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
const GRAPH = 'Service map · graph';
const FILTERS = 'Service map · filters';
@@ -129,3 +133,80 @@ export const serviceMapMocks = defineStoryMocks({
],
config: (values) => ({ route: serviceMapRoute(values.filters) }),
});
/** Opens the select under a test id; it closes again after every pick. */
export const openSelect = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const select = await within(canvasElement).findByTestId(
testId,
undefined,
untilLoaded,
);
await userEvent.click(within(select).getByRole('combobox'));
};
export const OPEN_DROPDOWN =
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)';
/** antd keeps a hidden copy of each label for screen readers; the title skips it. */
const visibleOption = (title: string): HTMLElement | null =>
document.querySelector<HTMLElement>(
`${OPEN_DROPDOWN} .ant-select-item-option[title="${title}"]`,
);
/**
* Picks the option titled `title` in the open dropdown. `userEvent.click`
* moves focus off the select on the way, which closes it before the option
* takes the click.
*/
export const pickOption = async (title: string): Promise<void> => {
const option = await waitFor(() => {
const match = visibleOption(title);
if (!match) {
throw new Error(`option "${title}" not found`);
}
return match;
}, untilLoaded);
await fireEvent.click(option);
};
/**
* Opens the attribute filter on its next step. Each single-choice pick closes
* the dropdown and swaps the select for the next step's, and a click that lands
* before the swap opens nothing, so it opens again until `title` shows.
*/
export const openAttributeFilterOn = (
canvasElement: HTMLElement,
title: string,
): Promise<void> =>
waitFor(
async () => {
if (visibleOption(title)) {
return;
}
if (!document.querySelector(OPEN_DROPDOWN)) {
await openSelect(canvasElement, 'resource-attributes-filter');
}
throw new Error(`option "${title}" not shown`);
},
{ ...untilLoaded, interval: 500 },
);
/** Stages `k8s.cluster.name IN` and leaves the filter open on its values. */
export const stageClusterIn = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
await pickOption('k8s.cluster.name');
await openAttributeFilterOn(canvasElement, 'IN');
await pickOption('IN');
await openAttributeFilterOn(canvasElement, 'staging-eu');
};

View File

@@ -1,10 +1,17 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { serviceMapMocks } from './ServiceMap.stories.mocks';
import {
OPEN_DROPDOWN,
openAttributeFilterOn,
openSelect,
pickOption,
serviceMapMocks,
stageClusterIn,
} from './ServiceMap.stories.mocks';
import ServiceMapContainer from '../index';
@@ -97,3 +104,49 @@ export const FilterAttributes: Story = {
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
},
};
/** A key staged as a chip, the filter open again on how to match it. */
export const FilterOperators: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
await pickOption('k8s.cluster.name');
await openAttributeFilterOn(canvasElement, 'Not IN');
},
};
/** A key and `IN` staged, the filter open on the values the key holds. */
export const FilterValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await stageClusterIn(canvasElement);
},
};
/** Two values ticked before the filter is left, which is what applies it. */
export const FilterValuesSelected: Story = {
play: async ({ canvasElement }): Promise<void> => {
await stageClusterIn(canvasElement);
await pickOption('prod-us-east');
await pickOption('prod-eu-west');
await waitFor(
() =>
expect(
document.querySelectorAll('.ant-select-item-option-selected'),
).toHaveLength(2),
untilLoaded,
);
},
};
/** The environment selector open on the environments the calls came from. */
export const EnvironmentOptions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openSelect(canvasElement, 'resource-environment-filter');
await waitFor(
() =>
expect(
document.querySelector(`${OPEN_DROPDOWN} .ant-select-item-option`),
).not.toBeNull(),
untilLoaded,
);
},
};

View File

@@ -103,6 +103,18 @@ export const DeleteDowntimeConfirm: Story = {
},
};
/** The new-downtime form set to repeat weekly, which adds the days and duration. */
export const NewDowntimeRecurring: Story = {
play: async ({ canvasElement }): Promise<void> => {
await NewDowntime.play?.({ canvasElement } as never);
await userEvent.click(
await screen.findByRole('combobox', { name: 'Repeats every' }),
);
await userEvent.click(await screen.findByText('Weekly'));
await screen.findByText('Duration');
},
};
/** A client-side search with no matching downtime schedule. */
export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -6,9 +6,19 @@
import { rest } from 'msw';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import { countControl, toggleControl } from '@/storybook/controls/controls';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
RESPONSE_STATES,
type ResponseState,
respondWith,
} from '@/storybook/runtime/responseState';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
@@ -23,6 +33,9 @@ import {
type ListErrorsBody,
} from './__story_mockdata__/exceptions';
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LIST = 'Exceptions · list';
const FILTERS = 'Exceptions · filters';
@@ -42,6 +55,13 @@ export const exceptionsMocks = defineStoryMocks({
value: 6,
max: EXCEPTION_QUICK_FILTER_CAP,
}),
filterKeys: choiceControl<ResponseState>('Filter keys', {
group: FILTERS,
description:
'How `/autocomplete/attribute_keys` answers when the resource filter opens, apart from the page-wide Data control.',
options: RESPONSE_STATES,
value: 'loaded',
}),
filterPanel: toggleControl('Quick filters panel', {
group: FILTERS,
description:
@@ -92,7 +112,7 @@ export const exceptionsMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) =>
respondWith(values.filterKeys, (req) =>
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
),
),
@@ -111,3 +131,61 @@ export const exceptionsMocks = defineStoryMocks({
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
},
});
export const openResourceFilter = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const filter = await within(canvasElement).findByTestId(
'qb-search-select',
undefined,
untilLoaded,
);
await userEvent.click(within(filter).getByRole('combobox'));
return filter;
};
/**
* Clicks the visible row whose label is `text`. The dropdown renders in the
* body, a key row carries its type beside the label, and antd keeps a hidden
* copy of each label for screen readers that takes no clicks.
*
* The key list renders twice after the filter opens, since a second key
* request empties it until it answers. The click happens in the same task as
* the lookup: `fireEvent` from `storybook/test` dispatches a tick later, which
* can land on a row already removed and never reach React.
*/
export const pickSuggestion = async (text: string): Promise<void> => {
await waitFor(() => {
const match = Array.from(
document.querySelectorAll<HTMLElement>(
'.query-builder-search.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
),
).find((option) =>
Array.from(option.querySelectorAll('*')).some(
(node) => node.children.length === 0 && node.textContent === text,
),
);
if (!match) {
throw new Error(`suggestion "${text}" not found`);
}
// `userEvent.click` moves focus off the search input on the way, which closes
// the dropdown before the row takes the click.
match.click();
}, untilLoaded);
};
export const commitFilter = async (
key: string,
operator: string,
value: string,
): Promise<void> => {
await pickSuggestion(key);
await screen.findByText('Operator for', { exact: false }, untilLoaded);
await pickSuggestion(operator);
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
await pickSuggestion(value);
};

View File

@@ -5,7 +5,12 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { exceptionsMocks } from './AllErrors.stories.mocks';
import {
commitFilter,
exceptionsMocks,
openResourceFilter,
pickSuggestion,
} from './AllErrors.stories.mocks';
import AllErrors from '../index';
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
@@ -125,3 +130,108 @@ export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/** The resource filter opened: every key the exceptions can be narrowed by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await screen.findByText('Suggested Filters', undefined, untilLoaded);
},
};
/** The key list grown past its first rows with the Show all shortcut. */
export const FilterAllKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await screen.findByText('Show all filter items', undefined, untilLoaded);
await userEvent.keyboard('{Control>}/{/Control}');
await screen.findByText('cloud.region', undefined, untilLoaded);
},
};
/** A partial key: the typed text as a free search, then the keys that match. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
const filter = await openResourceFilter(canvasElement);
await userEvent.type(within(filter).getByRole('combobox'), 'serv');
await screen.findByText('service.namespace', undefined, untilLoaded);
},
};
/** A key picked: the operators the exceptions page allows for it. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await pickSuggestion('service.name');
await screen.findByText('Operator for', { exact: false }, untilLoaded);
},
};
/** A key and an operator picked: the values the key holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await pickSuggestion('service.name');
await pickSuggestion('=');
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
},
};
/** Two conditions committed as chips, with the dropdown closed again. */
export const FilterChips: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await commitFilter('service.name', '=', 'checkout');
await commitFilter('deployment.environment', '!=', 'staging');
await userEvent.click(canvasElement.ownerDocument.body);
await within(canvasElement).findByText(
'deployment.environment != staging',
undefined,
untilLoaded,
);
},
};
/**
* A committed chip clicked to change it: its text goes back into the input,
* with the dropdown shut until the input is typed into.
*/
export const FilterEditChip: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await commitFilter('service.name', '=', 'checkout');
await userEvent.click(
await within(canvasElement).findByText(
'service.name = checkout',
undefined,
untilLoaded,
),
);
// The select remounts whenever its chips change, so it is looked up again.
await waitFor(
() =>
expect(
within(within(canvasElement).getByTestId('qb-search-select')).getByRole(
'combobox',
),
).toHaveValue('service.name = checkout'),
untilLoaded,
);
},
};
/** The key list while its request is still in flight. */
export const FilterKeysLoading: Story = {
args: { filterKeys: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await waitFor(
() =>
expect(
document.querySelector('.query-builder-search .ant-spin'),
).not.toBeNull(),
untilLoaded,
);
},
};

View File

@@ -15,7 +15,10 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
fieldKeysResponse,
fieldValuesResponse,
} from '@/storybook/msw/__story_mockdata__/fields';
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
import {
@@ -31,6 +34,7 @@ import {
import {
emptyPanelResponse,
NAMESPACE_VALUES,
VARIABLE_ATTRIBUTES,
panelResponse,
serviceVariableValues,
} from './__story_mockdata__/panelData';
@@ -238,6 +242,12 @@ export const dashboardMocks = defineStoryMocks({
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
),
// The dynamic variable editor lists the attributes a variable can read.
rest.get(
'http://localhost/api/v1/fields/keys',
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
),
// The header reads the public link on every load, so it answers even while
// the panels are held in the loading or failed state.
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>

View File

@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -285,6 +285,67 @@ export const SectionActionsMenu: Story = {
},
};
/** The panel menu's move-to-section submenu, open on the sections it can go to. */
export const PanelMoveToSectionSubmenu: Story = {
play: async (context) => {
await PanelActionsMenu.play?.(context);
await userEvent.hover(await screen.findByText('Move to section'));
// The submenu lists the sections the panel is not already in.
await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), {
timeout: 10000,
});
},
};
/** Dashboard settings on the Publish tab, where the public link is managed. */
export const SettingsPublicDashboard: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Configure' },
{ timeout: 10000 },
),
);
await userEvent.click(await screen.findByRole('tab', { name: 'Publish' }));
await screen.findByText('Default time range');
},
};
/** The Publish tab with its default time range select open. */
export const SettingsPublicDashboardSelectOpen: Story = {
play: async (context) => {
await SettingsPublicDashboard.play?.(context);
await userEvent.click(
within(screen.getByRole('tabpanel')).getByRole('combobox'),
);
await screen.findByRole('listbox');
},
};
/** The Variables tab of dashboard settings with a new variable's form open. */
export const SettingsVariablesNew: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Configure' },
{ timeout: 10000 },
),
);
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
const add = await within(await screen.findByRole('tabpanel')).findByRole(
'button',
{ name: 'Add variable' },
);
// The button stays disabled until its permission check resolves.
await waitFor(() => expect(add).toBeEnabled(), { timeout: 10000 });
await userEvent.click(add);
await screen.findByText('Variable Type');
},
};
/**
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
*

View File

@@ -168,6 +168,15 @@ export const serviceVariableValues = (count: number): string[] =>
);
/** Values the dynamic `namespace` variable resolves from the fields endpoint. */
/** Attributes the dynamic variable editor offers a variable to read. */
export const VARIABLE_ATTRIBUTES = [
'k8s.namespace.name',
'k8s.cluster.name',
'service.name',
'deployment.environment',
'host.name',
];
export const NAMESPACE_VALUES = [
'checkout-prod',
'payments-prod',

View File

@@ -6,6 +6,7 @@
import { rest } from 'msw';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -342,3 +343,30 @@ export const dashboardsListMocks = defineStoryMocks({
});
},
});
/** Opens the actions menu of the row at `index`. */
export const openRowActions = async (
canvasElement: HTMLElement,
index: number,
): Promise<void> => {
// The icon-only trigger carries no accessible name.
const triggers = await within(canvasElement).findAllByTestId(
'dashboard-action-icon',
{},
{ timeout: 10000 },
);
await userEvent.click(triggers[index]);
await screen.findByText('Rename');
};
/** Picks a row action, retrying while its permission check still disables it. */
export const pickRowAction = async (label: string | RegExp): Promise<void> => {
await waitFor(
async () => {
await userEvent.click(screen.getByText(label));
await screen.findByRole('dialog', {}, { timeout: 500 });
},
{ timeout: 10000 },
);
};

View File

@@ -6,7 +6,9 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardsListMocks,
openRowActions,
overflowingRows,
pickRowAction,
} from './DashboardsListPage.stories.mocks';
import { BuiltinViewId } from '../types';
@@ -114,6 +116,45 @@ export const Tooltips: Story = {
parameters: { msw: { handlers: [overflowingRows] } },
};
/** The first row's actions menu, open over the list. */
export const RowActionsMenu: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 0);
},
};
/** The rename dialog, opened from the menu of the second row (the first is locked). */
export const RenameDashboardDialog: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
await screen.findByRole('dialog', { name: 'Rename dashboard' });
},
};
/** The tags dialog, opened from the menu of the second row (the first is locked). */
export const EditTagsDialog: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
},
};
/** The popover that names the current filters as a new saved view. */
export const SaveViewPopover: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: 10000 },
),
);
await screen.findByText('Save as view');
},
};
/**
* The query the backend refused: the parse error it returned replaces the
* generic failure copy, and there is nothing to retry.

View File

@@ -1,4 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent, within } from 'storybook/test';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
@@ -41,6 +46,21 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS },
};
/** The selected pod's details drawer, switched to its logs tab. */
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(
await drawer.findByText('Logs', {}, { timeout: 10000 }),
);
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
},
};
/**
* Every tooltip the pod list carries, held open: Collapse Filters beside the
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
@@ -86,3 +106,27 @@ export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
await screen.findByText('Columns');
},
};
/**
* The pod list re-renders the filter when the viewport grows, and each render
* reconfigures the editor, which closes its suggestions. Shot at the height
* the page opened at.
*/
const heldViewport = { sbshot: { viewport: { height: 1200 } } };
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.node.name');
},
};
/** The pod filter on a namespace: the namespaces the pods run in. */
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'k8s.namespace.name = ');
await findSuggestion(canvasElement, 'kube-system');
},
};

View File

@@ -69,3 +69,12 @@ export const GroupActionsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The first mapping group's edit drawer, opened from its menu. */
export const GroupFormDrawer: Story = {
play: async (context): Promise<void> => {
await GroupActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Edit'));
await screen.findByText('Edit group');
},
};

View File

@@ -54,3 +54,24 @@ export const ModelCostActionsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The first pricing rule's drawer, opened from its row menu. */
export const ModelCostDrawer: Story = {
play: async (context): Promise<void> => {
await ModelCostActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Edit'));
await screen.findByText('Edit model cost');
},
};
/** The drawer with its cache mode select open. */
export const ModelCostDrawerCacheModeOpen: Story = {
play: async (context): Promise<void> => {
await ModelCostDrawer.play?.(context);
await userEvent.click(
await screen.findByRole('combobox', { name: 'Cache mode' }),
);
await screen.findByRole('listbox');
},
};

View File

@@ -4,10 +4,15 @@
min-height: 0;
overflow: hidden;
.log-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
}
.log-module-right-section {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0;
.log-explorer-query-container {
@@ -34,4 +39,26 @@
}
}
}
&.filter-visible {
.log-quick-filter-left-section {
width: 260px;
height: 100%;
overflow: visible;
min-height: 0;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
.quick-filters-container {
flex: 1;
min-height: 0;
}
}
.log-module-right-section {
width: calc(100% - 260px);
}
}
}

View File

@@ -1,130 +0,0 @@
import { MemoryRouter } from 'react-router-dom-v5-compat';
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { fireEvent, render, screen } from 'tests/test-utils';
import LogsExplorer from '../index';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.LOGS_EXPLORER}`,
}),
}));
jest.mock(
'container/Toolbar/Toolbar',
() =>
function Toolbar({
showLiveLogs,
onGoLive,
}: {
showLiveLogs?: boolean;
onGoLive?: () => void;
}): JSX.Element {
return (
<div>
<button type="button" data-testid="go-live" onClick={onGoLive}>
Live
</button>
<span data-testid="live-state">{showLiveLogs ? 'live' : 'not-live'}</span>
</div>
);
},
);
jest.mock(
'container/TimeSeriesView/TimeSeriesView',
() =>
function TimeSeriesView(): JSX.Element {
return <div>Time Series Chart</div>;
},
);
jest.mock(
'container/LogsExplorerChart',
() =>
function LogsExplorerChart(): JSX.Element {
return <div>Frequency chart content</div>;
},
);
jest.mock('container/LiveLogs/LiveLogsContainer', () => ({
__esModule: true,
default: (): JSX.Element => <div>Live logs</div>,
}));
jest.mock('constants/panelTypes', () => ({
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
}));
jest.mock('d3-interpolate', () => ({
interpolate: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): unknown => ({
preferences: {
columns: [],
formatting: { maxLines: 1, format: 'table', fontSize: 'small', version: 1 },
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
function page(): JSX.Element {
return (
<MemoryRouter initialEntries={[ROUTES.LOGS_EXPLORER]}>
<PreferenceContextProvider>
<LogsExplorer />
</PreferenceContextProvider>
</MemoryRouter>
);
}
describe('Logs Explorer live mode', () => {
// useLocation is mocked without a search string, so the tab is driven
// through the hook that reads it from the url.
const panelTypeSpy = jest.spyOn(
panelTypesQueryParamHooks,
'useGetPanelTypesQueryParam',
);
afterAll(() => {
panelTypeSpy.mockRestore();
});
it('ends when the url moves off the list tab, whatever moved it', () => {
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
const { rerender } = render(page());
fireEvent.click(screen.getByTestId('go-live'));
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
panelTypeSpy.mockReturnValue(PANEL_TYPES.TIME_SERIES);
rerender(page());
expect(screen.getByTestId('live-state')).toHaveTextContent('not-live');
});
it('stays on while the url keeps the list tab', () => {
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
const { rerender } = render(page());
fireEvent.click(screen.getByTestId('go-live'));
rerender(page());
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
});
});

View File

@@ -1,5 +1,4 @@
import { MemoryRouter } from 'react-router-dom-v5-compat';
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
// https://virtuoso.dev/mocking-in-tests/
import { VirtuosoMockContext } from 'react-virtuoso';
import {
@@ -131,30 +130,6 @@ describe('Logs Explorer Tests', () => {
// expect(oldLogsCTA).toBeInTheDocument();
});
it('renders the tab the url panelTypes param names', () => {
// useLocation is mocked without a search string in this file, so drive
// the param through the hook that reads it.
const panelTypeSpy = jest
.spyOn(panelTypesQueryParamHooks, 'useGetPanelTypesQueryParam')
.mockReturnValue(PANEL_TYPES.TIME_SERIES);
const { container } = render(
<MemoryRouter initialEntries={['/logs-explorer/?panelTypes=%22graph%22']}>
<PreferenceContextProvider>
<LogsExplorer />
</PreferenceContextProvider>
</MemoryRouter>,
);
expect(container.querySelector('.timeseries-view-tab')).toHaveClass(
'active-tab',
);
expect(container.querySelector('.list-view-tab')).not.toHaveClass(
'active-tab',
);
panelTypeSpy.mockRestore();
});
// update this test properly
it.skip('Logs Explorer Page should render with data', async () => {
// mocking the query range API to return the logs

View File

@@ -3,10 +3,10 @@ import { useQueryClient } from 'react-query';
import * as Sentry from '@sentry/react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -51,13 +51,9 @@ function LogsExplorer(): JSX.Element {
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const selectedView = panelTypeToExplorerView[panelTypesFromUrl];
useEffect(() => {
if (selectedView !== ExplorerViews.LIST) {
setShowLiveLogs(false);
}
}, [selectedView]);
const [selectedView, setSelectedView] = useState<ExplorerViews>(
() => panelTypeToExplorerView[panelTypesFromUrl],
);
const [showFilters, setShowFilters] = useState<boolean>(() => {
const localStorageValue = getLocalStorageKey(
@@ -120,10 +116,15 @@ function LogsExplorer(): JSX.Element {
);
handleSetConfig(nextPanelType, DataSource.LOGS);
setSelectedView(view);
if (view !== ExplorerViews.LIST) {
setShowLiveLogs(false);
}
handleExplorerTabChange(nextPanelType, querySearchParameters);
},
[handleSetConfig, handleExplorerTabChange],
[handleSetConfig, handleExplorerTabChange, setSelectedView],
);
// ─── AI Assistant page actions (only when license feature is on) ───────────
@@ -224,19 +225,21 @@ function LogsExplorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<EventSourceProvider>
<QuickFiltersLayout
className="logs-module-page"
showFilters={showFilters}
savedViewProps={{ source: SavedviewtypesSourceDTO.logs }}
quickFilterProps={{
className: 'qf-logs-explorer',
signal: SignalType.LOGS,
source: QuickFiltersSource.LOGS_EXPLORER,
handleFilterVisibilityChange,
useFieldApis: quickFilterFieldApis,
}}
<div
className={cx('logs-module-page', showFilters ? 'filter-visible' : '')}
>
<section className="log-module-right-section">
{showFilters && (
<section className={cx('log-quick-filter-left-section')}>
<QuickFilters
className="qf-logs-explorer"
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={quickFilterFieldApis}
/>
</section>
)}
<section className={cx('log-module-right-section')}>
<Toolbar
showAutoRefresh={false}
leftActions={
@@ -289,7 +292,7 @@ function LogsExplorer(): JSX.Element {
</div>
</div>
</section>
</QuickFiltersLayout>
</div>
</EventSourceProvider>
</Sentry.ErrorBoundary>
);

View File

@@ -25,6 +25,11 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
RESPONSE_STATES,
type ResponseState,
respondWith,
} from '@/storybook/runtime/responseState';
import {
logsSavedViewsResponse,
@@ -44,6 +49,8 @@ import {
logRowsResponse,
QUICK_FILTER_MAX,
logsQuickFiltersResponse,
RECENT_FILTER_MAX,
recentFiltersStorage,
RELATIVE_TIME,
timeRangeState,
} from './__story_mockdata__/logs';
@@ -158,6 +165,20 @@ export const logsMocks = defineStoryMocks({
value: QUICK_FILTER_MAX,
max: QUICK_FILTER_MAX,
}),
filterValues: choiceControl<ResponseState>('Filter values', {
group: FILTERS,
description:
'How `/fields/values` answers once a key and an operator are typed in the filter, apart from the page-wide Data control.',
options: RESPONSE_STATES,
value: 'loaded',
}),
recentFilters: countControl('Recent filters', {
group: FILTERS,
description:
'Filters run before in this browser, which the filter offers above its key suggestions.',
value: 0,
max: RECENT_FILTER_MAX,
}),
savedViews: countControl('Saved views', {
group: FILTERS,
description: 'The views the view picker above the query builder lists.',
@@ -217,7 +238,7 @@ export const logsMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
respondWith(values.filterValues, (req) =>
logFieldValuesResponse(
req.url.searchParams.get('name') ?? '',
req.url.searchParams.get('searchText') ?? '',
@@ -264,8 +285,16 @@ export const logsMocks = defineStoryMocks({
}
: {},
}),
effect: ({ frequencyChart, filtersPanel, format, maxLines, fontSize }) => {
effect: ({
frequencyChart,
filtersPanel,
format,
maxLines,
fontSize,
recentFilters,
}) => {
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
setLocalStorage(...recentFiltersStorage(recentFilters));
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, String(filtersPanel));
// The preferences loader reads localStorage ahead of the URL, so this is

View File

@@ -1,4 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
blurFilter,
openKeySuggestions,
showFilterErrors,
typeFilter,
typeFilterWithCaretBack,
findSuggestion,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -209,3 +217,138 @@ export const Tooltips: Story = {
);
},
};
/** The filter focused before anything is typed: every key the logs carry. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'severity_text');
},
};
/** A partial key, with the keys that still match and the typed part marked. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'serv');
await findSuggestion(canvasElement, 'service.name');
},
};
/** A string key followed by a space: the operators a string compares with. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name ');
await findSuggestion(canvasElement, 'CONTAINS');
},
};
/** A number key puts the range comparisons first. */
export const FilterNumberOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'http.status_code ');
await findSuggestion(canvasElement, 'BETWEEN');
},
};
/** `NOT` after a key narrows the list to the operators it can negate. */
export const FilterNegatedOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name NOT ');
await findSuggestion(canvasElement, 'IN');
},
};
/** A key and an operator: the values the key holds, fetched for it. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};
/** Values still being fetched for the key. */
export const FilterValuesLoading: Story = {
args: { filterValues: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Loading suggestions');
},
};
/** A key the backend holds no values for, such as the free-text body. */
export const FilterNoValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'body = ');
await findSuggestion(canvasElement, 'No suggestions available');
},
};
/** The values request failed. */
export const FilterValuesError: Story = {
args: { filterValues: 'error' },
// The values request deliberately fails.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Error loading suggestions');
},
};
/** Inside an `IN` list, after the first value: the rest of the values. */
export const FilterInList: Story = {
// The editor logs a TypeError while the list is open, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(
canvasElement,
"service.name IN ['auth', ]",
1,
'checkout',
);
},
};
/** A complete condition: the conjunctions that start the next one. */
export const FilterConjunctionSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, "service.name = 'checkout' ");
await findSuggestion(canvasElement, 'OR');
},
};
/** Inside an opened group: keys, another group and `NOT`. */
export const FilterNestedGroup: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
},
};
/** A long valid expression mixing operators, left for the next run. */
export const FilterComplete: Story = {
// The editor logs a TypeError while the `IN` list is typed, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(
canvasElement,
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
);
await blurFilter(canvasElement);
},
};
/** An incomplete expression after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'service.name = ');
},
};
/** Filters run before, offered above the key suggestions. */
export const FilterRecentSearches: Story = {
args: { recentFilters: 3 },
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(
canvasElement,
"k8s.namespace.name = 'observability'",
);
},
};

View File

@@ -15,6 +15,9 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import type { OptionsQuery } from 'container/OptionsMenu/types';
import { STORAGE_VERSION } from 'lib/recentQueries/constants';
import type { RecentQueriesStoreShape } from 'lib/recentQueries/types';
import { makeId, storageKeyFor } from 'lib/recentQueries/utils';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { quickFiltersListResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import type { AppState } from 'store/reducers';
@@ -470,3 +473,34 @@ const DASHBOARD_NAMES = [
export const dashboardsResponse = (): ListDashboardsForUserV2200 =>
dashboardsForUserResponse(DASHBOARD_NAMES);
const RECENT_FILTERS = [
"service.name = 'checkout' AND severity_text = 'ERROR'",
"k8s.namespace.name = 'observability'",
"body CONTAINS 'timeout'",
'http.status_code >= 500',
"deployment.environment IN ['production', 'staging']",
];
/** The filter lists this many recent entries at most. */
export const RECENT_FILTER_MAX = RECENT_FILTERS.length;
/**
* The explorer's recent filters as the store keeps them in localStorage,
* newest first, five minutes apart. Dated off `new Date()`, which the story
* clock freezes, so the "5 minutes ago" labels hold still.
*/
export const recentFiltersStorage = (count: number): [string, string] => {
const store: RecentQueriesStoreShape = {
version: STORAGE_VERSION,
entries: RECENT_FILTERS.slice(0, count).map((expression, index) => ({
id: makeId('logs', '', expression),
signal: 'logs',
source: '',
filter: { expression },
lastUsedAt: new Date().getTime() - (index + 1) * 5 * 60_000,
})),
};
return [storageKeyFor('logs', ''), JSON.stringify(store)];
};

View File

@@ -1,4 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -114,3 +119,18 @@ export const MetricDetailsDashboardsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The summary's metric search focused: the attributes metrics can be found by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
},
};
/** The metric search on an attribute and an operator: the values it holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};

View File

@@ -15,10 +15,14 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockResolver } from '@/storybook/msw/types';
import {
CREATE_OUTCOMES,
type CreateOutcome,
EXPIRIES,
type Expiry,
ingestionKeyCreateError,
ingestionKeysResponse,
KEYS_PER_PAGE,
legacyIngestionResponse,
@@ -29,6 +33,11 @@ import {
const KEYS = 'Ingestion · keys';
const LIMITS = 'Ingestion · limits';
const rejectCreate: MockResolver = (_req, res, ctx) =>
res(ctx.status(409), ctx.json(ingestionKeyCreateError()));
const holdCreate: MockResolver = (_req, res, ctx) => res(ctx.delay('infinite'));
export const ingestionMocks = defineStoryMocks({
controls: {
gateway: toggleControl('Gateway', {
@@ -50,6 +59,13 @@ export const ingestionMocks = defineStoryMocks({
options: EXPIRIES,
value: 'none',
}),
create: choiceControl<CreateOutcome>('Creating a key', {
group: KEYS,
description:
'What the create behind the new key form answers. `hangs` holds the submit button in its loading state.',
options: CREATE_OUTCOMES,
value: 'succeeds',
}),
limits: multiChoiceControl<LimitSignal>('Signals with a limit', {
group: LIMITS,
description:
@@ -85,10 +101,14 @@ export const ingestionMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v2/gateway/ingestion_keys',
response.json(() => ({
status: 'success',
data: { id: 'ingestion-key-new', value: 'sk_new' },
})),
{
succeeds: response.json(() => ({
status: 'success',
data: { id: 'ingestion-key-new', value: 'sk_new' },
})),
fails: rejectCreate,
hangs: holdCreate,
}[values.create],
),
rest.patch(

View File

@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import dayjs from 'dayjs';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -76,16 +77,149 @@ export const KeyLimits: Story = {
},
};
async function openCreateKey(
canvasElement: HTMLElement,
): Promise<ReturnType<typeof within>> {
await userEvent.click(
await within(canvasElement).findByText(
'New Ingestion key',
undefined,
untilLoaded,
),
);
return within(
await screen.findByRole(
'dialog',
{ name: 'Create new ingestion key' },
untilLoaded,
),
);
}
async function addTag(
dialog: ReturnType<typeof within>,
tag: string,
): Promise<void> {
await userEvent.click(await dialog.findByRole('button', { name: /New Tag/ }));
await userEvent.keyboard(`${tag}{Enter}`);
}
async function submitCreateKey(
dialog: ReturnType<typeof within>,
): Promise<void> {
await userEvent.type(dialog.getByLabelText('Name'), 'otel-collectors');
await userEvent.click(dialog.getByLabelText('Expiration'));
await userEvent.click(
await screen.findByTitle(dayjs().add(1, 'day').format('YYYY-MM-DD')),
);
await userEvent.click(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
);
}
/** The form a new key is named and dated in. */
export const CreateKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
'New Ingestion key',
undefined,
untilLoaded,
),
);
await screen.findByText('Create new ingestion key', undefined, untilLoaded);
await openCreateKey(canvasElement);
},
};
/** A tag typed on the new key and not confirmed yet. */
export const CreateKeyAddingTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.click(dialog.getByRole('button', { name: /New Tag/ }));
await userEvent.keyboard('team-payments');
},
};
/** The new key with its tags confirmed, each one removable. */
export const CreateKeyTagsAdded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
for (const tag of ['team-payments', 'env:production']) {
await addTag(dialog, tag);
await dialog.findByText(tag);
}
},
};
/**
* A tag typed again while the key already has it: Enter keeps the input open
* and adds nothing, with no message saying why.
*/
export const CreateKeyDuplicateTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await addTag(dialog, 'team-payments');
await addTag(dialog, 'team-payments');
await dialog.findByDisplayValue('team-payments');
},
};
/** A tag longer than its pill, cut short with an ellipsis. */
export const CreateKeyLongTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
const tag =
'team-payments-platform-observability-production-us-east-1-canary-collectors';
await addTag(dialog, tag);
await dialog.findByText(tag);
},
};
/** A name with a space and no expiration, submitted: each field names its rule. */
export const CreateKeyInvalid: Story = {
// The page logs the rejected validation through `console.error`.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.type(dialog.getByLabelText('Name'), 'otel collectors');
await userEvent.click(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
);
await dialog.findByText(/should only contain letters/);
},
};
/** The expiration calendar, where today and every day before it are disabled. */
export const CreateKeyExpirationOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.click(dialog.getByLabelText('Expiration'));
await screen.findByTitle(dayjs().format('YYYY-MM-DD'));
},
};
/** The new key sent, with the create still in flight. */
export const CreateKeySubmitting: Story = {
args: { create: 'hangs' },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await submitCreateKey(dialog);
await waitFor(() =>
expect(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
).toBeDisabled(),
);
},
};
/**
* A create the gateway rejects: the notification names the conflict and the
* form keeps what was typed.
*/
export const CreateKeyFailed: Story = {
args: { create: 'fails' },
// The deliberate 409 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await submitCreateKey(dialog);
await screen.findByText(
'An ingestion key with this name already exists.',
undefined,
untilLoaded,
);
},
};

View File

@@ -7,6 +7,7 @@ import type {
GatewaytypesIngestionKeyDTO,
GatewaytypesLimitDTO,
GetIngestionKeys200,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { IngestionInfo } from 'types/api/settings/ingestion';
@@ -21,6 +22,10 @@ export const EXPIRIES = ['none', 'soon', 'expired'] as const;
export type Expiry = (typeof EXPIRIES)[number];
export const CREATE_OUTCOMES = ['succeeds', 'fails', 'hangs'] as const;
export type CreateOutcome = (typeof CREATE_OUTCOMES)[number];
const KEY_NAMES = [
'production-us-east',
'production-eu-west',
@@ -117,6 +122,18 @@ export const ingestionKeysResponse = (
},
});
export const ingestionKeyCreateError = (): RenderErrorResponseDTO => ({
status: 'error',
error: {
code: 'already_exists',
type: 'already_exists',
message: 'An ingestion key with this name already exists.',
url: '',
errors: [],
suggestions: [],
},
});
/**
* The pre-gateway endpoint, which answers with one record and no limits: the tab
* falls back to it when the gateway feature is off.

View File

@@ -5,6 +5,7 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { userEvent, within } from 'storybook/test';
import { RoleType } from 'types/roles';
import { choiceControl } from '@/storybook/controls/controls';
@@ -84,3 +85,18 @@ export const roleEditorMocks = defineStoryMocks({
],
config: (values) => ({ route: routeFor(values.mode, values.editor) }),
});
/** Opens the Logs card and returns it. */
export const openLogsCard = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const header = await within(canvasElement).findByRole(
'button',
{ name: /^Logs:/ },
{ timeout: 15_000 },
);
await userEvent.click(header);
return header.parentElement as HTMLElement;
};

View File

@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { roleEditorMocks } from './RoleEditor.stories.mocks';
import { openLogsCard, roleEditorMocks } from './RoleEditor.stories.mocks';
import SettingsPage from '../../../Settings';
@@ -75,3 +75,27 @@ export const Tooltips: Story = {
export const TooltipsInJsonEditor: Story = {
args: { tooltipsOpen: true, mode: 'edit', editor: 'json' },
};
/** The Logs card's first verb granted over everything instead of nothing. */
export const PermissionEditorScope: Story = {
play: async ({ canvasElement }): Promise<void> => {
const card = await openLogsCard(canvasElement);
const [all] = await within(card).findAllByText('All');
await userEvent.click(all);
},
};
/** The selector wizard, opened from a Logs verb scoped to named objects. */
export const TelemetrySelectorWizard: Story = {
play: async ({ canvasElement }): Promise<void> => {
const card = await openLogsCard(canvasElement);
const [onlySelected] = await within(card).findAllByText('Only selected');
await userEvent.click(onlySelected);
await userEvent.click(
await within(card).findByRole('button', { name: 'Wizard' }),
);
await screen.findByRole('dialog', { name: 'Selector Wizard' });
},
};

View File

@@ -172,3 +172,31 @@ export const TraceOptionsMenu: Story = {
await screen.findByText('Preview fields');
},
};
/** The span panel moved from the right edge to the bottom through its dock toggle. */
export const DockModeSwitched: Story = {
play: async ({ canvasElement }): Promise<void> => {
// The dock options are icon-only and carry no accessible name.
const option = await within(canvasElement).findByTestId(
'dock-mode-docked',
undefined,
untilLoaded,
);
await userEvent.click(option.querySelector('button') ?? option);
},
};
/** The span's percentile badge expanded into the distribution it was ranked in. */
export const SpanPercentileOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /^p\d+/ },
untilLoaded,
),
);
await screen.findByText(/This span duration is/);
},
};

View File

@@ -5,7 +5,6 @@ import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
@@ -110,9 +109,8 @@ function TracesExplorer(): JSX.Element {
setIsLoadingQueries(false);
}, [queryClient]);
const selectedView = useMemo(
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
[searchParams, panelTypesFromUrl],
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
);
const [warning, setWarning] = useState<Warning | undefined>();
@@ -138,6 +136,8 @@ function TracesExplorer(): JSX.Element {
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
setSelectedView(view);
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
@@ -262,7 +262,6 @@ function TracesExplorer(): JSX.Element {
<QuickFiltersLayout
className="trace-explorer-page"
showFilters={isOpen}
savedViewProps={{ source: SavedviewtypesSourceDTO.traces }}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.TRACES_EXPLORER,

View File

@@ -1,4 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
@@ -142,3 +148,41 @@ export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/** The filter focused before anything is typed: span and resource keys together. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'status_code_string');
},
};
/** A context prefix: only the keys that live on the resource. */
export const FilterResourceKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'resource.');
await findSuggestion(canvasElement, 'resource.service.name');
},
};
/** A key and an operator: the services the spans came from. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};
/** A boolean key offers its two values. */
export const FilterBooleanValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'has_error = ');
await findSuggestion(canvasElement, 'false');
},
};
/** A dangling conjunction after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'has_error = true AND');
},
};

View File

@@ -2,9 +2,13 @@
import { useEffect, useState } from 'react';
import { useListSavedViews } from 'api/generated/services/saved-view';
import { TelemetryFieldKey } from 'api/v5/v5';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { findSavedView, toSavedViewSource } from 'container/SavedViews/utils';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
@@ -34,11 +38,43 @@ export function usePreferenceSync({
const [savedViewPreferences, setSavedViewPreferences] =
useState<Preferences | null>(null);
const withColumnNames = (
columns: TelemetryFieldKey[] | undefined,
): TelemetryFieldKey[] | null => {
if (!columns) {
return null;
}
return columns.map((column) => ({
...column,
name: column.name ?? column.key,
}));
};
useEffect(() => {
const spec = savedViewId
? findSavedView(viewsData?.data, savedViewId)?.spec
: undefined;
setSavedViewPreferences(getViewColumnsAndFormatting(spec, dataSource));
const selectedFields = spec?.selectedFields as
| TelemetryFieldKey[]
| undefined;
let columns: TelemetryFieldKey[] = [];
let formatting: FormattingOptions | undefined;
if (dataSource === DataSource.LOGS) {
columns = ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
);
formatting = {
maxLines: spec?.display?.maxLines || 1,
format: (spec?.display?.format as LogViewMode) || 'table',
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
version: 1,
};
}
if (dataSource === DataSource.TRACES) {
columns = selectedFields || defaultTraceSelectedColumns;
}
setSavedViewPreferences({ columns, formatting });
}, [viewsData, dataSource, savedViewId, mode]);
// We are using a reSync state because we have URL updates as well as local storage updates