@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.
Files changed (75) hide show
  1. package/dist/fields/checkbox/checkbox-field.js +3 -1
  2. package/dist/fields/code/code-field.js +2 -1
  3. package/dist/fields/datetime/datetime-field.js +4 -2
  4. package/dist/fields/field-renderer.js +2 -1
  5. package/dist/fields/field-services-types.d.ts +23 -0
  6. package/dist/fields/file/file-field.js +3 -2
  7. package/dist/fields/group/group-field.js +3 -1
  8. package/dist/fields/image/image-field.js +3 -2
  9. package/dist/fields/numerical/numerical-field.js +2 -1
  10. package/dist/fields/relation/relation-field.js +3 -2
  11. package/dist/fields/relation/relation-many-field.js +3 -2
  12. package/dist/fields/relation/relation-picker.js +38 -7
  13. package/dist/fields/relation/relation-picker.module.js +3 -0
  14. package/dist/fields/relation/relation-picker_module.css +20 -0
  15. package/dist/fields/select/select-field.js +3 -2
  16. package/dist/fields/text/text-field.js +2 -1
  17. package/dist/fields/text-area/text-area-field.js +2 -1
  18. package/dist/forms/form-context.js +4 -1
  19. package/dist/forms/form-dom-scope.d.ts +34 -0
  20. package/dist/forms/form-dom-scope.js +20 -0
  21. package/dist/forms/form-layout-styles.test.node.d.ts +8 -0
  22. package/dist/forms/form-layout.d.ts +39 -0
  23. package/dist/forms/form-layout.js +102 -0
  24. package/dist/forms/form-page-chrome.d.ts +110 -0
  25. package/dist/forms/form-page-chrome.js +253 -0
  26. package/dist/forms/form-renderer.d.ts +2 -18
  27. package/dist/forms/form-renderer.js +105 -423
  28. package/dist/forms/form-renderer.module.js +2 -0
  29. package/dist/forms/form-renderer_module.css +13 -0
  30. package/dist/forms/use-form-submission.d.ts +74 -0
  31. package/dist/forms/use-form-submission.js +166 -0
  32. package/dist/forms/use-form-tabs.d.ts +29 -0
  33. package/dist/forms/use-form-tabs.js +45 -0
  34. package/dist/presentation/tabs.js +2 -1
  35. package/dist/react.d.ts +1 -1
  36. package/package.json +7 -7
  37. package/src/fields/checkbox/checkbox-field.tsx +3 -1
  38. package/src/fields/code/code-field.tsx +2 -1
  39. package/src/fields/datetime/datetime-field.tsx +4 -2
  40. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  41. package/src/fields/field-dom-ids.test.tsx +293 -0
  42. package/src/fields/field-renderer.tsx +2 -1
  43. package/src/fields/field-services-types.ts +25 -0
  44. package/src/fields/file/file-field.tsx +3 -2
  45. package/src/fields/group/group-field.tsx +3 -1
  46. package/src/fields/image/image-field.tsx +3 -2
  47. package/src/fields/numerical/numerical-field.tsx +2 -1
  48. package/src/fields/relation/relation-field.tsx +3 -2
  49. package/src/fields/relation/relation-many-field.tsx +3 -2
  50. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  51. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  52. package/src/fields/relation/relation-picker.module.css +32 -0
  53. package/src/fields/relation/relation-picker.tsx +59 -1
  54. package/src/fields/select/select-field.tsx +3 -2
  55. package/src/fields/text/text-field.tsx +2 -1
  56. package/src/fields/text-area/text-area-field.tsx +2 -1
  57. package/src/forms/form-context.tsx +2 -1
  58. package/src/forms/form-dom-scope.test.tsx +213 -0
  59. package/src/forms/form-dom-scope.tsx +70 -0
  60. package/src/forms/form-layout-styles.test.node.ts +32 -0
  61. package/src/forms/form-layout.test.tsx +276 -0
  62. package/src/forms/form-layout.tsx +204 -0
  63. package/src/forms/form-page-chrome.tsx +460 -0
  64. package/src/forms/form-renderer-submit.test.tsx +40 -0
  65. package/src/forms/form-renderer-tab-persistence.test.tsx +145 -0
  66. package/src/forms/form-renderer.module.css +18 -0
  67. package/src/forms/form-renderer.tsx +126 -600
  68. package/src/forms/form-submit-isolation.test.tsx +247 -0
  69. package/src/forms/use-form-submission.test.tsx +564 -0
  70. package/src/forms/use-form-submission.ts +293 -0
  71. package/src/forms/use-form-tabs.ts +89 -0
  72. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  73. package/src/presentation/tabs.test.tsx +49 -2
  74. package/src/presentation/tabs.tsx +11 -1
  75. 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
+ })