@byline/admin 4.14.0 → 4.15.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/dist/forms/document-actions.d.ts +16 -1
- package/dist/forms/document-actions.js +43 -1
- package/dist/forms/form-renderer.d.ts +6 -1
- package/dist/forms/form-renderer.js +39 -36
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +7 -1
- package/dist/forms/form-status-display.d.ts +10 -1
- package/dist/forms/form-status-display.js +4 -2
- package/dist/forms/scheduled-publication-control.d.ts +56 -0
- package/dist/forms/scheduled-publication-control.js +376 -0
- package/dist/forms/scheduled-publication-control.module.js +25 -0
- package/dist/forms/scheduled-publication-control_module.css +95 -0
- package/dist/forms/scheduled-publication-state.d.ts +83 -0
- package/dist/forms/scheduled-publication-state.js +41 -0
- package/dist/forms/scheduled-publication-state.test.node.d.ts +8 -0
- package/dist/forms/scheduled-publication-time.d.ts +57 -0
- package/dist/forms/scheduled-publication-time.js +113 -0
- package/dist/forms/scheduled-publication-time.test.node.d.ts +8 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/package.json +11 -7
- package/src/forms/document-actions.tsx +73 -0
- package/src/forms/form-renderer.module.css +15 -0
- package/src/forms/form-renderer.tsx +59 -64
- package/src/forms/form-status-display.tsx +12 -0
- package/src/forms/path-widget.test.tsx +20 -8
- package/src/forms/scheduled-publication-control.module.css +149 -0
- package/src/forms/scheduled-publication-control.tsx +590 -0
- package/src/forms/scheduled-publication-datepicker.test.tsx +89 -0
- package/src/forms/scheduled-publication-state.test.node.ts +190 -0
- package/src/forms/scheduled-publication-state.ts +146 -0
- package/src/forms/scheduled-publication-time.test.node.ts +86 -0
- package/src/forms/scheduled-publication-time.ts +218 -0
- package/src/react.ts +3 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
5
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) Infonomic Company Limited
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Scheduled publication in the document editor.
|
|
13
|
+
*
|
|
14
|
+
* The feature is split across three surfaces rather than one block, because
|
|
15
|
+
* the states differ in how much of the editor's attention they deserve:
|
|
16
|
+
*
|
|
17
|
+
* - The **actions** live in the document-actions menu, next to the other
|
|
18
|
+
* document-level operations, so the status bar's primary Save / Publish
|
|
19
|
+
* controls keep their weight.
|
|
20
|
+
* - An **armed** schedule renders as one more metadata cell in the status
|
|
21
|
+
* bar, alongside Status and Last modified. It is a fact about the
|
|
22
|
+
* document, presented at the scale of the other facts.
|
|
23
|
+
* - **Needs re-confirmation**, **overdue** and **failing** schedules
|
|
24
|
+
* escalate to a non-dismissible Alert below the status bar, carrying
|
|
25
|
+
* their own actions. The `needs_reconfirm` notice in particular has to
|
|
26
|
+
* outlive the toast that announced it, which is exactly what an Alert
|
|
27
|
+
* with `close={false}` does.
|
|
28
|
+
*
|
|
29
|
+
* `useScheduledPublication` owns the state and the modal so a single parent
|
|
30
|
+
* can place the three surfaces independently.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
34
|
+
|
|
35
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
36
|
+
import {
|
|
37
|
+
Alert,
|
|
38
|
+
Button,
|
|
39
|
+
CloseIcon,
|
|
40
|
+
DatePicker,
|
|
41
|
+
IconButton,
|
|
42
|
+
Label,
|
|
43
|
+
Modal,
|
|
44
|
+
Select,
|
|
45
|
+
} from '@byline/ui/react'
|
|
46
|
+
import cx from 'clsx'
|
|
47
|
+
|
|
48
|
+
import styles from './scheduled-publication-control.module.css'
|
|
49
|
+
import { deriveScheduledPublicationState } from './scheduled-publication-state.js'
|
|
50
|
+
import {
|
|
51
|
+
joinWallTime,
|
|
52
|
+
resolveScheduledPublicationWallTime,
|
|
53
|
+
wallTimeInZone,
|
|
54
|
+
} from './scheduled-publication-time.js'
|
|
55
|
+
import type {
|
|
56
|
+
ScheduledPublicationCapabilities,
|
|
57
|
+
ScheduledPublicationState,
|
|
58
|
+
} from './scheduled-publication-state.js'
|
|
59
|
+
import type {
|
|
60
|
+
ScheduledPublicationInstantChoice,
|
|
61
|
+
ScheduledPublicationWallTime,
|
|
62
|
+
} from './scheduled-publication-time.js'
|
|
63
|
+
|
|
64
|
+
export type { ScheduledPublicationInfo } from './scheduled-publication-state.js'
|
|
65
|
+
|
|
66
|
+
import type { ScheduledPublicationInfo } from './scheduled-publication-state.js'
|
|
67
|
+
|
|
68
|
+
export interface SchedulePublicationInput {
|
|
69
|
+
publishAt: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** How often the armed → overdue boundary is re-checked while the editor sits open. */
|
|
73
|
+
const DUE_POLL_INTERVAL_MS = 30_000
|
|
74
|
+
|
|
75
|
+
/** Default offset for a fresh schedule — far enough out to be reviewable. */
|
|
76
|
+
const DEFAULT_LEAD_MS = 15 * 60_000
|
|
77
|
+
|
|
78
|
+
function browserTimeZone(): string {
|
|
79
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render a wall time for display without turning it into an instant.
|
|
84
|
+
*
|
|
85
|
+
* The calendar day is formatted through a UTC-noon stand-in, which is safe
|
|
86
|
+
* because only the day's name and number are taken from it; the clock reading
|
|
87
|
+
* is appended verbatim, since the whole point is to show the reading the editor
|
|
88
|
+
* chose rather than one a `Date` would have normalized it to.
|
|
89
|
+
*/
|
|
90
|
+
function formatWallTime(wall: ScheduledPublicationWallTime, locale: string): string {
|
|
91
|
+
const [year, month, day] = wall.date.split('-').map(Number)
|
|
92
|
+
if (year == null || month == null || day == null) {
|
|
93
|
+
return `${wall.date} ${wall.time}`
|
|
94
|
+
}
|
|
95
|
+
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
|
|
96
|
+
return `${wall.date} ${wall.time}`
|
|
97
|
+
}
|
|
98
|
+
const dayLabel = new Intl.DateTimeFormat(locale, {
|
|
99
|
+
dateStyle: 'medium',
|
|
100
|
+
timeZone: 'UTC',
|
|
101
|
+
}).format(new Date(Date.UTC(year, month - 1, day, 12)))
|
|
102
|
+
return `${dayLabel}, ${wall.time}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function seedScheduleInstant(schedule: ScheduledPublicationInfo | null): Date {
|
|
106
|
+
if (schedule != null) return new Date(schedule.publishAt)
|
|
107
|
+
const seed = new Date(Date.now() + DEFAULT_LEAD_MS)
|
|
108
|
+
seed.setSeconds(0, 0)
|
|
109
|
+
return seed
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface UseScheduledPublicationArgs {
|
|
113
|
+
schedule: ScheduledPublicationInfo | null
|
|
114
|
+
onSchedule?: (input: SchedulePublicationInput) => Promise<void>
|
|
115
|
+
onConfirm?: () => Promise<void>
|
|
116
|
+
onCancel?: () => Promise<void>
|
|
117
|
+
hasUnsavedChanges: boolean
|
|
118
|
+
onUnsavedChanges: () => void
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface UseScheduledPublicationReturn {
|
|
122
|
+
state: ScheduledPublicationState
|
|
123
|
+
timeZone: string
|
|
124
|
+
busy: boolean
|
|
125
|
+
/** True when any surface has something to render for this document. */
|
|
126
|
+
isActive: boolean
|
|
127
|
+
openSchedule: () => void
|
|
128
|
+
confirm: () => Promise<void>
|
|
129
|
+
cancel: () => Promise<void>
|
|
130
|
+
/** The schedule / reschedule modal. Render once, anywhere in the form. */
|
|
131
|
+
modal: React.ReactNode
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function useScheduledPublication({
|
|
135
|
+
schedule,
|
|
136
|
+
onSchedule,
|
|
137
|
+
onConfirm,
|
|
138
|
+
onCancel,
|
|
139
|
+
hasUnsavedChanges,
|
|
140
|
+
onUnsavedChanges,
|
|
141
|
+
}: UseScheduledPublicationArgs): UseScheduledPublicationReturn {
|
|
142
|
+
const timeZone = useMemo(browserTimeZone, [])
|
|
143
|
+
const [now, setNow] = useState(() => Date.now())
|
|
144
|
+
const [showSchedule, setShowSchedule] = useState(false)
|
|
145
|
+
const [busy, setBusy] = useState(false)
|
|
146
|
+
|
|
147
|
+
// Only tick while something is actually scheduled — an editor with no
|
|
148
|
+
// schedule has no boundary to cross, and a bare form should not re-render
|
|
149
|
+
// on a timer.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (schedule == null) return
|
|
152
|
+
const timer = setInterval(() => setNow(Date.now()), DUE_POLL_INTERVAL_MS)
|
|
153
|
+
return () => clearInterval(timer)
|
|
154
|
+
}, [schedule])
|
|
155
|
+
|
|
156
|
+
const capabilities: ScheduledPublicationCapabilities = useMemo(
|
|
157
|
+
() => ({
|
|
158
|
+
canSchedule: onSchedule != null,
|
|
159
|
+
canConfirm: onConfirm != null,
|
|
160
|
+
canCancel: onCancel != null,
|
|
161
|
+
}),
|
|
162
|
+
[onSchedule, onConfirm, onCancel]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const state = useMemo(
|
|
166
|
+
() => deriveScheduledPublicationState(schedule, capabilities, now),
|
|
167
|
+
[schedule, capabilities, now]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// Scheduling authorizes a specific reviewed version, so an unsaved edit has
|
|
171
|
+
// to be resolved before any of these operations can name a version. Cancel
|
|
172
|
+
// is exempt: withdrawing a schedule says nothing about content.
|
|
173
|
+
const openSchedule = useCallback(() => {
|
|
174
|
+
if (hasUnsavedChanges) {
|
|
175
|
+
onUnsavedChanges()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
setShowSchedule(true)
|
|
179
|
+
}, [hasUnsavedChanges, onUnsavedChanges])
|
|
180
|
+
|
|
181
|
+
const confirm = useCallback(async () => {
|
|
182
|
+
if (onConfirm == null) return
|
|
183
|
+
if (hasUnsavedChanges) {
|
|
184
|
+
onUnsavedChanges()
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
setBusy(true)
|
|
188
|
+
try {
|
|
189
|
+
await onConfirm()
|
|
190
|
+
} finally {
|
|
191
|
+
setBusy(false)
|
|
192
|
+
}
|
|
193
|
+
}, [onConfirm, hasUnsavedChanges, onUnsavedChanges])
|
|
194
|
+
|
|
195
|
+
const cancel = useCallback(async () => {
|
|
196
|
+
if (onCancel == null) return
|
|
197
|
+
setBusy(true)
|
|
198
|
+
try {
|
|
199
|
+
await onCancel()
|
|
200
|
+
} finally {
|
|
201
|
+
setBusy(false)
|
|
202
|
+
}
|
|
203
|
+
}, [onCancel])
|
|
204
|
+
|
|
205
|
+
const modal = showSchedule ? (
|
|
206
|
+
<ScheduleModal
|
|
207
|
+
schedule={schedule}
|
|
208
|
+
timeZone={timeZone}
|
|
209
|
+
onSubmit={async (input) => {
|
|
210
|
+
if (onSchedule == null) return
|
|
211
|
+
setBusy(true)
|
|
212
|
+
try {
|
|
213
|
+
await onSchedule(input)
|
|
214
|
+
setShowSchedule(false)
|
|
215
|
+
} finally {
|
|
216
|
+
setBusy(false)
|
|
217
|
+
}
|
|
218
|
+
}}
|
|
219
|
+
onDismiss={() => setShowSchedule(false)}
|
|
220
|
+
busy={busy}
|
|
221
|
+
/>
|
|
222
|
+
) : null
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
state,
|
|
226
|
+
timeZone,
|
|
227
|
+
busy,
|
|
228
|
+
isActive: state.kind !== 'none' || state.actions.schedule,
|
|
229
|
+
openSchedule,
|
|
230
|
+
confirm,
|
|
231
|
+
cancel,
|
|
232
|
+
modal,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Status-bar cell — the quiet, armed presentation
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
function useInstantFormatter(timeZone: string) {
|
|
241
|
+
const { locale } = useTranslation('byline-admin')
|
|
242
|
+
return useMemo(
|
|
243
|
+
() =>
|
|
244
|
+
new Intl.DateTimeFormat(locale, {
|
|
245
|
+
dateStyle: 'medium',
|
|
246
|
+
timeStyle: 'short',
|
|
247
|
+
timeZone,
|
|
248
|
+
}),
|
|
249
|
+
[locale, timeZone]
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One metadata cell for the form's status bar, matching the Status /
|
|
255
|
+
* Last modified / Created cells in scale, sitting immediately after the status
|
|
256
|
+
* cell — an armed schedule says where the document is headed, which continues
|
|
257
|
+
* what Status says about it rather than belonging with the timestamps.
|
|
258
|
+
*
|
|
259
|
+
* Written as a single phrase in the success colour rather than the label/value
|
|
260
|
+
* pair its neighbours use: it is the one cell reporting something pending
|
|
261
|
+
* rather than something already true, and it should read that way at a glance.
|
|
262
|
+
* Only an armed schedule appears here; every other state is carried by the
|
|
263
|
+
* notice instead, so the two never say the same thing twice.
|
|
264
|
+
*/
|
|
265
|
+
export function ScheduledPublicationCell({
|
|
266
|
+
state,
|
|
267
|
+
timeZone,
|
|
268
|
+
}: {
|
|
269
|
+
state: ScheduledPublicationState
|
|
270
|
+
timeZone: string
|
|
271
|
+
}) {
|
|
272
|
+
const { t } = useTranslation('byline-admin')
|
|
273
|
+
const format = useInstantFormatter(timeZone)
|
|
274
|
+
|
|
275
|
+
if (state.kind !== 'armed' || state.publishAt == null) return null
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<time
|
|
279
|
+
className={cx('byline-scheduled-publication-cell', styles.cell)}
|
|
280
|
+
dateTime={state.publishAt.toISOString()}
|
|
281
|
+
>
|
|
282
|
+
{t('scheduledPublication.status.cellText', {
|
|
283
|
+
dateTime: `${format.format(state.publishAt)} (${timeZone})`,
|
|
284
|
+
})}
|
|
285
|
+
</time>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Notice — the escalated presentation for every exceptional state
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The durable notice for a suspended, overdue or failing schedule. Not
|
|
295
|
+
* dismissible: `needs_reconfirm` has to stay on screen until an editor acts on
|
|
296
|
+
* it, long after the toast that announced the suspension has gone.
|
|
297
|
+
*/
|
|
298
|
+
export function ScheduledPublicationNotice({
|
|
299
|
+
state,
|
|
300
|
+
timeZone,
|
|
301
|
+
busy,
|
|
302
|
+
onConfirm,
|
|
303
|
+
onReschedule,
|
|
304
|
+
onCancel,
|
|
305
|
+
}: {
|
|
306
|
+
state: ScheduledPublicationState
|
|
307
|
+
timeZone: string
|
|
308
|
+
busy: boolean
|
|
309
|
+
onConfirm: () => void
|
|
310
|
+
onReschedule: () => void
|
|
311
|
+
onCancel: () => void
|
|
312
|
+
}) {
|
|
313
|
+
const { t } = useTranslation('byline-admin')
|
|
314
|
+
const format = useInstantFormatter(timeZone)
|
|
315
|
+
|
|
316
|
+
if (!state.isExceptional || state.publishAt == null) return null
|
|
317
|
+
|
|
318
|
+
const instant = `${format.format(state.publishAt)} (${timeZone})`
|
|
319
|
+
const title =
|
|
320
|
+
state.kind === 'needs_reconfirm'
|
|
321
|
+
? t('scheduledPublication.status.needsReconfirm')
|
|
322
|
+
: t('scheduledPublication.status.overdue')
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
// A landmark rather than a live region: the toast already announces the
|
|
326
|
+
// change assertively when it happens, and announcing the same thing twice
|
|
327
|
+
// helps nobody. What the notice needs is to stay *findable* afterwards,
|
|
328
|
+
// which a named region gives a screen-reader user.
|
|
329
|
+
<div role="region" aria-label={title}>
|
|
330
|
+
<Alert
|
|
331
|
+
className={cx('byline-scheduled-publication-notice', styles.notice)}
|
|
332
|
+
intent={state.tone === 'danger' ? 'danger' : 'warning'}
|
|
333
|
+
icon={true}
|
|
334
|
+
close={false}
|
|
335
|
+
title={title}
|
|
336
|
+
>
|
|
337
|
+
<p className={styles['notice-body']}>
|
|
338
|
+
{state.kind === 'needs_reconfirm'
|
|
339
|
+
? t('scheduledPublication.status.contentChanged')
|
|
340
|
+
: t('scheduledPublication.status.overdueBody')}
|
|
341
|
+
</p>
|
|
342
|
+
<p className={styles['notice-instant']}>
|
|
343
|
+
{t('scheduledPublication.status.authorizedFor', { dateTime: instant })}
|
|
344
|
+
{state.kind === 'needs_reconfirm' && state.isPastDue && (
|
|
345
|
+
<> {t('scheduledPublication.status.pastDueNote')}</>
|
|
346
|
+
)}
|
|
347
|
+
</p>
|
|
348
|
+
{state.attemptCount > 0 && (
|
|
349
|
+
<p className={styles['notice-attempts']}>
|
|
350
|
+
{t('scheduledPublication.status.attempts', { count: state.attemptCount })}
|
|
351
|
+
</p>
|
|
352
|
+
)}
|
|
353
|
+
{state.lastError != null && <p className={styles['notice-error']}>{state.lastError}</p>}
|
|
354
|
+
<div
|
|
355
|
+
className={cx('byline-scheduled-publication-notice-actions', styles['notice-actions'])}
|
|
356
|
+
>
|
|
357
|
+
{state.actions.confirm && (
|
|
358
|
+
<Button size="sm" type="button" intent="success" disabled={busy} onClick={onConfirm}>
|
|
359
|
+
{t('scheduledPublication.actions.confirm')}
|
|
360
|
+
</Button>
|
|
361
|
+
)}
|
|
362
|
+
{state.actions.reschedule && (
|
|
363
|
+
<Button size="sm" type="button" intent="info" disabled={busy} onClick={onReschedule}>
|
|
364
|
+
{t('scheduledPublication.actions.reschedule')}
|
|
365
|
+
</Button>
|
|
366
|
+
)}
|
|
367
|
+
{state.actions.cancel && (
|
|
368
|
+
<Button size="sm" type="button" variant="text" disabled={busy} onClick={onCancel}>
|
|
369
|
+
{t('scheduledPublication.actions.cancel')}
|
|
370
|
+
</Button>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
</Alert>
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Schedule / reschedule modal
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The picker's wall time is read, not its `Date`.
|
|
384
|
+
*
|
|
385
|
+
* `DatePicker` reports both: `onDateChange` gives an instant, and
|
|
386
|
+
* `onWallTimeChange` gives the day and clock reading the editor actually
|
|
387
|
+
* selected. Only the second one can express "02:30 on a day when 02:30 does not
|
|
388
|
+
* exist" — the instant has already been normalized to 03:30 by then, and an
|
|
389
|
+
* ambiguous 01:30 has already been resolved to the earlier of its two instants
|
|
390
|
+
* without asking. So the wall time goes to
|
|
391
|
+
* `resolveScheduledPublicationWallTime`, which is the only thing here allowed
|
|
392
|
+
* to turn a wall time into an instant, and which refuses or asks as required.
|
|
393
|
+
*/
|
|
394
|
+
function ScheduleModal({
|
|
395
|
+
schedule,
|
|
396
|
+
timeZone,
|
|
397
|
+
onSubmit,
|
|
398
|
+
onDismiss,
|
|
399
|
+
busy,
|
|
400
|
+
}: {
|
|
401
|
+
schedule: ScheduledPublicationInfo | null
|
|
402
|
+
timeZone: string
|
|
403
|
+
onSubmit: (input: SchedulePublicationInput) => Promise<void>
|
|
404
|
+
onDismiss: () => void
|
|
405
|
+
busy: boolean
|
|
406
|
+
}) {
|
|
407
|
+
const { t, locale } = useTranslation('byline-admin')
|
|
408
|
+
// The instant the picker opens on. Always a real instant — either the one
|
|
409
|
+
// already authorized, or a lead time from now — so seeding it never has to
|
|
410
|
+
// construct a `Date` from a wall time.
|
|
411
|
+
const [seedInstant] = useState<Date>(() => seedScheduleInstant(schedule))
|
|
412
|
+
// Held, not recomputed per render, so the calendar's disabled range does not
|
|
413
|
+
// shift under the editor while the modal is open.
|
|
414
|
+
const [today] = useState<Date>(() => new Date())
|
|
415
|
+
const [wall, setWall] = useState<ScheduledPublicationWallTime>(() =>
|
|
416
|
+
wallTimeInZone(seedInstant, timeZone)
|
|
417
|
+
)
|
|
418
|
+
const [instantChoices, setInstantChoices] = useState<ScheduledPublicationInstantChoice[]>([])
|
|
419
|
+
const [selectedInstant, setSelectedInstant] = useState('')
|
|
420
|
+
const [validationError, setValidationError] = useState<string | null>(null)
|
|
421
|
+
|
|
422
|
+
const resetResolution = () => {
|
|
423
|
+
setInstantChoices([])
|
|
424
|
+
setSelectedInstant('')
|
|
425
|
+
setValidationError(null)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const submit = async () => {
|
|
429
|
+
const value = joinWallTime(wall)
|
|
430
|
+
if (value == null) {
|
|
431
|
+
setValidationError(t('scheduledPublication.form.invalid'))
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const resolution = resolveScheduledPublicationWallTime(value, timeZone)
|
|
436
|
+
if (resolution.status === 'invalid') {
|
|
437
|
+
setValidationError(t('scheduledPublication.form.invalid'))
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
// Name the wall time in both daylight-saving messages. The picker's own
|
|
441
|
+
// field cannot be trusted to show it: having normalized the selection, it
|
|
442
|
+
// displays 03:30 for a 02:30 that does not exist, so a message that just
|
|
443
|
+
// said "that time" would appear to be rejecting the time on screen.
|
|
444
|
+
const offending = formatWallTime(wall, locale)
|
|
445
|
+
|
|
446
|
+
if (resolution.status === 'nonexistent') {
|
|
447
|
+
setValidationError(t('scheduledPublication.form.nonexistent', { wallTime: offending }))
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (
|
|
451
|
+
resolution.choices.length > 1 &&
|
|
452
|
+
!resolution.choices.some((c) => c.iso === selectedInstant)
|
|
453
|
+
) {
|
|
454
|
+
setInstantChoices(resolution.choices)
|
|
455
|
+
setValidationError(t('scheduledPublication.form.ambiguous', { wallTime: offending }))
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const publishAtIso = selectedInstant || resolution.choices[0]?.iso
|
|
460
|
+
if (publishAtIso == null) return
|
|
461
|
+
|
|
462
|
+
// Checked on the resolved instant rather than the wall time, because an
|
|
463
|
+
// ambiguous overlap has two instants an hour apart and only one of them may
|
|
464
|
+
// still be ahead.
|
|
465
|
+
//
|
|
466
|
+
// This is an affordance, not the guarantee. The server compares against
|
|
467
|
+
// database time and refuses `publish_at_not_future` regardless of what the
|
|
468
|
+
// browser's clock believes — catching it here just replaces a raw server
|
|
469
|
+
// error in a danger toast with a message next to the field that caused it.
|
|
470
|
+
if (Date.parse(publishAtIso) <= Date.now()) {
|
|
471
|
+
setValidationError(t('scheduledPublication.form.notFuture', { wallTime: offending }))
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
await onSubmit({ publishAt: publishAtIso })
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const title =
|
|
479
|
+
schedule == null
|
|
480
|
+
? t('scheduledPublication.form.scheduleTitle')
|
|
481
|
+
: t('scheduledPublication.form.rescheduleTitle')
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<Modal
|
|
485
|
+
isOpen
|
|
486
|
+
closeOnOverlayClick={!busy}
|
|
487
|
+
onDismiss={() => {
|
|
488
|
+
if (!busy) onDismiss()
|
|
489
|
+
}}
|
|
490
|
+
>
|
|
491
|
+
<Modal.Container style={{ maxWidth: '560px' }}>
|
|
492
|
+
<Modal.Header className={styles.modalHead}>
|
|
493
|
+
<h3 className={styles.modalTitle}>{title}</h3>
|
|
494
|
+
<IconButton
|
|
495
|
+
aria-label={t('common.actions.close')}
|
|
496
|
+
size="xs"
|
|
497
|
+
disabled={busy}
|
|
498
|
+
onClick={onDismiss}
|
|
499
|
+
>
|
|
500
|
+
<CloseIcon width="16px" height="16px" svgClassName="white-icon" />
|
|
501
|
+
</IconButton>
|
|
502
|
+
</Modal.Header>
|
|
503
|
+
<Modal.Content>
|
|
504
|
+
<div className={styles.field}>
|
|
505
|
+
<DatePicker
|
|
506
|
+
id="scheduled-publication-at"
|
|
507
|
+
name="scheduled-publication-at"
|
|
508
|
+
label={t('scheduledPublication.form.dateTime')}
|
|
509
|
+
mode="datetime"
|
|
510
|
+
inputSize="sm"
|
|
511
|
+
// Today is the earliest selectable day. Deliberately day-grained:
|
|
512
|
+
// a later slot today is a perfectly good schedule, so the day
|
|
513
|
+
// stays open and the submit-time check below is what rejects an
|
|
514
|
+
// hour that has already gone.
|
|
515
|
+
minDate={today}
|
|
516
|
+
yearsInPast={0}
|
|
517
|
+
yearsInFuture={5}
|
|
518
|
+
initialValue={seedInstant}
|
|
519
|
+
onWallTimeChange={(value) => {
|
|
520
|
+
if (value == null) return
|
|
521
|
+
setWall(value)
|
|
522
|
+
resetResolution()
|
|
523
|
+
}}
|
|
524
|
+
/>
|
|
525
|
+
</div>
|
|
526
|
+
<p className={styles.help}>{t('scheduledPublication.form.timeZone', { timeZone })}</p>
|
|
527
|
+
{instantChoices.length > 1 && (
|
|
528
|
+
<div className={styles.choice}>
|
|
529
|
+
<Label
|
|
530
|
+
id="scheduled-publication-offset-label"
|
|
531
|
+
htmlFor="scheduled-publication-offset"
|
|
532
|
+
label={t('scheduledPublication.form.offset')}
|
|
533
|
+
/>
|
|
534
|
+
<Select<string>
|
|
535
|
+
id="scheduled-publication-offset"
|
|
536
|
+
name="scheduled-publication-offset"
|
|
537
|
+
ariaLabel={t('scheduledPublication.form.offset')}
|
|
538
|
+
containerClassName={styles['choice-select']}
|
|
539
|
+
placeholder={t('scheduledPublication.form.offsetPlaceholder')}
|
|
540
|
+
size="sm"
|
|
541
|
+
value={selectedInstant}
|
|
542
|
+
// The resolver returns the overlap's instants in chronological
|
|
543
|
+
// order, so the two can be named by when they happen. A bare
|
|
544
|
+
// "UTC-04:00" asks the editor to know which side of the
|
|
545
|
+
// transition that is; "Earlier" and "Later" do not.
|
|
546
|
+
items={instantChoices.map((choice, index) => ({
|
|
547
|
+
value: choice.iso,
|
|
548
|
+
label: t(
|
|
549
|
+
index === 0
|
|
550
|
+
? 'scheduledPublication.form.offsetEarlier'
|
|
551
|
+
: 'scheduledPublication.form.offsetLater',
|
|
552
|
+
{ offset: choice.offsetLabel }
|
|
553
|
+
),
|
|
554
|
+
}))}
|
|
555
|
+
onValueChange={(value) => {
|
|
556
|
+
setSelectedInstant(value ?? '')
|
|
557
|
+
setValidationError(null)
|
|
558
|
+
}}
|
|
559
|
+
disabled={busy}
|
|
560
|
+
/>
|
|
561
|
+
</div>
|
|
562
|
+
)}
|
|
563
|
+
{validationError != null && (
|
|
564
|
+
<p className={styles.validation} role="alert">
|
|
565
|
+
{validationError}
|
|
566
|
+
</p>
|
|
567
|
+
)}
|
|
568
|
+
<p className={styles.warningText}>{t('scheduledPublication.form.editWarning')}</p>
|
|
569
|
+
</Modal.Content>
|
|
570
|
+
<Modal.Actions>
|
|
571
|
+
<Button size="sm" intent="noeffect" disabled={busy} onClick={onDismiss}>
|
|
572
|
+
{t('common.actions.cancel')}
|
|
573
|
+
</Button>
|
|
574
|
+
<Button
|
|
575
|
+
size="sm"
|
|
576
|
+
intent="primary"
|
|
577
|
+
disabled={busy || wall.date.length === 0 || wall.time.length === 0}
|
|
578
|
+
onClick={submit}
|
|
579
|
+
>
|
|
580
|
+
{busy
|
|
581
|
+
? t('scheduledPublication.form.saving')
|
|
582
|
+
: schedule == null
|
|
583
|
+
? t('scheduledPublication.actions.schedule')
|
|
584
|
+
: t('scheduledPublication.actions.reschedule')}
|
|
585
|
+
</Button>
|
|
586
|
+
</Modal.Actions>
|
|
587
|
+
</Modal.Container>
|
|
588
|
+
</Modal>
|
|
589
|
+
)
|
|
590
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
|
|
9
|
+
import { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import { DatePicker, type DatePickerWallTime } from '@byline/ui/react'
|
|
12
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
+
|
|
15
|
+
import { joinWallTime, resolveScheduledPublicationWallTime } from './scheduled-publication-time.js'
|
|
16
|
+
|
|
17
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
18
|
+
|
|
19
|
+
describe('the DatePicker wall time survives to a scheduled-publication instant', () => {
|
|
20
|
+
let container: HTMLDivElement
|
|
21
|
+
let root: Root
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
container = document.createElement('div')
|
|
25
|
+
document.body.appendChild(container)
|
|
26
|
+
root = createRoot(container)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
act(() => root.unmount())
|
|
31
|
+
container.remove()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
async function selectTime(date: Date, time: string): Promise<DatePickerWallTime> {
|
|
35
|
+
const onWallTimeChange = vi.fn<(wall: DatePickerWallTime | null) => void>()
|
|
36
|
+
|
|
37
|
+
await act(async () => {
|
|
38
|
+
root.render(
|
|
39
|
+
<DatePicker
|
|
40
|
+
id="publication-at"
|
|
41
|
+
name="publication-at"
|
|
42
|
+
mode="datetime"
|
|
43
|
+
initialValue={date}
|
|
44
|
+
onWallTimeChange={onWallTimeChange}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const input = container.querySelector('#publication-at')
|
|
50
|
+
expect(input).toBeInstanceOf(HTMLInputElement)
|
|
51
|
+
await act(async () => {
|
|
52
|
+
input?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const timeButton = [...document.querySelectorAll('button')].find(
|
|
56
|
+
(button) => button.textContent?.trim() === time
|
|
57
|
+
)
|
|
58
|
+
expect(timeButton).toBeInstanceOf(HTMLButtonElement)
|
|
59
|
+
await act(async () => {
|
|
60
|
+
timeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const wall = onWallTimeChange.mock.calls.at(-1)?.[0]
|
|
64
|
+
expect(wall).not.toBeNull()
|
|
65
|
+
expect(wall).toBeDefined()
|
|
66
|
+
return wall as DatePickerWallTime
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it('preserves a spring-forward wall time so the resolver can reject the gap', async () => {
|
|
70
|
+
const wall = await selectTime(new Date(2026, 2, 8, 1, 0), '02:30')
|
|
71
|
+
|
|
72
|
+
expect(wall).toEqual({ date: '2026-03-08', time: '02:30' })
|
|
73
|
+
expect(
|
|
74
|
+
resolveScheduledPublicationWallTime(joinWallTime(wall) as string, 'America/New_York')
|
|
75
|
+
).toEqual({ status: 'nonexistent' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('preserves an autumn-overlap wall time so the resolver can offer both instants', async () => {
|
|
79
|
+
const wall = await selectTime(new Date(2026, 10, 1, 0, 0), '01:30')
|
|
80
|
+
const resolution = resolveScheduledPublicationWallTime(
|
|
81
|
+
joinWallTime(wall) as string,
|
|
82
|
+
'America/New_York'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
expect(wall).toEqual({ date: '2026-11-01', time: '01:30' })
|
|
86
|
+
expect(resolution.status).toBe('valid')
|
|
87
|
+
expect(resolution.status === 'valid' && resolution.choices).toHaveLength(2)
|
|
88
|
+
})
|
|
89
|
+
})
|