@byline/admin 4.14.1 → 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.
Files changed (34) hide show
  1. package/dist/forms/document-actions.d.ts +16 -1
  2. package/dist/forms/document-actions.js +43 -1
  3. package/dist/forms/form-renderer.d.ts +6 -1
  4. package/dist/forms/form-renderer.js +39 -36
  5. package/dist/forms/form-renderer.module.js +2 -0
  6. package/dist/forms/form-renderer_module.css +7 -1
  7. package/dist/forms/form-status-display.d.ts +10 -1
  8. package/dist/forms/form-status-display.js +4 -2
  9. package/dist/forms/scheduled-publication-control.d.ts +56 -0
  10. package/dist/forms/scheduled-publication-control.js +376 -0
  11. package/dist/forms/scheduled-publication-control.module.js +25 -0
  12. package/dist/forms/scheduled-publication-control_module.css +95 -0
  13. package/dist/forms/scheduled-publication-state.d.ts +83 -0
  14. package/dist/forms/scheduled-publication-state.js +41 -0
  15. package/dist/forms/scheduled-publication-state.test.node.d.ts +8 -0
  16. package/dist/forms/scheduled-publication-time.d.ts +57 -0
  17. package/dist/forms/scheduled-publication-time.js +113 -0
  18. package/dist/forms/scheduled-publication-time.test.node.d.ts +8 -0
  19. package/dist/react.d.ts +1 -0
  20. package/dist/react.js +1 -0
  21. package/package.json +11 -7
  22. package/src/forms/document-actions.tsx +73 -0
  23. package/src/forms/form-renderer.module.css +15 -0
  24. package/src/forms/form-renderer.tsx +59 -64
  25. package/src/forms/form-status-display.tsx +12 -0
  26. package/src/forms/path-widget.test.tsx +20 -8
  27. package/src/forms/scheduled-publication-control.module.css +149 -0
  28. package/src/forms/scheduled-publication-control.tsx +590 -0
  29. package/src/forms/scheduled-publication-datepicker.test.tsx +89 -0
  30. package/src/forms/scheduled-publication-state.test.node.ts +190 -0
  31. package/src/forms/scheduled-publication-state.ts +146 -0
  32. package/src/forms/scheduled-publication-time.test.node.ts +86 -0
  33. package/src/forms/scheduled-publication-time.ts +218 -0
  34. 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
+ })