269 lines
8.2 KiB
TypeScript
269 lines
8.2 KiB
TypeScript
import {
|
|
Button,
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
Input,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Switch,
|
|
Textarea
|
|
} from "@ai-ui/ui";
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
import { useState } from "react";
|
|
|
|
type LaunchFormValues = {
|
|
email: string;
|
|
notifications: boolean;
|
|
role: string;
|
|
summary: string;
|
|
};
|
|
|
|
async function waitForCondition(
|
|
predicate: () => boolean,
|
|
message: string,
|
|
timeoutMs = 2000
|
|
) {
|
|
const startedAt = Date.now();
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, 16));
|
|
}
|
|
|
|
throw new Error(message);
|
|
}
|
|
|
|
function LaunchSettingsForm() {
|
|
const [submitted, setSubmitted] = useState<LaunchFormValues | null>(null);
|
|
const form = useForm<LaunchFormValues>({
|
|
defaultValues: {
|
|
email: "",
|
|
notifications: true,
|
|
role: "design",
|
|
summary: ""
|
|
}
|
|
});
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form
|
|
className="grid w-[620px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]"
|
|
noValidate
|
|
onSubmit={form.handleSubmit((values) => {
|
|
setSubmitted(values);
|
|
})}
|
|
>
|
|
<div className="space-y-2">
|
|
<h2 className="m-0 text-xl font-semibold tracking-[var(--tracking-tight)]">
|
|
Launch settings
|
|
</h2>
|
|
<p className="m-0 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
This story uses <code>react-hook-form</code> with the new form primitives.
|
|
</p>
|
|
</div>
|
|
|
|
<FormItem name="email" required>
|
|
<FormLabel>Email address</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="team@cadence.dev"
|
|
{...form.register("email", {
|
|
required: "Email is required.",
|
|
pattern: {
|
|
value: /\S+@\S+\.\S+/,
|
|
message: "Enter a valid email address."
|
|
}
|
|
})}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>Release notes and alerts will be sent here.</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
|
|
<Controller
|
|
control={form.control}
|
|
name="role"
|
|
rules={{
|
|
required: "Choose a review lane."
|
|
}}
|
|
render={({ field }) => (
|
|
<FormItem name="role">
|
|
<FormLabel>Review lane</FormLabel>
|
|
<FormControl>
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger aria-label="Review lane">
|
|
<SelectValue placeholder="Choose a lane" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="design">Design</SelectItem>
|
|
<SelectItem value="engineering">Engineering</SelectItem>
|
|
<SelectItem value="legal">Legal</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<FormDescription>
|
|
The selected team becomes the primary reviewer for this launch.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormItem name="summary">
|
|
<FormLabel>Launch summary</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Summarize the rollout, scope, and known risks."
|
|
{...form.register("summary", {
|
|
minLength: {
|
|
value: 24,
|
|
message: "Summary must be at least 24 characters."
|
|
}
|
|
})}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>Shown in the changelog card and Slack digest.</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
|
|
<Controller
|
|
control={form.control}
|
|
name="notifications"
|
|
render={({ field }) => (
|
|
<FormItem name="notifications">
|
|
<div className="flex items-start justify-between gap-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
|
<div className="space-y-1">
|
|
<FormLabel>Weekly digest</FormLabel>
|
|
<FormDescription>
|
|
Send a Friday summary of all rollout changes to stakeholders.
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl className="gap-0">
|
|
<Switch
|
|
aria-label="Weekly digest"
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</div>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<div className="flex items-center justify-end gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
form.reset();
|
|
setSubmitted(null);
|
|
}}
|
|
>
|
|
Reset
|
|
</Button>
|
|
<Button type="submit">Save settings</Button>
|
|
</div>
|
|
|
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
|
<p className="m-0 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Submitted payload
|
|
</p>
|
|
<pre className="m-0 mt-3 overflow-x-auto text-sm leading-6 text-[var(--color-foreground)]">
|
|
<code>{submitted ? JSON.stringify(submitted, null, 2) : "Submit the form to inspect values."}</code>
|
|
</pre>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
const meta = {
|
|
title: "Components/Form",
|
|
component: LaunchSettingsForm,
|
|
parameters: {
|
|
layout: "centered"
|
|
},
|
|
tags: ["autodocs"]
|
|
} satisfies Meta<typeof LaunchSettingsForm>;
|
|
|
|
export default meta;
|
|
|
|
type Story = StoryObj<typeof meta>;
|
|
|
|
export const LaunchSettings: Story = {
|
|
play: async ({ canvasElement }) => {
|
|
const emailInput = canvasElement.querySelector('input[placeholder="team@cadence.dev"]');
|
|
|
|
if (!(emailInput instanceof HTMLInputElement)) {
|
|
throw new Error("Expected the email input to render.");
|
|
}
|
|
|
|
emailInput.focus();
|
|
emailInput.value = "team@cadence.dev";
|
|
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
emailInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
const roleTrigger = [...canvasElement.querySelectorAll('[data-slot="trigger"]')].find(
|
|
(element) => element.textContent?.includes("Design")
|
|
);
|
|
|
|
if (!(roleTrigger instanceof HTMLElement)) {
|
|
throw new Error("Expected the role select trigger to render.");
|
|
}
|
|
|
|
roleTrigger.click();
|
|
|
|
await waitForCondition(
|
|
() => document.body.querySelector('[role="listbox"]') instanceof HTMLElement,
|
|
"Expected the role select content to open."
|
|
);
|
|
|
|
const legalOption = [...document.body.querySelectorAll('[role="option"]')].find((element) =>
|
|
element.textContent?.includes("Legal")
|
|
);
|
|
|
|
if (!(legalOption instanceof HTMLElement)) {
|
|
throw new Error("Expected to find the Legal option.");
|
|
}
|
|
|
|
legalOption.click();
|
|
|
|
const summaryInput = canvasElement.querySelector("textarea");
|
|
|
|
if (!(summaryInput instanceof HTMLTextAreaElement)) {
|
|
throw new Error("Expected the launch summary textarea to render.");
|
|
}
|
|
|
|
summaryInput.value = "This release coordinates approvals, copy, and rollout risks.";
|
|
summaryInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
summaryInput.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
const submitButton = [...canvasElement.querySelectorAll("button")].find((element) =>
|
|
element.textContent?.includes("Save settings")
|
|
);
|
|
|
|
if (!(submitButton instanceof HTMLButtonElement)) {
|
|
throw new Error("Expected the form submit button to render.");
|
|
}
|
|
|
|
submitButton.click();
|
|
|
|
await waitForCondition(
|
|
() => canvasElement.textContent?.includes('"email": "team@cadence.dev"') ?? false,
|
|
"Expected the submitted payload preview to update."
|
|
);
|
|
}
|
|
};
|