@byline/admin 6.1.0 → 6.2.1

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 (119) hide show
  1. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  2. package/dist/forms/form-layout.d.ts +39 -0
  3. package/dist/forms/form-layout.js +100 -0
  4. package/dist/forms/form-page-chrome.d.ts +110 -0
  5. package/dist/forms/form-page-chrome.js +253 -0
  6. package/dist/forms/form-renderer.d.ts +2 -18
  7. package/dist/forms/form-renderer.js +104 -418
  8. package/dist/forms/form-renderer.module.js +2 -0
  9. package/dist/forms/form-renderer_module.css +13 -0
  10. package/dist/forms/use-form-submission.d.ts +74 -0
  11. package/dist/forms/use-form-submission.js +166 -0
  12. package/dist/forms/use-form-tabs.d.ts +29 -0
  13. package/dist/forms/use-form-tabs.js +45 -0
  14. package/dist/presentation/tab-strip-drag.d.ts +75 -0
  15. package/dist/presentation/tab-strip-drag.js +25 -0
  16. package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
  17. package/dist/presentation/tab-strip-geometry.d.ts +75 -0
  18. package/dist/presentation/tab-strip-geometry.js +36 -0
  19. package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
  20. package/dist/presentation/tabs.d.ts +30 -7
  21. package/dist/presentation/tabs.js +260 -29
  22. package/dist/presentation/tabs.module.js +11 -1
  23. package/dist/presentation/tabs_module.css +166 -5
  24. package/package.json +7 -7
  25. package/src/forms/form-layout-styles.test.node.ts +32 -0
  26. package/src/forms/form-layout.test.tsx +276 -0
  27. package/src/forms/form-layout.tsx +198 -0
  28. package/src/forms/form-page-chrome.tsx +460 -0
  29. package/src/forms/form-renderer-submit.test.tsx +40 -0
  30. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  31. package/src/forms/form-renderer.module.css +18 -0
  32. package/src/forms/form-renderer.tsx +115 -593
  33. package/src/forms/use-form-submission.test.tsx +564 -0
  34. package/src/forms/use-form-submission.ts +293 -0
  35. package/src/forms/use-form-tabs.ts +89 -0
  36. package/src/presentation/tab-strip-drag.test.node.ts +143 -0
  37. package/src/presentation/tab-strip-drag.ts +114 -0
  38. package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
  39. package/src/presentation/tab-strip-geometry.ts +158 -0
  40. package/src/presentation/tabs.module.css +209 -11
  41. package/src/presentation/tabs.test.tsx +481 -0
  42. package/src/presentation/tabs.tsx +369 -34
  43. package/dist/exports-parity.test.node.js +0 -37
  44. package/dist/fields/array/array-field.jsx +0 -209
  45. package/dist/fields/blocks/blocks-field.jsx +0 -248
  46. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  47. package/dist/fields/code/code-editor.jsx +0 -211
  48. package/dist/fields/code/code-field.jsx +0 -109
  49. package/dist/fields/column-formatter.jsx +0 -25
  50. package/dist/fields/date-time-formatter.jsx +0 -18
  51. package/dist/fields/datetime/datetime-field.jsx +0 -22
  52. package/dist/fields/draggable-context-menu.jsx +0 -50
  53. package/dist/fields/field-admin.test.node.js +0 -40
  54. package/dist/fields/field-renderer.jsx +0 -136
  55. package/dist/fields/field-services-context.jsx +0 -17
  56. package/dist/fields/file/file-field.jsx +0 -176
  57. package/dist/fields/file/file-upload-field.jsx +0 -89
  58. package/dist/fields/group/group-field.jsx +0 -46
  59. package/dist/fields/image/image-field.jsx +0 -183
  60. package/dist/fields/image/image-upload-field.jsx +0 -157
  61. package/dist/fields/local-date-time.jsx +0 -69
  62. package/dist/fields/locale-badge.jsx +0 -19
  63. package/dist/fields/numerical/numerical-field.jsx +0 -93
  64. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  65. package/dist/fields/relation/relation-display.jsx +0 -118
  66. package/dist/fields/relation/relation-field.jsx +0 -108
  67. package/dist/fields/relation/relation-many-field.jsx +0 -142
  68. package/dist/fields/relation/relation-picker.jsx +0 -240
  69. package/dist/fields/relation/relation-summary.jsx +0 -45
  70. package/dist/fields/select/select-field.jsx +0 -27
  71. package/dist/fields/sortable-item.jsx +0 -54
  72. package/dist/fields/text/text-field.jsx +0 -79
  73. package/dist/fields/text-area/text-area-field.jsx +0 -79
  74. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  75. package/dist/forms/available-locales-widget.jsx +0 -63
  76. package/dist/forms/document-actions.jsx +0 -486
  77. package/dist/forms/form-context.jsx +0 -598
  78. package/dist/forms/form-modals.jsx +0 -128
  79. package/dist/forms/form-renderer.jsx +0 -532
  80. package/dist/forms/form-status-display.jsx +0 -69
  81. package/dist/forms/navigation-guard.jsx +0 -68
  82. package/dist/forms/nested-path.test.node.js +0 -115
  83. package/dist/forms/path-widget.jsx +0 -125
  84. package/dist/forms/pending-uploads.test.node.js +0 -19
  85. package/dist/forms/repeating-items.test.node.js +0 -29
  86. package/dist/forms/scheduled-publication-control.jsx +0 -389
  87. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  88. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  89. package/dist/forms/status-transitions.test.node.js +0 -76
  90. package/dist/forms/tree-placement-widget.jsx +0 -160
  91. package/dist/forms/upload-executor.test.node.js +0 -368
  92. package/dist/forms/use-form-layout.test.node.js +0 -69
  93. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  94. package/dist/modules/admin-account/components/container.jsx +0 -164
  95. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  96. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  97. package/dist/modules/admin-account/components/update.jsx +0 -186
  98. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  99. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  100. package/dist/modules/admin-roles/components/create.jsx +0 -155
  101. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  102. package/dist/modules/admin-roles/components/update.jsx +0 -148
  103. package/dist/modules/admin-users/components/create.jsx +0 -206
  104. package/dist/modules/admin-users/components/roles.jsx +0 -126
  105. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  106. package/dist/modules/admin-users/components/update.jsx +0 -215
  107. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  108. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  109. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  110. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  111. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  112. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  113. package/dist/presentation/group.jsx +0 -26
  114. package/dist/presentation/row.jsx +0 -23
  115. package/dist/presentation/tabs.jsx +0 -39
  116. package/dist/services/admin-services-context.jsx +0 -17
  117. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  118. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  119. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -10,47 +10,37 @@
10
10
 
11
11
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
12
 
13
- import type {
14
- AdminResourceConfig,
15
- Field,
16
- GroupDefinition,
17
- RowDefinition,
18
- TabSetDefinition,
19
- WorkflowStatus,
20
- } from '@byline/core'
13
+ import type { AdminResourceConfig, Field, WorkflowStatus } from '@byline/core'
21
14
  import { getAdminConfig } from '@byline/core'
22
- import type { DocumentPatch } from '@byline/core/patches'
23
15
  import { useTranslation } from '@byline/i18n/react'
24
- import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
25
16
  import cx from 'clsx'
26
17
 
27
- import { sliceFieldAdmin } from '../fields/field-admin'
28
- import { FieldRenderer } from '../fields/field-renderer'
29
18
  import { useBylineFieldServices } from '../fields/field-services-context'
30
- import { AdminGroup } from '../presentation/group'
31
- import { AdminRow } from '../presentation/row'
32
- import { AdminTabs } from '../presentation/tabs'
33
- import { AvailableLocalesWidget } from './available-locales-widget'
34
- import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
35
19
  import { FormProvider, useFieldValue, useFormContext } from './form-context'
20
+ import { FormLayout } from './form-layout'
36
21
  import { NavigationGuardModal, SystemFieldsConfirmModal, UnsavedChangesModal } from './form-modals'
22
+ import {
23
+ FormConcurrencyNotices,
24
+ FormHeadingRow,
25
+ FormSidebarWidgets,
26
+ FormStatusBar,
27
+ } from './form-page-chrome'
37
28
  import styles from './form-renderer.module.css'
38
- import { FormStatusDisplay } from './form-status-display'
39
29
  import { useNavigationGuardAdapter } from './navigation-guard'
40
- import { PathWidget } from './path-widget'
41
30
  import {
42
- ScheduledPublicationCell,
43
31
  type ScheduledPublicationInfo,
44
- ScheduledPublicationNotice,
45
32
  type SchedulePublicationInput,
46
33
  useScheduledPublication,
47
34
  } from './scheduled-publication-control'
48
35
  import { computeStatusTransitions } from './status-transitions'
49
- import { TreePlacementWidget } from './tree-placement-widget'
50
- import { executeUploadsWithProgress } from './upload-executor'
51
- import { useFormLayout } from './use-form-layout'
36
+ import { useFormSubmission } from './use-form-submission'
37
+ import type { DocumentActionsLocaleOption } from './document-actions'
52
38
  import type { UseNavigationGuard } from './navigation-guard'
53
39
 
40
+ // Re-exported so existing importers of this module keep working; the type now
41
+ // lives with `useFormSubmission`, which produces it.
42
+ export type { SystemFieldsSubmitPayload } from './use-form-submission'
43
+
54
44
  /** Metadata about a previously published version that is still live. */
55
45
  export interface PublishedVersionInfo {
56
46
  id: string
@@ -60,24 +50,6 @@ export interface PublishedVersionInfo {
60
50
  updatedAt: string | Date
61
51
  }
62
52
 
63
- /**
64
- * Payload emitted by the form on Save. Carries the content (field data +
65
- * patches) alongside the document-grain system fields (path / advertised
66
- * locales) and per-bucket dirty flags so the host can route each piece to the
67
- * right write path — versioned for content, immediate/non-versioned for the
68
- * system fields. See docs/08-internationalization/index.md.
69
- */
70
- export interface SystemFieldsSubmitPayload {
71
- // biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
72
- data: any
73
- patches: DocumentPatch[]
74
- contentDirty: boolean
75
- pathDirty: boolean
76
- systemPath?: string | null
77
- availableLocalesDirty: boolean
78
- systemAvailableLocales?: string[]
79
- }
80
-
81
53
  /** Props shared by both the public FormRenderer and its internal FormContent component. */
82
54
  export interface FormRendererProps {
83
55
  mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null
@@ -259,34 +231,13 @@ const FormContent = ({
259
231
  _activeTabBySet?: Record<string, string>
260
232
  _onTabChange?: (tabSetName: string, tabName: string) => void
261
233
  }) => {
262
- const {
263
- getFieldValues,
264
- runFieldHooks,
265
- validateForm,
266
- errors: initialErrors,
267
- hasChanges: hasChangesFn,
268
- resetHasChanges,
269
- getPatches,
270
- getDirtyBreakdown,
271
- getSystemPath,
272
- getSystemAvailableLocales,
273
- subscribeErrors,
274
- subscribeMeta,
275
- setFieldValue,
276
- setFieldError,
277
- getPendingUploads,
278
- clearPendingUploads,
279
- setFieldUploading,
280
- } = useFormContext()
234
+ // Field state now reaches the submission hook and the layout walk directly;
235
+ // FormContent keeps only what its own chrome needs.
236
+ const { hasChanges: hasChangesFn, subscribeMeta } = useFormContext()
281
237
  const { t } = useTranslation('byline-admin')
282
238
 
283
- const [errors, setErrors] = useState(initialErrors)
284
239
  const [hasChanges, setHasChanges] = useState(hasChangesFn())
285
240
  const [statusBusy, setStatusBusy] = useState(false)
286
- const [isUploading, setIsUploading] = useState(false)
287
- const submittingRef = useRef(false)
288
- const [isSubmitting, setIsSubmitting] = useState(false)
289
- const isBusy = isUploading || isSubmitting
290
241
  const formRef = useRef<HTMLFormElement>(null)
291
242
  const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
292
243
  const restoreFocusAfterBusyRef = useRef(false)
@@ -295,11 +246,6 @@ const FormContent = ({
295
246
  // is dirty — those actions operate on the saved version, so unsaved edits
296
247
  // would be silently excluded.
297
248
  const [showUnsavedModal, setShowUnsavedModal] = useState(false)
298
- // Holds the pending Save payload while the editor confirms an immediate,
299
- // non-versioned system-field write (path / advertised locales). Non-null
300
- // means the confirmation modal is open. See docs/08-internationalization/index.md.
301
- const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] =
302
- useState<SystemFieldsSubmitPayload | null>(null)
303
249
  const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
304
250
 
305
251
  // Scheduled publication owns three placements — a status-bar cell, an
@@ -334,13 +280,6 @@ const FormContent = ({
334
280
  if (initialLocale) setContentLocale(initialLocale)
335
281
  }, [initialLocale])
336
282
 
337
- // Layout primitives + lookup tables — pure derivations of `adminConfig` +
338
- // `fields`. The validator at startup guarantees every reachable name
339
- // resolves and every schema field is placed at most once, so the render-time
340
- // lookups below are unguarded. See ./use-form-layout.
341
- const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
342
- useFormLayout(adminConfig, fields)
343
-
344
283
  // ---------------------------------------------------------------------
345
284
  // Active-tab state — one tab name per declared tab set.
346
285
  // Lifted into FormRenderer via `_activeTabBySet` / `_onTabChange` so the
@@ -376,10 +315,6 @@ const FormContent = ({
376
315
  [_onTabChange]
377
316
  )
378
317
 
379
- // Track live form data so TabDefinition.condition functions can react to
380
- // field changes. Re-evaluated per keystroke via the meta-subscribe loop.
381
- const [formData, setFormData] = useState<Record<string, any>>(() => getFieldValues())
382
-
383
318
  // Live document heading — tracks the useAsTitle field as the user types
384
319
  const liveTitle = useFieldValue<string>(useAsTitle ?? '')
385
320
  const computedHeading =
@@ -425,18 +360,35 @@ const FormContent = ({
425
360
  nextStatus
426
361
  )
427
362
 
428
- useEffect(() => {
429
- return subscribeErrors((newErrors) => setErrors(newErrors))
430
- }, [subscribeErrors])
431
-
432
363
  useEffect(() => {
433
364
  return subscribeMeta(() => setHasChanges(hasChangesFn()))
434
365
  }, [subscribeMeta, hasChangesFn])
435
366
 
436
- // Keep formData in sync for evaluating TabDefinition.condition functions
437
- useEffect(() => {
438
- return subscribeMeta(() => setFormData(getFieldValues()))
439
- }, [subscribeMeta, getFieldValues])
367
+ const captureFocusBeforeBusy = useCallback(() => {
368
+ if (focusBeforeBusyRef.current != null) return
369
+ const activeElement = document.activeElement
370
+ if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
371
+ focusBeforeBusyRef.current = activeElement
372
+ restoreFocusAfterBusyRef.current = true
373
+ }, [])
374
+
375
+ // One save at a time: validate -> upload -> (confirm) -> submit. Admission is
376
+ // decided synchronously inside the hook, so two Saves in the same turn cannot
377
+ // both get through. `phase` is what the UI renders.
378
+ const submission = useFormSubmission({
379
+ mode,
380
+ fields,
381
+ documentId: mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
382
+ advertiseLocales,
383
+ onSubmit,
384
+ isBlocked: () => mutationBlockedRef.current,
385
+ onBeforeBusy: captureFocusBeforeBusy,
386
+ })
387
+ const isUploading = submission.phase.kind === 'uploading'
388
+ const isSubmitting = submission.phase.kind === 'submitting'
389
+ const isBusy = submission.isBusy
390
+ const pendingSystemFieldsSubmit =
391
+ submission.phase.kind === 'confirmingSystemFields' ? submission.phase.payload : null
440
392
 
441
393
  // `inert` removes the active control from the tab order while a save is in
442
394
  // flight. Restore the editor's position after React has removed `inert`;
@@ -463,233 +415,16 @@ const FormContent = ({
463
415
  target?.focus({ preventScroll: true })
464
416
  }, [isBusy, mutationIssue])
465
417
 
466
- const captureFocusBeforeBusy = useCallback(() => {
467
- if (focusBeforeBusyRef.current != null) return
468
- const activeElement = document.activeElement
469
- if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
470
- focusBeforeBusyRef.current = activeElement
471
- restoreFocusAfterBusyRef.current = true
472
- }, [])
473
-
474
418
  const handleCancel = () => {
475
419
  if (onCancel && typeof onCancel === 'function') {
476
420
  onCancel()
477
421
  }
478
422
  }
479
423
 
480
- // Await the host handler. Resolution means the save succeeded and the clean
481
- // baseline can be committed; rejection preserves dirty state so the editor
482
- // does not lose work and the navigation guard keeps blocking. Host handlers
483
- // that surface their own toast MUST rethrow afterwards.
484
- // Re-entry guard lives in a ref so the callback identity does not change
485
- // mid-flight; the mirrored state drives the Save button's disabled prop.
486
- const submitPayload = useCallback(
487
- async (payload: SystemFieldsSubmitPayload) => {
488
- if (mutationBlockedRef.current || typeof onSubmit !== 'function') return
489
- if (submittingRef.current) return
490
- submittingRef.current = true
491
- captureFocusBeforeBusy()
492
- setIsSubmitting(true)
493
- try {
494
- await onSubmit(payload)
495
- resetHasChanges()
496
- } catch {
497
- // Intentionally swallowed here — the host has already reported the
498
- // failure to the user. Dirty state is preserved by not resetting.
499
- } finally {
500
- submittingRef.current = false
501
- setIsSubmitting(false)
502
- }
503
- },
504
- [captureFocusBeforeBusy, onSubmit, resetHasChanges]
505
- )
506
-
507
424
  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
508
- if (mutationBlockedRef.current) {
509
- e.preventDefault()
510
- return
511
- }
512
425
  e.preventDefault()
513
-
514
- // Run field-level beforeValidate hooks (submit-time), then validate
515
- void (async () => {
516
- const hookErrors = await runFieldHooks(fields)
517
- const formErrors = validateForm(fields)
518
- const allErrors = [...hookErrors, ...formErrors]
519
-
520
- if (allErrors.length > 0) {
521
- console.error('Form validation failed:', allErrors)
522
- return
523
- }
524
-
525
- if (mutationBlockedRef.current) return
526
-
527
- // Execute any pending uploads before submitting
528
- const pendingUploads = getPendingUploads()
529
- if (pendingUploads.size > 0) {
530
- captureFocusBeforeBusy()
531
- setIsUploading(true)
532
- try {
533
- const uploadResult = await executeUploadsWithProgress(
534
- pendingUploads,
535
- uploadField,
536
- ({ fieldPath, status }) => {
537
- setFieldUploading(fieldPath, status === 'uploading')
538
- },
539
- {
540
- // Document context for server-side upload hooks: the persisted
541
- // document id (edit mode only) plus any `upload.context` form
542
- // values declared on the schema field. See UploadConfig.context
543
- // in @byline/core.
544
- documentId:
545
- mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
546
- fields,
547
- getFormValues: getFieldValues,
548
- }
549
- )
550
-
551
- // Check for upload errors
552
- if (!uploadResult.allSucceeded) {
553
- // Set field-level errors for failed uploads
554
- for (const [fieldPath, errorMessage] of uploadResult.errors.entries()) {
555
- setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
556
- }
557
- console.error('One or more uploads failed:', uploadResult.errors)
558
- setIsUploading(false)
559
- return
560
- }
561
-
562
- // Replace pending StoredFileValues with real ones in form data
563
- for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
564
- setFieldValue(fieldPath, storedFile)
565
- }
566
-
567
- // Clear pending uploads (blob URLs already revoked by clearPendingUploads)
568
- clearPendingUploads()
569
- } catch (err) {
570
- console.error('Upload execution error:', err)
571
- setIsUploading(false)
572
- return
573
- }
574
- setIsUploading(false)
575
- }
576
-
577
- const data = getFieldValues()
578
- const patches = getPatches()
579
- const { contentDirty, pathDirty, availableLocalesDirty, reason } = getDirtyBreakdown()
580
- const systemPath = getSystemPath()
581
- // Only emit the advertised-locale set for collections that opted into the
582
- // widget — otherwise leave it undefined so the write path never touches
583
- // `byline_document_available_locales` for non-advertising collections.
584
- const systemAvailableLocales = advertiseLocales ? getSystemAvailableLocales() : undefined
585
-
586
- const payload: SystemFieldsSubmitPayload = {
587
- data,
588
- patches,
589
- contentDirty,
590
- pathDirty,
591
- systemPath,
592
- availableLocalesDirty,
593
- systemAvailableLocales,
594
- }
595
-
596
- // Editing the document-grain system fields (path / advertised locales) is
597
- // an immediate, non-versioned write that does NOT reset workflow status,
598
- // so confirm it before saving. Create mode writes everything as part of
599
- // the initial version, so no confirmation applies there.
600
- if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
601
- setPendingSystemFieldsSubmit(payload)
602
- return
603
- }
604
-
605
- await submitPayload(payload)
606
- })()
607
- }
608
-
609
- // Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
610
- // Each <Tabs> bar consumes its own slice.
611
- const tabErrorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
612
- const result: Record<string, Record<string, number>> = {}
613
- for (const err of errors) {
614
- const path = fieldToTabPath.get(err.field)
615
- if (!path) continue
616
- result[path.tabSetName] ??= {}
617
- result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
618
- }
619
- return result
620
- }, [errors, fieldToTabPath])
621
-
622
- // -------------------------------------------------------------------
623
- // Layout walk — recursively dispatches each name in a region to the
624
- // appropriate primitive renderer or to <FieldRenderer>.
625
- // -------------------------------------------------------------------
626
-
627
- const renderField = (fieldName: string): ReactNode => {
628
- const field = fieldByName.get(fieldName)
629
- if (!field) return null
630
- return (
631
- <FieldRenderer
632
- key={field.name}
633
- field={field}
634
- defaultValue={initialData?.fields?.[field.name]}
635
- contentLocale={contentLocale}
636
- components={adminConfig?.fields?.[field.name]?.components}
637
- editor={adminConfig?.fields?.[field.name]?.editor}
638
- fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
639
- />
640
- )
641
- }
642
-
643
- const renderItem = (name: string): ReactNode => {
644
- const tabSet = tabSetByName.get(name)
645
- if (tabSet) return renderTabSet(tabSet)
646
-
647
- const group = groupByName.get(name)
648
- if (group) return renderGroup(group)
649
-
650
- const row = rowByName.get(name)
651
- if (row) return renderRow(row)
652
-
653
- return renderField(name)
654
- }
655
-
656
- const renderRow = (row: RowDefinition): ReactNode => (
657
- <AdminRow key={`row:${row.name}`}>{row.fields.map((name) => renderField(name))}</AdminRow>
658
- )
659
-
660
- const renderGroup = (group: GroupDefinition): ReactNode => (
661
- <AdminGroup key={`group:${group.name}`} label={group.label}>
662
- {group.fields.map((name) => renderItem(name))}
663
- </AdminGroup>
664
- )
665
-
666
- const renderTabSet = (set: TabSetDefinition): ReactNode => {
667
- const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(formData))
668
- const requested = activeTabBySet[set.name] ?? ''
669
- const resolvedActive =
670
- visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
671
- ? (visibleTabs[0]?.name ?? requested)
672
- : requested
673
- const activeTab = visibleTabs.find((t) => t.name === resolvedActive)
674
-
675
- return (
676
- <div key={`tabset:${set.name}`} className={cx('byline-form-tabset', styles.tabset)}>
677
- {visibleTabs.length > 0 && (
678
- <AdminTabs
679
- tabs={visibleTabs}
680
- activeTab={resolvedActive}
681
- onChange={(tabName) => handleTabChange(set.name, tabName)}
682
- errorCounts={tabErrorCountsBySet[set.name]}
683
- className={cx('byline-form-tabset-tabs', styles['tabset-tabs'])}
684
- />
685
- )}
686
- {activeTab && (
687
- <div className={cx('byline-form-tabset-fields', styles['tabset-fields'])}>
688
- {activeTab.fields.map((name) => renderItem(name))}
689
- </div>
690
- )}
691
- </div>
692
- )
426
+ if (mutationBlockedRef.current) return
427
+ void submission.submit()
693
428
  }
694
429
 
695
430
  const busyAnnouncement = isUploading
@@ -716,302 +451,89 @@ const FormContent = ({
716
451
  className={cx('byline-form', styles.form)}
717
452
  inert={isBusy ? true : undefined}
718
453
  >
719
- <div className={cx('byline-form-heading-row', styles['heading-row'])}>
720
- <h1 className={cx('byline-form-heading', styles.heading)}>{computedHeading}</h1>
721
- {/* Source-locale anchor indicator removed pending heading-layout work.
722
- To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
723
- here from `initialData.sourceLocale` (mismatch-only is the intended
724
- end state). See docs/08-internationalization/index.md. */}
725
- {headerSlot}
726
- </div>
727
- <div className={cx('byline-form-status-bar', styles['status-bar'])}>
728
- <div className={cx('byline-form-status-details', styles['status-details'])}>
729
- <FormStatusDisplay
454
+ <FormHeadingRow heading={computedHeading} headerSlot={headerSlot} />
455
+ <FormStatusBar
456
+ disabled={mutationsBlocked || discarding}
457
+ initialData={initialData}
458
+ workflowStatuses={workflowStatuses}
459
+ publishedVersion={publishedVersion}
460
+ scheduling={scheduling}
461
+ hasChanges={hasChanges}
462
+ isUploading={isUploading}
463
+ isSubmitting={isSubmitting}
464
+ onCancel={handleCancel}
465
+ transitions={{ primaryStatus, secondaryStatuses, isTerminal }}
466
+ statusBusy={statusBusy}
467
+ onStatusBusyChange={setStatusBusy}
468
+ onStatusChange={onStatusChange}
469
+ onMutationError={onMutationError}
470
+ isBlocked={() => mutationBlockedRef.current}
471
+ onUnsavedChanges={() => setShowUnsavedModal(true)}
472
+ onUnpublish={onUnpublish}
473
+ onDelete={onDelete}
474
+ onDuplicate={onDuplicate}
475
+ onCopyToLocale={onCopyToLocale}
476
+ onDeleteLocale={onDeleteLocale}
477
+ useAsTitle={useAsTitle}
478
+ contentLocale={contentLocale}
479
+ contentLocales={contentLocales}
480
+ defaultLocale={defaultLocale}
481
+ />
482
+ <FormConcurrencyNotices
483
+ disabled={mutationsBlocked || discarding}
484
+ mutationIssue={mutationIssue}
485
+ scheduledPublicationsNeedReconfirmation={scheduledPublicationsNeedReconfirmation}
486
+ scheduledPublicationsHref={scheduledPublicationsHref}
487
+ warningRef={warningRef}
488
+ discarding={discarding}
489
+ reloadFailed={reloadFailed}
490
+ onDiscardRequested={() => {
491
+ setReloadFailed(false)
492
+ setDiscarding(true)
493
+ }}
494
+ scheduling={scheduling}
495
+ restoreWarnings={restoreWarnings}
496
+ />
497
+ <FormLayout
498
+ fields={fields}
499
+ adminConfig={adminConfig}
500
+ initialData={initialData}
501
+ activeLocale={contentLocale}
502
+ collectionPath={collectionPath ?? undefined}
503
+ activeTabBySet={activeTabBySet}
504
+ onTabChange={handleTabChange}
505
+ sidebarSlot={
506
+ <FormSidebarWidgets
730
507
  disabled={mutationsBlocked || discarding}
508
+ mode={mode}
731
509
  initialData={initialData}
732
- workflowStatuses={workflowStatuses}
733
- publishedVersion={publishedVersion}
734
- onUnpublish={onUnpublish}
735
- afterStatusCells={
736
- <ScheduledPublicationCell
737
- state={scheduling.state}
738
- timeZone={scheduling.timeZone}
739
- />
740
- }
741
- />
742
- </div>
743
- <div className={cx('byline-form-actions', styles.actions)}>
744
- <Button
745
- className={cx('byline-form-actions-button', styles['actions-button'])}
746
- size="sm"
747
- intent="noeffect"
748
- type="button"
749
- onClick={handleCancel}
750
- >
751
- {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
752
- </Button>
753
- <Button
754
- className={cx('byline-form-actions-button', styles['actions-button'])}
755
- size="sm"
756
- type="submit"
757
- disabled={
758
- mutationsBlocked ||
759
- discarding ||
760
- hasChanges === false ||
761
- isUploading ||
762
- isSubmitting
763
- }
764
- aria-label={isSubmitting ? t('common.actions.save') : undefined}
765
- >
766
- {isUploading ? (
767
- t('forms.actions.uploading')
768
- ) : (
769
- <span className={cx('byline-form-save-content', styles['save-content'])}>
770
- <span
771
- className={cx(
772
- 'byline-form-save-label',
773
- styles['save-label'],
774
- isSubmitting && styles['save-label-hidden']
775
- )}
776
- >
777
- {t('common.actions.save')}
778
- </span>
779
- {isSubmitting ? (
780
- <span className={cx('byline-form-save-loader', styles['save-loader'])}>
781
- <LoaderEllipsis size={28} aria-hidden="true" />
782
- </span>
783
- ) : null}
784
- </span>
785
- )}
786
- </Button>
787
- {primaryStatus && onStatusChange && (
788
- <div
789
- className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}
790
- >
791
- <ComboButton
792
- buttonClassName={cx(
793
- 'byline-form-actions-combo-button',
794
- styles['actions-combo-button']
795
- )}
796
- triggerClassName={cx(
797
- 'byline-form-actions-combo-trigger',
798
- styles['actions-combo-trigger']
799
- )}
800
- options={secondaryStatuses.map((s) => ({
801
- label: isTerminal
802
- ? t('forms.actions.revertTo', { label: s.label ?? s.name })
803
- : (s.verb ?? s.label ?? s.name),
804
- value: s.name,
805
- }))}
806
- sideOffset={5}
807
- size="sm"
808
- type="button"
809
- intent={isTerminal ? 'info' : 'success'}
810
- disabled={mutationsBlocked || discarding || statusBusy}
811
- onOptionSelect={async (value: string) => {
812
- if (mutationBlockedRef.current) return
813
- if (hasChanges) {
814
- setShowUnsavedModal(true)
815
- return
816
- }
817
- setStatusBusy(true)
818
- try {
819
- await onStatusChange(value)
820
- } catch (error) {
821
- onMutationError?.(error)
822
- } finally {
823
- setStatusBusy(false)
824
- }
825
- }}
826
- onButtonClick={
827
- isTerminal
828
- ? undefined
829
- : async () => {
830
- if (mutationBlockedRef.current) return
831
- if (hasChanges) {
832
- setShowUnsavedModal(true)
833
- return
834
- }
835
- setStatusBusy(true)
836
- try {
837
- await onStatusChange(primaryStatus.name)
838
- } catch (error) {
839
- onMutationError?.(error)
840
- } finally {
841
- setStatusBusy(false)
842
- }
843
- }
844
- }
845
- >
846
- {statusBusy
847
- ? '...'
848
- : isTerminal
849
- ? (primaryStatus.label ?? primaryStatus.name)
850
- : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
851
- </ComboButton>
852
- </div>
853
- )}
854
- <DocumentActions
855
- disabled={mutationsBlocked || discarding}
856
- publishedVersion={publishedVersion}
857
- onUnpublish={onUnpublish}
858
- onDelete={onDelete}
859
- onDuplicate={onDuplicate}
860
- sourceTitle={
861
- useAsTitle != null && initialData != null
862
- ? ((initialData as Record<string, unknown>)[useAsTitle] as
863
- | string
864
- | null
865
- | undefined)
866
- : null
867
- }
868
- onCopyToLocale={onCopyToLocale}
869
- sourceLocale={contentLocale}
870
- contentLocales={contentLocales}
871
- hasUnsavedChanges={hasChanges}
872
- onUnsavedChanges={() => setShowUnsavedModal(true)}
873
- onDeleteLocale={onDeleteLocale}
510
+ collectionPath={collectionPath ?? ''}
874
511
  defaultLocale={defaultLocale}
875
- availableLocales={initialData?._availableVersionLocales as string[] | undefined}
876
- scheduledPublicationState={scheduling.state}
877
- onSchedulePublication={scheduling.openSchedule}
878
- onConfirmScheduledPublication={scheduling.confirm}
879
- onCancelScheduledPublication={scheduling.cancel}
512
+ contentLocale={contentLocale}
513
+ contentLocales={contentLocales}
514
+ showPath={showPath}
515
+ useAsPath={useAsPath}
516
+ lockPath={adminConfig?.lockPath}
517
+ pathSlugifier={pathSlugifier}
518
+ pathSourceLocked={pathSourceLocked}
519
+ tree={tree}
520
+ useAsTitle={useAsTitle}
521
+ advertiseLocales={advertiseLocales}
522
+ observedRevision={observedRevision}
523
+ onMutationError={onMutationError}
524
+ onTreeMutationCommitted={onTreeMutationCommitted}
880
525
  />
881
- </div>
882
- </div>
883
- {(mutationIssue || scheduledPublicationsNeedReconfirmation) && (
884
- <div
885
- ref={warningRef}
886
- tabIndex={-1}
887
- role="alert"
888
- aria-live="assertive"
889
- className={cx('byline-document-concurrency', styles.concurrency)}
890
- >
891
- {mutationIssue && (
892
- <Alert
893
- intent="warning"
894
- icon
895
- close={false}
896
- title={t(`documentConcurrency.${mutationIssue}Title`)}
897
- >
898
- <p>{t(`documentConcurrency.${mutationIssue}`)}</p>
899
- {mutationIssue !== 'committed' && (
900
- <Button
901
- type="button"
902
- disabled={discarding}
903
- onClick={() => {
904
- setReloadFailed(false)
905
- setDiscarding(true)
906
- }}
907
- >
908
- {t('documentConcurrency.reloadAction')}
909
- </Button>
910
- )}
911
- {reloadFailed && <p>{t('documentConcurrency.reloadFailed')}</p>}
912
- </Alert>
913
- )}
914
- {scheduledPublicationsNeedReconfirmation && (
915
- <Alert
916
- intent="warning"
917
- icon
918
- close={false}
919
- title={t('documentConcurrency.schedulesTitle')}
920
- >
921
- <p>{t('documentConcurrency.schedules')}</p>
922
- {scheduledPublicationsHref && (
923
- <a href={scheduledPublicationsHref}>
924
- {t('documentConcurrency.reviewSchedules')}
925
- </a>
926
- )}
927
- </Alert>
928
- )}
929
- </div>
930
- )}
931
- <ScheduledPublicationNotice
932
- state={scheduling.state}
933
- timeZone={scheduling.timeZone}
934
- busy={mutationsBlocked || discarding || scheduling.busy}
935
- onConfirm={scheduling.confirm}
936
- onReschedule={scheduling.openSchedule}
937
- onCancel={scheduling.cancel}
526
+ }
938
527
  />
939
- {scheduling.modal}
940
- {restoreWarnings && restoreWarnings.length > 0 && (
941
- <Alert
942
- className="m-0 mt-4"
943
- intent="warning"
944
- icon={true}
945
- close={false}
946
- title={t('forms.restoreWarnings.title')}
947
- >
948
- <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
949
- <ul>
950
- {restoreWarnings.map((w) => (
951
- <li key={w}>{w}</li>
952
- ))}
953
- </ul>
954
- </Alert>
955
- )}
956
- <div className={cx('byline-form-layout', styles.layout)}>
957
- <div className={cx('byline-form-content', styles.content)}>
958
- {layout.main.map((name) => renderItem(name))}
959
- </div>
960
- <div className={cx('byline-form-sidebar', styles.sidebar)}>
961
- {/* A locked collection's widget renders even with no `useAsPath`
962
- and nothing stored yet: its path is managed, and the editor
963
- needs to see that. `showPath: false` still wins — it marks a
964
- path that must never be presented at all. */}
965
- {showPath &&
966
- (useAsPath ||
967
- adminConfig?.lockPath === true ||
968
- (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
969
- <PathWidget
970
- disabled={mutationsBlocked || discarding}
971
- useAsPath={useAsPath}
972
- collectionPath={collectionPath ?? ''}
973
- defaultLocale={defaultLocale}
974
- activeLocale={contentLocale}
975
- mode={mode}
976
- slugifier={pathSlugifier}
977
- sourceLocked={pathSourceLocked}
978
- lockPath={adminConfig?.lockPath}
979
- />
980
- )}
981
- {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
982
- <TreePlacementWidget
983
- disabled={mutationsBlocked || discarding}
984
- onMutationError={onMutationError}
985
- onCommitted={onTreeMutationCommitted}
986
- expectedRevision={observedRevision ?? initialData.revision}
987
- collectionPath={collectionPath ?? ''}
988
- documentId={initialData.id as string}
989
- useAsTitle={useAsTitle}
990
- />
991
- )}
992
- {advertiseLocales && (
993
- <AvailableLocalesWidget
994
- disabled={mutationsBlocked || discarding}
995
- contentLocales={contentLocales ?? []}
996
- availableVersionLocales={
997
- (initialData?._availableVersionLocales as string[] | undefined) ?? []
998
- }
999
- />
1000
- )}
1001
- {(layout.sidebar ?? []).map((name) => renderItem(name))}
1002
- </div>
1003
- </div>
1004
528
  {showUnsavedModal && <UnsavedChangesModal onClose={() => setShowUnsavedModal(false)} />}
1005
529
  {!mutationsBlocked && !discarding && pendingSystemFieldsSubmit != null && (
1006
530
  <SystemFieldsConfirmModal
1007
531
  contentDirty={pendingSystemFieldsSubmit.contentDirty}
1008
532
  pathDirty={pendingSystemFieldsSubmit.pathDirty}
1009
533
  availableLocalesDirty={pendingSystemFieldsSubmit.availableLocalesDirty}
1010
- onCancel={() => setPendingSystemFieldsSubmit(null)}
534
+ onCancel={submission.cancelSystemFields}
1011
535
  onConfirm={() => {
1012
- const payload = pendingSystemFieldsSubmit
1013
- setPendingSystemFieldsSubmit(null)
1014
- void submitPayload(payload)
536
+ void submission.confirmSystemFields()
1015
537
  }}
1016
538
  />
1017
539
  )}