@djangocfg/layouts 2.1.601 → 2.1.603

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 (35) hide show
  1. package/package.json +12 -12
  2. package/src/components/errors/ErrorsTracker/components/ErrorButtons.tsx +2 -2
  3. package/src/layouts/AuthLayout/README.md +22 -0
  4. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +25 -60
  5. package/src/layouts/AuthLayout/components/oauth/OAuthNotice.tsx +94 -0
  6. package/src/layouts/AuthLayout/components/oauth/index.ts +1 -0
  7. package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +1 -42
  8. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +36 -0
  9. package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +86 -0
  10. package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +15 -2
  11. package/src/layouts/AuthLayout/context.tsx +7 -2
  12. package/src/layouts/AuthLayout/index.ts +1 -0
  13. package/src/layouts/AuthLayout/types.ts +10 -1
  14. package/src/layouts/PrivateLayout/PrivateLayout.tsx +2 -0
  15. package/src/layouts/PrivateLayout/README.md +5 -1
  16. package/src/layouts/PrivateLayout/components/SidebarBody.tsx +3 -1
  17. package/src/layouts/PrivateLayout/components/SidebarNav.test.tsx +152 -0
  18. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +85 -45
  19. package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +54 -13
  20. package/src/layouts/PrivateLayout/context.tsx +5 -3
  21. package/src/layouts/PrivateLayout/index.ts +4 -0
  22. package/src/layouts/PrivateLayout/mcpSidebar.ts +35 -0
  23. package/src/layouts/PrivateLayout/sidebarItems.test.ts +58 -0
  24. package/src/layouts/PrivateLayout/sidebarItems.ts +38 -0
  25. package/src/layouts/PrivateLayout/types.ts +30 -7
  26. package/src/layouts/SettingsLayout/README.md +21 -0
  27. package/src/layouts/SettingsLayout/components/AvatarSection.tsx +0 -1
  28. package/src/layouts/SettingsLayout/hooks/index.ts +3 -0
  29. package/src/layouts/SettingsLayout/hooks/useSettingsDialog.ts +11 -8
  30. package/src/layouts/SettingsLayout/hooks/useSettingsSectionState.ts +32 -0
  31. package/src/layouts/SettingsLayout/index.ts +4 -0
  32. package/src/layouts/SettingsLayout/sectionIds.test-d.ts +51 -0
  33. package/src/layouts/SettingsLayout/sectionIds.test.ts +31 -0
  34. package/src/layouts/SettingsLayout/sectionIds.ts +43 -0
  35. package/src/testing/MockAuthFormProvider.tsx +5 -1
@@ -15,9 +15,8 @@ import type { UserMenuConfig } from '../types';
15
15
  // Sidebar Item Types
16
16
  // ============================================================================
17
17
 
18
- export interface SidebarItem {
18
+ interface SidebarItemBase {
19
19
  label: string;
20
- href: string;
21
20
  icon?: string | LucideIcon;
22
21
  badge?: string | number;
23
22
  /** Visual style of `badge`: `'count'` (default, neutral pill) or `'pill'` (accent-tinted, e.g. "lite"/"new"). */
@@ -26,15 +25,39 @@ export interface SidebarItem {
26
25
  tooltip?: string;
27
26
  }
28
27
 
28
+ /** A row that navigates to a route. */
29
+ export interface SidebarLinkItem extends SidebarItemBase {
30
+ href: string;
31
+ settingsSection?: never;
32
+ }
33
+
34
+ /**
35
+ * A row that opens the Settings dialog on a section. It renders only while the mounted `SettingsDialog` lists that
36
+ * section, so there is no dead door when a section is switched off or not registered yet; it is active while the
37
+ * dialog shows the section. `Id` is the section-id union (see `defineSettingsSections`); a bare `SidebarItem` takes any string.
38
+ */
39
+ export interface SidebarSettingsItem<Id extends string = string> extends SidebarItemBase {
40
+ settingsSection: Id;
41
+ href?: never;
42
+ }
43
+
44
+ /** A sidebar row: a link (`href`) or a Settings launcher (`settingsSection`). */
45
+ export type SidebarItem<Id extends string = string> = SidebarLinkItem | SidebarSettingsItem<Id>;
46
+
29
47
  // ============================================================================
30
48
  // Sidebar Group Types
31
49
  // ============================================================================
32
50
 
33
- export interface SidebarGroupConfig {
51
+ export interface SidebarGroupConfig<Id extends string = string> {
34
52
  /** Group label displayed above items */
35
53
  label: string;
36
- /** Items in this group */
37
- items: SidebarItem[];
54
+ /** Items in this group. A group whose items are all `settingsSection` rows for unregistered sections is not rendered. */
55
+ items: SidebarItem<Id>[];
56
+ /**
57
+ * A secondary group (setup, connect): a full divider with extra space above it, and the label below the divider if
58
+ * there is one. The default groups are only a hairline apart. Works in the docked sidebar, the peek panel and the drawer.
59
+ */
60
+ separated?: boolean;
38
61
  /** If true, group is only shown when it has items (for dynamic groups like extensions) */
39
62
  dynamic?: boolean;
40
63
  /** Render group as an accordion (Mailersend/Vercel-style). Label becomes a clickable trigger. */
@@ -73,9 +96,9 @@ export interface SidebarFeaturedConfig {
73
96
  // Sidebar Config
74
97
  // ============================================================================
75
98
 
76
- export interface SidebarConfig {
99
+ export interface SidebarConfig<Id extends string = string> {
77
100
  /** Grouped items with labels */
78
- groups: SidebarGroupConfig[];
101
+ groups: SidebarGroupConfig<Id>[];
79
102
  /** Home link href */
80
103
  homeHref?: string;
81
104
  /**
@@ -64,6 +64,27 @@ const settings = useSettingsDialog();
64
64
  // #settings/api-keys → opens on the API-keys section
65
65
  ```
66
66
 
67
+ ### Typed section ids
68
+
69
+ `useSettingsDialog().open(id)` takes any string by default, so a typo opens the first section silently. Declare the
70
+ sections with `defineSettingsSections` and give the hook the id union; an unknown id is then a compile error.
71
+
72
+ ```tsx
73
+ const sections = defineSettingsSections([mcpSettingsSection(), { id: 'billing', label: 'Billing', content: <Billing /> }]);
74
+ type AppSection = SettingsSectionId<typeof sections>; // 'mcp' | 'billing' | 'account' | 'general' | 'api-keys'
75
+
76
+ <PrivateLayout settings={{ sections }} sidebar={{ groups: [
77
+ { label: 'Workspace', items: [{ label: 'Billing', settingsSection: 'billing' } satisfies SidebarItem<AppSection>] },
78
+ mcpSidebarGroup({ label: 'Connect' }), // separated group, appears only if 'mcp' is registered
79
+ ] }}>
80
+ const { open } = useSettingsDialog<AppSection>(); // open('biling') does not compile
81
+ <a href={settingsHref<AppSection>('billing')}>Billing</a> // "#settings/billing"
82
+ ```
83
+
84
+ `mcpSettingsSection()` returns `id: 'mcp'` as a literal, so it keeps its place in the union; a section whose `id` is a
85
+ plain `string` widens the union to `string` (the old behaviour). Existing `useSettingsDialog()` callers are unchanged.
86
+ `settingsHref(id, urlKey = 'settings')` is the one place that spells the `#settings/<id>` fragment.
87
+
67
88
  ### Inline page
68
89
 
69
90
  ```tsx
@@ -123,7 +123,6 @@ export const AvatarSection = () => {
123
123
  size="sm"
124
124
  onClick={handlePick}
125
125
  disabled={isUploading}
126
- className="h-8"
127
126
  >
128
127
  {isUploading ? 'Uploading…' : 'Change avatar'}
129
128
  </Button>
@@ -13,3 +13,6 @@ export type {
13
13
 
14
14
  export { useAccountMenuItems } from './useAccountMenuItems';
15
15
  export type { UseAccountMenuItemsOptions } from './useAccountMenuItems';
16
+
17
+ export { useSettingsSectionState } from './useSettingsSectionState';
18
+ export type { SettingsSectionState } from './useSettingsSectionState';
@@ -9,6 +9,9 @@
9
9
  * <Button onClick={() => settings.open()}>Settings</Button>
10
10
  * <MenuItem onClick={() => settings.open('billing')}>Billing</MenuItem>
11
11
  *
12
+ * The id is `string` by default. Pass the union of your sections to make a typo a compile error:
13
+ * const settings = useSettingsDialog<SettingsSectionId<typeof sections>>(); // see `defineSettingsSections`
14
+ *
12
15
  * Returns stable callbacks, safe in deps arrays. The dialog itself must be
13
16
  * mounted once in the tree (PrivateLayout does this).
14
17
  */
@@ -17,24 +20,24 @@ import { useCallback, useMemo } from 'react';
17
20
 
18
21
  import { useSettingsDialogStore, type SettingsMenuSection } from '../store';
19
22
 
20
- export interface UseSettingsDialogReturn {
23
+ export interface UseSettingsDialogReturn<Id extends string = string> {
21
24
  /** Whether the dialog is currently open. */
22
25
  isOpen: boolean;
23
26
  /** Currently active section id (null = fall back to first). */
24
- activeSection: string | null;
27
+ activeSection: Id | null;
25
28
  /** Open the dialog, optionally on a specific section. */
26
- open: (sectionId?: string) => void;
29
+ open: (sectionId?: Id) => void;
27
30
  /** Close the dialog. */
28
31
  close: () => void;
29
32
  /** Toggle open/closed. */
30
33
  toggle: () => void;
31
34
  /** Switch the active section. */
32
- setSection: (sectionId: string) => void;
35
+ setSection: (sectionId: Id) => void;
33
36
  /** The visible sections of the mounted dialog (built-in and the host's), in rail order: for menu shortcuts, `open(id)`. */
34
37
  sections: SettingsMenuSection[];
35
38
  }
36
39
 
37
- export function useSettingsDialog(): UseSettingsDialogReturn {
40
+ export function useSettingsDialog<Id extends string = string>(): UseSettingsDialogReturn<Id> {
38
41
  const isOpen = useSettingsDialogStore((s) => s.isOpen);
39
42
  const activeSection = useSettingsDialogStore((s) => s.activeSection);
40
43
  const storeOpen = useSettingsDialogStore((s) => s.open);
@@ -43,13 +46,13 @@ export function useSettingsDialog(): UseSettingsDialogReturn {
43
46
  const storeSetSection = useSettingsDialogStore((s) => s.setSection);
44
47
  const sections = useSettingsDialogStore((s) => s.sections);
45
48
 
46
- const open = useCallback((sectionId?: string) => storeOpen(sectionId), [storeOpen]);
49
+ const open = useCallback((sectionId?: Id) => storeOpen(sectionId), [storeOpen]);
47
50
  const close = useCallback(() => storeClose(), [storeClose]);
48
51
  const toggle = useCallback(() => storeToggle(), [storeToggle]);
49
- const setSection = useCallback((id: string) => storeSetSection(id), [storeSetSection]);
52
+ const setSection = useCallback((id: Id) => storeSetSection(id), [storeSetSection]);
50
53
 
51
54
  return useMemo(
52
- () => ({ isOpen, activeSection, open, close, toggle, setSection, sections }),
55
+ () => ({ isOpen, activeSection: activeSection as Id | null, open, close, toggle, setSection, sections }),
53
56
  [isOpen, activeSection, open, close, toggle, setSection, sections],
54
57
  );
55
58
  }
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useSettingsSectionState — what a launcher of one Settings section needs to know: is the section registered with the
5
+ * mounted dialog (a launcher for a missing section is a dead door), is the dialog showing it now, and how to open it.
6
+ *
7
+ * Reads the same store `useSettingsDialog` does. Registration comes from `<SettingsDialog />`, which lists its visible
8
+ * sections (built-in and the host's) after it mounts, so before that, or with no dialog, nothing is registered.
9
+ */
10
+
11
+ import { useCallback } from 'react';
12
+
13
+ import { useSettingsDialogStore } from '../store';
14
+
15
+ export interface SettingsSectionState {
16
+ /** The dialog lists this section in its rail. */
17
+ registered: boolean;
18
+ /** The dialog is open and showing this section (an open dialog with no pick shows the first one). */
19
+ active: boolean;
20
+ /** Open the dialog on this section. */
21
+ open: () => void;
22
+ }
23
+
24
+ export function useSettingsSectionState(id: string): SettingsSectionState {
25
+ const registered = useSettingsDialogStore((s) => s.sections.some((section) => section.id === id));
26
+ const active = useSettingsDialogStore(
27
+ (s) => s.isOpen && (s.activeSection ?? s.sections[0]?.id ?? null) === id,
28
+ );
29
+ const storeOpen = useSettingsDialogStore((s) => s.open);
30
+ const open = useCallback(() => storeOpen(id), [storeOpen, id]);
31
+ return { registered, active, open };
32
+ }
@@ -43,6 +43,10 @@ export type {
43
43
  ResolvedGroup,
44
44
  } from './hooks';
45
45
 
46
+ // Typed section ids, the `#settings/<id>` address
47
+ export { defineSettingsSections, settingsHref, MCP_SECTION_ID } from './sectionIds';
48
+ export type { SettingsSectionId, BuiltinSettingsSectionId } from './sectionIds';
49
+
46
50
  // Context (for advanced custom shells)
47
51
  export { SettingsProvider, useSettingsContext } from './context';
48
52
  export type { SettingsProviderProps } from './context';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Type tests: tsc (vitest typecheck, and `pnpm check`) must accept the first block and reject every
3
+ * `@ts-expect-error` line. A line that stops being an error fails the build.
4
+ */
5
+
6
+ import type { ComponentType } from 'react';
7
+ import { describe, expectTypeOf, it } from 'vitest';
8
+
9
+ import { MCP_SECTION_ID, defineSettingsSections, settingsHref, type SettingsSectionId } from './sectionIds';
10
+ import { useSettingsDialog } from './hooks/useSettingsDialog';
11
+ import type { SettingsSection } from './types';
12
+
13
+ declare const mcpLike: { id: 'mcp'; label: string; content: null; icon: ComponentType<{ className?: string }> };
14
+
15
+ const sections = defineSettingsSections([mcpLike, { id: 'billing', label: 'Billing', content: null }]);
16
+ type AppId = SettingsSectionId<typeof sections>;
17
+
18
+ describe('typed settings section ids', () => {
19
+ it('the id union is the declared ids plus the built-ins', () => {
20
+ expectTypeOf<AppId>().toEqualTypeOf<'mcp' | 'billing' | 'account' | 'general' | 'api-keys'>();
21
+ expectTypeOf(MCP_SECTION_ID).toEqualTypeOf<'mcp'>();
22
+ });
23
+
24
+ it('the result still goes where a SettingsSection[] goes', () => {
25
+ expectTypeOf(sections).toMatchTypeOf<SettingsSection[]>();
26
+ });
27
+
28
+ it('useSettingsDialog<Id> accepts known ids only', () => {
29
+ const typed = useSettingsDialog<AppId>();
30
+ typed.open('billing');
31
+ typed.setSection('mcp');
32
+ // @ts-expect-error unknown id
33
+ typed.open('biling');
34
+ // @ts-expect-error unknown id
35
+ typed.setSection('nope');
36
+ expectTypeOf(typed.activeSection).toEqualTypeOf<AppId | null>();
37
+ });
38
+
39
+ it('the untyped default still takes any string', () => {
40
+ const loose = useSettingsDialog();
41
+ loose.open('anything');
42
+ loose.setSection('else');
43
+ expectTypeOf(loose.activeSection).toEqualTypeOf<string | null>();
44
+ });
45
+
46
+ it('settingsHref keeps the id in the type', () => {
47
+ expectTypeOf(settingsHref('mcp')).toEqualTypeOf<`#${string}/mcp`>();
48
+ // @ts-expect-error the helper for a typed id rejects other strings
49
+ settingsHref<AppId>('biling');
50
+ });
51
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { parseSettingsHash } from './intent/parseSettingsHash';
4
+ import { MCP_SECTION_ID, defineSettingsSections, settingsHref } from './sectionIds';
5
+
6
+ describe('settingsHref', () => {
7
+ it('spells the fragment the dialog reads', () => {
8
+ expect(settingsHref('mcp')).toBe('#settings/mcp');
9
+ expect(settingsHref('api-keys')).toBe('#settings/api-keys');
10
+ });
11
+
12
+ it('follows a custom urlKey', () => {
13
+ expect(settingsHref('mcp', 'prefs')).toBe('#prefs/mcp');
14
+ });
15
+
16
+ it('round-trips through the parser', () => {
17
+ const fragment = settingsHref('billing').slice(1);
18
+ expect(parseSettingsHash(fragment, 'settings')).toMatchObject({ open: true, section: 'billing' });
19
+ });
20
+ });
21
+
22
+ describe('defineSettingsSections', () => {
23
+ it('returns the very array it was given', () => {
24
+ const list = [{ id: 'billing', label: 'Billing', content: null }];
25
+ expect(defineSettingsSections(list)).toBe(list);
26
+ });
27
+
28
+ it('MCP_SECTION_ID is the id the MCP widget uses', () => {
29
+ expect(MCP_SECTION_ID).toBe('mcp');
30
+ });
31
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Typed section ids.
3
+ *
4
+ * The dialog is mounted once and driven through a global store, so its `sections` prop cannot type the hooks that
5
+ * open it. Declare the sections with `defineSettingsSections` and hand the id union to the hook instead:
6
+ *
7
+ * const sections = defineSettingsSections([mcpSettingsSection(), { id: 'billing', ... }]);
8
+ * type AppSection = SettingsSectionId<typeof sections>; // 'mcp' | 'billing' | built-in ids
9
+ * useSettingsDialog<AppSection>().open('biling'); // compile error
10
+ *
11
+ * Nothing here is runtime weight beyond two one-line functions.
12
+ */
13
+
14
+ import type { SettingsSection } from './types';
15
+
16
+ /** Ids of the sections the layout ships (`builtins`). They are openable whether or not the host switched one off. */
17
+ export type BuiltinSettingsSectionId = 'account' | 'general' | 'api-keys';
18
+
19
+ /**
20
+ * The id of the MCP section. `@djangocfg/widget-mcp` (`mcpSettingsSection()`) uses the same literal; layouts cannot
21
+ * import the widget (a package never depends on a widget), so the two agree by value and each side has a test on it.
22
+ */
23
+ export const MCP_SECTION_ID = 'mcp' as const;
24
+
25
+ /** The ids of a section list: the host's own plus the built-ins. Collapses to `string` if any id is not a literal. */
26
+ export type SettingsSectionId<S extends readonly { id: string }[]> = S[number]['id'] | BuiltinSettingsSectionId;
27
+
28
+ /**
29
+ * Identity function that keeps each section's `id` as a literal type, so `SettingsSectionId<typeof sections>` is a
30
+ * closed union. Accepts the result of `mcpSettingsSection()` as is.
31
+ */
32
+ export function defineSettingsSections<const S extends SettingsSection[]>(sections: S): S {
33
+ return sections;
34
+ }
35
+
36
+ /**
37
+ * The address that opens the dialog on a section: `settingsHref('mcp')` is `"#settings/mcp"`. For a plain link
38
+ * (`href`, `<a>`), a route table or an email; `useSettingsUrl` opens the dialog when the page lands on it.
39
+ * `urlKey` follows `SettingsDialog`'s `urlKey` prop.
40
+ */
41
+ export function settingsHref<Id extends string = string>(id: Id, urlKey = 'settings'): `#${string}/${Id}` {
42
+ return `#${urlKey}/${id}`;
43
+ }
@@ -12,7 +12,7 @@ import {
12
12
 
13
13
  import { AuthFormContext } from '../layouts/AuthLayout/context';
14
14
  import { resolveOAuthProviders } from '../layouts/AuthLayout/components/oauth/oauthProviders';
15
- import type { AuthFormContextType, AuthLayoutConfig } from '../layouts/AuthLayout/types';
15
+ import type { AuthFormContextType, AuthLayoutConfig, OAuthFailure } from '../layouts/AuthLayout/types';
16
16
 
17
17
  // Storybook-only provider. Reproduces the AuthFormContext shape with
18
18
  // in-memory state and no-op async handlers, so step components and the
@@ -117,6 +117,7 @@ const MockAuthFormProviderBase: React.FC<MockAuthFormProviderProps> = ({
117
117
  );
118
118
 
119
119
  const isAutoSubmittingFromUrl = useRef(false);
120
+ const [oauthNotice, setOAuthNotice] = useState<OAuthFailure | null>(null);
120
121
 
121
122
  const value: AuthFormContextType = useMemo(() => {
122
123
  const stop = (e: React.FormEvent) => {
@@ -177,6 +178,8 @@ const MockAuthFormProviderBase: React.FC<MockAuthFormProviderProps> = ({
177
178
  termsUrl,
178
179
  privacyUrl,
179
180
  oauth,
181
+ oauthNotice,
182
+ setOAuthNotice,
180
183
  logo,
181
184
  redirectUrl,
182
185
  marketingConsentConfig,
@@ -197,6 +200,7 @@ const MockAuthFormProviderBase: React.FC<MockAuthFormProviderProps> = ({
197
200
  termsUrl,
198
201
  privacyUrl,
199
202
  oauth,
203
+ oauthNotice,
200
204
  logo,
201
205
  redirectUrl,
202
206
  marketingConsentConfig,