@open-mercato/ui 0.6.5-develop.4703.1.59a086a9ee → 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,16 +2215,38 @@ 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
|
|
2214
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
|
+
}
|
|
2215
2240
|
setValues((prev) => {
|
|
2216
2241
|
if (Object.is(prev[id], nextValue)) return prev
|
|
2242
|
+
const baselineSource = dirtyBaselineValuesRef.current ?? (prev as Record<string, unknown>)
|
|
2243
|
+
updateEditedFieldMarker(id, nextValue, baselineSource)
|
|
2217
2244
|
nextData = { ...prev, [id]: nextValue } as CrudFormValues<TValues>
|
|
2218
2245
|
valuesRef.current = nextData
|
|
2219
2246
|
if (!(embedded && !trackDirtyWhenEmbedded)) {
|
|
2220
2247
|
const baseline = dirtyBaselineSnapshotRef.current ?? createDirtySnapshot(prev as Record<string, unknown>)
|
|
2221
2248
|
dirtyBaselineSnapshotRef.current = baseline
|
|
2249
|
+
dirtyBaselineValuesRef.current = baselineSource
|
|
2222
2250
|
nextDirty = createDirtySnapshot(nextData as Record<string, unknown>) !== baseline
|
|
2223
2251
|
}
|
|
2224
2252
|
return nextData
|
|
@@ -2291,7 +2319,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2291
2319
|
}).catch((err) => {
|
|
2292
2320
|
console.error('[CrudForm] Error in onFieldChange:', err)
|
|
2293
2321
|
})
|
|
2294
|
-
}, [embedded, extendedInjectionEventsEnabled, flash, t, trackDirtyWhenEmbedded, translateValidationMessage, triggerInjectionEvent])
|
|
2322
|
+
}, [embedded, extendedInjectionEventsEnabled, flash, t, trackDirtyWhenEmbedded, translateValidationMessage, triggerInjectionEvent, updateEditedFieldMarker])
|
|
2295
2323
|
|
|
2296
2324
|
const onBlurRequest = React.useCallback((fieldId: string) => {
|
|
2297
2325
|
void validateFieldOnBlur(fieldId)
|
|
@@ -2319,6 +2347,8 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2319
2347
|
|
|
2320
2348
|
const appliedInitialValuesSnapshotRef = React.useRef<string | undefined>(undefined)
|
|
2321
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())
|
|
2322
2352
|
React.useLayoutEffect(() => {
|
|
2323
2353
|
if (!initialValues) return
|
|
2324
2354
|
const snapshot = JSON.stringify({
|
|
@@ -2340,10 +2370,17 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2340
2370
|
let hadUnsavedEdits = false
|
|
2341
2371
|
setValues((prev) => {
|
|
2342
2372
|
const priorBaseline = dirtyBaselineSnapshotRef.current
|
|
2373
|
+
const editedFieldIds = userEditedFieldIdsRef.current
|
|
2343
2374
|
hadUnsavedEdits =
|
|
2375
|
+
editedFieldIds.size > 0 ||
|
|
2344
2376
|
priorBaseline !== undefined &&
|
|
2345
2377
|
createDirtySnapshot(prev as Record<string, unknown>) !== priorBaseline
|
|
2346
|
-
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
|
+
}
|
|
2347
2384
|
for (const definition of injectedFieldDefinitions) {
|
|
2348
2385
|
if (merged[definition.id] !== undefined) continue
|
|
2349
2386
|
const extracted = readByDotPath(initialRecord, definition.id)
|
|
@@ -2381,6 +2418,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2381
2418
|
// baseline so the edit stays dirty until save/discard. Root cause of the flaky
|
|
2382
2419
|
// optimistic-lock stale-edit conflicts (#2055 / TC-LOCK-OSS-015 / TC-LOCK-OSS-029).
|
|
2383
2420
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2421
|
+
dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
|
|
2384
2422
|
}
|
|
2385
2423
|
if (!extendedInjectionEventsEnabled || !mergedValues) return
|
|
2386
2424
|
let cancelled = false
|
|
@@ -2397,6 +2435,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2397
2435
|
// edit — it would overwrite the user's unsaved changes and reset dirty.
|
|
2398
2436
|
if (isDirtyRef.current) return
|
|
2399
2437
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(transformed as Record<string, unknown>)
|
|
2438
|
+
dirtyBaselineValuesRef.current = { ...(transformed as Record<string, unknown>) }
|
|
2400
2439
|
setValues(transformed as CrudFormValues<TValues>)
|
|
2401
2440
|
} catch (err) {
|
|
2402
2441
|
console.error('[CrudForm] Error in transformDisplayData:', err)
|
|
@@ -2467,6 +2506,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2467
2506
|
// Update the dirty baseline so the form doesn't appear dirty from defaults
|
|
2468
2507
|
if (mergedValues) {
|
|
2469
2508
|
dirtyBaselineSnapshotRef.current = createDirtySnapshot(mergedValues as Record<string, unknown>)
|
|
2509
|
+
dirtyBaselineValuesRef.current = { ...(mergedValues as Record<string, unknown>) }
|
|
2470
2510
|
}
|
|
2471
2511
|
}, [isLoading, initialValuesHasId, cfDefinitions, customEntity])
|
|
2472
2512
|
|
|
@@ -88,6 +88,100 @@ describe('CrudForm unsaved navigation guard', () => {
|
|
|
88
88
|
expect(event.defaultPrevented).toBe(true)
|
|
89
89
|
})
|
|
90
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
|
+
|
|
91
185
|
it('blocks router-driven history pushes until the user confirms leaving', async () => {
|
|
92
186
|
confirmDialogMock.mockResolvedValueOnce(false)
|
|
93
187
|
|