@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
|
@@ -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 } =
|
|
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
|
|
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={`${
|
|
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
|
|
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
|
|
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 {
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
[
|
|
139
|
+
[
|
|
140
|
+
field,
|
|
141
|
+
path,
|
|
142
|
+
setFieldValue,
|
|
143
|
+
getFieldValue,
|
|
144
|
+
getFieldValues,
|
|
145
|
+
setFieldError,
|
|
146
|
+
clearFieldError,
|
|
147
|
+
trackFieldChange,
|
|
148
|
+
]
|
|
115
149
|
)
|
|
116
150
|
}
|