Compare commits
7
Commits
bd4aef005a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf759c8a1 | ||
|
|
ecf05c11c4 | ||
|
|
702e9f4d22 | ||
|
|
d3599398ab | ||
|
|
5e6458f18c | ||
|
|
12f8b70c2b | ||
|
|
107624d4f2 |
@@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-devtools": "^0.10.12",
|
"@tanstack/react-devtools": "^0.10.12",
|
||||||
@@ -23,6 +25,17 @@
|
|||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"oxlint": "^1.81.0",
|
"oxlint": "^1.81.0",
|
||||||
"typescript": "~6.0.2",
|
"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 "./App.css";
|
||||||
|
|
||||||
|
import { MyForm } from "./MyForm";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section>
|
<MyForm></MyForm>
|
||||||
<text>TanStackForm</text>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+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'
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite'
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// 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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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