@react-spa-scaffold/mcp 2.1.1 → 2.3.0
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 +2 -1
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/auth.d.ts +3 -0
- package/dist/features/definitions/auth.d.ts.map +1 -0
- package/dist/features/definitions/auth.js +17 -0
- package/dist/features/definitions/auth.js.map +1 -0
- package/dist/features/definitions/core.d.ts.map +1 -1
- package/dist/features/definitions/core.js +16 -1
- package/dist/features/definitions/core.js.map +1 -1
- package/dist/features/definitions/database.d.ts +3 -0
- package/dist/features/definitions/database.d.ts.map +1 -0
- package/dist/features/definitions/database.js +45 -0
- package/dist/features/definitions/database.js.map +1 -0
- package/dist/features/definitions/deployment.d.ts +3 -0
- package/dist/features/definitions/deployment.d.ts.map +1 -0
- package/dist/features/definitions/deployment.js +14 -0
- package/dist/features/definitions/deployment.js.map +1 -0
- package/dist/features/definitions/forms.d.ts.map +1 -1
- package/dist/features/definitions/forms.js +4 -0
- package/dist/features/definitions/forms.js.map +1 -1
- package/dist/features/definitions/index.d.ts +3 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +3 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/definitions/mobile.d.ts.map +1 -1
- package/dist/features/definitions/mobile.js +11 -2
- package/dist/features/definitions/mobile.js.map +1 -1
- package/dist/features/definitions/observability.js +1 -1
- package/dist/features/definitions/observability.js.map +1 -1
- package/dist/features/definitions/routing.d.ts.map +1 -1
- package/dist/features/definitions/routing.js +2 -1
- package/dist/features/definitions/routing.js.map +1 -1
- package/dist/features/definitions/state.d.ts.map +1 -1
- package/dist/features/definitions/state.js +9 -2
- package/dist/features/definitions/state.js.map +1 -1
- package/dist/features/definitions/testing.d.ts.map +1 -1
- package/dist/features/definitions/testing.js +4 -2
- package/dist/features/definitions/testing.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +4 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.test.js +6 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/resources/docs.d.ts.map +1 -1
- package/dist/resources/docs.js +5 -0
- package/dist/resources/docs.js.map +1 -1
- package/dist/tools/add-features.js +1 -1
- package/dist/tools/add-features.js.map +1 -1
- package/dist/utils/docs.d.ts.map +1 -1
- package/dist/utils/docs.js +2 -0
- package/dist/utils/docs.js.map +1 -1
- package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/index.js +3 -1
- package/dist/utils/scaffold/claude-md/index.js.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
- package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.js +132 -2
- package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
- package/dist/utils/scaffold/compute.js +1 -1
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts +2 -2
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +64 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +44 -10
- package/templates/.github/workflows/ci.yml +12 -4
- package/templates/.github/workflows/deploy.yml +59 -0
- package/templates/CLAUDE.md +251 -2
- package/templates/docs/ARCHITECTURE.md +13 -12
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/CODING_STANDARDS.md +65 -0
- package/templates/docs/DEPLOYMENT.md +268 -0
- package/templates/docs/E2E_TESTING.md +133 -11
- package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
- package/templates/docs/TESTING.md +195 -77
- package/templates/e2e/auth/auth.setup.ts +60 -0
- package/templates/e2e/fixtures/index.ts +24 -2
- package/templates/e2e/tests/profile.auth.spec.ts +103 -0
- package/templates/e2e/tests/profile.spec.ts +64 -0
- package/templates/e2e/tests/register-form.spec.ts +38 -0
- package/templates/gitignore +5 -0
- package/templates/package.json +15 -3
- package/templates/playwright.config.ts +39 -4
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +13 -1
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
- package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
- package/templates/src/components/shared/AccountButton/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
- package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
- package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
- package/templates/src/components/shared/ProfileSync/index.ts +1 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
- package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
- package/templates/src/components/shared/index.ts +5 -2
- package/templates/src/contexts/clerkContext.tsx +45 -0
- package/templates/src/contexts/performanceContext.tsx +3 -3
- package/templates/src/contexts/supabaseContext.test.tsx +59 -0
- package/templates/src/contexts/supabaseContext.tsx +87 -0
- package/templates/src/hooks/index.ts +40 -2
- package/templates/src/hooks/supabase/index.ts +12 -0
- package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
- package/templates/src/hooks/supabase/useProfiles.ts +213 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
- package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
- package/templates/src/hooks/useCopyFeedback.ts +41 -0
- package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
- package/templates/src/hooks/useDebouncedCallback.ts +47 -0
- package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
- package/templates/src/hooks/useDocumentTitle.ts +31 -0
- package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
- package/templates/src/hooks/useIOSViewportReset.ts +18 -0
- package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
- package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
- package/templates/src/hooks/useLocalStorage.test.ts +111 -0
- package/templates/src/hooks/useLocalStorage.ts +77 -0
- package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
- package/templates/src/hooks/useSyncedFormData.ts +21 -0
- package/templates/src/hooks/useSyncedState.test.ts +119 -0
- package/templates/src/hooks/useSyncedState.ts +30 -0
- package/templates/src/index.css +1 -0
- package/templates/src/lib/api.test.ts +30 -38
- package/templates/src/lib/api.ts +1 -7
- package/templates/src/lib/config.ts +54 -4
- package/templates/src/lib/constants.ts +10 -0
- package/templates/src/lib/createSelectors.test.ts +136 -0
- package/templates/src/lib/createSelectors.ts +31 -0
- package/templates/src/lib/env.ts +36 -14
- package/templates/src/lib/index.ts +5 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +58 -0
- package/templates/src/lib/storage.ts +6 -2
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.tsx +19 -31
- package/templates/src/mocks/constants.ts +31 -0
- package/templates/src/mocks/fixtures/index.ts +3 -1
- package/templates/src/mocks/fixtures/profiles.ts +55 -0
- package/templates/src/mocks/fixtures/users.ts +91 -0
- package/templates/src/mocks/handlers/index.ts +2 -1
- package/templates/src/mocks/handlers/supabase.ts +64 -0
- package/templates/src/mocks/handlers/todos.ts +1 -1
- package/templates/src/mocks/index.ts +6 -0
- package/templates/src/pages/Profile.test.tsx +263 -0
- package/templates/src/pages/Profile.tsx +171 -0
- package/templates/src/pages/index.ts +1 -0
- package/templates/src/stores/preferencesStore.ts +35 -9
- package/templates/src/test/clerkMock.tsx +137 -0
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +51 -2
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +10 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +42 -2
- package/templates/src/types/database.ts +46 -0
- package/templates/src/types/index.ts +1 -0
- package/templates/src/types/supabase.ts +167 -0
- package/templates/src/vite-env.d.ts +6 -0
- package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
- package/templates/vitest.config.ts +9 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useLocalStorage } from './useLocalStorage';
|
|
5
|
+
|
|
6
|
+
describe('useLocalStorage', () => {
|
|
7
|
+
const key = 'test-key';
|
|
8
|
+
const initialValue = 'initial';
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
localStorage.clear();
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns initial value when localStorage is empty', () => {
|
|
16
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
17
|
+
expect(result.current[0]).toBe(initialValue);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns stored value from localStorage', () => {
|
|
21
|
+
localStorage.setItem(key, JSON.stringify('stored'));
|
|
22
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
23
|
+
expect(result.current[0]).toBe('stored');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('updates value with direct setter', () => {
|
|
27
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
28
|
+
|
|
29
|
+
act(() => {
|
|
30
|
+
result.current[1]('new-value');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.current[0]).toBe('new-value');
|
|
34
|
+
expect(JSON.parse(localStorage.getItem(key)!)).toBe('new-value');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('updates value with updater function', () => {
|
|
38
|
+
const { result } = renderHook(() => useLocalStorage(key, 0));
|
|
39
|
+
|
|
40
|
+
act(() => {
|
|
41
|
+
result.current[1]((prev) => prev + 1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.current[0]).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('handles complex objects', () => {
|
|
48
|
+
const initial = { name: 'test', count: 0 };
|
|
49
|
+
const { result } = renderHook(() => useLocalStorage(key, initial));
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
result.current[1]({ name: 'updated', count: 1 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.current[0]).toEqual({ name: 'updated', count: 1 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('re-reads from localStorage when key changes', () => {
|
|
59
|
+
localStorage.setItem('key-a', JSON.stringify('value-a'));
|
|
60
|
+
localStorage.setItem('key-b', JSON.stringify('value-b'));
|
|
61
|
+
|
|
62
|
+
const { result, rerender } = renderHook(({ k }) => useLocalStorage(k, 'default'), {
|
|
63
|
+
initialProps: { k: 'key-a' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.current[0]).toBe('value-a');
|
|
67
|
+
|
|
68
|
+
rerender({ k: 'key-b' });
|
|
69
|
+
expect(result.current[0]).toBe('value-b');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles JSON parse errors gracefully', () => {
|
|
73
|
+
localStorage.setItem(key, 'not-valid-json');
|
|
74
|
+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
75
|
+
|
|
76
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
77
|
+
|
|
78
|
+
expect(result.current[0]).toBe(initialValue);
|
|
79
|
+
consoleWarn.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('syncs across tabs via storage event', () => {
|
|
83
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
84
|
+
|
|
85
|
+
act(() => {
|
|
86
|
+
window.dispatchEvent(
|
|
87
|
+
new StorageEvent('storage', {
|
|
88
|
+
key,
|
|
89
|
+
newValue: JSON.stringify('from-other-tab'),
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.current[0]).toBe('from-other-tab');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('ignores storage events for different keys', () => {
|
|
98
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
99
|
+
|
|
100
|
+
act(() => {
|
|
101
|
+
window.dispatchEvent(
|
|
102
|
+
new StorageEvent('storage', {
|
|
103
|
+
key: 'other-key',
|
|
104
|
+
newValue: JSON.stringify('other-value'),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(result.current[0]).toBe(initialValue);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useState-like hook with localStorage persistence.
|
|
5
|
+
* Syncs state across tabs via storage events.
|
|
6
|
+
*
|
|
7
|
+
* @param key - localStorage key
|
|
8
|
+
* @param initialValue - Default value when key doesn't exist
|
|
9
|
+
*/
|
|
10
|
+
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
|
11
|
+
// Lazy initialization reads from localStorage on first render
|
|
12
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
13
|
+
if (typeof window === 'undefined') {
|
|
14
|
+
return initialValue;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const item = localStorage.getItem(key);
|
|
18
|
+
return item ? (JSON.parse(item) as T) : initialValue;
|
|
19
|
+
} catch {
|
|
20
|
+
console.warn(`Failed to parse localStorage key "${key}"`);
|
|
21
|
+
return initialValue;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Track the previous key to detect changes
|
|
26
|
+
const [prevKey, setPrevKey] = useState(key);
|
|
27
|
+
|
|
28
|
+
// Re-read from localStorage when key changes
|
|
29
|
+
if (key !== prevKey) {
|
|
30
|
+
setPrevKey(key);
|
|
31
|
+
try {
|
|
32
|
+
const item = localStorage.getItem(key);
|
|
33
|
+
const newValue = item ? (JSON.parse(item) as T) : initialValue;
|
|
34
|
+
setStoredValue(newValue);
|
|
35
|
+
} catch {
|
|
36
|
+
setStoredValue(initialValue);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sync to localStorage whenever value changes
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (typeof window === 'undefined') return;
|
|
43
|
+
try {
|
|
44
|
+
localStorage.setItem(key, JSON.stringify(storedValue));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.warn(`Failed to write localStorage key "${key}":`, error);
|
|
47
|
+
}
|
|
48
|
+
}, [key, storedValue]);
|
|
49
|
+
|
|
50
|
+
// Setter that supports both direct values and updater functions
|
|
51
|
+
const setValue = useCallback((value: T | ((prev: T) => T)) => {
|
|
52
|
+
setStoredValue((prev) => {
|
|
53
|
+
const nextValue = value instanceof Function ? value(prev) : value;
|
|
54
|
+
return nextValue;
|
|
55
|
+
});
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
// Sync across tabs via storage events
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (typeof window === 'undefined') return;
|
|
61
|
+
|
|
62
|
+
const handleStorage = (e: StorageEvent) => {
|
|
63
|
+
if (e.key === key && e.newValue !== null) {
|
|
64
|
+
try {
|
|
65
|
+
setStoredValue(JSON.parse(e.newValue) as T);
|
|
66
|
+
} catch {
|
|
67
|
+
// Ignore parse errors from other tabs
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
window.addEventListener('storage', handleStorage);
|
|
73
|
+
return () => window.removeEventListener('storage', handleStorage);
|
|
74
|
+
}, [key]);
|
|
75
|
+
|
|
76
|
+
return [storedValue, setValue];
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useSyncedFormData } from './useSyncedFormData';
|
|
5
|
+
|
|
6
|
+
describe('useSyncedFormData', () => {
|
|
7
|
+
it('returns source data initially', () => {
|
|
8
|
+
const { result } = renderHook(() => useSyncedFormData({ name: 'test' }, 'trigger-1'));
|
|
9
|
+
expect(result.current[0]).toEqual({ name: 'test' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('provides a setter function', () => {
|
|
13
|
+
const { result } = renderHook(() => useSyncedFormData({ name: 'initial' }, 'trigger-1'));
|
|
14
|
+
expect(typeof result.current[1]).toBe('function');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('syncs when trigger value changes', () => {
|
|
18
|
+
const { result, rerender } = renderHook(
|
|
19
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
20
|
+
{ initialProps: { sourceData: { name: 'v1' }, syncTrigger: 'trigger-1' } },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(result.current[0]).toEqual({ name: 'v1' });
|
|
24
|
+
|
|
25
|
+
// Trigger changes - should sync to new source data
|
|
26
|
+
rerender({ sourceData: { name: 'v2' }, syncTrigger: 'trigger-2' });
|
|
27
|
+
expect(result.current[0]).toEqual({ name: 'v2' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('syncs when source data changes with same trigger', () => {
|
|
31
|
+
const { result, rerender } = renderHook(
|
|
32
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
33
|
+
{ initialProps: { sourceData: { name: 'v1' }, syncTrigger: 'trigger-1' } },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Source data changes (trigger also changes per useEffect deps)
|
|
37
|
+
rerender({ sourceData: { name: 'v2' }, syncTrigger: 'trigger-1' });
|
|
38
|
+
// Per the implementation, it syncs when either trigger or sourceData changes
|
|
39
|
+
expect(result.current[0]).toEqual({ name: 'v2' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles dialog open/close pattern (boolean trigger)', () => {
|
|
43
|
+
const { result, rerender } = renderHook(
|
|
44
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
45
|
+
{ initialProps: { sourceData: { name: 'original' }, syncTrigger: false } },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Dialog opens
|
|
49
|
+
rerender({ sourceData: { name: 'original' }, syncTrigger: true });
|
|
50
|
+
expect(result.current[0]).toEqual({ name: 'original' });
|
|
51
|
+
|
|
52
|
+
// Dialog closes
|
|
53
|
+
rerender({ sourceData: { name: 'original' }, syncTrigger: false });
|
|
54
|
+
expect(result.current[0]).toEqual({ name: 'original' });
|
|
55
|
+
|
|
56
|
+
// Dialog reopens with new data
|
|
57
|
+
rerender({ sourceData: { name: 'new-data' }, syncTrigger: true });
|
|
58
|
+
expect(result.current[0]).toEqual({ name: 'new-data' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles ID-based trigger pattern', () => {
|
|
62
|
+
const { result, rerender } = renderHook(
|
|
63
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
64
|
+
{ initialProps: { sourceData: { name: 'item-1' }, syncTrigger: 'id-1' } },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Switch to item 2
|
|
68
|
+
rerender({ sourceData: { name: 'item-2' }, syncTrigger: 'id-2' });
|
|
69
|
+
expect(result.current[0]).toEqual({ name: 'item-2' });
|
|
70
|
+
|
|
71
|
+
// Switch back to item 1
|
|
72
|
+
rerender({ sourceData: { name: 'item-1' }, syncTrigger: 'id-1' });
|
|
73
|
+
expect(result.current[0]).toEqual({ name: 'item-1' });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Syncs form data when a trigger condition changes (e.g., dialog opens, ID changes).
|
|
5
|
+
* Similar to useSyncedState but trigger-based rather than active-state-based.
|
|
6
|
+
*
|
|
7
|
+
* @param sourceData - The source data to sync from
|
|
8
|
+
* @param syncTrigger - Value that triggers a re-sync when changed (e.g., item ID, dialog open state)
|
|
9
|
+
*/
|
|
10
|
+
export function useSyncedFormData<T>(sourceData: T, syncTrigger: unknown): [T, Dispatch<SetStateAction<T>>] {
|
|
11
|
+
const [formData, setFormData] = useState<T>(sourceData);
|
|
12
|
+
|
|
13
|
+
// Sync form data when trigger changes
|
|
14
|
+
// This is an intentional pattern for synchronizing external props to local state
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional sync pattern
|
|
17
|
+
setFormData(sourceData);
|
|
18
|
+
}, [syncTrigger, sourceData]);
|
|
19
|
+
|
|
20
|
+
return [formData, setFormData];
|
|
21
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useSyncedState } from './useSyncedState';
|
|
5
|
+
|
|
6
|
+
describe('useSyncedState', () => {
|
|
7
|
+
it('returns external value initially', () => {
|
|
8
|
+
const { result } = renderHook(() => useSyncedState('initial', false));
|
|
9
|
+
expect(result.current[0]).toBe('initial');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('updates local value via setter', () => {
|
|
13
|
+
const { result } = renderHook(() => useSyncedState('initial', false));
|
|
14
|
+
|
|
15
|
+
act(() => {
|
|
16
|
+
result.current[1]('local-change');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(result.current[0]).toBe('local-change');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('syncs with external value when not active', () => {
|
|
23
|
+
const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
|
|
24
|
+
initialProps: { externalValue: 'v1', isActive: false },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result.current[0]).toBe('v1');
|
|
28
|
+
|
|
29
|
+
// External value changes while not active - should sync
|
|
30
|
+
rerender({ externalValue: 'v2', isActive: false });
|
|
31
|
+
expect(result.current[0]).toBe('v2');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not sync with external value while active', () => {
|
|
35
|
+
const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
|
|
36
|
+
initialProps: { externalValue: 'v1', isActive: true },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Initial sync when becoming active
|
|
40
|
+
expect(result.current[0]).toBe('v1');
|
|
41
|
+
|
|
42
|
+
// User makes local edit while active
|
|
43
|
+
act(() => {
|
|
44
|
+
result.current[1]('local-edit');
|
|
45
|
+
});
|
|
46
|
+
expect(result.current[0]).toBe('local-edit');
|
|
47
|
+
|
|
48
|
+
// External value changes while still active - should NOT sync (preserve user edit)
|
|
49
|
+
rerender({ externalValue: 'v2', isActive: true });
|
|
50
|
+
expect(result.current[0]).toBe('local-edit');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('syncs when switching from active to inactive', () => {
|
|
54
|
+
const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
|
|
55
|
+
initialProps: { externalValue: 'v1', isActive: true },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Make local edit while active
|
|
59
|
+
act(() => {
|
|
60
|
+
result.current[1]('local-edit');
|
|
61
|
+
});
|
|
62
|
+
expect(result.current[0]).toBe('local-edit');
|
|
63
|
+
|
|
64
|
+
// External value changed while we were editing
|
|
65
|
+
rerender({ externalValue: 'v2', isActive: true });
|
|
66
|
+
expect(result.current[0]).toBe('local-edit');
|
|
67
|
+
|
|
68
|
+
// Switch to inactive - should sync with new external value
|
|
69
|
+
rerender({ externalValue: 'v2', isActive: false });
|
|
70
|
+
expect(result.current[0]).toBe('v2');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('syncs when switching from inactive to active (e.g., opening drawer)', () => {
|
|
74
|
+
const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
|
|
75
|
+
initialProps: { externalValue: '', isActive: false },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result.current[0]).toBe('');
|
|
79
|
+
|
|
80
|
+
// Simulate opening a drawer with new content - external value and isActive change together
|
|
81
|
+
rerender({ externalValue: 'section content', isActive: true });
|
|
82
|
+
|
|
83
|
+
// Should sync with the new external value when becoming active
|
|
84
|
+
expect(result.current[0]).toBe('section content');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('preserves local edits while active after initial sync', () => {
|
|
88
|
+
const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
|
|
89
|
+
initialProps: { externalValue: '', isActive: false },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Open drawer with content
|
|
93
|
+
rerender({ externalValue: 'initial content', isActive: true });
|
|
94
|
+
expect(result.current[0]).toBe('initial content');
|
|
95
|
+
|
|
96
|
+
// User edits locally
|
|
97
|
+
act(() => {
|
|
98
|
+
result.current[1]('user edited content');
|
|
99
|
+
});
|
|
100
|
+
expect(result.current[0]).toBe('user edited content');
|
|
101
|
+
|
|
102
|
+
// External value changes (e.g., from another source) - should NOT overwrite user edit
|
|
103
|
+
rerender({ externalValue: 'different content', isActive: true });
|
|
104
|
+
expect(result.current[0]).toBe('user edited content');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('works with complex objects', () => {
|
|
108
|
+
const initial = { name: 'test', value: 1 };
|
|
109
|
+
const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
|
|
110
|
+
initialProps: { externalValue: initial, isActive: false },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.current[0]).toEqual(initial);
|
|
114
|
+
|
|
115
|
+
const updated = { name: 'updated', value: 2 };
|
|
116
|
+
rerender({ externalValue: updated, isActive: false });
|
|
117
|
+
expect(result.current[0]).toEqual(updated);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Syncs local state with an external value, but only when not actively editing.
|
|
5
|
+
* Prevents external updates from overwriting user input mid-edit.
|
|
6
|
+
*
|
|
7
|
+
* @param externalValue - The external/server value to sync from
|
|
8
|
+
* @param isActive - Whether the user is currently editing (blocks sync)
|
|
9
|
+
*/
|
|
10
|
+
export function useSyncedState<T>(externalValue: T, isActive: boolean): [T, Dispatch<SetStateAction<T>>] {
|
|
11
|
+
const [localValue, setLocalValue] = useState<T>(externalValue);
|
|
12
|
+
const prevIsActiveRef = useRef(isActive);
|
|
13
|
+
|
|
14
|
+
// Sync external value to local state when:
|
|
15
|
+
// 1. Not active (external updates flow through)
|
|
16
|
+
// 2. Activity state changed (sync on open/close transitions)
|
|
17
|
+
// This is an intentional pattern for synchronizing external props to local state
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const activityChanged = prevIsActiveRef.current !== isActive;
|
|
20
|
+
|
|
21
|
+
if (!isActive || activityChanged) {
|
|
22
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional sync pattern
|
|
23
|
+
setLocalValue(externalValue);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
prevIsActiveRef.current = isActive;
|
|
27
|
+
}, [externalValue, isActive]);
|
|
28
|
+
|
|
29
|
+
return [localValue, setLocalValue];
|
|
30
|
+
}
|
package/templates/src/index.css
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import { api, ApiClientError } from '@/lib/api';
|
|
4
|
+
import {
|
|
5
|
+
mockFetchError,
|
|
6
|
+
mockFetchNetworkError,
|
|
7
|
+
mockFetchNoContent,
|
|
8
|
+
mockFetchSuccess,
|
|
9
|
+
mockFetchUnknownError,
|
|
10
|
+
} from '@/test';
|
|
4
11
|
|
|
5
12
|
describe('api client', () => {
|
|
6
|
-
const mockFetch = vi.fn();
|
|
7
|
-
const originalFetch = global.fetch;
|
|
8
|
-
|
|
9
13
|
beforeEach(() => {
|
|
10
|
-
global
|
|
11
|
-
vi.clearAllMocks();
|
|
14
|
+
vi.spyOn(global, 'fetch');
|
|
12
15
|
});
|
|
13
16
|
|
|
14
17
|
afterEach(() => {
|
|
15
|
-
|
|
18
|
+
vi.restoreAllMocks();
|
|
16
19
|
});
|
|
17
20
|
|
|
18
21
|
describe('HTTP methods', () => {
|
|
@@ -23,15 +26,11 @@ describe('api client', () => {
|
|
|
23
26
|
{ method: 'patch', httpMethod: 'PATCH' },
|
|
24
27
|
{ method: 'delete', httpMethod: 'DELETE' },
|
|
25
28
|
] as const)('$method makes $httpMethod request', async ({ method, httpMethod }) => {
|
|
26
|
-
|
|
27
|
-
ok: true,
|
|
28
|
-
status: 200,
|
|
29
|
-
json: () => Promise.resolve({ id: 1 }),
|
|
30
|
-
});
|
|
29
|
+
mockFetchSuccess({ id: 1 });
|
|
31
30
|
|
|
32
31
|
await api[method]('/test');
|
|
33
32
|
|
|
34
|
-
expect(
|
|
33
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
35
34
|
expect.stringContaining('/test'),
|
|
36
35
|
expect.objectContaining({ method: httpMethod }),
|
|
37
36
|
);
|
|
@@ -39,30 +38,22 @@ describe('api client', () => {
|
|
|
39
38
|
|
|
40
39
|
it('sends request body for POST/PUT/PATCH', async () => {
|
|
41
40
|
const body = { name: 'Test' };
|
|
42
|
-
|
|
43
|
-
ok: true,
|
|
44
|
-
status: 200,
|
|
45
|
-
json: () => Promise.resolve({ id: 1 }),
|
|
46
|
-
});
|
|
41
|
+
mockFetchSuccess({ id: 1 });
|
|
47
42
|
|
|
48
43
|
await api.post('/test', body);
|
|
49
44
|
|
|
50
|
-
expect(
|
|
45
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
51
46
|
expect.anything(),
|
|
52
47
|
expect.objectContaining({ body: JSON.stringify(body) }),
|
|
53
48
|
);
|
|
54
49
|
});
|
|
55
50
|
|
|
56
51
|
it('handles full URL without prepending base URL', async () => {
|
|
57
|
-
|
|
58
|
-
ok: true,
|
|
59
|
-
status: 200,
|
|
60
|
-
json: () => Promise.resolve({}),
|
|
61
|
-
});
|
|
52
|
+
mockFetchSuccess({});
|
|
62
53
|
|
|
63
54
|
await api.get('https://external.api/data');
|
|
64
55
|
|
|
65
|
-
expect(
|
|
56
|
+
expect(global.fetch).toHaveBeenCalledWith('https://external.api/data', expect.anything());
|
|
66
57
|
});
|
|
67
58
|
});
|
|
68
59
|
|
|
@@ -71,12 +62,7 @@ describe('api client', () => {
|
|
|
71
62
|
{ status: 404, message: 'Resource not found', hasJson: true },
|
|
72
63
|
{ status: 500, message: 'Internal Server Error', hasJson: false },
|
|
73
64
|
])('handles $status error response', async ({ status, message, hasJson }) => {
|
|
74
|
-
|
|
75
|
-
ok: false,
|
|
76
|
-
status,
|
|
77
|
-
statusText: message,
|
|
78
|
-
json: hasJson ? () => Promise.resolve({ message }) : () => Promise.reject(new Error('No JSON')),
|
|
79
|
-
});
|
|
65
|
+
mockFetchError(status, message, hasJson);
|
|
80
66
|
|
|
81
67
|
const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
|
|
82
68
|
|
|
@@ -87,7 +73,7 @@ describe('api client', () => {
|
|
|
87
73
|
|
|
88
74
|
it('handles timeout with TIMEOUT code', async () => {
|
|
89
75
|
vi.useFakeTimers();
|
|
90
|
-
|
|
76
|
+
vi.mocked(global.fetch).mockImplementationOnce(
|
|
91
77
|
() =>
|
|
92
78
|
new Promise((_, reject) => {
|
|
93
79
|
const error = new Error('Aborted');
|
|
@@ -106,20 +92,26 @@ describe('api client', () => {
|
|
|
106
92
|
vi.useRealTimers();
|
|
107
93
|
});
|
|
108
94
|
|
|
109
|
-
it
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
95
|
+
it('handles NETWORK_ERROR', async () => {
|
|
96
|
+
mockFetchNetworkError();
|
|
97
|
+
|
|
98
|
+
const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
|
|
99
|
+
|
|
100
|
+
expect(error).toBeInstanceOf(ApiClientError);
|
|
101
|
+
expect(error.code).toBe('NETWORK_ERROR');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles UNKNOWN errors', async () => {
|
|
105
|
+
mockFetchUnknownError('string error');
|
|
114
106
|
|
|
115
107
|
const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
|
|
116
108
|
|
|
117
109
|
expect(error).toBeInstanceOf(ApiClientError);
|
|
118
|
-
expect(error.code).toBe(
|
|
110
|
+
expect(error.code).toBe('UNKNOWN');
|
|
119
111
|
});
|
|
120
112
|
|
|
121
113
|
it('returns undefined for 204 No Content', async () => {
|
|
122
|
-
|
|
114
|
+
mockFetchNoContent();
|
|
123
115
|
|
|
124
116
|
const result = await api.delete('/test/1');
|
|
125
117
|
|
package/templates/src/lib/api.ts
CHANGED
|
@@ -5,13 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ApiError } from '@/types/api';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
* API configuration.
|
|
10
|
-
*/
|
|
11
|
-
export const API_CONFIG = {
|
|
12
|
-
baseUrl: import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com',
|
|
13
|
-
timeout: 30000,
|
|
14
|
-
} as const;
|
|
8
|
+
import { API_CONFIG } from './config';
|
|
15
9
|
|
|
16
10
|
/**
|
|
17
11
|
* Custom API error class
|
|
@@ -1,15 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Application configuration.
|
|
3
3
|
* Centralized config for feature flags, etc.
|
|
4
|
+
*
|
|
5
|
+
* All environment variables flow through the validated `env` object from env.ts.
|
|
4
6
|
*/
|
|
5
7
|
|
|
8
|
+
import { env } from './env';
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// App Configuration
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
6
14
|
export const APP_CONFIG = {
|
|
7
|
-
name:
|
|
8
|
-
url:
|
|
15
|
+
name: env.VITE_APP_NAME,
|
|
16
|
+
url: env.VITE_APP_URL,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// API Configuration
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
export const API_CONFIG = {
|
|
24
|
+
baseUrl: env.VITE_API_URL,
|
|
25
|
+
timeout: 30000,
|
|
9
26
|
} as const;
|
|
10
27
|
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Sentry Configuration
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
11
32
|
export const SENTRY_CONFIG = {
|
|
12
|
-
enabled:
|
|
13
|
-
dsn:
|
|
33
|
+
enabled: env.VITE_SENTRY_ENABLED,
|
|
34
|
+
dsn: env.VITE_SENTRY_DSN,
|
|
35
|
+
environment: env.MODE,
|
|
14
36
|
tracesSampleRate: 0.1,
|
|
15
37
|
} as const;
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Clerk Configuration
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export const CLERK_CONFIG = {
|
|
44
|
+
publishableKey: env.VITE_CLERK_PUBLISHABLE_KEY,
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// Supabase Configuration
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
export const SUPABASE_CONFIG = {
|
|
52
|
+
url: env.VITE_SUPABASE_DATABASE_URL,
|
|
53
|
+
anonKey: env.VITE_SUPABASE_ANON_KEY,
|
|
54
|
+
/** Whether both URL and anon key are configured */
|
|
55
|
+
isConfigured: Boolean(env.VITE_SUPABASE_DATABASE_URL && env.VITE_SUPABASE_ANON_KEY),
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Performance Configuration
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
export const PERFORMANCE_CONFIG = {
|
|
63
|
+
/** Enable performance tracking in dev or when VITE_PERF_TEST is set */
|
|
64
|
+
enabled: env.DEV || env.VITE_PERF_TEST,
|
|
65
|
+
} as const;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized timing constants for consistent UX across the application.
|
|
3
|
+
* Use these instead of magic numbers to ensure consistency.
|
|
4
|
+
*/
|
|
5
|
+
export const TIMING = {
|
|
6
|
+
/** Debounce delay for user input in milliseconds */
|
|
7
|
+
DEBOUNCE_DELAY: 300,
|
|
8
|
+
/** Duration to show "Copied!" feedback in milliseconds */
|
|
9
|
+
COPY_FEEDBACK_DURATION: 2000,
|
|
10
|
+
} as const;
|