@object-ui/fields 6.2.2 → 7.0.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 +199 -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
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import { Badge, EmptyValue, cn } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ interface Option { label: string; value: string; color?: string }
6
+
7
+ /**
8
+ * MultiSelectField - select zero or more values from a fixed option set.
9
+ *
10
+ * Renders the configured options as toggleable chips. The stored value is a
11
+ * string[] (the selected option values). Used for the `multiselect` field type.
12
+ */
13
+ export function MultiSelectField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
14
+ const config = (field || (props as any).schema) as any;
15
+ const options: Option[] = config?.options || [];
16
+ const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
17
+
18
+ if (readonly) {
19
+ if (selected.length === 0) return <EmptyValue />;
20
+ return (
21
+ <div className="flex flex-wrap gap-1">
22
+ {selected.map((v) => {
23
+ const opt = options.find((o) => o.value === v);
24
+ return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
25
+ })}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ const toggle = (v: string) => {
31
+ const next = selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v];
32
+ onChange(next);
33
+ };
34
+
35
+ return (
36
+ <div className={cn('flex flex-wrap gap-1.5', className)}>
37
+ {options.map((opt) => {
38
+ const active = selected.includes(opt.value);
39
+ return (
40
+ <button
41
+ type="button"
42
+ key={opt.value}
43
+ onClick={() => toggle(opt.value)}
44
+ disabled={(props as any).disabled}
45
+ aria-pressed={active}
46
+ className={cn(
47
+ 'rounded-full border px-3 py-1 text-sm transition-colors',
48
+ active
49
+ ? 'border-primary bg-primary text-primary-foreground'
50
+ : 'border-input bg-background text-foreground hover:bg-accent',
51
+ )}
52
+ >
53
+ {opt.label}
54
+ </button>
55
+ );
56
+ })}
57
+ </div>
58
+ );
59
+ }
@@ -11,18 +11,30 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
11
11
  const percentField = (field || (props as any).schema) as any;
12
12
  const precision = percentField?.precision ?? 2;
13
13
 
14
+ // Convention detection. A field declaring `max > 1` (e.g. `max: 100`) stores
15
+ // WHOLE-NUMBER percents (0–100); otherwise values are FRACTIONS (0–1) shown
16
+ // as 0–100%. This matches the read-side formatter so the edit widget agrees
17
+ // with display — and, crucially, keeps the rendered <input> within its `max`
18
+ // (a whole-number 50 must show "50", not "5000", or HTML5 constraint
19
+ // validation marks the field `:invalid` and blocks the whole form's submit).
20
+ const maxAttr = typeof percentField?.max === 'number' ? (percentField.max as number) : undefined;
21
+ const whole = maxAttr != null && maxAttr > 1;
22
+ const toDisplay = (v: number) => (whole ? v : v * 100);
23
+ const fromDisplay = (n: number) => (whole ? n : n / 100);
24
+ const sliderMax = whole ? maxAttr! : 100;
25
+
14
26
  if (readonly) {
15
27
  if (value == null) return <EmptyValue />;
16
28
  return (
17
29
  <span className="text-sm font-medium tabular-nums">
18
- {(value * 100).toFixed(precision)}%
30
+ {toDisplay(value).toFixed(precision)}%
19
31
  </span>
20
32
  );
21
33
  }
22
34
 
23
- // Convert between stored value (0-1) and display value (0-100)
24
- const displayValue = value != null ? (value * 100) : '';
25
- const sliderValue = value != null ? value * 100 : 0;
35
+ // Convert between stored value and 0–100 display value
36
+ const displayValue = value != null ? toDisplay(value) : '';
37
+ const sliderValue = value != null ? toDisplay(value) : 0;
26
38
 
27
39
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
28
40
  if (e.target.value === '') {
@@ -30,7 +42,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
30
42
  return;
31
43
  }
32
44
  const parsed = parseFloat(e.target.value);
33
- const val = isNaN(parsed) ? null : parsed / 100;
45
+ const val = isNaN(parsed) ? null : fromDisplay(parsed);
34
46
  onChange(val as any);
35
47
  };
36
48
 
@@ -41,7 +53,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
41
53
  return;
42
54
  }
43
55
  const raw = values[0];
44
- const nextValue = typeof raw === 'number' ? raw / 100 : null;
56
+ const nextValue = typeof raw === 'number' ? fromDisplay(raw) : null;
45
57
  onChange(nextValue as any);
46
58
  };
47
59
 
@@ -70,7 +82,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
70
82
  value={[sliderValue]}
71
83
  onValueChange={handleSliderChange}
72
84
  min={0}
73
- max={100}
85
+ max={sliderMax}
74
86
  step={sliderStep}
75
87
  disabled={readonly || props.disabled}
76
88
  className="w-full"
@@ -0,0 +1,41 @@
1
+ import React, { useId } from 'react';
2
+ import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ interface Option { label: string; value: string }
6
+
7
+ /**
8
+ * RadioField - choose exactly one value from a fixed option set, rendered as a
9
+ * radio group. The stored value is the selected option value (string). Used for
10
+ * the `radio` field type.
11
+ */
12
+ export function RadioField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>) {
13
+ const config = (field || (props as any).schema) as any;
14
+ const options: Option[] = config?.options || [];
15
+ const groupId = useId();
16
+
17
+ if (readonly) {
18
+ if (value == null || value === '') return <EmptyValue />;
19
+ const opt = options.find((o) => o.value === value);
20
+ return <span className="text-sm">{opt?.label || String(value)}</span>;
21
+ }
22
+
23
+ return (
24
+ <RadioGroup
25
+ value={value ?? ''}
26
+ onValueChange={onChange}
27
+ disabled={(props as any).disabled}
28
+ className={className}
29
+ >
30
+ {options.map((opt) => {
31
+ const id = `${groupId}-${opt.value}`;
32
+ return (
33
+ <div key={opt.value} className="flex items-center space-x-2">
34
+ <RadioGroupItem value={opt.value} id={id} />
35
+ <Label htmlFor={id} className="font-normal">{opt.label}</Label>
36
+ </div>
37
+ );
38
+ })}
39
+ </RadioGroup>
40
+ );
41
+ }
@@ -38,6 +38,7 @@ import {
38
38
  X,
39
39
  } from 'lucide-react';
40
40
  import type { DataSource, QueryParams, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
41
+ import { useFieldTranslation } from './useFieldTranslation';
41
42
 
42
43
  /** Default page size for the Record Picker dialog */
43
44
  const DEFAULT_PAGE_SIZE = 10;
@@ -144,7 +145,7 @@ function fieldToLabel(field: string): string {
144
145
  * QueryParams.$filter. Supports operator mapping for eq/ne/gt/lt/gte/lte/
145
146
  * contains/in/notIn.
146
147
  */
147
- function lookupFiltersToRecord(
148
+ export function lookupFiltersToRecord(
148
149
  filters: LookupFilterDef[],
149
150
  ): Record<string, any> {
150
151
  const result: Record<string, any> = {};
@@ -336,6 +337,7 @@ export function RecordPickerDialog({
336
337
  renderFilterBar,
337
338
  renderGrid,
338
339
  }: RecordPickerDialogProps) {
340
+ const { t } = useFieldTranslation();
339
341
  const [records, setRecords] = useState<any[]>([]);
340
342
  const [loading, setLoading] = useState(false);
341
343
  const [error, setError] = useState<string | null>(null);
@@ -801,7 +803,7 @@ export function RecordPickerDialog({
801
803
  onValueChange={v => handleFilterChange(col.field, v)}
802
804
  >
803
805
  <SelectTrigger className="h-8 text-xs">
804
- <SelectValue placeholder={`Filter ${label}`} />
806
+ <SelectValue placeholder={t('lookup.filterPlaceholder', { label })} />
805
807
  </SelectTrigger>
806
808
  <SelectContent>
807
809
  {col.options?.map(opt => (
@@ -821,7 +823,7 @@ export function RecordPickerDialog({
821
823
  type="number"
822
824
  className="h-8 text-xs"
823
825
  value={val ?? ''}
824
- placeholder={`Filter ${label}`}
826
+ placeholder={t('lookup.filterPlaceholder', { label })}
825
827
  onChange={e => {
826
828
  const raw = e.target.value;
827
829
  handleFilterChange(col.field, raw === '' ? '' : Number(raw));
@@ -850,7 +852,7 @@ export function RecordPickerDialog({
850
852
  checked={Boolean(val)}
851
853
  onCheckedChange={checked => handleFilterChange(col.field, Boolean(checked))}
852
854
  />
853
- <span className="text-xs text-muted-foreground">Yes</span>
855
+ <span className="text-xs text-muted-foreground">{t('lookup.yes')}</span>
854
856
  </div>
855
857
  </div>
856
858
  );
@@ -862,7 +864,7 @@ export function RecordPickerDialog({
862
864
  <Input
863
865
  className="h-8 text-xs"
864
866
  value={val ?? ''}
865
- placeholder={`Filter ${label}`}
867
+ placeholder={t('lookup.filterPlaceholder', { label })}
866
868
  onChange={e => handleFilterChange(col.field, e.target.value)}
867
869
  />
868
870
  </div>
@@ -896,7 +898,7 @@ export function RecordPickerDialog({
896
898
  <div className="relative rounded-md border bg-muted/30 mb-3">
897
899
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
898
900
  <Input
899
- placeholder="Search..."
901
+ placeholder={t('table.search')}
900
902
  value={searchQuery}
901
903
  onChange={(e) => handleSearchChange(e.target.value)}
902
904
  className="pl-9 border-0 bg-transparent shadow-none focus-visible:ring-0"
@@ -936,7 +938,7 @@ export function RecordPickerDialog({
936
938
  onClick={() => setFilterBarOpen(prev => !prev)}
937
939
  >
938
940
  <SlidersHorizontal className="size-3.5" />
939
- Filters
941
+ {t('lookup.filters')}
940
942
  {activeFilterCount > 0 && (
941
943
  <span className="inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary">
942
944
  {activeFilterCount}
@@ -952,7 +954,7 @@ export function RecordPickerDialog({
952
954
  onClick={handleFilterClear}
953
955
  >
954
956
  <X className="size-3" />
955
- Clear
957
+ {t('lookup.clear')}
956
958
  </Button>
957
959
  )}
958
960
  </div>
@@ -979,7 +981,7 @@ export function RecordPickerDialog({
979
981
  onClick={() => fetchRecords(searchQuery || undefined, currentPage, currentSort)}
980
982
  type="button"
981
983
  >
982
- Retry
984
+ {t('lookup.retry')}
983
985
  </Button>
984
986
  </div>
985
987
  )}
@@ -1046,7 +1048,7 @@ export function RecordPickerDialog({
1046
1048
  {/* Empty state */}
1047
1049
  {!loading && !error && records.length === 0 && (
1048
1050
  <div className="py-8 text-center">
1049
- <p className="text-sm text-muted-foreground">No records found</p>
1051
+ <p className="text-sm text-muted-foreground">{t('lookup.noRecords')}</p>
1050
1052
  </div>
1051
1053
  )}
1052
1054
 
@@ -1054,6 +1056,8 @@ export function RecordPickerDialog({
1054
1056
  {!error && records.length > 0 && (
1055
1057
  <div
1056
1058
  className="relative flex-1 overflow-auto min-h-0 border rounded-md"
1059
+ aria-busy={loading || undefined}
1060
+ data-state={loading ? 'loading' : 'idle'}
1057
1061
  tabIndex={0}
1058
1062
  onKeyDown={handleTableKeyDown}
1059
1063
  role="grid"
@@ -1151,8 +1155,8 @@ export function RecordPickerDialog({
1151
1155
  data-testid="record-picker-pagination"
1152
1156
  >
1153
1157
  <span>
1154
- {totalCount} {totalCount === 1 ? 'record' : 'records'}
1155
- {totalPages > 1 && ` · Page ${currentPage} of ${totalPages}`}
1158
+ {totalCount === 1 ? t('lookup.recordCountOne') : t('lookup.recordCount', { count: totalCount })}
1159
+ {totalPages > 1 && ` · ${t('lookup.pageOf', { current: currentPage, total: totalPages })}`}
1156
1160
  </span>
1157
1161
  {totalPages > 1 && (
1158
1162
  <div className="flex items-center gap-1.5">
@@ -1163,7 +1167,7 @@ export function RecordPickerDialog({
1163
1167
  onClick={handlePrevPage}
1164
1168
  disabled={currentPage <= 1}
1165
1169
  type="button"
1166
- aria-label="Previous page"
1170
+ aria-label={t('lookup.prevPage')}
1167
1171
  >
1168
1172
  <ChevronLeft className="size-4" />
1169
1173
  </Button>
@@ -1173,7 +1177,7 @@ export function RecordPickerDialog({
1173
1177
  value={pageJumpValue}
1174
1178
  onChange={e => setPageJumpValue(e.target.value)}
1175
1179
  onKeyDown={handlePageJump}
1176
- aria-label="Jump to page"
1180
+ aria-label={t('lookup.jumpToPage')}
1177
1181
  data-testid="record-picker-page-jump"
1178
1182
  />
1179
1183
  <Button
@@ -1183,7 +1187,7 @@ export function RecordPickerDialog({
1183
1187
  onClick={handleNextPage}
1184
1188
  disabled={currentPage >= totalPages}
1185
1189
  type="button"
1186
- aria-label="Next page"
1190
+ aria-label={t('lookup.nextPage')}
1187
1191
  >
1188
1192
  <ChevronRight className="size-4" />
1189
1193
  </Button>
@@ -1199,14 +1203,14 @@ export function RecordPickerDialog({
1199
1203
  <DialogFooter>
1200
1204
  <div className="flex items-center gap-2 w-full justify-between">
1201
1205
  <span className="text-sm text-muted-foreground">
1202
- {pendingSelection.size} selected
1206
+ {t('table.selected', { count: pendingSelection.size })}
1203
1207
  </span>
1204
1208
  <div className="flex gap-2">
1205
1209
  <Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
1206
- Cancel
1210
+ {t('common.cancel')}
1207
1211
  </Button>
1208
1212
  <Button type="button" onClick={handleConfirm}>
1209
- Confirm
1213
+ {t('common.confirm')}
1210
1214
  </Button>
1211
1215
  </div>
1212
1216
  </div>
@@ -19,6 +19,11 @@ export function SelectField({ value, onChange, field, readonly, ...props }: Fiel
19
19
  const config = (field || (props as any).schema) as SelectFieldMetadata;
20
20
  const options = config?.options || [];
21
21
  const { t } = useFieldTranslation();
22
+ // Stable hook for automation/e2e — react-hook-form + Radix Select cannot be
23
+ // driven by synthetic DOM events, so e2e must target the trigger/options by a
24
+ // deterministic testid keyed on the field name. `props.name` is the
25
+ // react-hook-form field name spread in by the form renderer (FormField).
26
+ const fieldName = (props as any).name || (config as any)?.name || props.id || '';
22
27
 
23
28
  if (readonly) {
24
29
  const option = options.find((o) => o.value === value);
@@ -26,6 +31,22 @@ export function SelectField({ value, onChange, field, readonly, ...props }: Fiel
26
31
  return display ? <span className="text-sm">{display}</span> : <EmptyValue />;
27
32
  }
28
33
 
34
+ // A select with no options is unfillable — a silently-empty Radix dropdown
35
+ // reads as "broken widget" and hides the real cause (the field metadata has
36
+ // no `options`). Surface a legible empty state instead, without needing to
37
+ // open the popover. Mirrors the inline form renderer's behaviour
38
+ // (see plugin form `renderFieldComponent`'s `case 'select'`).
39
+ if (options.length === 0) {
40
+ return (
41
+ <div
42
+ data-testid={fieldName ? `select-empty-${fieldName}` : undefined}
43
+ className="flex h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
44
+ >
45
+ No options available
46
+ </div>
47
+ );
48
+ }
49
+
29
50
  return (
30
51
  <Select
31
52
  {...props}
@@ -33,12 +54,12 @@ export function SelectField({ value, onChange, field, readonly, ...props }: Fiel
33
54
  onValueChange={onChange}
34
55
  disabled={readonly || props.disabled}
35
56
  >
36
- <SelectTrigger className={props.className} id={props.id}>
57
+ <SelectTrigger className={props.className} id={props.id} data-testid={fieldName ? `select-trigger-${fieldName}` : undefined}>
37
58
  <SelectValue placeholder={config?.placeholder || t('common.selectOption')} />
38
59
  </SelectTrigger>
39
60
  <SelectContent position="popper">
40
61
  {options.map((option) => (
41
- <SelectItem key={option.value} value={option.value}>
62
+ <SelectItem key={option.value} value={option.value} data-testid={`select-option-${option.value}`}>
42
63
  {option.label}
43
64
  </SelectItem>
44
65
  ))}
@@ -0,0 +1,67 @@
1
+ import React from 'react';
2
+ import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ /**
6
+ * TagsField - free-form list of string tags. Type a value and press Enter (or
7
+ * comma) to add it; click a tag's × to remove it. The stored value is a
8
+ * string[]. Used for the `tags` field type.
9
+ */
10
+ export function TagsField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
11
+ const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
12
+ const [draft, setDraft] = React.useState('');
13
+
14
+ if (readonly) {
15
+ if (tags.length === 0) return <EmptyValue />;
16
+ return (
17
+ <div className="flex flex-wrap gap-1">
18
+ {tags.map((t) => <Badge key={t} variant="secondary">{t}</Badge>)}
19
+ </div>
20
+ );
21
+ }
22
+
23
+ const addTag = (raw: string) => {
24
+ const t = raw.trim();
25
+ if (!t || tags.includes(t)) return;
26
+ onChange([...tags, t]);
27
+ setDraft('');
28
+ };
29
+
30
+ const removeTag = (t: string) => onChange(tags.filter((x) => x !== t));
31
+
32
+ const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
33
+ if (e.key === 'Enter' || e.key === ',') {
34
+ e.preventDefault();
35
+ addTag(draft);
36
+ } else if (e.key === 'Backspace' && draft === '' && tags.length > 0) {
37
+ removeTag(tags[tags.length - 1]);
38
+ }
39
+ };
40
+
41
+ return (
42
+ <div className={cn('flex flex-wrap items-center gap-1.5 rounded-md border border-input bg-background p-1.5', className)}>
43
+ {tags.map((t) => (
44
+ <Badge key={t} variant="secondary" className="gap-1">
45
+ {t}
46
+ <button
47
+ type="button"
48
+ onClick={() => removeTag(t)}
49
+ className="ml-0.5 rounded-full text-muted-foreground hover:text-foreground"
50
+ aria-label={`Remove ${t}`}
51
+ >
52
+ ×
53
+ </button>
54
+ </Badge>
55
+ ))}
56
+ <Input
57
+ value={draft}
58
+ onChange={(e) => setDraft(e.target.value)}
59
+ onKeyDown={onKeyDown}
60
+ onBlur={() => addTag(draft)}
61
+ disabled={(props as any).disabled}
62
+ placeholder={tags.length === 0 ? '输入后回车添加…' : ''}
63
+ className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
64
+ />
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { deriveLookupColumns } from './deriveLookupColumns';
3
+
4
+ const accountSchema = {
5
+ name: 'showcase_account',
6
+ fields: {
7
+ created_at: { type: 'datetime', label: 'Created At' },
8
+ updated_at: { type: 'datetime', label: 'Last Modified At' },
9
+ name: { type: 'text', label: 'Account Name' },
10
+ industry: { type: 'select', label: 'Industry' },
11
+ annual_revenue: { type: 'currency', label: 'Annual Revenue' },
12
+ website: { type: 'url', label: 'Website' },
13
+ hq: { type: 'location', label: 'Headquarters' },
14
+ status: { type: 'select', label: 'Lifecycle' },
15
+ support_config: { type: 'json', label: 'Support Config' },
16
+ owner_id: { type: 'lookup', label: 'Owner' },
17
+ organization_id: { type: 'lookup', label: 'Organization' },
18
+ },
19
+ };
20
+
21
+ describe('deriveLookupColumns', () => {
22
+ it('returns [] when no schema/fields are available', () => {
23
+ expect(deriveLookupColumns(undefined, { displayField: 'name' })).toEqual([]);
24
+ expect(deriveLookupColumns({ fields: {} }, { displayField: 'name' })).toEqual([]);
25
+ });
26
+
27
+ it('leads with the display field and adds business fields in declaration order', () => {
28
+ const cols = deriveLookupColumns(accountSchema, { displayField: 'name', max: 4 });
29
+ expect(cols.map((c) => c.field)).toEqual(['name', 'industry', 'annual_revenue', 'website']);
30
+ });
31
+
32
+ it('carries field label and type onto each column for type-aware rendering', () => {
33
+ const cols = deriveLookupColumns(accountSchema, { displayField: 'name', max: 3 });
34
+ expect(cols[0]).toEqual({ field: 'name', label: 'Account Name', type: 'text' });
35
+ expect(cols[2]).toEqual({ field: 'annual_revenue', label: 'Annual Revenue', type: 'currency' });
36
+ });
37
+
38
+ it('skips system/audit fields and heavy non-tabular types', () => {
39
+ const cols = deriveLookupColumns(accountSchema, { displayField: 'name', max: 10 });
40
+ const names = cols.map((c) => c.field);
41
+ expect(names).not.toContain('created_at');
42
+ expect(names).not.toContain('updated_at');
43
+ expect(names).not.toContain('owner_id');
44
+ expect(names).not.toContain('organization_id');
45
+ expect(names).not.toContain('support_config'); // json
46
+ expect(names).not.toContain('hq'); // location
47
+ });
48
+
49
+ it('respects the max column cap', () => {
50
+ expect(deriveLookupColumns(accountSchema, { displayField: 'name', max: 2 })).toHaveLength(2);
51
+ });
52
+
53
+ it('honours an object-level displayFields list, with the display field first', () => {
54
+ const schema = {
55
+ fields: {
56
+ name: { type: 'text', label: 'Name' },
57
+ code: { type: 'text', label: 'Code' },
58
+ region: { type: 'select', label: 'Region' },
59
+ },
60
+ displayFields: ['code', 'region'],
61
+ };
62
+ const cols = deriveLookupColumns(schema, { displayField: 'name', max: 4 });
63
+ expect(cols.map((c) => c.field)).toEqual(['name', 'code', 'region']);
64
+ });
65
+
66
+ it('does not duplicate the display field when it also appears in displayFields', () => {
67
+ const schema = {
68
+ fields: { name: { type: 'text' }, region: { type: 'select' } },
69
+ displayFields: ['name', 'region'],
70
+ };
71
+ const cols = deriveLookupColumns(schema, { displayField: 'name' });
72
+ expect(cols.map((c) => c.field)).toEqual(['name', 'region']);
73
+ });
74
+ });
@@ -0,0 +1,104 @@
1
+ import type { LookupColumnDef } from '@object-ui/types';
2
+
3
+ /**
4
+ * System / audit fields that carry no disambiguating value in a record picker.
5
+ * Excluded from auto-derived columns so the picker shows business data, not
6
+ * bookkeeping.
7
+ */
8
+ const SYSTEM_FIELDS = new Set<string>([
9
+ 'id', '_id',
10
+ 'created', 'modified', 'created_at', 'updated_at',
11
+ 'created_by', 'updated_by', 'modified_by',
12
+ 'owner_id', 'organization_id', 'space', 'company_id',
13
+ 'instance_state', 'locked', 'is_deleted', 'deleted',
14
+ ]);
15
+
16
+ /**
17
+ * Field types that don't render usefully as a compact picker column (large
18
+ * blobs, media, geometry, free-form text). Kept out of auto-derived columns.
19
+ */
20
+ const NON_TABULAR_TYPES = new Set<string>([
21
+ 'json', 'object', 'grid', 'html', 'richtext', 'markdown', 'code',
22
+ 'image', 'file', 'avatar', 'vector', 'location', 'geometry',
23
+ 'textarea', 'secret', 'password', 'encrypted',
24
+ ]);
25
+
26
+ export interface DeriveColumnsOptions {
27
+ /** Primary display field — always the leading column. */
28
+ displayField: string;
29
+ /** Maximum number of columns to derive (including the display field). */
30
+ max?: number;
31
+ }
32
+
33
+ interface ObjectSchemaLike {
34
+ fields?: Record<string, { type?: string; label?: string; hidden?: boolean }>;
35
+ /** Object-level "fields to display in search result cards" (spec metadata). */
36
+ displayFields?: unknown;
37
+ }
38
+
39
+ function dedupe(names: string[]): string[] {
40
+ const seen = new Set<string>();
41
+ const out: string[] = [];
42
+ for (const n of names) {
43
+ if (!seen.has(n)) {
44
+ seen.add(n);
45
+ out.push(n);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Derive sensible Record Picker columns from a referenced object's schema when
53
+ * a lookup field declares no explicit `lookup_columns`.
54
+ *
55
+ * Priority:
56
+ * 1. The referenced object's own `displayFields` ("fields to display in
57
+ * search result cards") — the author's declared search-card shape.
58
+ * 2. Otherwise the display field plus the next few business fields in
59
+ * declaration order, skipping system/audit fields and heavy non-tabular
60
+ * types.
61
+ *
62
+ * The result gives every lookup a multi-column, disambiguating picker with zero
63
+ * field-level configuration. Column `type` is carried through so the picker can
64
+ * render currency / select / date cells via the shared cell renderer.
65
+ *
66
+ * Returns an empty array when no usable schema is available, in which case the
67
+ * caller should fall back to the single display-field column.
68
+ */
69
+ export function deriveLookupColumns(
70
+ objectSchema: ObjectSchemaLike | null | undefined,
71
+ { displayField, max = 4 }: DeriveColumnsOptions,
72
+ ): LookupColumnDef[] {
73
+ const fields = objectSchema?.fields ?? {};
74
+ const fieldDef = (name: string) => fields[name] ?? {};
75
+ const toCol = (name: string): LookupColumnDef => {
76
+ const f = fieldDef(name);
77
+ return { field: name, ...(f.label ? { label: f.label } : {}), ...(f.type ? { type: f.type } : {}) };
78
+ };
79
+
80
+ if (Object.keys(fields).length === 0) return [];
81
+
82
+ // 1. Honour the object's explicit search-card fields when present.
83
+ const declared = objectSchema?.displayFields;
84
+ if (Array.isArray(declared) && declared.length > 0) {
85
+ const names = (declared as unknown[]).filter(
86
+ (n): n is string => typeof n === 'string' && n.length > 0,
87
+ );
88
+ const ordered = dedupe([displayField, ...names.filter((n) => n !== displayField)]);
89
+ return ordered.slice(0, max).map(toCol);
90
+ }
91
+
92
+ // 2. Derive from the field set in declaration order.
93
+ const candidates = Object.keys(fields).filter((name) => {
94
+ if (name === displayField) return false;
95
+ if (SYSTEM_FIELDS.has(name)) return false;
96
+ const f = fieldDef(name);
97
+ if (f.hidden) return false;
98
+ if (f.type && NON_TABULAR_TYPES.has(f.type)) return false;
99
+ return true;
100
+ });
101
+
102
+ const ordered = dedupe([displayField, ...candidates]);
103
+ return ordered.slice(0, max).map(toCol);
104
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
3
+
4
+ describe('recentLookups', () => {
5
+ beforeEach(() => localStorage.clear());
6
+
7
+ it('returns [] when nothing stored', () => {
8
+ expect(getRecentLookupIds('acc')).toEqual([]);
9
+ });
10
+
11
+ it('stores and returns ids most-recent-first', () => {
12
+ pushRecentLookupId('acc', 'a');
13
+ pushRecentLookupId('acc', 'b');
14
+ expect(getRecentLookupIds('acc')).toEqual(['b', 'a']);
15
+ });
16
+
17
+ it('de-dupes and moves a re-picked id to the front', () => {
18
+ pushRecentLookupId('acc', 'a');
19
+ pushRecentLookupId('acc', 'b');
20
+ pushRecentLookupId('acc', 'a');
21
+ expect(getRecentLookupIds('acc')).toEqual(['a', 'b']);
22
+ });
23
+
24
+ it('caps at 5 entries', () => {
25
+ for (const id of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) pushRecentLookupId('acc', id);
26
+ const ids = getRecentLookupIds('acc');
27
+ expect(ids).toHaveLength(5);
28
+ expect(ids[0]).toBe('g');
29
+ expect(ids).not.toContain('a');
30
+ });
31
+
32
+ it('scopes per object name', () => {
33
+ pushRecentLookupId('acc', 'a');
34
+ pushRecentLookupId('prod', 'x');
35
+ expect(getRecentLookupIds('acc')).toEqual(['a']);
36
+ expect(getRecentLookupIds('prod')).toEqual(['x']);
37
+ });
38
+
39
+ it('ignores empty/nullish ids and object names', () => {
40
+ pushRecentLookupId('acc', '');
41
+ pushRecentLookupId('acc', null);
42
+ pushRecentLookupId('', 'a');
43
+ expect(getRecentLookupIds('acc')).toEqual([]);
44
+ });
45
+ });