@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
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { supabaseHandlers } from './supabase';
|
|
1
2
|
import { todosHandlers } from './todos';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* All MSW request handlers.
|
|
5
6
|
* Add new feature handlers here as the application grows.
|
|
6
7
|
*/
|
|
7
|
-
export const handlers = [...todosHandlers];
|
|
8
|
+
export const handlers = [...todosHandlers, ...supabaseHandlers];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSW handlers for Supabase PostgREST API.
|
|
3
|
+
*
|
|
4
|
+
* Minimal handlers for testing. Extend as needed.
|
|
5
|
+
* @see https://postgrest.org/en/stable/references/api.html
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { http, HttpResponse } from 'msw';
|
|
9
|
+
|
|
10
|
+
import { MOCK_SUPABASE_URL, MOCK_USER } from '../constants';
|
|
11
|
+
import { createProfile, mockProfiles } from '../fixtures/profiles';
|
|
12
|
+
|
|
13
|
+
export const supabaseHandlers = [
|
|
14
|
+
// GET /rest/v1/profiles
|
|
15
|
+
http.get(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, ({ request }) => {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
const idFilter = url.searchParams.get('id');
|
|
18
|
+
const isSingle = request.headers.get('Accept')?.includes('vnd.pgrst.object');
|
|
19
|
+
|
|
20
|
+
let profiles = [...mockProfiles];
|
|
21
|
+
|
|
22
|
+
// Filter by ID if specified (eq.user_123 format)
|
|
23
|
+
if (idFilter?.startsWith('eq.')) {
|
|
24
|
+
const id = idFilter.replace('eq.', '');
|
|
25
|
+
profiles = profiles.filter((p) => p.id === id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Simulate RLS: only return current user's data
|
|
29
|
+
profiles = profiles.filter((p) => p.id === MOCK_USER.id);
|
|
30
|
+
|
|
31
|
+
if (isSingle) {
|
|
32
|
+
return profiles.length > 0
|
|
33
|
+
? HttpResponse.json(profiles[0])
|
|
34
|
+
: HttpResponse.json({ message: 'No rows found', code: 'PGRST116' }, { status: 406 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return HttpResponse.json(profiles);
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
// POST /rest/v1/profiles
|
|
41
|
+
http.post(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, async ({ request }) => {
|
|
42
|
+
const body = await request.json();
|
|
43
|
+
const profile = createProfile(body as Record<string, unknown>);
|
|
44
|
+
|
|
45
|
+
return request.headers.get('Prefer')?.includes('return=representation')
|
|
46
|
+
? HttpResponse.json(profile, { status: 201 })
|
|
47
|
+
: new HttpResponse(null, { status: 201 });
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
// PATCH /rest/v1/profiles
|
|
51
|
+
http.patch(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, async ({ request }) => {
|
|
52
|
+
const body = await request.json();
|
|
53
|
+
const profile = { ...mockProfiles[0], ...(body as Record<string, unknown>), updated_at: new Date().toISOString() };
|
|
54
|
+
|
|
55
|
+
return request.headers.get('Prefer')?.includes('return=representation')
|
|
56
|
+
? HttpResponse.json(profile)
|
|
57
|
+
: new HttpResponse(null, { status: 204 });
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
// DELETE /rest/v1/profiles
|
|
61
|
+
http.delete(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, () => {
|
|
62
|
+
return new HttpResponse(null, { status: 204 });
|
|
63
|
+
}),
|
|
64
|
+
];
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { render, resetClerkMocks, createProfile } from '@/test';
|
|
6
|
+
|
|
7
|
+
import { ProfilePage } from './Profile';
|
|
8
|
+
|
|
9
|
+
// Mock the hooks
|
|
10
|
+
const mockMutate = vi.fn();
|
|
11
|
+
const mockRefetch = vi.fn();
|
|
12
|
+
|
|
13
|
+
// Default mock profile
|
|
14
|
+
const defaultProfile = createProfile({
|
|
15
|
+
full_name: 'Test User',
|
|
16
|
+
email: 'test@example.com',
|
|
17
|
+
avatar_url: 'https://example.com/avatar.jpg',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Hook mock state
|
|
21
|
+
let mockProfileState: {
|
|
22
|
+
profile: ReturnType<typeof createProfile> | null;
|
|
23
|
+
isLoading: boolean;
|
|
24
|
+
error: Error | null;
|
|
25
|
+
exists: boolean;
|
|
26
|
+
isFetching: boolean;
|
|
27
|
+
refetch: typeof mockRefetch;
|
|
28
|
+
} = {
|
|
29
|
+
profile: defaultProfile,
|
|
30
|
+
isLoading: false,
|
|
31
|
+
error: null,
|
|
32
|
+
exists: true,
|
|
33
|
+
isFetching: false,
|
|
34
|
+
refetch: mockRefetch,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let mockUpdateState = {
|
|
38
|
+
mutate: mockMutate,
|
|
39
|
+
isPending: false,
|
|
40
|
+
error: null as Error | null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
vi.mock('@/hooks', async () => {
|
|
44
|
+
const actual = await vi.importActual('@/hooks');
|
|
45
|
+
return {
|
|
46
|
+
...actual,
|
|
47
|
+
useProfile: vi.fn(() => mockProfileState),
|
|
48
|
+
useUpdateProfile: vi.fn(() => mockUpdateState),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('ProfilePage', () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
resetClerkMocks();
|
|
55
|
+
mockMutate.mockClear();
|
|
56
|
+
mockRefetch.mockClear();
|
|
57
|
+
|
|
58
|
+
// Reset mock state to defaults
|
|
59
|
+
mockProfileState = {
|
|
60
|
+
profile: defaultProfile,
|
|
61
|
+
isLoading: false,
|
|
62
|
+
error: null,
|
|
63
|
+
exists: true,
|
|
64
|
+
isFetching: false,
|
|
65
|
+
refetch: mockRefetch,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
mockUpdateState = {
|
|
69
|
+
mutate: mockMutate,
|
|
70
|
+
isPending: false,
|
|
71
|
+
error: null,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders profile page with title and description', () => {
|
|
76
|
+
render(<ProfilePage />);
|
|
77
|
+
// Verify both card title and description are present
|
|
78
|
+
expect(screen.getByText('Your Profile')).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText(/manage your profile information/i)).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('shows loading skeleton when profile is loading', () => {
|
|
83
|
+
mockProfileState.isLoading = true;
|
|
84
|
+
mockProfileState.profile = null;
|
|
85
|
+
|
|
86
|
+
const { container } = render(<ProfilePage />);
|
|
87
|
+
|
|
88
|
+
// Should show skeleton elements
|
|
89
|
+
const skeletons = container.querySelectorAll('[class*="animate-pulse"]');
|
|
90
|
+
expect(skeletons.length).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('displays profile data when loaded', () => {
|
|
94
|
+
render(<ProfilePage />);
|
|
95
|
+
|
|
96
|
+
// Name appears in header and name field - use getAllByText
|
|
97
|
+
expect(screen.getAllByText('Test User').length).toBeGreaterThan(0);
|
|
98
|
+
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByAltText(/profile avatar/i)).toHaveAttribute('src', 'https://example.com/avatar.jpg');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('shows avatar fallback when no avatar_url', () => {
|
|
103
|
+
mockProfileState.profile = createProfile({
|
|
104
|
+
full_name: 'John Doe',
|
|
105
|
+
avatar_url: null,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
render(<ProfilePage />);
|
|
109
|
+
|
|
110
|
+
// Should show first letter of name as fallback
|
|
111
|
+
expect(screen.getByText('J')).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('shows email initial when no name or avatar', () => {
|
|
115
|
+
mockProfileState.profile = createProfile({
|
|
116
|
+
full_name: null,
|
|
117
|
+
email: 'user@example.com',
|
|
118
|
+
avatar_url: null,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
render(<ProfilePage />);
|
|
122
|
+
|
|
123
|
+
// Should show first letter of email as fallback
|
|
124
|
+
expect(screen.getByText('U')).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('shows error state and retry button on fetch failure', async () => {
|
|
128
|
+
const user = userEvent.setup();
|
|
129
|
+
mockProfileState.error = new Error('Network error');
|
|
130
|
+
mockProfileState.profile = null;
|
|
131
|
+
|
|
132
|
+
render(<ProfilePage />);
|
|
133
|
+
|
|
134
|
+
expect(screen.getByText(/failed to load profile/i)).toBeInTheDocument();
|
|
135
|
+
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
|
136
|
+
|
|
137
|
+
const retryButton = screen.getByRole('button', { name: /try again/i });
|
|
138
|
+
await user.click(retryButton);
|
|
139
|
+
|
|
140
|
+
expect(mockRefetch).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('allows editing name field', async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
render(<ProfilePage />);
|
|
146
|
+
|
|
147
|
+
// Click edit button
|
|
148
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
149
|
+
await user.click(editButton);
|
|
150
|
+
|
|
151
|
+
// Should show input field
|
|
152
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
153
|
+
expect(input).toBeInTheDocument();
|
|
154
|
+
expect(input).toHaveValue('Test User');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('saves updated name via useUpdateProfile', async () => {
|
|
158
|
+
const user = userEvent.setup();
|
|
159
|
+
|
|
160
|
+
// Mock successful mutation
|
|
161
|
+
mockMutate.mockImplementation((_data, options) => {
|
|
162
|
+
options?.onSuccess?.();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
render(<ProfilePage />);
|
|
166
|
+
|
|
167
|
+
// Click edit
|
|
168
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
169
|
+
|
|
170
|
+
// Change name
|
|
171
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
172
|
+
await user.clear(input);
|
|
173
|
+
await user.type(input, 'New Name');
|
|
174
|
+
|
|
175
|
+
// Click save
|
|
176
|
+
await user.click(screen.getByRole('button', { name: /save/i }));
|
|
177
|
+
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(mockMutate).toHaveBeenCalledWith({ full_name: 'New Name' }, expect.any(Object));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('submits form on Enter key', async () => {
|
|
184
|
+
const user = userEvent.setup();
|
|
185
|
+
|
|
186
|
+
// Mock successful mutation
|
|
187
|
+
mockMutate.mockImplementation((_data, options) => {
|
|
188
|
+
options?.onSuccess?.();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
render(<ProfilePage />);
|
|
192
|
+
|
|
193
|
+
// Enter edit mode
|
|
194
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
195
|
+
|
|
196
|
+
// Change name and press Enter
|
|
197
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
198
|
+
await user.clear(input);
|
|
199
|
+
await user.type(input, 'Entered Name{Enter}');
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(mockMutate).toHaveBeenCalledWith({ full_name: 'Entered Name' }, expect.any(Object));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('shows saving state on button while updating', async () => {
|
|
207
|
+
const user = userEvent.setup();
|
|
208
|
+
mockUpdateState.isPending = true;
|
|
209
|
+
|
|
210
|
+
render(<ProfilePage />);
|
|
211
|
+
|
|
212
|
+
// Enter edit mode
|
|
213
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
214
|
+
|
|
215
|
+
// Should show saving state
|
|
216
|
+
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('cancel button reverts changes', async () => {
|
|
220
|
+
const user = userEvent.setup();
|
|
221
|
+
render(<ProfilePage />);
|
|
222
|
+
|
|
223
|
+
// Click edit
|
|
224
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
225
|
+
|
|
226
|
+
// Change name
|
|
227
|
+
const input = screen.getByRole('textbox', { name: /full name/i });
|
|
228
|
+
await user.clear(input);
|
|
229
|
+
await user.type(input, 'Changed Name');
|
|
230
|
+
|
|
231
|
+
// Click cancel
|
|
232
|
+
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
|
233
|
+
|
|
234
|
+
// Should exit edit mode and show original name (appears multiple times)
|
|
235
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
236
|
+
expect(screen.getAllByText('Test User').length).toBeGreaterThan(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('shows error message when update fails', async () => {
|
|
240
|
+
const user = userEvent.setup();
|
|
241
|
+
mockUpdateState.error = new Error('Update failed');
|
|
242
|
+
|
|
243
|
+
render(<ProfilePage />);
|
|
244
|
+
|
|
245
|
+
// Enter edit mode
|
|
246
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
247
|
+
|
|
248
|
+
// Should show error message
|
|
249
|
+
expect(screen.getByText(/failed to update/i)).toBeInTheDocument();
|
|
250
|
+
expect(screen.getByText(/update failed/i)).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('shows "Not set" when name is empty', () => {
|
|
254
|
+
mockProfileState.profile = createProfile({
|
|
255
|
+
full_name: null,
|
|
256
|
+
email: 'test@example.com',
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
render(<ProfilePage />);
|
|
260
|
+
|
|
261
|
+
expect(screen.getByText(/not set/i)).toBeInTheDocument();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Trans, useLingui } from '@lingui/react/macro';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { SEO } from '@/components/shared';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { Input } from '@/components/ui/input';
|
|
8
|
+
import { Label } from '@/components/ui/label';
|
|
9
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
10
|
+
import { useProfile, useSyncedState, useUpdateProfile } from '@/hooks';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Profile page demonstrating Supabase database integration.
|
|
14
|
+
* Shows current user's profile with ability to edit the name.
|
|
15
|
+
*/
|
|
16
|
+
export function ProfilePage() {
|
|
17
|
+
const { t } = useLingui();
|
|
18
|
+
const { profile, isLoading, error, refetch } = useProfile();
|
|
19
|
+
const updateProfile = useUpdateProfile();
|
|
20
|
+
|
|
21
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
22
|
+
// Sync name with profile, blocking sync while editing
|
|
23
|
+
const [name, setName] = useSyncedState(profile?.full_name ?? '', isEditing);
|
|
24
|
+
|
|
25
|
+
// Extract error messages for i18n (avoids object property access in Trans)
|
|
26
|
+
const fetchErrorMessage = error?.message;
|
|
27
|
+
const updateErrorMessage = updateProfile.error?.message;
|
|
28
|
+
|
|
29
|
+
const handleEdit = () => {
|
|
30
|
+
setIsEditing(true);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleCancel = () => {
|
|
34
|
+
setName(profile?.full_name ?? '');
|
|
35
|
+
setIsEditing(false);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleSave = () => {
|
|
39
|
+
updateProfile.mutate(
|
|
40
|
+
{ full_name: name },
|
|
41
|
+
{
|
|
42
|
+
onSuccess: () => {
|
|
43
|
+
setIsEditing(false);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
handleSave();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="container mx-auto max-w-lg px-4 py-8">
|
|
56
|
+
<SEO
|
|
57
|
+
title={t({ message: 'Profile', comment: 'Profile page title for SEO' })}
|
|
58
|
+
description={t({
|
|
59
|
+
message: 'Manage your profile information',
|
|
60
|
+
comment: 'Profile page meta description for SEO',
|
|
61
|
+
})}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
<Card>
|
|
65
|
+
<CardHeader>
|
|
66
|
+
<CardTitle>
|
|
67
|
+
<Trans comment="Profile page card title">Your Profile</Trans>
|
|
68
|
+
</CardTitle>
|
|
69
|
+
<CardDescription>
|
|
70
|
+
<Trans comment="Profile page card description">Manage your profile information stored in Supabase</Trans>
|
|
71
|
+
</CardDescription>
|
|
72
|
+
</CardHeader>
|
|
73
|
+
<CardContent>
|
|
74
|
+
{isLoading && (
|
|
75
|
+
<div className="space-y-4">
|
|
76
|
+
<div className="flex items-center gap-4">
|
|
77
|
+
<Skeleton className="size-16 rounded-full" />
|
|
78
|
+
<div className="space-y-2">
|
|
79
|
+
<Skeleton className="h-4 w-32" />
|
|
80
|
+
<Skeleton className="h-4 w-48" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<Skeleton className="h-10 w-full" />
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{error && (
|
|
88
|
+
<div className="space-y-4">
|
|
89
|
+
<p className="text-destructive text-sm" role="alert">
|
|
90
|
+
<Trans comment="Error message when profile fails to load">
|
|
91
|
+
Failed to load profile: {fetchErrorMessage}
|
|
92
|
+
</Trans>
|
|
93
|
+
</p>
|
|
94
|
+
<Button variant="outline" onClick={() => refetch()}>
|
|
95
|
+
<Trans comment="Retry button label">Try Again</Trans>
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{profile && (
|
|
101
|
+
<div className="space-y-6">
|
|
102
|
+
{/* Avatar and Email */}
|
|
103
|
+
<div className="flex items-center gap-4">
|
|
104
|
+
{profile.avatar_url ? (
|
|
105
|
+
<img
|
|
106
|
+
src={profile.avatar_url}
|
|
107
|
+
alt={t({ message: 'Profile avatar', comment: 'Alt text for profile avatar image' })}
|
|
108
|
+
className="size-16 rounded-full object-cover"
|
|
109
|
+
/>
|
|
110
|
+
) : (
|
|
111
|
+
<div className="bg-muted flex size-16 items-center justify-center rounded-full">
|
|
112
|
+
<span className="text-muted-foreground text-xl">
|
|
113
|
+
{profile.full_name?.[0]?.toUpperCase() ?? profile.email[0].toUpperCase()}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
<div>
|
|
118
|
+
<p className="font-medium">{profile.full_name ?? profile.email}</p>
|
|
119
|
+
<p className="text-muted-foreground text-sm">{profile.email}</p>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Name Editor */}
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<Label htmlFor="full-name">
|
|
126
|
+
<Trans comment="Full name field label">Full Name</Trans>
|
|
127
|
+
</Label>
|
|
128
|
+
{isEditing ? (
|
|
129
|
+
<form onSubmit={handleSubmit} className="space-y-2">
|
|
130
|
+
<Input
|
|
131
|
+
id="full-name"
|
|
132
|
+
value={name}
|
|
133
|
+
onChange={(e) => setName(e.target.value)}
|
|
134
|
+
placeholder={t({ message: 'Enter your name', comment: 'Name input placeholder' })}
|
|
135
|
+
/>
|
|
136
|
+
<div className="flex gap-2">
|
|
137
|
+
<Button type="submit" disabled={updateProfile.isPending}>
|
|
138
|
+
{updateProfile.isPending ? (
|
|
139
|
+
<Trans comment="Save button loading state">Saving...</Trans>
|
|
140
|
+
) : (
|
|
141
|
+
<Trans comment="Save button label">Save</Trans>
|
|
142
|
+
)}
|
|
143
|
+
</Button>
|
|
144
|
+
<Button type="button" variant="outline" onClick={handleCancel} disabled={updateProfile.isPending}>
|
|
145
|
+
<Trans comment="Cancel button label">Cancel</Trans>
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
{updateProfile.error && (
|
|
149
|
+
<p className="text-destructive text-sm" role="alert">
|
|
150
|
+
<Trans comment="Error message when update fails">Failed to update: {updateErrorMessage}</Trans>
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
</form>
|
|
154
|
+
) : (
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<p className="text-muted-foreground">
|
|
157
|
+
{profile.full_name || <Trans comment="Placeholder when no name is set">Not set</Trans>}
|
|
158
|
+
</p>
|
|
159
|
+
<Button variant="ghost" size="sm" onClick={handleEdit}>
|
|
160
|
+
<Trans comment="Edit button label">Edit</Trans>
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</CardContent>
|
|
168
|
+
</Card>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import { devtools, persist } from 'zustand/middleware';
|
|
3
3
|
|
|
4
|
+
import { createSelectors } from '@/lib/createSelectors';
|
|
5
|
+
import { env } from '@/lib/env';
|
|
4
6
|
import { STORAGE_KEYS } from '@/lib/storageKeys';
|
|
5
7
|
|
|
6
8
|
export type Theme = 'light' | 'dark' | 'system';
|
|
@@ -31,18 +33,25 @@ function getSystemTheme(): 'light' | 'dark' {
|
|
|
31
33
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
/** Current schema version for preferences store persistence */
|
|
37
|
+
const PREFERENCES_STORE_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
const usePreferencesStoreBase = create<PreferencesState>()(
|
|
35
40
|
devtools(
|
|
36
41
|
persist(
|
|
37
42
|
(set, get) => ({
|
|
38
43
|
...initialState,
|
|
39
|
-
setTheme: (theme) => set({ theme }),
|
|
44
|
+
setTheme: (theme) => set({ theme }, undefined, 'preferences/setTheme'),
|
|
40
45
|
toggleTheme: () =>
|
|
41
|
-
set(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
set(
|
|
47
|
+
(state) => {
|
|
48
|
+
const resolved = state.theme === 'system' ? getSystemTheme() : state.theme;
|
|
49
|
+
return { theme: resolved === 'light' ? 'dark' : 'light' };
|
|
50
|
+
},
|
|
51
|
+
undefined,
|
|
52
|
+
'preferences/toggleTheme',
|
|
53
|
+
),
|
|
54
|
+
reset: () => set(initialState, undefined, 'preferences/reset'),
|
|
46
55
|
getResolvedTheme: () => {
|
|
47
56
|
const { theme } = get();
|
|
48
57
|
return theme === 'system' ? getSystemTheme() : theme;
|
|
@@ -50,13 +59,30 @@ export const usePreferencesStore = create<PreferencesState>()(
|
|
|
50
59
|
}),
|
|
51
60
|
{
|
|
52
61
|
name: STORAGE_KEYS.preferences,
|
|
53
|
-
|
|
62
|
+
version: PREFERENCES_STORE_VERSION,
|
|
63
|
+
partialize: (state): Preferences => ({ theme: state.theme }),
|
|
64
|
+
migrate: (persisted, version) => {
|
|
65
|
+
const state = persisted as Preferences;
|
|
66
|
+
if (version === 0) {
|
|
67
|
+
// v0 → v1: No changes needed, establishes baseline for future migrations
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
return state;
|
|
71
|
+
},
|
|
72
|
+
onRehydrateStorage: () => (_state, error) => {
|
|
73
|
+
if (error) {
|
|
74
|
+
console.error('Failed to hydrate preferences store:', error);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
54
77
|
},
|
|
55
78
|
),
|
|
56
|
-
{ name: 'preferences' },
|
|
79
|
+
{ name: 'preferences', enabled: env.DEV },
|
|
57
80
|
),
|
|
58
81
|
);
|
|
59
82
|
|
|
83
|
+
/** Preferences store with auto-generated selectors */
|
|
84
|
+
export const usePreferencesStore = createSelectors(usePreferencesStoreBase);
|
|
85
|
+
|
|
60
86
|
/**
|
|
61
87
|
* Initialize multi-tab sync for preferences.
|
|
62
88
|
* Call this once at app startup.
|