@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.
- package/dist/fields/checkbox/checkbox-field.js +3 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +4 -2
- package/dist/fields/field-renderer.js +2 -1
- package/dist/fields/field-services-types.d.ts +23 -0
- package/dist/fields/file/file-field.js +3 -2
- package/dist/fields/group/group-field.js +3 -1
- package/dist/fields/image/image-field.js +3 -2
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +3 -2
- package/dist/fields/relation/relation-many-field.js +3 -2
- package/dist/fields/relation/relation-picker.js +38 -7
- package/dist/fields/relation/relation-picker.module.js +3 -0
- package/dist/fields/relation/relation-picker_module.css +20 -0
- package/dist/fields/select/select-field.js +3 -2
- package/dist/fields/text/text-field.js +2 -1
- package/dist/fields/text-area/text-area-field.js +2 -1
- package/dist/forms/form-context.js +4 -1
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
- package/dist/forms/form-layout.d.ts +39 -0
- package/dist/forms/form-layout.js +102 -0
- package/dist/forms/form-page-chrome.d.ts +110 -0
- package/dist/forms/form-page-chrome.js +253 -0
- package/dist/forms/form-renderer.d.ts +2 -18
- package/dist/forms/form-renderer.js +105 -423
- package/dist/forms/form-renderer.module.js +2 -0
- package/dist/forms/form-renderer_module.css +13 -0
- package/dist/forms/use-form-submission.d.ts +74 -0
- package/dist/forms/use-form-submission.js +166 -0
- package/dist/forms/use-form-tabs.d.ts +29 -0
- package/dist/forms/use-form-tabs.js +45 -0
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +7 -7
- package/src/fields/checkbox/checkbox-field.tsx +3 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +4 -2
- package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
- package/src/fields/field-dom-ids.test.tsx +293 -0
- package/src/fields/field-renderer.tsx +2 -1
- package/src/fields/field-services-types.ts +25 -0
- package/src/fields/file/file-field.tsx +3 -2
- package/src/fields/group/group-field.tsx +3 -1
- package/src/fields/image/image-field.tsx +3 -2
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +3 -2
- package/src/fields/relation/relation-many-field.tsx +3 -2
- package/src/fields/relation/relation-picker-create.test.tsx +203 -0
- package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
- package/src/fields/relation/relation-picker.module.css +32 -0
- package/src/fields/relation/relation-picker.tsx +59 -1
- package/src/fields/select/select-field.tsx +3 -2
- package/src/fields/text/text-field.tsx +2 -1
- package/src/fields/text-area/text-area-field.tsx +2 -1
- package/src/forms/form-context.tsx +2 -1
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout-styles.test.node.ts +32 -0
- package/src/forms/form-layout.test.tsx +276 -0
- package/src/forms/form-layout.tsx +204 -0
- package/src/forms/form-page-chrome.tsx +460 -0
- package/src/forms/form-renderer-submit.test.tsx +40 -0
- package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
- package/src/forms/form-renderer.module.css +18 -0
- package/src/forms/form-renderer.tsx +126 -600
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/use-form-submission.test.tsx +564 -0
- package/src/forms/use-form-submission.ts +293 -0
- package/src/forms/use-form-tabs.ts +89 -0
- package/src/presentation/tab-dom-ids.test.tsx +153 -0
- package/src/presentation/tabs.test.tsx +49 -2
- package/src/presentation/tabs.tsx +11 -1
- 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
|
+
)
|