@byline/admin 6.2.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/fields/checkbox/checkbox-field.js +3 -1
  2. package/dist/fields/code/code-field.js +2 -1
  3. package/dist/fields/datetime/datetime-field.js +4 -2
  4. package/dist/fields/field-renderer.js +2 -1
  5. package/dist/fields/field-services-types.d.ts +23 -0
  6. package/dist/fields/file/file-field.js +3 -2
  7. package/dist/fields/group/group-field.js +3 -1
  8. package/dist/fields/image/image-field.js +3 -2
  9. package/dist/fields/numerical/numerical-field.js +2 -1
  10. package/dist/fields/relation/relation-field.js +3 -2
  11. package/dist/fields/relation/relation-many-field.js +3 -2
  12. package/dist/fields/relation/relation-picker.js +38 -7
  13. package/dist/fields/relation/relation-picker.module.js +3 -0
  14. package/dist/fields/relation/relation-picker_module.css +20 -0
  15. package/dist/fields/select/select-field.js +3 -2
  16. package/dist/fields/text/text-field.js +2 -1
  17. package/dist/fields/text-area/text-area-field.js +2 -1
  18. package/dist/forms/form-context.js +4 -1
  19. package/dist/forms/form-dom-scope.d.ts +34 -0
  20. package/dist/forms/form-dom-scope.js +20 -0
  21. package/dist/forms/form-layout.js +3 -1
  22. package/dist/forms/form-renderer.js +1 -0
  23. package/dist/presentation/tabs.js +2 -1
  24. package/dist/react.d.ts +1 -1
  25. package/package.json +7 -7
  26. package/src/fields/checkbox/checkbox-field.tsx +3 -1
  27. package/src/fields/code/code-field.tsx +2 -1
  28. package/src/fields/datetime/datetime-field.tsx +4 -2
  29. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  30. package/src/fields/field-dom-ids.test.tsx +293 -0
  31. package/src/fields/field-renderer.tsx +2 -1
  32. package/src/fields/field-services-types.ts +25 -0
  33. package/src/fields/file/file-field.tsx +3 -2
  34. package/src/fields/group/group-field.tsx +3 -1
  35. package/src/fields/image/image-field.tsx +3 -2
  36. package/src/fields/numerical/numerical-field.tsx +2 -1
  37. package/src/fields/relation/relation-field.tsx +3 -2
  38. package/src/fields/relation/relation-many-field.tsx +3 -2
  39. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  40. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  41. package/src/fields/relation/relation-picker.module.css +32 -0
  42. package/src/fields/relation/relation-picker.tsx +59 -1
  43. package/src/fields/select/select-field.tsx +3 -2
  44. package/src/fields/text/text-field.tsx +2 -1
  45. package/src/fields/text-area/text-area-field.tsx +2 -1
  46. package/src/forms/form-context.tsx +2 -1
  47. package/src/forms/form-dom-scope.test.tsx +213 -0
  48. package/src/forms/form-dom-scope.tsx +70 -0
  49. package/src/forms/form-layout.tsx +7 -1
  50. package/src/forms/form-renderer.tsx +11 -0
  51. package/src/forms/form-submit-isolation.test.tsx +247 -0
  52. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  53. package/src/presentation/tabs.test.tsx +49 -2
  54. package/src/presentation/tabs.tsx +11 -1
  55. package/src/react.ts +2 -0
@@ -0,0 +1,203 @@
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
+ // base-ui's Modal portals into a floating layer jsdom cannot position, and the
18
+ // contract under test is what the picker puts *into* the modal.
19
+ vi.mock('@byline/ui/react', async (importOriginal) => {
20
+ const actual = await importOriginal<Record<string, unknown>>()
21
+ const Pass = ({ children }: any) => <div>{children}</div>
22
+ const Modal: any = ({ children, isOpen }: any) => (isOpen ? <div>{children}</div> : null)
23
+ Modal.Container = Pass
24
+ Modal.Header = Pass
25
+ Modal.Content = Pass
26
+ Modal.Actions = Pass
27
+ return {
28
+ ...actual,
29
+ Modal,
30
+ Search: ({ placeholder }: any) => <input aria-label="search" placeholder={placeholder} />,
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
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
60
+ } as any
61
+
62
+ const emptyList = async () => ({
63
+ docs: [],
64
+ meta: { totalPages: 1 },
65
+ included: { collection: { id: 'collection-1' } },
66
+ })
67
+
68
+ let container: HTMLDivElement
69
+ let root: Root
70
+
71
+ beforeEach(() => {
72
+ container = document.createElement('div')
73
+ document.body.appendChild(container)
74
+ root = createRoot(container)
75
+ })
76
+
77
+ afterEach(() => {
78
+ act(() => {
79
+ root.unmount()
80
+ })
81
+ container.remove()
82
+ })
83
+
84
+ const render = async (services: Record<string, unknown>, props: Record<string, unknown> = {}) => {
85
+ await act(async () => {
86
+ root.render(
87
+ <I18nProvider
88
+ bundle={adminTranslations({ locales: ['en'] })}
89
+ activeLocale="en"
90
+ defaultLocale="en"
91
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
92
+ >
93
+ <BylineFieldServicesProvider
94
+ services={
95
+ { getCollectionDocuments: emptyList, uploadField: async () => ({}), ...services } as any
96
+ }
97
+ >
98
+ <RelationPicker
99
+ targetCollectionPath="media"
100
+ targetDefinition={targetDefinition}
101
+ displayField="title"
102
+ isOpen={true}
103
+ onSelect={() => {}}
104
+ onDismiss={() => {}}
105
+ {...(props as any)}
106
+ />
107
+ </BylineFieldServicesProvider>
108
+ </I18nProvider>
109
+ )
110
+ })
111
+ }
112
+
113
+ const createLink = () =>
114
+ container.querySelector<HTMLAnchorElement>('.byline-field-relation-picker-create')
115
+
116
+ const bothCapabilities = {
117
+ canCreateInCollection: () => true,
118
+ getCreateDocumentUrl: (path: string) => `/control-panel/collections/${path}/create`,
119
+ }
120
+
121
+ describe('relation picker create affordance', () => {
122
+ it('is absent when the host wires no capabilities', async () => {
123
+ await render({})
124
+
125
+ expect(createLink()).toBeNull()
126
+ })
127
+
128
+ /**
129
+ * Permission is unknowable without `canCreateInCollection`, and offering a link
130
+ * that leads to a refusal is worse than offering none.
131
+ */
132
+ it('is absent when only the url capability is wired', async () => {
133
+ await render({ getCreateDocumentUrl: (path: string) => `/x/${path}/create` })
134
+
135
+ expect(createLink()).toBeNull()
136
+ })
137
+
138
+ it('is absent when only the permission capability is wired', async () => {
139
+ await render({ canCreateInCollection: () => true })
140
+
141
+ expect(createLink()).toBeNull()
142
+ })
143
+
144
+ it('is absent when the viewer may not create in the target collection', async () => {
145
+ await render({ ...bothCapabilities, canCreateInCollection: () => false })
146
+
147
+ expect(createLink()).toBeNull()
148
+ })
149
+
150
+ it('asks about the target collection, not some other one', async () => {
151
+ const canCreateInCollection = vi.fn(() => true)
152
+ await render({ ...bothCapabilities, canCreateInCollection })
153
+
154
+ expect(canCreateInCollection).toHaveBeenCalledWith('media')
155
+ })
156
+
157
+ it('renders the host-supplied url when permitted', async () => {
158
+ await render(bothCapabilities)
159
+
160
+ expect(createLink()?.getAttribute('href')).toBe('/control-panel/collections/media/create')
161
+ })
162
+
163
+ /**
164
+ * A new tab is the whole point: the parent editor and this picker stay mounted
165
+ * with their unsaved state. `noopener` because the opened page must not reach
166
+ * back into this one through `window.opener`.
167
+ */
168
+ it('opens in a new tab without handing over a window reference', async () => {
169
+ await render(bothCapabilities)
170
+
171
+ expect(createLink()?.getAttribute('target')).toBe('_blank')
172
+ expect(createLink()?.getAttribute('rel')).toContain('noopener')
173
+ })
174
+
175
+ it('names the target collection so the reader knows what they are creating', async () => {
176
+ await render(bothCapabilities)
177
+
178
+ expect(createLink()?.textContent).toContain('Media item')
179
+ })
180
+
181
+ /**
182
+ * An anchor rather than a button calling `window.open`: keyboard and
183
+ * middle-click work for free, and popup blockers leave it alone.
184
+ */
185
+ it('is a real link, reachable by keyboard', async () => {
186
+ await render(bothCapabilities)
187
+
188
+ const link = createLink()
189
+ expect(link?.tagName).toBe('A')
190
+ expect(link?.getAttribute('href')).toBeTruthy()
191
+ })
192
+
193
+ it('does not dismiss the picker when followed', async () => {
194
+ const onDismiss = vi.fn()
195
+ await render(bothCapabilities, { onDismiss })
196
+
197
+ act(() => {
198
+ createLink()?.click()
199
+ })
200
+
201
+ expect(onDismiss).not.toHaveBeenCalled()
202
+ })
203
+ })
@@ -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}`}>
@@ -62,7 +63,7 @@ export const SelectField = ({
62
63
  }}
63
64
  className={cx(isDirty && ['byline-field-select-dirty', styles.dirty])}
64
65
  />
65
- {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
66
+ {fieldError && <ErrorText id={`${htmlId}-error`} text={fieldError} />}
66
67
  </div>
67
68
  )
68
69
  }