@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
|
@@ -13,6 +13,7 @@ import { Input, Label } from '@byline/ui/react'
|
|
|
13
13
|
import cx from 'clsx'
|
|
14
14
|
|
|
15
15
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
16
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
16
17
|
import { LocaleBadge } from '../locale-badge'
|
|
17
18
|
import styles from './text-field.module.css'
|
|
18
19
|
|
|
@@ -41,7 +42,7 @@ export const TextField = ({
|
|
|
41
42
|
const fieldError = useFieldError(fieldPath)
|
|
42
43
|
const fieldValue = useFieldValue<string | undefined>(fieldPath)
|
|
43
44
|
const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
|
|
44
|
-
const htmlId = id
|
|
45
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
45
46
|
|
|
46
47
|
const handleChange = useCallback(
|
|
47
48
|
(value: string) => {
|
|
@@ -13,6 +13,7 @@ import { Label, TextArea } from '@byline/ui/react'
|
|
|
13
13
|
import cx from 'clsx'
|
|
14
14
|
|
|
15
15
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
16
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
16
17
|
import { LocaleBadge } from '../locale-badge'
|
|
17
18
|
import styles from './text-area-field.module.css'
|
|
18
19
|
|
|
@@ -41,7 +42,7 @@ export const TextAreaField = ({
|
|
|
41
42
|
const fieldError = useFieldError(fieldPath)
|
|
42
43
|
const fieldValue = useFieldValue<string | undefined>(fieldPath)
|
|
43
44
|
const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
|
|
44
|
-
const htmlId = id
|
|
45
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
45
46
|
|
|
46
47
|
const handleChange = useCallback(
|
|
47
48
|
(value: string) => {
|
|
@@ -15,6 +15,7 @@ import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/c
|
|
|
15
15
|
import { normalizeHooks } from '@byline/core'
|
|
16
16
|
import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
|
|
17
17
|
|
|
18
|
+
import { FormDomScopeProvider } from './form-dom-scope'
|
|
18
19
|
// Vendored nested get/set (see ./nested-path) — removes the lodash-es dep
|
|
19
20
|
// outright. A bare `from 'lodash-es'` import otherwise pools into a single
|
|
20
21
|
// ~85KB chunk that leaks onto the public frontend bundle (form-context is
|
|
@@ -749,7 +750,7 @@ export const FormProvider = ({
|
|
|
749
750
|
subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
|
|
750
751
|
}}
|
|
751
752
|
>
|
|
752
|
-
{children}
|
|
753
|
+
<FormDomScopeProvider>{children}</FormDomScopeProvider>
|
|
753
754
|
</FormContext.Provider>
|
|
754
755
|
)
|
|
755
756
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
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 { createRoot, hydrateRoot, type Root } from 'react-dom/client'
|
|
12
|
+
import { renderToString } from 'react-dom/server'
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
+
|
|
15
|
+
import { FormProvider } from './form-context'
|
|
16
|
+
import { useFormDomScope, useScopedDomId } from './form-dom-scope'
|
|
17
|
+
|
|
18
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
19
|
+
|
|
20
|
+
describe('form DOM scope', () => {
|
|
21
|
+
let container: HTMLDivElement
|
|
22
|
+
let root: Root
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
container = document.createElement('div')
|
|
26
|
+
document.body.appendChild(container)
|
|
27
|
+
root = createRoot(container)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
act(() => {
|
|
32
|
+
root.unmount()
|
|
33
|
+
})
|
|
34
|
+
container.remove()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const render = (node: React.ReactNode) => {
|
|
38
|
+
act(() => {
|
|
39
|
+
root.render(node)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('gives two concurrently mounted providers different scopes', () => {
|
|
44
|
+
const scopes: string[] = []
|
|
45
|
+
const Probe = () => {
|
|
46
|
+
scopes.push(useFormDomScope())
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
render(
|
|
51
|
+
<>
|
|
52
|
+
<FormProvider initialData={{}}>
|
|
53
|
+
<Probe />
|
|
54
|
+
</FormProvider>
|
|
55
|
+
<FormProvider initialData={{}}>
|
|
56
|
+
<Probe />
|
|
57
|
+
</FormProvider>
|
|
58
|
+
</>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
expect(scopes).toHaveLength(2)
|
|
62
|
+
expect(new Set(scopes).size).toBe(2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('derives a stable id for the same base across re-renders', () => {
|
|
66
|
+
const seen: string[] = []
|
|
67
|
+
const Probe = () => {
|
|
68
|
+
seen.push(useScopedDomId('title'))
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render(
|
|
73
|
+
<FormProvider initialData={{}}>
|
|
74
|
+
<Probe />
|
|
75
|
+
</FormProvider>
|
|
76
|
+
)
|
|
77
|
+
render(
|
|
78
|
+
<FormProvider initialData={{}}>
|
|
79
|
+
<Probe />
|
|
80
|
+
</FormProvider>
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
expect(seen.length).toBeGreaterThanOrEqual(2)
|
|
84
|
+
expect(new Set(seen).size).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('scopes the base so two forms derive different ids from one field name', () => {
|
|
88
|
+
const ids: string[] = []
|
|
89
|
+
const Probe = () => {
|
|
90
|
+
ids.push(useScopedDomId('title'))
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<>
|
|
96
|
+
<FormProvider initialData={{}}>
|
|
97
|
+
<Probe />
|
|
98
|
+
</FormProvider>
|
|
99
|
+
<FormProvider initialData={{}}>
|
|
100
|
+
<Probe />
|
|
101
|
+
</FormProvider>
|
|
102
|
+
</>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
expect(new Set(ids).size).toBe(2)
|
|
106
|
+
for (const id of ids) expect(id.endsWith('title')).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('returns a caller-supplied id unchanged rather than scoping it twice', () => {
|
|
110
|
+
let derived = ''
|
|
111
|
+
const Probe = () => {
|
|
112
|
+
derived = useScopedDomId('title', 'explicit-id')
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
render(
|
|
117
|
+
<FormProvider initialData={{}}>
|
|
118
|
+
<Probe />
|
|
119
|
+
</FormProvider>
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(derived).toBe('explicit-id')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Field widgets render outside a form provider in places — the admin user and
|
|
127
|
+
* role screens compose them directly — so the hook has to degrade to the bare
|
|
128
|
+
* base rather than throw or invent a scope.
|
|
129
|
+
*/
|
|
130
|
+
it('falls back to the bare base outside a provider', () => {
|
|
131
|
+
let derived = ''
|
|
132
|
+
let scope = 'unset'
|
|
133
|
+
const Probe = () => {
|
|
134
|
+
scope = useFormDomScope()
|
|
135
|
+
derived = useScopedDomId('title')
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
render(<Probe />)
|
|
140
|
+
|
|
141
|
+
expect(scope).toBe('')
|
|
142
|
+
expect(derived).toBe('title')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('produces a non-empty scope inside a provider', () => {
|
|
146
|
+
const scopes: string[] = []
|
|
147
|
+
const Probe = () => {
|
|
148
|
+
scopes.push(useFormDomScope())
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
render(
|
|
153
|
+
<FormProvider initialData={{}}>
|
|
154
|
+
<Probe />
|
|
155
|
+
</FormProvider>
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
expect(scopes[0]).not.toBe('')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The reason the scope comes from `useId` rather than a counter or a random
|
|
163
|
+
* value: the server render and the hydration pass must agree. If they do not,
|
|
164
|
+
* React discards the server-rendered attributes and every `for` and
|
|
165
|
+
* `aria-labelledby` written during SSR points at an id that no longer exists.
|
|
166
|
+
*
|
|
167
|
+
* This renders on the server for real and hydrates that markup, rather than
|
|
168
|
+
* inspecting the shape of the generated string.
|
|
169
|
+
*/
|
|
170
|
+
it('derives the same id on the server render and on hydration', async () => {
|
|
171
|
+
let renderedId = ''
|
|
172
|
+
const Probe = () => {
|
|
173
|
+
const id = useScopedDomId('title')
|
|
174
|
+
renderedId = id
|
|
175
|
+
return <span id={id}>title</span>
|
|
176
|
+
}
|
|
177
|
+
const tree = (
|
|
178
|
+
<FormProvider initialData={{}}>
|
|
179
|
+
<Probe />
|
|
180
|
+
</FormProvider>
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const html = renderToString(tree)
|
|
184
|
+
const serverId = /id="([^"]+)"/.exec(html)?.[1]
|
|
185
|
+
expect(serverId).toBeTruthy()
|
|
186
|
+
|
|
187
|
+
const host = document.createElement('div')
|
|
188
|
+
host.innerHTML = html
|
|
189
|
+
document.body.appendChild(host)
|
|
190
|
+
|
|
191
|
+
// React reports a hydration mismatch through console.error rather than by
|
|
192
|
+
// throwing, so silence alone is not evidence — collect and assert on it.
|
|
193
|
+
const consoleErrors: unknown[][] = []
|
|
194
|
+
const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
|
195
|
+
consoleErrors.push(args)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
let hydrated: Root | undefined
|
|
199
|
+
await act(async () => {
|
|
200
|
+
hydrated = hydrateRoot(host, tree)
|
|
201
|
+
})
|
|
202
|
+
spy.mockRestore()
|
|
203
|
+
|
|
204
|
+
expect(consoleErrors).toEqual([])
|
|
205
|
+
expect(renderedId).toBe(serverId)
|
|
206
|
+
expect(host.querySelector('span')?.id).toBe(serverId)
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
hydrated?.unmount()
|
|
210
|
+
})
|
|
211
|
+
host.remove()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
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 type { ReactNode } from 'react'
|
|
12
|
+
import { createContext, useContext, useId } from 'react'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-form-instance prefix for derived DOM ids.
|
|
16
|
+
*
|
|
17
|
+
* Field widgets derive an element id from the field's path, and tab strips derive
|
|
18
|
+
* theirs from the tab-set name. Both are unique within one form and identical
|
|
19
|
+
* between two forms editing the same collection. Mount a second form — the
|
|
20
|
+
* relationship picker's creation view does exactly that — and every `label`'s
|
|
21
|
+
* `for`, every `aria-describedby` and every `aria-labelledby` resolves to whichever
|
|
22
|
+
* element the browser happens to see first, which is the parent form's. The reader
|
|
23
|
+
* then gets the wrong label announced and a click on a label focuses a field in a
|
|
24
|
+
* different form.
|
|
25
|
+
*
|
|
26
|
+
* `null` rather than `''` as the default so `useFormDomScope` can tell "no provider
|
|
27
|
+
* above me" from "a provider that produced an empty scope", even though both
|
|
28
|
+
* currently degrade to the same unprefixed id.
|
|
29
|
+
*/
|
|
30
|
+
const FormDomScopeContext = createContext<string | null>(null)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Establishes one DOM id scope. `FormProvider` wraps its children in this, so a
|
|
34
|
+
* scope exists wherever form state does and no caller has to opt in.
|
|
35
|
+
*/
|
|
36
|
+
export const FormDomScopeProvider = ({ children }: { children: ReactNode }): ReactNode => {
|
|
37
|
+
// `useId` is the only generator that agrees between the server render and
|
|
38
|
+
// hydration. A counter or a random value produces different ids on each side,
|
|
39
|
+
// React discards the server-rendered attributes, and the `for`/`aria-*` pairings
|
|
40
|
+
// written during SSR are left pointing at ids that no longer exist.
|
|
41
|
+
const scope = useId()
|
|
42
|
+
return <FormDomScopeContext.Provider value={scope}>{children}</FormDomScopeContext.Provider>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The active scope, or `''` when rendered outside a form. Field widgets are also
|
|
47
|
+
* composed directly by the admin user and role screens, which have no
|
|
48
|
+
* `FormProvider`, so an absent scope degrades to the unprefixed id rather than
|
|
49
|
+
* throwing.
|
|
50
|
+
*/
|
|
51
|
+
export function useFormDomScope(): string {
|
|
52
|
+
return useContext(FormDomScopeContext) ?? ''
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scope a derived DOM id.
|
|
57
|
+
*
|
|
58
|
+
* A caller-supplied `id` is returned untouched: it is the author's identifier, it
|
|
59
|
+
* may already be referenced from outside the form, and scoping it would break that
|
|
60
|
+
* reference as well as double-prefix ids that pass through here twice.
|
|
61
|
+
*
|
|
62
|
+
* The result is a valid HTML id and a valid `for` / `aria-*` reference, but React's
|
|
63
|
+
* generated scope contains characters that need escaping in a CSS selector. Resolve
|
|
64
|
+
* these with `document.getElementById`, not `querySelector('#…')`.
|
|
65
|
+
*/
|
|
66
|
+
export function useScopedDomId(base: string, explicit?: string): string {
|
|
67
|
+
const scope = useFormDomScope()
|
|
68
|
+
if (explicit != null) return explicit
|
|
69
|
+
return scope === '' ? base : `${scope}${base}`
|
|
70
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest'
|
|
13
|
+
|
|
14
|
+
// jsdom applies no stylesheet, so the one-column guarantee of the stacked
|
|
15
|
+
// variant cannot be asserted by rendering. This reads the stylesheet directly:
|
|
16
|
+
// the class has to exist, and it must not be swept into the two-column grid
|
|
17
|
+
// rule that the split layout gets at >=60rem.
|
|
18
|
+
const css = readFileSync(join(__dirname, 'form-renderer.module.css'), 'utf8')
|
|
19
|
+
|
|
20
|
+
describe('stacked form layout styles', () => {
|
|
21
|
+
it('defines the stacked layout class', () => {
|
|
22
|
+
expect(css).toMatch(/\.layout-stacked\b/)
|
|
23
|
+
expect(css).toMatch(/:global\(\.byline-form-layout-stacked\)/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does not put the stacked class in the two-column grid rule', () => {
|
|
27
|
+
const desktop = css.slice(css.indexOf('@media (min-width: 60rem)'))
|
|
28
|
+
const gridRule = desktop.slice(0, desktop.indexOf('}\n}') + 3)
|
|
29
|
+
expect(gridRule).toContain('grid-template-columns')
|
|
30
|
+
expect(gridRule).not.toContain('layout-stacked')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -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
|
+
})
|