mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-11 10:51:01 +00:00
Compare commits
6 Commits
refactor/a
...
test/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49066ad192 | ||
|
|
c0d4d22046 | ||
|
|
141a420509 | ||
|
|
e487b0e16e | ||
|
|
b32e6689ef | ||
|
|
5c067d75fc |
@@ -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,
|
||||
};
|
||||
62
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelog.ts
generated
Normal file
62
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelog.ts
generated
Normal 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.',
|
||||
};
|
||||
19
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelogModal.play.ts
generated
Normal file
19
frontend/src/components/ChangelogModal/stories/__story_mockdata__/changelogModal.play.ts
generated
Normal 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(),
|
||||
);
|
||||
};
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
146
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.play.ts
generated
Normal file
146
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.play.ts
generated
Normal 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),
|
||||
});
|
||||
};
|
||||
22
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.ts
generated
Normal file
22
frontend/src/pages/AlertList/ChannelsEdit/stories/__story_mockdata__/channelsEdit.ts
generated
Normal 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;
|
||||
};
|
||||
@@ -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 },
|
||||
|
||||
@@ -978,7 +978,7 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const channelSpec = (
|
||||
export const channelSpec = (
|
||||
type: ChannelType,
|
||||
webhookAuth: WebhookAuthChoice,
|
||||
jiraDeployment: JiraDeploymentChoice,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
227
frontend/src/pages/OnboardingPageV2/stories/__story_mockdata__/onboardingV2.play.ts
generated
Normal file
227
frontend/src/pages/OnboardingPageV2/stories/__story_mockdata__/onboardingV2.play.ts
generated
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
293
frontend/src/pages/Settings/Members/stories/__story_mockdata__/members.play.ts
generated
Normal file
293
frontend/src/pages/Settings/Members/stories/__story_mockdata__/members.play.ts
generated
Normal 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(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
109
frontend/src/pages/Settings/Workspace/stories/__story_mockdata__/workspace.play.ts
generated
Normal file
109
frontend/src/pages/Settings/Workspace/stories/__story_mockdata__/workspace.play.ts
generated
Normal 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,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user