@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
|
@@ -211,6 +211,46 @@ describe('FormRenderer submit contract', () => {
|
|
|
211
211
|
expect(saveButton?.querySelector('.byline-loader-ellipsis')).toBeNull()
|
|
212
212
|
})
|
|
213
213
|
|
|
214
|
+
it('restores focus to the control that had it before the form went busy', async () => {
|
|
215
|
+
// `inert` drops the active control out of the tab order mid-save. The hook
|
|
216
|
+
// signals the transition through onBeforeBusy; the component owns the DOM
|
|
217
|
+
// refs. Drop that signal and focus silently lands on <body>.
|
|
218
|
+
//
|
|
219
|
+
// This covers the KEYBOARD path only: focus is in a field and the form is
|
|
220
|
+
// submitted from there, so the captured element is still focusable when the
|
|
221
|
+
// form comes back. Clicking Save is different -- the captured element is the
|
|
222
|
+
// Save button, which is disabled once the save succeeds -- and that path is
|
|
223
|
+
// broken in a real browser. See issue #98. Do not read this passing test as
|
|
224
|
+
// "focus recovery works" in general.
|
|
225
|
+
const submission = deferred()
|
|
226
|
+
render({ ...baseProps, onSubmit: () => submission.promise })
|
|
227
|
+
typeIntoTitle('Hello')
|
|
228
|
+
|
|
229
|
+
const input = container.querySelector<HTMLInputElement>('input[name="title"]')
|
|
230
|
+
if (input == null) throw new Error('title input not found')
|
|
231
|
+
act(() => {
|
|
232
|
+
input.focus()
|
|
233
|
+
})
|
|
234
|
+
expect(document.activeElement).toBe(input)
|
|
235
|
+
|
|
236
|
+
submitForm()
|
|
237
|
+
await act(async () => {})
|
|
238
|
+
|
|
239
|
+
// jsdom does not implement `inert`, so the browser's own focus removal has
|
|
240
|
+
// to be stood in for here. What is under test is the recovery afterwards.
|
|
241
|
+
act(() => {
|
|
242
|
+
input.blur()
|
|
243
|
+
})
|
|
244
|
+
expect(document.activeElement).not.toBe(input)
|
|
245
|
+
|
|
246
|
+
await act(async () => {
|
|
247
|
+
submission.resolve()
|
|
248
|
+
await submission.promise
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
expect(document.activeElement).toBe(input)
|
|
252
|
+
})
|
|
253
|
+
|
|
214
254
|
it('hides the busy indicator when onSubmit rejects', async () => {
|
|
215
255
|
const submission = deferred()
|
|
216
256
|
render({ ...baseProps, onSubmit: () => submission.promise })
|
|
@@ -0,0 +1,145 @@
|
|
|
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 { defineAdminConfig } from '@byline/core'
|
|
12
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
13
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
14
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
16
|
+
|
|
17
|
+
import { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
18
|
+
import { FormRenderer } from './form-renderer'
|
|
19
|
+
|
|
20
|
+
const globalWithAct = globalThis as any
|
|
21
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
22
|
+
|
|
23
|
+
defineAdminConfig({
|
|
24
|
+
i18n: {
|
|
25
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
26
|
+
content: { defaultLocale: 'en', locales: ['en', 'fr'] },
|
|
27
|
+
},
|
|
28
|
+
collections: [
|
|
29
|
+
{
|
|
30
|
+
path: 'pages',
|
|
31
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
32
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const fields = [
|
|
39
|
+
{ name: 'title', label: 'Title', type: 'text' as const },
|
|
40
|
+
{ name: 'seoTitle', label: 'SEO title', type: 'text' as const },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const adminConfig = {
|
|
44
|
+
layout: { main: ['content'] },
|
|
45
|
+
tabSets: [
|
|
46
|
+
{
|
|
47
|
+
name: 'content',
|
|
48
|
+
tabs: [
|
|
49
|
+
{ name: 'general', label: 'General', fields: ['title'] },
|
|
50
|
+
{ name: 'seo', label: 'SEO', fields: ['seoTitle'] },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let container: HTMLDivElement
|
|
57
|
+
let root: Root
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
container = document.createElement('div')
|
|
61
|
+
document.body.appendChild(container)
|
|
62
|
+
root = createRoot(container)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
act(() => {
|
|
67
|
+
root.unmount()
|
|
68
|
+
})
|
|
69
|
+
container.remove()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const mount = (locale: string, versionId: string) => {
|
|
73
|
+
act(() => {
|
|
74
|
+
root.render(
|
|
75
|
+
<I18nProvider
|
|
76
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
77
|
+
activeLocale="en"
|
|
78
|
+
defaultLocale="en"
|
|
79
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
80
|
+
>
|
|
81
|
+
<BylineFieldServicesProvider
|
|
82
|
+
services={
|
|
83
|
+
{
|
|
84
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
85
|
+
uploadField: async () => ({}),
|
|
86
|
+
} as any
|
|
87
|
+
}
|
|
88
|
+
>
|
|
89
|
+
<FormRenderer
|
|
90
|
+
mode="edit"
|
|
91
|
+
fields={fields as any}
|
|
92
|
+
initialLocale={locale}
|
|
93
|
+
initialData={{
|
|
94
|
+
id: 'doc1',
|
|
95
|
+
versionId,
|
|
96
|
+
fields: { title: locale, seoTitle: `${locale} SEO` },
|
|
97
|
+
}}
|
|
98
|
+
adminConfig={adminConfig as any}
|
|
99
|
+
collectionPath="pages"
|
|
100
|
+
onSubmit={() => {}}
|
|
101
|
+
onCancel={() => {}}
|
|
102
|
+
/>
|
|
103
|
+
</BylineFieldServicesProvider>
|
|
104
|
+
</I18nProvider>
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const selectedTabLabel = () =>
|
|
110
|
+
container.querySelector('[role="tab"][aria-selected="true"]')?.textContent
|
|
111
|
+
|
|
112
|
+
describe('FormRenderer tab persistence across keyed remounts', () => {
|
|
113
|
+
// FormProvider is keyed on `${initialLocale}-${versionId}`, so changing either
|
|
114
|
+
// remounts the whole subtree. Tab selection is lifted above that key on
|
|
115
|
+
// purpose; this asserts the wiring, which a controlled-prop test on
|
|
116
|
+
// FormLayout alone cannot reach.
|
|
117
|
+
//
|
|
118
|
+
// Adopted from the reviewer's Checkpoint 2 regression fixture.
|
|
119
|
+
it('retains the selected tab through locale and version remounts', () => {
|
|
120
|
+
mount('en', 'v1')
|
|
121
|
+
|
|
122
|
+
const seoTab = [...container.querySelectorAll('[role="tab"]')].find(
|
|
123
|
+
(t) => t.textContent === 'SEO'
|
|
124
|
+
)
|
|
125
|
+
if (seoTab == null) throw new Error('SEO tab not found')
|
|
126
|
+
act(() => {
|
|
127
|
+
;(seoTab as HTMLElement).click()
|
|
128
|
+
})
|
|
129
|
+
const first = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
|
|
130
|
+
expect(first).not.toBeNull()
|
|
131
|
+
|
|
132
|
+
mount('fr', 'v1') // locale change remounts FormProvider via its key
|
|
133
|
+
const french = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
|
|
134
|
+
expect(french).not.toBeNull()
|
|
135
|
+
expect(french).not.toBe(first)
|
|
136
|
+
expect(french?.value).toBe('fr SEO')
|
|
137
|
+
expect(selectedTabLabel()).toBe('SEO')
|
|
138
|
+
|
|
139
|
+
mount('fr', 'v2') // version change remounts it again
|
|
140
|
+
const versioned = container.querySelector<HTMLInputElement>('input[name="seoTitle"]')
|
|
141
|
+
expect(versioned).not.toBeNull()
|
|
142
|
+
expect(versioned).not.toBe(french)
|
|
143
|
+
expect(selectedTabLabel()).toBe('SEO')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -304,6 +304,24 @@
|
|
|
304
304
|
}
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/* One column at every width. The split layout above becomes a two-column grid
|
|
308
|
+
at >=60rem; a stacked form must not inherit that, or it reserves an empty
|
|
309
|
+
second track and narrows its only content column. */
|
|
310
|
+
.layout-stacked,
|
|
311
|
+
:global(.byline-form-layout-stacked) {
|
|
312
|
+
display: flex;
|
|
313
|
+
flex-direction: column;
|
|
314
|
+
gap: var(--spacing-12);
|
|
315
|
+
padding-top: var(--spacing-32);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@media (min-width: 60rem) {
|
|
319
|
+
.layout-stacked,
|
|
320
|
+
:global(.byline-form-layout-stacked) {
|
|
321
|
+
gap: var(--spacing-32);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
307
325
|
.content,
|
|
308
326
|
:global(.byline-form-content) {
|
|
309
327
|
display: flex;
|