@byline/admin 6.2.1 → 6.4.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/array/array-field.js +29 -12
- package/dist/fields/blocks/blocks-field.js +20 -8
- package/dist/fields/checkbox/checkbox-field.js +4 -1
- package/dist/fields/code/code-field.js +2 -1
- package/dist/fields/datetime/datetime-field.js +5 -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 +6 -4
- package/dist/fields/group/group-field.js +8 -3
- package/dist/fields/image/image-field.js +6 -4
- package/dist/fields/numerical/numerical-field.js +2 -1
- package/dist/fields/relation/relation-field.js +8 -2
- package/dist/fields/relation/relation-many-field.js +12 -4
- 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 +4 -2
- package/dist/fields/text/text-field.js +6 -3
- package/dist/fields/text-area/text-area-field.js +6 -3
- package/dist/fields/use-field-change-handler.js +24 -6
- package/dist/fields/use-field-condition.d.ts +0 -15
- package/dist/fields/use-field-condition.js +10 -1
- package/dist/forms/form-context.d.ts +3 -12
- package/dist/forms/form-context.js +115 -130
- 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 +10 -3
- package/dist/forms/form-page-chrome.js +14 -24
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +13 -56
- package/dist/forms/nested-path.d.ts +2 -0
- package/dist/forms/nested-path.js +26 -1
- package/dist/forms/use-document-reload-recovery.d.ts +22 -0
- package/dist/forms/use-document-reload-recovery.js +67 -0
- package/dist/forms/use-form-submission.d.ts +1 -1
- package/dist/forms/use-form-submission.js +45 -27
- package/dist/forms/use-form-tabs.d.ts +2 -2
- package/dist/forms/use-form-tabs.js +18 -11
- package/dist/presentation/tabs.js +2 -1
- package/dist/react.d.ts +1 -1
- package/package.json +14 -14
- package/src/fields/array/array-field.tsx +24 -12
- package/src/fields/blocks/blocks-field.tsx +18 -8
- package/src/fields/checkbox/checkbox-field.tsx +4 -1
- package/src/fields/code/code-field.tsx +2 -1
- package/src/fields/datetime/datetime-field.tsx +5 -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 +6 -4
- package/src/fields/group/group-field.tsx +5 -3
- package/src/fields/image/image-field.tsx +6 -4
- package/src/fields/numerical/numerical-field.tsx +2 -1
- package/src/fields/relation/relation-field.tsx +8 -2
- package/src/fields/relation/relation-many-field.tsx +19 -4
- 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 +4 -2
- package/src/fields/text/text-field.tsx +5 -3
- package/src/fields/text-area/text-area-field.tsx +5 -3
- package/src/fields/use-field-change-handler.ts +43 -9
- package/src/fields/use-field-condition.ts +17 -1
- package/src/forms/form-context.tsx +180 -234
- package/src/forms/form-correctness.test.tsx +560 -0
- 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 +10 -3
- package/src/forms/form-page-chrome.tsx +17 -34
- package/src/forms/form-renderer-submit.test.tsx +36 -0
- package/src/forms/form-renderer.tsx +26 -64
- package/src/forms/form-submit-isolation.test.tsx +247 -0
- package/src/forms/nested-path.ts +35 -0
- package/src/forms/use-document-reload-recovery.ts +92 -0
- package/src/forms/use-form-submission.ts +56 -27
- package/src/forms/use-form-tabs.ts +25 -17
- 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
|
@@ -10,6 +10,7 @@ import type { CheckboxField as FieldType } from '@byline/core'
|
|
|
10
10
|
import { Checkbox } from '@byline/ui/react'
|
|
11
11
|
|
|
12
12
|
import { useFieldError, useFieldValue } from '../../forms/form-context'
|
|
13
|
+
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
13
14
|
import styles from './checkbox-field.module.css'
|
|
14
15
|
|
|
15
16
|
export const CheckboxField = ({
|
|
@@ -28,6 +29,7 @@ export const CheckboxField = ({
|
|
|
28
29
|
path?: string
|
|
29
30
|
}) => {
|
|
30
31
|
const fieldPath = path ?? field.name
|
|
32
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
31
33
|
const fieldError = useFieldError(fieldPath)
|
|
32
34
|
const fieldValue = useFieldValue<boolean | undefined>(fieldPath)
|
|
33
35
|
const checked = value ?? fieldValue ?? defaultValue ?? false
|
|
@@ -35,7 +37,8 @@ export const CheckboxField = ({
|
|
|
35
37
|
return (
|
|
36
38
|
<div className={`byline-field-checkbox ${field.name} ${styles['field-checkbox']}`}>
|
|
37
39
|
<Checkbox
|
|
38
|
-
|
|
40
|
+
disabled={field.readOnly}
|
|
41
|
+
id={htmlId}
|
|
39
42
|
name={field.name}
|
|
40
43
|
label={field.label}
|
|
41
44
|
checked={checked}
|
|
@@ -13,6 +13,7 @@ import { ErrorText, HelpText, 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 './code-field.module.css'
|
|
18
19
|
|
|
@@ -57,7 +58,7 @@ export const CodeField = ({
|
|
|
57
58
|
const fieldError = useFieldError(fieldPath)
|
|
58
59
|
const fieldValue = useFieldValue<string | undefined>(fieldPath)
|
|
59
60
|
const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
|
|
60
|
-
const htmlId = id
|
|
61
|
+
const htmlId = useScopedDomId(fieldPath, id)
|
|
61
62
|
|
|
62
63
|
// Effective highlight language: a sibling `languageField` selection (e.g.
|
|
63
64
|
// a `select` next to the code field) wins over the schema's static
|
|
@@ -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,8 @@ export const DateTimeField = ({
|
|
|
37
39
|
return (
|
|
38
40
|
<div className={`byline-field-datetime ${field.name}`}>
|
|
39
41
|
<DatePicker
|
|
40
|
-
|
|
42
|
+
disabled={field.readOnly}
|
|
43
|
+
id={htmlId}
|
|
41
44
|
name={field.name}
|
|
42
45
|
label={field.label}
|
|
43
46
|
required={!field.optional}
|
|
@@ -48,7 +51,7 @@ export const DateTimeField = ({
|
|
|
48
51
|
onDateChange={(date) => onChange?.(date)}
|
|
49
52
|
className={cx(isDirty && ['byline-field-datetime-dirty', styles.dirty])}
|
|
50
53
|
/>
|
|
51
|
-
{fieldError && <ErrorText id={`${
|
|
54
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
52
55
|
</div>
|
|
53
56
|
)
|
|
54
57
|
}
|
|
@@ -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'
|
|
@@ -109,6 +110,7 @@ export const FileField = ({
|
|
|
109
110
|
const uploadGated = field.upload?.requireSavedDocument === true && documentId == null
|
|
110
111
|
|
|
111
112
|
const handleRemove = () => {
|
|
113
|
+
if (field.readOnly) return
|
|
112
114
|
if (isPending) {
|
|
113
115
|
removePendingUpload(fieldPath)
|
|
114
116
|
}
|
|
@@ -123,7 +125,7 @@ export const FileField = ({
|
|
|
123
125
|
const isVideo = incomingValue?.mimeType?.startsWith('video/') === true
|
|
124
126
|
const FileGlyph = isVideo ? VideoIcon : DocumentIcon
|
|
125
127
|
|
|
126
|
-
const htmlId = fieldPath
|
|
128
|
+
const htmlId = useScopedDomId(fieldPath)
|
|
127
129
|
|
|
128
130
|
return (
|
|
129
131
|
<div className={`byline-field-file ${field.name}`}>
|
|
@@ -145,7 +147,7 @@ export const FileField = ({
|
|
|
145
147
|
>
|
|
146
148
|
{t('fields.upload.requireSavedDocument')}
|
|
147
149
|
</div>
|
|
148
|
-
) : collectionPath ? (
|
|
150
|
+
) : collectionPath && !field.readOnly ? (
|
|
149
151
|
<FileUploadField
|
|
150
152
|
field={field}
|
|
151
153
|
collectionPath={collectionPath}
|
|
@@ -194,7 +196,7 @@ export const FileField = ({
|
|
|
194
196
|
intent="noeffect"
|
|
195
197
|
onClick={handleRemove}
|
|
196
198
|
size="xs"
|
|
197
|
-
disabled={isUploading}
|
|
199
|
+
disabled={isUploading || field.readOnly}
|
|
198
200
|
aria-label={t('fields.file.removeAriaLabel')}
|
|
199
201
|
>
|
|
200
202
|
<CloseIcon width="15px" height="15px" />
|
|
@@ -294,7 +296,7 @@ export const FileField = ({
|
|
|
294
296
|
|
|
295
297
|
{field.helpText && <HelpText text={field.helpText} />}
|
|
296
298
|
|
|
297
|
-
{fieldError && <ErrorText id={`${
|
|
299
|
+
{fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
|
|
298
300
|
</div>
|
|
299
301
|
)
|
|
300
302
|
}
|
|
@@ -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,7 +71,8 @@ export const GroupField = ({
|
|
|
70
71
|
contentLocale,
|
|
71
72
|
fieldAdmin,
|
|
72
73
|
}: GroupFieldProps) => {
|
|
73
|
-
const
|
|
74
|
+
const htmlId = useScopedDomId(path ?? field.name)
|
|
75
|
+
const fieldError = useFieldError(path ?? 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.
|
|
76
78
|
const groupData = useMemo(() => {
|
|
@@ -105,7 +107,7 @@ export const GroupField = ({
|
|
|
105
107
|
return (
|
|
106
108
|
<FieldRenderer
|
|
107
109
|
key={innerField.name}
|
|
108
|
-
field={innerField}
|
|
110
|
+
field={field.readOnly ? { ...innerField, readOnly: true } : innerField}
|
|
109
111
|
defaultValue={groupData[innerField.name]}
|
|
110
112
|
basePath={path}
|
|
111
113
|
disableSorting={disableSorting}
|
|
@@ -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
|
}
|