@prenta/admin 2.7.0 → 2.8.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/design-system/tokens.css +17 -0
  3. package/design-system/tokens.json +35 -1
  4. package/design-system/tokens.scss +15 -0
  5. package/design-system/tokens.tailwind.cjs +14 -0
  6. package/dist/AdminRoot.d.ts.map +1 -1
  7. package/dist/AdminRoot.js +2 -2
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
  10. package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/batch-wizard.test.js +144 -0
  12. package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
  13. package/dist/__tests__/lib/job-drawer.test.js +2 -0
  14. package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
  15. package/dist/__tests__/lib/job-view.test.js +100 -3
  16. package/dist/__tests__/lib/job-view.test.js.map +1 -1
  17. package/dist/__tests__/lib/jobs-service.test.js +56 -2
  18. package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
  19. package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
  22. package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
  23. package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
  24. package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
  25. package/dist/__tests__/views/header-footer-form.test.js +266 -0
  26. package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
  27. package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
  28. package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
  29. package/dist/__tests__/views/jobs-list.render.test.js +7 -5
  30. package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
  31. package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
  34. package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
  35. package/dist/lib/api.d.ts +2 -0
  36. package/dist/lib/api.d.ts.map +1 -1
  37. package/dist/lib/api.js +8 -1
  38. package/dist/lib/api.js.map +1 -1
  39. package/dist/lib/batch-wizard.d.ts +84 -0
  40. package/dist/lib/batch-wizard.d.ts.map +1 -0
  41. package/dist/lib/batch-wizard.js +122 -0
  42. package/dist/lib/batch-wizard.js.map +1 -0
  43. package/dist/lib/chrome-service.d.ts +69 -0
  44. package/dist/lib/chrome-service.d.ts.map +1 -0
  45. package/dist/lib/chrome-service.js +108 -0
  46. package/dist/lib/chrome-service.js.map +1 -0
  47. package/dist/lib/job-view.d.ts +43 -0
  48. package/dist/lib/job-view.d.ts.map +1 -1
  49. package/dist/lib/job-view.js +57 -1
  50. package/dist/lib/job-view.js.map +1 -1
  51. package/dist/lib/jobs-service.d.ts +19 -1
  52. package/dist/lib/jobs-service.d.ts.map +1 -1
  53. package/dist/lib/jobs-service.js +41 -0
  54. package/dist/lib/jobs-service.js.map +1 -1
  55. package/dist/prenta-admin.css +1 -1
  56. package/dist/views/AppearanceChrome.d.ts +22 -4
  57. package/dist/views/AppearanceChrome.d.ts.map +1 -1
  58. package/dist/views/AppearanceChrome.js +451 -15
  59. package/dist/views/AppearanceChrome.js.map +1 -1
  60. package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
  61. package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
  62. package/dist/views/Jobs/CancelJobDialog.js +13 -3
  63. package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
  64. package/dist/views/Jobs/DrawerReview.d.ts +7 -0
  65. package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
  66. package/dist/views/Jobs/DrawerReview.js +1 -1
  67. package/dist/views/Jobs/DrawerReview.js.map +1 -1
  68. package/dist/views/Jobs/JobDrawer.d.ts +23 -3
  69. package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
  70. package/dist/views/Jobs/JobDrawer.js +30 -5
  71. package/dist/views/Jobs/JobDrawer.js.map +1 -1
  72. package/dist/views/Jobs/JobsTable.d.ts +4 -2
  73. package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
  74. package/dist/views/Jobs/JobsTable.js +6 -5
  75. package/dist/views/Jobs/JobsTable.js.map +1 -1
  76. package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
  77. package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
  78. package/dist/views/Jobs/NewBatchJob.js +282 -0
  79. package/dist/views/Jobs/NewBatchJob.js.map +1 -0
  80. package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
  81. package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
  82. package/dist/views/Jobs/WorkerStrip.js +2 -2
  83. package/dist/views/Jobs/WorkerStrip.js.map +1 -1
  84. package/dist/views/Jobs/focus.d.ts +6 -0
  85. package/dist/views/Jobs/focus.d.ts.map +1 -1
  86. package/dist/views/Jobs/focus.js +10 -0
  87. package/dist/views/Jobs/focus.js.map +1 -1
  88. package/dist/views/Jobs.d.ts +12 -3
  89. package/dist/views/Jobs.d.ts.map +1 -1
  90. package/dist/views/Jobs.js +109 -31
  91. package/dist/views/Jobs.js.map +1 -1
  92. package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
  93. package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
  94. package/dist/views/header-footer/AnnouncementTab.js +43 -0
  95. package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
  96. package/dist/views/header-footer/ChromeField.d.ts +25 -0
  97. package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
  98. package/dist/views/header-footer/ChromeField.js +99 -0
  99. package/dist/views/header-footer/ChromeField.js.map +1 -0
  100. package/dist/views/header-footer/ChromePreview.d.ts +31 -0
  101. package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
  102. package/dist/views/header-footer/ChromePreview.js +195 -0
  103. package/dist/views/header-footer/ChromePreview.js.map +1 -0
  104. package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
  105. package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
  106. package/dist/views/header-footer/DeveloperTab.js +98 -0
  107. package/dist/views/header-footer/DeveloperTab.js.map +1 -0
  108. package/dist/views/header-footer/RegionTab.d.ts +27 -0
  109. package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
  110. package/dist/views/header-footer/RegionTab.js +80 -0
  111. package/dist/views/header-footer/RegionTab.js.map +1 -0
  112. package/dist/views/header-footer/chrome-form.d.ts +137 -0
  113. package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
  114. package/dist/views/header-footer/chrome-form.js +518 -0
  115. package/dist/views/header-footer/chrome-form.js.map +1 -0
  116. package/dist/views/header-footer/code-samples.d.ts +30 -0
  117. package/dist/views/header-footer/code-samples.d.ts.map +1 -0
  118. package/dist/views/header-footer/code-samples.js +127 -0
  119. package/dist/views/header-footer/code-samples.js.map +1 -0
  120. package/dist/views/header-footer/controls.d.ts +57 -0
  121. package/dist/views/header-footer/controls.d.ts.map +1 -0
  122. package/dist/views/header-footer/controls.js +43 -0
  123. package/dist/views/header-footer/controls.js.map +1 -0
  124. package/package.json +3 -3
  125. package/src/AdminRoot.tsx +16 -2
  126. package/src/__tests__/lib/batch-wizard.test.ts +199 -0
  127. package/src/__tests__/lib/job-drawer.test.ts +2 -0
  128. package/src/__tests__/lib/job-view.test.ts +123 -2
  129. package/src/__tests__/lib/jobs-service.test.ts +72 -3
  130. package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
  131. package/src/__tests__/views/header-footer-form.test.ts +333 -0
  132. package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
  133. package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
  134. package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
  135. package/src/lib/api.ts +12 -1
  136. package/src/lib/batch-wizard.ts +188 -0
  137. package/src/lib/chrome-service.ts +200 -0
  138. package/src/lib/job-view.ts +95 -1
  139. package/src/lib/jobs-service.ts +56 -1
  140. package/src/styles/prenta-tokens.css +17 -0
  141. package/src/styles/theme.css +15 -0
  142. package/src/views/AppearanceChrome.tsx +704 -46
  143. package/src/views/Jobs/CancelJobDialog.tsx +20 -2
  144. package/src/views/Jobs/DrawerReview.tsx +1 -1
  145. package/src/views/Jobs/JobDrawer.tsx +83 -3
  146. package/src/views/Jobs/JobsTable.tsx +11 -4
  147. package/src/views/Jobs/NewBatchJob.tsx +857 -0
  148. package/src/views/Jobs/WorkerStrip.tsx +16 -11
  149. package/src/views/Jobs/focus.ts +11 -0
  150. package/src/views/Jobs.tsx +163 -49
  151. package/src/views/header-footer/AnnouncementTab.tsx +231 -0
  152. package/src/views/header-footer/ChromeField.tsx +358 -0
  153. package/src/views/header-footer/ChromePreview.tsx +497 -0
  154. package/src/views/header-footer/DeveloperTab.tsx +331 -0
  155. package/src/views/header-footer/RegionTab.tsx +398 -0
  156. package/src/views/header-footer/chrome-form.ts +603 -0
  157. package/src/views/header-footer/code-samples.ts +146 -0
  158. package/src/views/header-footer/controls.tsx +166 -0
  159. package/dist/views/Jobs/placeholders.d.ts +0 -6
  160. package/dist/views/Jobs/placeholders.d.ts.map +0 -1
  161. package/dist/views/Jobs/placeholders.js +0 -20
  162. package/dist/views/Jobs/placeholders.js.map +0 -1
  163. package/src/views/Jobs/placeholders.tsx +0 -66
@@ -34,6 +34,8 @@ interface Server {
34
34
  reviewError: { status: number; message: string } | null
35
35
  decide: (jobId: string, itemId: string, body: Record<string, unknown>) => Reply | Promise<Reply>
36
36
  approveAll: (jobId: string) => Reply | Promise<Reply>
37
+ /** `POST /jobs/run`, `/jobs/:id/cancel` and `/jobs/:id/retry`. */
38
+ action: (endpoint: string, body: unknown) => Reply | Promise<Reply>
37
39
  calls: Call[]
38
40
  }
39
41
 
@@ -45,6 +47,7 @@ const server: Server = {
45
47
  reviewError: null,
46
48
  decide: () => ({ error: 'not stubbed', status: 500 }),
47
49
  approveAll: () => ({ error: 'not stubbed', status: 500 }),
50
+ action: () => ({ error: 'not stubbed', status: 500 }),
48
51
  calls: [],
49
52
  }
50
53
 
@@ -77,6 +80,8 @@ function cmsApiStub(endpoint: string, init: RequestInit = {}): Reply | Promise<R
77
80
  if (method === 'POST' && part === 'review' && itemId === 'approve-all')
78
81
  return server.approveAll(jobId!)
79
82
  if (method === 'POST' && part === 'review') return server.decide(jobId!, itemId!, body)
83
+ if (method === 'POST' && (jobId === 'run' || part === 'cancel' || part === 'retry'))
84
+ return server.action(endpoint, body)
80
85
  return { error: `unexpected ${method} ${endpoint}`, status: 404 }
81
86
  }
82
87
 
@@ -104,6 +109,10 @@ function fetchStub(url: string) {
104
109
 
105
110
  const { Jobs } = await import('../../views/Jobs.js')
106
111
 
112
+ type Viewer = { role: string | null; userId: string | null }
113
+ /** Someone who may run, cancel and retry any job. */
114
+ const ADMIN: Viewer = { role: 'ADMIN', userId: 'u1' }
115
+
107
116
  function iso(offsetMs: number): string {
108
117
  return new Date(Date.now() + offsetMs).toISOString()
109
118
  }
@@ -190,6 +199,7 @@ beforeEach(() => {
190
199
  reviewError: null,
191
200
  calls: [],
192
201
  approveAll: () => ({ error: 'not stubbed', status: 500 }),
202
+ action: () => ({ error: 'not stubbed', status: 500 }),
193
203
  })
194
204
  // By default a decision goes through: the change takes the new status.
195
205
  server.decide = (jobId, itemId, body) => {
@@ -220,8 +230,8 @@ function rowButton(id: string): HTMLElement {
220
230
  return button
221
231
  }
222
232
 
223
- async function renderJobs(onNavigate: (path: string) => void = () => {}) {
224
- render(<Jobs onNavigate={onNavigate} />)
233
+ async function renderJobs(onNavigate: (path: string) => void = () => {}, viewer: Viewer = ADMIN) {
234
+ render(<Jobs onNavigate={onNavigate} {...viewer} />)
225
235
  await screen.findByRole('list', { name: 'Jobs' })
226
236
  }
227
237
 
@@ -1045,3 +1055,499 @@ describe('Jobs drawer', () => {
1045
1055
  expect(raw).toContain('"id": "job_417"')
1046
1056
  })
1047
1057
  })
1058
+
1059
+ /*
1060
+ * P-19: the footer's Cancel job, "Retry from document {n+1}" and "Retry now",
1061
+ * the cancel dialog over the drawer, Run now reusing a job, and who sees them.
1062
+ */
1063
+
1064
+ function scan(
1065
+ type: 'seo-scan' | 'content-health',
1066
+ overrides: Partial<Job> & { done?: number; total?: number } = {},
1067
+ ): Job {
1068
+ const job = batch({
1069
+ id: type === 'seo-scan' ? 'job_scan' : 'job_health',
1070
+ type,
1071
+ title: type === 'seo-scan' ? 'SEO scan' : 'Content health',
1072
+ scope: type === 'seo-scan' ? 'All indexable pages' : 'All published documents',
1073
+ createdBy: { kind: 'cron', schedule: type === 'seo-scan' ? 'weekly-seo' : 'nightly-health' },
1074
+ payload: { trigger: 'cron' },
1075
+ ...overrides,
1076
+ })
1077
+ delete job.review
1078
+ return job
1079
+ }
1080
+
1081
+ /** A batch that ran out of attempts at document 8. */
1082
+ function failed(overrides: Partial<Job> = {}): Job {
1083
+ return batch({
1084
+ id: 'job_415',
1085
+ title: 'Translate excerpts to Spanish',
1086
+ status: 'dead',
1087
+ attempts: 3,
1088
+ done: 7,
1089
+ total: 30,
1090
+ lockedAt: null,
1091
+ lastError: 'ProviderError: 429 Too Many Requests',
1092
+ ...overrides,
1093
+ })
1094
+ }
1095
+
1096
+ /** A batch waiting out a failed slice's backoff. */
1097
+ function waiting(overrides: Partial<Job> = {}): Job {
1098
+ return batch({
1099
+ id: 'job_418',
1100
+ title: 'Generate alt text',
1101
+ status: 'queued',
1102
+ attempts: 1,
1103
+ done: 9,
1104
+ total: 64,
1105
+ lockedAt: null,
1106
+ lastError: 'ImageFetchError: img_2291.webp returned 404',
1107
+ runAt: iso(30_000),
1108
+ ...overrides,
1109
+ })
1110
+ }
1111
+
1112
+ /** The job as the server has it after an action; the list's next read returns it. */
1113
+ function patchJob(id: string, patch: Partial<Job>): Job {
1114
+ server.jobs = server.jobs.map((job) =>
1115
+ job.id === id ? { ...job, ...patch, updatedAt: iso(1_000) } : job,
1116
+ )
1117
+ return server.jobs.find((job) => job.id === id)!
1118
+ }
1119
+
1120
+ function panelOf(drawer: HTMLElement): HTMLElement {
1121
+ return drawer.querySelector<HTMLElement>('[role="tabpanel"]')!
1122
+ }
1123
+
1124
+ /** The cancel dialog, over the drawer or the list. */
1125
+ function cancelDialog(): Promise<HTMLElement> {
1126
+ return screen.findByRole('dialog', { name: /^Cancel “/ })
1127
+ }
1128
+
1129
+ async function cancelDialogGone() {
1130
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: /^Cancel “/ })).toBeNull())
1131
+ }
1132
+
1133
+ /** Click as a browser does: the button takes focus first. */
1134
+ function press(button: HTMLElement) {
1135
+ button.focus()
1136
+ fireEvent.click(button)
1137
+ }
1138
+
1139
+ describe('Jobs drawer actions (P-19)', () => {
1140
+ it('opens the cancel dialog over the drawer; Keep running and Escape go back to Cancel job', async () => {
1141
+ server.jobs = [batch({})]
1142
+ await renderJobs()
1143
+ const drawer = await openJob('job_417')
1144
+ const cancel = within(drawer).getByRole('button', { name: 'Cancel job' })
1145
+ expect(cancel.getAttribute('aria-haspopup')).toBe('dialog')
1146
+ expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
1147
+
1148
+ cancel.focus()
1149
+ fireEvent.click(cancel)
1150
+ const dialog = await cancelDialog()
1151
+ expect(dialog.getAttribute('aria-modal')).toBe('true')
1152
+ expect(dialog.textContent).toContain(
1153
+ 'It’s in the middle of a slice. It will stop when that slice finishes, usually within 20 seconds. The 19 changes already made stay in review. Nothing is applied unless you approve it.',
1154
+ )
1155
+ // The drawer's controls are hidden behind it. (aria-hidden keeps the
1156
+ // drawer's live region, and so the drawer element, exposed.)
1157
+ expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
1158
+ expect(within(drawer).queryByRole('tab')).toBeNull()
1159
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Keep running' }))
1160
+ await cancelDialogGone()
1161
+ await waitFor(() => expect(document.activeElement).toBe(cancel))
1162
+ expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBe(cancel)
1163
+
1164
+ // Escape closes the dialog on top, not the drawer under it.
1165
+ fireEvent.click(cancel)
1166
+ await cancelDialog()
1167
+ fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
1168
+ await cancelDialogGone()
1169
+ expect(screen.getByRole('dialog', { name: 'Rewrite meta descriptions' })).toBe(drawer)
1170
+ await waitFor(() => expect(document.activeElement).toBe(cancel))
1171
+ expect(posts('POST', '/cancel')).toHaveLength(0)
1172
+ })
1173
+
1174
+ it('uses the kit’s copy for each state', async () => {
1175
+ const auto = batch({}).payload as Extract<Job['payload'], { review: string }>
1176
+ server.jobs = [
1177
+ batch({ status: 'queued', lockedAt: null, payload: { ...auto, review: 'auto' } }),
1178
+ scan('content-health', { status: 'running', done: 50, total: 412 }),
1179
+ scan('seo-scan', { status: 'queued', lockedAt: null, done: 0, total: 340 }),
1180
+ ]
1181
+ await renderJobs()
1182
+ const copy = async (id: string) => {
1183
+ const drawer = await openJob(id)
1184
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }))
1185
+ const text = (await cancelDialog()).textContent ?? ''
1186
+ fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' })
1187
+ await cancelDialogGone()
1188
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
1189
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
1190
+ return text
1191
+ }
1192
+ expect(await copy('job_417')).toContain(
1193
+ 'It hasn’t started its next slice, so it stops right away. The 19 documents already changed keep their changes. You can undo them from each document’s history.',
1194
+ )
1195
+ expect(await copy('job_health')).toContain(
1196
+ 'It’s in the middle of a slice. It will stop when that slice finishes, usually within 20 seconds. Progress so far is kept. The next nightly cron run starts fresh.',
1197
+ )
1198
+ expect(await copy('job_scan')).toContain(
1199
+ 'It hasn’t started its next slice, so it stops right away. Progress so far is kept. The next weekly cron run starts fresh.',
1200
+ )
1201
+ })
1202
+
1203
+ it('cancels a running job: Cancelling · Stops after this slice, announced, focus in the drawer', async () => {
1204
+ server.jobs = [batch({})]
1205
+ server.action = (endpoint) =>
1206
+ endpoint === '/jobs/job_417/cancel'
1207
+ ? ok(patchJob('job_417', { cancelRequestedAt: iso(0) }))
1208
+ : { error: `unexpected ${endpoint}`, status: 500 }
1209
+ await renderJobs()
1210
+ const drawer = await openJob('job_417')
1211
+ press(within(drawer).getByRole('button', { name: 'Cancel job' }))
1212
+ press(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }))
1213
+
1214
+ await cancelDialogGone()
1215
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Stopping after this slice'))
1216
+ await waitFor(() => expect(announced()).toBe('Stopping after this slice'))
1217
+ expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0)
1218
+ expect(drawer.textContent).toContain(
1219
+ 'Stopping when the current slice finishes, usually within 20 seconds.',
1220
+ )
1221
+ expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
1222
+ // Cancel job went with the cancel, so focus lands on the panel showing the new state.
1223
+ await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
1224
+ expect(posts('POST', '/cancel')).toHaveLength(1)
1225
+
1226
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
1227
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
1228
+ const row = rowButton('job_417').closest('li')!
1229
+ expect(within(row).getByText('Cancelling')).toBeTruthy()
1230
+ expect(within(row).getByText('Stops after this slice')).toBeTruthy()
1231
+ expect(within(row).queryByRole('button', { name: /^Cancel/ })).toBeNull()
1232
+ })
1233
+
1234
+ it('shows a cancel’s outcome only once its dialog has closed', async () => {
1235
+ server.jobs = [batch({ status: 'queued', lockedAt: null })]
1236
+ let answer: (reply: Reply) => void = () => {}
1237
+ server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
1238
+ await renderJobs()
1239
+ const drawer = await openJob('job_417')
1240
+ press(within(drawer).getByRole('button', { name: 'Cancel job' }))
1241
+ const dialog = await cancelDialog()
1242
+ press(within(dialog).getByRole('button', { name: 'Cancel job' }))
1243
+ await waitFor(() => expect(posts('POST', '/cancel')).toHaveLength(1))
1244
+ // While the dialog is open Radix hides the rest of the page from screen
1245
+ // readers: the toaster, and the drawer with its live region.
1246
+ const dialogsAtToast: number[] = []
1247
+ toast.mockImplementationOnce(() => {
1248
+ dialogsAtToast.push(document.querySelectorAll('[role="dialog"]').length)
1249
+ })
1250
+ await act(async () => {
1251
+ answer(ok(patchJob('job_417', { status: 'cancelled', cancelRequestedAt: iso(0) })))
1252
+ })
1253
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Job cancelled'))
1254
+ expect(dialogsAtToast).toEqual([1])
1255
+ await waitFor(() => expect(announced()).toBe('Job cancelled'))
1256
+ // Focus was back in the drawer before the outcome was announced.
1257
+ expect(document.activeElement).toBe(panelOf(drawer))
1258
+ })
1259
+
1260
+ it('posts one cancel for a double click, and opens one dialog for a double click on Cancel job', async () => {
1261
+ server.jobs = [batch({})]
1262
+ let answer: (reply: Reply) => void = () => {}
1263
+ server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
1264
+ await renderJobs()
1265
+ const drawer = await openJob('job_417')
1266
+ const open = within(drawer).getByRole('button', { name: 'Cancel job' })
1267
+ fireEvent.click(open)
1268
+ fireEvent.click(open)
1269
+ const dialog = await cancelDialog()
1270
+ expect(screen.getAllByRole('dialog', { name: /^Cancel “/ })).toHaveLength(1)
1271
+
1272
+ const confirm = within(dialog).getByRole('button', { name: 'Cancel job' })
1273
+ confirm.focus()
1274
+ fireEvent.click(confirm)
1275
+ fireEvent.click(confirm)
1276
+ await waitFor(() => expect(confirm.getAttribute('aria-busy')).toBe('true'))
1277
+ fireEvent.click(confirm)
1278
+ expect(document.activeElement).toBe(confirm)
1279
+ // Keep running waits for the answer too.
1280
+ expect(
1281
+ within(dialog).getByRole('button', { name: 'Keep running' }).getAttribute('aria-disabled'),
1282
+ ).toBe('true')
1283
+ await act(async () => {
1284
+ answer(ok(patchJob('job_417', { cancelRequestedAt: iso(0) })))
1285
+ })
1286
+ await cancelDialogGone()
1287
+ expect(posts('POST', '/cancel')).toHaveLength(1)
1288
+ })
1289
+
1290
+ it('reads the list again on a 409 from a cancel that already finished, then says so', async () => {
1291
+ server.jobs = [batch({ review: { pending: 4, approved: 0, rejected: 0 } })]
1292
+ server.action = () => {
1293
+ // The last slice finished just before the cancel arrived.
1294
+ patchJob('job_417', {
1295
+ status: 'succeeded',
1296
+ lockedAt: null,
1297
+ finishedAt: iso(0),
1298
+ progress: { done: 42, total: 42, unit: 'documents', cursor: '42', completed: 42 },
1299
+ })
1300
+ return { error: 'This job already finished.', status: 409 }
1301
+ }
1302
+ await renderJobs()
1303
+ const drawer = await openJob('job_417')
1304
+ press(within(drawer).getByRole('button', { name: 'Cancel job' }))
1305
+ const dialog = await cancelDialog()
1306
+ const listReads = vi.mocked(fetch).mock.calls.length
1307
+ press(within(dialog).getByRole('button', { name: 'Cancel job' }))
1308
+
1309
+ await cancelDialogGone()
1310
+ const message = '“Rewrite meta descriptions” wasn’t cancelled: This job already finished.'
1311
+ await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
1312
+ expect(toast.error).not.toHaveBeenCalled()
1313
+ expect(vi.mocked(fetch).mock.calls.length).toBeGreaterThan(listReads)
1314
+ // The dialog closed onto the job as it is now: finished, with nothing to cancel.
1315
+ expect(within(drawer).getAllByText('Needs review').length).toBeGreaterThan(0)
1316
+ expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
1317
+ await waitFor(() => expect(announced()).toBe(message))
1318
+ await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
1319
+ })
1320
+
1321
+ it('says a job had already finished when the cancel comes back unchanged', async () => {
1322
+ server.jobs = [scan('content-health', { status: 'running', done: 50, total: 412 })]
1323
+ server.action = () =>
1324
+ ok(patchJob('job_health', { status: 'succeeded', lockedAt: null, finishedAt: iso(0) }))
1325
+ await renderJobs()
1326
+ const drawer = await openJob('job_health')
1327
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }))
1328
+ fireEvent.click(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }))
1329
+ await waitFor(() =>
1330
+ expect(toast).toHaveBeenCalledWith('“Content health” had already finished.'),
1331
+ )
1332
+ expect(within(drawer).getAllByText('Succeeded').length).toBeGreaterThan(0)
1333
+ expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
1334
+ })
1335
+
1336
+ it('retries a failed job from the drawer: Retry from document 8, then focus on the panel', async () => {
1337
+ server.jobs = [failed()]
1338
+ server.action = (endpoint) =>
1339
+ endpoint === '/jobs/job_415/retry'
1340
+ ? ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null }))
1341
+ : { error: `unexpected ${endpoint}`, status: 500 }
1342
+ await renderJobs()
1343
+ const drawer = await openJob('job_415')
1344
+ expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
1345
+ expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
1346
+ const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
1347
+ // The kit's primary button.
1348
+ expect(retry.className).toContain('bg-acc')
1349
+ retry.focus()
1350
+ fireEvent.click(retry)
1351
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'))
1352
+ await waitFor(() => expect(announced()).toBe('Retrying from document 8'))
1353
+ expect(within(drawer).getAllByText('In progress').length).toBeGreaterThan(0)
1354
+ expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
1355
+ // Queued again, it can be cancelled.
1356
+ expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
1357
+ await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
1358
+ expect(posts('POST', '/retry')).toHaveLength(1)
1359
+ })
1360
+
1361
+ it('counts pages for an SEO scan: Retry from page 101', async () => {
1362
+ server.jobs = [
1363
+ scan('seo-scan', {
1364
+ status: 'dead',
1365
+ attempts: 3,
1366
+ done: 100,
1367
+ total: 340,
1368
+ lockedAt: null,
1369
+ lastError: 'TimeoutError: fetch timed out',
1370
+ }),
1371
+ ]
1372
+ await renderJobs()
1373
+ const drawer = await openJob('job_scan')
1374
+ expect(within(drawer).getByRole('button', { name: 'Retry from page 101' })).toBeTruthy()
1375
+ })
1376
+
1377
+ it('posts one retry for a double click, keeping focus on the busy button', async () => {
1378
+ server.jobs = [failed()]
1379
+ let answer: (reply: Reply) => void = () => {}
1380
+ server.action = () => new Promise<Reply>((resolve) => (answer = resolve))
1381
+ await renderJobs()
1382
+ const drawer = await openJob('job_415')
1383
+ const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
1384
+ retry.focus()
1385
+ fireEvent.click(retry)
1386
+ fireEvent.click(retry)
1387
+ await waitFor(() => expect(retry.getAttribute('aria-busy')).toBe('true'))
1388
+ expect(retry.hasAttribute('disabled')).toBe(false)
1389
+ expect(document.activeElement).toBe(retry)
1390
+ fireEvent.click(retry)
1391
+ expect(posts('POST', '/retry')).toHaveLength(1)
1392
+ await act(async () => {
1393
+ answer(ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null })))
1394
+ })
1395
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'))
1396
+ expect(posts('POST', '/retry')).toHaveLength(1)
1397
+ })
1398
+
1399
+ it('reads the list again on a 409 from a retry, and shows the job as it is now', async () => {
1400
+ server.jobs = [failed()]
1401
+ server.action = () => {
1402
+ // Someone else retried it first, and the worker has picked it up.
1403
+ patchJob('job_415', { status: 'running', attempts: 0, lastError: null, lockedAt: iso(0) })
1404
+ return { error: 'Only a failed job, or one waiting to retry, can be retried', status: 409 }
1405
+ }
1406
+ await renderJobs()
1407
+ const drawer = await openJob('job_415')
1408
+ const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
1409
+ retry.focus()
1410
+ fireEvent.click(retry)
1411
+ const message =
1412
+ '“Translate excerpts to Spanish” wasn’t retried: Only a failed job, or one waiting to retry, can be retried'
1413
+ await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
1414
+ expect(toast.error).not.toHaveBeenCalled()
1415
+ // The answer shows with the job as it is now, read again: running.
1416
+ expect(within(drawer).getAllByText('Running').length).toBeGreaterThan(0)
1417
+ expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
1418
+ await waitFor(() => expect(announced()).toBe(message))
1419
+ await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
1420
+ })
1421
+
1422
+ it('keeps Retry and focus on a refusal that isn’t a 409', async () => {
1423
+ server.jobs = [failed()]
1424
+ server.action = () => ({ error: 'Insufficient permissions', status: 403 })
1425
+ await renderJobs()
1426
+ const drawer = await openJob('job_415')
1427
+ const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' })
1428
+ retry.focus()
1429
+ fireEvent.click(retry)
1430
+ const message = '“Translate excerpts to Spanish” wasn’t retried: Insufficient permissions'
1431
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message))
1432
+ await waitFor(() => expect(announced()).toBe(message))
1433
+ await waitFor(() => expect(retry.getAttribute('aria-busy')).toBeNull())
1434
+ expect(document.activeElement).toBe(retry)
1435
+ })
1436
+
1437
+ it('offers Retry now on a job waiting to retry, but not while cancelling or on a stale lock', async () => {
1438
+ server.jobs = [
1439
+ waiting(),
1440
+ waiting({ id: 'job_c', title: 'Cancelling batch', cancelRequestedAt: iso(0) }),
1441
+ batch({
1442
+ id: 'job_stale',
1443
+ title: 'Stuck batch',
1444
+ status: 'running',
1445
+ done: 4,
1446
+ attempts: 1,
1447
+ lockedAt: iso(-11 * MIN),
1448
+ }),
1449
+ ]
1450
+ server.action = (endpoint) =>
1451
+ endpoint === '/jobs/job_418/retry'
1452
+ ? ok(patchJob('job_418', { attempts: 0, lastError: null, runAt: iso(0) }))
1453
+ : { error: `unexpected ${endpoint}`, status: 500 }
1454
+ await renderJobs()
1455
+
1456
+ let drawer = await openJob('job_c')
1457
+ expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0)
1458
+ expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
1459
+ expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
1460
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
1461
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
1462
+
1463
+ // The server still holds a stale lock as running; the next worker run picks it up.
1464
+ drawer = await openJob('job_stale')
1465
+ expect(within(drawer).getAllByText('Retrying').length).toBeGreaterThan(0)
1466
+ expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
1467
+ expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
1468
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
1469
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
1470
+
1471
+ drawer = await openJob('job_418')
1472
+ expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy()
1473
+ const now = within(drawer).getByRole('button', { name: 'Retry now' })
1474
+ // The kit's plain `.jb-btn`, not the primary retry.
1475
+ expect(now.className).toContain('bg-card')
1476
+ now.focus()
1477
+ fireEvent.click(now)
1478
+ await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying on the next worker run'))
1479
+ await waitFor(() => expect(announced()).toBe('Retrying on the next worker run'))
1480
+ expect(posts('POST', '/jobs/job_418/retry')).toHaveLength(1)
1481
+ expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull()
1482
+ await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)))
1483
+ })
1484
+
1485
+ it('opens the running scan on Run now and announces the reuse in the drawer', async () => {
1486
+ const running = scan('seo-scan', { status: 'running', done: 40, total: 340 })
1487
+ server.jobs = [running]
1488
+ server.action = (endpoint) =>
1489
+ endpoint === '/jobs/run'
1490
+ ? ok({ job: running, reused: true })
1491
+ : { error: `unexpected ${endpoint}`, status: 500 }
1492
+ await renderJobs()
1493
+ fireEvent.click(screen.getByRole('button', { name: 'Run now: SEO scan' }))
1494
+ const drawer = await screen.findByRole('dialog', { name: 'SEO scan' })
1495
+ expect(toast).toHaveBeenCalledWith('SEO scan is already running. Reusing that job.')
1496
+ await waitFor(() => expect(announced()).toBe('SEO scan is already running. Reusing that job.'))
1497
+ expect(drawer.hasAttribute('data-job-drawer')).toBe(true)
1498
+ expect(posts('POST', '/jobs/run')).toHaveLength(1)
1499
+ })
1500
+
1501
+ it('hides Run now, Cancel and Retry from a viewer', async () => {
1502
+ server.jobs = [batch({}), failed(), waiting()]
1503
+ await renderJobs(() => {}, { role: 'VIEWER', userId: 'u9' })
1504
+ expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull()
1505
+ expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull()
1506
+ expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull()
1507
+ // The failure alert keeps See why.
1508
+ expect(within(screen.getByRole('alert')).getByRole('button', { name: 'See why' })).toBeTruthy()
1509
+
1510
+ for (const id of ['job_417', 'job_415', 'job_418']) {
1511
+ const drawer = await openJob(id)
1512
+ expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull()
1513
+ expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
1514
+ fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }))
1515
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
1516
+ }
1517
+ })
1518
+
1519
+ it('shows nothing to act with when there’s no role', async () => {
1520
+ server.jobs = [batch({}), failed()]
1521
+ await renderJobs(() => {}, { role: null, userId: null })
1522
+ expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull()
1523
+ expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull()
1524
+ expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull()
1525
+ })
1526
+
1527
+ it('lets an editor retry a scan, and only a batch they queued', async () => {
1528
+ server.jobs = [
1529
+ failed({ createdBy: { id: 'u2', name: 'Dana Cole', initials: 'DC' } }),
1530
+ failed({
1531
+ id: 'job_mine',
1532
+ title: 'My batch',
1533
+ createdBy: { id: 'u7', name: 'Ed', initials: 'E' },
1534
+ }),
1535
+ scan('content-health', {
1536
+ status: 'dead',
1537
+ attempts: 3,
1538
+ done: 50,
1539
+ total: 412,
1540
+ lockedAt: null,
1541
+ lastError: 'Error: boom',
1542
+ }),
1543
+ ]
1544
+ await renderJobs(() => {}, { role: 'EDITOR', userId: 'u7' })
1545
+ expect(screen.getByRole('button', { name: 'Run now: SEO scan' })).toBeTruthy()
1546
+ expect(screen.queryByRole('button', { name: 'Retry Translate excerpts to Spanish' })).toBeNull()
1547
+ expect(screen.getByRole('button', { name: 'Retry My batch' })).toBeTruthy()
1548
+ expect(screen.getByRole('button', { name: 'Retry Content health' })).toBeTruthy()
1549
+
1550
+ const drawer = await openJob('job_415')
1551
+ expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull()
1552
+ })
1553
+ })
@@ -104,6 +104,9 @@ function fetchStub(url: string, init: RequestInit = {}) {
104
104
 
105
105
  const { Jobs } = await import('../../views/Jobs.js')
106
106
 
107
+ /** Someone who may run, cancel and retry any job. */
108
+ const ADMIN = { role: 'ADMIN', userId: 'u1' }
109
+
107
110
  /** A time on the server's clock, `offsetMs` from now. */
108
111
  function iso(offsetMs: number): string {
109
112
  return new Date(Date.now() + server.clockOffsetMs + offsetMs).toISOString()
@@ -301,13 +304,13 @@ async function advance(ms: number) {
301
304
  }
302
305
 
303
306
  async function renderLoaded() {
304
- render(<Jobs onNavigate={() => {}} />)
307
+ render(<Jobs onNavigate={() => {}} {...ADMIN} />)
305
308
  await screen.findByRole('list', { name: 'Jobs' })
306
309
  }
307
310
 
308
311
  /** With fake timers: render and let the first load land. */
309
312
  async function renderOnClock() {
310
- render(<Jobs onNavigate={() => {}} />)
313
+ render(<Jobs onNavigate={() => {}} {...ADMIN} />)
311
314
  await advance(0)
312
315
  expect(screen.getByRole('list', { name: 'Jobs' })).toBeTruthy()
313
316
  }
@@ -428,7 +431,7 @@ describe('Jobs list', () => {
428
431
 
429
432
  it('shows the empty state', async () => {
430
433
  server.jobs = []
431
- render(<Jobs onNavigate={() => {}} />)
434
+ render(<Jobs onNavigate={() => {}} {...ADMIN} />)
432
435
  expect(await screen.findByText('No jobs here.')).toBeTruthy()
433
436
  expect(screen.queryByRole('alert')).toBeNull()
434
437
  expect(
@@ -574,7 +577,7 @@ describe('Jobs list refresh', () => {
574
577
  // StrictMode mounts, unmounts and remounts, aborting the first requests.
575
578
  render(
576
579
  <StrictMode>
577
- <Jobs onNavigate={() => {}} />
580
+ <Jobs onNavigate={() => {}} {...ADMIN} />
578
581
  </StrictMode>,
579
582
  )
580
583
  expect(await screen.findByText(/^Ran 2\ds ago/)).toBeTruthy()
@@ -649,7 +652,7 @@ describe('Jobs list refresh', () => {
649
652
  it('shows a load error with Try again, then focuses the list it loads', async () => {
650
653
  useClock()
651
654
  server.listError = { status: 500, message: 'Database unavailable' }
652
- render(<Jobs onNavigate={() => {}} />)
655
+ render(<Jobs onNavigate={() => {}} {...ADMIN} />)
653
656
  await advance(0)
654
657
  const alert = screen.getByRole('alert')
655
658
  expect(alert.textContent).toContain('Jobs couldn’t be loaded. Database unavailable')