@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,247 @@
|
|
|
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 { createPortal } from 'react-dom'
|
|
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 { FormRenderer } from './form-renderer'
|
|
20
|
+
|
|
21
|
+
const globalWithAct = globalThis as any
|
|
22
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
23
|
+
|
|
24
|
+
defineAdminConfig({
|
|
25
|
+
i18n: {
|
|
26
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
27
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
28
|
+
},
|
|
29
|
+
collections: [
|
|
30
|
+
{
|
|
31
|
+
path: 'pages',
|
|
32
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
33
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const fieldServices = {
|
|
40
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
41
|
+
uploadField: async () => ({}),
|
|
42
|
+
} as any
|
|
43
|
+
|
|
44
|
+
// Optional so submission is not blocked by validation: this file tests which
|
|
45
|
+
// form a submit event belongs to, not what the form does with it.
|
|
46
|
+
const fields = [{ name: 'title', label: 'Title', type: 'text' as const, optional: true }]
|
|
47
|
+
|
|
48
|
+
let container: HTMLDivElement
|
|
49
|
+
let root: Root
|
|
50
|
+
let portalHost: HTMLDivElement
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
container = document.createElement('div')
|
|
54
|
+
document.body.appendChild(container)
|
|
55
|
+
portalHost = document.createElement('div')
|
|
56
|
+
document.body.appendChild(portalHost)
|
|
57
|
+
root = createRoot(container)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
act(() => {
|
|
62
|
+
root.unmount()
|
|
63
|
+
})
|
|
64
|
+
container.remove()
|
|
65
|
+
portalHost.remove()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A nested form rendered through a portal: outside the parent `<form>` in the
|
|
70
|
+
* DOM, so no invalid nested-form markup arises, but still a descendant in the
|
|
71
|
+
* *React* tree — which is what React propagates synthetic events along.
|
|
72
|
+
*/
|
|
73
|
+
const PortalledInnerForm = ({
|
|
74
|
+
stopPropagation,
|
|
75
|
+
onInnerSubmit,
|
|
76
|
+
}: {
|
|
77
|
+
stopPropagation: boolean
|
|
78
|
+
onInnerSubmit: () => void
|
|
79
|
+
}) =>
|
|
80
|
+
createPortal(
|
|
81
|
+
<form
|
|
82
|
+
data-testid="inner-form"
|
|
83
|
+
onSubmit={(event) => {
|
|
84
|
+
event.preventDefault()
|
|
85
|
+
if (stopPropagation) event.stopPropagation()
|
|
86
|
+
onInnerSubmit()
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<input name="innerTitle" defaultValue="" />
|
|
90
|
+
<button type="submit">Save inner</button>
|
|
91
|
+
</form>,
|
|
92
|
+
portalHost
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const renderWithInnerForm = ({
|
|
96
|
+
parentSubmit,
|
|
97
|
+
onInnerSubmit = () => {},
|
|
98
|
+
stopPropagation = false,
|
|
99
|
+
}: {
|
|
100
|
+
parentSubmit: () => Promise<void>
|
|
101
|
+
onInnerSubmit?: () => void
|
|
102
|
+
stopPropagation?: boolean
|
|
103
|
+
}) => {
|
|
104
|
+
act(() => {
|
|
105
|
+
root.render(
|
|
106
|
+
<I18nProvider
|
|
107
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
108
|
+
activeLocale="en"
|
|
109
|
+
defaultLocale="en"
|
|
110
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
111
|
+
>
|
|
112
|
+
<BylineFieldServicesProvider services={fieldServices}>
|
|
113
|
+
<FormRenderer
|
|
114
|
+
mode="create"
|
|
115
|
+
fields={fields}
|
|
116
|
+
collectionPath="pages"
|
|
117
|
+
onCancel={() => {}}
|
|
118
|
+
onSubmit={parentSubmit}
|
|
119
|
+
headerSlot={
|
|
120
|
+
<PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={onInnerSubmit} />
|
|
121
|
+
}
|
|
122
|
+
/>
|
|
123
|
+
</BylineFieldServicesProvider>
|
|
124
|
+
</I18nProvider>
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parentForm = () => {
|
|
130
|
+
const form = container.querySelector('form')
|
|
131
|
+
if (form == null) throw new Error('parent form not found')
|
|
132
|
+
return form
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const innerForm = () => {
|
|
136
|
+
const form = portalHost.querySelector<HTMLFormElement>('[data-testid="inner-form"]')
|
|
137
|
+
if (form == null) throw new Error('inner form not found')
|
|
138
|
+
return form
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const submit = async (form: HTMLFormElement) => {
|
|
142
|
+
await act(async () => {
|
|
143
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe('submit isolation across a portal', () => {
|
|
148
|
+
/**
|
|
149
|
+
* The inner form here deliberately does **not** call `stopPropagation`, so
|
|
150
|
+
* these two cases exercise the parent's own guard. With the child-side defence
|
|
151
|
+
* in place as well, a broken parent guard would be invisible.
|
|
152
|
+
*/
|
|
153
|
+
it('does not run the parent submit handler when a portalled inner form submits', async () => {
|
|
154
|
+
const parentSubmit = vi.fn(async () => {})
|
|
155
|
+
renderWithInnerForm({ parentSubmit })
|
|
156
|
+
|
|
157
|
+
await submit(innerForm())
|
|
158
|
+
|
|
159
|
+
expect(parentSubmit).not.toHaveBeenCalled()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('still runs the inner form’s own handler', async () => {
|
|
163
|
+
const parentSubmit = vi.fn(async () => {})
|
|
164
|
+
const onInnerSubmit = vi.fn()
|
|
165
|
+
renderWithInnerForm({ parentSubmit, onInnerSubmit })
|
|
166
|
+
|
|
167
|
+
await submit(innerForm())
|
|
168
|
+
|
|
169
|
+
expect(onInnerSubmit).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(parentSubmit).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('still submits normally from the parent form itself', async () => {
|
|
174
|
+
const parentSubmit = vi.fn(async () => {})
|
|
175
|
+
renderWithInnerForm({ parentSubmit })
|
|
176
|
+
|
|
177
|
+
await submit(parentForm())
|
|
178
|
+
|
|
179
|
+
expect(parentSubmit).toHaveBeenCalledTimes(1)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('leaves the parent form submitting on repeat after an inner submit', async () => {
|
|
183
|
+
const parentSubmit = vi.fn(async () => {})
|
|
184
|
+
renderWithInnerForm({ parentSubmit })
|
|
185
|
+
|
|
186
|
+
await submit(innerForm())
|
|
187
|
+
await submit(parentForm())
|
|
188
|
+
await submit(parentForm())
|
|
189
|
+
|
|
190
|
+
expect(parentSubmit).toHaveBeenCalledTimes(2)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The child-side defence, proved without the parent guard in the picture. If this
|
|
196
|
+
* were asserted against `FormRenderer`, a working parent guard would satisfy it
|
|
197
|
+
* whether or not the child stopped propagation, and the test would prove nothing
|
|
198
|
+
* about the child.
|
|
199
|
+
*/
|
|
200
|
+
describe('the child-side stopPropagation defence', () => {
|
|
201
|
+
const PlainParent = ({
|
|
202
|
+
onParentSubmit,
|
|
203
|
+
stopPropagation,
|
|
204
|
+
}: {
|
|
205
|
+
onParentSubmit: () => void
|
|
206
|
+
stopPropagation: boolean
|
|
207
|
+
}) => (
|
|
208
|
+
<form
|
|
209
|
+
data-testid="plain-parent"
|
|
210
|
+
onSubmit={(event) => {
|
|
211
|
+
event.preventDefault()
|
|
212
|
+
onParentSubmit()
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={() => {}} />
|
|
216
|
+
</form>
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const renderPlain = (props: { onParentSubmit: () => void; stopPropagation: boolean }) => {
|
|
220
|
+
act(() => {
|
|
221
|
+
root.render(<PlainParent {...props} />)
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
it('keeps an unguarded parent handler from running when the child stops propagation', async () => {
|
|
226
|
+
const onParentSubmit = vi.fn()
|
|
227
|
+
renderPlain({ onParentSubmit, stopPropagation: true })
|
|
228
|
+
|
|
229
|
+
await submit(innerForm())
|
|
230
|
+
|
|
231
|
+
expect(onParentSubmit).not.toHaveBeenCalled()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The control: the same unguarded parent, with the child defence removed. It
|
|
236
|
+
* fails in exactly the way the feature is meant to prevent, which is what makes
|
|
237
|
+
* the assertion above meaningful.
|
|
238
|
+
*/
|
|
239
|
+
it('runs an unguarded parent handler when the child does not stop propagation', async () => {
|
|
240
|
+
const onParentSubmit = vi.fn()
|
|
241
|
+
renderPlain({ onParentSubmit, stopPropagation: false })
|
|
242
|
+
|
|
243
|
+
await submit(innerForm())
|
|
244
|
+
|
|
245
|
+
expect(onParentSubmit).toHaveBeenCalledTimes(1)
|
|
246
|
+
})
|
|
247
|
+
})
|