@object-ui/fields 17.3.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.
- package/CHANGELOG.md +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -3,6 +3,7 @@ import { Input, Button, Label, EmptyValue } from '@object-ui/components';
|
|
|
3
3
|
import { MapPin, Crosshair } from 'lucide-react';
|
|
4
4
|
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { toDomProps } from './toDomProps';
|
|
6
|
+
import { toHostGroupProps } from './toHostGroupProps';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Geolocation data structure
|
|
@@ -23,7 +24,24 @@ export function GeolocationField({ value, onChange, field, readonly, error, ...p
|
|
|
23
24
|
// DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
|
|
24
25
|
// sub-input (latitude); the composite's validation state goes onto BOTH
|
|
25
26
|
// focusable sub-inputs via `aria-invalid={!!error}`.
|
|
26
|
-
|
|
27
|
+
//
|
|
28
|
+
// `id` and `aria-labelledby` are held back and land on the group CONTAINER
|
|
29
|
+
// instead (objectui#3961) — they address the whole field, not the latitude box.
|
|
30
|
+
// The host `id` never reached the DOM before: it was overwritten one line later
|
|
31
|
+
// by `id={subId('latitude')}` (objectui#3343), leaving the form's group label
|
|
32
|
+
// pointing at nothing. And `aria-labelledby` on the latitude input would
|
|
33
|
+
// OVERRIDE its own "Latitude" label with the field name. `aria-describedby` and
|
|
34
|
+
// the rest stay on the first sub-input, where focus can reach them.
|
|
35
|
+
const {
|
|
36
|
+
id: _hostId,
|
|
37
|
+
'aria-labelledby': _hostLabelledBy,
|
|
38
|
+
...domProps
|
|
39
|
+
} = toDomProps(props);
|
|
40
|
+
// The pair held back above, in the one spelling every group-labelled widget
|
|
41
|
+
// uses for it — and computed HERE, before the readonly early return below,
|
|
42
|
+
// because that return used to drop both keys on the floor: a published label
|
|
43
|
+
// id with no consumer in the document (objectui#3990). See `toHostGroupProps`.
|
|
44
|
+
const hostGroupProps = toHostGroupProps(props);
|
|
27
45
|
// Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
|
|
28
46
|
// `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
|
|
29
47
|
// ("latitude" / "longitude") collide as soon as a form renders two
|
|
@@ -79,9 +97,13 @@ export function GeolocationField({ value, onChange, field, readonly, error, ...p
|
|
|
79
97
|
};
|
|
80
98
|
|
|
81
99
|
if (readonly) {
|
|
100
|
+
// Readonly the composite collapses to the formatted coordinates (plus the
|
|
101
|
+
// "View on map" link), so THAT row is the surface the host label names
|
|
102
|
+
// (objectui#3990). The `EmptyValue` placeholder sits inside it, so it needs
|
|
103
|
+
// nothing of its own.
|
|
82
104
|
const formatted = formatLocation(location);
|
|
83
105
|
return (
|
|
84
|
-
<div className="flex items-center gap-2">
|
|
106
|
+
<div {...hostGroupProps} className="flex items-center gap-2">
|
|
85
107
|
<MapPin className="w-4 h-4 text-muted-foreground" />
|
|
86
108
|
{formatted ? <span className="text-sm">{formatted}</span> : <EmptyValue />}
|
|
87
109
|
{location.latitude && location.longitude && (
|
|
@@ -100,7 +122,11 @@ export function GeolocationField({ value, onChange, field, readonly, error, ...p
|
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
return (
|
|
103
|
-
|
|
125
|
+
// `role="group"` only when a host actually named this container
|
|
126
|
+
// (objectui#3961); standalone rendering stays exactly as it was. That
|
|
127
|
+
// condition now lives in `toHostGroupProps` so this container and the
|
|
128
|
+
// readonly row above cannot answer differently (objectui#3990).
|
|
129
|
+
<div className="space-y-3" {...hostGroupProps}>
|
|
104
130
|
<div className="flex items-center gap-2">
|
|
105
131
|
<Button
|
|
106
132
|
type="button"
|
|
@@ -113,6 +113,232 @@ describe('GridField / LineItemsField — editable line items', () => {
|
|
|
113
113
|
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: 42.5 }]);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* objectui#3566 — the sub-grid face of #3127. `<input type="date">` accepts
|
|
118
|
+
* exactly `YYYY-MM-DD` and rejects every other shape SILENTLY: the attribute
|
|
119
|
+
* still lands in the DOM, so the value looks present in the markup, while
|
|
120
|
+
* `.value` reads back `''` and the cell paints its empty placeholder. Since
|
|
121
|
+
* the API hands back `2026-06-17T00:00:00.000Z` for a `date` field, every
|
|
122
|
+
* date cell in an inline child grid rendered blank on a row that has a value.
|
|
123
|
+
* Asserting `.value` (never `getAttribute('value')`) is the only way to see
|
|
124
|
+
* this bug at all.
|
|
125
|
+
*/
|
|
126
|
+
describe('date columns echo the stored value (#3566)', () => {
|
|
127
|
+
const dateField = {
|
|
128
|
+
columns: [
|
|
129
|
+
{ field: 'description', label: 'Description', type: 'text' as const },
|
|
130
|
+
{ field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
|
|
131
|
+
],
|
|
132
|
+
} as any;
|
|
133
|
+
|
|
134
|
+
// Both spellings the API may hand back for a `date` field. The bare form
|
|
135
|
+
// must survive VERBATIM — re-parsing it as UTC midnight and reading local
|
|
136
|
+
// calendar components back out would move it to the 16th anywhere west of
|
|
137
|
+
// Greenwich, which is the off-by-one this fix must not introduce.
|
|
138
|
+
it.each(['2026-06-17', '2026-06-17T00:00:00.000Z'])(
|
|
139
|
+
'shows the stored calendar day for %s instead of an empty cell',
|
|
140
|
+
(stored) => {
|
|
141
|
+
render(
|
|
142
|
+
<GridField value={[{ description: 'Taxi', incurred_on: stored }]} onChange={() => {}} field={dateField} />,
|
|
143
|
+
);
|
|
144
|
+
const cell = screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement;
|
|
145
|
+
expect(cell.type).toBe('date');
|
|
146
|
+
expect(cell.value).toBe('2026-06-17');
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
it('leaves an empty date cell empty', () => {
|
|
151
|
+
render(<GridField value={[{ description: 'Taxi', incurred_on: null }]} onChange={() => {}} field={dateField} />);
|
|
152
|
+
expect((screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement).value).toBe('');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('writes back the control\'s own YYYY-MM-DD, unchanged by the read fix', () => {
|
|
156
|
+
// No paired conversion on the write side: `onChange` hands over exactly
|
|
157
|
+
// what `<input type="date">` produces, matching DateField's contract. A
|
|
158
|
+
// conversion here would put read and write on different bases.
|
|
159
|
+
const onChange = vi.fn();
|
|
160
|
+
render(
|
|
161
|
+
<GridField
|
|
162
|
+
value={[{ description: 'Taxi', incurred_on: '2026-06-17T00:00:00.000Z' }]}
|
|
163
|
+
onChange={onChange}
|
|
164
|
+
field={dateField}
|
|
165
|
+
/>,
|
|
166
|
+
);
|
|
167
|
+
fireEvent.change(screen.getAllByLabelText('Incurred On')[0], { target: { value: '2026-06-18' } });
|
|
168
|
+
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', incurred_on: '2026-06-18' }]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* objectui#3569 — the type collapse behind #3566's symptom. `datetime` and
|
|
174
|
+
* `time` used to be mapped onto the ONE `date` column type, so a datetime
|
|
175
|
+
* cell rendered `<input type="date">`. That is not merely an under-render:
|
|
176
|
+
* the control emits a bare `YYYY-MM-DD`, so a user who only re-picked the
|
|
177
|
+
* DAY silently wrote the record's time component out of existence. The
|
|
178
|
+
* falsifiable claim of these tests is therefore the TIME COMPONENT, not the
|
|
179
|
+
* control's markup.
|
|
180
|
+
*
|
|
181
|
+
* Local-wall-clock expectations are computed here from `Date` rather than
|
|
182
|
+
* hard-coded, so the suite asserts the same thing in every timezone (and the
|
|
183
|
+
* day-shift arithmetic is done in LOCAL parts, so it is DST-safe too).
|
|
184
|
+
*/
|
|
185
|
+
describe('datetime / time columns get their own control (#3569)', () => {
|
|
186
|
+
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
187
|
+
/** The local wall clock an `<input type="datetime-local">` must show for `iso`. */
|
|
188
|
+
const localInputValue = (iso: string | Date) => {
|
|
189
|
+
const d = iso instanceof Date ? iso : new Date(iso);
|
|
190
|
+
return (
|
|
191
|
+
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
|
|
192
|
+
`T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const temporalField = {
|
|
197
|
+
columns: [
|
|
198
|
+
{ field: 'merchant', label: 'Merchant', type: 'text' as const },
|
|
199
|
+
{ field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
|
|
200
|
+
{ field: 'incurred_at', label: 'Incurred At', type: 'datetime' as const },
|
|
201
|
+
{ field: 'started_at', label: 'Started At', type: 'time' as const },
|
|
202
|
+
],
|
|
203
|
+
} as any;
|
|
204
|
+
|
|
205
|
+
const STORED_ISO = '2026-06-17T14:30:00.000Z';
|
|
206
|
+
|
|
207
|
+
it('renders a datetime column as datetime-local, echoing the stored instant', () => {
|
|
208
|
+
render(
|
|
209
|
+
<GridField
|
|
210
|
+
value={[{ merchant: 'Chipotle', incurred_at: STORED_ISO }]}
|
|
211
|
+
onChange={() => {}}
|
|
212
|
+
field={temporalField}
|
|
213
|
+
/>,
|
|
214
|
+
);
|
|
215
|
+
const cell = screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement;
|
|
216
|
+
expect(cell.type).toBe('datetime-local');
|
|
217
|
+
expect(cell.value).toBe(localInputValue(STORED_ISO));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does NOT collapse the datetime column onto the date control', () => {
|
|
221
|
+
render(
|
|
222
|
+
<GridField
|
|
223
|
+
value={[{ merchant: 'Chipotle', incurred_on: '2026-06-17', incurred_at: STORED_ISO }]}
|
|
224
|
+
onChange={() => {}}
|
|
225
|
+
field={temporalField}
|
|
226
|
+
/>,
|
|
227
|
+
);
|
|
228
|
+
// The sibling `date` column keeps its own control — the two are distinct
|
|
229
|
+
// now, which is exactly what was lost before.
|
|
230
|
+
expect((screen.getAllByLabelText('Incurred On')[0] as HTMLInputElement).type).toBe('date');
|
|
231
|
+
expect((screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement).type).toBe('datetime-local');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/** THE regression: editing only the day must not destroy the time. */
|
|
235
|
+
it('keeps the time component when only the day is edited', () => {
|
|
236
|
+
const onChange = vi.fn();
|
|
237
|
+
render(
|
|
238
|
+
<GridField
|
|
239
|
+
value={[{ merchant: 'Chipotle', incurred_at: STORED_ISO }]}
|
|
240
|
+
onChange={onChange}
|
|
241
|
+
field={temporalField}
|
|
242
|
+
/>,
|
|
243
|
+
);
|
|
244
|
+
const cell = screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement;
|
|
245
|
+
|
|
246
|
+
// What a user does to correct a date: move the day forward one, leave
|
|
247
|
+
// the wall clock alone.
|
|
248
|
+
const before = new Date(STORED_ISO);
|
|
249
|
+
const nextDay = new Date(before);
|
|
250
|
+
nextDay.setDate(nextDay.getDate() + 1);
|
|
251
|
+
fireEvent.change(cell, { target: { value: localInputValue(nextDay) } });
|
|
252
|
+
|
|
253
|
+
const emitted = onChange.mock.calls[0][0][0].incurred_at;
|
|
254
|
+
const after = new Date(emitted);
|
|
255
|
+
expect(after.getHours()).toBe(before.getHours());
|
|
256
|
+
expect(after.getMinutes()).toBe(before.getMinutes());
|
|
257
|
+
// Round-tripped as ISO-8601 (`fromDateTimeInputValue`), NOT the control's
|
|
258
|
+
// naive zone-less string — read and write share one basis.
|
|
259
|
+
expect(emitted).toBe(nextDay.toISOString());
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('leaves an empty datetime cell empty', () => {
|
|
263
|
+
render(
|
|
264
|
+
<GridField value={[{ merchant: 'Chipotle', incurred_at: null }]} onChange={() => {}} field={temporalField} />,
|
|
265
|
+
);
|
|
266
|
+
expect((screen.getAllByLabelText('Incurred At')[0] as HTMLInputElement).value).toBe('');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('renders a time column as a time control and round-trips HH:mm verbatim', () => {
|
|
270
|
+
const onChange = vi.fn();
|
|
271
|
+
render(
|
|
272
|
+
<GridField
|
|
273
|
+
value={[{ merchant: 'Chipotle', started_at: '14:30' }]}
|
|
274
|
+
onChange={onChange}
|
|
275
|
+
field={temporalField}
|
|
276
|
+
/>,
|
|
277
|
+
);
|
|
278
|
+
const cell = screen.getAllByLabelText('Started At')[0] as HTMLInputElement;
|
|
279
|
+
expect(cell.type).toBe('time');
|
|
280
|
+
expect(cell.value).toBe('14:30');
|
|
281
|
+
// A `time` value is a zone-less wall clock — stored exactly as typed, the
|
|
282
|
+
// same contract `TimeField` follows. No conversion in either direction.
|
|
283
|
+
fireEvent.change(cell, { target: { value: '09:15' } });
|
|
284
|
+
expect(onChange).toHaveBeenCalledWith([{ merchant: 'Chipotle', started_at: '09:15' }]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The read-only faces the issue calls out: `displayText()` and the
|
|
289
|
+
* read-only table both fell through to `String(value)`, printing the raw
|
|
290
|
+
* `2026-06-17T00:00:00.000Z` on screen. Neither could be fixed before the
|
|
291
|
+
* collapse was undone — with one column type the renderer had no way to
|
|
292
|
+
* know whether to show a day or a day plus a time.
|
|
293
|
+
*/
|
|
294
|
+
describe('read-only display formats each temporal type as itself', () => {
|
|
295
|
+
const row = { merchant: 'Chipotle', incurred_on: '2026-06-17T00:00:00.000Z', incurred_at: STORED_ISO, started_at: '14:30' };
|
|
296
|
+
// Spelled `'en'` rather than bare `toLocaleDateString()` (objectui#4468):
|
|
297
|
+
// the bare call reads the MACHINE's locale, so these pins agreed with the
|
|
298
|
+
// widget only by the accident of a CI runner set to en-US — and they
|
|
299
|
+
// would have kept agreeing with it after the widget started following the
|
|
300
|
+
// session locale, which is the bug this suite has to be able to see.
|
|
301
|
+
// `'en'` is what `useDisplayLocale()` resolves to with no provider.
|
|
302
|
+
const expectedDay = new Date(2026, 5, 17).toLocaleDateString('en');
|
|
303
|
+
const dt = new Date(STORED_ISO);
|
|
304
|
+
const expectedInstant = `${dt.toLocaleDateString('en')} ${dt.toLocaleTimeString('en')}`;
|
|
305
|
+
|
|
306
|
+
it('formats date / datetime / time in the read-only table', () => {
|
|
307
|
+
render(<GridField value={[row]} onChange={() => {}} field={temporalField} readonly />);
|
|
308
|
+
const table = screen.getByTestId('line-items-readonly');
|
|
309
|
+
expect(table.textContent).toContain(expectedDay);
|
|
310
|
+
expect(table.textContent).toContain(expectedInstant);
|
|
311
|
+
expect(table.textContent).toContain('14:30');
|
|
312
|
+
// The raw stored ISO must never reach the screen.
|
|
313
|
+
expect(table.textContent).not.toContain('2026-06-17T00:00:00.000Z');
|
|
314
|
+
expect(table.textContent).not.toContain(STORED_ISO);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('formats them the same way in list display mode', () => {
|
|
318
|
+
const { container } = render(
|
|
319
|
+
<GridField value={[row]} onChange={() => {}} field={temporalField} displayMode="list" onRowExpand={() => {}} />,
|
|
320
|
+
);
|
|
321
|
+
expect(container.textContent).toContain(expectedDay);
|
|
322
|
+
expect(container.textContent).toContain(expectedInstant);
|
|
323
|
+
expect(container.textContent).not.toContain(STORED_ISO);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('shows a value it cannot parse rather than "Invalid Date"', () => {
|
|
327
|
+
render(
|
|
328
|
+
<GridField value={[{ merchant: 'X', incurred_at: 'not-a-date' }]} onChange={() => {}} field={temporalField} readonly />,
|
|
329
|
+
);
|
|
330
|
+
expect(screen.getByTestId('line-items-readonly').textContent).toContain('not-a-date');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// The `zh` half of this — the sub-grid following the SESSION locale
|
|
334
|
+
// (objectui#4468) — lives in `__tests__/date-locale-channel.test.tsx`,
|
|
335
|
+
// not here. Mounting an `I18nProvider` anywhere in THIS file changes what
|
|
336
|
+
// the provider-less renders further down resolve, and the file-columns
|
|
337
|
+
// chip test reads a translated `aria-label`; keeping the provider in its
|
|
338
|
+
// own file keeps that coupling out of the way.
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
116
342
|
describe('trailing ghost row (start-with-one + auto-append)', () => {
|
|
117
343
|
it('renders a trailing empty row so an empty grid still has one input line', () => {
|
|
118
344
|
render(<GridField value={[]} onChange={() => {}} field={field} />);
|
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
} from '@object-ui/components';
|
|
18
18
|
import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react';
|
|
19
19
|
import { resolveFieldRuleState } from '@object-ui/core';
|
|
20
|
+
import { useDisplayLocale } from '@object-ui/i18n';
|
|
20
21
|
import { LookupField } from './LookupField';
|
|
21
22
|
import { FileCell } from './FileField';
|
|
23
|
+
import { toDateInputValue, toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* GridField / LineItemsField — editable child-grid ("line items") widget.
|
|
@@ -31,7 +33,8 @@ import { FileCell } from './FileField';
|
|
|
31
33
|
*
|
|
32
34
|
* Column config (a subset of `GridColumnDefinition`):
|
|
33
35
|
* { field, label?, type?, options?, width?, required?, prefix?, step? }
|
|
34
|
-
* type ∈ 'text' | 'number' | 'currency' | 'date' | '
|
|
36
|
+
* type ∈ 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time'
|
|
37
|
+
* | 'select' | 'lookup' | 'file'
|
|
35
38
|
*
|
|
36
39
|
* Field-level config (from `GridFieldMetadata`):
|
|
37
40
|
* columns, min_rows, max_rows, allow_add, allow_delete, total_field
|
|
@@ -40,7 +43,16 @@ import { FileCell } from './FileField';
|
|
|
40
43
|
export interface GridColumn {
|
|
41
44
|
field: string;
|
|
42
45
|
label?: string;
|
|
43
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Cell control + read/write adapter for the column.
|
|
48
|
+
*
|
|
49
|
+
* `date` / `datetime` / `time` are three DISTINCT controls, not one
|
|
50
|
+
* (objectui#3569). Collapsing `datetime` onto the `date` control did not
|
|
51
|
+
* merely under-render it — `<input type="date">` hands back a bare
|
|
52
|
+
* `YYYY-MM-DD` on change, so touching the day of a `datetime` cell silently
|
|
53
|
+
* DELETED its time component from the record.
|
|
54
|
+
*/
|
|
55
|
+
type?: 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file';
|
|
44
56
|
options?: Array<{ label: string; value: string }>;
|
|
45
57
|
width?: number;
|
|
46
58
|
required?: boolean;
|
|
@@ -110,6 +122,10 @@ const MIN_WIDTH_BY_TYPE: Record<string, number> = {
|
|
|
110
122
|
number: 104,
|
|
111
123
|
currency: 116,
|
|
112
124
|
date: 150,
|
|
125
|
+
// `<input type="datetime-local">` renders a day AND a wall clock, so it needs
|
|
126
|
+
// materially more room than a date cell before the browser starts eliding.
|
|
127
|
+
datetime: 200,
|
|
128
|
+
time: 116,
|
|
113
129
|
file: 168,
|
|
114
130
|
};
|
|
115
131
|
const minWidthFor = (c: GridColumn): number => c.width ?? MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
|
|
@@ -254,10 +270,52 @@ export function computeRow(columns: GridColumn[], row: Row): Row {
|
|
|
254
270
|
return next;
|
|
255
271
|
}
|
|
256
272
|
|
|
273
|
+
/** True for the three temporal column types, which all need a formatter on the
|
|
274
|
+
* read-only surfaces rather than the raw stored string (objectui#3569). */
|
|
275
|
+
const isTemporal = (t?: string) => t === 'date' || t === 'datetime' || t === 'time';
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read-only display text for a temporal cell (objectui#3569).
|
|
279
|
+
*
|
|
280
|
+
* The stored shapes differ per type and so must the rendering — which is only
|
|
281
|
+
* decidable now that `datetime`/`time` are no longer collapsed onto `date`:
|
|
282
|
+
*
|
|
283
|
+
* - `date` — a calendar day. Formatted from its VERBATIM `YYYY-MM-DD` parts via
|
|
284
|
+
* a local `Date`, never by parsing the stored string: `new Date('2026-06-17')`
|
|
285
|
+
* is UTC midnight, so reading local calendar components back out of it moves
|
|
286
|
+
* the day to the 16th everywhere west of Greenwich.
|
|
287
|
+
* - `datetime` — an instant. Rendered as local day + local time, the same basis
|
|
288
|
+
* `toDateTimeInputValue` uses for the editor, so the two never disagree (and
|
|
289
|
+
* matching `DateTimeField`'s own read-only rendering).
|
|
290
|
+
* - `time` — a zone-less wall clock (`HH:mm[:ss]`); it is already display-ready.
|
|
291
|
+
*
|
|
292
|
+
* An unparseable value falls through to its raw string rather than rendering
|
|
293
|
+
* "Invalid Date" — showing the user what is actually stored beats hiding it.
|
|
294
|
+
*
|
|
295
|
+
* `locale` is threaded rather than left to `Intl`'s default (objectui#4468):
|
|
296
|
+
* the default is the MACHINE's locale, which has nothing to do with the
|
|
297
|
+
* session, so a Chinese form rendered `6/17/2026` in its line items.
|
|
298
|
+
*/
|
|
299
|
+
function temporalText(type: string | undefined, value: any, locale: string): string {
|
|
300
|
+
const raw = String(value);
|
|
301
|
+
if (type === 'time') return raw;
|
|
302
|
+
if (type === 'date') {
|
|
303
|
+
const ymd = toDateInputValue(value);
|
|
304
|
+
if (!ymd) return raw;
|
|
305
|
+
const [y, m, d] = ymd.split('-').map(Number);
|
|
306
|
+
return new Date(y, m - 1, d).toLocaleDateString(locale);
|
|
307
|
+
}
|
|
308
|
+
const dt = value instanceof Date ? value : new Date(raw);
|
|
309
|
+
if (Number.isNaN(dt.getTime())) return raw;
|
|
310
|
+
return `${dt.toLocaleDateString(locale)} ${dt.toLocaleTimeString(locale)}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
257
313
|
/** Read-only display text for a cell in list mode (select → option label,
|
|
258
|
-
* currency/number → formatted,
|
|
259
|
-
|
|
314
|
+
* currency/number → formatted, date/datetime/time → localized, empty → em
|
|
315
|
+
* dash). Lookups render separately. */
|
|
316
|
+
function displayText(c: GridColumn, value: any, locale: string): string {
|
|
260
317
|
if (value === null || value === undefined || value === '') return '—';
|
|
318
|
+
if (isTemporal(c.type)) return temporalText(c.type, value, locale);
|
|
261
319
|
if (c.type === 'file') {
|
|
262
320
|
const files = Array.isArray(value) ? value : [value];
|
|
263
321
|
if (files.length === 0) return '—';
|
|
@@ -283,6 +341,14 @@ function coerce(type: string | undefined, raw: string): any {
|
|
|
283
341
|
const n = Number(raw);
|
|
284
342
|
return Number.isNaN(n) ? raw : n;
|
|
285
343
|
}
|
|
344
|
+
// `<input type="datetime-local">` hands back a ZONE-LESS local wall clock;
|
|
345
|
+
// storing that verbatim would put the write on a different basis from the
|
|
346
|
+
// read (`toDateTimeInputValue`), so a user in UTC+8 who picked 08:33 would
|
|
347
|
+
// store 08:33Z and see 16:33 back. The pair must be used as a pair — this is
|
|
348
|
+
// the same contract `DateTimeField` follows (objectui#3127 / #3569).
|
|
349
|
+
// `date` and `time` need no conversion: their controls already emit exactly
|
|
350
|
+
// the stored shape (`YYYY-MM-DD`, `HH:mm`).
|
|
351
|
+
if (type === 'datetime') return fromDateTimeInputValue(raw);
|
|
286
352
|
return raw;
|
|
287
353
|
}
|
|
288
354
|
|
|
@@ -328,6 +394,10 @@ export function GridField({
|
|
|
328
394
|
const allColumns: GridColumn[] = cfg.columns || [];
|
|
329
395
|
const rows: Row[] = Array.isArray(value) ? value : [];
|
|
330
396
|
const contextRecord = props.contextRecord;
|
|
397
|
+
// The one locale channel every field renderer resolves through: tenant
|
|
398
|
+
// regional default → active UI language → 'en' (objectui#4468). Read here
|
|
399
|
+
// and passed down, since `displayText` is a pure helper.
|
|
400
|
+
const displayLocale = useDisplayLocale();
|
|
331
401
|
|
|
332
402
|
// Per-cell CEL rule state (B2 in grids). A column with no readonlyWhen/
|
|
333
403
|
// requiredWhen resolves to its static flags (cheap fast-path — no engine
|
|
@@ -626,8 +696,13 @@ export function GridField({
|
|
|
626
696
|
readonly
|
|
627
697
|
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
|
|
628
698
|
/>
|
|
629
|
-
) : c.type === 'file' ? (
|
|
630
|
-
|
|
699
|
+
) : c.type === 'file' || isTemporal(c.type) ? (
|
|
700
|
+
// A temporal column printed with `String(value)` puts
|
|
701
|
+
// the raw stored ISO on screen — `2026-06-17T00:00:00.000Z`
|
|
702
|
+
// for a date, and for a datetime it would ALSO have been
|
|
703
|
+
// wrong to render as a bare day (objectui#3569). Now that
|
|
704
|
+
// the three types are distinct, each formats as itself.
|
|
705
|
+
displayText(c, row[c.field], displayLocale)
|
|
631
706
|
) : row[c.field] != null && row[c.field] !== '' ? (
|
|
632
707
|
String(row[c.field])
|
|
633
708
|
) : (
|
|
@@ -687,7 +762,7 @@ export function GridField({
|
|
|
687
762
|
}
|
|
688
763
|
return (
|
|
689
764
|
<span className={cn('px-2 text-sm text-foreground', isNumeric(c.type) && 'tabular-nums', (val == null || val === '') && 'text-muted-foreground')}>
|
|
690
|
-
{displayText(c, val)}
|
|
765
|
+
{displayText(c, val, displayLocale)}
|
|
691
766
|
</span>
|
|
692
767
|
);
|
|
693
768
|
}
|
|
@@ -699,7 +774,7 @@ export function GridField({
|
|
|
699
774
|
title="Computed"
|
|
700
775
|
data-computed={c.field}
|
|
701
776
|
>
|
|
702
|
-
{displayText(c, val)}
|
|
777
|
+
{displayText(c, val, displayLocale)}
|
|
703
778
|
</span>
|
|
704
779
|
);
|
|
705
780
|
}
|
|
@@ -757,10 +832,39 @@ export function GridField({
|
|
|
757
832
|
c.type === 'currency' && 'pl-6',
|
|
758
833
|
isNumeric(c.type) && 'text-right tabular-nums',
|
|
759
834
|
)}
|
|
760
|
-
type={
|
|
835
|
+
type={
|
|
836
|
+
c.type === 'date'
|
|
837
|
+
? 'date'
|
|
838
|
+
: c.type === 'datetime'
|
|
839
|
+
? 'datetime-local'
|
|
840
|
+
: c.type === 'time'
|
|
841
|
+
? 'time'
|
|
842
|
+
: isNumeric(c.type)
|
|
843
|
+
? 'number'
|
|
844
|
+
: 'text'
|
|
845
|
+
}
|
|
761
846
|
step={isNumeric(c.type) ? c.step ?? 'any' : undefined}
|
|
762
847
|
aria-label={c.label || c.field}
|
|
763
|
-
|
|
848
|
+
// A temporal cell holding the API's ISO shape (`2026-06-17T14:30:00.000Z`)
|
|
849
|
+
// is SILENTLY rejected by the native control — the attribute lands in
|
|
850
|
+
// the DOM but `input.value` reads back `''` and the cell paints empty
|
|
851
|
+
// (objectui#3566, the sub-grid face of #3127). So each type reads
|
|
852
|
+
// through its own adapter:
|
|
853
|
+
// date → `YYYY-MM-DD`; the control writes that shape back
|
|
854
|
+
// verbatim, so no paired conversion on `onChange`.
|
|
855
|
+
// datetime → `YYYY-MM-DDTHH:mm` local; PAIRED with
|
|
856
|
+
// `fromDateTimeInputValue` in `coerce` so read and write
|
|
857
|
+
// share one basis (objectui#3569).
|
|
858
|
+
// time → `HH:mm[:ss]` is already the stored shape, both ways.
|
|
859
|
+
value={
|
|
860
|
+
c.type === 'date'
|
|
861
|
+
? toDateInputValue(val)
|
|
862
|
+
: c.type === 'datetime'
|
|
863
|
+
? toDateTimeInputValue(val)
|
|
864
|
+
: val != null
|
|
865
|
+
? String(val)
|
|
866
|
+
: ''
|
|
867
|
+
}
|
|
764
868
|
onChange={(e) => setCell(rowIdx, c, e.target.value)}
|
|
765
869
|
disabled={locked}
|
|
766
870
|
/>
|
|
@@ -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
|
+
}));
|