@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.
- 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 +100 -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 +104 -418
- 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/tab-strip-drag.d.ts +75 -0
- package/dist/presentation/tab-strip-drag.js +25 -0
- package/dist/presentation/tab-strip-drag.test.node.d.ts +8 -0
- package/dist/presentation/tab-strip-geometry.d.ts +75 -0
- package/dist/presentation/tab-strip-geometry.js +36 -0
- package/dist/presentation/tab-strip-geometry.test.node.d.ts +8 -0
- package/dist/presentation/tabs.d.ts +30 -7
- package/dist/presentation/tabs.js +260 -29
- package/dist/presentation/tabs.module.js +11 -1
- package/dist/presentation/tabs_module.css +166 -5
- package/package.json +7 -7
- 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 +198 -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 +115 -593
- 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-strip-drag.test.node.ts +143 -0
- package/src/presentation/tab-strip-drag.ts +114 -0
- package/src/presentation/tab-strip-geometry.test.node.ts +254 -0
- package/src/presentation/tab-strip-geometry.ts +158 -0
- package/src/presentation/tabs.module.css +209 -11
- package/src/presentation/tabs.test.tsx +481 -0
- package/src/presentation/tabs.tsx +369 -34
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- package/dist/widgets/status-badge/status-badge.jsx +0 -46
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import type { AdminResourceConfig, Field } from '@byline/core'
|
|
12
|
+
import { defineAdminConfig } from '@byline/core'
|
|
13
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
14
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
15
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
17
|
+
|
|
18
|
+
import { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
19
|
+
import { FormProvider } from './form-context'
|
|
20
|
+
import { FormLayout } from './form-layout'
|
|
21
|
+
|
|
22
|
+
// Deliberately not the `;(globalThis as any)` idiom used elsewhere in this
|
|
23
|
+
// suite: Biome's import sorting can move an import across that leading
|
|
24
|
+
// semicolon, and without semicolons the result parses as a call on the import.
|
|
25
|
+
const globalWithAct = globalThis as any
|
|
26
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
27
|
+
|
|
28
|
+
defineAdminConfig({
|
|
29
|
+
i18n: {
|
|
30
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
31
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
32
|
+
},
|
|
33
|
+
collections: [
|
|
34
|
+
{
|
|
35
|
+
path: 'pages',
|
|
36
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
37
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const fields: Field[] = [
|
|
44
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
45
|
+
{ name: 'featured', label: 'Featured', type: 'text' },
|
|
46
|
+
{ name: 'seoTitle', label: 'SEO title', type: 'text' },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
let container: HTMLDivElement
|
|
50
|
+
let root: Root
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
container = document.createElement('div')
|
|
54
|
+
document.body.appendChild(container)
|
|
55
|
+
root = createRoot(container)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
act(() => {
|
|
60
|
+
root.unmount()
|
|
61
|
+
})
|
|
62
|
+
container.remove()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const render = (props: Partial<React.ComponentProps<typeof FormLayout>> = {}) => {
|
|
66
|
+
act(() => {
|
|
67
|
+
root.render(
|
|
68
|
+
<I18nProvider
|
|
69
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
70
|
+
activeLocale="en"
|
|
71
|
+
defaultLocale="en"
|
|
72
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
73
|
+
>
|
|
74
|
+
<BylineFieldServicesProvider
|
|
75
|
+
services={
|
|
76
|
+
{
|
|
77
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
78
|
+
uploadField: async () => ({}),
|
|
79
|
+
} as any
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
83
|
+
<FormLayout
|
|
84
|
+
fields={fields}
|
|
85
|
+
activeTabBySet={{}}
|
|
86
|
+
onTabChange={() => {}}
|
|
87
|
+
{...(props as any)}
|
|
88
|
+
/>
|
|
89
|
+
</FormProvider>
|
|
90
|
+
</BylineFieldServicesProvider>
|
|
91
|
+
</I18nProvider>
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const config = (c: Partial<AdminResourceConfig>) => c as AdminResourceConfig
|
|
97
|
+
|
|
98
|
+
describe('FormLayout regions', () => {
|
|
99
|
+
it('renders main-region fields', () => {
|
|
100
|
+
render({ adminConfig: config({ layout: { main: ['title'] } }) })
|
|
101
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('renders layout.sidebar schema fields', () => {
|
|
105
|
+
render({ adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }) })
|
|
106
|
+
const sidebar = container.querySelector('.byline-form-sidebar')
|
|
107
|
+
expect(sidebar?.querySelector('input[name="featured"]')).not.toBeNull()
|
|
108
|
+
expect(container.querySelector('.byline-form-content input[name="featured"]')).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('omits a region that was not requested', () => {
|
|
112
|
+
render({
|
|
113
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
114
|
+
regions: ['main'],
|
|
115
|
+
})
|
|
116
|
+
expect(container.querySelector('.byline-form-sidebar')).toBeNull()
|
|
117
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('renders both regions in one column in the stacked variant', () => {
|
|
121
|
+
render({
|
|
122
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
123
|
+
variant: 'stacked',
|
|
124
|
+
})
|
|
125
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
126
|
+
expect(container.querySelector('input[name="featured"]')).not.toBeNull()
|
|
127
|
+
expect(container.querySelector('.byline-form-sidebar')).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('FormLayout region order', () => {
|
|
132
|
+
// The prop is an ordered list, and both the interface and the plan promise
|
|
133
|
+
// order. Reducing it to membership checks silently always renders main first.
|
|
134
|
+
const both = config({ layout: { main: ['title'], sidebar: ['featured'] } })
|
|
135
|
+
|
|
136
|
+
const fieldOrder = () =>
|
|
137
|
+
[...container.querySelectorAll('input')].map((i) => i.getAttribute('name'))
|
|
138
|
+
|
|
139
|
+
it('honours a reversed region order in the split variant', () => {
|
|
140
|
+
render({ adminConfig: both, regions: ['sidebar', 'main'] })
|
|
141
|
+
expect(fieldOrder()).toEqual(['featured', 'title'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('honours a reversed region order in the stacked variant', () => {
|
|
145
|
+
render({ adminConfig: both, regions: ['sidebar', 'main'], variant: 'stacked' })
|
|
146
|
+
expect(fieldOrder()).toEqual(['featured', 'title'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keeps the slot ahead of the schema fields within the sidebar', () => {
|
|
150
|
+
render({
|
|
151
|
+
adminConfig: both,
|
|
152
|
+
regions: ['sidebar', 'main'],
|
|
153
|
+
sidebarSlot: <div data-testid="path-widget" />,
|
|
154
|
+
})
|
|
155
|
+
const marks = [...container.querySelectorAll('input, [data-testid="path-widget"]')].map(
|
|
156
|
+
(n) => n.getAttribute('data-testid') ?? n.getAttribute('name')
|
|
157
|
+
)
|
|
158
|
+
expect(marks).toEqual(['path-widget', 'featured', 'title'])
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('FormLayout stacked variant', () => {
|
|
163
|
+
// The split layout's own class becomes a two-column grid at >=60rem
|
|
164
|
+
// (form-renderer.module.css). Carrying it on a stacked form reserves an empty
|
|
165
|
+
// second track, which defeats the one-column variant. jsdom cannot measure
|
|
166
|
+
// that, so the guard is that the split class is not applied at all.
|
|
167
|
+
it('does not carry the two-column split layout class', () => {
|
|
168
|
+
render({
|
|
169
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
170
|
+
variant: 'stacked',
|
|
171
|
+
})
|
|
172
|
+
const root = container.querySelector('[class*="layout"]')
|
|
173
|
+
expect(root?.className).toContain('byline-form-layout-stacked')
|
|
174
|
+
expect(root?.classList.contains('byline-form-layout')).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('keeps the split class on the split variant', () => {
|
|
178
|
+
render({ adminConfig: config({ layout: { main: ['title'] } }) })
|
|
179
|
+
const root = container.querySelector('.byline-form-layout')
|
|
180
|
+
expect(root).not.toBeNull()
|
|
181
|
+
expect(root?.className).not.toContain('stacked')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('FormLayout sidebar slot', () => {
|
|
186
|
+
it('renders page-level widgets before the sidebar schema fields', () => {
|
|
187
|
+
// Deliberate deviation from the plan's Task 2 test, decided by Tony:
|
|
188
|
+
// the current editor renders page widgets first, and Phase 1's acceptance
|
|
189
|
+
// is behaviour preservation. Reversing the order here would visibly move
|
|
190
|
+
// `publishedOn` above the path widget in the shipped docs collection.
|
|
191
|
+
render({
|
|
192
|
+
adminConfig: config({ layout: { main: ['title'], sidebar: ['featured'] } }),
|
|
193
|
+
sidebarSlot: <div data-testid="path-widget" />,
|
|
194
|
+
})
|
|
195
|
+
const sidebar = container.querySelector('.byline-form-sidebar')
|
|
196
|
+
const order = [...(sidebar?.children ?? [])].map(
|
|
197
|
+
(n) => n.getAttribute('data-testid') ?? n.querySelector('input')?.getAttribute('name') ?? '?'
|
|
198
|
+
)
|
|
199
|
+
expect(order).toEqual(['path-widget', 'featured'])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('renders a sidebar for the slot even when no sidebar fields are declared', () => {
|
|
203
|
+
render({
|
|
204
|
+
adminConfig: config({ layout: { main: ['title'] } }),
|
|
205
|
+
sidebarSlot: <div data-testid="path-widget" />,
|
|
206
|
+
})
|
|
207
|
+
expect(container.querySelector('[data-testid="path-widget"]')).not.toBeNull()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('FormLayout tabs', () => {
|
|
212
|
+
const tabbed = config({
|
|
213
|
+
layout: { main: ['content'] },
|
|
214
|
+
tabSets: [
|
|
215
|
+
{
|
|
216
|
+
name: 'content',
|
|
217
|
+
tabs: [
|
|
218
|
+
{ name: 'general', label: 'General', fields: ['title'] },
|
|
219
|
+
{ name: 'seo', label: 'SEO', fields: ['seoTitle'] },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('renders the tab named by the controlled activeTabBySet prop', () => {
|
|
226
|
+
render({ adminConfig: tabbed, activeTabBySet: { content: 'seo' } })
|
|
227
|
+
expect(container.querySelector('input[name="seoTitle"]')).not.toBeNull()
|
|
228
|
+
expect(container.querySelector('input[name="title"]')).toBeNull()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('falls back to the first tab when the set has no selection', () => {
|
|
232
|
+
render({ adminConfig: tabbed, activeTabBySet: {} })
|
|
233
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('reports a tab change upward rather than selecting it itself', () => {
|
|
237
|
+
// Selection stays owned above the keyed FormProvider so tab choices
|
|
238
|
+
// survive the locale-change remount. FormLayout must not self-select.
|
|
239
|
+
const onTabChange = vi.fn()
|
|
240
|
+
render({ adminConfig: tabbed, activeTabBySet: { content: 'general' }, onTabChange })
|
|
241
|
+
|
|
242
|
+
const seoTab = [...container.querySelectorAll('button')].find((b) => b.textContent === 'SEO')
|
|
243
|
+
if (seoTab == null) throw new Error('SEO tab not found')
|
|
244
|
+
act(() => {
|
|
245
|
+
seoTab.click()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
expect(onTabChange).toHaveBeenCalledWith('content', 'seo')
|
|
249
|
+
expect(container.querySelector('input[name="seoTitle"]')).toBeNull()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('hides a tab whose condition is unmet and falls back to a visible one', () => {
|
|
253
|
+
const conditional = config({
|
|
254
|
+
layout: { main: ['content'] },
|
|
255
|
+
tabSets: [
|
|
256
|
+
{
|
|
257
|
+
name: 'content',
|
|
258
|
+
tabs: [
|
|
259
|
+
{ name: 'general', label: 'General', fields: ['title'] },
|
|
260
|
+
{
|
|
261
|
+
name: 'seo',
|
|
262
|
+
label: 'SEO',
|
|
263
|
+
fields: ['seoTitle'],
|
|
264
|
+
condition: (data: Record<string, any>) => data.title === 'show-seo',
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
})
|
|
270
|
+
render({ adminConfig: conditional, activeTabBySet: { content: 'seo' } })
|
|
271
|
+
|
|
272
|
+
const tabLabels = [...container.querySelectorAll('button')].map((b) => b.textContent)
|
|
273
|
+
expect(tabLabels).not.toContain('SEO')
|
|
274
|
+
expect(container.querySelector('input[name="title"]')).not.toBeNull()
|
|
275
|
+
})
|
|
276
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
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 styles from './form-renderer.module.css'
|
|
28
|
+
import { useFormLayout } from './use-form-layout'
|
|
29
|
+
import { useFormTabs } from './use-form-tabs'
|
|
30
|
+
|
|
31
|
+
export interface FormLayoutProps {
|
|
32
|
+
fields: Field[]
|
|
33
|
+
adminConfig?: AdminResourceConfig
|
|
34
|
+
/** Which regions to render, in order. Default: both. */
|
|
35
|
+
regions?: ReadonlyArray<'main' | 'sidebar'>
|
|
36
|
+
/** 'split' = two columns (full page); 'stacked' = one column (embedded). */
|
|
37
|
+
variant?: 'split' | 'stacked'
|
|
38
|
+
collectionPath?: string
|
|
39
|
+
/** Content locale passed through to every field renderer. */
|
|
40
|
+
activeLocale?: string
|
|
41
|
+
/** Initial document, for field default values. */
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: document shape is collection-specific
|
|
43
|
+
initialData?: any
|
|
44
|
+
/**
|
|
45
|
+
* Controlled active-tab state. Owned above the keyed `FormProvider` so tab
|
|
46
|
+
* choices survive the remount a locale change triggers — see `useFormTabs`.
|
|
47
|
+
*/
|
|
48
|
+
activeTabBySet: Record<string, string>
|
|
49
|
+
onTabChange: (tabSetName: string, tabName: string) => void
|
|
50
|
+
/**
|
|
51
|
+
* Page-level sidebar widgets (path, tree placement, available locales),
|
|
52
|
+
* rendered in the same sidebar column as the sidebar schema fields. The
|
|
53
|
+
* embedded creation view passes nothing.
|
|
54
|
+
*/
|
|
55
|
+
sidebarSlot?: ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const BOTH_REGIONS = ['main', 'sidebar'] as const
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The layout walk: recursively dispatches each name in a region to the
|
|
62
|
+
* appropriate primitive renderer or to `<FieldRenderer>`.
|
|
63
|
+
*/
|
|
64
|
+
export const FormLayout = ({
|
|
65
|
+
fields,
|
|
66
|
+
adminConfig,
|
|
67
|
+
regions = BOTH_REGIONS,
|
|
68
|
+
variant = 'split',
|
|
69
|
+
activeLocale,
|
|
70
|
+
initialData,
|
|
71
|
+
activeTabBySet,
|
|
72
|
+
onTabChange,
|
|
73
|
+
sidebarSlot,
|
|
74
|
+
}: FormLayoutProps): ReactNode => {
|
|
75
|
+
const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
|
|
76
|
+
useFormLayout(adminConfig, fields)
|
|
77
|
+
const tabs = useFormTabs(fieldToTabPath, activeTabBySet)
|
|
78
|
+
|
|
79
|
+
const renderField = (fieldName: string): ReactNode => {
|
|
80
|
+
const field = fieldByName.get(fieldName)
|
|
81
|
+
if (!field) return null
|
|
82
|
+
return (
|
|
83
|
+
<FieldRenderer
|
|
84
|
+
key={field.name}
|
|
85
|
+
field={field}
|
|
86
|
+
defaultValue={initialData?.fields?.[field.name]}
|
|
87
|
+
contentLocale={activeLocale}
|
|
88
|
+
components={adminConfig?.fields?.[field.name]?.components}
|
|
89
|
+
editor={adminConfig?.fields?.[field.name]?.editor}
|
|
90
|
+
fieldAdmin={sliceFieldAdmin(adminConfig?.fields, field.name)}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const renderItem = (name: string): ReactNode => {
|
|
96
|
+
const tabSet = tabSetByName.get(name)
|
|
97
|
+
if (tabSet) return renderTabSet(tabSet)
|
|
98
|
+
|
|
99
|
+
const group = groupByName.get(name)
|
|
100
|
+
if (group) return renderGroup(group)
|
|
101
|
+
|
|
102
|
+
const row = rowByName.get(name)
|
|
103
|
+
if (row) return renderRow(row)
|
|
104
|
+
|
|
105
|
+
return renderField(name)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const renderRow = (row: RowDefinition): ReactNode => (
|
|
109
|
+
<AdminRow key={`row:${row.name}`}>{row.fields.map((name) => renderField(name))}</AdminRow>
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const renderGroup = (group: GroupDefinition): ReactNode => (
|
|
113
|
+
<AdminGroup key={`group:${group.name}`} label={group.label}>
|
|
114
|
+
{group.fields.map((name) => renderItem(name))}
|
|
115
|
+
</AdminGroup>
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const renderTabSet = (set: TabSetDefinition): ReactNode => {
|
|
119
|
+
const { visibleTabs, activeTabName, activeTab, errorCounts } = tabs.resolve(set)
|
|
120
|
+
const idBase = `tabset:${set.name}`
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
|
|
124
|
+
{visibleTabs.length > 0 && (
|
|
125
|
+
<AdminTabs
|
|
126
|
+
idBase={idBase}
|
|
127
|
+
tabs={visibleTabs}
|
|
128
|
+
activeTab={activeTabName}
|
|
129
|
+
onChange={(tabName) => onTabChange(set.name, tabName)}
|
|
130
|
+
errorCounts={errorCounts}
|
|
131
|
+
className={cx('byline-form-tabset-tabs', styles['tabset-tabs'])}
|
|
132
|
+
/>
|
|
133
|
+
)}
|
|
134
|
+
{activeTab && (
|
|
135
|
+
<div
|
|
136
|
+
role="tabpanel"
|
|
137
|
+
id={tabPanelId(idBase, activeTab.name)}
|
|
138
|
+
aria-labelledby={tabTriggerId(idBase, activeTab.name)}
|
|
139
|
+
className={cx('byline-form-tabset-fields', styles['tabset-fields'])}
|
|
140
|
+
>
|
|
141
|
+
{activeTab.fields.map((name) => renderItem(name))}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const sidebarNames = layout.sidebar ?? []
|
|
149
|
+
|
|
150
|
+
// Page-level widgets come first, matching the current editor. The plan's
|
|
151
|
+
// Task 2 sketch had them after the schema fields; that would visibly reorder
|
|
152
|
+
// a shipped collection's sidebar during a refactor whose acceptance is
|
|
153
|
+
// behaviour preservation.
|
|
154
|
+
const sidebarContents = (
|
|
155
|
+
<>
|
|
156
|
+
{sidebarSlot}
|
|
157
|
+
{sidebarNames.map((name) => renderItem(name))}
|
|
158
|
+
</>
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// `regions` is an ordered list, not a set: iterate it in sequence rather than
|
|
162
|
+
// testing membership, or a caller asking for ['sidebar', 'main'] silently
|
|
163
|
+
// still gets main first.
|
|
164
|
+
const renderRegion = (region: 'main' | 'sidebar'): ReactNode =>
|
|
165
|
+
region === 'main' ? (
|
|
166
|
+
<div key="main" className={cx('byline-form-content', styles.content)}>
|
|
167
|
+
{layout.main.map((name) => renderItem(name))}
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
<div key="sidebar" className={cx('byline-form-sidebar', styles.sidebar)}>
|
|
171
|
+
{sidebarContents}
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// Stacked: one column, both regions inline, and deliberately **not** the
|
|
176
|
+
// split layout class - that class becomes a two-column grid at >=60rem, so
|
|
177
|
+
// carrying it here would reserve an empty second track and narrow the only
|
|
178
|
+
// column. Removing the sidebar element does not remove the grid track.
|
|
179
|
+
if (variant === 'stacked') {
|
|
180
|
+
return (
|
|
181
|
+
<div className={cx('byline-form-layout-stacked', styles['layout-stacked'])}>
|
|
182
|
+
<div className={cx('byline-form-content', styles.content)}>
|
|
183
|
+
{regions.map((region) => (
|
|
184
|
+
<Fragment key={region}>
|
|
185
|
+
{region === 'main' ? layout.main.map((name) => renderItem(name)) : sidebarContents}
|
|
186
|
+
</Fragment>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className={cx('byline-form-layout', styles.layout)}>
|
|
195
|
+
{regions.map((region) => renderRegion(region))}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|