@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,247 @@
|
|
|
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 { createPortal } from 'react-dom'
|
|
15
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
17
|
+
|
|
18
|
+
import { BylineFieldServicesProvider } from '../fields/field-services-context'
|
|
19
|
+
import { FormRenderer } from './form-renderer'
|
|
20
|
+
|
|
21
|
+
const globalWithAct = globalThis as any
|
|
22
|
+
globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
|
|
23
|
+
|
|
24
|
+
defineAdminConfig({
|
|
25
|
+
i18n: {
|
|
26
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
27
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
28
|
+
},
|
|
29
|
+
collections: [
|
|
30
|
+
{
|
|
31
|
+
path: 'pages',
|
|
32
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
33
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const fieldServices = {
|
|
40
|
+
getCollectionDocuments: async () => ({ docs: [], total: 0 }),
|
|
41
|
+
uploadField: async () => ({}),
|
|
42
|
+
} as any
|
|
43
|
+
|
|
44
|
+
// Optional so submission is not blocked by validation: this file tests which
|
|
45
|
+
// form a submit event belongs to, not what the form does with it.
|
|
46
|
+
const fields = [{ name: 'title', label: 'Title', type: 'text' as const, optional: true }]
|
|
47
|
+
|
|
48
|
+
let container: HTMLDivElement
|
|
49
|
+
let root: Root
|
|
50
|
+
let portalHost: HTMLDivElement
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
container = document.createElement('div')
|
|
54
|
+
document.body.appendChild(container)
|
|
55
|
+
portalHost = document.createElement('div')
|
|
56
|
+
document.body.appendChild(portalHost)
|
|
57
|
+
root = createRoot(container)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
act(() => {
|
|
62
|
+
root.unmount()
|
|
63
|
+
})
|
|
64
|
+
container.remove()
|
|
65
|
+
portalHost.remove()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A nested form rendered through a portal: outside the parent `<form>` in the
|
|
70
|
+
* DOM, so no invalid nested-form markup arises, but still a descendant in the
|
|
71
|
+
* *React* tree — which is what React propagates synthetic events along.
|
|
72
|
+
*/
|
|
73
|
+
const PortalledInnerForm = ({
|
|
74
|
+
stopPropagation,
|
|
75
|
+
onInnerSubmit,
|
|
76
|
+
}: {
|
|
77
|
+
stopPropagation: boolean
|
|
78
|
+
onInnerSubmit: () => void
|
|
79
|
+
}) =>
|
|
80
|
+
createPortal(
|
|
81
|
+
<form
|
|
82
|
+
data-testid="inner-form"
|
|
83
|
+
onSubmit={(event) => {
|
|
84
|
+
event.preventDefault()
|
|
85
|
+
if (stopPropagation) event.stopPropagation()
|
|
86
|
+
onInnerSubmit()
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<input name="innerTitle" defaultValue="" />
|
|
90
|
+
<button type="submit">Save inner</button>
|
|
91
|
+
</form>,
|
|
92
|
+
portalHost
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const renderWithInnerForm = ({
|
|
96
|
+
parentSubmit,
|
|
97
|
+
onInnerSubmit = () => {},
|
|
98
|
+
stopPropagation = false,
|
|
99
|
+
}: {
|
|
100
|
+
parentSubmit: () => Promise<void>
|
|
101
|
+
onInnerSubmit?: () => void
|
|
102
|
+
stopPropagation?: boolean
|
|
103
|
+
}) => {
|
|
104
|
+
act(() => {
|
|
105
|
+
root.render(
|
|
106
|
+
<I18nProvider
|
|
107
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
108
|
+
activeLocale="en"
|
|
109
|
+
defaultLocale="en"
|
|
110
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
111
|
+
>
|
|
112
|
+
<BylineFieldServicesProvider services={fieldServices}>
|
|
113
|
+
<FormRenderer
|
|
114
|
+
mode="create"
|
|
115
|
+
fields={fields}
|
|
116
|
+
collectionPath="pages"
|
|
117
|
+
onCancel={() => {}}
|
|
118
|
+
onSubmit={parentSubmit}
|
|
119
|
+
headerSlot={
|
|
120
|
+
<PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={onInnerSubmit} />
|
|
121
|
+
}
|
|
122
|
+
/>
|
|
123
|
+
</BylineFieldServicesProvider>
|
|
124
|
+
</I18nProvider>
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parentForm = () => {
|
|
130
|
+
const form = container.querySelector('form')
|
|
131
|
+
if (form == null) throw new Error('parent form not found')
|
|
132
|
+
return form
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const innerForm = () => {
|
|
136
|
+
const form = portalHost.querySelector<HTMLFormElement>('[data-testid="inner-form"]')
|
|
137
|
+
if (form == null) throw new Error('inner form not found')
|
|
138
|
+
return form
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const submit = async (form: HTMLFormElement) => {
|
|
142
|
+
await act(async () => {
|
|
143
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe('submit isolation across a portal', () => {
|
|
148
|
+
/**
|
|
149
|
+
* The inner form here deliberately does **not** call `stopPropagation`, so
|
|
150
|
+
* these two cases exercise the parent's own guard. With the child-side defence
|
|
151
|
+
* in place as well, a broken parent guard would be invisible.
|
|
152
|
+
*/
|
|
153
|
+
it('does not run the parent submit handler when a portalled inner form submits', async () => {
|
|
154
|
+
const parentSubmit = vi.fn(async () => {})
|
|
155
|
+
renderWithInnerForm({ parentSubmit })
|
|
156
|
+
|
|
157
|
+
await submit(innerForm())
|
|
158
|
+
|
|
159
|
+
expect(parentSubmit).not.toHaveBeenCalled()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('still runs the inner form’s own handler', async () => {
|
|
163
|
+
const parentSubmit = vi.fn(async () => {})
|
|
164
|
+
const onInnerSubmit = vi.fn()
|
|
165
|
+
renderWithInnerForm({ parentSubmit, onInnerSubmit })
|
|
166
|
+
|
|
167
|
+
await submit(innerForm())
|
|
168
|
+
|
|
169
|
+
expect(onInnerSubmit).toHaveBeenCalledTimes(1)
|
|
170
|
+
expect(parentSubmit).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('still submits normally from the parent form itself', async () => {
|
|
174
|
+
const parentSubmit = vi.fn(async () => {})
|
|
175
|
+
renderWithInnerForm({ parentSubmit })
|
|
176
|
+
|
|
177
|
+
await submit(parentForm())
|
|
178
|
+
|
|
179
|
+
expect(parentSubmit).toHaveBeenCalledTimes(1)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('leaves the parent form submitting on repeat after an inner submit', async () => {
|
|
183
|
+
const parentSubmit = vi.fn(async () => {})
|
|
184
|
+
renderWithInnerForm({ parentSubmit })
|
|
185
|
+
|
|
186
|
+
await submit(innerForm())
|
|
187
|
+
await submit(parentForm())
|
|
188
|
+
await submit(parentForm())
|
|
189
|
+
|
|
190
|
+
expect(parentSubmit).toHaveBeenCalledTimes(2)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The child-side defence, proved without the parent guard in the picture. If this
|
|
196
|
+
* were asserted against `FormRenderer`, a working parent guard would satisfy it
|
|
197
|
+
* whether or not the child stopped propagation, and the test would prove nothing
|
|
198
|
+
* about the child.
|
|
199
|
+
*/
|
|
200
|
+
describe('the child-side stopPropagation defence', () => {
|
|
201
|
+
const PlainParent = ({
|
|
202
|
+
onParentSubmit,
|
|
203
|
+
stopPropagation,
|
|
204
|
+
}: {
|
|
205
|
+
onParentSubmit: () => void
|
|
206
|
+
stopPropagation: boolean
|
|
207
|
+
}) => (
|
|
208
|
+
<form
|
|
209
|
+
data-testid="plain-parent"
|
|
210
|
+
onSubmit={(event) => {
|
|
211
|
+
event.preventDefault()
|
|
212
|
+
onParentSubmit()
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={() => {}} />
|
|
216
|
+
</form>
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const renderPlain = (props: { onParentSubmit: () => void; stopPropagation: boolean }) => {
|
|
220
|
+
act(() => {
|
|
221
|
+
root.render(<PlainParent {...props} />)
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
it('keeps an unguarded parent handler from running when the child stops propagation', async () => {
|
|
226
|
+
const onParentSubmit = vi.fn()
|
|
227
|
+
renderPlain({ onParentSubmit, stopPropagation: true })
|
|
228
|
+
|
|
229
|
+
await submit(innerForm())
|
|
230
|
+
|
|
231
|
+
expect(onParentSubmit).not.toHaveBeenCalled()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The control: the same unguarded parent, with the child defence removed. It
|
|
236
|
+
* fails in exactly the way the feature is meant to prevent, which is what makes
|
|
237
|
+
* the assertion above meaningful.
|
|
238
|
+
*/
|
|
239
|
+
it('runs an unguarded parent handler when the child does not stop propagation', async () => {
|
|
240
|
+
const onParentSubmit = vi.fn()
|
|
241
|
+
renderPlain({ onParentSubmit, stopPropagation: false })
|
|
242
|
+
|
|
243
|
+
await submit(innerForm())
|
|
244
|
+
|
|
245
|
+
expect(onParentSubmit).toHaveBeenCalledTimes(1)
|
|
246
|
+
})
|
|
247
|
+
})
|
package/src/forms/nested-path.ts
CHANGED
|
@@ -146,3 +146,38 @@ export function set<T extends object>(object: T, path: string, value: unknown):
|
|
|
146
146
|
setWithResult(object, path, value)
|
|
147
147
|
return object
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
/** Clone only the containers along a write path, preserving other snapshots. */
|
|
151
|
+
export function withValue<T extends object>(
|
|
152
|
+
object: T,
|
|
153
|
+
path: string,
|
|
154
|
+
value: unknown
|
|
155
|
+
): T | undefined {
|
|
156
|
+
const parsed = parseInstancePath(path)
|
|
157
|
+
if (!parsed.ok || !hasExistingIdTargets(object, path)) return undefined
|
|
158
|
+
const result: any = Array.isArray(object) ? [...object] : { ...object }
|
|
159
|
+
let current = result
|
|
160
|
+
for (const [index, segment] of parsed.segments.entries()) {
|
|
161
|
+
const key =
|
|
162
|
+
segment.kind === 'field'
|
|
163
|
+
? segment.name
|
|
164
|
+
: segment.kind === 'index'
|
|
165
|
+
? segment.index
|
|
166
|
+
: segment.kind === 'id'
|
|
167
|
+
? selectId(current, segment.id)
|
|
168
|
+
: undefined
|
|
169
|
+
if (key === undefined || key === -1) return undefined
|
|
170
|
+
if (index === parsed.segments.length - 1) {
|
|
171
|
+
current[key] = value
|
|
172
|
+
return result
|
|
173
|
+
}
|
|
174
|
+
const existing = current[key]
|
|
175
|
+
current[key] = Array.isArray(existing)
|
|
176
|
+
? [...existing]
|
|
177
|
+
: existing != null && typeof existing === 'object'
|
|
178
|
+
? { ...existing }
|
|
179
|
+
: newContainer(parsed.segments[index + 1])
|
|
180
|
+
current = current[key]
|
|
181
|
+
}
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { useCallback, useEffect, useRef, useState } from 'react'
|
|
10
|
+
|
|
11
|
+
/** Reload admission and focus recovery remain synchronous at event boundaries. */
|
|
12
|
+
export function useDocumentReloadRecovery({
|
|
13
|
+
mutationsBlocked,
|
|
14
|
+
mutationIssue,
|
|
15
|
+
onReloadDocument,
|
|
16
|
+
isBusy,
|
|
17
|
+
}: {
|
|
18
|
+
mutationsBlocked: boolean
|
|
19
|
+
mutationIssue?: string | null
|
|
20
|
+
onReloadDocument?: () => void | Promise<void>
|
|
21
|
+
isBusy: boolean
|
|
22
|
+
}) {
|
|
23
|
+
const formRef = useRef<HTMLFormElement>(null)
|
|
24
|
+
const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
|
|
25
|
+
const restoreFocusAfterBusyRef = useRef(false)
|
|
26
|
+
const [discarding, setDiscarding] = useState(false)
|
|
27
|
+
const [reloadFailed, setReloadFailed] = useState(false)
|
|
28
|
+
const warningRef = useRef<HTMLDivElement>(null)
|
|
29
|
+
const mutationBlockedRef = useRef(mutationsBlocked)
|
|
30
|
+
mutationBlockedRef.current = mutationsBlocked || discarding
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (mutationIssue) warningRef.current?.focus()
|
|
33
|
+
}, [mutationIssue])
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!discarding) return
|
|
36
|
+
// Let the guard's beforeunload listener detach before the explicit discard.
|
|
37
|
+
Promise.resolve()
|
|
38
|
+
.then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
|
|
39
|
+
.catch(() => {
|
|
40
|
+
setDiscarding(false)
|
|
41
|
+
setReloadFailed(true)
|
|
42
|
+
})
|
|
43
|
+
}, [discarding, onReloadDocument])
|
|
44
|
+
|
|
45
|
+
const captureFocusBeforeBusy = useCallback(() => {
|
|
46
|
+
if (focusBeforeBusyRef.current != null) return
|
|
47
|
+
const activeElement = document.activeElement
|
|
48
|
+
if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
|
|
49
|
+
focusBeforeBusyRef.current = activeElement
|
|
50
|
+
restoreFocusAfterBusyRef.current = true
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
// `inert` removes the active control from the tab order while a save is in
|
|
54
|
+
// flight. Restore the editor's position after React has removed `inert`;
|
|
55
|
+
// when the original control became disabled, fall back to the first usable
|
|
56
|
+
// form control instead of leaving focus on <body>.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (isBusy || !restoreFocusAfterBusyRef.current) return
|
|
59
|
+
restoreFocusAfterBusyRef.current = false
|
|
60
|
+
|
|
61
|
+
if (mutationIssue) {
|
|
62
|
+
warningRef.current?.focus()
|
|
63
|
+
focusBeforeBusyRef.current = null
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const original = focusBeforeBusyRef.current
|
|
67
|
+
focusBeforeBusyRef.current = null
|
|
68
|
+
const originalCanReceiveFocus =
|
|
69
|
+
original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
|
|
70
|
+
const target = originalCanReceiveFocus
|
|
71
|
+
? original
|
|
72
|
+
: formRef.current?.querySelector<HTMLElement>(
|
|
73
|
+
'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
74
|
+
)
|
|
75
|
+
target?.focus({ preventScroll: true })
|
|
76
|
+
}, [isBusy, mutationIssue])
|
|
77
|
+
|
|
78
|
+
const requestDiscard = useCallback(() => {
|
|
79
|
+
mutationBlockedRef.current = true
|
|
80
|
+
setReloadFailed(false)
|
|
81
|
+
setDiscarding(true)
|
|
82
|
+
}, [])
|
|
83
|
+
return {
|
|
84
|
+
formRef,
|
|
85
|
+
warningRef,
|
|
86
|
+
mutationBlockedRef,
|
|
87
|
+
discarding,
|
|
88
|
+
reloadFailed,
|
|
89
|
+
requestDiscard,
|
|
90
|
+
captureFocusBeforeBusy,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { useCallback, useRef, useState } from 'react'
|
|
12
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { type Field, getDocumentFieldValidationDetails } from '@byline/core'
|
|
14
14
|
import type { DocumentPatch } from '@byline/core/patches'
|
|
15
15
|
import { useTranslation } from '@byline/i18n/react'
|
|
16
16
|
|
|
@@ -105,7 +105,9 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
105
105
|
setFieldValue,
|
|
106
106
|
setFieldError,
|
|
107
107
|
getPendingUploads,
|
|
108
|
-
|
|
108
|
+
removePendingUpload,
|
|
109
|
+
clearFieldError,
|
|
110
|
+
waitForFieldChanges,
|
|
109
111
|
setFieldUploading,
|
|
110
112
|
} = useFormContext()
|
|
111
113
|
const { t } = useTranslation('byline-admin')
|
|
@@ -113,6 +115,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
113
115
|
|
|
114
116
|
const [phase, setPhase] = useState<SubmissionPhase>(IDLE)
|
|
115
117
|
const inFlightRef = useRef(false)
|
|
118
|
+
const confirmationRef = useRef<SystemFieldsSubmitPayload | null>(null)
|
|
116
119
|
|
|
117
120
|
const isBusy = phase.kind === 'uploading' || phase.kind === 'submitting'
|
|
118
121
|
|
|
@@ -136,14 +139,16 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
136
139
|
try {
|
|
137
140
|
await onSubmit(payload)
|
|
138
141
|
resetHasChanges()
|
|
139
|
-
} catch {
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const validation = getDocumentFieldValidationDetails(error)
|
|
144
|
+
for (const issue of validation?.issues ?? []) setFieldError(issue.field, issue.message)
|
|
140
145
|
// Intentionally swallowed here — the host has already reported the
|
|
141
146
|
// failure to the user. Dirty state is preserved by not resetting.
|
|
142
147
|
} finally {
|
|
143
148
|
setPhase(IDLE)
|
|
144
149
|
}
|
|
145
150
|
},
|
|
146
|
-
[isBlocked, onBeforeBusy, onSubmit, resetHasChanges]
|
|
151
|
+
[isBlocked, onBeforeBusy, onSubmit, resetHasChanges, setFieldError]
|
|
147
152
|
)
|
|
148
153
|
|
|
149
154
|
const runUploads = useCallback(async (): Promise<boolean> => {
|
|
@@ -170,28 +175,31 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
170
175
|
}
|
|
171
176
|
)
|
|
172
177
|
|
|
178
|
+
// Adopt each success before reporting partial failure, so retries only
|
|
179
|
+
// transport failed files. Do not overwrite a selection replaced in flight.
|
|
180
|
+
for (const [fieldPath, storedFile] of uploadResult.successful) {
|
|
181
|
+
if (getPendingUploads().get(fieldPath) !== pendingUploads.get(fieldPath)) continue
|
|
182
|
+
setFieldValue(fieldPath, storedFile)
|
|
183
|
+
clearFieldError(fieldPath)
|
|
184
|
+
removePendingUpload(fieldPath)
|
|
185
|
+
}
|
|
173
186
|
if (!uploadResult.allSucceeded) {
|
|
174
|
-
for (const [fieldPath, errorMessage] of uploadResult.errors
|
|
175
|
-
|
|
187
|
+
for (const [fieldPath, errorMessage] of uploadResult.errors) {
|
|
188
|
+
if (getPendingUploads().get(fieldPath) === pendingUploads.get(fieldPath)) {
|
|
189
|
+
setFieldError(fieldPath, t('forms.uploadFailedFieldError', { message: errorMessage }))
|
|
190
|
+
}
|
|
176
191
|
}
|
|
177
|
-
console.error('One or more uploads failed:', uploadResult.errors)
|
|
178
192
|
return false
|
|
179
193
|
}
|
|
180
|
-
|
|
181
|
-
// Replace pending StoredFileValues with real ones in form data
|
|
182
|
-
for (const [fieldPath, storedFile] of uploadResult.successful.entries()) {
|
|
183
|
-
setFieldValue(fieldPath, storedFile)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Clear pending uploads (blob URLs already revoked by clearPendingUploads)
|
|
187
|
-
clearPendingUploads()
|
|
194
|
+
if (getPendingUploads().size > 0) return false
|
|
188
195
|
return true
|
|
189
196
|
} catch (err) {
|
|
190
197
|
console.error('Upload execution error:', err)
|
|
191
198
|
return false
|
|
192
199
|
}
|
|
193
200
|
}, [
|
|
194
|
-
|
|
201
|
+
removePendingUpload,
|
|
202
|
+
clearFieldError,
|
|
195
203
|
documentId,
|
|
196
204
|
fields,
|
|
197
205
|
getFieldValues,
|
|
@@ -210,8 +218,8 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
210
218
|
return {
|
|
211
219
|
reason,
|
|
212
220
|
payload: {
|
|
213
|
-
data: getFieldValues(),
|
|
214
|
-
patches: getPatches(),
|
|
221
|
+
data: structuredClone(getFieldValues()),
|
|
222
|
+
patches: structuredClone(getPatches()),
|
|
215
223
|
contentDirty,
|
|
216
224
|
pathDirty,
|
|
217
225
|
systemPath: getSystemPath(),
|
|
@@ -230,21 +238,28 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
230
238
|
|
|
231
239
|
const submit = useCallback(async () => {
|
|
232
240
|
if (isBlocked()) return
|
|
233
|
-
if (inFlightRef.current) return
|
|
241
|
+
if (inFlightRef.current || confirmationRef.current) return
|
|
234
242
|
inFlightRef.current = true
|
|
235
243
|
try {
|
|
236
244
|
setPhase({ kind: 'validating' })
|
|
237
245
|
|
|
238
246
|
// Run field-level beforeValidate hooks (submit-time), then validate
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const allErrors = [...hookErrors, ...formErrors]
|
|
242
|
-
|
|
243
|
-
if (allErrors.length > 0) {
|
|
244
|
-
console.error('Form validation failed:', allErrors)
|
|
247
|
+
await waitForFieldChanges()
|
|
248
|
+
if (isBlocked()) {
|
|
245
249
|
setPhase(IDLE)
|
|
246
250
|
return
|
|
247
251
|
}
|
|
252
|
+
// Metadata-only saves do not create a content version and must remain
|
|
253
|
+
// available even when an older document lacks newly required fields.
|
|
254
|
+
if (mode !== 'edit' || getDirtyBreakdown().reason !== 'direct-write') {
|
|
255
|
+
const hookErrors = await runFieldHooks(fields)
|
|
256
|
+
const formErrors = validateForm(fields, hookErrors)
|
|
257
|
+
if (formErrors.length > 0) {
|
|
258
|
+
console.error('Form validation failed:', formErrors)
|
|
259
|
+
setPhase(IDLE)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
}
|
|
248
263
|
|
|
249
264
|
if (isBlocked()) {
|
|
250
265
|
setPhase(IDLE)
|
|
@@ -263,6 +278,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
263
278
|
// so confirm it before saving. Create mode writes everything as part of
|
|
264
279
|
// the initial version, so no confirmation applies there.
|
|
265
280
|
if (mode === 'edit' && (reason === 'direct-write' || reason === 'both')) {
|
|
281
|
+
confirmationRef.current = payload
|
|
266
282
|
setPhase({ kind: 'confirmingSystemFields', payload })
|
|
267
283
|
return
|
|
268
284
|
}
|
|
@@ -271,13 +287,25 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
271
287
|
} finally {
|
|
272
288
|
inFlightRef.current = false
|
|
273
289
|
}
|
|
274
|
-
}, [
|
|
290
|
+
}, [
|
|
291
|
+
buildPayload,
|
|
292
|
+
getDirtyBreakdown,
|
|
293
|
+
deliver,
|
|
294
|
+
fields,
|
|
295
|
+
isBlocked,
|
|
296
|
+
mode,
|
|
297
|
+
runUploads,
|
|
298
|
+
runFieldHooks,
|
|
299
|
+
validateForm,
|
|
300
|
+
waitForFieldChanges,
|
|
301
|
+
])
|
|
275
302
|
|
|
276
303
|
const confirmSystemFields = useCallback(async () => {
|
|
277
|
-
if (phase.kind !== 'confirmingSystemFields') return
|
|
304
|
+
if (phase.kind !== 'confirmingSystemFields' || confirmationRef.current !== phase.payload) return
|
|
278
305
|
if (inFlightRef.current) return
|
|
279
306
|
inFlightRef.current = true
|
|
280
307
|
const { payload } = phase
|
|
308
|
+
confirmationRef.current = null
|
|
281
309
|
try {
|
|
282
310
|
await deliver(payload)
|
|
283
311
|
} finally {
|
|
@@ -286,6 +314,7 @@ export function useFormSubmission(options: UseFormSubmissionOptions): UseFormSub
|
|
|
286
314
|
}, [deliver, phase])
|
|
287
315
|
|
|
288
316
|
const cancelSystemFields = useCallback(() => {
|
|
317
|
+
confirmationRef.current = null
|
|
289
318
|
setPhase((current) => (current.kind === 'confirmingSystemFields' ? IDLE : current))
|
|
290
319
|
}, [])
|
|
291
320
|
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* Copyright (c) Infonomic Company Limited
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
|
12
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { parseInstancePath, type TabDefinition, type TabSetDefinition } from '@byline/core'
|
|
14
14
|
|
|
15
15
|
import { useFormContext } from './form-context'
|
|
16
16
|
|
|
@@ -42,27 +42,35 @@ export interface UseFormTabsResult {
|
|
|
42
42
|
*/
|
|
43
43
|
export function useFormTabs(
|
|
44
44
|
fieldToTabPath: Map<string, { tabSetName: string; tabName: string }>,
|
|
45
|
-
activeTabBySet: Record<string, string
|
|
45
|
+
activeTabBySet: Record<string, string>,
|
|
46
|
+
tabSets: TabSetDefinition[]
|
|
46
47
|
): UseFormTabsResult {
|
|
47
|
-
const {
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
const { getErrors, subscribeErrors, subscribeMeta, getFieldValues } = useFormContext()
|
|
49
|
+
const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors)
|
|
50
|
+
const conditionalTabs = useMemo(
|
|
51
|
+
() => tabSets.flatMap((set) => set.tabs.filter((tab) => tab.condition)),
|
|
52
|
+
[tabSets]
|
|
53
|
+
)
|
|
54
|
+
const snapshot = useCallback(
|
|
55
|
+
() => JSON.stringify(conditionalTabs.map((tab) => Boolean(tab.condition?.(getFieldValues())))),
|
|
56
|
+
[conditionalTabs, getFieldValues]
|
|
57
|
+
)
|
|
58
|
+
const subscribe = useCallback(
|
|
59
|
+
(notify: () => void) => (conditionalTabs.length ? subscribeMeta(notify) : () => {}),
|
|
60
|
+
[conditionalTabs, subscribeMeta]
|
|
61
|
+
)
|
|
62
|
+
useSyncExternalStore(subscribe, snapshot, snapshot)
|
|
59
63
|
|
|
60
64
|
// Per-tab-set error counts: { [tabSetName]: { [tabName]: count } }.
|
|
61
65
|
// Each tab bar consumes its own slice.
|
|
62
66
|
const errorCountsBySet = useMemo<Record<string, Record<string, number>>>(() => {
|
|
63
67
|
const result: Record<string, Record<string, number>> = {}
|
|
64
68
|
for (const err of errors) {
|
|
65
|
-
const
|
|
69
|
+
const parsed = parseInstancePath(err.field)
|
|
70
|
+
const root = parsed.ok ? parsed.segments[0] : undefined
|
|
71
|
+
const path =
|
|
72
|
+
fieldToTabPath.get(err.field) ??
|
|
73
|
+
(root?.kind === 'field' ? fieldToTabPath.get(root.name) : undefined)
|
|
66
74
|
if (!path) continue
|
|
67
75
|
result[path.tabSetName] ??= {}
|
|
68
76
|
result[path.tabSetName]![path.tabName] = (result[path.tabSetName]?.[path.tabName] ?? 0) + 1
|
|
@@ -71,7 +79,7 @@ export function useFormTabs(
|
|
|
71
79
|
}, [errors, fieldToTabPath])
|
|
72
80
|
|
|
73
81
|
const resolve = (set: TabSetDefinition): ResolvedTabSet => {
|
|
74
|
-
const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(
|
|
82
|
+
const visibleTabs = set.tabs.filter((tab) => !tab.condition || tab.condition(getFieldValues()))
|
|
75
83
|
const requested = activeTabBySet[set.name] ?? ''
|
|
76
84
|
const activeTabName =
|
|
77
85
|
visibleTabs.length > 0 && !visibleTabs.some((t) => t.name === requested)
|