@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,113 @@
1
+ function parseWallParts(value) {
2
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
3
+ if (null == match) return null;
4
+ const [, year, month, day, hour, minute] = match;
5
+ const parts = {
6
+ year: Number(year),
7
+ month: Number(month),
8
+ day: Number(day),
9
+ hour: Number(hour),
10
+ minute: Number(minute)
11
+ };
12
+ if (parts.year < 1 || parts.month < 1 || parts.month > 12 || parts.day < 1 || parts.day > 31 || parts.hour > 23 || parts.minute > 59) return null;
13
+ const calendar = new Date(0);
14
+ calendar.setUTCFullYear(parts.year, parts.month - 1, parts.day);
15
+ calendar.setUTCHours(parts.hour, parts.minute, 0, 0);
16
+ if (calendar.getUTCFullYear() !== parts.year || calendar.getUTCMonth() !== parts.month - 1 || calendar.getUTCDate() !== parts.day) return null;
17
+ return parts;
18
+ }
19
+ function formatter(timeZone) {
20
+ return new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
21
+ timeZone,
22
+ year: 'numeric',
23
+ month: '2-digit',
24
+ day: '2-digit',
25
+ hour: '2-digit',
26
+ minute: '2-digit',
27
+ second: '2-digit',
28
+ hourCycle: 'h23'
29
+ });
30
+ }
31
+ function zonedParts(format, epochMs) {
32
+ const values = Object.fromEntries(format.formatToParts(epochMs).filter((part)=>'literal' !== part.type).map((part)=>[
33
+ part.type,
34
+ Number(part.value)
35
+ ]));
36
+ return {
37
+ year: values.year ?? 0 / 0,
38
+ month: values.month ?? 0 / 0,
39
+ day: values.day ?? 0 / 0,
40
+ hour: values.hour ?? 0 / 0,
41
+ minute: values.minute ?? 0 / 0,
42
+ second: values.second ?? 0 / 0
43
+ };
44
+ }
45
+ function sameWallTime(actual, expected) {
46
+ return actual.year === expected.year && actual.month === expected.month && actual.day === expected.day && actual.hour === expected.hour && actual.minute === expected.minute;
47
+ }
48
+ function offsetMinutes(format, epochMs) {
49
+ const parts = zonedParts(format, epochMs);
50
+ const localAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
51
+ return Math.round((localAsUtc - epochMs) / 60000);
52
+ }
53
+ function formatOffset(minutes) {
54
+ const sign = minutes < 0 ? '-' : '+';
55
+ const absolute = Math.abs(minutes);
56
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
57
+ const remainingMinutes = String(absolute % 60).padStart(2, '0');
58
+ return `UTC${sign}${hours}:${remainingMinutes}`;
59
+ }
60
+ function resolveScheduledPublicationWallTime(value, timeZone) {
61
+ const wall = parseWallParts(value);
62
+ if (null == wall) return {
63
+ status: 'invalid'
64
+ };
65
+ let format;
66
+ try {
67
+ format = formatter(timeZone);
68
+ } catch {
69
+ return {
70
+ status: 'invalid'
71
+ };
72
+ }
73
+ const wallAsUtc = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute);
74
+ const offsets = new Set();
75
+ for(let hours = -36; hours <= 36; hours += 6)offsets.add(offsetMinutes(format, wallAsUtc + 3600000 * hours));
76
+ const choices = Array.from(offsets).map((offset)=>({
77
+ epochMs: wallAsUtc - 60000 * offset,
78
+ offset
79
+ })).filter(({ epochMs })=>sameWallTime(zonedParts(format, epochMs), wall)).sort((left, right)=>left.epochMs - right.epochMs).map(({ epochMs, offset })=>({
80
+ iso: new Date(epochMs).toISOString(),
81
+ offsetLabel: formatOffset(offset)
82
+ }));
83
+ if (0 === choices.length) return {
84
+ status: 'nonexistent'
85
+ };
86
+ return {
87
+ status: 'valid',
88
+ choices
89
+ };
90
+ }
91
+ function wallTimeInZone(instant, timeZone) {
92
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
93
+ timeZone,
94
+ year: 'numeric',
95
+ month: '2-digit',
96
+ day: '2-digit',
97
+ hour: '2-digit',
98
+ minute: '2-digit',
99
+ hourCycle: 'h23'
100
+ }).formatToParts(instant).filter((part)=>'literal' !== part.type).map((part)=>[
101
+ part.type,
102
+ part.value
103
+ ]));
104
+ return {
105
+ date: `${parts.year}-${parts.month}-${parts.day}`,
106
+ time: `${parts.hour}:${parts.minute}`
107
+ };
108
+ }
109
+ function joinWallTime(wall) {
110
+ if (0 === wall.date.length || 0 === wall.time.length) return null;
111
+ return `${wall.date}T${wall.time.slice(0, 5)}`;
112
+ }
113
+ export { joinWallTime, resolveScheduledPublicationWallTime, wallTimeInZone };
@@ -0,0 +1,8 @@
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
+ export {};
package/dist/react.d.ts CHANGED
@@ -59,6 +59,7 @@ export * from './forms/form-context.js';
59
59
  export * from './forms/form-renderer.js';
60
60
  export * from './forms/navigation-guard.js';
61
61
  export * from './forms/path-widget.js';
62
+ export * from './forms/scheduled-publication-state.js';
62
63
  export * from './lib/translate-validation-error.js';
63
64
  export * from './presentation/group.js';
64
65
  export * from './presentation/row.js';
package/dist/react.js CHANGED
@@ -30,6 +30,7 @@ export * from "./forms/form-context.js";
30
30
  export * from "./forms/form-renderer.js";
31
31
  export * from "./forms/navigation-guard.js";
32
32
  export * from "./forms/path-widget.js";
33
+ export * from "./forms/scheduled-publication-state.js";
33
34
  export * from "./lib/translate-validation-error.js";
34
35
  export * from "./presentation/group.js";
35
36
  export * from "./presentation/row.js";
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/admin",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.14.1",
5
+ "version": "4.15.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -173,10 +173,10 @@
173
173
  "react-diff-viewer-continued": "^4.4.0",
174
174
  "uuid": "^14.0.2",
175
175
  "zod": "^4.4.3",
176
- "@byline/i18n": "4.14.1",
177
- "@byline/ui": "4.14.1",
178
- "@byline/auth": "4.14.1",
179
- "@byline/core": "4.14.1"
176
+ "@byline/auth": "4.15.0",
177
+ "@byline/core": "4.15.0",
178
+ "@byline/ui": "4.15.0",
179
+ "@byline/i18n": "4.15.0"
180
180
  },
181
181
  "peerDependencies": {
182
182
  "react": "^19.0.0",
@@ -189,6 +189,8 @@
189
189
  "@types/node": "^26.2.0",
190
190
  "@types/react": "19.2.18",
191
191
  "@types/react-dom": "19.2.4",
192
+ "@vitejs/plugin-react": "^6.1.0",
193
+ "jsdom": "^30.0.1",
192
194
  "react": "^19.2.8",
193
195
  "react-dom": "^19.2.8",
194
196
  "rimraf": "^6.1.3",
@@ -207,8 +209,10 @@
207
209
  "build": "rslib build",
208
210
  "clean": "node scripts/clean.js node_modules dist build .turbo",
209
211
  "lint": "biome check --write --unsafe --diagnostic-level=error",
210
- "test": "vitest run --mode=node",
212
+ "test": "vitest run --mode=jsdom && vitest run --mode=node",
211
213
  "test:watch": "vitest --mode=node",
212
- "typecheck": "tsc --noEmit"
214
+ "typecheck": "tsc --noEmit",
215
+ "test:jsdom": "vitest run --mode=jsdom",
216
+ "test:node": "vitest run --mode=node"
213
217
  }
214
218
  }
@@ -26,6 +26,7 @@ import cx from 'clsx'
26
26
 
27
27
  import styles from './document-actions.module.css'
28
28
  import type { PublishedVersionInfo } from './form-renderer'
29
+ import type { ScheduledPublicationState } from './scheduled-publication-state.js'
29
30
 
30
31
  const DUPLICATE_TITLE_SUFFIX = ' (copy)'
31
32
 
@@ -53,6 +54,10 @@ export function DocumentActions({
53
54
  onDeleteLocale,
54
55
  defaultLocale,
55
56
  availableLocales,
57
+ scheduledPublicationState,
58
+ onSchedulePublication,
59
+ onConfirmScheduledPublication,
60
+ onCancelScheduledPublication,
56
61
  }: {
57
62
  publishedVersion?: PublishedVersionInfo | null
58
63
  onUnpublish?: () => Promise<void>
@@ -118,6 +123,20 @@ export function DocumentActions({
118
123
  * minus the default locale and the `'all'` sentinel.
119
124
  */
120
125
  availableLocales?: string[]
126
+ /**
127
+ * Derived presentation state for the document's pending publication
128
+ * schedule. Decides which of the scheduling menu items appear; omit it (or
129
+ * pass a `none` state with no capabilities) and the group is hidden
130
+ * entirely — which is what an ineligible document, a single-status
131
+ * workflow, or an actor missing either ability all reduce to.
132
+ */
133
+ scheduledPublicationState?: ScheduledPublicationState
134
+ /** Opens the schedule / reschedule modal. Save-gated by the caller. */
135
+ onSchedulePublication?: () => void
136
+ /** Re-confirms a suspended schedule against the current version. Save-gated by the caller. */
137
+ onConfirmScheduledPublication?: () => void | Promise<void>
138
+ /** Withdraws the pending schedule. Deliberately not save-gated. */
139
+ onCancelScheduledPublication?: () => void | Promise<void>
121
140
  }) {
122
141
  const { t } = useTranslation('byline-admin')
123
142
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -151,6 +170,46 @@ export function DocumentActions({
151
170
  const [deleteLocaleBusy, setDeleteLocaleBusy] = useState(false)
152
171
  const [deleteTargetLocale, setDeleteTargetLocale] = useState<string>('')
153
172
 
173
+ // Scheduled-publication menu items. The derived state already accounts for
174
+ // eligibility and for the abilities the actor holds, so the only thing left
175
+ // here is to map the permitted operations onto entries. The group renders
176
+ // above the locale and duplicate items because scheduling acts on the
177
+ // document's lifecycle rather than on its copies.
178
+ const scheduling = scheduledPublicationState
179
+ const schedulingActions: Array<{ key: string; label: string; onSelect: () => void }> = []
180
+ if (scheduling != null) {
181
+ if (scheduling.actions.schedule && onSchedulePublication != null) {
182
+ schedulingActions.push({
183
+ key: 'schedule',
184
+ // Short form: the menu supplies the "this document" context that the
185
+ // modal's title and submit button have to spell out for themselves.
186
+ label: t('scheduledPublication.actions.scheduleMenuItem'),
187
+ onSelect: onSchedulePublication,
188
+ })
189
+ }
190
+ if (scheduling.actions.confirm && onConfirmScheduledPublication != null) {
191
+ schedulingActions.push({
192
+ key: 'confirm',
193
+ label: t('scheduledPublication.actions.confirm'),
194
+ onSelect: () => void onConfirmScheduledPublication(),
195
+ })
196
+ }
197
+ if (scheduling.actions.reschedule && onSchedulePublication != null) {
198
+ schedulingActions.push({
199
+ key: 'reschedule',
200
+ label: t('scheduledPublication.actions.reschedule'),
201
+ onSelect: onSchedulePublication,
202
+ })
203
+ }
204
+ if (scheduling.actions.cancel && onCancelScheduledPublication != null) {
205
+ schedulingActions.push({
206
+ key: 'cancel',
207
+ label: t('scheduledPublication.actions.cancel'),
208
+ onSelect: () => void onCancelScheduledPublication(),
209
+ })
210
+ }
211
+ }
212
+
154
213
  const handleOnDelete = () => {
155
214
  setShowDeleteConfirm(false)
156
215
  if (onDelete) {
@@ -270,6 +329,20 @@ export function DocumentActions({
270
329
  <DropdownComponent.Separator />
271
330
  </>
272
331
  )}*/}
332
+ {schedulingActions.length > 0 && (
333
+ <>
334
+ {schedulingActions.map((action) => (
335
+ <DropdownComponent.Item key={action.key} onClick={action.onSelect}>
336
+ <div className={cx('byline-form-actions-item', styles.item)}>
337
+ <span className={cx('byline-form-actions-item-text', styles['item-text'])}>
338
+ <button type="button">{action.label}</button>
339
+ </span>
340
+ </div>
341
+ </DropdownComponent.Item>
342
+ ))}
343
+ <DropdownComponent.Separator />
344
+ </>
345
+ )}
273
346
  {copyToLocaleAvailable && (
274
347
  <DropdownComponent.Item onClick={handleOpenCopyToLocale}>
275
348
  <div className={cx('byline-form-actions-item', styles.item)}>
@@ -101,6 +101,11 @@
101
101
  justify-content: center;
102
102
  }
103
103
 
104
+ .status-details,
105
+ :global(.byline-form-status-details) {
106
+ min-width: 0;
107
+ }
108
+
104
109
  .status-meta,
105
110
  :global(.byline-form-status-meta) {
106
111
  display: flex;
@@ -121,7 +126,16 @@
121
126
  .status-meta,
122
127
  :global(.byline-form-status-meta) {
123
128
  flex-direction: row;
129
+ flex-wrap: wrap;
124
130
  align-items: center;
131
+ /* Wrapping rather than truncating. Every cell here sets `min-width: 0` so
132
+ it can ellipsize, which works while the row holds three short facts and
133
+ fails once a fourth (a scheduled instant with its IANA zone) joins them —
134
+ the row starts shaving every cell at once, down to "Status: D…". Letting
135
+ it wrap keeps each fact whole and costs a line only when one is needed.
136
+ The row gap is explicit because the shared `gap` collapses the line-height
137
+ override above into an unreadably tight second row. */
138
+ row-gap: 0.35rem;
125
139
  }
126
140
  }
127
141
 
@@ -135,6 +149,7 @@
135
149
  .status-cell,
136
150
  :global(.byline-form-status-cell) {
137
151
  display: flex;
152
+ flex-wrap: wrap;
138
153
  align-items: center;
139
154
  gap: 0.25rem;
140
155
  min-width: 0;
@@ -38,6 +38,13 @@ import styles from './form-renderer.module.css'
38
38
  import { FormStatusDisplay } from './form-status-display'
39
39
  import { useNavigationGuardAdapter } from './navigation-guard'
40
40
  import { PathWidget } from './path-widget'
41
+ import {
42
+ ScheduledPublicationCell,
43
+ type ScheduledPublicationInfo,
44
+ ScheduledPublicationNotice,
45
+ type SchedulePublicationInput,
46
+ useScheduledPublication,
47
+ } from './scheduled-publication-control'
41
48
  import { computeStatusTransitions } from './status-transitions'
42
49
  import { TreePlacementWidget } from './tree-placement-widget'
43
50
  import { executeUploadsWithProgress } from './upload-executor'
@@ -79,6 +86,10 @@ export interface FormRendererProps {
79
86
  onCancel: () => void
80
87
  onStatusChange?: (nextStatus: string) => Promise<void>
81
88
  onUnpublish?: () => Promise<void>
89
+ scheduledPublication?: ScheduledPublicationInfo | null
90
+ onSchedulePublication?: (input: SchedulePublicationInput) => Promise<void>
91
+ onConfirmScheduledPublication?: () => Promise<void>
92
+ onCancelScheduledPublication?: () => Promise<void>
82
93
  onDelete?: () => Promise<void>
83
94
  /**
84
95
  * Called when the editor confirms the duplicate modal in
@@ -176,6 +187,10 @@ const FormContent = ({
176
187
  onCancel,
177
188
  onStatusChange,
178
189
  onUnpublish,
190
+ scheduledPublication,
191
+ onSchedulePublication,
192
+ onConfirmScheduledPublication,
193
+ onCancelScheduledPublication,
179
194
  onDelete,
180
195
  onDuplicate,
181
196
  onCopyToLocale,
@@ -241,6 +256,18 @@ const FormContent = ({
241
256
  const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] =
242
257
  useState<SystemFieldsSubmitPayload | null>(null)
243
258
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
259
+
260
+ // Scheduled publication owns three placements — a status-bar cell, an
261
+ // escalated notice, and the schedule modal — so its state lives here and the
262
+ // surfaces are rendered where each belongs.
263
+ const scheduling = useScheduledPublication({
264
+ schedule: scheduledPublication ?? null,
265
+ onSchedule: onSchedulePublication,
266
+ onConfirm: onConfirmScheduledPublication,
267
+ onCancel: onCancelScheduledPublication,
268
+ hasUnsavedChanges: hasChanges,
269
+ onUnsavedChanges: () => setShowUnsavedModal(true),
270
+ })
244
271
  const { uploadField } = useBylineFieldServices()
245
272
 
246
273
  // Path-widget wiring. The live preview must use the installation's
@@ -563,12 +590,17 @@ const FormContent = ({
563
590
  {headerSlot}
564
591
  </div>
565
592
  <div className={cx('byline-form-status-bar', styles['status-bar'])}>
566
- <FormStatusDisplay
567
- initialData={initialData}
568
- workflowStatuses={workflowStatuses}
569
- publishedVersion={publishedVersion}
570
- onUnpublish={onUnpublish}
571
- />
593
+ <div className={cx('byline-form-status-details', styles['status-details'])}>
594
+ <FormStatusDisplay
595
+ initialData={initialData}
596
+ workflowStatuses={workflowStatuses}
597
+ publishedVersion={publishedVersion}
598
+ onUnpublish={onUnpublish}
599
+ afterStatusCells={
600
+ <ScheduledPublicationCell state={scheduling.state} timeZone={scheduling.timeZone} />
601
+ }
602
+ />
603
+ </div>
572
604
  <div className={cx('byline-form-actions', styles.actions)}>
573
605
  <Button
574
606
  className={cx('byline-form-actions-button', styles['actions-button'])}
@@ -667,9 +699,22 @@ const FormContent = ({
667
699
  onDeleteLocale={onDeleteLocale}
668
700
  defaultLocale={defaultLocale}
669
701
  availableLocales={initialData?._availableVersionLocales as string[] | undefined}
702
+ scheduledPublicationState={scheduling.state}
703
+ onSchedulePublication={scheduling.openSchedule}
704
+ onConfirmScheduledPublication={scheduling.confirm}
705
+ onCancelScheduledPublication={scheduling.cancel}
670
706
  />
671
707
  </div>
672
708
  </div>
709
+ <ScheduledPublicationNotice
710
+ state={scheduling.state}
711
+ timeZone={scheduling.timeZone}
712
+ busy={scheduling.busy}
713
+ onConfirm={scheduling.confirm}
714
+ onReschedule={scheduling.openSchedule}
715
+ onCancel={scheduling.cancel}
716
+ />
717
+ {scheduling.modal}
673
718
  {restoreWarnings && restoreWarnings.length > 0 && (
674
719
  <Alert
675
720
  className="m-0 mt-4"
@@ -740,36 +785,9 @@ const FormContent = ({
740
785
  )
741
786
  }
742
787
 
743
- export const FormRenderer = ({
744
- mode,
745
- fields,
746
- onSubmit,
747
- onCancel,
748
- onStatusChange,
749
- onUnpublish,
750
- onDelete,
751
- onDuplicate,
752
- onCopyToLocale,
753
- onDeleteLocale,
754
- contentLocales,
755
- nextStatus,
756
- workflowStatuses,
757
- publishedVersion,
758
- initialData,
759
- adminConfig,
760
- useAsTitle,
761
- useAsPath,
762
- advertiseLocales,
763
- tree,
764
- headingLabel,
765
- headerSlot,
766
- collectionPath,
767
- initialLocale,
768
- onLocaleChange,
769
- defaultLocale,
770
- useNavigationGuard,
771
- restoreWarnings,
772
- }: FormRendererProps) => {
788
+ export const FormRenderer = (props: FormRendererProps) => {
789
+ const { mode, initialData, initialLocale, collectionPath } = props
790
+
773
791
  // Persists per-tab-set active tab across locale-change remounts of FormContent.
774
792
  // useRef so mutations never trigger a re-render of FormRenderer itself.
775
793
  const savedTabsRef = useRef<Record<string, string>>({})
@@ -781,35 +799,12 @@ export const FormRenderer = ({
781
799
  documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
782
800
  collectionPath={collectionPath ?? null}
783
801
  >
802
+ {/* Forwarded wholesale. An enumerated prop list here silently dropped
803
+ every prop added to FormRendererProps but not copied down — which is
804
+ how the scheduled-publication handlers reached FormContent as
805
+ `undefined` and the control never rendered. */}
784
806
  <FormContent
785
- mode={mode}
786
- fields={fields}
787
- onSubmit={onSubmit}
788
- onCancel={onCancel}
789
- onStatusChange={onStatusChange}
790
- onUnpublish={onUnpublish}
791
- onDelete={onDelete}
792
- onDuplicate={onDuplicate}
793
- onCopyToLocale={onCopyToLocale}
794
- onDeleteLocale={onDeleteLocale}
795
- contentLocales={contentLocales}
796
- nextStatus={nextStatus}
797
- workflowStatuses={workflowStatuses}
798
- publishedVersion={publishedVersion}
799
- initialData={initialData}
800
- adminConfig={adminConfig}
801
- useAsTitle={useAsTitle}
802
- useAsPath={useAsPath}
803
- advertiseLocales={advertiseLocales}
804
- tree={tree}
805
- headingLabel={headingLabel}
806
- headerSlot={headerSlot}
807
- collectionPath={collectionPath}
808
- initialLocale={initialLocale}
809
- onLocaleChange={onLocaleChange}
810
- defaultLocale={defaultLocale}
811
- useNavigationGuard={useNavigationGuard}
812
- restoreWarnings={restoreWarnings}
807
+ {...props}
813
808
  _activeTabBySet={savedTabsRef.current}
814
809
  _onTabChange={(tabSetName, tabName) => {
815
810
  savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName }
@@ -27,11 +27,21 @@ export const FormStatusDisplay = ({
27
27
  workflowStatuses,
28
28
  publishedVersion,
29
29
  onUnpublish,
30
+ afterStatusCells,
30
31
  }: {
31
32
  initialData?: Record<string, any>
32
33
  workflowStatuses?: WorkflowStatus[]
33
34
  publishedVersion?: PublishedVersionInfo | null
34
35
  onUnpublish?: () => Promise<void>
36
+ /**
37
+ * Rendered inside the status cell, directly after the status value, so it
38
+ * reads as a continuation of the same sentence: "Status: Draft — Scheduled
39
+ * for …". Scheduled publication uses this because an armed schedule is a
40
+ * statement about the document's lifecycle, not a timestamp to file beside
41
+ * Created. Falls back to standing alone when the workflow is single-status
42
+ * and no status cell is drawn.
43
+ */
44
+ afterStatusCells?: React.ReactNode
35
45
  }) => {
36
46
  const { t } = useTranslation('byline-admin')
37
47
  const statusCode = initialData?.status
@@ -51,8 +61,10 @@ export const FormStatusDisplay = ({
51
61
  <span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
52
62
  {statusLabel}
53
63
  </span>
64
+ {afterStatusCells}
54
65
  </div>
55
66
  )}
67
+ {!showStatusCell && afterStatusCells}
56
68
 
57
69
  {initialData?.updatedAt != null && (
58
70
  <div className={cx('byline-form-status-cell', styles['status-cell'])}>
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { act } from 'react'
10
10
 
11
+ import { adminTranslations } from '@byline/i18n/admin'
12
+ import { I18nProvider } from '@byline/i18n/react'
11
13
  import { createRoot, type Root } from 'react-dom/client'
12
14
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
15
 
@@ -112,15 +114,25 @@ describe('PathWidget', () => {
112
114
  ) => {
113
115
  act(() => {
114
116
  root.render(
115
- <PathWidget
116
- useAsPath={props.useAsPath ?? 'title'}
117
- collectionPath="pages"
117
+ // The real English admin bundle rather than a stubbed `t`, so the
118
+ // assertions below check the strings an editor actually sees. A stub
119
+ // would let a key be renamed or deleted without the test noticing.
120
+ <I18nProvider
121
+ bundle={adminTranslations({ locales: ['en'] })}
122
+ activeLocale="en"
118
123
  defaultLocale="en"
119
- activeLocale={props.activeLocale ?? 'en'}
120
- mode={props.mode ?? 'create'}
121
- slugifier={props.slugifier}
122
- sourceLocked={props.sourceLocked}
123
- />
124
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
125
+ >
126
+ <PathWidget
127
+ useAsPath={props.useAsPath ?? 'title'}
128
+ collectionPath="pages"
129
+ defaultLocale="en"
130
+ activeLocale={props.activeLocale ?? 'en'}
131
+ mode={props.mode ?? 'create'}
132
+ slugifier={props.slugifier}
133
+ sourceLocked={props.sourceLocked}
134
+ />
135
+ </I18nProvider>
124
136
  )
125
137
  })
126
138
  }