@byline/admin 5.1.2 → 5.1.4

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 (93) hide show
  1. package/dist/exports-parity.test.node.js +37 -0
  2. package/dist/fields/array/array-field.jsx +209 -0
  3. package/dist/fields/blocks/blocks-field.jsx +248 -0
  4. package/dist/fields/checkbox/checkbox-field.jsx +24 -0
  5. package/dist/fields/code/code-editor.jsx +211 -0
  6. package/dist/fields/code/code-field.jsx +109 -0
  7. package/dist/fields/column-formatter.jsx +25 -0
  8. package/dist/fields/date-time-formatter.jsx +18 -0
  9. package/dist/fields/datetime/datetime-field.jsx +22 -0
  10. package/dist/fields/draggable-context-menu.jsx +50 -0
  11. package/dist/fields/field-admin.test.node.js +40 -0
  12. package/dist/fields/field-renderer.jsx +136 -0
  13. package/dist/fields/field-services-context.jsx +17 -0
  14. package/dist/fields/file/file-field.jsx +176 -0
  15. package/dist/fields/file/file-upload-field.jsx +89 -0
  16. package/dist/fields/group/group-field.jsx +46 -0
  17. package/dist/fields/image/image-field.jsx +183 -0
  18. package/dist/fields/image/image-upload-field.jsx +157 -0
  19. package/dist/fields/local-date-time.jsx +69 -0
  20. package/dist/fields/locale-badge.jsx +19 -0
  21. package/dist/fields/numerical/numerical-field.jsx +93 -0
  22. package/dist/fields/relation/relation-column-formatter.jsx +74 -0
  23. package/dist/fields/relation/relation-display.jsx +118 -0
  24. package/dist/fields/relation/relation-field.jsx +108 -0
  25. package/dist/fields/relation/relation-many-field.jsx +142 -0
  26. package/dist/fields/relation/relation-picker.jsx +240 -0
  27. package/dist/fields/relation/relation-summary.jsx +45 -0
  28. package/dist/fields/select/select-field.jsx +27 -0
  29. package/dist/fields/sortable-item.jsx +54 -0
  30. package/dist/fields/text/text-field.jsx +79 -0
  31. package/dist/fields/text-area/text-area-field.jsx +79 -0
  32. package/dist/forms/available-locales-reconcile.test.node.js +43 -0
  33. package/dist/forms/available-locales-widget.jsx +63 -0
  34. package/dist/forms/document-actions.jsx +486 -0
  35. package/dist/forms/form-context.jsx +598 -0
  36. package/dist/forms/form-modals.jsx +128 -0
  37. package/dist/forms/form-renderer.jsx +532 -0
  38. package/dist/forms/form-status-display.jsx +69 -0
  39. package/dist/forms/navigation-guard.jsx +68 -0
  40. package/dist/forms/nested-path.test.node.js +115 -0
  41. package/dist/forms/path-widget.jsx +125 -0
  42. package/dist/forms/pending-uploads.test.node.js +19 -0
  43. package/dist/forms/repeating-items.test.node.js +29 -0
  44. package/dist/forms/scheduled-publication-control.jsx +389 -0
  45. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  46. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  47. package/dist/forms/status-transitions.test.node.js +76 -0
  48. package/dist/forms/tree-placement-widget.jsx +160 -0
  49. package/dist/forms/upload-executor.test.node.js +368 -0
  50. package/dist/forms/use-form-layout.test.node.js +69 -0
  51. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  52. package/dist/modules/admin-account/components/container.jsx +164 -0
  53. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  54. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  55. package/dist/modules/admin-account/components/update.jsx +186 -0
  56. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  57. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  58. package/dist/modules/admin-roles/components/create.jsx +155 -0
  59. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  60. package/dist/modules/admin-roles/components/update.jsx +148 -0
  61. package/dist/modules/admin-users/components/create.jsx +206 -0
  62. package/dist/modules/admin-users/components/roles.jsx +126 -0
  63. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  64. package/dist/modules/admin-users/components/update.jsx +215 -0
  65. package/dist/modules/analytics/components/dashboard.d.ts +13 -3
  66. package/dist/modules/analytics/components/dashboard.js +86 -68
  67. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  68. package/dist/modules/analytics/components/dashboard.module.js +10 -1
  69. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  70. package/dist/modules/analytics/components/dashboard_module.css +57 -0
  71. package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
  72. package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
  73. package/dist/modules/analytics/components/ranking.d.ts +45 -0
  74. package/dist/modules/analytics/components/ranking.js +71 -0
  75. package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
  76. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  77. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  78. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  79. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  80. package/dist/presentation/group.jsx +26 -0
  81. package/dist/presentation/row.jsx +23 -0
  82. package/dist/presentation/tabs.jsx +39 -0
  83. package/dist/services/admin-services-context.jsx +17 -0
  84. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  85. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  86. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  87. package/package.json +7 -7
  88. package/src/modules/analytics/components/dashboard.module.css +69 -0
  89. package/src/modules/analytics/components/dashboard.tsx +105 -83
  90. package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
  91. package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
  92. package/src/modules/analytics/components/ranking.test.node.ts +43 -0
  93. package/src/modules/analytics/components/ranking.tsx +122 -0
@@ -0,0 +1,389 @@
1
+ 'use client';
2
+ /**
3
+ * This Source Code is subject to the terms of the Mozilla Public
4
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
+ *
7
+ * Copyright (c) Infonomic Company Limited
8
+ */
9
+ /**
10
+ * Scheduled publication in the document editor.
11
+ *
12
+ * The feature is split across three surfaces rather than one block, because
13
+ * the states differ in how much of the editor's attention they deserve:
14
+ *
15
+ * - The **actions** live in the document-actions menu, next to the other
16
+ * document-level operations, so the status bar's primary Save / Publish
17
+ * controls keep their weight.
18
+ * - An **armed** schedule renders as one more metadata cell in the status
19
+ * bar, alongside Status and Last modified. It is a fact about the
20
+ * document, presented at the scale of the other facts.
21
+ * - **Needs re-confirmation**, **overdue** and **failing** schedules
22
+ * escalate to a non-dismissible Alert below the status bar, carrying
23
+ * their own actions. The `needs_reconfirm` notice in particular has to
24
+ * outlive the toast that announced it, which is exactly what an Alert
25
+ * with `close={false}` does.
26
+ *
27
+ * `useScheduledPublication` owns the state and the modal so a single parent
28
+ * can place the three surfaces independently.
29
+ */
30
+ import { useCallback, useEffect, useMemo, useState } from 'react';
31
+ import { useTranslation } from '@byline/i18n/react';
32
+ import { Alert, Button, CloseIcon, DatePicker, IconButton, Label, Modal, Select, } from '@byline/ui/react';
33
+ import cx from 'clsx';
34
+ import styles from './scheduled-publication-control.module.css';
35
+ import { deriveScheduledPublicationState } from './scheduled-publication-state.js';
36
+ import { joinWallTime, resolveScheduledPublicationWallTime, wallTimeInZone, } from './scheduled-publication-time.js';
37
+ /** How often the armed → overdue boundary is re-checked while the editor sits open. */
38
+ const DUE_POLL_INTERVAL_MS = 30_000;
39
+ /** Default offset for a fresh schedule — far enough out to be reviewable. */
40
+ const DEFAULT_LEAD_MS = 15 * 60_000;
41
+ function browserTimeZone() {
42
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
43
+ }
44
+ /**
45
+ * Render a wall time for display without turning it into an instant.
46
+ *
47
+ * The calendar day is formatted through a UTC-noon stand-in, which is safe
48
+ * because only the day's name and number are taken from it; the clock reading
49
+ * is appended verbatim, since the whole point is to show the reading the editor
50
+ * chose rather than one a `Date` would have normalized it to.
51
+ */
52
+ function formatWallTime(wall, locale) {
53
+ const [year, month, day] = wall.date.split('-').map(Number);
54
+ if (year == null || month == null || day == null) {
55
+ return `${wall.date} ${wall.time}`;
56
+ }
57
+ if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
58
+ return `${wall.date} ${wall.time}`;
59
+ }
60
+ const dayLabel = new Intl.DateTimeFormat(locale, {
61
+ dateStyle: 'medium',
62
+ timeZone: 'UTC',
63
+ }).format(new Date(Date.UTC(year, month - 1, day, 12)));
64
+ return `${dayLabel}, ${wall.time}`;
65
+ }
66
+ function seedScheduleInstant(schedule) {
67
+ if (schedule != null)
68
+ return new Date(schedule.publishAt);
69
+ const seed = new Date(Date.now() + DEFAULT_LEAD_MS);
70
+ seed.setSeconds(0, 0);
71
+ return seed;
72
+ }
73
+ export function useScheduledPublication({ disabled = false, schedule, onSchedule, onConfirm, onCancel, hasUnsavedChanges, onUnsavedChanges, }) {
74
+ const timeZone = useMemo(browserTimeZone, []);
75
+ const [now, setNow] = useState(() => Date.now());
76
+ const [showSchedule, setShowSchedule] = useState(false);
77
+ const [busy, setBusy] = useState(false);
78
+ // Only tick while something is actually scheduled — an editor with no
79
+ // schedule has no boundary to cross, and a bare form should not re-render
80
+ // on a timer.
81
+ useEffect(() => {
82
+ if (schedule == null)
83
+ return;
84
+ const timer = setInterval(() => setNow(Date.now()), DUE_POLL_INTERVAL_MS);
85
+ return () => clearInterval(timer);
86
+ }, [schedule]);
87
+ const capabilities = useMemo(() => ({
88
+ canSchedule: onSchedule != null,
89
+ canConfirm: onConfirm != null,
90
+ canCancel: onCancel != null,
91
+ }), [onSchedule, onConfirm, onCancel]);
92
+ const state = useMemo(() => deriveScheduledPublicationState(schedule, capabilities, now), [schedule, capabilities, now]);
93
+ // Scheduling authorizes a specific reviewed version, so an unsaved edit has
94
+ // to be resolved before any of these operations can name a version. Cancel
95
+ // is exempt: withdrawing a schedule says nothing about content.
96
+ const openSchedule = useCallback(() => {
97
+ if (disabled)
98
+ return;
99
+ if (hasUnsavedChanges) {
100
+ onUnsavedChanges();
101
+ return;
102
+ }
103
+ setShowSchedule(true);
104
+ }, [disabled, hasUnsavedChanges, onUnsavedChanges]);
105
+ const confirm = useCallback(async () => {
106
+ if (disabled || onConfirm == null)
107
+ return;
108
+ if (hasUnsavedChanges) {
109
+ onUnsavedChanges();
110
+ return;
111
+ }
112
+ setBusy(true);
113
+ try {
114
+ await onConfirm();
115
+ }
116
+ catch {
117
+ // The host reports the failure; keep this view open.
118
+ }
119
+ finally {
120
+ setBusy(false);
121
+ }
122
+ }, [disabled, onConfirm, hasUnsavedChanges, onUnsavedChanges]);
123
+ const cancel = useCallback(async () => {
124
+ if (disabled || onCancel == null)
125
+ return;
126
+ setBusy(true);
127
+ try {
128
+ await onCancel();
129
+ }
130
+ catch {
131
+ // The host reports the failure; keep this view open.
132
+ }
133
+ finally {
134
+ setBusy(false);
135
+ }
136
+ }, [disabled, onCancel]);
137
+ const modal = showSchedule ? (<ScheduleModal schedule={schedule} timeZone={timeZone} onSubmit={async (input) => {
138
+ if (disabled || onSchedule == null)
139
+ return;
140
+ setBusy(true);
141
+ try {
142
+ await onSchedule(input);
143
+ setShowSchedule(false);
144
+ }
145
+ catch {
146
+ // The host reports the failure; keep this view open.
147
+ }
148
+ finally {
149
+ setBusy(false);
150
+ }
151
+ }} onDismiss={() => setShowSchedule(false)} busy={disabled || busy}/>) : null;
152
+ return {
153
+ state,
154
+ timeZone,
155
+ busy,
156
+ isActive: state.kind !== 'none' || state.actions.schedule,
157
+ openSchedule,
158
+ confirm,
159
+ cancel,
160
+ modal,
161
+ };
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // Status-bar cell — the quiet, armed presentation
165
+ // ---------------------------------------------------------------------------
166
+ function useInstantFormatter(timeZone) {
167
+ const { locale } = useTranslation('byline-admin');
168
+ return useMemo(() => new Intl.DateTimeFormat(locale, {
169
+ dateStyle: 'medium',
170
+ timeStyle: 'short',
171
+ timeZone,
172
+ }), [locale, timeZone]);
173
+ }
174
+ /**
175
+ * One metadata cell for the form's status bar, matching the Status /
176
+ * Last modified / Created cells in scale, sitting immediately after the status
177
+ * cell — an armed schedule says where the document is headed, which continues
178
+ * what Status says about it rather than belonging with the timestamps.
179
+ *
180
+ * Written as a single phrase in the success colour rather than the label/value
181
+ * pair its neighbours use: it is the one cell reporting something pending
182
+ * rather than something already true, and it should read that way at a glance.
183
+ * Only an armed schedule appears here; every other state is carried by the
184
+ * notice instead, so the two never say the same thing twice.
185
+ */
186
+ export function ScheduledPublicationCell({ state, timeZone, }) {
187
+ const { t } = useTranslation('byline-admin');
188
+ const format = useInstantFormatter(timeZone);
189
+ if (state.kind !== 'armed' || state.publishAt == null)
190
+ return null;
191
+ return (<time className={cx('byline-scheduled-publication-cell', styles.cell)} dateTime={state.publishAt.toISOString()}>
192
+ {t('scheduledPublication.status.cellText', {
193
+ dateTime: `${format.format(state.publishAt)} (${timeZone})`,
194
+ })}
195
+ </time>);
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // Notice — the escalated presentation for every exceptional state
199
+ // ---------------------------------------------------------------------------
200
+ /**
201
+ * The durable notice for a suspended, overdue or failing schedule. Not
202
+ * dismissible: `needs_reconfirm` has to stay on screen until an editor acts on
203
+ * it, long after the toast that announced the suspension has gone.
204
+ */
205
+ export function ScheduledPublicationNotice({ state, timeZone, busy, onConfirm, onReschedule, onCancel, }) {
206
+ const { t } = useTranslation('byline-admin');
207
+ const format = useInstantFormatter(timeZone);
208
+ if (!state.isExceptional || state.publishAt == null)
209
+ return null;
210
+ const instant = `${format.format(state.publishAt)} (${timeZone})`;
211
+ const title = state.kind === 'needs_reconfirm'
212
+ ? t('scheduledPublication.status.needsReconfirm')
213
+ : t('scheduledPublication.status.overdue');
214
+ return (
215
+ // A landmark rather than a live region: the toast already announces the
216
+ // change assertively when it happens, and announcing the same thing twice
217
+ // helps nobody. What the notice needs is to stay *findable* afterwards,
218
+ // which a named region gives a screen-reader user.
219
+ <div role="region" aria-label={title}>
220
+ <Alert className={cx('byline-scheduled-publication-notice', styles.notice)} intent={state.tone === 'danger' ? 'danger' : 'warning'} icon={true} close={false} title={title}>
221
+ <p className={styles['notice-body']}>
222
+ {state.kind === 'needs_reconfirm'
223
+ ? t('scheduledPublication.status.contentChanged')
224
+ : t('scheduledPublication.status.overdueBody')}
225
+ </p>
226
+ <p className={styles['notice-instant']}>
227
+ {t('scheduledPublication.status.authorizedFor', { dateTime: instant })}
228
+ {state.kind === 'needs_reconfirm' && state.isPastDue && (<> {t('scheduledPublication.status.pastDueNote')}</>)}
229
+ </p>
230
+ {state.attemptCount > 0 && (<p className={styles['notice-attempts']}>
231
+ {t('scheduledPublication.status.attempts', { count: state.attemptCount })}
232
+ </p>)}
233
+ {state.lastError != null && <p className={styles['notice-error']}>{state.lastError}</p>}
234
+ <div className={cx('byline-scheduled-publication-notice-actions', styles['notice-actions'])}>
235
+ {state.actions.confirm && (<Button size="sm" type="button" intent="success" disabled={busy} onClick={onConfirm}>
236
+ {t('scheduledPublication.actions.confirm')}
237
+ </Button>)}
238
+ {state.actions.reschedule && (<Button size="sm" type="button" intent="info" disabled={busy} onClick={onReschedule}>
239
+ {t('scheduledPublication.actions.reschedule')}
240
+ </Button>)}
241
+ {state.actions.cancel && (<Button size="sm" type="button" variant="text" disabled={busy} onClick={onCancel}>
242
+ {t('scheduledPublication.actions.cancel')}
243
+ </Button>)}
244
+ </div>
245
+ </Alert>
246
+ </div>);
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // Schedule / reschedule modal
250
+ // ---------------------------------------------------------------------------
251
+ /**
252
+ * The picker's wall time is read, not its `Date`.
253
+ *
254
+ * `DatePicker` reports both: `onDateChange` gives an instant, and
255
+ * `onWallTimeChange` gives the day and clock reading the editor actually
256
+ * selected. Only the second one can express "02:30 on a day when 02:30 does not
257
+ * exist" — the instant has already been normalized to 03:30 by then, and an
258
+ * ambiguous 01:30 has already been resolved to the earlier of its two instants
259
+ * without asking. So the wall time goes to
260
+ * `resolveScheduledPublicationWallTime`, which is the only thing here allowed
261
+ * to turn a wall time into an instant, and which refuses or asks as required.
262
+ */
263
+ function ScheduleModal({ schedule, timeZone, onSubmit, onDismiss, busy, }) {
264
+ const { t, locale } = useTranslation('byline-admin');
265
+ // The instant the picker opens on. Always a real instant — either the one
266
+ // already authorized, or a lead time from now — so seeding it never has to
267
+ // construct a `Date` from a wall time.
268
+ const [seedInstant] = useState(() => seedScheduleInstant(schedule));
269
+ // Held, not recomputed per render, so the calendar's disabled range does not
270
+ // shift under the editor while the modal is open.
271
+ const [today] = useState(() => new Date());
272
+ const [wall, setWall] = useState(() => wallTimeInZone(seedInstant, timeZone));
273
+ const [instantChoices, setInstantChoices] = useState([]);
274
+ const [selectedInstant, setSelectedInstant] = useState('');
275
+ const [validationError, setValidationError] = useState(null);
276
+ const resetResolution = () => {
277
+ setInstantChoices([]);
278
+ setSelectedInstant('');
279
+ setValidationError(null);
280
+ };
281
+ const submit = async () => {
282
+ const value = joinWallTime(wall);
283
+ if (value == null) {
284
+ setValidationError(t('scheduledPublication.form.invalid'));
285
+ return;
286
+ }
287
+ const resolution = resolveScheduledPublicationWallTime(value, timeZone);
288
+ if (resolution.status === 'invalid') {
289
+ setValidationError(t('scheduledPublication.form.invalid'));
290
+ return;
291
+ }
292
+ // Name the wall time in both daylight-saving messages. The picker's own
293
+ // field cannot be trusted to show it: having normalized the selection, it
294
+ // displays 03:30 for a 02:30 that does not exist, so a message that just
295
+ // said "that time" would appear to be rejecting the time on screen.
296
+ const offending = formatWallTime(wall, locale);
297
+ if (resolution.status === 'nonexistent') {
298
+ setValidationError(t('scheduledPublication.form.nonexistent', { wallTime: offending }));
299
+ return;
300
+ }
301
+ if (resolution.choices.length > 1 &&
302
+ !resolution.choices.some((c) => c.iso === selectedInstant)) {
303
+ setInstantChoices(resolution.choices);
304
+ setValidationError(t('scheduledPublication.form.ambiguous', { wallTime: offending }));
305
+ return;
306
+ }
307
+ const publishAtIso = selectedInstant || resolution.choices[0]?.iso;
308
+ if (publishAtIso == null)
309
+ return;
310
+ // Checked on the resolved instant rather than the wall time, because an
311
+ // ambiguous overlap has two instants an hour apart and only one of them may
312
+ // still be ahead.
313
+ //
314
+ // This is an affordance, not the guarantee. The server compares against
315
+ // database time and refuses `publish_at_not_future` regardless of what the
316
+ // browser's clock believes — catching it here just replaces a raw server
317
+ // error in a danger toast with a message next to the field that caused it.
318
+ if (Date.parse(publishAtIso) <= Date.now()) {
319
+ setValidationError(t('scheduledPublication.form.notFuture', { wallTime: offending }));
320
+ return;
321
+ }
322
+ await onSubmit({ publishAt: publishAtIso });
323
+ };
324
+ const title = schedule == null
325
+ ? t('scheduledPublication.form.scheduleTitle')
326
+ : t('scheduledPublication.form.rescheduleTitle');
327
+ return (<Modal isOpen closeOnOverlayClick={!busy} onDismiss={() => {
328
+ if (!busy)
329
+ onDismiss();
330
+ }}>
331
+ <Modal.Container style={{ maxWidth: '560px' }}>
332
+ <Modal.Header className={styles.modalHead}>
333
+ <h3 className={styles.modalTitle}>{title}</h3>
334
+ <IconButton aria-label={t('common.actions.close')} size="xs" disabled={busy} onClick={onDismiss}>
335
+ <CloseIcon width="16px" height="16px" svgClassName="white-icon"/>
336
+ </IconButton>
337
+ </Modal.Header>
338
+ <Modal.Content>
339
+ <div className={styles.field}>
340
+ <DatePicker id="scheduled-publication-at" name="scheduled-publication-at" label={t('scheduledPublication.form.dateTime')} mode="datetime" inputSize="sm"
341
+ // Today is the earliest selectable day. Deliberately day-grained:
342
+ // a later slot today is a perfectly good schedule, so the day
343
+ // stays open and the submit-time check below is what rejects an
344
+ // hour that has already gone.
345
+ minDate={today} yearsInPast={0} yearsInFuture={5} initialValue={seedInstant} onWallTimeChange={(value) => {
346
+ if (value == null)
347
+ return;
348
+ setWall(value);
349
+ resetResolution();
350
+ }}/>
351
+ </div>
352
+ <p className={styles.help}>{t('scheduledPublication.form.timeZone', { timeZone })}</p>
353
+ {instantChoices.length > 1 && (<div className={styles.choice}>
354
+ <Label id="scheduled-publication-offset-label" htmlFor="scheduled-publication-offset" label={t('scheduledPublication.form.offset')}/>
355
+ <Select id="scheduled-publication-offset" name="scheduled-publication-offset" ariaLabel={t('scheduledPublication.form.offset')} containerClassName={styles['choice-select']} placeholder={t('scheduledPublication.form.offsetPlaceholder')} size="sm" value={selectedInstant}
356
+ // The resolver returns the overlap's instants in chronological
357
+ // order, so the two can be named by when they happen. A bare
358
+ // "UTC-04:00" asks the editor to know which side of the
359
+ // transition that is; "Earlier" and "Later" do not.
360
+ items={instantChoices.map((choice, index) => ({
361
+ value: choice.iso,
362
+ label: t(index === 0
363
+ ? 'scheduledPublication.form.offsetEarlier'
364
+ : 'scheduledPublication.form.offsetLater', { offset: choice.offsetLabel }),
365
+ }))} onValueChange={(value) => {
366
+ setSelectedInstant(value ?? '');
367
+ setValidationError(null);
368
+ }} disabled={busy}/>
369
+ </div>)}
370
+ {validationError != null && (<p className={styles.validation} role="alert">
371
+ {validationError}
372
+ </p>)}
373
+ <p className={styles.warningText}>{t('scheduledPublication.form.editWarning')}</p>
374
+ </Modal.Content>
375
+ <Modal.Actions>
376
+ <Button size="sm" intent="noeffect" disabled={busy} onClick={onDismiss}>
377
+ {t('common.actions.cancel')}
378
+ </Button>
379
+ <Button size="sm" intent="primary" disabled={busy || wall.date.length === 0 || wall.time.length === 0} onClick={submit}>
380
+ {busy
381
+ ? t('scheduledPublication.form.saving')
382
+ : schedule == null
383
+ ? t('scheduledPublication.actions.schedule')
384
+ : t('scheduledPublication.actions.reschedule')}
385
+ </Button>
386
+ </Modal.Actions>
387
+ </Modal.Container>
388
+ </Modal>);
389
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { deriveScheduledPublicationState } from './scheduled-publication-state.js';
10
+ const NOW = Date.parse('2026-08-22T12:00:00.000Z');
11
+ const ALL_CAPABILITIES = { canSchedule: true, canConfirm: true, canCancel: true };
12
+ const NO_CAPABILITIES = { canSchedule: false, canConfirm: false, canCancel: false };
13
+ function schedule(overrides = {}) {
14
+ return {
15
+ publishAt: '2026-08-24T09:00:00.000Z',
16
+ targetVersionId: '019f0000-0000-7000-8000-000000000001',
17
+ state: 'armed',
18
+ lastAuthorizedBy: '019e3daa-42dd-70bb-9be3-5f18b1594b0a',
19
+ lastError: null,
20
+ nextAttemptAt: '2026-08-24T09:00:00.000Z',
21
+ attemptCount: 0,
22
+ ...overrides,
23
+ };
24
+ }
25
+ describe('deriveScheduledPublicationState', () => {
26
+ describe('with no schedule', () => {
27
+ it('offers only the schedule action', () => {
28
+ const state = deriveScheduledPublicationState(null, ALL_CAPABILITIES, NOW);
29
+ expect(state.kind).toBe('none');
30
+ expect(state.isExceptional).toBe(false);
31
+ expect(state.actions).toEqual({
32
+ schedule: true,
33
+ reschedule: false,
34
+ confirm: false,
35
+ cancel: false,
36
+ });
37
+ });
38
+ it('offers nothing at all to an actor without the abilities', () => {
39
+ const state = deriveScheduledPublicationState(null, NO_CAPABILITIES, NOW);
40
+ expect(state.actions.schedule).toBe(false);
41
+ });
42
+ });
43
+ describe('armed', () => {
44
+ it('reads as a quiet, non-exceptional state', () => {
45
+ const state = deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, NOW);
46
+ expect(state.kind).toBe('armed');
47
+ expect(state.tone).toBe('info');
48
+ expect(state.isExceptional).toBe(false);
49
+ expect(state.isPastDue).toBe(false);
50
+ expect(state.publishAt?.toISOString()).toBe('2026-08-24T09:00:00.000Z');
51
+ });
52
+ it('offers reschedule and cancel but never confirm', () => {
53
+ const state = deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, NOW);
54
+ expect(state.actions).toEqual({
55
+ schedule: false,
56
+ reschedule: true,
57
+ confirm: false,
58
+ cancel: true,
59
+ });
60
+ });
61
+ it('becomes overdue the instant the authorized time is reached', () => {
62
+ const due = Date.parse('2026-08-24T09:00:00.000Z');
63
+ expect(deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, due - 1).kind).toBe('armed');
64
+ expect(deriveScheduledPublicationState(schedule(), ALL_CAPABILITIES, due).kind).toBe('overdue');
65
+ });
66
+ });
67
+ describe('needs re-confirmation', () => {
68
+ it('escalates and offers confirm alongside reschedule and cancel', () => {
69
+ const state = deriveScheduledPublicationState(schedule({ state: 'needs_reconfirm' }), ALL_CAPABILITIES, NOW);
70
+ expect(state.kind).toBe('needs_reconfirm');
71
+ expect(state.tone).toBe('warning');
72
+ expect(state.isExceptional).toBe(true);
73
+ expect(state.actions).toEqual({
74
+ schedule: false,
75
+ reschedule: true,
76
+ confirm: true,
77
+ cancel: true,
78
+ });
79
+ });
80
+ it('stays suspended rather than overdue once its instant has passed', () => {
81
+ const state = deriveScheduledPublicationState(schedule({ state: 'needs_reconfirm' }), ALL_CAPABILITIES, Date.parse('2026-08-25T09:00:00.000Z'));
82
+ // The reason it did not publish is the suspension, not the clock — but
83
+ // the elapsed instant still has to be reportable to the editor.
84
+ expect(state.kind).toBe('needs_reconfirm');
85
+ expect(state.isPastDue).toBe(true);
86
+ });
87
+ it('withholds confirm from an actor who cannot confirm', () => {
88
+ const state = deriveScheduledPublicationState(schedule({ state: 'needs_reconfirm' }), { canSchedule: false, canConfirm: false, canCancel: true }, NOW);
89
+ expect(state.actions.confirm).toBe(false);
90
+ expect(state.actions.reschedule).toBe(false);
91
+ expect(state.actions.cancel).toBe(true);
92
+ });
93
+ });
94
+ describe('overdue', () => {
95
+ const overdueNow = Date.parse('2026-08-24T09:05:00.000Z');
96
+ it('escalates and exposes the retry count', () => {
97
+ const state = deriveScheduledPublicationState(schedule({ attemptCount: 3 }), ALL_CAPABILITIES, overdueNow);
98
+ expect(state.kind).toBe('overdue');
99
+ expect(state.isExceptional).toBe(true);
100
+ expect(state.attemptCount).toBe(3);
101
+ });
102
+ it('raises the tone to danger when the sweep reported an error', () => {
103
+ const state = deriveScheduledPublicationState(schedule({ attemptCount: 2, lastError: 'workflow transition rejected' }), ALL_CAPABILITIES, overdueNow);
104
+ expect(state.tone).toBe('danger');
105
+ expect(state.lastError).toBe('workflow transition rejected');
106
+ });
107
+ it('raises the tone to danger even while still armed and not yet due', () => {
108
+ const state = deriveScheduledPublicationState(schedule({ lastError: 'transient database error' }), ALL_CAPABILITIES, NOW);
109
+ expect(state.kind).toBe('armed');
110
+ expect(state.tone).toBe('danger');
111
+ });
112
+ it('still offers cancel so a stuck schedule can be cleared', () => {
113
+ const state = deriveScheduledPublicationState(schedule({ attemptCount: 5, lastError: 'boom' }), { canSchedule: false, canConfirm: false, canCancel: true }, overdueNow);
114
+ expect(state.actions.cancel).toBe(true);
115
+ });
116
+ });
117
+ it('accepts a Date instant as readily as an ISO string', () => {
118
+ const state = deriveScheduledPublicationState(schedule({ publishAt: new Date('2026-08-24T09:00:00.000Z') }), ALL_CAPABILITIES, NOW);
119
+ expect(state.publishAt?.toISOString()).toBe('2026-08-24T09:00:00.000Z');
120
+ });
121
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { joinWallTime, resolveScheduledPublicationWallTime, wallTimeInZone, } from './scheduled-publication-time.js';
10
+ describe('resolveScheduledPublicationWallTime', () => {
11
+ it('rejects a calendar date that the Date constructor would normalize', () => {
12
+ expect(resolveScheduledPublicationWallTime('2026-02-30T12:00', 'UTC')).toEqual({
13
+ status: 'invalid',
14
+ });
15
+ });
16
+ it('resolves an ordinary wall time to one ISO instant', () => {
17
+ expect(resolveScheduledPublicationWallTime('2026-08-23T12:00', 'America/New_York')).toEqual({
18
+ status: 'valid',
19
+ choices: [{ iso: '2026-08-23T16:00:00.000Z', offsetLabel: 'UTC-04:00' }],
20
+ });
21
+ });
22
+ it('rejects a daylight-saving gap instead of silently shifting it', () => {
23
+ expect(resolveScheduledPublicationWallTime('2026-03-08T02:30', 'America/New_York')).toEqual({
24
+ status: 'nonexistent',
25
+ });
26
+ });
27
+ it('returns both instants in a daylight-saving overlap', () => {
28
+ expect(resolveScheduledPublicationWallTime('2026-11-01T01:30', 'America/New_York')).toEqual({
29
+ status: 'valid',
30
+ choices: [
31
+ { iso: '2026-11-01T05:30:00.000Z', offsetLabel: 'UTC-04:00' },
32
+ { iso: '2026-11-01T06:30:00.000Z', offsetLabel: 'UTC-05:00' },
33
+ ],
34
+ });
35
+ });
36
+ });
37
+ describe('wallTimeInZone', () => {
38
+ it('splits an instant into the day and clock reading shown in the zone', () => {
39
+ expect(wallTimeInZone(new Date('2026-08-23T16:00:00.000Z'), 'America/New_York')).toEqual({
40
+ date: '2026-08-23',
41
+ time: '12:00',
42
+ });
43
+ });
44
+ it('reports the previous day when the zone is behind the instant', () => {
45
+ expect(wallTimeInZone(new Date('2026-08-24T02:00:00.000Z'), 'America/New_York')).toEqual({
46
+ date: '2026-08-23',
47
+ time: '22:00',
48
+ });
49
+ });
50
+ it('round-trips an overlap instant back to the wall time that produced it', () => {
51
+ // The second 01:30 of the fall-back overlap. Both instants share a wall
52
+ // time, so re-opening the modal must show 01:30 rather than a UTC spelling.
53
+ expect(wallTimeInZone(new Date('2026-11-01T06:30:00.000Z'), 'America/New_York')).toEqual({
54
+ date: '2026-11-01',
55
+ time: '01:30',
56
+ });
57
+ });
58
+ });
59
+ describe('joinWallTime', () => {
60
+ it('joins the two halves into the resolver grammar', () => {
61
+ expect(joinWallTime({ date: '2026-08-23', time: '12:00' })).toBe('2026-08-23T12:00');
62
+ });
63
+ it('drops the seconds a native time input may append', () => {
64
+ expect(joinWallTime({ date: '2026-08-23', time: '12:00:00' })).toBe('2026-08-23T12:00');
65
+ });
66
+ it('returns null while either half is still blank', () => {
67
+ expect(joinWallTime({ date: '', time: '12:00' })).toBeNull();
68
+ expect(joinWallTime({ date: '2026-08-23', time: '' })).toBeNull();
69
+ });
70
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { computeStatusTransitions } from './status-transitions';
10
+ const draft = { name: 'draft', label: 'Draft' };
11
+ const review = { name: 'review', label: 'Review' };
12
+ const published = { name: 'published', label: 'Published' };
13
+ const flow = [draft, review, published];
14
+ describe('computeStatusTransitions', () => {
15
+ it('falls back to nextStatus when there is no workflow or no current status', () => {
16
+ expect(computeStatusTransitions(undefined, undefined, published)).toEqual({
17
+ primaryStatus: published,
18
+ secondaryStatuses: [],
19
+ isTerminal: false,
20
+ });
21
+ expect(computeStatusTransitions('draft', [], published)).toEqual({
22
+ primaryStatus: published,
23
+ secondaryStatuses: [],
24
+ isTerminal: false,
25
+ });
26
+ expect(computeStatusTransitions(undefined, flow, published)).toEqual({
27
+ primaryStatus: published,
28
+ secondaryStatuses: [],
29
+ isTerminal: false,
30
+ });
31
+ });
32
+ it('exposes no transitions for a single-status workflow', () => {
33
+ expect(computeStatusTransitions('draft', [draft], undefined)).toEqual({
34
+ primaryStatus: undefined,
35
+ secondaryStatuses: [],
36
+ isTerminal: false,
37
+ });
38
+ });
39
+ it('falls back to nextStatus when the current status is not in the workflow', () => {
40
+ expect(computeStatusTransitions('archived', flow, review)).toEqual({
41
+ primaryStatus: review,
42
+ secondaryStatuses: [],
43
+ isTerminal: false,
44
+ });
45
+ });
46
+ it('at the first step: primary is the forward step, no back-steps', () => {
47
+ const result = computeStatusTransitions('draft', flow, review);
48
+ expect(result.primaryStatus).toBe(review);
49
+ expect(result.isTerminal).toBe(false);
50
+ // Forward step is the primary, so it is filtered out of the dropdown.
51
+ expect(result.secondaryStatuses).toEqual([]);
52
+ });
53
+ it('in the middle: primary is forward, dropdown offers reset-to-first', () => {
54
+ const result = computeStatusTransitions('review', flow, published);
55
+ expect(result.primaryStatus).toBe(published);
56
+ expect(result.isTerminal).toBe(false);
57
+ // Reset-to-first (draft) is surfaced; the forward step (published) is the
58
+ // primary and filtered out. The "back one step" is draft itself here, so
59
+ // it is not duplicated.
60
+ expect(result.secondaryStatuses).toEqual([draft]);
61
+ });
62
+ it('at the terminal step: primary is the current status, back-steps in dropdown', () => {
63
+ const result = computeStatusTransitions('published', flow, undefined);
64
+ expect(result.primaryStatus).toBe(published);
65
+ expect(result.isTerminal).toBe(true);
66
+ // Both reset-to-first (draft) and back-one-step (review) are offered.
67
+ expect(result.secondaryStatuses).toEqual([draft, review]);
68
+ });
69
+ it('terminal back-one-step is omitted when the previous is already the first', () => {
70
+ const twoStep = [draft, published];
71
+ const result = computeStatusTransitions('published', twoStep, undefined);
72
+ expect(result.isTerminal).toBe(true);
73
+ // Only reset-to-first (draft); no separate back-one-step since prev === first.
74
+ expect(result.secondaryStatuses).toEqual([draft]);
75
+ });
76
+ });