Compare commits
7
Commits
bd4aef005a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf759c8a1 | ||
|
|
ecf05c11c4 | ||
|
|
702e9f4d22 | ||
|
|
d3599398ab | ||
|
|
5e6458f18c | ||
|
|
12f8b70c2b | ||
|
|
107624d4f2 |
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
"stories": [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
||||
],
|
||||
"addons": [
|
||||
"@chromatic-com/storybook",
|
||||
"@storybook/addon-vitest",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-mcp"
|
||||
],
|
||||
"framework": "@storybook/react-vite"
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Preview } from '@storybook/react-vite'
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
+15
-2
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-devtools": "^0.10.12",
|
||||
@@ -23,6 +25,17 @@
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0"
|
||||
"vite": "^8.3.0",
|
||||
"storybook": "^10.6.0",
|
||||
"@storybook/react-vite": "^10.6.0",
|
||||
"@chromatic-com/storybook": "latest",
|
||||
"@storybook/addon-vitest": "^10.6.0",
|
||||
"@storybook/addon-a11y": "10.6.0",
|
||||
"@storybook/addon-docs": "^10.6.0",
|
||||
"@storybook/addon-mcp": "^10.6.0",
|
||||
"vitest": "latest",
|
||||
"playwright": "latest",
|
||||
"@vitest/browser-playwright": "latest",
|
||||
"@vitest/coverage-v8": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2619
-5
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
import "./App.css";
|
||||
|
||||
import { MyForm } from "./MyForm";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<text>TanStackForm</text>
|
||||
</section>
|
||||
<MyForm></MyForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
|
||||
interface MyFormProps {
|
||||
onSubmit?: (values: {
|
||||
username?: string;
|
||||
age?: string;
|
||||
password?: string;
|
||||
}) => void;
|
||||
}
|
||||
export function MyForm({ onSubmit }: MyFormProps) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
username: "",
|
||||
age: "",
|
||||
password: "",
|
||||
},
|
||||
|
||||
onSubmit: async ({ value }) => {
|
||||
onSubmit?.(value);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "400px",
|
||||
margin: "20px auto",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
<h2>Registration Form (TanStack Form)</h2>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field
|
||||
name="username"
|
||||
validators={{
|
||||
onChange: ({ value }) => {
|
||||
if (!value) return "Full name is required";
|
||||
if (!/^[a-zA-Za-z\s]+$/.test(value)) {
|
||||
return "Please enter letters only";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor={field.name}
|
||||
style={{ display: "block", marginBottom: "5px" }}
|
||||
>
|
||||
Full Name (Letters only):
|
||||
</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<span style={{ color: "red", fontSize: "12px" }}>
|
||||
{field.state.meta.errors.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field
|
||||
name="age"
|
||||
validators={{
|
||||
onChange: ({ value }) => {
|
||||
if (!value) return "Age is required";
|
||||
if (isNaN(Number(value))) {
|
||||
return "Please enter numbers only";
|
||||
}
|
||||
if (Number(value) < 18) {
|
||||
return "Age must be at least 18";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor={field.name}
|
||||
style={{ display: "block", marginBottom: "5px" }}
|
||||
>
|
||||
Age (Numbers only):
|
||||
</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="number"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<span style={{ color: "red", fontSize: "12px" }}>
|
||||
{field.state.meta.errors.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field
|
||||
name="password"
|
||||
validators={{
|
||||
onChange: ({ value }) => {
|
||||
if (!value) return "Password is required";
|
||||
if (value.length < 8) {
|
||||
return "Password must be at least 8 characters long";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(field) => (
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor={field.name}
|
||||
style={{ display: "block", marginBottom: "5px" }}
|
||||
>
|
||||
Password:
|
||||
</label>
|
||||
<input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
{field.state.meta.errors.length > 0 && (
|
||||
<span style={{ color: "red", fontSize: "12px" }}>
|
||||
{field.state.meta.errors.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||
>
|
||||
{([canSubmit, isSubmitting]) => (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
backgroundColor: canSubmit ? "#007bff" : "#ccc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: canSubmit ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { within, userEvent, expect, fn } from "storybook/test";
|
||||
import { MyForm } from "../MyForm";
|
||||
|
||||
const meta: Meta<typeof MyForm> = {
|
||||
title: "Components/MyForm",
|
||||
component: MyForm,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
args: {
|
||||
onSubmit: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MyForm>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const ValidationErrors: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const nameInput = canvas.getByLabelText(/Full Name/i);
|
||||
await userEvent.type(nameInput, "12345");
|
||||
|
||||
const ageInput = canvas.getByLabelText(/Age/i);
|
||||
await userEvent.type(ageInput, "15");
|
||||
|
||||
const passwordInput = canvas.getByLabelText(/Password/i);
|
||||
await userEvent.type(passwordInput, "123");
|
||||
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(
|
||||
canvas.getByText(/Please enter letters only/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(/Age must be at least 18/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(/Password must be at least 8 characters long/i),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SuccessfulSubmit: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const nameInput = canvas.getByLabelText(/Full Name/i);
|
||||
await userEvent.type(nameInput, "Somayeh Ghanavati");
|
||||
|
||||
const ageInput = canvas.getByLabelText(/Age/i);
|
||||
await userEvent.type(ageInput, "37");
|
||||
|
||||
const passwordInput = canvas.getByLabelText(/Password/i);
|
||||
await userEvent.type(passwordInput, "password123");
|
||||
|
||||
const submitButton = canvas.getByRole("button", { name: /Sign Up/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
},
|
||||
};
|
||||
|
||||
export const FillAndFixForm: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const nameInput = canvas.getByLabelText(/Full Name/i);
|
||||
const ageInput = canvas.getByLabelText(/Age/i);
|
||||
const passwordInput = canvas.getByLabelText(/Password/i);
|
||||
const submitButton = canvas.getByRole("button", { name: /Sign Up/i });
|
||||
|
||||
await userEvent.type(nameInput, "1234");
|
||||
await userEvent.type(ageInput, "10");
|
||||
await userEvent.type(passwordInput, "12");
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(
|
||||
canvas.queryByText(/Please enter letters only/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText(/Age must be at least 18/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
canvas.queryByText(/Password must be at least 8 characters long/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "Somayeh Ghanavati");
|
||||
|
||||
await userEvent.clear(ageInput);
|
||||
await userEvent.type(ageInput, "36");
|
||||
|
||||
await userEvent.clear(passwordInput);
|
||||
await userEvent.type(passwordInput, "123456789");
|
||||
|
||||
await expect(
|
||||
canvas.queryByText(/Please enter letters only/i),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText(/Age must be at least 18/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
canvas.queryByText(/Password must be at least 8 characters long/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
},
|
||||
};
|
||||
+33
-3
@@ -1,7 +1,37 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
/// <reference types="vitest/config" />
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
test: {
|
||||
projects: [{
|
||||
extends: true,
|
||||
plugins: [
|
||||
// The plugin will run tests for the stories defined in your Storybook config
|
||||
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||
storybookTest({
|
||||
configDir: path.join(dirname, '.storybook')
|
||||
})],
|
||||
test: {
|
||||
name: 'storybook',
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
provider: playwright({}),
|
||||
instances: [{
|
||||
browser: 'chromium'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@vitest/browser-playwright" />
|
||||
Reference in New Issue
Block a user