Compare commits

...

27 Commits

Author SHA1 Message Date
Vinícius Lourenço
20604f83d0 test(storybook): move play helpers into the story mocks files 2026-10-09 09:40:44 -03:00
Vinícius Lourenço
b7d09f323c test(storybook): cover explorer, Noz and onboarding toasts 2026-10-08 17:11:16 -03:00
Vinícius Lourenço
bd0e879270 test(storybook): cover integration and LLM observability toasts 2026-10-08 17:11:16 -03:00
Vinícius Lourenço
166cf4ad0c test(storybook): cover dashboard toasts 2026-10-08 17:11:16 -03:00
Vinícius Lourenço
c284f9c353 test(storybook): cover alert toasts 2026-10-08 17:11:15 -03:00
Vinícius Lourenço
8f806a944e test(storybook): cover settings toasts 2026-10-08 17:11:15 -03:00
Vinícius Lourenço
c3fded3fbe test(storybook): keep toasts on screen behind a Toasts control 2026-10-08 17:11:15 -03:00
Vinícius Lourenço
c869eba847 test(storybook): cover classic alert form label states 2026-10-08 17:11:15 -03:00
Vinícius Lourenço
59ad37d076 test(storybook): add planned downtime edit story with specific alert rules 2026-10-08 17:11:15 -03:00
Vinícius Lourenço
ef91f8f0ab test(storybook): hold Noz click-opened state past the thread remount 2026-10-08 17:11:15 -03:00
Vinícius Lourenço
b39c622279 test(storybook): drop the fixed pause between filter keystrokes 2026-10-08 17:11:14 -03:00
Vinícius Lourenço
355c89bdb4 test(storybook): find dashboard settings and panel editor controls by test id 2026-10-08 17:11:14 -03:00
Vinícius Lourenço
b588f459e4 fix(tanstack-table): keep cells mounted when inline row callbacks change 2026-10-08 17:11:14 -03:00
Vinícius Lourenço
7c044ab4fb test(storybook): cover panel editor chart options, thresholds and links 2026-10-08 17:11:14 -03:00
Vinícius Lourenço
e0b290f4d5 test(storybook): cover dashboard settings, variables and authz states 2026-10-08 16:20:33 -03:00
Vinícius Lourenço
796874fcd1 fix(storybook): attribute failed responses to the story that sent them 2026-10-08 16:20:33 -03:00
Vinícius Lourenço
cbe05f1d51 test(storybook): cover Noz voice recording states 2026-10-08 16:20:32 -03:00
Vinícius Lourenço
e77c2c0ae3 test(storybook): assert the in-flight create key button is disabled 2026-10-08 16:20:22 -03:00
Vinícius Lourenço
a2150c49dc test(storybook): cover ingestion create key validation, expiry and submit states 2026-10-08 16:20:21 -03:00
Vinícius Lourenço
f7d2d9230b test(storybook): cover ingestion key tag adding and added states 2026-10-08 16:20:21 -03:00
Vinícius Lourenço
d097537ba2 test(storybook): click exceptions filter suggestions as they are found 2026-10-08 16:20:21 -03:00
Vinícius Lourenço
cc33624cbf test(storybook): hold the pod filter stories at their opening height 2026-10-08 16:20:21 -03:00
Vinícius Lourenço
88a15c1f6b test(storybook): wait for the filter's scrollbars before focusing it 2026-10-08 16:20:21 -03:00
Vinícius Lourenço
84f5d13f63 test(storybook): keep filter focused while scrollbars initialise 2026-10-08 16:20:21 -03:00
Vinícius Lourenço
26d7399d25 test(storybook): add query builder filter interaction stories 2026-10-08 16:20:20 -03:00
Vinícius Lourenço
0dbaabb2d2 test(storybook): fix interaction stories against current main 2026-10-08 16:20:08 -03:00
Vinícius Lourenço
a309eb8a3f test(storybook): add interaction stories for menus, drawers and selects 2026-10-08 16:20:08 -03:00
100 changed files with 7270 additions and 278 deletions

View File

@@ -43,6 +43,11 @@ const mockAliases = [
find: /^@signozhq\/ui\/tooltip$/,
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
},
{
// jest: not replaced, a test asserts on the toast it fires.
find: /^@signozhq\/ui\/sonner$/,
replacement: `${srcPath}/storybook/mocks/sonner.mock.tsx`,
},
];
/**

View File

@@ -70,3 +70,49 @@
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
})();
</script>
<script>
// Storybook has no microphone. Chromium's recognizer asks for one and for
// Google's speech service, and the `not-allowed` or `network` it reports makes
// the assistant hide its mic for the rest of the tab. This one starts
// listening at once and hears only what a story passes to `hear`.
(() => {
class StorySpeechRecognition extends EventTarget {
static listening = null;
lang = 'en-US';
continuous = false;
interimResults = false;
onstart = null;
onresult = null;
onerror = null;
onend = null;
start() {
StorySpeechRecognition.listening = this;
setTimeout(() => this.onstart?.());
}
stop() {
if (StorySpeechRecognition.listening === this) {
StorySpeechRecognition.listening = null;
}
setTimeout(() => this.onend?.());
}
abort() {
this.stop();
}
/** An interim result: the words so far, still listening. */
hear(transcript) {
const result = Object.assign([{ transcript, confidence: 1 }], {
isFinal: false,
});
this.onresult?.({ resultIndex: 0, results: [result] });
}
}
window.SpeechRecognition = StorySpeechRecognition;
window.webkitSpeechRecognition = StorySpeechRecognition;
})();
</script>

View File

@@ -122,7 +122,21 @@ const preview: Preview = {
['List', 'New', 'Edit'],
],
'Dashboards',
['List', 'Detail', 'Panel Editor', 'Public'],
[
'List',
'Detail',
[
'Overview',
'Settings',
'Variables',
'New Variable',
'Edit Variable',
'Authz',
],
'Panel Editor',
['Overview', 'Chart Options', 'Thresholds and Links'],
'Public',
],
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',

View File

@@ -23,6 +23,13 @@ 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+ /,
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
// already reports with its URL and the time its request left.
/^Failed to load resource: the server responded with a status of /,
];
interface CapturedMessage {
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push({
at: Date.now(),
// The browser's clock, when the request left: a busy runner receives
// the outgoing story's responses after the next story has started.
at: response.request().timing().startTime,
text: `[response] ${response.status()} ${response.url()}`,
});
}

View File

@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import LabelColumn from '../LabelColumn';
const meta = {
title: 'Components/Label Column',
component: LabelColumn,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 180 })],
args: {
labels: ['severity', 'team', 'service', 'environment'],
value: {
severity: 'critical',
team: 'payments',
service: 'checkout',
environment: 'production',
},
},
} satisfies Meta<typeof LabelColumn>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Labels that fit the column; the rest collapse into a `+N` chip. */
export const Default: Story = {};
/** Copying one label from its tooltip. */
export const LabelCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.hover(
await within(canvasElement).findByTestId('label-tag-severity'),
);
await userEvent.click(
await screen.findByRole('button', { name: 'Copy to clipboard' }),
);
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
},
};
/** Copying the labels that overflow the column from the `+N` chip's tooltip. */
export const OverflowLabelsCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.hover(
await within(canvasElement).findByTestId('label-overflow-badge'),
);
await userEvent.click(
await screen.findByRole('button', { name: 'Copy to clipboard' }),
);
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
},
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
@@ -56,6 +56,21 @@ export const UnsavedChanges: Story = {
},
};
/** Saving: the toast raised once the edited columns are applied. */
export const SavedToast: Story = {
play: async (): Promise<void> => {
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
await userEvent.click(addField);
await userEvent.click(
await screen.findByRole('button', { name: 'Save changes' }),
);
await waitFor(() =>
expect(screen.getByText('Saved successfully')).toBeVisible(),
);
},
};
/** Empty: the suggestion request succeeds with no columns to add. */
export const NoResults: Story = {
parameters: {

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import HeaderRightSection from '../HeaderRightSection';
const meta = {
title: 'Components/Header Right Section',
component: HeaderRightSection,
tags: ['play'],
args: {
enableFeedback: true,
enableShare: true,
enableAnnouncements: false,
},
} satisfies Meta<typeof HeaderRightSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** The top bar's actions: feedback and share. */
export const Default: Story = {};
/** A feedback note submitted from the popover, which toasts once it is sent. */
export const FeedbackSubmittedToast: Story = {
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('button', { name: 'Feedback' }),
);
await userEvent.type(
await screen.findByPlaceholderText(/write your feedback here/i),
'The new explorer is great',
);
await userEvent.click(await screen.findByRole('button', { name: /submit/i }));
await screen.findByText(/feedback submitted successfully/i);
},
};

View File

@@ -0,0 +1,229 @@
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 };
/** 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 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',
});
}
};
/**
* 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

@@ -117,3 +117,38 @@ export const Tooltips: TooltipsStory = {
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
},
};
const [firstQuery] = queryBuilder.currentQuery.builder.queryData;
/** Interaction: the query picker lists every query the filters can follow. */
export const QueryPickerOpen: Story = {
parameters: {
signoz: {
queryBuilder: {
...queryBuilder,
currentQuery: {
builder: {
queryData: [
{ ...firstQuery, queryName: 'A' },
{ ...firstQuery, queryName: 'B' },
],
},
},
},
},
},
play: async ({ canvasElement }): Promise<void> => {
const trigger = await waitFor(() => {
const element = canvasElement.querySelector<HTMLElement>(
'[data-slot="combobox-trigger"]',
);
expect(element).toBeInTheDocument();
return element as HTMLElement;
});
await userEvent.click(trigger);
await screen.findByRole('listbox');
},
};

View File

@@ -233,12 +233,23 @@ function TanStackTableInner<TData, TItemKey = string>(
[effectiveColumns],
);
// A rebuilt column def is a new cell component to React, which remounts every
// cell it renders. Read the row callbacks through refs so inline ones do not.
const isRowActiveRef = useRef(isRowActive);
isRowActiveRef.current = isRowActive;
const getRowKeyDataRef = useRef(getRowKeyData);
getRowKeyDataRef.current = getRowKeyData;
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
buildTanstackColumnDef<TData, TItemKey>(
colDef,
(row) => isRowActiveRef.current?.(row) ?? false,
(index) => getRowKeyDataRef.current(index),
),
),
[effectiveColumns, isRowActive, getRowKeyData],
[effectiveColumns],
);
const getRowId = useCallback(

View File

@@ -1,8 +1,16 @@
import { useEffect, useState } from 'react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
import { renderTanStackTable } from './testUtils';
import TanStackTable from '../index';
import type { TableColumnDef } from '../types';
import {
defaultData,
renderTanStackTable,
renderWithProviders,
type TestRow,
} from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
@@ -810,6 +818,58 @@ describe('TanStackTableView Integration', () => {
});
});
describe('cell identity', () => {
it('keeps cells mounted across re-renders that pass new inline row callbacks', async () => {
const user = userEvent.setup();
const onMount = jest.fn();
function MountTracker({ id }: { id: string }): JSX.Element {
useEffect(() => onMount(id), [id]);
return <span data-testid={`tracked-${id}`}>Row {id}</span>;
}
const columns: TableColumnDef<TestRow>[] = [
{
id: 'id',
header: 'ID',
accessorKey: 'id',
cell: ({ row }): JSX.Element => <MountTracker id={row.id} />,
},
];
function Harness(): JSX.Element {
const [, setRenders] = useState(0);
return (
<>
<button
type="button"
data-testid="rerender"
onClick={(): void => setRenders((count) => count + 1)}
>
Re-render
</button>
<TanStackTable<TestRow>
data={defaultData}
columns={columns}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === '1'}
/>
</>
);
}
renderWithProviders(<Harness />);
await screen.findByTestId('tracked-2');
onMount.mockClear();
await user.click(screen.getByTestId('rerender'));
await user.hover(screen.getByTestId('tracked-2'));
expect(onMount).not.toHaveBeenCalled();
});
});
describe('disableVirtualScroll', () => {
it('throws error when used with onEndReached', () => {
expect(() => {

View File

@@ -143,6 +143,19 @@ export const Pagination: Story = {
},
};
/** Interaction: the page size menu lists the row counts a page can show. */
export const PageSizeOpen: Story = {
args: {
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
},
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('pagination-page-size'),
);
await screen.findByRole('listbox');
},
};
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
export const Empty: Story = {
args: { data: [], testId: 'tanstack-empty-table' },

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

@@ -4,9 +4,11 @@
*/
import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
import removeSessionStorage from 'api/browser/sessionstorage/remove';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { rest, type RequestHandler } from 'msw';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -270,3 +272,58 @@ export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
);
},
);
/** The recognizer `.storybook/preview-head.html` installs in place of the browser's. */
interface StorySpeechRecognition {
listening: {
hear: (transcript: string) => void;
onerror: ((event: { error: string }) => void) | null;
} | null;
}
/**
* Clicks the composer's mic and waits for the recording to start. The composer
* stays disabled until the thread has loaded.
*/
export const startVoiceInput = async (
canvasElement: HTMLElement,
): Promise<void> => {
const canvas = within(canvasElement);
const mic = await waitFor(
async () => {
const button = canvas.getByRole('button', { name: /start voice input/i });
// Depending on its version, Button disables through `disabled` or `aria-disabled`.
await expect(button).toBeEnabled();
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
return button;
},
{ timeout: 15_000 },
);
await userEvent.click(mic);
await canvas.findByRole('status', { name: /recording voice input/i });
};
/** Speaks into the open recording: the words so far, still listening. */
export const speak = (transcript: string): void => {
(
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
).webkitSpeechRecognition.listening?.hear(transcript);
};
/**
* Clears the flag `ChatInput` keeps in sessionStorage after a failed recording,
* which hides the mic for the rest of the tab, test-runner's included.
*/
export const forgetVoiceFailure = (): void => {
removeSessionStorage('ai-assistant-voice-unavailable');
};
/** Fails the open recording the way the browser's recognizer reports it. */
export const failVoiceInput = (error: 'network' | 'not-allowed'): void => {
(
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
).webkitSpeechRecognition.listening?.onerror?.({ error });
};

View File

@@ -9,7 +9,11 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import AIAssistantPage from '../AIAssistantPage';
import {
aiAssistantMocks,
failVoiceInput,
forgetVoiceFailure,
longActionTooltipHandlers,
speak,
startVoiceInput,
} from './AIAssistantPage.stories.mocks';
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
@@ -38,6 +42,7 @@ const meta = {
),
...pageStory,
parameters: { ...pageStory.parameters },
beforeEach: forgetVoiceFailure,
} satisfies Meta<AIAssistantArgs>;
export default meta;
@@ -47,11 +52,15 @@ type Story = StoryObj<AIAssistantArgs>;
/** The thread list resolves before the thread does, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** How long what a click opened has to stay on screen to count as open. */
const HOLD_MS = 1_000;
/**
* Click something, and keep clicking until what it opens is on screen. The
* message list remounts its items while it measures a freshly loaded thread, so
* a single click can land on a row that is about to be replaced, taking the
* state it just set with it.
* state it just set with it. The replacement can land after that state has
* rendered, so it has to still be there `HOLD_MS` later.
*/
const clickUntil = async (
find: () => Promise<HTMLElement>,
@@ -60,6 +69,10 @@ const clickUntil = async (
await waitFor(async () => {
await userEvent.click(await find());
await screen.findByText(opens, undefined, { timeout: 1_000 });
await new Promise((resolve) => {
setTimeout(resolve, HOLD_MS);
});
screen.getByText(opens);
}, untilLoaded);
};
@@ -173,6 +186,36 @@ export const ApprovalDiff: Story = {
play: openApprovalDiff,
};
/**
* Dictating a question: the mic listening, the words heard so far in the
* composer, and the controls to discard them or stop and send.
*/
export const VoiceRecording: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startVoiceInput(canvasElement);
speak('why did checkout p99 jump after the 14:00 deploy');
await within(canvasElement).findByDisplayValue(/checkout p99 jump/);
},
};
/** The recognizer reporting it cannot reach the speech service. */
export const VoiceInputUnavailableToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startVoiceInput(canvasElement);
failVoiceInput('network');
await screen.findByText(/voice input unavailable in this browser/i);
},
};
/** The browser refusing the microphone. */
export const MicrophoneDeniedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startVoiceInput(canvasElement);
failVoiceInput('not-allowed');
await screen.findByText(/microphone access denied/i);
},
};
/** The comment box a thumbs down opens, which a thumbs up does not. */
export const NegativeFeedback: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -223,6 +266,23 @@ export const AddContext: Story = {
},
};
/** "Copy link" on a conversation row, which toasts once the link is copied. */
export const ConversationLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
const [actions] = await within(canvasElement).findAllByLabelText(
/conversation actions/i,
undefined,
untilLoaded,
);
return actions;
}, /copy link/i);
await userEvent.click(await screen.findByText(/copy link/i));
await screen.findByText(/conversation link copied to clipboard/i);
},
};
/**
* Every tooltip the thread carries, held open at once: the composer's voice and
* send buttons, the sidebar's new conversation, the copy chip under each user
@@ -251,3 +311,13 @@ export const TooltipsInApprovalDiff: Story = {
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/**
* The recording controls' tooltips, held open: discard, stop and send, and the
* send button beside them, which waits for words.
*/
export const TooltipsInVoiceRecording: Story = {
args: { tooltipsOpen: true, contents: BRIEF },
play: async ({ canvasElement }): Promise<void> =>
startVoiceInput(canvasElement),
};

View File

@@ -4,6 +4,7 @@
*/
import { rest } from 'msw';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -33,6 +34,9 @@ const STORY_RELATIVE_TIME = '6h';
const RULE = 'Alert overview · rule';
const PREVIEW = 'Alert overview · preview';
const TEST_NOTIFICATIONS = ['sent', 'no-alerts'] as const;
type TestNotification = (typeof TEST_NOTIFICATIONS)[number];
export const alertOverviewMocks = defineStoryMocks({
controls: {
alertSchema: choiceControl<AlertSchema>('Alert schema', {
@@ -66,6 +70,13 @@ export const alertOverviewMocks = defineStoryMocks({
value: 3,
max: 6,
}),
testNotification: choiceControl<TestNotification>('Test notification', {
group: 'Alert overview · rule',
description:
'What the test run finds. `sent` raises the success toast, `no-alerts` the error toast saying the condition was not met.',
options: TEST_NOTIFICATIONS,
value: 'sent',
}),
},
handlers: (values, response) => [
rest.get(
@@ -96,7 +107,10 @@ export const alertOverviewMocks = defineStoryMocks({
ctx.status(200),
ctx.json({
status: 'success',
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
data: {
alertCount: values.testNotification === 'no-alerts' ? 0 : 2,
message: 'Rule tested against the last 6 hours',
},
}),
),
),
@@ -146,3 +160,21 @@ export const alertOverviewMocks = defineStoryMocks({
route: `/alerts/overview?ruleId=${STORY_RULE_ID}&relativeTime=${STORY_RELATIVE_TIME}`,
}),
});
export const clickFooterButton = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
// The footer stays disabled, and remounts, until the rule has loaded.
const button = await waitFor(
() => {
const element = within(canvasElement).getByTestId(testId);
expect(element).toBeEnabled();
return element;
},
{ timeout: 15_000 },
);
await userEvent.click(button);
};

View File

@@ -1,10 +1,13 @@
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 { alertOverviewMocks } from './AlertOverview.stories.mocks';
import {
alertOverviewMocks,
clickFooterButton,
} from './AlertOverview.stories.mocks';
import AlertList from '../../index';
@@ -75,3 +78,33 @@ export const AlertActionsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The rule saved unchanged from the footer: the toast raised once the PUT answers. */
export const AlertRuleUpdatedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickFooterButton(canvasElement, 'save-alert-rule-button');
await waitFor(() =>
expect(screen.getByText(/alert rule updated successfully/i)).toBeVisible(),
);
},
};
/** Test Notification from the footer. */
export const TestNotificationSentToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await clickFooterButton(canvasElement, 'test-notification-button');
if (args.testNotification === 'sent') {
await waitFor(() =>
expect(
screen.getByText(/test notification sent successfully/i),
).toBeVisible(),
);
}
},
};
/** Test Notification finding nothing to alert on: the error toast. */
export const TestNotificationNoAlertsToast: Story = {
args: { testNotification: 'no-alerts' },
play: TestNotificationSentToast.play,
};

View File

@@ -3,7 +3,8 @@
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { rest, type ResponseComposition, type RestContext } from 'msw';
import { screen, userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -19,6 +20,34 @@ import {
import { AlertListTabs } from '../../types';
const LIST = 'Alert rules · list';
const ACTIONS = 'Alert rules · row actions';
const ACTION_STATES = ['success', 'loading', 'error'] as const;
type ActionState = (typeof ACTION_STATES)[number];
const actionDescription = (call: string): string =>
`How the ${call} behind the row action answers. \`loading\` never answers and keeps the promise toast pending, \`error\` settles it with the failure.`;
const actionResponse = (
state: ActionState,
res: ResponseComposition,
ctx: RestContext,
body: () => unknown,
): ReturnType<ResponseComposition> => {
if (state === 'loading') {
return res(ctx.delay('infinite'));
}
return state === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'The server could not finish' },
}),
)
: res(ctx.status(200), ctx.json(body()));
};
export const alertRulesMocks = defineStoryMocks({
controls: {
@@ -41,6 +70,24 @@ export const alertRulesMocks = defineStoryMocks({
options: RULE_STATE_CHOICES,
value: 'mixed',
}),
toggleState: choiceControl<ActionState>('Enable or disable', {
group: ACTIONS,
description: actionDescription('PATCH'),
options: ACTION_STATES,
value: 'success',
}),
cloneState: choiceControl<ActionState>('Clone', {
group: ACTIONS,
description: actionDescription('POST'),
options: ACTION_STATES,
value: 'success',
}),
deleteState: choiceControl<ActionState>('Delete', {
group: ACTIONS,
description: actionDescription('DELETE'),
options: ACTION_STATES,
value: 'success',
}),
},
handlers: (values, response) => [
rest.get(
@@ -52,6 +99,47 @@ export const alertRulesMocks = defineStoryMocks({
}),
),
),
rest.patch('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
actionResponse(values.toggleState, res, ctx, () => ({
status: 'success',
data: null,
})),
),
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
actionResponse(values.cloneState, res, ctx, () => ({
status: 'success',
data: alertRulesResponse(1, {
severity: values.ruleSeverity,
state: values.ruleState,
}).data?.[0],
})),
),
rest.delete('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
actionResponse(values.deleteState, res, ctx, () => ({
status: 'success',
data: null,
})),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.ALERT_RULES}` }),
});
/** The page fetches before it renders a row, which outlasts the 1s default. */
export const untilLoaded = { timeout: 15_000 };
export const runRowAction = async (
canvasElement: HTMLElement,
name: RegExp,
): Promise<void> => {
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
untilLoaded,
);
await userEvent.click(actions);
await userEvent.click(await screen.findByRole('menuitem', { name }));
};

View File

@@ -1,10 +1,14 @@
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 { alertRulesMocks } from './AlertRules.stories.mocks';
import {
alertRulesMocks,
runRowAction,
untilLoaded,
} from './AlertRules.stories.mocks';
import AlertList from '../../index';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
@@ -31,9 +35,6 @@ export default meta;
type Story = StoryObj<AlertRulesArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* Every alert rule the org has configured, with the state each one evaluated to
* on its last run and the severity it fires at.
@@ -149,3 +150,95 @@ export const BottomStripPaginated: Story = {
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
},
};
/** Enable or Disable from the row menu: the promise toast, pending then settled. */
export const ToggleAlertToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await runRowAction(canvasElement, /^(enable|disable)$/i);
if (args.toggleState === 'success') {
await waitFor(() =>
expect(screen.getByText(/alert (enabled|disabled)/i)).toBeVisible(),
);
}
},
};
/** The PATCH never answers: the "Enabling or Disabling alert..." toast stays pending. */
export const ToggleAlertLoadingToast: Story = {
args: { toggleState: 'loading' },
play: ToggleAlertToast.play,
};
/** The PATCH failed: the promise toast settles with the server message. */
export const ToggleAlertErrorToast: Story = {
args: { toggleState: 'error' },
parameters: { allowConsoleErrors: true },
play: ToggleAlertToast.play,
};
/** Clone from the row menu. */
export const CloneAlertToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await runRowAction(canvasElement, /^clone$/i);
if (args.cloneState === 'success') {
await waitFor(() =>
expect(screen.getByText(/alert cloned successfully/i)).toBeVisible(),
);
}
},
};
/** The POST never answers: "Cloning alert..." stays pending. */
export const CloneAlertLoadingToast: Story = {
args: { cloneState: 'loading' },
play: CloneAlertToast.play,
};
/** The POST failed. */
export const CloneAlertErrorToast: Story = {
args: { cloneState: 'error' },
parameters: { allowConsoleErrors: true },
play: CloneAlertToast.play,
};
/** Delete from the row menu. */
export const DeleteAlertToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await runRowAction(canvasElement, /^delete$/i);
if (args.deleteState === 'success') {
await waitFor(() =>
expect(screen.getByText(/alert deleted successfully/i)).toBeVisible(),
);
}
},
};
/** The DELETE never answers: "Deleting alert..." stays pending. */
export const DeleteAlertLoadingToast: Story = {
args: { deleteState: 'loading' },
play: DeleteAlertToast.play,
};
/** The DELETE failed. */
export const DeleteAlertErrorToast: Story = {
args: { deleteState: 'error' },
parameters: { allowConsoleErrors: true },
play: DeleteAlertToast.play,
};
/** The copy button in a label badge's tooltip, clicked. */
export const LabelCopiedToast: Story = {
args: { tooltipsOpen: true },
play: async (): Promise<void> => {
const [copy] = await screen.findAllByRole(
'button',
{ name: 'Copy to clipboard' },
untilLoaded,
);
await userEvent.click(copy);
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
},
};

View File

@@ -4,6 +4,7 @@
*/
import { rest } from 'msw';
import { userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -11,8 +12,9 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
DOWNTIME_KINDS,
DOWNTIME_MAX,
downtimeSchedulesResponse,
type DowntimeKind,
downtimeSchedulesResponse,
FIRST_DOWNTIME_NAME,
} from './__story_mockdata__/plannedDowntime';
import {
@@ -101,3 +103,28 @@ export const plannedDowntimeMocks = defineStoryMocks({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
}),
});
/** The page fetches before it renders a row, which outlasts the 1s default. */
export const untilLoaded = { timeout: 15_000 };
export const clickFirstRowAction = async (
canvasElement: HTMLElement,
action: 'edit' | 'delete',
): Promise<void> => {
const icon = (
await within(canvasElement).findByText(
FIRST_DOWNTIME_NAME,
undefined,
untilLoaded,
)
)
.closest('.header-content')
// The row action holds edit then delete, neither of them labelled.
?.querySelectorAll('.action-btn svg')[action === 'edit' ? 0 : 1];
if (!icon) {
throw new Error(`Downtime ${action} action did not render`);
}
await userEvent.click(icon);
};

View File

@@ -4,7 +4,11 @@ import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { plannedDowntimeMocks } from './PlannedDowntime.stories.mocks';
import {
clickFirstRowAction,
plannedDowntimeMocks,
untilLoaded,
} from './PlannedDowntime.stories.mocks';
import { FIRST_DOWNTIME_NAME } from './__story_mockdata__/plannedDowntime';
import AlertList from '../../index';
@@ -31,9 +35,6 @@ export default meta;
type Story = StoryObj<PlannedDowntimeArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The windows where alerting is held back: what is running now, what is
* scheduled, and which rules each one silences.
@@ -82,27 +83,36 @@ export const NewDowntime: Story = {
/** The deletion confirmation opened from the first schedule's real row action. */
export const DeleteDowntimeConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const action = (
await within(canvasElement).findByText(
FIRST_DOWNTIME_NAME,
undefined,
untilLoaded,
)
)
.closest('.header-content')
// The row action holds edit then delete, neither of them labelled.
?.querySelectorAll('.action-btn svg')[1];
if (!action) {
throw new Error('Downtime delete action did not render');
}
await userEvent.click(action);
await clickFirstRowAction(canvasElement, 'delete');
// The modal titles itself and its confirm button the same.
await screen.findByRole('button', { name: 'Delete Schedule' });
},
};
/**
* The first schedule opened for editing: the specific rules it silences sit
* above the rule picker, each with its own remove button.
*/
export const EditDowntime: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickFirstRowAction(canvasElement, 'edit');
await screen.findByText(/edit planned downtime/i);
await screen.findByText(/^Postgres connections/);
},
};
/** 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

@@ -4,6 +4,7 @@
*/
import { rest } from 'msw';
import { screen, userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -26,6 +27,18 @@ const REQUEST = 'Routing policies · requests';
const REQUEST_STATES = ['loaded', 'error'] as const;
type RequestState = (typeof REQUEST_STATES)[number];
const MUTATION_STATES = ['success', 'error'] as const;
type MutationState = (typeof MUTATION_STATES)[number];
const MUTATION = 'Routing policies · mutations';
const mutationError = (
message: string,
): { status: string; error: { code: string; message: string } } => ({
status: 'error',
error: { code: 'invalid_input', message },
});
export const routingPoliciesMocks = defineStoryMocks({
controls: {
policies: countControl('Routing policies', {
@@ -51,6 +64,27 @@ export const routingPoliciesMocks = defineStoryMocks({
options: REQUEST_STATES,
value: 'loaded',
}),
createState: choiceControl<MutationState>('Create policy', {
group: MUTATION,
description:
'How the POST behind "Save Routing Policy" answers. `error` raises the error toast.',
options: MUTATION_STATES,
value: 'success',
}),
updateState: choiceControl<MutationState>('Update policy', {
group: MUTATION,
description:
'How the PUT behind "Save Routing Policy" answers when editing. `error` raises the error toast.',
options: MUTATION_STATES,
value: 'success',
}),
deleteState: choiceControl<MutationState>('Delete policy', {
group: MUTATION,
description:
'How the DELETE behind the confirmation answers. `error` raises the error toast.',
options: MUTATION_STATES,
value: 'success',
}),
},
handlers: (values, _response) => [
rest.get('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
@@ -64,15 +98,24 @@ export const routingPoliciesMocks = defineStoryMocks({
),
rest.post('http://localhost/api/v1/route_policies', (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null })),
values.createState === 'error'
? res(
ctx.status(400),
ctx.json(mutationError('Policy name already exists')),
)
: res(ctx.status(201), ctx.json({ status: 'success', data: null })),
),
rest.put('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
values.updateState === 'error'
? res(ctx.status(500), ctx.json(mutationError('Could not update')))
: res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.delete('http://localhost/api/v1/route_policies/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
values.deleteState === 'error'
? res(ctx.status(500), ctx.json(mutationError('Could not delete')))
: res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v1/channels', (_req, res, ctx) =>
@@ -85,3 +128,40 @@ export const routingPoliciesMocks = defineStoryMocks({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.ROUTING_POLICIES}`,
}),
});
/** The page fetches before it renders a row, which outlasts the 1s default. */
export const untilLoaded = { timeout: 15_000 };
export const fillNewPolicy = async (): Promise<void> => {
await userEvent.type(
await screen.findByPlaceholderText('e.g. Base routing policy...'),
'Toast policy',
);
await userEvent.type(
await screen.findByPlaceholderText(/e\.g\. service\.name/),
'severity = "critical"',
);
await userEvent.click(await screen.findByRole('combobox'));
await userEvent.click(await screen.findByTitle('ops-slack'));
await userEvent.keyboard('{Escape}');
await userEvent.click(
await screen.findByRole('button', { name: 'Save Routing Policy' }),
);
};
export const openEditPolicy = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
(
await within(canvasElement).findAllByTestId(
'edit-routing-policy',
undefined,
untilLoaded,
)
)[0],
);
await userEvent.click(
await screen.findByRole('button', { name: 'Save Routing Policy' }),
);
};

View File

@@ -1,10 +1,15 @@
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 { routingPoliciesMocks } from './RoutingPolicies.stories.mocks';
import {
fillNewPolicy,
openEditPolicy,
routingPoliciesMocks,
untilLoaded,
} from './RoutingPolicies.stories.mocks';
import { FIRST_POLICY_NAME } from './__story_mockdata__/routingPolicies';
import AlertList from '../../index';
@@ -31,9 +36,6 @@ export default meta;
type Story = StoryObj<RoutingPoliciesArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The rules that decide which channel an alert reaches, matched on the labels
* the alert carries.
@@ -137,3 +139,74 @@ export const FormValidationError: Story = {
await screen.findByText('Please provide a name for the routing policy');
},
};
const confirmDelete = async (canvasElement: HTMLElement): Promise<void> => {
await DeletePolicyConfirm.play?.({ canvasElement } as never);
await userEvent.click(
await screen.findByRole('button', { name: 'Delete Routing Policy' }),
);
};
/** A new policy saved: the toast raised once the POST answers. */
export const CreatePolicyToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await NewPolicy.play?.({ canvasElement } as never);
await fillNewPolicy();
if (args.createState === 'success') {
await waitFor(() =>
expect(
screen.getByText(/routing policy created successfully/i),
).toBeVisible(),
);
}
},
};
/** The POST failed: the error toast carries the server message. */
export const CreatePolicyErrorToast: Story = {
args: { createState: 'error' },
parameters: { allowConsoleErrors: true },
play: CreatePolicyToast.play,
};
/** An existing policy saved unchanged from its edit form. */
export const UpdatePolicyToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await openEditPolicy(canvasElement);
if (args.updateState === 'success') {
await waitFor(() =>
expect(
screen.getByText(/routing policy updated successfully/i),
).toBeVisible(),
);
}
},
};
/** The PUT failed. */
export const UpdatePolicyErrorToast: Story = {
args: { updateState: 'error' },
parameters: { allowConsoleErrors: true },
play: UpdatePolicyToast.play,
};
/** The first policy deleted from its confirmation. */
export const DeletePolicyToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await confirmDelete(canvasElement);
if (args.deleteState === 'success') {
await waitFor(() =>
expect(
screen.getByText(/routing policy deleted successfully/i),
).toBeVisible(),
);
}
},
};
/** The DELETE failed. */
export const DeletePolicyErrorToast: Story = {
args: { deleteState: 'error' },
parameters: { allowConsoleErrors: true },
play: DeletePolicyToast.play,
};

View File

@@ -1,5 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import {
expect,
fireEvent,
screen,
userEvent,
waitFor,
within,
} from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -148,3 +155,23 @@ export const Tooltips: Story = {
args: { tooltipsOpen: true },
parameters: { msw: { handlers: [overflowingLabels] } },
};
/** The copy button in the overflow chip's tooltip, clicked. */
export const OverflowLabelsCopiedToast: Story = {
args: { tooltipsOpen: true },
parameters: { msw: { handlers: [overflowingLabels] } },
play: async (): Promise<void> => {
// The overflow tooltips render after the label ones, and theirs come last.
await screen.findAllByText(/^ruleId: /, undefined, { timeout: 15_000 });
const copyButtons = screen.getAllByRole('button', {
name: 'Copy to clipboard',
});
// A pointerdown re-renders the held-open tooltip under the pointer, so the
// click is dispatched alone.
await fireEvent.click(copyButtons[copyButtons.length - 1]);
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
},
};

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>;
@@ -133,3 +138,108 @@ export const QuickFiltersSettingsWithBanner: Story = {
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/** 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

@@ -7,6 +7,7 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { rest } from 'msw';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { choiceControl, countControl } from '@/storybook/controls/controls';
@@ -76,6 +77,9 @@ const routeFor = (mode: AlertMode): string => {
const FORM = 'Create alert · form';
const TEST_NOTIFICATIONS = ['sent', 'no-alerts'] as const;
type TestNotification = (typeof TEST_NOTIFICATIONS)[number];
export const createAlertMocks = defineStoryMocks({
controls: {
alertMode: choiceControl<AlertMode>('Alert being created', {
@@ -96,6 +100,13 @@ export const createAlertMocks = defineStoryMocks({
value: 3,
max: 6,
}),
testNotification: choiceControl<TestNotification>('Test notification', {
group: 'Create alert · form',
description:
'What the test run finds. `sent` raises the success toast, `no-alerts` the error toast saying the condition was not met.',
options: TEST_NOTIFICATIONS,
value: 'sent',
}),
},
handlers: (values, response) => [
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
@@ -107,7 +118,10 @@ export const createAlertMocks = defineStoryMocks({
ctx.status(200),
ctx.json({
status: 'success',
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
data: {
alertCount: values.testNotification === 'no-alerts' ? 0 : 2,
message: 'Rule tested against the last 6 hours',
},
}),
),
),
@@ -155,3 +169,35 @@ export const createAlertMocks = defineStoryMocks({
],
config: (values) => ({ route: routeFor(values.alertMode) }),
});
const untilLoaded = { timeout: 15_000 };
export const fillValidAlert = async (
canvasElement: HTMLElement,
): Promise<void> => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('alert-name-input', undefined, untilLoaded),
'Toast alert',
);
const channels = await canvas.findByTestId(
'threshold-notification-channel-select',
undefined,
untilLoaded,
);
await userEvent.click(within(channels).getByRole('combobox'));
await userEvent.click(await screen.findByTitle('ops-slack'));
await userEvent.keyboard('{Escape}');
};
export const clickFooterButton = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const button = await within(canvasElement).findByTestId(testId);
await waitFor(() => expect(button).toBeEnabled());
await userEvent.click(button);
};

View File

@@ -1,10 +1,14 @@
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 { createAlertMocks } from './CreateAlert.stories.mocks';
import {
clickFooterButton,
createAlertMocks,
fillValidAlert,
} from './CreateAlert.stories.mocks';
import CreateAlertPage from '../index';
@@ -87,3 +91,35 @@ export const Tooltips: Story = {
await screen.findByText('Example:', undefined, { timeout: 15_000 });
},
};
/** A valid rule saved from the footer: the toast raised once the POST answers. */
export const AlertRuleCreatedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await fillValidAlert(canvasElement);
await clickFooterButton(canvasElement, 'save-alert-rule-button');
await waitFor(() =>
expect(screen.getByText(/alert rule created successfully/i)).toBeVisible(),
);
},
};
/** Test Notification on a rule whose condition fires: the success toast. */
export const TestNotificationSentToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await fillValidAlert(canvasElement);
await clickFooterButton(canvasElement, 'test-notification-button');
if (args.testNotification === 'sent') {
await waitFor(() =>
expect(
screen.getByText(/test notification sent successfully/i),
).toBeVisible(),
);
}
},
};
/** Test Notification on a rule that finds nothing to alert on: the error toast. */
export const TestNotificationNoAlertsToast: Story = {
args: { testNotification: 'no-alerts' },
play: TestNotificationSentToast.play,
};

View File

@@ -6,6 +6,7 @@
import { rest } from 'msw';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { choiceControl, toggleControl } from '@/storybook/controls/controls';
@@ -23,6 +24,11 @@ import {
NEW_PANEL_ID,
newPanelSearch,
} from '../../DashboardContainer/PanelEditor/newPanelRoute';
import {
API_RESULTS,
settle,
type ApiResult,
} from '../../stories/__story_mockdata__/settled';
import {
currentDashboardDocument,
PANEL_IDS,
@@ -86,6 +92,13 @@ export const panelEditorMocks = defineStoryMocks({
'A locked dashboard is read-only, so the editor loads but Save is refused with the reason.',
value: false,
}),
panelSave: choiceControl<ApiResult>('Panel save', {
group: PANEL,
description:
'How the JSON Patch behind Save changes answers. `success` raises the "Panel saved" toast, `loading` never answers and leaves the button spinning.',
options: API_RESULTS,
value: 'success',
}),
noData: toggleControl('Preview returns nothing', {
group: DATA,
description: 'The preview query answers with an empty result.',
@@ -109,18 +122,14 @@ export const panelEditorMocks = defineStoryMocks({
// Saving the panel is a JSON Patch whose response replaces the cache, so
// the ops are applied to the story's document and the edit stays.
rest.patch(
'http://localhost/api/v2/dashboards/:id',
async (req, res, ctx) => {
rest.patch('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
settle(values.panelSave, req, res, ctx, async () => {
const ops = (await req.json()) as Parameters<
typeof patchDashboardDocument
>[1];
return res(
ctx.status(200),
ctx.json(patchDashboardDocument(document, ops)),
);
},
return patchDashboardDocument(document, ops);
}),
),
rest.post(
@@ -203,3 +212,85 @@ export const panelEditorMocks = defineStoryMocks({
});
},
});
export const savePanel = async (): Promise<void> => {
const save = await screen.findByTestId(
'panel-editor-v2-save',
{},
{ timeout: 10000 },
);
await waitFor(() => expect(save).toBeEnabled(), { timeout: 10000 });
await userEvent.click(save);
};
// The editor resolves its panel from the dashboard document before it renders.
const PAGE_LOAD = { timeout: 10000 };
/**
* Opens a display-options section and scrolls it to the top of the pane, where
* a screenshot of the viewport can see it. Returns the section to query in.
*/
export const expandSection = async (title: string): Promise<HTMLElement> => {
// The toggle's test id is the title, lowercased, with whitespace as dashes.
const toggle = await screen.findByTestId(
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
{},
PAGE_LOAD,
);
if (toggle.getAttribute('aria-expanded') !== 'true') {
await userEvent.click(toggle);
}
toggle.scrollIntoView({ block: 'start' });
return toggle.closest('section') ?? document.body;
};
/** Picks one tile of a radio-tile control by its value. */
export const pickSegment = async (
testId: string,
value: string,
): Promise<void> => {
const radio = (): HTMLElement =>
within(screen.getByTestId(`${testId}-${value}`)).getByRole('radio');
// A pick made while the editor still seeds its draft from the panel is
// dropped, so it is made again until it holds.
await waitFor(async () => {
if (!(radio() as HTMLInputElement).checked) {
await userEvent.click(radio());
}
await expect(radio()).toBeChecked();
}, PAGE_LOAD);
};
/**
* The pane's pickers are antd Selects: the element carrying the test id does
* nothing on click, the combobox inside it is what opens the list.
*/
export const openConfigSelect = async (testId: string): Promise<void> => {
const select = await screen.findByTestId(testId, {}, PAGE_LOAD);
await userEvent.click(within(select).getByRole('combobox'));
};
/**
* Opens Thresholds and adds one row through the section's own button, whose
* test id depends on the panel kind.
*/
export const addThreshold = async (
testId = 'panel-editor-v2-add-threshold',
): Promise<void> => {
const section = await expandSection('Thresholds');
await userEvent.click(within(section).getByTestId(testId));
};
export const openLinkDialog = async (): Promise<void> => {
const section = await expandSection('Context Links');
// The header's quick add carries the same name, so the body button goes by id.
await userEvent.click(within(section).getByTestId('panel-editor-v2-add-link'));
await screen.findByTestId('context-link-dialog');
};

View File

@@ -7,7 +7,7 @@ import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { panelEditorMocks } from './PanelEditorPage.stories.mocks';
import { panelEditorMocks, savePanel } from './PanelEditorPage.stories.mocks';
import PanelEditorPage from '../PanelEditorPage';
@@ -22,8 +22,8 @@ const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
* Route: `/dashboard/:dashboardId/panel/:panelId`.
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor',
tags: ['play'],
title: 'Pages/Dashboards/Panel Editor/Overview',
tags: ['authz', 'play'],
// The page is wrapped in `withAuthZPage`, which types its props as an index
// signature; the story's args are what the controls resolve to.
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
@@ -70,11 +70,39 @@ export const NewPanel: Story = {
args: { panel: 'new' },
};
/** A number panel: one value, formatted, with comparison thresholds. */
export const NumberPanel: Story = {
args: { panel: 'p99-latency' },
};
/** A bar chart, whose Visualization section offers stacking. */
export const BarChartPanel: Story = {
args: { panel: 'errors-by-status' },
};
/** A pie chart, with a legend per slice and no axes. */
export const PieChartPanel: Story = {
args: { panel: 'traffic-share' },
};
/** A list panel, where the config pane is the column editor. */
export const ListPanel: Story = {
args: { panel: 'recent-logs' },
};
/** Interaction: the list panel's add column menu, searching field keys. */
export const ListPanelAddColumnOpen: Story = {
args: { panel: 'recent-logs' },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByTestId('list-columns-add', undefined, {
timeout: 10_000,
}),
);
await screen.findAllByTestId('list-columns-suggestion');
},
};
/** A table panel, with its column units and thresholds. */
export const TablePanel: Story = {
args: { panel: 'top-endpoints' },
@@ -201,3 +229,23 @@ export const ConfigChanged: Story = {
);
},
};
/**
* Save changes on a saved panel: the toast raised once the patch answers,
* before the editor hands back to the dashboard. Set Panel save to `loading`
* to see the button spin instead.
*/
export const PanelSavedToast: Story = {
play: async ({ args }): Promise<void> => {
await savePanel();
if (args.panelSave === 'success') {
await screen.findByText('Panel saved');
}
},
};
/** Save changes on a new text panel, the editor kind with no query to bake in. */
export const TextPanelSavedToast: Story = {
args: { panel: 'new', newPanelKind: 'text' },
play: PanelSavedToast.play,
};

View File

@@ -0,0 +1,175 @@
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 { expect, screen, userEvent, waitFor } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
expandSection,
openConfigSelect,
panelEditorMocks,
pickSegment,
} from '../PanelEditorPage.stories.mocks';
import PanelEditorPage from '../../PanelEditorPage';
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
/**
* The panel editor's display options, one section open at a time: what each
* panel kind lets a user tune about how its data is drawn, and the preview
* following along.
*
* Route: `/dashboard/:dashboardId/panel/:panelId`.
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor/Chart Options',
tags: ['authz', 'play'],
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<PanelEditorArgs>;
export default meta;
type Story = StoryObj<PanelEditorArgs>;
/** The time range the panel reads, open on its choices. */
export const TimePreferenceOpen: Story = {
play: async () => {
await openConfigSelect('panel-editor-v2-time-preference');
await screen.findByRole('listbox');
},
};
/** Formatting & Units: the unit and decimals the values are shown with. */
export const Formatting: Story = {
play: async () => {
await expandSection('Formatting & Units');
await screen.findByTestId('panel-editor-v2-decimals');
},
};
/** The unit picker, open on its categories. */
export const UnitPickerOpen: Story = {
play: async (context) => {
await Formatting.play?.(context);
await openConfigSelect('panel-editor-v2-unit');
await screen.findByRole('listbox');
},
};
/** Axes: soft bounds for the y axis and its scale. */
export const Axes: Story = {
play: async () => {
await expandSection('Axes');
await screen.findByTestId('panel-editor-v2-log-scale');
},
};
/** The y axis on a log scale, which the preview redraws with. */
export const AxesLogScale: Story = {
play: async (context) => {
await Axes.play?.(context);
await pickSegment('panel-editor-v2-log-scale', 'log');
},
};
/** Legend: its position, and a color per series the query returned. */
export const Legend: Story = {
play: async () => {
await expandSection('Legend');
await screen.findByTestId('panel-editor-v2-legend-position');
},
};
/** The legend moved to the right of the preview. */
export const LegendRight: Story = {
play: async (context) => {
await Legend.play?.(context);
await pickSegment('panel-editor-v2-legend-position', 'right');
},
};
/** Chart appearance: line style, interpolation, fill and points. */
export const ChartAppearance: Story = {
play: async () => {
await expandSection('Chart appearance');
await screen.findByTestId('panel-editor-v2-line-style');
},
};
/** Dashed lines under a gradient fill, with the points drawn. */
export const ChartAppearanceDashedGradient: Story = {
play: async (context) => {
await ChartAppearance.play?.(context);
await pickSegment('panel-editor-v2-line-style', 'dashed');
await pickSegment('panel-editor-v2-fill-mode', 'gradient');
const points = screen.getByTestId('panel-editor-v2-show-points');
await userEvent.click(points);
await waitFor(() => expect(points).toHaveAttribute('aria-checked', 'true'));
},
};
/** An area chart's appearance, which adds the fill opacity. */
export const AreaChartAppearance: Story = {
args: { panel: 'new', newPanelKind: 'area-chart' },
play: async () => {
await expandSection('Chart appearance');
await screen.findByTestId('panel-editor-v2-fill-opacity');
},
};
/** A bar chart with its series stacked on each other. */
export const BarStacked: Story = {
args: { panel: 'errors-by-status' },
play: async () => {
await expandSection('Visualization');
await pickSegment('panel-editor-v2-stacked-bar-chart', 'stacked');
},
};
/** A table's formatting: a unit per value column. */
export const TableColumnUnits: Story = {
args: { panel: 'top-endpoints' },
play: async () => {
await expandSection('Formatting & Units');
await screen.findByTestId('panel-editor-v2-decimals');
},
};
/** A pie chart's legend, colored per slice. */
export const PieLegend: Story = {
args: { panel: 'traffic-share' },
play: async () => {
await expandSection('Legend');
await screen.findByTestId('panel-editor-v2-legend-position');
},
};
/** A histogram's buckets: how many, how wide, and whether queries merge. */
export const HistogramBuckets: Story = {
args: { panel: 'new', newPanelKind: 'histogram' },
play: async () => {
await expandSection('Buckets');
await screen.findByTestId('panel-editor-v2-bucket-count');
},
};
/** A text panel's appearance: alignment and background. */
export const TextAppearance: Story = {
args: { panel: 'new', newPanelKind: 'text' },
play: async () => {
await expandSection('Panel appearance');
await screen.findByTestId('text-layout-align');
},
};

View File

@@ -0,0 +1,138 @@
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 { expect, screen, userEvent, waitFor } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
addThreshold,
openLinkDialog,
panelEditorMocks,
} from '../PanelEditorPage.stories.mocks';
import PanelEditorPage from '../../PanelEditorPage';
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
/**
* The panel editor's Thresholds and Context Links sections. A threshold row
* takes the shape of its panel kind: a labelled line on a chart, a comparison
* on a number, a column rule on a table. A context link opens a dialog whose
* URL can carry the dashboard's variables.
*
* Route: `/dashboard/:dashboardId/panel/:panelId`.
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor/Thresholds and Links',
tags: ['authz', 'play'],
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<PanelEditorArgs>;
export default meta;
type Story = StoryObj<PanelEditorArgs>;
/** A time series threshold: a value, a color and a label drawn on the chart. */
export const Threshold: Story = {
play: async () => {
await addThreshold();
await screen.findByTestId('threshold-value-0');
},
};
/** The threshold's custom color picker, open beside the preset swatches. */
export const ThresholdColorOpen: Story = {
play: async (context) => {
await Threshold.play?.(context);
await userEvent.click(screen.getByTestId('threshold-color-0-custom'));
await waitFor(() =>
expect(
document.querySelector('.ant-color-picker-inner-content'),
).toBeVisible(),
);
},
};
/** A number panel's threshold, which compares the value and recolors it. */
export const NumberThreshold: Story = {
args: { panel: 'p99-latency' },
play: async () => {
await addThreshold('panel-editor-v2-add-comparison-threshold');
await screen.findByTestId('comparison-threshold-operator-0');
},
};
/** A table's threshold, which picks the column it colors. */
export const TableThreshold: Story = {
args: { panel: 'top-endpoints' },
play: async () => {
await addThreshold('panel-editor-v2-add-table-threshold');
await screen.findByTestId('table-threshold-column-0');
},
};
/** The add-link dialog, empty, with Save waiting for a URL. */
export const ContextLinkDialog: Story = {
play: async () => {
await openLinkDialog();
},
};
/** The URL field focused, offering the variables it can interpolate. */
export const ContextLinkVariables: Story = {
play: async () => {
await openLinkDialog();
await userEvent.click(screen.getByTestId('context-link-url'));
await screen.findByTestId('context-link-variable-service');
},
};
/** A URL with query parameters, split into rows that can be edited one by one. */
export const ContextLinkParams: Story = {
play: async () => {
await openLinkDialog();
await userEvent.type(
screen.getByTestId('context-link-label'),
'Open service traces',
);
await userEvent.click(screen.getByTestId('context-link-url'));
await userEvent.paste(
'/traces-explorer?service={{service}}&env={{environment}}',
);
// Leaving the URL closes the variables list that covers the rows.
await userEvent.click(screen.getByTestId('context-link-label'));
await screen.findByTestId('context-link-param-key-1');
},
};
/** A URL the link cannot open, which the field flags and Save refuses. */
export const ContextLinkInvalidUrl: Story = {
play: async () => {
await openLinkDialog();
await userEvent.click(screen.getByTestId('context-link-url'));
await userEvent.paste('traces for {{service}}');
await screen.findByTestId('context-link-url-error');
},
};
/** A saved link, listed in the section with its edit and remove actions. */
export const ContextLinkSaved: Story = {
play: async (context) => {
await ContextLinkParams.play?.(context);
await userEvent.click(screen.getByTestId('context-link-save'));
await waitFor(() =>
expect(screen.queryByTestId('context-link-dialog')).toBeNull(),
);
await screen.findByTestId('context-link-item-0');
},
};

View File

@@ -7,19 +7,26 @@ import { rest } from 'msw';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import type { GetPublicDashboard200 } from 'api/generated/services/sigNoz.schemas';
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
choiceControl,
countControl,
multiChoiceControl,
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 {
currentDashboardDocument,
cyclicVariablesDashboardResponse,
patchDashboardDocument,
PANEL_IDS,
seedDashboardDocument,
@@ -29,8 +36,14 @@ import {
type VariableKind,
} from './__story_mockdata__/dashboard';
import {
API_RESULTS,
settle,
type ApiResult,
} from './__story_mockdata__/settled';
import {
attributeValues,
emptyPanelResponse,
NAMESPACE_VALUES,
VARIABLE_ATTRIBUTES,
panelResponse,
serviceVariableValues,
} from './__story_mockdata__/panelData';
@@ -44,6 +57,7 @@ import {
const LAYOUT = 'Dashboard · layout';
const DATA = 'Dashboard · panels';
const SHARING = 'Dashboard · sharing';
const REQUESTS = 'Dashboard · requests';
export const dashboardRoute = (): string =>
generatePath(ROUTES.DASHBOARD, { dashboardId: STORY_DASHBOARD_ID });
@@ -65,6 +79,14 @@ const NOT_FOUND = {
const ok = { status: 'success', data: null };
// The editor rewrites any `Syntax error:` into its own hint, so the ClickHouse
// wording only shows through the variable bar.
const VARIABLE_QUERY_ERROR = {
status: 'error',
error:
"Code: 62. DB::Exception: Syntax error: failed at position 58 ('$environment'). (SYNTAX_ERROR)",
};
const publicMeta = (): GetPublicDashboard200 => ({
status: 'success',
data: {
@@ -137,6 +159,12 @@ export const dashboardMocks = defineStoryMocks({
value: 4,
max: 12,
}),
variableQueryFails: toggleControl('Variable query fails', {
group: DATA,
description:
"The query-backed `service` variable answers with a ClickHouse syntax error, which the variable bar and the editor's Test Run both report.",
value: false,
}),
noData: toggleControl('Panels return nothing', {
group: DATA,
description:
@@ -149,6 +177,44 @@ export const dashboardMocks = defineStoryMocks({
'Answers the dashboard document with a 404, which is the page-level failure the shell renders around.',
value: false,
}),
dashboardPatch: choiceControl<ApiResult>('Dashboard edit', {
group: REQUESTS,
description:
'How the JSON Patch behind every spec edit answers: rename, settings, variables, clone panel or section and the variable review. `loading` never answers, `error` answers 500.',
options: API_RESULTS,
value: 'success',
}),
dashboardUpdate: choiceControl<ApiResult>('Dashboard replace', {
group: REQUESTS,
description: "How the PUT behind the JSON editor's Apply changes answers.",
options: API_RESULTS,
value: 'success',
}),
dashboardClone: choiceControl<ApiResult>('Dashboard clone', {
group: REQUESTS,
description: 'How the endpoint behind Actions, Clone dashboard answers.',
options: API_RESULTS,
value: 'success',
}),
dashboardDelete: choiceControl<ApiResult>('Dashboard delete', {
group: REQUESTS,
description: 'How the endpoint behind Actions, Delete dashboard answers.',
options: API_RESULTS,
value: 'success',
}),
publicWrite: choiceControl<ApiResult>('Public link change', {
group: REQUESTS,
description: 'How publish, update and unpublish on the Publish tab answer.',
options: API_RESULTS,
value: 'success',
}),
substituteVars: choiceControl<ApiResult>('Variable substitution', {
group: REQUESTS,
description:
'How the endpoint that resolves variables before Create Alerts answers.',
options: API_RESULTS,
value: 'success',
}),
published: toggleControl('Published publicly', {
group: SHARING,
description:
@@ -177,26 +243,37 @@ export const dashboardMocks = defineStoryMocks({
// Every spec edit travels as a JSON Patch, and its response is what
// replaces the cache, so the ops are applied to the story's document
// rather than answered away.
rest.patch(
'http://localhost/api/v2/dashboards/:id',
async (req, res, ctx) => {
rest.patch('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
settle(values.dashboardPatch, req, res, ctx, async () => {
const ops = (await req.json()) as Parameters<
typeof patchDashboardDocument
>[1];
return res(
ctx.status(200),
ctx.json(patchDashboardDocument(document, ops)),
);
},
return patchDashboardDocument(document, ops);
}),
),
rest.post('http://localhost/api/v2/dashboards/:id/clone', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(currentDashboardDocument(document))),
rest.put('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
settle(values.dashboardUpdate, req, res, ctx, () =>
currentDashboardDocument(document),
),
),
rest.delete('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
rest.post('http://localhost/api/v2/dashboards/:id/clone', (req, res, ctx) =>
settle(values.dashboardClone, req, res, ctx, () =>
currentDashboardDocument(document),
),
),
rest.delete('http://localhost/api/v2/dashboards/:id', (req, res, ctx) =>
settle(values.dashboardDelete, req, res, ctx, () => ok),
),
rest.post('http://localhost/api/v5/substitute_vars', (req, res, ctx) =>
settle(values.substituteVars, req, res, ctx, async () => ({
status: 'success',
data: await req.json(),
})),
),
rest.put('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
@@ -228,18 +305,35 @@ export const dashboardMocks = defineStoryMocks({
// The variable bar resolves before the panels and stays laid out while
// they load or fail, so its two endpoints answer on their own rather
// than through the Data control.
rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
values.variableQueryFails
? rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
res(ctx.status(400), ctx.json(VARIABLE_QUERY_ERROR)),
)
: rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
variableValues: serviceVariableValues(values.variableValues),
},
}),
),
),
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { variableValues: serviceVariableValues(values.variableValues) },
}),
ctx.json(
fieldValuesResponse(attributeValues(req.url.searchParams.get('name'))),
),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(ctx.status(200), ctx.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
@@ -250,18 +344,17 @@ export const dashboardMocks = defineStoryMocks({
: res(ctx.status(404), ctx.json(NOT_PUBLIC)),
),
rest.post(
'http://localhost/api/v1/dashboards/:id/public',
(_req, res, ctx) => res(ctx.status(200), ctx.json(publicMeta())),
rest.post('http://localhost/api/v1/dashboards/:id/public', (req, res, ctx) =>
settle(values.publicWrite, req, res, ctx, publicMeta),
),
rest.put('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(publicMeta())),
rest.put('http://localhost/api/v1/dashboards/:id/public', (req, res, ctx) =>
settle(values.publicWrite, req, res, ctx, publicMeta),
),
rest.delete(
'http://localhost/api/v1/dashboards/:id/public',
(_req, res, ctx) => res(ctx.status(200), ctx.json(ok)),
(req, res, ctx) => settle(values.publicWrite, req, res, ctx, () => ok),
),
];
},
@@ -273,6 +366,9 @@ export const dashboardMocks = defineStoryMocks({
variables: values.variables,
locked: values.locked,
});
// The sync mode persists per dashboard, so one story's pick would open the
// next one on it.
useDashboardPreferencesStore.setState({ preferences: {} });
},
});
@@ -331,3 +427,218 @@ export const metricsListHandler = rest.get(
(_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: { metrics: [] } })),
);
export const cyclicVariablesDashboardHandler = rest.get(
'http://localhost/api/v2/dashboards/:id',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(cyclicVariablesDashboardResponse())),
);
// The dashboard document loads before the toolbar renders.
export const PAGE_LOAD = { timeout: 10000 };
export const clickAction = async (
canvasElement: HTMLElement,
label: string,
): Promise<void> => {
// The dropdown trigger's Slot merge drops the button's own test id.
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Actions' },
PAGE_LOAD,
),
);
const item = await screen.findByText(label);
const menuItem = item.closest('[role="menuitem"]') ?? item;
await waitFor(() => {
expect(menuItem).not.toHaveAttribute('aria-disabled', 'true');
expect(menuItem).not.toHaveAttribute('data-disabled');
});
await userEvent.click(menuItem);
};
export const clickPanelAction = async (
canvasElement: HTMLElement,
panelId: string,
label: string,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
`panel-actions-${panelId}`,
{},
PAGE_LOAD,
),
);
await userEvent.click(await screen.findByText(label));
};
export const clickSectionAction = async (
canvasElement: HTMLElement,
label: string,
): Promise<void> => {
const [firstSection] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Section actions' },
PAGE_LOAD,
);
await userEvent.click(firstSection);
await userEvent.click(await screen.findByText(label));
};
export const expectToast = (text: string | RegExp): Promise<HTMLElement> =>
screen.findByText(text, undefined, PAGE_LOAD);
export const openJsonEditor = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('edit-json', {}, PAGE_LOAD),
);
await screen.findByTestId('json-editor-copy', {}, PAGE_LOAD);
};
export const confirmImpact = async (): Promise<void> => {
await userEvent.click(await screen.findByTestId('variable-impact-confirm'));
};
/**
* A denied control stays on screen, disabled. Buttons say so through
* `disabled` or `aria-disabled` depending on the Button, menu rows through
* `data-disabled`.
*/
const isDisabled = (element: HTMLElement): boolean =>
element.hasAttribute('disabled') ||
element.getAttribute('aria-disabled') === 'true' ||
element.hasAttribute('data-disabled');
export const expectDisabled = (
element: HTMLElement,
disabled = true,
): Promise<void> =>
waitFor(() => expect(isDisabled(element)).toBe(disabled), PAGE_LOAD);
export const toolbarButton = (
canvasElement: HTMLElement,
name: string,
): Promise<HTMLElement> =>
within(canvasElement).findByRole('button', { name }, PAGE_LOAD);
export const openActionsMenu = async (
canvasElement: HTMLElement,
): Promise<void> => {
// The dropdown trigger's Slot merge drops the button's own test id.
await userEvent.click(await toolbarButton(canvasElement, 'Actions'));
await screen.findByText('Clone dashboard');
};
// Not by test id: the Actions menu only carries them on some Dropdown versions.
export const menuItem = (name: string): HTMLElement =>
screen.getByRole('menuitem', { name });
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
export type VariableType = 'dynamic' | 'textbox' | 'custom' | 'query';
type RowAction = 'edit' | 'delete' | 'apply-all';
/** Opens the settings drawer from the toolbar and returns the tab's panel. */
export const openSettings = async (
canvasElement: HTMLElement,
tab: SettingsTab = 'Overview',
): Promise<HTMLElement> => {
await userEvent.click(
await within(canvasElement).findByTestId('show-drawer', {}, PAGE_LOAD),
);
if (tab !== 'Overview') {
await userEvent.click(await screen.findByRole('tab', { name: tab }));
}
return screen.findByRole('tabpanel');
};
export const variableRow = (name: string): Promise<HTMLElement> =>
screen.findByTestId(`variable-row-${name}`, {}, PAGE_LOAD);
/** Row actions render once the edit permission resolves. */
export const clickRowAction = async (
name: string,
action: RowAction,
): Promise<void> => {
const row = await variableRow(name);
await userEvent.click(
action === 'apply-all'
? // The tooltip trigger's Slot merge drops this button's test id.
await within(row).findByRole('button', { name: 'Apply to all' }, PAGE_LOAD)
: await within(row).findByTestId(
`variable-${action}-${name}`,
{},
PAGE_LOAD,
),
);
};
/** A new variable's editor, which opens on the Dynamic type. */
export const openNewVariable = async (
canvasElement: HTMLElement,
): Promise<void> => {
const panel = await openSettings(canvasElement, 'Variables');
const add = await within(panel).findByTestId('add-variable', {}, PAGE_LOAD);
// Disabled until its permission check resolves: through `disabled` or
// `aria-disabled`, depending on the Button.
await waitFor(() => expect(add).toBeEnabled(), PAGE_LOAD);
await waitFor(
() => expect(add).not.toHaveAttribute('aria-disabled', 'true'),
PAGE_LOAD,
);
await userEvent.click(add);
await screen.findByText('Variable Type');
};
export const openVariableEditor = async (
canvasElement: HTMLElement,
name: string,
): Promise<void> => {
await openSettings(canvasElement, 'Variables');
await clickRowAction(name, 'edit');
await screen.findByText('Variable Type');
};
/**
* A value in the editor's preview. The toolbar's selector for the same variable
* resolves to its first value too, whenever its own query returns.
*/
export const findPreviewValue = async (value: string): Promise<HTMLElement> => {
const label = await screen.findByText('Preview of Values');
return within(label.parentElement as HTMLElement).findByText(
value,
undefined,
PAGE_LOAD,
);
};
export const pickVariableType = async (type: VariableType): Promise<void> => {
await userEvent.click(await screen.findByTestId(`variable-type-${type}`));
};
export const typeVariableName = async (name: string): Promise<void> => {
const input = await screen.findByTestId('variable-name');
await userEvent.clear(input);
await userEvent.type(input, name);
};
/**
* The editor's pickers are antd Selects: the element carrying the test id does
* nothing on click, the combobox inside it is what opens the list.
*/
export const openVariableSelect = async (testId: string): Promise<void> => {
const select = await screen.findByTestId(testId);
await userEvent.click(within(select).getByRole('combobox'));
};

View File

@@ -1,16 +1,23 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import * as monaco from 'monaco-editor';
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';
import {
clickAction,
clickPanelAction,
clickSectionAction,
dashboardMocks,
desyncedDashboardHandler,
expectToast,
metricsListHandler,
openJsonEditor,
PAGE_LOAD,
tooltipDashboardHandler,
tooltipRoute,
warnedPanelQueryHandler,
@@ -30,8 +37,8 @@ const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail',
tags: ['role-gated', 'play'],
title: 'Pages/Dashboards/Detail/Overview',
tags: ['authz', 'play'],
// The page is wrapped in `withAuthZPage`, which types its props as an index
// signature; the story's args are what the controls resolve to.
component: DashboardPage as ComponentType<DashboardArgs>,
@@ -159,53 +166,6 @@ export const TooltipsInJsonDrawer: Story = {
},
};
/**
* The Overview tab of dashboard settings, where Cross-Panel Sync explains what
* syncing the crosshair does and links out to the docs.
*/
export const TooltipsInSettings: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
);
await screen.findByText('Sync Mode');
},
};
/**
* The Variables tab of dashboard settings, where a dynamic variable's Apply to
* all says whether it is already a filter on every panel. The row keeps its
* actions invisible until it is hovered, which the story does first.
*/
export const TooltipsInVariableSettings: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
);
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
// The tooltip trigger's Slot merge drops the button's own test id.
await userEvent.hover(
await screen.findByRole(
'button',
{ name: 'Apply to all' },
{ timeout: 10000 },
),
);
await screen.findByText(
'Add this variable as a filter to every panel',
undefined,
{ timeout: 10000 },
);
},
};
/**
* A panel expanded into view mode, whose header carries the full panel name its
* title truncates, over the dashboard's own tooltips behind the dialog.
@@ -390,6 +350,194 @@ export const NewPanelPickerFromSection: 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,
});
},
};
/**
* Actions, Clone dashboard: the toast announcing the copy, raised once the
* clone endpoint answers. Set Dashboard clone to `error` to see the error
* modal instead.
*/
export const DashboardClonedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await clickAction(canvasElement, 'Clone dashboard');
if (args.dashboardClone === 'success') {
await expectToast('Dashboard cloned');
}
},
};
/**
* The title edited inline: the toast announcing the new name, raised once the
* patch answers.
*/
export const DashboardRenamedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await clickAction(canvasElement, 'Rename');
const input = await screen.findByTestId('dashboard-title-input');
await userEvent.clear(input);
await userEvent.type(input, 'Service overview (renamed){enter}');
if (args.dashboardPatch === 'success') {
await expectToast('Dashboard renamed successfully');
}
},
};
/**
* Actions, Delete dashboard, confirmed: the toast announcing the deletion,
* raised once the delete endpoint answers and before the page leaves for the
* list.
*/
export const DashboardDeletedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await clickAction(canvasElement, 'Delete dashboard');
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
if (args.dashboardDelete === 'success') {
await expectToast('Dashboard deleted successfully');
}
},
};
/** A panel's Clone: the promise toast, loading until the patch answers. */
export const PanelClonedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await clickPanelAction(canvasElement, 'p99-latency', 'Clone');
if (args.dashboardPatch === 'success') {
await expectToast('Panel cloned');
}
},
};
/** A panel's Clone whose patch is refused. */
export const PanelCloneFailedToast: Story = {
args: { dashboardPatch: 'error' },
// The refused patch is the state under test.
parameters: { allowConsoleErrors: true },
play: async (context) => {
await PanelClonedToast.play?.(context);
await expectToast('Failed to clone panel');
},
};
/** A panel's Clone whose patch never answers, so the toast stays on loading. */
export const PanelCloneLoadingToast: Story = {
args: { dashboardPatch: 'loading' },
play: async (context) => {
await PanelClonedToast.play?.(context);
await expectToast('Cloning panel…');
},
};
/** A section's Clone section: the promise toast, loading until the patch answers. */
export const SectionClonedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await clickSectionAction(canvasElement, 'Clone section');
if (args.dashboardPatch === 'success') {
await expectToast('Section cloned');
}
},
};
/** A section's Clone section whose patch is refused. */
export const SectionCloneFailedToast: Story = {
args: { dashboardPatch: 'error' },
// The refused patch is the state under test.
parameters: { allowConsoleErrors: true },
play: async (context) => {
await SectionClonedToast.play?.(context);
await expectToast('Failed to clone section');
},
};
/** A section's Clone section whose patch never answers, so the toast stays on loading. */
export const SectionCloneLoadingToast: Story = {
args: { dashboardPatch: 'loading' },
play: async (context) => {
await SectionClonedToast.play?.(context);
await expectToast('Cloning section…');
},
};
/**
* A panel's Create Alerts when the substitute-variables call is refused: the
* dashboard has variables selected, so the query is resolved before the alert
* page opens.
*/
export const PanelCreateAlertErrorToast: Story = {
args: { substituteVars: 'error' },
// The refused request is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await clickPanelAction(canvasElement, 'p99-latency', 'Create Alerts');
await expectToast('Failed to create alert from panel');
},
};
/**
* The View modal on a time series panel, Save in its series manager: the toast
* confirming the legend state was stored.
*/
export const ChartManagerSavedToast: Story = {
parameters: { msw: { handlers: [metricsListHandler] } },
play: async ({ canvasElement }): Promise<void> => {
await clickPanelAction(canvasElement, 'request-rate', 'View');
await userEvent.click(
await screen.findByRole('button', { name: 'Save' }, PAGE_LOAD),
);
await expectToast('The updated graphs & legends are saved');
},
};
/** The JSON editor's Copy: the toast confirming the clipboard write. */
export const JsonCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openJsonEditor(canvasElement);
await userEvent.click(screen.getByTestId('json-editor-copy'));
await expectToast('JSON copied to clipboard');
},
};
/**
* The JSON editor with a whitespace edit, applied: the toast announcing the
* update, raised once the PUT answers.
*/
export const JsonAppliedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await openJsonEditor(canvasElement);
await waitFor(
() => {
if (monaco.editor.getModels().length === 0) {
throw new Error('Monaco has not mounted');
}
},
{ timeout: 20000 },
);
// Typing into Monaco's hidden textarea never reaches its model in this runner.
monaco.editor
.getModels()[0]
.applyEdits([{ range: new monaco.Range(1, 1, 1, 1), text: ' ' }]);
await waitFor(() =>
expect(screen.getByTestId('json-editor-apply')).not.toHaveAttribute(
'aria-disabled',
'true',
),
);
await userEvent.click(screen.getByTestId('json-editor-apply'));
if (args.dashboardUpdate === 'success') {
await expectToast('Dashboard updated');
}
},
};
/**
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
*

View File

@@ -336,6 +336,28 @@ const VARIABLES: Record<VariableKind, DashboardtypesVariableDTO> = {
},
};
/**
* `service` already filters on `$environment`, so an `environment` that reads
* `$service` back closes the loop. An imported dashboard can arrive like this;
* the editor only refuses it on save.
*/
const CYCLIC_ENVIRONMENT: DashboardtypesVariableDTO = {
kind: ListVariableKind.ListVariable,
spec: {
name: 'environment',
display: { name: 'environment' },
allowMultiple: false,
allowAllValue: false,
plugin: {
kind: QueryVariableKind['signoz/QueryVariable'],
spec: {
queryValue:
'SELECT DISTINCT env FROM signoz_metrics WHERE service_name IN $service',
},
},
},
};
export interface PanelQueryShape {
requestType: string;
metricName?: string;
@@ -451,3 +473,18 @@ export const patchDashboardDocument = (
return envelope(document);
};
export const cyclicVariablesDashboardResponse = (): GetDashboardV2200 => {
const response = dashboardResponse({
panels: PANEL_IDS.length,
sectioned: true,
variables: VARIABLE_KINDS,
locked: false,
});
response.data.spec.variables = response.data.spec.variables.map((variable) =>
variable === VARIABLES.custom ? CYCLIC_ENVIRONMENT : variable,
);
return response;
};

View File

@@ -173,3 +173,20 @@ export const NAMESPACE_VALUES = [
'payments-prod',
'platform-prod',
] as const;
/**
* The attributes the dynamic variable editor offers a variable to read, with
* the values each one resolves to.
*/
const ATTRIBUTE_VALUES: Record<string, readonly string[]> = {
'k8s.namespace.name': NAMESPACE_VALUES,
'k8s.cluster.name': ['prod-us-east-1', 'prod-eu-west-1'],
'service.name': SERVICES,
'deployment.environment': ['production', 'staging', 'development'],
'host.name': ['ip-10-0-1-12', 'ip-10-0-1-37', 'ip-10-0-2-4'],
};
export const VARIABLE_ATTRIBUTES = Object.keys(ATTRIBUTE_VALUES);
export const attributeValues = (name: string | null): readonly string[] =>
ATTRIBUTE_VALUES[name ?? ''] ?? NAMESPACE_VALUES;

View File

@@ -0,0 +1,46 @@
import type {
DefaultBodyType,
MockedResponse,
PathParams,
ResponseComposition,
RestContext,
RestRequest,
} from 'msw';
export const API_RESULTS = ['success', 'loading', 'error'] as const;
export type ApiResult = (typeof API_RESULTS)[number];
const SERVER_ERROR = {
status: 'error',
error: {
code: 'internal',
message: 'Something went wrong on the server',
url: '',
errors: [],
},
};
/**
* How a mutation endpoint answers for a result: never, with a 500, or with
* whatever `ok` builds from the request.
*/
export const settle = async (
result: ApiResult,
req: RestRequest<DefaultBodyType, PathParams<string>>,
res: ResponseComposition,
ctx: RestContext,
ok: (
req: RestRequest<DefaultBodyType, PathParams<string>>,
) => object | Promise<object>,
): Promise<MockedResponse> => {
if (result === 'loading') {
return res(ctx.delay('infinite'));
}
if (result === 'error') {
return res(ctx.status(500), ctx.json(SERVER_ERROR));
}
return res(ctx.status(200), ctx.json(await ok(req)));
};

View File

@@ -0,0 +1,206 @@
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 { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardMocks,
expectDisabled,
menuItem,
openActionsMenu,
toolbarButton,
} from '../DashboardPage.stories.mocks';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The dashboard with one permission missing at a time. Every story here is an
* admin denied exactly what its name says. `read` decides whether the page
* renders at all; `update` is every edit, the settings drawer included, so
* Configure is as far as a user without it gets.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Authz',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
const READ = 'read:dashboard';
const UPDATE = 'update:dashboard';
const DELETE = 'delete:dashboard';
const CREATE = 'create:dashboard';
/** The page refuses to render: `read` is the one check it mounts on. */
export const NoRead: Story = {
args: { revoked: [READ] },
play: async () => {
await screen.findByText('Uh-oh! You are not authorized', undefined, {
timeout: 10000,
});
},
};
/**
* The panels still render, and Configure and New Panel stay in the toolbar,
* disabled.
*/
export const NoUpdate: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
await expectDisabled(await toolbarButton(canvasElement, 'New Panel'));
},
};
/** The same toolbar with its tooltips open, each naming what is missing. */
export const TooltipsWithoutUpdate: Story = {
args: { revoked: [UPDATE], tooltipsOpen: true },
play: NoUpdate.play,
};
/**
* A locked dashboard the user could not have edited anyway: the tooltips name
* the permission rather than the lock.
*/
export const TooltipsWithoutUpdateWhenLocked: Story = {
args: { revoked: [UPDATE], locked: true, tooltipsOpen: true },
play: NoUpdate.play,
};
/** The Actions menu: rename, lock and new section locked; clone and delete open. */
export const NoUpdateActionsMenu: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Rename'));
await expectDisabled(menuItem('Lock dashboard'));
await expectDisabled(menuItem('New section'));
await expectDisabled(menuItem('Clone dashboard'), false);
await expectDisabled(menuItem('Delete dashboard'), false);
},
};
/** A panel's menu: view and download stay, every edit is locked. */
export const NoUpdatePanelMenu: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'panel-actions-p99-latency',
{},
{ timeout: 10000 },
),
);
const menu = await screen.findByRole('menu');
await expectDisabled(
within(menu).getByRole('menuitem', { name: /Edit panel/ }),
);
await expectDisabled(
within(menu).getByRole('menuitem', { name: /Delete panel/ }),
);
},
};
/** The JSON editor opens read-only, and Apply says why it is locked. */
export const NoUpdateJsonEditor: Story = {
args: { revoked: [UPDATE], tooltipsOpen: true },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'edit-json',
{},
{ timeout: 10000 },
),
);
await expectDisabled(await screen.findByTestId('json-editor-apply'));
},
};
/** A blank dashboard whose add-panel prompts are locked. */
export const NoUpdateEmpty: Story = {
args: { revoked: [UPDATE], panels: 0, sectioned: false },
play: async () => {
await expectDisabled(
await screen.findByTestId('add-panel', {}, { timeout: 10000 }),
);
},
};
/** Delete dashboard locked in the Actions menu; every edit still works. */
export const NoDelete: Story = {
args: { revoked: [DELETE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Delete dashboard'));
await expectDisabled(menuItem('Rename'), false);
},
};
/** Cloning is a create, so the copy is locked while the original stays editable. */
export const NoCreate: Story = {
args: { revoked: [CREATE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Clone dashboard'));
await expectDisabled(menuItem('Rename'), false);
},
};
/** Everything but reading: the Actions menu with only full screen left. */
export const ReadOnly: Story = {
args: { revoked: [UPDATE, DELETE, CREATE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Clone dashboard'));
await expectDisabled(menuItem('Delete dashboard'));
await expectDisabled(menuItem('Full screen'), false);
},
};
/**
* The page holds on its spinner until the permission check answers. The
* spinner's `Loading dashboard...` tip never shows: antd only renders a tip
* around nested content.
*/
export const CheckLoading: Story = {
args: { authzState: 'loading' },
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(canvasElement.querySelector('.ant-spin')).not.toBeNull(),
);
},
};
/**
* The permission check failing rather than denying: the page renders, and
* every edit stays locked as if `update` were denied.
*/
export const CheckFailed: Story = {
args: { authzState: 'error' },
// The mocked check intentionally fails; the resulting console error is the
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
},
};

View File

@@ -0,0 +1,129 @@
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 { expect, screen, userEvent, waitFor } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
cyclicVariablesDashboardHandler,
dashboardMocks,
findPreviewValue,
openVariableEditor,
openVariableSelect,
} from '../DashboardPage.stories.mocks';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The variable editor on each of the dashboard's variables, reached from a
* row's edit action in the Variables tab: what every type saved, the values it
* resolves to, and the query failures and dependency loops it reports.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Edit Variable',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/**
* `$environment`: a comma-separated list with one value by default. The preview
* stays empty until the options are edited.
*/
export const Custom: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'environment');
await screen.findByDisplayValue('production,staging,development');
},
};
/**
* `$service`: a ClickHouse query that reads `$environment`, run once on open so
* the preview and default value have its values.
*/
export const Query: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'service');
await findPreviewValue('inventory');
},
};
/** `$service` with its query failing, which the preview reports in its place. */
export const QueryFailed: Story = {
args: { variableQueryFails: true },
// The failed variable query is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'service');
await screen.findByText(
'Please make sure query is valid and dependent variables are selected',
undefined,
{ timeout: 10000 },
);
},
};
/** The sort picker, open over the previewed values it reorders. */
export const SortOpen: Story = {
play: async (context) => {
await Query.play?.(context);
await openVariableSelect('variable-sort-select');
await screen.findByTitle('Alphabetical (descending)');
},
};
/**
* `$namespace`: a dynamic variable reading `k8s.namespace.name` from metrics,
* with the panels it could be applied to.
*/
export const Dynamic: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'namespace');
await findPreviewValue('payments-prod');
},
};
/** `$owner`: a Textbox variable with its default value. */
export const Textbox: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'owner');
await waitFor(() =>
expect(screen.getByTestId('variable-text-input')).toHaveValue(
'platform-team',
),
);
},
};
/**
* `$environment` and `$service` reading each other, as an imported dashboard
* can arrive: saving either one is refused and names the loop.
*
* The document is answered by the story, so the Panels, Sections, Variables and
* Locked controls do not reach it.
*/
export const CircularDependency: Story = {
parameters: { msw: { handlers: [cyclicVariablesDashboardHandler] } },
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'service');
await userEvent.click(screen.getByTestId('variable-save'));
await screen.findByText(/circular dependency detected/);
},
};

View File

@@ -0,0 +1,184 @@
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 } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardMocks,
findPreviewValue,
openNewVariable,
openVariableSelect,
pickVariableType,
typeVariableName,
} from '../DashboardPage.stories.mocks';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The variable editor on a new variable, reached from Add variable in the
* Variables tab: each type's own fields, the pickers they open, and the name
* and attribute checks that keep Save disabled.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/New Variable',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/** The editor as it opens, on the Dynamic type with no field picked. */
export const Default: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
},
};
/** The dynamic variable's field picker, open on the attributes it can read. */
export const FieldPickerOpen: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await openVariableSelect('variable-field-select');
await screen.findByRole(
'option',
{ name: 'k8s.cluster.name' },
{ timeout: 10000 },
);
},
};
/** The telemetry source the field is read from, open. */
export const SignalPickerOpen: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await openVariableSelect('variable-signal-select');
await screen.findByTitle('Traces');
},
};
/**
* A field picked: the name follows it until typed over, and the values it
* resolves to fill the preview and the default value.
*/
export const DynamicFieldPicked: Story = {
play: async (context) => {
await FieldPickerOpen.play?.(context);
await userEvent.click(
screen.getByRole('option', { name: 'k8s.cluster.name' }),
);
await findPreviewValue('prod-us-east-1');
},
};
/** A field another dynamic variable already reads, which Save refuses. */
export const AttributeTaken: Story = {
play: async (context) => {
await FieldPickerOpen.play?.(context);
await userEvent.click(
screen.getByRole('option', { name: 'k8s.namespace.name' }),
);
await screen.findByText('A variable with this attribute key already exists');
},
};
/** The panels the new filter can be added to, open. */
export const ApplyToPanelsOpen: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await openVariableSelect('variable-apply-panels');
await screen.findByRole('listbox');
},
};
/** A name typed and cleared again. */
export const NameRequired: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await userEvent.type(screen.getByTestId('variable-name'), 'x{Backspace}');
await screen.findByText('Variable name is required');
},
};
/** A name another variable on the dashboard has. */
export const NameTaken: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('environment');
await screen.findByText('Variable name already exists');
},
};
/** A name with a space in it, which a `$name` reference could not spell. */
export const NameWithSpaces: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('build id');
await screen.findByText('Variable name cannot contain whitespaces');
},
};
/** A Textbox variable: a free-text value with an optional default. */
export const Textbox: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('region');
await pickVariableType('textbox');
await userEvent.type(
await screen.findByTestId('variable-text-input'),
'us-east-1',
);
},
};
/**
* A Custom variable with its options typed in, previewed as they are parsed,
* and multiple values on, which adds the ALL option toggle.
*/
export const Custom: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('region');
await pickVariableType('custom');
await userEvent.type(
await screen.findByTestId('variable-custom-input'),
'us-east-1,eu-west-1,ap-south-1',
);
await userEvent.click(screen.getByTestId('variable-multi-switch'));
await screen.findByText('Include an option for ALL values');
},
};
/** The Custom variable's default value picker, open on the parsed options. */
export const CustomDefaultOpen: Story = {
play: async (context) => {
await Custom.play?.(context);
await openVariableSelect('variable-default-select');
await screen.findByRole('option', { name: 'eu-west-1' });
},
};
/** A Query variable before its query is written: Test Run waits for one. */
export const Query: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await pickVariableType('query');
await screen.findByText('Test Run Query');
},
};

View File

@@ -0,0 +1,181 @@
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 { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardMocks, openSettings } from '../DashboardPage.stories.mocks';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The dashboard's settings drawer on its Overview and Publish tabs: the name,
* icon, description and tags, the cross-panel sync mode, and the public link.
* The Publish tab only exists on cloud and enterprise licenses.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Settings',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/** The drawer as Configure opens it, on the Overview tab. */
export const Overview: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement);
await screen.findByText('Sync Mode');
},
};
/** Two fields edited, so the footer counts them and offers to save or discard. */
export const OverviewUnsavedChanges: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement);
await userEvent.type(await screen.findByTestId('dashboard-name'), ' (v2)');
await userEvent.type(
screen.getByTestId('dashboard-desc'),
' Owned by the platform team.',
);
await screen.findByText('2 unsaved changes');
},
};
/** The icon picker beside the name, open on the system icons. */
export const OverviewIconPicker: Story = {
play: async ({ canvasElement }) => {
const panel = await openSettings(canvasElement);
await userEvent.click(within(panel).getAllByRole('combobox')[0]);
await screen.findByRole('listbox');
},
};
/**
* Sync set to Tooltip, which adds the choice between every series and only the
* ones that share the group-by.
*/
export const OverviewTooltipSync: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement);
await userEvent.click(await screen.findByRole('radio', { name: 'Tooltip' }));
await screen.findByText('Synced Tooltip Series');
},
};
/**
* The Overview tab's own tooltip: Cross-Panel Sync explains what syncing does
* and links out to the docs.
*/
export const TooltipsInOverview: Story = {
args: { tooltipsOpen: true },
play: Overview.play,
};
/** The Publish tab of a dashboard with a public link. */
export const Publish: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Publish');
await screen.findByText('Default time range');
},
};
/** The Publish tab with its default time range select open. */
export const PublishTimeRangeOpen: Story = {
play: async (context) => {
await Publish.play?.(context);
await userEvent.click(
within(screen.getByRole('tabpanel')).getByRole('combobox'),
);
await screen.findByRole('listbox');
},
};
/** The Publish tab of a dashboard nobody published: no link, one Publish button. */
export const PublishPrivate: Story = {
args: { published: false },
// The 404 an unpublished dashboard answers with is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Publish');
await screen.findByText('This dashboard is private');
},
};
/** The name edited and saved: the toast announcing the update, raised once the patch answers. */
export const OverviewSavedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement);
await userEvent.type(await screen.findByTestId('dashboard-name'), ' (v2)');
await userEvent.click(await screen.findByTestId('save-dashboard-config'));
if (args.dashboardPatch === 'success') {
await screen.findByText('Dashboard updated');
}
},
};
/** Publish Dashboard on a private dashboard: the toast announcing the new link. */
export const PublishedToast: Story = {
args: { published: false },
// The 404 an unpublished dashboard answers with is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Publish');
await userEvent.click(await screen.findByTestId('public-dashboard-publish'));
if (args.publicWrite === 'success') {
await screen.findByText('Dashboard published successfully');
}
},
};
/** Update Dashboard on a published dashboard: the toast confirming the new settings. */
export const PublicUpdatedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Publish');
await userEvent.click(await screen.findByTestId('public-dashboard-update'));
if (args.publicWrite === 'success') {
await screen.findByText('Public dashboard updated successfully');
}
},
};
/** Unpublish Dashboard: the toast confirming the link is gone. */
export const UnpublishedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Publish');
await userEvent.click(
await screen.findByTestId('public-dashboard-unpublish'),
);
if (args.publicWrite === 'success') {
await screen.findByText('Dashboard unpublished successfully');
}
},
};
/** The copy button beside the public link: the toast confirming the copy. */
export const PublicUrlCopiedToast: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Publish');
await userEvent.click(
await screen.findByRole('button', { name: 'Copy link' }),
);
await screen.findByText('Copied public dashboard URL successfully');
},
};

View File

@@ -0,0 +1,220 @@
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 } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
clickRowAction,
confirmImpact,
dashboardMocks,
openSettings,
openVariableEditor,
typeVariableName,
variableRow,
} from '../DashboardPage.stories.mocks';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The Variables tab of dashboard settings: the variables in the order the bar
* shows them, and what deleting, renaming or applying one does to the panels
* that read it. Every panel filters on `$environment`, and `$service` reads it
* too; nothing reads `$namespace` or `$owner`.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Variables',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/** One variable of each type, listed by name and description. */
export const Default: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await variableRow('owner');
},
};
/** A dashboard without variables, where the tab is its add-variable prompt. */
export const Empty: Story = {
args: { variables: [] },
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await screen.findByText('No variables yet');
},
};
/**
* A dynamic variable's Apply to all says whether it is already a filter on
* every panel. The row keeps its actions invisible until it is hovered, which
* the story does first.
*/
export const TooltipsInVariables: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
// The tooltip trigger's Slot merge drops the button's own test id.
await userEvent.hover(
await screen.findByRole(
'button',
{ name: 'Apply to all' },
{ timeout: 10000 },
),
);
await screen.findByText(
'Add this variable as a filter to every panel',
undefined,
{ timeout: 10000 },
);
},
};
/** Deleting a variable nothing reads, which the row confirms inline. */
export const DeleteConfirm: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('owner', 'delete');
await screen.findByText('Delete?');
},
};
/**
* Deleting `$environment`, which the panels and `$service` read, so each usage
* is listed for review before anything is removed.
*/
export const DeleteReferenced: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('environment', 'delete');
await screen.findByText('Delete $environment');
},
};
/** Apply to all on `$namespace`: the filter it would add to every panel query. */
export const ApplyToAll: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('namespace', 'apply-all');
await screen.findByText('Apply $namespace to panels');
},
};
/**
* Renaming `$environment` to `$env`: saving the editor holds the rename until
* the rewritten queries are reviewed.
*/
export const RenameReferenced: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'environment');
await typeVariableName('env');
await userEvent.click(screen.getByTestId('variable-save'));
await screen.findByText('Rename $environment');
},
};
/** Deleting a variable nothing reads, confirmed: the toast raised once the patch answers. */
export const VariablesUpdatedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('owner', 'delete');
await userEvent.click(
await screen.findByRole('button', { name: 'Confirm delete' }),
);
if (args.dashboardPatch === 'success') {
await screen.findByText('Variables updated');
}
},
};
/** Deleting `$environment` after reviewing its usages: the toast raised once the patch answers. */
export const VariableDeletedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('environment', 'delete');
await screen.findByText('Delete $environment');
await confirmImpact();
if (args.dashboardPatch === 'success') {
await screen.findByText('Deleted $environment');
}
},
};
/** Deleting `$environment` whose patch is refused. */
export const VariableDeleteFailedToast: Story = {
args: { dashboardPatch: 'error' },
// The refused patch is the state under test.
parameters: { allowConsoleErrors: true },
play: async (context) => {
await VariableDeletedToast.play?.(context);
await screen.findByText('Could not delete the variable');
},
};
/** Renaming `$environment` to `$env` after reviewing the rewritten queries. */
export const VariableRenamedToast: Story = {
play: async ({ canvasElement, args }) => {
await openVariableEditor(canvasElement, 'environment');
await typeVariableName('env');
await userEvent.click(screen.getByTestId('variable-save'));
await screen.findByText('Rename $environment');
await confirmImpact();
if (args.dashboardPatch === 'success') {
await screen.findByText('Renamed to $env');
}
},
};
/** Renaming `$environment` whose patch is refused. */
export const VariableRenameFailedToast: Story = {
args: { dashboardPatch: 'error' },
// The refused patch is the state under test.
parameters: { allowConsoleErrors: true },
play: async (context) => {
await VariableRenamedToast.play?.(context);
await screen.findByText('Could not rename the variable');
},
};
/** Apply to all on `$namespace`, confirmed after the review. */
export const VariableAppliedToast: Story = {
play: async ({ canvasElement, args }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('namespace', 'apply-all');
await screen.findByText('Apply $namespace to panels');
await confirmImpact();
if (args.dashboardPatch === 'success') {
await screen.findByText('Applied $namespace to panels');
}
},
};
/** Apply to all on `$namespace` whose patch is refused. */
export const VariableApplyFailedToast: Story = {
args: { dashboardPatch: 'error' },
// The refused patch is the state under test.
parameters: { allowConsoleErrors: true },
play: async (context) => {
await VariableAppliedToast.play?.(context);
await screen.findByText('Could not apply the variable to panels');
},
};

View File

@@ -3,9 +3,23 @@
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import {
rest,
type ResponseResolver,
type RestContext,
type RestRequest,
} from 'msw';
import logEvent from 'api/common/logEvent';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import {
expect,
mocked,
screen,
userEvent,
waitFor,
within,
} from 'storybook/test';
import {
choiceControl,
@@ -21,14 +35,20 @@ import {
dashboardViewsResponse,
dashboardsListResponse,
orgUsersResponse,
PIN_WRITES,
recentDashboardIds,
ROW_MARKERS,
savedView,
seedPinnedDashboards,
setDashboardPinned,
STORY_USER_EMAIL,
TEMPLATE_REQUESTS,
TOOLTIP_TAGS,
WRITE_STATES,
type PinWrite,
type RowMarker,
type TemplateRequest,
type WriteState,
} from './__story_mockdata__/dashboardsList';
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
import {
@@ -40,6 +60,7 @@ import { builtinViewQuery } from '../utils/views';
const LIST = 'Dashboards · list';
const VIEWS = 'Dashboards · views';
const WRITES = 'Dashboards · writes';
const VIEWS_OPTIONS = [
BuiltinViewId.All,
@@ -71,6 +92,42 @@ const writtenDashboard = (): GetDashboardV2200 =>
const ok = { status: 'success', data: null };
const serverError = {
status: 'error',
error: {
code: 'internal',
message: 'Something went wrong',
url: '',
errors: [],
},
};
const pinLimitError = {
status: 'error',
error: {
code: 'already_exists',
message: 'Pin limit reached',
url: '',
errors: [],
},
};
/** Answers a write per its control: the `success` resolver, never, or a 500. */
const gated =
(
state: WriteState,
success: ResponseResolver<RestRequest, RestContext>,
): ResponseResolver<RestRequest, RestContext> =>
(req, res, ctx) => {
if (state === 'loading') {
return res(ctx.delay('infinite'));
}
return state === 'error'
? res(ctx.status(500), ctx.json(serverError))
: success(req, res, ctx);
};
/**
* `formatQueryErrorMessage` strips the `invalid filter query:` prefix and turns
* the backticks into quotes, so the message carries both to show it doing it.
@@ -174,6 +231,48 @@ export const dashboardsListMocks = defineStoryMocks({
'Answers the list with a 400 and a parse error, which is the Invalid query state: the backend message replaces the generic one and Retry is gone.',
value: false,
}),
rowWrite: choiceControl<WriteState>('Row action', {
group: WRITES,
description:
'How rename, tags, duplicate, lock and delete answer. `success` raises their toast, `loading` never answers, `error` opens the error modal instead.',
options: WRITE_STATES,
value: 'success',
}),
pinWrite: choiceControl<PinWrite>('Pin', {
group: WRITES,
description:
'How pinning answers. `limit` refuses the pin with a 409 and raises the 10-pin toast, `error` fails both pin and unpin with a toast.',
options: PIN_WRITES,
value: 'success',
}),
createWrite: choiceControl<WriteState>('Create dashboard', {
group: WRITES,
description:
'How the POST behind Create and Import answers. `error` raises a failure toast beside the error modal.',
options: WRITE_STATES,
value: 'success',
}),
migrationWrite: choiceControl<WriteState>('Retry migration', {
group: WRITES,
description:
'How the legacy dashboard migration answers. `success` raises the migrated toast, `error` the failure toast.',
options: WRITE_STATES,
value: 'success',
}),
viewWrite: choiceControl<WriteState>('Saved view writes', {
group: WRITES,
description:
'How saving, renaming and deleting a saved view answer. `error` raises the failure toast.',
options: WRITE_STATES,
value: 'success',
}),
templateRequest: choiceControl<TemplateRequest>('Template request', {
group: WRITES,
description:
'How the analytics event behind "Request a new template" answers: `error` returns a failed response, `rejected` throws.',
options: TEMPLATE_REQUESTS,
value: 'success',
}),
view: choiceControl<ViewOption>('Active view', {
group: VIEWS,
description:
@@ -237,6 +336,12 @@ export const dashboardsListMocks = defineStoryMocks({
rest.put(
'http://localhost/api/v2/users/me/dashboards/:id/pins',
(req, res, ctx) => {
if (values.pinWrite !== 'success') {
return values.pinWrite === 'limit'
? res(ctx.status(409), ctx.json(pinLimitError))
: res(ctx.status(500), ctx.json(serverError));
}
setDashboardPinned(String(req.params.id), true);
return res(ctx.status(200), ctx.json(ok));
@@ -246,44 +351,75 @@ export const dashboardsListMocks = defineStoryMocks({
rest.delete(
'http://localhost/api/v2/users/me/dashboards/:id/pins',
(req, res, ctx) => {
if (values.pinWrite === 'error') {
return res(ctx.status(500), ctx.json(serverError));
}
setDashboardPinned(String(req.params.id), false);
return res(ctx.status(200), ctx.json(ok));
},
),
rest.post('http://localhost/api/v2/dashboards', (_req, res, ctx) =>
res(ctx.status(201), ctx.json(writtenDashboard())),
rest.post(
'http://localhost/api/v2/dashboards',
gated(values.createWrite, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(writtenDashboard())),
),
),
rest.put('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
rest.put(
'http://localhost/api/v2/dashboards/:id',
gated(values.rowWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
),
),
rest.post('http://localhost/api/v2/dashboards/:id/clone', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
rest.patch(
'http://localhost/api/v2/dashboards/:id',
gated(values.rowWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
),
),
rest.post(
'http://localhost/api/v2/dashboards/:id/clone',
gated(values.rowWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
),
),
rest.post(
'http://localhost/api/v2/dashboards/:id/migrate',
(_req, res, ctx) => res(ctx.status(200), ctx.json(writtenDashboard())),
gated(values.migrationWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(writtenDashboard())),
),
),
rest.delete('http://localhost/api/v2/dashboards/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
rest.delete(
'http://localhost/api/v2/dashboards/:id',
gated(values.rowWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
),
rest.put('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
rest.put(
'http://localhost/api/v2/dashboards/:id/lock',
gated(values.rowWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
),
rest.delete('http://localhost/api/v2/dashboards/:id/lock', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
rest.delete(
'http://localhost/api/v2/dashboards/:id/lock',
gated(values.rowWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
),
rest.post(
'http://localhost/api/v2/dashboard_views',
async (req, res, ctx) => {
gated(values.viewWrite, async (req, res, ctx) => {
const body = (await req.json()) as { name: string };
return res(
@@ -298,12 +434,12 @@ export const dashboardsListMocks = defineStoryMocks({
},
}),
);
},
}),
),
rest.put(
'http://localhost/api/v2/dashboard_views/:id',
async (req, res, ctx) => {
gated(values.viewWrite, async (req, res, ctx) => {
const body = (await req.json()) as { name: string; data: unknown };
return res(
@@ -318,15 +454,37 @@ export const dashboardsListMocks = defineStoryMocks({
},
}),
);
},
}),
),
rest.delete('http://localhost/api/v2/dashboard_views/:id', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
rest.delete(
'http://localhost/api/v2/dashboard_views/:id',
gated(values.viewWrite, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(ok)),
),
),
],
config: (values) => ({ route: listRoute(values.view) }),
effect: (values) => {
mocked(logEvent).mockImplementation(async () => {
if (values.templateRequest === 'rejected') {
throw new Error('Event rejected');
}
return values.templateRequest === 'error'
? {
statusCode: 500,
payload: null,
error: 'Event rejected',
message: null,
}
: {
statusCode: 200,
error: null,
message: 'success',
payload: { status: 'success', data: '' },
};
});
seedPinnedDashboards(
values.markers.includes('pinned')
? Array.from({ length: PINNED_COUNT }, (_unused, index) =>
@@ -342,3 +500,105 @@ 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 },
);
};
/** Picks a row action once its permission check has enabled it. */
export const clickRowAction = async (testId: string): Promise<void> => {
const item = await screen.findByTestId(testId);
await waitFor(
async () => {
await expect(item).toBeEnabled();
await expect(item).not.toHaveAttribute('data-disabled');
await expect(item).not.toHaveAttribute('aria-disabled', 'true');
},
{ timeout: 10000 },
);
await userEvent.click(item);
};
export const TOAST_TIMEOUT = 10000;
export const openLegacyDialog = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-title-4',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByTestId('legacy-dashboard-id');
};
export const retryMigration = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openLegacyDialog(canvasElement);
const retry = await screen.findByTestId('legacy-dashboard-retry-migration');
await waitFor(() => expect(retry).toBeEnabled(), { timeout: TOAST_TIMEOUT });
await userEvent.click(retry);
};
export const uploadDashboardJson = async (json: object): Promise<void> => {
await userEvent.click(await screen.findByText('Import JSON'));
const dialog = await screen.findByRole('dialog');
const input = dialog.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) {
throw new Error('The import dialog has no file input');
}
await userEvent.upload(
input,
new File([JSON.stringify(json)], 'dashboard.json', {
type: 'application/json',
}),
);
await userEvent.click(await screen.findByTestId('import-json-submit'));
};
export const requestTemplate = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await userEvent.click(await screen.findByText('From a template'));
await userEvent.type(
await screen.findByTestId('request-dashboard-name'),
'Redis overview',
);
await userEvent.click(await screen.findByTestId('request-dashboard-submit'));
};

View File

@@ -5,8 +5,16 @@ import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
clickRowAction,
dashboardsListMocks,
openLegacyDialog,
openRowActions,
overflowingRows,
pickRowAction,
requestTemplate,
retryMigration,
TOAST_TIMEOUT,
uploadDashboardJson,
} from './DashboardsListPage.stories.mocks';
import { BuiltinViewId } from '../types';
@@ -114,6 +122,415 @@ 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 renamed toast, raised once the PATCH answers. */
export const DashboardRenamedToast: Story = {
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
const input = await screen.findByTestId('rename-dashboard-input');
await userEvent.clear(input);
await userEvent.type(input, 'API latency overview');
await userEvent.click(await screen.findByTestId('rename-dashboard-submit'));
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard renamed', {}, { timeout: TOAST_TIMEOUT });
}
},
};
/** The tags toast, raised once the PATCH answers. */
export const TagsUpdatedToast: Story = {
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
const input = await screen.findByPlaceholderText('key:value (press Enter)');
await userEvent.type(input, 'owner:sre{Enter}');
await userEvent.click(await screen.findByTestId('edit-tags-submit'));
if (args.rowWrite === 'success') {
await screen.findByText('Tags updated', {}, { timeout: TOAST_TIMEOUT });
}
},
};
/** Duplicate from the row menu: the toast names the source dashboard. */
export const DashboardDuplicatedToast: Story = {
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-duplicate');
if (args.rowWrite === 'success') {
await screen.findByText(
'Duplicated "API latency and errors"',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Lock from the row menu of an unlocked dashboard. */
export const DashboardLockedToast: Story = {
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText('Dashboard locked', {}, { timeout: TOAST_TIMEOUT });
}
},
};
/** Unlock from the row menu of the first row, which is locked. */
export const DashboardUnlockedToast: Story = {
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 0);
await clickRowAction('dashboard-action-lock');
if (args.rowWrite === 'success') {
await screen.findByText(
'Dashboard unlocked',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Delete confirmed in the dialog the row menu opens. */
export const DashboardDeletedToast: Story = {
play: async ({ canvasElement, args }) => {
await openRowActions(canvasElement, 1);
await clickRowAction('dashboard-action-delete');
const dialog = await screen.findByRole('dialog');
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
if (args.rowWrite === 'success') {
await screen.findByText(
'Dashboard deleted successfully',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Pinning past the limit: the backend refuses with a 409. */
export const PinLimitToast: Story = {
args: { pinWrite: 'limit' },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
/You can pin up to 10 dashboards/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** A pin the backend failed to write. */
export const PinFailedToast: Story = {
args: { pinWrite: 'error' },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-3',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to pin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** An unpin the backend failed to write, from the first (pinned) row. */
export const UnpinFailedToast: Story = {
args: { pinWrite: 'error' },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'dashboard-pin-0',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await screen.findByText(
'Failed to unpin dashboard.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** The id copied from the legacy dashboard dialog. */
export const LegacyDashboardIdCopiedToast: Story = {
play: async ({ canvasElement }) => {
await openLegacyDialog(canvasElement);
await userEvent.click(await screen.findByTestId('legacy-dashboard-copy-id'));
await screen.findByText(
'Dashboard ID copied',
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** Retry migration of the legacy dashboard succeeding. */
export const DashboardMigratedToast: Story = {
play: async ({ canvasElement, args }) => {
await retryMigration(canvasElement);
if (args.migrationWrite === 'success') {
await screen.findByText(
'Dashboard migrated to the new experience',
{},
{ timeout: TOAST_TIMEOUT },
);
}
},
};
/** Retry migration of the legacy dashboard refused by the backend. */
export const DashboardMigrationFailedToast: Story = {
args: { migrationWrite: 'error' },
play: async ({ canvasElement }) => {
await retryMigration(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** The Blank tab's create failing: the toast raised beside the error modal. */
export const CreateDashboardFailedToast: Story = {
args: { createWrite: 'error' },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await userEvent.click(await screen.findByTestId('create-dashboard-submit'));
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
const IMPORTED_SPEC = { layouts: [], panels: {}, variables: [] };
/** A dashboard file whose `image` is neither an asset path nor base64. */
export const ImportJsonInvalidImageToast: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await uploadDashboardJson({
schemaVersion: 'v6',
image: 'https://example.com/logo.png',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findByText(
/Dashboard "image" must be/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** A valid dashboard file the create endpoint refuses. */
export const ImportJsonCreateFailedToast: Story = {
args: { createWrite: 'error' },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'new-dashboard-cta',
{},
{ timeout: TOAST_TIMEOUT },
),
);
await uploadDashboardJson({
schemaVersion: 'v6',
spec: { display: { name: 'Imported' }, ...IMPORTED_SPEC },
});
await screen.findAllByText(
/Something went wrong/,
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** A template request the analytics endpoint accepted. */
export const TemplateRequestSubmittedToast: Story = {
play: async ({ canvasElement }) => {
await requestTemplate(canvasElement);
await screen.findByText(
'Dashboard request submitted',
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** A template request the analytics endpoint answered with a failure. */
export const TemplateRequestFailedToast: Story = {
args: { templateRequest: 'error' },
play: async ({ canvasElement }) => {
await requestTemplate(canvasElement);
await screen.findByText('Event rejected', {}, { timeout: TOAST_TIMEOUT });
},
};
/** A template request that threw before an answer came back. */
export const TemplateRequestRejectedToast: Story = {
args: { templateRequest: 'rejected' },
play: async ({ canvasElement }) => {
await requestTemplate(canvasElement);
await screen.findByText(
'Something went wrong',
{},
{ timeout: TOAST_TIMEOUT },
);
},
};
/** Saving the current filters as a view, refused by the backend. */
export const SaveViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: TOAST_TIMEOUT },
),
);
await userEvent.type(await screen.findByTestId('save-view-name'), 'On-call');
await userEvent.click(await screen.findByTestId('save-view-confirm'));
await screen.findByText(
'Failed to save view.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** Renaming a saved view, refused by the backend. */
export const RenameViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: async ({ canvasElement }) => {
const [rename] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Rename view' },
{ timeout: TOAST_TIMEOUT },
);
// The actions only take pointer events while the row is hovered by CSS.
await userEvent.click(rename, { pointerEventsCheck: 0 });
const input = await screen.findByTestId('rename-view-name');
await userEvent.clear(input);
await userEvent.type(input, 'Renamed view');
await userEvent.click(await screen.findByTestId('rename-view-confirm'));
await screen.findByText(
'Failed to update view.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/** Deleting a saved view, refused by the backend. */
export const DeleteViewFailedToast: Story = {
args: { viewWrite: 'error' },
play: async ({ canvasElement }) => {
const [remove] = await within(canvasElement).findAllByRole(
'button',
{ name: 'Delete view' },
{ timeout: TOAST_TIMEOUT },
);
await userEvent.click(remove, { pointerEventsCheck: 0 });
const dialog = await screen.findByRole('dialog');
await userEvent.click(
await within(dialog).findByRole('button', { name: 'Delete' }),
);
await screen.findByText(
'Failed to delete view.',
{},
{ timeout: TOAST_TIMEOUT },
);
},
parameters: { allowConsoleErrors: true },
};
/**
* The query the backend refused: the parse error it returned replaces the
* generic failure copy, and there is nothing to retry.

View File

@@ -346,3 +346,15 @@ export const recentDashboardIds = (count: number): string[] =>
{ length: count },
(_, index) => `storybook-dashboard-${index + 4}`,
);
export const WRITE_STATES = ['success', 'loading', 'error'] as const;
export type WriteState = (typeof WRITE_STATES)[number];
export const PIN_WRITES = ['success', 'limit', 'error'] as const;
export type PinWrite = (typeof PIN_WRITES)[number];
export const TEMPLATE_REQUESTS = ['success', 'error', 'rejected'] as const;
export type TemplateRequest = (typeof TEMPLATE_REQUESTS)[number];

View File

@@ -5,6 +5,7 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -120,3 +121,36 @@ export const editRulesMocks = defineStoryMocks({
route: `${ROUTES.EDIT_ALERTS}?ruleId=${STORY_RULE_ID}&relativeTime=6h`,
}),
});
/** The page fetches the rule before it renders the form, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LABEL_INPUT = 'alert-labels-input-v1';
/** The labels field, scrolled into view once the rule has loaded. */
export const findLabelInput = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const input = await within(canvasElement).findByTestId(
LABEL_INPUT,
undefined,
untilLoaded,
);
input.scrollIntoView({ block: 'center' });
return input;
};
export const removeLabel = async (
canvasElement: HTMLElement,
label: string,
): Promise<void> => {
const chip = within(canvasElement)
.getByText(label)
.closest<HTMLElement>('[data-slot="badge"]');
if (!chip) {
throw new Error(`Label ${label} did not render`);
}
await userEvent.click(within(chip).getByRole('button'));
};

View File

@@ -1,9 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { editRulesMocks } from './EditRules.stories.mocks';
import {
editRulesMocks,
findLabelInput,
removeLabel,
} from './EditRules.stories.mocks';
import EditRules from '../index';
@@ -19,6 +24,7 @@ const pageStory = storyMocks(editRulesMocks, { layout: 'app' });
*/
const meta = {
title: 'Pages/Alerts/Edit',
tags: ['play'],
component: EditRules,
...pageStory,
parameters: { ...pageStory.parameters },
@@ -53,3 +59,79 @@ export const RuleNotFound: Story = {
export const Tooltips: Story = {
args: { tooltipsOpen: true, previewSeries: 6 },
};
/** The rule's own labels, each with its remove button, above the empty input. */
export const Labels: Story = {
play: async ({ canvasElement }): Promise<void> => {
await findLabelInput(canvasElement);
const canvas = within(canvasElement);
await canvas.findByText('team: platform');
await canvas.findByText('env: prod');
},
};
/** A key typed and confirmed: it waits as its own chip while the input asks for its value. */
export const LabelKeyEntered: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await userEvent.type(input, 'region{enter}');
await within(canvasElement).findByPlaceholderText(
'Enter a value for label key(region) then press ENTER.',
);
},
};
/** A key and its value confirmed, added after the rule's own labels. */
export const LabelAdded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await userEvent.type(input, 'region{enter}');
await userEvent.type(input, 'us-east-1{enter}');
await within(canvasElement).findByText('region: us-east-1');
},
};
/** One of the rule's labels removed, the other left in place. */
export const LabelRemoved: Story = {
play: async ({ canvasElement }): Promise<void> => {
await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
await removeLabel(canvasElement, 'team: platform');
await waitFor(() =>
expect(within(canvasElement).queryByText('team: platform')).toBeNull(),
);
await expect(within(canvasElement).getByText('env: prod')).toBeVisible();
},
};
/** Every label removed: the input is back to its prompt and the clear-all button is gone. */
export const NoLabels: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
await removeLabel(canvasElement, 'team: platform');
await removeLabel(canvasElement, 'env: prod');
await within(canvasElement).findByPlaceholderText(
'Click here to enter a label (key value pairs)',
);
await expect(input.parentElement?.querySelector('button')).toBeNull();
},
};
/** The confirmation the clear-all button opens before it drops every label. */
export const ClearLabelsConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
const clearAll = input.parentElement?.querySelector('button');
if (!clearAll) {
throw new Error('Clear-all labels button did not render');
}
await userEvent.click(clearAll);
await screen.findByText(
'This action will remove all the labels. Do you want to proceed?',
);
},
};

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,43 @@ 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 });
},
};
/** The copy button beside the pod name in the drawer, which toasts the copy. */
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
await userEvent.click(
await screen.findByTestId('copy-id-button', {}, { timeout: 10000 }),
);
await screen.findByText('Pod name copied to clipboard');
},
};
/** A metadata value's copy button in the drawer, which toasts the label it copied. */
export const PodMetadataCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
await userEvent.click(
await screen.findByTestId('copy-metadata-namespace', {}, { timeout: 10000 }),
);
await screen.findByText('NAMESPACE copied to clipboard');
},
};
/**
* 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 +128,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

@@ -5,6 +5,7 @@
import { rest } from 'msw';
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -17,6 +18,8 @@ import {
CLOUD_PROVIDERS,
CLOUD_SERVICE_CAP,
type CloudProvider,
MUTATION_STATES,
type MutationState,
createdAccountResponse,
BUILT_IN_INTEGRATION_NOT_FOUND,
credentialsResponse,
@@ -33,7 +36,30 @@ import {
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
/**
* Answers a mutation by the control's state: never, with a failure, or with the
* success body the caller builds.
*/
const mutationResolver =
(state: MutationState, ok: MockResolver): MockResolver =>
(req, res, ctx, ...rest) => {
if (state === 'loading') {
return res(ctx.delay('infinite'));
}
return state === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Request failed' },
}),
)
: ok(req, res, ctx, ...rest);
};
const PROVIDER = 'Cloud integration · provider';
const MUTATIONS = 'Cloud integration · mutations';
const SERVICES = 'Cloud integration · services';
export const cloudIntegrationMocks = defineStoryMocks({
@@ -66,6 +92,27 @@ export const cloudIntegrationMocks = defineStoryMocks({
value: 3,
max: CLOUD_SERVICE_CAP,
}),
accountCreate: choiceControl<MutationState>('Account connection', {
group: MUTATIONS,
description:
'How the POST behind the connect flow answers. AWS and Azure raise "Failed to create account connection" on `error` and the "account connected" toast on `success`, GCP raises its toast after the agent check-in and shows `error` inline. `loading` never answers.',
options: MUTATION_STATES,
value: 'success',
}),
accountUpdate: choiceControl<MutationState>('Account settings update', {
group: MUTATIONS,
description:
'How the PUT behind Update Changes in the account settings drawer answers. `success` raises "Account settings updated successfully", `error` raises "Failed to update account settings".',
options: MUTATION_STATES,
value: 'success',
}),
serviceUpdate: choiceControl<MutationState>('Service config update', {
group: MUTATIONS,
description:
'How the PUT behind Save on a service answers. `error` raises "Failed to update service config", `loading` leaves Save spinning.',
options: MUTATION_STATES,
value: 'success',
}),
},
handlers: (values, response) => [
rest.get(
@@ -75,12 +122,12 @@ export const cloudIntegrationMocks = defineStoryMocks({
rest.put(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id/services/:serviceId',
response.json(async (req) => {
mutationResolver(values.serviceUpdate, async (req, res, ctx) => {
const body = (await req.json()) as CloudintegrationtypesUpdatableServiceDTO;
setServiceSignals(String(req.params.serviceId), body.config ?? {});
return { status: 'success', data: null };
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
}),
),
@@ -117,15 +164,26 @@ export const cloudIntegrationMocks = defineStoryMocks({
response.json(() => credentialsResponse()),
),
rest.post(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/check_in',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.post(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
response.json(() => createdAccountResponse(values.provider)),
mutationResolver(values.accountCreate, (_req, res, ctx) =>
res(ctx.status(201), ctx.json(createdAccountResponse(values.provider))),
),
),
rest.put(
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
response.json((req) =>
accountResponse(values.provider, String(req.params.id)),
mutationResolver(values.accountUpdate, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(accountResponse(values.provider, String(req.params.id))),
),
),
),
@@ -148,3 +206,123 @@ export const cloudIntegrationMocks = defineStoryMocks({
seedServiceSignals(values.provider, values.services, values.enabledServices);
},
});
/** The page picks its first service before it can render it. */
export const untilLoaded = { timeout: 20_000 };
export const expectToast = async (text: RegExp): Promise<void> => {
await waitFor(() => expect(screen.getByText(text)).toBeVisible(), {
timeout: 10_000,
});
};
export const openConnectFlow = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/integrate now/i,
undefined,
untilLoaded,
),
);
};
export const connectAwsAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.click(
await screen.findByRole('combobox', undefined, untilLoaded),
);
await userEvent.click(await screen.findByText('US East (N. Virginia)'));
await userEvent.click(
await screen.findByRole('checkbox', { name: /us-east-1/i }),
);
await userEvent.click(
await screen.findByRole('button', { name: /launch cloud formation/i }),
);
};
export const connectAzureAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openConnectFlow(canvasElement);
const [region, resourceGroups] = await screen.findAllByRole(
'combobox',
undefined,
untilLoaded,
);
await userEvent.click(region);
await userEvent.click(
await screen.findByText('Australia East (australiaeast)'),
);
await userEvent.type(resourceGroups, 'prod-platform-rg{Enter}');
await userEvent.click(
await screen.findByRole('button', { name: /generate azure setup commands/i }),
);
};
export const connectGcpAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.type(
await screen.findByTestId('gcp-account-name-input', undefined, untilLoaded),
'acme-org',
);
await userEvent.type(
screen.getByTestId('gcp-deployment-project-id-input'),
'acme-deploy-123',
);
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
await userEvent.click(await screen.findByText('Mumbai (asia-south1)'));
await userEvent.type(
document.querySelector<HTMLElement>('#gcp-project-ids-select') as HTMLElement,
'project-a,',
);
await userEvent.click(screen.getByTestId('gcp-connect-account-btn'));
};
const openEditAccount = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/edit account/i,
undefined,
untilLoaded,
),
);
await screen.findByRole('dialog', undefined, untilLoaded);
};
export const updateAwsAccount = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openEditAccount(canvasElement);
await userEvent.click(
await screen.findByRole('checkbox', { name: /us-east-2/i }),
);
await userEvent.click(
await screen.findByRole('button', { name: /update changes/i }),
);
};
export const updateTaggedAccount = async (
canvasElement: HTMLElement,
tag: string,
): Promise<void> => {
await openEditAccount(canvasElement);
const dialog = await screen.findByRole('dialog');
await userEvent.type(await within(dialog).findByRole('combobox'), tag);
const option = await screen.findByText(tag, {
selector: '.ant-select-item-option-content',
});
// The drawer disables pointer events outside itself, which the portalled popup is.
await userEvent.click(option, { pointerEventsCheck: 0 });
await userEvent.click(
await screen.findByRole('button', { name: /update changes/i }),
);
};

View File

@@ -6,7 +6,17 @@ import ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { cloudIntegrationMocks } from './CloudIntegration.stories.mocks';
import {
cloudIntegrationMocks,
connectAwsAccount,
connectAzureAccount,
connectGcpAccount,
expectToast,
openConnectFlow,
untilLoaded,
updateAwsAccount,
updateTaggedAccount,
} from './CloudIntegration.stories.mocks';
import IntegrationsDetailsPage from '../../index';
@@ -42,9 +52,6 @@ export default meta;
type Story = StoryObj<CloudIntegrationArgs>;
/** The page picks its first service before it can render it. */
const untilLoaded = { timeout: 20_000 };
/**
* An AWS account SigNoz collects from: the services it can watch split by
* whether a signal is switched on, and the first enabled one open on its
@@ -164,3 +171,193 @@ export const TooltipsInDataCollected: Story = {
);
},
};
/**
* Connecting an AWS account: region and monitored regions picked, the template
* launched, and the first poll finds the account connected. Set Account
* connection to `error` for the failure toast.
*/
export const AwsAccountConnectedToast: Story = {
args: { accounts: 0 },
play: async ({ canvasElement, args }): Promise<void> => {
await connectAwsAccount(canvasElement);
if (args.accountCreate === 'success') {
await expectToast(/aws account connected successfully/i);
}
},
};
/** The AWS connect flow when the account cannot be created. */
export const AwsAccountConnectionErrorToast: Story = {
args: { accounts: 0, accountCreate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await connectAwsAccount(canvasElement);
if (args.accountCreate === 'error') {
await expectToast(/failed to create account connection/i);
}
},
};
/** Connecting an Azure subscription through the generated setup commands. */
export const AzureAccountConnectedToast: Story = {
args: { provider: 'azure', accounts: 0 },
play: async ({ canvasElement, args }): Promise<void> => {
await connectAzureAccount(canvasElement);
if (args.accountCreate === 'success') {
await expectToast(/azure account connected successfully/i);
}
},
};
/** The Azure connect flow when the account cannot be created. */
export const AzureAccountConnectionErrorToast: Story = {
args: { provider: 'azure', accounts: 0, accountCreate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await connectAzureAccount(canvasElement);
if (args.accountCreate === 'error') {
await expectToast(/failed to create account connection/i);
}
},
};
/**
* Connecting a GCP account manually: the account is created and the story's
* check-in stands in for the agent. A failure at either step shows inline in the
* drawer rather than as a toast.
*/
export const GcpAccountConnectedToast: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement, args }): Promise<void> => {
await connectGcpAccount(canvasElement);
if (args.accountCreate === 'success') {
await expectToast(/gcp account connected successfully/i);
}
},
};
/** Interaction: the deployment region menu of the GCP setup drawer. */
export const GcpRegionOpen: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.click(
await screen.findByTestId(
'gcp-deployment-region-select',
undefined,
untilLoaded,
),
);
await screen.findByRole('listbox');
},
};
/**
* Saving the regions of a connected AWS account. Set Account settings update
* to `error` for the failure toast.
*/
export const AwsAccountSettingsUpdatedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await updateAwsAccount(canvasElement);
if (args.accountUpdate === 'success') {
await expectToast(/account settings updated successfully/i);
}
},
};
/** The AWS settings drawer when the update is rejected. */
export const AwsAccountSettingsUpdateErrorToast: Story = {
args: { accountUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateAwsAccount(canvasElement);
if (args.accountUpdate === 'error') {
await expectToast(/failed to update account settings/i);
}
},
};
/** Adding a resource group to a connected Azure subscription. */
export const AzureAccountSettingsUpdatedToast: Story = {
args: { provider: 'azure' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-rg');
if (args.accountUpdate === 'success') {
await expectToast(/account settings updated successfully/i);
}
},
};
/** The Azure settings drawer when the update is rejected. */
export const AzureAccountSettingsUpdateErrorToast: Story = {
args: { provider: 'azure', accountUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-rg');
if (args.accountUpdate === 'error') {
await expectToast(/failed to update account settings/i);
}
},
};
/** Adding a project to a connected GCP account. */
export const GcpAccountSettingsUpdatedToast: Story = {
args: { provider: 'gcp' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
if (args.accountUpdate === 'success') {
await expectToast(/account settings updated successfully/i);
}
},
};
/** The GCP settings drawer when the update is rejected. */
export const GcpAccountSettingsUpdateErrorToast: Story = {
args: { provider: 'gcp', accountUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
await updateTaggedAccount(canvasElement, 'extra-project');
if (args.accountUpdate === 'error') {
await expectToast(/failed to update account settings/i);
}
},
};
/**
* Saving a service's collection switches. The page opens on the first enabled
* service; flipping a switch brings up Save, and Service config update picks
* how the PUT answers.
*/
export const ServiceConfigUpdateErrorToast: Story = {
args: { serviceUpdate: 'error' },
play: async ({ canvasElement, args }): Promise<void> => {
const [firstSwitch] = await within(canvasElement).findAllByRole(
'switch',
undefined,
untilLoaded,
);
await userEvent.click(firstSwitch);
await userEvent.click(
await within(canvasElement).findByRole('button', { name: /^save$/i }),
);
if (args.serviceUpdate === 'error') {
await expectToast(/failed to update service config/i);
}
},
};
/**
* Copying a backend-provided credential in the GCP connect drawer, which raises
* a toast naming the field.
*/
export const GcpCredentialCopiedToast: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await openConnectFlow(canvasElement);
await userEvent.click(
await screen.findByRole(
'button',
{ name: /copy ingestion key/i },
untilLoaded,
),
);
await expectToast(/ingestion key copied to clipboard/i);
},
};

View File

@@ -20,6 +20,9 @@ import { monogramIcon } from '@/pages/IntegrationsModulePage/stories/__story_moc
export const CLOUD_PROVIDERS = ['aws', 'azure', 'gcp'] as const;
export type CloudProvider = (typeof CLOUD_PROVIDERS)[number];
export const MUTATION_STATES = ['success', 'loading', 'error'] as const;
export type MutationState = (typeof MUTATION_STATES)[number];
interface CloudServiceSeed {
id: string;
title: string;
@@ -321,10 +324,8 @@ export const accountResponse = (
provider: CloudProvider,
id: string,
): GetAccount200 => {
const index = Math.max(
0,
Number.parseInt(id.replace(`${provider}-account-`, ''), 10) - 1,
);
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
return { status: 'success', data: account(provider, index) };
};
@@ -552,6 +553,12 @@ export const createdAccountResponse = (
aws: {
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
},
azure: {
cliCommand:
'az deployment sub create --template-uri https://signoz.io/azure.json',
cloudPowerShellCommand:
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
},
},
},
});

View File

@@ -12,10 +12,13 @@ import {
allIntegrationsResponse,
INSTALLATION_MIXES,
INTEGRATION_CATALOGUE_SIZE,
REQUEST_SUBMISSIONS,
type InstallationMix,
type RequestSubmission,
} from './__story_mockdata__/integrations';
const LIST = 'Integrations · list';
const REQUEST = 'Integrations · request';
export const integrationsMocks = defineStoryMocks({
controls: {
@@ -33,6 +36,13 @@ export const integrationsMocks = defineStoryMocks({
options: INSTALLATION_MIXES,
value: 'mixed',
}),
requestSubmission: choiceControl<RequestSubmission>('Request submission', {
group: REQUEST,
description:
'How the event behind the Request Integration form answers. `success` raises the "Integration Request Submitted" toast, `loading` never answers and leaves the button spinning, `error` raises the failure toast.',
options: REQUEST_SUBMISSIONS,
value: 'success',
}),
},
handlers: (values, response) => [
rest.get(
@@ -41,5 +51,21 @@ export const integrationsMocks = defineStoryMocks({
allIntegrationsResponse(values.integrations, values.installation),
),
),
rest.post('http://localhost/api/v1/event', (_req, res, ctx) => {
if (values.requestSubmission === 'loading') {
return res(ctx.delay('infinite'));
}
return values.requestSubmission === 'error'
? res(
ctx.status(500),
ctx.json({ status: 'error', error: 'Event rejected' }),
)
: res(
ctx.status(200),
ctx.json({ status: 'success', data: 'Event Processed Successfully' }),
);
}),
],
});

View File

@@ -1,5 +1,5 @@
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 ROUTES from 'constants/routes';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -85,3 +85,30 @@ export const RequestIntegration: Story = {
);
},
};
/**
* Submitting the request form raises "Integration Request Submitted" when the
* event is accepted. Set Request submission to `error` for the failure toast, or
* `loading` to keep the submit button spinning.
*/
export const RequestIntegrationToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/request integration/i,
undefined,
untilLoaded,
),
);
await userEvent.type(
await screen.findByPlaceholderText(/enter integration name/i),
'Kafka',
);
await userEvent.click(await screen.findByRole('button', { name: /submit/i }));
if (args.requestSubmission === 'success') {
await waitFor(() =>
expect(screen.getByText(/integration request submitted/i)).toBeVisible(),
);
}
},
};

View File

@@ -11,6 +11,9 @@ import type {
export const INSTALLATION_MIXES = ['mixed', 'all', 'none'] as const;
export type InstallationMix = (typeof INSTALLATION_MIXES)[number];
export const REQUEST_SUBMISSIONS = ['success', 'loading', 'error'] as const;
export type RequestSubmission = (typeof REQUEST_SUBMISSIONS)[number];
/**
* The backend base64-encodes each integration's `icon.svg` into a data URI
* (`readFileIfUri` in `pkg/query-service/app/integrations/builtin.go`), so the

View File

@@ -6,16 +6,21 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
GROUP_SAVES,
type GroupSave,
MAPPING_GROUP_MAX,
spanMapperGroupsResponse,
wideConditionGroupsResponse,
} from './__story_mockdata__/attributeMapping';
const GROUPS = 'Attribute mapping · groups';
const SAVE = 'Attribute mapping · save';
let groupsSaved = false;
export const wideConditionGroup = rest.get(
'http://localhost/api/v1/span_mapper_groups',
@@ -32,12 +37,44 @@ export const attributeMappingMocks = defineStoryMocks({
value: 3,
max: MAPPING_GROUP_MAX,
}),
groupsSave: choiceControl<GroupSave>('Save changes', {
group: SAVE,
description:
'How the PATCH behind "Save changes" answers. `success` raises the saved toast, `loading` never answers, `error` raises the failure toast, `refreshFailed` saves but fails the list refetch that follows, which raises the warning toast.',
options: GROUP_SAVES,
value: 'success',
}),
},
handlers: (values) => {
groupsSaved = false;
return [
rest.patch(
'http://localhost/api/v1/span_mapper_groups/:groupId',
(_req, res, ctx) => {
if (values.groupsSave === 'loading') {
return res(ctx.delay('infinite'));
}
if (values.groupsSave === 'error') {
return res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Could not update the group' },
}),
);
}
groupsSaved = true;
return res(ctx.status(204));
},
),
rest.get('http://localhost/api/v1/span_mapper_groups', (_req, res, ctx) =>
values.groupsSave === 'refreshFailed' && groupsSaved
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(ctx.status(200), ctx.json(spanMapperGroupsResponse(values.groups))),
),
];
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/span_mapper_groups',
response.json(() => spanMapperGroupsResponse(values.groups)),
),
],
config: () => ({ route: ROUTES.AI_OBSERVABILITY_ATTRIBUTE_MAPPING }),
});

View File

@@ -1,5 +1,5 @@
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';
@@ -69,3 +69,35 @@ 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');
},
};
/**
* A group toggled off and saved: the toast announcing the saved changes, raised
* once the PATCH and the list refetch answer. Set Save changes to `error`,
* `refreshFailed` or `loading` for the other outcomes.
*/
export const ChangesSavedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
const [toggle] = await within(canvasElement).findAllByTestId(
/^group-enabled-/,
{},
{ timeout: 10000 },
);
await userEvent.click(toggle);
await userEvent.click(await screen.findByTestId('save-changes-btn'));
if (args.groupsSave === 'success') {
await waitFor(() =>
expect(screen.getByText(/attribute mapping changes saved/i)).toBeVisible(),
);
}
},
};

View File

@@ -110,3 +110,12 @@ export const wideConditionGroupsResponse = (): ListSpanMapperGroups200 => {
},
};
};
export const GROUP_SAVES = [
'success',
'loading',
'error',
'refreshFailed',
] as const;
export type GroupSave = (typeof GROUP_SAVES)[number];

View File

@@ -6,17 +6,22 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
PRICING_RULE_MAX,
pricingRulesResponse,
RULE_DELETES,
RULE_SAVES,
type RuleDelete,
type RuleSave,
UNPRICED_MODEL_MAX,
unmappedModelsResponse,
} from './__story_mockdata__/modelPricing';
const RULES = 'Model pricing · rules';
const WRITES = 'Model pricing · writes';
export const modelPricingMocks = defineStoryMocks({
controls: {
@@ -34,8 +39,58 @@ export const modelPricingMocks = defineStoryMocks({
value: 3,
max: UNPRICED_MODEL_MAX,
}),
ruleSave: choiceControl<RuleSave>('Rule save', {
group: WRITES,
description:
'How the PUT behind the cost drawer\'s Save and the unpriced tab\'s "Map model" answers. `success` raises the saved or "Mapped model" toast, `loading` never answers, `error` shows the failure in the drawer or raises the error toast.',
options: RULE_SAVES,
value: 'success',
}),
ruleDelete: choiceControl<RuleDelete>('Rule delete', {
group: WRITES,
description:
"How the DELETE behind the row menu's Delete answers. `success` raises the deleted toast, `loading` never answers, `error` raises the error toast.",
options: RULE_DELETES,
value: 'success',
}),
},
handlers: (values, response) => [
rest.put('http://localhost/api/v1/llm_pricing_rules', (_req, res, ctx) => {
if (values.ruleSave === 'loading') {
return res(ctx.delay('infinite'));
}
return values.ruleSave === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Could not save the pricing rule' },
}),
)
: res(ctx.status(204));
}),
rest.delete(
'http://localhost/api/v1/llm_pricing_rules/:id',
(_req, res, ctx) => {
if (values.ruleDelete === 'loading') {
return res(ctx.delay('infinite'));
}
return values.ruleDelete === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: {
code: 'internal',
message: 'Could not delete the pricing rule',
},
}),
)
: res(ctx.status(204));
},
),
rest.get(
'http://localhost/api/v1/llm_pricing_rules/unmapped_models',
response.json(() => unmappedModelsResponse(values.unpricedModels)),

View File

@@ -1,5 +1,5 @@
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';
@@ -54,3 +54,102 @@ 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');
},
};
/**
* A pricing rule saved from its drawer: the toast announcing the update, raised
* once the PUT answers. Set Rule save to `error` or `loading` to see the drawer
* instead.
*/
export const ModelCostSavedToast: Story = {
play: async (context): Promise<void> => {
await ModelCostDrawer.play?.(context);
await userEvent.type(
await screen.findByTestId('drawer-pattern-input'),
'gpt-4o-2026{Enter}',
);
await userEvent.click(await screen.findByTestId('drawer-save-btn'));
if (context.args.ruleSave === 'success') {
await waitFor(() =>
expect(screen.getByText(/model cost .*updated/i)).toBeVisible(),
);
}
},
};
/**
* A pricing rule deleted from its row menu: the toast announcing it, raised once
* the DELETE answers. Set Rule delete to `error` for the failure toast.
*/
export const ModelCostDeletedToast: Story = {
play: async (context): Promise<void> => {
await ModelCostActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Delete'));
await userEvent.click(await screen.findByTestId('drawer-delete-confirm-btn'));
if (context.args.ruleDelete === 'success') {
await waitFor(() =>
expect(screen.getByText(/model cost .*deleted/i)).toBeVisible(),
);
}
},
};
/** Interaction: the billing model menu of an unpriced model, with its create footer. */
export const UnpricedModelMapToOpen: Story = {
parameters: {
signoz: { route: '/ai-observability/configuration?tab=unpriced-models' },
},
play: async ({ canvasElement }): Promise<void> => {
const [trigger] = await within(canvasElement).findAllByTestId(
/^map-to-select-/,
{},
{ timeout: 10000 },
);
await userEvent.click(trigger);
await screen.findAllByTestId(/^map-to-option-/);
},
};
/**
* An unpriced model mapped onto an existing billing model: the "Mapped model"
* toast, or the failure toast when Rule save is `error`.
*/
export const UnpricedModelMappedToast: Story = {
parameters: {
signoz: { route: '/ai-observability/configuration?tab=unpriced-models' },
},
play: async ({ canvasElement, args }): Promise<void> => {
const [trigger] = await within(canvasElement).findAllByTestId(
/^map-to-select-/,
{},
{ timeout: 10000 },
);
await userEvent.click(trigger);
const [option] = await screen.findAllByTestId(/^map-to-option-/);
await userEvent.click(option);
await userEvent.click(await screen.findByTestId('unpriced-map-confirm-btn'));
if (args.ruleSave === 'success') {
await waitFor(() => expect(screen.getByText('Mapped model')).toBeVisible());
}
},
};

View File

@@ -113,3 +113,11 @@ export const unmappedModelsResponse = (
status: 'success',
data: { items: UNPRICED.slice(0, count) },
});
export const RULE_SAVES = ['success', 'loading', 'error'] as const;
export type RuleSave = (typeof RULE_SAVES)[number];
export const RULE_DELETES = ['success', 'loading', 'error'] as const;
export type RuleDelete = (typeof RULE_DELETES)[number];

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, waitFor, 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';
@@ -84,3 +84,33 @@ export const Default: Story = {};
export const Waiting: Story = {
args: { liveLogs: 0 },
};
/** Row actions render on hover, so a streamed line is hovered before its copy link is pressed. */
export const LogLinkCopiedToast: Story = {
play: async ({ mount, canvasElement }): Promise<void> => {
await mount();
await goLive(canvasElement);
const [line] = await within(canvasElement).findAllByText(
/order 8412 placed/,
undefined,
untilLoaded,
);
await userEvent.hover(line);
await userEvent.click(
await waitFor(() => {
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
if (!button) {
throw new Error('Row actions did not render');
}
return button;
}, untilLoaded),
);
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
};

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,190 @@ 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'",
);
},
};
/** Row actions render on hover, so the first row is hovered before the copy link is pressed. */
export const LogLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^logs-table-row-/,
undefined,
untilLoaded,
);
const [, timestamp] = within(row).getAllByRole('cell');
await userEvent.hover(timestamp);
await userEvent.click(
await waitFor(() => {
const button = canvasElement.querySelector<HTMLElement>('.copy-log-btn');
if (!button) {
throw new Error('Row actions did not render');
}
return button;
}, untilLoaded),
);
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
};
/** "Copy log" in the log detail drawer's actions menu. */
export const LogCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText('Copy log'));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
};
/** "Copy link to log" in the log detail drawer's actions menu. */
export const LogDetailsLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstLog(canvasElement);
await userEvent.click(await screen.findByTestId('log-details-header-menu'));
await userEvent.click(await screen.findByText('Copy link to log'));
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
};

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

@@ -22,6 +22,7 @@ import {
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { rest } from 'msw';
import type { AppState } from 'store/reducers';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryRangeRequestV5, TimeSeries } from 'types/api/v5/queryRange';
@@ -331,6 +332,19 @@ export const metricsMocks = defineStoryMocks({
value: 4,
max: SERIES_SERVICES.length,
}),
metricUnit: choiceControl<'saved' | 'unset'>('Explorer metric unit', {
group: EXPLORER,
description:
'Whether the metadata carries a unit. Unset, the explorer offers to save the unit picked in the selector.',
options: ['saved', 'unset'],
value: 'saved',
}),
unitSave: choiceControl<'success' | 'error'>('Unit save', {
group: EXPLORER,
description: 'How the metadata write answers when the unit is saved.',
options: ['success', 'error'],
value: 'success',
}),
savedViews: countControl('Saved views', {
group: EXPLORER,
description: 'Fills the explorer views dropdown and the Views tab.',
@@ -385,16 +399,24 @@ export const metricsMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v2/metrics/metadata',
response.json((req) =>
metricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
),
response.json((req) => {
const metadata = metricMetadataResponse(
req.url.searchParams.get('metricName') ?? '',
);
return values.metricUnit === 'unset'
? { ...metadata, data: { ...metadata.data, unit: '' } }
: metadata;
}),
),
// The drawer writes description, unit and type back. The metadata a story
// answers with comes from the catalogue, so the save succeeds and the
// refetch reads the metric as it was.
rest.post('http://localhost/api/v2/metrics/metadata', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
values.unitSave === 'error'
? res(ctx.status(500), ctx.json({ status: 'error', error: 'forced' }))
: res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get(
@@ -539,3 +561,26 @@ export const metricsMocks = defineStoryMocks({
);
},
});
export const saveUnit = async (
canvasElement: HTMLElement,
expected: string | false,
): Promise<void> => {
const selector = await within(canvasElement).findByTestId(
'y-axis-unit-selector',
{},
{ timeout: 15000 },
);
await userEvent.click(within(selector).getByRole('combobox'));
await userEvent.keyboard('bytes');
const bytes = await screen.findByText('Bytes (B)');
await userEvent.click(
(bytes.closest('.ant-select-item-option') as HTMLElement | null) ?? bytes,
);
await userEvent.click(await screen.findByRole('button', { name: 'Yes' }));
if (expected) {
await waitFor(() => expect(screen.getByText(expected)).toBeVisible());
}
};

View File

@@ -1,10 +1,15 @@
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';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { metricsMocks } from './MetricsExplorerPage.stories.mocks';
import { metricsMocks, saveUnit } from './MetricsExplorerPage.stories.mocks';
import MetricsExplorerPage from '../MetricsExplorerPage';
type MetricsArgs = PageStoryArgs<typeof metricsMocks>;
@@ -114,3 +119,44 @@ 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');
},
};
/**
* "Set the selected unit as the metric unit?" answered Yes: the toast raised
* once the metadata write lands. Set Unit save to `error` for the failure.
*/
export const UnitSavedToast: Story = {
args: { tab: 'explorer', metricUnit: 'unset' },
play: async ({ canvasElement, args }): Promise<void> => {
await saveUnit(
canvasElement,
args.unitSave === 'success'
? 'Unit saved successfully'
: 'Failed to save unit',
);
},
};
/** The same save with the metadata write failing. */
export const UnitSaveErrorToast: Story = {
args: { tab: 'explorer', metricUnit: 'unset', unitSave: 'error' },
// The deliberate 500 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await saveUnit(canvasElement, 'Failed to save unit');
},
};

View File

@@ -13,6 +13,7 @@ import { choiceControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
const SOURCE = 'Onboarding · source';
const INVITES = 'Onboarding · invites';
/**
* `catalogue` is the picker itself; the rest are `getStartedSource` values,
@@ -45,6 +46,10 @@ export const SETUP_STEPS = [
export type SetupStep = (typeof SETUP_STEPS)[number];
export const INVITE_RESULTS = ['success', 'loading', 'error'] as const;
export type InviteResult = (typeof INVITE_RESULTS)[number];
export const onboardingV2Mocks = defineStoryMocks({
controls: {
source: choiceControl<Source>('Data source', {
@@ -61,8 +66,15 @@ export const onboardingV2Mocks = defineStoryMocks({
options: SETUP_STEPS,
value: 'select-data-source',
}),
invite: choiceControl<InviteResult>('Invite result', {
group: INVITES,
description:
'How the POST behind Send Invites in the header modal answers. `success` closes the modal and raises "Invites sent successfully", `loading` never answers, `error` keeps the modal open with the failure inline.',
options: INVITE_RESULTS,
value: 'success',
}),
},
handlers: (_values, response) => [
handlers: (values, response) => [
// The setup instructions quote the workspace's ingestion key, which is the
// same payload the ingestion settings tab reads.
rest.get(
@@ -74,6 +86,24 @@ export const onboardingV2Mocks = defineStoryMocks({
'http://localhost/api/v1/roles',
response.json(() => rolesListResponse(0)),
),
rest.post('http://localhost/api/v2/users', (_req, res, ctx) => {
if (values.invite === 'loading') {
return res(ctx.delay('infinite'));
}
return values.invite === 'error'
? res(
ctx.status(409),
ctx.json({
status: 'error',
error: { code: 'already_exists', message: 'User already exists' },
}),
)
: res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'user-new' } }),
);
}),
],
config: (values) => {
const dataSource = DATA_SOURCE_ID[values.source];

View File

@@ -1,5 +1,5 @@
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';
@@ -167,3 +167,55 @@ export const NoSearchResults: Story = {
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
},
};
/**
* Inviting a teammate from the header: an email and a role, then Send Invites.
* The modal closes and "Invites sent successfully" is raised once the request
* is accepted; Invite result changes how it answers.
*/
export const InvitesSentToast: Story = {
play: async ({ mount, args, canvasElement }): Promise<void> => {
await mount();
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /invite a teammate/i },
untilRendered,
),
);
const dialog = await screen.findByRole('dialog', undefined, untilRendered);
const [email] = await within(dialog).findAllByTestId(
/^invite-email-/,
undefined,
untilRendered,
);
await userEvent.type(email, 'jon@winterfell.dev');
const [roles] = await within(dialog).findAllByRole('combobox');
await userEvent.click(roles);
const viewers = await screen.findAllByText(
'viewer',
undefined,
untilRendered,
);
await userEvent.click(
viewers[viewers.length - 1].closest(
'.ant-select-item-option',
) as HTMLElement,
);
await userEvent.keyboard('{Escape}');
await userEvent.click(
await within(dialog).findByRole('button', { name: /send invites/i }),
);
if (args.invite === 'success') {
await waitFor(() =>
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
);
}
},
};

View File

@@ -26,6 +26,10 @@ export const QUESTIONNAIRE_PAGES = [
export type QuestionnairePage = (typeof QUESTIONNAIRE_PAGES)[number];
export const PROFILE_UPDATES = ['success', 'error'] as const;
export type ProfileUpdate = (typeof PROFILE_UPDATES)[number];
export const orgOnboardingMocks = defineStoryMocks({
controls: {
page: choiceControl<QuestionnairePage>('Questionnaire page', {
@@ -35,13 +39,28 @@ export const orgOnboardingMocks = defineStoryMocks({
options: QUESTIONNAIRE_PAGES,
value: 'about-your-org',
}),
profileUpdate: choiceControl<ProfileUpdate>('Profile save', {
group: QUESTIONNAIRE,
description:
'How the PUT behind the scale page answers. `error` raises the failure as a toast and lets the questionnaire carry on to the last page.',
options: PROFILE_UPDATES,
value: 'success',
}),
},
handlers: (_values, response) => [
handlers: (values, response) => [
// Answering the scale page saves the profile, and the last page only opens
// once that call settles: a plain resolver, so `Data` cannot strand the
// questionnaire two pages in.
rest.put('http://localhost/api/v2/zeus/profiles', (_req, res, ctx) =>
res(ctx.status(204)),
values.profileUpdate === 'error'
? res(
ctx.status(500),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'Could not save the profile' },
}),
)
: res(ctx.status(204)),
),
rest.get(
'http://localhost/api/v1/roles',

View File

@@ -117,3 +117,13 @@ export const YourScale: Story = {
export const InviteTeam: Story = {
args: { page: 'invite-team' },
};
/**
* Saving the answers fails on the scale page. The failure is a toast and the
* questionnaire still opens the last page.
*/
export const ProfileSaveErrorToast: Story = {
args: { page: 'invite-team', profileUpdate: 'error' },
// The deliberate 500 is the state under test.
parameters: { allowConsoleErrors: true },
};

View File

@@ -9,6 +9,12 @@ import { rest } from 'msw';
import { choiceControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
MUTATION_OUTCOMES,
type MutationOutcome,
mutationResolver,
} from '../../stories/__story_mockdata__/mutationOutcome';
import {
seedTimezone,
TIMEZONES,
@@ -17,6 +23,7 @@ import {
} from './__story_mockdata__/account';
const PREFERENCES = 'Account · preferences';
const PROFILE = 'Account · profile';
export const accountMocks = defineStoryMocks({
controls: {
@@ -27,16 +34,30 @@ export const accountMocks = defineStoryMocks({
options: TIMEZONES,
value: 'browser',
}),
nameUpdate: choiceControl<MutationOutcome>('Name update', {
group: PROFILE,
description:
'How the PATCH behind "Update name" answers. `success` raises the "Name updated" toast, `loading` never answers, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
passwordUpdate: choiceControl<MutationOutcome>('Password update', {
group: PROFILE,
description:
'How the request behind "Reset password" answers. `success` raises the "Password updated" toast, `loading` never answers, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
},
handlers: (_values, response) => [
rest.patch(
handlers: (values) => [
rest.put(
'http://localhost/api/v2/users/me',
response.json(() => updatedUserResponse()),
mutationResolver(values.nameUpdate, updatedUserResponse()),
),
rest.post(
rest.put(
'http://localhost/api/v2/users/me/factor_password',
response.json(() => updatedUserResponse()),
mutationResolver(values.passwordUpdate, updatedUserResponse()),
),
],
config: () => ({ route: ROUTES.MY_SETTINGS }),

View File

@@ -1,5 +1,5 @@
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';
@@ -77,3 +77,67 @@ export const ResetPassword: Story = {
await screen.findByText(/current password/i, undefined, untilLoaded);
},
};
/**
* A new name saved: the toast confirming it. Set Name update to `error` or
* `loading` to see the modal instead.
*/
export const NameUpdatedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/update name/i,
undefined,
untilLoaded,
),
);
const input = await screen.findByPlaceholderText(
/e\.g\. john doe/i,
undefined,
untilLoaded,
);
await userEvent.clear(input);
await userEvent.type(input, 'Jon Targaryen');
await userEvent.click(await screen.findByTestId('update-name-btn'));
if (args.nameUpdate === 'success') {
await waitFor(() =>
expect(screen.getByText(/name updated successfully/i)).toBeVisible(),
);
}
},
};
/**
* A new password saved: the toast confirming it. Set Password update to `error`
* or `loading` to see the modal instead.
*/
export const PasswordUpdatedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/reset password/i,
undefined,
untilLoaded,
),
);
await userEvent.type(
await screen.findByTestId(
'current-password-textbox',
undefined,
untilLoaded,
),
'old-password-1',
);
await userEvent.type(
await screen.findByTestId('new-password-textbox'),
'new-password-2',
);
await userEvent.click(await screen.findByTestId('reset-password-btn'));
if (args.passwordUpdate === 'success') {
await waitFor(() =>
expect(screen.getByText(/password updated successfully/i)).toBeVisible(),
);
}
},
};

View File

@@ -5,7 +5,9 @@
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import { rest } from 'msw';
import { screen, userEvent, within } from 'storybook/test';
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
import {
@@ -15,10 +17,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 +35,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 +61,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 +103,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(
@@ -130,3 +152,46 @@ export const ingestionMocks = defineStoryMocks({
},
}),
});
/** The list fetches before it renders a row, which outlasts the 1s default. */
export const untilLoaded = { timeout: 15_000 };
export 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,
),
);
}
export 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}`);
}
export 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' }),
);
}

View File

@@ -1,10 +1,17 @@
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';
import { ingestionMocks } from './Ingestion.stories.mocks';
import {
addTag,
ingestionMocks,
openCreateKey,
submitCreateKey,
untilLoaded,
} from './Ingestion.stories.mocks';
import SettingsPage from '../../Settings';
@@ -30,9 +37,6 @@ export default meta;
type Story = StoryObj<IngestionArgs>;
/** The list fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The keys collectors send data with, and how much each one is allowed to send
* per signal before the gateway starts rejecting.
@@ -79,13 +83,106 @@ export const KeyLimits: Story = {
/** 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

@@ -1,5 +1,5 @@
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';
@@ -77,3 +77,31 @@ export const NonAdmin: Story = {
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};
/** The client config snippet copied: the toast confirming it. */
export const SnippetCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /^copy .* config$/i },
untilLoaded,
),
);
await screen.findByText(/snippet copied to clipboard/i);
},
};
/** The instance URL copied: the toast confirming it. */
export const InstanceUrlCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /copy signoz instance url/i },
untilLoaded,
),
);
await screen.findByText(/instance url copied to clipboard/i);
},
};

View File

@@ -5,13 +5,18 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { screen, userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
ACTIVE_MEMBER_MAX,
BLOCKED_INVITE_DOMAIN,
createdUserResponse,
emptyResetTokenResponse,
RESET_LINK_OUTCOMES,
type ResetLinkOutcome,
DELETED_MEMBER_MAX,
INVITED_MEMBER_MAX,
RESET_TOKEN_STATES,
@@ -21,6 +26,12 @@ import {
usersResponse,
} from './__story_mockdata__/members';
import {
MUTATION_OUTCOMES,
type MutationOutcome,
mutationResolver,
mutationResponse,
} from '../../stories/__story_mockdata__/mutationOutcome';
import {
CUSTOM_ROLE_MAX,
rolesListResponse,
@@ -28,6 +39,7 @@ import {
const LIST = 'Members · list';
const INVITES = 'Members · invites';
const DRAWER = 'Members · drawer';
export const membersMocks = defineStoryMocks({
controls: {
@@ -61,6 +73,33 @@ export const membersMocks = defineStoryMocks({
options: RESET_TOKEN_STATES,
value: 'valid',
}),
invite: choiceControl<MutationOutcome>('Sending invites', {
group: INVITES,
description: `How each invite answers. An address on ${BLOCKED_INVITE_DOMAIN} is always refused, which leaves a batch half sent. \`success\` raises the "Invites sent" toast, \`loading\` never answers, \`error\` refuses every invite.`,
options: MUTATION_OUTCOMES,
value: 'success',
}),
memberUpdate: choiceControl<MutationOutcome>('Saving the member', {
group: DRAWER,
description:
'How the PATCH behind "Save Member Details" answers. `success` raises the "Member details updated" toast, `loading` leaves the button spinning, `error` lists the failure in the drawer.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
memberDelete: choiceControl<MutationOutcome>('Deleting the member', {
group: DRAWER,
description:
'How the DELETE behind the confirmation answers. `success` raises the "Member deleted" or "Invite revoked" toast, `loading` leaves the button busy, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
resetLink: choiceControl<ResetLinkOutcome>('Generating a link', {
group: DRAWER,
description:
'How the POST behind the reset or invite link answers. `success` opens the dialog whose copy button raises the toast, `no-token` raises the "Failed to generate" toast, `loading` never answers, `error` opens the error modal.',
options: RESET_LINK_OUTCOMES,
value: 'success',
}),
},
handlers: (values, response) => [
rest.get(
@@ -74,19 +113,35 @@ export const membersMocks = defineStoryMocks({
),
),
rest.post(
'http://localhost/api/v2/users',
response.json(() => createdUserResponse()),
),
rest.post('http://localhost/api/v2/users', async (req, res, ctx) => {
const { email } = (await req.json()) as { email?: string };
if (String(email).endsWith(BLOCKED_INVITE_DOMAIN)) {
return res(
ctx.status(409),
ctx.json({
status: 'error',
error: { code: 'already_exists', message: 'User already exists' },
}),
);
}
return mutationResponse(values.invite, createdUserResponse(), res, ctx);
}),
rest.get(
'http://localhost/api/v2/users/:id/reset_password_tokens',
response.json(() => resetPasswordTokenResponse(values.inviteToken)),
),
rest.post(
rest.put(
'http://localhost/api/v2/users/:id/reset_password_tokens',
response.json(() => resetPasswordTokenResponse('valid')),
mutationResolver(
values.resetLink === 'no-token' ? 'success' : values.resetLink,
values.resetLink === 'no-token'
? emptyResetTokenResponse()
: resetPasswordTokenResponse('valid'),
),
),
rest.get(
@@ -94,14 +149,14 @@ export const membersMocks = defineStoryMocks({
response.json((req) => userDetailResponse(String(req.params.id))),
),
rest.patch(
rest.put(
'http://localhost/api/v2/users/:id',
response.json(() => ({ status: 'success', data: null })),
mutationResolver(values.memberUpdate),
),
rest.delete(
'http://localhost/api/v2/users/:id',
response.json(() => ({ status: 'success', data: null })),
mutationResolver(values.memberDelete),
),
rest.post(
@@ -121,3 +176,83 @@ export const membersMocks = defineStoryMocks({
],
config: () => ({ route: ROUTES.MEMBERS_SETTINGS }),
});
/** The table fetches before it renders a row, which outlasts the 1s default. */
export const untilLoaded = { timeout: 15_000 };
/**
* The click handler sits on the row rather than the cell, and the header row
* resolves before the body has one: wait on a cell, then click the row it is in.
*/
export const openMember = async (
canvasElement: HTMLElement,
name: RegExp,
): Promise<void> => {
const cell = await within(canvasElement).findByText(
name,
undefined,
untilLoaded,
);
await userEvent.click(cell.closest('tr') as HTMLElement);
};
/**
* Selects `viewer` in the roles dropdown at `index`. The option's content ignores
* pointer events, so the click goes to the option around it. A click on the
* label closes the dropdown, where Escape would close the dialog too.
*/
const pickRole = async (index: number): Promise<void> => {
const comboboxes = await screen.findAllByRole(
'combobox',
undefined,
untilLoaded,
);
await userEvent.click(comboboxes[index]);
const options = await screen.findAllByText('viewer', undefined, untilLoaded);
await userEvent.click(
options[options.length - 1].closest('.ant-select-item-option') as HTMLElement,
);
await userEvent.click(await screen.findByText('Email address'));
};
export const sendInvites = async (
canvasElement: HTMLElement,
emails: string[],
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/invite member/i,
undefined,
untilLoaded,
),
);
const fields = await screen.findAllByTestId(
/^invite-email-/,
undefined,
untilLoaded,
);
for (const [index, email] of emails.entries()) {
await userEvent.type(fields[index], email);
await pickRole(index);
}
await userEvent.click(
await screen.findByRole('button', { name: 'Invite Team Members' }),
);
};
export const openResetLink = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
await userEvent.click(
await screen.findByRole(
'button',
{ name: 'Generate Password Reset Link' },
untilLoaded,
),
);
};

View File

@@ -1,10 +1,16 @@
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 { membersMocks } from './Members.stories.mocks';
import {
membersMocks,
openMember,
openResetLink,
sendInvites,
untilLoaded,
} from './Members.stories.mocks';
import SettingsPage from '../../Settings';
@@ -30,29 +36,9 @@ export default meta;
type Story = StoryObj<MembersArgs>;
/** The table fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** One more than the twenty rows a page holds. */
const OVER_ONE_PAGE = 21;
/**
* The click handler sits on the row rather than the cell, and the header row
* resolves before the body has one: wait on a cell, then click the row it is in.
*/
const openMember = async (
canvasElement: HTMLElement,
name: RegExp,
): Promise<void> => {
const cell = await within(canvasElement).findByText(
name,
undefined,
untilLoaded,
);
await userEvent.click(cell.closest('tr') as HTMLElement);
};
const openPendingMember = async ({
canvasElement,
}: {
@@ -214,3 +200,161 @@ export const InviteMembers: Story = {
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
},
};
/**
* Invites sent to every address: the toast confirming it. Set Sending invites
* to `error` or `loading` to see the form instead.
*/
export const InvitesSentToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await sendInvites(canvasElement, [
'bran@nightswatch.io',
'hodor@nightswatch.io',
]);
if (args.invite === 'success') {
await waitFor(() =>
expect(screen.getByText(/invites sent successfully/i)).toBeVisible(),
);
}
},
};
/** One address refused out of two: the warning that some invites failed. */
export const InvitesPartiallyFailedToast: Story = {
// The 409 for the refused address is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement, args }): Promise<void> => {
await sendInvites(canvasElement, [
'bran@nightswatch.io',
'hodor@blocked.example',
]);
if (args.invite === 'success') {
await waitFor(() =>
expect(screen.getByText(/some invites failed/i)).toBeVisible(),
);
}
},
};
/**
* A member's name changed and saved: the toast confirming it. Set Saving the
* member to `error` or `loading` to see the drawer instead.
*/
export const MemberUpdatedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
const name = await screen.findByPlaceholderText(
/enter name/i,
undefined,
untilLoaded,
);
await waitFor(() => expect(name).toHaveValue('Jon Snow'), untilLoaded);
await userEvent.type(name, ' Targaryen');
await userEvent.click(
await screen.findByRole('button', { name: 'Save Member Details' }),
);
if (args.memberUpdate === 'success') {
await waitFor(() =>
expect(
screen.getByText(/member details updated successfully/i),
).toBeVisible(),
);
}
},
};
/**
* A member deleted from the confirmation: the toast confirming it. Set Deleting
* the member to `error` or `loading` to see the dialog instead.
*/
export const MemberDeletedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
await userEvent.click(
await screen.findByRole('button', { name: 'Delete Member' }),
);
const dialog = await screen.findByRole('dialog', { name: 'Delete Member' });
await userEvent.click(
within(dialog).getByRole('button', { name: 'Delete Member' }),
);
if (args.memberDelete === 'success') {
await waitFor(() =>
expect(screen.getByText(/member deleted successfully/i)).toBeVisible(),
);
}
},
};
/**
* A pending invite revoked from the confirmation: the toast confirming it. Set
* Deleting the member to `error` or `loading` to see the dialog instead.
*/
export const InviteRevokedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await openPendingMember({ canvasElement });
await userEvent.click(
await screen.findByRole('button', { name: 'Revoke Invite' }),
);
const dialog = await screen.findByRole('dialog', { name: 'Revoke Invite' });
await userEvent.click(
within(dialog).getByRole('button', { name: 'Revoke Invite' }),
);
if (args.memberDelete === 'success') {
await waitFor(() =>
expect(screen.getByText(/invite revoked successfully/i)).toBeVisible(),
);
}
},
};
/**
* The generated password reset link copied from its dialog: the toast
* confirming it.
*/
export const ResetLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResetLink(canvasElement);
await userEvent.click(
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
);
await screen.findByText(/reset link copied to clipboard/i);
},
};
/**
* The invite link of a pending member copied from its dialog: the toast
* confirming it.
*/
export const InviteLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openPendingMember({ canvasElement });
await userEvent.click(
await screen.findByRole('button', { name: /invite link/i }, untilLoaded),
);
await userEvent.click(
await screen.findByRole('button', { name: /^copy$/i }, untilLoaded),
);
await screen.findByText(/invite link copied to clipboard/i);
},
};
/**
* A reset link request that answers without a token: the toast reporting the
* failure. Set Generating a link to `success` for the dialog instead.
*/
export const ResetLinkFailedToast: Story = {
args: { resetLink: 'no-token' },
play: async ({ canvasElement, args }): Promise<void> => {
await openResetLink(canvasElement);
if (args.resetLink === 'no-token') {
await waitFor(() =>
expect(
screen.getByText(/failed to generate password reset link/i),
).toBeVisible(),
);
}
},
};

View File

@@ -154,3 +154,26 @@ export const createdUserResponse = (): CreateUser201 => ({
status: 'success',
data: { id: 'user-active-new' },
});
export const RESET_LINK_OUTCOMES = [
'success',
'no-token',
'loading',
'error',
] as const;
export type ResetLinkOutcome = (typeof RESET_LINK_OUTCOMES)[number];
/** A reset-token response that carries no token, which the drawer reports as a failure. */
export const emptyResetTokenResponse = (): GetResetPasswordToken200 => ({
status: 'success',
data: {
id: 'reset-token-1',
passwordId: 'password-1',
token: '',
expiresAt: new Date(JOINED_AT).toISOString(),
},
});
/** An invite for an address the backend refuses, to leave a batch half sent. */
export const BLOCKED_INVITE_DOMAIN = 'blocked.example';

View File

@@ -5,11 +5,17 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { RoleType } from 'types/roles';
import { choiceControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
MUTATION_OUTCOMES,
type MutationOutcome,
mutationResolver,
} from '../../../stories/__story_mockdata__/mutationOutcome';
import {
CUSTOM_ROLE_ID,
PERMISSION_BREADTHS,
@@ -63,6 +69,13 @@ export const roleEditorMocks = defineStoryMocks({
options: PERMISSION_BREADTHS,
value: 'broad',
}),
roleSave: choiceControl<MutationOutcome>('Saving the role', {
group: EDITOR,
description:
'How the request behind the save button answers. `success` raises the created or updated toast, `loading` leaves the button spinning, `error` shows the failure above the form.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
},
handlers: (values, response) => [
rest.get(
@@ -74,13 +87,47 @@ export const roleEditorMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v1/roles',
response.json(() => ({ status: 'success', data: { id: CUSTOM_ROLE_ID } })),
mutationResolver(values.roleSave, {
status: 'success',
data: { id: CUSTOM_ROLE_ID },
}),
),
rest.put(
'http://localhost/api/v1/roles/:id',
response.json(() => ({ status: 'success', data: null })),
mutationResolver(values.roleSave),
),
],
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;
};
/** Clicks save once the permission check behind the button has answered. */
export const save = async (canvasElement: HTMLElement): Promise<void> => {
const button = await waitFor(
() => {
const found = within(canvasElement).getByTestId('save-button');
expect(found).not.toHaveAttribute('aria-disabled', 'true');
return found;
},
{ timeout: 15_000 },
);
await userEvent.click(button);
};

View File

@@ -1,10 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { 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 { roleEditorMocks } from './RoleEditor.stories.mocks';
import {
openLogsCard,
roleEditorMocks,
save,
} from './RoleEditor.stories.mocks';
import SettingsPage from '../../../Settings';
@@ -75,3 +79,74 @@ 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' });
},
};
/**
* A new role saved: the toast confirming it. Set Saving the role to `error` or
* `loading` to see the page instead.
*/
export const RoleCreatedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await userEvent.type(
await within(canvasElement).findByTestId('role-name-input', undefined, {
timeout: 15_000,
}),
'release-manager',
);
await save(canvasElement);
if (args.roleSave === 'success') {
await waitFor(() =>
expect(screen.getByText(/role created successfully/i)).toBeVisible(),
);
}
},
};
/**
* An existing role's description changed and saved: the toast confirming it.
* Set Saving the role to `error` or `loading` to see the page instead.
*/
export const RoleUpdatedToast: Story = {
args: { mode: 'edit' },
play: async ({ canvasElement, args }): Promise<void> => {
const description = await within(canvasElement).findByTestId(
'role-description-input',
undefined,
{ timeout: 15_000 },
);
await waitFor(() => expect(description).not.toHaveValue(''), {
timeout: 15_000,
});
await userEvent.type(description, ' Also pages the secondary.');
await save(canvasElement);
if (args.roleSave === 'success') {
await waitFor(() =>
expect(screen.getByText(/role updated successfully/i)).toBeVisible(),
);
}
},
};

View File

@@ -25,6 +25,11 @@ import {
serviceAccountsResponse,
} from './__story_mockdata__/serviceAccounts';
import {
MUTATION_OUTCOMES,
type MutationOutcome,
mutationResolver,
} from '../../stories/__story_mockdata__/mutationOutcome';
import {
CUSTOM_ROLE_MAX,
rolesListResponse,
@@ -81,6 +86,34 @@ export const serviceAccountsMocks = defineStoryMocks({
options: SAVE_OUTCOMES,
value: 'succeeds',
}),
accountCreate: choiceControl<MutationOutcome>('Creating an account', {
group: LIST,
description:
'How the POST behind "Create Service Account" answers. `success` raises the created toast, `loading` leaves the button spinning, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
accountDelete: choiceControl<MutationOutcome>('Deleting an account', {
group: DRAWER,
description:
'How the DELETE behind the confirmation answers. `success` raises the deleted toast, `loading` leaves the button spinning, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
keyUpdate: choiceControl<MutationOutcome>('Updating a key', {
group: KEYS,
description:
'How the PUT behind the key dialog’s Save Changes answers. `success` raises the updated toast, `loading` leaves the button spinning, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
keyRevoke: choiceControl<MutationOutcome>('Revoking a key', {
group: KEYS,
description:
'How the DELETE behind a key’s revoke confirmation answers. `success` raises the revoked toast, `loading` leaves the button spinning, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
},
handlers: (values, response) => [
rest.get(
@@ -92,7 +125,7 @@ export const serviceAccountsMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v1/service_accounts',
response.json(() => createdServiceAccountResponse()),
mutationResolver(values.accountCreate, createdServiceAccountResponse()),
),
rest.get(
@@ -109,12 +142,12 @@ export const serviceAccountsMocks = defineStoryMocks({
rest.put(
'http://localhost/api/v1/service_accounts/:id/keys/:fid',
response.json(() => ({ status: 'success', data: null })),
mutationResolver(values.keyUpdate),
),
rest.delete(
'http://localhost/api/v1/service_accounts/:id/keys/:fid',
response.json(() => ({ status: 'success', data: null })),
mutationResolver(values.keyRevoke),
),
rest.get(
@@ -136,7 +169,7 @@ export const serviceAccountsMocks = defineStoryMocks({
rest.delete(
'http://localhost/api/v1/service_accounts/:id',
response.json(() => ({ status: 'success', data: null })),
mutationResolver(values.accountDelete),
),
rest.post(

View File

@@ -280,3 +280,208 @@ export const RevokeKey: Story = {
export const Viewer: Story = {
args: { access: 'viewer' },
};
/**
* A rename saved: the toast confirming it. Set Saving the account to `fails` to
* see the drawer keep the edit instead.
*/
export const AccountUpdatedToast: Story = {
parameters: at(`account=${ACCOUNT}`),
play: async ({ args }): Promise<void> => {
await userEvent.type(
await screen.findByDisplayValue('ci-pipeline', undefined, untilLoaded),
'-v2',
);
await userEvent.click(await screen.findByText(/save changes/i));
if (args.saveOutcome === 'succeeds') {
await waitFor(() =>
expect(
screen.getByText(/service account updated successfully/i),
).toBeVisible(),
);
}
},
};
/**
* A new account created: the toast confirming it. Set Creating an account to
* `error` or `loading` to see the dialog instead.
*/
export const AccountCreatedToast: Story = {
parameters: at('create-sa=true'),
play: async ({ args }): Promise<void> => {
await userEvent.type(
await screen.findByTestId('create-sa-name-input', undefined, untilLoaded),
'release-signer',
);
await userEvent.click(
await waitFor(() => {
const button = screen.getByTestId('create-sa-submit-btn');
expect(button).not.toHaveAttribute('aria-disabled', 'true');
expect(button).toBeEnabled();
return button;
}, untilLoaded),
);
if (args.accountCreate === 'success') {
await waitFor(() =>
expect(
screen.getByText(/service account created successfully/i),
).toBeVisible(),
);
}
},
};
/**
* An account deleted from its confirmation: the toast confirming it. Set
* Deleting an account to `error` or `loading` to see the dialog instead.
*/
export const AccountDeletedToast: Story = {
parameters: at(`account=${ACCOUNT}&delete-sa=true`),
play: async ({ args }): Promise<void> => {
await userEvent.click(
await waitFor(() => {
const button = screen.getByTestId('confirm-delete-btn');
expect(button).not.toHaveAttribute('aria-disabled', 'true');
expect(button).toBeEnabled();
return button;
}, untilLoaded),
);
if (args.accountDelete === 'success') {
await waitFor(() =>
expect(screen.getByText(/service account deleted/i)).toBeVisible(),
);
}
},
};
/**
* A key renamed in its dialog: the toast confirming it. Set Updating a key to
* `error` or `loading` to see the dialog instead.
*/
export const KeyUpdatedToast: Story = {
parameters: at(`${KEYS_TAB}&edit-key=factor-api-key-0`),
play: async ({ args }): Promise<void> => {
await userEvent.type(
await screen.findByPlaceholderText(
/enter key name/i,
undefined,
untilLoaded,
),
'-rotated',
);
await userEvent.click(
await waitFor(() => {
const button = within(screen.getByTestId('edit-key-modal')).getByRole(
'button',
{ name: 'Save Changes' },
);
expect(button).not.toHaveAttribute('aria-disabled', 'true');
expect(button).toBeEnabled();
return button;
}, untilLoaded),
);
if (args.keyUpdate === 'success') {
await waitFor(() =>
expect(screen.getByText(/key updated successfully/i)).toBeVisible(),
);
}
},
};
/**
* A key revoked from its edit dialog: the toast confirming it. Set Revoking a
* key to `error` or `loading` to see the confirmation instead.
*/
export const KeyRevokedFromEditToast: Story = {
parameters: at(`${KEYS_TAB}&edit-key=factor-api-key-0`),
play: async ({ args }): Promise<void> => {
await userEvent.click(
await waitFor(() => {
const button = screen.getByRole('button', { name: 'Revoke Key' });
expect(button).not.toHaveAttribute('aria-disabled', 'true');
expect(button).toBeEnabled();
return button;
}, untilLoaded),
);
const dialog = await screen.findByRole(
'dialog',
{ name: /revoke production-writer/i },
untilLoaded,
);
await userEvent.click(
await waitFor(() => {
const button = within(dialog).getByRole('button', {
name: 'Revoke Key',
});
expect(button).not.toHaveAttribute('aria-disabled', 'true');
expect(button).toBeEnabled();
return button;
}, untilLoaded),
);
if (args.keyRevoke === 'success') {
await waitFor(() =>
expect(screen.getByText(/key revoked successfully/i)).toBeVisible(),
);
}
},
};
/**
* A key revoked from its row's confirmation: the toast confirming it. Set
* Revoking a key to `error` or `loading` to see the confirmation instead.
*/
export const KeyRevokedToast: Story = {
parameters: at(`${KEYS_TAB}&revoke-key=factor-api-key-0`),
play: async ({ args }): Promise<void> => {
await userEvent.click(
await waitFor(() => {
const button = screen.getByRole('button', { name: 'Revoke Key' });
expect(button).not.toHaveAttribute('aria-disabled', 'true');
expect(button).toBeEnabled();
return button;
}, untilLoaded),
);
if (args.keyRevoke === 'success') {
await waitFor(() =>
expect(screen.getByText(/key revoked successfully/i)).toBeVisible(),
);
}
},
};
/** The new key's secret copied from the creation dialog: the toast confirming it. */
export const KeyCopiedToast: Story = {
parameters: at(`${KEYS_TAB}&add-key=true`),
play: async (): Promise<void> => {
await userEvent.type(
await screen.findByPlaceholderText(
/enter key name/i,
undefined,
untilLoaded,
),
'release-signer',
);
await userEvent.click(await screen.findByText(/create key/i));
// The copy button is the one beside the new key, which may have no name.
const key = await screen.findByText(/^sk-/, undefined, untilLoaded);
await userEvent.click(
within(key.parentElement as HTMLElement).getByRole('button'),
);
await screen.findByText(/key copied to clipboard/i);
},
};

View File

@@ -21,9 +21,15 @@ import {
myOrganizationResponse,
} from './__story_mockdata__/singleSignOn';
import {
MUTATION_OUTCOMES,
type MutationOutcome,
mutationResolver,
} from '../../stories/__story_mockdata__/mutationOutcome';
import { rolesListResponse } from '../../stories/__story_mockdata__/roles';
const DOMAINS = 'Single sign-on · domains';
const ACTIONS = 'Single sign-on · actions';
export const singleSignOnMocks = defineStoryMocks({
controls: {
@@ -45,6 +51,27 @@ export const singleSignOnMocks = defineStoryMocks({
'Whether members of the domain are made to sign in through the provider rather than with a password.',
value: true,
}),
displayNameUpdate: choiceControl<MutationOutcome>('Display name update', {
group: ACTIONS,
description:
'How the PUT behind the display name form answers. `success` raises the success toast, `loading` never answers, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
domainSave: choiceControl<MutationOutcome>('Domain save', {
group: ACTIONS,
description:
'How creating or updating a domain answers. `success` raises the created or updated toast, `loading` leaves the button spinning, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
domainDelete: choiceControl<MutationOutcome>('Domain delete', {
group: ACTIONS,
description:
'How the DELETE behind "Delete Domain" answers. `success` raises the "Domain deleted" toast, `loading` leaves the button spinning, `error` opens the error modal.',
options: MUTATION_OUTCOMES,
value: 'success',
}),
},
handlers: (values, response) => [
rest.get(
@@ -56,20 +83,23 @@ export const singleSignOnMocks = defineStoryMocks({
rest.put(
'http://localhost/api/v2/auth_domains/:id',
response.json(() => ({ status: 'success', data: { id: 'auth-domain-0' } })),
mutationResolver(values.domainSave, {
status: 'success',
data: { id: 'auth-domain-0' },
}),
),
rest.post(
'http://localhost/api/v2/auth_domains',
response.json(() => ({
mutationResolver(values.domainSave, {
status: 'success',
data: { id: 'auth-domain-new' },
})),
}),
),
rest.delete(
'http://localhost/api/v2/auth_domains/:id',
response.json(() => ({ status: 'success', data: null })),
mutationResolver(values.domainDelete),
),
// The provider form maps IdP groups onto SigNoz roles, so it reads the role
@@ -85,8 +115,9 @@ export const singleSignOnMocks = defineStoryMocks({
res(ctx.status(200), ctx.json(myOrganizationResponse())),
),
rest.put('http://localhost/api/v2/orgs/me', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(myOrganizationResponse())),
rest.put(
'http://localhost/api/v2/orgs/me',
mutationResolver(values.displayNameUpdate, myOrganizationResponse()),
),
],
config: () => ({ route: ROUTES.ORG_SETTINGS }),

View File

@@ -1,5 +1,5 @@
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';
@@ -158,3 +158,48 @@ export const DeleteDomain: Story = {
);
},
};
/**
* The org's display name saved: the toast confirming it. Set Display name
* update to `error` or `loading` to see the modal instead.
*/
export const DisplayNameUpdatedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
const input = await within(canvasElement).findByLabelText(
/display name/i,
undefined,
untilLoaded,
);
await userEvent.clear(input);
await userEvent.type(input, 'Night Watch');
await userEvent.click(
await within(canvasElement).findByRole('button', { name: /^submit$/i }),
);
if (args.displayNameUpdate === 'success') {
await waitFor(() => expect(screen.getByText(/^success$/i)).toBeVisible());
}
},
};
/**
* A domain removed: the toast confirming it. Set Domain delete to `error` or
* `loading` to see the modal instead.
*/
export const DomainDeletedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await clickFirst(canvasElement, 'auth-domain-delete');
await userEvent.click(
await screen.findByTestId(
'auth-domain-delete-confirm',
undefined,
untilLoaded,
),
);
if (args.domainDelete === 'success') {
await waitFor(() =>
expect(screen.getByText(/domain deleted successfully/i)).toBeVisible(),
);
}
},
};

View File

@@ -17,7 +17,9 @@ import {
metricsRetentionResponse,
RETENTION_STATUSES,
tracesRetentionResponse,
DOMAIN_UPDATES,
WORKSPACE_URLS,
type DomainUpdate,
type WorkspaceUrl,
} from './__story_mockdata__/workspace';
@@ -46,6 +48,13 @@ export const workspaceMocks = defineStoryMocks({
options: WORKSPACE_URLS,
value: 'custom',
}),
domainUpdate: choiceControl<DomainUpdate>('Domain update', {
group: DOMAIN,
description:
'How the PUT behind "Apply Changes" answers. `success` raises the "Workspace URL updated" toast, `loading` never answers and leaves the button spinning, `error` shows the failure in the modal.',
options: DOMAIN_UPDATES,
value: 'success',
}),
},
handlers: (values, response) => [
// One path, two signals: the type the page asks for is a query param.
@@ -75,6 +84,22 @@ export const workspaceMocks = defineStoryMocks({
rest.get('http://localhost/api/v2/zeus/hosts', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(hostsResponse(values.workspaceUrl))),
),
rest.put('http://localhost/api/v2/zeus/hosts', (_req, res, ctx) => {
if (values.domainUpdate === 'loading') {
return res(ctx.delay('infinite'));
}
return values.domainUpdate === 'error'
? res(
ctx.status(409),
ctx.json({
status: 'error',
error: { code: 'already_exists', message: 'Subdomain is taken' },
}),
)
: res(ctx.status(204));
}),
],
config: () => ({ route: ROUTES.SETTINGS }),
});

View File

@@ -1,5 +1,5 @@
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';
@@ -72,3 +72,45 @@ export const CustomDomainMenu: Story = {
await screen.findByRole('menu');
},
};
/**
* "Edit workspace link" applied: the toast announcing the new URL, raised once
* the PUT answers. Set Domain update to `error` or `loading` to see the modal
* instead.
*/
export const CustomDomainUpdatedToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /edit workspace link/i },
{ timeout: 10000 },
),
);
const input = await screen.findByRole('textbox');
await userEvent.clear(input);
await userEvent.type(input, 'watchers');
await userEvent.click(
await screen.findByRole('button', { name: /apply changes/i }),
);
if (args.domainUpdate === 'success') {
await waitFor(() =>
expect(screen.getByText(/workspace url is being updated/i)).toBeVisible(),
);
}
},
};
/** The license key copied: the toast confirming it. */
export const LicenseKeyCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'license-key-row-copy-btn',
{},
{ timeout: 10000 },
),
);
await screen.findByText(/license key copied to clipboard/i);
},
};

View File

@@ -84,6 +84,10 @@ const CUSTOM_HOST = {
export const WORKSPACE_URLS = ['default', 'custom'] as const;
export const DOMAIN_UPDATES = ['success', 'loading', 'error'] as const;
export type DomainUpdate = (typeof DOMAIN_UPDATES)[number];
export type WorkspaceUrl = (typeof WORKSPACE_URLS)[number];
/**

View File

@@ -0,0 +1,36 @@
import type { DefaultBodyType, ResponseComposition, RestContext } from 'msw';
import type { MockResolver } from '@/storybook/msw/types';
export const MUTATION_OUTCOMES = ['success', 'loading', 'error'] as const;
export type MutationOutcome = (typeof MUTATION_OUTCOMES)[number];
const FAILURE = {
status: 'error',
error: { code: 'internal', message: 'The request failed' },
};
/** The answer for an outcome: the body, never answering, or a 500. */
export const mutationResponse = (
outcome: MutationOutcome,
body: DefaultBodyType,
res: ResponseComposition<DefaultBodyType>,
ctx: RestContext,
): ReturnType<typeof res> => {
if (outcome === 'loading') {
return res(ctx.delay('infinite'));
}
return outcome === 'error'
? res(ctx.status(500), ctx.json(FAILURE))
: res(ctx.status(200), ctx.json(body));
};
export const mutationResolver =
(
outcome: MutationOutcome,
body: DefaultBodyType = { status: 'success', data: null },
): MockResolver =>
(_req, res, ctx) =>
mutationResponse(outcome, body, res, ctx);

View File

@@ -12,6 +12,7 @@ import {
import type { SpantypesPostableTraceAggregationsDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { screen, userEvent, within } from 'storybook/test';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
@@ -50,6 +51,12 @@ import {
const TRACE = 'Trace · trace';
const PANEL = 'Trace · span panel';
const EXPORT = 'Trace · export';
const EXPORT_STATES = ['success', 'loading', 'error'] as const;
type ExportState = (typeof EXPORT_STATES)[number];
const PANEL_POSITIONS = [
SpanDetailVariant.DOCKED_RIGHT,
SpanDetailVariant.DOCKED,
@@ -133,6 +140,13 @@ export const traceDetailsMocks = defineStoryMocks({
value: 8,
max: 20,
}),
exportState: choiceControl<ExportState>('Trace export', {
group: EXPORT,
description:
'How export_raw_data answers behind Download trace. `success` raises "Export completed successfully", `loading` never answers so the download panel stays open (cancelling it raises "Export cancelled"), `error` raises "Failed to download trace".',
options: EXPORT_STATES,
value: 'success',
}),
},
handlers: (values, response) => {
const trace = {
@@ -142,6 +156,20 @@ export const traceDetailsMocks = defineStoryMocks({
};
return [
rest.post('http://localhost/api/v1/export_raw_data', (_req, res, ctx) => {
if (values.exportState === 'loading') {
return res(ctx.delay('infinite'));
}
return values.exportState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(
ctx.status(200),
ctx.set('Content-Type', 'text/csv'),
ctx.body('trace_id,span_id\n'),
);
}),
rest.post(
'http://localhost/api/v4/traces/:traceId/waterfall',
response.json(() =>
@@ -259,3 +287,56 @@ export const traceDetailsMocks = defineStoryMocks({
set(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, values.panelPosition);
},
});
/** The waterfall renders once the trace resolves, which outlasts the 1s default. */
export const untilLoaded = { timeout: 15_000 };
/** Opens the pretty view's action menu on the first row that offers `item`. */
export const openRowMenu = async (
canvasElement: HTMLElement,
item: string,
): Promise<void> => {
await within(canvasElement).findAllByText(
/rpc\.method/,
undefined,
untilLoaded,
);
const triggers = Array.from(
canvasElement.querySelectorAll<HTMLElement>('.pretty-view__actions'),
);
for (const trigger of triggers) {
// eslint-disable-next-line no-await-in-loop
await userEvent.click(trigger);
// eslint-disable-next-line no-await-in-loop
const match = await screen
.findByRole('menuitem', { name: item })
.catch(() => null);
if (match) {
await userEvent.click(match);
return;
}
// eslint-disable-next-line no-await-in-loop
await userEvent.keyboard('{Escape}');
}
throw new Error(`no row offers "${item}"`);
};
export const startDownload = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Trace options' },
untilLoaded,
),
);
await userEvent.click(await screen.findByTestId('download-trace-submenu'));
await userEvent.click(await screen.findByTestId('download-trace-csv'));
};

View File

@@ -1,12 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { fireEvent, screen, userEvent, waitFor, within } from 'storybook/test';
import {
expect,
fireEvent,
screen,
userEvent,
waitFor,
within,
} from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { traceDetailsMocks } from './TraceDetailsV3.stories.mocks';
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
import {
openRowMenu,
startDownload,
traceDetailsMocks,
untilLoaded,
} from './TraceDetailsV3.stories.mocks';
import TraceDetailsV3 from '../index';
type TraceDetailsArgs = PageStoryArgs<typeof traceDetailsMocks>;
@@ -83,9 +96,6 @@ export const BottomStripLoading: Story = {
args: { bottomStrip: true, dataState: 'loading' },
};
/** The waterfall renders once the trace resolves, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* Turns on Highlight errors, which writes `has_error = true` into the filter and
* expands the bar to show it.
@@ -190,3 +200,106 @@ 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/);
},
};
/** The span's trace id clicked in the panel, which copies it. */
export const TraceIdCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: STORY_TRACE_ID },
untilLoaded,
),
);
await expect(
await screen.findByText('Trace ID copied to clipboard'),
).toBeVisible();
},
};
/** "Copy Value" picked from a row of the span panel's pretty view. */
export const PrettyViewValueCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openRowMenu(canvasElement, 'Copy Value');
await expect(await screen.findByText('Copied to clipboard')).toBeVisible();
},
};
/** The copy-link button on a waterfall row, which copies the span's URL. */
export const SpanLinkCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [row] = await within(canvasElement).findAllByTestId(
/^cell-0-/,
undefined,
untilLoaded,
);
await fireEvent.mouseOver(row);
// The hover re-renders the row, so the button has to be looked up after it settles.
await new Promise((resolve) => {
setTimeout(resolve, 300);
});
const [hovered] = within(canvasElement).getAllByTestId(/^cell-0-/);
// The row's hover actions have no accessible name; copy link comes first.
const [link] = within(hovered).getAllByRole('button');
await fireEvent.click(link);
await waitFor(() =>
expect(screen.getByText('Copied to clipboard')).toBeVisible(),
);
},
};
/** "Download trace" as CSV, finished. */
export const TraceExportedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startDownload(canvasElement);
},
};
/** "Download trace" whose export request fails. */
export const TraceExportErrorToast: Story = {
args: { exportState: 'error' },
// The deliberate 500 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await startDownload(canvasElement);
},
};
/** "Download trace" cancelled from the download panel while the export runs. */
export const TraceExportCancelledToast: Story = {
args: { exportState: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await startDownload(canvasElement);
await userEvent.click(await screen.findByTestId('trace-download-cancel'));
},
};

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

@@ -116,6 +116,10 @@ Storybook fills the seams with:
because elapsed-time code reads it — `lodash.debounce` compares two readings
to decide its trailing call is due, and a frozen one leaves every debounced
input in the app filtering nothing. `?storyClock=live` opts out.
- A speech recognizer in place of the browser's, also in
`.storybook/preview-head.html`, because Storybook has no microphone. It
starts listening on `start()` and hears only what a `play` passes to
`webkitSpeechRecognition.listening.hear(text)`.
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Trash2, TriangleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Toaster, toast } from '@signozhq/ui/sonner';
import { toast } from '@signozhq/ui/sonner';
import MessageTip from '@/components/MessageTip';
@@ -103,7 +103,6 @@ function FeedbackFixture({ state }: FeedbackFixtureProps): JSX.Element {
</ConfirmDialog>
</>
)}
<Toaster closeButton position="top-right" />
</>
);
}

View File

@@ -2,6 +2,7 @@ import { composeStoryMocks } from '../controls/composeStoryMocks';
import { appShellMocks } from './appShellMocks';
import { authzMocks } from './authzMocks';
import { nozMocks } from './nozMocks';
import { toastMocks } from './toastMocks';
import { tooltipMocks } from './tooltipMocks';
/**
@@ -14,6 +15,7 @@ export const globalMocks = composeStoryMocks(
appShellMocks,
nozMocks,
tooltipMocks,
toastMocks,
);
export type GlobalMockArgs = typeof globalMocks.args;

View File

@@ -0,0 +1,25 @@
import { toggleControl } from '../controls/controls';
import { defineStoryMocks } from '../controls/defineStoryMocks';
import type { StoryMockArgs } from '../controls/types';
import { persistToasts } from '../mocks/toastsPersisted';
const TOASTS = 'Toasts';
/**
* Keeps every toast the story fires on screen, so a toast is one screenshot
* rather than a race against sonner's 4s timer. `mocks/sonner.mock.tsx` is what
* reads it.
*/
export const toastMocks = defineStoryMocks({
controls: {
toastsPersist: toggleControl('Keep toasts on screen', {
group: TOASTS,
description:
'Stops toasts from dismissing themselves. A toast that sets its own `duration` is left alone. Toasts behind a click or an async action are not fired until something reaches them, so those still need a `play`.',
value: true,
}),
},
effect: ({ toastsPersist }) => persistToasts(toastsPersist),
});
export type ToastArgs = StoryMockArgs<typeof toastMocks>;

View File

@@ -0,0 +1,71 @@
import {
Toaster as UiToaster,
toast as uiToast,
type SonnerToasterProps,
// eslint-disable-next-line signoz/no-signozhq-ui-barrel
} from '@signozhq/ui';
import { areToastsPersisted, toastDuration } from './toastsPersisted';
/**
* Replaces `@signozhq/ui/sonner` in Storybook (aliased in `.storybook/main.ts`)
* so the Toasts control can keep every toast the story fires on screen. The real
* components come from the package root for the reason `tooltip.mock.tsx` gives.
*/
const PersistentToaster = ({
toastOptions,
...props
}: SonnerToasterProps): JSX.Element => (
<UiToaster
{...props}
toastOptions={{ duration: toastDuration(), ...toastOptions }}
/>
);
/**
* Calls pass their options second, `toast.promise` and `toast.custom` included.
* A `duration` the call set itself (`CustomDomainSettings` asks for 5s) would
* outlive the control otherwise, so the control wins while it is on.
*/
const withPersistedOptions = (args: unknown[]): unknown[] => {
if (!areToastsPersisted()) {
return args;
}
const [first, options, ...rest] = args;
return [first, { ...(options as object), duration: Infinity }, ...rest];
};
const persistentToast = new Proxy(uiToast, {
apply: (target, thisArg, args): unknown =>
Reflect.apply(target, thisArg, withPersistedOptions(args)),
get: (target, property, receiver): unknown => {
const value = Reflect.get(target, property, receiver);
if (
typeof value !== 'function' ||
property === 'dismiss' ||
property === 'getHistory' ||
property === 'getToasts'
) {
return value;
}
return (...args: unknown[]): unknown =>
value.apply(target, withPersistedOptions(args));
},
});
const sonnerModule: typeof import('@signozhq/ui/sonner') = {
Toaster: PersistentToaster,
toast: persistentToast,
};
export const { Toaster, toast } = sonnerModule;
export type {
SonnerToasterProps,
ToasterProps,
// eslint-disable-next-line signoz/no-signozhq-ui-barrel
} from '@signozhq/ui';

View File

@@ -0,0 +1,15 @@
let persisted = true;
/** Set from the Toasts control, through `globals/toastMocks.ts`. */
export const persistToasts = (value: boolean): void => {
persisted = value;
};
/**
* Sonner's default is 4s. A toast with no `duration` of its own follows the
* `Toaster`, so `Infinity` here keeps it on screen for the capture.
*/
export const toastDuration = (): number | undefined =>
persisted ? Infinity : undefined;
export const areToastsPersisted = (): boolean => persisted;

View File

@@ -7,6 +7,7 @@ import AppProviders from '@/app/AppProviders';
import AppShell from '@/app/AppShell';
import type { AppLayer } from '@/app/types';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import { Toaster } from '@signozhq/ui/sonner';
import AppLayout from 'container/AppLayout';
import history from 'lib/history';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
@@ -50,7 +51,10 @@ const storyRouter: AppLayer = (children) => (
const appLayout: AppLayer = (children) => <AppLayout>{children}</AppLayout>;
const bareLayout: AppLayer = (children) => (
<TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>
{children}
<Toaster />
</TooltipProvider>
);
function StorybookProviders({