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/*" ] }