@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.
Files changed (166) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +31 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.js +57 -2
  6. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -1
  7. package/dist/__tests__/router/query-string.test.d.ts +2 -0
  8. package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
  9. package/dist/__tests__/router/query-string.test.js +44 -0
  10. package/dist/__tests__/router/query-string.test.js.map +1 -0
  11. package/dist/__tests__/views/form-entries.render.test.js +14 -4
  12. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  13. package/dist/__tests__/views/form-submissions.render.test.js +14 -4
  14. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  15. package/dist/__tests__/views/media-seo-audit.render.test.js +26 -0
  16. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -1
  17. package/dist/__tests__/views/redirects-pane.render.test.d.ts +2 -0
  18. package/dist/__tests__/views/redirects-pane.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/redirects-pane.render.test.js +121 -0
  20. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -0
  21. package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
  24. package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/components/SEOPanel.js +3 -3
  27. package/dist/components/SEOPanel.js.map +1 -1
  28. package/dist/components/collections/EntryDetailPane.js +1 -1
  29. package/dist/components/collections/EntryDetailPane.js.map +1 -1
  30. package/dist/components/forms/EntriesTable.d.ts +3 -1
  31. package/dist/components/forms/EntriesTable.d.ts.map +1 -1
  32. package/dist/components/forms/EntriesTable.js +5 -2
  33. package/dist/components/forms/EntriesTable.js.map +1 -1
  34. package/dist/components/forms/FormEntryDetailPane.d.ts +10 -0
  35. package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -0
  36. package/dist/components/forms/FormEntryDetailPane.js +137 -0
  37. package/dist/components/forms/FormEntryDetailPane.js.map +1 -0
  38. package/dist/components/forms/FormSchemaPane.js +1 -1
  39. package/dist/components/forms/FormSchemaPane.js.map +1 -1
  40. package/dist/components/script-tags/ScriptTagEditorPane.js +1 -1
  41. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
  42. package/dist/components/seo/RedirectEditorPane.d.ts +12 -0
  43. package/dist/components/seo/RedirectEditorPane.d.ts.map +1 -0
  44. package/dist/components/seo/RedirectEditorPane.js +89 -0
  45. package/dist/components/seo/RedirectEditorPane.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.js +1 -1
  47. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  48. package/dist/components/ui/DataTable.js +2 -2
  49. package/dist/components/ui/DataTable.js.map +1 -1
  50. package/dist/components/ui/Skeleton.js +3 -3
  51. package/dist/components/ui/Skeleton.js.map +1 -1
  52. package/dist/components/ui/Toggle.js +1 -1
  53. package/dist/components/ui/Toggle.js.map +1 -1
  54. package/dist/fields/BlockBuilderField.js +1 -1
  55. package/dist/fields/BlockBuilderField.js.map +1 -1
  56. package/dist/fields/MediaField.js +1 -1
  57. package/dist/fields/MediaField.js.map +1 -1
  58. package/dist/fields/PropInput.js +1 -1
  59. package/dist/fields/PropInput.js.map +1 -1
  60. package/dist/hooks/useIdleLogout.d.ts +21 -6
  61. package/dist/hooks/useIdleLogout.d.ts.map +1 -1
  62. package/dist/hooks/useIdleLogout.js +41 -8
  63. package/dist/hooks/useIdleLogout.js.map +1 -1
  64. package/dist/layout/Header.js +1 -1
  65. package/dist/layout/Header.js.map +1 -1
  66. package/dist/router/index.d.ts +7 -0
  67. package/dist/router/index.d.ts.map +1 -1
  68. package/dist/router/index.js +23 -4
  69. package/dist/router/index.js.map +1 -1
  70. package/dist/views/DocumentEdit.js +1 -1
  71. package/dist/views/DocumentEdit.js.map +1 -1
  72. package/dist/views/FormEntries.d.ts.map +1 -1
  73. package/dist/views/FormEntries.js +18 -14
  74. package/dist/views/FormEntries.js.map +1 -1
  75. package/dist/views/FormSubmissions.d.ts.map +1 -1
  76. package/dist/views/FormSubmissions.js +18 -15
  77. package/dist/views/FormSubmissions.js.map +1 -1
  78. package/dist/views/Forms.js +5 -5
  79. package/dist/views/Forms.js.map +1 -1
  80. package/dist/views/MediaBrowser.d.ts +4 -1
  81. package/dist/views/MediaBrowser.d.ts.map +1 -1
  82. package/dist/views/MediaBrowser.js +28 -6
  83. package/dist/views/MediaBrowser.js.map +1 -1
  84. package/dist/views/MediaSeoAudit.d.ts +4 -1
  85. package/dist/views/MediaSeoAudit.d.ts.map +1 -1
  86. package/dist/views/MediaSeoAudit.js +12 -2
  87. package/dist/views/MediaSeoAudit.js.map +1 -1
  88. package/dist/views/Pages/PagesListView.js +14 -14
  89. package/dist/views/Pages/PagesListView.js.map +1 -1
  90. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
  91. package/dist/views/Pages/PagesTreeTable.js +3 -3
  92. package/dist/views/Pages/PagesTreeTable.js.map +1 -1
  93. package/dist/views/SEO.d.ts.map +1 -1
  94. package/dist/views/SEO.js +2 -1
  95. package/dist/views/SEO.js.map +1 -1
  96. package/dist/views/ScriptTags.d.ts.map +1 -1
  97. package/dist/views/ScriptTags.js +3 -3
  98. package/dist/views/ScriptTags.js.map +1 -1
  99. package/dist/views/Settings.d.ts +4 -1
  100. package/dist/views/Settings.d.ts.map +1 -1
  101. package/dist/views/Settings.js +4 -3
  102. package/dist/views/Settings.js.map +1 -1
  103. package/dist/views/Users.d.ts +4 -1
  104. package/dist/views/Users.d.ts.map +1 -1
  105. package/dist/views/Users.js +22 -2
  106. package/dist/views/Users.js.map +1 -1
  107. package/dist/views/seo/ContentTab.js +6 -6
  108. package/dist/views/seo/ContentTab.js.map +1 -1
  109. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  110. package/dist/views/seo/RedirectsTab.js +23 -8
  111. package/dist/views/seo/RedirectsTab.js.map +1 -1
  112. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  113. package/dist/views/settings/SecurityTab.js +2 -1
  114. package/dist/views/settings/SecurityTab.js.map +1 -1
  115. package/package.json +2 -2
  116. package/src/AdminRoot.tsx +53 -12
  117. package/src/__tests__/hooks/useIdleLogout.test.tsx +103 -1
  118. package/src/__tests__/router/query-string.test.ts +54 -0
  119. package/src/__tests__/views/form-entries.render.test.tsx +16 -4
  120. package/src/__tests__/views/form-submissions.render.test.tsx +16 -4
  121. package/src/__tests__/views/media-seo-audit.render.test.tsx +34 -0
  122. package/src/__tests__/views/redirects-pane.render.test.tsx +156 -0
  123. package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
  124. package/src/components/SEOPanel.tsx +9 -9
  125. package/src/components/collections/EntryDetailPane.tsx +1 -1
  126. package/src/components/forms/EntriesTable.tsx +12 -3
  127. package/src/components/forms/{EntryDetailDrawer.tsx → FormEntryDetailPane.tsx} +112 -102
  128. package/src/components/forms/FormSchemaPane.tsx +1 -1
  129. package/src/components/script-tags/ScriptTagEditorPane.tsx +1 -1
  130. package/src/components/seo/RedirectEditorPane.tsx +271 -0
  131. package/src/components/seo/SeoEditorPane.tsx +1 -1
  132. package/src/components/ui/DataTable.tsx +3 -3
  133. package/src/components/ui/Skeleton.tsx +8 -8
  134. package/src/components/ui/Toggle.tsx +1 -1
  135. package/src/fields/BlockBuilderField.tsx +1 -1
  136. package/src/fields/MediaField.tsx +1 -1
  137. package/src/fields/PropInput.tsx +1 -1
  138. package/src/hooks/useIdleLogout.ts +65 -13
  139. package/src/layout/Header.tsx +2 -2
  140. package/src/router/index.ts +25 -4
  141. package/src/styles/actuate-tokens.css +4 -0
  142. package/src/styles/theme.css +9 -3
  143. package/src/views/DocumentEdit.tsx +4 -4
  144. package/src/views/FormEntries.tsx +81 -66
  145. package/src/views/FormSubmissions.tsx +78 -64
  146. package/src/views/Forms.tsx +16 -16
  147. package/src/views/MediaBrowser.tsx +34 -6
  148. package/src/views/MediaSeoAudit.tsx +39 -2
  149. package/src/views/Pages/PagesListView.tsx +46 -46
  150. package/src/views/Pages/PagesTreeTable.tsx +11 -8
  151. package/src/views/SEO.tsx +2 -1
  152. package/src/views/ScriptTags.tsx +26 -36
  153. package/src/views/Settings.tsx +7 -2
  154. package/src/views/Users.tsx +23 -2
  155. package/src/views/seo/ContentTab.tsx +10 -10
  156. package/src/views/seo/RedirectsTab.tsx +145 -109
  157. package/src/views/settings/SecurityTab.tsx +2 -1
  158. package/dist/components/forms/EntryDetailDrawer.d.ts +0 -11
  159. package/dist/components/forms/EntryDetailDrawer.d.ts.map +0 -1
  160. package/dist/components/forms/EntryDetailDrawer.js +0 -145
  161. package/dist/components/forms/EntryDetailDrawer.js.map +0 -1
  162. package/dist/components/seo/RedirectEditorDrawer.d.ts +0 -9
  163. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +0 -1
  164. package/dist/components/seo/RedirectEditorDrawer.js +0 -95
  165. package/dist/components/seo/RedirectEditorDrawer.js.map +0 -1
  166. 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
- const params = new URLSearchParams(
331
- typeof window !== 'undefined' ? window.location.search : '',
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 = new URLSearchParams(currentPath.split('?')[1] ?? '')
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 <Settings onNavigate={navigate} config={config} session={session} initialTab="users" />
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 onNavigate={navigate} config={config} session={session} initialTab="api-keys" />
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 <Settings onNavigate={navigate} config={config} session={session} />
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
- if (currentPath.endsWith('/preview')) {
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
- const segment = currentPath === '/' ? 'Dashboard' : currentPath.split('/').filter(Boolean)[0]
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
- useIdleLogout({ enabled, timeoutMs, onIdle })
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 drawer.
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 drawer when a row is clicked', async () => {
125
+ it('opens the detail pane inline when a row is clicked', async () => {
126
126
  render(<FormEntries onNavigate={() => {}} />)
127
- const row = await screen.findByText('Jordan Avery')
127
+ const rowButton = await screen.findByRole('button', { name: 'Jordan Avery' })
128
+ expect(rowButton.getAttribute('aria-expanded')).toBe('false')
128
129
 
129
- fireEvent.click(row)
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 drawer without a real backend.
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 drawer when a row is clicked', async () => {
121
+ it('opens the detail pane inline when a row is clicked', async () => {
122
122
  render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
123
- const row = await screen.findByText('Jordan Avery')
123
+ const rowButton = await screen.findByRole('button', { name: 'Jordan Avery' })
124
+ expect(rowButton.getAttribute('aria-expanded')).toBe('false')
124
125
 
125
- fireEvent.click(row)
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
+ })