@object-ui/fields 16.1.0 → 17.2.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 (129) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/FieldEditWidget.d.ts +9 -2
  3. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  4. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  5. package/dist/field-type-alias.d.ts +16 -0
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.d.ts +14 -11
  8. package/dist/index.js +2426 -2100
  9. package/dist/widgets/AddressField.d.ts +2 -2
  10. package/dist/widgets/AutoNumberField.d.ts +2 -2
  11. package/dist/widgets/AvatarField.d.ts +2 -2
  12. package/dist/widgets/BooleanField.d.ts +2 -2
  13. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  14. package/dist/widgets/CheckboxesField.d.ts +2 -2
  15. package/dist/widgets/CodeField.d.ts +2 -2
  16. package/dist/widgets/ColorField.d.ts +2 -2
  17. package/dist/widgets/CurrencyField.d.ts +2 -2
  18. package/dist/widgets/DateField.d.ts +2 -2
  19. package/dist/widgets/DateTimeField.d.ts +2 -2
  20. package/dist/widgets/EmailField.d.ts +2 -2
  21. package/dist/widgets/FileField.d.ts +2 -2
  22. package/dist/widgets/FilterConditionField.d.ts +34 -2
  23. package/dist/widgets/FormulaField.d.ts +2 -2
  24. package/dist/widgets/GeolocationField.d.ts +2 -2
  25. package/dist/widgets/GridField.d.ts +2 -2
  26. package/dist/widgets/ImageField.d.ts +2 -2
  27. package/dist/widgets/ImageLightbox.d.ts +30 -0
  28. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  29. package/dist/widgets/LocationField.d.ts +2 -2
  30. package/dist/widgets/LookupField.d.ts +2 -2
  31. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  32. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  33. package/dist/widgets/MasterDetailField.d.ts +2 -2
  34. package/dist/widgets/MultiSelectField.d.ts +2 -2
  35. package/dist/widgets/NumberField.d.ts +2 -2
  36. package/dist/widgets/ObjectField.d.ts +2 -2
  37. package/dist/widgets/ObjectRefField.d.ts +2 -2
  38. package/dist/widgets/PasswordField.d.ts +2 -2
  39. package/dist/widgets/PercentField.d.ts +2 -2
  40. package/dist/widgets/PhoneField.d.ts +2 -2
  41. package/dist/widgets/QRCodeField.d.ts +2 -2
  42. package/dist/widgets/RadioField.d.ts +2 -2
  43. package/dist/widgets/RatingField.d.ts +2 -2
  44. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  45. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  46. package/dist/widgets/RichTextField.d.ts +2 -2
  47. package/dist/widgets/SelectField.d.ts +2 -2
  48. package/dist/widgets/SignatureField.d.ts +2 -2
  49. package/dist/widgets/SliderField.d.ts +2 -2
  50. package/dist/widgets/SummaryField.d.ts +2 -2
  51. package/dist/widgets/TagsField.d.ts +2 -2
  52. package/dist/widgets/TextAreaField.d.ts +2 -2
  53. package/dist/widgets/TextField.d.ts +2 -2
  54. package/dist/widgets/TimeField.d.ts +2 -2
  55. package/dist/widgets/UrlField.d.ts +2 -2
  56. package/dist/widgets/UserField.d.ts +2 -2
  57. package/dist/widgets/VectorField.d.ts +2 -2
  58. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  59. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  60. package/dist/widgets/file-value.d.ts +99 -0
  61. package/dist/widgets/file-value.test.d.ts +1 -0
  62. package/dist/widgets/types.d.ts +119 -3
  63. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  64. package/package.json +9 -8
  65. package/src/FieldEditWidget.test.ts +74 -2
  66. package/src/FieldEditWidget.tsx +50 -8
  67. package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
  68. package/src/__tests__/widget-props-contract.test.tsx +129 -0
  69. package/src/complex-widgets.test.tsx +3 -3
  70. package/src/datetime-widgets.test.tsx +2 -2
  71. package/src/field-type-alias.ts +100 -0
  72. package/src/index.tsx +97 -134
  73. package/src/widgets/AddressField.tsx +2 -2
  74. package/src/widgets/AutoNumberField.tsx +2 -2
  75. package/src/widgets/AvatarField.tsx +2 -2
  76. package/src/widgets/BooleanField.tsx +2 -2
  77. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  78. package/src/widgets/CheckboxesField.tsx +12 -9
  79. package/src/widgets/CodeField.tsx +2 -2
  80. package/src/widgets/ColorField.tsx +2 -2
  81. package/src/widgets/CurrencyField.tsx +2 -2
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +2 -2
  85. package/src/widgets/FileField.tsx +90 -59
  86. package/src/widgets/FilterConditionField.tsx +112 -12
  87. package/src/widgets/FormulaField.tsx +2 -2
  88. package/src/widgets/GeolocationField.tsx +2 -2
  89. package/src/widgets/GridField.tsx +3 -3
  90. package/src/widgets/ImageField.tsx +73 -35
  91. package/src/widgets/ImageLightbox.test.tsx +52 -0
  92. package/src/widgets/ImageLightbox.tsx +108 -0
  93. package/src/widgets/LocationField.tsx +2 -2
  94. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  95. package/src/widgets/LookupField.idField.test.tsx +61 -0
  96. package/src/widgets/LookupField.tsx +116 -19
  97. package/src/widgets/MasterDetailField.tsx +2 -2
  98. package/src/widgets/MultiSelectField.tsx +10 -7
  99. package/src/widgets/NumberField.tsx +2 -2
  100. package/src/widgets/ObjectField.tsx +2 -2
  101. package/src/widgets/ObjectRefField.tsx +13 -8
  102. package/src/widgets/PasswordField.tsx +2 -2
  103. package/src/widgets/PeoplePicker.test.tsx +20 -0
  104. package/src/widgets/PeoplePicker.tsx +15 -3
  105. package/src/widgets/PercentField.tsx +2 -2
  106. package/src/widgets/PhoneField.tsx +2 -2
  107. package/src/widgets/QRCodeField.tsx +2 -2
  108. package/src/widgets/RadioField.tsx +10 -7
  109. package/src/widgets/RatingField.tsx +2 -2
  110. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  111. package/src/widgets/RecipientPickerField.tsx +40 -20
  112. package/src/widgets/RichTextField.tsx +2 -2
  113. package/src/widgets/SelectField.tsx +5 -5
  114. package/src/widgets/SignatureField.tsx +2 -2
  115. package/src/widgets/SliderField.tsx +2 -2
  116. package/src/widgets/SummaryField.tsx +2 -2
  117. package/src/widgets/TagsField.tsx +3 -3
  118. package/src/widgets/TextAreaField.tsx +6 -3
  119. package/src/widgets/TextField.tsx +2 -2
  120. package/src/widgets/TimeField.tsx +2 -2
  121. package/src/widgets/UrlField.tsx +2 -2
  122. package/src/widgets/UserField.tsx +2 -2
  123. package/src/widgets/VectorField.tsx +2 -2
  124. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  125. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  126. package/src/widgets/file-value.test.ts +230 -0
  127. package/src/widgets/file-value.ts +246 -0
  128. package/src/widgets/types.ts +129 -6
  129. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -0,0 +1,129 @@
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
+ * `FieldWidgetComponentProps` is a CLOSED contract (objectui#3221).
11
+ *
12
+ * It used to end in `[key: string]: any`, which made every check over it
13
+ * vacuous: a type claiming to have every key can never be reported as missing
14
+ * one (objectstack#4075). Three consequences, one test each below:
15
+ *
16
+ * 1. a key the type does not declare is a compile error, not a silent `any`
17
+ * — which is what makes the `error` / `errorMessage` divergence
18
+ * (objectui#3222) decidable by the compiler at all;
19
+ * 2. a MISSPELLED prop (`readOnly` for `readonly`) is rejected;
20
+ * 3. the pass-through keys hosts genuinely forward still type-check, and
21
+ * still reach the rendered control at runtime — closing the type must not
22
+ * have been paid for by dropping real behaviour.
23
+ *
24
+ * (1) and (2) are compile-time: a violation fails `pnpm --filter
25
+ * @object-ui/fields type-check`, not this run. `@ts-expect-error` inverts
26
+ * that — the line fails to compile if the error it expects ever STOPS
27
+ * happening, i.e. if the index signature comes back.
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { render, screen } from '@testing-library/react';
32
+
33
+ import { TextField } from '../widgets/TextField';
34
+ import { BooleanField } from '../widgets/BooleanField';
35
+ import type { FieldWidgetComponentProps } from '../widgets/types';
36
+ import type { FieldMetadata } from '@object-ui/types';
37
+
38
+ type Assert<T extends true> = T;
39
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
40
+ type Extends<A, B> = [A] extends [B] ? true : false;
41
+
42
+ const field = { name: 'title', label: 'Title', type: 'text' } as unknown as FieldMetadata;
43
+
44
+ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
45
+ it('has no `[key: string]` index signature', () => {
46
+ // The single assertion the whole change turns on. While the index
47
+ // signature existed this was `true`, and every check below was vacuous.
48
+ type _NotOpen = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
49
+ expect(true).toBe(true);
50
+ });
51
+
52
+ it('rejects keys it does not declare', () => {
53
+ const props = {} as FieldWidgetComponentProps<string>;
54
+
55
+ // The spec's `FieldWidgetPropsSchema` declares both; this type declares
56
+ // neither. Reading them used to give `any` and `undefined` forever.
57
+ // Whether to ADD them is objectui#3222 — deliberately not decided here.
58
+ // @ts-expect-error `required` is not part of this contract
59
+ void props.required;
60
+ // @ts-expect-error `error` is not part of this contract — this type's slot is `errorMessage`
61
+ void props.error;
62
+
63
+ // …and the class of bug the index signature hid best: a typo.
64
+ // @ts-expect-error the prop is `readonly`, not React's DOM `readOnly`
65
+ void props.readOnly;
66
+ // @ts-expect-error the prop is `onChange`
67
+ void props.onchange;
68
+
69
+ expect(true).toBe(true);
70
+ });
71
+
72
+ it('still accepts the pass-through keys hosts actually forward', () => {
73
+ // Closing the type is only correct if the real call sites still compile.
74
+ // Each key here has a named producer: the form renderer, the inline-edit
75
+ // host, or the line-item grid. Deleting one from the type breaks this.
76
+ const passThrough: FieldWidgetComponentProps<string> = {
77
+ value: '',
78
+ onChange: () => {},
79
+ field,
80
+ // form renderer (`renderFieldComponent`)
81
+ schema: field,
82
+ dataSource: {},
83
+ dependentValues: { country: 'cn' },
84
+ dependsOn: ['country'],
85
+ emptyHint: 'Select country first',
86
+ name: 'title',
87
+ // inline-edit hosts / line-item grid
88
+ compact: true,
89
+ autoFocus: true,
90
+ onSelectRecord: () => {},
91
+ onCreateNew: () => {},
92
+ // DOM / a11y
93
+ id: 'title-input',
94
+ 'aria-label': 'Title',
95
+ 'data-testid': 'field-title',
96
+ };
97
+ expect(passThrough.compact).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe('closing the type kept the pass-through behaviour', () => {
102
+ it('forwards a11y and data attributes to the rendered input', () => {
103
+ render(
104
+ <TextField
105
+ value="hello"
106
+ onChange={() => {}}
107
+ field={field}
108
+ aria-label="Title"
109
+ data-testid="title-input"
110
+ />,
111
+ );
112
+ const input = screen.getByTestId('title-input');
113
+ expect(input).toHaveAttribute('aria-label', 'Title');
114
+ expect(input).toHaveValue('hello');
115
+ });
116
+
117
+ it('forwards `disabled` — a declared key, not an index-signature accident', () => {
118
+ render(
119
+ <BooleanField
120
+ value={false}
121
+ onChange={() => {}}
122
+ field={{ name: 'active', label: 'Active', type: 'boolean' } as unknown as FieldMetadata}
123
+ disabled
124
+ data-testid="active-switch"
125
+ />,
126
+ );
127
+ expect(screen.getByTestId('active-switch')).toBeDisabled();
128
+ });
129
+ });
@@ -6,7 +6,7 @@ import { FieldEditWidget } from './FieldEditWidget';
6
6
  import { MasterDetailField } from './widgets/MasterDetailField';
7
7
  import { GridField } from './widgets/GridField';
8
8
  import { FileField } from './widgets/FileField';
9
- import type { FieldWidgetProps } from './widgets/types';
9
+ import type { FieldWidgetComponentProps } from './widgets/types';
10
10
 
11
11
  // ------------- Mocks & Setup -------------
12
12
 
@@ -15,7 +15,7 @@ const mockField = {
15
15
  label: 'Test Field',
16
16
  } as any;
17
17
 
18
- const baseProps: FieldWidgetProps<any> = {
18
+ const baseProps: FieldWidgetComponentProps<any> = {
19
19
  field: mockField,
20
20
  value: undefined,
21
21
  onChange: vi.fn(),
@@ -78,7 +78,7 @@ describe('Complex & Relationship Widgets', () => {
78
78
  reference_field: 'name',
79
79
  } as any;
80
80
 
81
- const dynamicProps: FieldWidgetProps<any> = {
81
+ const dynamicProps: FieldWidgetComponentProps<any> = {
82
82
  ...baseProps,
83
83
  field: dynamicField,
84
84
  dataSource: mockDataSource,
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
3
3
  import { DateField } from './widgets/DateField';
4
4
  import { DateTimeField } from './widgets/DateTimeField';
5
5
  import { TimeField } from './widgets/TimeField';
6
- import type { FieldWidgetProps } from './widgets/types';
6
+ import type { FieldWidgetComponentProps } from './widgets/types';
7
7
 
8
8
  const mockField = {
9
9
  name: 'test_field',
@@ -11,7 +11,7 @@ const mockField = {
11
11
  type: 'date',
12
12
  } as any;
13
13
 
14
- const baseProps: FieldWidgetProps<string> = {
14
+ const baseProps: FieldWidgetComponentProps<string> = {
15
15
  field: mockField,
16
16
  value: '',
17
17
  onChange: vi.fn(),
@@ -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
+ }
package/src/index.tsx CHANGED
@@ -303,6 +303,8 @@ import { PercentField } from './widgets/PercentField';
303
303
  import { PasswordField } from './widgets/PasswordField';
304
304
  import { FileField } from './widgets/FileField';
305
305
  import { ImageField } from './widgets/ImageField';
306
+ import { ImageLightbox } from './widgets/ImageLightbox';
307
+ import { readFileValues } from './widgets/file-value';
306
308
  import { LocationField } from './widgets/LocationField';
307
309
  import { FormulaField } from './widgets/FormulaField';
308
310
  import { SummaryField } from './widgets/SummaryField';
@@ -506,7 +508,7 @@ function formatRelativeDays(diffDays: number, locale?: string): string {
506
508
 
507
509
  /**
508
510
  * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
509
- * localized via `Intl.RelativeTimeFormat` (objectstack-ai/framework#3040).
511
+ * localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
510
512
  *
511
513
  * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
512
514
  * isn't overdue, only a past due/deadline-semantic field is. Non-due-like
@@ -1268,50 +1270,88 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
1268
1270
  }
1269
1271
 
1270
1272
  /**
1271
- * 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.
1272
1284
  */
1273
1285
  export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
1274
- 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
+ );
1275
1296
 
1276
- // An image value may be a plain URL string, an object ({ url | src | href }),
1277
- // or an array of either. Normalize so a string-URL field (common — e.g. a
1278
- // `cover` seeded with a CDN link) renders a thumbnail instead of a broken
1279
- // <img src=""> placeholder.
1280
- const urlOf = (v: any): string =>
1281
- typeof v === 'string' ? v : (v?.url || v?.src || v?.href || v?.thumbnailUrl || '');
1282
- const nameOf = (v: any, fallback: string): string =>
1283
- (v && typeof v === 'object' && (v.name || v.original_name)) || fallback;
1297
+ if (!value || imgs.length === 0) return <EmptyValue />;
1284
1298
 
1285
- if (Array.isArray(value)) {
1286
- const imgs = value.map((v) => ({ url: urlOf(v), name: nameOf(v, 'Image') })).filter((i) => i.url);
1287
- 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) {
1288
1318
  return (
1289
- <div className="flex -space-x-2">
1290
- {imgs.slice(0, 3).map((img, idx) => (
1291
- <img
1292
- key={idx}
1293
- src={img.url}
1294
- alt={img.name || `Image ${idx + 1}`}
1295
- className="size-8 rounded-md border-2 border-white object-cover"
1296
- />
1297
- ))}
1298
- {imgs.length > 3 && (
1299
- <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">
1300
- +{imgs.length - 3}
1301
- </div>
1302
- )}
1303
- </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
+ </>
1304
1342
  );
1305
1343
  }
1306
1344
 
1307
- const url = urlOf(value);
1308
- if (!url) return <EmptyValue />;
1309
1345
  return (
1310
- <img
1311
- src={url}
1312
- alt={nameOf(value, 'Image')}
1313
- className="size-10 rounded-md object-cover"
1314
- />
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
+ </>
1315
1355
  );
1316
1356
  }
1317
1357
 
@@ -1823,98 +1863,10 @@ setCellRendererResolver(getCellRenderer);
1823
1863
 
1824
1864
 
1825
1865
 
1826
- /**
1827
- * Map field type to form component type
1828
- *
1829
- * @param fieldType - The ObjectQL field type identifier to convert
1830
- * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
1831
- * @returns The normalized form field type string used in the form schema
1832
- * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
1833
- */
1834
- export function mapFieldTypeToFormType(fieldType: string): string {
1835
- const typeMap: Record<string, string> = {
1836
- // Text-based fields
1837
- text: 'field:text',
1838
- textarea: 'field:textarea',
1839
- markdown: 'field:markdown', // Markdown editor (fallback to textarea)
1840
- html: 'field:html', // Rich text editor (fallback to textarea)
1841
- richtext: 'field:richtext', // WYSIWYG rich-text editor
1842
- secret: 'field:password', // encrypted-at-rest value — mask input like a password
1843
-
1844
- // Numeric fields
1845
- number: 'field:number',
1846
- currency: 'field:currency',
1847
- percent: 'field:percent',
1848
- slider: 'field:slider',
1849
- progress: 'field:slider', // bounded 0..100 progress — edit via slider
1850
- rating: 'field:rating',
1851
-
1852
- // Date/Time fields
1853
- date: 'field:date',
1854
- datetime: 'field:datetime',
1855
- time: 'field:time',
1856
-
1857
- // Boolean
1858
- boolean: 'field:boolean',
1859
- toggle: 'field:boolean', // toggle is a boolean rendered as a switch
1860
-
1861
- // Selection fields
1862
- select: 'field:select',
1863
- multiselect: 'field:multiselect',
1864
- radio: 'field:radio',
1865
- checkboxes: 'field:checkboxes',
1866
- tags: 'field:tags',
1867
- lookup: 'field:lookup',
1868
- master_detail: 'field:master_detail',
1869
- tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
1870
- // `user` is a lookup specialized to sys_user; `owner` mirrors it (record
1871
- // ownership). Both render via the UserField person-picker (delegates to the
1872
- // lookup picker). Without these they would fall through to `field:text`.
1873
- user: 'field:user',
1874
- owner: 'field:owner',
1875
-
1876
- // Contact fields
1877
- email: 'field:email',
1878
- phone: 'field:phone',
1879
- url: 'field:url',
1880
-
1881
- // File / media fields
1882
- file: 'field:file',
1883
- image: 'field:image',
1884
- avatar: 'field:avatar',
1885
- video: 'field:file', // uploads as a file
1886
- audio: 'field:file', // uploads as a file
1887
- signature: 'field:signature',
1888
-
1889
- // Special / enhanced fields
1890
- password: 'field:password',
1891
- location: 'field:location', // Location/map field (fallback to input)
1892
- geolocation: 'field:geolocation',
1893
- address: 'field:address',
1894
- color: 'field:color',
1895
- code: 'field:code',
1896
- json: 'field:code', // JSON edited in the code editor
1897
- qrcode: 'field:qrcode',
1898
- vector: 'field:vector',
1899
-
1900
- // Embedded structured values (stored as JSON on the row)
1901
- object: 'field:object',
1902
- composite: 'field:object', // embedded object
1903
- record: 'field:object', // name-keyed map
1904
- repeater: 'field:grid', // embedded array of rows
1905
-
1906
- // Auto-generated/computed fields (typically read-only)
1907
- formula: 'field:formula',
1908
- summary: 'field:summary',
1909
- // `@objectstack/spec` spells this `autonumber`; the widget map key is
1910
- // `auto_number`. Map both spellings so a spec-typed field/param doesn't
1911
- // fall through to the plain text input.
1912
- autonumber: 'field:auto_number',
1913
- auto_number: 'field:auto_number',
1914
- };
1915
-
1916
- return typeMap[fieldType] || 'field:text';
1917
- }
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';
1918
1870
 
1919
1871
  /**
1920
1872
  * Formats file size in bytes to human-readable string
@@ -1966,18 +1918,23 @@ export function buildValidationRules(field: any): any {
1966
1918
  // vars); a field-authored `*_message` is a string and passes through as-is,
1967
1919
  // still winning over the localized default. See form.tsx `localizeRule`.
1968
1920
 
1969
- // Length validation for text fields
1970
- 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) {
1971
1927
  rules.minLength = {
1972
- value: field.min_length,
1928
+ value: minLength,
1973
1929
  message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
1974
1930
  messageKey: 'validation.minLength',
1975
1931
  };
1976
1932
  }
1977
1933
 
1978
- if (field.max_length) {
1934
+ const maxLength = (field as any).maxLength ?? field.max_length;
1935
+ if (maxLength) {
1979
1936
  rules.maxLength = {
1980
- value: field.max_length,
1937
+ value: maxLength,
1981
1938
  message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
1982
1939
  messageKey: 'validation.maxLength',
1983
1940
  };
@@ -2091,7 +2048,9 @@ function createFieldRenderer(FieldWidget: React.ComponentType<any>) {
2091
2048
  readonly: schema?.readonly || schema?.readOnly,
2092
2049
  help: schema?.help,
2093
2050
  description: schema?.description,
2094
- defaultValue: schema?.defaultValue || schema?.value,
2051
+ // `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
2052
+ // and `||` silently threw it away (same falsy-as-empty class as cloud#972).
2053
+ defaultValue: schema?.defaultValue ?? schema?.value,
2095
2054
  ...schema,
2096
2055
  };
2097
2056
 
@@ -2438,6 +2397,10 @@ export function registerFields() {
2438
2397
  }
2439
2398
 
2440
2399
  export * from './widgets/types';
2400
+ // File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
2401
+ // arbiter of reference vs expanded vs legacy-blob form, shared by the upload
2402
+ // widgets and by action-param serialization.
2403
+ export * from './widgets/file-value';
2441
2404
  export * from './FieldEditWidget';
2442
2405
  export * from './widgets/TextField';
2443
2406
  export * from './widgets/NumberField';
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Input, Label, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * Address data structure
@@ -17,7 +17,7 @@ export interface AddressValue {
17
17
  * Address field widget - provides a structured address input
18
18
  * Supports street, city, state, zip code, and country
19
19
  */
20
- export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<AddressValue>) {
20
+ export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<AddressValue>) {
21
21
  const address = value || {};
22
22
 
23
23
  const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * AutoNumberField - Read-only auto-generated field
7
7
  * Values are automatically generated by the system and cannot be edited
8
8
  */
9
- export function AutoNumberField({ value, field, ...props }: FieldWidgetProps<string | number>) {
9
+ export function AutoNumberField({ value, field, ...props }: FieldWidgetComponentProps<string | number>) {
10
10
  if (value == null) {
11
11
  return <EmptyValue className={props.className} />;
12
12
  }
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
3
3
  import { Upload, X } from 'lucide-react';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * Avatar field widget - provides an avatar/profile picture uploader
8
8
  * Supports image URLs or file uploads
9
9
  */
10
- export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  const [isHovered, setIsHovered] = React.useState(false);
12
12
  const fileInputRef = React.useRef<HTMLInputElement>(null);
13
13
 
@@ -1,12 +1,12 @@
1
1
  import React, { useId } from 'react';
2
2
  import { Switch, Checkbox, Label } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * BooleanField - Toggle input supporting switch and checkbox variants
7
7
  * Renders as Switch or Checkbox based on field widget configuration
8
8
  */
9
- export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<boolean>) {
9
+ export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
10
10
  const config = (field || (props as any).schema) as any;
11
11
  // Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
12
12
  const widget = config?.widget;
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import { Badge, EmptyValue, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
4
  import type { DataSource, QueryParams } from '@object-ui/types';
5
- import { FieldWidgetProps } from './types';
5
+ import { FieldWidgetComponentProps } from './types';
6
6
  import { useFieldTranslation } from './useFieldTranslation';
7
7
 
8
8
  /**
@@ -86,12 +86,12 @@ export function CapabilityMultiSelectField({
86
86
  readonly,
87
87
  className,
88
88
  ...props
89
- }: FieldWidgetProps<string | string[]>) {
89
+ }: FieldWidgetComponentProps<string | string[]>) {
90
90
  const { t } = useFieldTranslation();
91
91
  const ctx = React.useContext(SchemaRendererContext);
92
92
  const dataSource: DataSource | null =
93
- (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
94
- const disabled = (props as any).disabled as boolean | undefined;
93
+ (props.dataSource as any) ?? (ctx as any)?.dataSource ?? null;
94
+ const disabled = props.disabled;
95
95
 
96
96
  const [caps, setCaps] = React.useState<Capability[] | null>(null);
97
97