@byline/admin 6.2.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/fields/checkbox/checkbox-field.js +3 -1
  2. package/dist/fields/code/code-field.js +2 -1
  3. package/dist/fields/datetime/datetime-field.js +4 -2
  4. package/dist/fields/field-renderer.js +2 -1
  5. package/dist/fields/field-services-types.d.ts +23 -0
  6. package/dist/fields/file/file-field.js +3 -2
  7. package/dist/fields/group/group-field.js +3 -1
  8. package/dist/fields/image/image-field.js +3 -2
  9. package/dist/fields/numerical/numerical-field.js +2 -1
  10. package/dist/fields/relation/relation-field.js +3 -2
  11. package/dist/fields/relation/relation-many-field.js +3 -2
  12. package/dist/fields/relation/relation-picker.js +38 -7
  13. package/dist/fields/relation/relation-picker.module.js +3 -0
  14. package/dist/fields/relation/relation-picker_module.css +20 -0
  15. package/dist/fields/select/select-field.js +3 -2
  16. package/dist/fields/text/text-field.js +2 -1
  17. package/dist/fields/text-area/text-area-field.js +2 -1
  18. package/dist/forms/form-context.js +4 -1
  19. package/dist/forms/form-dom-scope.d.ts +34 -0
  20. package/dist/forms/form-dom-scope.js +20 -0
  21. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  22. package/dist/forms/form-layout.d.ts +39 -0
  23. package/dist/forms/form-layout.js +102 -0
  24. package/dist/forms/form-page-chrome.d.ts +110 -0
  25. package/dist/forms/form-page-chrome.js +253 -0
  26. package/dist/forms/form-renderer.d.ts +2 -18
  27. package/dist/forms/form-renderer.js +105 -423
  28. package/dist/forms/form-renderer.module.js +2 -0
  29. package/dist/forms/form-renderer_module.css +13 -0
  30. package/dist/forms/use-form-submission.d.ts +74 -0
  31. package/dist/forms/use-form-submission.js +166 -0
  32. package/dist/forms/use-form-tabs.d.ts +29 -0
  33. package/dist/forms/use-form-tabs.js +45 -0
  34. package/dist/presentation/tabs.js +2 -1
  35. package/dist/react.d.ts +1 -1
  36. package/package.json +7 -7
  37. package/src/fields/checkbox/checkbox-field.tsx +3 -1
  38. package/src/fields/code/code-field.tsx +2 -1
  39. package/src/fields/datetime/datetime-field.tsx +4 -2
  40. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  41. package/src/fields/field-dom-ids.test.tsx +293 -0
  42. package/src/fields/field-renderer.tsx +2 -1
  43. package/src/fields/field-services-types.ts +25 -0
  44. package/src/fields/file/file-field.tsx +3 -2
  45. package/src/fields/group/group-field.tsx +3 -1
  46. package/src/fields/image/image-field.tsx +3 -2
  47. package/src/fields/numerical/numerical-field.tsx +2 -1
  48. package/src/fields/relation/relation-field.tsx +3 -2
  49. package/src/fields/relation/relation-many-field.tsx +3 -2
  50. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  51. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  52. package/src/fields/relation/relation-picker.module.css +32 -0
  53. package/src/fields/relation/relation-picker.tsx +59 -1
  54. package/src/fields/select/select-field.tsx +3 -2
  55. package/src/fields/text/text-field.tsx +2 -1
  56. package/src/fields/text-area/text-area-field.tsx +2 -1
  57. package/src/forms/form-context.tsx +2 -1
  58. package/src/forms/form-dom-scope.test.tsx +213 -0
  59. package/src/forms/form-dom-scope.tsx +70 -0
  60. package/src/forms/form-layout-styles.test.node.ts +32 -0
  61. package/src/forms/form-layout.test.tsx +276 -0
  62. package/src/forms/form-layout.tsx +204 -0
  63. package/src/forms/form-page-chrome.tsx +460 -0
  64. package/src/forms/form-renderer-submit.test.tsx +40 -0
  65. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  66. package/src/forms/form-renderer.module.css +18 -0
  67. package/src/forms/form-renderer.tsx +126 -600
  68. package/src/forms/form-submit-isolation.test.tsx +247 -0
  69. package/src/forms/use-form-submission.test.tsx +564 -0
  70. package/src/forms/use-form-submission.ts +293 -0
  71. package/src/forms/use-form-tabs.ts +89 -0
  72. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  73. package/src/presentation/tabs.test.tsx +49 -2
  74. package/src/presentation/tabs.tsx +11 -1
  75. package/src/react.ts +2 -0
@@ -0,0 +1,204 @@
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 { Fragment, type ReactNode } from 'react'
12
+
13
+ import type {
14
+ AdminResourceConfig,
15
+ Field,
16
+ GroupDefinition,
17
+ RowDefinition,
18
+ TabSetDefinition,
19
+ } from '@byline/core'
20
+ import cx from 'clsx'
21
+
22
+ import { sliceFieldAdmin } from '../fields/field-admin'
23
+ import { FieldRenderer } from '../fields/field-renderer'
24
+ import { AdminGroup } from '../presentation/group'
25
+ import { AdminRow } from '../presentation/row'
26
+ import { AdminTabs, tabPanelId, tabTriggerId } from '../presentation/tabs'
27
+ import { useFormDomScope } from './form-dom-scope'
28
+ import styles from './form-renderer.module.css'
29
+ import { useFormLayout } from './use-form-layout'
30
+ import { useFormTabs } from './use-form-tabs'
31
+
32
+ export interface FormLayoutProps {
33
+ fields: Field[]
34
+ adminConfig?: AdminResourceConfig
35
+ /** Which regions to render, in order. Default: both. */
36
+ regions?: ReadonlyArray<'main' | 'sidebar'>
37
+ /** 'split' = two columns (full page); 'stacked' = one column (embedded). */
38
+ variant?: 'split' | 'stacked'
39
+ collectionPath?: string
40
+ /** Content locale passed through to every field renderer. */
41
+ activeLocale?: string
42
+ /** Initial document, for field default values. */
43
+ // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific
44
+ initialData?: any
45
+ /**
46
+ * Controlled active-tab state. Owned above the keyed `FormProvider` so tab
47
+ * choices survive the remount a locale change triggers — see `useFormTabs`.
48
+ */
49
+ activeTabBySet: Record<string, string>
50
+ onTabChange: (tabSetName: string, tabName: string) => void
51
+ /**
52
+ * Page-level sidebar widgets (path, tree placement, available locales),
53
+ * rendered in the same sidebar column as the sidebar schema fields. The
54
+ * embedded creation view passes nothing.
55
+ */
56
+ sidebarSlot?: ReactNode
57
+ }
58
+
59
+ const BOTH_REGIONS = ['main', 'sidebar'] as const
60
+
61
+ /**
62
+ * The layout walk: recursively dispatches each name in a region to the
63
+ * appropriate primitive renderer or to `<FieldRenderer>`.
64
+ */
65
+ export const FormLayout = ({
66
+ fields,
67
+ adminConfig,
68
+ regions = BOTH_REGIONS,
69
+ variant = 'split',
70
+ activeLocale,
71
+ initialData,
72
+ activeTabBySet,
73
+ onTabChange,
74
+ sidebarSlot,
75
+ }: FormLayoutProps): ReactNode => {
76
+ const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
77
+ useFormLayout(adminConfig, fields)
78
+ const tabs = useFormTabs(fieldToTabPath, activeTabBySet)
79
+ // Two forms over one collection share its admin configuration, so their tab
80
+ // sets carry the same name and would otherwise derive the same trigger and
81
+ // panel ids. `aria-labelledby` resolves document-wide, so the second form's
82
+ // panel would name the first form's trigger.
83
+ const domScope = useFormDomScope()
84
+
85
+ const renderField = (fieldName: string): ReactNode => {
86
+ const field = fieldByName.get(fieldName)
87
+ if (!field) return null
88
+ return (
89
+ <FieldRenderer
90
+ key={field.name}
91
+ field={field}
92
+ defaultValue={initialData?.fields?.[field.name]}
93
+ contentLocale={activeLocale}
94
+ components={adminConfig?.fields?.[field.name]?.components}
95
+ editor={adminConfig?.fields?.[field.name]?.editor}
96
+ fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
97
+ />
98
+ )
99
+ }
100
+
101
+ const renderItem = (name: string): ReactNode => {
102
+ const tabSet = tabSetByName.get(name)
103
+ if (tabSet) return renderTabSet(tabSet)
104
+
105
+ const group = groupByName.get(name)
106
+ if (group) return renderGroup(group)
107
+
108
+ const row = rowByName.get(name)
109
+ if (row) return renderRow(row)
110
+
111
+ return renderField(name)
112
+ }
113
+
114
+ const renderRow = (row: RowDefinition): ReactNode => (
115
+ <AdminRow key={`row:${row.name}`}>{row.fields.map((name) => renderField(name))}</AdminRow>
116
+ )
117
+
118
+ const renderGroup = (group: GroupDefinition): ReactNode => (
119
+ <AdminGroup key={`group:${group.name}`} label={group.label}>
120
+ {group.fields.map((name) => renderItem(name))}
121
+ </AdminGroup>
122
+ )
123
+
124
+ const renderTabSet = (set: TabSetDefinition): ReactNode => {
125
+ const { visibleTabs, activeTabName, activeTab, errorCounts } = tabs.resolve(set)
126
+ const idBase = `${domScope}tabset:${set.name}`
127
+
128
+ return (
129
+ <div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
130
+ {visibleTabs.length > 0 && (
131
+ <AdminTabs
132
+ idBase={idBase}
133
+ tabs={visibleTabs}
134
+ activeTab={activeTabName}
135
+ onChange={(tabName) => onTabChange(set.name, tabName)}
136
+ errorCounts={errorCounts}
137
+ className={cx('byline-form-tabset-tabs', styles['tabset-tabs'])}
138
+ />
139
+ )}
140
+ {activeTab && (
141
+ <div
142
+ role="tabpanel"
143
+ id={tabPanelId(idBase, activeTab.name)}
144
+ aria-labelledby={tabTriggerId(idBase, activeTab.name)}
145
+ className={cx('byline-form-tabset-fields', styles['tabset-fields'])}
146
+ >
147
+ {activeTab.fields.map((name) => renderItem(name))}
148
+ </div>
149
+ )}
150
+ </div>
151
+ )
152
+ }
153
+
154
+ const sidebarNames = layout.sidebar ?? []
155
+
156
+ // Page-level widgets come first, matching the current editor. The plan's
157
+ // Task 2 sketch had them after the schema fields; that would visibly reorder
158
+ // a shipped collection's sidebar during a refactor whose acceptance is
159
+ // behaviour preservation.
160
+ const sidebarContents = (
161
+ <>
162
+ {sidebarSlot}
163
+ {sidebarNames.map((name) => renderItem(name))}
164
+ </>
165
+ )
166
+
167
+ // `regions` is an ordered list, not a set: iterate it in sequence rather than
168
+ // testing membership, or a caller asking for ['sidebar', 'main'] silently
169
+ // still gets main first.
170
+ const renderRegion = (region: 'main' | 'sidebar'): ReactNode =>
171
+ region === 'main' ? (
172
+ <div key="main" className={cx('byline-form-content', styles.content)}>
173
+ {layout.main.map((name) => renderItem(name))}
174
+ </div>
175
+ ) : (
176
+ <div key="sidebar" className={cx('byline-form-sidebar', styles.sidebar)}>
177
+ {sidebarContents}
178
+ </div>
179
+ )
180
+
181
+ // Stacked: one column, both regions inline, and deliberately **not** the
182
+ // split layout class - that class becomes a two-column grid at >=60rem, so
183
+ // carrying it here would reserve an empty second track and narrow the only
184
+ // column. Removing the sidebar element does not remove the grid track.
185
+ if (variant === 'stacked') {
186
+ return (
187
+ <div className={cx('byline-form-layout-stacked', styles['layout-stacked'])}>
188
+ <div className={cx('byline-form-content', styles.content)}>
189
+ {regions.map((region) => (
190
+ <Fragment key={region}>
191
+ {region === 'main' ? layout.main.map((name) => renderItem(name)) : sidebarContents}
192
+ </Fragment>
193
+ ))}
194
+ </div>
195
+ </div>
196
+ )
197
+ }
198
+
199
+ return (
200
+ <div className={cx('byline-form-layout', styles.layout)}>
201
+ {regions.map((region) => renderRegion(region))}
202
+ </div>
203
+ )
204
+ }
@@ -0,0 +1,460 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ /**
12
+ * Page-level chrome for the full-page document editor: the parts that frame a
13
+ * form rather than render it. The embedded creation view renders none of this.
14
+ *
15
+ * Each component takes only the props it needs — deliberately not a
16
+ * `FormRendererProps` pass-through, so what a region actually depends on stays
17
+ * visible at its call site.
18
+ */
19
+
20
+ import type { ReactNode, RefObject } from 'react'
21
+
22
+ import type { SlugifierFn, StructuralMutationReceipt, WorkflowStatus } from '@byline/core'
23
+ import { useTranslation } from '@byline/i18n/react'
24
+ import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
25
+ import cx from 'clsx'
26
+
27
+ import { AvailableLocalesWidget } from './available-locales-widget'
28
+ import { DocumentActions, type DocumentActionsLocaleOption } from './document-actions'
29
+ import styles from './form-renderer.module.css'
30
+ import { FormStatusDisplay } from './form-status-display'
31
+ import { PathWidget } from './path-widget'
32
+ import {
33
+ ScheduledPublicationCell,
34
+ ScheduledPublicationNotice,
35
+ type UseScheduledPublicationReturn,
36
+ } from './scheduled-publication-control'
37
+ import { TreePlacementWidget } from './tree-placement-widget'
38
+ import type { PublishedVersionInfo } from './form-renderer'
39
+ import type { StatusTransitions } from './status-transitions'
40
+
41
+ export interface FormHeadingRowProps {
42
+ heading: ReactNode
43
+ /** Host-supplied controls rendered beside the heading (locale switcher, …). */
44
+ headerSlot?: ReactNode
45
+ }
46
+
47
+ export const FormHeadingRow = ({ heading, headerSlot }: FormHeadingRowProps): ReactNode => (
48
+ <div className={cx('byline-form-heading-row', styles['heading-row'])}>
49
+ <h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
50
+ {/* Source-locale anchor indicator removed pending heading-layout work.
51
+ To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
52
+ here from `initialData.sourceLocale` (mismatch-only is the intended
53
+ end state). See docs/08-internationalization/index.md. */}
54
+ {headerSlot}
55
+ </div>
56
+ )
57
+
58
+ export interface FormStatusBarProps {
59
+ /** Mutations blocked or a discard in flight. */
60
+ disabled: boolean
61
+ // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific
62
+ initialData?: any
63
+ workflowStatuses?: WorkflowStatus[]
64
+ publishedVersion?: PublishedVersionInfo | null
65
+ scheduling: UseScheduledPublicationReturn
66
+ hasChanges: boolean
67
+ isUploading: boolean
68
+ isSubmitting: boolean
69
+ onCancel: () => void
70
+ /** Status transitions, already computed for the current workflow position. */
71
+ transitions: StatusTransitions
72
+ statusBusy: boolean
73
+ onStatusBusyChange: (busy: boolean) => void
74
+ onStatusChange?: (nextStatus: string) => Promise<void>
75
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void
76
+ /** Read synchronously at click time, not at render time. */
77
+ isBlocked: () => boolean
78
+ /** A guarded action was attempted while the form is dirty. */
79
+ onUnsavedChanges: () => void
80
+ onUnpublish?: () => Promise<void>
81
+ onDelete?: () => Promise<void>
82
+ onDuplicate?: () => Promise<void>
83
+ onCopyToLocale?: (args: { targetLocale: string; overwrite: boolean }) => Promise<void>
84
+ onDeleteLocale?: (args: { targetLocale: string }) => Promise<void>
85
+ useAsTitle?: string
86
+ contentLocale: string
87
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>
88
+ defaultLocale: string
89
+ }
90
+
91
+ export const FormStatusBar = ({
92
+ disabled,
93
+ initialData,
94
+ workflowStatuses,
95
+ publishedVersion,
96
+ scheduling,
97
+ hasChanges,
98
+ isUploading,
99
+ isSubmitting,
100
+ onCancel,
101
+ transitions,
102
+ statusBusy,
103
+ onStatusBusyChange,
104
+ onStatusChange,
105
+ onMutationError,
106
+ isBlocked,
107
+ onUnsavedChanges,
108
+ onUnpublish,
109
+ onDelete,
110
+ onDuplicate,
111
+ onCopyToLocale,
112
+ onDeleteLocale,
113
+ useAsTitle,
114
+ contentLocale,
115
+ contentLocales,
116
+ defaultLocale,
117
+ }: FormStatusBarProps): ReactNode => {
118
+ const { t } = useTranslation('byline-admin')
119
+ const { primaryStatus, secondaryStatuses, isTerminal } = transitions
120
+
121
+ return (
122
+ <div className={cx('byline-form-status-bar', styles['status-bar'])}>
123
+ <div className={cx('byline-form-status-details', styles['status-details'])}>
124
+ <FormStatusDisplay
125
+ disabled={disabled}
126
+ initialData={initialData}
127
+ workflowStatuses={workflowStatuses}
128
+ publishedVersion={publishedVersion}
129
+ onUnpublish={onUnpublish}
130
+ afterStatusCells={
131
+ <ScheduledPublicationCell state={scheduling.state} timeZone={scheduling.timeZone} />
132
+ }
133
+ />
134
+ </div>
135
+ <div className={cx('byline-form-actions', styles.actions)}>
136
+ <Button
137
+ className={cx('byline-form-actions-button', styles['actions-button'])}
138
+ size="sm"
139
+ intent="noeffect"
140
+ type="button"
141
+ onClick={onCancel}
142
+ >
143
+ {hasChanges === false ? t('common.actions.close') : t('common.actions.cancel')}
144
+ </Button>
145
+ <Button
146
+ className={cx('byline-form-actions-button', styles['actions-button'])}
147
+ size="sm"
148
+ type="submit"
149
+ disabled={disabled || hasChanges === false || isUploading || isSubmitting}
150
+ aria-label={isSubmitting ? t('common.actions.save') : undefined}
151
+ >
152
+ {isUploading ? (
153
+ t('forms.actions.uploading')
154
+ ) : (
155
+ <span className={cx('byline-form-save-content', styles['save-content'])}>
156
+ <span
157
+ className={cx(
158
+ 'byline-form-save-label',
159
+ styles['save-label'],
160
+ isSubmitting && styles['save-label-hidden']
161
+ )}
162
+ >
163
+ {t('common.actions.save')}
164
+ </span>
165
+ {isSubmitting ? (
166
+ <span className={cx('byline-form-save-loader', styles['save-loader'])}>
167
+ <LoaderEllipsis size={28} aria-hidden="true" />
168
+ </span>
169
+ ) : null}
170
+ </span>
171
+ )}
172
+ </Button>
173
+ {primaryStatus && onStatusChange && (
174
+ <div className={cx('byline-form-actions-status-wrap', styles['actions-status-wrap'])}>
175
+ <ComboButton
176
+ buttonClassName={cx(
177
+ 'byline-form-actions-combo-button',
178
+ styles['actions-combo-button']
179
+ )}
180
+ triggerClassName={cx(
181
+ 'byline-form-actions-combo-trigger',
182
+ styles['actions-combo-trigger']
183
+ )}
184
+ options={secondaryStatuses.map((s) => ({
185
+ label: isTerminal
186
+ ? t('forms.actions.revertTo', { label: s.label ?? s.name })
187
+ : (s.verb ?? s.label ?? s.name),
188
+ value: s.name,
189
+ }))}
190
+ sideOffset={5}
191
+ size="sm"
192
+ type="button"
193
+ intent={isTerminal ? 'info' : 'success'}
194
+ disabled={disabled || statusBusy}
195
+ onOptionSelect={async (value: string) => {
196
+ if (isBlocked()) return
197
+ if (hasChanges) {
198
+ onUnsavedChanges()
199
+ return
200
+ }
201
+ onStatusBusyChange(true)
202
+ try {
203
+ await onStatusChange(value)
204
+ } catch (error) {
205
+ onMutationError?.(error)
206
+ } finally {
207
+ onStatusBusyChange(false)
208
+ }
209
+ }}
210
+ onButtonClick={
211
+ isTerminal
212
+ ? undefined
213
+ : async () => {
214
+ if (isBlocked()) return
215
+ if (hasChanges) {
216
+ onUnsavedChanges()
217
+ return
218
+ }
219
+ onStatusBusyChange(true)
220
+ try {
221
+ await onStatusChange(primaryStatus.name)
222
+ } catch (error) {
223
+ onMutationError?.(error)
224
+ } finally {
225
+ onStatusBusyChange(false)
226
+ }
227
+ }
228
+ }
229
+ >
230
+ {statusBusy
231
+ ? '...'
232
+ : isTerminal
233
+ ? (primaryStatus.label ?? primaryStatus.name)
234
+ : (primaryStatus.verb ?? primaryStatus.label ?? primaryStatus.name)}
235
+ </ComboButton>
236
+ </div>
237
+ )}
238
+ <DocumentActions
239
+ disabled={disabled}
240
+ publishedVersion={publishedVersion}
241
+ onUnpublish={onUnpublish}
242
+ onDelete={onDelete}
243
+ onDuplicate={onDuplicate}
244
+ sourceTitle={
245
+ useAsTitle != null && initialData != null
246
+ ? ((initialData as Record<string, unknown>)[useAsTitle] as string | null | undefined)
247
+ : null
248
+ }
249
+ onCopyToLocale={onCopyToLocale}
250
+ sourceLocale={contentLocale}
251
+ contentLocales={contentLocales}
252
+ hasUnsavedChanges={hasChanges}
253
+ onUnsavedChanges={() => onUnsavedChanges()}
254
+ onDeleteLocale={onDeleteLocale}
255
+ defaultLocale={defaultLocale}
256
+ availableLocales={initialData?._availableVersionLocales as string[] | undefined}
257
+ scheduledPublicationState={scheduling.state}
258
+ onSchedulePublication={scheduling.openSchedule}
259
+ onConfirmScheduledPublication={scheduling.confirm}
260
+ onCancelScheduledPublication={scheduling.cancel}
261
+ />
262
+ </div>
263
+ </div>
264
+ )
265
+ }
266
+
267
+ export interface FormConcurrencyNoticesProps {
268
+ /** Mutations blocked or a discard in flight. */
269
+ disabled: boolean
270
+ mutationIssue?: 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed' | null
271
+ scheduledPublicationsNeedReconfirmation?: boolean
272
+ scheduledPublicationsHref?: string
273
+ /**
274
+ * Focus target when a mutation issue appears. The ref belongs to the form
275
+ * component, which restores focus after a save; this region only attaches it.
276
+ */
277
+ warningRef: RefObject<HTMLDivElement | null>
278
+ discarding: boolean
279
+ reloadFailed: boolean
280
+ onDiscardRequested: () => void
281
+ scheduling: UseScheduledPublicationReturn
282
+ restoreWarnings?: string[]
283
+ }
284
+
285
+ export const FormConcurrencyNotices = ({
286
+ disabled,
287
+ mutationIssue,
288
+ scheduledPublicationsNeedReconfirmation,
289
+ scheduledPublicationsHref,
290
+ warningRef,
291
+ discarding,
292
+ reloadFailed,
293
+ onDiscardRequested,
294
+ scheduling,
295
+ restoreWarnings,
296
+ }: FormConcurrencyNoticesProps): ReactNode => {
297
+ const { t } = useTranslation('byline-admin')
298
+
299
+ return (
300
+ <>
301
+ {(mutationIssue || scheduledPublicationsNeedReconfirmation) && (
302
+ <div
303
+ ref={warningRef}
304
+ tabIndex={-1}
305
+ role="alert"
306
+ aria-live="assertive"
307
+ className={cx('byline-document-concurrency', styles.concurrency)}
308
+ >
309
+ {mutationIssue && (
310
+ <Alert
311
+ intent="warning"
312
+ icon
313
+ close={false}
314
+ title={t(`documentConcurrency.${mutationIssue}Title`)}
315
+ >
316
+ <p>{t(`documentConcurrency.${mutationIssue}`)}</p>
317
+ {mutationIssue !== 'committed' && (
318
+ <Button type="button" disabled={discarding} onClick={onDiscardRequested}>
319
+ {t('documentConcurrency.reloadAction')}
320
+ </Button>
321
+ )}
322
+ {reloadFailed && <p>{t('documentConcurrency.reloadFailed')}</p>}
323
+ </Alert>
324
+ )}
325
+ {scheduledPublicationsNeedReconfirmation && (
326
+ <Alert
327
+ intent="warning"
328
+ icon
329
+ close={false}
330
+ title={t('documentConcurrency.schedulesTitle')}
331
+ >
332
+ <p>{t('documentConcurrency.schedules')}</p>
333
+ {scheduledPublicationsHref && (
334
+ <a href={scheduledPublicationsHref}>{t('documentConcurrency.reviewSchedules')}</a>
335
+ )}
336
+ </Alert>
337
+ )}
338
+ </div>
339
+ )}
340
+ <ScheduledPublicationNotice
341
+ state={scheduling.state}
342
+ timeZone={scheduling.timeZone}
343
+ busy={disabled || scheduling.busy}
344
+ onConfirm={scheduling.confirm}
345
+ onReschedule={scheduling.openSchedule}
346
+ onCancel={scheduling.cancel}
347
+ />
348
+ {scheduling.modal}
349
+ {restoreWarnings && restoreWarnings.length > 0 && (
350
+ <Alert
351
+ className="m-0 mt-4"
352
+ intent="warning"
353
+ icon={true}
354
+ close={false}
355
+ title={t('forms.restoreWarnings.title')}
356
+ >
357
+ <p>{t('forms.restoreWarnings.body', { count: restoreWarnings.length })}</p>
358
+ <ul>
359
+ {restoreWarnings.map((w) => (
360
+ <li key={w}>{w}</li>
361
+ ))}
362
+ </ul>
363
+ </Alert>
364
+ )}
365
+ </>
366
+ )
367
+ }
368
+
369
+ export interface FormSidebarWidgetsProps {
370
+ /** Mutations blocked or a discard in flight. */
371
+ disabled: boolean
372
+ mode: 'create' | 'edit'
373
+ // biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific
374
+ initialData?: any
375
+ collectionPath?: string
376
+ defaultLocale: string
377
+ contentLocale: string
378
+ contentLocales?: ReadonlyArray<DocumentActionsLocaleOption>
379
+ showPath: boolean
380
+ useAsPath?: string
381
+ lockPath?: boolean
382
+ pathSlugifier?: SlugifierFn
383
+ pathSourceLocked?: boolean
384
+ tree?: boolean
385
+ useAsTitle?: string
386
+ advertiseLocales?: boolean
387
+ observedRevision?: number
388
+ onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void
389
+ onTreeMutationCommitted?: (receipt: StructuralMutationReceipt) => void
390
+ }
391
+
392
+ /**
393
+ * The page-level widgets in the editor's sidebar: path, tree placement and
394
+ * advertised locales. They are page concerns rather than schema fields, so they
395
+ * reach `FormLayout` through its `sidebarSlot` and the embedded creation view
396
+ * passes none of them.
397
+ */
398
+ export const FormSidebarWidgets = ({
399
+ disabled,
400
+ mode,
401
+ initialData,
402
+ collectionPath,
403
+ defaultLocale,
404
+ contentLocale,
405
+ contentLocales,
406
+ showPath,
407
+ useAsPath,
408
+ lockPath,
409
+ pathSlugifier,
410
+ pathSourceLocked,
411
+ tree,
412
+ useAsTitle,
413
+ advertiseLocales,
414
+ observedRevision,
415
+ onMutationError,
416
+ onTreeMutationCommitted,
417
+ }: FormSidebarWidgetsProps): ReactNode => (
418
+ <>
419
+ {/* A locked collection's widget renders even with no `useAsPath`
420
+ and nothing stored yet: its path is managed, and the editor
421
+ needs to see that. `showPath: false` still wins — it marks a
422
+ path that must never be presented at all. */}
423
+ {showPath &&
424
+ (useAsPath ||
425
+ lockPath === true ||
426
+ (typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
427
+ <PathWidget
428
+ disabled={disabled}
429
+ useAsPath={useAsPath}
430
+ collectionPath={collectionPath ?? ''}
431
+ defaultLocale={defaultLocale}
432
+ activeLocale={contentLocale}
433
+ mode={mode}
434
+ slugifier={pathSlugifier}
435
+ sourceLocked={pathSourceLocked}
436
+ lockPath={lockPath}
437
+ />
438
+ )}
439
+ {tree && mode === 'edit' && typeof initialData?.id === 'string' && (
440
+ <TreePlacementWidget
441
+ disabled={disabled}
442
+ onMutationError={onMutationError}
443
+ onCommitted={onTreeMutationCommitted}
444
+ expectedRevision={observedRevision ?? initialData.revision}
445
+ collectionPath={collectionPath ?? ''}
446
+ documentId={initialData.id as string}
447
+ useAsTitle={useAsTitle}
448
+ />
449
+ )}
450
+ {advertiseLocales && (
451
+ <AvailableLocalesWidget
452
+ disabled={disabled}
453
+ contentLocales={contentLocales ?? []}
454
+ availableVersionLocales={
455
+ (initialData?._availableVersionLocales as string[] | undefined) ?? []
456
+ }
457
+ />
458
+ )}
459
+ </>
460
+ )