@actuate-media/cms-admin 0.66.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +36 -9
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
- package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
- package/dist/__tests__/components/inspector-pane.test.js +31 -0
- package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.js +173 -0
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
- package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +77 -1
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/InspectorPane.d.ts +51 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -0
- package/dist/components/ui/InspectorPane.js +42 -0
- package/dist/components/ui/InspectorPane.js.map +1 -0
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +11 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +8 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +44 -0
- package/dist/lib/pages-tree.d.ts.map +1 -0
- package/dist/lib/pages-tree.js +106 -0
- package/dist/lib/pages-tree.js.map +1 -0
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +31 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -2
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +25 -0
- package/dist/views/MediaSeoAudit.d.ts.map +1 -0
- package/dist/views/MediaSeoAudit.js +97 -0
- package/dist/views/MediaSeoAudit.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +90 -10
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
- package/dist/views/Pages/PagesTreeTable.js +28 -0
- package/dist/views/Pages/PagesTreeTable.js.map +1 -0
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/ScriptTags.d.ts +5 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +32 -7
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
- package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
- package/dist/views/dashboard/OnboardingBlock.js +71 -0
- package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -3
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +42 -3
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +43 -10
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
- package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/index.ts +10 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-tree.ts +145 -0
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Dashboard.tsx +238 -196
- package/src/views/Forms.tsx +154 -126
- package/src/views/MediaBrowser.tsx +641 -511
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +352 -183
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/dist/views/ScriptTagEditor.d.ts +0 -6
- package/dist/views/ScriptTagEditor.d.ts.map +0 -1
- package/dist/views/ScriptTagEditor.js +0 -111
- package/dist/views/ScriptTagEditor.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
- package/src/views/ScriptTagEditor.tsx +0 -353
|
@@ -6,18 +6,44 @@ const EMPTY_FORM = {
|
|
|
6
6
|
mfaRequired: false,
|
|
7
7
|
mfaGracePeriodDays: 7,
|
|
8
8
|
maxConcurrentSessions: 5,
|
|
9
|
+
timeoutHours: 168,
|
|
10
|
+
idleLogoutEnabled: false,
|
|
11
|
+
ipAllowlistText: '',
|
|
12
|
+
auditLoggingEnabled: true,
|
|
9
13
|
};
|
|
14
|
+
/** Split the textarea into trimmed, de-duplicated entries (one per line). */
|
|
15
|
+
export function parseAllowlistText(text) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const line of text.split('\n')) {
|
|
18
|
+
const entry = line.trim();
|
|
19
|
+
if (entry && !out.includes(entry))
|
|
20
|
+
out.push(entry);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
10
24
|
function formFromSettings(s) {
|
|
11
25
|
return {
|
|
12
26
|
mfaRequired: s.mfa.required,
|
|
13
27
|
mfaGracePeriodDays: s.mfa.gracePeriodDays,
|
|
14
28
|
maxConcurrentSessions: s.session.maxConcurrentSessions,
|
|
29
|
+
timeoutHours: s.session.timeoutHours ?? 168,
|
|
30
|
+
idleLogoutEnabled: s.session.idleLogoutEnabled ?? false,
|
|
31
|
+
ipAllowlistText: (s.access?.ipAllowlist ?? []).join('\n'),
|
|
32
|
+
auditLoggingEnabled: s.access?.auditLoggingEnabled ?? true,
|
|
15
33
|
};
|
|
16
34
|
}
|
|
17
35
|
function formToSettings(f) {
|
|
18
36
|
return {
|
|
19
37
|
mfa: { required: f.mfaRequired, gracePeriodDays: f.mfaGracePeriodDays },
|
|
20
|
-
session: {
|
|
38
|
+
session: {
|
|
39
|
+
maxConcurrentSessions: f.maxConcurrentSessions,
|
|
40
|
+
timeoutHours: f.timeoutHours,
|
|
41
|
+
idleLogoutEnabled: f.idleLogoutEnabled,
|
|
42
|
+
},
|
|
43
|
+
access: {
|
|
44
|
+
ipAllowlist: parseAllowlistText(f.ipAllowlistText),
|
|
45
|
+
auditLoggingEnabled: f.auditLoggingEnabled,
|
|
46
|
+
},
|
|
21
47
|
};
|
|
22
48
|
}
|
|
23
49
|
/**
|
|
@@ -35,6 +61,7 @@ export function useSecuritySettings(canEdit) {
|
|
|
35
61
|
const [form, setForm] = useState(EMPTY_FORM);
|
|
36
62
|
const [saveState, setSaveState] = useState('idle');
|
|
37
63
|
const [saveError, setSaveError] = useState(null);
|
|
64
|
+
const [lockoutPrompt, setLockoutPrompt] = useState(null);
|
|
38
65
|
const [revokeState, setRevokeState] = useState('idle');
|
|
39
66
|
const [reloadKey, setReloadKey] = useState(0);
|
|
40
67
|
useEffect(() => {
|
|
@@ -85,18 +112,30 @@ export function useSecuritySettings(canEdit) {
|
|
|
85
112
|
return list;
|
|
86
113
|
}, [form, baseline.mfaRequired, data]);
|
|
87
114
|
const hasErrors = issues.some((i) => i.severity === 'error');
|
|
88
|
-
const save = useCallback(async () => {
|
|
115
|
+
const save = useCallback(async (options) => {
|
|
89
116
|
if (!canEdit || hasErrors || !dirty)
|
|
90
117
|
return;
|
|
91
118
|
setSaveState('saving');
|
|
92
119
|
setSaveError(null);
|
|
120
|
+
setLockoutPrompt(null);
|
|
93
121
|
try {
|
|
94
122
|
const res = await cmsApi('/security', {
|
|
95
123
|
method: 'PUT',
|
|
96
|
-
body: JSON.stringify(
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
...formToSettings(form),
|
|
126
|
+
...(options?.confirmLockout ? { confirmLockout: true } : {}),
|
|
127
|
+
}),
|
|
97
128
|
});
|
|
98
|
-
|
|
129
|
+
// The server's lockout guard: surface a confirm dialog instead of a
|
|
130
|
+
// generic failure so the operator can retry with confirmLockout.
|
|
131
|
+
if (res.code === 'IP_LOCKOUT_RISK') {
|
|
132
|
+
setSaveState('idle');
|
|
133
|
+
setLockoutPrompt(res.error ?? 'This allowlist may lock you out. Save anyway?');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (res.error || !res.data) {
|
|
99
137
|
throw new Error(res.error ?? 'Failed to save security settings.');
|
|
138
|
+
}
|
|
100
139
|
setData(res.data);
|
|
101
140
|
const f = formFromSettings(res.data.settings);
|
|
102
141
|
setBaseline(f);
|
|
@@ -108,10 +147,12 @@ export function useSecuritySettings(canEdit) {
|
|
|
108
147
|
setSaveError(err instanceof Error ? err.message : 'Failed to save security settings.');
|
|
109
148
|
}
|
|
110
149
|
}, [canEdit, hasErrors, dirty, form]);
|
|
150
|
+
const dismissLockoutPrompt = useCallback(() => setLockoutPrompt(null), []);
|
|
111
151
|
const reset = useCallback(() => {
|
|
112
152
|
setForm(baseline);
|
|
113
153
|
setSaveState('idle');
|
|
114
154
|
setSaveError(null);
|
|
155
|
+
setLockoutPrompt(null);
|
|
115
156
|
}, [baseline]);
|
|
116
157
|
const refetch = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
117
158
|
const revokeAllOtherSessions = useCallback(async () => {
|
|
@@ -147,6 +188,8 @@ export function useSecuritySettings(canEdit) {
|
|
|
147
188
|
save,
|
|
148
189
|
reset,
|
|
149
190
|
refetch,
|
|
191
|
+
lockoutPrompt,
|
|
192
|
+
dismissLockoutPrompt,
|
|
150
193
|
revokeState,
|
|
151
194
|
revokeAllOtherSessions,
|
|
152
195
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSecuritySettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACjE,OAAO,EACL,wBAAwB,GAGzB,MAAM,yCAAyC,CAAA;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"useSecuritySettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"tailwindcss": "^4.0.0",
|
|
96
96
|
"typescript": "^5.7.0",
|
|
97
97
|
"vitest": "^4.1.0",
|
|
98
|
-
"@actuate-media/cms-core": "0.
|
|
98
|
+
"@actuate-media/cms-core": "0.88.0",
|
|
99
99
|
"@actuate-media/component-blocks": "0.2.1"
|
|
100
100
|
},
|
|
101
101
|
"scripts": {
|
package/src/AdminRoot.tsx
CHANGED
|
@@ -26,7 +26,6 @@ import { FormSubmissions } from './views/FormSubmissions.js'
|
|
|
26
26
|
import { FormEntries } from './views/FormEntries.js'
|
|
27
27
|
import { SEO } from './views/SEO.js'
|
|
28
28
|
import { ScriptTags } from './views/ScriptTags.js'
|
|
29
|
-
import { ScriptTagEditor } from './views/ScriptTagEditor.js'
|
|
30
29
|
import { SetupWizard } from './views/SetupWizard.js'
|
|
31
30
|
import { Login } from './views/Login.js'
|
|
32
31
|
import { HiddenLoginNotFound } from './views/HiddenLoginNotFound.js'
|
|
@@ -39,6 +38,7 @@ import { ThemeProvider } from './components/ThemeProvider.js'
|
|
|
39
38
|
import { LocaleProvider } from './components/LocaleProvider.js'
|
|
40
39
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js'
|
|
41
40
|
import { useAppearanceBootstrap } from './hooks/useAppearanceBootstrap.js'
|
|
41
|
+
import { useIdleLogout } from './hooks/useIdleLogout.js'
|
|
42
42
|
import { PageBuilder } from './views/page-builder/PageBuilder.js'
|
|
43
43
|
import { PageSectionEditor } from './views/page-editor/PageSectionEditor.js'
|
|
44
44
|
import { PagePreview } from './views/page-editor/PagePreview.js'
|
|
@@ -53,6 +53,7 @@ import { PagesListView } from './views/Pages/PagesListView.js'
|
|
|
53
53
|
import { PageTagManager } from './views/Pages/PageTagManager.js'
|
|
54
54
|
import { PostsListView } from './views/Posts/PostsListView.js'
|
|
55
55
|
import { PostTypesView } from './views/Posts/PostTypesView.js'
|
|
56
|
+
import { PostTypeEditor } from './views/Posts/PostTypeEditor.js'
|
|
56
57
|
import { NewPostPicker } from './views/Posts/NewPostPicker.js'
|
|
57
58
|
import { NewPostTypeInstructions } from './views/Posts/NewPostTypeInstructions.js'
|
|
58
59
|
import { PostSectionEditor } from './views/post-editor/PostSectionEditor.js'
|
|
@@ -180,6 +181,39 @@ function AdminShell({
|
|
|
180
181
|
[buildHref],
|
|
181
182
|
)
|
|
182
183
|
|
|
184
|
+
// Idle auto-logout (Settings → Security). The policy is read from the
|
|
185
|
+
// non-admin-gated /auth/session-policy endpoint so every member's client
|
|
186
|
+
// enforces it, not just admins. When enabled, `timeoutHours` of inactivity
|
|
187
|
+
// signs the user out through the normal logout flow (server revocation +
|
|
188
|
+
// cookie clear + local state reset).
|
|
189
|
+
const [sessionPolicy, setSessionPolicy] = useState<{
|
|
190
|
+
timeoutHours: number
|
|
191
|
+
idleLogoutEnabled: boolean
|
|
192
|
+
} | null>(null)
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!session) {
|
|
195
|
+
setSessionPolicy(null)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
let cancelled = false
|
|
199
|
+
void cmsApi<{ timeoutHours: number; idleLogoutEnabled: boolean }>('/auth/session-policy').then(
|
|
200
|
+
(res) => {
|
|
201
|
+
if (!cancelled && res.data && typeof res.data.timeoutHours === 'number') {
|
|
202
|
+
setSessionPolicy(res.data)
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
return () => {
|
|
207
|
+
cancelled = true
|
|
208
|
+
}
|
|
209
|
+
}, [session])
|
|
210
|
+
|
|
211
|
+
useIdleLogout({
|
|
212
|
+
enabled: Boolean(session && sessionPolicy?.idleLogoutEnabled),
|
|
213
|
+
timeoutMs: (sessionPolicy?.timeoutHours ?? 0) * 3_600_000,
|
|
214
|
+
onIdle: handleLogout,
|
|
215
|
+
})
|
|
216
|
+
|
|
183
217
|
useBranding(config)
|
|
184
218
|
useAppearanceBootstrap(session)
|
|
185
219
|
useAdminPageTitle(config, currentPath)
|
|
@@ -359,13 +393,7 @@ function AdminShell({
|
|
|
359
393
|
}
|
|
360
394
|
const postTypeEdit = matchRoute('/posts/types/:slug/edit')
|
|
361
395
|
if (postTypeEdit?.slug) {
|
|
362
|
-
|
|
363
|
-
// PR introduces a dedicated PostTypeEditor with field-level
|
|
364
|
-
// metadata. The list view is the natural per-type management
|
|
365
|
-
// surface today (it already shows posts in that type).
|
|
366
|
-
return (
|
|
367
|
-
<CollectionList collectionSlug={postTypeEdit.slug} config={config} onNavigate={navigate} />
|
|
368
|
-
)
|
|
396
|
+
return <PostTypeEditor slug={postTypeEdit.slug} onNavigate={navigate} />
|
|
369
397
|
}
|
|
370
398
|
if (matchRoute('/posts/types')) {
|
|
371
399
|
return <PostTypesView onNavigate={navigate} />
|
|
@@ -432,8 +460,13 @@ function AdminShell({
|
|
|
432
460
|
return <PageTagManager onNavigate={navigate} />
|
|
433
461
|
}
|
|
434
462
|
if (matchRoute('/pages/new')) {
|
|
463
|
+
// "Add sub-page" in the Pages tree view arrives with the parent id
|
|
464
|
+
// in the query string (`/pages/new?parent=<id>`); matchRoute ignores
|
|
465
|
+
// the query so it's parsed here and preselected in the editor.
|
|
466
|
+
const newPageQuery = new URLSearchParams(currentPath.split('?')[1] ?? '')
|
|
435
467
|
return (
|
|
436
468
|
<PageSectionEditor
|
|
469
|
+
initialParentId={newPageQuery.get('parent')}
|
|
437
470
|
config={config}
|
|
438
471
|
session={session}
|
|
439
472
|
onNavigate={navigate}
|
|
@@ -509,11 +542,11 @@ function AdminShell({
|
|
|
509
542
|
}
|
|
510
543
|
|
|
511
544
|
if (matchRoute('/script-tags/new')) {
|
|
512
|
-
return <
|
|
545
|
+
return <ScriptTags onNavigate={navigate} initialNew />
|
|
513
546
|
}
|
|
514
547
|
const scriptTagEdit = matchRoute('/script-tags/:id')
|
|
515
548
|
if (scriptTagEdit?.id) {
|
|
516
|
-
return <
|
|
549
|
+
return <ScriptTags onNavigate={navigate} initialSelectedId={scriptTagEdit.id} />
|
|
517
550
|
}
|
|
518
551
|
if (matchRoute('/script-tags')) {
|
|
519
552
|
return <ScriptTags onNavigate={navigate} />
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
InspectorPane,
|
|
6
|
+
InspectorPaneBody,
|
|
7
|
+
InspectorPaneFooter,
|
|
8
|
+
InspectorPaneHeader,
|
|
9
|
+
InspectorPaneSection,
|
|
10
|
+
SplitPaneLayout,
|
|
11
|
+
SplitPaneList,
|
|
12
|
+
} from '../../components/ui/InspectorPane.js'
|
|
13
|
+
|
|
14
|
+
function Pane({ open }: { open: boolean }) {
|
|
15
|
+
return (
|
|
16
|
+
<SplitPaneLayout>
|
|
17
|
+
<SplitPaneList>
|
|
18
|
+
<table aria-label="Rows" />
|
|
19
|
+
</SplitPaneList>
|
|
20
|
+
<InspectorPane open={open} width={420} aria-label="Row editor">
|
|
21
|
+
<InspectorPaneHeader onClose={() => {}}>
|
|
22
|
+
<span>Editing</span>
|
|
23
|
+
</InspectorPaneHeader>
|
|
24
|
+
<InspectorPaneBody>
|
|
25
|
+
<InspectorPaneSection title="Details">content</InspectorPaneSection>
|
|
26
|
+
</InspectorPaneBody>
|
|
27
|
+
<InspectorPaneFooter>
|
|
28
|
+
<button type="button">Save</button>
|
|
29
|
+
</InspectorPaneFooter>
|
|
30
|
+
</InspectorPane>
|
|
31
|
+
</SplitPaneLayout>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('InspectorPane', () => {
|
|
36
|
+
it('renders pane content with a labelled region when open', () => {
|
|
37
|
+
render(<Pane open />)
|
|
38
|
+
expect(screen.getByRole('region', { name: 'Row editor' })).toBeTruthy()
|
|
39
|
+
expect(screen.getByText('Editing')).toBeTruthy()
|
|
40
|
+
expect(screen.getByText('Details')).toBeTruthy()
|
|
41
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('collapses to zero width and hides content when closed', () => {
|
|
45
|
+
const { container } = render(<Pane open={false} />)
|
|
46
|
+
const shell = container.querySelector('[aria-hidden="true"]') as HTMLElement
|
|
47
|
+
expect(shell).toBeTruthy()
|
|
48
|
+
expect(shell.style.width).toBe('0px')
|
|
49
|
+
expect(screen.queryByText('Editing')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('close button in the header fires onClose with an accessible label', () => {
|
|
53
|
+
const onClose = vi.fn()
|
|
54
|
+
render(
|
|
55
|
+
<InspectorPane open width={400} aria-label="Detail">
|
|
56
|
+
<InspectorPaneHeader onClose={onClose} closeLabel="Close detail">
|
|
57
|
+
<span>Title</span>
|
|
58
|
+
</InspectorPaneHeader>
|
|
59
|
+
</InspectorPane>,
|
|
60
|
+
)
|
|
61
|
+
screen.getByRole('button', { name: 'Close detail' }).click()
|
|
62
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { useIdleLogout } from '../../hooks/useIdleLogout.js'
|
|
6
|
+
|
|
7
|
+
function Harness({
|
|
8
|
+
enabled,
|
|
9
|
+
timeoutMs,
|
|
10
|
+
onIdle,
|
|
11
|
+
}: {
|
|
12
|
+
enabled: boolean
|
|
13
|
+
timeoutMs: number
|
|
14
|
+
onIdle: () => void
|
|
15
|
+
}) {
|
|
16
|
+
useIdleLogout({ enabled, timeoutMs, onIdle })
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TIMEOUT_MS = 60_000
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.useFakeTimers()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.useRealTimers()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('useIdleLogout', () => {
|
|
31
|
+
it('fires onIdle exactly once after the timeout with no activity', () => {
|
|
32
|
+
const onIdle = vi.fn()
|
|
33
|
+
render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
34
|
+
|
|
35
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 1_000)
|
|
36
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
37
|
+
|
|
38
|
+
vi.advanceTimersByTime(TIMEOUT_MS)
|
|
39
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
40
|
+
|
|
41
|
+
// The idle callback must not re-fire on subsequent ticks.
|
|
42
|
+
vi.advanceTimersByTime(TIMEOUT_MS * 3)
|
|
43
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('resets the idle deadline on user activity', () => {
|
|
47
|
+
const onIdle = vi.fn()
|
|
48
|
+
render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
49
|
+
|
|
50
|
+
// Just before the deadline the user types — the clock restarts.
|
|
51
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 5_000)
|
|
52
|
+
window.dispatchEvent(new Event('keydown'))
|
|
53
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 5_000)
|
|
54
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
55
|
+
|
|
56
|
+
// A full quiet timeout after the last activity signs the user out.
|
|
57
|
+
vi.advanceTimersByTime(TIMEOUT_MS)
|
|
58
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does nothing when disabled', () => {
|
|
62
|
+
const onIdle = vi.fn()
|
|
63
|
+
render(<Harness enabled={false} timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
64
|
+
vi.advanceTimersByTime(TIMEOUT_MS * 10)
|
|
65
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('tears everything down on unmount', () => {
|
|
69
|
+
const onIdle = vi.fn()
|
|
70
|
+
const { unmount } = render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
71
|
+
unmount()
|
|
72
|
+
vi.advanceTimersByTime(TIMEOUT_MS * 10)
|
|
73
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('checks the deadline immediately when a hidden tab becomes visible', () => {
|
|
77
|
+
const onIdle = vi.fn()
|
|
78
|
+
render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
79
|
+
|
|
80
|
+
// Simulate the deadline passing while the tab was hidden (the interval is
|
|
81
|
+
// shared with visibility, so freeze it by only moving the mocked clock).
|
|
82
|
+
vi.setSystemTime(Date.now() + TIMEOUT_MS + 1_000)
|
|
83
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
84
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildPageTree,
|
|
4
|
+
defaultExpandedIds,
|
|
5
|
+
flattenPageTree,
|
|
6
|
+
type PageTreeNode,
|
|
7
|
+
} from '../../lib/pages-tree.js'
|
|
8
|
+
import type { Page } from '../../lib/pages-service.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Unit coverage for the Pages tree hierarchy resolution. Pure functions —
|
|
12
|
+
* locked independent of the REST layer and the view. The contract:
|
|
13
|
+
* explicit `parentPageId` wins, path-prefix derivation fills the gaps,
|
|
14
|
+
* and no input (orphans, cycles, duplicates) can lose or duplicate a page.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function page(partial: Partial<Page> & Pick<Page, 'id' | 'title' | 'path'>): Page {
|
|
18
|
+
return {
|
|
19
|
+
id: partial.id,
|
|
20
|
+
title: partial.title,
|
|
21
|
+
path: partial.path,
|
|
22
|
+
slug: partial.slug ?? partial.path.split('/').filter(Boolean).pop() ?? '',
|
|
23
|
+
parentPageId: partial.parentPageId ?? null,
|
|
24
|
+
tags: [],
|
|
25
|
+
primaryTagId: null,
|
|
26
|
+
authorId: 'u1',
|
|
27
|
+
authorName: 'Maria K.',
|
|
28
|
+
authorInitials: 'MK',
|
|
29
|
+
status: partial.status ?? 'PUBLISHED',
|
|
30
|
+
publishDate: null,
|
|
31
|
+
scheduledDate: null,
|
|
32
|
+
lastModified: '2026-05-01',
|
|
33
|
+
seoScore: null,
|
|
34
|
+
seoTitle: null,
|
|
35
|
+
seoDescription: null,
|
|
36
|
+
templateId: partial.templateId ?? null,
|
|
37
|
+
isHomepage: partial.path === '/',
|
|
38
|
+
isSystemPage: false,
|
|
39
|
+
createdAt: '2026-01-01',
|
|
40
|
+
updatedAt: '2026-05-01',
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function titles(nodes: PageTreeNode[]): string[] {
|
|
45
|
+
return nodes.map((n) => n.page.title)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function countNodes(nodes: PageTreeNode[]): number {
|
|
49
|
+
return nodes.reduce((n, node) => n + 1 + countNodes(node.children), 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('buildPageTree', () => {
|
|
53
|
+
it('nests children under explicit parentPageId parents', () => {
|
|
54
|
+
const roots = buildPageTree([
|
|
55
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
56
|
+
page({ id: 'team', title: 'Our Team', path: '/team-page', parentPageId: 'about' }),
|
|
57
|
+
])
|
|
58
|
+
expect(titles(roots)).toEqual(['About Us'])
|
|
59
|
+
expect(titles(roots[0]!.children)).toEqual(['Our Team'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('derives parents from path prefixes when parentPageId is null', () => {
|
|
63
|
+
const roots = buildPageTree([
|
|
64
|
+
page({ id: 'home', title: 'Home', path: '/' }),
|
|
65
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
66
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
67
|
+
page({ id: 'story', title: 'Our Story', path: '/about/story' }),
|
|
68
|
+
page({ id: 'contact', title: 'Contact', path: '/contact' }),
|
|
69
|
+
])
|
|
70
|
+
expect(titles(roots)).toEqual(['About Us', 'Contact', 'Home'])
|
|
71
|
+
const about = roots[0]!
|
|
72
|
+
expect(titles(about.children)).toEqual(['Our Story', 'Our Team'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('skips missing intermediate path levels to the longest existing prefix', () => {
|
|
76
|
+
const roots = buildPageTree([
|
|
77
|
+
page({ id: 'svc', title: 'Services', path: '/services' }),
|
|
78
|
+
// No "/services/web" page exists — attaches to "/services".
|
|
79
|
+
page({ id: 'design', title: 'Web Design', path: '/services/web/design' }),
|
|
80
|
+
])
|
|
81
|
+
expect(titles(roots)).toEqual(['Services'])
|
|
82
|
+
expect(titles(roots[0]!.children)).toEqual(['Web Design'])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('never derives the homepage ("/") as a path parent', () => {
|
|
86
|
+
const roots = buildPageTree([
|
|
87
|
+
page({ id: 'home', title: 'Home', path: '/' }),
|
|
88
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
89
|
+
])
|
|
90
|
+
expect(titles(roots)).toEqual(['Blog', 'Home'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('prefers explicit parentPageId over the path prefix', () => {
|
|
94
|
+
const roots = buildPageTree([
|
|
95
|
+
page({ id: 'about', title: 'About', path: '/about' }),
|
|
96
|
+
page({ id: 'lp', title: 'Landing', path: '/lp' }),
|
|
97
|
+
// Path says "/about/…" but the explicit link says "lp" — link wins.
|
|
98
|
+
page({ id: 'x', title: 'Campaign', path: '/about/campaign', parentPageId: 'lp' }),
|
|
99
|
+
])
|
|
100
|
+
const lp = roots.find((n) => n.page.id === 'lp')!
|
|
101
|
+
expect(titles(lp.children)).toEqual(['Campaign'])
|
|
102
|
+
expect(roots.find((n) => n.page.id === 'about')!.children).toHaveLength(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('treats orphans (parent not in the set) as roots', () => {
|
|
106
|
+
const roots = buildPageTree([
|
|
107
|
+
page({ id: 'a', title: 'A', path: '/a', parentPageId: 'missing' }),
|
|
108
|
+
page({ id: 'b', title: 'B', path: '/b' }),
|
|
109
|
+
])
|
|
110
|
+
expect(titles(roots)).toEqual(['A', 'B'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('breaks parentPageId cycles to the root without losing pages', () => {
|
|
114
|
+
const roots = buildPageTree([
|
|
115
|
+
page({ id: 'a', title: 'A', path: '/x-a', parentPageId: 'b' }),
|
|
116
|
+
page({ id: 'b', title: 'B', path: '/x-b', parentPageId: 'a' }),
|
|
117
|
+
page({ id: 'c', title: 'C', path: '/x-c' }),
|
|
118
|
+
])
|
|
119
|
+
expect(countNodes(roots)).toBe(3)
|
|
120
|
+
// One of the cycle members must have been rooted.
|
|
121
|
+
const rootIds = roots.map((n) => n.page.id)
|
|
122
|
+
expect(rootIds).toContain('c')
|
|
123
|
+
expect(rootIds.some((id) => id === 'a' || id === 'b')).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('ignores a self-referencing parentPageId', () => {
|
|
127
|
+
const roots = buildPageTree([page({ id: 'a', title: 'A', path: '/a', parentPageId: 'a' })])
|
|
128
|
+
expect(titles(roots)).toEqual(['A'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('orders siblings by title then path, at every depth', () => {
|
|
132
|
+
const roots = buildPageTree([
|
|
133
|
+
page({ id: 'svc', title: 'Services', path: '/services' }),
|
|
134
|
+
page({ id: 's2', title: 'Web Design', path: '/services/web-design' }),
|
|
135
|
+
page({ id: 's1', title: 'CMS Solutions', path: '/services/cms' }),
|
|
136
|
+
page({ id: 's3', title: 'SEO Services', path: '/services/seo' }),
|
|
137
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
138
|
+
])
|
|
139
|
+
expect(titles(roots)).toEqual(['Blog', 'Services'])
|
|
140
|
+
expect(titles(roots.find((n) => n.page.id === 'svc')!.children)).toEqual([
|
|
141
|
+
'CMS Solutions',
|
|
142
|
+
'SEO Services',
|
|
143
|
+
'Web Design',
|
|
144
|
+
])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('keeps every page exactly once on messy input', () => {
|
|
148
|
+
const pages = [
|
|
149
|
+
page({ id: 'home', title: 'Home', path: '/' }),
|
|
150
|
+
page({ id: 'dup1', title: 'Dup A', path: '/dup' }),
|
|
151
|
+
page({ id: 'dup2', title: 'Dup B', path: '/dup' }),
|
|
152
|
+
page({ id: 'kid', title: 'Kid', path: '/dup/kid' }),
|
|
153
|
+
page({ id: 'cyc1', title: 'Cyc 1', path: '/c1', parentPageId: 'cyc2' }),
|
|
154
|
+
page({ id: 'cyc2', title: 'Cyc 2', path: '/c2', parentPageId: 'cyc1' }),
|
|
155
|
+
page({ id: 'orph', title: 'Orphan', path: '/orphan', parentPageId: 'gone' }),
|
|
156
|
+
]
|
|
157
|
+
const roots = buildPageTree(pages)
|
|
158
|
+
expect(countNodes(roots)).toBe(pages.length)
|
|
159
|
+
// "/dup/kid" attaches to the first page seen with path "/dup".
|
|
160
|
+
expect(roots.some((n) => n.page.id === 'dup1' && n.children.length === 1)).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('flattenPageTree', () => {
|
|
165
|
+
const roots = buildPageTree([
|
|
166
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
167
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
168
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
it('renders children only when the parent is expanded', () => {
|
|
172
|
+
const collapsed = flattenPageTree(roots, new Set())
|
|
173
|
+
expect(collapsed.map((r) => r.page.id)).toEqual(['about', 'blog'])
|
|
174
|
+
|
|
175
|
+
const open = flattenPageTree(roots, new Set(['about']))
|
|
176
|
+
expect(open.map((r) => r.page.id)).toEqual(['about', 'team', 'blog'])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('reports depth and hasChildren per row', () => {
|
|
180
|
+
const rows = flattenPageTree(roots, new Set(['about']))
|
|
181
|
+
expect(rows[0]).toMatchObject({ depth: 0, hasChildren: true })
|
|
182
|
+
expect(rows[1]).toMatchObject({ depth: 1, hasChildren: false })
|
|
183
|
+
expect(rows[2]).toMatchObject({ depth: 0, hasChildren: false })
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('defaultExpandedIds', () => {
|
|
188
|
+
it('expands only root nodes that have children', () => {
|
|
189
|
+
const roots = buildPageTree([
|
|
190
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
191
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
192
|
+
page({ id: 'deep', title: 'Deep', path: '/about/team/deep' }),
|
|
193
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
194
|
+
])
|
|
195
|
+
expect([...defaultExpandedIds(roots)]).toEqual(['about'])
|
|
196
|
+
})
|
|
197
|
+
})
|