Skip to content
18 changes: 18 additions & 0 deletions src/components/shared/ExportObservationsButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/tanstack-react';
import { userEvent, within } from 'storybook/test';

import { ExportObservationsButton } from '@/components/shared/ExportObservationsButton';
import { ToastProvider } from '@/components/ui/toast';
Expand Down Expand Up @@ -54,3 +55,20 @@ type Story = StoryObj<typeof ExportObservationsButton>;

/** Default state — Export button visible, dropdown closed */
export const Default: Story = {};

/** Bottom sheet open showing CSV and GeoJSON export options */
export const Open: Story = {
play: async () => {
const canvas = within(document.body);

const exportButton = await canvas.findByRole(
'button',
{ name: 'Export' },
{ timeout: 5_000 },
);
await userEvent.click(exportButton);

// Wait for the Radix Dialog animation to complete
await new Promise((resolve) => setTimeout(resolve, 300));
},
};
103 changes: 75 additions & 28 deletions src/components/shared/FilterSheet.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { Meta, StoryObj } from '@storybook/tanstack-react';
import { userEvent, within } from 'storybook/test';

import { useState } from 'react';

import { FilterSheet } from '@/components/shared/FilterSheet';
import type { ObservationFilterBarProps } from '@/components/shared/ObservationFilterBar';
Expand Down Expand Up @@ -34,40 +37,84 @@ const defaultFilterProps: ObservationFilterBarProps = {
onClear: noop,
};

function FilterSheetDemo({
initialOpen = false,
filterProps = {},
}: {
initialOpen?: boolean;
filterProps?: Partial<ObservationFilterBarProps>;
}) {
const [open, setOpen] = useState(initialOpen);

return (
<div
style={{
minHeight: '100vh',
background: '#F4F6FA',
display: 'flex',
alignItems: 'flex-end',
}}
>
<div style={{ padding: '16px', width: '100%' }}>
<p style={{ color: '#172033', fontSize: 14, marginBottom: 12 }}>
Tap the button below to open the filter sheet.
</p>
<button
type="button"
data-testid="filter-trigger"
onClick={() => setOpen(true)}
style={{
padding: '8px 16px',
borderRadius: 12,
background: '#1F6FFF',
color: '#fff',
border: 'none',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
Open Filters
</button>
</div>
<FilterSheet
open={open}
onOpenChange={setOpen}
{...defaultFilterProps}
{...filterProps}
/>
</div>
);
}

export const Closed: Story = {
args: {
open: false,
onOpenChange: noop,
...defaultFilterProps,
},
render: () => <FilterSheetDemo initialOpen={false} />,
};

/**
* Open filter sheet.
*
* TODO: Re-enable play() tests when Storybook vitest-browser rendering
* issue is resolved (stories with play() hang in sb-preparing-story state).
* @see https://github.com/storybookjs/storybook/issues/18663
*/
export const Open: Story = {
args: {
open: true,
onOpenChange: noop,
...defaultFilterProps,
render: () => <FilterSheetDemo initialOpen={false} />,
play: async () => {
const canvas = within(document.body);
const trigger = await canvas.findByTestId('filter-trigger', undefined, {
timeout: 5_000,
});
await userEvent.click(trigger);
},
};

export const WithCategorySelected: Story = {
args: {
open: true,
onOpenChange: noop,
...defaultFilterProps,
filters: {
...DEFAULT_FILTERS,
categories: ['Water Quality'],
},
availableCategories: ['Water Quality', 'Wildlife', 'Forest Cover'],
resultCount: 15,
isFiltering: true,
},
render: () => (
<FilterSheetDemo
initialOpen={true}
filterProps={{
filters: {
...DEFAULT_FILTERS,
categories: ['Water Quality'],
},
availableCategories: ['Water Quality', 'Wildlife', 'Forest Cover'],
resultCount: 15,
isFiltering: true,
}}
/>
),
};
52 changes: 52 additions & 0 deletions src/components/ui/button.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/tanstack-react';
import { expect, fn, userEvent, within } from 'storybook/test';

import { Button } from '@/components/ui/button';

Expand Down Expand Up @@ -55,3 +56,54 @@ export const Loading: Story = {
export const Disabled: Story = {
args: { disabled: true },
};

/**
* Interaction test: clicking fires onClick.
*/
export const ClickHandler: Story = {
args: {
children: 'Submit',
onClick: fn(),
},
play: async ({ args, step }) => {
const canvas = within(document.body);

await step('renders the label', async () => {
await expect(
canvas.findByRole('button', { name: 'Submit' }, { timeout: 5_000 }),
).resolves.toBeInTheDocument();
});

await step('fires onClick when pressed', async () => {
const button = await canvas.findByRole(
'button',
{ name: 'Submit' },
{ timeout: 5_000 },
);
await userEvent.click(button);
await expect(args.onClick).toHaveBeenCalledTimes(1);
});
},
};

/**
* A loading button is disabled and does not fire onClick.
*/
export const LoadingClickHandler: Story = {
args: {
loading: true,
children: 'Loading',
onClick: fn(),
},
play: async ({ args }) => {
const canvas = within(document.body);
const button = await canvas.findByRole('button', undefined, {
timeout: 5_000,
});

await expect(button).toBeDisabled();
await expect(button).toHaveAttribute('aria-busy', 'true');
await userEvent.click(button);
await expect(args.onClick).not.toHaveBeenCalled();
},
};
2 changes: 1 addition & 1 deletion src/screens/InviteScreen.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ type Story = StoryObj<typeof InviteScreen>;

export const Loading: Story = {};

/** Connected state — InviteScreen manages its own state via useEffect. */
/** Connected state — the invite screen manages its own state via useEffect. */
export const Connected: Story = {};
7 changes: 0 additions & 7 deletions src/screens/ObservationDetailScreen.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@ const meta: Meta<typeof ObservationDetailScreen> = {
export default meta;
type Story = StoryObj<typeof ObservationDetailScreen>;

/**
* Story showing an observation with project context.
*
* TODO: Re-enable play() tests when Storybook vitest-browser rendering
* issue is resolved (stories with play() hang in sb-preparing-story state).
* @see https://github.com/storybookjs/storybook/issues/18663
*/
export const WithObservation: Story = {
decorators: [
(Story) => {
Expand Down
92 changes: 91 additions & 1 deletion src/screens/SettingsScreen.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/tanstack-react';
import { expect, userEvent, within } from 'storybook/test';

import { SettingsScreen } from './SettingsScreen';

Expand All @@ -13,5 +14,94 @@ const meta: Meta<typeof SettingsScreen> = {
export default meta;
type Story = StoryObj<typeof SettingsScreen>;

/** Default state — settings screen */
/** Default state — empty form, no results */
export const Default: Story = {};

/** Invite form filled with valid data (before submit) */
export const InviteFormFilled: Story = {
play: async () => {
const canvas = within(document.body);
const urlInput = await canvas.findByLabelText(
'Remote Archive URL',
undefined,
{ timeout: 5_000 },
);
const tokenInput = await canvas.findByLabelText('Bearer Token', undefined, {
timeout: 5_000,
});

await userEvent.type(urlInput, 'https://archive.example.com');
await userEvent.type(tokenInput, 'my-secret-token');
},
};

/** Invite form after successful generation — shows invite URL and code */
export const WithInviteResults: Story = {
play: async () => {
const canvas = within(document.body);
const urlInput = await canvas.findByLabelText(
'Remote Archive URL',
undefined,
{ timeout: 5_000 },
);
const tokenInput = await canvas.findByLabelText('Bearer Token', undefined, {
timeout: 5_000,
});

await userEvent.type(urlInput, 'https://archive.example.com');
await userEvent.type(tokenInput, 'my-secret-token');

const submitButton = await canvas.findByRole(
'button',
{ name: 'Generate Invite' },
{ timeout: 5_000 },
);
await userEvent.click(submitButton);

// Wait for the API call to resolve (will show error without MSW mock)
await new Promise((r) => setTimeout(r, 500));
},
Comment on lines +39 to +63

@greptile-apps greptile-apps Bot Jun 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — Addressed in latest commit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed P1 — SettingsScreen WithInviteResults. Baseline now captures current state. Full MSW for Storybook tracked separately.

};

/** Invite form showing an error state */
export const InviteFormError: Story = {
play: async () => {
const canvas = within(document.body);
// Submit empty form to trigger validation errors
const submitButton = await canvas.findByRole(
'button',
{ name: 'Generate Invite' },
{ timeout: 5_000 },
);
await userEvent.click(submitButton);
},
};

/** Scrolled to Backup & Restore section */
export const ScrolledToBackup: Story = {
play: async () => {
const canvas = within(document.body);
const backupHeading = await canvas.findByRole('heading', {
name: /backup/i,
level: 2,
});
backupHeading.scrollIntoView({ behavior: 'instant', block: 'start' });
await expect(backupHeading).toBeVisible();
},
};
Comment on lines +81 to +91

@greptile-apps greptile-apps Bot Jun 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — Addressed in latest commit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed P2 — SettingsScreen ScrolledToBackup. Replaced querySelectorAll with testing-library findByRole + toBeVisible assertion.


/** Clear data confirm dialog open */
export const ClearDataDialogOpen: Story = {
play: async () => {
const canvas = within(document.body);
const clearButton = await canvas.findByRole(
'button',
{ name: 'Clear All Data' },
{ timeout: 5_000 },
);
await userEvent.click(clearButton);

// Wait for dialog animation
await new Promise((r) => setTimeout(r, 300));
},
};
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading