Compare commits

...

2 Commits

Author SHA1 Message Date
Vinicius Lourenço
5d2e6d12e4 chore(storybook): add more stories for specific pages/components (#13001)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

This PR adds a few more stories for:

- track query builder (v5 and old v3/v4)
- track few missing states of dashboard/planned downtime/ai
observability

I'm using these stories to help me track the changes to adopt the new
version of our component library.

#### Issues closed by this PR

Related to https://github.com/SigNoz/engineering-pod/issues/6157

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

A few stories as example:

| Header |
|--------|
| <img width="1680" height="1329" alt="image"
src="https://github.com/user-attachments/assets/15fa2e9b-d3aa-4bcf-8357-1e196220c33a"
/> |
| <img width="1680" height="1329" alt="image"
src="https://github.com/user-attachments/assets/89ccc4b2-8b2c-4c51-9402-e0899dc99a09"
/> |
| <img width="1680" height="1731" alt="image"
src="https://github.com/user-attachments/assets/cd876b83-2472-4d05-ba19-4e67841cbb0e"
/> |
| <img width="1680" height="1357" alt="image"
src="https://github.com/user-attachments/assets/8fddf06c-060a-4e44-b340-5102d9020f5d"
/> |
| <img width="1680" height="1329" alt="image"
src="https://github.com/user-attachments/assets/0755b400-92eb-4304-8125-dfc62f3b564d"
/> |
2026-10-12 01:22:40 +00:00
Nikhil Soni
6b13794728 feat(promote): support indexes on trace attributes and remove prefix requirement in paths (#13021)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Stacked on #12839.

## What this does

**Per-path indexes for trace attributes**
The `traces/attribute` domain now supports `indexes`, same as logs body.
Promoted attributes are indexed on `attributes_promoted`, unpromoted
ones on `attributes` — so indexing without promoting works, like it does
for logs body.

Trace index expressions are plain sub-column casts, e.g.
`attributes_promoted.http.route::String`.

**Bare paths in every domain (breaking)**
The `body.` prefix is dropped — the API URL already names the context,
so paths are bare attribute names everywhere: `body.user.name` →
`user.name`. Existing promotions stay valid (paths were always stored
bare). Prefixed paths (`body.`, `attributes.`, …) are rejected with a
guiding error.
2026-10-09 15:05:01 +00:00
115 changed files with 7910 additions and 423 deletions

View File

@@ -13691,7 +13691,7 @@ paths:
post:
deprecated: false
description: This endpoint promotes paths of JSON columns to their promoted
columns. Each path names its promotion domain with its signal and context,
columns. Each path names its promotion target with its signal and context,
e.g. traces/attribute.
operationId: PromotePaths
requestBody:
@@ -13706,7 +13706,7 @@ paths:
- fieldDataType: string
granularity: 1
type: ngrambf_v1(4, 1024, 2, 0)
path: body.user.name
path: user.name
promote: true
signal: logs
traces_attribute:

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

@@ -146,7 +146,7 @@ export const invalidateListPromotedPaths = async (
};
/**
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion domain with its signal and context, e.g. traces/attribute.
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
* @summary Promote paths
*/
export const promotePaths = (

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 {
@@ -51,6 +52,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,
@@ -134,6 +141,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 = {
@@ -143,6 +157,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(() =>
@@ -267,3 +295,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';

Some files were not shown because too many files have changed in this diff Show More