@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.4703.1.59a086a9ee",
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.4703.1.59a086a9ee",
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.4703.1.59a086a9ee",
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,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, ...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
+ }
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