From 5b6e0e65bb23baac5e53d15b28169fde7da50b60 Mon Sep 17 00:00:00 2001 From: Erican Date: Sun, 25 Oct 2026 17:48:55 +0100 Subject: [PATCH] Storybook: every story is signed in (AuthProvider, fake session), stories for the Studio's FeedForm, PublishPanel, RulePanel and TemplateDialog Co-Authored-By: Claude Sonnet 5.5 --- .../SKILL.md | 4 +- TODO.md | 2 +- .../apps/mediatracker/.storybook/fakeApi.ts | 2 +- .../apps/mediatracker/.storybook/preview.tsx | 24 +++++++-- .../src/components/FeedForm.stories.tsx | 35 +++++++++++++ .../src/components/PublishPanel.stories.tsx | 44 ++++++++++++++++ .../src/components/RulePanel.stories.tsx | 51 +++++++++++++++++++ .../src/components/TemplateDialog.stories.tsx | 27 ++++++++++ 8 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 frontend/apps/studio/src/components/FeedForm.stories.tsx create mode 100644 frontend/apps/studio/src/components/PublishPanel.stories.tsx create mode 100644 frontend/apps/studio/src/components/RulePanel.stories.tsx create mode 100644 frontend/apps/studio/src/components/TemplateDialog.stories.tsx diff --git a/.claude/skills/mediatracker-frontend-conventions/SKILL.md b/.claude/skills/mediatracker-frontend-conventions/SKILL.md index d4345466..8cec2952 100644 --- a/.claude/skills/mediatracker-frontend-conventions/SKILL.md +++ b/.claude/skills/mediatracker-frontend-conventions/SKILL.md @@ -25,7 +25,7 @@ Storybook shows single components in all their states, without a backend: `npm r - **Config:** `apps/mediatracker/.storybook` reads the app's `vite.config.ts`, so aliases, React and Tailwind are the app's. Stories (`*.stories.tsx`, next to the component) are found in `packages/ui/src` and `apps/mediatracker/src`. **A story has no `title`: it comes from where the file is** (`main.ts` gives each directory a `titlePrefix`), so the sidebar groups the stories by origin - `Shared` (packages/ui, used by both apps), `Mediatracker` (`storySort` in `preview.tsx` puts them in that order) - and then by folder and component (`Shared/components/Collapsible`). `Studio` (`apps/studio/src`; its texts are a namespace of their own that `preview.tsx` loads) is the third; the story ids start with the prefix (`shared-components-collapsible--open`). A new place = one more entry in `stories` with its `titlePrefix`. - **`preview.tsx` wraps every story** in React Query, `ApiConfigProvider`, a `MemoryRouter` and i18n, with toolbar choices for the theme (light/dark) and the language (en/de). **A story must not add its own router.** A story with state of its own uses `render()` with `useState`. -- **A fake backend** (`.storybook/fakeApi.ts`): no `config.json` is loaded, but `preview.tsx` sets the API address to `http://storybook.invalid` and `installFakeApi()` wraps `fetch` so that every request to that address is answered from the routes of the story: **`parameters: { api: { '/movies/tmdb/1/reviews': [...], '/tvshows/tmdb/1/seasons/1/episodes/4': {...} } }`** (a path without the query; a value, a function of the request's `URL`, `{ status, body }` for another status, a key ending in `*` for everything below it). `/sources` is answered with TMDB as the one source with all capabilities (`fakeSources`) unless the story sets it; every other path is a 404, like a real backend for an unknown title. The query cache is emptied when another story starts. So a component that asks an API through its hooks works as it does in the app, and a failing answer shows its failure state (`ReviewsCollapsible`'s `Failing` story). Images with a data address (`posterOf`) are shown as they are (`useImageUrl` doesn't put them through the proxy). Not typechecked: `.storybook/` is outside the tsconfig projects. -- **Odd titles:** `storybook/edgeTitles.ts` holds them (the same kinds as the mock backend's `EdgeCaseTitles`), plus `posterOf` (a poster as a data address) and `brokenPoster`. **A new component with states worth looking at gets a story, and the odd titles go through it.** Stories exist for (October 2026, 164 stories; the Studio's: `AddTitleForm`, `CopyTextButton`, `FeedStatusBadge`, `JsonViewer`, `PairListEditor`, `ParameterListEditor`, `TextFieldTools`): shared `Avatar`, `ChipSelect`, `Collapsible`, `ConfirmDialog`, `FormattedNumber`, `LogLevelBadge`, `ProgressBar`, `ProgressChip`, `SegmentedControl`, `SegmentedMultiControl`, `SettingFieldFrame`, `Skeleton`, `Spinner`, `TabBar`, `UnitNumberInput`; the app's `DetailActionButton`, `DetailField`, `DetailUnavailableNotice`, `FeedCard`, `IdChip`, `LibraryItemCard`, `MappingLabel`, `MediaCard`, `PosterImage`, `RatingDialog`, `ReviewCard`, `ReviewsCollapsible`, `EpisodeCard` (also compact), `StarRating`, `TrackButton`. +- **A fake backend** (`.storybook/fakeApi.ts`): no `config.json` is loaded, but `preview.tsx` sets the API address to `http://storybook.invalid` and `installFakeApi()` wraps `fetch` so that every request to that address is answered from the routes of the story: **`parameters: { api: { '/movies/tmdb/1/reviews': [...], '/tvshows/tmdb/1/seasons/1/episodes/4': {...} } }`** (a path without the query; a value, a function of the request's `URL`, `{ status, body }` for another status, a key ending in `*` for everything below it). `/sources` is answered with TMDB as the one source with all capabilities (`fakeSources`) and `/auth/session` with the fake admin account unless the story sets them; **every story is signed in** (`preview.tsx` wraps it in an `AuthProvider` with the token `storybook`; `parameters: { signedIn: false }` for a signed-out one), which is also what the Studio's components need (`useStudioApi` takes the token and the API address of the app); every other path is a 404, like a real backend for an unknown title. The query cache is emptied when another story starts. So a component that asks an API through its hooks works as it does in the app, and a failing answer shows its failure state (`ReviewsCollapsible`'s `Failing` story). Images with a data address (`posterOf`) are shown as they are (`useImageUrl` doesn't put them through the proxy). Not typechecked: `.storybook/` is outside the tsconfig projects. +- **Odd titles:** `storybook/edgeTitles.ts` holds them (the same kinds as the mock backend's `EdgeCaseTitles`), plus `posterOf` (a poster as a data address) and `brokenPoster`. **A new component with states worth looking at gets a story, and the odd titles go through it.** Stories exist for (October 2026, 183 stories; the Studio's: `AddTitleForm`, `CopyTextButton`, `FeedStatusBadge`, `JsonViewer`, `PairListEditor`, `ParameterListEditor`, `TextFieldTools`): shared `Avatar`, `ChipSelect`, `Collapsible`, `ConfirmDialog`, `FormattedNumber`, `LogLevelBadge`, `ProgressBar`, `ProgressChip`, `SegmentedControl`, `SegmentedMultiControl`, `SettingFieldFrame`, `Skeleton`, `Spinner`, `TabBar`, `UnitNumberInput`; the app's `DetailActionButton`, `DetailField`, `DetailUnavailableNotice`, `FeedCard`, `IdChip`, `LibraryItemCard`, `MappingLabel`, `MediaCard`, `PosterImage`, `RatingDialog`, `ReviewCard`, `ReviewsCollapsible`, `EpisodeCard` (also compact), `StarRating`, `TrackButton`; the Studio's also `FeedForm`, `PublishPanel`, `RulePanel`, `TemplateDialog`. - **Viewports and the check of all stories:** the toolbar's viewport menu has Phone small (320), Phone (375), Tablet (768) and Desktop (1280) (`parameters.viewport` in `preview.tsx`), next to the theme and language switches. **`npm run check:stories`** (`frontend/scripts/checkStories.mjs`, `puppeteer-core` with the installed Chrome, `CHROME_PATH` to change it) renders every story of the running Storybook (`STORYBOOK_URL`, default `:6006`) at 320, 375, 768 and 1280 px, light and dark, English and German (7 runs, each with a Chrome of its own, about 15 s each; `CONFIG_FILTER="dark de"` runs only the configurations whose name contains the text; run it with Storybook already started and nothing else heavy running - a Chrome that is starved or a dev server that restarts makes a run fail with "Connection closed", which is not a finding) and reports an error display, an empty story, or content wider than its box or the window (also dialogs, which sit outside the story's root); exit code 1 on a finding, so it can go into CI. Overflow that is meant (`progressbar`'s floating percentage, `detailactionbutton--highlighted`'s corner count) is listed as `intended` in the script. It found long names without spaces overflowing `DetailField`, `MappingLabel`, `Collapsible`'s heading, `SettingFieldFrame`, `ConfirmDialog` and `RatingDialog` (fixed with `wrap-anywhere`). **A story's own wrapper must not be wider than the viewport** (`w-full max-w-[28rem]`, not `w-[28rem]`), and a story of a full-width component uses `parameters.layout: 'padded'`, because the default `centered` sizes the story to its content. The check measures; it does not judge looks - take screenshots (puppeteer `page.screenshot`) of the stories with odd data at 375 px in dark and German and look at them. - **Not done:** stories for the rest of the components, interaction and visual-regression tests (the stories could be run as tests), a Studio Storybook (this one also loads shared components, but the Studio's own are not in it). diff --git a/TODO.md b/TODO.md index 12d096e9..ab588983 100644 --- a/TODO.md +++ b/TODO.md @@ -34,6 +34,6 @@ Open tasks and decisions. The skills in `.claude/skills/` describe how things ar - **Where:** skills `mediatracker-testing` (mock hosts, fakes) and `mediatracker-frontend-conventions` (Storybook). - **C# of the mock work:** `Mediatracker.Testing.Tests` covers the fakes (`EdgeCaseTitles`, `SyntheticCatalogue.Faults`, the fake login); still without tests: `ApiHost`/`StudioHost` with their customizations and `MockDatabase` (they need a PostgreSQL: Testcontainers like the Studio's integration tests), `MockWorld`/`MockServices`. - **Mock data:** faults are per title, not per request; only TMDB (and `Acme`) as sources; the mock Studio has no feeds with odd names, and the embedded Studio login in the mocked Mediatracker is not set up; admin pages show real services (Tap, AniDB) as down or off. -- **Storybook:** about 100 components of the Mediatracker app and most of the Studio's have no story; components that ask an API can have `parameters.api` (done for `ReviewsCollapsible` and `EpisodeCard`), still open: `MediaSection`, `SeasonCollapsible`, the Studio's `FeedForm`, `RulePanel`, `PublishPanel` (the Studio's API address and token live in its own context, `studioApiContext`, which the preview doesn't provide yet); most stories were only checked by `npm run check:stories` (renders, overflow), not looked at; no interaction or screenshot tests, `check:stories` is in no pipeline. +- **Storybook:** about 100 components of the Mediatracker app and most of the Studio's have no story; components that ask an API can have `parameters.api` (done for `ReviewsCollapsible` and `EpisodeCard`), the Studio's `FeedForm`, `RulePanel`, `PublishPanel` and `TemplateDialog` too; still open: `MediaSection`, `SeasonCollapsible`, `FeedListSection`, `DatasourceEditor`; most stories were only checked by `npm run check:stories` (renders, overflow), not looked at; no interaction or screenshot tests, `check:stories` is in no pipeline. - **Frontend walk-through with the odd titles:** the Mediatracker's pages (home, library, calendar, search, discover, lists, feeds, detail pages of the odd and failing titles, settings, admin) were walked at 375/768/1280 px (dark at 375) with `npm run walk:pages`: no page gets wider than the window; what is left are small overflows in loading skeletons (a few px) and the id mappings page at 768 px (cards' texts 56/47, 63/47); with `npm run walk:interactions` the collapsibles of the detail pages (seasons with the odd episodes, media, alternative titles) with their episode cards expanded and the dialogs behind the action buttons (watch history, add to list, rating, move to another entry) were opened at 375 and 1280 px: nothing overflows (the only finding is a toast's icon, a library's own element). Not done: menus, forms filled in, the Log watch / mark several dialogs on a show with episodes. The Studio's feed pages, tokens, accounts, datasources and identity were walked the same way, but not its rule panel, template dialog, datasource editor with data, nor the publish flow. - **Not tried in the IDEs:** the Rider and WebStorm run configurations were written as files, not opened (the ids in the `Compose all` ones follow the pattern of the existing multi-launch file). diff --git a/frontend/apps/mediatracker/.storybook/fakeApi.ts b/frontend/apps/mediatracker/.storybook/fakeApi.ts index df24139d..1e860e85 100644 --- a/frontend/apps/mediatracker/.storybook/fakeApi.ts +++ b/frontend/apps/mediatracker/.storybook/fakeApi.ts @@ -33,7 +33,7 @@ let installed = false /** Sets what the fake backend answers from now on (a story's `parameters.api`, on top of the defaults). */ export function setFakeApiRoutes(next: FakeApiRoutes) { - routes = { '/sources': fakeSources, ...next } + routes = { '/sources': fakeSources, '/auth/session': { did: 'did:plc:mockuser', handle: 'mock.test', isAdmin: true }, ...next } } function answer(request: URL): { status: number; body: unknown } { diff --git a/frontend/apps/mediatracker/.storybook/preview.tsx b/frontend/apps/mediatracker/.storybook/preview.tsx index 75d1fee9..e90da338 100644 --- a/frontend/apps/mediatracker/.storybook/preview.tsx +++ b/frontend/apps/mediatracker/.storybook/preview.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useEffect, useRef } from 'react' import { MemoryRouter } from 'react-router-dom' import i18n from 'i18next' +import { AuthProvider } from '@mediatracker/ui/auth/AuthContext' import { ApiConfigProvider } from '@mediatracker/ui/config/ApiConfigContext' import '@mediatracker/ui/index.css' import '@mediatracker/ui/i18n' @@ -61,6 +62,17 @@ const preview: Preview = { lastStory.current = context.id setFakeApiRoutes((context.parameters.api as FakeApiRoutes | undefined) ?? {}) queryClient.clear() + + // Signed in as the fake account unless the story says `signedIn: false` (read when the AuthProvider below mounts). + try { + if (context.parameters.signedIn === false) { + localStorage.removeItem('mediatracker.sessionToken') + } else { + localStorage.setItem('mediatracker.sessionToken', 'storybook') + } + } catch { + // storage may be blocked: the story is then signed out + } } useEffect(() => { @@ -74,11 +86,13 @@ const preview: Preview = { return ( - -
- -
-
+ + +
+ +
+
+
) diff --git a/frontend/apps/studio/src/components/FeedForm.stories.tsx b/frontend/apps/studio/src/components/FeedForm.stories.tsx new file mode 100644 index 00000000..76cb7200 --- /dev/null +++ b/frontend/apps/studio/src/components/FeedForm.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { FeedDto } from '../api/studio' +import { FeedForm } from './FeedForm' + +const feed: FeedDto = { + key: 'anime-seasons', + uri: 'at://did:plc:mockuser/dev.erican.mediatracker.feed.generator/anime-seasons', + displayName: 'Anime Seasons', + description: 'The anime of every season.', + lists: [], + hosting: 'service', + isPublished: false, + needsPublishing: false, + createdAt: '2026-10-01T10:00:00Z', + updatedAt: '2026-10-01T10:00:00Z', +} + +const meta = { + component: FeedForm, + parameters: { layout: 'padded' }, + decorators: [(Story) =>
], + args: { onDone: () => {}, onCancel: () => {} }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const New: Story = {} + +export const Editing: Story = { args: { feed } } + +export const EditingAPublishedFeed: Story = { args: { feed: { ...feed, isPublished: true } } } + +export const LongValues: Story = { args: { feed: { ...feed, displayName: 'W'.repeat(64), description: 'D'.repeat(300) } } } diff --git a/frontend/apps/studio/src/components/PublishPanel.stories.tsx b/frontend/apps/studio/src/components/PublishPanel.stories.tsx new file mode 100644 index 00000000..1ee5fb7f --- /dev/null +++ b/frontend/apps/studio/src/components/PublishPanel.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { FeedDto } from '../api/studio' +import { PublishPanel } from './PublishPanel' + +const feed: FeedDto = { + key: 'anime-seasons', + uri: 'at://did:plc:mockuser/dev.erican.mediatracker.feed.generator/anime-seasons', + displayName: 'Anime Seasons', + description: 'The anime of every season.', + lists: [], + hosting: 'service', + isPublished: false, + needsPublishing: false, + createdAt: '2026-10-01T10:00:00Z', + updatedAt: '2026-10-01T10:00:00Z', +} + +const meta = { + component: PublishPanel, + parameters: { layout: 'padded' }, + decorators: [(Story) =>
], + args: { feed }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const NotPublished: Story = {} + +export const Published: Story = { args: { feed: { ...feed, isPublished: true, publishedAt: '2026-10-02T12:00:00Z' } } } + +export const NeedsPublishing: Story = { args: { feed: { ...feed, isPublished: true, needsPublishing: true, publishedAt: '2026-10-02T12:00:00Z' } } } + +export const RecordsFeed: Story = { args: { feed: { ...feed, hosting: 'records' } } } + +export const LongKey: Story = { + args: { feed: { ...feed, key: 'k'.repeat(60), uri: `at://did:plc:mockuser/dev.erican.mediatracker.feed.generator/${'k'.repeat(60)}` } }, +} + +/** Pressing "Publish" gets the Studio's refusal: no login of the account at the PDS yet. */ +export const PublishRefused: Story = { + parameters: { api: { '/api/feeds/anime-seasons/publish': { status: 409, body: { title: 'No session' } } } }, +} diff --git a/frontend/apps/studio/src/components/RulePanel.stories.tsx b/frontend/apps/studio/src/components/RulePanel.stories.tsx new file mode 100644 index 00000000..e0d43744 --- /dev/null +++ b/frontend/apps/studio/src/components/RulePanel.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { DatasourceSummaryDto, FeedListRuleDto } from '../api/studio' +import { RulePanel } from './RulePanel' + +const datasources: DatasourceSummaryDto[] = [ + { + key: 'jikan-season', + name: 'Jikan season', + description: 'The anime of a season from Jikan-Edge.', + parameters: [{ name: 'season', label: 'Season', type: 'text', required: true, default: 'now', help: 'now, upcoming or 2026/fall' }], + }, +] + +const rule: FeedListRuleDto = { + kind: 'datasource', + config: { datasource: 'jikan-season', parameters: { season: 'now' }, maxItems: 200 }, + intervalMinutes: 1440, + isEnabled: true, + lastRunAt: '2026-10-24T03:00:00Z', + lastSuccessAt: '2026-10-24T03:00:00Z', + lastItemCount: 163, + nextRunAt: '2026-10-25T03:00:00Z', +} + +const path = '/api/feeds/anime/lists/current/rule' + +const meta = { + component: RulePanel, + parameters: { layout: 'padded', api: { '/api/datasources': datasources } }, + decorators: [(Story) =>
], + args: { feedKey: 'anime', listKey: 'current', listName: 'This season', hasRule: false }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const NoRule: Story = {} + +export const WithRule: Story = { args: { hasRule: true }, parameters: { api: { '/api/datasources': datasources, [path]: rule } } } + +export const LastRunFailed: Story = { + args: { hasRule: true }, + parameters: { api: { '/api/datasources': datasources, [path]: { ...rule, lastFailure: 'Unreachable', lastSuccessAt: undefined, lastItemCount: undefined } } }, +} + +export const Disabled: Story = { args: { hasRule: true }, parameters: { api: { '/api/datasources': datasources, [path]: { ...rule, isEnabled: false } } } } + +export const LongListName: Story = { args: { listName: 'W'.repeat(120) } } + +export const NoDatasourceToUse: Story = { parameters: { api: { '/api/datasources': [] } } } diff --git a/frontend/apps/studio/src/components/TemplateDialog.stories.tsx b/frontend/apps/studio/src/components/TemplateDialog.stories.tsx new file mode 100644 index 00000000..189fe12a --- /dev/null +++ b/frontend/apps/studio/src/components/TemplateDialog.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TemplateDialog } from './TemplateDialog' + +const meta = { + component: TemplateDialog, + parameters: { layout: 'fullscreen' }, + args: { onDone: () => {}, onCancel: () => {} }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Templates: Story = { + parameters: { + api: { + '/api/templates': [ + { id: 'anime-seasons', name: 'Anime Seasons', description: 'One list per season, filled from MyAnimeList.' }, + { id: 'tmdb-popular', name: 'W'.repeat(80), description: 'A very long description. '.repeat(20) }, + ], + }, + }, +} + +export const None: Story = { parameters: { api: { '/api/templates': [] } } } + +export const Failing: Story = { parameters: { api: { '/api/templates': { status: 500, body: { title: 'Server error' } } } } } -- knotmirror 0.0.1