@djangocfg/layouts 2.1.422 → 2.1.423

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.422",
3
+ "version": "2.1.423",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -89,13 +89,13 @@
89
89
  "check": "tsc --noEmit"
90
90
  },
91
91
  "peerDependencies": {
92
- "@djangocfg/api": "^2.1.422",
93
- "@djangocfg/centrifugo": "^2.1.422",
94
- "@djangocfg/debuger": "^2.1.422",
95
- "@djangocfg/i18n": "^2.1.422",
96
- "@djangocfg/monitor": "^2.1.422",
97
- "@djangocfg/ui-core": "^2.1.422",
98
- "@djangocfg/ui-nextjs": "^2.1.422",
92
+ "@djangocfg/api": "^2.1.423",
93
+ "@djangocfg/centrifugo": "^2.1.423",
94
+ "@djangocfg/debuger": "^2.1.423",
95
+ "@djangocfg/i18n": "^2.1.423",
96
+ "@djangocfg/monitor": "^2.1.423",
97
+ "@djangocfg/ui-core": "^2.1.423",
98
+ "@djangocfg/ui-nextjs": "^2.1.423",
99
99
  "@hookform/resolvers": "^5.2.2",
100
100
  "consola": "^3.4.2",
101
101
  "lucide-react": "^0.545.0",
@@ -126,15 +126,15 @@
126
126
  "uuid": "^11.1.0"
127
127
  },
128
128
  "devDependencies": {
129
- "@djangocfg/api": "^2.1.422",
130
- "@djangocfg/centrifugo": "^2.1.422",
131
- "@djangocfg/debuger": "^2.1.422",
132
- "@djangocfg/i18n": "^2.1.422",
133
- "@djangocfg/monitor": "^2.1.422",
134
- "@djangocfg/typescript-config": "^2.1.422",
135
- "@djangocfg/ui-core": "^2.1.422",
136
- "@djangocfg/ui-nextjs": "^2.1.422",
137
- "@djangocfg/ui-tools": "^2.1.422",
129
+ "@djangocfg/api": "^2.1.423",
130
+ "@djangocfg/centrifugo": "^2.1.423",
131
+ "@djangocfg/debuger": "^2.1.423",
132
+ "@djangocfg/i18n": "^2.1.423",
133
+ "@djangocfg/monitor": "^2.1.423",
134
+ "@djangocfg/typescript-config": "^2.1.423",
135
+ "@djangocfg/ui-core": "^2.1.423",
136
+ "@djangocfg/ui-nextjs": "^2.1.423",
137
+ "@djangocfg/ui-tools": "^2.1.423",
138
138
  "@types/node": "^25.2.3",
139
139
  "@types/react": "^19.2.15",
140
140
  "@types/react-dom": "^19.2.3",
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React from 'react';
10
10
 
11
- import { extractDomain, formatErrorTitle, formatZodIssues } from '../utils/formatters';
11
+ import { extractDomain, formatErrorTitle, formatZodIssues, safeZodIssues } from '../utils/formatters';
12
12
  import { ErrorButtons } from './ErrorButtons';
13
13
 
14
14
  import type {
@@ -39,9 +39,11 @@ function buildValidationDescription(
39
39
 
40
40
  // Add error count
41
41
  if (config.showErrorCount) {
42
- const count = detail.error.issues.length;
43
- const plural = count === 1 ? 'error' : 'errors';
44
- descriptionParts.push(`${count} ${plural}`);
42
+ const count = safeZodIssues(detail.error).length;
43
+ if (count > 0) {
44
+ const plural = count === 1 ? 'error' : 'errors';
45
+ descriptionParts.push(`${count} ${plural}`);
46
+ }
45
47
  }
46
48
 
47
49
  // Add formatted error messages
@@ -302,9 +302,20 @@ export function ErrorTrackingProvider({
302
302
  if (validationConfig.enabled) {
303
303
  const handler = (event: Event) => {
304
304
  if (!(event instanceof CustomEvent)) return;
305
+ const raw = event.detail ?? {};
306
+ // The generated client dispatches `{ operation, method, path, issues,
307
+ // data, timestamp }` — i.e. `issues`/`data`, not a `ZodError` in
308
+ // `error`. Normalise into the ValidationErrorDetail shape so the toast
309
+ // can render issue count/messages (and never read undefined.issues).
310
+ const error =
311
+ raw.error && Array.isArray(raw.error.issues)
312
+ ? raw.error
313
+ : { issues: Array.isArray(raw.issues) ? raw.issues : [] };
305
314
  const detail: ValidationErrorDetail = {
306
- ...event.detail,
315
+ ...raw,
307
316
  type: 'validation' as const,
317
+ error: error as ValidationErrorDetail['error'],
318
+ response: raw.response ?? raw.data,
308
319
  };
309
320
  handleError(detail, validationConfig);
310
321
  };
@@ -9,18 +9,31 @@ import type { ValidationErrorDetail, CORSErrorDetail, NetworkErrorDetail, Centri
9
9
  import type { MonitorEvent } from '@djangocfg/monitor';
10
10
  import { EventType, EventLevel } from '@djangocfg/monitor';
11
11
 
12
+ /**
13
+ * Safely read Zod issues. The error toast must never crash itself, so we
14
+ * tolerate a missing/malformed `error` (e.g. a non-ZodError slipping through)
15
+ * instead of throwing `Cannot read properties of undefined (reading 'issues')`.
16
+ */
17
+ export function safeZodIssues(error: ZodError | null | undefined): ZodError['issues'] {
18
+ const issues = (error as ZodError | undefined)?.issues;
19
+ return Array.isArray(issues) ? issues : [];
20
+ }
21
+
12
22
  /**
13
23
  * Format Zod error issues for display
14
24
  */
15
25
  export function formatZodIssues(error: ZodError, maxIssues: number = 3): string {
16
- const issues = error.issues.slice(0, maxIssues);
26
+ const allIssues = safeZodIssues(error);
27
+ if (allIssues.length === 0) return '';
28
+
29
+ const issues = allIssues.slice(0, maxIssues);
17
30
  const formatted = issues.map((issue) => {
18
31
  const path = issue.path.join('.') || 'root';
19
32
  return `${path}: ${issue.message}`;
20
33
  });
21
34
 
22
- if (error.issues.length > maxIssues) {
23
- formatted.push(`... and ${error.issues.length - maxIssues} more`);
35
+ if (allIssues.length > maxIssues) {
36
+ formatted.push(`... and ${allIssues.length - maxIssues} more`);
24
37
  }
25
38
 
26
39
  return formatted.join(', ');
@@ -30,6 +43,7 @@ export function formatZodIssues(error: ZodError, maxIssues: number = 3): string
30
43
  * Format validation error for clipboard
31
44
  */
32
45
  export function formatValidationErrorForClipboard(detail: ValidationErrorDetail): string {
46
+ const issues = safeZodIssues(detail.error);
33
47
  const errorData = {
34
48
  type: 'validation',
35
49
  timestamp: detail.timestamp.toISOString(),
@@ -38,7 +52,7 @@ export function formatValidationErrorForClipboard(detail: ValidationErrorDetail)
38
52
  method: detail.method,
39
53
  path: detail.path,
40
54
  },
41
- validation_errors: detail.error.issues.map((issue) => ({
55
+ validation_errors: issues.map((issue) => ({
42
56
  path: issue.path.join('.') || 'root',
43
57
  message: issue.message,
44
58
  code: issue.code,
@@ -48,7 +62,7 @@ export function formatValidationErrorForClipboard(detail: ValidationErrorDetail)
48
62
  ...(('maximum' in issue) && { maximum: issue.maximum }),
49
63
  })),
50
64
  response: detail.response,
51
- total_errors: detail.error.issues.length,
65
+ total_errors: issues.length,
52
66
  };
53
67
 
54
68
  return JSON.stringify(errorData, null, 2);
@@ -17,7 +17,20 @@ export interface ProfileDialogProps {
17
17
  }
18
18
 
19
19
  export const ProfileDialog: React.FC<ProfileDialogProps> = ({ title }) => {
20
- const { isOpen, close, initialTab } = useProfileDialogStore();
20
+ const {
21
+ isOpen,
22
+ close,
23
+ initialTab,
24
+ tabs,
25
+ slots,
26
+ enable2FA,
27
+ enableAPIKeys,
28
+ enableDeleteAccount,
29
+ title: storeTitle,
30
+ } = useProfileDialogStore();
31
+
32
+ // Title precedence: explicit dialog prop > value passed to open() > ProfileForm default.
33
+ const resolvedTitle = title ?? storeTitle;
21
34
 
22
35
  return (
23
36
  <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
@@ -25,7 +38,18 @@ export const ProfileDialog: React.FC<ProfileDialogProps> = ({ title }) => {
25
38
  <DialogHeader className="sr-only">
26
39
  <DialogTitle>Profile</DialogTitle>
27
40
  </DialogHeader>
28
- <ProfileForm title={title} defaultTab={initialTab} />
41
+ {/* Undefined fields fall through to ProfileForm's own defaults
42
+ * (e.g. enableAPIKeys defaults to true), so this stays backward
43
+ * compatible with bare open() / open({ initialTab }). */}
44
+ <ProfileForm
45
+ title={resolvedTitle}
46
+ defaultTab={initialTab}
47
+ tabs={tabs}
48
+ slots={slots}
49
+ enable2FA={enable2FA}
50
+ enableAPIKeys={enableAPIKeys}
51
+ enableDeleteAccount={enableDeleteAccount}
52
+ />
29
53
  </DialogContent>
30
54
  </Dialog>
31
55
  );
@@ -1,2 +1,4 @@
1
1
  export { ProfileDialog } from './ProfileDialog';
2
+ export type { ProfileDialogProps } from './ProfileDialog';
2
3
  export { useProfileDialogStore } from './store';
4
+ export type { ProfileDialogContent } from './store';
@@ -1,19 +1,51 @@
1
1
  import { create } from 'zustand';
2
2
 
3
- import type { ProfileTabValue } from '../hooks/useProfileTabs';
3
+ import type { ProfileTabValueOrCustom } from '../hooks/useProfileTabs';
4
+ import type { ProfileSlots, ProfileTab } from '../types';
4
5
 
5
- interface ProfileDialogState {
6
+ /**
7
+ * Content the dialog renders into <ProfileForm>. All optional — anything
8
+ * omitted in `open()` falls back to ProfileForm's own default. Lets any caller
9
+ * open the profile dialog with custom tabs/slots/flags in one line.
10
+ */
11
+ export interface ProfileDialogContent {
12
+ initialTab?: ProfileTabValueOrCustom;
13
+ tabs?: ProfileTab[];
14
+ slots?: ProfileSlots;
15
+ enable2FA?: boolean;
16
+ enableAPIKeys?: boolean;
17
+ enableDeleteAccount?: boolean;
18
+ title?: string;
19
+ }
20
+
21
+ interface ProfileDialogState extends ProfileDialogContent {
6
22
  isOpen: boolean;
7
- initialTab: ProfileTabValue | undefined;
8
- open: (options?: { initialTab?: ProfileTabValue }) => void;
23
+ open: (options?: ProfileDialogContent) => void;
9
24
  close: () => void;
10
25
  toggle: () => void;
11
26
  }
12
27
 
28
+ /** Content fields reset to undefined on close (keeps `isOpen` separate). */
29
+ const EMPTY_CONTENT: Required<{ [K in keyof ProfileDialogContent]: undefined }> = {
30
+ initialTab: undefined,
31
+ tabs: undefined,
32
+ slots: undefined,
33
+ enable2FA: undefined,
34
+ enableAPIKeys: undefined,
35
+ enableDeleteAccount: undefined,
36
+ title: undefined,
37
+ };
38
+
13
39
  export const useProfileDialogStore = create<ProfileDialogState>((set) => ({
14
40
  isOpen: false,
15
- initialTab: undefined,
16
- open: (options) => set({ isOpen: true, initialTab: options?.initialTab }),
17
- close: () => set({ isOpen: false, initialTab: undefined }),
41
+ ...EMPTY_CONTENT,
42
+ open: (options) =>
43
+ set({
44
+ isOpen: true,
45
+ // Replace (not merge) content so a previous open()'s tabs/flags don't leak.
46
+ ...EMPTY_CONTENT,
47
+ ...options,
48
+ }),
49
+ close: () => set({ isOpen: false, ...EMPTY_CONTENT }),
18
50
  toggle: () => set((state) => ({ isOpen: !state.isOpen })),
19
51
  }));
@@ -16,7 +16,6 @@ import { ApiKeySection, ProfileHeader, ProfileTab, TwoFactorSection } from '../c
16
16
  import { ProfileProvider, useProfileContext } from './context';
17
17
  import { useProfileTabs } from '../hooks/useProfileTabs';
18
18
  import type { ProfileFormProps } from '../types';
19
- import type { ProfileTabValue } from '../hooks/useProfileTabs';
20
19
 
21
20
  // ─────────────────────────────────────────────────────────────────────────────
22
21
  // Built-in tab panels
@@ -64,7 +63,7 @@ function ProfileContent({
64
63
  });
65
64
 
66
65
  const handleTabChange = React.useCallback(
67
- (value: string) => setTab(value as ProfileTabValue),
66
+ (value: string) => setTab(value),
68
67
  [setTab],
69
68
  );
70
69
 
@@ -28,6 +28,7 @@ import { ProfileDialog, useProfileDialogStore } from '@djangocfg/layouts';
28
28
 
29
29
  // Open on a specific tab
30
30
  useProfileDialogStore.getState().open({ initialTab: 'security' });
31
+ // …or with custom tabs/slots — see "Opening with custom content" below.
31
32
 
32
33
  // In your layout (lazy-loaded)
33
34
  <ProfileDialog />
@@ -44,7 +45,7 @@ useProfileDialogStore.getState().open({ initialTab: 'security' });
44
45
  | `tabs` | `[]` | Extra `ProfileTab[]` appended after built-in tabs |
45
46
  | `slots` | — | Named slots: `headerMenuItems`, `headerBadge`, `headerAfter`, `footer` |
46
47
  | `title` | i18n | Page title |
47
- | `defaultTab` | — | Initial active tab. When provided (e.g. by `ProfileDialog`), tabs start at this value. |
48
+ | `defaultTab` | — | Initial active tab. Accepts a built-in value or a custom tab's `value`. When provided (e.g. by `ProfileDialog`), tabs start at this value. |
48
49
 
49
50
  ## Global Profile Dialog
50
51
 
@@ -62,6 +63,37 @@ open();
62
63
  open({ initialTab: 'security' });
63
64
  ```
64
65
 
66
+ ### Opening with custom content
67
+
68
+ `open()` accepts the same content props as `ProfileForm` (all optional), so any
69
+ caller can open the dialog with custom tabs / slots / flags — and start on a
70
+ custom tab — in one line:
71
+
72
+ ```tsx
73
+ useProfileDialogStore.getState().open({
74
+ initialTab: 'api-keys',
75
+ tabs: [{ value: 'api-keys', label: 'API keys', content: <MyApiKeysTab /> }],
76
+ enableAPIKeys: false, // hide the built-in API Keys tab
77
+ });
78
+ ```
79
+
80
+ The `open()` payload is typed as `ProfileDialogContent`:
81
+
82
+ | Field | Type | Notes |
83
+ |-------|------|-------|
84
+ | `initialTab` | `string` | Built-in (`'profile' \| 'security' \| 'api-keys'`) **or** a custom tab's `value`. |
85
+ | `tabs` | `ProfileTab[]` | Extra tabs appended after the built-in ones. |
86
+ | `slots` | `ProfileSlots` | `headerMenuItems`, `headerBadge`, `headerAfter`, `footer`. |
87
+ | `enable2FA` | `boolean` | Override the Security tab. |
88
+ | `enableAPIKeys` | `boolean` | Override the built-in API Keys tab. |
89
+ | `enableDeleteAccount` | `boolean` | Override Delete Account in the header dropdown. |
90
+ | `title` | `string` | Dialog title (the `<ProfileDialog title>` prop wins if both set). |
91
+
92
+ Each `open()` **replaces** the previous content (it does not merge), and `close()`
93
+ clears it — so a bare `open()` always shows the default profile. Omitted fields
94
+ fall through to `ProfileForm`'s own defaults, keeping `open()` / `open({ initialTab })`
95
+ backward compatible.
96
+
65
97
  Wire it into `PrivateLayout` for global access:
66
98
 
67
99
  ```tsx
@@ -94,7 +126,7 @@ ProfileLayout/
94
126
  │ └── context.tsx Root context (labels, onLogout, onFieldSave)
95
127
  ├── ProfileDialog/
96
128
  │ ├── ProfileDialog.tsx Dialog wrapper around ProfileForm
97
- │ └── store.ts Zustand store (isOpen, initialTab, open/close)
129
+ │ └── store.ts Zustand store (isOpen + ProfileDialogContent: tabs/slots/flags/initialTab/title)
98
130
  ├── hooks/
99
131
  │ └── useProfileTabs.ts Local tab state (useState)
100
132
  ├── types.ts ProfileFormProps, ProfileTab, ProfileSlots
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react';
4
4
  import { parsePhoneNumberFromString } from 'libphonenumber-js';
5
5
 
6
6
  import { Button, Input, PhoneInput } from '@djangocfg/ui-core/components';
7
+ import { toast } from '@djangocfg/ui-core/hooks';
7
8
  import { cn } from '@djangocfg/ui-core/lib';
8
9
 
9
10
  import { useProfileContext } from '../ProfileForm/context';
@@ -52,6 +53,9 @@ export const EditableField = ({
52
53
  try {
53
54
  await onSave(editValue);
54
55
  setIsEditing(false);
56
+ } catch {
57
+ // Keep editing mode open with the entered value so the user can retry.
58
+ toast.error(labels.failedToUpdate);
55
59
  } finally {
56
60
  setIsSaving(false);
57
61
  }
@@ -1,2 +1,2 @@
1
1
  export { useProfileTabs } from './useProfileTabs';
2
- export type { ProfileTabValue, UseProfileTabsOptions } from './useProfileTabs';
2
+ export type { ProfileTabValue, ProfileTabValueOrCustom, UseProfileTabsOptions } from './useProfileTabs';
@@ -2,14 +2,22 @@
2
2
 
3
3
  import { useMemo, useState, useCallback } from 'react';
4
4
 
5
+ /** Built-in tab values. */
5
6
  export type ProfileTabValue = 'profile' | 'security' | 'api-keys';
6
7
 
8
+ /**
9
+ * Any tab value: a built-in one, or a custom tab's `value` string.
10
+ * `(string & {})` keeps the built-in literals as autocomplete hints while
11
+ * still accepting arbitrary strings (custom tabs added via `tabs`).
12
+ */
13
+ export type ProfileTabValueOrCustom = ProfileTabValue | (string & {});
14
+
7
15
  export interface UseProfileTabsOptions {
8
16
  enable2FA?: boolean;
9
17
  enableAPIKeys?: boolean;
10
18
  extraTabValues?: string[];
11
- /** Initial active tab. Defaults to `'profile'`. */
12
- defaultTab?: ProfileTabValue;
19
+ /** Initial active tab. Defaults to `'profile'`. Accepts custom tab values. */
20
+ defaultTab?: ProfileTabValueOrCustom;
13
21
  }
14
22
 
15
23
  /**
@@ -25,18 +33,18 @@ export function useProfileTabs(options: UseProfileTabsOptions = {}) {
25
33
  const { enable2FA, enableAPIKeys, extraTabValues = [], defaultTab } = options;
26
34
 
27
35
  const allowed = useMemo(() => {
28
- const base: ProfileTabValue[] = ['profile'];
36
+ const base: ProfileTabValueOrCustom[] = ['profile'];
29
37
  if (enable2FA) base.push('security');
30
38
  if (enableAPIKeys) base.push('api-keys');
31
- return [...base, ...extraTabValues] as ProfileTabValue[];
39
+ return [...base, ...extraTabValues];
32
40
  }, [enable2FA, enableAPIKeys, extraTabValues]);
33
41
 
34
- const [tab, setTabState] = useState<ProfileTabValue>(
42
+ const [tab, setTabState] = useState<ProfileTabValueOrCustom>(
35
43
  defaultTab && allowed.includes(defaultTab) ? defaultTab : 'profile',
36
44
  );
37
45
 
38
46
  const setTab = useCallback(
39
- (value: ProfileTabValue) => {
47
+ (value: ProfileTabValueOrCustom) => {
40
48
  if (allowed.includes(value)) {
41
49
  setTabState(value);
42
50
  }
@@ -2,6 +2,7 @@ export { ProfileForm, useProfileContext } from './ProfileForm';
2
2
  export { ProfileProvider } from './ProfileForm/context';
3
3
  export { useProfileTabs } from './hooks';
4
4
  export { ProfileDialog, useProfileDialogStore } from './ProfileDialog';
5
+ export type { ProfileDialogProps, ProfileDialogContent } from './ProfileDialog';
5
6
  export type { ProfileLabels } from './ProfileForm/context';
6
7
  export type { ProfileFormProps, ProfileLayoutProps, ProfileTab, ProfileSlots } from './types';
7
- export type { ProfileTabValue, UseProfileTabsOptions } from './hooks';
8
+ export type { ProfileTabValue, ProfileTabValueOrCustom, UseProfileTabsOptions } from './hooks';
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
 
3
- import type { ProfileTabValue } from './hooks/useProfileTabs';
3
+ import type { ProfileTabValueOrCustom } from './hooks/useProfileTabs';
4
4
 
5
5
  // ─────────────────────────────────────────────────────────────────────────────
6
6
  // Slot + Tab types
@@ -39,8 +39,9 @@ export interface ProfileFormProps {
39
39
  /**
40
40
  * When provided, the active tab is controlled locally (no URL sync).
41
41
  * Useful for dialogs where query-string pollution is undesirable.
42
+ * Accepts a built-in value or a custom tab's `value`.
42
43
  */
43
- defaultTab?: ProfileTabValue;
44
+ defaultTab?: ProfileTabValueOrCustom;
44
45
  }
45
46
 
46
47
  /** @deprecated Use ProfileFormProps instead */