@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.
- 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,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.
|
|
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/
|
|
177
|
-
"@byline/
|
|
178
|
-
"@byline/
|
|
179
|
-
"@byline/
|
|
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
|
-
<
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
}
|