@object-ui/fields 16.0.0 → 17.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 (71) hide show
  1. package/CHANGELOG.md +630 -0
  2. package/README.md +45 -7
  3. package/dist/FieldEditWidget.d.ts +7 -0
  4. package/dist/field-type-alias.d.ts +16 -0
  5. package/dist/index.cjs +5 -5
  6. package/dist/index.d.ts +35 -11
  7. package/dist/index.js +2241 -1853
  8. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  9. package/dist/widgets/CheckboxesField.d.ts +8 -1
  10. package/dist/widgets/FileField.d.ts +1 -1
  11. package/dist/widgets/FilterConditionField.d.ts +32 -0
  12. package/dist/widgets/ImageField.d.ts +1 -1
  13. package/dist/widgets/ImageLightbox.d.ts +30 -0
  14. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  15. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  16. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  17. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  18. package/dist/widgets/MultiSelectField.d.ts +10 -2
  19. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  20. package/dist/widgets/RadioField.d.ts +8 -1
  21. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  22. package/dist/widgets/SelectField.d.ts +13 -9
  23. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  24. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  25. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  26. package/dist/widgets/file-value.d.ts +112 -0
  27. package/dist/widgets/file-value.test.d.ts +1 -0
  28. package/dist/widgets/types.d.ts +6 -0
  29. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  30. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  31. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  32. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  33. package/package.json +11 -10
  34. package/src/FieldEditWidget.test.ts +74 -2
  35. package/src/FieldEditWidget.tsx +44 -4
  36. package/src/field-type-alias.ts +100 -0
  37. package/src/field-type-coverage.test.ts +3 -1
  38. package/src/index.tsx +137 -135
  39. package/src/standard-widgets.test.tsx +44 -0
  40. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  41. package/src/widgets/CheckboxesField.tsx +77 -11
  42. package/src/widgets/FileField.tsx +91 -52
  43. package/src/widgets/FilterConditionField.tsx +109 -9
  44. package/src/widgets/ImageField.tsx +106 -65
  45. package/src/widgets/ImageLightbox.test.tsx +52 -0
  46. package/src/widgets/ImageLightbox.tsx +108 -0
  47. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  48. package/src/widgets/LookupField.idField.test.tsx +61 -0
  49. package/src/widgets/LookupField.tsx +106 -9
  50. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  51. package/src/widgets/MultiSelectField.tsx +78 -11
  52. package/src/widgets/ObjectRefField.tsx +9 -4
  53. package/src/widgets/PeoplePicker.test.tsx +20 -0
  54. package/src/widgets/PeoplePicker.tsx +15 -3
  55. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  56. package/src/widgets/RadioField.tsx +69 -9
  57. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  58. package/src/widgets/RecipientPickerField.tsx +36 -16
  59. package/src/widgets/SelectField.tsx +33 -31
  60. package/src/widgets/TextAreaField.tsx +8 -3
  61. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  62. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  63. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  64. package/src/widgets/deriveLookupColumns.ts +5 -15
  65. package/src/widgets/file-value.test.ts +230 -0
  66. package/src/widgets/file-value.ts +230 -0
  67. package/src/widgets/types.ts +6 -0
  68. package/src/widgets/useCascadingOptions.ts +46 -0
  69. package/src/widgets/useFieldTranslation.ts +30 -0
  70. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  71. package/src/widgets/useUploadingSignal.ts +20 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Map field type to form component type
11
+ *
12
+ * @param fieldType - The ObjectQL field type identifier to convert
13
+ * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
14
+ * @returns The normalized form field type string used in the form schema
15
+ * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
16
+ */
17
+ export function mapFieldTypeToFormType(fieldType: string): string {
18
+ const typeMap: Record<string, string> = {
19
+ // Text-based fields
20
+ text: 'field:text',
21
+ textarea: 'field:textarea',
22
+ markdown: 'field:markdown', // Markdown editor (fallback to textarea)
23
+ html: 'field:html', // Rich text editor (fallback to textarea)
24
+ richtext: 'field:richtext', // WYSIWYG rich-text editor
25
+ secret: 'field:password', // encrypted-at-rest value — mask input like a password
26
+
27
+ // Numeric fields
28
+ number: 'field:number',
29
+ currency: 'field:currency',
30
+ percent: 'field:percent',
31
+ slider: 'field:slider',
32
+ progress: 'field:slider', // bounded 0..100 progress — edit via slider
33
+ rating: 'field:rating',
34
+
35
+ // Date/Time fields
36
+ date: 'field:date',
37
+ datetime: 'field:datetime',
38
+ time: 'field:time',
39
+
40
+ // Boolean
41
+ boolean: 'field:boolean',
42
+ toggle: 'field:boolean', // toggle is a boolean rendered as a switch
43
+
44
+ // Selection fields
45
+ select: 'field:select',
46
+ multiselect: 'field:multiselect',
47
+ radio: 'field:radio',
48
+ checkboxes: 'field:checkboxes',
49
+ tags: 'field:tags',
50
+ lookup: 'field:lookup',
51
+ master_detail: 'field:master_detail',
52
+ tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
53
+ // `user` is a lookup specialized to sys_user; `owner` mirrors it (record
54
+ // ownership). Both render via the UserField person-picker (delegates to the
55
+ // lookup picker). Without these they would fall through to `field:text`.
56
+ user: 'field:user',
57
+ owner: 'field:owner',
58
+
59
+ // Contact fields
60
+ email: 'field:email',
61
+ phone: 'field:phone',
62
+ url: 'field:url',
63
+
64
+ // File / media fields
65
+ file: 'field:file',
66
+ image: 'field:image',
67
+ avatar: 'field:avatar',
68
+ video: 'field:file', // uploads as a file
69
+ audio: 'field:file', // uploads as a file
70
+ signature: 'field:signature',
71
+
72
+ // Special / enhanced fields
73
+ password: 'field:password',
74
+ location: 'field:location', // Location/map field (fallback to input)
75
+ geolocation: 'field:geolocation',
76
+ address: 'field:address',
77
+ color: 'field:color',
78
+ code: 'field:code',
79
+ json: 'field:code', // JSON edited in the code editor
80
+ qrcode: 'field:qrcode',
81
+ vector: 'field:vector',
82
+
83
+ // Embedded structured values (stored as JSON on the row)
84
+ object: 'field:object',
85
+ composite: 'field:object', // embedded object
86
+ record: 'field:object', // name-keyed map
87
+ repeater: 'field:grid', // embedded array of rows
88
+
89
+ // Auto-generated/computed fields (typically read-only)
90
+ formula: 'field:formula',
91
+ summary: 'field:summary',
92
+ // `@objectstack/spec` spells this `autonumber`; the widget map key is
93
+ // `auto_number`. Map both spellings so a spec-typed field/param doesn't
94
+ // fall through to the plain text input.
95
+ autonumber: 'field:auto_number',
96
+ auto_number: 'field:auto_number',
97
+ };
98
+
99
+ return typeMap[fieldType] || 'field:text';
100
+ }
@@ -27,7 +27,9 @@ const FORM_WIDGET_TYPES = [
27
27
  'file', 'image', 'avatar', 'video', 'audio', 'signature',
28
28
  'location', 'geolocation', 'address', 'color', 'code', 'json', 'qrcode', 'vector',
29
29
  'object', 'composite', 'record', 'repeater',
30
- 'formula', 'summary', 'auto_number',
30
+ // `autonumber` is the spec `FieldType` spelling; `auto_number` is the widget
31
+ // map key. Both must resolve to the AutoNumber widget, not text.
32
+ 'formula', 'summary', 'auto_number', 'autonumber',
31
33
  ];
32
34
 
33
35
  /**
package/src/index.tsx CHANGED
@@ -277,12 +277,11 @@ function useFieldLabel() {
277
277
  * fallback in that case.
278
278
  */
279
279
  function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) => string) | undefined {
280
- try {
281
- const { t } = useObjectTranslation();
282
- return t as (key: string, params?: Record<string, unknown>) => string;
283
- } catch {
284
- return undefined;
285
- }
280
+ // useObjectTranslation is provider-safe (optional context read; react-i18next
281
+ // falls back to the global instance), so it never throws — no try/catch, which
282
+ // would wrap a hook call and violate rules-of-hooks. Mirrors useFieldLabel.
283
+ const { t } = useObjectTranslation();
284
+ return t as (key: string, params?: Record<string, unknown>) => string;
286
285
  }
287
286
 
288
287
  import { TextField } from './widgets/TextField';
@@ -304,6 +303,8 @@ import { PercentField } from './widgets/PercentField';
304
303
  import { PasswordField } from './widgets/PasswordField';
305
304
  import { FileField } from './widgets/FileField';
306
305
  import { ImageField } from './widgets/ImageField';
306
+ import { ImageLightbox } from './widgets/ImageLightbox';
307
+ import { readFileValues } from './widgets/file-value';
307
308
  import { LocationField } from './widgets/LocationField';
308
309
  import { FormulaField } from './widgets/FormulaField';
309
310
  import { SummaryField } from './widgets/SummaryField';
@@ -507,7 +508,7 @@ function formatRelativeDays(diffDays: number, locale?: string): string {
507
508
 
508
509
  /**
509
510
  * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
510
- * localized via `Intl.RelativeTimeFormat` (objectstack-ai/framework#3040).
511
+ * localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
511
512
  *
512
513
  * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
513
514
  * isn't overdue, only a past due/deadline-semantic field is. Non-due-like
@@ -1269,50 +1270,88 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
1269
1270
  }
1270
1271
 
1271
1272
  /**
1272
- * Image field cell renderer (with thumbnails)
1273
+ * Image field cell renderer (with thumbnails + click-to-zoom).
1274
+ *
1275
+ * An image value may be a plain URL string, an object ({ url | src | href … }),
1276
+ * a bare `sys_file` id, or an array of any of those. Normalising through
1277
+ * `readFileValues` (which resolves a bare id to its stable download URL) means
1278
+ * a string-URL field, a CDN link, and an unexpanded reference all render a
1279
+ * thumbnail instead of a broken `<img src="">` placeholder.
1280
+ *
1281
+ * Clicking a thumbnail opens a full-screen lightbox (single or gallery). The
1282
+ * click is `stopPropagation`-guarded so, inside a grid row, enlarging an image
1283
+ * doesn't also trigger row navigation.
1273
1284
  */
1274
1285
  export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
1275
- if (!value) return <EmptyValue />;
1286
+ const { t } = useObjectTranslation();
1287
+ const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
1288
+
1289
+ const imgs = React.useMemo(
1290
+ () =>
1291
+ readFileValues(value, 'Image')
1292
+ .filter((v) => v.url)
1293
+ .map((v) => ({ url: v.url as string, name: v.name })),
1294
+ [value],
1295
+ );
1276
1296
 
1277
- // An image value may be a plain URL string, an object ({ url | src | href }),
1278
- // or an array of either. Normalize so a string-URL field (common — e.g. a
1279
- // `cover` seeded with a CDN link) renders a thumbnail instead of a broken
1280
- // <img src=""> placeholder.
1281
- const urlOf = (v: any): string =>
1282
- typeof v === 'string' ? v : (v?.url || v?.src || v?.href || v?.thumbnailUrl || '');
1283
- const nameOf = (v: any, fallback: string): string =>
1284
- (v && typeof v === 'object' && (v.name || v.original_name)) || fallback;
1297
+ if (!value || imgs.length === 0) return <EmptyValue />;
1285
1298
 
1286
- if (Array.isArray(value)) {
1287
- const imgs = value.map((v) => ({ url: urlOf(v), name: nameOf(v, 'Image') })).filter((i) => i.url);
1288
- if (imgs.length === 0) return <EmptyValue />;
1299
+ const imageAlt = (idx: number, name?: string) =>
1300
+ name || t('fields.image.imageAlt', { index: idx + 1 });
1301
+ const open = (idx: number) => (e: React.MouseEvent) => {
1302
+ e.stopPropagation();
1303
+ setLightboxIndex(idx);
1304
+ };
1305
+ const multiple = imgs.length > 1;
1306
+
1307
+ const lightbox = lightboxIndex !== null && (
1308
+ <ImageLightbox
1309
+ images={imgs}
1310
+ index={lightboxIndex}
1311
+ open
1312
+ onOpenChange={(o) => !o && setLightboxIndex(null)}
1313
+ onIndexChange={setLightboxIndex}
1314
+ />
1315
+ );
1316
+
1317
+ if (multiple) {
1289
1318
  return (
1290
- <div className="flex -space-x-2">
1291
- {imgs.slice(0, 3).map((img, idx) => (
1292
- <img
1293
- key={idx}
1294
- src={img.url}
1295
- alt={img.name || `Image ${idx + 1}`}
1296
- className="size-8 rounded-md border-2 border-white object-cover"
1297
- />
1298
- ))}
1299
- {imgs.length > 3 && (
1300
- <div className="size-8 rounded-md border-2 border-white bg-gray-100 flex items-center justify-center text-xs font-medium text-gray-600">
1301
- +{imgs.length - 3}
1302
- </div>
1303
- )}
1304
- </div>
1319
+ <>
1320
+ <div className="flex -space-x-2">
1321
+ {imgs.slice(0, 3).map((img, idx) => (
1322
+ <img
1323
+ key={idx}
1324
+ src={img.url}
1325
+ alt={imageAlt(idx, img.name)}
1326
+ onClick={open(idx)}
1327
+ className="size-8 cursor-zoom-in rounded-md border-2 border-background object-cover transition-transform hover:scale-110 hover:z-10"
1328
+ />
1329
+ ))}
1330
+ {imgs.length > 3 && (
1331
+ <button
1332
+ type="button"
1333
+ onClick={open(3)}
1334
+ className="size-8 rounded-md border-2 border-background bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground hover:bg-muted/80"
1335
+ >
1336
+ +{imgs.length - 3}
1337
+ </button>
1338
+ )}
1339
+ </div>
1340
+ {lightbox}
1341
+ </>
1305
1342
  );
1306
1343
  }
1307
1344
 
1308
- const url = urlOf(value);
1309
- if (!url) return <EmptyValue />;
1310
1345
  return (
1311
- <img
1312
- src={url}
1313
- alt={nameOf(value, 'Image')}
1314
- className="size-10 rounded-md object-cover"
1315
- />
1346
+ <>
1347
+ <img
1348
+ src={imgs[0].url}
1349
+ alt={imageAlt(0, imgs[0].name)}
1350
+ onClick={open(0)}
1351
+ className="size-10 cursor-zoom-in rounded-md object-cover transition-transform hover:scale-105"
1352
+ />
1353
+ {lightbox}
1354
+ </>
1316
1355
  );
1317
1356
  }
1318
1357
 
@@ -1824,94 +1863,10 @@ setCellRendererResolver(getCellRenderer);
1824
1863
 
1825
1864
 
1826
1865
 
1827
- /**
1828
- * Map field type to form component type
1829
- *
1830
- * @param fieldType - The ObjectQL field type identifier to convert
1831
- * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
1832
- * @returns The normalized form field type string used in the form schema
1833
- * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
1834
- */
1835
- export function mapFieldTypeToFormType(fieldType: string): string {
1836
- const typeMap: Record<string, string> = {
1837
- // Text-based fields
1838
- text: 'field:text',
1839
- textarea: 'field:textarea',
1840
- markdown: 'field:markdown', // Markdown editor (fallback to textarea)
1841
- html: 'field:html', // Rich text editor (fallback to textarea)
1842
- richtext: 'field:richtext', // WYSIWYG rich-text editor
1843
- secret: 'field:password', // encrypted-at-rest value — mask input like a password
1844
-
1845
- // Numeric fields
1846
- number: 'field:number',
1847
- currency: 'field:currency',
1848
- percent: 'field:percent',
1849
- slider: 'field:slider',
1850
- progress: 'field:slider', // bounded 0..100 progress — edit via slider
1851
- rating: 'field:rating',
1852
-
1853
- // Date/Time fields
1854
- date: 'field:date',
1855
- datetime: 'field:datetime',
1856
- time: 'field:time',
1857
-
1858
- // Boolean
1859
- boolean: 'field:boolean',
1860
- toggle: 'field:boolean', // toggle is a boolean rendered as a switch
1861
-
1862
- // Selection fields
1863
- select: 'field:select',
1864
- multiselect: 'field:multiselect',
1865
- radio: 'field:radio',
1866
- checkboxes: 'field:checkboxes',
1867
- tags: 'field:tags',
1868
- lookup: 'field:lookup',
1869
- master_detail: 'field:master_detail',
1870
- tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
1871
- // `user` is a lookup specialized to sys_user; `owner` mirrors it (record
1872
- // ownership). Both render via the UserField person-picker (delegates to the
1873
- // lookup picker). Without these they would fall through to `field:text`.
1874
- user: 'field:user',
1875
- owner: 'field:owner',
1876
-
1877
- // Contact fields
1878
- email: 'field:email',
1879
- phone: 'field:phone',
1880
- url: 'field:url',
1881
-
1882
- // File / media fields
1883
- file: 'field:file',
1884
- image: 'field:image',
1885
- avatar: 'field:avatar',
1886
- video: 'field:file', // uploads as a file
1887
- audio: 'field:file', // uploads as a file
1888
- signature: 'field:signature',
1889
-
1890
- // Special / enhanced fields
1891
- password: 'field:password',
1892
- location: 'field:location', // Location/map field (fallback to input)
1893
- geolocation: 'field:geolocation',
1894
- address: 'field:address',
1895
- color: 'field:color',
1896
- code: 'field:code',
1897
- json: 'field:code', // JSON edited in the code editor
1898
- qrcode: 'field:qrcode',
1899
- vector: 'field:vector',
1900
-
1901
- // Embedded structured values (stored as JSON on the row)
1902
- object: 'field:object',
1903
- composite: 'field:object', // embedded object
1904
- record: 'field:object', // name-keyed map
1905
- repeater: 'field:grid', // embedded array of rows
1906
-
1907
- // Auto-generated/computed fields (typically read-only)
1908
- formula: 'field:formula',
1909
- summary: 'field:summary',
1910
- auto_number: 'field:auto_number',
1911
- };
1912
-
1913
- return typeMap[fieldType] || 'field:text';
1914
- }
1866
+ // `mapFieldTypeToFormType` moved to './field-type-alias' (re-exported below) so
1867
+ // FieldEditWidget can resolve spec aliases without importing this barrel.
1868
+ export { mapFieldTypeToFormType } from './field-type-alias';
1869
+ import { mapFieldTypeToFormType } from './field-type-alias';
1915
1870
 
1916
1871
  /**
1917
1872
  * Formats file size in bytes to human-readable string
@@ -1963,18 +1918,23 @@ export function buildValidationRules(field: any): any {
1963
1918
  // vars); a field-authored `*_message` is a string and passes through as-is,
1964
1919
  // still winning over the localized default. See form.tsx `localizeRule`.
1965
1920
 
1966
- // Length validation for text fields
1967
- if (field.min_length) {
1921
+ // Length validation for text fields. The spec-canonical keys are camelCase
1922
+ // (`minLength`/`maxLength`, @objectstack/spec FieldSchema — what the server
1923
+ // record-validator enforces); the snake_case pair is the legacy objectui
1924
+ // spelling, kept as fallback (framework#1878/#1891 naming-drift closeout).
1925
+ const minLength = (field as any).minLength ?? field.min_length;
1926
+ if (minLength) {
1968
1927
  rules.minLength = {
1969
- value: field.min_length,
1928
+ value: minLength,
1970
1929
  message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
1971
1930
  messageKey: 'validation.minLength',
1972
1931
  };
1973
1932
  }
1974
1933
 
1975
- if (field.max_length) {
1934
+ const maxLength = (field as any).maxLength ?? field.max_length;
1935
+ if (maxLength) {
1976
1936
  rules.maxLength = {
1977
- value: field.max_length,
1937
+ value: maxLength,
1978
1938
  message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
1979
1939
  messageKey: 'validation.maxLength',
1980
1940
  };
@@ -2222,6 +2182,44 @@ const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentTyp
2222
2182
  */
2223
2183
  export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fieldWidgetMap));
2224
2184
 
2185
+ /**
2186
+ * Resolve an arbitrary field-type spelling to the widget key the form uses.
2187
+ * A key already present in the widget map resolves to itself; spec aliases
2188
+ * (`toggle`, `json`, `repeater`, `secret`, …) resolve through
2189
+ * {@link mapFieldTypeToFormType}; anything unknown falls back to `text` —
2190
+ * the same fallback the form renderer applies.
2191
+ *
2192
+ * Consumers that render field widgets outside the form (e.g. the app-shell
2193
+ * `ActionParamDialog`) use this + {@link getLazyFieldWidget} so their type
2194
+ * support can never drift behind the form surface (ADR-0059).
2195
+ */
2196
+ export function resolveFormWidgetType(fieldType: string): string {
2197
+ if (fieldWidgetMap[fieldType]) return fieldType;
2198
+ const mapped = mapFieldTypeToFormType(fieldType).replace(/^field:/, '');
2199
+ return fieldWidgetMap[mapped] ? mapped : 'text';
2200
+ }
2201
+
2202
+ /** Cache so each widget type creates one lazy component (and one chunk request). */
2203
+ const lazyFieldWidgets = new Map<string, React.ComponentType<any>>();
2204
+
2205
+ /**
2206
+ * Lazily-loaded form field widget for a field type. Shares the exact loaders
2207
+ * of {@link fieldWidgetMap} (the same components `registerField` registers for
2208
+ * forms), wrapped in `React.lazy` and cached per type — so a consumer can
2209
+ * render any form-supported field type without eagerly bundling every widget.
2210
+ * Render inside a `<Suspense>` boundary. Unknown types resolve to the `text`
2211
+ * widget via {@link resolveFormWidgetType}.
2212
+ */
2213
+ export function getLazyFieldWidget(fieldType: string): React.ComponentType<any> {
2214
+ const key = resolveFormWidgetType(fieldType);
2215
+ let Widget = lazyFieldWidgets.get(key);
2216
+ if (!Widget) {
2217
+ Widget = React.lazy(fieldWidgetMap[key]);
2218
+ lazyFieldWidgets.set(key, Widget);
2219
+ }
2220
+ return Widget;
2221
+ }
2222
+
2225
2223
  /**
2226
2224
  * Register a specific field type lazily
2227
2225
  * @param fieldType - The field type to register (e.g., 'text', 'number')
@@ -2397,6 +2395,10 @@ export function registerFields() {
2397
2395
  }
2398
2396
 
2399
2397
  export * from './widgets/types';
2398
+ // File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
2399
+ // arbiter of reference vs expanded vs legacy-blob form, shared by the upload
2400
+ // widgets and by action-param serialization.
2401
+ export * from './widgets/file-value';
2400
2402
  export * from './FieldEditWidget';
2401
2403
  export * from './widgets/TextField';
2402
2404
  export * from './widgets/NumberField';
@@ -213,6 +213,50 @@ describe('Standard Field Widgets', () => {
213
213
  );
214
214
  expect(container.textContent).toBe('Option A');
215
215
  });
216
+
217
+ // A `select` declared `multiple` is a multi-value picker (spec allows
218
+ // `multiple` on select). SelectField delegates to the chip picker so the
219
+ // form, inline editor, and ActionParamDialog all get multi-select from the
220
+ // one `select` widget — no single-select fallback, no per-surface drift.
221
+ it('renders the multi-value chip picker when field.multiple is set', () => {
222
+ render(
223
+ <SelectField
224
+ value={['a']}
225
+ onChange={mockOnChange}
226
+ field={{ ...fieldMock, multiple: true } as any}
227
+ />
228
+ );
229
+ // Chip picker uses toggle buttons (aria-pressed), not a Radix combobox.
230
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
231
+ const optionA = screen.getByRole('button', { name: 'Option A' });
232
+ expect(optionA).toHaveAttribute('aria-pressed', 'true');
233
+ expect(screen.getByRole('button', { name: 'Option B' })).toHaveAttribute('aria-pressed', 'false');
234
+ });
235
+
236
+ it('multi-select toggles values as an array', () => {
237
+ render(
238
+ <SelectField
239
+ value={['a']}
240
+ onChange={mockOnChange}
241
+ field={{ ...fieldMock, multiple: true } as any}
242
+ />
243
+ );
244
+ fireEvent.click(screen.getByRole('button', { name: 'Option B' }));
245
+ expect(mockOnChange).toHaveBeenCalledWith(['a', 'b']);
246
+ });
247
+
248
+ it('multi-select renders selected values as badges in readonly mode', () => {
249
+ const { container } = render(
250
+ <SelectField
251
+ value={['a', 'b']}
252
+ onChange={mockOnChange}
253
+ field={{ ...fieldMock, multiple: true } as any}
254
+ readonly={true}
255
+ />
256
+ );
257
+ expect(container.textContent).toContain('Option A');
258
+ expect(container.textContent).toContain('Option B');
259
+ });
216
260
  });
217
261
 
218
262
  describe('BooleanField', () => {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Cascading / role-gated `checkboxes` options (#2715 follow-up) — parity with
11
+ * `MultiSelectField` (ADR-0058 / #2284).
12
+ *
13
+ * Exercises the observable widget behaviour: the dependency gate (a "select the
14
+ * parent first" empty-state) and the per-element cascade clear — a selection
15
+ * dropped from the array when the offered set no longer includes it, while
16
+ * still-valid selections are kept. The per-option filtering itself is unit-tested
17
+ * in `@object-ui/core` (`optionRules.test.ts`); here we prove the checkbox list
18
+ * wires `dependentValues` + predicate scope into that resolver via the shared
19
+ * `useCascadingOptions` hook.
20
+ */
21
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
22
+ import React from 'react';
23
+ import { render, screen } from '@testing-library/react';
24
+ import '@testing-library/jest-dom';
25
+ import { PredicateScopeProvider } from '@object-ui/react';
26
+ import { CheckboxesField } from './CheckboxesField';
27
+
28
+ const provinceField = {
29
+ name: 'provinces',
30
+ type: 'checkboxes',
31
+ dependsOn: 'country',
32
+ options: [
33
+ { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
34
+ { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
35
+ ],
36
+ } as any;
37
+
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ describe('CheckboxesField — dependency gating (#2715)', () => {
43
+ it('gates with a "select parent first" hint while the controlling field is empty', () => {
44
+ render(
45
+ <CheckboxesField
46
+ value={[]}
47
+ onChange={vi.fn()}
48
+ field={provinceField}
49
+ {...({ name: 'provinces', dependentValues: {} } as any)}
50
+ />,
51
+ );
52
+ const gate = screen.getByTestId('checkboxes-empty-provinces');
53
+ expect(gate).toHaveTextContent(/select country first/i);
54
+ // Gated → no checkboxes offered.
55
+ expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
56
+ });
57
+
58
+ it('unlocks the checkboxes once the controlling field is set', () => {
59
+ render(
60
+ <CheckboxesField
61
+ value={[]}
62
+ onChange={vi.fn()}
63
+ field={provinceField}
64
+ {...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
65
+ />,
66
+ );
67
+ expect(screen.queryByTestId('checkboxes-empty-provinces')).not.toBeInTheDocument();
68
+ // Only the `cn` option is offered.
69
+ expect(screen.getByRole('checkbox', { name: 'Zhejiang' })).toBeInTheDocument();
70
+ expect(screen.queryByRole('checkbox', { name: 'California' })).not.toBeInTheDocument();
71
+ });
72
+ });
73
+
74
+ describe('CheckboxesField — cascade clear (#2715)', () => {
75
+ it('drops only the selections the parent change no longer offers', () => {
76
+ const onChange = vi.fn();
77
+ render(
78
+ <CheckboxesField
79
+ value={['zj', 'ca']}
80
+ onChange={onChange}
81
+ field={provinceField}
82
+ {...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
83
+ />,
84
+ );
85
+ // 'ca' is a US province — under country=cn it is not offered, so it is
86
+ // pruned; the still-valid 'zj' is kept.
87
+ expect(onChange).toHaveBeenCalledWith(['zj']);
88
+ });
89
+
90
+ it('keeps selections that are all still offered', () => {
91
+ const onChange = vi.fn();
92
+ render(
93
+ <CheckboxesField
94
+ value={['zj']}
95
+ onChange={onChange}
96
+ field={provinceField}
97
+ {...({ name: 'provinces', dependentValues: { country: 'cn' } } as any)}
98
+ />,
99
+ );
100
+ expect(onChange).not.toHaveBeenCalled();
101
+ });
102
+ });
103
+
104
+ describe('CheckboxesField — role / context gating (#2715)', () => {
105
+ const tierField = {
106
+ name: 'tiers',
107
+ type: 'checkboxes',
108
+ options: [
109
+ { label: 'Standard', value: 'standard' },
110
+ { label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
111
+ ],
112
+ } as any;
113
+
114
+ it('prunes an admin-only value for a non-admin (offered set excludes it)', () => {
115
+ const onChange = vi.fn();
116
+ render(
117
+ <PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
118
+ <CheckboxesField
119
+ value={['standard', 'admin_only']}
120
+ onChange={onChange}
121
+ field={tierField}
122
+ {...({ name: 'tiers', dependentValues: {} } as any)}
123
+ />
124
+ </PredicateScopeProvider>,
125
+ );
126
+ expect(onChange).toHaveBeenCalledWith(['standard']);
127
+ });
128
+
129
+ it('keeps an admin-only value for an admin', () => {
130
+ const onChange = vi.fn();
131
+ render(
132
+ <PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
133
+ <CheckboxesField
134
+ value={['standard', 'admin_only']}
135
+ onChange={onChange}
136
+ field={tierField}
137
+ {...({ name: 'tiers', dependentValues: {} } as any)}
138
+ />
139
+ </PredicateScopeProvider>,
140
+ );
141
+ expect(onChange).not.toHaveBeenCalled();
142
+ });
143
+ });