@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
@@ -0,0 +1,325 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { act } from 'react'
10
+
11
+ import { defineAdminConfig } from '@byline/core'
12
+ import { adminTranslations } from '@byline/i18n/admin'
13
+ import { I18nProvider } from '@byline/i18n/react'
14
+ import { createRoot, type Root } from 'react-dom/client'
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16
+
17
+ vi.mock('@byline/ui/react', async (importOriginal) => {
18
+ const actual = await importOriginal<Record<string, unknown>>()
19
+ const Pass = ({ children }: any) => <div>{children}</div>
20
+ const Modal: any = ({ children, isOpen }: any) => (isOpen ? <div>{children}</div> : null)
21
+ Modal.Container = Pass
22
+ Modal.Header = Pass
23
+ Modal.Content = Pass
24
+ Modal.Actions = Pass
25
+ return {
26
+ ...actual,
27
+ Modal,
28
+ Search: ({ onSearch }: any) => (
29
+ <input aria-label="search" onChange={(event) => onSearch?.(event.target.value)} />
30
+ ),
31
+ LoaderRing: () => <span />,
32
+ }
33
+ })
34
+
35
+ import { BylineFieldServicesProvider } from '../field-services-context'
36
+ import { RelationPicker } from './relation-picker'
37
+
38
+ const globalWithAct = globalThis as any
39
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
40
+
41
+ defineAdminConfig({
42
+ i18n: {
43
+ admin: { defaultLocale: 'en', locales: ['en'] },
44
+ content: { defaultLocale: 'en', locales: ['en'] },
45
+ },
46
+ collections: [
47
+ {
48
+ path: 'media',
49
+ labels: { singular: 'Media item', plural: 'Media' },
50
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
51
+ },
52
+ ],
53
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
54
+ })
55
+
56
+ const targetDefinition = {
57
+ path: 'media',
58
+ labels: { singular: 'Media item', plural: 'Media' },
59
+ useAsTitle: 'title',
60
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
61
+ } as any
62
+
63
+ const page = (docs: Array<{ id: string; title: string }>) => ({
64
+ docs: docs.map(({ id, title }) => ({ id, fields: { title } })),
65
+ meta: { totalPages: 1 },
66
+ included: { collection: { id: 'collection-1' } },
67
+ })
68
+
69
+ let container: HTMLDivElement
70
+ let root: Root
71
+
72
+ beforeEach(() => {
73
+ container = document.createElement('div')
74
+ document.body.appendChild(container)
75
+ root = createRoot(container)
76
+ })
77
+
78
+ afterEach(() => {
79
+ act(() => {
80
+ root.unmount()
81
+ })
82
+ container.remove()
83
+ })
84
+
85
+ const render = async (getCollectionDocuments: any, props: Record<string, unknown> = {}) => {
86
+ await act(async () => {
87
+ root.render(
88
+ <I18nProvider
89
+ bundle={adminTranslations({ locales: ['en'] })}
90
+ activeLocale="en"
91
+ defaultLocale="en"
92
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
93
+ >
94
+ <BylineFieldServicesProvider
95
+ services={{ getCollectionDocuments, uploadField: async () => ({}) } as any}
96
+ >
97
+ <RelationPicker
98
+ targetCollectionPath="media"
99
+ targetDefinition={targetDefinition}
100
+ displayField="title"
101
+ isOpen={true}
102
+ onSelect={() => {}}
103
+ onDismiss={() => {}}
104
+ {...(props as any)}
105
+ />
106
+ </BylineFieldServicesProvider>
107
+ </I18nProvider>
108
+ )
109
+ })
110
+ }
111
+
112
+ const refreshButton = () =>
113
+ container.querySelector<HTMLButtonElement>('.byline-field-relation-picker-refresh')
114
+
115
+ const rowText = () =>
116
+ Array.from(container.querySelectorAll('.byline-field-relation-picker-rows li')).map((node) =>
117
+ (node.textContent ?? '').trim()
118
+ )
119
+
120
+ const search = async (value: string) => {
121
+ const input = container.querySelector<HTMLInputElement>('input[aria-label="search"]')
122
+ if (input == null) throw new Error('search input not found')
123
+ // React's `onChange` is an `input` listener, and assigning `.value` directly
124
+ // is swallowed by its value tracker — drive the native setter.
125
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
126
+ await act(async () => {
127
+ setter?.call(input, value)
128
+ input.dispatchEvent(new Event('input', { bubbles: true }))
129
+ })
130
+ }
131
+
132
+ /** The request argument of the most recent list call, failing loudly if absent. */
133
+ const lastRequest = (mock: { mock: { calls: unknown[][] } }): any => {
134
+ const call = mock.mock.calls.at(-1)
135
+ if (call == null) throw new Error('no list request was made')
136
+ return call[0]
137
+ }
138
+
139
+ const clickRefresh = async () => {
140
+ await act(async () => {
141
+ refreshButton()?.click()
142
+ })
143
+ }
144
+
145
+ describe('relation picker refresh', () => {
146
+ it('re-runs the query and shows a document created since the picker opened', async () => {
147
+ let docs = [{ id: 'a', title: 'Apple' }]
148
+ const getCollectionDocuments = vi.fn(async () => page(docs))
149
+ await render(getCollectionDocuments)
150
+
151
+ expect(rowText().some((text) => text.includes('Apple'))).toBe(true)
152
+ expect(rowText().some((text) => text.includes('Banana'))).toBe(false)
153
+
154
+ // Stands in for the editor creating a document in the other tab.
155
+ docs = [
156
+ { id: 'a', title: 'Apple' },
157
+ { id: 'b', title: 'Banana' },
158
+ ]
159
+ await clickRefresh()
160
+
161
+ expect(rowText().some((text) => text.includes('Banana'))).toBe(true)
162
+ })
163
+
164
+ /**
165
+ * The reader may have typed a search before leaving to create the document.
166
+ * Refresh must re-run *their* query, not reset to an unfiltered first page.
167
+ */
168
+ it('preserves the search term across a refresh', async () => {
169
+ const getCollectionDocuments = vi.fn(async () => page([{ id: 'a', title: 'Apple' }]))
170
+ await render(getCollectionDocuments)
171
+
172
+ await search('app')
173
+ const callsAfterSearch = getCollectionDocuments.mock.calls.length
174
+ await clickRefresh()
175
+
176
+ expect(getCollectionDocuments.mock.calls.length).toBeGreaterThan(callsAfterSearch)
177
+ const lastCall = lastRequest(getCollectionDocuments)
178
+ expect(lastCall.params.query).toBe('app')
179
+ })
180
+
181
+ it('preserves the current page across a refresh', async () => {
182
+ const getCollectionDocuments = vi.fn(async () => ({
183
+ docs: [{ id: 'a', fields: { title: 'Apple' } }],
184
+ meta: { totalPages: 3 },
185
+ included: { collection: { id: 'collection-1' } },
186
+ }))
187
+ await render(getCollectionDocuments)
188
+
189
+ const next = Array.from(container.querySelectorAll('button')).find(
190
+ (button) => button.textContent?.trim() === 'Next'
191
+ )
192
+ if (next == null) throw new Error('Next button not found')
193
+ await act(async () => {
194
+ next.click()
195
+ })
196
+ expect(lastRequest(getCollectionDocuments).params.page).toBe(2)
197
+ await clickRefresh()
198
+
199
+ expect(lastRequest(getCollectionDocuments).params.page).toBe(2)
200
+ })
201
+
202
+ it('keeps a multi-select choice through a refresh', async () => {
203
+ const getCollectionDocuments = vi.fn(async () =>
204
+ page([
205
+ { id: 'a', title: 'Apple' },
206
+ { id: 'b', title: 'Banana' },
207
+ ])
208
+ )
209
+ const onSelectMany = vi.fn()
210
+ await render(getCollectionDocuments, { multiple: true, onSelectMany })
211
+
212
+ const firstChoice = container.querySelector<HTMLButtonElement>(
213
+ '.byline-field-relation-picker-rows li button'
214
+ )
215
+ if (firstChoice == null) throw new Error('First choice not found')
216
+ await act(async () => {
217
+ firstChoice.click()
218
+ })
219
+ expect(firstChoice.getAttribute('aria-pressed')).toBe('true')
220
+
221
+ await clickRefresh()
222
+
223
+ const selected = container.querySelectorAll('[aria-pressed="true"]')
224
+ expect(selected).toHaveLength(1)
225
+ expect(selected[0]?.textContent).toContain('Apple')
226
+ const confirm = Array.from(container.querySelectorAll('button')).find(
227
+ (button) => button.textContent?.trim() === 'Add selected (1)'
228
+ )
229
+ if (confirm == null) throw new Error('Selection confirmation not found')
230
+ await act(async () => {
231
+ confirm.click()
232
+ })
233
+ expect(onSelectMany).toHaveBeenCalledExactlyOnceWith([
234
+ {
235
+ targetDocumentId: 'a',
236
+ targetCollectionId: 'collection-1',
237
+ record: { id: 'a', fields: { title: 'Apple' } },
238
+ },
239
+ ])
240
+ })
241
+
242
+ it('keeps a single choice and confirms its refreshed record', async () => {
243
+ let title = 'Apple'
244
+ const getCollectionDocuments = vi.fn(async () => page([{ id: 'a', title }]))
245
+ const onSelect = vi.fn()
246
+ await render(getCollectionDocuments, { onSelect })
247
+
248
+ const choice = container.querySelector<HTMLButtonElement>(
249
+ '.byline-field-relation-picker-rows li button'
250
+ )
251
+ if (choice == null) throw new Error('Choice not found')
252
+ await act(async () => {
253
+ choice.click()
254
+ })
255
+ expect(choice.classList.contains('byline-field-relation-picker-row-selected')).toBe(true)
256
+
257
+ title = 'Updated Apple'
258
+ await clickRefresh()
259
+
260
+ const selected = container.querySelector('.byline-field-relation-picker-row-selected')
261
+ expect(selected?.textContent).toContain('Updated Apple')
262
+ const confirm = Array.from(container.querySelectorAll('button')).find(
263
+ (button) => button.textContent?.trim() === 'Select'
264
+ )
265
+ if (confirm == null) throw new Error('Select button not found')
266
+ expect(confirm.disabled).toBe(false)
267
+ await act(async () => {
268
+ confirm.click()
269
+ })
270
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith({
271
+ targetDocumentId: 'a',
272
+ targetCollectionId: 'collection-1',
273
+ record: { id: 'a', fields: { title: 'Updated Apple' } },
274
+ })
275
+ })
276
+
277
+ /**
278
+ * A refresh issued while an earlier request is still in flight must win, even
279
+ * if the earlier one resolves last. The fetch effect's `cancelled` cleanup
280
+ * gives this for free — but only because refresh re-runs the effect rather
281
+ * than calling the service imperatively, which would bypass it.
282
+ */
283
+ it('ignores a superseded request that resolves after the refresh', async () => {
284
+ const resolvers: Array<(value: unknown) => void> = []
285
+ const getCollectionDocuments = vi.fn(
286
+ () =>
287
+ new Promise((resolve) => {
288
+ resolvers.push(resolve)
289
+ })
290
+ )
291
+ await render(getCollectionDocuments)
292
+
293
+ await clickRefresh()
294
+ expect(resolvers.length).toBeGreaterThanOrEqual(2)
295
+
296
+ // Resolve the newest first, then let the stale one land after it.
297
+ await act(async () => {
298
+ resolvers[resolvers.length - 1]?.(page([{ id: 'b', title: 'Banana' }]))
299
+ })
300
+ await act(async () => {
301
+ resolvers[0]?.(page([{ id: 'a', title: 'Apple' }]))
302
+ })
303
+
304
+ expect(rowText().some((text) => text.includes('Banana'))).toBe(true)
305
+ expect(rowText().some((text) => text.includes('Apple'))).toBe(false)
306
+ })
307
+
308
+ it('surfaces a refresh failure and recovers on a later refresh', async () => {
309
+ let shouldFail = false
310
+ const getCollectionDocuments = vi.fn(async () => {
311
+ if (shouldFail) throw new Error('network down')
312
+ return page([{ id: 'a', title: 'Apple' }])
313
+ })
314
+ await render(getCollectionDocuments)
315
+
316
+ shouldFail = true
317
+ await clickRefresh()
318
+ expect(container.querySelector('.byline-field-relation-picker-error')).not.toBeNull()
319
+
320
+ shouldFail = false
321
+ await clickRefresh()
322
+ expect(container.querySelector('.byline-field-relation-picker-error')).toBeNull()
323
+ expect(rowText().some((text) => text.includes('Apple'))).toBe(true)
324
+ })
325
+ })
@@ -25,6 +25,38 @@
25
25
  :global(.byline-field-relation-picker-header) {
26
26
  padding-top: 1rem;
27
27
  margin-bottom: var(--spacing-8);
28
+ display: flex;
29
+ align-items: baseline;
30
+ justify-content: space-between;
31
+ gap: var(--spacing-8);
32
+ flex-wrap: wrap;
33
+ }
34
+
35
+ /* Header actions: refresh, and the create link when the host permits it.
36
+ Stable override handle: `.byline-field-relation-picker-actions`. */
37
+ .actions,
38
+ :global(.byline-field-relation-picker-actions) {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: var(--spacing-8);
42
+ }
43
+
44
+ /* Re-runs the current query. Stable override handle:
45
+ `.byline-field-relation-picker-refresh`. */
46
+ .refresh,
47
+ :global(.byline-field-relation-picker-refresh) {
48
+ white-space: nowrap;
49
+ }
50
+
51
+ /* Opens the target collection's create view in a new tab. Stable override
52
+ handle: `.byline-field-relation-picker-create`. */
53
+ .create,
54
+ :global(.byline-field-relation-picker-create) {
55
+ /* Rendered as an anchor so it behaves like a link, but it belongs to the
56
+ toolbar visually: drop the global anchor underline, and set no font size so
57
+ the button's own `xs` sizing governs rather than being overridden here. */
58
+ text-decoration: none;
59
+ white-space: nowrap;
28
60
  }
29
61
 
30
62
  .title,
@@ -133,8 +133,24 @@ export const RelationPicker = ({
133
133
  const [documents, setDocuments] = useState<any[]>([])
134
134
  const [totalPages, setTotalPages] = useState<number>(1)
135
135
  const [collectionId, setCollectionId] = useState<string | null>(null)
136
+ // Refresh is a dependency of the fetch effect rather than an imperative
137
+ // refetch, so re-running it goes through the effect's `cancelled` cleanup
138
+ // below: a superseded request that resolves late is discarded instead of
139
+ // overwriting newer results. Calling the service directly would lose that.
140
+ const [refreshNonce, setRefreshNonce] = useState(0)
136
141
 
137
- const { getCollectionDocuments } = useBylineFieldServices()
142
+ const { getCollectionDocuments, canCreateInCollection, getCreateDocumentUrl } =
143
+ useBylineFieldServices()
144
+
145
+ // Both capabilities are optional, and the affordance needs both: without
146
+ // `getCreateDocumentUrl` there is nowhere to send the reader, and without
147
+ // `canCreateInCollection` their permission is unknowable — offering a link that
148
+ // leads to a refusal is worse than offering none. The check is cosmetic; the
149
+ // create view enforces the ability server-side regardless.
150
+ const createHref =
151
+ getCreateDocumentUrl != null && canCreateInCollection?.(targetCollectionPath) === true
152
+ ? getCreateDocumentUrl(targetCollectionPath)
153
+ : null
138
154
 
139
155
  const targetAdminConfig: CollectionAdminConfig | null =
140
156
  getCollectionAdminConfig(targetCollectionPath)
@@ -152,6 +168,12 @@ export const RelationPicker = ({
152
168
  }, [isOpen])
153
169
 
154
170
  // Fetch whenever the modal is open and the query / page changes.
171
+ //
172
+ // `refreshNonce` is a re-run trigger rather than a value this effect reads, so
173
+ // the exhaustive-dependencies rule sees it as surplus. It is not: bumping it is
174
+ // how the refresh button re-runs the fetch, and routing refresh through the
175
+ // effect is what makes the `cancelled` cleanup discard a superseded request.
176
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see above
155
177
  useEffect(() => {
156
178
  if (!isOpen) return
157
179
  let cancelled = false
@@ -213,6 +235,7 @@ export const RelationPicker = ({
213
235
  getCollectionDocuments,
214
236
  t,
215
237
  targetAdminConfig?.itemViewSort,
238
+ refreshNonce,
216
239
  ])
217
240
 
218
241
  const resolvedDisplayField =
@@ -264,6 +287,41 @@ export const RelationPicker = ({
264
287
  <Modal.Container style={{ maxWidth: '600px', width: '100%' }}>
265
288
  <Modal.Header className={cx('byline-field-relation-picker-header', styles.header)}>
266
289
  <h3 className={cx('byline-field-relation-picker-title', styles.title)}>{title}</h3>
290
+ <div className={cx('byline-field-relation-picker-actions', styles.actions)}>
291
+ {/* Deliberately not disabled while loading: a request that never
292
+ settles must not leave the reader unable to retry, and a refresh
293
+ issued mid-flight supersedes the earlier one rather than racing
294
+ it. */}
295
+ <Button
296
+ type="button"
297
+ size="xs"
298
+ variant="outlined"
299
+ className={cx('byline-field-relation-picker-refresh', styles.refresh)}
300
+ onClick={() => setRefreshNonce((nonce) => nonce + 1)}
301
+ >
302
+ {t('fields.relation.picker.refresh')}
303
+ </Button>
304
+ {createHref != null && (
305
+ // Rendered as an anchor, styled as a button: keyboard activation and
306
+ // middle-click work without help and popup blockers leave real links
307
+ // alone, while `Button`'s `render` prop keeps it visually part of the
308
+ // toolbar. A new tab keeps this picker and the parent editor mounted,
309
+ // so unsaved work survives — the whole point of the affordance.
310
+ // `noopener` keeps the opened page out of `window.opener`.
311
+ <Button
312
+ size="xs"
313
+ variant="outlined"
314
+ className={cx('byline-field-relation-picker-create', styles.create)}
315
+ render={
316
+ <a href={createHref} target="_blank" rel="noopener noreferrer">
317
+ {t('fields.relation.picker.createNew', {
318
+ label: targetDefinition?.labels.singular ?? targetCollectionPath,
319
+ })}
320
+ </a>
321
+ }
322
+ />
323
+ )}
324
+ </div>
267
325
  </Modal.Header>
268
326
  <Modal.Content>
269
327
  <div className={cx('byline-field-relation-picker-body', styles.body)}>
@@ -11,6 +11,7 @@ import { ErrorText, Label, Select } 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 './select-field.module.css'
15
16
 
16
17
  export const SelectField = ({
@@ -33,7 +34,7 @@ export const SelectField = ({
33
34
  const isDirty = useIsDirty(fieldPath)
34
35
  const fieldValue = useFieldValue<string | undefined>(fieldPath)
35
36
  const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
36
- const htmlId = id ?? fieldPath
37
+ const htmlId = useScopedDomId(fieldPath, id)
37
38
 
38
39
  return (
39
40
  <div className={`byline-field-select ${field.name}`}>
@@ -47,6 +48,7 @@ export const SelectField = ({
47
48
  />
48
49
  )}
49
50
  <Select<string>
51
+ disabled={field.readOnly}
50
52
  size="xs"
51
53
  variant="outlined"
52
54
  id={htmlId}
@@ -62,7 +64,7 @@ export const SelectField = ({
62
64
  }}
63
65
  className={cx(isDirty && ['byline-field-select-dirty', styles.dirty])}
64
66
  />
65
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
67
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
66
68
  </div>
67
69
  )
68
70
  }
@@ -13,6 +13,7 @@ import { Input, Label } from '@byline/ui/react'
13
13
  import cx from 'clsx'
14
14
 
15
15
  import { useFieldError, useFieldValue } from '../../forms/form-context'
16
+ import { useScopedDomId } from '../../forms/form-dom-scope'
16
17
  import { LocaleBadge } from '../locale-badge'
17
18
  import styles from './text-field.module.css'
18
19
 
@@ -41,15 +42,15 @@ export const TextField = ({
41
42
  const fieldError = useFieldError(fieldPath)
42
43
  const fieldValue = useFieldValue<string | undefined>(fieldPath)
43
44
  const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
44
- const htmlId = id ?? fieldPath
45
+ const htmlId = useScopedDomId(fieldPath, id)
45
46
 
46
47
  const handleChange = useCallback(
47
48
  (value: string) => {
48
- if (onChange) {
49
+ if (onChange && !field.readOnly) {
49
50
  onChange(value)
50
51
  }
51
52
  },
52
- [onChange]
53
+ [onChange, field.readOnly]
53
54
  )
54
55
 
55
56
  // Custom component slots (from admin config)
@@ -121,6 +122,7 @@ export const TextField = ({
121
122
  }
122
123
  return (
123
124
  <Input
125
+ readOnly={field.readOnly}
124
126
  id={htmlId}
125
127
  name={field.name}
126
128
  label={suppressInputLabel ? undefined : field.label}
@@ -13,6 +13,7 @@ import { Label, TextArea } from '@byline/ui/react'
13
13
  import cx from 'clsx'
14
14
 
15
15
  import { useFieldError, useFieldValue } from '../../forms/form-context'
16
+ import { useScopedDomId } from '../../forms/form-dom-scope'
16
17
  import { LocaleBadge } from '../locale-badge'
17
18
  import styles from './text-area-field.module.css'
18
19
 
@@ -41,15 +42,15 @@ export const TextAreaField = ({
41
42
  const fieldError = useFieldError(fieldPath)
42
43
  const fieldValue = useFieldValue<string | undefined>(fieldPath)
43
44
  const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
44
- const htmlId = id ?? fieldPath
45
+ const htmlId = useScopedDomId(fieldPath, id)
45
46
 
46
47
  const handleChange = useCallback(
47
48
  (value: string) => {
48
- if (onChange) {
49
+ if (onChange && !field.readOnly) {
49
50
  onChange(value)
50
51
  }
51
52
  },
52
- [onChange]
53
+ [onChange, field.readOnly]
53
54
  )
54
55
 
55
56
  // Custom component slots (from admin config)
@@ -121,6 +122,7 @@ export const TextAreaField = ({
121
122
  }
122
123
  return (
123
124
  <TextArea
125
+ readOnly={field.readOnly}
124
126
  id={htmlId}
125
127
  name={field.name}
126
128
  label={suppressInputLabel ? undefined : field.label}
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import { useCallback } from 'react'
9
+ import { useCallback, useEffect, useRef } from 'react'
10
10
 
11
11
  import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/core'
12
12
  import { normalizeHooks } from '@byline/core'
@@ -28,11 +28,31 @@ import { useFormContext } from '../forms/form-context'
28
28
  * to `setFieldValue` (no promises, no extra allocations).
29
29
  */
30
30
  export function useFieldChangeHandler(field: Field, path: string) {
31
- const { setFieldValue, getFieldValue, getFieldValues, setFieldError, clearFieldError } =
32
- useFormContext()
31
+ const {
32
+ setFieldValue,
33
+ getFieldValue,
34
+ getFieldValues,
35
+ setFieldError,
36
+ clearFieldError,
37
+ trackFieldChange,
38
+ } = useFormContext()
39
+
40
+ const generation = useRef(0)
41
+ const releasePending = useRef<(() => void) | undefined>(undefined)
42
+ useEffect(
43
+ () => () => {
44
+ generation.current++
45
+ releasePending.current?.()
46
+ },
47
+ []
48
+ )
33
49
 
34
50
  return useCallback(
35
51
  (value: any) => {
52
+ releasePending.current?.()
53
+ const invocation = ++generation.current
54
+ if (field.readOnly) return
55
+ const current = () => invocation === generation.current
36
56
  const hooks = field.hooks
37
57
 
38
58
  // ── fast path: no hooks defined ────────────────────────────
@@ -56,7 +76,9 @@ export function useFieldChangeHandler(field: Field, path: string) {
56
76
  // Raw store write for cross-field behaviour (e.g. mutual exclusivity
57
77
  // across array items). Deliberately does not run the target field's
58
78
  // own hooks — see FieldHookContext.setFieldValue.
59
- setFieldValue,
79
+ setFieldValue: (target, next) => {
80
+ if (current()) setFieldValue(target, next)
81
+ },
60
82
  }
61
83
 
62
84
  clearFieldError(path)
@@ -70,12 +92,13 @@ export function useFieldChangeHandler(field: Field, path: string) {
70
92
  setFieldValue(path, value)
71
93
  }
72
94
 
73
- void (async () => {
95
+ const pending = (async () => {
74
96
  try {
75
97
  // 1. beforeValidate (advisory — value is always committed)
76
98
  let advisoryError: string | undefined
77
99
  for (const fn of validateFns) {
78
100
  const result = (await fn(ctx)) as FieldBeforeChangeResult | undefined
101
+ if (!current()) return
79
102
  if (result?.error) {
80
103
  advisoryError = result.error
81
104
  }
@@ -87,6 +110,7 @@ export function useFieldChangeHandler(field: Field, path: string) {
87
110
  // 2. beforeChange
88
111
  for (const fn of changeFns) {
89
112
  const result = (await fn(ctx)) as FieldBeforeChangeResult | undefined
113
+ if (!current()) return
90
114
  if (result?.error) {
91
115
  setFieldError(path, result.error)
92
116
  return // block the change
@@ -97,20 +121,30 @@ export function useFieldChangeHandler(field: Field, path: string) {
97
121
  }
98
122
 
99
123
  // 3. commit the (possibly transformed) value and surface any advisory error.
100
- // If there were no beforeChange hooks we already committed synchronously above;
101
- // this call is a no-op if the value hasn't been altered by a hook.
102
- setFieldValue(path, ctx.value)
124
+ if (!current()) return
125
+ if (changeFns.length > 0 || !Object.is(ctx.value, value)) setFieldValue(path, ctx.value)
103
126
  if (advisoryError) {
104
127
  setFieldError(path, advisoryError)
105
128
  }
106
129
  } catch (err) {
130
+ if (!current()) return
107
131
  // Surface unexpected hook errors as field errors rather than crashing
108
132
  const message = err instanceof Error ? err.message : 'Unexpected hook error'
109
133
  setFieldError(path, message)
110
134
  }
111
135
  })()
136
+ releasePending.current = trackFieldChange(pending)
112
137
  },
113
138
  // field reference is stable per render cycle; path is derived from props
114
- [field, path, setFieldValue, getFieldValue, getFieldValues, setFieldError, clearFieldError]
139
+ [
140
+ field,
141
+ path,
142
+ setFieldValue,
143
+ getFieldValue,
144
+ getFieldValues,
145
+ setFieldError,
146
+ clearFieldError,
147
+ trackFieldChange,
148
+ ]
115
149
  )
116
150
  }