@object-ui/fields 17.5.0 → 17.6.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 (229) hide show
  1. package/CHANGELOG.md +714 -0
  2. package/dist/FieldEditWidget.d.ts +16 -2
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +4 -4
  5. package/dist/index.css +27 -9
  6. package/dist/index.d.ts +425 -72
  7. package/dist/index.js +2530 -2228
  8. package/dist/widgets/AddressField.d.ts +3 -3
  9. package/dist/widgets/AutoNumberField.d.ts +1 -1
  10. package/dist/widgets/AvatarField.d.ts +1 -1
  11. package/dist/widgets/BooleanField.d.ts +1 -1
  12. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  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/DateField.d.ts +1 -1
  18. package/dist/widgets/DateTimeField.d.ts +1 -1
  19. package/dist/widgets/EmailField.d.ts +1 -1
  20. package/dist/widgets/FileField.d.ts +18 -2
  21. package/dist/widgets/FilterConditionField.d.ts +28 -1
  22. package/dist/widgets/FormulaField.d.ts +1 -1
  23. package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
  24. package/dist/widgets/GeolocationField.d.ts +1 -1
  25. package/dist/widgets/GridField.d.ts +56 -4
  26. package/dist/widgets/ImageField.d.ts +1 -1
  27. package/dist/widgets/LocationField.d.ts +1 -1
  28. package/dist/widgets/LookupField.d.ts +1 -1
  29. package/dist/widgets/MasterDetailField.d.ts +1 -1
  30. package/dist/widgets/MultiSelectField.d.ts +1 -1
  31. package/dist/widgets/NumberField.d.ts +1 -1
  32. package/dist/widgets/ObjectField.d.ts +1 -1
  33. package/dist/widgets/ObjectRefField.d.ts +1 -1
  34. package/dist/widgets/OptionsEmptyState.d.ts +13 -7
  35. package/dist/widgets/PasswordField.d.ts +1 -1
  36. package/dist/widgets/PercentField.d.ts +1 -1
  37. package/dist/widgets/PhoneField.d.ts +1 -1
  38. package/dist/widgets/QRCodeField.d.ts +1 -1
  39. package/dist/widgets/RadioField.d.ts +1 -1
  40. package/dist/widgets/RatingField.d.ts +1 -1
  41. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  42. package/dist/widgets/RecordPickerDialog.d.ts +2 -4
  43. package/dist/widgets/RichTextField.d.ts +37 -1
  44. package/dist/widgets/SelectField.d.ts +1 -1
  45. package/dist/widgets/SignatureField.d.ts +32 -2
  46. package/dist/widgets/SliderField.d.ts +37 -2
  47. package/dist/widgets/SummaryField.d.ts +1 -1
  48. package/dist/widgets/TagsField.d.ts +1 -1
  49. package/dist/widgets/TextAreaField.d.ts +6 -2
  50. package/dist/widgets/TextField.d.ts +1 -1
  51. package/dist/widgets/TimeField.d.ts +1 -1
  52. package/dist/widgets/UrlField.d.ts +1 -1
  53. package/dist/widgets/UserField.d.ts +1 -1
  54. package/dist/widgets/VectorField.d.ts +1 -1
  55. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  56. package/dist/widgets/address-format.d.ts +27 -7
  57. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  58. package/dist/widgets/richTextDisplay.d.ts +80 -0
  59. package/dist/widgets/toHostGroupProps.d.ts +119 -21
  60. package/dist/widgets/types.d.ts +8 -3
  61. package/package.json +9 -10
  62. package/dist/widgets/CharacterCount.d.ts +0 -56
  63. package/src/FieldEditWidget.test.ts +0 -133
  64. package/src/FieldEditWidget.tsx +0 -207
  65. package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
  66. package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
  67. package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
  68. package/src/__tests__/DateCellRenderer.test.tsx +0 -82
  69. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
  70. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  71. package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
  72. package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
  73. package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
  74. package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
  75. package/src/__tests__/cell-truncation.test.tsx +0 -96
  76. package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
  77. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
  78. package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
  79. package/src/__tests__/date-locale-channel.test.tsx +0 -305
  80. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  81. package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
  82. package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
  83. package/src/__tests__/group-labelling-declaration.test.ts +0 -148
  84. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  85. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  86. package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
  87. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  88. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
  89. package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
  90. package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
  91. package/src/__tests__/registry-collision.test.ts +0 -91
  92. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  93. package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
  94. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  95. package/src/__tests__/validation-feedback.test.tsx +0 -361
  96. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  97. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  98. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
  99. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  100. package/src/coerce-safe-value.test.ts +0 -34
  101. package/src/complex-widgets.test.tsx +0 -924
  102. package/src/currency.ts +0 -144
  103. package/src/datetime-widgets.test.tsx +0 -185
  104. package/src/field-type-alias.multiple.test.ts +0 -81
  105. package/src/field-type-alias.ts +0 -156
  106. package/src/field-type-coverage.test.ts +0 -67
  107. package/src/index.css +0 -44
  108. package/src/index.tsx +0 -2745
  109. package/src/standard-widgets.test.tsx +0 -364
  110. package/src/widgets/AddressField.postalCode.test.tsx +0 -257
  111. package/src/widgets/AddressField.tsx +0 -182
  112. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
  113. package/src/widgets/AutoNumberField.tsx +0 -18
  114. package/src/widgets/AvatarField.tsx +0 -117
  115. package/src/widgets/BooleanField.tsx +0 -86
  116. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  117. package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
  118. package/src/widgets/CharacterCount.tsx +0 -263
  119. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  120. package/src/widgets/CheckboxesField.tsx +0 -169
  121. package/src/widgets/CodeField.tsx +0 -41
  122. package/src/widgets/ColorField.tsx +0 -53
  123. package/src/widgets/CurrencyField.tsx +0 -135
  124. package/src/widgets/DateField.tsx +0 -40
  125. package/src/widgets/DateTimeField.tsx +0 -46
  126. package/src/widgets/EmailField.tsx +0 -37
  127. package/src/widgets/FileField.tsx +0 -512
  128. package/src/widgets/FilterConditionField.tsx +0 -454
  129. package/src/widgets/FormulaField.tsx +0 -38
  130. package/src/widgets/FullscreenFieldEditor.tsx +0 -60
  131. package/src/widgets/GeolocationField.tsx +0 -193
  132. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  133. package/src/widgets/GridField.test.tsx +0 -632
  134. package/src/widgets/GridField.tsx +0 -1070
  135. package/src/widgets/ImageCropperDialog.tsx +0 -245
  136. package/src/widgets/ImageField.maxSize.test.tsx +0 -154
  137. package/src/widgets/ImageField.tsx +0 -305
  138. package/src/widgets/ImageLightbox.test.tsx +0 -52
  139. package/src/widgets/ImageLightbox.tsx +0 -108
  140. package/src/widgets/LocationField.tsx +0 -57
  141. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  142. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  143. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  144. package/src/widgets/LookupField.idField.test.tsx +0 -61
  145. package/src/widgets/LookupField.tsx +0 -1295
  146. package/src/widgets/MarkdownContent.tsx +0 -19
  147. package/src/widgets/MasterDetailField.tsx +0 -131
  148. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  149. package/src/widgets/MultiSelectField.tsx +0 -196
  150. package/src/widgets/NumberField.tsx +0 -49
  151. package/src/widgets/ObjectField.tsx +0 -94
  152. package/src/widgets/ObjectRefField.tsx +0 -121
  153. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  154. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  155. package/src/widgets/OptionsEmptyState.tsx +0 -99
  156. package/src/widgets/PasswordField.tsx +0 -49
  157. package/src/widgets/PeoplePicker.test.tsx +0 -360
  158. package/src/widgets/PeoplePicker.tsx +0 -579
  159. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  160. package/src/widgets/PercentField.tsx +0 -95
  161. package/src/widgets/PersonRow.tsx +0 -117
  162. package/src/widgets/PhoneField.tsx +0 -37
  163. package/src/widgets/QRCodeField.tsx +0 -103
  164. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  165. package/src/widgets/RadioField.tsx +0 -143
  166. package/src/widgets/RatingField.tsx +0 -94
  167. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  168. package/src/widgets/RecipientPickerField.tsx +0 -188
  169. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
  170. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
  171. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  172. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  173. package/src/widgets/RecordPickerDialog.tsx +0 -1365
  174. package/src/widgets/RichTextField.tsx +0 -176
  175. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  176. package/src/widgets/SelectField.tsx +0 -193
  177. package/src/widgets/SelectionTray.tsx +0 -110
  178. package/src/widgets/SignatureField.tsx +0 -140
  179. package/src/widgets/SliderField.tsx +0 -39
  180. package/src/widgets/SummaryField.tsx +0 -31
  181. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  182. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  183. package/src/widgets/TagsField.tsx +0 -88
  184. package/src/widgets/TextAreaField.tsx +0 -213
  185. package/src/widgets/TextField.tsx +0 -45
  186. package/src/widgets/TimeField.tsx +0 -31
  187. package/src/widgets/UrlField.tsx +0 -46
  188. package/src/widgets/UserField.tsx +0 -59
  189. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  190. package/src/widgets/VectorField.tsx +0 -29
  191. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  192. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
  193. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  194. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  195. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  196. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  197. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  198. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  199. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  200. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  201. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  202. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  203. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  204. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  205. package/src/widgets/address-format.ts +0 -120
  206. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  207. package/src/widgets/deriveLookupColumns.ts +0 -108
  208. package/src/widgets/file-size-guard.ts +0 -55
  209. package/src/widgets/file-value.test.ts +0 -230
  210. package/src/widgets/file-value.ts +0 -246
  211. package/src/widgets/nativeDateValue.test.ts +0 -115
  212. package/src/widgets/nativeDateValue.ts +0 -112
  213. package/src/widgets/openNativePicker.ts +0 -18
  214. package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
  215. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
  216. package/src/widgets/personDisplay.test.ts +0 -113
  217. package/src/widgets/personDisplay.ts +0 -92
  218. package/src/widgets/recentLookups.test.ts +0 -45
  219. package/src/widgets/recentLookups.ts +0 -47
  220. package/src/widgets/toDomProps.ts +0 -204
  221. package/src/widgets/toHostGroupProps.ts +0 -102
  222. package/src/widgets/types.ts +0 -242
  223. package/src/widgets/useCascadingOptions.ts +0 -46
  224. package/src/widgets/useFieldTranslation.ts +0 -132
  225. package/src/widgets/useRecordQuery.test.ts +0 -174
  226. package/src/widgets/useRecordQuery.ts +0 -291
  227. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  228. package/src/widgets/useUploadingSignal.ts +0 -20
  229. package/src/withFieldCarrier.tsx +0 -75
@@ -1,245 +0,0 @@
1
- /**
2
- * ImageCropperDialog - Lightweight crop/rotate dialog for ImageField.
3
- *
4
- * Implementation notes:
5
- * - Uses a Canvas + native pointer events (no extra dependency) so the bundle
6
- * stays small and the widget works in all SDUI contexts.
7
- * - Exposes a rectangular crop selection that can be dragged around the image
8
- * and a rotate-by-90° button.
9
- * - On confirm, returns a `Blob` of the cropped image (PNG by default) so callers
10
- * can wire it into upload providers or `URL.createObjectURL()`.
11
- */
12
- import React, { useCallback, useEffect, useRef, useState } from 'react';
13
- import {
14
- Button,
15
- Dialog,
16
- DialogContent,
17
- DialogDescription,
18
- DialogFooter,
19
- DialogHeader,
20
- DialogTitle,
21
- } from '@object-ui/components';
22
- import { RotateCw, Crop as CropIcon } from 'lucide-react';
23
-
24
- export interface ImageCropperDialogProps {
25
- open: boolean;
26
- onOpenChange: (open: boolean) => void;
27
- /** Source image URL or data URL */
28
- src: string;
29
- /** MIME type for the cropped output. Defaults to image/png. */
30
- outputType?: string;
31
- /** Suggested filename (passed back via onConfirm). Defaults to "cropped.png". */
32
- outputName?: string;
33
- /** Called when the user confirms the crop. */
34
- onConfirm: (blob: Blob, name: string) => void;
35
- }
36
-
37
- interface CropRect {
38
- x: number;
39
- y: number;
40
- width: number;
41
- height: number;
42
- }
43
-
44
- export const ImageCropperDialog: React.FC<ImageCropperDialogProps> = ({
45
- open,
46
- onOpenChange,
47
- src,
48
- outputType = 'image/png',
49
- outputName = 'cropped.png',
50
- onConfirm,
51
- }) => {
52
- const canvasRef = useRef<HTMLCanvasElement>(null);
53
- const imgRef = useRef<HTMLImageElement | null>(null);
54
- const [rotation, setRotation] = useState(0); // 0, 90, 180, 270
55
- const [crop, setCrop] = useState<CropRect>({ x: 0, y: 0, width: 100, height: 100 });
56
- const [drag, setDrag] = useState<{ ox: number; oy: number; cx: number; cy: number } | null>(null);
57
- const [imageReady, setImageReady] = useState(false);
58
-
59
- // Load the image when the dialog opens
60
- useEffect(() => {
61
- if (!open || !src) return;
62
- setImageReady(false);
63
- const img = new Image();
64
- img.crossOrigin = 'anonymous';
65
- img.onload = () => {
66
- imgRef.current = img;
67
- // Default crop = centred 80% box
68
- const w = img.naturalWidth * 0.8;
69
- const h = img.naturalHeight * 0.8;
70
- setCrop({
71
- x: (img.naturalWidth - w) / 2,
72
- y: (img.naturalHeight - h) / 2,
73
- width: w,
74
- height: h,
75
- });
76
- setRotation(0);
77
- setImageReady(true);
78
- };
79
- img.onerror = () => setImageReady(false);
80
- img.src = src;
81
- }, [open, src]);
82
-
83
- // Render image + crop overlay
84
- useEffect(() => {
85
- if (!imageReady || !imgRef.current || !canvasRef.current) return;
86
- const img = imgRef.current;
87
- const canvas = canvasRef.current;
88
- const ctx = canvas.getContext('2d');
89
- if (!ctx) return;
90
-
91
- // Scale image to fit a 480px-wide preview while preserving aspect
92
- const previewW = 480;
93
- const scale = previewW / img.naturalWidth;
94
- const previewH = img.naturalHeight * scale;
95
- canvas.width = previewW;
96
- canvas.height = previewH;
97
- ctx.clearRect(0, 0, previewW, previewH);
98
-
99
- // Apply rotation to the image (purely visual — actual rotation is baked in on confirm)
100
- ctx.save();
101
- ctx.translate(previewW / 2, previewH / 2);
102
- ctx.rotate((rotation * Math.PI) / 180);
103
- const drawW = rotation % 180 === 0 ? previewW : previewH;
104
- const drawH = rotation % 180 === 0 ? previewH : previewW;
105
- ctx.drawImage(img, -drawW / 2, -drawH / 2, drawW, drawH);
106
- ctx.restore();
107
-
108
- // Draw crop overlay (in preview coordinates)
109
- const px = crop.x * scale;
110
- const py = crop.y * scale;
111
- const pw = crop.width * scale;
112
- const ph = crop.height * scale;
113
- ctx.fillStyle = 'rgba(0,0,0,0.45)';
114
- ctx.fillRect(0, 0, previewW, py);
115
- ctx.fillRect(0, py + ph, previewW, previewH - py - ph);
116
- ctx.fillRect(0, py, px, ph);
117
- ctx.fillRect(px + pw, py, previewW - px - pw, ph);
118
- ctx.strokeStyle = '#3b82f6';
119
- ctx.lineWidth = 2;
120
- ctx.strokeRect(px, py, pw, ph);
121
- }, [imageReady, crop, rotation]);
122
-
123
- const handlePointerDown = useCallback(
124
- (e: React.PointerEvent<HTMLCanvasElement>) => {
125
- if (!imgRef.current || !canvasRef.current) return;
126
- const rect = canvasRef.current.getBoundingClientRect();
127
- const scale = imgRef.current.naturalWidth / rect.width;
128
- setDrag({
129
- ox: (e.clientX - rect.left) * scale,
130
- oy: (e.clientY - rect.top) * scale,
131
- cx: crop.x,
132
- cy: crop.y,
133
- });
134
- (e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
135
- },
136
- [crop],
137
- );
138
-
139
- const handlePointerMove = useCallback(
140
- (e: React.PointerEvent<HTMLCanvasElement>) => {
141
- if (!drag || !imgRef.current || !canvasRef.current) return;
142
- const rect = canvasRef.current.getBoundingClientRect();
143
- const scale = imgRef.current.naturalWidth / rect.width;
144
- const dx = (e.clientX - rect.left) * scale - drag.ox;
145
- const dy = (e.clientY - rect.top) * scale - drag.oy;
146
- const nextX = Math.max(0, Math.min(drag.cx + dx, imgRef.current.naturalWidth - crop.width));
147
- const nextY = Math.max(0, Math.min(drag.cy + dy, imgRef.current.naturalHeight - crop.height));
148
- setCrop((c) => ({ ...c, x: nextX, y: nextY }));
149
- },
150
- [drag, crop.width, crop.height],
151
- );
152
-
153
- const handlePointerUp = useCallback(() => setDrag(null), []);
154
-
155
- const rotate = useCallback(() => setRotation((r) => (r + 90) % 360), []);
156
-
157
- const handleConfirm = useCallback(async () => {
158
- if (!imgRef.current) return;
159
- const img = imgRef.current;
160
- const out = document.createElement('canvas');
161
- // After rotation, swap width/height when needed
162
- const cw = crop.width;
163
- const ch = crop.height;
164
- out.width = rotation % 180 === 0 ? cw : ch;
165
- out.height = rotation % 180 === 0 ? ch : cw;
166
- const ctx = out.getContext('2d');
167
- if (!ctx) return;
168
- ctx.save();
169
- ctx.translate(out.width / 2, out.height / 2);
170
- ctx.rotate((rotation * Math.PI) / 180);
171
- ctx.drawImage(img, crop.x, crop.y, cw, ch, -cw / 2, -ch / 2, cw, ch);
172
- ctx.restore();
173
- const blob: Blob | null = await new Promise((resolve) => out.toBlob(resolve, outputType));
174
- if (blob) {
175
- onConfirm(blob, outputName);
176
- onOpenChange(false);
177
- }
178
- }, [crop, rotation, outputName, outputType, onConfirm, onOpenChange]);
179
-
180
- return (
181
- <Dialog open={open} onOpenChange={onOpenChange}>
182
- <DialogContent className="max-w-xl">
183
- <DialogHeader>
184
- <DialogTitle>Crop & rotate image</DialogTitle>
185
- <DialogDescription>
186
- Drag the highlighted box to reposition the crop. Use the rotate button to spin the image
187
- in 90° increments.
188
- </DialogDescription>
189
- </DialogHeader>
190
-
191
- <div className="flex flex-col items-center gap-3">
192
- {imageReady ? (
193
- <canvas
194
- ref={canvasRef}
195
- data-testid="image-cropper-canvas"
196
- role="img"
197
- aria-label="Image crop preview"
198
- className="border rounded-md cursor-move touch-none max-w-full"
199
- onPointerDown={handlePointerDown}
200
- onPointerMove={handlePointerMove}
201
- onPointerUp={handlePointerUp}
202
- onPointerCancel={handlePointerUp}
203
- />
204
- ) : (
205
- <div className="flex h-[270px] w-full items-center justify-center text-sm text-muted-foreground">
206
- Loading image…
207
- </div>
208
- )}
209
- <div className="flex w-full items-center justify-between text-xs text-muted-foreground">
210
- <span>
211
- Crop: {Math.round(crop.width)} × {Math.round(crop.height)} px
212
- </span>
213
- <span>Rotation: {rotation}°</span>
214
- </div>
215
- </div>
216
-
217
- <DialogFooter className="flex justify-between sm:justify-between">
218
- <Button
219
- type="button"
220
- variant="outline"
221
- onClick={rotate}
222
- data-testid="image-cropper-rotate"
223
- >
224
- <RotateCw className="size-4 mr-2" />
225
- Rotate 90°
226
- </Button>
227
- <div className="flex gap-2">
228
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
229
- Cancel
230
- </Button>
231
- <Button
232
- type="button"
233
- onClick={handleConfirm}
234
- disabled={!imageReady}
235
- data-testid="image-cropper-confirm"
236
- >
237
- <CropIcon className="size-4 mr-2" />
238
- Apply crop
239
- </Button>
240
- </div>
241
- </DialogFooter>
242
- </DialogContent>
243
- </Dialog>
244
- );
245
- };
@@ -1,154 +0,0 @@
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
- }));