@actuate-media/cms-admin 0.68.0 → 0.69.1
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 +18 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +31 -11
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.js +57 -2
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -1
- package/dist/__tests__/router/query-string.test.d.ts +2 -0
- package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
- package/dist/__tests__/router/query-string.test.js +44 -0
- package/dist/__tests__/router/query-string.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +14 -4
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.js +14 -4
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.js +26 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/redirects-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/redirects-pane.render.test.js +121 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/SEOPanel.js +3 -3
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/collections/EntryDetailPane.js +1 -1
- package/dist/components/collections/EntryDetailPane.js.map +1 -1
- package/dist/components/forms/EntriesTable.d.ts +3 -1
- package/dist/components/forms/EntriesTable.d.ts.map +1 -1
- package/dist/components/forms/EntriesTable.js +5 -2
- package/dist/components/forms/EntriesTable.js.map +1 -1
- package/dist/components/forms/FormEntryDetailPane.d.ts +10 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -0
- package/dist/components/forms/FormEntryDetailPane.js +137 -0
- package/dist/components/forms/FormEntryDetailPane.js.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +1 -1
- package/dist/components/forms/FormSchemaPane.js.map +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.js +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
- package/dist/components/seo/RedirectEditorPane.d.ts +12 -0
- package/dist/components/seo/RedirectEditorPane.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorPane.js +89 -0
- package/dist/components/seo/RedirectEditorPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +1 -1
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/ui/DataTable.js +2 -2
- package/dist/components/ui/DataTable.js.map +1 -1
- package/dist/components/ui/Skeleton.js +3 -3
- package/dist/components/ui/Skeleton.js.map +1 -1
- package/dist/components/ui/Toggle.js +1 -1
- package/dist/components/ui/Toggle.js.map +1 -1
- package/dist/fields/BlockBuilderField.js +1 -1
- package/dist/fields/BlockBuilderField.js.map +1 -1
- package/dist/fields/MediaField.js +1 -1
- package/dist/fields/MediaField.js.map +1 -1
- package/dist/fields/PropInput.js +1 -1
- package/dist/fields/PropInput.js.map +1 -1
- package/dist/hooks/useIdleLogout.d.ts +21 -6
- package/dist/hooks/useIdleLogout.d.ts.map +1 -1
- package/dist/hooks/useIdleLogout.js +41 -8
- package/dist/hooks/useIdleLogout.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +23 -4
- package/dist/router/index.js.map +1 -1
- package/dist/views/DocumentEdit.js +1 -1
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/FormEntries.d.ts.map +1 -1
- package/dist/views/FormEntries.js +18 -14
- package/dist/views/FormEntries.js.map +1 -1
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +18 -15
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.js +5 -5
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts +4 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -6
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +4 -1
- package/dist/views/MediaSeoAudit.d.ts.map +1 -1
- package/dist/views/MediaSeoAudit.js +12 -2
- package/dist/views/MediaSeoAudit.js.map +1 -1
- package/dist/views/Pages/PagesListView.js +14 -14
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
- package/dist/views/Pages/PagesTreeTable.js +3 -3
- package/dist/views/Pages/PagesTreeTable.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +2 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +3 -3
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +22 -2
- package/dist/views/Users.js.map +1 -1
- package/dist/views/seo/ContentTab.js +6 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +23 -8
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +2 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +53 -12
- package/src/__tests__/hooks/useIdleLogout.test.tsx +103 -1
- package/src/__tests__/router/query-string.test.ts +54 -0
- package/src/__tests__/views/form-entries.render.test.tsx +16 -4
- package/src/__tests__/views/form-submissions.render.test.tsx +16 -4
- package/src/__tests__/views/media-seo-audit.render.test.tsx +34 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +156 -0
- package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
- package/src/components/SEOPanel.tsx +9 -9
- package/src/components/collections/EntryDetailPane.tsx +1 -1
- package/src/components/forms/EntriesTable.tsx +12 -3
- package/src/components/forms/{EntryDetailDrawer.tsx → FormEntryDetailPane.tsx} +112 -102
- package/src/components/forms/FormSchemaPane.tsx +1 -1
- package/src/components/script-tags/ScriptTagEditorPane.tsx +1 -1
- package/src/components/seo/RedirectEditorPane.tsx +271 -0
- package/src/components/seo/SeoEditorPane.tsx +1 -1
- package/src/components/ui/DataTable.tsx +3 -3
- package/src/components/ui/Skeleton.tsx +8 -8
- package/src/components/ui/Toggle.tsx +1 -1
- package/src/fields/BlockBuilderField.tsx +1 -1
- package/src/fields/MediaField.tsx +1 -1
- package/src/fields/PropInput.tsx +1 -1
- package/src/hooks/useIdleLogout.ts +65 -13
- package/src/layout/Header.tsx +2 -2
- package/src/router/index.ts +25 -4
- package/src/styles/actuate-tokens.css +4 -0
- package/src/styles/theme.css +9 -3
- package/src/views/DocumentEdit.tsx +4 -4
- package/src/views/FormEntries.tsx +81 -66
- package/src/views/FormSubmissions.tsx +78 -64
- package/src/views/Forms.tsx +16 -16
- package/src/views/MediaBrowser.tsx +34 -6
- package/src/views/MediaSeoAudit.tsx +39 -2
- package/src/views/Pages/PagesListView.tsx +46 -46
- package/src/views/Pages/PagesTreeTable.tsx +11 -8
- package/src/views/SEO.tsx +2 -1
- package/src/views/ScriptTags.tsx +26 -36
- package/src/views/Settings.tsx +7 -2
- package/src/views/Users.tsx +23 -2
- package/src/views/seo/ContentTab.tsx +10 -10
- package/src/views/seo/RedirectsTab.tsx +145 -109
- package/src/views/settings/SecurityTab.tsx +2 -1
- package/dist/components/forms/EntryDetailDrawer.d.ts +0 -11
- package/dist/components/forms/EntryDetailDrawer.d.ts.map +0 -1
- package/dist/components/forms/EntryDetailDrawer.js +0 -145
- package/dist/components/forms/EntryDetailDrawer.js.map +0 -1
- package/dist/components/seo/RedirectEditorDrawer.d.ts +0 -9
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/RedirectEditorDrawer.js +0 -95
- package/dist/components/seo/RedirectEditorDrawer.js.map +0 -1
- package/src/components/seo/RedirectEditorDrawer.tsx +0 -231
package/src/AdminRoot.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import * as Dialog from '@radix-ui/react-dialog'
|
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { Layout } from './layout/Layout.js'
|
|
7
7
|
import { adminPortalContainer } from './lib/portal-container.js'
|
|
8
|
-
import { useAdminRouter } from './router/index.js'
|
|
8
|
+
import { useAdminRouter, parseRouteQuery } from './router/index.js'
|
|
9
9
|
import { cmsApi, ensureCsrfToken } from './lib/api.js'
|
|
10
10
|
import { setDocumentFavicon, BRAND_UPDATED_EVENT } from './lib/favicon.js'
|
|
11
11
|
import {
|
|
@@ -208,10 +208,26 @@ function AdminShell({
|
|
|
208
208
|
}
|
|
209
209
|
}, [session])
|
|
210
210
|
|
|
211
|
-
useIdleLogout({
|
|
211
|
+
const { stayActive } = useIdleLogout({
|
|
212
212
|
enabled: Boolean(session && sessionPolicy?.idleLogoutEnabled),
|
|
213
213
|
timeoutMs: (sessionPolicy?.timeoutHours ?? 0) * 3_600_000,
|
|
214
214
|
onIdle: handleLogout,
|
|
215
|
+
// One-minute heads-up so the sign-out is never a surprise. Any activity
|
|
216
|
+
// (or the toast action) resets the clock and dismisses the warning.
|
|
217
|
+
warningMs: 60_000,
|
|
218
|
+
onWarn: () => {
|
|
219
|
+
toast.warning('You will be signed out in about a minute due to inactivity.', {
|
|
220
|
+
id: 'idle-logout-warning',
|
|
221
|
+
duration: 60_000,
|
|
222
|
+
action: {
|
|
223
|
+
label: 'Stay signed in',
|
|
224
|
+
onClick: () => stayActive(),
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
},
|
|
228
|
+
onWarnCancelled: () => {
|
|
229
|
+
toast.dismiss('idle-logout-warning')
|
|
230
|
+
},
|
|
215
231
|
})
|
|
216
232
|
|
|
217
233
|
useBranding(config)
|
|
@@ -327,10 +343,11 @@ function AdminShell({
|
|
|
327
343
|
|
|
328
344
|
const resetMatch = matchRoute('/reset-password')
|
|
329
345
|
if (resetMatch) {
|
|
330
|
-
|
|
331
|
-
|
|
346
|
+
// currentPath carries the query string (router syncs it from the URL on
|
|
347
|
+
// mount/popstate), so the emailed `?token=` deep link survives reloads.
|
|
348
|
+
return (
|
|
349
|
+
<ResetPassword onNavigate={navigate} token={parseRouteQuery(currentPath).get('token')} />
|
|
332
350
|
)
|
|
333
|
-
return <ResetPassword onNavigate={navigate} token={params.get('token')} />
|
|
334
351
|
}
|
|
335
352
|
|
|
336
353
|
if (onLogin) {
|
|
@@ -367,7 +384,7 @@ function AdminShell({
|
|
|
367
384
|
// a collection slugged `media`, `users`, or `settings`).
|
|
368
385
|
|
|
369
386
|
if (matchRoute('/media')) {
|
|
370
|
-
return <MediaBrowser onNavigate={navigate} />
|
|
387
|
+
return <MediaBrowser onNavigate={navigate} currentPath={currentPath} />
|
|
371
388
|
}
|
|
372
389
|
|
|
373
390
|
// ─── Posts area ────────────────────────────────────────────────
|
|
@@ -463,7 +480,7 @@ function AdminShell({
|
|
|
463
480
|
// "Add sub-page" in the Pages tree view arrives with the parent id
|
|
464
481
|
// in the query string (`/pages/new?parent=<id>`); matchRoute ignores
|
|
465
482
|
// the query so it's parsed here and preselected in the editor.
|
|
466
|
-
const newPageQuery =
|
|
483
|
+
const newPageQuery = parseRouteQuery(currentPath)
|
|
467
484
|
return (
|
|
468
485
|
<PageSectionEditor
|
|
469
486
|
initialParentId={newPageQuery.get('parent')}
|
|
@@ -567,17 +584,38 @@ function AdminShell({
|
|
|
567
584
|
// Users + API Keys moved into the Settings view as tabs. The legacy
|
|
568
585
|
// top-level routes still resolve — they just land on the matching tab.
|
|
569
586
|
if (matchRoute('/users')) {
|
|
570
|
-
return
|
|
587
|
+
return (
|
|
588
|
+
<Settings
|
|
589
|
+
onNavigate={navigate}
|
|
590
|
+
config={config}
|
|
591
|
+
session={session}
|
|
592
|
+
initialTab="users"
|
|
593
|
+
currentPath={currentPath}
|
|
594
|
+
/>
|
|
595
|
+
)
|
|
571
596
|
}
|
|
572
597
|
|
|
573
598
|
if (matchRoute('/api-keys')) {
|
|
574
599
|
return (
|
|
575
|
-
<Settings
|
|
600
|
+
<Settings
|
|
601
|
+
onNavigate={navigate}
|
|
602
|
+
config={config}
|
|
603
|
+
session={session}
|
|
604
|
+
initialTab="api-keys"
|
|
605
|
+
currentPath={currentPath}
|
|
606
|
+
/>
|
|
576
607
|
)
|
|
577
608
|
}
|
|
578
609
|
|
|
579
610
|
if (matchRoute('/settings')) {
|
|
580
|
-
return
|
|
611
|
+
return (
|
|
612
|
+
<Settings
|
|
613
|
+
onNavigate={navigate}
|
|
614
|
+
config={config}
|
|
615
|
+
session={session}
|
|
616
|
+
currentPath={currentPath}
|
|
617
|
+
/>
|
|
618
|
+
)
|
|
581
619
|
}
|
|
582
620
|
|
|
583
621
|
if (matchRoute('/profile')) {
|
|
@@ -716,7 +754,8 @@ function AdminShell({
|
|
|
716
754
|
// Preview routes render as standalone pages (no sidebar/header) so a
|
|
717
755
|
// new-tab preview looks like the real published page rather than an
|
|
718
756
|
// embedded admin view. Everything else gets the full admin chrome.
|
|
719
|
-
|
|
757
|
+
// Query string is stripped — currentPath carries it after reloads.
|
|
758
|
+
if (currentPath.split('?')[0]!.endsWith('/preview')) {
|
|
720
759
|
return (
|
|
721
760
|
<div className="bg-background h-full min-h-screen">
|
|
722
761
|
<ErrorBoundary>{renderView()}</ErrorBoundary>
|
|
@@ -867,7 +906,9 @@ function useAdminPageTitle(config: any, currentPath: string) {
|
|
|
867
906
|
const baseName = branding?.title ?? (branding?.name ? `${branding.name} Admin` : null)
|
|
868
907
|
if (!baseName) return
|
|
869
908
|
|
|
870
|
-
|
|
909
|
+
// Strip the query so `/settings?tab=users` titles as "Settings".
|
|
910
|
+
const pathname = currentPath.split('?')[0]!.split('#')[0]!
|
|
911
|
+
const segment = pathname === '/' ? 'Dashboard' : pathname.split('/').filter(Boolean)[0]
|
|
871
912
|
const pageName = segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard'
|
|
872
913
|
document.title = `${pageName} — ${baseName}`
|
|
873
914
|
}, [config, currentPath])
|
|
@@ -8,12 +8,28 @@ function Harness({
|
|
|
8
8
|
enabled,
|
|
9
9
|
timeoutMs,
|
|
10
10
|
onIdle,
|
|
11
|
+
warningMs,
|
|
12
|
+
onWarn,
|
|
13
|
+
onWarnCancelled,
|
|
14
|
+
stayActiveRef,
|
|
11
15
|
}: {
|
|
12
16
|
enabled: boolean
|
|
13
17
|
timeoutMs: number
|
|
14
18
|
onIdle: () => void
|
|
19
|
+
warningMs?: number
|
|
20
|
+
onWarn?: () => void
|
|
21
|
+
onWarnCancelled?: () => void
|
|
22
|
+
stayActiveRef?: { current: (() => void) | null }
|
|
15
23
|
}) {
|
|
16
|
-
|
|
24
|
+
const { stayActive } = useIdleLogout({
|
|
25
|
+
enabled,
|
|
26
|
+
timeoutMs,
|
|
27
|
+
onIdle,
|
|
28
|
+
warningMs,
|
|
29
|
+
onWarn,
|
|
30
|
+
onWarnCancelled,
|
|
31
|
+
})
|
|
32
|
+
if (stayActiveRef) stayActiveRef.current = stayActive
|
|
17
33
|
return null
|
|
18
34
|
}
|
|
19
35
|
|
|
@@ -83,4 +99,90 @@ describe('useIdleLogout', () => {
|
|
|
83
99
|
document.dispatchEvent(new Event('visibilitychange'))
|
|
84
100
|
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
85
101
|
})
|
|
102
|
+
|
|
103
|
+
describe('warning window', () => {
|
|
104
|
+
const WARNING_MS = 20_000
|
|
105
|
+
|
|
106
|
+
it('fires onWarn once inside the warning window, then onIdle at the deadline', () => {
|
|
107
|
+
const onIdle = vi.fn()
|
|
108
|
+
const onWarn = vi.fn()
|
|
109
|
+
render(
|
|
110
|
+
<Harness
|
|
111
|
+
enabled
|
|
112
|
+
timeoutMs={TIMEOUT_MS}
|
|
113
|
+
warningMs={WARNING_MS}
|
|
114
|
+
onIdle={onIdle}
|
|
115
|
+
onWarn={onWarn}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Before the warning window: nothing.
|
|
120
|
+
vi.advanceTimersByTime(TIMEOUT_MS - WARNING_MS - 5_000)
|
|
121
|
+
expect(onWarn).not.toHaveBeenCalled()
|
|
122
|
+
|
|
123
|
+
// Inside the window: warn exactly once, no logout yet.
|
|
124
|
+
vi.advanceTimersByTime(10_000)
|
|
125
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
126
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
127
|
+
|
|
128
|
+
// Deadline: logout, and the warning doesn't re-fire.
|
|
129
|
+
vi.advanceTimersByTime(TIMEOUT_MS)
|
|
130
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
131
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('activity after the warning cancels it and resets the clock', () => {
|
|
135
|
+
const onIdle = vi.fn()
|
|
136
|
+
const onWarn = vi.fn()
|
|
137
|
+
const onWarnCancelled = vi.fn()
|
|
138
|
+
render(
|
|
139
|
+
<Harness
|
|
140
|
+
enabled
|
|
141
|
+
timeoutMs={TIMEOUT_MS}
|
|
142
|
+
warningMs={WARNING_MS}
|
|
143
|
+
onIdle={onIdle}
|
|
144
|
+
onWarn={onWarn}
|
|
145
|
+
onWarnCancelled={onWarnCancelled}
|
|
146
|
+
/>,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 10_000)
|
|
150
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
151
|
+
|
|
152
|
+
window.dispatchEvent(new Event('keydown'))
|
|
153
|
+
expect(onWarnCancelled).toHaveBeenCalledTimes(1)
|
|
154
|
+
|
|
155
|
+
// The clock restarted: warn again on the next quiet cycle, no logout yet.
|
|
156
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 10_000)
|
|
157
|
+
expect(onWarn).toHaveBeenCalledTimes(2)
|
|
158
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('stayActive resets the clock like real activity', () => {
|
|
162
|
+
const onIdle = vi.fn()
|
|
163
|
+
const onWarn = vi.fn()
|
|
164
|
+
const onWarnCancelled = vi.fn()
|
|
165
|
+
const stayActiveRef: { current: (() => void) | null } = { current: null }
|
|
166
|
+
render(
|
|
167
|
+
<Harness
|
|
168
|
+
enabled
|
|
169
|
+
timeoutMs={TIMEOUT_MS}
|
|
170
|
+
warningMs={WARNING_MS}
|
|
171
|
+
onIdle={onIdle}
|
|
172
|
+
onWarn={onWarn}
|
|
173
|
+
onWarnCancelled={onWarnCancelled}
|
|
174
|
+
stayActiveRef={stayActiveRef}
|
|
175
|
+
/>,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 10_000)
|
|
179
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
180
|
+
|
|
181
|
+
stayActiveRef.current?.()
|
|
182
|
+
expect(onWarnCancelled).toHaveBeenCalledTimes(1)
|
|
183
|
+
|
|
184
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 15_000)
|
|
185
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
86
188
|
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { act, renderHook } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { parseRouteQuery, useAdminRouter } from '../../router/index.js'
|
|
6
|
+
|
|
7
|
+
// The admin router must carry the query string in `currentPath` so deep links
|
|
8
|
+
// like `/pages/new?parent=<id>` survive hard reloads and back/forward — not
|
|
9
|
+
// just in-app `navigate()` calls (which always carried it).
|
|
10
|
+
describe('useAdminRouter query strings', () => {
|
|
11
|
+
it('includes the query string when syncing from the browser URL on mount', () => {
|
|
12
|
+
window.history.pushState({}, '', '/admin/pages/new?parent=abc')
|
|
13
|
+
|
|
14
|
+
const { result } = renderHook(() => useAdminRouter('/admin', '/pages/new'))
|
|
15
|
+
|
|
16
|
+
expect(result.current.currentPath).toBe('/pages/new?parent=abc')
|
|
17
|
+
expect(result.current.search).toBe('?parent=abc')
|
|
18
|
+
// matchRoute strips the query, so route patterns still match.
|
|
19
|
+
expect(result.current.matchRoute('/pages/new')).toEqual({})
|
|
20
|
+
expect(parseRouteQuery(result.current.currentPath).get('parent')).toBe('abc')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('carries the query string through popstate (back/forward)', () => {
|
|
24
|
+
window.history.pushState({}, '', '/admin/settings?tab=users')
|
|
25
|
+
const { result } = renderHook(() => useAdminRouter('/admin', '/'))
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
window.history.pushState({}, '', '/admin/seo?tab=audit&issueId=i1')
|
|
29
|
+
window.dispatchEvent(new PopStateEvent('popstate'))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
expect(result.current.currentPath).toBe('/seo?tab=audit&issueId=i1')
|
|
33
|
+
expect(result.current.search).toBe('?tab=audit&issueId=i1')
|
|
34
|
+
expect(result.current.matchRoute('/seo')).toEqual({})
|
|
35
|
+
expect(result.current.matchRoute('/seo/:id')).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles a query on the admin root path', () => {
|
|
39
|
+
window.history.pushState({}, '', '/admin?foo=1')
|
|
40
|
+
|
|
41
|
+
const { result } = renderHook(() => useAdminRouter('/admin', '/'))
|
|
42
|
+
|
|
43
|
+
expect(result.current.currentPath).toBe('/?foo=1')
|
|
44
|
+
expect(result.current.matchRoute('/')).toEqual({})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('parseRouteQuery accepts full paths, raw search strings, and hashes', () => {
|
|
48
|
+
expect(parseRouteQuery('/pages/new?parent=abc').get('parent')).toBe('abc')
|
|
49
|
+
expect(parseRouteQuery('?tab=users&selected=u1').get('selected')).toBe('u1')
|
|
50
|
+
expect(parseRouteQuery('/pages').get('parent')).toBeNull()
|
|
51
|
+
expect(parseRouteQuery('').get('parent')).toBeNull()
|
|
52
|
+
expect(parseRouteQuery('/seo?tab=audit#section').get('tab')).toBe('audit')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
|
4
4
|
|
|
5
5
|
// Drive the global All Entries inbox through a mocked forms-service so the test
|
|
6
6
|
// owns the data and can assert the cross-form table (Form column + form
|
|
7
|
-
// filter), status-pill refetches, and opening the detail
|
|
7
|
+
// filter), status-pill refetches, and opening the inline detail pane.
|
|
8
8
|
function makeEntry(over: Record<string, unknown> = {}) {
|
|
9
9
|
return {
|
|
10
10
|
id: 'e1',
|
|
@@ -122,12 +122,24 @@ describe('FormEntries (All Entries)', () => {
|
|
|
122
122
|
)
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
-
it('opens the detail
|
|
125
|
+
it('opens the detail pane inline when a row is clicked', async () => {
|
|
126
126
|
render(<FormEntries onNavigate={() => {}} />)
|
|
127
|
-
const
|
|
127
|
+
const rowButton = await screen.findByRole('button', { name: 'Jordan Avery' })
|
|
128
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('false')
|
|
128
129
|
|
|
129
|
-
fireEvent.click(
|
|
130
|
+
fireEvent.click(rowButton)
|
|
130
131
|
|
|
132
|
+
expect(screen.getByRole('region', { name: 'Entry details' })).toBeTruthy()
|
|
133
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('true')
|
|
131
134
|
await waitFor(() => expect(fetchEntry).toHaveBeenCalledWith('e1'))
|
|
132
135
|
})
|
|
136
|
+
|
|
137
|
+
it('collapses the pane via its close button', async () => {
|
|
138
|
+
render(<FormEntries onNavigate={() => {}} />)
|
|
139
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Jordan Avery' }))
|
|
140
|
+
expect(screen.getByRole('region', { name: 'Entry details' })).toBeTruthy()
|
|
141
|
+
|
|
142
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close entry details' }))
|
|
143
|
+
expect(screen.queryByRole('region', { name: 'Entry details' })).toBeNull()
|
|
144
|
+
})
|
|
133
145
|
})
|
|
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
|
4
4
|
|
|
5
5
|
// Drive the per-form submissions table through a mocked forms-service so the
|
|
6
6
|
// test owns the data and can assert rendering, status-pill refetches, bulk
|
|
7
|
-
// selection, and opening the detail
|
|
7
|
+
// selection, and opening the inline detail pane without a real backend.
|
|
8
8
|
function makeEntry(over: Record<string, unknown> = {}) {
|
|
9
9
|
return {
|
|
10
10
|
id: 'e1',
|
|
@@ -118,12 +118,24 @@ describe('FormSubmissions table', () => {
|
|
|
118
118
|
expect(screen.getByRole('button', { name: 'Mark read' })).toBeTruthy()
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
-
it('opens the detail
|
|
121
|
+
it('opens the detail pane inline when a row is clicked', async () => {
|
|
122
122
|
render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
|
|
123
|
-
const
|
|
123
|
+
const rowButton = await screen.findByRole('button', { name: 'Jordan Avery' })
|
|
124
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('false')
|
|
124
125
|
|
|
125
|
-
fireEvent.click(
|
|
126
|
+
fireEvent.click(rowButton)
|
|
126
127
|
|
|
128
|
+
expect(screen.getByRole('region', { name: 'Entry details' })).toBeTruthy()
|
|
129
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('true')
|
|
127
130
|
await waitFor(() => expect(fetchEntry).toHaveBeenCalledWith('e1'))
|
|
128
131
|
})
|
|
132
|
+
|
|
133
|
+
it('collapses the pane via its close button', async () => {
|
|
134
|
+
render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
|
|
135
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Jordan Avery' }))
|
|
136
|
+
expect(screen.getByRole('region', { name: 'Entry details' })).toBeTruthy()
|
|
137
|
+
|
|
138
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close entry details' }))
|
|
139
|
+
expect(screen.queryByRole('region', { name: 'Entry details' })).toBeNull()
|
|
140
|
+
})
|
|
129
141
|
})
|
|
@@ -114,4 +114,38 @@ describe('MediaSeoAudit', () => {
|
|
|
114
114
|
fireEvent.click(screen.getByRole('button', { name: 'Missing Alt' }))
|
|
115
115
|
expect(screen.getByText('No items match this filter')).toBeTruthy()
|
|
116
116
|
})
|
|
117
|
+
|
|
118
|
+
it('renders a retryable error state instead of an empty audit when the fetch failed', () => {
|
|
119
|
+
const onRetry = vi.fn()
|
|
120
|
+
render(<MediaSeoAudit items={[]} onEdit={vi.fn()} error="Network error" onRetry={onRetry} />)
|
|
121
|
+
|
|
122
|
+
expect(screen.getByRole('alert')).toBeTruthy()
|
|
123
|
+
expect(screen.getByText("Couldn't load media")).toBeTruthy()
|
|
124
|
+
expect(screen.getByText('Network error')).toBeTruthy()
|
|
125
|
+
expect(screen.queryByText('No media to audit')).toBeNull()
|
|
126
|
+
|
|
127
|
+
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
|
128
|
+
expect(onRetry).toHaveBeenCalledTimes(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('still renders loaded items when an error arrives after data (stale-but-usable)', () => {
|
|
132
|
+
render(<MediaSeoAudit items={ITEMS} onEdit={vi.fn()} error="Refresh failed" />)
|
|
133
|
+
expect(screen.getByRole('table', { name: 'Media SEO audit' })).toBeTruthy()
|
|
134
|
+
expect(screen.queryByRole('alert')).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('activates a row with the keyboard (Enter and Space)', () => {
|
|
138
|
+
const onEdit = vi.fn()
|
|
139
|
+
render(<MediaSeoAudit items={ITEMS} onEdit={onEdit} />)
|
|
140
|
+
|
|
141
|
+
const row = screen.getByRole('row', { name: 'Edit SEO metadata for hero-banner.jpg' })
|
|
142
|
+
expect(row.getAttribute('tabindex')).toBe('0')
|
|
143
|
+
|
|
144
|
+
fireEvent.keyDown(row, { key: 'Enter' })
|
|
145
|
+
expect(onEdit).toHaveBeenCalledTimes(1)
|
|
146
|
+
expect(onEdit.mock.calls[0]![0].id).toBe('no-alt')
|
|
147
|
+
|
|
148
|
+
fireEvent.keyDown(row, { key: ' ' })
|
|
149
|
+
expect(onEdit).toHaveBeenCalledTimes(2)
|
|
150
|
+
})
|
|
117
151
|
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Redirects tab with the inline editor pane (design split-pane pattern —
|
|
7
|
+
* previously the `RedirectEditorDrawer` overlay). Pins: row click toggles the
|
|
8
|
+
* 420px pane, "New redirect" opens it in create mode, and save flows through
|
|
9
|
+
* the same `createRedirectRule` / `updateRedirectRule` service calls.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function makeRule(over: Record<string, unknown> = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'r1',
|
|
15
|
+
type: 301 as const,
|
|
16
|
+
fromPath: '/old-path',
|
|
17
|
+
toPath: '/new-path',
|
|
18
|
+
enabled: true,
|
|
19
|
+
priority: 0,
|
|
20
|
+
preserveQuery: false,
|
|
21
|
+
hits: 3,
|
|
22
|
+
lastHitAt: null,
|
|
23
|
+
stale: false,
|
|
24
|
+
source: 'manual',
|
|
25
|
+
notes: null,
|
|
26
|
+
createdAt: '2026-06-01T00:00:00Z',
|
|
27
|
+
updatedAt: '2026-06-01T00:00:00Z',
|
|
28
|
+
...over,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const RULES = [makeRule(), makeRule({ id: 'r2', fromPath: '/other', toPath: '/elsewhere' })]
|
|
33
|
+
|
|
34
|
+
const fetchRedirectRules = vi.fn(async () => RULES)
|
|
35
|
+
const createRedirectRule = vi.fn(async (_p: unknown) => ({}))
|
|
36
|
+
const updateRedirectRule = vi.fn(async (_id: string, _p: unknown) => ({}))
|
|
37
|
+
const deleteRedirectRule = vi.fn(async (_id: string) => ({}))
|
|
38
|
+
|
|
39
|
+
vi.mock('../../lib/seo-service.js', () => ({
|
|
40
|
+
fetchRedirectOverview: vi.fn(async () => ({
|
|
41
|
+
activeRedirects: 2,
|
|
42
|
+
staleRedirects: 0,
|
|
43
|
+
notFoundCount: 0,
|
|
44
|
+
chainsAndLoops: 0,
|
|
45
|
+
})),
|
|
46
|
+
fetchRedirectRules: () => fetchRedirectRules(),
|
|
47
|
+
fetchRedirectChains: vi.fn(async () => []),
|
|
48
|
+
fetchRedirectSuggestions: vi.fn(async () => []),
|
|
49
|
+
createRedirectRule: (p: unknown) => createRedirectRule(p),
|
|
50
|
+
updateRedirectRule: (id: string, p: unknown) => updateRedirectRule(id, p),
|
|
51
|
+
deleteRedirectRule: (id: string) => deleteRedirectRule(id),
|
|
52
|
+
flattenRedirectChain: vi.fn(async () => ({})),
|
|
53
|
+
generate404RecoverySuggestions: vi.fn(async () => ({})),
|
|
54
|
+
acceptRedirectSuggestion: vi.fn(async () => ({})),
|
|
55
|
+
dismissRedirectSuggestion: vi.fn(async () => ({})),
|
|
56
|
+
exportRedirectsCsv: vi.fn(async () => ({ csv: '' })),
|
|
57
|
+
importRedirectsCsv: vi.fn(async () => ({ imported: 0 })),
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
61
|
+
|
|
62
|
+
const { RedirectsTab } = await import('../../views/seo/RedirectsTab.js')
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
fetchRedirectRules.mockClear()
|
|
66
|
+
createRedirectRule.mockClear()
|
|
67
|
+
updateRedirectRule.mockClear()
|
|
68
|
+
deleteRedirectRule.mockClear()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('Redirects inline editor pane', () => {
|
|
72
|
+
it('opens the pane on row click with the rule loaded', async () => {
|
|
73
|
+
render(<RedirectsTab />)
|
|
74
|
+
const rowButton = await screen.findByRole('button', { name: '/old-path' })
|
|
75
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('false')
|
|
76
|
+
|
|
77
|
+
fireEvent.click(rowButton)
|
|
78
|
+
|
|
79
|
+
expect(screen.getByRole('region', { name: 'Redirect editor' })).toBeTruthy()
|
|
80
|
+
expect(rowButton.getAttribute('aria-expanded')).toBe('true')
|
|
81
|
+
// The form is prefilled from the selected rule.
|
|
82
|
+
expect(screen.getByLabelText('Source path')).toHaveProperty('value', '/old-path')
|
|
83
|
+
expect(screen.getByLabelText('Destination')).toHaveProperty('value', '/new-path')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('toggles the pane closed when the same row is clicked again', async () => {
|
|
87
|
+
render(<RedirectsTab />)
|
|
88
|
+
const rowButton = await screen.findByRole('button', { name: '/old-path' })
|
|
89
|
+
|
|
90
|
+
fireEvent.click(rowButton)
|
|
91
|
+
expect(screen.getByRole('region', { name: 'Redirect editor' })).toBeTruthy()
|
|
92
|
+
|
|
93
|
+
fireEvent.click(rowButton)
|
|
94
|
+
expect(screen.queryByRole('region', { name: 'Redirect editor' })).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('collapses the pane via the close button', async () => {
|
|
98
|
+
render(<RedirectsTab />)
|
|
99
|
+
fireEvent.click(await screen.findByRole('button', { name: '/old-path' }))
|
|
100
|
+
expect(screen.getByRole('region', { name: 'Redirect editor' })).toBeTruthy()
|
|
101
|
+
|
|
102
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close redirect editor' }))
|
|
103
|
+
expect(screen.queryByRole('region', { name: 'Redirect editor' })).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('saves an edited rule through updateRedirectRule and refetches', async () => {
|
|
107
|
+
render(<RedirectsTab />)
|
|
108
|
+
fireEvent.click(await screen.findByRole('button', { name: '/old-path' }))
|
|
109
|
+
const before = fetchRedirectRules.mock.calls.length
|
|
110
|
+
|
|
111
|
+
fireEvent.change(screen.getByLabelText('Destination'), { target: { value: '/moved' } })
|
|
112
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
|
113
|
+
|
|
114
|
+
await waitFor(() =>
|
|
115
|
+
expect(updateRedirectRule).toHaveBeenCalledWith(
|
|
116
|
+
'r1',
|
|
117
|
+
expect.objectContaining({ fromPath: '/old-path', toPath: '/moved', type: 301 }),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
// Saving closes the pane and refetches the list.
|
|
121
|
+
await waitFor(() =>
|
|
122
|
+
expect(screen.queryByRole('region', { name: 'Redirect editor' })).toBeNull(),
|
|
123
|
+
)
|
|
124
|
+
await waitFor(() => expect(fetchRedirectRules.mock.calls.length).toBeGreaterThan(before))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('creates a rule from the New redirect pane', async () => {
|
|
128
|
+
render(<RedirectsTab />)
|
|
129
|
+
await screen.findByRole('button', { name: '/old-path' })
|
|
130
|
+
|
|
131
|
+
fireEvent.click(screen.getByRole('button', { name: /New redirect/ }))
|
|
132
|
+
expect(screen.getByRole('region', { name: 'Redirect editor' })).toBeTruthy()
|
|
133
|
+
|
|
134
|
+
fireEvent.change(screen.getByLabelText('Source path'), { target: { value: 'legacy' } })
|
|
135
|
+
fireEvent.change(screen.getByLabelText('Destination'), { target: { value: '/fresh' } })
|
|
136
|
+
fireEvent.click(screen.getByRole('button', { name: 'Create redirect' }))
|
|
137
|
+
|
|
138
|
+
// Paths are normalized to a leading slash before saving.
|
|
139
|
+
await waitFor(() =>
|
|
140
|
+
expect(createRedirectRule).toHaveBeenCalledWith(
|
|
141
|
+
expect.objectContaining({ fromPath: '/legacy', toPath: '/fresh', type: 301 }),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('shows a validation error instead of saving when the source is missing', async () => {
|
|
147
|
+
render(<RedirectsTab />)
|
|
148
|
+
await screen.findByRole('button', { name: '/old-path' })
|
|
149
|
+
|
|
150
|
+
fireEvent.click(screen.getByRole('button', { name: /New redirect/ }))
|
|
151
|
+
fireEvent.click(screen.getByRole('button', { name: 'Create redirect' }))
|
|
152
|
+
|
|
153
|
+
expect(await screen.findByRole('alert')).toBeTruthy()
|
|
154
|
+
expect(createRedirectRule).not.toHaveBeenCalled()
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render tests for the `?selected=<userId>` deep link written by the command
|
|
7
|
+
* palette (`/users?selected=<id>`): once the member list loads, the matching
|
|
8
|
+
* member's detail drawer opens. Unknown ids are tolerated — no drawer, no crash.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MEMBER = {
|
|
12
|
+
id: 'u1',
|
|
13
|
+
name: 'Priya Nair',
|
|
14
|
+
email: 'priya@actuatemedia.com',
|
|
15
|
+
initials: 'PN',
|
|
16
|
+
role: 'EDITOR',
|
|
17
|
+
roleName: 'Editor' as const,
|
|
18
|
+
status: 'active' as const,
|
|
19
|
+
mfaEnabled: true,
|
|
20
|
+
externalDomain: false,
|
|
21
|
+
lastLoginAt: null,
|
|
22
|
+
lastActiveAt: null,
|
|
23
|
+
acceptedInviteAt: new Date('2026-01-02').toISOString(),
|
|
24
|
+
createdAt: new Date('2026-01-01').toISOString(),
|
|
25
|
+
isCurrentUser: false,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DETAIL = {
|
|
29
|
+
...MEMBER,
|
|
30
|
+
avatarUrl: null,
|
|
31
|
+
mfaRequired: false,
|
|
32
|
+
passwordSet: true,
|
|
33
|
+
forcePasswordReset: false,
|
|
34
|
+
failedLoginCount: 0,
|
|
35
|
+
lockedAt: null,
|
|
36
|
+
suspendedAt: null,
|
|
37
|
+
authProvider: null,
|
|
38
|
+
oauthProviders: [],
|
|
39
|
+
ssoOnly: false,
|
|
40
|
+
emailVerified: true,
|
|
41
|
+
invitedBy: null,
|
|
42
|
+
contentOwnedCount: 4,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const STATS = {
|
|
46
|
+
members: 1,
|
|
47
|
+
pendingInvites: 0,
|
|
48
|
+
admins: 0,
|
|
49
|
+
seatsUsed: 1,
|
|
50
|
+
seatLimit: null,
|
|
51
|
+
seatsAvailable: null,
|
|
52
|
+
emailConfigured: true,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { cmsApi } = vi.hoisted(() => ({ cmsApi: vi.fn() }))
|
|
56
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi }))
|
|
57
|
+
|
|
58
|
+
cmsApi.mockImplementation(async (endpoint?: string) => {
|
|
59
|
+
if (!endpoint) return { data: null, status: 200 }
|
|
60
|
+
if (endpoint === '/invites') return { data: [], status: 200 }
|
|
61
|
+
if (endpoint === '/users/stats') return { data: STATS, status: 200 }
|
|
62
|
+
if (endpoint === '/access-review') return { data: [], status: 200 }
|
|
63
|
+
if (endpoint.startsWith('/users/u1')) return { data: DETAIL, status: 200 }
|
|
64
|
+
if (endpoint === '/users' || endpoint.startsWith('/users?'))
|
|
65
|
+
return { data: [MEMBER], status: 200 }
|
|
66
|
+
return { data: null, status: 200 }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const { Users } = await import('../../views/Users.js')
|
|
70
|
+
|
|
71
|
+
describe('Users ?selected= deep link', () => {
|
|
72
|
+
beforeEach(() => cmsApi.mockClear())
|
|
73
|
+
|
|
74
|
+
it('opens the detail drawer for the selected member once the list loads', async () => {
|
|
75
|
+
render(<Users currentPath="/users?selected=u1" />)
|
|
76
|
+
|
|
77
|
+
// "Content owned" only renders inside the detail drawer's overview tab.
|
|
78
|
+
expect(await screen.findByText('Content owned')).toBeTruthy()
|
|
79
|
+
expect(cmsApi.mock.calls.some(([endpoint]) => String(endpoint).startsWith('/users/u1'))).toBe(
|
|
80
|
+
true,
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('ignores a selected id that matches no member', async () => {
|
|
85
|
+
render(<Users currentPath="/users?selected=does-not-exist" />)
|
|
86
|
+
|
|
87
|
+
// List renders normally…
|
|
88
|
+
expect(await screen.findByText('priya@actuatemedia.com')).toBeTruthy()
|
|
89
|
+
// …but no drawer opens and no detail fetch is issued.
|
|
90
|
+
expect(screen.queryByText('Content owned')).toBeNull()
|
|
91
|
+
expect(
|
|
92
|
+
cmsApi.mock.calls.some(([endpoint]) => String(endpoint).includes('/users/does-not-exist')),
|
|
93
|
+
).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('renders without a drawer when there is no query at all', async () => {
|
|
97
|
+
render(<Users currentPath="/users" />)
|
|
98
|
+
|
|
99
|
+
expect(await screen.findByText('priya@actuatemedia.com')).toBeTruthy()
|
|
100
|
+
expect(screen.queryByText('Content owned')).toBeNull()
|
|
101
|
+
})
|
|
102
|
+
})
|