@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.
Files changed (82) hide show
  1. package/dist/fields/array/array-field.js +29 -12
  2. package/dist/fields/blocks/blocks-field.js +20 -8
  3. package/dist/fields/checkbox/checkbox-field.js +4 -1
  4. package/dist/fields/code/code-field.js +2 -1
  5. package/dist/fields/datetime/datetime-field.js +5 -2
  6. package/dist/fields/field-renderer.js +2 -1
  7. package/dist/fields/field-services-types.d.ts +23 -0
  8. package/dist/fields/file/file-field.js +6 -4
  9. package/dist/fields/group/group-field.js +8 -3
  10. package/dist/fields/image/image-field.js +6 -4
  11. package/dist/fields/numerical/numerical-field.js +2 -1
  12. package/dist/fields/relation/relation-field.js +8 -2
  13. package/dist/fields/relation/relation-many-field.js +12 -4
  14. package/dist/fields/relation/relation-picker.js +38 -7
  15. package/dist/fields/relation/relation-picker.module.js +3 -0
  16. package/dist/fields/relation/relation-picker_module.css +20 -0
  17. package/dist/fields/select/select-field.js +4 -2
  18. package/dist/fields/text/text-field.js +6 -3
  19. package/dist/fields/text-area/text-area-field.js +6 -3
  20. package/dist/fields/use-field-change-handler.js +24 -6
  21. package/dist/fields/use-field-condition.d.ts +0 -15
  22. package/dist/fields/use-field-condition.js +10 -1
  23. package/dist/forms/form-context.d.ts +3 -12
  24. package/dist/forms/form-context.js +115 -130
  25. package/dist/forms/form-dom-scope.d.ts +34 -0
  26. package/dist/forms/form-dom-scope.js +20 -0
  27. package/dist/forms/form-layout.js +10 -3
  28. package/dist/forms/form-page-chrome.js +14 -24
  29. package/dist/forms/form-renderer.d.ts +2 -1
  30. package/dist/forms/form-renderer.js +13 -56
  31. package/dist/forms/nested-path.d.ts +2 -0
  32. package/dist/forms/nested-path.js +26 -1
  33. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  34. package/dist/forms/use-document-reload-recovery.js +67 -0
  35. package/dist/forms/use-form-submission.d.ts +1 -1
  36. package/dist/forms/use-form-submission.js +45 -27
  37. package/dist/forms/use-form-tabs.d.ts +2 -2
  38. package/dist/forms/use-form-tabs.js +18 -11
  39. package/dist/presentation/tabs.js +2 -1
  40. package/dist/react.d.ts +1 -1
  41. package/package.json +14 -14
  42. package/src/fields/array/array-field.tsx +24 -12
  43. package/src/fields/blocks/blocks-field.tsx +18 -8
  44. package/src/fields/checkbox/checkbox-field.tsx +4 -1
  45. package/src/fields/code/code-field.tsx +2 -1
  46. package/src/fields/datetime/datetime-field.tsx +5 -2
  47. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  48. package/src/fields/field-dom-ids.test.tsx +293 -0
  49. package/src/fields/field-renderer.tsx +2 -1
  50. package/src/fields/field-services-types.ts +25 -0
  51. package/src/fields/file/file-field.tsx +6 -4
  52. package/src/fields/group/group-field.tsx +5 -3
  53. package/src/fields/image/image-field.tsx +6 -4
  54. package/src/fields/numerical/numerical-field.tsx +2 -1
  55. package/src/fields/relation/relation-field.tsx +8 -2
  56. package/src/fields/relation/relation-many-field.tsx +19 -4
  57. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  58. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  59. package/src/fields/relation/relation-picker.module.css +32 -0
  60. package/src/fields/relation/relation-picker.tsx +59 -1
  61. package/src/fields/select/select-field.tsx +4 -2
  62. package/src/fields/text/text-field.tsx +5 -3
  63. package/src/fields/text-area/text-area-field.tsx +5 -3
  64. package/src/fields/use-field-change-handler.ts +43 -9
  65. package/src/fields/use-field-condition.ts +17 -1
  66. package/src/forms/form-context.tsx +180 -234
  67. package/src/forms/form-correctness.test.tsx +560 -0
  68. package/src/forms/form-dom-scope.test.tsx +213 -0
  69. package/src/forms/form-dom-scope.tsx +70 -0
  70. package/src/forms/form-layout.tsx +10 -3
  71. package/src/forms/form-page-chrome.tsx +17 -34
  72. package/src/forms/form-renderer-submit.test.tsx +36 -0
  73. package/src/forms/form-renderer.tsx +26 -64
  74. package/src/forms/form-submit-isolation.test.tsx +247 -0
  75. package/src/forms/nested-path.ts +35 -0
  76. package/src/forms/use-document-reload-recovery.ts +92 -0
  77. package/src/forms/use-form-submission.ts +56 -27
  78. package/src/forms/use-form-tabs.ts +25 -17
  79. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  80. package/src/presentation/tabs.test.tsx +49 -2
  81. package/src/presentation/tabs.tsx +11 -1
  82. 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
- id={id ?? fieldPath}
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 ?? fieldPath
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
- id={id ?? fieldPath}
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={`${field.name}-error`} text={fieldError} />}
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={`${field.name}-error`} text={fieldError} />}
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 fieldError = useFieldError(field.name)
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={`${field.name}-error`} text={fieldError} />}
122
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
121
123
  </div>
122
124
  )
123
125
  }