mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Storybook coverage: scan harness + stories (#7073)
## What Gets most of the app's components into Storybook and adds a scan that runs every story in a real browser, so we have a base to build accessibility testing on next. - **~380 new stories**, taking story files from 144 to 526. Components with a story: | Layer | Before | After | |---|---|---| | core | 41 / 309 (13%) | **183 / 309 (59%)** | | portal | 91 / 161 (57%) | **127 / 161 (79%)** | | proprietary | 1 / 105 (1%) | **39 / 105 (37%)** | | cloud / desktop / saas / portal-saas / prototypes | 0 / 84 | 0 / 84 (unchanged) | | **Total** | **133 / 659 (20%)** | **349 / 659 (53%)** | Both columns are counted the same way — every `.tsx` exporting a component, so the denominator includes things that aren't really visual units (contexts, providers, barrels). Excluding those it's 22% → 58%. Either way it's reproducible from the tree rather than a number you have to take on trust. - **Scan harness** — the Storybook Vitest addon runs each story in headless Chromium as a **render/smoke check** (a story must mount without throwing). New task: `task frontend:storybook:test` (pass a filter, e.g. `-- Button`). Separate Vitest config so it doesn't touch the existing jsdom unit tests. ## Scope - **Stories and Storybook config only, with one exception:** a one-line fix to `ProviderCard`, which re-rendered forever whenever its optional `settings` prop was omitted. Called out because it's the only component source change here. - The preview gains a `QueryClientProvider` (the portal app has one, so stories reaching a query hook threw without it), and the scan task now installs the browser it drives. - **a11y is report-only** and **nothing runs the scan in CI yet** — enforcing a11y and wiring it into CI is the follow-up, #7086. - Components that can't render as an isolated unit are **not** included: anything needing the full editor runtime (ToolWorkflow / FileManager / AppConfig / a live PDF engine) or that's headless (providers, gates, API bridges, config factories). Stories that only rendered by mounting the whole `AppProviders` tree were dropped for the same reason — that isn't isolation, and the tree's ErrorBoundary swallowed render failures so those stories could never fail. A few that need assets the headless browser can't serve are tagged `!test`, so they still show in the UI but sit out the scan. ## Testing Typecheck (all build variants), ESLint, Prettier and the unit suite pass. Every story in the scanned set mounts without throwing. ## Notes for reviewers - Stories use the `@app`/`@core`/`@portal`/`@proprietary` aliases (no deep relative imports) and mock data-fetching components with MSW. - Running the full suite in one go can flake on the Vite dep-optimizer; scan in small batches (or by filter) for a stable local run.
This commit is contained in:
@@ -194,6 +194,22 @@ tasks:
|
||||
cmds:
|
||||
- npx storybook build {{.CLI_ARGS}}
|
||||
|
||||
storybook:browser:
|
||||
internal: true
|
||||
desc: "Install the Chromium build the story scan runs in"
|
||||
run: once
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx playwright install chromium
|
||||
|
||||
storybook:test:
|
||||
desc: "Scan every story in real Chromium — each must mount without throwing"
|
||||
deps: [install, storybook:browser]
|
||||
cmds:
|
||||
# Runs each story as a browser test. Pass a filter through, e.g.
|
||||
# task frontend:storybook:test -- Button
|
||||
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
|
||||
|
||||
# ============================================================
|
||||
# Code quality
|
||||
# ============================================================
|
||||
|
||||
@@ -15,7 +15,11 @@ const config: StorybookConfig = {
|
||||
"../editor/src/portal/**/*.mdx",
|
||||
"../editor/src/**/*.stories.@(ts|tsx)",
|
||||
],
|
||||
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
|
||||
addons: [
|
||||
"@storybook/addon-themes",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-vitest",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ import { withThemeByDataAttribute } from "@storybook/addon-themes";
|
||||
// classic runtime needs it present even though it's not named in the JSX.
|
||||
void React;
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
@@ -86,6 +87,14 @@ if (!i18next.isInitialized) {
|
||||
// Start MSW once. Storybook runs in a browser so this uses the service worker.
|
||||
initialize({ onUnhandledRequest: "bypass" }, handlers);
|
||||
|
||||
// PortalApp wraps the app in a QueryClientProvider, so any component reaching a
|
||||
// shared query hook throws "No QueryClient set" without one here. `retry: false`
|
||||
// matches the portal test providers: a story showing an error state should show
|
||||
// it immediately rather than sitting through backoff retries.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
|
||||
// method, wallet) clear the session check and reach the MSW handlers instead of
|
||||
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
|
||||
@@ -194,24 +203,26 @@ const withProviders: Decorator = (Story, context) => {
|
||||
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ThemeProvider>
|
||||
<SchemeSetup scheme={colorScheme} />
|
||||
<ThemeBridge theme={colorScheme}>
|
||||
<SuiProvider colorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<SchemeSetup scheme={colorScheme} />
|
||||
<ThemeBridge theme={colorScheme}>
|
||||
<SuiProvider colorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
|
||||
|
||||
/**
|
||||
* Dedicated Vitest config that turns every story into a browser test: it mounts
|
||||
* the story in real Chromium as a render/smoke check (a story must mount without
|
||||
* throwing). a11y is currently report-only (preview's `a11y.test: "todo"`) and is
|
||||
* not yet enforced here — flipping it to pass/fail is a follow-up. Kept separate
|
||||
* from editor/vitest.config.ts (the jsdom unit tests) so the two suites don't collide.
|
||||
*
|
||||
* The storybook test must live in a `test.projects[]` entry (not a flat config)
|
||||
* so Vitest wires up the browser test runner correctly.
|
||||
*
|
||||
* Run with: npx vitest run --config .storybook/vitest.config.ts
|
||||
*/
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
// Pre-scan every story + the preview so Vite discovers the story set's large
|
||||
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
|
||||
entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"],
|
||||
// `entries` alone does not catch deps reached only through a transformed
|
||||
// JSX runtime import, so Vite optimizes them lazily mid-run and emits
|
||||
// "optimized dependencies changed, reloading". That reload tears down the
|
||||
// browser worker and whichever stories were mid-load fail with a bogus
|
||||
// "Failed to fetch dynamically imported module" — a result that looks real.
|
||||
// Naming them here keeps a run deterministic.
|
||||
include: [
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"react-dom",
|
||||
"react-dom/client",
|
||||
],
|
||||
},
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
// Reads .storybook/main.ts (stories glob + viteFinal aliases) so every
|
||||
// discovered story becomes a test with the app's real module resolution.
|
||||
plugins: [storybookTest({ configDir: resolve(__dirname) })],
|
||||
test: {
|
||||
name: "storybook",
|
||||
// Mounting a story takes well over Vitest's 5s default on the heavier
|
||||
// screens, and a story that trips the timeout is reported as a failure
|
||||
// with no message — which reads like a crash. Give it room; a
|
||||
// genuinely hung story still fails, just later.
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 60_000,
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
provider: "playwright",
|
||||
instances: [{ browser: "chromium" }],
|
||||
},
|
||||
setupFiles: [resolve(__dirname, "vitest.setup.ts")],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { beforeAll } from "vitest";
|
||||
import { setProjectAnnotations } from "@storybook/react-vite";
|
||||
// eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
|
||||
import * as projectAnnotations from "./preview";
|
||||
|
||||
// Apply the same decorators/parameters/globals the Storybook UI uses (providers,
|
||||
// i18n, theme) so stories run under Vitest render identically to the browser.
|
||||
const project = setProjectAnnotations([projectAnnotations]);
|
||||
|
||||
beforeAll(project.beforeAll);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Components/AppLayout",
|
||||
component: AppLayout,
|
||||
parameters: { layout: "fullscreen" },
|
||||
// AppLayout reads the active banner from BannerContext and renders
|
||||
// NavigationWarningModal + LoginAgreementModal, which need the navigation
|
||||
// guard (backed by the tool registry) mounted above them.
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<BannerProvider>
|
||||
<Story />
|
||||
</BannerProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof AppLayout>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const sampleContent = (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h1>Workbench</h1>
|
||||
<p>Tool panels and file previews render in this area.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: sampleContent,
|
||||
},
|
||||
};
|
||||
|
||||
// Calls BannerContext's setBanner on mount so the story can exercise
|
||||
// AppLayout's height-adjustment behaviour (the child area shrinks to make
|
||||
// room for the banner) without adding a banner prop to AppLayout itself.
|
||||
function BannerSetter() {
|
||||
const { setBanner } = useBanner();
|
||||
useEffect(() => {
|
||||
setBanner(
|
||||
<InfoBanner
|
||||
icon="info-rounded"
|
||||
title="Heads up"
|
||||
message="This workspace is running in offline mode."
|
||||
/>,
|
||||
);
|
||||
return () => setBanner(null);
|
||||
}, [setBanner]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const WithBanner: Story = {
|
||||
args: {
|
||||
children: sampleContent,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<>
|
||||
<BannerSetter />
|
||||
<Story />
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import StorageStatsCard from "@app/components/StorageStatsCard";
|
||||
import { StorageStats } from "@app/services/fileStorage";
|
||||
|
||||
const storageStats: StorageStats = {
|
||||
used: 128 * 1024 * 1024,
|
||||
available: 512 * 1024 * 1024,
|
||||
fileCount: 12,
|
||||
quota: 512 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Components/StorageStatsCard",
|
||||
component: StorageStatsCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
storageStats,
|
||||
filesCount: 12,
|
||||
onClearAll: () => {},
|
||||
onReloadFiles: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof StorageStatsCard>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const NearingQuota: Story = {
|
||||
args: {
|
||||
storageStats: {
|
||||
...storageStats,
|
||||
used: 460 * 1024 * 1024,
|
||||
fileCount: 48,
|
||||
},
|
||||
filesCount: 48,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoQuota: Story = {
|
||||
args: {
|
||||
storageStats: {
|
||||
used: 32 * 1024 * 1024,
|
||||
available: 0,
|
||||
fileCount: 3,
|
||||
},
|
||||
filesCount: 3,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
||||
import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
|
||||
// BaseAnnotationTool reads usePDFAnnotation()/useSignature() for undo/redo and
|
||||
// placement wiring — stub both providers so the story can mount standalone.
|
||||
const StoryProviders = ({ children }: { children: ReactNode }) => (
|
||||
<SignatureProvider>
|
||||
<PDFAnnotationProvider
|
||||
activateDrawMode={() => {}}
|
||||
deactivateDrawMode={() => {}}
|
||||
activateSignaturePlacementMode={() => {}}
|
||||
activateDeleteMode={() => {}}
|
||||
updateDrawSettings={() => {}}
|
||||
undo={() => {}}
|
||||
redo={() => {}}
|
||||
storeImageData={() => {}}
|
||||
getImageData={() => undefined}
|
||||
isPlacementMode={false}
|
||||
signatureConfig={null}
|
||||
setSignatureConfig={() => {}}
|
||||
>
|
||||
{children}
|
||||
</PDFAnnotationProvider>
|
||||
</SignatureProvider>
|
||||
);
|
||||
|
||||
// BaseAnnotationTool clones its child with tool-specific props (selectedColor,
|
||||
// signatureData, onSignatureDataChange, onColorSwatchClick, disabled) — a real
|
||||
// tool component absorbs these; a bare DOM element would just log prop warnings.
|
||||
const ToolContent = (_props: Record<string, unknown>) => (
|
||||
<div>Tool content</div>
|
||||
);
|
||||
|
||||
const meta = {
|
||||
title: "Annotation/BaseAnnotationTool",
|
||||
component: BaseAnnotationTool,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<StoryProviders>
|
||||
<Story />
|
||||
</StoryProviders>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof BaseAnnotationTool>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
config: {
|
||||
enableImageUpload: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Image",
|
||||
},
|
||||
children: <ToolContent />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ColorControl } from "@app/components/annotation/shared/ColorControl";
|
||||
|
||||
const meta: Meta<typeof ColorControl> = {
|
||||
title: "Annotation/ColorControl",
|
||||
component: ColorControl,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ColorControl>;
|
||||
|
||||
function ColorControlDemo({
|
||||
initialColor = "#ff0000",
|
||||
disabled,
|
||||
}: {
|
||||
initialColor?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [color, setColor] = useState(initialColor);
|
||||
return (
|
||||
<ColorControl
|
||||
label="Colour"
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = { render: () => <ColorControlDemo /> };
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => <ColorControlDemo disabled />,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||
|
||||
const meta = {
|
||||
title: "Annotation/Shared/ColorPicker",
|
||||
component: ColorPicker,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
selectedColor: "#cc0000",
|
||||
onColorChange: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof ColorPicker>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function ColorPickerDemo(
|
||||
props: Partial<React.ComponentProps<typeof ColorPicker>>,
|
||||
) {
|
||||
const [color, setColor] = useState(props.selectedColor ?? "#cc0000");
|
||||
const [opacity, setOpacity] = useState(props.opacity ?? 100);
|
||||
return (
|
||||
<ColorPicker
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
{...props}
|
||||
selectedColor={color}
|
||||
onColorChange={setColor}
|
||||
opacity={opacity}
|
||||
onOpacityChange={setOpacity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The base modal: swatches + hex picker, no opacity slider. */
|
||||
export const Default: Story = {
|
||||
render: () => <ColorPickerDemo />,
|
||||
};
|
||||
|
||||
/** With the opacity slider shown, for tools that need translucent fills (e.g. highlight, watermark). */
|
||||
export const WithOpacity: Story = {
|
||||
render: () => <ColorPickerDemo showOpacity />,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
||||
|
||||
const meta = {
|
||||
title: "Annotation/DrawingCanvas",
|
||||
component: DrawingCanvas,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
selectedColor: "#000000",
|
||||
penSize: 3,
|
||||
penSizeInput: "3",
|
||||
onColorSwatchClick: () => {},
|
||||
onPenSizeChange: () => {},
|
||||
onPenSizeInputChange: () => {},
|
||||
onSignatureDataChange: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof DrawingCanvas>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function DrawingCanvasDemo(
|
||||
props: Partial<React.ComponentProps<typeof DrawingCanvas>>,
|
||||
) {
|
||||
const [penSize, setPenSize] = useState(3);
|
||||
const [penSizeInput, setPenSizeInput] = useState("3");
|
||||
|
||||
return (
|
||||
<DrawingCanvas
|
||||
selectedColor="#000000"
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => {}}
|
||||
onPenSizeChange={(size) => {
|
||||
setPenSize(size);
|
||||
setPenSizeInput(String(size));
|
||||
}}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty canvas preview with the drawing modal closed. */
|
||||
export const Default: Story = { render: () => <DrawingCanvasDemo /> };
|
||||
|
||||
/** Preview disabled — clicking the canvas should not open the modal. */
|
||||
export const Disabled: Story = {
|
||||
render: () => <DrawingCanvasDemo disabled />,
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DrawingControls } from "@app/components/annotation/shared/DrawingControls";
|
||||
|
||||
const meta: Meta<typeof DrawingControls> = {
|
||||
title: "Annotation/DrawingControls",
|
||||
component: DrawingControls,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DrawingControls>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onUndo: () => {},
|
||||
onRedo: () => {},
|
||||
onPlaceSignature: () => {},
|
||||
hasSignatureData: true,
|
||||
canUndo: true,
|
||||
canRedo: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoHistory: Story = {
|
||||
args: {
|
||||
onUndo: () => {},
|
||||
onRedo: () => {},
|
||||
onPlaceSignature: () => {},
|
||||
hasSignatureData: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
||||
|
||||
const meta = {
|
||||
title: "Annotation/Shared/ImageUploader",
|
||||
component: ImageUploader,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof ImageUploader>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onImageChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabelAndHint: Story = {
|
||||
args: {
|
||||
onImageChange: () => {},
|
||||
label: "Signature image",
|
||||
hint: "PNG, JPG, or SVG - transparent backgrounds work best",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBackgroundRemoval: Story = {
|
||||
args: {
|
||||
onImageChange: () => {},
|
||||
allowBackgroundRemoval: true,
|
||||
onProcessedImageData: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
onImageChange: () => {},
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { OpacityControl } from "@app/components/annotation/shared/OpacityControl";
|
||||
|
||||
const meta: Meta<typeof OpacityControl> = {
|
||||
title: "Annotation/OpacityControl",
|
||||
component: OpacityControl,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof OpacityControl>;
|
||||
|
||||
function OpacityControlDemo({ disabled }: { disabled?: boolean }) {
|
||||
const [value, setValue] = useState(80);
|
||||
return (
|
||||
<OpacityControl value={value} onChange={setValue} disabled={disabled} />
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = { render: () => <OpacityControlDemo /> };
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => <OpacityControlDemo disabled />,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PropertiesPopover } from "@app/components/annotation/shared/PropertiesPopover";
|
||||
|
||||
const meta = {
|
||||
title: "Annotation/Shared/PropertiesPopover",
|
||||
component: PropertiesPopover,
|
||||
parameters: { layout: "centered" },
|
||||
} satisfies Meta<typeof PropertiesPopover>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Text: Story = {
|
||||
args: {
|
||||
annotationType: "text",
|
||||
annotation: undefined,
|
||||
onUpdate: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Shape: Story = {
|
||||
args: {
|
||||
annotationType: "shape",
|
||||
annotation: undefined,
|
||||
onUpdate: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
annotationType: "text",
|
||||
annotation: undefined,
|
||||
onUpdate: () => {},
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
||||
|
||||
const meta = {
|
||||
title: "Annotation/Shared/TextInputWithFont",
|
||||
component: TextInputWithFont,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
text: "Confidential",
|
||||
onTextChange: () => {},
|
||||
fontSize: 24,
|
||||
onFontSizeChange: () => {},
|
||||
fontFamily: "Helvetica",
|
||||
onFontFamilyChange: () => {},
|
||||
label: "Text",
|
||||
placeholder: "Enter text",
|
||||
fontLabel: "Font",
|
||||
fontSizeLabel: "Size",
|
||||
fontSizePlaceholder: "24",
|
||||
},
|
||||
} satisfies Meta<typeof TextInputWithFont>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function TextInputWithFontDemo(
|
||||
props: Partial<React.ComponentProps<typeof TextInputWithFont>>,
|
||||
) {
|
||||
const [text, setText] = useState(props.text ?? "Confidential");
|
||||
const [fontSize, setFontSize] = useState(props.fontSize ?? 24);
|
||||
const [fontFamily, setFontFamily] = useState(props.fontFamily ?? "Helvetica");
|
||||
const [textColor, setTextColor] = useState(props.textColor ?? "#000000");
|
||||
const [textAlign, setTextAlign] = useState<"left" | "center" | "right">(
|
||||
props.textAlign ?? "left",
|
||||
);
|
||||
|
||||
return (
|
||||
<TextInputWithFont
|
||||
label="Text"
|
||||
placeholder="Enter text"
|
||||
fontLabel="Font"
|
||||
fontSizeLabel="Size"
|
||||
fontSizePlaceholder="24"
|
||||
colorLabel="Colour"
|
||||
{...props}
|
||||
text={text}
|
||||
onTextChange={setText}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={setFontSize}
|
||||
fontFamily={fontFamily}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
textColor={textColor}
|
||||
onTextColorChange={setTextColor}
|
||||
textAlign={textAlign}
|
||||
onTextAlignChange={setTextAlign}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full control set: text, font, size, colour and alignment. */
|
||||
export const Default: Story = {
|
||||
render: () => <TextInputWithFontDemo />,
|
||||
};
|
||||
|
||||
/** Without the colour picker or alignment control, for tools that don't need them. */
|
||||
export const WithoutColorOrAlign: Story = {
|
||||
render: () => (
|
||||
<TextInputWithFontDemo
|
||||
onTextColorChange={undefined}
|
||||
onTextAlignChange={undefined}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** All fields disabled. */
|
||||
export const Disabled: Story = {
|
||||
render: () => <TextInputWithFontDemo disabled />,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { WidthControl } from "@app/components/annotation/shared/WidthControl";
|
||||
|
||||
const meta: Meta<typeof WidthControl> = {
|
||||
title: "Annotation/WidthControl",
|
||||
component: WidthControl,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WidthControl>;
|
||||
|
||||
function WidthControlDemo({
|
||||
min = 1,
|
||||
max = 12,
|
||||
disabled,
|
||||
}: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState(Math.round((min + max) / 2));
|
||||
return (
|
||||
<WidthControl
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = { render: () => <WidthControlDemo /> };
|
||||
|
||||
export const Disabled: Story = { render: () => <WidthControlDemo disabled /> };
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DrawingTool } from "@app/components/annotation/tools/DrawingTool";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider";
|
||||
|
||||
// DrawingTool renders BaseAnnotationTool, which reads both SignatureContext and
|
||||
// PDFAnnotationContext — neither is mounted by the shared preview, so stub both
|
||||
// here with no-op handlers.
|
||||
const meta = {
|
||||
title: "Annotation/DrawingTool",
|
||||
component: DrawingTool,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<SignatureProvider>
|
||||
<PDFAnnotationProvider
|
||||
activateDrawMode={() => {}}
|
||||
deactivateDrawMode={() => {}}
|
||||
activateSignaturePlacementMode={() => {}}
|
||||
activateDeleteMode={() => {}}
|
||||
updateDrawSettings={() => {}}
|
||||
undo={() => {}}
|
||||
redo={() => {}}
|
||||
storeImageData={() => {}}
|
||||
getImageData={() => undefined}
|
||||
isPlacementMode={false}
|
||||
signatureConfig={null}
|
||||
setSignatureConfig={() => {}}
|
||||
>
|
||||
<Story />
|
||||
</PDFAnnotationProvider>
|
||||
</SignatureProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof DrawingTool>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onDrawingChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
onDrawingChange: () => {},
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ImageTool } from "@app/components/annotation/tools/ImageTool";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider";
|
||||
|
||||
// ImageTool renders BaseAnnotationTool, which reads both SignatureContext and
|
||||
// PDFAnnotationContext — neither is mounted by the shared preview, so stub both
|
||||
// here with no-op handlers.
|
||||
const meta = {
|
||||
title: "Annotation/ImageTool",
|
||||
component: ImageTool,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<SignatureProvider>
|
||||
<PDFAnnotationProvider
|
||||
activateDrawMode={() => {}}
|
||||
deactivateDrawMode={() => {}}
|
||||
activateSignaturePlacementMode={() => {}}
|
||||
activateDeleteMode={() => {}}
|
||||
updateDrawSettings={() => {}}
|
||||
undo={() => {}}
|
||||
redo={() => {}}
|
||||
storeImageData={() => {}}
|
||||
getImageData={() => undefined}
|
||||
isPlacementMode={false}
|
||||
signatureConfig={null}
|
||||
setSignatureConfig={() => {}}
|
||||
>
|
||||
<Story />
|
||||
</PDFAnnotationProvider>
|
||||
</SignatureProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof ImageTool>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onImageChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
onImageChange: () => {},
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const buildFileStub = (
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub => ({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FileEditor/FileEditorFileName",
|
||||
component: FileEditorFileName,
|
||||
} satisfies Meta<typeof FileEditorFileName>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
file: buildFileStub(),
|
||||
},
|
||||
};
|
||||
|
||||
export const LongFileName: Story = {
|
||||
args: {
|
||||
file: buildFileStub({
|
||||
name: "annual-financial-report-quarter-four-2026-final-version.pdf",
|
||||
}),
|
||||
maxLength: 30,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import CompactFileDetails from "@app/components/fileManager/CompactFileDetails";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const buildFileStub = (
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub => ({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1024 * 512,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/CompactFileDetails",
|
||||
component: CompactFileDetails,
|
||||
args: {
|
||||
onPrevious: () => {},
|
||||
onNext: () => {},
|
||||
onOpenFiles: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof CompactFileDetails>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
currentFile: buildFileStub(),
|
||||
thumbnail: null,
|
||||
selectedFiles: [buildFileStub()],
|
||||
currentFileIndex: 0,
|
||||
numberOfFiles: 1,
|
||||
isAnimating: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleFiles: Story = {
|
||||
args: {
|
||||
currentFile: buildFileStub({ name: "invoice-final.pdf", versionNumber: 2 }),
|
||||
thumbnail: null,
|
||||
selectedFiles: [
|
||||
buildFileStub({ id: "file-1" as FileId, name: "invoice-final.pdf" }),
|
||||
buildFileStub({ id: "file-2" as FileId, name: "receipt.pdf" }),
|
||||
buildFileStub({ id: "file-3" as FileId, name: "statement.pdf" }),
|
||||
],
|
||||
currentFileIndex: 1,
|
||||
numberOfFiles: 3,
|
||||
isAnimating: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFileLoaded: Story = {
|
||||
args: {
|
||||
currentFile: null,
|
||||
thumbnail: null,
|
||||
selectedFiles: [],
|
||||
currentFileIndex: 0,
|
||||
numberOfFiles: 0,
|
||||
isAnimating: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import DragOverlay from "@app/components/fileManager/DragOverlay";
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/DragOverlay",
|
||||
component: DragOverlay,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof DragOverlay>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isVisible: true,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ position: "relative", height: "20rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
isVisible: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileDetails from "@app/components/fileManager/FileDetails";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFile: StirlingFileStub = {
|
||||
id: "story-file-1" as FileId,
|
||||
name: "quarterly-report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "story-file-1",
|
||||
versionNumber: 1,
|
||||
// Set so useIndexedDBThumbnail short-circuits on the stored thumbnail
|
||||
// instead of trying to read file bytes out of IndexedDB.
|
||||
thumbnailUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E",
|
||||
};
|
||||
|
||||
/**
|
||||
* FileDetails reads from FileManagerContext, which itself needs FileContext
|
||||
* (for useFileActions/useFileManagement) and IndexedDBContext (pulled in by
|
||||
* FileContextProvider) further up the tree — neither is part of the shared
|
||||
* preview decorators, so both are stood up here with static mock data.
|
||||
*/
|
||||
function withFileManager(activeFileIds: FileId[]) {
|
||||
return (Story: () => ReactElement) => (
|
||||
<FileContextProvider>
|
||||
<FileManagerProvider
|
||||
recentFiles={[mockFile]}
|
||||
onRecentFilesSelected={() => {}}
|
||||
onNewFilesSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
isFileSupported={() => true}
|
||||
isOpen
|
||||
onFileRemove={() => {}}
|
||||
modalHeight="600px"
|
||||
refreshRecentFiles={async () => {}}
|
||||
isLoading={false}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
<Story />
|
||||
</FileManagerProvider>
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "FileManager/FileDetails",
|
||||
component: FileDetails,
|
||||
} satisfies Meta<typeof FileDetails>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [withFileManager([mockFile.id])],
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
decorators: [withFileManager([])],
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
},
|
||||
decorators: [withFileManager([mockFile.id])],
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const buildFileStub = (
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub => ({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const localFile = buildFileStub();
|
||||
const cloudOnlyFile = buildFileStub({
|
||||
id: "server-1" as FileId,
|
||||
name: "shared-invoice.pdf",
|
||||
originalFileId: "server-1",
|
||||
remoteStorageId: 42,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/DeleteFilesDialog",
|
||||
component: DeleteFilesDialog,
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
onConfirm: async () => {},
|
||||
},
|
||||
} satisfies Meta<typeof DeleteFilesDialog>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
files: [localFile],
|
||||
},
|
||||
};
|
||||
|
||||
export const CloudOnly: Story = {
|
||||
args: {
|
||||
files: [cloudOnlyFile],
|
||||
},
|
||||
};
|
||||
|
||||
export const LocalAndCloudChoice: Story = {
|
||||
args: {
|
||||
files: [localFile, cloudOnlyFile],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog";
|
||||
import { createFolderId } from "@app/types/folder";
|
||||
|
||||
const mockFolder = {
|
||||
id: createFolderId(),
|
||||
name: "Invoices",
|
||||
parentFolderId: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/DeleteFolderDialog",
|
||||
component: DeleteFolderDialog,
|
||||
} satisfies Meta<typeof DeleteFolderDialog>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
folder: mockFolder,
|
||||
fileCount: 0,
|
||||
onClose: () => {},
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFiles: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
folder: mockFolder,
|
||||
fileCount: 12,
|
||||
onClose: () => {},
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { FolderRecord } from "@app/types/folder";
|
||||
|
||||
const buildFileStub = (
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub => ({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 245_760,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1" as FileId,
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const folder: FolderRecord = {
|
||||
id: "folder-1" as FolderRecord["id"],
|
||||
name: "Contracts",
|
||||
parentFolderId: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FileDetailsPanel",
|
||||
component: FileDetailsPanel,
|
||||
} satisfies Meta<typeof FileDetailsPanel>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const singleFile = buildFileStub();
|
||||
const fileMap = new Map<FileId, StirlingFileStub>([
|
||||
[singleFile.id, singleFile],
|
||||
]);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
selectedFileIds: [singleFile.id],
|
||||
fileMap,
|
||||
currentFolder: null,
|
||||
onClose: () => {},
|
||||
onAddToWorkspace: () => {},
|
||||
onMove: () => {},
|
||||
onRemove: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const InFolder: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
currentFolder: folder,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
args: (() => {
|
||||
const fileA = buildFileStub({ id: "file-a" as FileId, name: "a.pdf" });
|
||||
const fileB = buildFileStub({
|
||||
id: "file-b" as FileId,
|
||||
name: "b.pdf",
|
||||
size: 102_400,
|
||||
});
|
||||
return {
|
||||
selectedFileIds: [fileA.id, fileB.id],
|
||||
fileMap: new Map<FileId, StirlingFileStub>([
|
||||
[fileA.id, fileA],
|
||||
[fileB.id, fileB],
|
||||
]),
|
||||
currentFolder: null,
|
||||
onClose: () => {},
|
||||
onAddToWorkspace: () => {},
|
||||
onMove: () => {},
|
||||
onRemove: () => {},
|
||||
};
|
||||
})(),
|
||||
};
|
||||
|
||||
export const LocalOnlyWithSaveToServer: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
onSaveToServer: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
FileGrid,
|
||||
type FilesPageEntry,
|
||||
} from "@app/components/filesPage/FileGrid";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const buildFileStub = (
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub => ({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1_240_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
// Set so useLazyThumbnail short-circuits on the stored thumbnail instead of
|
||||
// trying to read file bytes out of IndexedDB.
|
||||
thumbnailUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const localFile = buildFileStub();
|
||||
const cloudFile = buildFileStub({
|
||||
id: "file-2" as FileId,
|
||||
name: "shared-invoice.pdf",
|
||||
originalFileId: "file-2",
|
||||
remoteStorageId: 42,
|
||||
remoteOwnedByCurrentUser: true,
|
||||
});
|
||||
const spreadsheetFile = buildFileStub({
|
||||
id: "file-3" as FileId,
|
||||
name: "budget.xlsx",
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
originalFileId: "file-3",
|
||||
thumbnailUrl: undefined,
|
||||
});
|
||||
|
||||
const fileEntries: FilesPageEntry[] = [
|
||||
{ kind: "file", file: localFile },
|
||||
{ kind: "file", file: cloudFile },
|
||||
{ kind: "file", file: spreadsheetFile },
|
||||
];
|
||||
|
||||
/**
|
||||
* FileGrid renders file cards/rows via useLazyThumbnail, which reads
|
||||
* IndexedDBContext + FileContext further up the tree - neither is part of
|
||||
* the shared preview decorators, so FileContextProvider is stood up here.
|
||||
* Folder entries are intentionally left out of these mocks: FolderCard /
|
||||
* FolderRow call useFolders(), which needs a FolderProvider wired to
|
||||
* IndexedDB/auth/app-config context this story doesn't stand up.
|
||||
*/
|
||||
function withFileContext(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FileGrid",
|
||||
component: FileGrid,
|
||||
decorators: [withFileContext],
|
||||
args: {
|
||||
entries: fileEntries,
|
||||
selectedFileIds: new Set<FileId>(),
|
||||
viewMode: "grid",
|
||||
onSelectFile: () => {},
|
||||
onOpenFolder: () => {},
|
||||
onOpenFile: () => {},
|
||||
onMoveFiles: () => {},
|
||||
onMoveFolder: () => {},
|
||||
onRenameFolder: () => {},
|
||||
onDeleteFolder: () => {},
|
||||
onChangeFolderAppearance: () => {},
|
||||
onRemoveFiles: () => {},
|
||||
onPromptMoveFiles: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof FileGrid>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const ListMode: Story = {
|
||||
args: {
|
||||
viewMode: "list",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
entries: [],
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
entries: [],
|
||||
loading: false,
|
||||
currentTab: "all",
|
||||
onEmptyUpload: () => {},
|
||||
onEmptyCreateFolder: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge";
|
||||
|
||||
const meta: Meta<typeof FileOriginBadge> = {
|
||||
title: "FilesPage/FileOriginBadge",
|
||||
component: FileOriginBadge,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
origin: "local",
|
||||
},
|
||||
};
|
||||
|
||||
export const Cloud: Story = {
|
||||
args: {
|
||||
origin: "cloud",
|
||||
},
|
||||
};
|
||||
|
||||
export const SharedWithMe: Story = {
|
||||
args: {
|
||||
origin: "shared-with-me",
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
origin: "cloud",
|
||||
compact: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
||||
import { FolderRecord } from "@app/types/folder";
|
||||
|
||||
const folder: FolderRecord = {
|
||||
id: "folder-1" as FolderRecord["id"],
|
||||
name: "Contracts",
|
||||
parentFolderId: null,
|
||||
color: "#3b82f6",
|
||||
icon: "star",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FolderAppearancePicker",
|
||||
component: FolderAppearancePicker,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
folder,
|
||||
onChange: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof FolderAppearancePicker>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const NoAppearanceSet: Story = {
|
||||
args: {
|
||||
folder: {
|
||||
...folder,
|
||||
color: undefined,
|
||||
icon: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FolderNameDialog",
|
||||
component: FolderNameDialog,
|
||||
} satisfies Meta<typeof FolderNameDialog>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
title: "New folder",
|
||||
submitLabel: "Create",
|
||||
onClose: () => {},
|
||||
onSubmit: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Rename: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
title: "Rename folder",
|
||||
initialName: "Invoices 2026",
|
||||
submitLabel: "Save",
|
||||
onClose: () => {},
|
||||
onSubmit: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FolderThumbnail",
|
||||
component: FolderThumbnail,
|
||||
} satisfies Meta<typeof FolderThumbnail>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
color: "#6366f1",
|
||||
fileCount: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export const RowSize: Story = {
|
||||
args: {
|
||||
color: "#22c55e",
|
||||
fileCount: 3,
|
||||
size: "row",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIconGlyph: Story = {
|
||||
args: {
|
||||
color: "#f97316",
|
||||
fileCount: 5,
|
||||
iconGlyph: "📄",
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
size: "thumb",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { FolderProvider } from "@app/contexts/FolderContext";
|
||||
import { FilesPageProvider } from "@app/contexts/FilesPageContext";
|
||||
import { ROOT_FOLDER_ID } from "@app/types/folder";
|
||||
|
||||
/**
|
||||
* FolderTreeSidebar reads the folder tree and active tab from FolderContext /
|
||||
* FilesPageContext, neither of which is part of the shared preview
|
||||
* decorators. Both providers also pull in IndexedDBContext (via
|
||||
* FileContextProvider) further up the tree, so all three are stood up here.
|
||||
* No folders are seeded into IndexedDB, so the tree renders with just the
|
||||
* pinned "All files" / "Local" rows - an accurate empty state.
|
||||
*/
|
||||
function withFolderContexts(Story: () => React.JSX.Element) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<FolderProvider>
|
||||
<FilesPageProvider>
|
||||
<Story />
|
||||
</FilesPageProvider>
|
||||
</FolderProvider>
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FolderTreeSidebar",
|
||||
component: FolderTreeSidebar,
|
||||
decorators: [withFolderContexts],
|
||||
args: {
|
||||
fileCounts: new Map([[ROOT_FOLDER_ID, 0]]),
|
||||
onRequestNewFolder: () => {},
|
||||
onRenameFolder: () => {},
|
||||
onDeleteFolder: () => {},
|
||||
onMoveFilesIntoFolder: async () => {},
|
||||
},
|
||||
} satisfies Meta<typeof FolderTreeSidebar>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithFileCounts: Story = {
|
||||
args: {
|
||||
fileCounts: new Map([[ROOT_FOLDER_ID, 12]]),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
|
||||
import { createFolderId, FolderRecord } from "@app/types/folder";
|
||||
|
||||
const workId = createFolderId();
|
||||
const invoicesId = createFolderId();
|
||||
const archivedId = createFolderId();
|
||||
|
||||
const folders: FolderRecord[] = [
|
||||
{
|
||||
id: workId,
|
||||
name: "Work",
|
||||
parentFolderId: null,
|
||||
color: "#3b82f6",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: invoicesId,
|
||||
name: "Invoices",
|
||||
parentFolderId: workId,
|
||||
color: "#10b981",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: archivedId,
|
||||
name: "Archived",
|
||||
parentFolderId: null,
|
||||
color: "#f59e0b",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/MoveToFolderDialog",
|
||||
component: MoveToFolderDialog,
|
||||
} satisfies Meta<typeof MoveToFolderDialog>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
folders,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDisabledDescendant: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
folders,
|
||||
disabledFolderId: workId,
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCreateFolder: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
folders,
|
||||
onConfirm: () => {},
|
||||
onCreateFolder: async (name, parentFolderId) => ({
|
||||
id: createFolderId(),
|
||||
name,
|
||||
parentFolderId,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
folders: [],
|
||||
onConfirm: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFile: StirlingFileStub = {
|
||||
id: "file-1" as FileId,
|
||||
name: "document.pdf",
|
||||
type: "application/pdf",
|
||||
size: 12345,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
};
|
||||
|
||||
/** Core-flavor stub: renders nothing (desktop-only menu item). */
|
||||
const meta = {
|
||||
title: "FilesPage/OpenInNewWindowMenuItem",
|
||||
component: OpenInNewWindowMenuItem,
|
||||
} satisfies Meta<typeof OpenInNewWindowMenuItem>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
file: mockFile,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFile: StirlingFileStub = {
|
||||
id: "file-3" as FileId,
|
||||
name: "report-watermarked.pdf",
|
||||
type: "application/pdf",
|
||||
size: 110592,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1" as FileId,
|
||||
versionNumber: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* The modal loads its version chain from IndexedDB (empty in Storybook) and
|
||||
* dispatches file/navigation actions on add-to-workspace and remove, so it
|
||||
* needs FileContext (also supplies IndexedDBContext) and the tool-registry-
|
||||
* backed NavigationContext mounted above it.
|
||||
*/
|
||||
function withProviders(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<Story />
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/VersionHistoryModal",
|
||||
component: VersionHistoryModal,
|
||||
decorators: [withProviders],
|
||||
args: {
|
||||
onClose: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof VersionHistoryModal>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** No prior versions exist in the (empty) storage, so the modal shows the empty state. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
file: mockFile,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFileSelected: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
file: null,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { VersionTimeline } from "@app/components/filesPage/VersionTimeline";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId, ToolOperation } from "@app/types/file";
|
||||
|
||||
const buildFileStub = (
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub => ({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const toolOp = (toolId: ToolOperation["toolId"]): ToolOperation => ({
|
||||
toolId,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
const shortChain: StirlingFileStub[] = [
|
||||
buildFileStub({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
size: 204800,
|
||||
versionNumber: 1,
|
||||
}),
|
||||
buildFileStub({
|
||||
id: "file-2" as FileId,
|
||||
name: "report-compressed.pdf",
|
||||
size: 102400,
|
||||
versionNumber: 2,
|
||||
parentFileId: "file-1" as FileId,
|
||||
toolHistory: [toolOp("compress")],
|
||||
}),
|
||||
buildFileStub({
|
||||
id: "file-3" as FileId,
|
||||
name: "report-watermarked.pdf",
|
||||
size: 110592,
|
||||
versionNumber: 3,
|
||||
parentFileId: "file-2" as FileId,
|
||||
toolHistory: [toolOp("compress"), toolOp("watermark")],
|
||||
}),
|
||||
];
|
||||
|
||||
const longChain: StirlingFileStub[] = Array.from({ length: 9 }, (_, index) => {
|
||||
const versionNumber = index + 1;
|
||||
const toolHistory: ToolOperation[] =
|
||||
versionNumber === 1
|
||||
? []
|
||||
: [toolOp(versionNumber % 2 === 0 ? "compress" : "watermark")];
|
||||
return buildFileStub({
|
||||
id: `file-${versionNumber}` as FileId,
|
||||
name: `report-v${versionNumber}.pdf`,
|
||||
size: 100000 + versionNumber * 1024,
|
||||
versionNumber,
|
||||
parentFileId:
|
||||
versionNumber > 1 ? (`file-${versionNumber - 1}` as FileId) : undefined,
|
||||
toolHistory,
|
||||
});
|
||||
});
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/VersionTimeline",
|
||||
component: VersionTimeline,
|
||||
args: {
|
||||
onAddToWorkspace: () => {},
|
||||
onRemove: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof VersionTimeline>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
chain: shortChain,
|
||||
currentId: "file-3" as FileId,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoHeader: Story = {
|
||||
args: {
|
||||
chain: shortChain,
|
||||
currentId: "file-3" as FileId,
|
||||
hideHeader: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongChainCollapsed: Story = {
|
||||
args: {
|
||||
chain: longChain,
|
||||
currentId: "file-9" as FileId,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
// The shared preview only loads the portal tokens; the shell reads the editor
|
||||
// theme tokens (--bg-surface, --onboarding-title, …), so load them here or the
|
||||
// card renders transparent over the dark overlay.
|
||||
import "@app/styles/theme.css";
|
||||
import OnboardingSlideShell, {
|
||||
ShellHero,
|
||||
type ShellButton,
|
||||
} from "@app/components/onboarding/OnboardingSlideShell";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/Slide Shell",
|
||||
component: OnboardingSlideShell,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof OnboardingSlideShell>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const BUTTONS: ShellButton[] = [
|
||||
{ key: "back", back: true, action: "back" },
|
||||
{ key: "skip", label: "Skip", action: "skip" },
|
||||
{ key: "next", label: "Next", primary: true, action: "next" },
|
||||
];
|
||||
|
||||
/** A single standalone card — no step count, so no progress bar or pill. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
hero: <ShellHero appIcon />,
|
||||
slideKey: "default",
|
||||
title: "Welcome to Stirling PDF",
|
||||
body: "Everything you need to view, edit and manage your PDFs in one place.",
|
||||
stepIndex: 0,
|
||||
stepCount: 1,
|
||||
buttons: [{ key: "next", label: "Next", primary: true, action: "next" }],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** Mid-flow step: shows the step pill + progress bar, plus a back control. */
|
||||
export const SteppedWithBack: Story = {
|
||||
args: {
|
||||
hero: <ShellHero>2</ShellHero>,
|
||||
slideKey: "stepped",
|
||||
title: "Choose your role",
|
||||
body: "This helps us tailor the tools you see first.",
|
||||
stepIndex: 2,
|
||||
stepCount: 5,
|
||||
buttons: BUTTONS,
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** Dismiss (close button + escape-to-close) disabled — used for mandatory
|
||||
* steps like a forced first-login password change. */
|
||||
export const NotDismissible: Story = {
|
||||
args: {
|
||||
hero: <ShellHero appIcon />,
|
||||
slideKey: "mandatory",
|
||||
title: "Set a new password",
|
||||
body: "You're using the default password — choose a new one to continue.",
|
||||
stepIndex: 0,
|
||||
stepCount: 3,
|
||||
buttons: [
|
||||
{
|
||||
key: "next",
|
||||
label: "Continue",
|
||||
primary: true,
|
||||
action: "next",
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
allowDismiss: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { OnboardingStepper } from "@app/components/onboarding/OnboardingStepper";
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/OnboardingStepper",
|
||||
component: OnboardingStepper,
|
||||
args: { totalSteps: 5, activeStep: 2 },
|
||||
argTypes: {
|
||||
totalSteps: { control: { type: "number", min: 1, max: 10 } },
|
||||
activeStep: { control: { type: "number", min: 0, max: 9 } },
|
||||
},
|
||||
} satisfies Meta<typeof OnboardingStepper>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { args: { totalSteps: 5, activeStep: 2 } };
|
||||
|
||||
export const FirstStep: Story = { args: { totalSteps: 5, activeStep: 0 } };
|
||||
|
||||
export const LastStep: Story = { args: { totalSteps: 5, activeStep: 4 } };
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
import "@app/components/onboarding/OnboardingTour.css";
|
||||
|
||||
/**
|
||||
* Selectors go unused here since the target elements don't exist on the
|
||||
* canvas, so reactour just centers the popover instead of anchoring to them.
|
||||
*/
|
||||
const SAMPLE_STEPS: StepType[] = [
|
||||
{
|
||||
selector: "body",
|
||||
content: "Welcome to Stirling PDF! Let's take a quick look around.",
|
||||
},
|
||||
{
|
||||
selector: "body",
|
||||
content: "Here you can upload and manage your <strong>files</strong>.",
|
||||
},
|
||||
{
|
||||
selector: "body",
|
||||
content: "This is the tools panel where you apply operations to a PDF.",
|
||||
},
|
||||
];
|
||||
|
||||
function TourStage(props: {
|
||||
tourType?: string;
|
||||
isRTL?: boolean;
|
||||
dimBackground?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<OnboardingTour
|
||||
tourSteps={SAMPLE_STEPS}
|
||||
tourType={props.tourType ?? "welcome"}
|
||||
isRTL={props.isRTL ?? false}
|
||||
t={t}
|
||||
isOpen={true}
|
||||
onAdvance={({ setCurrentStep, currentStep, steps }) => {
|
||||
const isLast = currentStep === (steps?.length ?? 0) - 1;
|
||||
if (isLast) return;
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}}
|
||||
onClose={({ setIsOpen }) => setIsOpen(false)}
|
||||
dimBackground={props.dimBackground}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/OnboardingTour",
|
||||
component: TourStage,
|
||||
} satisfies Meta<typeof TourStage>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Default tour, mask dimmed to 70% opacity. */
|
||||
export const Default: Story = { args: {} };
|
||||
|
||||
/** Admin tour uses a dark mask class instead of the default dim mask. */
|
||||
export const AdminTour: Story = { args: { tourType: "admin" } };
|
||||
|
||||
/** RTL layout swaps the "next" arrow direction. */
|
||||
export const RTL: Story = { args: { isRTL: true } };
|
||||
|
||||
/** `dimBackground={false}` keeps the page fully visible behind the popover. */
|
||||
export const NoDim: Story = { args: { dimBackground: false } };
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
// The shared preview only loads the portal tokens; the onboarding modal reads
|
||||
// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them
|
||||
// here or the modal surface renders transparent over the dark overlay.
|
||||
import "@app/styles/theme.css";
|
||||
import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide";
|
||||
import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||
|
||||
/**
|
||||
* Renders the "interrupt" onboarding modals — slides shown outside the normal
|
||||
* step flow (analytics consent, first-login password change, MFA setup, the
|
||||
* external server-license notice) — each with dismissal disabled, since none
|
||||
* of these can be skipped by the user.
|
||||
*/
|
||||
const meta = {
|
||||
title: "Onboarding/Static Onboarding Slide",
|
||||
component: StaticOnboardingSlide,
|
||||
// Excluded from the automated (Vitest browser) test run: some slides fetch
|
||||
// remote assets/endpoints (analytics, licensing) that aren't served in the
|
||||
// headless scan, so they 404 and reject. Still renders in the Storybook UI
|
||||
// for manual review.
|
||||
tags: ["!test"],
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof StaticOnboardingSlide>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Opt-in analytics choice, shown once after first login. */
|
||||
export const AnalyticsChoice: Story = {
|
||||
args: {
|
||||
slideId: "analytics-choice",
|
||||
runtimeState: DEFAULT_RUNTIME_STATE,
|
||||
params: { analyticsError: null, analyticsLoading: false },
|
||||
onSkip: () => {},
|
||||
onAction: () => {},
|
||||
allowDismiss: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Forced password change on first login with the default credentials. */
|
||||
export const FirstLogin: Story = {
|
||||
args: {
|
||||
slideId: "first-login",
|
||||
runtimeState: {
|
||||
...DEFAULT_RUNTIME_STATE,
|
||||
requiresPasswordChange: true,
|
||||
firstLoginUsername: "admin",
|
||||
usingDefaultCredentials: true,
|
||||
},
|
||||
params: {
|
||||
firstLoginUsername: "admin",
|
||||
onPasswordChanged: () => {},
|
||||
usingDefaultCredentials: true,
|
||||
},
|
||||
onSkip: () => {},
|
||||
onAction: () => {},
|
||||
allowDismiss: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** Two-factor setup, triggered when the account requires MFA. */
|
||||
export const MfaSetup: Story = {
|
||||
args: {
|
||||
slideId: "mfa-setup",
|
||||
runtimeState: { ...DEFAULT_RUNTIME_STATE, requiresMfaSetup: true },
|
||||
params: { onMfaSetupComplete: () => {} },
|
||||
onSkip: () => {},
|
||||
onAction: () => {},
|
||||
allowDismiss: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** External license notice — the back button is stripped via
|
||||
* `transformButtons` since there's no prior slide to return to. */
|
||||
export const ServerLicenseNotice: Story = {
|
||||
args: {
|
||||
slideId: "server-license",
|
||||
transformButtons: (buttons) =>
|
||||
buttons.filter((btn) => btn.key !== "license-back"),
|
||||
runtimeState: {
|
||||
...DEFAULT_RUNTIME_STATE,
|
||||
licenseNotice: {
|
||||
totalUsers: 12,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: true,
|
||||
requiresLicense: true,
|
||||
},
|
||||
},
|
||||
params: {
|
||||
osOptions: [],
|
||||
onDownloadUrlChange: () => {},
|
||||
licenseNotice: {
|
||||
totalUsers: 12,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: true,
|
||||
requiresLicense: true,
|
||||
},
|
||||
loginEnabled: true,
|
||||
},
|
||||
onSkip: () => {},
|
||||
onAction: () => {},
|
||||
allowDismiss: false,
|
||||
},
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide";
|
||||
|
||||
// AnalyticsChoiceSlide is a slide-content factory (returns a SlideConfig), not
|
||||
// a component, so wrap it to render its `body` the way the real onboarding
|
||||
// shell does.
|
||||
function AnalyticsChoiceSlideDemo({
|
||||
analyticsError = null,
|
||||
}: {
|
||||
analyticsError?: string | null;
|
||||
}) {
|
||||
const slide = AnalyticsChoiceSlide({ analyticsError });
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 420,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
|
||||
{slide.body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/AnalyticsChoiceSlide",
|
||||
component: AnalyticsChoiceSlideDemo,
|
||||
} satisfies Meta<typeof AnalyticsChoiceSlideDemo>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { args: {} };
|
||||
|
||||
export const WithError: Story = {
|
||||
args: { analyticsError: "Failed to save your analytics preference." },
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
import type { AnimatedCircleConfig } from "@app/types/types";
|
||||
|
||||
const circles: AnimatedCircleConfig[] = [
|
||||
{
|
||||
size: 320,
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
position: "bottom-left",
|
||||
blur: 40,
|
||||
},
|
||||
{
|
||||
size: 220,
|
||||
color: "rgba(255, 255, 255, 0.3)",
|
||||
position: "top-right",
|
||||
opacity: 0.6,
|
||||
blur: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/AnimatedSlideBackground",
|
||||
component: AnimatedSlideBackground,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof AnimatedSlideBackground>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
gradientStops: ["#6E56CF", "#3B82F6"],
|
||||
circles,
|
||||
isActive: true,
|
||||
slideKey: "welcome",
|
||||
},
|
||||
};
|
||||
|
||||
export const Inactive: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
isActive: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
DesktopInstallTitle,
|
||||
type OSOption,
|
||||
} from "@app/components/onboarding/slides/DesktopInstallTitle";
|
||||
|
||||
const OS_OPTIONS: OSOption[] = [
|
||||
{ label: "macOS (Apple Silicon)", url: "#mac-arm", value: "mac-arm" },
|
||||
{ label: "macOS (Intel)", url: "#mac-intel", value: "mac-intel" },
|
||||
{ label: "Windows", url: "#windows", value: "windows" },
|
||||
{ label: "Linux", url: "#linux", value: "linux" },
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/Slides/DesktopInstallTitle",
|
||||
component: DesktopInstallTitle,
|
||||
} satisfies Meta<typeof DesktopInstallTitle>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Multiple OS options: title plus a dropdown to switch the download target. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
osLabel: "macOS (Apple Silicon)",
|
||||
osUrl: "#mac-arm",
|
||||
osOptions: OS_OPTIONS,
|
||||
onDownloadUrlChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** A single detected OS collapses to plain text — no dropdown affordance. */
|
||||
export const SingleOption: Story = {
|
||||
args: {
|
||||
osLabel: "Windows",
|
||||
osUrl: "#windows",
|
||||
osOptions: [{ label: "Windows", url: "#windows", value: "windows" }],
|
||||
onDownloadUrlChange: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide";
|
||||
|
||||
// FirstLoginSlide is a slide-content factory (returns a SlideConfig), not a
|
||||
// component, so wrap it to render its `body` the way the real onboarding shell
|
||||
// does.
|
||||
function FirstLoginSlideDemo({
|
||||
username = "admin",
|
||||
usingDefaultCredentials = false,
|
||||
}: {
|
||||
username?: string;
|
||||
usingDefaultCredentials?: boolean;
|
||||
}) {
|
||||
const slide = FirstLoginSlide({
|
||||
username,
|
||||
onPasswordChanged: () => {},
|
||||
usingDefaultCredentials,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 420,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
|
||||
{slide.body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/FirstLoginSlide",
|
||||
component: FirstLoginSlideDemo,
|
||||
} satisfies Meta<typeof FirstLoginSlideDemo>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { args: { username: "admin" } };
|
||||
|
||||
export const UsingDefaultCredentials: Story = {
|
||||
args: { username: "admin", usingDefaultCredentials: true },
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide";
|
||||
import type { LicenseNotice } from "@app/types/types";
|
||||
|
||||
interface PlanOverviewStageProps {
|
||||
isAdmin: boolean;
|
||||
licenseNotice?: LicenseNotice;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
// PlanOverviewSlide returns a SlideConfig (title/body nodes plus background
|
||||
// config) rather than JSX, so this stage has to render those pieces itself.
|
||||
function PlanOverviewStage({
|
||||
isAdmin,
|
||||
licenseNotice,
|
||||
loginEnabled,
|
||||
}: PlanOverviewStageProps) {
|
||||
const slide = PlanOverviewSlide({ isAdmin, licenseNotice, loginEnabled });
|
||||
return (
|
||||
<div style={{ maxWidth: 480, padding: 24 }}>
|
||||
<h2>{slide.title}</h2>
|
||||
<div>{slide.body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/Slides/PlanOverviewSlide",
|
||||
component: PlanOverviewStage,
|
||||
} satisfies Meta<typeof PlanOverviewStage>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Regular user overview — no admin controls, no free-tier notice. */
|
||||
export const Default: Story = {
|
||||
args: { isAdmin: false },
|
||||
};
|
||||
|
||||
/** Admin overview with login mode already enabled. */
|
||||
export const AdminLoginEnabled: Story = {
|
||||
args: {
|
||||
isAdmin: true,
|
||||
loginEnabled: true,
|
||||
licenseNotice: {
|
||||
totalUsers: 3,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: false,
|
||||
requiresLicense: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Admin overview before login mode is enabled — different body copy. */
|
||||
export const AdminLoginDisabled: Story = {
|
||||
args: {
|
||||
isAdmin: true,
|
||||
loginEnabled: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ProcessorIntroSlide from "@app/components/onboarding/slides/ProcessorIntroSlide";
|
||||
|
||||
// ProcessorIntroSlide is a slide-content factory (returns a SlideConfig), not
|
||||
// a component, so wrap it to render its `title`/`body` the way the real
|
||||
// onboarding shell does.
|
||||
function ProcessorIntroSlideDemo() {
|
||||
const slide = ProcessorIntroSlide();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 420,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
|
||||
<div style={{ color: "#fff" }}>{slide.body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/ProcessorIntroSlide",
|
||||
component: ProcessorIntroSlideDemo,
|
||||
} satisfies Meta<typeof ProcessorIntroSlideDemo>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { args: {} };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide";
|
||||
|
||||
// SecurityCheckSlide is a slide-content factory (returns a SlideConfig), not a
|
||||
// component, so wrap it to render its `body` the way the real onboarding shell
|
||||
// does, with local state standing in for the modal's role selection state.
|
||||
function SecurityCheckSlideDemo({
|
||||
initialRole = null,
|
||||
}: {
|
||||
initialRole?: "admin" | "user" | null;
|
||||
}) {
|
||||
const [selectedRole, setSelectedRole] = useState(initialRole);
|
||||
const slide = SecurityCheckSlide({
|
||||
selectedRole,
|
||||
onRoleSelect: setSelectedRole,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 420,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
|
||||
{slide.body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/SecurityCheckSlide",
|
||||
component: SecurityCheckSlideDemo,
|
||||
} satisfies Meta<typeof SecurityCheckSlideDemo>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { args: {} };
|
||||
|
||||
export const AdminSelected: Story = { args: { initialRole: "admin" } };
|
||||
|
||||
export const UserSelected: Story = { args: { initialRole: "user" } };
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide";
|
||||
import type { LicenseNotice } from "@app/types/types";
|
||||
|
||||
interface ServerLicenseStageProps {
|
||||
licenseNotice?: LicenseNotice;
|
||||
}
|
||||
|
||||
// ServerLicenseSlide is a factory that returns a SlideConfig (title/body nodes
|
||||
// plus background config), not JSX itself, so this stage renders those pieces
|
||||
// directly to preview the slide in isolation.
|
||||
function ServerLicenseStage({ licenseNotice }: ServerLicenseStageProps) {
|
||||
const slide = ServerLicenseSlide({ licenseNotice });
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
|
||||
<div style={{ color: "#fff" }}>{slide.body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/Slides/ServerLicenseSlide",
|
||||
component: ServerLicenseStage,
|
||||
} satisfies Meta<typeof ServerLicenseStage>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Default free-tier notice — under the limit. */
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
/** Under the free-tier limit with a known user count. */
|
||||
export const UnderLimit: Story = {
|
||||
args: {
|
||||
licenseNotice: {
|
||||
totalUsers: 3,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: false,
|
||||
requiresLicense: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Over the free-tier limit — prompts to upgrade with a different gradient. */
|
||||
export const OverLimit: Story = {
|
||||
args: {
|
||||
licenseNotice: {
|
||||
totalUsers: 12,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: true,
|
||||
requiresLicense: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
|
||||
|
||||
// WelcomeSlide is a slide-content factory (returns a SlideConfig), not
|
||||
// a component, so wrap it to render its `title`/`body` the way the real
|
||||
// onboarding shell does.
|
||||
function WelcomeSlideDemo() {
|
||||
const slide = WelcomeSlide();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 420,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
|
||||
<div style={{ color: "#fff" }}>{slide.body}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Onboarding/WelcomeSlide",
|
||||
component: WelcomeSlideDemo,
|
||||
} satisfies Meta<typeof WelcomeSlideDemo>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = { args: {} };
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
|
||||
|
||||
interface MockGridItem {
|
||||
id: string;
|
||||
pageNumber?: number;
|
||||
originalFileId?: string;
|
||||
}
|
||||
|
||||
const buildItems = (count: number): MockGridItem[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
id: `page-${index + 1}`,
|
||||
pageNumber: index + 1,
|
||||
originalFileId: "file-1",
|
||||
}));
|
||||
|
||||
const renderItem = (
|
||||
item: MockGridItem,
|
||||
index: number,
|
||||
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||
boxSelectedIds: string[],
|
||||
clearBoxSelection: () => void,
|
||||
activeDragIds: string[],
|
||||
justMoved: boolean,
|
||||
dragHandleProps?: any,
|
||||
zoomLevel?: number,
|
||||
) => {
|
||||
const { ref: dndRef, ...restDragProps } = dragHandleProps ?? {};
|
||||
const isBoxSelected = boxSelectedIds.includes(item.id);
|
||||
const isDragging = activeDragIds.includes(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
refs.current.set(item.id, element);
|
||||
} else {
|
||||
refs.current.delete(item.id);
|
||||
}
|
||||
dndRef?.(element);
|
||||
}}
|
||||
{...restDragProps}
|
||||
onClick={clearBoxSelection}
|
||||
style={{
|
||||
width: `calc(10rem * ${zoomLevel ?? 1})`,
|
||||
height: `calc(13rem * ${zoomLevel ?? 1})`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.5rem",
|
||||
border: isBoxSelected
|
||||
? "2px solid var(--mantine-color-blue-6)"
|
||||
: "1px solid var(--mantine-color-gray-4)",
|
||||
background: isDragging
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: "var(--mantine-color-body)",
|
||||
opacity: justMoved ? 0.7 : 1,
|
||||
cursor: "grab",
|
||||
}}
|
||||
>
|
||||
Page {item.pageNumber ?? index + 1}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const noopReorder = () => {};
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/DragDropGrid",
|
||||
component: DragDropGrid,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof DragDropGrid>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const ScrollDecorator = (StoryComponent: React.ComponentType) => (
|
||||
<div
|
||||
data-scrolling-container="true"
|
||||
style={{ height: "40rem", overflow: "auto" }}
|
||||
>
|
||||
<StoryComponent />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: buildItems(8),
|
||||
onReorderPages: noopReorder,
|
||||
renderItem,
|
||||
},
|
||||
decorators: [ScrollDecorator],
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
items: [],
|
||||
onReorderPages: noopReorder,
|
||||
renderItem,
|
||||
},
|
||||
decorators: [ScrollDecorator],
|
||||
};
|
||||
|
||||
export const Zoomed: Story = {
|
||||
args: {
|
||||
items: buildItems(6),
|
||||
onReorderPages: noopReorder,
|
||||
renderItem,
|
||||
zoomLevel: 1.5,
|
||||
},
|
||||
decorators: [ScrollDecorator],
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PageEditorControls from "@app/components/pageEditor/PageEditorControls";
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/PageEditorControls",
|
||||
component: PageEditorControls,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof PageEditorControls>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const baseArgs = {
|
||||
onClosePdf: () => {},
|
||||
onUndo: () => {},
|
||||
onRedo: () => {},
|
||||
canUndo: true,
|
||||
canRedo: true,
|
||||
onRotate: () => {},
|
||||
onDelete: () => {},
|
||||
onSplit: () => {},
|
||||
onSplitAll: () => {},
|
||||
onPageBreak: () => {},
|
||||
onPageBreakAll: () => {},
|
||||
onExportAll: () => {},
|
||||
exportLoading: false,
|
||||
selectionMode: true,
|
||||
selectedPageIds: ["page-1", "page-2"],
|
||||
displayDocument: {
|
||||
pages: [
|
||||
{ id: "page-1", pageNumber: 1 },
|
||||
{ id: "page-2", pageNumber: 2 },
|
||||
{ id: "page-3", pageNumber: 3 },
|
||||
],
|
||||
},
|
||||
splitPositions: new Set<string>(),
|
||||
totalPages: 3,
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: baseArgs,
|
||||
};
|
||||
|
||||
export const NoSelection: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
selectedPageIds: [],
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithExistingSplits: Story = {
|
||||
args: {
|
||||
...baseArgs,
|
||||
splitPositions: new Set<string>(["page-1", "page-2"]),
|
||||
},
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AdvancedSelectionPanel from "@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel";
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/BulkSelectionPanel/AdvancedSelectionPanel",
|
||||
component: AdvancedSelectionPanel,
|
||||
} satisfies Meta<typeof AdvancedSelectionPanel>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
csvInput: "",
|
||||
setCsvInput: () => {},
|
||||
onUpdatePagesFromCSV: () => {},
|
||||
maxPages: 20,
|
||||
advancedOpened: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithExpression: Story = {
|
||||
args: {
|
||||
csvInput: "1-5, odd",
|
||||
setCsvInput: () => {},
|
||||
onUpdatePagesFromCSV: () => {},
|
||||
maxPages: 20,
|
||||
advancedOpened: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Closed: Story = {
|
||||
args: {
|
||||
csvInput: "",
|
||||
setCsvInput: () => {},
|
||||
onUpdatePagesFromCSV: () => {},
|
||||
maxPages: 20,
|
||||
advancedOpened: false,
|
||||
},
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import OperatorsSection from "@app/components/pageEditor/bulkSelectionPanel/OperatorsSection";
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/BulkSelectionPanel/OperatorsSection",
|
||||
component: OperatorsSection,
|
||||
} satisfies Meta<typeof OperatorsSection>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
csvInput: "1,2,3",
|
||||
onInsertOperator: (op) => console.log("insert operator", op),
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyInput: Story = {
|
||||
args: {
|
||||
csvInput: "",
|
||||
onInsertOperator: (op) => console.log("insert operator", op),
|
||||
},
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SelectPages from "@app/components/pageEditor/bulkSelectionPanel/SelectPages";
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/SelectPages",
|
||||
component: SelectPages,
|
||||
} satisfies Meta<typeof SelectPages>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "Select pages",
|
||||
placeholder: "Page number",
|
||||
onApply: (value: number) => console.log("apply", value),
|
||||
maxPages: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValidation: Story = {
|
||||
args: {
|
||||
title: "Select pages",
|
||||
placeholder: "Page number",
|
||||
onApply: (value: number) => console.log("apply", value),
|
||||
maxPages: 10,
|
||||
validationFn: (value: number) =>
|
||||
value > 10 ? "Page number exceeds document length" : null,
|
||||
},
|
||||
};
|
||||
|
||||
export const Range: Story = {
|
||||
args: {
|
||||
title: "Select page range",
|
||||
placeholder: "Start page",
|
||||
onApply: (value: number) => console.log("apply", value),
|
||||
maxPages: 10,
|
||||
isRange: true,
|
||||
rangeEndValue: 5,
|
||||
onRangeEndChange: (value: string | number) =>
|
||||
console.log("range end", value),
|
||||
rangeEndPlaceholder: "End page",
|
||||
},
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SelectedPagesDisplay from "@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay";
|
||||
|
||||
const displayDocument = {
|
||||
pages: [
|
||||
{ id: "page-1", pageNumber: 1 },
|
||||
{ id: "page-2", pageNumber: 2 },
|
||||
{ id: "page-3", pageNumber: 3 },
|
||||
{ id: "page-4", pageNumber: 4 },
|
||||
],
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "PageEditor/SelectedPagesDisplay",
|
||||
component: SelectedPagesDisplay,
|
||||
} satisfies Meta<typeof SelectedPagesDisplay>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
selectedPageIds: ["page-1", "page-3"],
|
||||
displayDocument,
|
||||
syntaxError: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const SyntaxError: Story = {
|
||||
args: {
|
||||
selectedPageIds: ["page-1", "page-3"],
|
||||
displayDocument,
|
||||
syntaxError: "Invalid page range: 1-abc",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AllToolsNavButton from "@app/components/shared/AllToolsNavButton";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
/**
|
||||
* The button reads/writes tool selection and panel state via ToolWorkflowContext,
|
||||
* and derives the home link href from NavigationContext plus the tool registry —
|
||||
* all four providers must be present for it to render.
|
||||
*/
|
||||
function withProviders(Story: () => ReactElement) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<Story />
|
||||
</ToolWorkflowProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/AllToolsNavButton",
|
||||
component: AllToolsNavButton,
|
||||
decorators: [withProviders],
|
||||
args: {
|
||||
activeButton: "tools",
|
||||
setActiveButton: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof AllToolsNavButton>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Highlighted when it is the active quick-access button. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
activeButton: "tools",
|
||||
setActiveButton: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
function InactiveDemo() {
|
||||
const [activeButton, setActiveButton] = useState("home");
|
||||
return (
|
||||
<AllToolsNavButton
|
||||
activeButton={activeButton}
|
||||
setActiveButton={setActiveButton}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Not the active button — a different quick-access item is selected. */
|
||||
export const Inactive: Story = {
|
||||
render: () => <InactiveDemo />,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppSwitch } from "@app/components/shared/AppSwitch";
|
||||
|
||||
/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */
|
||||
const meta: Meta<typeof AppSwitch> = {
|
||||
title: "Shared/AppSwitch",
|
||||
component: AppSwitch,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Editor: Story = {
|
||||
args: {
|
||||
current: "editor",
|
||||
theme: "light",
|
||||
onSwitch: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Processor: Story = {
|
||||
args: {
|
||||
current: "processor",
|
||||
theme: "light",
|
||||
onSwitch: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const DarkTheme: Story = {
|
||||
args: {
|
||||
current: "editor",
|
||||
theme: "dark",
|
||||
onSwitch: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactElement } from "react";
|
||||
import BulkShareModal from "@app/components/shared/BulkShareModal";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFiles: StirlingFileStub[] = [
|
||||
{
|
||||
id: "story-file-1" as FileId,
|
||||
name: "quarterly-report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "story-file-1",
|
||||
versionNumber: 1,
|
||||
},
|
||||
{
|
||||
id: "story-file-2" as FileId,
|
||||
name: "cover-letter.pdf",
|
||||
type: "application/pdf",
|
||||
size: 180_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "story-file-2",
|
||||
versionNumber: 1,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* BulkShareModal reads useFileActions() from FileContext, which isn't part of
|
||||
* the shared preview decorators, so it's provided here for every story.
|
||||
* AppConfig (gating share links on `storageShareLinksEnabled`) is added
|
||||
* per-story instead, since Default and LinksEnabled need different values.
|
||||
*/
|
||||
function withFileContext(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/BulkShareModal",
|
||||
component: BulkShareModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
files: mockFiles,
|
||||
},
|
||||
decorators: [withFileContext],
|
||||
} satisfies Meta<typeof BulkShareModal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Share links disabled by server config — default when no config is loaded. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Share links enabled — the role selector and "Generate Link" action are active. */
|
||||
export const LinksEnabled: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ storageShareLinksEnabled: true }}
|
||||
autoFetch={false}
|
||||
>
|
||||
<Story />
|
||||
</AppConfigProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ReactElement } from "react";
|
||||
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFiles: StirlingFileStub[] = [
|
||||
{
|
||||
id: "file-1" as FileId,
|
||||
name: "quarterly-report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1" as FileId,
|
||||
versionNumber: 1,
|
||||
},
|
||||
{
|
||||
id: "file-2" as FileId,
|
||||
name: "invoice-march.pdf",
|
||||
type: "application/pdf",
|
||||
size: 512_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "file-2" as FileId,
|
||||
versionNumber: 1,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The modal dispatches updateStirlingFileStub on upload, so it needs
|
||||
* FileContext (also supplies IndexedDBContext) mounted above it.
|
||||
*/
|
||||
function withProviders(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/BulkUploadToServerModal",
|
||||
component: BulkUploadToServerModal,
|
||||
decorators: [withProviders],
|
||||
args: {
|
||||
onClose: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof BulkUploadToServerModal>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
files: mockFiles,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleFile: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
files: [mockFiles[0]],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
ButtonToggle,
|
||||
ButtonToggleOption,
|
||||
} from "@app/components/shared/ButtonToggle";
|
||||
|
||||
const meta: Meta<typeof ButtonToggle> = {
|
||||
title: "Shared/ButtonToggle",
|
||||
component: ButtonToggle,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ButtonToggle>;
|
||||
|
||||
const options: ButtonToggleOption[] = [
|
||||
{
|
||||
value: "automatic",
|
||||
label: "Automatic",
|
||||
description: "Detect and redact automatically",
|
||||
},
|
||||
{ value: "manual", label: "Manual", description: "Select regions yourself" },
|
||||
];
|
||||
|
||||
function ToggleDemo({
|
||||
disabled,
|
||||
size,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}) {
|
||||
const [value, setValue] = useState("automatic");
|
||||
return (
|
||||
<ButtonToggle
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Default toggle with two options, each carrying a label + description. */
|
||||
export const Default: Story = { render: () => <ToggleDemo /> };
|
||||
|
||||
/** Disabled state — the selected segment must still be legible. */
|
||||
export const Disabled: Story = { render: () => <ToggleDemo disabled /> };
|
||||
|
||||
/** Small size variant. */
|
||||
export const Small: Story = { render: () => <ToggleDemo size="sm" /> };
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
GoogleDriveIcon,
|
||||
OneDriveIcon,
|
||||
DropboxIcon,
|
||||
} from "@app/components/shared/CloudStorageIcons";
|
||||
|
||||
/** Cloud storage brand icons with brand-color / muted current-color variants. */
|
||||
const meta: Meta<typeof GoogleDriveIcon> = {
|
||||
title: "Shared/CloudStorageIcons",
|
||||
component: GoogleDriveIcon,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const GoogleDrive: Story = {
|
||||
args: { colored: true },
|
||||
};
|
||||
|
||||
export const GoogleDriveMuted: Story = {
|
||||
args: { colored: false },
|
||||
};
|
||||
|
||||
export const OneDrive: Story = {
|
||||
render: (args) => <OneDriveIcon {...args} />,
|
||||
args: { colored: true },
|
||||
};
|
||||
|
||||
export const Dropbox: Story = {
|
||||
render: (args) => <DropboxIcon {...args} />,
|
||||
args: { colored: true },
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "@mantine/core";
|
||||
import DropdownListWithFooter, {
|
||||
DropdownItem,
|
||||
} from "@app/components/shared/DropdownListWithFooter";
|
||||
|
||||
const meta: Meta<typeof DropdownListWithFooter> = {
|
||||
title: "Shared/DropdownListWithFooter",
|
||||
component: DropdownListWithFooter,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "22rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DropdownListWithFooter>;
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ value: "single", name: "Single page" },
|
||||
{ value: "facing", name: "Facing pages" },
|
||||
{ value: "book", name: "Book view" },
|
||||
{ value: "continuous", name: "Continuous scroll", disabled: true },
|
||||
];
|
||||
|
||||
function SingleSelectDemo() {
|
||||
const [value, setValue] = useState("single");
|
||||
return (
|
||||
<DropdownListWithFooter
|
||||
label="Page layout"
|
||||
items={items}
|
||||
value={value}
|
||||
onChange={(v) => setValue(v as string)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectDemo() {
|
||||
const [value, setValue] = useState<string[]>(["single"]);
|
||||
return (
|
||||
<DropdownListWithFooter
|
||||
label="Page layouts"
|
||||
items={items}
|
||||
value={value}
|
||||
onChange={(v) => setValue(v as string[])}
|
||||
multiSelect
|
||||
searchable
|
||||
footer={
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
fullWidth
|
||||
onClick={() => setValue([])}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single-select dropdown with a disabled item. */
|
||||
export const Default: Story = { render: () => <SingleSelectDemo /> };
|
||||
|
||||
/** Multi-select with search box and a footer action. */
|
||||
export const MultiSelectWithFooter: Story = {
|
||||
render: () => <MultiSelectDemo />,
|
||||
};
|
||||
|
||||
/** No items available — empty state message inside the dropdown. */
|
||||
export const Empty: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<DropdownListWithFooter
|
||||
label="Page layout"
|
||||
items={[]}
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import EditableSecretField from "@app/components/shared/EditableSecretField";
|
||||
|
||||
const meta: Meta<typeof EditableSecretField> = {
|
||||
title: "Shared/EditableSecretField",
|
||||
component: EditableSecretField,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EditableSecretField>;
|
||||
|
||||
function SecretFieldDemo({
|
||||
initialValue = "",
|
||||
...rest
|
||||
}: {
|
||||
initialValue?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<EditableSecretField
|
||||
label="API key"
|
||||
description="Used to authenticate requests to the third-party service."
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty value: renders a normal password input. */
|
||||
export const Default: Story = {
|
||||
render: () => <SecretFieldDemo />,
|
||||
};
|
||||
|
||||
/** Backend returned a masked value (********): shows a read-only display + Edit button. */
|
||||
export const Masked: Story = {
|
||||
render: () => <SecretFieldDemo initialValue="********" />,
|
||||
};
|
||||
|
||||
/** Disabled state — the Edit button on a masked value must still read as inert. */
|
||||
export const MaskedDisabled: Story = {
|
||||
render: () => <SecretFieldDemo initialValue="********" disabled />,
|
||||
};
|
||||
|
||||
/** Validation error surfaced under the password input. */
|
||||
export const WithError: Story = {
|
||||
render: () => <SecretFieldDemo error="Secret value is required." />,
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/EncryptedPdfUnlockModal",
|
||||
component: EncryptedPdfUnlockModal,
|
||||
args: {
|
||||
opened: true,
|
||||
password: "",
|
||||
isProcessing: false,
|
||||
remainingCount: 0,
|
||||
onPasswordChange: () => {},
|
||||
onUnlock: () => {},
|
||||
onUnlockAll: () => {},
|
||||
onSkip: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof EncryptedPdfUnlockModal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function UnlockDemo(
|
||||
props: Partial<ComponentProps<typeof EncryptedPdfUnlockModal>>,
|
||||
) {
|
||||
const [password, setPassword] = useState("");
|
||||
return (
|
||||
<EncryptedPdfUnlockModal
|
||||
opened
|
||||
fileName="contract-final.pdf"
|
||||
password={password}
|
||||
errorMessage={null}
|
||||
isProcessing={false}
|
||||
remainingCount={0}
|
||||
onPasswordChange={setPassword}
|
||||
onUnlock={() => {}}
|
||||
onUnlockAll={() => {}}
|
||||
onSkip={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = { render: () => <UnlockDemo /> };
|
||||
|
||||
export const MultipleFilesRemaining: Story = {
|
||||
render: () => <UnlockDemo remainingCount={2} />,
|
||||
};
|
||||
|
||||
export const IncorrectPassword: Story = {
|
||||
render: () => (
|
||||
<UnlockDemo
|
||||
password="wrong-password"
|
||||
errorMessage="Incorrect password. Please try again."
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const Processing: Story = {
|
||||
render: () => <UnlockDemo password="secret" isProcessing />,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Text } from "@mantine/core";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
|
||||
const meta: Meta<typeof ErrorBoundary> = {
|
||||
title: "Shared/ErrorBoundary",
|
||||
component: ErrorBoundary,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ErrorBoundary>;
|
||||
|
||||
function ThrowingChild(): never {
|
||||
throw new Error("Simulated render error for Storybook");
|
||||
}
|
||||
|
||||
/** Normal path — children render untouched when nothing throws. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: <Text>Protected content renders normally.</Text>,
|
||||
},
|
||||
};
|
||||
|
||||
/** A child throwing during render is caught, showing the default fallback with a retry button. */
|
||||
export const CaughtError: Story = {
|
||||
args: {
|
||||
children: <ThrowingChild />,
|
||||
},
|
||||
};
|
||||
|
||||
/** A custom fallback component receives the error and a retry callback. */
|
||||
export const CustomFallback: Story = {
|
||||
args: {
|
||||
children: <ThrowingChild />,
|
||||
fallback: ({ error, retry }) => (
|
||||
<Text c="red">
|
||||
Custom fallback: {error?.message}
|
||||
<button onClick={retry} style={{ marginLeft: 8 }}>
|
||||
Retry
|
||||
</button>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileCard from "@app/components/shared/FileCard";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { StirlingFileStub, FileId } from "@app/types/fileContext";
|
||||
|
||||
function makeFile(name: string, type = "application/pdf"): File {
|
||||
return new File(["%PDF-1.4 storybook fixture"], name, {
|
||||
type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function makeStub(id: string): StirlingFileStub {
|
||||
return {
|
||||
id: id as FileId,
|
||||
name: "Annual-Report-2026.pdf",
|
||||
type: "application/pdf",
|
||||
size: 245_760,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** FileCard reads/writes files via FileContext + IndexedDB, so it needs a real provider tree. */
|
||||
const meta = {
|
||||
title: "Shared/FileCard",
|
||||
component: FileCard,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof FileCard>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
file: makeFile("Annual-Report-2026.pdf"),
|
||||
fileStub: makeStub("story-file-1"),
|
||||
onRemove: () => {},
|
||||
onView: () => {},
|
||||
onEdit: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
isSelected: true,
|
||||
onSelect: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Unsupported: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
isSupported: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/FileDocIcon",
|
||||
component: FileDocIcon,
|
||||
parameters: { layout: "padded" },
|
||||
args: { variant: "pdf" },
|
||||
} satisfies Meta<typeof FileDocIcon>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { variant: "pdf" },
|
||||
};
|
||||
|
||||
/** All file-type variants, each using its own default accent color. */
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "1.5rem", alignItems: "center" }}>
|
||||
<FileDocIcon variant="pdf" />
|
||||
<FileDocIcon variant="spreadsheet" />
|
||||
<FileDocIcon variant="doc" />
|
||||
<FileDocIcon variant="image" />
|
||||
<FileDocIcon variant="archive" />
|
||||
<FileDocIcon variant="code" />
|
||||
<FileDocIcon variant="generic" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicit `color` overrides the variant's default accent. */
|
||||
export const CustomColor: Story = {
|
||||
args: { variant: "pdf", color: "#e64980" },
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileDropdownMenu } from "@app/components/shared/FileDropdownMenu";
|
||||
|
||||
const viewOptionStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
};
|
||||
|
||||
const activeFiles = [
|
||||
{ fileId: "file-1", name: "Contract-Draft-v1.pdf" },
|
||||
{ fileId: "file-2", name: "Invoice-2026-04.pdf", versionNumber: 2 },
|
||||
{ fileId: "file-3", name: "Scanned-Document-With-A-Very-Long-Name.pdf" },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof FileDropdownMenu> = {
|
||||
title: "Shared/FileDropdownMenu",
|
||||
component: FileDropdownMenu,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
displayName: "Contract-Draft-v1.pdf",
|
||||
activeFiles,
|
||||
currentFileIndex: 0,
|
||||
viewOptionStyle,
|
||||
onFileSelect: () => {},
|
||||
onFileRemove: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Switching: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
switchingTo: "viewer",
|
||||
},
|
||||
};
|
||||
|
||||
export const NoRemove: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
onFileRemove: undefined,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileGrid from "@app/components/shared/FileGrid";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* FileGrid renders FileCard entries, which call useFileThumbnail ->
|
||||
* useIndexedDBThumbnail. That hook reads IndexedDBContext + FileContext,
|
||||
* neither of which is part of the shared preview decorators, so
|
||||
* FileContextProvider (which wraps IndexedDBProvider internally) is stood up
|
||||
* here.
|
||||
*/
|
||||
function withFileContext(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const buildFile = (name: string, size: number, type: string): File => {
|
||||
return new File([new Uint8Array(size)], name, {
|
||||
type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const buildRecord = (
|
||||
id: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): StirlingFileStub => ({
|
||||
id: id as FileId,
|
||||
name: overrides.name ?? "report.pdf",
|
||||
type: overrides.type ?? "application/pdf",
|
||||
size: overrides.size ?? 1_240_000,
|
||||
lastModified: overrides.lastModified ?? Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
// Set so useLazyThumbnail short-circuits on the stored thumbnail instead of
|
||||
// trying to read file bytes out of IndexedDB.
|
||||
thumbnailUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const files = [
|
||||
{
|
||||
file: buildFile("report.pdf", 1_240_000, "application/pdf"),
|
||||
record: buildRecord("file-1", { name: "report.pdf" }),
|
||||
},
|
||||
{
|
||||
file: buildFile("invoice.pdf", 540_000, "application/pdf"),
|
||||
record: buildRecord("file-2", {
|
||||
name: "invoice.pdf",
|
||||
size: 540_000,
|
||||
}),
|
||||
},
|
||||
{
|
||||
file: buildFile(
|
||||
"budget.xlsx",
|
||||
82_000,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
),
|
||||
record: buildRecord("file-3", {
|
||||
name: "budget.xlsx",
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
size: 82_000,
|
||||
thumbnailUrl: undefined,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Shared/FileGrid",
|
||||
component: FileGrid,
|
||||
decorators: [withFileContext],
|
||||
args: {
|
||||
files,
|
||||
onRemove: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof FileGrid>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const SearchAndSort: Story = {
|
||||
args: {
|
||||
showSearch: true,
|
||||
showSort: true,
|
||||
onDeleteAll: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
files: [],
|
||||
showSearch: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
|
||||
const mockStoredFiles = [
|
||||
{ id: "file-1", name: "invoice.pdf", size: 245_000, thumbnail: null },
|
||||
{
|
||||
id: "file-2",
|
||||
name: "contract-draft.pdf",
|
||||
size: 1_240_000,
|
||||
thumbnail: null,
|
||||
},
|
||||
{ id: "file-3", name: "scanned-form.pdf", size: 3_400_000, thumbnail: null },
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Shared/FilePickerModal",
|
||||
component: FilePickerModal,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
onSelectFiles: () => {},
|
||||
storedFiles: mockStoredFiles,
|
||||
},
|
||||
} satisfies Meta<typeof FilePickerModal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Storage populated with a few files available to pick from. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** No files exist in storage yet — shows the empty-state message. */
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
storedFiles: [],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FilePreview from "@app/components/shared/FilePreview";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFile: StirlingFileStub = {
|
||||
id: "file-1" as FileId,
|
||||
name: "annual-report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 245_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Shared/FilePreview",
|
||||
component: FilePreview,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "12rem", height: "12rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof FilePreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
file: mockFile,
|
||||
thumbnail: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
file: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithNavigation: Story = {
|
||||
args: {
|
||||
file: mockFile,
|
||||
thumbnail: null,
|
||||
showStacking: true,
|
||||
showHoverOverlay: true,
|
||||
showNavigation: true,
|
||||
totalFiles: 3,
|
||||
onFileClick: () => {},
|
||||
onPrevious: () => {},
|
||||
onNext: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileSelectorPicker } from "@app/components/shared/FileSelectorPicker";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
|
||||
/**
|
||||
* Reads from FileContext (workbench files) and IndexedDBContext (persisted
|
||||
* saved files) further up the tree — neither is part of the shared preview
|
||||
* decorators, so FileContextProvider (which also wraps IndexedDBProvider) is
|
||||
* stood up here. The popover starts closed, so no IndexedDB read happens
|
||||
* until a story interacts with it.
|
||||
*/
|
||||
function withFileContext(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<div style={{ maxWidth: "16rem" }}>
|
||||
<Story />
|
||||
</div>
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/FileSelectorPicker",
|
||||
component: FileSelectorPicker,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
onSelect: () => {},
|
||||
},
|
||||
decorators: [withFileContext],
|
||||
} satisfies Meta<typeof FileSelectorPicker>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const CustomPlaceholder: Story = {
|
||||
args: {
|
||||
placeholder: "Choose a comparison file",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FileUploadButton from "@app/components/shared/FileUploadButton";
|
||||
|
||||
const meta: Meta<typeof FileUploadButton> = {
|
||||
title: "Shared/FileUploadButton",
|
||||
component: FileUploadButton,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "22rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FileUploadButton>;
|
||||
|
||||
function UploadDemo({
|
||||
initialFile,
|
||||
...rest
|
||||
}: {
|
||||
initialFile?: File;
|
||||
disabled?: boolean;
|
||||
accept?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | undefined>(initialFile);
|
||||
return (
|
||||
<FileUploadButton
|
||||
file={file}
|
||||
onChange={(next) => setFile(next ?? undefined)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** No file chosen yet — shows the default "Choose File" placeholder. */
|
||||
export const Default: Story = { render: () => <UploadDemo /> };
|
||||
|
||||
/** A file has already been selected — the button shows its name. */
|
||||
export const WithFileSelected: Story = {
|
||||
render: () => (
|
||||
<UploadDemo
|
||||
initialFile={
|
||||
new File(["dummy content"], "document.pdf", { type: "application/pdf" })
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Disabled state — should still be legible but non-interactive. */
|
||||
export const Disabled: Story = {
|
||||
render: () => <UploadDemo disabled />,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FirstLoginModal from "@app/components/shared/FirstLoginModal";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/FirstLoginModal",
|
||||
component: FirstLoginModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof FirstLoginModal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
username: "jane.doe",
|
||||
onPasswordChanged: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import FitText from "@app/components/shared/FitText";
|
||||
|
||||
const meta: Meta<typeof FitText> = {
|
||||
title: "Shared/FitText",
|
||||
component: FitText,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "12rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Single-line text that shrinks its font size to fit the available width. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: "Invoice_2026_Quarterly_Report.pdf",
|
||||
},
|
||||
};
|
||||
|
||||
/** Multi-line clamp with soft-break hints inserted after '/', '-' and '_'. */
|
||||
export const MultiLine: Story = {
|
||||
args: {
|
||||
text: "path/to/some-very/long_document/name_that_needs_multiple_lines.pdf",
|
||||
lines: 3,
|
||||
},
|
||||
};
|
||||
|
||||
/** Explicit font size (rem) with a lower minimum shrink scale. */
|
||||
export const CustomFontSize: Story = {
|
||||
args: {
|
||||
text: "Custom Sized Label",
|
||||
fontSize: 1.5,
|
||||
minimumFontScale: 0.5,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/Footer",
|
||||
component: Footer,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof Footer>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Defaults: no overrides supplied, only the always-present links render. */
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
/** All optional legal links populated, plus the cookie preferences button. */
|
||||
export const AllLinksAndCookieBanner: Story = {
|
||||
args: {
|
||||
privacyPolicy: "https://example.com/privacy",
|
||||
termsAndConditions: "https://example.com/terms",
|
||||
accessibilityStatement: "https://example.com/accessibility",
|
||||
cookiePolicy: "https://example.com/cookies",
|
||||
impressum: "https://example.com/impressum",
|
||||
analyticsEnabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import HoverActionMenu, {
|
||||
type HoverAction,
|
||||
} from "@app/components/shared/HoverActionMenu";
|
||||
import { iconMap } from "@app/components/tools/automate/iconMap";
|
||||
|
||||
const { EditIcon, DeleteIcon, DownloadIcon } = iconMap;
|
||||
|
||||
const actions: HoverAction[] = [
|
||||
{
|
||||
id: "edit",
|
||||
icon: <EditIcon style={{ fontSize: 16 }} />,
|
||||
label: "Edit",
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
icon: <DownloadIcon style={{ fontSize: 16 }} />,
|
||||
label: "Download",
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
icon: <DeleteIcon style={{ fontSize: 16 }} />,
|
||||
label: "Delete",
|
||||
onClick: () => {},
|
||||
color: "var(--text-error)",
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof HoverActionMenu> = {
|
||||
title: "Shared/HoverActionMenu",
|
||||
component: HoverActionMenu,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ position: "relative", width: "16rem", height: "4rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof HoverActionMenu>;
|
||||
|
||||
/** Visible menu with the standard edit/download/delete action set. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
show: true,
|
||||
actions,
|
||||
},
|
||||
};
|
||||
|
||||
/** Hidden state (`show: false`) — menu stays mounted but faded/non-interactive. */
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
show: false,
|
||||
actions,
|
||||
},
|
||||
};
|
||||
|
||||
/** One action disabled with a custom tooltip explaining why. */
|
||||
export const WithDisabledAction: Story = {
|
||||
args: {
|
||||
show: true,
|
||||
actions: [
|
||||
actions[0],
|
||||
actions[1],
|
||||
{
|
||||
...actions[2],
|
||||
disabled: true,
|
||||
tooltip: "Deletion is restricted by policy",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/InfoBanner",
|
||||
component: InfoBanner,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof InfoBanner>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: "info-rounded",
|
||||
title: "Heads up",
|
||||
message: "This document contains form fields that will be flattened.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
tone: "warning",
|
||||
icon: "warning-rounded",
|
||||
title: "Action required",
|
||||
message: "Some pages could not be processed and were skipped.",
|
||||
buttonText: "Review",
|
||||
onButtonClick: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
icon: "info-rounded",
|
||||
message: "Autosave is enabled for this file.",
|
||||
dismissible: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
|
||||
|
||||
/** Decorative stack only: window dots + grey bars — no props, no i18n. */
|
||||
const meta: Meta<typeof LandingDocumentStack> = {
|
||||
title: "Shared/LandingDocumentStack",
|
||||
component: LandingDocumentStack,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import LanguageSelector from "@app/components/shared/LanguageSelector";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/LanguageSelector",
|
||||
component: LanguageSelector,
|
||||
} satisfies Meta<typeof LanguageSelector>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
tooltip: "Change language",
|
||||
},
|
||||
};
|
||||
|
||||
export const TopStartPosition: Story = {
|
||||
args: {
|
||||
position: "top-start",
|
||||
offset: 4,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
|
||||
/** Full-screen splash shown while i18next Suspense is loading translations. */
|
||||
const meta: Meta<typeof LoadingFallback> = {
|
||||
title: "Shared/LoadingFallback",
|
||||
component: LoadingFallback,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
const meta: Meta<typeof LocalIcon> = {
|
||||
title: "Shared/LocalIcon",
|
||||
component: LocalIcon,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: "description",
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
},
|
||||
};
|
||||
|
||||
export const NumericSize: Story = {
|
||||
args: {
|
||||
icon: "download",
|
||||
width: 32,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFullCollectionPrefix: Story = {
|
||||
args: {
|
||||
icon: "material-symbols:error-rounded",
|
||||
width: "1.5rem",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import LoginAgreementModal from "@app/components/shared/LoginAgreementModal";
|
||||
|
||||
/**
|
||||
* Renders nothing by default: the modal only opens after fetching
|
||||
* `/api/v1/config/login-disclaimer` and finding it enabled, which requires a
|
||||
* live AppConfigProvider/backend. In Storybook (no providers configured) the
|
||||
* config stays null, so the effect bails out and the component stays hidden.
|
||||
*/
|
||||
const meta: Meta<typeof LoginAgreementModal> = {
|
||||
title: "Shared/LoginAgreementModal",
|
||||
component: LoginAgreementModal,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
|
||||
|
||||
const meta: Meta<typeof MobileUploadModal> = {
|
||||
title: "Shared/MobileUploadModal",
|
||||
component: MobileUploadModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
onFilesReceived: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** QR code + instructions for scanning a file upload session from a phone. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Closed state — modal renders nothing visible. */
|
||||
export const Closed: Story = {
|
||||
args: {
|
||||
opened: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import MultiSelectControls from "@app/components/shared/MultiSelectControls";
|
||||
|
||||
const meta: Meta<typeof MultiSelectControls> = {
|
||||
title: "Shared/MultiSelectControls",
|
||||
component: MultiSelectControls,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
selectedCount: 3,
|
||||
onClearSelection: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Only the always-present "Clear files" action, since none of the optional handlers are passed. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** All optional actions supplied — every button in the group renders. */
|
||||
export const AllActions: Story = {
|
||||
args: {
|
||||
onAddToUpload: () => {},
|
||||
onOpenInFileEditor: () => {},
|
||||
onOpenInPageEditor: () => {},
|
||||
onDeleteAll: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** Renders nothing when no files are selected. */
|
||||
export const NoSelection: Story = {
|
||||
args: {
|
||||
selectedCount: 0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, type ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
|
||||
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
|
||||
import {
|
||||
NavigationProvider,
|
||||
useNavigationGuard,
|
||||
type NavigationWarningHandlers,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
|
||||
/**
|
||||
* The modal renders nothing until NavigationContext has unsaved changes AND a
|
||||
* pending navigation to warn about, so this drives both into place on mount —
|
||||
* mirroring what a real editor does when it calls requestNavigation() while
|
||||
* hasUnsavedChanges is true. It also registers any warning handlers the story
|
||||
* supplies, since the modal only shows the "Apply & Leave"/"Export & Leave"
|
||||
* buttons when a handler for them is present.
|
||||
*/
|
||||
function TriggerWarning({
|
||||
children,
|
||||
handlers,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
handlers?: NavigationWarningHandlers;
|
||||
}) {
|
||||
const {
|
||||
hasUnsavedChanges,
|
||||
setHasUnsavedChanges,
|
||||
requestNavigation,
|
||||
registerNavigationWarningHandlers,
|
||||
} = useNavigationGuard();
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedChanges(true);
|
||||
}, [setHasUnsavedChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (handlers) {
|
||||
registerNavigationWarningHandlers(handlers);
|
||||
}
|
||||
}, [handlers, registerNavigationWarningHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
requestNavigation(() => {});
|
||||
}
|
||||
}, [hasUnsavedChanges, requestNavigation]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function withProviders(
|
||||
Story: () => ReactElement,
|
||||
context: { parameters: { navigationHandlers?: NavigationWarningHandlers } },
|
||||
) {
|
||||
return (
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<TriggerWarning handlers={context.parameters.navigationHandlers}>
|
||||
<Story />
|
||||
</TriggerWarning>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/NavigationWarningModal",
|
||||
component: NavigationWarningModal,
|
||||
decorators: [withProviders],
|
||||
} satisfies Meta<typeof NavigationWarningModal>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Unsaved changes plus a pending navigation trigger the confirmation dialog. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/**
|
||||
* When the active tool registers an "apply and continue" handler (e.g. a
|
||||
* pending edit that can be committed before leaving), the modal adds a third
|
||||
* action alongside "Keep Working" and "Discard Changes".
|
||||
*/
|
||||
export const WithApplyAndContinue: Story = {
|
||||
parameters: {
|
||||
navigationHandlers: {
|
||||
onApplyAndContinue: async () => {},
|
||||
} satisfies NavigationWarningHandlers,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* When the active tool registers an "export and continue" handler (e.g. a
|
||||
* conversion tool that can export its result before leaving), the modal adds
|
||||
* an "Export & Leave" action instead.
|
||||
*/
|
||||
export const WithExportAndContinue: Story = {
|
||||
parameters: {
|
||||
navigationHandlers: {
|
||||
onExportAndContinue: async () => {},
|
||||
} satisfies NavigationWarningHandlers,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ObscuredOverlay from "@app/components/shared/ObscuredOverlay";
|
||||
|
||||
const meta: Meta<typeof ObscuredOverlay> = {
|
||||
title: "Shared/ObscuredOverlay",
|
||||
component: ObscuredOverlay,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "22rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const Content = () => (
|
||||
<div style={{ padding: "1rem", background: "var(--mantine-color-gray-1)" }}>
|
||||
Underlying content that gets obscured.
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Unobscured: Story = {
|
||||
args: {
|
||||
obscured: false,
|
||||
children: <Content />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Obscured: Story = {
|
||||
args: {
|
||||
obscured: true,
|
||||
overlayMessage: "This feature requires an upgrade",
|
||||
buttonText: "Upgrade",
|
||||
onButtonClick: () => {},
|
||||
children: <Content />,
|
||||
},
|
||||
};
|
||||
|
||||
export const RoundedCorners: Story = {
|
||||
args: {
|
||||
obscured: true,
|
||||
overlayMessage: "Locked",
|
||||
borderRadius: "0.5rem",
|
||||
children: <Content />,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
|
||||
|
||||
const meta = {
|
||||
title: "Shared/PageSelectionSyntaxHint",
|
||||
component: PageSelectionSyntaxHint,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof PageSelectionSyntaxHint>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Valid syntax ("1-3,5") renders nothing — no hint shown. */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
input: "1-3,5",
|
||||
maxPages: 10,
|
||||
},
|
||||
};
|
||||
|
||||
/** Malformed expression falls back to CSV parsing and shows the panel-style hint. */
|
||||
export const SyntaxError: Story = {
|
||||
args: {
|
||||
input: "abc",
|
||||
maxPages: 10,
|
||||
},
|
||||
};
|
||||
|
||||
/** Same malformed input, compact variant used inline within a tool panel. */
|
||||
export const CompactSyntaxError: Story = {
|
||||
args: {
|
||||
input: "abc",
|
||||
maxPages: 10,
|
||||
variant: "compact",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PolicyBadges } from "@app/components/shared/PolicyBadges";
|
||||
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
|
||||
|
||||
const mockPolicies: FileItemPolicyRef[] = [
|
||||
{ id: "policy-1", name: "Redact PII", accentColor: "#e03131", recent: true },
|
||||
{ id: "policy-2", name: "Sanitize", accentColor: "#2f9e44", recent: false },
|
||||
{ id: "policy-3", name: "Watermark", accentColor: "#4263eb", recent: false },
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Shared/PolicyBadges",
|
||||
component: PolicyBadges,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof PolicyBadges>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
policies: mockPolicies,
|
||||
},
|
||||
};
|
||||
|
||||
export const Enforcing: Story = {
|
||||
args: {
|
||||
policies: [
|
||||
{
|
||||
id: "policy-1",
|
||||
name: "Redact PII",
|
||||
accentColor: "#e03131",
|
||||
recent: false,
|
||||
enforcing: true,
|
||||
},
|
||||
...mockPolicies.slice(1),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
policies: [],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
|
||||
/** Layout-invisible wrapper that tags sensitive content with 'ph-no-capture' to exclude it from analytics. */
|
||||
const meta: Meta<typeof PrivateContent> = {
|
||||
title: "Shared/PrivateContent",
|
||||
component: PrivateContent,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "sensitive-filename.pdf",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomClassName: Story = {
|
||||
args: {
|
||||
children: "sensitive-filename.pdf",
|
||||
className: "custom-class",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFile: StirlingFileStub = {
|
||||
id: "story-file-1" as FileId,
|
||||
name: "quarterly-report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "story-file-1",
|
||||
versionNumber: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* ShareFileModal reads useFileActions() from FileContext (stood up here since
|
||||
* it isn't part of the shared preview decorators) and useAppConfig() to gate
|
||||
* share links on `storageShareLinksEnabled` — wrapped per-story to show both
|
||||
* the disabled and enabled states.
|
||||
*/
|
||||
function withFileContext(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/ShareFileModal",
|
||||
component: ShareFileModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
file: mockFile,
|
||||
},
|
||||
decorators: [withFileContext],
|
||||
} satisfies Meta<typeof ShareFileModal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Share links disabled by server config — default when no config is loaded. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Share links enabled — the role selector and "Generate Link" action are active. */
|
||||
export const LinksEnabled: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ storageShareLinksEnabled: true }}
|
||||
autoFetch={false}
|
||||
>
|
||||
<Story />
|
||||
</AppConfigProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFile: StirlingFileStub = {
|
||||
id: "story-file-1" as FileId,
|
||||
name: "quarterly-report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "story-file-1",
|
||||
versionNumber: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* ShareManagementModal reads useFileActions() from FileContext (stood up here
|
||||
* since it isn't part of the shared preview decorators) and useAppConfig() to
|
||||
* gate share links on `storageShareLinksEnabled` — wrapped per-story to show
|
||||
* both the disabled and enabled states.
|
||||
*/
|
||||
function withFileContext(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/ShareManagementModal",
|
||||
component: ShareManagementModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
file: mockFile,
|
||||
},
|
||||
decorators: [withFileContext],
|
||||
} satisfies Meta<typeof ShareManagementModal>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Share links disabled by server config — default when no config is loaded. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Share links enabled — the role selector, link generation and activity panel are active. */
|
||||
export const LinksEnabled: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AppConfigProvider
|
||||
initialConfig={{
|
||||
storageSharingEnabled: true,
|
||||
storageShareLinksEnabled: true,
|
||||
}}
|
||||
autoFetch={false}
|
||||
>
|
||||
<Story />
|
||||
</AppConfigProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
|
||||
const meta: Meta<typeof SkeletonLoader> = {
|
||||
title: "Shared/SkeletonLoader",
|
||||
component: SkeletonLoader,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const PageGrid: Story = {
|
||||
args: { type: "pageGrid", count: 4 },
|
||||
};
|
||||
|
||||
export const FileGrid: Story = {
|
||||
args: { type: "fileGrid", count: 4 },
|
||||
};
|
||||
|
||||
export const Controls: Story = {
|
||||
args: { type: "controls" },
|
||||
};
|
||||
|
||||
export const Viewer: Story = {
|
||||
args: { type: "viewer" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Block: Story = {
|
||||
args: { type: "block", width: 120, height: 20 },
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
import { ToolOperation } from "@app/types/file";
|
||||
|
||||
function op(toolId: ToolOperation["toolId"], timestamp: number): ToolOperation {
|
||||
return { toolId, timestamp };
|
||||
}
|
||||
|
||||
const shortChain: ToolOperation[] = [op("watermark", 1), op("ocr", 2)];
|
||||
|
||||
const longChain: ToolOperation[] = [
|
||||
op("split", 1),
|
||||
op("merge", 2),
|
||||
op("watermark", 3),
|
||||
op("ocr", 4),
|
||||
op("rotate", 5),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Shared/ToolChain",
|
||||
component: ToolChain,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof ToolChain>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Default text style, short chain (no truncation needed). */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
toolChain: shortChain,
|
||||
},
|
||||
};
|
||||
|
||||
/** Text style with a long chain — truncates to first → +N → last, with a tooltip for the full chain. */
|
||||
export const TextTruncated: Story = {
|
||||
args: {
|
||||
toolChain: longChain,
|
||||
displayStyle: "text",
|
||||
},
|
||||
};
|
||||
|
||||
/** Badge style — shows up to 3 badges, with "..." + final badge and a tooltip when longer. */
|
||||
export const Badges: Story = {
|
||||
args: {
|
||||
toolChain: longChain,
|
||||
displayStyle: "badges",
|
||||
},
|
||||
};
|
||||
|
||||
/** Compact style — collapses to a tool count once more than one tool is present. */
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
toolChain: longChain,
|
||||
displayStyle: "compact",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { iconMap } from "@app/components/tools/automate/iconMap";
|
||||
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||
|
||||
const { PictureAsPdfIcon } = iconMap;
|
||||
|
||||
const meta = {
|
||||
title: "Shared/ToolIcon",
|
||||
component: ToolIcon,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof ToolIcon>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: <PictureAsPdfIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
/** Visually unavailable state, for tools the user can't run. */
|
||||
export const ReducedOpacity: Story = {
|
||||
args: {
|
||||
icon: <PictureAsPdfIcon />,
|
||||
opacity: 0.25,
|
||||
},
|
||||
};
|
||||
|
||||
/** No right margin, for inline placement. */
|
||||
export const NoMargin: Story = {
|
||||
args: {
|
||||
icon: <PictureAsPdfIcon />,
|
||||
marginRight: "0",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader";
|
||||
|
||||
const meta: Meta<typeof ToolPanelHeader> = {
|
||||
title: "Shared/ToolPanelHeader",
|
||||
component: ToolPanelHeader,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: <SettingsIcon sx={{ fontSize: 18 }} />,
|
||||
title: "Split",
|
||||
},
|
||||
};
|
||||
|
||||
/** Trailing close button only renders when `onClose` is supplied. */
|
||||
export const WithCloseButton: Story = {
|
||||
args: {
|
||||
icon: <SettingsIcon sx={{ fontSize: 18 }} />,
|
||||
title: "Split",
|
||||
onClose: () => {},
|
||||
closeLabel: "Close tool panel",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import UpdateModal from "@app/components/shared/UpdateModal";
|
||||
import type { UpdateSummary, MachineInfo } from "@app/services/updateService";
|
||||
|
||||
const UPDATE_SUMMARY: UpdateSummary = {
|
||||
latest_version: "2.5.0",
|
||||
latest_stable_version: "2.5.0",
|
||||
max_priority: "normal",
|
||||
recommended_action: "This update contains important fixes and improvements.",
|
||||
any_breaking: false,
|
||||
migration_guides: [
|
||||
{
|
||||
version: "2.5.0",
|
||||
notes: "Config file format changed for custom watermark presets.",
|
||||
url: "https://docs.stirlingpdf.com/migration/2.5.0",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MACHINE_INFO: MachineInfo = {
|
||||
machineType: "Client-win",
|
||||
activeSecurity: false,
|
||||
licenseType: "NORMAL",
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Shared/UpdateModal",
|
||||
component: UpdateModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: () => {},
|
||||
currentVersion: "2.4.0",
|
||||
updateSummary: UPDATE_SUMMARY,
|
||||
machineInfo: MACHINE_INFO,
|
||||
downloadSizeBytes: 235_000_000,
|
||||
},
|
||||
} satisfies Meta<typeof UpdateModal>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Web/server build: no Tauri updater, so the footer offers a plain download link. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Desktop app: an update has finished installing and is waiting for a restart. */
|
||||
export const DesktopInstallReadyToRestart: Story = {
|
||||
args: {
|
||||
desktopInstall: {
|
||||
state: "ready-to-restart",
|
||||
progress: null,
|
||||
errorMessage: null,
|
||||
actions: {
|
||||
startInstall: async () => true,
|
||||
restartApp: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Desktop app on a non-admin machine: install probe reported it can't write to
|
||||
* the install directory, so Install Now is disabled and the docs alert shows. */
|
||||
export const DesktopInstallBlocked: Story = {
|
||||
args: {
|
||||
desktopInstall: {
|
||||
state: "idle",
|
||||
progress: null,
|
||||
errorMessage: null,
|
||||
actions: {
|
||||
startInstall: async () => true,
|
||||
restartApp: async () => {},
|
||||
},
|
||||
canInstall: {
|
||||
canInstall: false,
|
||||
reason: "Install directory is not writable without elevation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
|
||||
/**
|
||||
* Startup update-check popup — renders null until an update is detected, so
|
||||
* every story here shows an empty canvas. The stories exercise the gating
|
||||
* logic (`isUpdatePopupAllowed`) rather than the (invisible) update-found UI,
|
||||
* which additionally requires a real startup delay + network round trip.
|
||||
*/
|
||||
const meta = {
|
||||
title: "Shared/UpdateStartupPopup",
|
||||
component: UpdateStartupPopup,
|
||||
} satisfies Meta<typeof UpdateStartupPopup>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** No app config resolved yet — gate is closed, renders nothing. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Config resolved but `shouldShowUpdate` is false — gate stays closed. */
|
||||
export const UpdatesDisabled: Story = {
|
||||
decorators: [
|
||||
(StoryComponent) => (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{
|
||||
appVersion: "1.2.3",
|
||||
shouldShowUpdate: false,
|
||||
}}
|
||||
>
|
||||
<StoryComponent />
|
||||
</AppConfigProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Gate is open (`shouldShowUpdate: true`), so the startup timer would fire and
|
||||
* check for an update — but the modal itself only appears once that check
|
||||
* resolves with a newer version, well after the 15s startup delay.
|
||||
*/
|
||||
export const UpdatesEnabled: Story = {
|
||||
decorators: [
|
||||
(StoryComponent) => (
|
||||
<AppConfigProvider
|
||||
autoFetch={false}
|
||||
bootstrapMode="non-blocking"
|
||||
initialConfig={{
|
||||
appVersion: "1.2.3",
|
||||
shouldShowUpdate: true,
|
||||
machineType: "docker",
|
||||
activeSecurity: false,
|
||||
license: "NORMAL",
|
||||
}}
|
||||
>
|
||||
<StoryComponent />
|
||||
</AppConfigProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
const mockFile: StirlingFileStub = {
|
||||
id: "file-1" as FileId,
|
||||
name: "quarterly-report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 2_400_000,
|
||||
lastModified: Date.now(),
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1" as FileId,
|
||||
versionNumber: 1,
|
||||
};
|
||||
|
||||
const mockUploadedFile: StirlingFileStub = {
|
||||
...mockFile,
|
||||
id: "file-2" as FileId,
|
||||
originalFileId: "file-2" as FileId,
|
||||
remoteStorageId: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* The modal dispatches updateStirlingFileStub on upload, so it needs
|
||||
* FileContext (also supplies IndexedDBContext) mounted above it.
|
||||
*/
|
||||
function withProviders(Story: () => ReactElement) {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<Story />
|
||||
</FileContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Shared/UploadToServerModal",
|
||||
component: UploadToServerModal,
|
||||
decorators: [withProviders],
|
||||
args: {
|
||||
onClose: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof UploadToServerModal>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
file: mockFile,
|
||||
},
|
||||
};
|
||||
|
||||
export const AlreadyUploaded: Story = {
|
||||
args: {
|
||||
opened: true,
|
||||
file: mockUploadedFile,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import UserSelector from "@app/components/shared/UserSelector";
|
||||
|
||||
/**
|
||||
* Fetches `/api/v1/user/users` on mount — unmocked here, so stories render
|
||||
* whatever the fetch settles to (loader, then the "no users" empty state).
|
||||
*/
|
||||
const meta = {
|
||||
title: "Shared/UserSelector",
|
||||
component: UserSelector,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
value: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof UserSelector>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
};
|
||||
|
||||
export const CustomPlaceholder: Story = {
|
||||
args: { placeholder: "Add collaborators..." },
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user