@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
|
@@ -77,6 +77,8 @@ export interface WorkerStripProps {
|
|
|
77
77
|
rows: JobRow[]
|
|
78
78
|
/** Live is on and the tab is visible: the clock ticks every second. */
|
|
79
79
|
ticking: boolean
|
|
80
|
+
/** The viewer may start a scan. Viewers get no Run now (the server refuses it). */
|
|
81
|
+
canRun: boolean
|
|
80
82
|
onRunNow: (type: RunnableJobType) => Promise<void>
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -91,6 +93,7 @@ export function WorkerStrip({
|
|
|
91
93
|
loading,
|
|
92
94
|
rows,
|
|
93
95
|
ticking,
|
|
96
|
+
canRun,
|
|
94
97
|
onRunNow,
|
|
95
98
|
}: WorkerStripProps) {
|
|
96
99
|
const now = useNow(ticking) + skewMs
|
|
@@ -125,17 +128,19 @@ export function WorkerStrip({
|
|
|
125
128
|
<Text label={JOB_TYPES[type].label}>
|
|
126
129
|
{when} {next ? <small>· {next}</small> : null}
|
|
127
130
|
</Text>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
131
|
+
{canRun ? (
|
|
132
|
+
<BusyButton
|
|
133
|
+
variant="secondary"
|
|
134
|
+
size="sm"
|
|
135
|
+
className="ml-auto"
|
|
136
|
+
leftIcon={<JobIcons.play size={11} />}
|
|
137
|
+
busy={busy === type}
|
|
138
|
+
aria-label={`Run now: ${JOB_TYPES[type].label}`}
|
|
139
|
+
onClick={() => void run(type)}
|
|
140
|
+
>
|
|
141
|
+
Run now
|
|
142
|
+
</BusyButton>
|
|
143
|
+
) : null}
|
|
139
144
|
</Cell>
|
|
140
145
|
)
|
|
141
146
|
})}
|
package/src/views/Jobs/focus.ts
CHANGED
|
@@ -31,6 +31,17 @@ export function focusJobRow(id: string): void {
|
|
|
31
31
|
else focusJobsFallback()
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Focus the open drawer's tab panel, where the job's new state shows, when a
|
|
36
|
+
* footer action's button went away (a cancel, a retry). The row is behind the
|
|
37
|
+
* drawer, which keeps focus inside itself.
|
|
38
|
+
*/
|
|
39
|
+
export function focusJobDrawer(): void {
|
|
40
|
+
const drawer = document.querySelector<HTMLElement>('[data-job-drawer]')
|
|
41
|
+
const target = drawer?.querySelector<HTMLElement>('[role="tabpanel"]') ?? drawer
|
|
42
|
+
target?.focus()
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
/**
|
|
35
46
|
* Focus return for the Jobs dialogs. Radix's modal Dialog only returns focus
|
|
36
47
|
* to a `Dialog.Trigger`, and these open from several controls, so the control
|
package/src/views/Jobs.tsx
CHANGED
|
@@ -8,12 +8,17 @@ import { Segmented } from '../components/ui/Segmented.js'
|
|
|
8
8
|
import {
|
|
9
9
|
JOB_FILTERS,
|
|
10
10
|
JOB_TYPES,
|
|
11
|
+
canActOnJobs,
|
|
12
|
+
canRetryJob,
|
|
11
13
|
countJobs,
|
|
12
|
-
|
|
14
|
+
jobActions,
|
|
15
|
+
jobTotal,
|
|
13
16
|
matchJob,
|
|
17
|
+
retryPoint,
|
|
14
18
|
toJobRows,
|
|
15
19
|
type JobFilter,
|
|
16
20
|
type JobRow,
|
|
21
|
+
type JobViewer,
|
|
17
22
|
} from '../lib/job-view.js'
|
|
18
23
|
import {
|
|
19
24
|
JOBS_LIST_LIMIT,
|
|
@@ -24,20 +29,35 @@ import {
|
|
|
24
29
|
} from '../lib/jobs-service.js'
|
|
25
30
|
import { BusyButton } from './Jobs/BusyButton.js'
|
|
26
31
|
import { CancelJobDialog, staleRunning } from './Jobs/CancelJobDialog.js'
|
|
27
|
-
import { focusJobRow } from './Jobs/focus.js'
|
|
32
|
+
import { focusJobDrawer, focusJobRow } from './Jobs/focus.js'
|
|
28
33
|
import { JobsTable, type JobDrawerTab } from './Jobs/JobsTable.js'
|
|
29
34
|
import { JobIcons } from './Jobs/icons.js'
|
|
30
|
-
import { JobDrawer } from './Jobs/JobDrawer.js'
|
|
31
|
-
import { NewBatchJob } from './Jobs/
|
|
35
|
+
import { JobDrawer, type DrawerNotice } from './Jobs/JobDrawer.js'
|
|
36
|
+
import { NewBatchJob } from './Jobs/NewBatchJob.js'
|
|
32
37
|
import { usePageVisible, useJobsFeed } from './Jobs/useJobsFeed.js'
|
|
33
38
|
import { WorkerStrip } from './Jobs/WorkerStrip.js'
|
|
34
39
|
|
|
35
40
|
export interface JobsProps {
|
|
36
41
|
onNavigate: (path: string) => void
|
|
42
|
+
/**
|
|
43
|
+
* The signed-in user's role. It decides which job actions show: viewers
|
|
44
|
+
* get none (the server refuses them anyway). Without one, none show.
|
|
45
|
+
*/
|
|
46
|
+
role?: string | null
|
|
47
|
+
/** The signed-in user's id: a batch's Retry shows to the person who queued it. */
|
|
48
|
+
userId?: string | null
|
|
49
|
+
/** The CMS config: the batch wizard lists its page and post collections. */
|
|
50
|
+
config?: unknown
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
const FILTER_OPTIONS = JOB_FILTERS.map(({ id, label }) => ({ id, label }))
|
|
40
54
|
|
|
55
|
+
/** What a run, retry or cancel came to, for a toast and the drawer's live region. */
|
|
56
|
+
interface Outcome {
|
|
57
|
+
text: string
|
|
58
|
+
tone: 'info' | 'error'
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
/** `.jb-livebtn`: pauses and resumes the 5-second refresh. */
|
|
42
62
|
function LiveButton({ live, onToggle }: { live: boolean; onToggle: () => void }) {
|
|
43
63
|
return (
|
|
@@ -65,10 +85,10 @@ function LiveButton({ live, onToggle }: { live: boolean; onToggle: () => void })
|
|
|
65
85
|
* seconds while Live is on and the tab is visible. Filters and counts work
|
|
66
86
|
* on the loaded rows.
|
|
67
87
|
*
|
|
68
|
-
* Opening a job sets `open` and shows its drawer (P-18).
|
|
69
|
-
* (P-20)
|
|
88
|
+
* Opening a job sets `open` and shows its drawer (P-18). New batch AI job
|
|
89
|
+
* opens the wizard (P-20); a queued job is added at the top of the list.
|
|
70
90
|
*/
|
|
71
|
-
export function Jobs({ onNavigate }: JobsProps) {
|
|
91
|
+
export function Jobs({ onNavigate, role = null, userId = null, config }: JobsProps) {
|
|
72
92
|
const [live, setLive] = useState(true)
|
|
73
93
|
const visible = usePageVisible()
|
|
74
94
|
const feed = useJobsFeed(live && visible)
|
|
@@ -80,13 +100,26 @@ export function Jobs({ onNavigate }: JobsProps) {
|
|
|
80
100
|
const cancelledId = useRef<string | null>(null)
|
|
81
101
|
const [wizard, setWizard] = useState(false)
|
|
82
102
|
const [retryingId, setRetryingId] = useState<string | null>(null)
|
|
103
|
+
/** Jobs with a retry request out, so a second click can't post another. */
|
|
104
|
+
const retrying = useRef(new Set<string>())
|
|
83
105
|
/** A row whose action button just went away; its open button takes focus if focus was lost. */
|
|
84
106
|
const refocus = useRef<string | null>(null)
|
|
107
|
+
/**
|
|
108
|
+
* The latest outcome, which the open drawer announces too: screen readers
|
|
109
|
+
* are kept inside the drawer (aria-modal), away from the toaster. Cleared
|
|
110
|
+
* whenever a drawer opens or closes, so a drawer never announces an older one.
|
|
111
|
+
*/
|
|
112
|
+
const [notice, setNotice] = useState<DrawerNotice | null>(null)
|
|
113
|
+
/** A cancel's outcome, held until its dialog has gone (`onClosed`). */
|
|
114
|
+
const cancelOutcome = useRef<Outcome | null>(null)
|
|
115
|
+
const viewer = useMemo<JobViewer>(() => ({ role, userId }), [role, userId])
|
|
85
116
|
|
|
86
117
|
useEffect(() => {
|
|
87
118
|
const id = refocus.current
|
|
88
119
|
if (!id) return
|
|
89
120
|
refocus.current = null
|
|
121
|
+
// The drawer places focus after its own footer's actions.
|
|
122
|
+
if (open) return
|
|
90
123
|
const current = document.activeElement
|
|
91
124
|
if (current && current !== document.body && current.isConnected) return
|
|
92
125
|
focusJobRow(id)
|
|
@@ -112,56 +145,97 @@ export function Jobs({ onNavigate }: JobsProps) {
|
|
|
112
145
|
const openRow = open ? rows.find((row) => row.id === open.id) : undefined
|
|
113
146
|
const ready = feed.status === 'ready'
|
|
114
147
|
|
|
115
|
-
const openAt = (id: string, tab: JobDrawerTab = null) =>
|
|
148
|
+
const openAt = (id: string, tab: JobDrawerTab = null) => {
|
|
149
|
+
setOpen({ id, tab })
|
|
150
|
+
setNotice(null)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Toast an outcome, and have an open drawer announce it. */
|
|
154
|
+
function report(text: string, tone: Outcome['tone'] = 'info') {
|
|
155
|
+
if (tone === 'error') toast.error(text)
|
|
156
|
+
else toast(text)
|
|
157
|
+
setNotice((prev) => ({ text, n: (prev?.n ?? 0) + 1 }))
|
|
158
|
+
}
|
|
116
159
|
|
|
117
160
|
async function runNow(t: RunnableJobType) {
|
|
118
161
|
const res = await runJobNow(t)
|
|
119
162
|
if (!res.ok) {
|
|
120
|
-
|
|
163
|
+
report(`${JOB_TYPES[t].label} didn’t start: ${res.error}`, 'error')
|
|
121
164
|
return
|
|
122
165
|
}
|
|
123
166
|
const { job, reused } = res.value
|
|
124
167
|
upsert(job)
|
|
125
168
|
if (reused) {
|
|
126
|
-
toast(`${JOB_TYPES[t].label} is already ${job.status}. Reusing that job.`)
|
|
127
169
|
openAt(job.id)
|
|
170
|
+
report(`${JOB_TYPES[t].label} is already ${job.status}. Reusing that job.`)
|
|
128
171
|
} else {
|
|
129
|
-
|
|
172
|
+
report(`${JOB_TYPES[t].label} queued. It starts on the next worker run.`)
|
|
130
173
|
}
|
|
131
174
|
void reload()
|
|
132
175
|
}
|
|
133
176
|
|
|
134
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Retry a failed job from where it stopped, or ("Retry now") one waiting
|
|
179
|
+
* out a failed slice's backoff. A 409 means it was retried elsewhere or
|
|
180
|
+
* isn't waiting any more, so the list is read again before the answer shows.
|
|
181
|
+
*/
|
|
182
|
+
async function retry(j: JobRow, now = false) {
|
|
183
|
+
if (retrying.current.has(j.id)) return
|
|
184
|
+
retrying.current.add(j.id)
|
|
135
185
|
setRetryingId(j.id)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
186
|
+
try {
|
|
187
|
+
const res = await retryJob(j.id)
|
|
188
|
+
if (res.ok) {
|
|
189
|
+
refocus.current = j.id
|
|
190
|
+
upsert(res.value)
|
|
191
|
+
report(now ? 'Retrying on the next worker run' : `Retrying from ${retryPoint(j)}`)
|
|
192
|
+
void reload()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const message = `“${j.title}” wasn’t retried: ${res.error}`
|
|
196
|
+
if (res.status === 409) await reload()
|
|
197
|
+
report(message, res.status === 409 ? 'info' : 'error')
|
|
198
|
+
} finally {
|
|
199
|
+
retrying.current.delete(j.id)
|
|
200
|
+
setRetryingId(null)
|
|
141
201
|
}
|
|
142
|
-
refocus.current = j.id
|
|
143
|
-
upsert(res.value)
|
|
144
|
-
toast(`Retrying from ${jobType(j.type).unit === 'pages' ? 'page' : 'document'} ${j.done + 1}`)
|
|
145
|
-
void reload()
|
|
146
202
|
}
|
|
147
203
|
|
|
204
|
+
function askCancel(row: JobRow) {
|
|
205
|
+
cancelledId.current = row.id
|
|
206
|
+
setCancelTarget(row)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The dialog's Cancel job. A cancel that lands after the job ended comes
|
|
211
|
+
* back as the job, unchanged. A 409 (it ended, or was cancelled, first) is
|
|
212
|
+
* shown once the list has been read again, so the dialog closes onto it.
|
|
213
|
+
*/
|
|
148
214
|
async function confirmCancel(j: JobRow) {
|
|
149
215
|
const res = await cancelJob(j.id)
|
|
150
216
|
if (!res.ok) {
|
|
151
|
-
|
|
217
|
+
const text = `“${j.title}” wasn’t cancelled: ${res.error}`
|
|
218
|
+
if (res.status === 409) await reload()
|
|
219
|
+
cancelOutcome.current = { text, tone: res.status === 409 ? 'info' : 'error' }
|
|
152
220
|
return
|
|
153
221
|
}
|
|
154
222
|
const job = res.value
|
|
155
223
|
upsert(job)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
else if (
|
|
159
|
-
else
|
|
224
|
+
let text: string
|
|
225
|
+
if (job.status === 'cancelled') text = 'Job cancelled'
|
|
226
|
+
else if (staleRunning(j)) text = 'Stopping on the next worker run'
|
|
227
|
+
else if (job.status === 'queued' || job.status === 'running') text = 'Stopping after this slice'
|
|
228
|
+
else text = `“${j.title}” had already finished.`
|
|
229
|
+
cancelOutcome.current = { text, tone: 'info' }
|
|
160
230
|
void reload()
|
|
161
231
|
}
|
|
162
232
|
|
|
163
233
|
const deadFirst = dead[0]
|
|
164
234
|
const firstReview = revJobs[0]
|
|
235
|
+
// The dialog follows the job as the list refreshes, so its copy fits the state it's in.
|
|
236
|
+
const cancelRow = cancelTarget
|
|
237
|
+
? (rows.find((row) => row.id === cancelTarget.id) ?? cancelTarget)
|
|
238
|
+
: null
|
|
165
239
|
const deadCause = deadFirst?.error?.replace(/\.\s*$/, '')
|
|
166
240
|
|
|
167
241
|
return (
|
|
@@ -173,13 +247,16 @@ export function Jobs({ onNavigate }: JobsProps) {
|
|
|
173
247
|
actions={
|
|
174
248
|
<>
|
|
175
249
|
<LiveButton live={live} onToggle={() => setLive((value) => !value)} />
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
250
|
+
{/* Like Run now: the server refuses a batch to anyone else. */}
|
|
251
|
+
{canActOnJobs(viewer) ? (
|
|
252
|
+
<Button
|
|
253
|
+
data-jobs-new=""
|
|
254
|
+
leftIcon={<JobIcons.spark size={14} />}
|
|
255
|
+
onClick={() => setWizard(true)}
|
|
256
|
+
>
|
|
257
|
+
New batch AI job
|
|
258
|
+
</Button>
|
|
259
|
+
) : null}
|
|
183
260
|
</>
|
|
184
261
|
}
|
|
185
262
|
/>
|
|
@@ -191,6 +268,7 @@ export function Jobs({ onNavigate }: JobsProps) {
|
|
|
191
268
|
loading={feed.status === 'loading'}
|
|
192
269
|
rows={rows}
|
|
193
270
|
ticking={live && visible}
|
|
271
|
+
canRun={canActOnJobs(viewer)}
|
|
194
272
|
onRunNow={runNow}
|
|
195
273
|
/>
|
|
196
274
|
|
|
@@ -211,14 +289,16 @@ export function Jobs({ onNavigate }: JobsProps) {
|
|
|
211
289
|
<Button variant="secondary" size="sm" onClick={() => openAt(deadFirst.id, 'overview')}>
|
|
212
290
|
See why
|
|
213
291
|
</Button>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
292
|
+
{canRetryJob(deadFirst, viewer) ? (
|
|
293
|
+
<BusyButton
|
|
294
|
+
size="sm"
|
|
295
|
+
leftIcon={<JobIcons.retry size={12} />}
|
|
296
|
+
busy={retryingId === deadFirst.id}
|
|
297
|
+
onClick={() => void retry(deadFirst)}
|
|
298
|
+
>
|
|
299
|
+
Retry
|
|
300
|
+
</BusyButton>
|
|
301
|
+
) : null}
|
|
222
302
|
</div>
|
|
223
303
|
) : null}
|
|
224
304
|
|
|
@@ -278,11 +358,9 @@ export function Jobs({ onNavigate }: JobsProps) {
|
|
|
278
358
|
status={feed.status}
|
|
279
359
|
error={feed.error}
|
|
280
360
|
retryingId={retryingId}
|
|
361
|
+
viewer={viewer}
|
|
281
362
|
onOpen={openAt}
|
|
282
|
-
onCancel={
|
|
283
|
-
cancelledId.current = row.id
|
|
284
|
-
setCancelTarget(row)
|
|
285
|
-
}}
|
|
363
|
+
onCancel={askCancel}
|
|
286
364
|
onRetry={(row) => void retry(row)}
|
|
287
365
|
onReload={reload}
|
|
288
366
|
/>
|
|
@@ -295,18 +373,54 @@ export function Jobs({ onNavigate }: JobsProps) {
|
|
|
295
373
|
tab={open.tab}
|
|
296
374
|
now={feed.fetchedAt}
|
|
297
375
|
worker={feed.worker}
|
|
298
|
-
|
|
376
|
+
actions={jobActions(openRow, viewer)}
|
|
377
|
+
retrying={retryingId === openRow.id}
|
|
378
|
+
notice={notice}
|
|
379
|
+
onCancel={() => askCancel(openRow)}
|
|
380
|
+
onRetry={(now) => retry(openRow, now)}
|
|
381
|
+
onClose={() => {
|
|
382
|
+
setOpen(null)
|
|
383
|
+
setNotice(null)
|
|
384
|
+
}}
|
|
299
385
|
onReload={() => void reload()}
|
|
300
386
|
onNavigate={onNavigate}
|
|
301
387
|
/>
|
|
302
388
|
) : null}
|
|
303
|
-
{wizard ?
|
|
389
|
+
{wizard ? (
|
|
390
|
+
<NewBatchJob
|
|
391
|
+
role={role}
|
|
392
|
+
config={config}
|
|
393
|
+
onClose={() => setWizard(false)}
|
|
394
|
+
onQueued={(job) => {
|
|
395
|
+
upsert(job)
|
|
396
|
+
setWizard(false)
|
|
397
|
+
setFilter('all')
|
|
398
|
+
setType('all')
|
|
399
|
+
toast(`“${job.title}” queued · ${jobTotal(job)} documents`)
|
|
400
|
+
void reload()
|
|
401
|
+
}}
|
|
402
|
+
onFocusFallback={(job) =>
|
|
403
|
+
job
|
|
404
|
+
? focusJobRow(job.id)
|
|
405
|
+
: document.querySelector<HTMLElement>('[data-jobs-new]')?.focus()
|
|
406
|
+
}
|
|
407
|
+
/>
|
|
408
|
+
) : null}
|
|
304
409
|
<CancelJobDialog
|
|
305
|
-
job={
|
|
410
|
+
job={cancelRow}
|
|
306
411
|
onClose={() => setCancelTarget(null)}
|
|
307
412
|
onConfirm={confirmCancel}
|
|
308
413
|
onFocusFallback={() => {
|
|
309
|
-
|
|
414
|
+
// Opened from the drawer, whose Cancel job went with the cancel.
|
|
415
|
+
if (open) focusJobDrawer()
|
|
416
|
+
else if (cancelledId.current) focusJobRow(cancelledId.current)
|
|
417
|
+
}}
|
|
418
|
+
onClosed={() => {
|
|
419
|
+
// Screen readers are kept inside the dialog while it's open, so a
|
|
420
|
+
// toast or the drawer's announcement then could go unread.
|
|
421
|
+
const outcome = cancelOutcome.current
|
|
422
|
+
cancelOutcome.current = null
|
|
423
|
+
if (outcome) report(outcome.text, outcome.tone)
|
|
310
424
|
}}
|
|
311
425
|
/>
|
|
312
426
|
</div>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, type ReactNode } from 'react'
|
|
4
|
+
import type { AnnouncementTone } from '@prenta/core'
|
|
5
|
+
|
|
6
|
+
import { Toggle } from '../../components/ui/Toggle.js'
|
|
7
|
+
import {
|
|
8
|
+
ANNOUNCEMENT_LINK_MAX,
|
|
9
|
+
ANNOUNCEMENT_MAX,
|
|
10
|
+
ANNOUNCEMENT_TONES,
|
|
11
|
+
type AnnouncementForm,
|
|
12
|
+
} from './chrome-form.js'
|
|
13
|
+
import { CARD, FieldLabel, Help, INPUT, MONO, RadioPills } from './controls.js'
|
|
14
|
+
|
|
15
|
+
/** The swatch beside each Style pill. The site styles the tones; this only hints. */
|
|
16
|
+
const TONE_SWATCH: Record<AnnouncementTone, string> = {
|
|
17
|
+
brand: 'var(--acc)',
|
|
18
|
+
dark: 'var(--site-txt)',
|
|
19
|
+
alert: 'var(--site-alert)',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A labelled switch row (the kit's `Line`): label, a help line, then the switch. */
|
|
23
|
+
function SwitchLine({
|
|
24
|
+
label,
|
|
25
|
+
sub,
|
|
26
|
+
on,
|
|
27
|
+
onChange,
|
|
28
|
+
disabled,
|
|
29
|
+
}: {
|
|
30
|
+
label: string
|
|
31
|
+
sub: string
|
|
32
|
+
on: boolean
|
|
33
|
+
onChange: (on: boolean) => void
|
|
34
|
+
disabled: boolean
|
|
35
|
+
}) {
|
|
36
|
+
const id = useId()
|
|
37
|
+
return (
|
|
38
|
+
<div className="text-sm2 text-txt flex items-center gap-2 leading-[normal] font-medium">
|
|
39
|
+
<span className="min-w-0 flex-1">
|
|
40
|
+
<span id={`${id}-label`}>{label}</span>
|
|
41
|
+
<small id={`${id}-sub`} className="text-hint text-faint mt-px block font-normal">
|
|
42
|
+
{sub}
|
|
43
|
+
</small>
|
|
44
|
+
</span>
|
|
45
|
+
<Toggle
|
|
46
|
+
aria-labelledby={`${id}-label`}
|
|
47
|
+
aria-describedby={`${id}-sub`}
|
|
48
|
+
checked={on}
|
|
49
|
+
disabled={disabled}
|
|
50
|
+
onCheckedChange={onChange}
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Field({
|
|
57
|
+
label,
|
|
58
|
+
htmlFor,
|
|
59
|
+
help,
|
|
60
|
+
children,
|
|
61
|
+
}: {
|
|
62
|
+
label: string
|
|
63
|
+
htmlFor: string
|
|
64
|
+
help?: ReactNode
|
|
65
|
+
children: ReactNode
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="min-w-0">
|
|
69
|
+
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
|
70
|
+
{children}
|
|
71
|
+
{help ? <Help>{help}</Help> : null}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AnnouncementTabProps {
|
|
77
|
+
form: AnnouncementForm
|
|
78
|
+
onChange: (form: AnnouncementForm) => void
|
|
79
|
+
problem: string | null
|
|
80
|
+
readOnly: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The announcement bar card. The header switch turns the bar on and off; while
|
|
85
|
+
* it's off the body is at 50% and can't be used. The site styles the tones and
|
|
86
|
+
* keeps a visitor's dismissal by the announcement's `id`, which the server
|
|
87
|
+
* changes whenever the message, link or tone changes.
|
|
88
|
+
*/
|
|
89
|
+
export function AnnouncementTab({ form, onChange, problem, readOnly }: AnnouncementTabProps) {
|
|
90
|
+
const id = useId()
|
|
91
|
+
const set = (patch: Partial<AnnouncementForm>) => onChange({ ...form, ...patch })
|
|
92
|
+
const off = !form.on
|
|
93
|
+
const bodyDisabled = readOnly || off
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<section aria-labelledby={`${id}-title`} className={CARD}>
|
|
97
|
+
<div className="border-bdr flex items-center gap-2.5 border-b px-4 py-[13px]">
|
|
98
|
+
<div className="min-w-0 flex-1">
|
|
99
|
+
<h2
|
|
100
|
+
id={`${id}-title`}
|
|
101
|
+
className="text-card-title text-txt m-0 leading-[normal] font-[650]"
|
|
102
|
+
>
|
|
103
|
+
Announcement bar
|
|
104
|
+
</h2>
|
|
105
|
+
<p className="text-meta text-sub m-0 mt-px leading-[normal]">
|
|
106
|
+
Shows above the header on every page. No developer needed.
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
<Toggle
|
|
110
|
+
aria-label="Show announcement bar"
|
|
111
|
+
checked={form.on}
|
|
112
|
+
disabled={readOnly}
|
|
113
|
+
onCheckedChange={(on) => set({ on })}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
<div
|
|
117
|
+
data-off={off || undefined}
|
|
118
|
+
// Off: 50% and not interactive (the kit's `pointer-events: none`);
|
|
119
|
+
// `inert` also keeps it out of the Tab order and the accessibility tree.
|
|
120
|
+
inert={off || undefined}
|
|
121
|
+
className={`flex flex-col gap-3.5 px-4 py-3.5 ${off ? 'pointer-events-none opacity-50' : ''}`}
|
|
122
|
+
>
|
|
123
|
+
<Field
|
|
124
|
+
label="Message"
|
|
125
|
+
htmlFor={`${id}-text`}
|
|
126
|
+
help={
|
|
127
|
+
<span aria-live="polite">
|
|
128
|
+
<span className={form.text.length > ANNOUNCEMENT_MAX ? 'text-err' : undefined}>
|
|
129
|
+
{form.text.length}/{ANNOUNCEMENT_MAX}
|
|
130
|
+
</span>{' '}
|
|
131
|
+
· keep it to one line on mobile.
|
|
132
|
+
</span>
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
<input
|
|
136
|
+
id={`${id}-text`}
|
|
137
|
+
className={INPUT}
|
|
138
|
+
maxLength={ANNOUNCEMENT_MAX}
|
|
139
|
+
value={form.text}
|
|
140
|
+
disabled={bodyDisabled}
|
|
141
|
+
onChange={(event) => set({ text: event.target.value })}
|
|
142
|
+
/>
|
|
143
|
+
</Field>
|
|
144
|
+
<div className="grid grid-cols-2 gap-2 [@media(width<=560px)]:grid-cols-1">
|
|
145
|
+
<Field label="Link text" htmlFor={`${id}-link`} help="Optional">
|
|
146
|
+
<input
|
|
147
|
+
id={`${id}-link`}
|
|
148
|
+
className={INPUT}
|
|
149
|
+
maxLength={ANNOUNCEMENT_LINK_MAX}
|
|
150
|
+
value={form.linkLabel}
|
|
151
|
+
disabled={bodyDisabled}
|
|
152
|
+
onChange={(event) => set({ linkLabel: event.target.value })}
|
|
153
|
+
/>
|
|
154
|
+
</Field>
|
|
155
|
+
<Field label="Links to" htmlFor={`${id}-url`}>
|
|
156
|
+
<input
|
|
157
|
+
id={`${id}-url`}
|
|
158
|
+
className={`${INPUT} ${MONO}`}
|
|
159
|
+
value={form.linkUrl}
|
|
160
|
+
spellCheck={false}
|
|
161
|
+
disabled={bodyDisabled}
|
|
162
|
+
onChange={(event) => set({ linkUrl: event.target.value })}
|
|
163
|
+
/>
|
|
164
|
+
</Field>
|
|
165
|
+
</div>
|
|
166
|
+
<div>
|
|
167
|
+
<span className="text-meta text-txt mb-[5px] block font-semibold">Style</span>
|
|
168
|
+
<RadioPills
|
|
169
|
+
name={`${id}-tone`}
|
|
170
|
+
label="Style"
|
|
171
|
+
value={form.tone}
|
|
172
|
+
disabled={bodyDisabled}
|
|
173
|
+
options={ANNOUNCEMENT_TONES.map((tone) => ({
|
|
174
|
+
id: tone.id,
|
|
175
|
+
label: tone.label,
|
|
176
|
+
swatch: TONE_SWATCH[tone.id],
|
|
177
|
+
}))}
|
|
178
|
+
onChange={(tone) => set({ tone: tone as AnnouncementTone })}
|
|
179
|
+
/>
|
|
180
|
+
<Help>Your site styles each tone with its own colors.</Help>
|
|
181
|
+
</div>
|
|
182
|
+
<SwitchLine
|
|
183
|
+
label="Visitors can dismiss it"
|
|
184
|
+
sub="Stays hidden for that visitor until the message changes."
|
|
185
|
+
on={form.dismissible}
|
|
186
|
+
disabled={bodyDisabled}
|
|
187
|
+
onChange={(dismissible) => set({ dismissible })}
|
|
188
|
+
/>
|
|
189
|
+
<SwitchLine
|
|
190
|
+
label="Schedule"
|
|
191
|
+
sub="Turns itself on and off. Your site is refreshed automatically."
|
|
192
|
+
on={form.scheduled}
|
|
193
|
+
disabled={bodyDisabled}
|
|
194
|
+
onChange={(scheduled) => set({ scheduled })}
|
|
195
|
+
/>
|
|
196
|
+
{form.scheduled ? (
|
|
197
|
+
<div className="grid grid-cols-2 gap-2 [@media(width<=560px)]:grid-cols-1">
|
|
198
|
+
<Field label="Starts" htmlFor={`${id}-start`}>
|
|
199
|
+
<input
|
|
200
|
+
id={`${id}-start`}
|
|
201
|
+
className={INPUT}
|
|
202
|
+
type="datetime-local"
|
|
203
|
+
value={form.startsAt}
|
|
204
|
+
disabled={bodyDisabled}
|
|
205
|
+
onChange={(event) => set({ startsAt: event.target.value })}
|
|
206
|
+
/>
|
|
207
|
+
</Field>
|
|
208
|
+
<Field label="Ends" htmlFor={`${id}-end`}>
|
|
209
|
+
<input
|
|
210
|
+
id={`${id}-end`}
|
|
211
|
+
className={INPUT}
|
|
212
|
+
type="datetime-local"
|
|
213
|
+
value={form.endsAt}
|
|
214
|
+
disabled={bodyDisabled}
|
|
215
|
+
onChange={(event) => set({ endsAt: event.target.value })}
|
|
216
|
+
/>
|
|
217
|
+
</Field>
|
|
218
|
+
</div>
|
|
219
|
+
) : null}
|
|
220
|
+
</div>
|
|
221
|
+
{problem ? (
|
|
222
|
+
// Outside the body, so it shows at full strength while the bar is off.
|
|
223
|
+
<div className="-mt-1.5 px-4 pb-3.5">
|
|
224
|
+
<Help tone="error">
|
|
225
|
+
<span role="alert">{problem}</span>
|
|
226
|
+
</Help>
|
|
227
|
+
</div>
|
|
228
|
+
) : null}
|
|
229
|
+
</section>
|
|
230
|
+
)
|
|
231
|
+
}
|