Compare commits

...

6 Commits

Author SHA1 Message Date
Vinícius Lourenço
49066ad192 test(storybook): cover workspace domain conflict and onboarding facing issues 2026-11-10 18:17:45 -03:00
Vinícius Lourenço
c0d4d22046 test(storybook): cover invite roles and move members plays to a play file 2026-11-10 18:17:41 -03:00
Vinícius Lourenço
141a420509 test(storybook): add changelog modal stories for cloud and self-hosted 2026-11-10 18:17:33 -03:00
Vinícius Lourenço
e487b0e16e test(stories): cover the channel edit form, editable and read-only 2026-11-10 14:37:54 -03:00
Vinícius Lourenço
b32e6689ef test(stories): add K8s drawer depth and pods log details stories 2026-11-09 21:39:13 -03:00
Manika Malhotra
5c067d75fc test(stories): cover the states the component migration changes 2026-11-09 21:39:13 -03:00
42 changed files with 1856 additions and 456 deletions

View File

@@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import { LicensePlatform } from 'types/api/licensesV3/getActive';
import { USER_ROLES } from 'types/roles';
import { fn } from 'storybook/test';
import { storyChangelog } from './__story_mockdata__/changelog';
import {
showsCloudChangelog,
showsSelfHostedChangelog,
} from './__story_mockdata__/changelogModal.play';
import ChangelogModal from '../ChangelogModal';
const { activeLicense } = createAppContextMock(USER_ROLES.ADMIN);
/** `AppLayout` opens it one second after load, for a version the user has not seen. */
const meta = {
title: 'Components/Changelog Modal',
component: ChangelogModal,
tags: ['play'],
parameters: { signoz: {} },
args: {
changelog: storyChangelog,
onClose: fn(),
},
} satisfies Meta<typeof ChangelogModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Cloud workspaces update on their own, so the footer has no update actions. */
export const Cloud: Story = {
play: showsCloudChangelog,
};
/** Self-hosted workspaces get "Skip for now" and "Update my workspace". */
export const SelfHosted: Story = {
parameters: {
signoz: {
appContext: {
activeLicense: activeLicense && {
...activeLicense,
platform: LicensePlatform.SELF_HOSTED,
},
},
},
},
play: showsSelfHostedChangelog,
};

View File

@@ -0,0 +1,62 @@
import {
ChangelogSchema,
DeploymentType,
} from 'types/api/changelog/getChangelogByVersion';
const TIMESTAMP = '2026-11-03T12:00:00Z';
function feature(
id: number,
title: string,
description: string,
): ChangelogSchema['features'][number] {
return {
id,
documentId: `feature-${id}`,
title,
description,
media: null,
sort_order: id,
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
publishedAt: TIMESTAMP,
deployment_type: DeploymentType.ALL,
};
}
/** Long enough to scroll, so the "Scroll for more" button shows. */
export const storyChangelog: ChangelogSchema = {
id: 1,
documentId: 'changelog-v0.99.0',
version: 'v0.99.0',
release_date: '2026-11-03',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
publishedAt: TIMESTAMP,
features: [
feature(
1,
'Dashboard sections',
'Group panels into **sections** you can collapse, rename and drag. Existing dashboards move into a single section the first time you edit them.',
),
feature(
2,
'Service accounts',
'Create service accounts with their own API keys and roles. Keys can expire, and you can revoke them without touching the account. See the [docs](https://signoz.io/docs/) for the API.',
),
feature(
3,
'Faster log search',
'Full-text search over log bodies now uses a token index:\n\n- queries over a day of logs return up to 4x faster\n- `has()` and `hasToken()` use the same index\n- the old behaviour stays behind a setting for a release',
),
feature(
4,
'Routing policies',
'Send an alert to a channel based on its labels instead of picking the channel in every rule. Policies are evaluated in order and the first match wins.',
),
],
bug_fixes:
'- Fixed the time picker losing the selected range after a refresh.\n- Fixed trace waterfall rows overlapping when a span name wraps.\n- Fixed CSV export dropping columns with dots in their names.',
maintenance:
'- Upgraded the OpenTelemetry collector to the latest release.\n- Reduced the memory used by the query service on large dashboards.',
};

View File

@@ -0,0 +1,19 @@
import { expect, screen, waitFor } from 'storybook/test';
const TITLE = /What’s New ⎯ Changelog : November 3, 2026/;
export const showsCloudChangelog = async (): Promise<void> => {
await screen.findByRole('dialog', { name: TITLE });
await expect(
screen.queryByRole('button', { name: /Skip for now/ }),
).not.toBeInTheDocument();
};
export const showsSelfHostedChangelog = async (): Promise<void> => {
await screen.findByRole('dialog', { name: TITLE });
await waitFor(() =>
expect(
screen.getByRole('button', { name: /Update my workspace/ }),
).toBeVisible(),
);
};

View File

@@ -98,6 +98,15 @@ export const noSavedFiltersHandler = rest.get(
),
);
export const noSavedTracesFiltersHandler = rest.get(
'http://localhost/api/v2/quick_filters/traces',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.traces, [])),
),
);
export const loadingFiltersHandlers = [
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
res(ctx.delay('infinite')),

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { ComponentProps, ComponentType } from 'react';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import type { GlobalMockArgs } from '@/storybook/globals';
@@ -16,8 +17,9 @@ import {
queryBuilder,
selectedServiceQueryBuilder,
noSavedFiltersHandler,
noSavedTracesFiltersHandler,
} from './QuickFilters.stories.mocks';
import { QuickFiltersSource } from '../types';
import { FiltersType, QuickFiltersSource } from '../types';
const meta = {
title: 'Components/Quick Filters',
@@ -110,6 +112,29 @@ export const SelectedExpandedCheckbox: Story = {
},
};
/** The duration min/max row, which a checkbox-only config never reaches. */
export const DurationFilter: Story = {
args: {
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
config: [
...checkboxConfig,
{
attributeKey: {
dataType: DataTypes.Float64,
key: 'duration_nano',
type: '',
},
defaultOpen: true,
title: 'Duration',
type: FiltersType.DURATION,
},
],
},
parameters: {
msw: { handlers: [noSavedTracesFiltersHandler, ...handlers] },
},
};
/**
* Every tooltip the panel renders, held open: the reveal on each truncated
* filter value. Nothing bounds those values, so the panel is answered with

View File

@@ -16,13 +16,42 @@ import {
} from './K8sBaseDetails.stories.mocks';
import type { K8sBaseDetailsProps } from './types';
import {
DrawerHistoryEntry,
getDrawerHistoryDepth,
pushDrawerHistory,
resetDrawerHistory,
useDrawerHistoryStore,
} from './useDrawerHistoryStore';
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
K8sBaseDetailsProps<unknown>;
K8sBaseDetailsProps<unknown> & { earlierSteps?: number };
const TRAIL: DrawerHistoryEntry[] = [
{
category: InfraMonitoringEntity.CLUSTERS,
label: 'mgmt',
queryKeyPrefix: 'cluster',
},
{
category: InfraMonitoringEntity.NAMESPACES,
label: 'generator',
queryKeyPrefix: 'namespace',
},
{
category: InfraMonitoringEntity.DEPLOYMENTS,
label: 'app-demo-loadgenerator',
queryKeyPrefix: 'deployment',
},
{
category: InfraMonitoringEntity.NODES,
label: NODE_NAME,
queryKeyPrefix: 'node',
},
].map(({ category, label, queryKeyPrefix }) => ({
params: { selectedItem: label, category },
category,
label,
entity: { name: label },
queryKeyPrefix,
}));
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
@@ -33,19 +62,13 @@ const meta = {
decorators: [
(Story, context): JSX.Element => {
// The back control appears only for a resource opened from another
const depth = getDrawerHistoryDepth();
if (context.args.openedFromANode && depth === 0) {
pushDrawerHistory({
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
category: InfraMonitoringEntity.NODES,
label: NODE_NAME,
entity: { nodeName: NODE_NAME },
queryKeyPrefix: 'node',
const steps =
context.args.earlierSteps ?? (context.args.openedFromANode ? 1 : 0);
if (getDrawerHistoryDepth() !== steps) {
useDrawerHistoryStore.setState({
entries: TRAIL.slice(TRAIL.length - steps),
});
}
if (!context.args.openedFromANode && depth > 0) {
resetDrawerHistory();
}
return <Story />;
},
@@ -60,3 +83,19 @@ type Story = StoryObj<DrawerArgs>;
export const Default: Story = {
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
};
/** A pod drilled through a cluster, namespace, deployment and node, so the back control names the node. */
export const DeepTrail: Story = {
args: {
...podEntityConfig.details,
earlierSteps: 4,
} as Partial<DrawerArgs> as DrawerArgs,
};
/** A pod opened from the list, so the header shows the close control in place of back. */
export const OpenedFromTheList: Story = {
args: {
...podEntityConfig.details,
openedFromANode: false,
} as Partial<DrawerArgs> as DrawerArgs,
};

View File

@@ -139,6 +139,35 @@ export const AwaitingClarification: Story = {
args: { agent: 'awaiting-clarification', contents: BRIEF },
};
/** The severity select open, including the free-text option the form's Select renders. */
export const ClarificationSelectOpen: Story = {
args: { agent: 'awaiting-clarification', contents: BRIEF },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
// Open from the keyboard, and retry: the message list remounts its items
// while it measures the thread, which can close the popup again. The
// "Other" checkbox in the Notify field carries the same text, so assert
// on the popup's option role.
await waitFor(async () => {
const severityLabel = canvas.getByText('Severity');
const field = severityLabel.closest('div');
if (!field) {
throw new Error('Severity field is not in the clarification form');
}
within(field).getByRole('combobox').focus();
await userEvent.keyboard('{Enter}');
await screen.findByRole(
'option',
{ name: 'Other (type your own)' },
{ timeout: 1_000 },
);
}, untilLoaded);
},
};
/** Reopening the page on a thread that has not come back yet. */
export const LoadingThread: Story = {
args: { dataState: 'loading' },
@@ -231,6 +260,23 @@ export const NegativeFeedback: Story = {
},
};
/** Renaming a thread: the title input the row swaps in. */
export const ConversationRename: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickUntil(async () => {
const [actions] = await within(canvasElement).findAllByLabelText(
/conversation actions/i,
undefined,
untilLoaded,
);
return actions;
}, /^Rename$/);
await userEvent.click(screen.getByText('Rename'));
await within(canvasElement).findByDisplayValue(/checkout p99 regression/i);
},
};
/** What a conversation row offers: rename, a link to it, and archiving. */
export const ConversationActions: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -21,6 +21,10 @@ import {
WEBHOOK_AUTHS,
type WebhookAuthChoice,
} from '../../stories/__story_mockdata__/alerts';
import {
recordChannelTestRequest,
resetChannelTestRequests,
} from './__story_mockdata__/channelsEdit';
const STORY_CHANNEL_ID = '1';
@@ -87,6 +91,13 @@ export const channelsEditMocks = defineStoryMocks({
: resolveChannelActionSuccess,
),
rest.post(
'http://localhost/api/v2/notification_channels/test',
async (req) => {
recordChannelTestRequest(await req.json());
},
),
rest.post(
'http://localhost/api/v2/notification_channels/test',
values.testOutcome === 'fails'
@@ -95,4 +106,5 @@ export const channelsEditMocks = defineStoryMocks({
),
],
config: () => ({ route: `/alerts/channels/edit/${STORY_CHANNEL_ID}` }),
effect: () => resetChannelTestRequests(),
});

View File

@@ -4,6 +4,7 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { expectEditableForm } from './__story_mockdata__/channelsEdit.play';
import { channelsEditMocks } from './ChannelsEdit.stories.mocks';
import AlertList from '../../index';
@@ -36,8 +37,10 @@ type Story = StoryObj<ChannelsEditArgs>;
/**
* A saved notification channel opened for editing: the name and the type are
* fixed, and the integration's own settings are filled from what was stored.
* The Channel type control swaps the integration, and typing into any field
* unlocks Save.
*/
export const Default: Story = {};
export const Default: Story = { play: expectEditableForm };
/** Mutation: clearing the required webhook URL surfaces the form's validation feedback. */
export const InvalidRequiredFields: Story = {
@@ -110,21 +113,25 @@ export const SaveFailure: Story = {
*/
export const PagerDuty: Story = {
args: { channelType: 'pagerduty' },
play: expectEditableForm,
};
/** The webhook channel, saved with basic auth on the outgoing request. */
export const Webhook: Story = {
args: { channelType: 'webhook' },
play: expectEditableForm,
};
/** The webhook channel saved with a bearer token: only the token field shows. */
export const WebhookBearerToken: Story = {
args: { channelType: 'webhook', webhookAuth: 'bearer' },
play: expectEditableForm,
};
/** The webhook channel saved without credentials: the auth option is None. */
export const WebhookWithoutAuth: Story = {
args: { channelType: 'webhook', webhookAuth: 'none' },
play: expectEditableForm,
};
/**
@@ -133,6 +140,7 @@ export const WebhookWithoutAuth: Story = {
*/
export const Opsgenie: Story = {
args: { channelType: 'opsgenie' },
play: expectEditableForm,
};
/**
@@ -141,6 +149,7 @@ export const Opsgenie: Story = {
*/
export const Email: Story = {
args: { channelType: 'email' },
play: expectEditableForm,
};
/**
@@ -149,11 +158,13 @@ export const Email: Story = {
*/
export const MicrosoftTeams: Story = {
args: { channelType: 'msteams' },
play: expectEditableForm,
};
/** The Google Chat channel, filled from the space's incoming webhook. */
export const GoogleChat: Story = {
args: { channelType: 'googlechat' },
play: expectEditableForm,
};
/**
@@ -163,7 +174,8 @@ export const GoogleChat: Story = {
*/
export const Jira: Story = {
args: { channelType: 'jira' },
play: async ({ canvasElement }): Promise<void> => {
play: async ({ canvasElement, args }): Promise<void> => {
await expectEditableForm({ canvasElement, args });
// the advanced fields have their own card now, so nothing has to be
// expanded before they can be read
await expect(
@@ -183,7 +195,8 @@ export const Jira: Story = {
*/
export const JiraSelfHosted: Story = {
args: { channelType: 'jira', jiraDeployment: 'selfhosted' },
play: async ({ canvasElement }): Promise<void> => {
play: async ({ canvasElement, args }): Promise<void> => {
await expectEditableForm({ canvasElement, args });
const canvas = within(canvasElement);
await expect(
await canvas.findByTestId('jira-pat-textbox', undefined, {
@@ -202,6 +215,7 @@ export const JiraSelfHosted: Story = {
*/
export const JiraServiceManagementOps: Story = {
args: { channelType: 'jsmops' },
play: expectEditableForm,
};
/**
@@ -210,9 +224,11 @@ export const JiraServiceManagementOps: Story = {
*/
export const IncidentIO: Story = {
args: { channelType: 'incidentio' },
play: expectEditableForm,
};
/** A Telegram channel, with its bot token masked behind the password field. */
export const Telegram: Story = {
args: { channelType: 'telegram' },
play: expectEditableForm,
};

View File

@@ -0,0 +1,146 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { expect, userEvent, waitFor, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { channelSpec } from '../../../stories/__story_mockdata__/alerts';
import { PAGE_LOAD } from '../../../stories/channelAuthz';
import type { channelsEditMocks } from '../ChannelsEdit.stories.mocks';
import { channelTestRequests } from './channelsEdit';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof channelsEditMocks>;
}
/** Native text controls only: the switches and radios keep a hidden input of their own. */
const FIELD_SELECTOR =
'form input:not([aria-hidden="true"]):not([type="hidden"]), form textarea';
const allFields = (canvasElement: HTMLElement): HTMLElement[] =>
Array.from(canvasElement.querySelectorAll<HTMLElement>(FIELD_SELECTOR));
/** A saved channel's name is fixed, so it is off whether or not the form is. */
const editableFields = (canvasElement: HTMLElement): HTMLElement[] =>
allFields(canvasElement).filter((field) => field.id !== 'channel-name');
const SLACK_ROW_BUTTONS = /^slack-(field|action)-(remove-\d+|add)$/;
const slackRowButtons = (canvasElement: HTMLElement): HTMLElement[] =>
within(canvasElement).queryAllByTestId(SLACK_ROW_BUTTONS);
export const expectEditableForm = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
);
await waitFor(
() => expect(editableFields(canvasElement).length).toBeGreaterThan(0),
PAGE_LOAD,
);
for (const field of editableFields(canvasElement)) {
await expect(field).toBeEnabled();
}
await expect(save).toHaveAttribute('aria-disabled', 'true');
await userEvent.type(editableFields(canvasElement)[0], 'x');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
PAGE_LOAD,
);
};
/**
* The permission check answers after the form has mounted editable, so the
* fields are polled until they lock rather than read once.
*/
export const expectReadOnlyForm = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
const save = await canvas.findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
);
await waitFor(() => {
const fields = allFields(canvasElement);
void expect(fields.length).toBeGreaterThan(1);
fields.forEach((field) => {
void expect(field).toBeDisabled();
});
}, PAGE_LOAD);
await expect(save).toHaveAttribute('aria-disabled', 'true');
if (args.channelType === 'slack') {
const buttons = slackRowButtons(canvasElement);
const fieldCount = allFields(canvasElement).length;
// two field rows, one action row, and an add button for each list
await expect(buttons).toHaveLength(5);
for (const button of buttons) {
await userEvent.click(button);
}
await expect(slackRowButtons(canvasElement)).toHaveLength(5);
await expect(allFields(canvasElement)).toHaveLength(fieldCount);
}
};
/**
* Switching the mode swaps which credential fields render, so a click that
* landed would change how many fields the form holds.
*/
export const expectModeRadioLocked = async (
context: PlayContext,
radio: RegExp,
): Promise<void> => {
await expectReadOnlyForm(context);
const { canvasElement } = context;
const fieldCount = allFields(canvasElement).length;
await userEvent.click(
within(canvasElement).getByRole('radio', { name: radio }),
);
await expect(allFields(canvasElement)).toHaveLength(fieldCount);
};
export const expectWebhookAuthLocked = (context: PlayContext): Promise<void> =>
expectModeRadioLocked(context, /bearer/i);
export const expectJiraDeploymentLocked = (
context: PlayContext,
): Promise<void> => expectModeRadioLocked(context, /self-hosted/i);
export const sendSavedConfig = async (context: PlayContext): Promise<void> => {
const { canvasElement, args } = context;
await expectReadOnlyForm(context);
const test = await within(canvasElement).findByTestId('test-channel-button');
await waitFor(
() => expect(test).toHaveAttribute('aria-disabled', 'false'),
PAGE_LOAD,
);
await userEvent.click(test);
await waitFor(() => expect(channelTestRequests()).toHaveLength(1), PAGE_LOAD);
await expect(channelTestRequests()[0].config).toMatchObject({
kind: args.channelType,
spec: channelSpec(args.channelType, args.webhookAuth, args.jiraDeployment),
});
};

View File

@@ -0,0 +1,22 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type { AlertmanagertypesTestableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
/** Every test send the form made since the story mounted, oldest first. */
const testRequests: AlertmanagertypesTestableNotificationChannelDTO[] = [];
export const recordChannelTestRequest = (
request: AlertmanagertypesTestableNotificationChannelDTO,
): void => {
testRequests.push(request);
};
export const channelTestRequests =
(): readonly AlertmanagertypesTestableNotificationChannelDTO[] => testRequests;
export const resetChannelTestRequests = (): void => {
testRequests.length = 0;
};

View File

@@ -13,6 +13,12 @@ import {
READ,
UPDATE,
} from '../../../stories/channelAuthz';
import {
expectJiraDeploymentLocked,
expectReadOnlyForm,
expectWebhookAuthLocked,
sendSavedConfig,
} from '../__story_mockdata__/channelsEdit.play';
import { channelsEditMocks } from '../ChannelsEdit.stories.mocks';
import AlertList from '../../../index';
@@ -56,18 +62,12 @@ export const NoRead: Story = {
/**
* Reading is enough to open the page, so losing `update` alone leaves it
* standing read-only: the fields go disabled and Save is locked, not hidden.
* standing read-only: every field goes disabled and Save is locked, not hidden.
* The Channel type control walks the same check across every integration.
*/
export const NoUpdate: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
);
await expectDisabled(await canvas.findByTestId('slack-channel-textbox'));
},
play: expectReadOnlyForm,
};
/**
@@ -101,17 +101,24 @@ export const NoCreate: Story = {
*/
export const ReadOnly: Story = {
args: { revoked: [UPDATE, CREATE, DELETE] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
play: async (context) => {
await expectReadOnlyForm(context);
await expectDisabled(
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
await within(context.canvasElement).findByTestId('test-channel-button'),
);
await expectDisabled(await canvas.findByTestId('test-channel-button'));
await expectDisabled(await canvas.findByTestId('slack-channel-textbox'));
},
};
/**
* Read-only keeps Test: sending is a create on the collection, so a reader who
* cannot edit the channel can still test it. The request carries the saved
* config.
*/
export const NoUpdateTest: Story = {
args: { revoked: [UPDATE] },
play: sendSavedConfig,
};
/**
* The same denial on a Telegram channel. Its fields are the kind the form gained
* last, including the only toggle a settings panel renders, so this is where a
@@ -145,6 +152,25 @@ export const TelegramReadOnly: Story = {
},
};
/**
* The same denial on a webhook saved with basic auth. The credential fields are
* off, and so is the radio that would swap them for a bearer token.
*/
export const WebhookReadOnly: Story = {
args: { channelType: 'webhook', revoked: [UPDATE, CREATE, DELETE] },
play: expectWebhookAuthLocked,
};
/**
* The same denial on a Jira Cloud channel. Every field is off, including the
* advanced ones, and so is the radio that would swap the Atlassian email and API
* token for a personal access token.
*/
export const JiraReadOnly: Story = {
args: { channelType: 'jira', revoked: [UPDATE, CREATE, DELETE] },
play: expectJiraDeploymentLocked,
};
/** No channel permission at all: the page is denied before the form exists. */
export const NoChannelAccess: Story = {
args: { revoked: NOTIFICATION_CHANNEL },

View File

@@ -978,7 +978,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
},
};
const channelSpec = (
export const channelSpec = (
type: ChannelType,
webhookAuth: WebhookAuthChoice,
jiraDeployment: JiraDeploymentChoice,

View File

@@ -63,6 +63,20 @@ const expandConfigSections: Story['play'] = async ({ canvasElement }) => {
*/
export const Default: Story = {};
/** The ClickHouse query tab, including its info callout. */
export const ClickHouseQuery: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText('ClickHouse Query', undefined, {
timeout: 15000,
}),
);
await screen.findByText(/ClickHouse query agent skill/i, undefined, {
timeout: 15000,
});
},
};
/** The create route, seeding an unsaved panel of the chosen kind. */
export const NewPanel: Story = {
args: { panel: 'new' },
@@ -83,6 +97,21 @@ export const PieChartPanel: Story = {
args: { panel: 'traffic-share' },
};
/** A new area chart: the time series options with the area filled. */
export const AreaChartPanel: Story = {
args: { panel: 'new', newPanelKind: 'area-chart' },
};
/** A new histogram, where the display options are its buckets. */
export const HistogramPanel: Story = {
args: { panel: 'new', newPanelKind: 'histogram' },
};
/** A new text panel: a markdown body in place of the query builder. */
export const TextPanel: Story = {
args: { panel: 'new', newPanelKind: 'text' },
};
/** A list panel, where the config pane is the column editor. */
export const ListPanel: Story = {
args: { panel: 'recent-logs' },

View File

@@ -105,6 +105,19 @@ export const ChartAppearance: Story = {
},
};
/** Disconnect-values in threshold mode, with an invalid duration showing the inline error. */
export const ChartAppearanceDisconnectThreshold: Story = {
play: async (context) => {
await ChartAppearance.play?.(context);
await pickSegment('panel-editor-v2-span-gaps', 'threshold');
const input = await screen.findByTestId('panel-editor-v2-span-gaps-value');
await userEvent.clear(input);
await userEvent.type(input, '5x');
await screen.findByText(/enter a valid duration/i);
},
};
/** Dashed lines under a gradient fill, with the points drawn. */
export const ChartAppearanceDashedGradient: Story = {
play: async (context) => {

View File

@@ -191,6 +191,22 @@ export const TooltipsInViewPanelModal: Story = {
},
};
/** A list or table panel's search field, expanded and filled. */
export const PanelSearchExpanded: Story = {
play: async ({ canvasElement }) => {
// Both the table and the log list render a search trigger; expand the first.
const [trigger] = await within(canvasElement).findAllByTestId(
'panel-header-search-trigger',
{},
{ timeout: 15000 },
);
await userEvent.click(trigger);
const input = await screen.findByTestId('panel-header-search-input');
await userEvent.type(input, 'error');
},
};
/**
* The dashboard's own Actions menu, open: rename, clone, lock, full screen and
* delete, the menu the toolbar's Actions button carries.
@@ -243,6 +259,34 @@ export const SectionActionsMenu: Story = {
},
};
/** Delete dashboard, the confirm dialog the Actions menu opens. */
export const DeleteDashboardDialog: Story = {
play: async (context) => {
await ActionsMenu.play?.(context);
// The row stays inert until its authz checks resolve, so one click can
// land too early; keep clicking until the confirm dialog is up.
await waitFor(
async () => {
const item = screen.queryByTestId('dashboard-action-delete');
if (item) {
await userEvent.click(item);
}
screen.getByText('This action cannot be undone.');
},
{ timeout: 10000 },
);
},
};
/** Rename section, the title dialog a section menu opens. */
export const RenameSectionDialog: Story = {
play: async (context) => {
await SectionActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Rename section'));
await screen.findByRole('dialog', { name: /rename section/i });
},
};
const openNewPanelPicker = async (
canvasElement: HTMLElement,
): Promise<void> => {

View File

@@ -97,3 +97,20 @@ export const NavSettingsMenu: Story = {
await screen.findByRole('menu');
},
};
/** Impersonation mode: the warning banner above the page content. */
export const NoAuthBanner: Story = {
args: { banner: 'no-auth' },
play: async (): Promise<void> => {
await screen.findByTestId('no-auth-banner', undefined, { timeout: 10000 });
},
};
/** The command palette, opened with the same shortcut the shell listens for. */
export const CommandPalette: Story = {
play: async (): Promise<void> => {
await userEvent.keyboard('{Meta>}k{/Meta}');
await screen.findByPlaceholderText('Search…', undefined, { timeout: 10000 });
await screen.findByText('Go to Home');
},
};

View File

@@ -69,6 +69,28 @@ export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
},
};
/** A log opened from the pod's logs tab, over the details drawer. */
export const DetailsDrawerLogDetails: 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 }),
);
const [line] = await drawer.findAllByText(
/handled request in/,
{},
{ timeout: 10000 },
);
await userEvent.click(line);
await screen.findByTestId('log-detail-drawer', {}, { timeout: 10000 });
},
};
/** The copy button beside the pod name in the drawer, which toasts the copy. */
export const PodNameCopiedToast: StoryObj<PodsArgs> = {
args: { drawer: true },

View File

@@ -117,6 +117,43 @@ export const ConnectAccount: Story = {
},
};
/** Azure's connect dialog, which the AWS regions form does not open. */
export const AzureConnectAccount: Story = {
args: { provider: 'azure', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/integrate now/i,
undefined,
untilLoaded,
),
);
await screen.findByRole('dialog', undefined, untilLoaded);
},
};
/** The GCP region combobox, opened from the connect drawer. */
export const GcpRegionDropdown: Story = {
args: { provider: 'gcp', accounts: 0 },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/integrate now/i,
undefined,
untilLoaded,
),
);
await userEvent.click(
await screen.findByTestId(
'gcp-deployment-region-select',
undefined,
untilLoaded,
),
);
await screen.findByRole('listbox');
},
};
/** The regions an already-connected account collects from. */
export const EditAccount: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -14,6 +14,7 @@ import {
type GroupSave,
MAPPING_GROUP_MAX,
spanMapperGroupsResponse,
spanMappersResponse,
wideConditionGroupsResponse,
} from './__story_mockdata__/attributeMapping';
@@ -28,6 +29,15 @@ export const wideConditionGroup = rest.get(
res(ctx.status(200), ctx.json(wideConditionGroupsResponse())),
);
export const groupMappers = rest.get(
'http://localhost/api/v1/span_mapper_groups/:groupId/span_mappers',
(req, res, ctx) =>
res(
ctx.status(200),
ctx.json(spanMappersResponse(String(req.params.groupId))),
),
);
export const attributeMappingMocks = defineStoryMocks({
controls: {
groups: countControl('Mapping groups', {

View File

@@ -7,6 +7,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import LLMObservabilityPage from '../../index';
import {
attributeMappingMocks,
groupMappers,
wideConditionGroup,
} from './AttributeMapping.stories.mocks';
@@ -79,6 +80,29 @@ export const GroupFormDrawer: Story = {
},
};
/** The drawer that adds a mapping to the first group. */
export const AddMappingDrawer: Story = {
parameters: { msw: { handlers: [groupMappers] } },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
const [firstGroup] = await canvas.findAllByTestId(
/^group-expand-/,
{},
{ timeout: 15000 },
);
await userEvent.click(firstGroup);
await userEvent.click(
await canvas.findByRole(
'button',
{ name: /add mapping/i },
{ timeout: 15000 },
),
);
await screen.findByText('New custom mapping');
},
};
/**
* 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`,

View File

@@ -5,10 +5,15 @@
import type {
ListSpanMapperGroups200,
ListSpanMappers200,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SpantypesSpanMapperOriginDTO } from 'api/generated/services/sigNoz.schemas';
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperOriginDTO,
} from 'api/generated/services/sigNoz.schemas';
const ORG_ID = 'org-signoz';
@@ -72,6 +77,38 @@ export const spanMapperGroupsResponse = (
data: { items: GROUPS.slice(0, count).map(group) },
});
/** The mappings an expanded group lists, which the groups response does not carry. */
export const spanMappersResponse = (groupId: string): ListSpanMappers200 => ({
status: 'success',
data: {
items: [
{
id: `${groupId}-mapper-1`,
groupId,
name: 'gen_ai.request.model',
enabled: true,
origin: SpantypesSpanMapperOriginDTO.user,
fieldContext: SpantypesFieldContextDTO.attribute,
config: {
sources: [
{
key: 'langchain.model',
context: SpantypesFieldContextDTO.attribute,
operation: SpantypesSpanMapperOperationDTO.copy,
priority: 0,
enabled: true,
},
],
},
createdAt: '2026-07-18T11:20:00Z',
createdBy: 'anna@signoz.io',
updatedAt: '2026-08-05T08:40:00Z',
updatedBy: 'anna@signoz.io',
},
],
},
});
const MANY_ATTRIBUTES = [
'langchain.model',
'langchain.prompt_tokens',

View File

@@ -39,6 +39,34 @@ export const NothingPriced: Story = {
args: { rules: 0 },
};
/** Models seen in spans that no rule prices yet. */
export const UnpricedModels: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'tab',
{ name: /unpriced models/i },
{ timeout: 15000 },
),
);
await screen.findByRole('tab', { name: /unpriced models/i, selected: true });
},
};
/** Creating a rule: the blank drawer, with the provider select enabled. */
export const ModelCostDrawerCreate: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /add model cost/i },
{ timeout: 15000 },
),
);
await screen.findByTestId('drawer-provider-select');
},
};
/**
* The first pricing rule's menu, open over the table: edit the rule or drop it.
*/

View File

@@ -116,6 +116,16 @@ export const AddProcessor: Story = {
},
};
/** Severity Parser is the type whose mapping fields are CSV inputs. */
export const AddSeverityParserProcessor: Story = {
play: async (context): Promise<void> => {
await AddProcessor.play?.(context);
await userEvent.click(await screen.findByText('Grok'));
await userEvent.click(await screen.findByTitle('Severity Parser'));
await screen.findByText('Parse Severity Value From');
},
};
/**
* The logs a pipeline matches, over the interval the preview asks for, with the
* simulation of its processors one click away.

View File

@@ -106,6 +106,21 @@ export const RawFormat: Story = {
args: { format: 'raw' },
};
/** The format popover: the format list, the max-lines stepper, and the font-size control. */
export const FormatMenuOpen: Story = {
args: { format: 'raw' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'periscope-btn-format-options',
undefined,
untilLoaded,
),
);
await screen.findByTestId('max-lines-per-row', undefined, untilLoaded);
},
};
/** An explorer with no configured quick filters. */
export const NoQuickFilters: Story = {
args: { quickFilters: 0 },

View File

@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { MessagingQueuesViewTypeOptions } from 'pages/MessagingQueues/MessagingQueuesUtils';
import { expect, 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';
@@ -98,6 +98,21 @@ export const DropRate: Story = {
args: { view: MessagingQueuesViewTypeOptions.DropRate },
};
/** The custom-interval input inside the evaluation-time dropdown. */
export const DropRateIntervalOpen: Story = {
args: { view: MessagingQueuesViewTypeOptions.DropRate },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText('10ms', undefined, untilLoaded),
);
await screen.findByPlaceholderText(
'Enter custom time (ms)',
undefined,
untilLoaded,
);
},
};
/** The broker, producer, consumer and JVM metrics kafka itself reports. */
export const MetricView: Story = {
args: { view: MessagingQueuesViewTypeOptions.MetricPage },

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { renderAtRoute } from '@/storybook/renderAtRoute';
import { screen, userEvent, within } from 'storybook/test';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -76,3 +76,26 @@ export const DownloadMenu: Story = {
await screen.findByRole('menu');
},
};
/** The apdex threshold popover, opened from the overview's settings control. */
export const ApDexSettingsOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
const trigger = await waitFor(
() => {
const found = canvasElement.querySelector<HTMLElement>(
'.ap-dex-settings-popover-content button',
);
if (!found) {
throw new Error('Apdex settings control is not rendered');
}
return found;
},
{ timeout: 15000 },
);
await userEvent.click(trigger);
await screen.findByText(/apdex threshold/i);
},
};

View File

@@ -86,6 +86,11 @@ export const onboardingV2Mocks = defineStoryMocks({
'http://localhost/api/v1/roles',
response.json(() => rolesListResponse(0)),
),
// Add Credit Card creates the subscription; an empty redirect keeps the
// story from opening a billing tab.
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
res(ctx.json({ status: 'success', data: { redirectURL: '' } })),
),
rest.post('http://localhost/api/v2/users', (_req, res, ctx) => {
if (values.invite === 'loading') {
return res(ctx.delay('infinite'));

View File

@@ -1,82 +1,20 @@
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 OnboardingPageV2 from '../OnboardingPageV2';
import { onboardingV2Mocks } from './OnboardingPageV2.stories.mocks';
import {
onboardingV2Mocks,
SETUP_STEPS,
type SetupStep,
} from './OnboardingPageV2.stories.mocks';
openAddCreditCard,
openInviteModal,
searchWithoutResults,
sendInvite,
walkToStep,
} from './__story_mockdata__/onboardingV2.play';
type OnboardingV2Args = PageStoryArgs<typeof onboardingV2Mocks>;
type Canvas = ReturnType<typeof within>;
/** The catalogue mounts before the first group of sources paints. */
const untilRendered = { timeout: 15_000 };
/**
* Every option is a button wrapping its own logo, so the accessible name carries
* the label twice and matches the other sources with the label as a prefix
* (`Java`, `JavaScript`). The label's own text node is the one thing that is
* exact.
*/
const chooseOption = async (canvas: Canvas, label: string): Promise<void> => {
const option = await canvas.findByText(label, undefined, untilRendered);
await userEvent.click(option.closest('button') as HTMLElement);
};
const answerDataSource = (canvas: Canvas): Promise<void> =>
chooseOption(canvas, 'Java');
const answerFramework = (canvas: Canvas): Promise<void> =>
chooseOption(canvas, 'Spring Boot');
const answerEnvironment = (canvas: Canvas): Promise<void> =>
chooseOption(canvas, 'VM');
const openConfigureProduct = async (canvas: Canvas): Promise<void> => {
await userEvent.click(
await canvas.findByRole(
'button',
{ name: /configure your product/i },
untilRendered,
),
);
};
const ANSWER_STEP = [
answerDataSource,
answerFramework,
answerEnvironment,
openConfigureProduct,
];
const advanceToSetupStep = async (
canvasElement: HTMLElement,
step: SetupStep,
): Promise<void> => {
const canvas = within(canvasElement);
// A source that arrived through the param is already on the setup
// instructions, with no question left to answer.
if (!canvas.queryByText('Select your data source')) {
return;
}
const answers = ANSWER_STEP.slice(0, SETUP_STEPS.indexOf(step));
// Sequential: each answer is what renders the question the next one reads.
await answers.reduce(
(walked, answer) => walked.then(() => answer(canvas)),
Promise.resolve(),
);
};
const pageStory = storyMocks(onboardingV2Mocks, { layout: 'app' });
/**
@@ -89,16 +27,7 @@ const meta = {
title: 'Pages/Onboarding/Add Data Source',
tags: ['play'],
component: OnboardingPageV2,
/**
* Destructuring `mount` is what makes the step control a control: Storybook
* re-runs a play function on an arg change only for a story that asks to be
* remounted, and otherwise re-renders the one it already walked.
*/
play: async ({ mount, args, canvasElement }): Promise<void> => {
await mount();
await advanceToSetupStep(canvasElement, args.step);
},
play: walkToStep,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<OnboardingV2Args>;
@@ -137,35 +66,12 @@ export const SourcePreselected: Story = {
/** The one thing the header offers besides support: bringing the team along. */
export const InviteTeammate: Story = {
play: async ({ mount, args, canvasElement }): Promise<void> => {
await mount();
await advanceToSetupStep(canvasElement, args.step);
await userEvent.click(
within(canvasElement).getByRole('button', { name: /invite a teammate/i }),
);
// antd portals the modal out of the story root.
await screen.findByText('Invite a team member', undefined, untilRendered);
},
play: openInviteModal,
};
/** A search nothing answers, where the catalogue offers to add the source instead. */
export const NoSearchResults: Story = {
play: async ({ mount, canvasElement }): Promise<void> => {
await mount();
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByPlaceholderText('Search', undefined, untilRendered),
'ledger',
);
// The search is debounced, so the miss lands a beat after the last keystroke.
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
},
play: searchWithoutResults,
};
/**
@@ -174,48 +80,14 @@ export const NoSearchResults: Story = {
* 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(),
);
}
},
play: sendInvite,
};
/**
* A cloud trial without premium support, whose Contact Support in the header asks
* for a card to unlock chat support instead of opening the chat.
*/
export const FacingIssuesAddCard: Story = {
args: { support: 'add-card' },
play: openAddCreditCard,
};

View File

@@ -0,0 +1,227 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
onboardingV2Mocks,
SETUP_STEPS,
type SetupStep,
} from '../OnboardingPageV2.stories.mocks';
type OnboardingV2Args = PageStoryArgs<typeof onboardingV2Mocks>;
interface PlayContext {
mount: () => Promise<unknown>;
args: OnboardingV2Args;
canvasElement: HTMLElement;
}
type Canvas = ReturnType<typeof within>;
/** The catalogue mounts before the first group of sources paints. */
const untilRendered = { timeout: 15_000 };
/**
* Every option is a button wrapping its own logo, so the accessible name carries
* the label twice and matches the other sources with the label as a prefix
* (`Java`, `JavaScript`). The label's own text node is the one thing that is
* exact.
*/
const chooseOption = async (canvas: Canvas, label: string): Promise<void> => {
const option = await canvas.findByText(label, undefined, untilRendered);
await userEvent.click(option.closest('button') as HTMLElement);
};
const answerDataSource = (canvas: Canvas): Promise<void> =>
chooseOption(canvas, 'Java');
const answerFramework = (canvas: Canvas): Promise<void> =>
chooseOption(canvas, 'Spring Boot');
const answerEnvironment = (canvas: Canvas): Promise<void> =>
chooseOption(canvas, 'VM');
const openConfigureProduct = async (canvas: Canvas): Promise<void> => {
await userEvent.click(
await canvas.findByRole(
'button',
{ name: /configure your product/i },
untilRendered,
),
);
};
const ANSWER_STEP = [
answerDataSource,
answerFramework,
answerEnvironment,
openConfigureProduct,
];
const advanceToSetupStep = async (
canvasElement: HTMLElement,
step: SetupStep,
): Promise<void> => {
const canvas = within(canvasElement);
// A source that arrived through the param is already on the setup
// instructions, with no question left to answer.
if (!canvas.queryByText('Select your data source')) {
return;
}
const answers = ANSWER_STEP.slice(0, SETUP_STEPS.indexOf(step));
// Sequential: each answer is what renders the question the next one reads.
await answers.reduce(
(walked, answer) => walked.then(() => answer(canvas)),
Promise.resolve(),
);
};
/**
* Destructuring `mount` is what makes the step control a control: Storybook
* re-runs a play function on an arg change only for a story that asks to be
* remounted, and otherwise re-renders the one it already walked.
*/
export const walkToStep = async ({
mount,
args,
canvasElement,
}: PlayContext): Promise<void> => {
await mount();
await advanceToSetupStep(canvasElement, args.step);
};
export const openInviteModal = async ({
mount,
args,
canvasElement,
}: PlayContext): Promise<void> => {
await mount();
await advanceToSetupStep(canvasElement, args.step);
await userEvent.click(
within(canvasElement).getByRole('button', { name: /invite a teammate/i }),
);
// antd portals the modal out of the story root.
await screen.findByText('Invite a team member', undefined, untilRendered);
};
export const searchWithoutResults = async ({
mount,
canvasElement,
}: PlayContext): Promise<void> => {
await mount();
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByPlaceholderText('Search', undefined, untilRendered),
'ledger',
);
// The search is debounced, so the miss lands a beat after the last keystroke.
await canvas.findByText(/no results for ledger/i, undefined, untilRendered);
};
export const sendInvite = async ({
mount,
args,
canvasElement,
}: PlayContext): 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(),
{ timeout: 5_000 },
);
}
};
/** Banners that turn the cloud license into a self-hosted one. */
const SELF_HOSTED_BANNERS: readonly OnboardingV2Args['banner'][] = [
'license-expired',
'license-terminated',
];
/** Only a cloud workspace without premium support is offered the card. */
const offersAddCard = (args: OnboardingV2Args): boolean =>
args.support === 'add-card' &&
args.license === 'cloud' &&
!SELF_HOSTED_BANNERS.includes(args.banner);
const ADD_CARD_DIALOG = { name: /Add Credit Card for Chat Support/i };
export const openAddCreditCard = async ({
mount,
args,
canvasElement,
}: PlayContext): Promise<void> => {
await mount();
const canvas = within(canvasElement);
if (!offersAddCard(args)) {
await canvas.findByRole(
'button',
{ name: /invite a teammate/i },
untilRendered,
);
await expect(
screen.queryByRole('dialog', ADD_CARD_DIALOG),
).not.toBeInTheDocument();
return;
}
await userEvent.click(
await canvas.findByRole(
'button',
{ name: /contact support/i },
untilRendered,
),
);
// antd portals the modal out of the story root.
await screen.findByRole('dialog', ADD_CARD_DIALOG, untilRendered);
};

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -35,3 +36,20 @@ export const ExpiredLink: Story = {
// The deliberate 410 is the state under test.
parameters: { allowConsoleErrors: true },
};
/** The mismatch callout, after the two password fields disagree and blur. */
export const PasswordMismatch: Story = {
play: async ({ canvasElement }): Promise<void> => {
const password = await screen.findByTestId('password', undefined, {
timeout: 15000,
});
const confirm = await screen.findByPlaceholderText(
/confirm your new password/i,
);
await userEvent.type(password, 'password123');
await userEvent.type(confirm, 'password456');
await userEvent.click(canvasElement);
await screen.findByText(/passwords don't match/i);
},
};

View File

@@ -197,30 +197,8 @@ export const openMember = async (
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 (
export const openInviteForm = async (
canvasElement: HTMLElement,
emails: string[],
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
@@ -229,6 +207,49 @@ export const sendInvites = async (
untilLoaded,
),
);
// The form opens with a row per seat, each carrying its own email field.
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
};
/** A click on the label closes the dropdown, where Escape would close the dialog too. */
export const closeRoles = async (): Promise<void> => {
await userEvent.click(await screen.findByText('Email address'));
};
/**
* Picks `role` in the open roles dropdown. The option's content ignores pointer
* events, so the click goes to the option around it.
*/
export const pickOpenRole = async (role: string): Promise<void> => {
const options = await screen.findAllByText(role, undefined, untilLoaded);
await userEvent.click(
options[options.length - 1].closest('.ant-select-item-option') as HTMLElement,
);
};
/** Opens the roles dropdown of the invite row at `index`. */
export const openInviteRoleDropdown = async (index: number): Promise<void> => {
const comboboxes = await screen.findAllByRole(
'combobox',
undefined,
untilLoaded,
);
await userEvent.click(comboboxes[index]);
};
const pickRole = async (index: number): Promise<void> => {
await openInviteRoleDropdown(index);
await pickOpenRole('viewer');
await closeRoles();
};
export const sendInvites = async (
canvasElement: HTMLElement,
emails: string[],
): Promise<void> => {
await openInviteForm(canvasElement);
const fields = await screen.findAllByTestId(
/^invite-email-/,
undefined,

View File

@@ -1,16 +1,31 @@
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 {
membersMocks,
openMember,
openResetLink,
sendInvites,
untilLoaded,
} from './Members.stories.mocks';
copyInviteLink,
copyResetLink,
deleteMember,
failResetLink,
filterDeletedOnly,
filterPendingOnly,
openAssignRoles,
openDeleteMemberConfirm,
openEditMember,
openFilterMenu,
openInviteMembers,
openInviteRoles,
openPendingMember,
openRevokeInviteConfirm,
revokeInvite,
saveMemberName,
searchNoResults,
selectInviteRoles,
sendInvitesPartiallyRefused,
sendInvitesToAll,
} from './__story_mockdata__/members.play';
import { membersMocks } from './Members.stories.mocks';
import SettingsPage from '../../Settings';
@@ -39,15 +54,6 @@ type Story = StoryObj<MembersArgs>;
/** One more than the twenty rows a page holds. */
const OVER_ONE_PAGE = 21;
const openPendingMember = async ({
canvasElement,
}: {
canvasElement: HTMLElement;
}): Promise<void> => {
await openMember(canvasElement, /podrick payne/i);
await screen.findByText(/invited on/i, undefined, untilLoaded);
};
/**
* Everyone with a seat in the workspace: who they are, whether they have taken
* up their invite, and when they joined.
@@ -61,68 +67,22 @@ export const AllPending: Story = {
/** Interaction: the status menu is open with the real member counts. */
export const FilterMenuOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/all members/i,
undefined,
untilLoaded,
),
);
await screen.findByText(/pending invites/i, undefined, untilLoaded);
},
play: openFilterMenu,
};
/** Interaction: the pending-invite filter narrows the real member table. */
export const PendingOnly: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/all members/i,
undefined,
untilLoaded,
),
);
await userEvent.click(
await screen.findByText(/^pending invites/i, undefined, untilLoaded),
);
// One badge per pending row.
await screen.findAllByText('INVITED', undefined, untilLoaded);
},
play: filterPendingOnly,
};
/** Interaction: the deleted-member filter exposes the read-only member rows. */
export const DeletedOnly: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/all members/i,
undefined,
untilLoaded,
),
);
await userEvent.click(
// The unfiltered table already shows a DELETED badge, so the menu option
// is matched on its own count suffix.
await screen.findByText(/^deleted ⎯/i, undefined, untilLoaded),
);
await screen.findAllByText('DELETED', undefined, untilLoaded);
},
play: filterDeletedOnly,
};
/** Empty: searching for a non-member renders the table's supported empty state. */
export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.type(
await within(canvasElement).findByPlaceholderText(
/search by name or email/i,
undefined,
untilLoaded,
),
'no-such-member',
);
await screen.findByText(/no results for/i, undefined, untilLoaded);
},
play: searchNoResults,
};
/** More members than one page holds, which is where the pager appears. */
@@ -132,10 +92,7 @@ export const Paginated: Story = {
/** A member opened up: the name and roles that can be changed from here. */
export const EditMember: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
await screen.findByPlaceholderText(/enter name/i, undefined, untilLoaded);
},
play: openEditMember,
};
/**
@@ -154,51 +111,32 @@ export const ExpiredInvite: Story = {
/** Interaction: deleting an active member is guarded by its confirmation dialog. */
export const DeleteMemberConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
await userEvent.click(
await screen.findByRole('button', { name: 'Delete Member' }),
);
await screen.findByRole('dialog', { name: 'Delete Member' });
},
play: openDeleteMemberConfirm,
};
/** Interaction: revoking a pending invite uses its distinct confirmation copy. */
export const RevokeInviteConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openPendingMember({ canvasElement });
await userEvent.click(
await screen.findByRole('button', { name: 'Revoke Invite' }),
);
await screen.findByRole('dialog', { name: 'Revoke Invite' });
},
play: openRevokeInviteConfirm,
};
/**
* The roles a member can be given. The dropdown opens off the inner combobox,
* not the wrapper the click lands on first.
*/
/** The roles a member can be given. */
export const AssignRoles: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
await userEvent.click(await screen.findByRole('combobox'));
await screen.findByText('oncall-responder', undefined, untilLoaded);
},
play: openAssignRoles,
};
/** The form that mails seats out: an email and a role per row. */
export const InviteMembers: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/invite member/i,
undefined,
untilLoaded,
),
);
// The form opens with a row per seat, each carrying its own email field.
await screen.findAllByTestId(/^invite-email-/, undefined, untilLoaded);
},
play: openInviteMembers,
};
/** The invite form with the first row's roles dropdown open. */
export const InviteRolesOpen: Story = {
play: openInviteRoles,
};
/** The invite form after three roles were picked on the first row. */
export const InviteRolesSelected: Story = {
play: selectInviteRoles,
};
/**
@@ -206,34 +144,14 @@ export const InviteMembers: Story = {
* 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(),
);
}
},
play: sendInvitesToAll,
};
/** 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(),
);
}
},
play: sendInvitesPartiallyRefused,
};
/**
@@ -241,27 +159,7 @@ export const InvitesPartiallyFailedToast: Story = {
* 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(),
);
}
},
play: saveMemberName,
};
/**
@@ -269,22 +167,7 @@ export const MemberUpdatedToast: Story = {
* 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(),
);
}
},
play: deleteMember,
};
/**
@@ -292,22 +175,7 @@ export const MemberDeletedToast: Story = {
* 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(),
);
}
},
play: revokeInvite,
};
/**
@@ -315,13 +183,7 @@ export const InviteRevokedToast: Story = {
* 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);
},
play: copyResetLink,
};
/**
@@ -329,16 +191,7 @@ export const ResetLinkCopiedToast: Story = {
* 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);
},
play: copyInviteLink,
};
/**
@@ -347,14 +200,5 @@ export const InviteLinkCopiedToast: Story = {
*/
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(),
);
}
},
play: failResetLink,
};

View File

@@ -0,0 +1,293 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
closeRoles,
membersMocks,
openInviteForm,
openInviteRoleDropdown,
openMember,
openResetLink,
pickOpenRole,
sendInvites,
untilLoaded,
} from '../Members.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof membersMocks>;
}
const openStatusFilter = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
/all members/i,
undefined,
untilLoaded,
),
);
};
export const openFilterMenu = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openStatusFilter(canvasElement);
await screen.findByText(/pending invites/i, undefined, untilLoaded);
};
export const filterPendingOnly = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openStatusFilter(canvasElement);
await userEvent.click(
await screen.findByText(/^pending invites/i, undefined, untilLoaded),
);
// One badge per pending row.
await screen.findAllByText('INVITED', undefined, untilLoaded);
};
export const filterDeletedOnly = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openStatusFilter(canvasElement);
await userEvent.click(
// The unfiltered table already shows a DELETED badge, so the menu option
// is matched on its own count suffix.
await screen.findByText(/^deleted ⎯/i, undefined, untilLoaded),
);
await screen.findAllByText('DELETED', undefined, untilLoaded);
};
export const searchNoResults = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.type(
await within(canvasElement).findByPlaceholderText(
/search by name or email/i,
undefined,
untilLoaded,
),
'no-such-member',
);
await screen.findByText(/no results for/i, undefined, untilLoaded);
};
export const openEditMember = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
await screen.findByPlaceholderText(/enter name/i, undefined, untilLoaded);
};
export const openPendingMember = async ({
canvasElement,
}: Pick<PlayContext, 'canvasElement'>): Promise<void> => {
await openMember(canvasElement, /podrick payne/i);
await screen.findByText(/invited on/i, undefined, untilLoaded);
};
export const openDeleteMemberConfirm = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
await userEvent.click(
await screen.findByRole('button', { name: 'Delete Member' }),
);
await screen.findByRole('dialog', { name: 'Delete Member' });
};
export const openRevokeInviteConfirm = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openPendingMember({ canvasElement });
await userEvent.click(
await screen.findByRole('button', { name: 'Revoke Invite' }),
);
await screen.findByRole('dialog', { name: 'Revoke Invite' });
};
export const openAssignRoles = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openMember(canvasElement, /jon snow/i);
// The dropdown opens off the inner combobox, not the wrapper the click lands on first.
await userEvent.click(await screen.findByRole('combobox'));
await screen.findByText('oncall-responder', undefined, untilLoaded);
};
export const openInviteMembers = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openInviteForm(canvasElement);
};
export const openInviteRoles = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openInviteForm(canvasElement);
await openInviteRoleDropdown(0);
await screen.findAllByText('viewer', undefined, untilLoaded);
};
export const selectInviteRoles = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await openInviteForm(canvasElement);
await openInviteRoleDropdown(0);
for (const role of ['viewer', 'editor', 'oncall-responder']) {
await pickOpenRole(role);
}
await closeRoles();
// The open list titles its options too, so the check is scoped to the selected tags.
await waitFor(
() =>
expect(
document.querySelector(
'.ant-select-selection-item[title="oncall-responder"]',
),
).toBeVisible(),
untilLoaded,
);
};
export const sendInvitesToAll = async ({
canvasElement,
args,
}: PlayContext): 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(),
);
}
};
export const sendInvitesPartiallyRefused = async ({
canvasElement,
args,
}: PlayContext): 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(),
);
}
};
export const saveMemberName = async ({
canvasElement,
args,
}: PlayContext): 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(),
);
}
};
export const deleteMember = async ({
canvasElement,
args,
}: PlayContext): 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(),
);
}
};
export const revokeInvite = async ({
canvasElement,
args,
}: PlayContext): 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(),
);
}
};
export const copyResetLink = async ({
canvasElement,
}: PlayContext): 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);
};
export const copyInviteLink = async ({
canvasElement,
}: PlayContext): 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);
};
export const failResetLink = async ({
canvasElement,
args,
}: PlayContext): 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

@@ -100,6 +100,10 @@ export const workspaceMocks = defineStoryMocks({
)
: res(ctx.status(204));
}),
rest.post('http://localhost/api/v1/subscriptions', (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
),
],
config: () => ({ route: ROUTES.SETTINGS }),
});

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 {
copyLicenseKey,
openAddCreditCardFromConflict,
openCustomDomainMenu,
updateCustomDomain,
} from './__story_mockdata__/workspace.play';
import { workspaceMocks } from './Workspace.stories.mocks';
import SettingsPage from '../../Settings';
@@ -61,16 +66,7 @@ export const NoColdStorage: Story = {
* of them is the active one.
*/
export const CustomDomainMenu: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'custom-domain-menu-trigger',
{},
{ timeout: 10000 },
),
);
await screen.findByRole('menu');
},
play: openCustomDomainMenu,
};
/**
@@ -79,38 +75,21 @@ export const CustomDomainMenu: Story = {
* 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(),
);
}
},
play: updateCustomDomain,
};
/**
* A workspace link change the instance refuses with a 409: the edit modal swaps
* Apply Changes for Contact Support, which on a cloud trial without a card opens
* the Add Credit Card modal.
*/
export const CustomDomainConflictAddCard: Story = {
args: { domainUpdate: 'error', support: 'add-card' },
parameters: { allowConsoleErrors: true },
play: openAddCreditCardFromConflict,
};
/** 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);
},
play: copyLicenseKey,
};

View File

@@ -0,0 +1,109 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { USER_ROLES } from 'types/roles';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { accessFor } from '@/storybook/access/access';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import type { workspaceMocks } from '../Workspace.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof workspaceMocks>;
}
const untilLoaded = { timeout: 10000 };
/** The domain card only renders for an admin on cloud. */
const canEditDomain = ({ args }: Pick<PlayContext, 'args'>): boolean =>
args.license === 'cloud' &&
accessFor(args.access, args.permissions, args.revoked).legacyRole ===
USER_ROLES.ADMIN;
const submitNewSubdomain = async (
canvasElement: HTMLElement,
): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /edit workspace link/i },
untilLoaded,
),
);
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 }),
);
};
export const openCustomDomainMenu = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'custom-domain-menu-trigger',
undefined,
untilLoaded,
),
);
await screen.findByRole('menu');
};
export const updateCustomDomain = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await submitNewSubdomain(canvasElement);
if (args.domainUpdate === 'success') {
await waitFor(() =>
expect(screen.getByText(/workspace url is being updated/i)).toBeVisible(),
);
}
};
export const copyLicenseKey = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'license-key-row-copy-btn',
undefined,
untilLoaded,
),
);
await screen.findByText(/license key copied to clipboard/i);
};
export const openAddCreditCardFromConflict = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
if (!canEditDomain({ args })) {
return;
}
await submitNewSubdomain(canvasElement);
if (args.domainUpdate !== 'error') {
return;
}
await userEvent.click(
await screen.findByRole('button', { name: /contact support/i }, untilLoaded),
);
// Only a plan without premium support is offered the modal over the chat widget.
if (args.support === 'add-card') {
await screen.findByRole(
'dialog',
{ name: /Add Credit Card for Chat Support/i },
untilLoaded,
);
}
};

View File

@@ -32,6 +32,7 @@ import {
timeSeriesPoints,
} from '@/storybook/msw/__story_mockdata__/queryRange';
import { traceFunnelsResponse } from '../../TracesModulePage/stories/__story_mockdata__/traces';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import {
focusSpanIndex,
@@ -221,6 +222,11 @@ export const traceDetailsMocks = defineStoryMocks({
}),
),
rest.get(
'http://localhost/api/v1/trace-funnels/list',
response.json(() => traceFunnelsResponse(3)),
),
rest.post(
'http://localhost/api/v1/span_percentile',
response.json(spanPercentilesResponse),

View File

@@ -248,6 +248,72 @@ export const SpanPercentileOpen: Story = {
},
};
/** Spans the backend never received, shown as the missing-spans callout. */
export const MissingSpans: Story = {
args: { missingSpans: true },
play: async (): Promise<void> => {
await screen.findByText(
/this trace has missing spans/i,
undefined,
untilLoaded,
);
},
};
/** The analytics panel the header toggle opens. */
export const AnalyticsOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Analytics' },
untilLoaded,
),
);
await screen.findByTestId('trace-analytics-panel', undefined, untilLoaded);
},
};
/** The download progress panel, held open by an export that never answers. */
export const TraceDownload: Story = {
args: { exportState: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await startDownload(canvasElement);
await screen.findByTestId('trace-download-panel', undefined, untilLoaded);
},
};
const openAddSpanToFunnel = async (
canvasElement: HTMLElement,
): Promise<void> => {
const [button] = await within(canvasElement).findAllByTestId(
'span-add-to-funnel-btn',
undefined,
untilLoaded,
);
await fireEvent.click(button);
await screen.findByText('Add span to funnel', undefined, untilLoaded);
};
/** The funnel picker a span row's add-to-funnel action opens. */
export const AddSpanToFunnel: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openAddSpanToFunnel(canvasElement);
},
};
/** The name field behind "Create new funnel" in that picker. */
export const AddSpanToFunnelCreateStep: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openAddSpanToFunnel(canvasElement);
await userEvent.click(
await screen.findByRole('button', { name: /create new funnel/i }),
);
await screen.findByTestId('create-funnel-name-input');
},
};
/** The span has no logs of its own but its trace does, so the Logs tab points to the trace's. */
export const SpanPanelTraceOnlyLogs: Story = {
args: { traceOnlyLogs: true },

View File

@@ -123,6 +123,43 @@ export const Funnels: Story = {
args: { tab: 'funnels' },
};
/** The name field for a funnel that does not exist yet. */
export const CreateFunnel: Story = {
args: { tab: 'funnels' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /new funnel/i },
untilLoaded,
),
);
await screen.findByRole('dialog', { name: /create new funnel/i });
},
};
/** The rename field for a funnel already on the list. */
export const RenameFunnel: Story = {
args: { tab: 'funnels' },
play: async ({ canvasElement }): Promise<void> => {
const action = await waitFor(() => {
const found = canvasElement.querySelector<HTMLElement>(
'.funnel-item__action-icon',
);
if (!found) {
throw new Error('Funnel row actions are not rendered');
}
return found;
}, untilLoaded);
await userEvent.click(action);
await userEvent.click(await screen.findByRole('button', { name: 'Rename' }));
await screen.findByRole('dialog', { name: /rename funnel/i });
},
};
/**
* A fresh workspace: nothing ingested in the window and no quick filters
* configured yet, so the table and the filter panel both show their empty

View File

@@ -64,6 +64,70 @@ export const LongMultilineToast: Story = {
},
};
/** A request still in flight: the loading toast stays until the promise settles. */
export const PromiseLoadingToast: Story = {
args: { state: 'promise-loading-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(
await screen.findByText('Enabling the alert rule'),
).toBeVisible();
},
};
/** A failed request: the rejected toast keeps a Dismiss action, stacked top-center. */
export const PromiseErrorToast: Story = {
args: { state: 'promise-error-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(
await screen.findByText('Could not clone the panel'),
).toBeVisible();
await screen.findByRole('button', { name: 'Dismiss' });
},
};
/** A warning that stays up, matching invites that only partly succeed. */
export const WarningToast: Story = {
args: { state: 'warning-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(await screen.findByText('Some invites failed')).toBeVisible();
},
};
/** An info toast pinned to the bottom-right stack, as export cancellation does. */
export const InfoToast: Story = {
args: { state: 'info-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(await screen.findByText('Export cancelled')).toBeVisible();
},
};
/** An error toast with a description and a Dismiss action, bottom-right. */
export const DangerWithDescription: Story = {
args: { state: 'danger-description-toast' },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
within(canvasElement).getByTestId('show-feedback-toast'),
);
await expect(
await screen.findByText('Failed to update account settings'),
).toBeVisible();
await screen.findByText(/does not have permission/i);
await screen.findByRole('button', { name: 'Dismiss' });
},
};
/** Mutation: the inline application alert preserves its action affordance. */
export const InlineAlert: Story = {
args: { state: 'inline-alert' },

View File

@@ -10,6 +10,11 @@ export type FeedbackState =
| 'success-toast'
| 'actionable-error-toast'
| 'long-toast'
| 'promise-loading-toast'
| 'promise-error-toast'
| 'warning-toast'
| 'info-toast'
| 'danger-description-toast'
| 'inline-alert'
| 'confirmation'
| 'toast-over-confirmation';
@@ -21,6 +26,10 @@ interface FeedbackFixtureProps {
const longMessage =
'We could not apply the retention policy because the selected storage tier is unavailable. Review the workspace settings, then try again.';
const pendingSave = new Promise<void>(() => undefined);
const dismissAction = { label: 'Dismiss', onClick: (): void => undefined };
function FeedbackFixture({ state }: FeedbackFixtureProps): JSX.Element {
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
@@ -37,13 +46,62 @@ function FeedbackFixture({ state }: FeedbackFixtureProps): JSX.Element {
return;
}
if (state === 'promise-loading-toast') {
toast.promise(pendingSave, {
loading: 'Enabling the alert rule',
success: 'Alert rule enabled',
error: 'Could not enable the alert rule',
});
return;
}
if (state === 'promise-error-toast') {
toast.promise(Promise.reject(new Error('failed')), {
loading: 'Cloning the panel',
success: 'Panel cloned',
error: { message: 'Could not clone the panel', action: dismissAction },
position: 'top-center',
duration: Infinity,
});
return;
}
if (state === 'warning-toast') {
toast.warning('Some invites failed', { duration: Infinity });
return;
}
if (state === 'info-toast') {
toast.info('Export cancelled', {
position: 'bottom-right',
duration: Infinity,
});
return;
}
if (state === 'danger-description-toast') {
toast.error('Failed to update account settings', {
description:
'The service account does not have permission to change this connection.',
action: dismissAction,
position: 'bottom-right',
duration: Infinity,
});
return;
}
toast.error(longMessage, { duration: Infinity });
};
const isToastStory =
state === 'success-toast' ||
state === 'actionable-error-toast' ||
state === 'long-toast';
state === 'long-toast' ||
state === 'promise-loading-toast' ||
state === 'promise-error-toast' ||
state === 'warning-toast' ||
state === 'info-toast' ||
state === 'danger-description-toast';
const confirmationTitle = 'Delete environment';
return (