@react-spa-scaffold/mcp 0.4.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -19
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +59 -25
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.d.ts +1 -0
- package/dist/features/types.d.ts.map +1 -1
- package/dist/features/versions.json +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/get-example.js +1 -1
- package/dist/tools/get-scaffold.d.ts +1 -0
- package/dist/tools/get-scaffold.d.ts.map +1 -1
- package/dist/tools/get-scaffold.js +33 -17
- package/dist/tools/get-scaffold.js.map +1 -1
- package/dist/utils/examples.d.ts.map +1 -1
- package/dist/utils/examples.js +19 -16
- package/dist/utils/examples.js.map +1 -1
- package/dist/utils/paths.d.ts +2 -1
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +15 -2
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/scaffold.d.ts +6 -1
- package/dist/utils/scaffold.d.ts.map +1 -1
- package/dist/utils/scaffold.js +86 -13
- package/dist/utils/scaffold.js.map +1 -1
- package/package.json +1 -1
- package/templates/CLAUDE.md +4 -2
- package/templates/docs/API_REFERENCE.md +0 -1
- package/templates/docs/INTERNATIONALIZATION.md +26 -0
- package/templates/gitignore +33 -0
- package/templates/package.json +1 -1
- package/templates/src/components/shared/RegisterForm/RegisterForm.tsx +91 -0
- package/templates/src/components/shared/RegisterForm/index.ts +1 -0
- package/templates/src/components/shared/index.ts +1 -0
- package/templates/src/components/ui/card.tsx +70 -0
- package/templates/src/components/ui/input.tsx +19 -0
- package/templates/src/components/ui/label.tsx +19 -0
- package/templates/src/hooks/index.ts +1 -1
- package/templates/src/hooks/useRegisterForm.ts +36 -0
- package/templates/src/lib/index.ts +1 -11
- package/templates/src/lib/validations.ts +6 -13
- package/templates/src/pages/Home.tsx +29 -10
- package/templates/tests/unit/components/RegisterForm.test.tsx +105 -0
- package/templates/tests/unit/hooks/useRegisterForm.test.tsx +153 -0
- package/templates/tests/unit/lib/validations.test.ts +22 -33
- package/templates/tests/unit/stores/preferencesStore.test.ts +81 -0
- package/templates/vitest.config.ts +1 -1
- package/templates/src/hooks/useContactForm.ts +0 -33
- package/templates/src/lib/constants.ts +0 -8
- package/templates/src/lib/format.ts +0 -119
- package/templates/tests/unit/hooks/useContactForm.test.ts +0 -60
- package/templates/tests/unit/lib/format.test.ts +0 -100
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Label as LabelPrimitive } from 'radix-ui';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
7
|
+
return (
|
|
8
|
+
<LabelPrimitive.Root
|
|
9
|
+
data-slot="label"
|
|
10
|
+
className={cn(
|
|
11
|
+
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { Label };
|
|
@@ -3,5 +3,5 @@ export { useThemeEffect } from './useThemeEffect';
|
|
|
3
3
|
export { useTouchSizes } from './useTouchSizes';
|
|
4
4
|
export { useLanguage } from './useLanguage';
|
|
5
5
|
export { useExampleQuery } from './useExampleQuery';
|
|
6
|
-
export {
|
|
6
|
+
export { useRegisterForm } from './useRegisterForm';
|
|
7
7
|
export { useMobileContext } from '@/contexts/mobileContext';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
2
|
+
import { useForm } from 'react-hook-form';
|
|
3
|
+
|
|
4
|
+
import { type RegisterFormData, registerFormSchema } from '@/lib/validations';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* React Hook Form + Zod hook for registration.
|
|
8
|
+
* Demonstrates cross-field validation (password confirmation).
|
|
9
|
+
*/
|
|
10
|
+
export function useRegisterForm() {
|
|
11
|
+
const form = useForm<RegisterFormData>({
|
|
12
|
+
resolver: zodResolver(registerFormSchema),
|
|
13
|
+
defaultValues: {
|
|
14
|
+
username: '',
|
|
15
|
+
email: '',
|
|
16
|
+
password: '',
|
|
17
|
+
confirmPassword: '',
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const onSubmit = async (data: RegisterFormData) => {
|
|
22
|
+
// Simulate API call delay
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
24
|
+
// Replace with your actual registration logic
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.log('Registration submitted:', data);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
form,
|
|
31
|
+
onSubmit: form.handleSubmit(onSubmit),
|
|
32
|
+
isSubmitting: form.formState.isSubmitting,
|
|
33
|
+
errors: form.formState.errors,
|
|
34
|
+
reset: form.reset,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export { cn } from './utils';
|
|
7
|
-
export { TIMING, UI } from './constants';
|
|
8
7
|
export { STORAGE_KEYS, isAppKey } from './storageKeys';
|
|
9
8
|
export { APP_CONFIG, SENTRY_CONFIG } from './config';
|
|
10
9
|
export { API_CONFIG } from './api';
|
|
@@ -12,13 +11,4 @@ export { ROUTES, type AppRoute } from './routes';
|
|
|
12
11
|
export { env, validateEnv, type Env } from './env';
|
|
13
12
|
export { api, ApiClientError } from './api';
|
|
14
13
|
export { getStorageItem, setStorageItem, removeStorageItem, clearAppStorage } from './storage';
|
|
15
|
-
export {
|
|
16
|
-
formatDate,
|
|
17
|
-
formatDateTime,
|
|
18
|
-
formatRelativeTime,
|
|
19
|
-
formatNumber,
|
|
20
|
-
formatCurrency,
|
|
21
|
-
formatPercent,
|
|
22
|
-
formatBytes,
|
|
23
|
-
} from './format';
|
|
24
|
-
export { contactFormSchema, registerFormSchema, type ContactFormData, type RegisterFormData } from './validations';
|
|
14
|
+
export { registerFormSchema, type RegisterFormData } from './validations';
|
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
message: z.string().min(10, 'Message must be at least 10 characters'),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
export type ContactFormData = z.infer<typeof contactFormSchema>;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Example validation schema for user registration.
|
|
4
|
+
* Registration form validation schema.
|
|
5
|
+
* Demonstrates Zod validation patterns:
|
|
6
|
+
* - Basic validations: min, max, email, regex
|
|
7
|
+
* - Cross-field validation with refine() (password confirmation)
|
|
8
|
+
* - Custom error messages
|
|
9
|
+
* - Type inference with z.infer<>
|
|
17
10
|
*/
|
|
18
11
|
export const registerFormSchema = z
|
|
19
12
|
.object({
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Trans, useLingui } from '@lingui/react/macro';
|
|
2
2
|
|
|
3
|
-
import { SEO } from '@/components/shared';
|
|
3
|
+
import { RegisterForm, SEO } from '@/components/shared';
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
4
5
|
|
|
5
6
|
export function HomePage() {
|
|
6
7
|
const { t } = useLingui();
|
|
7
8
|
|
|
8
9
|
return (
|
|
9
|
-
<div className="container mx-auto px-4 py-8">
|
|
10
|
+
<div className="container mx-auto max-w-lg px-4 py-8">
|
|
10
11
|
<SEO
|
|
11
12
|
title={t({ message: 'Home', comment: 'Home page title for SEO' })}
|
|
12
13
|
description={t({
|
|
@@ -14,14 +15,32 @@ export function HomePage() {
|
|
|
14
15
|
comment: 'Home page meta description for SEO',
|
|
15
16
|
})}
|
|
16
17
|
/>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
|
|
19
|
+
<div className="mb-8 text-center">
|
|
20
|
+
<h1 className="text-3xl font-bold">
|
|
21
|
+
<Trans comment="Main heading on the home page">Welcome to My App</Trans>
|
|
22
|
+
</h1>
|
|
23
|
+
<p className="text-muted-foreground mt-2">
|
|
24
|
+
<Trans comment="Instructions for developers on how to start customizing the app">
|
|
25
|
+
Get started by editing <code className="bg-muted rounded px-1">src/App.tsx</code>
|
|
26
|
+
</Trans>
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
{/* Form validation demo */}
|
|
31
|
+
<Card>
|
|
32
|
+
<CardHeader>
|
|
33
|
+
<CardTitle>
|
|
34
|
+
<Trans comment="Register form card title">Registration Form</Trans>
|
|
35
|
+
</CardTitle>
|
|
36
|
+
<CardDescription>
|
|
37
|
+
<Trans comment="Register form card description">React Hook Form + Zod validation demo</Trans>
|
|
38
|
+
</CardDescription>
|
|
39
|
+
</CardHeader>
|
|
40
|
+
<CardContent>
|
|
41
|
+
<RegisterForm />
|
|
42
|
+
</CardContent>
|
|
43
|
+
</Card>
|
|
25
44
|
</div>
|
|
26
45
|
);
|
|
27
46
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { RegisterForm } from '@/components/shared/RegisterForm';
|
|
6
|
+
import { render } from '@/test';
|
|
7
|
+
|
|
8
|
+
describe('RegisterForm', () => {
|
|
9
|
+
it('renders all form fields', () => {
|
|
10
|
+
render(<RegisterForm />);
|
|
11
|
+
|
|
12
|
+
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
|
|
13
|
+
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
|
14
|
+
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('shows validation errors for empty fields on submit', async () => {
|
|
20
|
+
const user = userEvent.setup();
|
|
21
|
+
render(<RegisterForm />);
|
|
22
|
+
|
|
23
|
+
await user.click(screen.getByRole('button', { name: /create account/i }));
|
|
24
|
+
|
|
25
|
+
await waitFor(() => {
|
|
26
|
+
expect(screen.getByText(/username must be at least 3 characters/i)).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(screen.getByText(/please enter a valid email address/i)).toBeInTheDocument();
|
|
30
|
+
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('shows password mismatch error when passwords differ', async () => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
render(<RegisterForm />);
|
|
36
|
+
|
|
37
|
+
await user.type(screen.getByLabelText(/username/i), 'testuser');
|
|
38
|
+
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
|
|
39
|
+
await user.type(screen.getByLabelText(/^password$/i), 'Password1');
|
|
40
|
+
await user.type(screen.getByLabelText(/confirm password/i), 'Password2');
|
|
41
|
+
await user.click(screen.getByRole('button', { name: /create account/i }));
|
|
42
|
+
|
|
43
|
+
await waitFor(() => {
|
|
44
|
+
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('submits form with valid data', async () => {
|
|
49
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
render(<RegisterForm />);
|
|
52
|
+
|
|
53
|
+
await user.type(screen.getByLabelText(/username/i), 'testuser');
|
|
54
|
+
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
|
|
55
|
+
await user.type(screen.getByLabelText(/^password$/i), 'Password1');
|
|
56
|
+
await user.type(screen.getByLabelText(/confirm password/i), 'Password1');
|
|
57
|
+
await user.click(screen.getByRole('button', { name: /create account/i }));
|
|
58
|
+
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
expect(screen.getByRole('button', { name: /creating account/i })).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await waitFor(
|
|
64
|
+
() => {
|
|
65
|
+
expect(consoleSpy).toHaveBeenCalledWith('Registration submitted:', {
|
|
66
|
+
username: 'testuser',
|
|
67
|
+
email: 'test@example.com',
|
|
68
|
+
password: 'Password1',
|
|
69
|
+
confirmPassword: 'Password1',
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
{ timeout: 2000 },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
consoleSpy.mockRestore();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('disables submit button while submitting', async () => {
|
|
79
|
+
const user = userEvent.setup();
|
|
80
|
+
render(<RegisterForm />);
|
|
81
|
+
|
|
82
|
+
await user.type(screen.getByLabelText(/username/i), 'testuser');
|
|
83
|
+
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
|
|
84
|
+
await user.type(screen.getByLabelText(/^password$/i), 'Password1');
|
|
85
|
+
await user.type(screen.getByLabelText(/confirm password/i), 'Password1');
|
|
86
|
+
await user.click(screen.getByRole('button', { name: /create account/i }));
|
|
87
|
+
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(screen.getByRole('button', { name: /creating account/i })).toBeDisabled();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('sets aria-invalid on fields with errors', async () => {
|
|
94
|
+
const user = userEvent.setup();
|
|
95
|
+
render(<RegisterForm />);
|
|
96
|
+
|
|
97
|
+
await user.click(screen.getByRole('button', { name: /create account/i }));
|
|
98
|
+
|
|
99
|
+
await waitFor(() => {
|
|
100
|
+
expect(screen.getByLabelText(/username/i)).toHaveAttribute('aria-invalid', 'true');
|
|
101
|
+
expect(screen.getByLabelText(/email/i)).toHaveAttribute('aria-invalid', 'true');
|
|
102
|
+
expect(screen.getByLabelText(/^password$/i)).toHaveAttribute('aria-invalid', 'true');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useRegisterForm } from '@/hooks/useRegisterForm';
|
|
5
|
+
|
|
6
|
+
describe('useRegisterForm', () => {
|
|
7
|
+
it('returns form object with expected properties', () => {
|
|
8
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
9
|
+
|
|
10
|
+
expect(result.current.form).toBeDefined();
|
|
11
|
+
expect(result.current.onSubmit).toBeDefined();
|
|
12
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
13
|
+
expect(result.current.errors).toBeDefined();
|
|
14
|
+
expect(result.current.reset).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('initializes with empty default values', () => {
|
|
18
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
19
|
+
|
|
20
|
+
expect(result.current.form.getValues()).toEqual({
|
|
21
|
+
username: '',
|
|
22
|
+
email: '',
|
|
23
|
+
password: '',
|
|
24
|
+
confirmPassword: '',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('validates username minimum length', async () => {
|
|
29
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
30
|
+
|
|
31
|
+
await act(async () => {
|
|
32
|
+
result.current.form.setValue('username', 'ab');
|
|
33
|
+
await result.current.form.trigger('username');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result.current.errors.username?.message).toBe('Username must be at least 3 characters');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('validates username maximum length', async () => {
|
|
40
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
41
|
+
|
|
42
|
+
await act(async () => {
|
|
43
|
+
result.current.form.setValue('username', 'a'.repeat(21));
|
|
44
|
+
await result.current.form.trigger('username');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.current.errors.username?.message).toBe('Username must be at most 20 characters');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('validates username format (alphanumeric and underscore only)', async () => {
|
|
51
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
52
|
+
|
|
53
|
+
await act(async () => {
|
|
54
|
+
result.current.form.setValue('username', 'user@name');
|
|
55
|
+
await result.current.form.trigger('username');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result.current.errors.username?.message).toBe('Username can only contain letters, numbers, and underscores');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('validates email format', async () => {
|
|
62
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
63
|
+
|
|
64
|
+
await act(async () => {
|
|
65
|
+
result.current.form.setValue('email', 'invalid-email');
|
|
66
|
+
await result.current.form.trigger('email');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.current.errors.email?.message).toBe('Please enter a valid email address');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('validates password requires uppercase letter', async () => {
|
|
73
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
74
|
+
|
|
75
|
+
await act(async () => {
|
|
76
|
+
result.current.form.setValue('password', 'lowercase1');
|
|
77
|
+
await result.current.form.trigger('password');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result.current.errors.password?.message).toBe('Password must contain at least one uppercase letter');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('validates password requires lowercase letter', async () => {
|
|
84
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
85
|
+
|
|
86
|
+
await act(async () => {
|
|
87
|
+
result.current.form.setValue('password', 'UPPERCASE1');
|
|
88
|
+
await result.current.form.trigger('password');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result.current.errors.password?.message).toBe('Password must contain at least one lowercase letter');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('validates password requires number', async () => {
|
|
95
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
96
|
+
|
|
97
|
+
await act(async () => {
|
|
98
|
+
result.current.form.setValue('password', 'NoNumbers');
|
|
99
|
+
await result.current.form.trigger('password');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.current.errors.password?.message).toBe('Password must contain at least one number');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('calls onSubmit with valid data', async () => {
|
|
106
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
107
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
108
|
+
|
|
109
|
+
await act(async () => {
|
|
110
|
+
result.current.form.setValue('username', 'validuser');
|
|
111
|
+
result.current.form.setValue('email', 'test@example.com');
|
|
112
|
+
result.current.form.setValue('password', 'Password1');
|
|
113
|
+
result.current.form.setValue('confirmPassword', 'Password1');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
await result.current.onSubmit();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(consoleSpy).toHaveBeenCalledWith('Registration submitted:', {
|
|
122
|
+
username: 'validuser',
|
|
123
|
+
email: 'test@example.com',
|
|
124
|
+
password: 'Password1',
|
|
125
|
+
confirmPassword: 'Password1',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
consoleSpy.mockRestore();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('resets form to default values', async () => {
|
|
133
|
+
const { result } = renderHook(() => useRegisterForm());
|
|
134
|
+
|
|
135
|
+
await act(async () => {
|
|
136
|
+
result.current.form.setValue('username', 'testuser');
|
|
137
|
+
result.current.form.setValue('email', 'test@example.com');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result.current.form.getValues().username).toBe('testuser');
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
result.current.reset();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.current.form.getValues()).toEqual({
|
|
147
|
+
username: '',
|
|
148
|
+
email: '',
|
|
149
|
+
password: '',
|
|
150
|
+
confirmPassword: '',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -1,32 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
describe('contactFormSchema', () => {
|
|
6
|
-
const validContact = {
|
|
7
|
-
name: 'John Doe',
|
|
8
|
-
email: 'john@example.com',
|
|
9
|
-
message: 'This is a valid message that is long enough.',
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
it('accepts valid data', () => {
|
|
13
|
-
expect(contactFormSchema.safeParse(validContact).success).toBe(true);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it.each([
|
|
17
|
-
{ field: 'name', value: 'J', errorContains: 'at least 2 characters' },
|
|
18
|
-
{ field: 'email', value: 'not-an-email', errorContains: 'valid email' },
|
|
19
|
-
{ field: 'message', value: 'Short', errorContains: 'at least 10 characters' },
|
|
20
|
-
])('rejects invalid $field', ({ field, value, errorContains }) => {
|
|
21
|
-
const data = { ...validContact, [field]: value };
|
|
22
|
-
const result = contactFormSchema.safeParse(data);
|
|
23
|
-
|
|
24
|
-
expect(result.success).toBe(false);
|
|
25
|
-
if (!result.success) {
|
|
26
|
-
expect(result.error.issues[0].message).toContain(errorContains);
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
});
|
|
3
|
+
import { registerFormSchema } from '@/lib/validations';
|
|
30
4
|
|
|
31
5
|
describe('registerFormSchema', () => {
|
|
32
6
|
const validRegister = {
|
|
@@ -41,16 +15,31 @@ describe('registerFormSchema', () => {
|
|
|
41
15
|
});
|
|
42
16
|
|
|
43
17
|
it.each([
|
|
18
|
+
{ field: 'username', value: 'ab', errorContains: 'at least 3 characters' },
|
|
44
19
|
{ field: 'username', value: 'john@doe', errorContains: 'letters, numbers, and underscores' },
|
|
45
|
-
{ field: '
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
20
|
+
{ field: 'email', value: 'not-an-email', errorContains: 'valid email' },
|
|
21
|
+
{ field: 'password', value: 'short', errorContains: 'at least 8 characters' },
|
|
22
|
+
{ field: 'password', value: 'alllowercase1', errorContains: 'uppercase letter' },
|
|
23
|
+
{ field: 'password', value: 'ALLUPPERCASE1', errorContains: 'lowercase letter' },
|
|
24
|
+
{ field: 'password', value: 'NoNumbersHere', errorContains: 'one number' },
|
|
25
|
+
])('rejects invalid $field with value "$value"', ({ field, value, errorContains }) => {
|
|
26
|
+
const data = { ...validRegister, [field]: value };
|
|
27
|
+
const result = registerFormSchema.safeParse(data);
|
|
28
|
+
|
|
29
|
+
expect(result.success).toBe(false);
|
|
30
|
+
if (!result.success) {
|
|
31
|
+
const errorMessages = result.error.issues.map((i) => i.message).join(' ');
|
|
32
|
+
expect(errorMessages).toContain(errorContains);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects mismatched passwords', () => {
|
|
37
|
+
const data = { ...validRegister, confirmPassword: 'DifferentPass123' };
|
|
49
38
|
const result = registerFormSchema.safeParse(data);
|
|
50
39
|
|
|
51
40
|
expect(result.success).toBe(false);
|
|
52
|
-
if (!result.success
|
|
53
|
-
expect(result.error.issues[0].message).toContain(
|
|
41
|
+
if (!result.success) {
|
|
42
|
+
expect(result.error.issues[0].message).toContain("don't match");
|
|
54
43
|
}
|
|
55
44
|
});
|
|
56
45
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { act } from '@testing-library/react';
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
|
+
import { STORAGE_KEYS } from '@/lib/storageKeys';
|
|
4
5
|
import { initPreferencesSync, usePreferencesStore } from '@/stores/preferencesStore';
|
|
5
6
|
import { mockMatchMedia } from '@/test';
|
|
6
7
|
|
|
@@ -71,5 +72,85 @@ describe('preferencesStore', () => {
|
|
|
71
72
|
|
|
72
73
|
expect(removeSpy).toHaveBeenCalledWith('storage', expect.any(Function));
|
|
73
74
|
});
|
|
75
|
+
|
|
76
|
+
it('updates store when valid storage event is received', () => {
|
|
77
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
78
|
+
initPreferencesSync();
|
|
79
|
+
|
|
80
|
+
const storageEvent = new StorageEvent('storage', {
|
|
81
|
+
key: STORAGE_KEYS.preferences,
|
|
82
|
+
newValue: JSON.stringify({ state: { theme: 'dark' } }),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
act(() => {
|
|
86
|
+
window.dispatchEvent(storageEvent);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(usePreferencesStore.getState().theme).toBe('dark');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ignores storage events for other keys', () => {
|
|
93
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
94
|
+
initPreferencesSync();
|
|
95
|
+
|
|
96
|
+
const storageEvent = new StorageEvent('storage', {
|
|
97
|
+
key: 'other-key',
|
|
98
|
+
newValue: JSON.stringify({ state: { theme: 'dark' } }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
window.dispatchEvent(storageEvent);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(usePreferencesStore.getState().theme).toBe('light');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('ignores storage events with null newValue', () => {
|
|
109
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
110
|
+
initPreferencesSync();
|
|
111
|
+
|
|
112
|
+
const storageEvent = new StorageEvent('storage', {
|
|
113
|
+
key: STORAGE_KEYS.preferences,
|
|
114
|
+
newValue: null,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
window.dispatchEvent(storageEvent);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(usePreferencesStore.getState().theme).toBe('light');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('ignores storage events with invalid JSON', () => {
|
|
125
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
126
|
+
initPreferencesSync();
|
|
127
|
+
|
|
128
|
+
const storageEvent = new StorageEvent('storage', {
|
|
129
|
+
key: STORAGE_KEYS.preferences,
|
|
130
|
+
newValue: 'invalid-json',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
act(() => {
|
|
134
|
+
window.dispatchEvent(storageEvent);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(usePreferencesStore.getState().theme).toBe('light');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('ignores storage events without state property', () => {
|
|
141
|
+
usePreferencesStore.setState({ theme: 'light' });
|
|
142
|
+
initPreferencesSync();
|
|
143
|
+
|
|
144
|
+
const storageEvent = new StorageEvent('storage', {
|
|
145
|
+
key: STORAGE_KEYS.preferences,
|
|
146
|
+
newValue: JSON.stringify({ foo: 'bar' }),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
act(() => {
|
|
150
|
+
window.dispatchEvent(storageEvent);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(usePreferencesStore.getState().theme).toBe('light');
|
|
154
|
+
});
|
|
74
155
|
});
|
|
75
156
|
});
|
|
@@ -21,7 +21,7 @@ export default defineConfig({
|
|
|
21
21
|
coverage: {
|
|
22
22
|
provider: 'v8',
|
|
23
23
|
reporter: ['text', 'json', 'html', 'lcov'],
|
|
24
|
-
exclude: ['**/*.test.{ts,tsx}', '**/index.ts', 'src/types/**', 'src/components/ui/**'],
|
|
24
|
+
exclude: ['**/*.test.{ts,tsx}', '**/index.ts', 'src/types/**', 'src/components/ui/**', 'src/mocks/**'],
|
|
25
25
|
thresholds: {
|
|
26
26
|
lines: 80,
|
|
27
27
|
functions: 80,
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { zodResolver } from '@hookform/resolvers/zod';
|
|
2
|
-
import { useForm } from 'react-hook-form';
|
|
3
|
-
|
|
4
|
-
import { type ContactFormData, contactFormSchema } from '@/lib/validations';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Example React Hook Form + Zod hook for a contact form.
|
|
8
|
-
* Demonstrates the pattern for form validation.
|
|
9
|
-
*/
|
|
10
|
-
export function useContactForm() {
|
|
11
|
-
const form = useForm<ContactFormData>({
|
|
12
|
-
resolver: zodResolver(contactFormSchema),
|
|
13
|
-
defaultValues: {
|
|
14
|
-
name: '',
|
|
15
|
-
email: '',
|
|
16
|
-
message: '',
|
|
17
|
-
},
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const onSubmit = async (data: ContactFormData) => {
|
|
21
|
-
// Replace with your actual form submission logic
|
|
22
|
-
// eslint-disable-next-line no-console
|
|
23
|
-
console.log('Form submitted:', data);
|
|
24
|
-
// await api.submitContactForm(data);
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
form,
|
|
29
|
-
onSubmit: form.handleSubmit(onSubmit),
|
|
30
|
-
isSubmitting: form.formState.isSubmitting,
|
|
31
|
-
errors: form.formState.errors,
|
|
32
|
-
};
|
|
33
|
-
}
|