diff --git a/.storybook/main.ts b/.storybook/main.ts
index ba0ac2ae52..72321cbf3f 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -11,7 +11,21 @@ const config: StorybookConfig = {
name: '@storybook/react-vite',
options: {},
},
- staticDirs: ['./static'],
+ staticDirs: [
+ './static',
+ // We need to manually specify the assets because of the symlink in public/sw.js
+ ...[
+ 'avatars',
+ 'emoji',
+ 'headers',
+ 'sounds',
+ 'badge.png',
+ 'loading.gif',
+ 'loading.png',
+ 'oops.gif',
+ 'oops.png',
+ ].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
+ ],
};
export default config;
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index c879cf10d1..f25d0547e8 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -2,16 +2,19 @@ import { useEffect, useState } from 'react';
import { IntlProvider } from 'react-intl';
+import { MemoryRouter, Route } from 'react-router';
+
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import type { Preview } from '@storybook/react-vite';
-import { http, passthrough } from 'msw';
import { initialize, mswLoader } from 'msw-storybook-addon';
+import { action } from 'storybook/actions';
import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store';
+import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// If you want to run the dark theme during development,
// you can change the below to `/application.scss`
@@ -22,7 +25,9 @@ const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
});
// Initialize MSW
-initialize();
+initialize({
+ onUnhandledRequest: unhandledRequestHandler,
+});
const preview: Preview = {
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
@@ -94,6 +99,21 @@ const preview: Preview = {
);
},
+ (Story) => (
+
+
+ {
+ if (location.pathname !== '/') {
+ action(`route change to ${location.pathname}`)(location);
+ }
+ return null;
+ }}
+ />
+
+ ),
],
loaders: [mswLoader],
parameters: {
@@ -115,20 +135,10 @@ const preview: Preview = {
state: {},
- // Force docs to use an iframe as it breaks MSW handlers.
- // See: https://github.com/mswjs/msw-storybook-addon/issues/83
- docs: {
- story: {
- inline: false,
- },
- },
+ docs: {},
msw: {
- handlers: [
- http.get('/index.json', passthrough),
- http.get('/packs-dev/*', passthrough),
- http.get('/sounds/*', passthrough),
- ],
+ handlers: mockHandlers,
},
},
};
diff --git a/app/javascript/mastodon/components/__tests__/button-test.jsx b/app/javascript/mastodon/components/__tests__/button-test.jsx
index a09c1f7323..dcaf5f43a1 100644
--- a/app/javascript/mastodon/components/__tests__/button-test.jsx
+++ b/app/javascript/mastodon/components/__tests__/button-test.jsx
@@ -1,6 +1,6 @@
import renderer from 'react-test-renderer';
-import { render, fireEvent, screen } from 'mastodon/test_helpers';
+import { render, fireEvent, screen } from '@/testing/rendering';
import { Button } from '../button';
diff --git a/app/javascript/mastodon/components/account/account.stories.tsx b/app/javascript/mastodon/components/account/account.stories.tsx
new file mode 100644
index 0000000000..3a3a255b7f
--- /dev/null
+++ b/app/javascript/mastodon/components/account/account.stories.tsx
@@ -0,0 +1,120 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
+
+import { Account } from './index';
+
+const meta = {
+ title: 'Components/Account',
+ component: Account,
+ argTypes: {
+ id: {
+ type: 'string',
+ description: 'ID of the account to display',
+ },
+ size: {
+ type: 'number',
+ description: 'Size of the avatar in pixels',
+ },
+ hidden: {
+ type: 'boolean',
+ description: 'Whether the account is hidden or not',
+ },
+ minimal: {
+ type: 'boolean',
+ description: 'Whether to display a minimal version of the account',
+ },
+ defaultAction: {
+ type: 'string',
+ control: 'select',
+ options: ['block', 'mute'],
+ description: 'Default action to take on the account',
+ },
+ withBio: {
+ type: 'boolean',
+ description: 'Whether to display the account bio or not',
+ },
+ withMenu: {
+ type: 'boolean',
+ description: 'Whether to display the account menu or not',
+ },
+ },
+ args: {
+ id: '1',
+ size: 46,
+ hidden: false,
+ minimal: false,
+ defaultAction: 'mute',
+ withBio: false,
+ withMenu: true,
+ },
+ parameters: {
+ state: {
+ accounts: {
+ '1': accountFactoryState(),
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Primary: Story = {
+ args: {
+ id: '1',
+ },
+};
+
+export const Hidden: Story = {
+ args: {
+ hidden: true,
+ },
+};
+
+export const Minimal: Story = {
+ args: {
+ minimal: true,
+ },
+};
+
+export const WithBio: Story = {
+ args: {
+ withBio: true,
+ },
+};
+
+export const NoMenu: Story = {
+ args: {
+ withMenu: false,
+ },
+};
+
+export const Blocked: Story = {
+ args: {
+ defaultAction: 'block',
+ },
+ parameters: {
+ state: {
+ relationships: {
+ '1': relationshipsFactory({
+ blocking: true,
+ }),
+ },
+ },
+ },
+};
+
+export const Muted: Story = {
+ args: {},
+ parameters: {
+ state: {
+ relationships: {
+ '1': relationshipsFactory({
+ muting: true,
+ }),
+ },
+ },
+ },
+};
diff --git a/app/javascript/mastodon/components/account.tsx b/app/javascript/mastodon/components/account/index.tsx
similarity index 100%
rename from app/javascript/mastodon/components/account.tsx
rename to app/javascript/mastodon/components/account/index.tsx
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx b/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx
index f84d2ab9db..d4e248f443 100644
--- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx
@@ -1,4 +1,4 @@
-import { render, fireEvent, screen } from 'mastodon/test_helpers';
+import { render, fireEvent, screen } from '@/testing/rendering';
import Column from '../column';
diff --git a/app/javascript/testing/api.ts b/app/javascript/testing/api.ts
new file mode 100644
index 0000000000..4948d71997
--- /dev/null
+++ b/app/javascript/testing/api.ts
@@ -0,0 +1,53 @@
+import { http, HttpResponse } from 'msw';
+import { action } from 'storybook/actions';
+
+import { relationshipsFactory } from './factories';
+
+export const mockHandlers = {
+ mute: http.post<{ id: string }>('/api/v1/accounts/:id/mute', ({ params }) => {
+ action('muting account')(params);
+ return HttpResponse.json(
+ relationshipsFactory({ id: params.id, muting: true }),
+ );
+ }),
+ unmute: http.post<{ id: string }>(
+ '/api/v1/accounts/:id/unmute',
+ ({ params }) => {
+ action('unmuting account')(params);
+ return HttpResponse.json(
+ relationshipsFactory({ id: params.id, muting: false }),
+ );
+ },
+ ),
+ block: http.post<{ id: string }>(
+ '/api/v1/accounts/:id/block',
+ ({ params }) => {
+ action('blocking account')(params);
+ return HttpResponse.json(
+ relationshipsFactory({ id: params.id, blocking: true }),
+ );
+ },
+ ),
+ unblock: http.post<{ id: string }>(
+ '/api/v1/accounts/:id/unblock',
+ ({ params }) => {
+ action('unblocking account')(params);
+ return HttpResponse.json(
+ relationshipsFactory({
+ id: params.id,
+ blocking: false,
+ }),
+ );
+ },
+ ),
+};
+
+export const unhandledRequestHandler = ({ url }: Request) => {
+ const { pathname } = new URL(url);
+ if (pathname.startsWith('/api/v1/')) {
+ action(`unhandled request to ${pathname}`)(url);
+ console.warn(
+ `Unhandled request to ${pathname}. Please add a handler for this request in your storybook configuration.`,
+ );
+ }
+};
diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts
new file mode 100644
index 0000000000..5b2fbfe594
--- /dev/null
+++ b/app/javascript/testing/factories.ts
@@ -0,0 +1,70 @@
+import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
+import { createAccountFromServerJSON } from '@/mastodon/models/account';
+import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
+
+type FactoryOptions = {
+ id?: string;
+} & Partial;
+
+type FactoryFunction = (options?: FactoryOptions) => T;
+
+export const accountFactory: FactoryFunction = ({
+ id,
+ ...data
+} = {}) => ({
+ id: id ?? '1',
+ acct: 'testuser',
+ avatar: '/avatars/original/missing.png',
+ avatar_static: '/avatars/original/missing.png',
+ username: 'testuser',
+ display_name: 'Test User',
+ bot: false,
+ created_at: '2023-01-01T00:00:00.000Z',
+ discoverable: true,
+ emojis: [],
+ fields: [],
+ followers_count: 0,
+ following_count: 0,
+ group: false,
+ header: '/header.png',
+ header_static: '/header_static.png',
+ indexable: true,
+ last_status_at: '2023-01-01',
+ locked: false,
+ mute_expires_at: null,
+ note: 'This is a test user account.',
+ statuses_count: 0,
+ suspended: false,
+ url: '/@testuser',
+ uri: '/users/testuser',
+ noindex: false,
+ roles: [],
+ hide_collections: false,
+ ...data,
+});
+
+export const accountFactoryState = (
+ options: FactoryOptions = {},
+) => createAccountFromServerJSON(accountFactory(options));
+
+export const relationshipsFactory: FactoryFunction = ({
+ id,
+ ...data
+} = {}) => ({
+ id: id ?? '1',
+ following: false,
+ followed_by: false,
+ blocking: false,
+ blocked_by: false,
+ languages: null,
+ muting_notifications: false,
+ note: '',
+ requested_by: false,
+ muting: false,
+ requested: false,
+ domain_blocking: false,
+ endorsed: false,
+ notifying: false,
+ showing_reblogs: true,
+ ...data,
+});
diff --git a/app/javascript/mastodon/test_helpers.tsx b/app/javascript/testing/rendering.tsx
similarity index 95%
rename from app/javascript/mastodon/test_helpers.tsx
rename to app/javascript/testing/rendering.tsx
index ae1f1cd4f6..0cb671c367 100644
--- a/app/javascript/mastodon/test_helpers.tsx
+++ b/app/javascript/testing/rendering.tsx
@@ -5,7 +5,7 @@ import { MemoryRouter } from 'react-router';
import type { RenderOptions } from '@testing-library/react';
import { render as rtlRender } from '@testing-library/react';
-import { IdentityContext } from './identity_context';
+import { IdentityContext } from '@/mastodon/identity_context';
beforeAll(() => {
global.requestIdleCallback = vi.fn((cb: IdleRequestCallback) => {
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ecd188e3c5..21545a1e3d 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -251,8 +251,7 @@ export default tseslint.config([
devDependencies: [
'eslint.config.mjs',
'app/javascript/mastodon/performance.js',
- 'app/javascript/mastodon/test_setup.js',
- 'app/javascript/mastodon/test_helpers.tsx',
+ 'app/javascript/testing/**/*',
'app/javascript/**/__tests__/**',
'app/javascript/**/*.stories.ts',
'app/javascript/**/*.stories.tsx',
diff --git a/tsconfig.json b/tsconfig.json
index 80745b43bb..2b981b67ab 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,10 +29,7 @@
"vite.config.mts",
"vitest.config.mts",
"config/vite",
- "app/javascript/mastodon",
- "app/javascript/entrypoints",
- "app/javascript/types",
- ".storybook/*.ts",
- ".storybook/*.tsx"
+ "app/javascript",
+ ".storybook/*"
]
}