Add brewery discovery & detail pages with location-based search (#253)

This commit is contained in:
Aaron Po
2026-08-29 00:44:33 -04:00
committed by GitHub
parent 92a30b11db
commit a580314583
90 changed files with 3874 additions and 187 deletions

View File

@@ -1,71 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import FormField from '../app/components/ui/forms/FormField';
const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`;
const meta = {
title: 'Forms/FormField',
component: FormField,
tags: ['autodocs'],
args: {
id: 'email',
name: 'email',
type: 'email',
label: 'Email address',
placeholder: 'you@example.com',
hint: 'We only use this to manage your account.',
},
parameters: {
layout: 'centered',
docs: {
description: {
component: formFieldDescription,
},
},
},
render: (args) => (
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg">
<FormField {...args} />
</div>
),
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithHint: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument();
await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument();
},
};
export const WithError: Story = {
args: {
error: 'Please enter a valid email address.',
hint: undefined,
'aria-invalid': true,
defaultValue: 'not-an-email',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute(
'aria-invalid',
'true',
);
},
};
export const PasswordField: Story = {
args: {
id: 'password',
name: 'password',
type: 'password',
label: 'Password',
placeholder: 'Enter a strong password',
hint: 'Use 12 or more characters.',
},
};

View File

@@ -1,67 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { expect, userEvent, within } from 'storybook/test';
import LoginForm from '../app/features/auth/components/LoginForm';
import { loginSchema, type LoginSchema } from '../app/features/auth/schemas';
const loginFormDescription = `Renders the real \`LoginForm\` component wired to the real \`loginSchema\` (zod) through the same react-hook-form setup used on the login page, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
const LoginFormHarness = () => {
const [submitted, setSubmitted] = useState<LoginSchema | null>(null);
const form = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
return (
<div className="w-full max-w-md space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
<LoginForm
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
formState={form.formState}
register={form.register}
submitting={false}
/>
{submitted ? (
<p data-testid="submit-result">Signed in as {submitted.username}</p>
) : null}
</div>
);
};
const meta = {
title: 'Forms/LoginForm',
component: LoginFormHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: loginFormDescription,
},
},
},
} satisfies Meta<typeof LoginFormHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {};
export const RejectsEmptySubmission: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
await expect(canvas.getByText(/username is required/i)).toBeInTheDocument();
await expect(canvas.getByText(/password is required/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const AcceptsValidLogin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/username/i), 'hans');
await userEvent.type(canvas.getByLabelText(/password/i), 'correct-horse-battery');
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('hans');
},
};

View File

@@ -1,69 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import Navbar from '../app/components/ui/navbar/Navbar';
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
const meta = {
title: 'Navigation/Navbar',
component: Navbar,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: navbarDescription,
},
},
},
} satisfies Meta<typeof Navbar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Guest: Story = {
args: {
auth: null,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('link', { name: /the biergarten app/i })).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: /login/i })).toBeInTheDocument();
await expect(canvas.getByRole('link', { name: /register user/i })).toBeInTheDocument();
},
};
export const Authenticated: Story = {
args: {
auth: {
username: 'Hans',
accessToken: 'access-token',
refreshToken: 'refresh-token',
userAccountId: 'user-1',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton);
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
},
};
export const MobileMenu: Story = {
args: {
auth: null,
},
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /toggle navigation/i }));
await expect(canvas.getByRole('link', { name: /beer styles/i })).toBeInTheDocument();
},
};

View File

@@ -1,132 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
import { expect, userEvent, within } from 'storybook/test';
import PasswordSection from '../app/features/account/components/PasswordSection';
import { updatePasswordSchema } from '../app/features/account/schemas';
import type { ActionResult } from '../app/features/account/types';
const passwordSectionDescription = `Renders the real \`PasswordSection\` component (the account page's collapsible password-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updatePasswordSchema\` (zod) cross-field rules. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`;
const PasswordSectionRoute = () => {
const [open, setOpen] = useState(true);
const result = useActionData() as ActionResult | undefined;
return (
<div className="w-full max-w-md">
<PasswordSection open={open} onToggle={() => setOpen((value) => !value)} result={result} />
</div>
);
};
const PasswordSectionHarness = () => {
const [router] = useState(() =>
createMemoryRouter([
{
path: '/',
element: <PasswordSectionRoute />,
action: async ({ request }) => {
const formData = await request.formData();
const parsed = updatePasswordSchema.safeParse({
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmNewPassword: formData.get('confirmNewPassword'),
});
if (!parsed.success) {
return {
intent: 'password',
error: parsed.error.issues[0].message,
} satisfies ActionResult;
}
return redirect('/success');
},
},
{
path: '/success',
element: <p data-testid="redirect-success">Password changed successfully.</p>,
},
]),
);
return <RouterProvider router={router} />;
};
const meta = {
title: 'Account/PasswordSection',
component: PasswordSectionHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
usesDataRouter: true,
docs: {
description: {
component: passwordSectionDescription,
},
},
},
} satisfies Meta<typeof PasswordSectionHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {};
const fillPasswordForm = async (
canvas: ReturnType<typeof within>,
values: { current: string; next: string; confirm: string },
) => {
await userEvent.type(canvas.getByLabelText(/current password/i), values.current);
await userEvent.type(canvas.getByLabelText('New Password'), values.next);
await userEvent.type(canvas.getByLabelText(/confirm new password/i), values.confirm);
await userEvent.click(canvas.getByRole('button', { name: /change password/i }));
};
export const RejectsShortPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'OldPassword1!',
next: 'short',
confirm: 'short',
});
await expect(canvas.getByText(/at least 8 characters/i)).toBeInTheDocument();
},
};
export const RejectsMismatchedConfirmation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'OldPassword1!',
next: 'NewPassword2!',
confirm: 'NewPassword3!',
});
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
},
};
export const RejectsReusingCurrentPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'SamePassword1!',
next: 'SamePassword1!',
confirm: 'SamePassword1!',
});
await expect(canvas.getByText(/different from the current password/i)).toBeInTheDocument();
},
};
export const AcceptsValidPasswordChange: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillPasswordForm(canvas, {
current: 'OldPassword1!',
next: 'NewPassword2!',
confirm: 'NewPassword2!',
});
await expect(canvas.getByTestId('redirect-success')).toBeInTheDocument();
},
};

View File

@@ -1,116 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { expect, fireEvent, userEvent, within } from 'storybook/test';
import RegisterForm from '../app/features/auth/components/RegisterForm';
import { registerSchema, type RegisterSchema } from '../app/features/auth/schemas';
const registerFormDescription = `Renders the real \`RegisterForm\` component wired to the real \`registerSchema\` (zod) through the same react-hook-form setup used on the register page, including its cross-field rule that the confirmation must match the password, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
const RegisterFormHarness = () => {
const [submitted, setSubmitted] = useState<RegisterSchema | null>(null);
const form = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
return (
<div className="w-full max-w-lg space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
<RegisterForm
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
formState={form.formState}
register={form.register}
submitting={false}
/>
{submitted ? <p data-testid="submit-result">Registered {submitted.username}</p> : null}
</div>
);
};
const meta = {
title: 'Forms/RegisterForm',
component: RegisterFormHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: registerFormDescription,
},
},
},
} satisfies Meta<typeof RegisterFormHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {};
const fillValidRegistration = async (canvas: ReturnType<typeof within>) => {
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
await userEvent.type(canvas.getByLabelText(/first name/i), 'Jane');
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
target: { value: '2000-01-15' },
});
await userEvent.type(canvas.getByLabelText('Password'), 'StrongPass1!');
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!');
};
export const RejectsEmptySubmission: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(
canvas.getByText(/username must be at least 3 characters/i),
).toBeInTheDocument();
await expect(canvas.getByText(/first name is required/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const RejectsInvalidEmail: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.clear(canvas.getByLabelText(/^email$/i));
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example');
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByText(/invalid email address/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const RejectsWeakPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.clear(canvas.getByLabelText('Password'));
await userEvent.type(canvas.getByLabelText('Password'), 'weakpassword');
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'weakpassword');
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByText(/uppercase letter/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const RejectsMismatchedConfirmPassword: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'Different1!');
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
},
};
export const AcceptsValidRegistration: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await fillValidRegistration(canvas);
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('jane_doe');
},
};

View File

@@ -1,75 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Lock } from 'iconoir-react';
import { useState } from 'react';
import { expect, userEvent, within } from 'storybook/test';
import SectionCard from '../app/features/account/components/SectionCard';
const sectionCardDescription = `Collapsible card used on the account settings page to group a labeled form behind a toggle. These stories cover the closed and open states, plus the toggle interaction, so you can review spacing, icon alignment, and disclosure behavior in isolation.`;
const SectionCardDemo = (props: React.ComponentProps<typeof SectionCard>) => {
const [open, setOpen] = useState(props.open);
return (
<div className="w-full max-w-md">
<SectionCard {...props} open={open} onToggle={() => setOpen((value) => !value)} />
</div>
);
};
const meta = {
title: 'Account/SectionCard',
component: SectionCard,
tags: ['autodocs'],
args: {
icon: <Lock className="size-5" aria-hidden="true" />,
title: 'Password',
description: 'Change the password used to sign in.',
open: false,
onToggle: () => undefined,
children: <p className="text-sm">Form fields go here.</p>,
},
parameters: {
layout: 'centered',
docs: {
description: {
component: sectionCardDescription,
},
},
},
render: (args) => <SectionCardDemo {...args} />,
} satisfies Meta<typeof SectionCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Closed: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Password')).toBeInTheDocument();
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
},
};
export const Open: Story = {
args: {
open: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Form fields go here.')).toBeInTheDocument();
},
};
export const ToggleInteraction: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByLabelText(/toggle password form/i);
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
await userEvent.click(toggle);
await expect(canvas.getByText('Form fields go here.')).toBeInTheDocument();
await userEvent.click(toggle);
await expect(canvas.queryByText('Form fields go here.')).not.toBeInTheDocument();
},
};

View File

@@ -1,52 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import SubmitButton from '../app/components/ui/forms/SubmitButton';
const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`;
const meta = {
title: 'Forms/SubmitButton',
component: SubmitButton,
tags: ['autodocs'],
args: {
idleText: 'Save changes',
submittingText: 'Saving changes',
isSubmitting: false,
},
parameters: {
layout: 'centered',
docs: {
description: {
component: submitButtonDescription,
},
},
},
} satisfies Meta<typeof SubmitButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Idle: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /save changes/i })).toBeEnabled();
},
};
export const Submitting: Story = {
args: {
isSubmitting: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: /saving changes/i })).toBeDisabled();
},
};
export const CustomWidth: Story = {
args: {
className: 'btn btn-secondary min-w-64',
idleText: 'Register user',
submittingText: 'Registering user',
},
};

View File

@@ -1,157 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { biergartenThemes } from '../app/features/theme/themes';
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
const ThemeSwatch = ({ label, className }: { label: string; className: string }) => {
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
};
/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
const CssVarSwatch = ({ label, bg, color }: { label: string; bg: string; color: string }) => {
return (
<div
className="rounded-box p-3 text-sm font-medium"
style={{ backgroundColor: `var(${bg})`, color: `var(${color})` }}
>
{label}
</div>
);
};
const TextTokenSample = ({
label,
background,
text,
}: {
label: string;
background: string;
text: string;
}) => {
return (
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
{label}
</p>
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
Secondary copy, placeholders, and helper text.
</p>
</div>
);
};
const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe: string }) => {
return (
<section
data-theme={value}
className="rounded-box border border-base-300 bg-base-100 shadow-lg"
>
<div className="space-y-4 p-5">
<div className="space-y-1">
<h2 className="text-2xl font-bold">{label}</h2>
<p className="text-sm text-base-content/70">{vibe}</p>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Core
</p>
<div className="grid gap-2 sm:grid-cols-2">
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
<ThemeSwatch
label="Secondary"
className="bg-secondary text-secondary-content"
/>
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
</div>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Status
</p>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
<ThemeSwatch label="Info" className="bg-info text-info-content" />
<ThemeSwatch label="Success" className="bg-success text-success-content" />
<ThemeSwatch label="Warning" className="bg-warning text-warning-content" />
<ThemeSwatch label="Error" className="bg-error text-error-content" />
</div>
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Content
</p>
<div className="grid gap-2 sm:grid-cols-3">
<CssVarSwatch
label="Surface"
bg="--color-surface"
color="--color-surface-content"
/>
<TextTokenSample
label="Muted on Base"
background="--color-base-100"
text="--color-muted"
/>
<CssVarSwatch
label="Highlight"
bg="--color-highlight"
color="--color-highlight-content"
/>
</div>
<div className="mt-2">
<TextTokenSample
label="Muted on Surface"
background="--color-surface"
text="--color-muted"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button className="btn btn-primary btn-sm">Primary</button>
<button className="btn btn-secondary btn-sm">Secondary</button>
<button className="btn btn-outline btn-sm">Outline</button>
</div>
<div role="alert" className="alert alert-success alert-soft">
<span>Semantic tokens stay stable while the atmosphere changes.</span>
</div>
</div>
</section>
);
};
const meta = {
title: 'Themes/Biergarten Themes',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: themesDescription,
},
},
},
tags: ['autodocs'],
render: () => (
<div className="grid gap-6 p-6 lg:grid-cols-2">
{biergartenThemes.map((theme) => (
<ThemePanel key={theme.value} {...theme} />
))}
</div>
),
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const Gallery: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
for (const theme of biergartenThemes) {
await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument();
}
},
};

View File

@@ -1,74 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, within } from 'storybook/test';
import ToastProvider from '../app/components/ui/toast/ToastProvider';
import {
dismissToasts,
showErrorToast,
showInfoToast,
showSuccessToast,
} from '../app/components/ui/toast/toast';
const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`;
const ToastDemo = () => {
return (
<div className="space-y-4">
<ToastProvider />
<div className="card border border-base-300 bg-base-100 shadow-md">
<div className="card-body">
<h2 className="card-title">Toast demo</h2>
<p className="text-sm text-base-content/70">
Use these actions to preview toast styles.
</p>
<div className="flex flex-wrap gap-2">
<button
className="btn btn-success btn-sm"
onClick={() => showSuccessToast('Saved successfully')}
>
Success
</button>
<button
className="btn btn-error btn-sm"
onClick={() => showErrorToast('Something went wrong')}
>
Error
</button>
<button
className="btn btn-info btn-sm"
onClick={() => showInfoToast('Heads up: check your email')}
>
Info
</button>
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
Dismiss all
</button>
</div>
</div>
</div>
</div>
);
};
const meta = {
title: 'Feedback/Toast',
component: ToastDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: toastDescription,
},
},
},
} satisfies Meta<typeof ToastDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /success/i }));
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
},
};

View File

@@ -1,107 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
import { expect, userEvent, within } from 'storybook/test';
import UsernameSection from '../app/features/account/components/UsernameSection';
import { updateUsernameSchema } from '../app/features/account/schemas';
import type { ActionResult } from '../app/features/account/types';
const usernameSectionDescription = `Renders the real \`UsernameSection\` component (the account page's collapsible username-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updateUsernameSchema\` (zod) validation. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`;
const UsernameSectionRoute = () => {
const [open, setOpen] = useState(true);
const result = useActionData() as ActionResult | undefined;
return (
<div className="w-full max-w-md">
<UsernameSection
username="jane_doe"
open={open}
onToggle={() => setOpen((value) => !value)}
result={result}
/>
</div>
);
};
const UsernameSectionHarness = () => {
const [router] = useState(() =>
createMemoryRouter([
{
path: '/',
element: <UsernameSectionRoute />,
action: async ({ request }) => {
const formData = await request.formData();
const parsed = updateUsernameSchema.safeParse({
newUsername: formData.get('newUsername'),
});
if (!parsed.success) {
return {
intent: 'username',
error: parsed.error.issues[0].message,
} satisfies ActionResult;
}
return redirect('/success');
},
},
{
path: '/success',
element: <p data-testid="redirect-success">Username updated successfully.</p>,
},
]),
);
return <RouterProvider router={router} />;
};
const meta = {
title: 'Account/UsernameSection',
component: UsernameSectionHarness,
tags: ['autodocs'],
parameters: {
layout: 'centered',
usesDataRouter: true,
docs: {
description: {
component: usernameSectionDescription,
},
},
},
} satisfies Meta<typeof UsernameSectionHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {};
export const RejectsShortUsername: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.clear(canvas.getByLabelText(/new username/i));
await userEvent.type(canvas.getByLabelText(/new username/i), 'ab');
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
await expect(canvas.getByText(/at least 3 characters/i)).toBeInTheDocument();
},
};
export const RejectsInvalidCharacters: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.clear(canvas.getByLabelText(/new username/i));
await userEvent.type(canvas.getByLabelText(/new username/i), 'bad user!');
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
await expect(canvas.getByText(/letters, numbers, dots, underscores/i)).toBeInTheDocument();
},
};
export const AcceptsValidUsername: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.clear(canvas.getByLabelText(/new username/i));
await userEvent.type(canvas.getByLabelText(/new username/i), 'valid.user-99');
await userEvent.click(canvas.getByRole('button', { name: /update username/i }));
await expect(canvas.getByTestId('redirect-success')).toBeInTheDocument();
},
};