@open-mercato/ui 0.6.5-develop.4695.1.42ee0ddf0e → 0.6.5-develop.4718.1.56d834bb34
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.4718.1.56d834bb34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -149,13 +149,13 @@
|
|
|
149
149
|
"remark-gfm": "^4.0.1"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
152
|
+
"@open-mercato/shared": "0.6.5-develop.4718.1.56d834bb34",
|
|
153
153
|
"react": ">=18.0.0",
|
|
154
154
|
"react-dom": ">=18.0.0",
|
|
155
155
|
"react-is": ">=18.0.0"
|
|
156
156
|
},
|
|
157
157
|
"devDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.5-develop.4718.1.56d834bb34",
|
|
159
159
|
"@testing-library/dom": "^10.4.1",
|
|
160
160
|
"@testing-library/jest-dom": "^6.9.1",
|
|
161
161
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -560,6 +560,10 @@ function createDirtySnapshot(source: Record<string, unknown>): string {
|
|
|
560
560
|
return JSON.stringify(normalizeDirtySnapshotValue(source) ?? {})
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
+
function createDirtyValueSnapshot(value: unknown): string {
|
|
564
|
+
return JSON.stringify(normalizeDirtySnapshotValue(value) ?? null)
|
|
565
|
+
}
|
|
566
|
+
|
|
563
567
|
const FIELDSET_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
564
568
|
layers: Layers,
|
|
565
569
|
tag: Tag,
|
|
@@ -931,6 +935,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
931
935
|
const clearDirtyState = React.useCallback((snapshotSource?: Record<string, unknown>) => {
|
|
932
936
|
const source = snapshotSource ?? (valuesRef.current as Record<string, unknown>)
|
|
933
937
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(source)
|
|
938
|
+
dirtyBaselineValuesRef.current = { ...source }
|
|
939
|
+
userEditedFieldIdsRef.current.clear()
|
|
934
940
|
isDirtyRef.current = false
|
|
935
941
|
setHasUnsavedChanges(false)
|
|
936
942
|
}, [])
|
|
@@ -2209,14 +2215,46 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2209
2215
|
}
|
|
2210
2216
|
}, [errors, formId])
|
|
2211
2217
|
|
|
2218
|
+
const updateEditedFieldMarker = React.useCallback((
|
|
2219
|
+
id: string,
|
|
2220
|
+
nextValue: unknown,
|
|
2221
|
+
baselineSource: Record<string, unknown> | undefined = dirtyBaselineValuesRef.current,
|
|
2222
|
+
) => {
|
|
2223
|
+
if (
|
|
2224
|
+
baselineSource &&
|
|
2225
|
+
createDirtyValueSnapshot(baselineSource[id]) === createDirtyValueSnapshot(nextValue)
|
|
2226
|
+
) {
|
|
2227
|
+
userEditedFieldIdsRef.current.delete(id)
|
|
2228
|
+
return
|
|
2229
|
+
}
|
|
2230
|
+
userEditedFieldIdsRef.current.add(id)
|
|
2231
|
+
}, [])
|
|
2232
|
+
|
|
2212
2233
|
const setValue = React.useCallback((id: string, nextValue: unknown) => {
|
|
2213
2234
|
let nextData: CrudFormValues<TValues> | null = null
|
|
2235
|
+
let nextDirty: boolean | null = null
|
|
2236
|
+
const currentValue = (valuesRef.current as Record<string, unknown>)[id]
|
|
2237
|
+
if (!Object.is(currentValue, nextValue)) {
|
|
2238
|
+
updateEditedFieldMarker(id, nextValue)
|
|
2239
|
+
}
|
|
2214
2240
|
setValues((prev) => {
|
|
2215
2241
|
if (Object.is(prev[id], nextValue)) return prev
|
|
2242
|
+
const baselineSource = dirtyBaselineValuesRef.current ?? (prev as Record<string, unknown>)
|
|
2243
|
+
updateEditedFieldMarker(id, nextValue, baselineSource)
|
|
2216
2244
|
nextData = { ...prev, [id]: nextValue } as CrudFormValues<TValues>
|
|
2217
2245
|
valuesRef.current = nextData
|
|
2246
|
+
if (!(embedded && !trackDirtyWhenEmbedded)) {
|
|
2247
|
+
const baseline = dirtyBaselineSnapshotRef.current ?? createDirtySnapshot(prev as Record<string, unknown>)
|
|
2248
|
+
dirtyBaselineSnapshotRef.current = baseline
|
|
2249
|
+
dirtyBaselineValuesRef.current = baselineSource
|
|
2250
|
+
nextDirty = createDirtySnapshot(nextData as Record<string, unknown>) !== baseline
|
|
2251
|
+
}
|
|
2218
2252
|
return nextData
|
|
2219
2253
|
})
|
|
2254
|
+
if (nextDirty !== null) {
|
|
2255
|
+
isDirtyRef.current = nextDirty
|
|
2256
|
+
setHasUnsavedChanges(nextDirty)
|
|
2257
|
+
}
|
|
2220
2258
|
const clearedMessages: string[] = []
|
|
2221
2259
|
setErrors((prev) => {
|
|
2222
2260
|
if (!Object.keys(prev).length) return prev
|
|
@@ -2281,7 +2319,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2281
2319
|
}).catch((err) => {
|
|
2282
2320
|
console.error('[CrudForm] Error in onFieldChange:', err)
|
|
2283
2321
|
})
|
|
2284
|
-
}, [extendedInjectionEventsEnabled, flash, t, translateValidationMessage, triggerInjectionEvent])
|
|
2322
|
+
}, [embedded, extendedInjectionEventsEnabled, flash, t, trackDirtyWhenEmbedded, translateValidationMessage, triggerInjectionEvent, updateEditedFieldMarker])
|
|
2285
2323
|
|
|
2286
2324
|
const onBlurRequest = React.useCallback((fieldId: string) => {
|
|
2287
2325
|
void validateFieldOnBlur(fieldId)
|
|
@@ -2309,6 +2347,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2309
2347
|
|
|
2310
2348
|
const appliedInitialValuesSnapshotRef = React.useRef<string | undefined>(undefined)
|
|
2311
2349
|
const dirtyBaselineSnapshotRef = React.useRef<string | undefined>(undefined)
|
|
2350
|
+
const dirtyBaselineValuesRef = React.useRef<Record<string, unknown> | undefined>(undefined)
|
|
2351
|
+
const userEditedFieldIdsRef = React.useRef<Set<string>>(new Set())
|
|
2312
2352
|
React.useLayoutEffect(() => {
|
|
2313
2353
|
if (!initialValues) return
|
|
2314
2354
|
const snapshot = JSON.stringify({
|
|
@@ -2330,10 +2370,17 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2330
2370
|
let hadUnsavedEdits = false
|
|
2331
2371
|
setValues((prev) => {
|
|
2332
2372
|
const priorBaseline = dirtyBaselineSnapshotRef.current
|
|
2373
|
+
const editedFieldIds = userEditedFieldIdsRef.current
|
|
2333
2374
|
hadUnsavedEdits =
|
|
2375
|
+
editedFieldIds.size > 0 ||
|
|
2334
2376
|
priorBaseline !== undefined &&
|
|
2335
2377
|
createDirtySnapshot(prev as Record<string, unknown>) !== priorBaseline
|
|
2336
|
-
const merged = { ...prev
|
|
2378
|
+
const merged = { ...prev } as CrudFormValues<TValues>
|
|
2379
|
+
const mergedRecord = merged as Record<string, unknown>
|
|
2380
|
+
for (const [key, value] of Object.entries(initialValues as Record<string, unknown>)) {
|
|
2381
|
+
if (editedFieldIds.has(key)) continue
|
|
2382
|
+
mergedRecord[key] = value
|
|
2383
|
+
}
|
|
2337
2384
|
for (const definition of injectedFieldDefinitions) {
|
|
2338
2385
|
if (merged[definition.id] !== undefined) continue
|
|
2339
2386
|
const extracted = readByDotPath(initialRecord, definition.id)
|
|
@@ -2371,6 +2418,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2371
2418
|
// baseline so the edit stays dirty until save/discard. Root cause of the flaky
|
|
2372
2419
|
// optimistic-lock stale-edit conflicts (#2055 / TC-LOCK-OSS-015 / TC-LOCK-OSS-029).
|
|
2373
2420
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2421
|
+
dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
|
|
2374
2422
|
}
|
|
2375
2423
|
if (!extendedInjectionEventsEnabled || !mergedValues) return
|
|
2376
2424
|
let cancelled = false
|
|
@@ -2387,6 +2435,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2387
2435
|
// edit — it would overwrite the user's unsaved changes and reset dirty.
|
|
2388
2436
|
if (isDirtyRef.current) return
|
|
2389
2437
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(transformed as Record<string, unknown>)
|
|
2438
|
+
dirtyBaselineValuesRef.current = { ...(transformed as Record<string, unknown>) }
|
|
2390
2439
|
setValues(transformed as CrudFormValues<TValues>)
|
|
2391
2440
|
} catch (err) {
|
|
2392
2441
|
console.error('[CrudForm] Error in transformDisplayData:', err)
|
|
@@ -2457,6 +2506,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2457
2506
|
// Update the dirty baseline so the form doesn't appear dirty from defaults
|
|
2458
2507
|
if (mergedValues) {
|
|
2459
2508
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2509
|
+
dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
|
|
2460
2510
|
}
|
|
2461
2511
|
}, [isLoading, initialValuesHasId, cfDefinitions, customEntity])
|
|
2462
2512
|
|
|
@@ -62,6 +62,126 @@ describe('CrudForm unsaved navigation guard', () => {
|
|
|
62
62
|
expect(event.returnValue).toBe('')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
it('marks the form dirty on the first edit even when no baseline has been initialized yet', async () => {
|
|
66
|
+
const onDirtyChange = jest.fn()
|
|
67
|
+
const { container } = renderWithProviders(
|
|
68
|
+
<CrudForm title="Form" fields={fields} onSubmit={() => {}} onDirtyChange={onDirtyChange} />,
|
|
69
|
+
{
|
|
70
|
+
dict: {
|
|
71
|
+
'ui.forms.actions.save': 'Save',
|
|
72
|
+
'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const input = container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
|
|
78
|
+
await act(async () => {
|
|
79
|
+
fireEvent.change(input, { target: { value: 'Alice updated' } })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
83
|
+
|
|
84
|
+
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
|
|
85
|
+
Object.defineProperty(event, 'returnValue', { writable: true, value: undefined })
|
|
86
|
+
window.dispatchEvent(event)
|
|
87
|
+
|
|
88
|
+
expect(event.defaultPrevented).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('keeps a first edit dirty when late initialValues refresh after the user types', async () => {
|
|
92
|
+
const onDirtyChange = jest.fn()
|
|
93
|
+
const view = renderWithProviders(
|
|
94
|
+
<CrudForm
|
|
95
|
+
title="Form"
|
|
96
|
+
fields={fields}
|
|
97
|
+
initialValues={{ name: 'Alice' }}
|
|
98
|
+
embedded
|
|
99
|
+
trackDirtyWhenEmbedded
|
|
100
|
+
onDirtyChange={onDirtyChange}
|
|
101
|
+
onSubmit={() => {}}
|
|
102
|
+
/>,
|
|
103
|
+
{
|
|
104
|
+
dict: {
|
|
105
|
+
'ui.forms.actions.save': 'Save',
|
|
106
|
+
'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const input = view.container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
|
|
112
|
+
await act(async () => {
|
|
113
|
+
fireEvent.change(input, { target: { value: 'Alice updated' } })
|
|
114
|
+
})
|
|
115
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
view.rerender(
|
|
119
|
+
<CrudForm
|
|
120
|
+
title="Form"
|
|
121
|
+
fields={fields}
|
|
122
|
+
initialValues={{ name: 'Alice from server' }}
|
|
123
|
+
embedded
|
|
124
|
+
trackDirtyWhenEmbedded
|
|
125
|
+
onDirtyChange={onDirtyChange}
|
|
126
|
+
onSubmit={() => {}}
|
|
127
|
+
/>,
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(input).toHaveValue('Alice updated')
|
|
132
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('accepts late initialValues refresh after the edited field is reverted to baseline', async () => {
|
|
136
|
+
const onDirtyChange = jest.fn()
|
|
137
|
+
const view = renderWithProviders(
|
|
138
|
+
<CrudForm
|
|
139
|
+
title="Form"
|
|
140
|
+
fields={fields}
|
|
141
|
+
initialValues={{ name: 'Alice' }}
|
|
142
|
+
embedded
|
|
143
|
+
trackDirtyWhenEmbedded
|
|
144
|
+
onDirtyChange={onDirtyChange}
|
|
145
|
+
onSubmit={() => {}}
|
|
146
|
+
/>,
|
|
147
|
+
{
|
|
148
|
+
dict: {
|
|
149
|
+
'ui.forms.actions.save': 'Save',
|
|
150
|
+
'ui.forms.confirmUnsavedChanges': 'Unsaved changes',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const input = view.container.querySelector('[data-crud-field-id="name"] input[type="text"]') as HTMLInputElement
|
|
156
|
+
await act(async () => {
|
|
157
|
+
fireEvent.change(input, { target: { value: 'Alice updated' } })
|
|
158
|
+
})
|
|
159
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
|
|
160
|
+
|
|
161
|
+
await act(async () => {
|
|
162
|
+
fireEvent.change(input, { target: { value: 'Alice' } })
|
|
163
|
+
fireEvent.blur(input)
|
|
164
|
+
})
|
|
165
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
view.rerender(
|
|
169
|
+
<CrudForm
|
|
170
|
+
title="Form"
|
|
171
|
+
fields={fields}
|
|
172
|
+
initialValues={{ name: 'Alice from server' }}
|
|
173
|
+
embedded
|
|
174
|
+
trackDirtyWhenEmbedded
|
|
175
|
+
onDirtyChange={onDirtyChange}
|
|
176
|
+
onSubmit={() => {}}
|
|
177
|
+
/>,
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
expect(input).toHaveValue('Alice from server')
|
|
182
|
+
expect(onDirtyChange).toHaveBeenLastCalledWith(false)
|
|
183
|
+
})
|
|
184
|
+
|
|
65
185
|
it('blocks router-driven history pushes until the user confirms leaving', async () => {
|
|
66
186
|
confirmDialogMock.mockResolvedValueOnce(false)
|
|
67
187
|
|