@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.
- package/CHANGELOG.md +1533 -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 +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- 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 +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- 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__/cell-truncation.test.tsx +96 -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__/field-carrier-sdui.test.tsx +169 -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__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- 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 +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- 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 +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- 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__/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-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/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.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.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/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/SelectField.cascade.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/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
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
* Unique sub-input ids for the composite `geolocation` widget
|
|
11
|
+
* (objectui#3343).
|
|
12
|
+
*
|
|
13
|
+
* The widget used to hardcode literal ids ("latitude" / "longitude") on its
|
|
14
|
+
* sub-inputs. Two geolocation fields in one form — e.g. origin + destination
|
|
15
|
+
* — then produced duplicate DOM ids, and every `Label htmlFor` resolved to
|
|
16
|
+
* the FIRST match in the document: each sub-label of the second field
|
|
17
|
+
* clicked/announced the first field's input.
|
|
18
|
+
*
|
|
19
|
+
* The fix follows the `groupId` paradigm of RadioField / CheckboxesField:
|
|
20
|
+
* a `useId()` prefix + the sub-field name. These tests pin it by rendering
|
|
21
|
+
* TWO instances in one form:
|
|
22
|
+
*
|
|
23
|
+
* 1. every `[id]` in the document is globally unique (red again if anyone
|
|
24
|
+
* reverts to hardcoded literals — both instances would emit
|
|
25
|
+
* id="latitude");
|
|
26
|
+
* 2. each sub-label of the SECOND instance resolves — via the exact
|
|
27
|
+
* mechanism browsers use for label activation, `for` → getElementById —
|
|
28
|
+
* to an input inside its OWN instance, and focusing it lands there.
|
|
29
|
+
*/
|
|
30
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
31
|
+
import React from 'react';
|
|
32
|
+
import { render, within } from '@testing-library/react';
|
|
33
|
+
import '@testing-library/jest-dom';
|
|
34
|
+
import { GeolocationField } from './GeolocationField';
|
|
35
|
+
|
|
36
|
+
const field = { name: 'location', type: 'geolocation' } as any;
|
|
37
|
+
|
|
38
|
+
const SUB_LABELS = ['Latitude', 'Longitude'];
|
|
39
|
+
|
|
40
|
+
function renderTwoInOneForm() {
|
|
41
|
+
return render(
|
|
42
|
+
<form>
|
|
43
|
+
<div data-testid="geo-a">
|
|
44
|
+
<GeolocationField value={{}} onChange={vi.fn()} field={field} />
|
|
45
|
+
</div>
|
|
46
|
+
<div data-testid="geo-b">
|
|
47
|
+
<GeolocationField value={{}} onChange={vi.fn()} field={field} />
|
|
48
|
+
</div>
|
|
49
|
+
</form>,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('GeolocationField — unique sub-input ids (objectui#3343)', () => {
|
|
54
|
+
it('two geolocation fields in one form produce globally unique DOM ids', () => {
|
|
55
|
+
const { baseElement } = renderTwoInOneForm();
|
|
56
|
+
const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
|
|
57
|
+
// 2 sub-inputs per instance — both instances must actually render ids.
|
|
58
|
+
expect(ids.length).toBeGreaterThanOrEqual(4);
|
|
59
|
+
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
60
|
+
expect(duplicates).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("each sub-label of the SECOND field focuses that field's own input", () => {
|
|
64
|
+
const { getByTestId } = renderTwoInOneForm();
|
|
65
|
+
const first = getByTestId('geo-a');
|
|
66
|
+
const second = getByTestId('geo-b');
|
|
67
|
+
|
|
68
|
+
for (const text of SUB_LABELS) {
|
|
69
|
+
const label = within(second).getByText(text);
|
|
70
|
+
const forId = label.getAttribute('for');
|
|
71
|
+
expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
|
|
72
|
+
|
|
73
|
+
// Exactly what the browser does on label click / SR announcement:
|
|
74
|
+
// resolve `for` against the document.
|
|
75
|
+
const control = document.getElementById(forId!);
|
|
76
|
+
expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
|
|
77
|
+
expect(control!.tagName).toBe('INPUT');
|
|
78
|
+
expect(
|
|
79
|
+
second.contains(control),
|
|
80
|
+
`"${text}" must resolve into its OWN field, not the first one`,
|
|
81
|
+
).toBe(true);
|
|
82
|
+
expect(first.contains(control)).toBe(false);
|
|
83
|
+
|
|
84
|
+
(control as HTMLInputElement).focus();
|
|
85
|
+
expect(control).toHaveFocus();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -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
|
|
|
@@ -324,10 +390,14 @@ export function GridField({
|
|
|
324
390
|
* the header (`parent.status == 'paid'`). Supplied by MasterDetailForm. */
|
|
325
391
|
contextRecord?: Record<string, unknown>;
|
|
326
392
|
}) {
|
|
327
|
-
const cfg = (field ||
|
|
393
|
+
const cfg = (field || {}) as any;
|
|
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
|
/>
|