@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
@@ -151,7 +151,8 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
151
151
  | Field | Type | Notes |
152
152
  |---|---|---|
153
153
  | `label` | `string` | Group title. Empty string → flat group (no header). |
154
- | `items` | `SidebarItem[]` | Nav links. |
154
+ | `items` | `SidebarItem[]` | Nav links, or `settingsSection` rows (below). A group whose rows are all launchers for unregistered sections is not rendered. |
155
+ | `separated` | `boolean` | A secondary group (setup, connect): a full divider with air above it, then the label. Ordinary groups are only a hairline apart. Same in the docked sidebar, the peek panel and the drawer (there is no icon rail: a collapsed sidebar is hidden). |
155
156
  | `dynamic` | `boolean` | Hide group when `items` is empty (for extension-driven groups). |
156
157
  | `collapsible` | `boolean` | Render as accordion. Label becomes a clickable trigger; auto-expanded if a child is active. |
157
158
  | `defaultOpen` | `boolean` | Initial open state for collapsible groups. |
@@ -163,9 +164,12 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
163
164
  | Field | Type | Notes |
164
165
  |---|---|---|
165
166
  | `label`, `href`, `icon`, `tooltip` | — | Standard nav item fields. |
167
+ | `settingsSection` | section id | Instead of `href`: a row that opens the Settings dialog on that section. Rendered only while the mounted dialog lists the section (no dead door), active while the dialog shows it. Type it with `SidebarItem<AppSectionId>` (see [SettingsLayout](../SettingsLayout/README.md#typed-section-ids)). |
166
168
  | `badge` | `string \| number` | Trailing badge content. |
167
169
  | `badgeVariant` | `'count' \| 'pill'` | Visual style. `'pill'` is accent-tinted (e.g. "lite"/"new"); `'count'` is neutral (default). |
168
170
 
171
+ **MCP defaults.** `mcpSidebarItem({ label, icon = 'Cable' })` and `mcpSidebarGroup({ label, itemLabel = 'MCP' })` are ready entries for the section `mcpSettingsSection()` (`@djangocfg/widget-mcp`) registers. Layouts matches it by id (`MCP_SECTION_ID`), so it has no dependency on the widget. Add them unconditionally; they vanish while the section is not registered.
172
+
169
173
  `SidebarFeaturedConfig`:
170
174
 
171
175
  | Field | Type | Notes |
@@ -8,6 +8,7 @@
8
8
  import React, { useMemo } from 'react';
9
9
 
10
10
  import { usePrivateLayoutContext } from '../context';
11
+ import { sidebarItemKey } from '../sidebarItems';
11
12
  import { PrivateSidebarAccount } from './PrivateSidebarAccount';
12
13
  import { SidebarBrand } from './SidebarBrand';
13
14
  import { SidebarNavGroup } from './SidebarNavGroup';
@@ -19,9 +20,10 @@ export function SidebarBody() {
19
20
  const groups = useMemo(
20
21
  () =>
21
22
  (sidebar?.groups ?? []).map((group) =>
23
+ // A group of launchers for sections nobody registered renders nothing itself (see SidebarNavGroup).
22
24
  group.dynamic && group.items.length === 0 ? null : (
23
25
  <SidebarNavGroup
24
- key={group.label || `__flat_${group.items.map((i) => i.href).join('|')}`}
26
+ key={group.label || `__flat_${group.items.map(sidebarItemKey).join('|')}`}
25
27
  group={group}
26
28
  />
27
29
  ),
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import { SidebarProvider } from '@djangocfg/ui-core/components';
7
+
8
+ import { useSettingsDialogStore } from '../../SettingsLayout/store';
9
+ import { PrivateLayoutProvider } from '../context';
10
+ import { mcpSidebarGroup } from '../mcpSidebar';
11
+ import type { SidebarConfig, SidebarGroupConfig } from '../types';
12
+ import { SidebarNavGroup } from './SidebarNavGroup';
13
+
14
+ // react-dom + jsdom only: the package declares no testing-library.
15
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ let host: HTMLElement | null = null;
18
+ let root: Root | null = null;
19
+
20
+ /** Elements by ARIA role and accessible text (own text content), enough for these rows. */
21
+ const screen = {
22
+ all(selector: string, name?: string): HTMLElement[] {
23
+ const found = Array.from(host?.querySelectorAll<HTMLElement>(selector) ?? []);
24
+ return name === undefined ? found : found.filter((el) => el.textContent?.trim() === name);
25
+ },
26
+ link: (name: string) => screen.all('a[href]', name)[0] ?? null,
27
+ button: (name: string) => screen.all('button', name)[0] ?? null,
28
+ separators: () => screen.all('[role="separator"]'),
29
+ text: (value: string) => screen.all('span, div, p', value)[0] ?? null,
30
+ };
31
+
32
+ beforeAll(() => {
33
+ // jsdom has no matchMedia; the sidebar's mobile check reads it.
34
+ window.matchMedia ??= ((query: string) => ({
35
+ matches: false,
36
+ media: query,
37
+ onchange: null,
38
+ addEventListener: () => {},
39
+ removeEventListener: () => {},
40
+ addListener: () => {},
41
+ removeListener: () => {},
42
+ dispatchEvent: () => false,
43
+ })) as typeof window.matchMedia;
44
+ });
45
+
46
+ const registerSections = (...ids: string[]) =>
47
+ useSettingsDialogStore.setState({ sections: ids.map((id) => ({ id, label: id })) });
48
+
49
+ function renderGroups(groups: SidebarGroupConfig[], pathname = '/') {
50
+ const sidebar: SidebarConfig = { groups };
51
+ host = document.createElement('div');
52
+ document.body.appendChild(host);
53
+ root = createRoot(host);
54
+ act(() => root!.render(
55
+ <SidebarProvider>
56
+ <PrivateLayoutProvider sidebar={sidebar} header={undefined} pathname={pathname} isMobile={false}>
57
+ {groups.map((group, i) => (
58
+ <SidebarNavGroup key={i} group={group} />
59
+ ))}
60
+ </PrivateLayoutProvider>
61
+ </SidebarProvider>,
62
+ ));
63
+ }
64
+
65
+ const main: SidebarGroupConfig = { label: 'Main', items: [{ label: 'Home', href: '/' }, { label: 'Reports', href: '/reports' }] };
66
+ const connect = mcpSidebarGroup({ label: 'Connect', itemLabel: 'MCP' });
67
+
68
+ beforeEach(() => {
69
+ useSettingsDialogStore.setState({ sections: [], isOpen: false, activeSection: null, intent: null });
70
+ });
71
+ afterEach(() => {
72
+ act(() => root?.unmount());
73
+ host?.remove();
74
+ host = root = null;
75
+ });
76
+
77
+ describe('href rows (unchanged)', () => {
78
+ it('render as links and mark the current route', () => {
79
+ renderGroups([main], '/reports');
80
+ const reports = screen.link('Reports')!;
81
+ expect(reports.getAttribute('href')).toBe('/reports');
82
+ expect(reports.getAttribute('aria-current')).toBe('page');
83
+ expect(screen.link('Home')!.getAttribute('aria-current')).toBeNull();
84
+ expect(screen.separators()).toHaveLength(0);
85
+ });
86
+ });
87
+
88
+ describe('settingsSection rows', () => {
89
+ it('render nothing, and no group, while the section is not registered', () => {
90
+ renderGroups([main, connect]);
91
+ expect(screen.button('MCP')).toBeNull();
92
+ expect(screen.text('Connect')).toBeNull();
93
+ expect(screen.separators()).toHaveLength(0);
94
+ });
95
+
96
+ it('appear once the section is registered, and open the dialog on it', () => {
97
+ registerSections('account', 'mcp');
98
+ renderGroups([main, connect]);
99
+ act(() => screen.button('MCP')!.click());
100
+ const state = useSettingsDialogStore.getState();
101
+ expect(state.isOpen).toBe(true);
102
+ expect(state.activeSection).toBe('mcp');
103
+ });
104
+
105
+ it('are active only while the dialog shows that section', () => {
106
+ registerSections('account', 'mcp');
107
+ renderGroups([main, connect]);
108
+ const row = () => screen.button('MCP')!;
109
+ expect(row().getAttribute('aria-current')).toBeNull();
110
+
111
+ act(() => useSettingsDialogStore.getState().open('account'));
112
+ expect(row().getAttribute('aria-current')).toBeNull();
113
+
114
+ act(() => useSettingsDialogStore.getState().open('mcp'));
115
+ expect(row().getAttribute('aria-current')).toBe('true');
116
+ expect(row().getAttribute('data-active')).toBe('true');
117
+
118
+ act(() => useSettingsDialogStore.getState().close());
119
+ expect(row().getAttribute('aria-current')).toBeNull();
120
+ });
121
+
122
+ it('do not take over the route highlight of href rows', () => {
123
+ registerSections('mcp');
124
+ renderGroups([main, connect], '/');
125
+ expect(screen.link('Home')!.getAttribute('aria-current')).toBe('page');
126
+ });
127
+ });
128
+
129
+ describe('separated groups', () => {
130
+ it('draw a divider above the label', () => {
131
+ registerSections('mcp');
132
+ renderGroups([main, connect]);
133
+ const separators = screen.separators();
134
+ expect(separators).toHaveLength(1);
135
+ const divider = separators[0]!;
136
+ const label = screen.text('Connect')!;
137
+ expect(divider.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
138
+ expect(divider.getAttribute('data-sidebar')).toBe('group-divider');
139
+ });
140
+
141
+ it('draw a bare divider when the label is empty', () => {
142
+ registerSections('mcp');
143
+ renderGroups([main, mcpSidebarGroup({ label: '' })]);
144
+ expect(screen.separators()).toHaveLength(1);
145
+ expect(screen.button('MCP')).not.toBeNull();
146
+ });
147
+
148
+ it('are not drawn for ordinary groups', () => {
149
+ renderGroups([main, { label: 'More', items: [{ label: 'Docs', href: '/docs' }] }]);
150
+ expect(screen.separators()).toHaveLength(0);
151
+ });
152
+ });
@@ -20,8 +20,10 @@ import {
20
20
  import { cn } from '@djangocfg/ui-core/lib';
21
21
 
22
22
  import { LucideIcon } from '../../../components';
23
+ import { useSettingsDialogStore } from '../../SettingsLayout/store';
23
24
  import { shallowEqualBy } from '../../../utils/shallowEqualBy';
24
25
  import { usePrivateLayoutContext } from '../context';
26
+ import { isSettingsItem, isSidebarGroupVisible, sidebarItemKey, visibleSidebarItems } from '../sidebarItems';
25
27
  import type { SidebarGroupConfig } from '../types';
26
28
  import { SidebarNavItem } from './SidebarNavItem';
27
29
 
@@ -30,6 +32,21 @@ const GROUP_CLASS = cn(
30
32
  '[&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-2',
31
33
  );
32
34
 
35
+ /** A `separated` group draws its own, stronger divider (below), so it drops the hairline and takes more air. */
36
+ const SEPARATED_GROUP_CLASS = 'gap-0 px-2 pb-1 pt-0';
37
+
38
+ /** Full-width rule with room above and below; decorative, so the rail and the drawer read the same. */
39
+ function GroupDivider() {
40
+ return (
41
+ <div
42
+ role="separator"
43
+ aria-orientation="horizontal"
44
+ data-sidebar="group-divider"
45
+ className="mx-2 mb-2 mt-3 h-px shrink-0 bg-sidebar-border"
46
+ />
47
+ );
48
+ }
49
+
33
50
  function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
34
51
  const { isActive, geometry } = usePrivateLayoutContext();
35
52
 
@@ -37,9 +54,20 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
37
54
  const isCollapsible = Boolean(group.collapsible) && hasLabel;
38
55
  const hideItemIcons = group.hideItemIcons ?? isCollapsible;
39
56
 
57
+ const menuSections = useSettingsDialogStore((s) => s.sections);
58
+ const dialogSection = useSettingsDialogStore((s) =>
59
+ s.isOpen ? (s.activeSection ?? s.sections[0]?.id ?? null) : null,
60
+ );
61
+ const registered = useMemo(() => new Set(menuSections.map((s) => s.id)), [menuSections]);
62
+ const items = useMemo(() => visibleSidebarItems(group.items, registered), [group.items, registered]);
63
+ const visible = isSidebarGroupVisible(group, registered);
64
+
40
65
  const hasActiveChild = useMemo(
41
- () => group.items.some((item) => isActive(item.href)),
42
- [group.items, isActive],
66
+ () =>
67
+ items.some((item) =>
68
+ isSettingsItem(item) ? item.settingsSection === dialogSection : isActive(item.href),
69
+ ),
70
+ [items, isActive, dialogSection],
43
71
  );
44
72
 
45
73
  const [open, setOpen] = useState(isCollapsible ? Boolean(group.defaultOpen) || hasActiveChild : true);
@@ -50,59 +78,69 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
50
78
 
51
79
  const handleOpenChange = useCallback((value: boolean) => setOpen(value), []);
52
80
 
53
- const items = group.items.map((item) => (
54
- <SidebarNavItem key={item.href} item={item} hideItemIcons={hideItemIcons} />
81
+ const rows = items.map((item) => (
82
+ <SidebarNavItem key={sidebarItemKey(item)} item={item} hideItemIcons={hideItemIcons} />
55
83
  ));
84
+ if (!visible) return null;
85
+
86
+ const groupClass = group.separated ? SEPARATED_GROUP_CLASS : GROUP_CLASS;
87
+ const divider = group.separated ? <GroupDivider /> : null;
56
88
 
57
89
  if (isCollapsible) {
58
90
  return (
59
- <SidebarGroup className={GROUP_CLASS}>
60
- <Collapsible open={open} onOpenChange={handleOpenChange} className="w-full">
61
- <CollapsibleTrigger asChild>
62
- <button
63
- type="button"
64
- aria-expanded={open}
65
- className={cn(
66
- 'flex w-full items-center gap-2.5 rounded-lg font-normal text-sidebar-foreground',
67
- 'transition-colors duration-150 hover:bg-sidebar-accent/60',
68
- 'outline-none ring-sidebar-ring focus-visible:ring-2',
69
- geometry.item,
70
- )}
71
- >
72
- {group.icon ? <LucideIcon icon={group.icon} className="shrink-0 text-sidebar-foreground/60" /> : null}
73
- <span className="flex-1 truncate text-left">{group.label}</span>
74
- <ChevronDown
75
- aria-hidden
91
+ <>
92
+ {divider}
93
+ <SidebarGroup className={groupClass}>
94
+ <Collapsible open={open} onOpenChange={handleOpenChange} className="w-full">
95
+ <CollapsibleTrigger asChild>
96
+ <button
97
+ type="button"
98
+ aria-expanded={open}
76
99
  className={cn(
77
- 'size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200',
78
- open && 'rotate-180',
100
+ 'flex w-full items-center gap-2.5 rounded-lg font-normal text-sidebar-foreground',
101
+ 'transition-colors duration-150 hover:bg-sidebar-accent/60',
102
+ 'outline-none ring-sidebar-ring focus-visible:ring-2',
103
+ geometry.item,
79
104
  )}
80
- />
81
- </button>
82
- </CollapsibleTrigger>
83
- <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
84
- <SidebarGroupContent>
85
- <SidebarMenu className="mt-0.5 gap-0.5">{items}</SidebarMenu>
86
- </SidebarGroupContent>
87
- </CollapsibleContent>
88
- </Collapsible>
89
- </SidebarGroup>
105
+ >
106
+ {group.icon ? <LucideIcon icon={group.icon} className="shrink-0 text-sidebar-foreground/60" /> : null}
107
+ <span className="flex-1 truncate text-left">{group.label}</span>
108
+ <ChevronDown
109
+ aria-hidden
110
+ className={cn(
111
+ 'size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200',
112
+ open && 'rotate-180',
113
+ )}
114
+ />
115
+ </button>
116
+ </CollapsibleTrigger>
117
+ <CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
118
+ <SidebarGroupContent>
119
+ <SidebarMenu className="mt-0.5 gap-0.5">{rows}</SidebarMenu>
120
+ </SidebarGroupContent>
121
+ </CollapsibleContent>
122
+ </Collapsible>
123
+ </SidebarGroup>
124
+ </>
90
125
  );
91
126
  }
92
127
 
93
128
  return (
94
- <SidebarGroup className={GROUP_CLASS}>
95
- {hasLabel ? (
96
- <SidebarGroupLabel
97
- className={cn('font-medium normal-case tracking-normal text-sidebar-foreground/50', geometry.label)}
98
- >
99
- {group.label}
100
- </SidebarGroupLabel>
101
- ) : null}
102
- <SidebarGroupContent>
103
- <SidebarMenu className="gap-0.5">{items}</SidebarMenu>
104
- </SidebarGroupContent>
105
- </SidebarGroup>
129
+ <>
130
+ {divider}
131
+ <SidebarGroup className={groupClass}>
132
+ {hasLabel ? (
133
+ <SidebarGroupLabel
134
+ className={cn('font-medium normal-case tracking-normal text-sidebar-foreground/50', geometry.label)}
135
+ >
136
+ {group.label}
137
+ </SidebarGroupLabel>
138
+ ) : null}
139
+ <SidebarGroupContent>
140
+ <SidebarMenu className="gap-0.5">{rows}</SidebarMenu>
141
+ </SidebarGroupContent>
142
+ </SidebarGroup>
143
+ </>
106
144
  );
107
145
  }
108
146
 
@@ -112,12 +150,14 @@ const groupEqual = (a: SidebarGroupConfig, b: SidebarGroupConfig) =>
112
150
  a.defaultOpen === b.defaultOpen &&
113
151
  a.icon === b.icon &&
114
152
  a.hideItemIcons === b.hideItemIcons &&
153
+ a.separated === b.separated &&
115
154
  a.dynamic === b.dynamic &&
116
155
  shallowEqualBy(
117
156
  a.items,
118
157
  b.items,
119
158
  (x, y) =>
120
159
  x.href === y.href &&
160
+ x.settingsSection === y.settingsSection &&
121
161
  x.label === y.label &&
122
162
  x.icon === y.icon &&
123
163
  x.badge === y.badge &&
@@ -17,7 +17,9 @@ import { cn } from '@djangocfg/ui-core/lib';
17
17
 
18
18
  import { LucideIcon } from '../../../components';
19
19
  import { usePrivateLayoutContext } from '../context';
20
- import type { SidebarItem } from '../types';
20
+ import { useSettingsSectionState } from '../../SettingsLayout/hooks/useSettingsSectionState';
21
+ import { isSettingsItem } from '../sidebarItems';
22
+ import type { SidebarItem, SidebarLinkItem, SidebarSettingsItem } from '../types';
21
23
 
22
24
  interface SidebarNavItemProps {
23
25
  item: SidebarItem;
@@ -32,7 +34,47 @@ const ITEM_CLASS = cn(
32
34
  'data-[active=true]:[&>svg]:text-sidebar-foreground',
33
35
  );
34
36
 
35
- function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
37
+ function ItemContent({ item, hideItemIcons }: SidebarNavItemProps) {
38
+ return (
39
+ <>
40
+ {!hideItemIcons && item.icon ? <LucideIcon icon={item.icon} /> : null}
41
+ <span>{item.label}</span>
42
+ {item.badge ? (
43
+ <SidebarMenuBadge
44
+ className={cn(item.badgeVariant === 'pill' && 'rounded-md bg-primary/15 px-1.5 font-medium text-primary')}
45
+ >
46
+ {item.badge}
47
+ </SidebarMenuBadge>
48
+ ) : null}
49
+ </>
50
+ );
51
+ }
52
+
53
+ /**
54
+ * A row that opens Settings on a section. Not rendered while the dialog does not list the section (no dead door),
55
+ * active while the dialog shows it. The dialog owns the URL, so a row never writes the address itself.
56
+ */
57
+ function SidebarSettingsNavItem({ item, hideItemIcons }: { item: SidebarSettingsItem; hideItemIcons?: boolean }) {
58
+ const { geometry } = usePrivateLayoutContext();
59
+ const { registered, active, open } = useSettingsSectionState(item.settingsSection);
60
+ if (!registered) return null;
61
+
62
+ return (
63
+ <SidebarMenuItem>
64
+ <SidebarMenuButton
65
+ isActive={active}
66
+ onClick={open}
67
+ aria-haspopup="dialog"
68
+ aria-current={active ? 'true' : undefined}
69
+ className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
70
+ >
71
+ <ItemContent item={item} hideItemIcons={hideItemIcons} />
72
+ </SidebarMenuButton>
73
+ </SidebarMenuItem>
74
+ );
75
+ }
76
+
77
+ function SidebarLinkNavItem({ item, hideItemIcons }: { item: SidebarLinkItem; hideItemIcons?: boolean }) {
36
78
  const { isActive, geometry } = usePrivateLayoutContext();
37
79
  const active = isActive(item.href);
38
80
 
@@ -44,25 +86,24 @@ function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
44
86
  className={cn(ITEM_CLASS, geometry.item, hideItemIcons && 'pl-8')}
45
87
  >
46
88
  <Link href={item.href} aria-current={active ? 'page' : undefined}>
47
- {!hideItemIcons && item.icon ? <LucideIcon icon={item.icon} /> : null}
48
- <span>{item.label}</span>
49
- {item.badge ? (
50
- <SidebarMenuBadge
51
- className={cn(
52
- item.badgeVariant === 'pill' && 'rounded-md bg-primary/15 px-1.5 font-medium text-primary',
53
- )}
54
- >
55
- {item.badge}
56
- </SidebarMenuBadge>
57
- ) : null}
89
+ <ItemContent item={item} hideItemIcons={hideItemIcons} />
58
90
  </Link>
59
91
  </SidebarMenuButton>
60
92
  </SidebarMenuItem>
61
93
  );
62
94
  }
63
95
 
96
+ function SidebarNavItemRaw({ item, hideItemIcons }: SidebarNavItemProps) {
97
+ return isSettingsItem(item) ? (
98
+ <SidebarSettingsNavItem item={item} hideItemIcons={hideItemIcons} />
99
+ ) : (
100
+ <SidebarLinkNavItem item={item} hideItemIcons={hideItemIcons} />
101
+ );
102
+ }
103
+
64
104
  const itemEqual = (a: SidebarItem, b: SidebarItem) =>
65
105
  a.href === b.href &&
106
+ a.settingsSection === b.settingsSection &&
66
107
  a.label === b.label &&
67
108
  a.icon === b.icon &&
68
109
  a.badge === b.badge &&
@@ -11,7 +11,8 @@ import React, { createContext, useContext, useMemo } from 'react';
11
11
 
12
12
  import { NAV_GEOMETRY } from './geometry';
13
13
  import type { NavGeometry } from './geometry';
14
- import type { HeaderConfig, SidebarConfig, SidebarItem } from './types';
14
+ import { isLinkItem } from './sidebarItems';
15
+ import type { HeaderConfig, SidebarConfig, SidebarLinkItem } from './types';
15
16
 
16
17
  export interface PrivateLayoutContextValue {
17
18
  sidebar: SidebarConfig | undefined;
@@ -19,7 +20,8 @@ export interface PrivateLayoutContextValue {
19
20
  pathname: string;
20
21
  isMobile: boolean;
21
22
  geometry: NavGeometry;
22
- allItems: SidebarItem[];
23
+ /** The route rows; Settings launchers have no route and take no part in the active-route check. */
24
+ allItems: SidebarLinkItem[];
23
25
  isActive: (href: string) => boolean;
24
26
  homeHref: string;
25
27
  brandTitle: string;
@@ -60,7 +62,7 @@ export function PrivateLayoutProvider({
60
62
  ).toUpperCase();
61
63
 
62
64
  const allItems = useMemo(
63
- () => sidebar?.groups.flatMap((g) => g.items) ?? [],
65
+ () => sidebar?.groups.flatMap((g) => g.items).filter(isLinkItem) ?? [],
64
66
  [sidebar?.groups],
65
67
  );
66
68
 
@@ -4,11 +4,15 @@
4
4
 
5
5
  export { PrivateLayout } from './PrivateLayout';
6
6
  export { PrivateLayoutProvider, usePrivateLayoutContext } from './context';
7
+ export { mcpSidebarItem, mcpSidebarGroup } from './mcpSidebar';
8
+ export type { McpSidebarItemOptions, McpSidebarGroupOptions } from './mcpSidebar';
7
9
  export { SidebarBrandSwitcher, ForbiddenState } from './components';
8
10
  export type { ForbiddenStateProps } from './components';
9
11
  export type {
10
12
  PrivateLayoutProps,
11
13
  SidebarItem,
14
+ SidebarLinkItem,
15
+ SidebarSettingsItem,
12
16
  SidebarGroupConfig,
13
17
  SidebarConfig,
14
18
  HeaderConfig,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Ready sidebar entries for the MCP section (`mcpSettingsSection()` from `@djangocfg/widget-mcp`).
3
+ *
4
+ * They name the section by id only (`MCP_SECTION_ID`), so layouts holds no import of the widget. They render only
5
+ * while the host registered that section: add them to the config unconditionally.
6
+ */
7
+
8
+ import type { LucideIcon } from 'lucide-react';
9
+
10
+ import { MCP_SECTION_ID } from '../SettingsLayout/sectionIds';
11
+ import type { SidebarGroupConfig, SidebarSettingsItem } from './types';
12
+
13
+ export interface McpSidebarItemOptions {
14
+ /** The row's text. The host's to translate. */
15
+ label: string;
16
+ /** Lucide icon name or component. Default `"Cable"`. */
17
+ icon?: string | LucideIcon;
18
+ }
19
+
20
+ export interface McpSidebarGroupOptions extends Partial<McpSidebarItemOptions> {
21
+ /** The group's label, under the divider. Use `''` for a bare divider. */
22
+ label: string;
23
+ /** The row's text. Default `"MCP"` (a product name, not translated). */
24
+ itemLabel?: string;
25
+ }
26
+
27
+ /** A row that opens Settings on the MCP section; hidden while the section is not registered. */
28
+ export function mcpSidebarItem({ label, icon = 'Cable' }: McpSidebarItemOptions): SidebarSettingsItem<typeof MCP_SECTION_ID> {
29
+ return { label, icon, settingsSection: MCP_SECTION_ID };
30
+ }
31
+
32
+ /** A separated group holding the MCP row; the whole group is hidden while the section is not registered. */
33
+ export function mcpSidebarGroup({ label, itemLabel = 'MCP', icon }: McpSidebarGroupOptions): SidebarGroupConfig<typeof MCP_SECTION_ID> {
34
+ return { label, separated: true, items: [mcpSidebarItem({ label: itemLabel, icon })] };
35
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { mcpSidebarGroup, mcpSidebarItem } from './mcpSidebar';
4
+ import { isSidebarGroupVisible, sidebarItemKey, visibleSidebarItems } from './sidebarItems';
5
+ import type { SidebarGroupConfig, SidebarItem } from './types';
6
+
7
+ const link: SidebarItem = { label: 'Home', href: '/' };
8
+ const mcp = mcpSidebarItem({ label: 'MCP' });
9
+ const none: ReadonlySet<string> = new Set();
10
+ const withMcp: ReadonlySet<string> = new Set(['account', 'mcp']);
11
+
12
+ describe('visibleSidebarItems', () => {
13
+ it('keeps links whatever is registered', () => {
14
+ expect(visibleSidebarItems([link], none)).toEqual([link]);
15
+ });
16
+
17
+ it('drops a launcher for a section that is not registered (no dead door)', () => {
18
+ expect(visibleSidebarItems([link, mcp], none)).toEqual([link]);
19
+ expect(visibleSidebarItems([link, mcp], withMcp)).toEqual([link, mcp]);
20
+ });
21
+ });
22
+
23
+ describe('isSidebarGroupVisible', () => {
24
+ it('hides a group whose launchers are all unregistered, label and divider with it', () => {
25
+ const group = mcpSidebarGroup({ label: 'Connect' });
26
+ expect(isSidebarGroupVisible(group, none)).toBe(false);
27
+ expect(isSidebarGroupVisible(group, withMcp)).toBe(true);
28
+ });
29
+
30
+ it('keeps a mixed group while one row survives', () => {
31
+ const group: SidebarGroupConfig = { label: 'Tools', items: [link, mcp] };
32
+ expect(isSidebarGroupVisible(group, none)).toBe(true);
33
+ });
34
+
35
+ it('keeps the old rules for href-only groups', () => {
36
+ expect(isSidebarGroupVisible({ label: 'A', items: [link] }, none)).toBe(true);
37
+ expect(isSidebarGroupVisible({ label: 'A', items: [], dynamic: true }, none)).toBe(false);
38
+ expect(isSidebarGroupVisible({ label: 'A', items: [] }, none)).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe('keys and the MCP defaults', () => {
43
+ it('keys a link by href and a launcher by section', () => {
44
+ expect(sidebarItemKey(link)).toBe('/');
45
+ expect(sidebarItemKey(mcp)).toBe('settings:mcp');
46
+ });
47
+
48
+ it('mcpSidebarItem defaults the icon to Cable and names the section by id only', () => {
49
+ expect(mcp).toEqual({ label: 'MCP', icon: 'Cable', settingsSection: 'mcp' });
50
+ expect(mcpSidebarItem({ label: 'X', icon: 'Plug' }).icon).toBe('Plug');
51
+ });
52
+
53
+ it('mcpSidebarGroup is a separated group with one MCP row', () => {
54
+ const group = mcpSidebarGroup({ label: 'Connect', itemLabel: 'Assistants' });
55
+ expect(group).toMatchObject({ label: 'Connect', separated: true });
56
+ expect(group.items).toEqual([{ label: 'Assistants', icon: 'Cable', settingsSection: 'mcp' }]);
57
+ });
58
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure helpers over sidebar config: which rows are launchers, what is visible, a stable key.
3
+ * No React and no store, so the rules are tested without rendering.
4
+ */
5
+
6
+ import type { SidebarGroupConfig, SidebarItem, SidebarLinkItem, SidebarSettingsItem } from './types';
7
+
8
+ export function isSettingsItem<Id extends string>(item: SidebarItem<Id>): item is SidebarSettingsItem<Id> {
9
+ return typeof item.settingsSection === 'string';
10
+ }
11
+
12
+ export function isLinkItem(item: SidebarItem): item is SidebarLinkItem {
13
+ return typeof item.href === 'string';
14
+ }
15
+
16
+ /** Identity of a row for React keys and memo checks. */
17
+ export function sidebarItemKey(item: SidebarItem): string {
18
+ return isSettingsItem(item) ? `settings:${item.settingsSection}` : (item as SidebarLinkItem).href;
19
+ }
20
+
21
+ /**
22
+ * The rows of a group that may render: links always, launchers only for a registered section (no dead door).
23
+ * `registered` is the id set the mounted SettingsDialog lists.
24
+ */
25
+ export function visibleSidebarItems(items: SidebarItem[], registered: ReadonlySet<string>): SidebarItem[] {
26
+ return items.filter((item) => !isSettingsItem(item) || registered.has(item.settingsSection));
27
+ }
28
+
29
+ /**
30
+ * Whether a group renders at all. A `dynamic` group needs a row; any group that was given rows needs one of them to
31
+ * survive, so a "Connect" group whose only row is an unregistered section leaves no label and no divider behind.
32
+ * A group declared with no rows keeps its old behaviour (it renders, as a label).
33
+ */
34
+ export function isSidebarGroupVisible(group: SidebarGroupConfig, registered: ReadonlySet<string>): boolean {
35
+ if (group.dynamic && group.items.length === 0) return false;
36
+ if (group.items.length === 0) return true;
37
+ return visibleSidebarItems(group.items, registered).length > 0;
38
+ }