@open-mercato/ui 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

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.
Files changed (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -8
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +92 -93
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiIcon.js +17 -0
  8. package/dist/ai/AiIcon.js.map +7 -0
  9. package/dist/ai/LoopDisabledBanner.js +28 -0
  10. package/dist/ai/LoopDisabledBanner.js.map +7 -0
  11. package/dist/ai/LoopTracePanel.js +123 -0
  12. package/dist/ai/LoopTracePanel.js.map +7 -0
  13. package/dist/ai/ModelPicker.js.map +1 -1
  14. package/dist/ai/index.js +8 -0
  15. package/dist/ai/index.js.map +2 -2
  16. package/dist/ai/parts/ConfirmationCard.js +1 -5
  17. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  18. package/dist/ai/parts/FieldDiffCard.js +8 -15
  19. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  20. package/dist/ai/parts/MutationResultCard.js +1 -4
  21. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  22. package/dist/ai/ui-parts/pending-phase3-placeholder.js +0 -2
  23. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  24. package/dist/ai/useAiChat.js +18 -4
  25. package/dist/ai/useAiChat.js.map +2 -2
  26. package/dist/backend/CrudForm.js +24 -2
  27. package/dist/backend/CrudForm.js.map +2 -2
  28. package/dist/backend/DataTable.js +2 -2
  29. package/dist/backend/DataTable.js.map +2 -2
  30. package/dist/backend/confirm-dialog/ConfirmDialog.js +102 -96
  31. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  32. package/dist/backend/detail/CustomDataSection.js +22 -3
  33. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  34. package/dist/backend/filters/AdvancedFilterBuilder.js +2 -2
  35. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  36. package/dist/backend/injection/ComponentOverrideProvider.js +1 -1
  37. package/dist/backend/injection/ComponentOverrideProvider.js.map +2 -2
  38. package/dist/backend/inputs/ComboboxInput.js +25 -7
  39. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  40. package/dist/backend/progress/useProgressPoll.js +58 -3
  41. package/dist/backend/progress/useProgressPoll.js.map +2 -2
  42. package/dist/backend/progress/useProgressSse.js +15 -2
  43. package/dist/backend/progress/useProgressSse.js.map +2 -2
  44. package/dist/backend/utils/bulkDelete.js +76 -0
  45. package/dist/backend/utils/bulkDelete.js.map +2 -2
  46. package/package.json +11 -6
  47. package/src/ai/AiAssistantLauncher.tsx +8 -8
  48. package/src/ai/AiChat.tsx +44 -38
  49. package/src/ai/AiIcon.tsx +12 -0
  50. package/src/ai/LoopDisabledBanner.tsx +38 -0
  51. package/src/ai/LoopTracePanel.tsx +184 -0
  52. package/src/ai/ModelPicker.tsx +1 -1
  53. package/src/ai/__tests__/AiChat.test.tsx +174 -5
  54. package/src/ai/index.ts +8 -0
  55. package/src/ai/parts/ConfirmationCard.tsx +1 -5
  56. package/src/ai/parts/FieldDiffCard.tsx +0 -3
  57. package/src/ai/parts/MutationResultCard.tsx +1 -4
  58. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +5 -0
  59. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +0 -2
  60. package/src/ai/useAiChat.ts +35 -5
  61. package/src/backend/CrudForm.tsx +30 -3
  62. package/src/backend/DataTable.tsx +2 -2
  63. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +72 -0
  64. package/src/backend/__tests__/CustomDataSection.test.tsx +76 -0
  65. package/src/backend/__tests__/DataTable.render.test.tsx +36 -0
  66. package/src/backend/__tests__/component-replacement.test.tsx +44 -0
  67. package/src/backend/confirm-dialog/ConfirmDialog.tsx +101 -91
  68. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +19 -0
  69. package/src/backend/detail/CustomDataSection.tsx +26 -3
  70. package/src/backend/filters/AdvancedFilterBuilder.tsx +6 -6
  71. package/src/backend/injection/ComponentOverrideProvider.tsx +2 -1
  72. package/src/backend/inputs/ComboboxInput.tsx +29 -7
  73. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +33 -2
  74. package/src/backend/progress/useProgressPoll.ts +68 -3
  75. package/src/backend/progress/useProgressSse.ts +16 -2
  76. package/src/backend/utils/__tests__/bulkDelete.test.ts +42 -0
  77. package/src/backend/utils/bulkDelete.ts +93 -0
@@ -233,6 +233,82 @@ describe('CustomDataSection relation display', () => {
233
233
  expect(notesContent.className).toContain('text-sm')
234
234
  })
235
235
 
236
+ it('renders select values from bare custom-field response keys', async () => {
237
+ const fields: CrudField[] = [
238
+ {
239
+ id: 'cf_buying_role',
240
+ label: 'Buying role',
241
+ type: 'select',
242
+ options: [
243
+ { value: 'economic_buyer', label: 'Economic buyer' },
244
+ { value: 'champion', label: 'Champion' },
245
+ { value: 'technical_evaluator', label: 'Technical evaluator' },
246
+ { value: 'influencer', label: 'Influencer' },
247
+ ],
248
+ },
249
+ {
250
+ id: 'cf_relationship_health',
251
+ label: 'Relationship health',
252
+ type: 'select',
253
+ options: [
254
+ { value: 'healthy', label: 'Healthy' },
255
+ { value: 'monitor', label: 'Monitor' },
256
+ { value: 'at_risk', label: 'At risk' },
257
+ ],
258
+ },
259
+ {
260
+ id: 'cf_renewal_quarter',
261
+ label: 'Renewal quarter',
262
+ type: 'select',
263
+ options: [
264
+ { value: 'Q1', label: 'Q1' },
265
+ { value: 'Q2', label: 'Q2' },
266
+ { value: 'Q3', label: 'Q3' },
267
+ { value: 'Q4', label: 'Q4' },
268
+ ],
269
+ },
270
+ ]
271
+ const definitions: CustomFieldDefDto[] = [
272
+ {
273
+ key: 'buying_role',
274
+ kind: 'select',
275
+ label: 'Buying role',
276
+ },
277
+ {
278
+ key: 'relationship_health',
279
+ kind: 'select',
280
+ label: 'Relationship health',
281
+ },
282
+ {
283
+ key: 'renewal_quarter',
284
+ kind: 'select',
285
+ label: 'Renewal quarter',
286
+ },
287
+ ]
288
+
289
+ renderWithProviders(
290
+ <CustomDataSection
291
+ entityIds={['customers:customer_person_profile', 'customers:customer_company_profile']}
292
+ values={{ buying_role: 'champion', relationship_health: 'monitor', renewal_quarter: 'Q4' }}
293
+ onSubmit={async () => {}}
294
+ title="Custom fields"
295
+ loadFields={async () => ({ fields, definitions })}
296
+ labels={{
297
+ loading: 'Loading custom data\u2026',
298
+ emptyValue: 'No value',
299
+ noFields: 'No fields',
300
+ saveShortcut: 'Save now',
301
+ edit: 'Edit',
302
+ cancel: 'Cancel',
303
+ }}
304
+ />,
305
+ )
306
+
307
+ expect(await screen.findByText('Champion')).toBeInTheDocument()
308
+ expect(await screen.findByText('Monitor')).toBeInTheDocument()
309
+ expect(screen.getByText('Q4')).toBeInTheDocument()
310
+ })
311
+
236
312
  it('resolves customer_person_profile with entity_id route context', async () => {
237
313
  const entityId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
238
314
  const profileId = 'pppppppp-1111-2222-3333-444444444444'
@@ -45,4 +45,40 @@ describe('DataTable SSR render', () => {
45
45
  queryClient.clear()
46
46
  }
47
47
  })
48
+
49
+ it('keeps rows-per-page controls on one line', () => {
50
+ const columns: ColumnDef<Row>[] = [
51
+ { accessorKey: 'name', header: 'Name' },
52
+ ]
53
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
54
+ try {
55
+ const html = renderToString(
56
+ React.createElement(
57
+ QueryClientProvider as any,
58
+ { client: queryClient },
59
+ React.createElement(
60
+ I18nProvider as any,
61
+ { locale: 'en', dict: {} },
62
+ React.createElement(DataTable as any, {
63
+ columns,
64
+ data: [{ id: '1', name: 'Ada' }],
65
+ pagination: {
66
+ page: 1,
67
+ pageSize: 20,
68
+ total: 1,
69
+ totalPages: 1,
70
+ onPageChange: () => {},
71
+ onPageSizeChange: () => {},
72
+ pageSizeOptions: [10, 20, 50],
73
+ },
74
+ }),
75
+ ),
76
+ )
77
+ )
78
+ expect(html).toContain('whitespace-nowrap')
79
+ expect(html).toContain('per page')
80
+ } finally {
81
+ queryClient.clear()
82
+ }
83
+ })
48
84
  })
@@ -1,12 +1,14 @@
1
1
  /** @jest-environment jsdom */
2
2
  import * as React from 'react'
3
3
  import { render, screen } from '@testing-library/react'
4
+ import { renderToString } from 'react-dom/server'
4
5
  import { z } from 'zod'
5
6
  import {
6
7
  registerComponent,
7
8
  registerComponentOverrides,
8
9
  ComponentReplacementHandles,
9
10
  } from '@open-mercato/shared/modules/widgets/component-registry'
11
+ import { ComponentOverrideProvider } from '../injection/ComponentOverrideProvider'
10
12
  import { useRegisteredComponent } from '../injection/useRegisteredComponent'
11
13
  import { DetailFieldsSection } from '../detail/DetailFieldsSection'
12
14
 
@@ -65,6 +67,48 @@ describe('component replacement', () => {
65
67
  expect(screen.getByText('wrapped rendered')).toBeInTheDocument()
66
68
  })
67
69
 
70
+ it('applies provider overrides during the first render for hydration parity', () => {
71
+ const componentId = 'test.provider.first.render'
72
+ const Base = ({ value }: { value: string }) => <span>{value}</span>
73
+
74
+ registerComponent({
75
+ id: componentId,
76
+ component: Base,
77
+ metadata: { module: 'test' },
78
+ })
79
+
80
+ function Consumer() {
81
+ const Resolved = useRegisteredComponent<{ value: string }>(componentId)
82
+ return <Resolved value="first render" />
83
+ }
84
+
85
+ const html = renderToString(
86
+ <ComponentOverrideProvider
87
+ overrides={[
88
+ {
89
+ target: { componentId },
90
+ priority: 10,
91
+ metadata: { module: 'test' },
92
+ wrapper: (Original) => {
93
+ const Wrapped = (props: { value: string }) => (
94
+ <div data-testid="provider-wrapped">
95
+ <Original {...props} />
96
+ </div>
97
+ )
98
+ Wrapped.displayName = 'ProviderWrapped'
99
+ return Wrapped
100
+ },
101
+ },
102
+ ]}
103
+ >
104
+ <Consumer />
105
+ </ComponentOverrideProvider>,
106
+ )
107
+
108
+ expect(html).toContain('data-testid="provider-wrapped"')
109
+ expect(html).toContain('first render')
110
+ })
111
+
68
112
  it('ignores invalid override entries when resolving registered components', () => {
69
113
  const componentId = 'test.invalid.override'
70
114
  const Base = ({ value }: { value: string }) => <span>{value}</span>
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
+ import { createPortal } from "react-dom";
3
4
  import { useT } from "@open-mercato/shared/lib/i18n/context";
4
5
  import { Button } from "@open-mercato/ui/primitives/button";
5
6
  import { IconButton } from "../../primitives/icon-button";
@@ -53,6 +54,7 @@ export function ConfirmDialog({
53
54
  const [internalOpen, setInternalOpen] = React.useState(false);
54
55
  const cancelButtonRef = React.useRef<HTMLButtonElement>(null);
55
56
  const confirmButtonRef = React.useRef<HTMLButtonElement>(null);
57
+ const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
56
58
  // Unique IDs so multiple ConfirmDialog instances on the same page don't
57
59
  // collide and make `aria-labelledby` resolve to the wrong dialog's title.
58
60
  const reactId = React.useId();
@@ -88,8 +90,12 @@ export function ConfirmDialog({
88
90
  handleCancelCallback();
89
91
  }, [setOpen, handleCancelCallback]);
90
92
 
93
+ React.useLayoutEffect(() => {
94
+ setPortalTarget(document.body);
95
+ }, []);
96
+
91
97
  // Handle dialog open/close with native showModal/close
92
- React.useEffect(() => {
98
+ React.useLayoutEffect(() => {
93
99
  const dialog = dialogRef.current;
94
100
  if (!dialog) return;
95
101
 
@@ -110,7 +116,7 @@ export function ConfirmDialog({
110
116
  dialog.close();
111
117
  }
112
118
  }
113
- }, [open, resolvedCancelText]);
119
+ }, [open, resolvedCancelText, portalTarget]);
114
120
 
115
121
  // Handle native cancel event (Escape key)
116
122
  React.useEffect(() => {
@@ -172,109 +178,113 @@ export function ConfirmDialog({
172
178
  }
173
179
  };
174
180
 
175
- return (
176
- <>
177
- {trigger && (
178
- <div onClick={handleTriggerClick} className="inline-block" role="presentation">
179
- {trigger}
180
- </div>
181
+ const dialogElement = (
182
+ <dialog
183
+ ref={dialogRef}
184
+ role="alertdialog"
185
+ aria-labelledby={titleId}
186
+ aria-describedby={text ? descriptionId : undefined}
187
+ onClick={handleBackdropClick}
188
+ className={cn(
189
+ // Reset dialog defaults
190
+ "m-0 p-0 max-w-none bg-transparent border-none pointer-events-auto",
191
+ // Backdrop styling
192
+ "backdrop:bg-black/50 backdrop:backdrop-blur-sm backdrop:transition-opacity",
193
+ // Mobile: bottom sheet
194
+ "fixed inset-x-0 bottom-0 top-auto w-full",
195
+ // Desktop: centered
196
+ "sm:inset-auto sm:mx-auto sm:my-auto sm:max-w-md sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2",
197
+ // Animation with reduced motion support
198
+ "motion-safe:open:animate-in motion-safe:open:fade-in-0 motion-safe:open:slide-in-from-bottom-4",
199
+ "sm:motion-safe:open:slide-in-from-bottom-0 sm:motion-safe:open:zoom-in-95",
200
+ // Duration
201
+ "motion-safe:open:duration-300"
181
202
  )}
182
-
183
- <dialog
184
- ref={dialogRef}
185
- role="alertdialog"
186
- aria-labelledby={titleId}
187
- aria-describedby={text ? descriptionId : undefined}
188
- onClick={handleBackdropClick}
203
+ >
204
+ <div
205
+ role="document"
189
206
  className={cn(
190
- // Reset dialog defaults
191
- "m-0 p-0 max-w-none bg-transparent border-none pointer-events-auto",
192
- // Backdrop styling
193
- "backdrop:bg-black/50 backdrop:backdrop-blur-sm backdrop:transition-opacity",
194
- // Mobile: bottom sheet
195
- "fixed inset-x-0 bottom-0 top-auto w-full",
196
- // Desktop: centered
197
- "sm:inset-auto sm:mx-auto sm:my-auto sm:max-w-md sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2",
198
- // Animation with reduced motion support
199
- "motion-safe:open:animate-in motion-safe:open:fade-in-0 motion-safe:open:slide-in-from-bottom-4",
200
- "sm:motion-safe:open:slide-in-from-bottom-0 sm:motion-safe:open:zoom-in-95",
201
- // Duration
202
- "motion-safe:open:duration-300"
207
+ // Panel container
208
+ "flex flex-col gap-4 rounded-t-2xl border-t bg-card p-6 text-foreground shadow-lg",
209
+ "sm:rounded-xl sm:border",
210
+ // Relative positioning for close button
211
+ "relative"
203
212
  )}
204
213
  >
205
- <div
206
- role="document"
214
+ {/* Close button */}
215
+ <IconButton
216
+ type="button"
217
+ variant="ghost"
218
+ size="sm"
219
+ onClick={handleCancel}
220
+ disabled={loading}
221
+ aria-label={closeAriaLabel}
222
+ className="absolute right-4 top-4 opacity-70 hover:opacity-100"
223
+ >
224
+ <X className="h-4 w-4" />
225
+ </IconButton>
226
+
227
+ {/* Title */}
228
+ <h2
229
+ id={titleId}
207
230
  className={cn(
208
- // Panel container
209
- "flex flex-col gap-4 rounded-t-2xl border-t bg-card p-6 text-foreground shadow-lg",
210
- "sm:rounded-xl sm:border",
211
- // Relative positioning for close button
212
- "relative"
231
+ "text-sm font-medium leading-snug tracking-tight pr-6",
232
+ // Mobile: centered, Desktop: left-aligned
233
+ "text-center sm:text-left"
213
234
  )}
214
235
  >
215
- {/* Close button */}
216
- <IconButton
217
- type="button"
218
- variant="ghost"
219
- size="sm"
220
- onClick={handleCancel}
221
- disabled={loading}
222
- aria-label={closeAriaLabel}
223
- className="absolute right-4 top-4 opacity-70 hover:opacity-100"
224
- >
225
- <X className="h-4 w-4" />
226
- </IconButton>
236
+ {resolvedTitle}
237
+ </h2>
227
238
 
228
- {/* Title */}
229
- <h2
230
- id={titleId}
231
- className={cn(
232
- "text-sm font-medium leading-snug tracking-tight pr-6",
233
- // Mobile: centered, Desktop: left-aligned
234
- "text-center sm:text-left"
235
- )}
239
+ {/* Description (optional) */}
240
+ {text && (
241
+ <p
242
+ id={descriptionId}
243
+ className="text-sm font-medium leading-snug text-muted-foreground"
236
244
  >
237
- {resolvedTitle}
238
- </h2>
245
+ {text}
246
+ </p>
247
+ )}
239
248
 
240
- {/* Description (optional) */}
241
- {text && (
242
- <p
243
- id={descriptionId}
244
- className="text-sm font-medium leading-snug text-muted-foreground"
249
+ {/* Actions */}
250
+ <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
251
+ {resolvedCancelText !== false && (
252
+ <Button
253
+ ref={cancelButtonRef}
254
+ variant="outline"
255
+ onClick={handleCancel}
256
+ disabled={loading}
257
+ className="w-full sm:w-auto"
258
+ >
259
+ {resolvedCancelText}
260
+ </Button>
261
+ )}
262
+ {resolvedConfirmText !== false && (
263
+ <Button
264
+ ref={confirmButtonRef}
265
+ variant={variant === "destructive" ? "destructive" : "default"}
266
+ onClick={handleConfirm}
267
+ disabled={loading}
268
+ className="w-full sm:w-auto"
245
269
  >
246
- {text}
247
- </p>
270
+ {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
271
+ {resolvedConfirmText}
272
+ </Button>
248
273
  )}
274
+ </div>
275
+ </div>
276
+ </dialog>
277
+ );
249
278
 
250
- {/* Actions */}
251
- <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
252
- {resolvedCancelText !== false && (
253
- <Button
254
- ref={cancelButtonRef}
255
- variant="outline"
256
- onClick={handleCancel}
257
- disabled={loading}
258
- className="w-full sm:w-auto"
259
- >
260
- {resolvedCancelText}
261
- </Button>
262
- )}
263
- {resolvedConfirmText !== false && (
264
- <Button
265
- ref={confirmButtonRef}
266
- variant={variant === "destructive" ? "destructive" : "default"}
267
- onClick={handleConfirm}
268
- disabled={loading}
269
- className="w-full sm:w-auto"
270
- >
271
- {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
272
- {resolvedConfirmText}
273
- </Button>
274
- )}
275
- </div>
279
+ return (
280
+ <>
281
+ {trigger && (
282
+ <div onClick={handleTriggerClick} className="inline-block" role="presentation">
283
+ {trigger}
276
284
  </div>
277
- </dialog>
285
+ )}
286
+
287
+ {portalTarget ? createPortal(dialogElement, portalTarget) : null}
278
288
  </>
279
289
  );
280
290
  }
@@ -41,4 +41,23 @@ describe('ConfirmDialog', () => {
41
41
 
42
42
  expect(screen.getByRole('alertdialog').className).toEqual(expect.stringContaining('pointer-events-auto'))
43
43
  })
44
+
45
+ it('portals the native dialog out of hidden ancestors', () => {
46
+ const { container } = renderWithProviders(
47
+ <div hidden>
48
+ <ConfirmDialog
49
+ open
50
+ onOpenChange={() => undefined}
51
+ onConfirm={() => undefined}
52
+ title="Discard unsaved changes?"
53
+ confirmText="Discard"
54
+ cancelText="Cancel"
55
+ />
56
+ </div>,
57
+ )
58
+
59
+ const dialog = screen.getByRole('alertdialog')
60
+ expect(container.contains(dialog)).toBe(false)
61
+ expect(dialog.parentElement).toBe(document.body)
62
+ })
44
63
  })
@@ -75,6 +75,20 @@ function extractDictionaryValue(entry: unknown): string | null {
75
75
  return null
76
76
  }
77
77
 
78
+ function readCustomDataFieldValue(values: Record<string, unknown> | undefined, fieldId: string): unknown {
79
+ if (!values || !fieldId) return undefined
80
+ const candidates = fieldId.startsWith('cf_')
81
+ ? [fieldId, `cf:${fieldId.slice(3)}`, fieldId.slice(3)]
82
+ : [fieldId, `cf_${fieldId}`, `cf:${fieldId}`]
83
+
84
+ for (const candidate of candidates) {
85
+ if (Object.prototype.hasOwnProperty.call(values, candidate)) {
86
+ return values[candidate]
87
+ }
88
+ }
89
+ return undefined
90
+ }
91
+
78
92
  export type CustomDataLabels = {
79
93
  loading: string
80
94
  emptyValue: string
@@ -312,6 +326,15 @@ function CustomDataSectionImpl({
312
326
  () => customFieldFormsQuery.data?.definitions ?? [],
313
327
  [customFieldFormsQuery.data],
314
328
  )
329
+ const formInitialValues = React.useMemo(() => {
330
+ const next: Record<string, unknown> = { ...(values ?? {}) }
331
+ fields.forEach((field) => {
332
+ if (!field.id || Object.prototype.hasOwnProperty.call(next, field.id)) return
333
+ const resolved = readCustomDataFieldValue(values, field.id)
334
+ if (resolved !== undefined) next[field.id] = resolved
335
+ })
336
+ return next
337
+ }, [fields, values])
315
338
  const [dictionaryLoading, setDictionaryLoading] = React.useState(false)
316
339
  const [relationLoading, setRelationLoading] = React.useState(false)
317
340
  const loading = customFieldFormsQuery.isLoading || dictionaryLoading || relationLoading
@@ -473,7 +496,7 @@ function CustomDataSectionImpl({
473
496
  const normalizedKey = field.id.startsWith('cf_') ? field.id.slice(3) : field.id
474
497
  const definition = definitionsByKey.get(normalizedKey.toLowerCase())
475
498
  if (!definition || definition.kind !== 'relation') return null
476
- const relationIds = collectRelationValueIds(values?.[field.id])
499
+ const relationIds = collectRelationValueIds(readCustomDataFieldValue(values, field.id))
477
500
  if (!relationIds.length) return null
478
501
  return { field, definition, relationIds }
479
502
  })
@@ -621,7 +644,7 @@ function CustomDataSectionImpl({
621
644
  entityId={primaryEntityId}
622
645
  entityIds={resolvedEntityIds}
623
646
  fields={fields}
624
- initialValues={values}
647
+ initialValues={formInitialValues}
625
648
  onSubmit={handleSubmit}
626
649
  submitLabel={labels.saveShortcut}
627
650
  isLoading={loading}
@@ -659,7 +682,7 @@ function CustomDataSectionImpl({
659
682
  <div className="text-sm break-words">
660
683
  {formatFieldValue(
661
684
  field,
662
- values?.[field.id],
685
+ readCustomDataFieldValue(values, field.id),
663
686
  labels.emptyValue,
664
687
  dictionaryMapsByField[field.id],
665
688
  markdownPlugins,
@@ -290,7 +290,7 @@ function GroupView({
290
290
  : 'space-y-3 rounded-lg border border-border bg-muted/30 p-3'
291
291
 
292
292
  // Toolbar-first layout (Kendo React Filter pattern): one [And]/[Or] toggle per group
293
- // is the only combinator UI. Mixing AND with OR requires `+ Add subgroup`. Matches
293
+ // is the only combinator UI. Mixing AND with OR requires `Add subgroup`. Matches
294
294
  // `CompositeFilterDescriptor` semantics (one logic per group).
295
295
  return (
296
296
  <div className={containerClass}>
@@ -841,8 +841,8 @@ function toneDotClass(tone: NonNullable<FilterFieldDef['options']>[number]['tone
841
841
  }
842
842
  }
843
843
 
844
- // Single Kendo-style toolbar per group: combinator toggle, `+ Add condition`,
845
- // `+ Add subgroup` / `+ Add group`, plus a trailing delete for non-root groups.
844
+ // Single Kendo-style toolbar per group: combinator toggle, `Add condition`,
845
+ // `Add subgroup` / `Add group`, plus a trailing delete for non-root groups.
846
846
  function GroupToolbar({
847
847
  group, level, tree, dispatch, defaultField, fields, isRoot, t,
848
848
  }: {
@@ -886,8 +886,8 @@ function GroupToolbar({
886
886
  : undefined
887
887
 
888
888
  const addGroupLabel = level === 1
889
- ? t('ui.advancedFilter.addGroup', '+ Add group')
890
- : t('ui.advancedFilter.addSubgroup', '+ Add subgroup')
889
+ ? t('ui.advancedFilter.addGroup', 'Add group')
890
+ : t('ui.advancedFilter.addSubgroup', 'Add subgroup')
891
891
 
892
892
  // `text-brand-violet` overrides Button's default disabled token, so we have to
893
893
  // re-state muted disabled colors here — otherwise disabled CTAs still look clickable.
@@ -912,7 +912,7 @@ function GroupToolbar({
912
912
  className={ctaClass}
913
913
  >
914
914
  <Plus className="size-4" />
915
- {t('ui.advancedFilter.addCondition', '+ Add condition')}
915
+ {t('ui.advancedFilter.addCondition', 'Add condition')}
916
916
  </Button>
917
917
  <FilterFieldPicker
918
918
  fields={fields}
@@ -23,8 +23,9 @@ export function ComponentOverrideProvider({
23
23
  }) {
24
24
  const [userFeatures, setUserFeatures] = React.useState<readonly string[]>([])
25
25
 
26
+ registerComponentOverrides(overrides)
27
+
26
28
  React.useEffect(() => {
27
- registerComponentOverrides(overrides)
28
29
  return () => {
29
30
  registerComponentOverrides([])
30
31
  }
@@ -50,6 +50,16 @@ function normalizeOptions(input?: Array<string | ComboboxOption>): ComboboxOptio
50
50
  .filter((option): option is ComboboxOption => !!option)
51
51
  }
52
52
 
53
+ function areOptionsEqual(a: ComboboxOption[], b: ComboboxOption[]): boolean {
54
+ if (a.length !== b.length) return false
55
+ return a.every((option, index) => {
56
+ const next = b[index]
57
+ return option.value === next.value
58
+ && option.label === next.label
59
+ && (option.description ?? null) === (next.description ?? null)
60
+ })
61
+ }
62
+
53
63
  export function ComboboxInput({
54
64
  value,
55
65
  onChange,
@@ -75,6 +85,7 @@ export function ComboboxInput({
75
85
  const [selectedIndex, setSelectedIndex] = React.useState(-1)
76
86
  const inputRef = React.useRef<HTMLInputElement>(null)
77
87
  const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))
88
+ const eagerFallbackLoadedValueRef = React.useRef<string | null>(null)
78
89
 
79
90
  const staticOptions = React.useMemo(
80
91
  () => normalizeOptions([...(seedOptions ?? []), ...(suggestions ?? [])]),
@@ -91,6 +102,14 @@ export function ComboboxInput({
91
102
  return set
92
103
  }, [staticOptions, asyncOptions, resolvedOptions])
93
104
 
105
+ const coveredOptionValues = React.useMemo(() => {
106
+ const set = new Set<string>()
107
+ for (const option of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
108
+ set.add(option.value)
109
+ }
110
+ return set
111
+ }, [staticOptions, asyncOptions, resolvedOptions])
112
+
94
113
  const optionMap = React.useMemo(() => {
95
114
  const map = new Map<string, ComboboxOption>()
96
115
  const register = (option: ComboboxOption) => {
@@ -106,16 +125,15 @@ export function ComboboxInput({
106
125
  if (value) {
107
126
  const existing = map.get(value)
108
127
  if (!existing) {
109
- const sync = typeof resolveLabel === 'function' ? resolveLabel(value) : undefined
110
128
  map.set(value, {
111
129
  value,
112
- label: typeof sync === 'string' && sync ? sync : value,
130
+ label: value,
113
131
  description: resolveDescription?.(value) ?? null,
114
132
  })
115
133
  }
116
134
  }
117
135
  return map
118
- }, [asyncOptions, resolvedOptions, resolveDescription, resolveLabel, staticOptions, value])
136
+ }, [asyncOptions, resolvedOptions, resolveDescription, staticOptions, value])
119
137
 
120
138
  const availableOptions = React.useMemo(() => {
121
139
  return Array.from(optionMap.values())
@@ -140,7 +158,8 @@ export function ComboboxInput({
140
158
  try {
141
159
  const items = await loadSuggestions(query)
142
160
  if (!cancelled) {
143
- setAsyncOptions(normalizeOptions(items))
161
+ const normalized = normalizeOptions(items)
162
+ setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
144
163
  }
145
164
  } finally {
146
165
  if (!cancelled) setLoading(false)
@@ -157,7 +176,6 @@ export function ComboboxInput({
157
176
  const eagerResolveLabel = typeof resolveLabel === 'function' ? resolveLabel : undefined
158
177
  React.useEffect(() => {
159
178
  if (!value || disabled) return
160
- if (knownLabelValues.has(value)) return
161
179
  let cancelled = false
162
180
  const apply = (label?: string | null, description?: string | null) => {
163
181
  const clean = typeof label === 'string' ? label.trim() : ''
@@ -168,12 +186,16 @@ export function ComboboxInput({
168
186
  })
169
187
  }
170
188
  if (eagerResolveLabel) {
189
+ if (knownLabelValues.has(value)) return
171
190
  Promise.resolve()
172
191
  .then(() => eagerResolveLabel(value))
173
192
  .then((label) => apply(label, resolveDescription?.(value)))
174
193
  .catch(() => {})
175
194
  return () => { cancelled = true }
176
195
  }
196
+ if (coveredOptionValues.has(value)) return
197
+ if (eagerFallbackLoadedValueRef.current === value) return
198
+ eagerFallbackLoadedValueRef.current = value
177
199
  // Fallback: pull the first page of async suggestions so a remount that lost
178
200
  // its option cache can still recover the label without user interaction.
179
201
  if (loadSuggestions) {
@@ -183,14 +205,14 @@ export function ComboboxInput({
183
205
  .then((items) => {
184
206
  if (cancelled) return
185
207
  const normalized = normalizeOptions(items)
186
- setAsyncOptions(normalized)
208
+ setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
187
209
  })
188
210
  .catch(() => {})
189
211
  .finally(() => { if (!cancelled) setLoading(false) })
190
212
  }
191
213
  return () => { cancelled = true }
192
214
  // eslint-disable-next-line react-hooks/exhaustive-deps
193
- }, [value, disabled, knownLabelValues, eagerResolveLabel, loadSuggestions])
215
+ }, [value, disabled, knownLabelValues, coveredOptionValues, eagerResolveLabel, loadSuggestions])
194
216
 
195
217
  // Sync input with value when value changes externally and input is not focused.
196
218
  React.useEffect(() => {