@byline/admin 3.12.1 → 3.12.2
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/form-context.js +30 -60
- package/dist/forms/form-modals.d.ts +30 -0
- package/dist/forms/form-modals.js +189 -0
- package/dist/forms/form-renderer.js +22 -363
- package/dist/forms/form-status-display.d.ts +21 -0
- package/dist/forms/form-status-display.js +94 -0
- package/dist/forms/status-transitions.d.ts +23 -0
- package/dist/forms/status-transitions.js +37 -0
- package/dist/forms/status-transitions.test.node.d.ts +8 -0
- package/dist/forms/use-form-layout.d.ts +35 -0
- package/dist/forms/use-form-layout.js +77 -0
- package/dist/forms/use-form-layout.test.node.d.ts +8 -0
- package/dist/forms/use-tracked-slot.d.ts +45 -0
- package/dist/forms/use-tracked-slot.js +46 -0
- package/package.json +5 -5
- package/src/forms/form-context.tsx +36 -92
- package/src/forms/form-modals.tsx +186 -0
- package/src/forms/form-renderer.tsx +24 -387
- package/src/forms/form-status-display.tsx +108 -0
- package/src/forms/status-transitions.test.node.ts +87 -0
- package/src/forms/status-transitions.ts +86 -0
- package/src/forms/use-form-layout.test.node.ts +82 -0
- package/src/forms/use-form-layout.ts +134 -0
- package/src/forms/use-tracked-slot.ts +101 -0
|
@@ -20,22 +20,25 @@ import type {
|
|
|
20
20
|
} from '@byline/core'
|
|
21
21
|
import type { DocumentPatch } from '@byline/core/patches'
|
|
22
22
|
import { useTranslation } from '@byline/i18n/react'
|
|
23
|
-
import { Alert, Button,
|
|
23
|
+
import { Alert, Button, ComboButton } from '@byline/ui/react'
|
|
24
24
|
import cx from 'classnames'
|
|
25
25
|
|
|
26
26
|
import { FieldRenderer } from '../fields/field-renderer'
|
|
27
27
|
import { useBylineFieldServices } from '../fields/field-services-context'
|
|
28
|
-
import { LocalDateTime } from '../fields/local-date-time'
|
|
29
28
|
import { AdminGroup } from '../presentation/group'
|
|
30
29
|
import { AdminRow } from '../presentation/row'
|
|
31
30
|
import { AdminTabs } from '../presentation/tabs'
|
|
32
31
|
import { AvailableLocalesWidget } from './available-locales-widget'
|
|
33
32
|
import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
|
|
34
33
|
import { FormProvider, useFieldValue, useFormContext } from './form-context'
|
|
34
|
+
import { NavigationGuardModal, SystemFieldsConfirmModal, UnsavedChangesModal } from './form-modals'
|
|
35
35
|
import styles from './form-renderer.module.css'
|
|
36
|
+
import { FormStatusDisplay } from './form-status-display'
|
|
36
37
|
import { useNavigationGuardAdapter } from './navigation-guard'
|
|
37
38
|
import { PathWidget } from './path-widget'
|
|
39
|
+
import { computeStatusTransitions } from './status-transitions'
|
|
38
40
|
import { executeUploadsWithProgress } from './upload-executor'
|
|
41
|
+
import { useFormLayout } from './use-form-layout'
|
|
39
42
|
import type { UseNavigationGuard } from './navigation-guard'
|
|
40
43
|
|
|
41
44
|
/** Metadata about a previously published version that is still live. */
|
|
@@ -156,166 +159,6 @@ export interface FormRendererProps {
|
|
|
156
159
|
useNavigationGuard?: UseNavigationGuard
|
|
157
160
|
}
|
|
158
161
|
|
|
159
|
-
const FormStatusDisplay = ({
|
|
160
|
-
initialData,
|
|
161
|
-
workflowStatuses,
|
|
162
|
-
publishedVersion,
|
|
163
|
-
onUnpublish,
|
|
164
|
-
}: {
|
|
165
|
-
initialData?: Record<string, any>
|
|
166
|
-
workflowStatuses?: WorkflowStatus[]
|
|
167
|
-
publishedVersion?: PublishedVersionInfo | null
|
|
168
|
-
onUnpublish?: () => Promise<void>
|
|
169
|
-
}) => {
|
|
170
|
-
const { t } = useTranslation('byline-admin')
|
|
171
|
-
const statusCode = initialData?.status
|
|
172
|
-
const statusLabel = workflowStatuses?.find((s) => s.name === statusCode)?.label ?? statusCode
|
|
173
|
-
// Single-status workflows (e.g. lookups) have no editorial lifecycle —
|
|
174
|
-
// suppress the "Status: …" cell since there is nothing meaningful to convey.
|
|
175
|
-
const showStatusCell = (workflowStatuses?.length ?? 0) > 1
|
|
176
|
-
|
|
177
|
-
return (
|
|
178
|
-
<div className={cx('byline-form-status', styles.status)}>
|
|
179
|
-
<div className={cx('byline-form-status-meta', styles['status-meta'])}>
|
|
180
|
-
{showStatusCell && (
|
|
181
|
-
<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
182
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
183
|
-
{t('forms.status.label')}
|
|
184
|
-
</span>
|
|
185
|
-
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
186
|
-
{statusLabel}
|
|
187
|
-
</span>
|
|
188
|
-
</div>
|
|
189
|
-
)}
|
|
190
|
-
|
|
191
|
-
{initialData?.updatedAt != null && (
|
|
192
|
-
<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
193
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
194
|
-
{t('forms.status.lastModified')}
|
|
195
|
-
</span>
|
|
196
|
-
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
197
|
-
<LocalDateTime value={initialData.updatedAt} />
|
|
198
|
-
</span>
|
|
199
|
-
</div>
|
|
200
|
-
)}
|
|
201
|
-
|
|
202
|
-
{initialData?.createdAt != null && (
|
|
203
|
-
<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
204
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
205
|
-
{t('forms.status.created')}
|
|
206
|
-
</span>
|
|
207
|
-
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
208
|
-
<LocalDateTime value={initialData.createdAt} />
|
|
209
|
-
</span>
|
|
210
|
-
</div>
|
|
211
|
-
)}
|
|
212
|
-
</div>
|
|
213
|
-
|
|
214
|
-
{publishedVersion != null && (
|
|
215
|
-
<div className={cx('byline-form-status-published', styles['status-published'])}>
|
|
216
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
217
|
-
{t('forms.status.publishedLive')}{' '}
|
|
218
|
-
{publishedVersion.updatedAt ? (
|
|
219
|
-
<span>
|
|
220
|
-
{t('forms.status.publishedOn', { date: new Date(publishedVersion.updatedAt) })}
|
|
221
|
-
</span>
|
|
222
|
-
) : (
|
|
223
|
-
''
|
|
224
|
-
)}
|
|
225
|
-
</span>
|
|
226
|
-
{onUnpublish && (
|
|
227
|
-
<>
|
|
228
|
-
{' '}
|
|
229
|
-
<button
|
|
230
|
-
type="button"
|
|
231
|
-
onClick={onUnpublish}
|
|
232
|
-
className={cx('byline-form-status-unpublish', styles['status-unpublish'])}
|
|
233
|
-
>
|
|
234
|
-
{t('common.actions.unpublish')}
|
|
235
|
-
</button>
|
|
236
|
-
</>
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
)}
|
|
240
|
-
</div>
|
|
241
|
-
)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Compute the primary and secondary status transitions for the ComboButton.
|
|
246
|
-
* - Primary: the main action (forward step), or the current status itself
|
|
247
|
-
* when the document has reached the final workflow step (terminal state).
|
|
248
|
-
* - Secondary: other available transitions to show as dropdown options.
|
|
249
|
-
* - isTerminal: true when the document is at the final workflow status —
|
|
250
|
-
* the primary button renders as a non-actionable indicator and all
|
|
251
|
-
* back-steps move into the dropdown.
|
|
252
|
-
*/
|
|
253
|
-
function computeStatusTransitions(
|
|
254
|
-
currentStatus: string | undefined,
|
|
255
|
-
workflowStatuses: WorkflowStatus[] | undefined,
|
|
256
|
-
nextStatus: WorkflowStatus | undefined
|
|
257
|
-
): {
|
|
258
|
-
primaryStatus: WorkflowStatus | undefined
|
|
259
|
-
secondaryStatuses: WorkflowStatus[]
|
|
260
|
-
isTerminal: boolean
|
|
261
|
-
} {
|
|
262
|
-
if (!workflowStatuses || workflowStatuses.length === 0 || !currentStatus) {
|
|
263
|
-
return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups) have
|
|
267
|
-
// no transitions — short-circuit so the form shows only Close / Save.
|
|
268
|
-
if (workflowStatuses.length <= 1) {
|
|
269
|
-
return { primaryStatus: undefined, secondaryStatuses: [], isTerminal: false }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const currentIndex = workflowStatuses.findIndex((s) => s.name === currentStatus)
|
|
273
|
-
if (currentIndex === -1) {
|
|
274
|
-
return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const isAtEnd = currentIndex === workflowStatuses.length - 1
|
|
278
|
-
const isAtStart = currentIndex === 0
|
|
279
|
-
|
|
280
|
-
// Collect all available target statuses
|
|
281
|
-
const availableTargets: WorkflowStatus[] = []
|
|
282
|
-
|
|
283
|
-
// Reset to first (if not at first)
|
|
284
|
-
if (!isAtStart && workflowStatuses[0]) {
|
|
285
|
-
availableTargets.push(workflowStatuses[0])
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Back one step (if not at start and the previous is not already the first)
|
|
289
|
-
const prev = workflowStatuses[currentIndex - 1]
|
|
290
|
-
if (currentIndex > 1 && prev) {
|
|
291
|
-
availableTargets.push(prev)
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Forward one step (if not at end) - this is the nextStatus
|
|
295
|
-
const next = workflowStatuses[currentIndex + 1]
|
|
296
|
-
if (!isAtEnd && next) {
|
|
297
|
-
availableTargets.push(next)
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (isAtEnd) {
|
|
301
|
-
// Terminal state: the primary button is a non-actionable indicator of the
|
|
302
|
-
// current status; both back-steps (revert to previous / reset to first)
|
|
303
|
-
// are surfaced in the dropdown.
|
|
304
|
-
return {
|
|
305
|
-
primaryStatus: workflowStatuses[currentIndex],
|
|
306
|
-
secondaryStatuses: availableTargets,
|
|
307
|
-
isTerminal: true,
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Not at end: primary is the forward step (nextStatus)
|
|
312
|
-
return {
|
|
313
|
-
primaryStatus: nextStatus,
|
|
314
|
-
secondaryStatuses: availableTargets.filter((s) => s.name !== nextStatus?.name),
|
|
315
|
-
isTerminal: false,
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
162
|
const FormContent = ({
|
|
320
163
|
mode,
|
|
321
164
|
fields,
|
|
@@ -394,80 +237,12 @@ const FormContent = ({
|
|
|
394
237
|
if (initialLocale) setContentLocale(initialLocale)
|
|
395
238
|
}, [initialLocale])
|
|
396
239
|
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
// ---------------------------------------------------------------------
|
|
404
|
-
|
|
405
|
-
const fieldByName = useMemo(() => {
|
|
406
|
-
const map = new Map<string, Field>()
|
|
407
|
-
for (const field of fields) {
|
|
408
|
-
if ('name' in field) map.set(field.name, field)
|
|
409
|
-
}
|
|
410
|
-
return map
|
|
411
|
-
}, [fields])
|
|
412
|
-
|
|
413
|
-
const tabSetByName = useMemo(() => {
|
|
414
|
-
const map = new Map<string, TabSetDefinition>()
|
|
415
|
-
for (const set of adminConfig?.tabSets ?? []) map.set(set.name, set)
|
|
416
|
-
return map
|
|
417
|
-
}, [adminConfig])
|
|
418
|
-
|
|
419
|
-
const rowByName = useMemo(() => {
|
|
420
|
-
const map = new Map<string, RowDefinition>()
|
|
421
|
-
for (const row of adminConfig?.rows ?? []) map.set(row.name, row)
|
|
422
|
-
return map
|
|
423
|
-
}, [adminConfig])
|
|
424
|
-
|
|
425
|
-
const groupByName = useMemo(() => {
|
|
426
|
-
const map = new Map<string, GroupDefinition>()
|
|
427
|
-
for (const group of adminConfig?.groups ?? []) map.set(group.name, group)
|
|
428
|
-
return map
|
|
429
|
-
}, [adminConfig])
|
|
430
|
-
|
|
431
|
-
// When `layout` is omitted, synthesise main = all schema fields in order.
|
|
432
|
-
const layout = useMemo(() => {
|
|
433
|
-
if (adminConfig?.layout) return adminConfig.layout
|
|
434
|
-
return { main: fields.filter((f) => 'name' in f).map((f) => (f as { name: string }).name) }
|
|
435
|
-
}, [adminConfig, fields])
|
|
436
|
-
|
|
437
|
-
// Reverse index: schema field name → which tab set + tab it lives in.
|
|
438
|
-
// Powers per-tab-set error badge counts. Fields not under any tab set
|
|
439
|
-
// (e.g. raw-field placement directly in `layout.main`) are absent from
|
|
440
|
-
// this map.
|
|
441
|
-
const fieldToTabPath = useMemo(() => {
|
|
442
|
-
const map = new Map<string, { tabSetName: string; tabName: string }>()
|
|
443
|
-
const visit = (
|
|
444
|
-
names: readonly string[],
|
|
445
|
-
tabSetName: string,
|
|
446
|
-
tabName: string,
|
|
447
|
-
seen: Set<string>
|
|
448
|
-
) => {
|
|
449
|
-
for (const name of names) {
|
|
450
|
-
if (fieldByName.has(name)) {
|
|
451
|
-
map.set(name, { tabSetName, tabName })
|
|
452
|
-
} else if (seen.has(name)) {
|
|
453
|
-
} else if (rowByName.has(name)) {
|
|
454
|
-
const row = rowByName.get(name)!
|
|
455
|
-
const next = new Set(seen).add(name)
|
|
456
|
-
visit(row.fields, tabSetName, tabName, next)
|
|
457
|
-
} else if (groupByName.has(name)) {
|
|
458
|
-
const group = groupByName.get(name)!
|
|
459
|
-
const next = new Set(seen).add(name)
|
|
460
|
-
visit(group.fields, tabSetName, tabName, next)
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
for (const set of adminConfig?.tabSets ?? []) {
|
|
465
|
-
for (const tab of set.tabs) {
|
|
466
|
-
visit(tab.fields, set.name, tab.name, new Set())
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
return map
|
|
470
|
-
}, [adminConfig, fieldByName, rowByName, groupByName])
|
|
240
|
+
// Layout primitives + lookup tables — pure derivations of `adminConfig` +
|
|
241
|
+
// `fields`. The validator at startup guarantees every reachable name
|
|
242
|
+
// resolves and every schema field is placed at most once, so the render-time
|
|
243
|
+
// lookups below are unguarded. See ./use-form-layout.
|
|
244
|
+
const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
|
|
245
|
+
useFormLayout(adminConfig, fields)
|
|
471
246
|
|
|
472
247
|
// ---------------------------------------------------------------------
|
|
473
248
|
// Active-tab state — one tab name per declared tab set.
|
|
@@ -901,159 +676,21 @@ const FormContent = ({
|
|
|
901
676
|
{(layout.sidebar ?? []).map((name) => renderItem(name))}
|
|
902
677
|
</div>
|
|
903
678
|
</div>
|
|
904
|
-
{showUnsavedModal && (
|
|
905
|
-
<Modal
|
|
906
|
-
isOpen={true}
|
|
907
|
-
closeOnOverlayClick={true}
|
|
908
|
-
onDismiss={() => setShowUnsavedModal(false)}
|
|
909
|
-
>
|
|
910
|
-
<Modal.Container style={{ maxWidth: '460px' }}>
|
|
911
|
-
<Modal.Header
|
|
912
|
-
className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}
|
|
913
|
-
>
|
|
914
|
-
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
915
|
-
{t('forms.unsavedChanges.title')}
|
|
916
|
-
</h3>
|
|
917
|
-
</Modal.Header>
|
|
918
|
-
<Modal.Content>
|
|
919
|
-
<p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
|
|
920
|
-
{t('forms.unsavedChanges.message')}
|
|
921
|
-
</p>
|
|
922
|
-
</Modal.Content>
|
|
923
|
-
<Modal.Actions>
|
|
924
|
-
<Button
|
|
925
|
-
size="sm"
|
|
926
|
-
style={{ minWidth: '60px' }}
|
|
927
|
-
intent="primary"
|
|
928
|
-
type="button"
|
|
929
|
-
onClick={() => setShowUnsavedModal(false)}
|
|
930
|
-
>
|
|
931
|
-
{t('forms.unsavedChanges.okButton')}
|
|
932
|
-
</Button>
|
|
933
|
-
</Modal.Actions>
|
|
934
|
-
</Modal.Container>
|
|
935
|
-
</Modal>
|
|
936
|
-
)}
|
|
679
|
+
{showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
|
|
937
680
|
{pendingSystemFieldsSubmit != null && (
|
|
938
|
-
<
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
? t('forms.systemFieldsConfirm.bothTitle')
|
|
950
|
-
: t('forms.systemFieldsConfirm.title')}
|
|
951
|
-
</h3>
|
|
952
|
-
<IconButton
|
|
953
|
-
aria-label={t('common.actions.close')}
|
|
954
|
-
size="xs"
|
|
955
|
-
onClick={() => setPendingSystemFieldsSubmit(null)}
|
|
956
|
-
>
|
|
957
|
-
<CloseIcon width="16px" height="16px" svgClassName="white-icon" />
|
|
958
|
-
</IconButton>
|
|
959
|
-
</Modal.Header>
|
|
960
|
-
<Modal.Content className="prose">
|
|
961
|
-
{/* Lead with reassurance: content edits follow the normal
|
|
962
|
-
revision + publish workflow. The immediate, document-level
|
|
963
|
-
system-field write is explained below the divider. */}
|
|
964
|
-
{pendingSystemFieldsSubmit.contentDirty && (
|
|
965
|
-
<p className={cx('byline-form-system-fields-content-note', 'm-0 mt-2')}>
|
|
966
|
-
{t('forms.systemFieldsConfirm.contentNote')}
|
|
967
|
-
</p>
|
|
968
|
-
)}
|
|
969
|
-
<p
|
|
970
|
-
className="m-0 mt-2"
|
|
971
|
-
style={
|
|
972
|
-
pendingSystemFieldsSubmit.contentDirty
|
|
973
|
-
? {
|
|
974
|
-
marginTop: 'var(--spacing-8)',
|
|
975
|
-
paddingTop: 'var(--spacing-12)',
|
|
976
|
-
borderTop: '1px solid var(--border-color)',
|
|
977
|
-
}
|
|
978
|
-
: undefined
|
|
979
|
-
}
|
|
980
|
-
>
|
|
981
|
-
{t('forms.systemFieldsConfirm.intro')}
|
|
982
|
-
</p>
|
|
983
|
-
<ul
|
|
984
|
-
className={cx('byline-form-system-fields-list', styles['guard-modal-text'], 'm-0')}
|
|
985
|
-
>
|
|
986
|
-
{pendingSystemFieldsSubmit.pathDirty && (
|
|
987
|
-
<li>{t('forms.systemFieldsConfirm.bulletPath')}</li>
|
|
988
|
-
)}
|
|
989
|
-
{pendingSystemFieldsSubmit.availableLocalesDirty && (
|
|
990
|
-
<li>{t('forms.systemFieldsConfirm.bulletLocales')}</li>
|
|
991
|
-
)}
|
|
992
|
-
</ul>
|
|
993
|
-
<p
|
|
994
|
-
className={cx('byline-form-system-fields-effect', styles['guard-modal-text'])}
|
|
995
|
-
style={{
|
|
996
|
-
marginTop: 'var(--spacing-4)',
|
|
997
|
-
marginBottom: 0,
|
|
998
|
-
color: 'var(--text-subtle)',
|
|
999
|
-
}}
|
|
1000
|
-
>
|
|
1001
|
-
{t('forms.systemFieldsConfirm.effectLine')}
|
|
1002
|
-
</p>
|
|
1003
|
-
</Modal.Content>
|
|
1004
|
-
<Modal.Actions>
|
|
1005
|
-
<Button
|
|
1006
|
-
size="sm"
|
|
1007
|
-
style={{ minWidth: '80px' }}
|
|
1008
|
-
intent="noeffect"
|
|
1009
|
-
type="button"
|
|
1010
|
-
onClick={() => setPendingSystemFieldsSubmit(null)}
|
|
1011
|
-
>
|
|
1012
|
-
{t('common.actions.cancel')}
|
|
1013
|
-
</Button>
|
|
1014
|
-
<Button
|
|
1015
|
-
size="sm"
|
|
1016
|
-
style={{ minWidth: '80px' }}
|
|
1017
|
-
intent="primary"
|
|
1018
|
-
type="button"
|
|
1019
|
-
onClick={() => {
|
|
1020
|
-
const payload = pendingSystemFieldsSubmit
|
|
1021
|
-
setPendingSystemFieldsSubmit(null)
|
|
1022
|
-
submitPayload(payload)
|
|
1023
|
-
}}
|
|
1024
|
-
>
|
|
1025
|
-
{t('forms.systemFieldsConfirm.confirmButton')}
|
|
1026
|
-
</Button>
|
|
1027
|
-
</Modal.Actions>
|
|
1028
|
-
</Modal.Container>
|
|
1029
|
-
</Modal>
|
|
1030
|
-
)}
|
|
1031
|
-
{guard.isBlocked && (
|
|
1032
|
-
<Modal isOpen={true} closeOnOverlayClick={false} onDismiss={guard.stay}>
|
|
1033
|
-
<Modal.Container style={{ maxWidth: '460px' }}>
|
|
1034
|
-
<Modal.Header
|
|
1035
|
-
className={cx('byline-form-guard-modal-head', styles['guard-modal-head'])}
|
|
1036
|
-
>
|
|
1037
|
-
<h3 className={cx('byline-form-guard-modal-title', styles['guard-modal-title'])}>
|
|
1038
|
-
{t('forms.navigationGuard.title')}
|
|
1039
|
-
</h3>
|
|
1040
|
-
</Modal.Header>
|
|
1041
|
-
<Modal.Content>
|
|
1042
|
-
<p className={cx('byline-form-guard-modal-text', styles['guard-modal-text'])}>
|
|
1043
|
-
{t('forms.navigationGuard.message')}
|
|
1044
|
-
</p>
|
|
1045
|
-
</Modal.Content>
|
|
1046
|
-
<Modal.Actions>
|
|
1047
|
-
<Button size="sm" intent="noeffect" type="button" onClick={guard.stay}>
|
|
1048
|
-
{t('forms.navigationGuard.stayButton')}
|
|
1049
|
-
</Button>
|
|
1050
|
-
<Button size="sm" intent="danger" type="button" onClick={guard.proceed}>
|
|
1051
|
-
{t('forms.navigationGuard.leaveButton')}
|
|
1052
|
-
</Button>
|
|
1053
|
-
</Modal.Actions>
|
|
1054
|
-
</Modal.Container>
|
|
1055
|
-
</Modal>
|
|
681
|
+
<SystemFieldsConfirmModal
|
|
682
|
+
contentDirty={pendingSystemFieldsSubmit.contentDirty}
|
|
683
|
+
pathDirty={pendingSystemFieldsSubmit.pathDirty}
|
|
684
|
+
availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
|
|
685
|
+
onCancel={() => setPendingSystemFieldsSubmit(null)}
|
|
686
|
+
onConfirm={() => {
|
|
687
|
+
const payload = pendingSystemFieldsSubmit
|
|
688
|
+
setPendingSystemFieldsSubmit(null)
|
|
689
|
+
submitPayload(payload)
|
|
690
|
+
}}
|
|
691
|
+
/>
|
|
1056
692
|
)}
|
|
693
|
+
{guard.isBlocked && <NavigationGuardModal onStay={guard.stay} onProceed={guard.proceed} />}
|
|
1057
694
|
</form>
|
|
1058
695
|
)
|
|
1059
696
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
import type { WorkflowStatus } from '@byline/core'
|
|
12
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
13
|
+
import cx from 'classnames'
|
|
14
|
+
|
|
15
|
+
import { LocalDateTime } from '../fields/local-date-time'
|
|
16
|
+
import styles from './form-renderer.module.css'
|
|
17
|
+
import type { PublishedVersionInfo } from './form-renderer'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The status / metadata strip at the top of a document form — current
|
|
21
|
+
* workflow status (suppressed for single-status workflows), last-modified and
|
|
22
|
+
* created timestamps, and a "published live" indicator with an inline
|
|
23
|
+
* Unpublish action when a previously-published version is still live.
|
|
24
|
+
*/
|
|
25
|
+
export const FormStatusDisplay = ({
|
|
26
|
+
initialData,
|
|
27
|
+
workflowStatuses,
|
|
28
|
+
publishedVersion,
|
|
29
|
+
onUnpublish,
|
|
30
|
+
}: {
|
|
31
|
+
initialData?: Record<string, any>
|
|
32
|
+
workflowStatuses?: WorkflowStatus[]
|
|
33
|
+
publishedVersion?: PublishedVersionInfo | null
|
|
34
|
+
onUnpublish?: () => Promise<void>
|
|
35
|
+
}) => {
|
|
36
|
+
const { t } = useTranslation('byline-admin')
|
|
37
|
+
const statusCode = initialData?.status
|
|
38
|
+
const statusLabel = workflowStatuses?.find((s) => s.name === statusCode)?.label ?? statusCode
|
|
39
|
+
// Single-status workflows (e.g. lookups) have no editorial lifecycle —
|
|
40
|
+
// suppress the "Status: …" cell since there is nothing meaningful to convey.
|
|
41
|
+
const showStatusCell = (workflowStatuses?.length ?? 0) > 1
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={cx('byline-form-status', styles.status)}>
|
|
45
|
+
<div className={cx('byline-form-status-meta', styles['status-meta'])}>
|
|
46
|
+
{showStatusCell && (
|
|
47
|
+
<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
48
|
+
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
49
|
+
{t('forms.status.label')}
|
|
50
|
+
</span>
|
|
51
|
+
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
52
|
+
{statusLabel}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{initialData?.updatedAt != null && (
|
|
58
|
+
<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
59
|
+
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
60
|
+
{t('forms.status.lastModified')}
|
|
61
|
+
</span>
|
|
62
|
+
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
63
|
+
<LocalDateTime value={initialData.updatedAt} />
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{initialData?.createdAt != null && (
|
|
69
|
+
<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
70
|
+
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
71
|
+
{t('forms.status.created')}
|
|
72
|
+
</span>
|
|
73
|
+
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
74
|
+
<LocalDateTime value={initialData.createdAt} />
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{publishedVersion != null && (
|
|
81
|
+
<div className={cx('byline-form-status-published', styles['status-published'])}>
|
|
82
|
+
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
83
|
+
{t('forms.status.publishedLive')}{' '}
|
|
84
|
+
{publishedVersion.updatedAt ? (
|
|
85
|
+
<span>
|
|
86
|
+
{t('forms.status.publishedOn', { date: new Date(publishedVersion.updatedAt) })}
|
|
87
|
+
</span>
|
|
88
|
+
) : (
|
|
89
|
+
''
|
|
90
|
+
)}
|
|
91
|
+
</span>
|
|
92
|
+
{onUnpublish && (
|
|
93
|
+
<>
|
|
94
|
+
{' '}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onUnpublish}
|
|
98
|
+
className={cx('byline-form-status-unpublish', styles['status-unpublish'])}
|
|
99
|
+
>
|
|
100
|
+
{t('common.actions.unpublish')}
|
|
101
|
+
</button>
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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 type { WorkflowStatus } from '@byline/core'
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { computeStatusTransitions } from './status-transitions'
|
|
13
|
+
|
|
14
|
+
const draft: WorkflowStatus = { name: 'draft', label: 'Draft' }
|
|
15
|
+
const review: WorkflowStatus = { name: 'review', label: 'Review' }
|
|
16
|
+
const published: WorkflowStatus = { name: 'published', label: 'Published' }
|
|
17
|
+
const flow = [draft, review, published]
|
|
18
|
+
|
|
19
|
+
describe('computeStatusTransitions', () => {
|
|
20
|
+
it('falls back to nextStatus when there is no workflow or no current status', () => {
|
|
21
|
+
expect(computeStatusTransitions(undefined, undefined, published)).toEqual({
|
|
22
|
+
primaryStatus: published,
|
|
23
|
+
secondaryStatuses: [],
|
|
24
|
+
isTerminal: false,
|
|
25
|
+
})
|
|
26
|
+
expect(computeStatusTransitions('draft', [], published)).toEqual({
|
|
27
|
+
primaryStatus: published,
|
|
28
|
+
secondaryStatuses: [],
|
|
29
|
+
isTerminal: false,
|
|
30
|
+
})
|
|
31
|
+
expect(computeStatusTransitions(undefined, flow, published)).toEqual({
|
|
32
|
+
primaryStatus: published,
|
|
33
|
+
secondaryStatuses: [],
|
|
34
|
+
isTerminal: false,
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('exposes no transitions for a single-status workflow', () => {
|
|
39
|
+
expect(computeStatusTransitions('draft', [draft], undefined)).toEqual({
|
|
40
|
+
primaryStatus: undefined,
|
|
41
|
+
secondaryStatuses: [],
|
|
42
|
+
isTerminal: false,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('falls back to nextStatus when the current status is not in the workflow', () => {
|
|
47
|
+
expect(computeStatusTransitions('archived', flow, review)).toEqual({
|
|
48
|
+
primaryStatus: review,
|
|
49
|
+
secondaryStatuses: [],
|
|
50
|
+
isTerminal: false,
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('at the first step: primary is the forward step, no back-steps', () => {
|
|
55
|
+
const result = computeStatusTransitions('draft', flow, review)
|
|
56
|
+
expect(result.primaryStatus).toBe(review)
|
|
57
|
+
expect(result.isTerminal).toBe(false)
|
|
58
|
+
// Forward step is the primary, so it is filtered out of the dropdown.
|
|
59
|
+
expect(result.secondaryStatuses).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('in the middle: primary is forward, dropdown offers reset-to-first', () => {
|
|
63
|
+
const result = computeStatusTransitions('review', flow, published)
|
|
64
|
+
expect(result.primaryStatus).toBe(published)
|
|
65
|
+
expect(result.isTerminal).toBe(false)
|
|
66
|
+
// Reset-to-first (draft) is surfaced; the forward step (published) is the
|
|
67
|
+
// primary and filtered out. The "back one step" is draft itself here, so
|
|
68
|
+
// it is not duplicated.
|
|
69
|
+
expect(result.secondaryStatuses).toEqual([draft])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('at the terminal step: primary is the current status, back-steps in dropdown', () => {
|
|
73
|
+
const result = computeStatusTransitions('published', flow, undefined)
|
|
74
|
+
expect(result.primaryStatus).toBe(published)
|
|
75
|
+
expect(result.isTerminal).toBe(true)
|
|
76
|
+
// Both reset-to-first (draft) and back-one-step (review) are offered.
|
|
77
|
+
expect(result.secondaryStatuses).toEqual([draft, review])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('terminal back-one-step is omitted when the previous is already the first', () => {
|
|
81
|
+
const twoStep = [draft, published]
|
|
82
|
+
const result = computeStatusTransitions('published', twoStep, undefined)
|
|
83
|
+
expect(result.isTerminal).toBe(true)
|
|
84
|
+
// Only reset-to-first (draft); no separate back-one-step since prev === first.
|
|
85
|
+
expect(result.secondaryStatuses).toEqual([draft])
|
|
86
|
+
})
|
|
87
|
+
})
|