@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.
- package/CHANGELOG.md +34 -0
- package/design-system/tokens.css +17 -0
- package/design-system/tokens.json +35 -1
- package/design-system/tokens.scss +15 -0
- package/design-system/tokens.tailwind.cjs +14 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/batch-wizard.test.d.ts +2 -0
- package/dist/__tests__/lib/batch-wizard.test.d.ts.map +1 -0
- package/dist/__tests__/lib/batch-wizard.test.js +144 -0
- package/dist/__tests__/lib/batch-wizard.test.js.map +1 -0
- package/dist/__tests__/lib/job-drawer.test.js +2 -0
- package/dist/__tests__/lib/job-drawer.test.js.map +1 -1
- package/dist/__tests__/lib/job-view.test.js +100 -3
- package/dist/__tests__/lib/job-view.test.js.map +1 -1
- package/dist/__tests__/lib/jobs-service.test.js +56 -2
- package/dist/__tests__/lib/jobs-service.test.js.map +1 -1
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts +2 -0
- package/dist/__tests__/views/appearance-chrome.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js +688 -0
- package/dist/__tests__/views/appearance-chrome.render.test.js.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts +2 -0
- package/dist/__tests__/views/header-footer-form.test.d.ts.map +1 -0
- package/dist/__tests__/views/header-footer-form.test.js +266 -0
- package/dist/__tests__/views/header-footer-form.test.js.map +1 -0
- package/dist/__tests__/views/jobs-drawer.render.test.js +442 -2
- package/dist/__tests__/views/jobs-drawer.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-list.render.test.js +7 -5
- package/dist/__tests__/views/jobs-list.render.test.js.map +1 -1
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts +2 -0
- package/dist/__tests__/views/jobs-wizard.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js +588 -0
- package/dist/__tests__/views/jobs-wizard.render.test.js.map +1 -0
- package/dist/lib/api.d.ts +2 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +8 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/batch-wizard.d.ts +84 -0
- package/dist/lib/batch-wizard.d.ts.map +1 -0
- package/dist/lib/batch-wizard.js +122 -0
- package/dist/lib/batch-wizard.js.map +1 -0
- package/dist/lib/chrome-service.d.ts +69 -0
- package/dist/lib/chrome-service.d.ts.map +1 -0
- package/dist/lib/chrome-service.js +108 -0
- package/dist/lib/chrome-service.js.map +1 -0
- package/dist/lib/job-view.d.ts +43 -0
- package/dist/lib/job-view.d.ts.map +1 -1
- package/dist/lib/job-view.js +57 -1
- package/dist/lib/job-view.js.map +1 -1
- package/dist/lib/jobs-service.d.ts +19 -1
- package/dist/lib/jobs-service.d.ts.map +1 -1
- package/dist/lib/jobs-service.js +41 -0
- package/dist/lib/jobs-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/AppearanceChrome.d.ts +22 -4
- package/dist/views/AppearanceChrome.d.ts.map +1 -1
- package/dist/views/AppearanceChrome.js +451 -15
- package/dist/views/AppearanceChrome.js.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts +7 -1
- package/dist/views/Jobs/CancelJobDialog.d.ts.map +1 -1
- package/dist/views/Jobs/CancelJobDialog.js +13 -3
- package/dist/views/Jobs/CancelJobDialog.js.map +1 -1
- package/dist/views/Jobs/DrawerReview.d.ts +7 -0
- package/dist/views/Jobs/DrawerReview.d.ts.map +1 -1
- package/dist/views/Jobs/DrawerReview.js +1 -1
- package/dist/views/Jobs/DrawerReview.js.map +1 -1
- package/dist/views/Jobs/JobDrawer.d.ts +23 -3
- package/dist/views/Jobs/JobDrawer.d.ts.map +1 -1
- package/dist/views/Jobs/JobDrawer.js +30 -5
- package/dist/views/Jobs/JobDrawer.js.map +1 -1
- package/dist/views/Jobs/JobsTable.d.ts +4 -2
- package/dist/views/Jobs/JobsTable.d.ts.map +1 -1
- package/dist/views/Jobs/JobsTable.js +6 -5
- package/dist/views/Jobs/JobsTable.js.map +1 -1
- package/dist/views/Jobs/NewBatchJob.d.ts +34 -0
- package/dist/views/Jobs/NewBatchJob.d.ts.map +1 -0
- package/dist/views/Jobs/NewBatchJob.js +282 -0
- package/dist/views/Jobs/NewBatchJob.js.map +1 -0
- package/dist/views/Jobs/WorkerStrip.d.ts +3 -1
- package/dist/views/Jobs/WorkerStrip.d.ts.map +1 -1
- package/dist/views/Jobs/WorkerStrip.js +2 -2
- package/dist/views/Jobs/WorkerStrip.js.map +1 -1
- package/dist/views/Jobs/focus.d.ts +6 -0
- package/dist/views/Jobs/focus.d.ts.map +1 -1
- package/dist/views/Jobs/focus.js +10 -0
- package/dist/views/Jobs/focus.js.map +1 -1
- package/dist/views/Jobs.d.ts +12 -3
- package/dist/views/Jobs.d.ts.map +1 -1
- package/dist/views/Jobs.js +109 -31
- package/dist/views/Jobs.js.map +1 -1
- package/dist/views/header-footer/AnnouncementTab.d.ts +15 -0
- package/dist/views/header-footer/AnnouncementTab.d.ts.map +1 -0
- package/dist/views/header-footer/AnnouncementTab.js +43 -0
- package/dist/views/header-footer/AnnouncementTab.js.map +1 -0
- package/dist/views/header-footer/ChromeField.d.ts +25 -0
- package/dist/views/header-footer/ChromeField.d.ts.map +1 -0
- package/dist/views/header-footer/ChromeField.js +99 -0
- package/dist/views/header-footer/ChromeField.js.map +1 -0
- package/dist/views/header-footer/ChromePreview.d.ts +31 -0
- package/dist/views/header-footer/ChromePreview.d.ts.map +1 -0
- package/dist/views/header-footer/ChromePreview.js +195 -0
- package/dist/views/header-footer/ChromePreview.js.map +1 -0
- package/dist/views/header-footer/DeveloperTab.d.ts +17 -0
- package/dist/views/header-footer/DeveloperTab.d.ts.map +1 -0
- package/dist/views/header-footer/DeveloperTab.js +98 -0
- package/dist/views/header-footer/DeveloperTab.js.map +1 -0
- package/dist/views/header-footer/RegionTab.d.ts +27 -0
- package/dist/views/header-footer/RegionTab.d.ts.map +1 -0
- package/dist/views/header-footer/RegionTab.js +80 -0
- package/dist/views/header-footer/RegionTab.js.map +1 -0
- package/dist/views/header-footer/chrome-form.d.ts +137 -0
- package/dist/views/header-footer/chrome-form.d.ts.map +1 -0
- package/dist/views/header-footer/chrome-form.js +518 -0
- package/dist/views/header-footer/chrome-form.js.map +1 -0
- package/dist/views/header-footer/code-samples.d.ts +30 -0
- package/dist/views/header-footer/code-samples.d.ts.map +1 -0
- package/dist/views/header-footer/code-samples.js +127 -0
- package/dist/views/header-footer/code-samples.js.map +1 -0
- package/dist/views/header-footer/controls.d.ts +57 -0
- package/dist/views/header-footer/controls.d.ts.map +1 -0
- package/dist/views/header-footer/controls.js +43 -0
- package/dist/views/header-footer/controls.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +16 -2
- package/src/__tests__/lib/batch-wizard.test.ts +199 -0
- package/src/__tests__/lib/job-drawer.test.ts +2 -0
- package/src/__tests__/lib/job-view.test.ts +123 -2
- package/src/__tests__/lib/jobs-service.test.ts +72 -3
- package/src/__tests__/views/appearance-chrome.render.test.tsx +818 -0
- package/src/__tests__/views/header-footer-form.test.ts +333 -0
- package/src/__tests__/views/jobs-drawer.render.test.tsx +508 -2
- package/src/__tests__/views/jobs-list.render.test.tsx +8 -5
- package/src/__tests__/views/jobs-wizard.render.test.tsx +707 -0
- package/src/lib/api.ts +12 -1
- package/src/lib/batch-wizard.ts +188 -0
- package/src/lib/chrome-service.ts +200 -0
- package/src/lib/job-view.ts +95 -1
- package/src/lib/jobs-service.ts +56 -1
- package/src/styles/prenta-tokens.css +17 -0
- package/src/styles/theme.css +15 -0
- package/src/views/AppearanceChrome.tsx +704 -46
- package/src/views/Jobs/CancelJobDialog.tsx +20 -2
- package/src/views/Jobs/DrawerReview.tsx +1 -1
- package/src/views/Jobs/JobDrawer.tsx +83 -3
- package/src/views/Jobs/JobsTable.tsx +11 -4
- package/src/views/Jobs/NewBatchJob.tsx +857 -0
- package/src/views/Jobs/WorkerStrip.tsx +16 -11
- package/src/views/Jobs/focus.ts +11 -0
- package/src/views/Jobs.tsx +163 -49
- package/src/views/header-footer/AnnouncementTab.tsx +231 -0
- package/src/views/header-footer/ChromeField.tsx +358 -0
- package/src/views/header-footer/ChromePreview.tsx +497 -0
- package/src/views/header-footer/DeveloperTab.tsx +331 -0
- package/src/views/header-footer/RegionTab.tsx +398 -0
- package/src/views/header-footer/chrome-form.ts +603 -0
- package/src/views/header-footer/code-samples.ts +146 -0
- package/src/views/header-footer/controls.tsx +166 -0
- package/dist/views/Jobs/placeholders.d.ts +0 -6
- package/dist/views/Jobs/placeholders.d.ts.map +0 -1
- package/dist/views/Jobs/placeholders.js +0 -20
- package/dist/views/Jobs/placeholders.js.map +0 -1
- package/src/views/Jobs/placeholders.tsx +0 -66
|
@@ -17,6 +17,7 @@ const server = {
|
|
|
17
17
|
reviewError: null,
|
|
18
18
|
decide: () => ({ error: 'not stubbed', status: 500 }),
|
|
19
19
|
approveAll: () => ({ error: 'not stubbed', status: 500 }),
|
|
20
|
+
action: () => ({ error: 'not stubbed', status: 500 }),
|
|
20
21
|
calls: [],
|
|
21
22
|
};
|
|
22
23
|
function ok(data, extra = {}) {
|
|
@@ -49,6 +50,8 @@ function cmsApiStub(endpoint, init = {}) {
|
|
|
49
50
|
return server.approveAll(jobId);
|
|
50
51
|
if (method === 'POST' && part === 'review')
|
|
51
52
|
return server.decide(jobId, itemId, body);
|
|
53
|
+
if (method === 'POST' && (jobId === 'run' || part === 'cancel' || part === 'retry'))
|
|
54
|
+
return server.action(endpoint, body);
|
|
52
55
|
return { error: `unexpected ${method} ${endpoint}`, status: 404 };
|
|
53
56
|
}
|
|
54
57
|
vi.mock('../../lib/api.js', () => ({
|
|
@@ -71,6 +74,8 @@ function fetchStub(url) {
|
|
|
71
74
|
});
|
|
72
75
|
}
|
|
73
76
|
const { Jobs } = await import('../../views/Jobs.js');
|
|
77
|
+
/** Someone who may run, cancel and retry any job. */
|
|
78
|
+
const ADMIN = { role: 'ADMIN', userId: 'u1' };
|
|
74
79
|
function iso(offsetMs) {
|
|
75
80
|
return new Date(Date.now() + offsetMs).toISOString();
|
|
76
81
|
}
|
|
@@ -150,6 +155,7 @@ beforeEach(() => {
|
|
|
150
155
|
reviewError: null,
|
|
151
156
|
calls: [],
|
|
152
157
|
approveAll: () => ({ error: 'not stubbed', status: 500 }),
|
|
158
|
+
action: () => ({ error: 'not stubbed', status: 500 }),
|
|
153
159
|
});
|
|
154
160
|
// By default a decision goes through: the change takes the new status.
|
|
155
161
|
server.decide = (jobId, itemId, body) => {
|
|
@@ -175,8 +181,8 @@ function rowButton(id) {
|
|
|
175
181
|
throw new Error(`no row ${id}`);
|
|
176
182
|
return button;
|
|
177
183
|
}
|
|
178
|
-
async function renderJobs(onNavigate = () => { }) {
|
|
179
|
-
render(_jsx(Jobs, { onNavigate: onNavigate }));
|
|
184
|
+
async function renderJobs(onNavigate = () => { }, viewer = ADMIN) {
|
|
185
|
+
render(_jsx(Jobs, { onNavigate: onNavigate, ...viewer }));
|
|
180
186
|
await screen.findByRole('list', { name: 'Jobs' });
|
|
181
187
|
}
|
|
182
188
|
async function openJob(id, tab) {
|
|
@@ -905,4 +911,438 @@ describe('Jobs drawer', () => {
|
|
|
905
911
|
expect(raw).toContain('"id": "job_417"');
|
|
906
912
|
});
|
|
907
913
|
});
|
|
914
|
+
/*
|
|
915
|
+
* P-19: the footer's Cancel job, "Retry from document {n+1}" and "Retry now",
|
|
916
|
+
* the cancel dialog over the drawer, Run now reusing a job, and who sees them.
|
|
917
|
+
*/
|
|
918
|
+
function scan(type, overrides = {}) {
|
|
919
|
+
const job = batch({
|
|
920
|
+
id: type === 'seo-scan' ? 'job_scan' : 'job_health',
|
|
921
|
+
type,
|
|
922
|
+
title: type === 'seo-scan' ? 'SEO scan' : 'Content health',
|
|
923
|
+
scope: type === 'seo-scan' ? 'All indexable pages' : 'All published documents',
|
|
924
|
+
createdBy: { kind: 'cron', schedule: type === 'seo-scan' ? 'weekly-seo' : 'nightly-health' },
|
|
925
|
+
payload: { trigger: 'cron' },
|
|
926
|
+
...overrides,
|
|
927
|
+
});
|
|
928
|
+
delete job.review;
|
|
929
|
+
return job;
|
|
930
|
+
}
|
|
931
|
+
/** A batch that ran out of attempts at document 8. */
|
|
932
|
+
function failed(overrides = {}) {
|
|
933
|
+
return batch({
|
|
934
|
+
id: 'job_415',
|
|
935
|
+
title: 'Translate excerpts to Spanish',
|
|
936
|
+
status: 'dead',
|
|
937
|
+
attempts: 3,
|
|
938
|
+
done: 7,
|
|
939
|
+
total: 30,
|
|
940
|
+
lockedAt: null,
|
|
941
|
+
lastError: 'ProviderError: 429 Too Many Requests',
|
|
942
|
+
...overrides,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
/** A batch waiting out a failed slice's backoff. */
|
|
946
|
+
function waiting(overrides = {}) {
|
|
947
|
+
return batch({
|
|
948
|
+
id: 'job_418',
|
|
949
|
+
title: 'Generate alt text',
|
|
950
|
+
status: 'queued',
|
|
951
|
+
attempts: 1,
|
|
952
|
+
done: 9,
|
|
953
|
+
total: 64,
|
|
954
|
+
lockedAt: null,
|
|
955
|
+
lastError: 'ImageFetchError: img_2291.webp returned 404',
|
|
956
|
+
runAt: iso(30_000),
|
|
957
|
+
...overrides,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
/** The job as the server has it after an action; the list's next read returns it. */
|
|
961
|
+
function patchJob(id, patch) {
|
|
962
|
+
server.jobs = server.jobs.map((job) => job.id === id ? { ...job, ...patch, updatedAt: iso(1_000) } : job);
|
|
963
|
+
return server.jobs.find((job) => job.id === id);
|
|
964
|
+
}
|
|
965
|
+
function panelOf(drawer) {
|
|
966
|
+
return drawer.querySelector('[role="tabpanel"]');
|
|
967
|
+
}
|
|
968
|
+
/** The cancel dialog, over the drawer or the list. */
|
|
969
|
+
function cancelDialog() {
|
|
970
|
+
return screen.findByRole('dialog', { name: /^Cancel “/ });
|
|
971
|
+
}
|
|
972
|
+
async function cancelDialogGone() {
|
|
973
|
+
await waitFor(() => expect(screen.queryByRole('dialog', { name: /^Cancel “/ })).toBeNull());
|
|
974
|
+
}
|
|
975
|
+
/** Click as a browser does: the button takes focus first. */
|
|
976
|
+
function press(button) {
|
|
977
|
+
button.focus();
|
|
978
|
+
fireEvent.click(button);
|
|
979
|
+
}
|
|
980
|
+
describe('Jobs drawer actions (P-19)', () => {
|
|
981
|
+
it('opens the cancel dialog over the drawer; Keep running and Escape go back to Cancel job', async () => {
|
|
982
|
+
server.jobs = [batch({})];
|
|
983
|
+
await renderJobs();
|
|
984
|
+
const drawer = await openJob('job_417');
|
|
985
|
+
const cancel = within(drawer).getByRole('button', { name: 'Cancel job' });
|
|
986
|
+
expect(cancel.getAttribute('aria-haspopup')).toBe('dialog');
|
|
987
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull();
|
|
988
|
+
cancel.focus();
|
|
989
|
+
fireEvent.click(cancel);
|
|
990
|
+
const dialog = await cancelDialog();
|
|
991
|
+
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
|
992
|
+
expect(dialog.textContent).toContain('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.');
|
|
993
|
+
// The drawer's controls are hidden behind it. (aria-hidden keeps the
|
|
994
|
+
// drawer's live region, and so the drawer element, exposed.)
|
|
995
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull();
|
|
996
|
+
expect(within(drawer).queryByRole('tab')).toBeNull();
|
|
997
|
+
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep running' }));
|
|
998
|
+
await cancelDialogGone();
|
|
999
|
+
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
|
1000
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBe(cancel);
|
|
1001
|
+
// Escape closes the dialog on top, not the drawer under it.
|
|
1002
|
+
fireEvent.click(cancel);
|
|
1003
|
+
await cancelDialog();
|
|
1004
|
+
fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' });
|
|
1005
|
+
await cancelDialogGone();
|
|
1006
|
+
expect(screen.getByRole('dialog', { name: 'Rewrite meta descriptions' })).toBe(drawer);
|
|
1007
|
+
await waitFor(() => expect(document.activeElement).toBe(cancel));
|
|
1008
|
+
expect(posts('POST', '/cancel')).toHaveLength(0);
|
|
1009
|
+
});
|
|
1010
|
+
it('uses the kit’s copy for each state', async () => {
|
|
1011
|
+
const auto = batch({}).payload;
|
|
1012
|
+
server.jobs = [
|
|
1013
|
+
batch({ status: 'queued', lockedAt: null, payload: { ...auto, review: 'auto' } }),
|
|
1014
|
+
scan('content-health', { status: 'running', done: 50, total: 412 }),
|
|
1015
|
+
scan('seo-scan', { status: 'queued', lockedAt: null, done: 0, total: 340 }),
|
|
1016
|
+
];
|
|
1017
|
+
await renderJobs();
|
|
1018
|
+
const copy = async (id) => {
|
|
1019
|
+
const drawer = await openJob(id);
|
|
1020
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }));
|
|
1021
|
+
const text = (await cancelDialog()).textContent ?? '';
|
|
1022
|
+
fireEvent.keyDown(document.activeElement ?? document, { key: 'Escape' });
|
|
1023
|
+
await cancelDialogGone();
|
|
1024
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
|
|
1025
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
1026
|
+
return text;
|
|
1027
|
+
};
|
|
1028
|
+
expect(await copy('job_417')).toContain('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.');
|
|
1029
|
+
expect(await copy('job_health')).toContain('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.');
|
|
1030
|
+
expect(await copy('job_scan')).toContain('It hasn’t started its next slice, so it stops right away. Progress so far is kept. The next weekly cron run starts fresh.');
|
|
1031
|
+
});
|
|
1032
|
+
it('cancels a running job: Cancelling · Stops after this slice, announced, focus in the drawer', async () => {
|
|
1033
|
+
server.jobs = [batch({})];
|
|
1034
|
+
server.action = (endpoint) => endpoint === '/jobs/job_417/cancel'
|
|
1035
|
+
? ok(patchJob('job_417', { cancelRequestedAt: iso(0) }))
|
|
1036
|
+
: { error: `unexpected ${endpoint}`, status: 500 };
|
|
1037
|
+
await renderJobs();
|
|
1038
|
+
const drawer = await openJob('job_417');
|
|
1039
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }));
|
|
1040
|
+
press(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }));
|
|
1041
|
+
await cancelDialogGone();
|
|
1042
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Stopping after this slice'));
|
|
1043
|
+
await waitFor(() => expect(announced()).toBe('Stopping after this slice'));
|
|
1044
|
+
expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0);
|
|
1045
|
+
expect(drawer.textContent).toContain('Stopping when the current slice finishes, usually within 20 seconds.');
|
|
1046
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull();
|
|
1047
|
+
// Cancel job went with the cancel, so focus lands on the panel showing the new state.
|
|
1048
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)));
|
|
1049
|
+
expect(posts('POST', '/cancel')).toHaveLength(1);
|
|
1050
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
|
|
1051
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
1052
|
+
const row = rowButton('job_417').closest('li');
|
|
1053
|
+
expect(within(row).getByText('Cancelling')).toBeTruthy();
|
|
1054
|
+
expect(within(row).getByText('Stops after this slice')).toBeTruthy();
|
|
1055
|
+
expect(within(row).queryByRole('button', { name: /^Cancel/ })).toBeNull();
|
|
1056
|
+
});
|
|
1057
|
+
it('shows a cancel’s outcome only once its dialog has closed', async () => {
|
|
1058
|
+
server.jobs = [batch({ status: 'queued', lockedAt: null })];
|
|
1059
|
+
let answer = () => { };
|
|
1060
|
+
server.action = () => new Promise((resolve) => (answer = resolve));
|
|
1061
|
+
await renderJobs();
|
|
1062
|
+
const drawer = await openJob('job_417');
|
|
1063
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }));
|
|
1064
|
+
const dialog = await cancelDialog();
|
|
1065
|
+
press(within(dialog).getByRole('button', { name: 'Cancel job' }));
|
|
1066
|
+
await waitFor(() => expect(posts('POST', '/cancel')).toHaveLength(1));
|
|
1067
|
+
// While the dialog is open Radix hides the rest of the page from screen
|
|
1068
|
+
// readers: the toaster, and the drawer with its live region.
|
|
1069
|
+
const dialogsAtToast = [];
|
|
1070
|
+
toast.mockImplementationOnce(() => {
|
|
1071
|
+
dialogsAtToast.push(document.querySelectorAll('[role="dialog"]').length);
|
|
1072
|
+
});
|
|
1073
|
+
await act(async () => {
|
|
1074
|
+
answer(ok(patchJob('job_417', { status: 'cancelled', cancelRequestedAt: iso(0) })));
|
|
1075
|
+
});
|
|
1076
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Job cancelled'));
|
|
1077
|
+
expect(dialogsAtToast).toEqual([1]);
|
|
1078
|
+
await waitFor(() => expect(announced()).toBe('Job cancelled'));
|
|
1079
|
+
// Focus was back in the drawer before the outcome was announced.
|
|
1080
|
+
expect(document.activeElement).toBe(panelOf(drawer));
|
|
1081
|
+
});
|
|
1082
|
+
it('posts one cancel for a double click, and opens one dialog for a double click on Cancel job', async () => {
|
|
1083
|
+
server.jobs = [batch({})];
|
|
1084
|
+
let answer = () => { };
|
|
1085
|
+
server.action = () => new Promise((resolve) => (answer = resolve));
|
|
1086
|
+
await renderJobs();
|
|
1087
|
+
const drawer = await openJob('job_417');
|
|
1088
|
+
const open = within(drawer).getByRole('button', { name: 'Cancel job' });
|
|
1089
|
+
fireEvent.click(open);
|
|
1090
|
+
fireEvent.click(open);
|
|
1091
|
+
const dialog = await cancelDialog();
|
|
1092
|
+
expect(screen.getAllByRole('dialog', { name: /^Cancel “/ })).toHaveLength(1);
|
|
1093
|
+
const confirm = within(dialog).getByRole('button', { name: 'Cancel job' });
|
|
1094
|
+
confirm.focus();
|
|
1095
|
+
fireEvent.click(confirm);
|
|
1096
|
+
fireEvent.click(confirm);
|
|
1097
|
+
await waitFor(() => expect(confirm.getAttribute('aria-busy')).toBe('true'));
|
|
1098
|
+
fireEvent.click(confirm);
|
|
1099
|
+
expect(document.activeElement).toBe(confirm);
|
|
1100
|
+
// Keep running waits for the answer too.
|
|
1101
|
+
expect(within(dialog).getByRole('button', { name: 'Keep running' }).getAttribute('aria-disabled')).toBe('true');
|
|
1102
|
+
await act(async () => {
|
|
1103
|
+
answer(ok(patchJob('job_417', { cancelRequestedAt: iso(0) })));
|
|
1104
|
+
});
|
|
1105
|
+
await cancelDialogGone();
|
|
1106
|
+
expect(posts('POST', '/cancel')).toHaveLength(1);
|
|
1107
|
+
});
|
|
1108
|
+
it('reads the list again on a 409 from a cancel that already finished, then says so', async () => {
|
|
1109
|
+
server.jobs = [batch({ review: { pending: 4, approved: 0, rejected: 0 } })];
|
|
1110
|
+
server.action = () => {
|
|
1111
|
+
// The last slice finished just before the cancel arrived.
|
|
1112
|
+
patchJob('job_417', {
|
|
1113
|
+
status: 'succeeded',
|
|
1114
|
+
lockedAt: null,
|
|
1115
|
+
finishedAt: iso(0),
|
|
1116
|
+
progress: { done: 42, total: 42, unit: 'documents', cursor: '42', completed: 42 },
|
|
1117
|
+
});
|
|
1118
|
+
return { error: 'This job already finished.', status: 409 };
|
|
1119
|
+
};
|
|
1120
|
+
await renderJobs();
|
|
1121
|
+
const drawer = await openJob('job_417');
|
|
1122
|
+
press(within(drawer).getByRole('button', { name: 'Cancel job' }));
|
|
1123
|
+
const dialog = await cancelDialog();
|
|
1124
|
+
const listReads = vi.mocked(fetch).mock.calls.length;
|
|
1125
|
+
press(within(dialog).getByRole('button', { name: 'Cancel job' }));
|
|
1126
|
+
await cancelDialogGone();
|
|
1127
|
+
const message = '“Rewrite meta descriptions” wasn’t cancelled: This job already finished.';
|
|
1128
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith(message));
|
|
1129
|
+
expect(toast.error).not.toHaveBeenCalled();
|
|
1130
|
+
expect(vi.mocked(fetch).mock.calls.length).toBeGreaterThan(listReads);
|
|
1131
|
+
// The dialog closed onto the job as it is now: finished, with nothing to cancel.
|
|
1132
|
+
expect(within(drawer).getAllByText('Needs review').length).toBeGreaterThan(0);
|
|
1133
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull();
|
|
1134
|
+
await waitFor(() => expect(announced()).toBe(message));
|
|
1135
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)));
|
|
1136
|
+
});
|
|
1137
|
+
it('says a job had already finished when the cancel comes back unchanged', async () => {
|
|
1138
|
+
server.jobs = [scan('content-health', { status: 'running', done: 50, total: 412 })];
|
|
1139
|
+
server.action = () => ok(patchJob('job_health', { status: 'succeeded', lockedAt: null, finishedAt: iso(0) }));
|
|
1140
|
+
await renderJobs();
|
|
1141
|
+
const drawer = await openJob('job_health');
|
|
1142
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel job' }));
|
|
1143
|
+
fireEvent.click(within(await cancelDialog()).getByRole('button', { name: 'Cancel job' }));
|
|
1144
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('“Content health” had already finished.'));
|
|
1145
|
+
expect(within(drawer).getAllByText('Succeeded').length).toBeGreaterThan(0);
|
|
1146
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull();
|
|
1147
|
+
});
|
|
1148
|
+
it('retries a failed job from the drawer: Retry from document 8, then focus on the panel', async () => {
|
|
1149
|
+
server.jobs = [failed()];
|
|
1150
|
+
server.action = (endpoint) => endpoint === '/jobs/job_415/retry'
|
|
1151
|
+
? ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null }))
|
|
1152
|
+
: { error: `unexpected ${endpoint}`, status: 500 };
|
|
1153
|
+
await renderJobs();
|
|
1154
|
+
const drawer = await openJob('job_415');
|
|
1155
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull();
|
|
1156
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull();
|
|
1157
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' });
|
|
1158
|
+
// The kit's primary button.
|
|
1159
|
+
expect(retry.className).toContain('bg-acc');
|
|
1160
|
+
retry.focus();
|
|
1161
|
+
fireEvent.click(retry);
|
|
1162
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'));
|
|
1163
|
+
await waitFor(() => expect(announced()).toBe('Retrying from document 8'));
|
|
1164
|
+
expect(within(drawer).getAllByText('In progress').length).toBeGreaterThan(0);
|
|
1165
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull();
|
|
1166
|
+
// Queued again, it can be cancelled.
|
|
1167
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy();
|
|
1168
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)));
|
|
1169
|
+
expect(posts('POST', '/retry')).toHaveLength(1);
|
|
1170
|
+
});
|
|
1171
|
+
it('counts pages for an SEO scan: Retry from page 101', async () => {
|
|
1172
|
+
server.jobs = [
|
|
1173
|
+
scan('seo-scan', {
|
|
1174
|
+
status: 'dead',
|
|
1175
|
+
attempts: 3,
|
|
1176
|
+
done: 100,
|
|
1177
|
+
total: 340,
|
|
1178
|
+
lockedAt: null,
|
|
1179
|
+
lastError: 'TimeoutError: fetch timed out',
|
|
1180
|
+
}),
|
|
1181
|
+
];
|
|
1182
|
+
await renderJobs();
|
|
1183
|
+
const drawer = await openJob('job_scan');
|
|
1184
|
+
expect(within(drawer).getByRole('button', { name: 'Retry from page 101' })).toBeTruthy();
|
|
1185
|
+
});
|
|
1186
|
+
it('posts one retry for a double click, keeping focus on the busy button', async () => {
|
|
1187
|
+
server.jobs = [failed()];
|
|
1188
|
+
let answer = () => { };
|
|
1189
|
+
server.action = () => new Promise((resolve) => (answer = resolve));
|
|
1190
|
+
await renderJobs();
|
|
1191
|
+
const drawer = await openJob('job_415');
|
|
1192
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' });
|
|
1193
|
+
retry.focus();
|
|
1194
|
+
fireEvent.click(retry);
|
|
1195
|
+
fireEvent.click(retry);
|
|
1196
|
+
await waitFor(() => expect(retry.getAttribute('aria-busy')).toBe('true'));
|
|
1197
|
+
expect(retry.hasAttribute('disabled')).toBe(false);
|
|
1198
|
+
expect(document.activeElement).toBe(retry);
|
|
1199
|
+
fireEvent.click(retry);
|
|
1200
|
+
expect(posts('POST', '/retry')).toHaveLength(1);
|
|
1201
|
+
await act(async () => {
|
|
1202
|
+
answer(ok(patchJob('job_415', { status: 'queued', attempts: 0, lastError: null })));
|
|
1203
|
+
});
|
|
1204
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying from document 8'));
|
|
1205
|
+
expect(posts('POST', '/retry')).toHaveLength(1);
|
|
1206
|
+
});
|
|
1207
|
+
it('reads the list again on a 409 from a retry, and shows the job as it is now', async () => {
|
|
1208
|
+
server.jobs = [failed()];
|
|
1209
|
+
server.action = () => {
|
|
1210
|
+
// Someone else retried it first, and the worker has picked it up.
|
|
1211
|
+
patchJob('job_415', { status: 'running', attempts: 0, lastError: null, lockedAt: iso(0) });
|
|
1212
|
+
return { error: 'Only a failed job, or one waiting to retry, can be retried', status: 409 };
|
|
1213
|
+
};
|
|
1214
|
+
await renderJobs();
|
|
1215
|
+
const drawer = await openJob('job_415');
|
|
1216
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' });
|
|
1217
|
+
retry.focus();
|
|
1218
|
+
fireEvent.click(retry);
|
|
1219
|
+
const message = '“Translate excerpts to Spanish” wasn’t retried: Only a failed job, or one waiting to retry, can be retried';
|
|
1220
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith(message));
|
|
1221
|
+
expect(toast.error).not.toHaveBeenCalled();
|
|
1222
|
+
// The answer shows with the job as it is now, read again: running.
|
|
1223
|
+
expect(within(drawer).getAllByText('Running').length).toBeGreaterThan(0);
|
|
1224
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull();
|
|
1225
|
+
await waitFor(() => expect(announced()).toBe(message));
|
|
1226
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)));
|
|
1227
|
+
});
|
|
1228
|
+
it('keeps Retry and focus on a refusal that isn’t a 409', async () => {
|
|
1229
|
+
server.jobs = [failed()];
|
|
1230
|
+
server.action = () => ({ error: 'Insufficient permissions', status: 403 });
|
|
1231
|
+
await renderJobs();
|
|
1232
|
+
const drawer = await openJob('job_415');
|
|
1233
|
+
const retry = within(drawer).getByRole('button', { name: 'Retry from document 8' });
|
|
1234
|
+
retry.focus();
|
|
1235
|
+
fireEvent.click(retry);
|
|
1236
|
+
const message = '“Translate excerpts to Spanish” wasn’t retried: Insufficient permissions';
|
|
1237
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
|
|
1238
|
+
await waitFor(() => expect(announced()).toBe(message));
|
|
1239
|
+
await waitFor(() => expect(retry.getAttribute('aria-busy')).toBeNull());
|
|
1240
|
+
expect(document.activeElement).toBe(retry);
|
|
1241
|
+
});
|
|
1242
|
+
it('offers Retry now on a job waiting to retry, but not while cancelling or on a stale lock', async () => {
|
|
1243
|
+
server.jobs = [
|
|
1244
|
+
waiting(),
|
|
1245
|
+
waiting({ id: 'job_c', title: 'Cancelling batch', cancelRequestedAt: iso(0) }),
|
|
1246
|
+
batch({
|
|
1247
|
+
id: 'job_stale',
|
|
1248
|
+
title: 'Stuck batch',
|
|
1249
|
+
status: 'running',
|
|
1250
|
+
done: 4,
|
|
1251
|
+
attempts: 1,
|
|
1252
|
+
lockedAt: iso(-11 * MIN),
|
|
1253
|
+
}),
|
|
1254
|
+
];
|
|
1255
|
+
server.action = (endpoint) => endpoint === '/jobs/job_418/retry'
|
|
1256
|
+
? ok(patchJob('job_418', { attempts: 0, lastError: null, runAt: iso(0) }))
|
|
1257
|
+
: { error: `unexpected ${endpoint}`, status: 500 };
|
|
1258
|
+
await renderJobs();
|
|
1259
|
+
let drawer = await openJob('job_c');
|
|
1260
|
+
expect(within(drawer).getAllByText('Cancelling').length).toBeGreaterThan(0);
|
|
1261
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull();
|
|
1262
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull();
|
|
1263
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
|
|
1264
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
1265
|
+
// The server still holds a stale lock as running; the next worker run picks it up.
|
|
1266
|
+
drawer = await openJob('job_stale');
|
|
1267
|
+
expect(within(drawer).getAllByText('Retrying').length).toBeGreaterThan(0);
|
|
1268
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull();
|
|
1269
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy();
|
|
1270
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
|
|
1271
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
1272
|
+
drawer = await openJob('job_418');
|
|
1273
|
+
expect(within(drawer).getByRole('button', { name: 'Cancel job' })).toBeTruthy();
|
|
1274
|
+
const now = within(drawer).getByRole('button', { name: 'Retry now' });
|
|
1275
|
+
// The kit's plain `.jb-btn`, not the primary retry.
|
|
1276
|
+
expect(now.className).toContain('bg-card');
|
|
1277
|
+
now.focus();
|
|
1278
|
+
fireEvent.click(now);
|
|
1279
|
+
await waitFor(() => expect(toast).toHaveBeenCalledWith('Retrying on the next worker run'));
|
|
1280
|
+
await waitFor(() => expect(announced()).toBe('Retrying on the next worker run'));
|
|
1281
|
+
expect(posts('POST', '/jobs/job_418/retry')).toHaveLength(1);
|
|
1282
|
+
expect(within(drawer).queryByRole('button', { name: 'Retry now' })).toBeNull();
|
|
1283
|
+
await waitFor(() => expect(document.activeElement).toBe(panelOf(drawer)));
|
|
1284
|
+
});
|
|
1285
|
+
it('opens the running scan on Run now and announces the reuse in the drawer', async () => {
|
|
1286
|
+
const running = scan('seo-scan', { status: 'running', done: 40, total: 340 });
|
|
1287
|
+
server.jobs = [running];
|
|
1288
|
+
server.action = (endpoint) => endpoint === '/jobs/run'
|
|
1289
|
+
? ok({ job: running, reused: true })
|
|
1290
|
+
: { error: `unexpected ${endpoint}`, status: 500 };
|
|
1291
|
+
await renderJobs();
|
|
1292
|
+
fireEvent.click(screen.getByRole('button', { name: 'Run now: SEO scan' }));
|
|
1293
|
+
const drawer = await screen.findByRole('dialog', { name: 'SEO scan' });
|
|
1294
|
+
expect(toast).toHaveBeenCalledWith('SEO scan is already running. Reusing that job.');
|
|
1295
|
+
await waitFor(() => expect(announced()).toBe('SEO scan is already running. Reusing that job.'));
|
|
1296
|
+
expect(drawer.hasAttribute('data-job-drawer')).toBe(true);
|
|
1297
|
+
expect(posts('POST', '/jobs/run')).toHaveLength(1);
|
|
1298
|
+
});
|
|
1299
|
+
it('hides Run now, Cancel and Retry from a viewer', async () => {
|
|
1300
|
+
server.jobs = [batch({}), failed(), waiting()];
|
|
1301
|
+
await renderJobs(() => { }, { role: 'VIEWER', userId: 'u9' });
|
|
1302
|
+
expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull();
|
|
1303
|
+
expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull();
|
|
1304
|
+
expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull();
|
|
1305
|
+
// The failure alert keeps See why.
|
|
1306
|
+
expect(within(screen.getByRole('alert')).getByRole('button', { name: 'See why' })).toBeTruthy();
|
|
1307
|
+
for (const id of ['job_417', 'job_415', 'job_418']) {
|
|
1308
|
+
const drawer = await openJob(id);
|
|
1309
|
+
expect(within(drawer).queryByRole('button', { name: 'Cancel job' })).toBeNull();
|
|
1310
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull();
|
|
1311
|
+
fireEvent.click(within(drawer).getByRole('button', { name: 'Close' }));
|
|
1312
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
it('shows nothing to act with when there’s no role', async () => {
|
|
1316
|
+
server.jobs = [batch({}), failed()];
|
|
1317
|
+
await renderJobs(() => { }, { role: null, userId: null });
|
|
1318
|
+
expect(screen.queryByRole('button', { name: /^Run now/ })).toBeNull();
|
|
1319
|
+
expect(screen.queryByRole('button', { name: /^Cancel / })).toBeNull();
|
|
1320
|
+
expect(screen.queryByRole('button', { name: /^Retry/ })).toBeNull();
|
|
1321
|
+
});
|
|
1322
|
+
it('lets an editor retry a scan, and only a batch they queued', async () => {
|
|
1323
|
+
server.jobs = [
|
|
1324
|
+
failed({ createdBy: { id: 'u2', name: 'Dana Cole', initials: 'DC' } }),
|
|
1325
|
+
failed({
|
|
1326
|
+
id: 'job_mine',
|
|
1327
|
+
title: 'My batch',
|
|
1328
|
+
createdBy: { id: 'u7', name: 'Ed', initials: 'E' },
|
|
1329
|
+
}),
|
|
1330
|
+
scan('content-health', {
|
|
1331
|
+
status: 'dead',
|
|
1332
|
+
attempts: 3,
|
|
1333
|
+
done: 50,
|
|
1334
|
+
total: 412,
|
|
1335
|
+
lockedAt: null,
|
|
1336
|
+
lastError: 'Error: boom',
|
|
1337
|
+
}),
|
|
1338
|
+
];
|
|
1339
|
+
await renderJobs(() => { }, { role: 'EDITOR', userId: 'u7' });
|
|
1340
|
+
expect(screen.getByRole('button', { name: 'Run now: SEO scan' })).toBeTruthy();
|
|
1341
|
+
expect(screen.queryByRole('button', { name: 'Retry Translate excerpts to Spanish' })).toBeNull();
|
|
1342
|
+
expect(screen.getByRole('button', { name: 'Retry My batch' })).toBeTruthy();
|
|
1343
|
+
expect(screen.getByRole('button', { name: 'Retry Content health' })).toBeTruthy();
|
|
1344
|
+
const drawer = await openJob('job_415');
|
|
1345
|
+
expect(within(drawer).queryByRole('button', { name: /^Retry/ })).toBeNull();
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
908
1348
|
//# sourceMappingURL=jobs-drawer.render.test.js.map
|