@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.4695.1.42ee0ddf0e",
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.4695.1.42ee0ddf0e",
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.4695.1.42ee0ddf0e",
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",
@@ -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, ...initialValues } as CrudFormValues<TValues>
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