@object-ui/fields 6.2.3 → 7.1.0

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 (97) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/dist/complex-widgets.test.d.ts +1 -0
  3. package/dist/currency.d.ts +13 -0
  4. package/dist/datetime-widgets.test.d.ts +1 -0
  5. package/dist/field-type-coverage.test.d.ts +1 -0
  6. package/dist/index.cjs +4 -3
  7. package/dist/index.d.ts +300 -2
  8. package/dist/index.js +2460 -1274
  9. package/dist/standard-widgets.test.d.ts +0 -0
  10. package/dist/{packages/fields/src/widgets → widgets}/AddressField.d.ts +2 -1
  11. package/dist/{packages/fields/src/widgets → widgets}/AutoNumberField.d.ts +2 -1
  12. package/dist/{packages/fields/src/widgets → widgets}/AvatarField.d.ts +2 -1
  13. package/dist/{packages/fields/src/widgets → widgets}/BooleanField.d.ts +2 -1
  14. package/dist/widgets/CheckboxesField.d.ts +8 -0
  15. package/dist/{packages/fields/src/widgets → widgets}/CodeField.d.ts +2 -1
  16. package/dist/{packages/fields/src/widgets → widgets}/ColorField.d.ts +2 -1
  17. package/dist/{packages/fields/src/widgets → widgets}/CurrencyField.d.ts +2 -1
  18. package/dist/{packages/fields/src/widgets → widgets}/DateField.d.ts +2 -1
  19. package/dist/{packages/fields/src/widgets → widgets}/DateTimeField.d.ts +2 -1
  20. package/dist/{packages/fields/src/widgets → widgets}/EmailField.d.ts +2 -1
  21. package/dist/{packages/fields/src/widgets → widgets}/FileField.d.ts +2 -1
  22. package/dist/{packages/fields/src/widgets → widgets}/FormulaField.d.ts +2 -1
  23. package/dist/{packages/fields/src/widgets → widgets}/GeolocationField.d.ts +2 -1
  24. package/dist/widgets/GridField.d.ts +123 -0
  25. package/dist/widgets/GridField.test.d.ts +1 -0
  26. package/dist/{packages/fields/src/widgets → widgets}/ImageField.d.ts +2 -1
  27. package/dist/{packages/fields/src/widgets → widgets}/LocationField.d.ts +2 -1
  28. package/dist/{packages/fields/src/widgets → widgets}/LookupField.d.ts +2 -1
  29. package/dist/widgets/MarkdownContent.d.ts +9 -0
  30. package/dist/{packages/fields/src/widgets → widgets}/MasterDetailField.d.ts +2 -1
  31. package/dist/widgets/MultiSelectField.d.ts +9 -0
  32. package/dist/{packages/fields/src/widgets → widgets}/NumberField.d.ts +2 -1
  33. package/dist/{packages/fields/src/widgets → widgets}/ObjectField.d.ts +2 -1
  34. package/dist/{packages/fields/src/widgets → widgets}/PasswordField.d.ts +2 -1
  35. package/dist/{packages/fields/src/widgets → widgets}/PercentField.d.ts +2 -1
  36. package/dist/{packages/fields/src/widgets → widgets}/PhoneField.d.ts +2 -1
  37. package/dist/{packages/fields/src/widgets → widgets}/QRCodeField.d.ts +2 -1
  38. package/dist/widgets/RadioField.d.ts +8 -0
  39. package/dist/{packages/fields/src/widgets → widgets}/RatingField.d.ts +2 -1
  40. package/dist/{packages/fields/src/widgets → widgets}/RecordPickerDialog.d.ts +8 -2
  41. package/dist/{packages/fields/src/widgets → widgets}/RichTextField.d.ts +2 -1
  42. package/dist/{packages/fields/src/widgets → widgets}/SelectField.d.ts +2 -1
  43. package/dist/{packages/fields/src/widgets → widgets}/SignatureField.d.ts +2 -1
  44. package/dist/{packages/fields/src/widgets → widgets}/SliderField.d.ts +2 -1
  45. package/dist/{packages/fields/src/widgets → widgets}/SummaryField.d.ts +2 -1
  46. package/dist/widgets/TagsField.d.ts +8 -0
  47. package/dist/{packages/fields/src/widgets → widgets}/TextAreaField.d.ts +2 -1
  48. package/dist/{packages/fields/src/widgets → widgets}/TextField.d.ts +2 -1
  49. package/dist/{packages/fields/src/widgets → widgets}/TimeField.d.ts +2 -1
  50. package/dist/{packages/fields/src/widgets → widgets}/UrlField.d.ts +2 -1
  51. package/dist/{packages/fields/src/widgets → widgets}/UserField.d.ts +2 -1
  52. package/dist/{packages/fields/src/widgets → widgets}/VectorField.d.ts +2 -1
  53. package/dist/widgets/deriveLookupColumns.d.ts +36 -0
  54. package/dist/widgets/deriveLookupColumns.test.d.ts +1 -0
  55. package/dist/widgets/recentLookups.d.ts +13 -0
  56. package/dist/widgets/recentLookups.test.d.ts +1 -0
  57. package/dist/{packages/fields/src/widgets → widgets}/types.d.ts +1 -1
  58. package/package.json +14 -11
  59. package/src/__tests__/registry-collision.test.ts +91 -0
  60. package/src/__tests__/resolveFieldCurrency.test.ts +20 -0
  61. package/src/coerce-safe-value.test.ts +34 -0
  62. package/src/complex-widgets.test.tsx +58 -7
  63. package/src/currency.ts +14 -0
  64. package/src/field-type-coverage.test.ts +65 -0
  65. package/src/index.tsx +372 -57
  66. package/src/widgets/CheckboxesField.tsx +53 -0
  67. package/src/widgets/CurrencyField.tsx +5 -1
  68. package/src/widgets/GridField.test.tsx +309 -0
  69. package/src/widgets/GridField.tsx +907 -33
  70. package/src/widgets/LookupField.tsx +258 -107
  71. package/src/widgets/MarkdownContent.tsx +19 -0
  72. package/src/widgets/MultiSelectField.tsx +59 -0
  73. package/src/widgets/PercentField.tsx +19 -7
  74. package/src/widgets/RadioField.tsx +41 -0
  75. package/src/widgets/RecordPickerDialog.tsx +22 -18
  76. package/src/widgets/SelectField.tsx +23 -2
  77. package/src/widgets/TagsField.tsx +67 -0
  78. package/src/widgets/deriveLookupColumns.test.ts +74 -0
  79. package/src/widgets/deriveLookupColumns.ts +104 -0
  80. package/src/widgets/recentLookups.test.ts +45 -0
  81. package/src/widgets/recentLookups.ts +47 -0
  82. package/src/widgets/useFieldTranslation.ts +30 -0
  83. package/dist/packages/fields/src/fields-edge-cases.stories.d.ts +0 -45
  84. package/dist/packages/fields/src/fields.stories.d.ts +0 -56
  85. package/dist/packages/fields/src/index.d.ts +0 -286
  86. package/dist/packages/fields/src/record-picker-dialog.stories.d.ts +0 -25
  87. package/dist/packages/fields/src/widgets/GridField.d.ts +0 -7
  88. package/src/fields-edge-cases.stories.tsx +0 -407
  89. package/src/fields.stories.tsx +0 -566
  90. package/src/record-picker-dialog.stories.tsx +0 -160
  91. /package/dist/{packages/fields/src/__tests__/validation-feedback.test.d.ts → __tests__/registry-collision.test.d.ts} +0 -0
  92. /package/dist/{packages/fields/src/complex-widgets.test.d.ts → __tests__/resolveFieldCurrency.test.d.ts} +0 -0
  93. /package/dist/{packages/fields/src/standard-widgets.test.d.ts → __tests__/validation-feedback.test.d.ts} +0 -0
  94. /package/dist/{packages/fields/src/datetime-widgets.test.d.ts → coerce-safe-value.test.d.ts} +0 -0
  95. /package/dist/{packages/fields/src/widgets → widgets}/ImageCropperDialog.d.ts +0 -0
  96. /package/dist/{packages/fields/src/widgets → widgets}/_cell-renderer-bridge.d.ts +0 -0
  97. /package/dist/{packages/fields/src/widgets → widgets}/useFieldTranslation.d.ts +0 -0
@@ -1,5 +1,6 @@
1
1
  import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
2
- import { Button,
2
+ import { cn,
3
+ Button,
3
4
  Input,
4
5
  Badge,
5
6
  Popover,
@@ -8,8 +9,10 @@ import { Button,
8
9
  import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
9
10
  import { FieldWidgetProps } from './types';
10
11
  import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
11
- import { RecordPickerDialog } from './RecordPickerDialog';
12
+ import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
12
13
  import type { RecordPickerFilterColumn } from './RecordPickerDialog';
14
+ import { deriveLookupColumns } from './deriveLookupColumns';
15
+ import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
13
16
  import { getCellRendererResolver } from './_cell-renderer-bridge';
14
17
  import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
15
18
  import { useFieldTranslation } from './useFieldTranslation';
@@ -127,6 +130,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
127
130
  const [isOpen, setIsOpen] = useState(false);
128
131
  const [searchQuery, setSearchQuery] = useState('');
129
132
  const { t } = useFieldTranslation();
133
+ const listboxId = React.useId();
130
134
 
131
135
  // Dynamic data loading state
132
136
  const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
@@ -158,16 +162,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
158
162
 
159
163
  const staticOptions: LookupOption[] = fieldMeta?.options || [];
160
164
  const multiple = fieldMeta?.multiple || false;
161
- const displayField = fieldMeta?.display_field || fieldMeta?.reference_field || 'name';
162
- const descriptionField: string | undefined = fieldMeta?.description_field;
165
+ const displayField = fieldMeta?.display_field || fieldMeta?.displayField || fieldMeta?.reference_field || 'name';
166
+ const descriptionField: string | undefined = fieldMeta?.description_field ?? fieldMeta?.descriptionField;
163
167
  const idField = fieldMeta?.id_field || 'id';
164
168
  // ObjectStack convention uses `reference`; types define `reference_to` — support both
165
169
  const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
170
+ // Opt-in inline quick-create: when set, an empty/zero-result picker offers to
171
+ // create a record from the typed text via dataSource.create.
172
+ const allowCreate: boolean = !!(fieldMeta?.allow_create ?? fieldMeta?.allowCreate);
166
173
 
167
174
  // Enterprise Record Picker configuration
168
- const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns;
169
- const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size;
170
- const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters;
175
+ const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
176
+ const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
177
+ const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
171
178
 
172
179
  /**
173
180
  * Dependent lookups — restrict candidates based on values of *other* fields
@@ -194,24 +201,6 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
194
201
 
195
202
  // Resolve dependent field values from explicit prop or SchemaRendererContext.data
196
203
  const dependentValuesProp = (props as any).dependentValues as Record<string, any> | undefined;
197
- // Derive filter columns from lookup_columns that have type info
198
- const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
199
- if (!lookupColumns) return undefined;
200
- const cols: RecordPickerFilterColumn[] = [];
201
- for (const c of lookupColumns) {
202
- if (typeof c === 'object' && c.type) {
203
- const filterType = mapFieldTypeToFilterType(c.type);
204
- if (filterType) {
205
- cols.push({
206
- field: c.field,
207
- label: c.label,
208
- type: filterType,
209
- });
210
- }
211
- }
212
- }
213
- return cols.length > 0 ? cols : undefined;
214
- }, [lookupColumns]);
215
204
 
216
205
  // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
217
206
  const ctx = useContext(SchemaRendererContext);
@@ -262,6 +251,42 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
262
251
  return null;
263
252
  }, [refObjectSchema]);
264
253
 
254
+ /**
255
+ * Picker columns. Honour explicit `lookup_columns` when authored; otherwise
256
+ * derive a multi-column, disambiguating set from the referenced object's
257
+ * schema so every lookup gets a useful picker with zero field-level config.
258
+ */
259
+ const pickerColumns = useMemo<Array<string | LookupColumnDef> | undefined>(() => {
260
+ if (lookupColumns && lookupColumns.length > 0) return lookupColumns;
261
+ const derived = deriveLookupColumns(refObjectSchema, { displayField });
262
+ return derived.length > 0 ? derived : undefined;
263
+ }, [lookupColumns, refObjectSchema, displayField]);
264
+
265
+ /**
266
+ * Secondary line under each quick-select option. Honour explicit
267
+ * `description_field`; otherwise reuse the first derived non-display column so
268
+ * the inline popover also benefits from the richer schema.
269
+ */
270
+ const effectiveDescriptionField = useMemo<string | undefined>(() => {
271
+ if (descriptionField) return descriptionField;
272
+ const extra = pickerColumns?.find((c) => (typeof c === 'string' ? c : c.field) !== displayField);
273
+ if (!extra) return undefined;
274
+ return typeof extra === 'string' ? extra : extra.field;
275
+ }, [descriptionField, pickerColumns, displayField]);
276
+
277
+ // Derive filter-bar columns from any typed picker columns.
278
+ const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
279
+ if (!pickerColumns) return undefined;
280
+ const cols: RecordPickerFilterColumn[] = [];
281
+ for (const c of pickerColumns) {
282
+ if (typeof c === 'object' && c.type) {
283
+ const filterType = mapFieldTypeToFilterType(c.type);
284
+ if (filterType) cols.push({ field: c.field, label: c.label, type: filterType });
285
+ }
286
+ }
287
+ return cols.length > 0 ? cols : undefined;
288
+ }, [pickerColumns]);
289
+
265
290
  // Optional create-new callback
266
291
  const onCreateNew: ((searchQuery: string) => void) | undefined =
267
292
  (props as any).onCreateNew ?? lookupField?.onCreateNew;
@@ -325,9 +350,15 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
325
350
  }
326
351
  }
327
352
 
353
+ // Apply base scoping filters so the quick-select popover matches the
354
+ // full picker dialog (lookupFilters were previously dialog-only).
355
+ if (lookupFilters && lookupFilters.length > 0) {
356
+ params.$filter = { ...(params.$filter ?? {}), ...lookupFiltersToRecord(lookupFilters) };
357
+ }
358
+
328
359
  const result = await dataSource.find(referenceTo, params);
329
360
  const records: any[] = result?.data ?? result ?? [];
330
- const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField, refTitleFormat));
361
+ const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
331
362
 
332
363
  setFetchedOptions(mapped);
333
364
  setTotalCount(result?.total ?? records.length);
@@ -339,7 +370,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
339
370
  setLoading(false);
340
371
  }
341
372
  },
342
- [dataSource, referenceTo, displayField, idField, descriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues],
373
+ [dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues, lookupFilters],
343
374
  );
344
375
 
345
376
  // Re-fetch when dependent values change while the picker is open. This keeps
@@ -424,14 +455,14 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
424
455
  for (const id of unresolved) {
425
456
  if (typeof (dataSource as any).findOne === 'function') {
426
457
  const rec = await (dataSource as any).findOne(referenceTo, id);
427
- if (rec) fetched.push(recordToOption(rec, displayField, idField, descriptionField, refTitleFormat));
458
+ if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat));
428
459
  } else {
429
460
  const res = await dataSource.find(referenceTo, {
430
461
  $filter: { [idField]: id },
431
462
  $top: 1,
432
463
  } as QueryParams);
433
464
  const rows = res?.data ?? res ?? [];
434
- if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, descriptionField, refTitleFormat));
465
+ if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat));
435
466
  }
436
467
  }
437
468
  if (!cancelled && fetched.length) {
@@ -449,7 +480,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
449
480
  cancelled = true;
450
481
  };
451
482
  // eslint-disable-next-line react-hooks/exhaustive-deps
452
- }, [value, hasDataSource, referenceTo, displayField, idField, descriptionField, multiple]);
483
+ }, [value, hasDataSource, referenceTo, displayField, idField, effectiveDescriptionField, multiple]);
453
484
 
454
485
  // Get selected option(s) — check static, fetched, and picker-resolved options
455
486
  const findOption = useCallback(
@@ -467,23 +498,32 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
467
498
  ? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean)
468
499
  : value ? [findOption(value)].filter(Boolean) : [];
469
500
 
501
+ // Optional: receive the FULL selected record (not just its id) so a host can
502
+ // auto-fill sibling fields from it — e.g. a line-item grid copying a product's
503
+ // unit_price/description when the item is chosen. When provided (single
504
+ // select), it drives the update and the host owns the resulting value change.
505
+ const onSelectRecord = (props as any).onSelectRecord as ((record: LookupOption) => void) | undefined;
506
+
470
507
  const handleSelect = useCallback(
471
508
  (option: LookupOption) => {
472
509
  if (multiple) {
473
510
  const currentValues = Array.isArray(value) ? value : [];
474
511
  const isSelected = currentValues.includes(option.value);
475
-
512
+
476
513
  if (isSelected) {
477
514
  onChange(currentValues.filter((v: any) => v !== option.value));
478
515
  } else {
516
+ if (referenceTo) pushRecentLookupId(referenceTo, option.value);
479
517
  onChange([...currentValues, option.value]);
480
518
  }
481
519
  } else {
482
- onChange(option.value);
520
+ if (referenceTo) pushRecentLookupId(referenceTo, option.value);
521
+ if (onSelectRecord) onSelectRecord(option);
522
+ else onChange(option.value);
483
523
  setIsOpen(false);
484
524
  }
485
525
  },
486
- [multiple, value, onChange],
526
+ [multiple, value, onChange, onSelectRecord, referenceTo],
487
527
  );
488
528
 
489
529
  const handleRemove = (optionValue: any) => {
@@ -499,10 +539,89 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
499
539
  // findOption can resolve display labels after the dialog closes.
500
540
  const handlePickerSelectRecords = useCallback(
501
541
  (records: any[]) => {
502
- const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField, refTitleFormat));
542
+ const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
543
+ if (referenceTo) mapped.forEach((o) => pushRecentLookupId(referenceTo, o.value));
503
544
  setPickerResolvedRecords(mapped);
504
545
  },
505
- [displayField, idField, descriptionField, refTitleFormat],
546
+ [displayField, idField, effectiveDescriptionField, refTitleFormat, referenceTo],
547
+ );
548
+
549
+ // ── Recently-used, quick-create, combined option list ────────────────────
550
+ const [recentOptions, setRecentOptions] = useState<LookupOption[]>([]);
551
+ useEffect(() => {
552
+ if (!isOpen || !hasDataSource || !dataSource || !referenceTo || searchQuery) return;
553
+ const ids = getRecentLookupIds(referenceTo);
554
+ if (!ids.length) { setRecentOptions([]); return; }
555
+ let cancelled = false;
556
+ (async () => {
557
+ try {
558
+ const recs: LookupOption[] = [];
559
+ for (const id of ids) {
560
+ const cached = findOption(id);
561
+ if (cached) { recs.push(cached); continue; }
562
+ if (typeof (dataSource as any).findOne === 'function') {
563
+ const r = await (dataSource as any).findOne(referenceTo, id);
564
+ if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
565
+ }
566
+ }
567
+ if (!cancelled) setRecentOptions(recs);
568
+ } catch { if (!cancelled) setRecentOptions([]); }
569
+ })();
570
+ return () => { cancelled = true; };
571
+ // eslint-disable-next-line react-hooks/exhaustive-deps
572
+ }, [isOpen, hasDataSource, referenceTo, searchQuery]);
573
+
574
+ // Recently-used first (only before the user types), then live results — one
575
+ // de-duped list that drives BOTH rendering and arrow-key navigation.
576
+ const recentCount = (!searchQuery && hasDataSource) ? recentOptions.length : 0;
577
+ const visibleOptions = useMemo(() => {
578
+ if (searchQuery || !hasDataSource || recentOptions.length === 0) return filteredOptions;
579
+ const recentIds = new Set(recentOptions.map((o) => o.value));
580
+ return [...recentOptions, ...filteredOptions.filter((o) => !recentIds.has(o.value))];
581
+ }, [searchQuery, hasDataSource, recentOptions, filteredOptions]);
582
+ useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
583
+
584
+ const [creating, setCreating] = useState(false);
585
+ const canCreate = !!onCreateNew || (allowCreate && hasDataSource);
586
+ const handleCreateNew = useCallback(
587
+ async (q: string) => {
588
+ const label = (q || '').trim();
589
+ if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
590
+ if (!allowCreate || !dataSource || !referenceTo || !label) return;
591
+ setCreating(true);
592
+ setError(null);
593
+ try {
594
+ const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
595
+ const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat);
596
+ setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
597
+ handleSelect(opt);
598
+ } catch (err) {
599
+ setError(err instanceof Error ? err.message : String(err));
600
+ } finally {
601
+ setCreating(false);
602
+ }
603
+ },
604
+ [onCreateNew, allowCreate, dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, handleSelect],
605
+ );
606
+
607
+ // Compact one-line preview of an option's extra (non-display) columns —
608
+ // shown as a native tooltip so users can disambiguate without opening it.
609
+ const previewOf = useCallback(
610
+ (option: LookupOption): string | undefined => {
611
+ if (!pickerColumns || pickerColumns.length === 0) return undefined;
612
+ const parts: string[] = [];
613
+ for (const c of pickerColumns) {
614
+ const f = typeof c === 'string' ? c : c.field;
615
+ if (f === displayField) continue;
616
+ const v = (option as any)[f];
617
+ if (v === null || v === undefined || v === '') continue;
618
+ const lbl = typeof c === 'string' ? f : (c.label || f);
619
+ const text = typeof v === 'object' ? (v.name ?? v.label ?? JSON.stringify(v)) : v;
620
+ parts.push(`${lbl}: ${text}`);
621
+ }
622
+ return parts.length ? parts.join(' · ') : undefined;
623
+ },
624
+ [pickerColumns, displayField],
506
625
  );
507
626
 
508
627
  // Keyboard handler for the search input — arrow keys + Enter
@@ -511,19 +630,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
511
630
  if (e.key === 'ArrowDown') {
512
631
  e.preventDefault();
513
632
  setActiveIndex(prev =>
514
- prev < filteredOptions.length - 1 ? prev + 1 : prev,
633
+ prev < visibleOptions.length - 1 ? prev + 1 : prev,
515
634
  );
516
635
  } else if (e.key === 'ArrowUp') {
517
636
  e.preventDefault();
518
637
  setActiveIndex(prev => (prev > 0 ? prev - 1 : 0));
519
638
  } else if (e.key === 'Enter') {
520
639
  e.preventDefault();
521
- if (activeIndex >= 0 && activeIndex < filteredOptions.length) {
522
- handleSelect(filteredOptions[activeIndex]);
640
+ if (activeIndex >= 0 && activeIndex < visibleOptions.length) {
641
+ handleSelect(visibleOptions[activeIndex]);
523
642
  }
524
643
  }
525
644
  },
526
- [filteredOptions, activeIndex, handleSelect],
645
+ [visibleOptions, activeIndex, handleSelect],
527
646
  );
528
647
 
529
648
  // Scroll active item into view
@@ -558,14 +677,20 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
558
677
  );
559
678
  }
560
679
 
680
+ // Compact mode (e.g. inside a line-item grid cell): show the selected value
681
+ // INSIDE a borderless trigger on a single line — no chip stacked above a
682
+ // separate "Select…" button (which double-stacks and wastes the row height).
683
+ const compact = !!(props as any).compact;
684
+ const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
685
+
561
686
  return (
562
- <div className="space-y-2">
563
- {/* Selected values display */}
564
- {selectedOptions.length > 0 && (
687
+ <div className={compact ? '' : 'space-y-2'}>
688
+ {/* Selected values display (full mode only — compact shows it in-trigger) */}
689
+ {selectedOptions.length > 0 && !compact && (
565
690
  <div className="flex flex-wrap gap-1">
566
691
  {selectedOptions.map((opt, idx) => (
567
- <Badge
568
- key={idx}
692
+ <Badge
693
+ key={idx}
569
694
  variant="outline"
570
695
  className="gap-1"
571
696
  >
@@ -574,7 +699,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
574
699
  onClick={() => handleRemove(opt?.value)}
575
700
  className="ml-1 hover:text-destructive"
576
701
  type="button"
577
- aria-label={`Remove ${opt?.label || opt?.[displayField]}`}
702
+ aria-label={t('lookup.remove', { label: opt?.label || opt?.[displayField] })}
578
703
  >
579
704
  <X className="size-3" />
580
705
  </button>
@@ -587,23 +712,33 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
587
712
  <div className="flex items-center gap-1.5">
588
713
  <Popover open={isOpen} onOpenChange={(o) => !dependenciesMissing && setIsOpen(o)}>
589
714
  <PopoverTrigger asChild>
590
- <Button
591
- variant="outline"
592
- className="min-w-0 flex-1 justify-start text-left font-normal"
715
+ <Button
716
+ variant="outline"
717
+ className={cn(
718
+ 'min-w-0 flex-1 justify-start text-left font-normal',
719
+ compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
720
+ )}
593
721
  type="button"
722
+ aria-haspopup="listbox"
723
+ aria-expanded={isOpen}
724
+ aria-controls={listboxId}
594
725
  disabled={dependenciesMissing || (props as any).disabled}
595
- data-testid={dependenciesMissing ? 'lookup-trigger-gated' : undefined}
726
+ data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
596
727
  title={dependenciesMissing
597
- ? `Select ${dependsOn.map(d => d.field).join(', ')} first`
728
+ ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
598
729
  : undefined}
599
730
  >
600
- <Search className="mr-2 size-4" />
731
+ <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
732
+ <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
601
733
  {dependenciesMissing
602
- ? `Select ${dependsOn.map(d => d.field).join(', ')} first`
603
- : selectedOptions.length === 0
604
- ? lookupField?.placeholder || t('common.select')
605
- : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
734
+ ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
735
+ : compact && !multiple && selectedOptions.length > 0
736
+ ? singleSelectedLabel
737
+ : selectedOptions.length === 0
738
+ ? lookupField?.placeholder || t('common.select')
739
+ : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
606
740
  }
741
+ </span>
607
742
  </Button>
608
743
  </PopoverTrigger>
609
744
  <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
@@ -617,6 +752,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
617
752
  onChange={(e) => handleSearchChange(e.target.value)}
618
753
  onKeyDown={handleSearchKeyDown}
619
754
  className="w-full pl-9 h-8 text-sm"
755
+ role="combobox"
756
+ aria-autocomplete="list"
757
+ aria-controls={listboxId}
758
+ aria-expanded={isOpen}
759
+ aria-activedescendant={activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined}
620
760
  />
621
761
  {loading && (
622
762
  <Loader2
@@ -638,7 +778,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
638
778
  onClick={() => fetchLookupData(searchQuery || undefined)}
639
779
  type="button"
640
780
  >
641
- Retry
781
+ {t('lookup.retry')}
642
782
  </Button>
643
783
  </div>
644
784
  )}
@@ -647,77 +787,90 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
647
787
  {loading && filteredOptions.length === 0 && !error && (
648
788
  <div className="flex flex-col items-center gap-2 py-6" role="status" aria-live="polite">
649
789
  <Loader2 className="size-6 animate-spin text-muted-foreground" />
650
- <p className="text-sm text-muted-foreground">Loading…</p>
790
+ <p className="text-sm text-muted-foreground">{t('lookup.loading')}</p>
651
791
  </div>
652
792
  )}
653
793
 
654
794
  {/* Options list */}
655
795
  {!error && !(loading && filteredOptions.length === 0) && (
656
- <div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox">
657
- {filteredOptions.length === 0 ? (
796
+ <div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox" id={listboxId}>
797
+ {visibleOptions.length === 0 ? (
658
798
  <div className="py-4 text-center">
659
799
  <p className="text-sm text-muted-foreground">
660
- No options found
800
+ {t('lookup.noOptions')}
661
801
  </p>
662
802
  {/* Quick-create entry */}
663
- {onCreateNew && (
803
+ {canCreate && (
664
804
  <Button
665
805
  variant="ghost"
666
806
  size="sm"
667
807
  className="mt-2 gap-1"
668
808
  type="button"
669
- onClick={() => {
670
- onCreateNew(searchQuery);
671
- setIsOpen(false);
672
- }}
809
+ disabled={creating}
810
+ onClick={() => handleCreateNew(searchQuery)}
673
811
  >
674
- <Plus className="size-4" />
675
- Create new
812
+ {creating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
813
+ {searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
676
814
  </Button>
677
815
  )}
678
816
  </div>
679
817
  ) : (
680
818
  <>
681
- {filteredOptions.map((option, idx) => {
819
+ {visibleOptions.map((option, idx) => {
682
820
  const isSelected = multiple
683
821
  ? (Array.isArray(value) ? value : []).includes(option.value)
684
822
  : value === option.value;
685
823
  const isActive = idx === activeIndex;
824
+ const showRecentHeader = recentCount > 0 && idx === 0;
825
+ const showResultsHeader = recentCount > 0 && idx === recentCount;
686
826
 
687
827
  return (
688
- <button
689
- key={option.value}
690
- data-lookup-index={idx}
691
- role="option"
692
- aria-selected={isSelected}
693
- onClick={() => handleSelect(option)}
694
- className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
695
- isActive
696
- ? 'bg-accent text-accent-foreground'
697
- : isSelected
698
- ? 'bg-accent/50 text-accent-foreground'
699
- : ''
700
- }`}
701
- type="button"
702
- >
703
- <div className="min-w-0 flex-1">
704
- <span className="block truncate">{option.label}</span>
705
- {option.description && (
706
- <span className="block truncate text-xs text-muted-foreground">
707
- {option.description}
708
- </span>
709
- )}
710
- </div>
711
- {isSelected && (
712
- <Badge variant="default" className="ml-2 shrink-0">Selected</Badge>
828
+ <React.Fragment key={option.value}>
829
+ {showRecentHeader && (
830
+ <div className="px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
831
+ {t('lookup.recentlyUsed')}
832
+ </div>
713
833
  )}
714
- </button>
834
+ {showResultsHeader && (
835
+ <div className="mt-1 border-t px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
836
+ {t('lookup.allResults')}
837
+ </div>
838
+ )}
839
+ <button
840
+ id={`${listboxId}-opt-${idx}`}
841
+ data-lookup-index={idx}
842
+ role="option"
843
+ aria-selected={isSelected}
844
+ title={previewOf(option)}
845
+ onClick={() => handleSelect(option)}
846
+ className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
847
+ isActive
848
+ ? 'bg-accent text-accent-foreground'
849
+ : isSelected
850
+ ? 'bg-accent/50 text-accent-foreground'
851
+ : ''
852
+ }`}
853
+ type="button"
854
+ >
855
+ <div className="min-w-0 flex-1">
856
+ <span className="block truncate">{option.label}</span>
857
+ {option.description && (
858
+ <span className="block truncate text-xs text-muted-foreground">
859
+ {option.description}
860
+ </span>
861
+ )}
862
+ </div>
863
+ {isSelected && (
864
+ <Badge variant="default" className="ml-2 shrink-0">{t('lookup.selectedBadge')}</Badge>
865
+ )}
866
+ </button>
867
+ </React.Fragment>
715
868
  );
716
869
  })}
717
870
  {/* Show total count when fetched from DataSource */}
718
871
  {hasDataSource && totalCount > filteredOptions.length && (
719
872
  <p className="text-xs text-muted-foreground text-center py-2">
720
- Showing {filteredOptions.length} of {totalCount} results.
873
+ {t('lookup.showingResults', { shown: filteredOptions.length, total: totalCount })}
721
874
  </p>
722
875
  )}
723
876
  {/* "Show All Results" button — opens the full Record Picker (Level 2) */}
@@ -732,21 +885,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
732
885
  data-testid="show-all-results"
733
886
  >
734
887
  <TableProperties className="size-3.5" />
735
- Show All Results ({totalCount})
888
+ {t('lookup.showAllResults', { count: totalCount })}
736
889
  </button>
737
890
  )}
738
891
  {/* Quick-create entry (below results) */}
739
- {onCreateNew && (
892
+ {canCreate && (
740
893
  <button
741
894
  type="button"
742
895
  className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground"
743
- onClick={() => {
744
- onCreateNew(searchQuery);
745
- setIsOpen(false);
746
- }}
896
+ disabled={creating}
897
+ onClick={() => handleCreateNew(searchQuery)}
747
898
  >
748
- <Plus className="size-3.5" />
749
- Create new{searchQuery ? ` "${searchQuery}"` : ''}
899
+ {creating ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
900
+ {searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
750
901
  </button>
751
902
  )}
752
903
  </>
@@ -764,8 +915,8 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
764
915
  className="shrink-0"
765
916
  type="button"
766
917
  onClick={() => setIsPickerOpen(true)}
767
- aria-label="Browse all records"
768
- title="Browse all records"
918
+ aria-label={t('lookup.browseAll')}
919
+ title={t('lookup.browseAll')}
769
920
  data-testid="browse-all-records"
770
921
  >
771
922
  <TableProperties className="size-4" />
@@ -778,11 +929,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
778
929
  <RecordPickerDialog
779
930
  open={isPickerOpen}
780
931
  onOpenChange={setIsPickerOpen}
781
- title={lookupField?.label || 'Select'}
932
+ title={lookupField?.label || t('common.select')}
782
933
  multiple={multiple}
783
934
  dataSource={dataSource}
784
935
  objectName={referenceTo}
785
- columns={lookupColumns}
936
+ columns={pickerColumns}
786
937
  displayField={displayField}
787
938
  titleFormat={refTitleFormat}
788
939
  idField={idField}
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import rehypeSanitize from 'rehype-sanitize';
5
+
6
+ /**
7
+ * Heavy markdown renderer, split into its own module so it can be lazy-loaded
8
+ * by the cell/detail renderers (react-markdown pulls in ~100-200 KB). GitHub
9
+ * Flavored Markdown + sanitization (defense against XSS in stored content).
10
+ */
11
+ export default function MarkdownContent({ value }: { value: string }) {
12
+ return (
13
+ <div className="prose prose-sm max-w-none dark:prose-invert break-words">
14
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
15
+ {value}
16
+ </ReactMarkdown>
17
+ </div>
18
+ );
19
+ }