@byline/admin 6.2.1 → 6.4.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/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +4 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +5 -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 +6 -4
- package/dist/fields/group/group-field.js +8 -3
- package/dist/fields/image/image-field.js +6 -4
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +8 -2
- package/dist/fields/relation/relation-many-field.js +12 -4
- 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 +4 -2
- package/dist/fields/text/text-field.js +6 -3
- package/dist/fields/text-area/text-area-field.js +6 -3
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +115 -130
- package/dist/forms/form-dom-scope.d.ts +34 -0
- package/dist/forms/form-dom-scope.js +20 -0
- package/dist/forms/form-layout.js +10 -3
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +13 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +4 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +5 -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 +6 -4
- package/src/fields/group/group-field.tsx +5 -3
- package/src/fields/image/image-field.tsx +6 -4
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +8 -2
- package/src/fields/relation/relation-many-field.tsx +19 -4
- 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 +4 -2
- package/src/fields/text/text-field.tsx +5 -3
- package/src/fields/text-area/text-area-field.tsx +5 -3
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +180 -234
- package/src/forms/form-correctness.test.tsx +560 -0
- package/src/forms/form-dom-scope.test.tsx +213 -0
- package/src/forms/form-dom-scope.tsx +70 -0
- package/src/forms/form-layout.tsx +10 -3
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +26 -64
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
- 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,153 @@
|
|
|
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 } from 'vitest'
|
|
17
|
+
|
|
18
|
+
import { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
19
|
+
import { FormProvider } from '../forms/form-context'
|
|
20
|
+
import { FormLayout } from '../forms/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: 'seoTitle', label: 'SEO title', type: 'text' },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Both forms declare a tab set under the same name, which is the ordinary case:
|
|
50
|
+
* two forms over one collection share its admin configuration verbatim.
|
|
51
|
+
*/
|
|
52
|
+
const adminConfig = {
|
|
53
|
+
layout: { main: ['main'] },
|
|
54
|
+
tabSets: [
|
|
55
|
+
{
|
|
56
|
+
name: 'main',
|
|
57
|
+
tabs: [
|
|
58
|
+
{ name: 'general', label: 'General', fields: ['title'] },
|
|
59
|
+
{ name: 'seo', label: 'SEO', fields: ['seoTitle'] },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
} as unknown as AdminResourceConfig
|
|
64
|
+
|
|
65
|
+
let container: HTMLDivElement
|
|
66
|
+
let root: Root
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
container = document.createElement('div')
|
|
70
|
+
document.body.appendChild(container)
|
|
71
|
+
root = createRoot(container)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
act(() => {
|
|
76
|
+
root.unmount()
|
|
77
|
+
})
|
|
78
|
+
container.remove()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const Form = ({ name }: { name: string }) => (
|
|
82
|
+
<form aria-label={name}>
|
|
83
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
84
|
+
<FormLayout
|
|
85
|
+
fields={fields}
|
|
86
|
+
adminConfig={adminConfig}
|
|
87
|
+
activeTabBySet={{ main: 'general' }}
|
|
88
|
+
onTabChange={() => {}}
|
|
89
|
+
/>
|
|
90
|
+
</FormProvider>
|
|
91
|
+
</form>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const renderTwoForms = () => {
|
|
95
|
+
act(() => {
|
|
96
|
+
root.render(
|
|
97
|
+
<I18nProvider
|
|
98
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
99
|
+
activeLocale="en"
|
|
100
|
+
defaultLocale="en"
|
|
101
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
102
|
+
>
|
|
103
|
+
<BylineFieldServicesProvider
|
|
104
|
+
services={
|
|
105
|
+
{
|
|
106
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
107
|
+
uploadField: async () => ({}),
|
|
108
|
+
} as any
|
|
109
|
+
}
|
|
110
|
+
>
|
|
111
|
+
<Form name="parent" />
|
|
112
|
+
<Form name="child" />
|
|
113
|
+
</BylineFieldServicesProvider>
|
|
114
|
+
</I18nProvider>
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('tab DOM ids across concurrently mounted forms', () => {
|
|
120
|
+
it('keeps tab trigger ids distinct across two forms with a "main" tab set', () => {
|
|
121
|
+
renderTwoForms()
|
|
122
|
+
|
|
123
|
+
const triggers = Array.from(container.querySelectorAll('[role="tab"]')).map((n) => n.id)
|
|
124
|
+
|
|
125
|
+
expect(triggers.length).toBeGreaterThan(0)
|
|
126
|
+
expect(triggers.every((id) => id !== '')).toBe(true)
|
|
127
|
+
expect(new Set(triggers).size).toBe(triggers.length)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('labels each tab panel from a trigger in its own form', () => {
|
|
131
|
+
renderTwoForms()
|
|
132
|
+
|
|
133
|
+
const panels = Array.from(container.querySelectorAll('[role="tabpanel"]'))
|
|
134
|
+
expect(panels).toHaveLength(2)
|
|
135
|
+
|
|
136
|
+
for (const panel of panels) {
|
|
137
|
+
const owner = document.getElementById(panel.getAttribute('aria-labelledby') as string)
|
|
138
|
+
expect(owner).not.toBeNull()
|
|
139
|
+
// Resolution is document-wide, so an unscoped id points the second form's
|
|
140
|
+
// panel at the first form's trigger.
|
|
141
|
+
expect(owner?.closest('form')).toBe(panel.closest('form'))
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('keeps panel ids distinct so aria-controls cannot cross forms', () => {
|
|
146
|
+
renderTwoForms()
|
|
147
|
+
|
|
148
|
+
const panelIds = Array.from(container.querySelectorAll('[role="tabpanel"]')).map((n) => n.id)
|
|
149
|
+
|
|
150
|
+
expect(panelIds.every((id) => id !== '')).toBe(true)
|
|
151
|
+
expect(new Set(panelIds).size).toBe(panelIds.length)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -51,7 +51,7 @@ import { AdminTabs } from './tabs'
|
|
|
51
51
|
* report overflow. Drive the two widths the component reads off its stable
|
|
52
52
|
* override handles.
|
|
53
53
|
*/
|
|
54
|
-
const layout = { container: 0, row: 0 }
|
|
54
|
+
const layout = { container: 0, row: 0, rowReads: 0 }
|
|
55
55
|
|
|
56
56
|
function installPointerStubs() {
|
|
57
57
|
// jsdom implements neither pointer capture nor scrollTo; the component guards
|
|
@@ -96,7 +96,11 @@ function installLayoutStubs() {
|
|
|
96
96
|
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
|
97
97
|
configurable: true,
|
|
98
98
|
get(this: HTMLElement) {
|
|
99
|
-
|
|
99
|
+
if (!this.classList.contains('byline-admin-tablist')) return 0
|
|
100
|
+
// Counted, not just answered: in a browser this getter is what forces
|
|
101
|
+
// the synchronous layout, so the count is the cost under test.
|
|
102
|
+
layout.rowReads += 1
|
|
103
|
+
return layout.row
|
|
100
104
|
},
|
|
101
105
|
})
|
|
102
106
|
}
|
|
@@ -135,6 +139,7 @@ describe('AdminTabs', () => {
|
|
|
135
139
|
observed.length = 0
|
|
136
140
|
layout.container = 1000
|
|
137
141
|
layout.row = 400
|
|
142
|
+
layout.rowReads = 0
|
|
138
143
|
onChange = vi.fn()
|
|
139
144
|
container = document.createElement('div')
|
|
140
145
|
document.body.appendChild(container)
|
|
@@ -367,6 +372,48 @@ describe('AdminTabs', () => {
|
|
|
367
372
|
expect(observedClasses.some((c) => c.includes('byline-admin-tabs '))).toBe(true)
|
|
368
373
|
})
|
|
369
374
|
|
|
375
|
+
/**
|
|
376
|
+
* The tab array arrives with a fresh identity on every render of the form
|
|
377
|
+
* layout: `useFormTabs.resolve()` filters `set.tabs` each time it is called,
|
|
378
|
+
* and the live form data it filters against updates per keystroke. Keying the
|
|
379
|
+
* measurement effect on that identity made every unrelated render tear down
|
|
380
|
+
* the ResizeObserver and force a synchronous layout read — measured in Chrome
|
|
381
|
+
* against a seeded `pages` document as one forced read per keystroke, and two
|
|
382
|
+
* on an editor mount.
|
|
383
|
+
*/
|
|
384
|
+
it('does not re-measure when the parent re-renders with an equal tab array', () => {
|
|
385
|
+
render()
|
|
386
|
+
const readsAfterMount = layout.rowReads
|
|
387
|
+
const observedAfterMount = observed.length
|
|
388
|
+
|
|
389
|
+
render({ tabs: TABS.map((tab) => ({ ...tab })) })
|
|
390
|
+
|
|
391
|
+
expect(layout.rowReads).toBe(readsAfterMount)
|
|
392
|
+
expect(observed.length).toBe(observedAfterMount)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('re-measures when a tab is added or removed', () => {
|
|
396
|
+
render()
|
|
397
|
+
const readsAfterMount = layout.rowReads
|
|
398
|
+
|
|
399
|
+
render({ tabs: [...TABS, { name: 'indexing', label: 'Indexing' }] })
|
|
400
|
+
|
|
401
|
+
expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A label carries the width, so a translation change has to re-measure even
|
|
406
|
+
* though the tab names are untouched.
|
|
407
|
+
*/
|
|
408
|
+
it('re-measures when a label changes', () => {
|
|
409
|
+
render()
|
|
410
|
+
const readsAfterMount = layout.rowReads
|
|
411
|
+
|
|
412
|
+
render({ tabs: TABS.map((tab, i) => (i === 0 ? { ...tab, label: 'Détails' } : tab)) })
|
|
413
|
+
|
|
414
|
+
expect(layout.rowReads).toBeGreaterThan(readsAfterMount)
|
|
415
|
+
})
|
|
416
|
+
|
|
370
417
|
// ─── Drag-to-scroll (desktop) ─────────────────────────────────────────
|
|
371
418
|
|
|
372
419
|
const viewport = () =>
|
|
@@ -124,6 +124,16 @@ export const AdminTabs = ({
|
|
|
124
124
|
|
|
125
125
|
const [strip, setStrip] = useState<StripState>(INITIAL_STRIP_STATE)
|
|
126
126
|
|
|
127
|
+
// The measurement effect is keyed on what the tabs *are*, not on the identity
|
|
128
|
+
// of the array carrying them. A form layout rebuilds that array on every
|
|
129
|
+
// render — `useFormTabs.resolve()` filters `set.tabs` against live form data,
|
|
130
|
+
// so a new array arrives per keystroke — and re-running the effect for one of
|
|
131
|
+
// those renders would disconnect the observer and force a synchronous layout
|
|
132
|
+
// read on a tree the browser has just invalidated. Labels are part of the key
|
|
133
|
+
// because the label is what carries the width: a translation change has to
|
|
134
|
+
// re-measure, a keystroke elsewhere in the form does not.
|
|
135
|
+
const tabsKey = tabs.map((tab) => `${tab.name}:${tab.label}`).join('\u0000')
|
|
136
|
+
|
|
127
137
|
const measure = useCallback(() => {
|
|
128
138
|
const container = containerRef.current
|
|
129
139
|
const row = rowRef.current
|
|
@@ -162,7 +172,7 @@ export const AdminTabs = ({
|
|
|
162
172
|
observer.observe(container)
|
|
163
173
|
observer.observe(row)
|
|
164
174
|
return () => observer.disconnect()
|
|
165
|
-
}, [measure,
|
|
175
|
+
}, [measure, tabsKey])
|
|
166
176
|
|
|
167
177
|
const reveal = useCallback((name: string) => {
|
|
168
178
|
const viewport = viewportRef.current
|
package/src/react.ts
CHANGED
|
@@ -81,10 +81,12 @@ export * from './widgets/status-badge/status-badge.js'
|
|
|
81
81
|
// Field-side service contract types.
|
|
82
82
|
export type {
|
|
83
83
|
BylineFieldServices,
|
|
84
|
+
CanCreateInCollectionFn,
|
|
84
85
|
CollectionListDoc,
|
|
85
86
|
CollectionListParams,
|
|
86
87
|
CollectionListResponse,
|
|
87
88
|
GetCollectionDocumentsFn,
|
|
89
|
+
GetCreateDocumentUrlFn,
|
|
88
90
|
GetTreeAncestorsFn,
|
|
89
91
|
GetTreeParentFn,
|
|
90
92
|
PlaceTreeNodeFn,
|