@byline/admin 5.1.2 → 5.1.3
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/dist/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- package/package.json +7 -7
|
@@ -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
|
+
});
|