@byline/admin 6.2.1 → 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.js +3 -1
- package/dist/forms/form-renderer.js +1 -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.tsx +7 -1
- package/src/forms/form-renderer.tsx +11 -0
- package/src/forms/form-submit-isolation.test.tsx +247 -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
|
@@ -11,6 +11,7 @@ import { DatePicker, ErrorText } from '@byline/ui/react'
|
|
|
11
11
|
import cx from 'clsx'
|
|
12
12
|
|
|
13
13
|
import { useFieldError, useFieldValue, useIsDirty } from '../../forms/form-context'
|
|
14
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
14
15
|
import styles from './datetime-field.module.css'
|
|
15
16
|
|
|
16
17
|
export const DateTimeField = ({
|
|
@@ -29,6 +30,7 @@ export const DateTimeField = ({
|
|
|
29
30
|
path?: string
|
|
30
31
|
}) => {
|
|
31
32
|
const fieldPath = path ?? field.name
|
|
33
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
32
34
|
const fieldError = useFieldError(fieldPath)
|
|
33
35
|
const isDirty = useIsDirty(fieldPath)
|
|
34
36
|
const fieldValue = useFieldValue<Date | null | undefined>(fieldPath)
|
|
@@ -37,7 +39,7 @@ export const DateTimeField = ({
|
|
|
37
39
|
return (
|
|
38
40
|
<div className={`byline-field-datetime ${field.name}`}>
|
|
39
41
|
<DatePicker
|
|
40
|
-
id={
|
|
42
|
+
id={htmlId}
|
|
41
43
|
name={field.name}
|
|
42
44
|
label={field.label}
|
|
43
45
|
required={!field.optional}
|
|
@@ -48,7 +50,7 @@ export const DateTimeField = ({
|
|
|
48
50
|
onDateChange={(date) => onChange?.(date)}
|
|
49
51
|
className={cx(isDirty && ['byline-field-datetime-dirty', styles.dirty])}
|
|
50
52
|
/>
|
|
51
|
-
{fieldError && <ErrorText id={`${
|
|
53
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
52
54
|
</div>
|
|
53
55
|
)
|
|
54
56
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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 { Field } 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 { FormProvider } from '../forms/form-context'
|
|
18
|
+
import { CheckboxField } from './checkbox/checkbox-field'
|
|
19
|
+
import { DateTimeField } from './datetime/datetime-field'
|
|
20
|
+
import { TextField } from './text/text-field'
|
|
21
|
+
|
|
22
|
+
const globalWithAct = globalThis as any
|
|
23
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
24
|
+
|
|
25
|
+
let container: HTMLDivElement
|
|
26
|
+
let root: Root
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
document.body.appendChild(container)
|
|
31
|
+
root = createRoot(container)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
act(() => {
|
|
36
|
+
root.unmount()
|
|
37
|
+
})
|
|
38
|
+
container.remove()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* These widgets are re-exported from `@byline/admin/react`, so a host can compose
|
|
43
|
+
* them directly rather than through `FieldRenderer`. Going through `FieldRenderer`
|
|
44
|
+
* would hand them an already-scoped `id` and prove nothing about their own
|
|
45
|
+
* fallback, which is the path a direct consumer takes.
|
|
46
|
+
*/
|
|
47
|
+
const renderDirect = (node: (formName: string) => React.ReactNode) => {
|
|
48
|
+
act(() => {
|
|
49
|
+
root.render(
|
|
50
|
+
<I18nProvider
|
|
51
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
52
|
+
activeLocale="en"
|
|
53
|
+
defaultLocale="en"
|
|
54
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
55
|
+
>
|
|
56
|
+
{['parent', 'child'].map((formName) => (
|
|
57
|
+
<form aria-label={formName} key={formName}>
|
|
58
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
59
|
+
{node(formName)}
|
|
60
|
+
</FormProvider>
|
|
61
|
+
</form>
|
|
62
|
+
))}
|
|
63
|
+
</I18nProvider>
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const controlIds = (selector: string) =>
|
|
69
|
+
Array.from(container.querySelectorAll(selector)).map((node) => node.id)
|
|
70
|
+
|
|
71
|
+
describe('widgets composed directly under two form providers', () => {
|
|
72
|
+
it('gives a text field distinct ids without FieldRenderer supplying one', () => {
|
|
73
|
+
const field = { name: 'title', label: 'Title', type: 'text' } as Field
|
|
74
|
+
renderDirect(() => <TextField field={field as any} />)
|
|
75
|
+
|
|
76
|
+
const ids = controlIds('input')
|
|
77
|
+
expect(ids).toHaveLength(2)
|
|
78
|
+
expect(ids.every((id) => id !== '')).toBe(true)
|
|
79
|
+
expect(new Set(ids).size).toBe(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('gives a checkbox distinct ids', () => {
|
|
83
|
+
const field = { name: 'featured', label: 'Featured', type: 'checkbox' } as Field
|
|
84
|
+
renderDirect(() => <CheckboxField field={field as any} />)
|
|
85
|
+
|
|
86
|
+
const ids = Array.from(container.querySelectorAll('[role="checkbox"], input[type="checkbox"]'))
|
|
87
|
+
.map((node) => node.id)
|
|
88
|
+
.filter((id) => id !== '')
|
|
89
|
+
|
|
90
|
+
expect(ids).toHaveLength(2)
|
|
91
|
+
expect(new Set(ids).size).toBe(2)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('gives a datetime control distinct ids on the control itself', () => {
|
|
95
|
+
const field = { name: 'publishedOn', label: 'Published on', type: 'datetime' } as Field
|
|
96
|
+
renderDirect(() => <DateTimeField field={field as any} />)
|
|
97
|
+
|
|
98
|
+
// The scoped id must reach the control the label points at, not only the
|
|
99
|
+
// error element — the first revision scoped the error and left the input.
|
|
100
|
+
const ids = controlIds('input').filter((id) => id !== '')
|
|
101
|
+
expect(ids.length).toBeGreaterThanOrEqual(2)
|
|
102
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('associates each directly composed label with the control in its own form', () => {
|
|
106
|
+
const field = { name: 'title', label: 'Title', type: 'text' } as Field
|
|
107
|
+
renderDirect(() => <TextField field={field as any} />)
|
|
108
|
+
|
|
109
|
+
const labels = Array.from(container.querySelectorAll('label[for]'))
|
|
110
|
+
expect(labels.length).toBeGreaterThanOrEqual(2)
|
|
111
|
+
|
|
112
|
+
for (const label of labels) {
|
|
113
|
+
const target = document.getElementById(label.getAttribute('for') as string)
|
|
114
|
+
expect(target).not.toBeNull()
|
|
115
|
+
expect(target?.closest('form')).toBe(label.closest('form'))
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* An explicit id belongs to the caller: it may already be referenced from
|
|
121
|
+
* outside the form, and scoping it would break that reference. Two forms given
|
|
122
|
+
* the same explicit id therefore still collide — that is the caller's choice,
|
|
123
|
+
* and the test records it as intended rather than accidental.
|
|
124
|
+
*/
|
|
125
|
+
it('passes a caller-supplied id through unchanged in every direct widget', () => {
|
|
126
|
+
const text = { name: 'title', label: 'Title', type: 'text' } as Field
|
|
127
|
+
renderDirect(() => <TextField field={text as any} id="explicit-text" />)
|
|
128
|
+
expect(controlIds('input')).toEqual(['explicit-text', 'explicit-text'])
|
|
129
|
+
|
|
130
|
+
const checkbox = { name: 'featured', label: 'Featured', type: 'checkbox' } as Field
|
|
131
|
+
renderDirect(() => <CheckboxField field={checkbox as any} id="explicit-checkbox" />)
|
|
132
|
+
expect(
|
|
133
|
+
Array.from(container.querySelectorAll('[id="explicit-checkbox"]')).length
|
|
134
|
+
).toBeGreaterThanOrEqual(2)
|
|
135
|
+
|
|
136
|
+
const datetime = { name: 'publishedOn', label: 'On', type: 'datetime' } as Field
|
|
137
|
+
renderDirect(() => <DateTimeField field={datetime as any} id="explicit-datetime" />)
|
|
138
|
+
expect(
|
|
139
|
+
Array.from(container.querySelectorAll('[id="explicit-datetime"]')).length
|
|
140
|
+
).toBeGreaterThanOrEqual(2)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,293 @@
|
|
|
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, useEffect } 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 { FormProvider, useFormContext } from '../forms/form-context'
|
|
19
|
+
import { FormLayout } from '../forms/form-layout'
|
|
20
|
+
import { BylineFieldServicesProvider } from './field-services-context'
|
|
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
|
+
// `helpText` makes the uikit control emit a real `help-for-…` element and point
|
|
45
|
+
// `aria-describedby` at it; without it the ARIA assertions have nothing to bite on.
|
|
46
|
+
{ name: 'title', label: 'Title', type: 'text', helpText: 'The public title' },
|
|
47
|
+
]
|
|
48
|
+
const adminConfig = { layout: { main: ['title'] } } as AdminResourceConfig
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Two groups in ONE form, each declaring a field called `title`. Field names are
|
|
52
|
+
* only unique within their level, so this is where a name-derived id collides
|
|
53
|
+
* without a second form being involved at all.
|
|
54
|
+
*/
|
|
55
|
+
const nestedFields: Field[] = [
|
|
56
|
+
{
|
|
57
|
+
name: 'intro',
|
|
58
|
+
label: 'Intro',
|
|
59
|
+
type: 'group',
|
|
60
|
+
fields: [{ name: 'title', label: 'Title', type: 'text', helpText: 'Intro title' }],
|
|
61
|
+
} as unknown as Field,
|
|
62
|
+
{
|
|
63
|
+
name: 'outro',
|
|
64
|
+
label: 'Outro',
|
|
65
|
+
type: 'group',
|
|
66
|
+
fields: [{ name: 'title', label: 'Title', type: 'text', helpText: 'Outro title' }],
|
|
67
|
+
} as unknown as Field,
|
|
68
|
+
]
|
|
69
|
+
const nestedAdminConfig = { layout: { main: ['intro', 'outro'] } } as AdminResourceConfig
|
|
70
|
+
|
|
71
|
+
let container: HTMLDivElement
|
|
72
|
+
let root: Root
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
container = document.createElement('div')
|
|
76
|
+
document.body.appendChild(container)
|
|
77
|
+
root = createRoot(container)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
act(() => {
|
|
82
|
+
root.unmount()
|
|
83
|
+
})
|
|
84
|
+
container.remove()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Puts a field into its error state through the real form context, so the widget
|
|
89
|
+
* renders an `error-for-…` element and references it — the same path validation
|
|
90
|
+
* takes.
|
|
91
|
+
*/
|
|
92
|
+
const ErrorProbe = ({ field }: { field: string }) => {
|
|
93
|
+
const { setFieldError } = useFormContext()
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setFieldError(field, 'Required')
|
|
96
|
+
}, [field, setFieldError])
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One form, wrapped in its own `<form>` so the assertions can ask which form an
|
|
102
|
+
* element belongs to — the same question a browser answers when it resolves a
|
|
103
|
+
* label's `for`.
|
|
104
|
+
*/
|
|
105
|
+
const Form = ({ name }: { name: string }) => (
|
|
106
|
+
<form aria-label={name}>
|
|
107
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
108
|
+
<FormLayout
|
|
109
|
+
fields={fields}
|
|
110
|
+
adminConfig={adminConfig}
|
|
111
|
+
activeTabBySet={{}}
|
|
112
|
+
onTabChange={() => {}}
|
|
113
|
+
/>
|
|
114
|
+
<ErrorProbe field="title" />
|
|
115
|
+
</FormProvider>
|
|
116
|
+
</form>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Two forms over the same collection, mounted at once — the shape the relationship
|
|
121
|
+
* picker's creation view produces, and the shape in which a field name is no longer
|
|
122
|
+
* unique in the document.
|
|
123
|
+
*/
|
|
124
|
+
const renderTwoForms = () => {
|
|
125
|
+
act(() => {
|
|
126
|
+
root.render(
|
|
127
|
+
<I18nProvider
|
|
128
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
129
|
+
activeLocale="en"
|
|
130
|
+
defaultLocale="en"
|
|
131
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
132
|
+
>
|
|
133
|
+
<BylineFieldServicesProvider
|
|
134
|
+
services={
|
|
135
|
+
{
|
|
136
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
137
|
+
uploadField: async () => ({}),
|
|
138
|
+
} as any
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
<Form name="parent" />
|
|
142
|
+
<Form name="child" />
|
|
143
|
+
</BylineFieldServicesProvider>
|
|
144
|
+
</I18nProvider>
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('field DOM ids across concurrently mounted forms', () => {
|
|
150
|
+
it('gives two mounted forms distinct ids for the same field', () => {
|
|
151
|
+
renderTwoForms()
|
|
152
|
+
|
|
153
|
+
const ids = Array.from(container.querySelectorAll('input')).map((node) => node.id)
|
|
154
|
+
|
|
155
|
+
expect(ids).toHaveLength(2)
|
|
156
|
+
expect(ids.every((id) => id !== '')).toBe(true)
|
|
157
|
+
expect(new Set(ids).size).toBe(2)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('associates each label with the input in its own form', () => {
|
|
161
|
+
renderTwoForms()
|
|
162
|
+
|
|
163
|
+
const labels = Array.from(container.querySelectorAll('label[for]'))
|
|
164
|
+
expect(labels).toHaveLength(2)
|
|
165
|
+
|
|
166
|
+
for (const label of labels) {
|
|
167
|
+
const target = document.getElementById(label.getAttribute('for') as string)
|
|
168
|
+
expect(target).not.toBeNull()
|
|
169
|
+
// The browser resolves `for` document-wide, so an unscoped id silently
|
|
170
|
+
// pairs the second form's label with the first form's input.
|
|
171
|
+
expect(target?.closest('form')).toBe(label.closest('form'))
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every id an assistive technology follows has to land in the same form, not
|
|
177
|
+
* just the input's own id: a stale `aria-describedby` announces another form's
|
|
178
|
+
* error text, which is worse than announcing nothing.
|
|
179
|
+
*
|
|
180
|
+
* The earlier version of this test skipped references whose target was missing
|
|
181
|
+
* and rendered no help or error state, so it could pass with nothing exercised
|
|
182
|
+
* and with every association broken. It now requires the references to exist.
|
|
183
|
+
*/
|
|
184
|
+
it('keeps every aria reference inside its own form, and resolves all of them', () => {
|
|
185
|
+
renderTwoForms()
|
|
186
|
+
|
|
187
|
+
const referencing = Array.from(
|
|
188
|
+
container.querySelectorAll('[aria-describedby], [aria-labelledby]')
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// Both forms are in an error state and both fields carry help text, so there
|
|
192
|
+
// is something to check; an empty list would mean the fixture stopped
|
|
193
|
+
// producing ARIA references and the assertions below became vacuous.
|
|
194
|
+
expect(referencing.length).toBeGreaterThanOrEqual(2)
|
|
195
|
+
|
|
196
|
+
let checkedRefs = 0
|
|
197
|
+
for (const node of referencing) {
|
|
198
|
+
const refs = [
|
|
199
|
+
...(node.getAttribute('aria-describedby') ?? '').split(/\s+/),
|
|
200
|
+
...(node.getAttribute('aria-labelledby') ?? '').split(/\s+/),
|
|
201
|
+
].filter(Boolean)
|
|
202
|
+
|
|
203
|
+
expect(refs.length).toBeGreaterThan(0)
|
|
204
|
+
|
|
205
|
+
for (const ref of refs) {
|
|
206
|
+
const target = document.getElementById(ref)
|
|
207
|
+
// A dangling reference is a defect in its own right: the browser
|
|
208
|
+
// announces nothing, and it is invisible without this assertion.
|
|
209
|
+
expect(target, `dangling aria reference: ${ref}`).not.toBeNull()
|
|
210
|
+
expect(target?.closest('form')).toBe(node.closest('form'))
|
|
211
|
+
checkedRefs += 1
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
expect(checkedRefs).toBeGreaterThanOrEqual(2)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('renders the error element inside the form whose field is in error', () => {
|
|
219
|
+
renderTwoForms()
|
|
220
|
+
|
|
221
|
+
const errors = Array.from(container.querySelectorAll('[id^="error-for-"]'))
|
|
222
|
+
expect(errors).toHaveLength(2)
|
|
223
|
+
|
|
224
|
+
const ids = errors.map((node) => node.id)
|
|
225
|
+
expect(new Set(ids).size).toBe(2)
|
|
226
|
+
|
|
227
|
+
for (const error of errors) {
|
|
228
|
+
const described = container.querySelector(`[aria-describedby~="${error.id}"]`)
|
|
229
|
+
expect(described).not.toBeNull()
|
|
230
|
+
expect(described?.closest('form')).toBe(error.closest('form'))
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The nested case, inside a single form: field names are unique only within
|
|
236
|
+
* their level, so two groups can both declare `title`. A name-derived id
|
|
237
|
+
* collides here with no second form involved.
|
|
238
|
+
*/
|
|
239
|
+
it('keeps ids distinct for a repeated field name in two groups of one form', () => {
|
|
240
|
+
act(() => {
|
|
241
|
+
root.render(
|
|
242
|
+
<I18nProvider
|
|
243
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
244
|
+
activeLocale="en"
|
|
245
|
+
defaultLocale="en"
|
|
246
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
247
|
+
>
|
|
248
|
+
<BylineFieldServicesProvider
|
|
249
|
+
services={
|
|
250
|
+
{
|
|
251
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
252
|
+
uploadField: async () => ({}),
|
|
253
|
+
} as any
|
|
254
|
+
}
|
|
255
|
+
>
|
|
256
|
+
<form aria-label="single">
|
|
257
|
+
<FormProvider initialData={{ fields: {} }} collectionPath="pages">
|
|
258
|
+
<FormLayout
|
|
259
|
+
fields={nestedFields}
|
|
260
|
+
adminConfig={nestedAdminConfig}
|
|
261
|
+
activeTabBySet={{}}
|
|
262
|
+
onTabChange={() => {}}
|
|
263
|
+
/>
|
|
264
|
+
<ErrorProbe field="intro.title" />
|
|
265
|
+
<ErrorProbe field="outro.title" />
|
|
266
|
+
</FormProvider>
|
|
267
|
+
</form>
|
|
268
|
+
</BylineFieldServicesProvider>
|
|
269
|
+
</I18nProvider>
|
|
270
|
+
)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const inputIds = Array.from(container.querySelectorAll('input')).map((node) => node.id)
|
|
274
|
+
expect(inputIds).toHaveLength(2)
|
|
275
|
+
expect(new Set(inputIds).size).toBe(2)
|
|
276
|
+
|
|
277
|
+
const errorIds = Array.from(container.querySelectorAll('[id^="error-for-"]')).map((n) => n.id)
|
|
278
|
+
expect(errorIds).toHaveLength(2)
|
|
279
|
+
expect(new Set(errorIds).size).toBe(2)
|
|
280
|
+
|
|
281
|
+
const allIds = Array.from(container.querySelectorAll('[id]')).map((node) => node.id)
|
|
282
|
+
expect(allIds.filter((id, index) => allIds.indexOf(id) !== index)).toEqual([])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('leaves every id unique across the document', () => {
|
|
286
|
+
renderTwoForms()
|
|
287
|
+
|
|
288
|
+
const ids = Array.from(container.querySelectorAll('[id]')).map((node) => node.id)
|
|
289
|
+
const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index)
|
|
290
|
+
|
|
291
|
+
expect(duplicates).toEqual([])
|
|
292
|
+
})
|
|
293
|
+
})
|
|
@@ -19,6 +19,7 @@ import { getAdminConfig } from '@byline/core'
|
|
|
19
19
|
import cx from 'clsx'
|
|
20
20
|
|
|
21
21
|
import { useFormContext } from '../forms/form-context'
|
|
22
|
+
import { useScopedDomId } from '../forms/form-dom-scope'
|
|
22
23
|
import { ArrayField } from './array/array-field'
|
|
23
24
|
import { BlocksField } from './blocks/blocks-field'
|
|
24
25
|
import { CheckboxField } from './checkbox/checkbox-field'
|
|
@@ -90,7 +91,7 @@ export const FieldRenderer = ({
|
|
|
90
91
|
fieldAdmin,
|
|
91
92
|
}: FieldRendererProps) => {
|
|
92
93
|
const path = basePath ? `${basePath}.${field.name}` : field.name
|
|
93
|
-
const htmlId = path.replace(/[[\].]/g, '-')
|
|
94
|
+
const htmlId = useScopedDomId(path.replace(/[[\].]/g, '-'))
|
|
94
95
|
|
|
95
96
|
const handleChange = useFieldChangeHandler(field, path)
|
|
96
97
|
const { getFieldValue } = useFormContext()
|
|
@@ -111,6 +111,12 @@ export type GetTreeParentFn = (input: {
|
|
|
111
111
|
documentId: string
|
|
112
112
|
}) => Promise<{ placed: boolean; parentDocumentId: string | null }>
|
|
113
113
|
|
|
114
|
+
/** See `BylineFieldServices.canCreateInCollection`. */
|
|
115
|
+
export type CanCreateInCollectionFn = (collectionPath: string) => boolean
|
|
116
|
+
|
|
117
|
+
/** See `BylineFieldServices.getCreateDocumentUrl`. */
|
|
118
|
+
export type GetCreateDocumentUrlFn = (collectionPath: string) => string
|
|
119
|
+
|
|
114
120
|
export interface BylineFieldServices {
|
|
115
121
|
getCollectionDocuments: GetCollectionDocumentsFn
|
|
116
122
|
uploadField: UploadFieldFn
|
|
@@ -123,4 +129,23 @@ export interface BylineFieldServices {
|
|
|
123
129
|
removeFromTree?: RemoveFromTreeFn
|
|
124
130
|
getTreeAncestors?: GetTreeAncestorsFn
|
|
125
131
|
getTreeParent?: GetTreeParentFn
|
|
132
|
+
/**
|
|
133
|
+
* Whether the viewer may create documents in a collection, used to decide
|
|
134
|
+
* whether to offer a create affordance in the relation picker.
|
|
135
|
+
*
|
|
136
|
+
* Cosmetic only: the create view enforces the ability server-side regardless
|
|
137
|
+
* of what this returns. Optional, like the tree operations above — a host that
|
|
138
|
+
* wires neither this nor `getCreateDocumentUrl` simply gets no affordance, and
|
|
139
|
+
* the widget guards on their presence.
|
|
140
|
+
*/
|
|
141
|
+
canCreateInCollection?: CanCreateInCollectionFn
|
|
142
|
+
/**
|
|
143
|
+
* Root-relative URL of a collection's create view, built from the host's
|
|
144
|
+
* configured admin path.
|
|
145
|
+
*
|
|
146
|
+
* Root-relative rather than absolute because no origin is available during the
|
|
147
|
+
* server render, and the admin base path is host configuration —
|
|
148
|
+
* `@byline/admin` must not assume `/admin`.
|
|
149
|
+
*/
|
|
150
|
+
getCreateDocumentUrl?: GetCreateDocumentUrlFn
|
|
126
151
|
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
useIsDirty,
|
|
33
33
|
useIsFieldUploading,
|
|
34
34
|
} from '../../forms/form-context'
|
|
35
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
35
36
|
import { useFieldChangeHandler } from '../use-field-change-handler'
|
|
36
37
|
import styles from './file-field.module.css'
|
|
37
38
|
import { FileUploadField } from './file-upload-field'
|
|
@@ -123,7 +124,7 @@ export const FileField = ({
|
|
|
123
124
|
const isVideo = incomingValue?.mimeType?.startsWith('video/') === true
|
|
124
125
|
const FileGlyph = isVideo ? VideoIcon : DocumentIcon
|
|
125
126
|
|
|
126
|
-
const htmlId = fieldPath
|
|
127
|
+
const htmlId = useScopedDomId(fieldPath)
|
|
127
128
|
|
|
128
129
|
return (
|
|
129
130
|
<div className={`byline-field-file ${field.name}`}>
|
|
@@ -294,7 +295,7 @@ export const FileField = ({
|
|
|
294
295
|
|
|
295
296
|
{field.helpText && <HelpText text={field.helpText} />}
|
|
296
297
|
|
|
297
|
-
{fieldError && <ErrorText id={`${
|
|
298
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
298
299
|
</div>
|
|
299
300
|
)
|
|
300
301
|
}
|
|
@@ -16,6 +16,7 @@ import { sliceFieldAdmin } from '../../fields/field-admin'
|
|
|
16
16
|
import { placeholderForField } from '../../fields/field-helpers'
|
|
17
17
|
import { FieldRenderer } from '../../fields/field-renderer'
|
|
18
18
|
import { useFieldError } from '../../forms/form-context'
|
|
19
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
19
20
|
import styles from './group-field.module.css'
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
@@ -70,6 +71,7 @@ export const GroupField = ({
|
|
|
70
71
|
contentLocale,
|
|
71
72
|
fieldAdmin,
|
|
72
73
|
}: GroupFieldProps) => {
|
|
74
|
+
const htmlId = useScopedDomId(path ?? field.name)
|
|
73
75
|
const fieldError = useFieldError(field.name)
|
|
74
76
|
// Default value for a group field is a plain object: { rating: 5, comment: '...' }
|
|
75
77
|
// Normalize to a plain object if not already one.
|
|
@@ -117,7 +119,7 @@ export const GroupField = ({
|
|
|
117
119
|
)
|
|
118
120
|
})}
|
|
119
121
|
</div>
|
|
120
|
-
{fieldError && <ErrorText id={`${
|
|
122
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
121
123
|
</div>
|
|
122
124
|
)
|
|
123
125
|
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
useIsDirty,
|
|
33
33
|
useIsFieldUploading,
|
|
34
34
|
} from '../../forms/form-context'
|
|
35
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
35
36
|
import { useFieldChangeHandler } from '../use-field-change-handler'
|
|
36
37
|
import styles from './image-field.module.css'
|
|
37
38
|
import { ImageUploadField } from './image-upload-field'
|
|
@@ -114,7 +115,7 @@ export const ImageField = ({
|
|
|
114
115
|
const [lightboxOpen, setLightboxOpen] = useState(false)
|
|
115
116
|
const canOpenLightbox = !isPending && !!incomingValue?.storageUrl
|
|
116
117
|
|
|
117
|
-
const htmlId = fieldPath
|
|
118
|
+
const htmlId = useScopedDomId(fieldPath)
|
|
118
119
|
|
|
119
120
|
return (
|
|
120
121
|
<div className={`byline-field-image ${field.name}`}>
|
|
@@ -297,7 +298,7 @@ export const ImageField = ({
|
|
|
297
298
|
|
|
298
299
|
{field.helpText && <HelpText text={field.helpText} />}
|
|
299
300
|
|
|
300
|
-
{fieldError && <ErrorText id={`${
|
|
301
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
301
302
|
|
|
302
303
|
{canOpenLightbox && incomingValue?.storageUrl && (
|
|
303
304
|
<ImageLightbox
|
|
@@ -20,6 +20,7 @@ import { normalizeNumericValue } from '@byline/core'
|
|
|
20
20
|
import { Input } from '@byline/ui/react'
|
|
21
21
|
|
|
22
22
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
23
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
23
24
|
|
|
24
25
|
type NumericalValue = string | number | null
|
|
25
26
|
|
|
@@ -48,7 +49,7 @@ export const NumericalField = ({
|
|
|
48
49
|
const fieldValue = useFieldValue<NumericalValue | undefined>(fieldPath)
|
|
49
50
|
const incomingValue =
|
|
50
51
|
value !== undefined ? value : fieldValue !== undefined ? fieldValue : (defaultValue ?? null)
|
|
51
|
-
const htmlId = id
|
|
52
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
52
53
|
const canonicalDisplay = incomingValue == null ? '' : String(incomingValue)
|
|
53
54
|
const [displayValue, setDisplayValue] = useState(canonicalDisplay)
|
|
54
55
|
const [isEditing, setIsEditing] = useState(false)
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import cx from 'clsx'
|
|
29
29
|
|
|
30
30
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
31
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
31
32
|
import styles from './relation-field.module.css'
|
|
32
33
|
import { RelationPicker } from './relation-picker'
|
|
33
34
|
import { RelationSummary } from './relation-summary'
|
|
@@ -65,7 +66,7 @@ export const RelationField = ({
|
|
|
65
66
|
path,
|
|
66
67
|
}: RelationFieldProps) => {
|
|
67
68
|
const fieldPath = path ?? field.name
|
|
68
|
-
const htmlId = id
|
|
69
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
69
70
|
const fieldError = useFieldError(fieldPath)
|
|
70
71
|
const fieldValue = useFieldValue<IncomingRelationValue | null | undefined>(fieldPath)
|
|
71
72
|
|
|
@@ -208,7 +209,7 @@ export const RelationField = ({
|
|
|
208
209
|
|
|
209
210
|
{field.helpText != null && <HelpText text={field.helpText} />}
|
|
210
211
|
|
|
211
|
-
{fieldError && <ErrorText id={`${
|
|
212
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
212
213
|
|
|
213
214
|
{!isUnknown && (
|
|
214
215
|
<RelationPicker
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import cx from 'clsx'
|
|
33
33
|
|
|
34
34
|
import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context'
|
|
35
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
35
36
|
import styles from './relation-field.module.css'
|
|
36
37
|
import { RelationPicker, type RelationPickerSelection } from './relation-picker'
|
|
37
38
|
import { RelationSummary } from './relation-summary'
|
|
@@ -117,7 +118,7 @@ interface RelationManyFieldProps {
|
|
|
117
118
|
|
|
118
119
|
export const RelationManyField = ({ field, defaultValue, id, path }: RelationManyFieldProps) => {
|
|
119
120
|
const fieldPath = path ?? field.name
|
|
120
|
-
const htmlId = id
|
|
121
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
121
122
|
const { t } = useTranslation('byline-admin')
|
|
122
123
|
const fieldError = useFieldError(fieldPath)
|
|
123
124
|
const { getFieldValue, setFieldValue } = useFormContext()
|
|
@@ -268,7 +269,7 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
268
269
|
|
|
269
270
|
{field.helpText != null && <HelpText text={field.helpText} />}
|
|
270
271
|
|
|
271
|
-
{fieldError && <ErrorText id={`${
|
|
272
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
272
273
|
|
|
273
274
|
<RelationPicker
|
|
274
275
|
multiple
|