@object-ui/fields 17.2.0 → 17.5.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 (212) hide show
  1. package/CHANGELOG.md +1533 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +52 -12
  9. package/dist/index.js +5167 -4759
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AvatarField.d.ts +1 -1
  12. package/dist/widgets/CharacterCount.d.ts +56 -0
  13. package/dist/widgets/CheckboxesField.d.ts +1 -1
  14. package/dist/widgets/CodeField.d.ts +1 -1
  15. package/dist/widgets/ColorField.d.ts +1 -1
  16. package/dist/widgets/CurrencyField.d.ts +1 -1
  17. package/dist/widgets/EmailField.d.ts +1 -1
  18. package/dist/widgets/FileField.d.ts +1 -1
  19. package/dist/widgets/FilterConditionField.d.ts +1 -1
  20. package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
  21. package/dist/widgets/GeolocationField.d.ts +1 -1
  22. package/dist/widgets/GridField.d.ts +12 -2
  23. package/dist/widgets/ImageField.d.ts +1 -1
  24. package/dist/widgets/LocationField.d.ts +1 -1
  25. package/dist/widgets/LookupField.d.ts +1 -1
  26. package/dist/widgets/MultiSelectField.d.ts +1 -1
  27. package/dist/widgets/ObjectField.d.ts +1 -1
  28. package/dist/widgets/ObjectRefField.d.ts +1 -1
  29. package/dist/widgets/OptionsEmptyState.d.ts +59 -0
  30. package/dist/widgets/PercentField.d.ts +1 -1
  31. package/dist/widgets/PhoneField.d.ts +1 -1
  32. package/dist/widgets/QRCodeField.d.ts +1 -1
  33. package/dist/widgets/RadioField.d.ts +1 -1
  34. package/dist/widgets/RatingField.d.ts +1 -1
  35. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  36. package/dist/widgets/RecordPickerDialog.d.ts +49 -7
  37. package/dist/widgets/RichTextField.d.ts +19 -1
  38. package/dist/widgets/SelectField.d.ts +25 -5
  39. package/dist/widgets/TagsField.d.ts +1 -1
  40. package/dist/widgets/TextAreaField.d.ts +44 -5
  41. package/dist/widgets/UrlField.d.ts +1 -1
  42. package/dist/widgets/address-format.d.ts +68 -0
  43. package/dist/widgets/file-size-guard.d.ts +41 -0
  44. package/dist/widgets/nativeDateValue.d.ts +37 -0
  45. package/dist/widgets/toDomProps.d.ts +111 -0
  46. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  47. package/dist/widgets/types.d.ts +128 -33
  48. package/dist/widgets/useRecordQuery.d.ts +7 -1
  49. package/dist/withFieldCarrier.d.ts +56 -0
  50. package/package.json +20 -16
  51. package/src/FieldEditWidget.tsx +17 -1
  52. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  53. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  54. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  55. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  56. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  57. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  58. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  59. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  60. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  61. package/src/__tests__/cell-truncation.test.tsx +96 -0
  62. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  63. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  64. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  65. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  66. package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
  67. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  68. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  69. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  70. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  71. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  72. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  73. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  74. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  75. package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
  76. package/src/__tests__/validation-feedback.test.tsx +22 -13
  77. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
  78. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
  79. package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
  80. package/src/__tests__/widget-props-contract.test.tsx +22 -9
  81. package/src/complex-widgets.test.tsx +22 -6
  82. package/src/currency.ts +130 -0
  83. package/src/datetime-widgets.test.tsx +94 -2
  84. package/src/field-type-alias.multiple.test.ts +81 -0
  85. package/src/field-type-alias.ts +58 -2
  86. package/src/index.css +44 -0
  87. package/src/index.tsx +558 -268
  88. package/src/standard-widgets.test.tsx +8 -1
  89. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  90. package/src/widgets/AddressField.tsx +107 -38
  91. package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
  92. package/src/widgets/AvatarField.tsx +9 -2
  93. package/src/widgets/BooleanField.tsx +44 -10
  94. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  95. package/src/widgets/CharacterCount.tsx +263 -0
  96. package/src/widgets/CheckboxesField.tsx +66 -16
  97. package/src/widgets/CodeField.tsx +9 -2
  98. package/src/widgets/ColorField.tsx +11 -2
  99. package/src/widgets/CurrencyField.tsx +60 -19
  100. package/src/widgets/DateField.tsx +13 -4
  101. package/src/widgets/DateTimeField.tsx +14 -5
  102. package/src/widgets/EmailField.tsx +5 -5
  103. package/src/widgets/FileField.tsx +39 -11
  104. package/src/widgets/FilterConditionField.tsx +11 -0
  105. package/src/widgets/FormulaField.tsx +7 -2
  106. package/src/widgets/FullscreenFieldEditor.tsx +60 -0
  107. package/src/widgets/GeolocationField.tsx +49 -8
  108. package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
  109. package/src/widgets/GridField.test.tsx +226 -0
  110. package/src/widgets/GridField.tsx +115 -11
  111. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  112. package/src/widgets/ImageField.tsx +63 -6
  113. package/src/widgets/LocationField.tsx +9 -2
  114. package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
  115. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  116. package/src/widgets/LookupField.tsx +49 -10
  117. package/src/widgets/MasterDetailField.tsx +1 -1
  118. package/src/widgets/MultiSelectField.tsx +86 -16
  119. package/src/widgets/NumberField.tsx +3 -3
  120. package/src/widgets/ObjectField.tsx +17 -8
  121. package/src/widgets/ObjectRefField.tsx +10 -0
  122. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
  123. package/src/widgets/OptionsEmptyState.test.tsx +169 -0
  124. package/src/widgets/OptionsEmptyState.tsx +99 -0
  125. package/src/widgets/PasswordField.tsx +3 -3
  126. package/src/widgets/PeoplePicker.tsx +7 -1
  127. package/src/widgets/PercentField.tsx +5 -4
  128. package/src/widgets/PhoneField.tsx +5 -5
  129. package/src/widgets/QRCodeField.tsx +9 -2
  130. package/src/widgets/RadioField.tsx +58 -16
  131. package/src/widgets/RatingField.tsx +32 -4
  132. package/src/widgets/RecipientPickerField.tsx +18 -0
  133. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  134. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  135. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
  136. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
  137. package/src/widgets/RecordPickerDialog.tsx +235 -21
  138. package/src/widgets/RichTextField.tsx +151 -24
  139. package/src/widgets/SelectField.tsx +84 -22
  140. package/src/widgets/SliderField.tsx +1 -1
  141. package/src/widgets/SummaryField.tsx +1 -1
  142. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
  143. package/src/widgets/TagsField.placeholder.test.tsx +95 -0
  144. package/src/widgets/TagsField.tsx +23 -2
  145. package/src/widgets/TextAreaField.tsx +171 -93
  146. package/src/widgets/TextField.tsx +3 -3
  147. package/src/widgets/TimeField.tsx +2 -2
  148. package/src/widgets/UrlField.tsx +5 -5
  149. package/src/widgets/UserField.tsx +4 -3
  150. package/src/widgets/VectorField.tsx +1 -1
  151. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  152. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
  153. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
  154. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
  155. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
  156. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
  157. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
  158. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
  159. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
  160. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
  161. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
  162. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
  163. package/src/widgets/address-format.ts +120 -0
  164. package/src/widgets/file-size-guard.ts +55 -0
  165. package/src/widgets/nativeDateValue.test.ts +115 -0
  166. package/src/widgets/nativeDateValue.ts +112 -0
  167. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  168. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  169. package/src/widgets/toDomProps.ts +204 -0
  170. package/src/widgets/toHostGroupProps.ts +102 -0
  171. package/src/widgets/types.ts +135 -35
  172. package/src/widgets/useFieldTranslation.ts +46 -4
  173. package/src/widgets/useRecordQuery.ts +30 -3
  174. package/src/withFieldCarrier.tsx +75 -0
  175. package/dist/FieldEditWidget.test.d.ts +0 -8
  176. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  177. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  178. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  179. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  180. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  181. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  182. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  183. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  184. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  185. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  186. package/dist/coerce-safe-value.test.d.ts +0 -1
  187. package/dist/complex-widgets.test.d.ts +0 -1
  188. package/dist/datetime-widgets.test.d.ts +0 -1
  189. package/dist/field-type-coverage.test.d.ts +0 -1
  190. package/dist/standard-widgets.test.d.ts +0 -0
  191. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  192. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  193. package/dist/widgets/GridField.test.d.ts +0 -1
  194. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  195. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  196. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  197. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  198. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  199. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  200. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  201. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  202. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  203. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  204. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  205. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  206. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  207. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  208. package/dist/widgets/file-value.test.d.ts +0 -1
  209. package/dist/widgets/personDisplay.test.d.ts +0 -8
  210. package/dist/widgets/recentLookups.test.d.ts +0 -1
  211. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  212. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ImageField — a declared `maxSize` is enforced BEFORE any upload starts
3
+ * (objectui#4141).
4
+ *
5
+ * The defect this pins: `paramToField` copies `maxSize` onto the field config
6
+ * for every param type, so the image widget RECEIVES the constraint — it just
7
+ * never read it. An oversized pick therefore fired the whole
8
+ * `presigned → PUT → complete` chain and rendered a preview, while the sibling
9
+ * file param rejected the same pick with zero uploads.
10
+ *
11
+ * So the assertion that matters is **zero upload attempts**, not "no preview":
12
+ * a widget that uploads the bytes and then hides the thumbnail has still
13
+ * stored an over-limit file. The spy sits on the `UploadProvider` adapter —
14
+ * the layer that performs the presigned request — so a call there IS the
15
+ * network attempt.
16
+ *
17
+ * Two upload entry points exist on this widget and both are covered here:
18
+ * 1. the native picker (`handleFileChange`, single and `multiple`);
19
+ * 2. the crop dialog's confirm (`handleCropConfirm`), which uploads a
20
+ * freshly re-encoded PNG blob — canvas re-encoding can push a crop of an
21
+ * in-limit JPEG *over* the limit, so it is a real second door.
22
+ */
23
+ import { describe, it, expect, vi } from 'vitest';
24
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
25
+ import { UploadProvider, type UploadAdapter } from '@object-ui/providers';
26
+ import { ImageField } from './ImageField';
27
+ // Imported at module scope (not awaited in a hook) so the widget's
28
+ // `React.lazy(() => import('./ImageCropperDialog'))` resolves from the ESM
29
+ // cache instead of racing RTL's 1000ms `findBy` budget — AGENTS.md §测试纪律.
30
+ import './ImageCropperDialog';
31
+
32
+ const MB = 1024 * 1024;
33
+
34
+ /** A File whose reported `size` is the declared one (constructor bytes are not). */
35
+ function imageOfSize(name: string, bytes: number): File {
36
+ const file = new File(['x'], name, { type: 'image/png' });
37
+ Object.defineProperty(file, 'size', { value: bytes });
38
+ return file;
39
+ }
40
+
41
+ function blobOfSize(bytes: number): Blob {
42
+ const blob = new Blob(['x'], { type: 'image/png' });
43
+ Object.defineProperty(blob, 'size', { value: bytes });
44
+ return blob;
45
+ }
46
+
47
+ function renderField(field: Record<string, unknown>, value: unknown = undefined) {
48
+ const upload = vi.fn(async (f: File | Blob) => ({
49
+ url: 'https://cdn.example/uploaded.png',
50
+ name: (f as File).name ?? 'cropped.png',
51
+ size: f.size,
52
+ mimeType: f.type || 'image/png',
53
+ }));
54
+ const adapter: UploadAdapter = { name: 'spy', upload };
55
+ const onChange = vi.fn();
56
+ render(
57
+ <UploadProvider adapter={adapter}>
58
+ <ImageField field={field as any} value={value} onChange={onChange} />
59
+ </UploadProvider>,
60
+ );
61
+ return { upload, onChange, input: document.querySelector('input[type="file"]') as HTMLInputElement };
62
+ }
63
+
64
+ describe('ImageField — client-side maxSize guard (#4141)', () => {
65
+ it('never reaches the upload layer for a pick over the declared maxSize', async () => {
66
+ const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
67
+
68
+ fireEvent.change(input, { target: { files: [imageOfSize('huge.png', 6.3 * MB)] } });
69
+
70
+ // The message is the proof the guard ran; the spy is the proof nothing was sent.
71
+ await screen.findByText(/"huge\.png" exceeds max size \(5\.0 MB\)/);
72
+ expect(upload).not.toHaveBeenCalled();
73
+ expect(onChange).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('still uploads a pick inside the declared maxSize', async () => {
77
+ const { upload, onChange, input } = renderField({ name: 'p_cover', maxSize: 5 * MB });
78
+
79
+ fireEvent.change(input, { target: { files: [imageOfSize('ok.png', 1 * MB)] } });
80
+
81
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
82
+ expect(upload).toHaveBeenCalledTimes(1);
83
+ expect(screen.queryByText(/exceeds max size/)).toBeNull();
84
+ });
85
+
86
+ it('uploads unrestricted when the param declares no maxSize', async () => {
87
+ const { upload, onChange, input } = renderField({ name: 'p_cover' });
88
+
89
+ fireEvent.change(input, { target: { files: [imageOfSize('enormous.png', 500 * MB)] } });
90
+
91
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
92
+ expect(upload).toHaveBeenCalledTimes(1);
93
+ expect(screen.queryByText(/exceeds max size/)).toBeNull();
94
+ });
95
+
96
+ it('rejects only the oversized picks of a multi-select and uploads the rest', async () => {
97
+ const { upload, onChange, input } = renderField({ name: 'p_gallery', multiple: true, maxSize: 5 * MB });
98
+
99
+ fireEvent.change(input, {
100
+ target: { files: [imageOfSize('small.png', 1 * MB), imageOfSize('huge.png', 6.3 * MB)] },
101
+ });
102
+
103
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
104
+ expect(upload).toHaveBeenCalledTimes(1);
105
+ expect((upload.mock.calls[0][0] as File).name).toBe('small.png');
106
+ expect(screen.getByText(/"huge\.png" exceeds max size \(5\.0 MB\)/)).toBeTruthy();
107
+ });
108
+
109
+ it('guards the crop dialog too — an oversized re-encode is never uploaded', async () => {
110
+ const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
111
+ const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
112
+
113
+ fireEvent.click(screen.getByTestId('image-field-crop-0'));
114
+ const confirm = await screen.findByTestId('fake-crop-confirm');
115
+ fireEvent.click(confirm);
116
+
117
+ await screen.findByText(/exceeds max size \(5\.0 MB\)/);
118
+ expect(upload).not.toHaveBeenCalled();
119
+ expect(onChange).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('still uploads a crop that fits inside the declared maxSize', async () => {
123
+ const existing = { name: 'cover.png', url: 'https://cdn.example/cover.png', mime_type: 'image/png' };
124
+ const { upload, onChange } = renderField({ name: 'p_cover', maxSize: 5 * MB, crop: true }, existing);
125
+
126
+ fireEvent.click(screen.getByTestId('image-field-crop-0'));
127
+ fireEvent.click(await screen.findByTestId('fake-crop-confirm-small'));
128
+
129
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
130
+ expect(upload).toHaveBeenCalledTimes(1);
131
+ });
132
+ });
133
+
134
+ /**
135
+ * The real cropper is a canvas + `toBlob` pipeline that happy-dom cannot run;
136
+ * this stand-in drives the ONE contract ImageField has with it — `onConfirm`
137
+ * hands back a blob and a name — with a blob size on either side of the limit.
138
+ */
139
+ vi.mock('./ImageCropperDialog', () => ({
140
+ ImageCropperDialog: ({ onConfirm }: { onConfirm: (blob: Blob, name: string) => void }) => (
141
+ <div>
142
+ <button type="button" data-testid="fake-crop-confirm" onClick={() => onConfirm(blobOfSize(6.3 * MB), 'cover.png')}>
143
+ confirm big
144
+ </button>
145
+ <button
146
+ type="button"
147
+ data-testid="fake-crop-confirm-small"
148
+ onClick={() => onConfirm(blobOfSize(0.5 * MB), 'cover.png')}
149
+ >
150
+ confirm small
151
+ </button>
152
+ </div>
153
+ ),
154
+ }));
@@ -4,8 +4,10 @@ import { useUpload } from '@object-ui/providers';
4
4
  import { useObjectTranslation } from '@object-ui/i18n';
5
5
  import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
6
6
  import { FieldWidgetComponentProps } from './types';
7
+ import { toDomProps } from './toDomProps';
7
8
  import { ImageLightbox } from './ImageLightbox';
8
9
  import { useUploadingSignal } from './useUploadingSignal';
10
+ import { maxSizeError, type TranslateFn } from './file-size-guard';
9
11
  import {
10
12
  fileValueForSubmit,
11
13
  readFileValues,
@@ -24,11 +26,17 @@ const ImageCropperDialog = lazy(() =>
24
26
  * ImageField - Image upload widget with preview thumbnails
25
27
  * Supports single and multiple image uploads with drag-and-drop and preview display
26
28
  */
27
- export function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps<any>) {
29
+ export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps<any>) {
28
30
  const inputRef = useRef<HTMLInputElement>(null);
29
- const imageField = (field || (props as any).schema) as any;
31
+ const imageField = field as any;
30
32
  const multiple = imageField?.multiple || false;
31
33
  const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
34
+ /**
35
+ * Max upload size in bytes. Delivered by `paramToField` for an action param
36
+ * and by the field config for a record field; enforced before any upload
37
+ * starts, exactly as FileField does (objectui#4141).
38
+ */
39
+ const maxSize = imageField?.maxSize as number | undefined;
32
40
  /**
33
41
  * Set `field.crop = false` to opt out of inline cropping. Defaults to enabled.
34
42
  */
@@ -38,6 +46,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
38
46
  const { upload } = useUpload();
39
47
  const { t } = useObjectTranslation();
40
48
  const [uploading, setUploading] = useState(false);
49
+ /** Client-side rejections (oversize picks), cleared on the next attempt. */
50
+ const [errors, setErrors] = useState<string[]>([]);
41
51
  // Display details of just-uploaded images, keyed by their new `sys_file` id.
42
52
  // Submitting the reference form means the field value no longer carries the
43
53
  // URL a thumbnail needs; these keep the preview visible until the next read
@@ -58,6 +68,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
58
68
  const handleCropConfirm = useCallback(
59
69
  async (blob: Blob, name: string) => {
60
70
  if (!cropTarget) return;
71
+ // The size that matters here is the CROP's, not the source image's: the
72
+ // cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a
73
+ // blob over the limit. Checked before `setUploading` so a rejected crop
74
+ // never flashes the spinner, and the dialog closes so the message lands
75
+ // in the field's error row rather than behind the open dialog.
76
+ const rejection = maxSizeError(t as TranslateFn, { name, size: blob.size }, maxSize);
77
+ if (rejection) {
78
+ setErrors([rejection]);
79
+ setCropTarget(null);
80
+ return;
81
+ }
82
+ setErrors([]);
61
83
  setUploading(true);
62
84
  try {
63
85
  const result = await upload(blob);
@@ -75,7 +97,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
75
97
  setCropTarget(null);
76
98
  }
77
99
  },
78
- [cropTarget, images, multiple, onChange, upload, remember],
100
+ [cropTarget, images, multiple, onChange, upload, remember, maxSize, t],
79
101
  );
80
102
 
81
103
  const openCropper = useCallback(
@@ -127,12 +149,32 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
127
149
 
128
150
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
129
151
  const selectedFiles = Array.from(e.target.files || []);
152
+ // Reset the input up front rather than in `finally`: a pick rejected below
153
+ // returns before the upload runs, and leaving the old value in place would
154
+ // stop the user re-picking the same name after shrinking the image.
155
+ // `selectedFiles` already holds the File objects, so clearing is safe.
156
+ if (inputRef.current) inputRef.current.value = '';
130
157
  if (selectedFiles.length === 0) return;
131
158
 
159
+ // Enforce the declared limit before anything touches the network. Partial
160
+ // acceptance matches FileField: the in-limit picks of a multi-select still
161
+ // upload, and only the oversize ones are reported.
162
+ const rejections: string[] = [];
163
+ const validFiles = selectedFiles.filter((file) => {
164
+ const rejection = maxSizeError(t as TranslateFn, file, maxSize);
165
+ if (rejection) {
166
+ rejections.push(rejection);
167
+ return false;
168
+ }
169
+ return true;
170
+ });
171
+ setErrors(rejections);
172
+ if (validFiles.length === 0) return;
173
+
132
174
  setUploading(true);
133
175
  try {
134
176
  const imageObjects = await Promise.all(
135
- selectedFiles.map(async (file) => {
177
+ validFiles.map(async (file) => {
136
178
  const result = await upload(file);
137
179
  remember(result, file.name);
138
180
  return fileValueForSubmit(result, file.name);
@@ -146,8 +188,6 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
146
188
  }
147
189
  } finally {
148
190
  setUploading(false);
149
- // Reset input so picking the same file again still triggers change.
150
- if (inputRef.current) inputRef.current.value = '';
151
191
  }
152
192
  };
153
193
 
@@ -212,12 +252,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
212
252
  )}
213
253
 
214
254
  <Button
255
+ // DOM pass-through onto the widget's real focusable control — the
256
+ // upload button is the keyboard path to the hidden file input
257
+ // (objectui#3318).
258
+ {...toDomProps(props)}
215
259
  type="button"
216
260
  variant="outline"
217
261
  onClick={() => inputRef.current?.click()}
218
262
  className="w-full"
219
263
  disabled={uploading}
220
264
  data-testid="image-field-upload-button"
265
+ // AFTER the spread so this widget's own computation wins (#3222).
266
+ aria-invalid={!!error}
221
267
  >
222
268
  {uploading ? (
223
269
  <Loader2 className="size-4 mr-2 animate-spin" />
@@ -230,6 +276,17 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
230
276
  ? t('fields.image.addMore')
231
277
  : t('fields.image.upload')}
232
278
  </Button>
279
+
280
+ {/* Client-side rejections (oversize picks). Same presentation as
281
+ FileField's error row — this widget previously had no surface for
282
+ them at all, because it never rejected anything (objectui#4141). */}
283
+ {errors.length > 0 && (
284
+ <div className="space-y-0.5">
285
+ {errors.map((err, i) => (
286
+ <p key={i} className="text-xs text-destructive">{err}</p>
287
+ ))}
288
+ </div>
289
+ )}
233
290
  </div>
234
291
 
235
292
  {cropEnabled && cropTarget && (
@@ -1,13 +1,14 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { FieldWidgetComponentProps } from './types';
4
+ import { toDomProps } from './toDomProps';
4
5
 
5
6
  /**
6
7
  * LocationField - Geographic coordinate input for latitude and longitude
7
8
  * Stores location as { latitude, longitude } object and displays as comma-separated pair
8
9
  */
9
- export function LocationField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
10
- const config = field || (props as any).schema;
10
+ export function LocationField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
11
+ const config = field;
11
12
  // Location is stored as { latitude, longitude } object
12
13
  // For display, convert to "latitude, longitude" string format
13
14
  const displayValue = value && typeof value === 'object'
@@ -39,12 +40,18 @@ export function LocationField({ value, onChange, field, readonly, ...props }: Fi
39
40
 
40
41
  return (
41
42
  <Input
43
+ // DOM pass-through onto the real focusable control (objectui#3318).
44
+ {...toDomProps(props)}
42
45
  type="text"
43
46
  value={displayValue}
44
47
  onChange={handleChange}
45
48
  placeholder={config?.placeholder || 'latitude, longitude'}
46
49
  disabled={readonly || props.disabled}
47
50
  className={props.className}
51
+ // AFTER the spread so this widget's own computation wins: `error` is
52
+ // the published validation slot (#3222), `!!undefined` → explicit
53
+ // "false".
54
+ aria-invalid={!!error}
48
55
  />
49
56
  );
50
57
  }
@@ -0,0 +1,135 @@
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
+ * A gated lookup names its controlling field by LABEL — objectstack#5407.
11
+ *
12
+ * The gate sentence (`lookup.selectFirst`) is translated in all ten packs, but
13
+ * its `{{fields}}` interpolation came straight off `depends_on`, which holds
14
+ * API names. So the rendered hint was localized around a raw identifier in
15
+ * every locale, English included:
16
+ *
17
+ * en Select crm_account first
18
+ * zh-CN 请先选择crm_account
19
+ *
20
+ * The `en` row is why this is not merely an i18n bug: an English user was shown
21
+ * an internal identifier. The form renderer knows the label (it already
22
+ * resolves the same map for the fixed-option widgets' `emptyHint`) and now
23
+ * hands it over as `dependsOnLabels`.
24
+ *
25
+ * All three gated surfaces are asserted — the trigger's visible text, the
26
+ * trigger's `title`, and the "browse all" button's `title` — because they were
27
+ * three independent copies of the same expression and a fix applied to one of
28
+ * them looks identical from the outside.
29
+ */
30
+
31
+ import { render, screen, cleanup } from '@testing-library/react';
32
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33
+ import { LookupField } from './LookupField';
34
+
35
+ /** Mirrors the reported metadata: the parent field's API name IS an object-ish
36
+ * name (`crm_account`), which is exactly what made the leak so legible. */
37
+ const gatedField = {
38
+ name: 'contact',
39
+ label: 'Contact',
40
+ reference_to: 'crm_contact',
41
+ reference_field: 'name',
42
+ depends_on: ['crm_account'],
43
+ } as any;
44
+
45
+ const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) } as any;
46
+
47
+ function renderGated(extra: Record<string, unknown>) {
48
+ return render(
49
+ <LookupField
50
+ field={gatedField}
51
+ value={undefined}
52
+ onChange={vi.fn()}
53
+ readonly={false}
54
+ dataSource={dataSource}
55
+ {...({ dependentValues: { crm_account: null }, ...extra } as any)}
56
+ />,
57
+ );
58
+ }
59
+
60
+ beforeEach(() => {
61
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 });
62
+ window.matchMedia = ((query: string) => ({
63
+ matches: false,
64
+ media: query,
65
+ onchange: null,
66
+ addEventListener: () => {},
67
+ removeEventListener: () => {},
68
+ addListener: () => {},
69
+ removeListener: () => {},
70
+ dispatchEvent: () => false,
71
+ })) as any;
72
+ });
73
+
74
+ afterEach(() => cleanup());
75
+
76
+ describe('LookupField gate hint names the controlling field (objectstack#5407)', () => {
77
+ it('interpolates the label the host supplied, not the API name', () => {
78
+ renderGated({ dependsOnLabels: { crm_account: 'Account' } });
79
+
80
+ const trigger = screen.getByTestId('lookup-trigger-gated');
81
+ expect(trigger).toHaveTextContent('Select Account first');
82
+ // The identifier must be gone from the copy entirely, not merely
83
+ // accompanied by the label.
84
+ expect(trigger).not.toHaveTextContent('crm_account');
85
+ expect(trigger).toHaveAttribute('title', 'Select Account first');
86
+ });
87
+
88
+ it('labels the gated "browse all" button the same way', () => {
89
+ renderGated({ dependsOnLabels: { crm_account: 'Account' } });
90
+
91
+ const browse = screen.getByTestId('browse-all-records');
92
+ expect(browse).toBeDisabled();
93
+ expect(browse).toHaveAttribute('title', 'Select Account first');
94
+ });
95
+
96
+ it('joins several controlling fields by their labels', () => {
97
+ render(
98
+ <LookupField
99
+ field={{ ...gatedField, depends_on: ['crm_account', 'lead_source'] }}
100
+ value={undefined}
101
+ onChange={vi.fn()}
102
+ readonly={false}
103
+ dataSource={dataSource}
104
+ {...({
105
+ dependentValues: { crm_account: null, lead_source: null },
106
+ dependsOnLabels: { crm_account: 'Account', lead_source: 'Lead Source' },
107
+ } as any)}
108
+ />,
109
+ );
110
+
111
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
112
+ 'Select Account, Lead Source first',
113
+ );
114
+ });
115
+
116
+ it('falls back to the API name for a field the host did not map', () => {
117
+ // Standalone use (no host form) has to keep rendering what it rendered
118
+ // before — the map is an enrichment, not a new requirement. This is also
119
+ // the assertion that would go red if `dependsOnLabels` were consulted for
120
+ // its VALUES rather than keyed by field name.
121
+ renderGated({ dependsOnLabels: { some_other_field: 'Irrelevant' } });
122
+
123
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
124
+ 'Select crm_account first',
125
+ );
126
+ });
127
+
128
+ it('falls back to the API name when the host passes no map at all', () => {
129
+ renderGated({});
130
+
131
+ expect(screen.getByTestId('lookup-trigger-gated')).toHaveTextContent(
132
+ 'Select crm_account first',
133
+ );
134
+ });
135
+ });
@@ -71,7 +71,7 @@ describe('LookupField — multi-value hydration batches and shows loading (#3108
71
71
 
72
72
  // In-flight: loading indicator + raw committed count, no "Select…".
73
73
  await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
74
- expect(screen.queryByText('Select...')).toBeNull();
74
+ expect(screen.queryByText('Select…')).toBeNull();
75
75
  expect(screen.getByText(/3/)).toBeTruthy();
76
76
 
77
77
  resolveFind({ data: USERS });
@@ -11,6 +11,7 @@ import { cn,
11
11
  PopoverContent, EmptyValue } from '@object-ui/components';
12
12
  import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
13
13
  import { FieldWidgetComponentProps } from './types';
14
+ import { toDomProps } from './toDomProps';
14
15
  import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
15
16
  import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
16
17
  import type { RecordPickerFilterColumn } from './RecordPickerDialog';
@@ -191,7 +192,7 @@ function mapFieldTypeToFilterType(
191
192
  * from the referenced object using `DataSource.find()`.
192
193
  * Falls back to static `options` when no DataSource is available.
193
194
  */
194
- export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
195
+ export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps<any>) {
195
196
  const [isOpen, setIsOpen] = useState(false);
196
197
  const { t } = useFieldTranslation();
197
198
  const listboxId = React.useId();
@@ -215,11 +216,15 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
215
216
  const [activeIndex, setActiveIndex] = useState(-1);
216
217
  const listRef = useRef<HTMLDivElement>(null);
217
218
 
218
- const lookupField = (field || (props as any).schema) as any;
219
+ const lookupField = field as any;
219
220
 
220
- // When rendered via createFieldRenderer wrapper the actual objectSchema field
221
- // metadata (reference_to, display_field, etc.) lives at lookupField.field.
222
- // Unwrap it so lookup-specific properties resolve correctly.
221
+ // The form renderer passes `field: field.field || field` — `.field` is the
222
+ // declared metadata slot (objectui#3090) — so the actual objectSchema field
223
+ // metadata (reference_to, display_field, etc.) can arrive nested at
224
+ // `lookupField.field`. Unwrap it so lookup-specific properties resolve
225
+ // correctly. (This used to credit the docs-demo `createFieldRenderer` wrapper,
226
+ // which never produced the nesting and was removed in objectui#3910; the form
227
+ // path is the real producer.)
223
228
  // ObjectStack convention uses `reference` while the types use `reference_to`,
224
229
  // so we check for both property names.
225
230
  const innerField = lookupField?.field;
@@ -279,6 +284,23 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
279
284
  return [];
280
285
  }, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
281
286
 
287
+ /**
288
+ * The gate sentence's `{{fields}}` — the controlling fields named the way the
289
+ * user sees them on the form, not the way the metadata spells them.
290
+ *
291
+ * `depends_on` holds API names, and this used to interpolate them straight
292
+ * into the sentence, so every locale — `en` included — read "Select
293
+ * crm_account first" (objectstack#5407): an internal identifier in the UI,
294
+ * not merely an untranslated word. The host form supplies the name→label map
295
+ * (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a
296
+ * standalone widget with no host renders exactly what it did before.
297
+ */
298
+ const dependsOnLabelsProp = props.dependsOnLabels;
299
+ const dependsOnFieldsText = useMemo(
300
+ () => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '),
301
+ [dependsOn, dependsOnLabelsProp],
302
+ );
303
+
282
304
  // Resolve dependent field values from explicit prop or SchemaRendererContext.data
283
305
  const dependentValuesProp = props.dependentValues;
284
306
 
@@ -912,19 +934,35 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
912
934
  // Shared field trigger — the anchor for either the inline PeoplePicker
913
935
  // (search fields) or the classic quick-select popover. No onClick: the Radix
914
936
  // trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
937
+ //
938
+ // DOM pass-through onto this button — the widget's real focusable control —
939
+ // per objectui#3318. `name` is withheld: no native control here takes part
940
+ // in form submission, and a stray `name` on a button invites exactly the
941
+ // accidental-submitter semantics #3306 kept it off the SelectTrigger for.
942
+ const { name: _domName, ...triggerDomProps } = toDomProps(props);
915
943
  const triggerButton = (
916
944
  <Button
945
+ {...triggerDomProps}
917
946
  variant="outline"
918
947
  className={cn(
919
- 'min-w-0 flex-1 justify-start text-left font-normal',
948
+ // max-w-full: in a BLOCK parent (detail-section inline edit) the
949
+ // inline-flex button is content-sized — a long selected label pushed
950
+ // it past the card edge; bounding it lets the inner `truncate` span
951
+ // clip instead (objectui#3466). flex-1/min-w-0 keep handling the
952
+ // flex-parent (form row) case.
953
+ 'min-w-0 max-w-full flex-1 justify-start text-left font-normal',
920
954
  compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
921
955
  )}
922
956
  type="button"
923
957
  disabled={dependenciesMissing || props.disabled}
924
958
  data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
925
959
  title={dependenciesMissing
926
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
960
+ ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
927
961
  : undefined}
962
+ // AFTER the spread so this widget's own computation wins (#3222):
963
+ // `fieldError` is the published validation slot — NOT the popover's
964
+ // fetch error, which is a widget-internal state named `error` below.
965
+ aria-invalid={!!fieldError}
928
966
  >
929
967
  {hydrating ? (
930
968
  <Loader2
@@ -936,7 +974,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
936
974
  )}
937
975
  <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
938
976
  {dependenciesMissing
939
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
977
+ ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
940
978
  : hydrating
941
979
  // The value EXISTS but its labels are still loading — say so
942
980
  // instead of the empty placeholder (#3108).
@@ -1044,7 +1082,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
1044
1082
  <div className="relative">
1045
1083
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
1046
1084
  <Input
1047
- placeholder={t('common.search') + '...'}
1085
+ placeholder={t('table.search')}
1048
1086
  value={searchQuery}
1049
1087
  onChange={(e) => handleSearchChange(e.target.value)}
1050
1088
  onKeyDown={handleSearchKeyDown}
@@ -1218,7 +1256,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
1218
1256
  onClick={() => setIsPickerOpen(true)}
1219
1257
  aria-label={t('lookup.browseAll')}
1220
1258
  title={dependenciesMissing
1221
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
1259
+ ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
1222
1260
  : t('lookup.browseAll')}
1223
1261
  data-testid="browse-all-records"
1224
1262
  >
@@ -1248,6 +1286,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
1248
1286
  lookupFilters={lookupFilters}
1249
1287
  baseFilter={dependentFilter}
1250
1288
  cellRenderer={getCellRendererResolver()}
1289
+ fieldsMeta={refObjectSchema?.fields}
1251
1290
  filterColumns={filterColumns}
1252
1291
  />
1253
1292
  )}
@@ -25,7 +25,7 @@ export function MasterDetailField({
25
25
  ...props
26
26
  }: FieldWidgetComponentProps<MasterDetailValue[]>) {
27
27
  const items = value || [];
28
- const config = field || (props as any).schema;
28
+ const config = field;
29
29
 
30
30
  const handleAdd = () => {
31
31
  // This would typically open a dialog to select/create related records