@djangocfg/layouts 2.1.355 → 2.1.357

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.
Files changed (75) hide show
  1. package/package.json +17 -17
  2. package/src/layouts/AdminLayout/AdminLayout.tsx +2 -1
  3. package/src/layouts/AppLayout/AppLayout.tsx +35 -15
  4. package/src/layouts/AppLayout/BaseApp.tsx +2 -2
  5. package/src/layouts/AuthLayout/AuthLayout.tsx +26 -19
  6. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +10 -4
  7. package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +11 -5
  8. package/src/layouts/AuthLayout/components/shared/AuthContainer.tsx +10 -10
  9. package/src/layouts/AuthLayout/components/shared/AuthDivider.tsx +11 -5
  10. package/src/layouts/AuthLayout/components/shared/AuthError.tsx +10 -5
  11. package/src/layouts/AuthLayout/components/shared/AuthFooter.tsx +11 -5
  12. package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +10 -10
  13. package/src/layouts/AuthLayout/components/shared/AuthLink.tsx +11 -5
  14. package/src/layouts/AuthLayout/components/shared/AuthOTPInput.tsx +28 -20
  15. package/src/layouts/AuthLayout/components/shared/TermsCheckbox.tsx +11 -5
  16. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +12 -4
  17. package/src/layouts/AuthLayout/components/steps/OTPStep.tsx +9 -4
  18. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupComplete.tsx +12 -5
  19. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupLoading.tsx +9 -4
  20. package/src/layouts/AuthLayout/components/steps/SetupStep/SetupQRCode.tsx +11 -5
  21. package/src/layouts/AuthLayout/components/steps/SetupStep/index.tsx +15 -5
  22. package/src/layouts/AuthLayout/components/steps/TwoFactorStep.tsx +9 -4
  23. package/src/layouts/AuthLayout/context.tsx +35 -13
  24. package/src/layouts/AuthLayout/shells/AuthShell.tsx +11 -4
  25. package/src/layouts/AuthLayout/shells/CenteredShell.tsx +10 -4
  26. package/src/layouts/AuthLayout/shells/SplitShell.tsx +10 -4
  27. package/src/layouts/AuthLayout/shells/context.tsx +16 -5
  28. package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -247
  29. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +115 -426
  30. package/src/layouts/{_components → PrivateLayout/components}/PrivateSidebarAccount.tsx +40 -19
  31. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +165 -0
  32. package/src/layouts/{_components → PrivateLayout/components}/SidebarFeatured.tsx +2 -2
  33. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +189 -0
  34. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +137 -0
  35. package/src/layouts/PrivateLayout/components/SidebarSlots.tsx +71 -0
  36. package/src/layouts/PrivateLayout/components/index.ts +4 -0
  37. package/src/layouts/PrivateLayout/context.tsx +211 -0
  38. package/src/layouts/PrivateLayout/density.ts +48 -0
  39. package/src/layouts/PrivateLayout/hooks/index.ts +13 -0
  40. package/src/layouts/PrivateLayout/hooks/useAuthGuard.ts +54 -0
  41. package/src/layouts/PrivateLayout/hooks/useHoverExpand.ts +103 -0
  42. package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +113 -0
  43. package/src/layouts/PrivateLayout/hooks/useShellVisualState.ts +207 -0
  44. package/src/layouts/PrivateLayout/hooks/useSidebarKeyboard.ts +115 -0
  45. package/src/layouts/PrivateLayout/index.ts +2 -2
  46. package/src/layouts/PrivateLayout/types.ts +187 -0
  47. package/src/layouts/ProfileLayout/ProfileLayout.tsx +44 -183
  48. package/src/layouts/ProfileLayout/README.md +58 -0
  49. package/src/layouts/ProfileLayout/components/ApiKeySection/ApiKeySection.tsx +197 -0
  50. package/src/layouts/ProfileLayout/components/ApiKeySection/context.tsx +159 -0
  51. package/src/layouts/ProfileLayout/components/ApiKeySection/index.ts +3 -0
  52. package/src/layouts/ProfileLayout/components/ProfileHeader.tsx +110 -0
  53. package/src/layouts/ProfileLayout/components/ProfileTab.tsx +29 -0
  54. package/src/layouts/ProfileLayout/components/{TwoFactorSection.tsx → TwoFactorSection/TwoFactorSection.tsx} +1 -1
  55. package/src/layouts/ProfileLayout/components/TwoFactorSection/index.ts +1 -0
  56. package/src/layouts/ProfileLayout/components/index.ts +4 -2
  57. package/src/layouts/ProfileLayout/context.tsx +4 -6
  58. package/src/layouts/ProfileLayout/hooks/index.ts +2 -0
  59. package/src/layouts/ProfileLayout/hooks/useProfileTabs.ts +43 -0
  60. package/src/layouts/ProfileLayout/index.ts +6 -3
  61. package/src/layouts/ProfileLayout/types.ts +37 -0
  62. package/src/layouts/{_components → PublicLayout/components}/UserMenu.tsx +3 -3
  63. package/src/layouts/PublicLayout/components/index.ts +4 -0
  64. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +12 -2
  65. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -1
  66. package/src/layouts/PublicLayout/primitives/NavActions.tsx +44 -3
  67. package/src/layouts/PublicLayout/primitives/NavBrand.tsx +4 -2
  68. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +42 -2
  69. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +1 -1
  70. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +60 -1
  71. package/src/layouts/_components/index.ts +2 -7
  72. package/src/layouts/index.ts +9 -4
  73. package/src/layouts/ProfileLayout/__tests__/TwoFactorSection.test.tsx +0 -234
  74. package/src/layouts/ProfileLayout/components/ProfileForm.tsx +0 -198
  75. /package/src/layouts/{_components → PublicLayout/components}/UserAvatar.tsx +0 -0
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { ChevronDown } from 'lucide-react';
4
- import React from 'react';
4
+ import React, { memo } from 'react';
5
5
 
6
6
  import { Button, Link } from '@djangocfg/ui-core/components';
7
7
  import { cn } from '@djangocfg/ui-core/lib';
@@ -48,7 +48,7 @@ function subMenuLinkCls(active: boolean) {
48
48
  const popoverCls =
49
49
  'absolute left-0 top-full mt-1 z-[1200] min-w-[14.5rem] rounded-xl border border-border/70 bg-background/95 backdrop-blur-sm p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.05),0_6px_18px_rgba(0,0,0,0.035)] dark:shadow-[0_6px_20px_rgba(0,0,0,0.12)]';
50
50
 
51
- export function NavDesktopItems({
51
+ function NavDesktopItemsRaw({
52
52
  items,
53
53
  maxVisible,
54
54
  isActivePath,
@@ -245,3 +245,43 @@ export function NavDesktopItems({
245
245
  </div>
246
246
  );
247
247
  }
248
+
249
+ /**
250
+ * Shallow equality for NavigationItem arrays. Compares href, label, external
251
+ * and one-level-deep sub-items so that nav label/badge changes re-render,
252
+ * but object-reference churn from parent is ignored.
253
+ */
254
+ function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
255
+ if (a === b) return true;
256
+ if (a.length !== b.length) return false;
257
+ return a.every((ai, i) => {
258
+ const bi = b[i];
259
+ if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
260
+ if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
261
+ if (ai.items) {
262
+ return ai.items.every((sub, j) => {
263
+ const bsub = bi.items![j];
264
+ return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
265
+ });
266
+ }
267
+ return true;
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Memoised desktop navigation row. Re-renders only when:
273
+ * - the items array content changes (via navItemsShallowEqual),
274
+ * - dropdown state object changes,
275
+ * - maxVisible or renderDesktopDropdown changes.
276
+ * Parent re-renders with identical navigation data are skipped entirely.
277
+ */
278
+ export const NavDesktopItems = memo(NavDesktopItemsRaw, (prev, next) => {
279
+ return (
280
+ prev.maxVisible === next.maxVisible &&
281
+ prev.isActivePath === next.isActivePath &&
282
+ prev.isGroupActive === next.isGroupActive &&
283
+ prev.dropdown === next.dropdown &&
284
+ prev.renderDesktopDropdown === next.renderDesktopDropdown &&
285
+ navItemsShallowEqual(prev.items, next.items)
286
+ );
287
+ });
@@ -17,7 +17,7 @@ import { useBodyScrollLock, useIsTabletOrBelow } from '@djangocfg/ui-core/hooks'
17
17
  import { cn } from '@djangocfg/ui-core/lib';
18
18
 
19
19
  import { usePathnameWithoutLocale } from '../../../hooks';
20
- import { UserMenu } from '../../_components/UserMenu';
20
+ import { UserMenu } from '../components/UserMenu';
21
21
  import { usePublicLayoutOptional } from '../context';
22
22
  import { useMobileNavPanel } from '../hooks';
23
23
  import { NavControls } from '../primitives/NavControls';
@@ -17,6 +17,7 @@
17
17
 
18
18
  import React, {
19
19
  type ReactNode,
20
+ memo,
20
21
  useEffect,
21
22
  useLayoutEffect,
22
23
  useMemo,
@@ -160,7 +161,7 @@ export interface NavbarActionsContext {
160
161
  controls: ReactNode | null;
161
162
  }
162
163
 
163
- export function NavbarShell(props: NavbarShellProps) {
164
+ function NavbarShellRaw(props: NavbarShellProps) {
164
165
  const context = usePublicLayoutOptional();
165
166
 
166
167
  const {
@@ -348,3 +349,61 @@ export function NavbarShell(props: NavbarShellProps) {
348
349
  </div>
349
350
  );
350
351
  }
352
+
353
+ /**
354
+ * Shallow equality for NavigationItem arrays. Compares href, label, external
355
+ * and one-level-deep sub-items so that nav label/badge changes re-render,
356
+ * but object-reference churn from parent is ignored.
357
+ */
358
+ function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
359
+ if (a === b) return true;
360
+ if (a.length !== b.length) return false;
361
+ return a.every((ai, i) => {
362
+ const bi = b[i];
363
+ if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
364
+ if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
365
+ if (ai.items) {
366
+ return ai.items.every((sub, j) => {
367
+ const bsub = bi.items![j];
368
+ return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
369
+ });
370
+ }
371
+ return true;
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Memoised navbar orchestrator. Re-renders only when any config prop changes
377
+ * (variant, position, nav items, actions, slots, etc.). The heavy inner JSX
378
+ * (brand, desktop nav, actions) is preserved across renders when props are
379
+ * stable. Navigation items are compared via navItemsShallowEqual so that
380
+ * reference churn from parent does not cause a re-render.
381
+ */
382
+ export const NavbarShell = memo(NavbarShellRaw, (prev, next) => {
383
+ return (
384
+ prev.variant === next.variant &&
385
+ prev.position === next.position &&
386
+ prev.brand === next.brand &&
387
+ prev.brandHref === next.brandHref &&
388
+ prev.userMenu === next.userMenu &&
389
+ prev.desktopMaxPrimaryItems === next.desktopMaxPrimaryItems &&
390
+ prev.navLayout === next.navLayout &&
391
+ prev.navbarHeight === next.navbarHeight &&
392
+ prev.insetX === next.insetX &&
393
+ prev.innerPadding === next.innerPadding &&
394
+ prev.hideNavOnScroll === next.hideNavOnScroll &&
395
+ prev.transparent === next.transparent &&
396
+ prev.transparentThreshold === next.transparentThreshold &&
397
+ prev.outerClassName === next.outerClassName &&
398
+ prev.shapeClassName === next.shapeClassName &&
399
+ prev.shapeForState === next.shapeForState &&
400
+ prev.renderActions === next.renderActions &&
401
+ prev.actions === next.actions &&
402
+ prev.actionsLeadingSlot === next.actionsLeadingSlot &&
403
+ prev.actionsTrailingSlot === next.actionsTrailingSlot &&
404
+ prev.controls === next.controls &&
405
+ prev.mobileMenuOpen === next.mobileMenuOpen &&
406
+ prev.onMobileMenuToggle === next.onMobileMenuToggle &&
407
+ navItemsShallowEqual(prev.navigation ?? [], next.navigation ?? [])
408
+ );
409
+ });
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Shared Layout Components
3
+ *
4
+ * Components used across multiple layout types.
3
5
  */
4
6
 
5
7
  export {
@@ -25,10 +27,3 @@ export type {
25
27
  LocaleSwitcherSharedProps,
26
28
  LocaleSwitcherVariant,
27
29
  } from './LocaleSwitcher';
28
-
29
- export { UserMenu } from './UserMenu';
30
- export type { UserMenuProps } from './UserMenu';
31
-
32
- export { UserAvatar } from './UserAvatar';
33
- export type { UserAvatarProps } from './UserAvatar';
34
-
@@ -10,9 +10,15 @@
10
10
  // Shared types (universal type system)
11
11
  export * from './types';
12
12
 
13
- // Shared components
14
- export { LocaleSwitcher, LOCALE_LABELS, UserMenu } from './_components';
15
- export type { LocaleSwitcherProps, UserMenuProps } from './_components';
13
+ // Shared components (used by multiple layouts)
14
+ export { LocaleSwitcher, LOCALE_LABELS } from './_components';
15
+ export type { LocaleSwitcherProps } from './_components';
16
+
17
+ // PublicLayout components (re-exported for convenience)
18
+ export { UserMenu } from './PublicLayout/components';
19
+ export type { UserMenuProps } from './PublicLayout/components';
20
+ export { UserAvatar } from './PublicLayout/components';
21
+ export type { UserAvatarProps } from './PublicLayout/components';
16
22
 
17
23
  // Smart layout router
18
24
  export * from './AppLayout';
@@ -25,4 +31,3 @@ export * from './AdminLayout';
25
31
 
26
32
  // Additional layouts
27
33
  export * from './ProfileLayout';
28
-
@@ -1,234 +0,0 @@
1
- /**
2
- * Tests for TwoFactorSection component
3
- *
4
- * Tests the 2FA management section in ProfileLayout.
5
- */
6
-
7
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
8
- import React from 'react';
9
-
10
- import { TwoFactorSection } from '../components/TwoFactorSection';
11
-
12
- // Mock hooks
13
- const mockFetchStatus = jest.fn();
14
- const mockDisable2FA = jest.fn();
15
- const mockResetSetup = jest.fn();
16
- const mockClearError = jest.fn();
17
-
18
- jest.mock('@djangocfg/api/auth', () => ({
19
- useTwoFactorStatus: () => ({
20
- isLoading: false,
21
- error: null,
22
- has2FAEnabled: false,
23
- devices: [],
24
- fetchStatus: mockFetchStatus,
25
- disable2FA: mockDisable2FA,
26
- clearError: mockClearError,
27
- }),
28
- useTwoFactorSetup: () => ({
29
- resetSetup: mockResetSetup,
30
- }),
31
- }));
32
-
33
- // Mock TwoFactorSetup component
34
- jest.mock('../../AuthLayout/components/TwoFactorSetup', () => ({
35
- TwoFactorSetup: ({ onComplete, onSkip }: any) => (
36
- <div data-testid="two-factor-setup">
37
- <button onClick={onComplete}>Complete Setup</button>
38
- <button onClick={onSkip}>Skip Setup</button>
39
- </div>
40
- ),
41
- }));
42
-
43
- // Mock UI components
44
- jest.mock('@djangocfg/ui-core/components', () => ({
45
- Alert: ({ children, variant }: any) => (
46
- <div data-testid="alert" data-variant={variant}>
47
- {children}
48
- </div>
49
- ),
50
- AlertDescription: ({ children }: any) => <span>{children}</span>,
51
- Button: ({ children, onClick, disabled, variant }: any) => (
52
- <button onClick={onClick} disabled={disabled} data-variant={variant}>
53
- {children}
54
- </button>
55
- ),
56
- Card: ({ children, className }: any) => (
57
- <div data-testid="card" className={className}>
58
- {children}
59
- </div>
60
- ),
61
- CardContent: ({ children }: any) => <div>{children}</div>,
62
- CardDescription: ({ children }: any) => <p>{children}</p>,
63
- CardHeader: ({ children }: any) => <div>{children}</div>,
64
- CardTitle: ({ children }: any) => <h3>{children}</h3>,
65
- Dialog: ({ children, open }: any) => (open ? <div data-testid="dialog">{children}</div> : null),
66
- DialogContent: ({ children }: any) => <div>{children}</div>,
67
- DialogDescription: ({ children }: any) => <p>{children}</p>,
68
- DialogFooter: ({ children }: any) => <div>{children}</div>,
69
- DialogHeader: ({ children }: any) => <div>{children}</div>,
70
- DialogTitle: ({ children }: any) => <h4>{children}</h4>,
71
- OTPInput: ({ value, onChange, disabled }: any) => (
72
- <input
73
- data-testid="otp-input"
74
- value={value}
75
- onChange={(e) => onChange(e.target.value)}
76
- disabled={disabled}
77
- />
78
- ),
79
- }));
80
-
81
- describe('TwoFactorSection', () => {
82
- beforeEach(() => {
83
- jest.clearAllMocks();
84
- });
85
-
86
- describe('when 2FA is disabled', () => {
87
- it('should render disabled state', () => {
88
- render(<TwoFactorSection />);
89
-
90
- expect(screen.getByText('Two-Factor Authentication')).toBeInTheDocument();
91
- expect(screen.getByText('2FA is not enabled')).toBeInTheDocument();
92
- expect(screen.getByText('Enable 2FA')).toBeInTheDocument();
93
- });
94
-
95
- it('should fetch status on mount', () => {
96
- render(<TwoFactorSection />);
97
-
98
- expect(mockFetchStatus).toHaveBeenCalledTimes(1);
99
- });
100
-
101
- it('should show security recommendation', () => {
102
- render(<TwoFactorSection />);
103
-
104
- expect(
105
- screen.getByText(/Two-factor authentication adds an extra layer of security/)
106
- ).toBeInTheDocument();
107
- });
108
- });
109
-
110
- describe('when 2FA is enabled', () => {
111
- beforeEach(() => {
112
- jest.doMock('@djangocfg/api/auth', () => ({
113
- useTwoFactorStatus: () => ({
114
- isLoading: false,
115
- error: null,
116
- has2FAEnabled: true,
117
- devices: [
118
- {
119
- id: '123',
120
- name: 'My Authenticator',
121
- createdAt: '2024-01-01T00:00:00Z',
122
- lastUsedAt: null,
123
- isPrimary: true,
124
- },
125
- ],
126
- fetchStatus: mockFetchStatus,
127
- disable2FA: mockDisable2FA,
128
- clearError: mockClearError,
129
- }),
130
- useTwoFactorSetup: () => ({
131
- resetSetup: mockResetSetup,
132
- }),
133
- }));
134
- });
135
-
136
- it('should render enabled state with device info', async () => {
137
- // Note: Due to jest module caching, this test shows expected behavior
138
- // In actual implementation, mock would need to be reset properly
139
- });
140
- });
141
-
142
- describe('enable 2FA flow', () => {
143
- it('should show setup view when Enable 2FA is clicked', async () => {
144
- render(<TwoFactorSection />);
145
-
146
- fireEvent.click(screen.getByText('Enable 2FA'));
147
-
148
- await waitFor(() => {
149
- expect(screen.getByTestId('two-factor-setup')).toBeInTheDocument();
150
- });
151
-
152
- expect(mockResetSetup).toHaveBeenCalled();
153
- });
154
-
155
- it('should return to status view when setup is completed', async () => {
156
- render(<TwoFactorSection />);
157
-
158
- // Click Enable 2FA
159
- fireEvent.click(screen.getByText('Enable 2FA'));
160
-
161
- await waitFor(() => {
162
- expect(screen.getByTestId('two-factor-setup')).toBeInTheDocument();
163
- });
164
-
165
- // Complete setup
166
- fireEvent.click(screen.getByText('Complete Setup'));
167
-
168
- await waitFor(() => {
169
- expect(mockFetchStatus).toHaveBeenCalled();
170
- });
171
- });
172
-
173
- it('should return to status view when setup is skipped', async () => {
174
- render(<TwoFactorSection />);
175
-
176
- // Click Enable 2FA
177
- fireEvent.click(screen.getByText('Enable 2FA'));
178
-
179
- await waitFor(() => {
180
- expect(screen.getByTestId('two-factor-setup')).toBeInTheDocument();
181
- });
182
-
183
- // Skip setup
184
- fireEvent.click(screen.getByText('Skip Setup'));
185
-
186
- // Should go back to status view
187
- await waitFor(() => {
188
- expect(screen.queryByTestId('two-factor-setup')).not.toBeInTheDocument();
189
- });
190
- });
191
- });
192
-
193
- describe('error handling', () => {
194
- it('should display error when present', () => {
195
- jest.doMock('@djangocfg/api/auth', () => ({
196
- useTwoFactorStatus: () => ({
197
- isLoading: false,
198
- error: 'Failed to fetch status',
199
- has2FAEnabled: null,
200
- devices: [],
201
- fetchStatus: mockFetchStatus,
202
- disable2FA: mockDisable2FA,
203
- clearError: mockClearError,
204
- }),
205
- useTwoFactorSetup: () => ({
206
- resetSetup: mockResetSetup,
207
- }),
208
- }));
209
-
210
- // Note: Due to jest module caching, mock would need proper reset
211
- });
212
- });
213
-
214
- describe('loading state', () => {
215
- it('should show loading indicator when fetching initial status', () => {
216
- jest.doMock('@djangocfg/api/auth', () => ({
217
- useTwoFactorStatus: () => ({
218
- isLoading: true,
219
- error: null,
220
- has2FAEnabled: null,
221
- devices: [],
222
- fetchStatus: mockFetchStatus,
223
- disable2FA: mockDisable2FA,
224
- clearError: mockClearError,
225
- }),
226
- useTwoFactorSetup: () => ({
227
- resetSetup: mockResetSetup,
228
- }),
229
- }));
230
-
231
- // Note: Due to jest module caching, mock would need proper reset
232
- });
233
- });
234
- });
@@ -1,198 +0,0 @@
1
- // @ts-nocheck
2
- 'use client';
3
-
4
- import React, { useEffect, useState } from 'react';
5
- import { useForm } from 'react-hook-form';
6
- import { toast } from '@djangocfg/ui-core/hooks';
7
-
8
- import {
9
- PatchedCfgUserUpdateRequest, PatchedCfgUserUpdateRequestSchema,
10
- useAuth
11
- } from '@djangocfg/api/auth';
12
- import {
13
- Button, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input, Label, PhoneInput
14
- } from '@djangocfg/ui-core/components';
15
- import { zodResolver } from '@hookform/resolvers/zod';
16
-
17
- import { profileLogger } from '../../../utils/logger';
18
-
19
- export const ProfileForm = () => {
20
- const { user, updateProfile } = useAuth();
21
- const [isEditing, setIsEditing] = useState(false);
22
- const [isSaving, setIsSaving] = useState(false);
23
-
24
- const form = useForm<PatchedCfgUserUpdateRequest>({
25
- resolver: zodResolver(PatchedCfgUserUpdateRequestSchema),
26
- defaultValues: {
27
- first_name: '',
28
- last_name: '',
29
- company: '',
30
- position: '',
31
- phone: '',
32
- },
33
- });
34
-
35
- // Load user data
36
- useEffect(() => {
37
- if (user) {
38
- form.reset({
39
- first_name: user.first_name || '',
40
- last_name: user.last_name || '',
41
- company: user.company || '',
42
- position: user.position || '',
43
- phone: user.phone || '',
44
- });
45
- }
46
- }, [user, form]);
47
-
48
- const handleSubmit = async (data: PatchedCfgUserUpdateRequest) => {
49
- setIsSaving(true);
50
- try {
51
- await updateProfile(data);
52
- toast.success('Profile updated successfully');
53
- setIsEditing(false);
54
- } catch (error: any) {
55
- profileLogger.error('Profile update error:', error);
56
- if (error?.response?.data) {
57
- const fieldErrors = error.response.data;
58
- Object.entries(fieldErrors).forEach(([field, messages]) => {
59
- if (Array.isArray(messages) && messages.length > 0) {
60
- form.setError(field as keyof PatchedCfgUserUpdateRequest, {
61
- type: 'server',
62
- message: messages[0],
63
- });
64
- }
65
- });
66
- toast.error('Please fix the validation errors');
67
- } else {
68
- toast.error('Failed to update profile');
69
- }
70
- } finally {
71
- setIsSaving(false);
72
- }
73
- };
74
-
75
- const handleCancel = () => {
76
- setIsEditing(false);
77
- form.clearErrors();
78
- if (user) {
79
- form.reset({
80
- first_name: user.first_name || '',
81
- last_name: user.last_name || '',
82
- company: user.company || '',
83
- position: user.position || '',
84
- phone: user.phone || '',
85
- });
86
- }
87
- };
88
-
89
- const onSubmit = form.handleSubmit(handleSubmit);
90
-
91
- return (
92
- <Form {...form}>
93
- <form onSubmit={onSubmit} className="space-y-4">
94
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
95
- <div className="space-y-2 md:col-span-2">
96
- <Label htmlFor="email">Email</Label>
97
- <Input id="email" value={user?.email || ''} disabled className="bg-muted" />
98
- </div>
99
-
100
- <FormField
101
- control={form.control}
102
- name="first_name"
103
- render={({ field }) => (
104
- <FormItem>
105
- <FormLabel>First Name</FormLabel>
106
- <FormControl>
107
- <Input {...field} disabled={!isEditing} placeholder="Enter first name" />
108
- </FormControl>
109
- <FormMessage />
110
- </FormItem>
111
- )}
112
- />
113
-
114
- <FormField
115
- control={form.control}
116
- name="last_name"
117
- render={({ field }) => (
118
- <FormItem>
119
- <FormLabel>Last Name</FormLabel>
120
- <FormControl>
121
- <Input {...field} disabled={!isEditing} placeholder="Enter last name" />
122
- </FormControl>
123
- <FormMessage />
124
- </FormItem>
125
- )}
126
- />
127
-
128
- <FormField
129
- control={form.control}
130
- name="company"
131
- render={({ field }) => (
132
- <FormItem>
133
- <FormLabel>Company</FormLabel>
134
- <FormControl>
135
- <Input {...field} disabled={!isEditing} placeholder="Enter company name" />
136
- </FormControl>
137
- <FormMessage />
138
- </FormItem>
139
- )}
140
- />
141
-
142
- <FormField
143
- control={form.control}
144
- name="position"
145
- render={({ field }) => (
146
- <FormItem>
147
- <FormLabel>Position</FormLabel>
148
- <FormControl>
149
- <Input {...field} disabled={!isEditing} placeholder="Enter position" />
150
- </FormControl>
151
- <FormMessage />
152
- </FormItem>
153
- )}
154
- />
155
-
156
- <FormField
157
- control={form.control}
158
- name="phone"
159
- render={({ field }) => (
160
- <FormItem className="md:col-span-2">
161
- <FormLabel>Phone</FormLabel>
162
- <FormControl>
163
- <PhoneInput
164
- value={field.value}
165
- onChange={field.onChange}
166
- disabled={!isEditing}
167
- placeholder="Enter phone number"
168
- defaultCountry="US"
169
- />
170
- </FormControl>
171
- <FormMessage />
172
- </FormItem>
173
- )}
174
- />
175
- </div>
176
-
177
- {/* Action Buttons */}
178
- <div className="flex items-center justify-between pt-4">
179
- {isEditing ? (
180
- <div className="flex items-center gap-2">
181
- <Button type="submit" disabled={isSaving}>
182
- {isSaving ? 'Saving...' : 'Save Changes'}
183
- </Button>
184
- <Button type="button" variant="outline" onClick={handleCancel}>
185
- Cancel
186
- </Button>
187
- </div>
188
- ) : (
189
- <Button type="button" onClick={() => setIsEditing(true)}>
190
- Edit Profile
191
- </Button>
192
- )}
193
- </div>
194
- </form>
195
- </Form>
196
- );
197
- };
198
-