@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.
- package/CHANGELOG.md +714 -0
- package/dist/FieldEditWidget.d.ts +16 -2
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +4 -4
- package/dist/index.css +27 -9
- package/dist/index.d.ts +425 -72
- package/dist/index.js +2530 -2228
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/BooleanField.d.ts +1 -1
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- 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/DateField.d.ts +1 -1
- package/dist/widgets/DateTimeField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +28 -1
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +56 -4
- 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/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.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 +13 -7
- package/dist/widgets/PasswordField.d.ts +1 -1
- 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 +2 -4
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +1 -1
- package/dist/widgets/TimeField.d.ts +1 -1
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
- package/dist/widgets/richTextDisplay.d.ts +80 -0
- package/dist/widgets/toHostGroupProps.d.ts +119 -21
- package/dist/widgets/types.d.ts +8 -3
- package/package.json +9 -10
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,632 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
3
|
-
import React from 'react';
|
|
4
|
-
import { UploadProvider } from '@object-ui/providers';
|
|
5
|
-
import { GridField, LineItemsField, sumColumn, lookupAutofillPatch } from './GridField';
|
|
6
|
-
|
|
7
|
-
const columns = [
|
|
8
|
-
{ field: 'description', label: 'Description', type: 'text' as const },
|
|
9
|
-
{ field: 'amount', label: 'Amount', type: 'currency' as const },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
const field = { columns, total_field: 'amount' } as any;
|
|
13
|
-
|
|
14
|
-
describe('GridField / LineItemsField — editable line items', () => {
|
|
15
|
-
it('is exported under both names', () => {
|
|
16
|
-
expect(LineItemsField).toBe(GridField);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('renders a column header per config and an empty hint', () => {
|
|
20
|
-
render(<GridField value={[]} onChange={() => {}} field={field} />);
|
|
21
|
-
expect(screen.getByText('Description')).toBeTruthy();
|
|
22
|
-
expect(screen.getByText('Amount')).toBeTruthy();
|
|
23
|
-
expect(screen.getByRole('button', { name: /Add line/i })).toBeTruthy();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('Add line appends a blank row keyed by columns', () => {
|
|
27
|
-
const onChange = vi.fn();
|
|
28
|
-
render(<GridField value={[]} onChange={onChange} field={field} />);
|
|
29
|
-
fireEvent.click(screen.getByRole('button', { name: /Add line/i }));
|
|
30
|
-
expect(onChange).toHaveBeenCalledWith([{ description: null, amount: null }]);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('column chooser (defaultHidden columns)', () => {
|
|
34
|
-
const withHidden = {
|
|
35
|
-
columns: [
|
|
36
|
-
{ field: 'title', label: 'Title', type: 'text' as const, required: true },
|
|
37
|
-
{ field: 'amount', label: 'Amount', type: 'currency' as const },
|
|
38
|
-
{ field: 'notes', label: 'Notes', type: 'text' as const, defaultHidden: true },
|
|
39
|
-
],
|
|
40
|
-
} as any;
|
|
41
|
-
|
|
42
|
-
it('hides defaultHidden columns by default but keeps required/visible ones', () => {
|
|
43
|
-
render(<GridField value={[]} onChange={() => {}} field={withHidden} />);
|
|
44
|
-
expect(screen.getByText('Title')).toBeTruthy();
|
|
45
|
-
expect(screen.getByText('Amount')).toBeTruthy();
|
|
46
|
-
expect(screen.queryByText('Notes')).toBeNull(); // collapsed into the chooser
|
|
47
|
-
expect(screen.getByTestId('line-items-columns')).toBeTruthy();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('reveals an optional column when toggled in the chooser', () => {
|
|
51
|
-
render(<GridField value={[]} onChange={() => {}} field={withHidden} />);
|
|
52
|
-
fireEvent.click(screen.getByTestId('line-items-columns'));
|
|
53
|
-
fireEvent.click(screen.getByLabelText('Notes'));
|
|
54
|
-
expect(screen.getAllByText('Notes').length).toBeGreaterThan(0);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('shows no chooser when there are no optional columns', () => {
|
|
58
|
-
render(<GridField value={[]} onChange={() => {}} field={field} />);
|
|
59
|
-
expect(screen.queryByTestId('line-items-columns')).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('list mode (displayMode="list" — form-factor for fat children)', () => {
|
|
64
|
-
const listField = {
|
|
65
|
-
columns: [
|
|
66
|
-
{ field: 'title', label: 'Title', type: 'text' as const, required: true },
|
|
67
|
-
{ field: 'status', label: 'Status', type: 'select' as const, options: [{ label: 'To Do', value: 'todo' }] },
|
|
68
|
-
],
|
|
69
|
-
} as any;
|
|
70
|
-
|
|
71
|
-
it('renders rows read-only (no cell inputs) and an Add button', () => {
|
|
72
|
-
const onAdd = vi.fn();
|
|
73
|
-
render(
|
|
74
|
-
<GridField
|
|
75
|
-
value={[{ title: 'Ship it', status: 'todo' }]}
|
|
76
|
-
onChange={() => {}}
|
|
77
|
-
field={listField}
|
|
78
|
-
displayMode="list"
|
|
79
|
-
onRowExpand={() => {}}
|
|
80
|
-
onAdd={onAdd}
|
|
81
|
-
/>,
|
|
82
|
-
);
|
|
83
|
-
// Read-only display: the status renders its option label, not a combobox.
|
|
84
|
-
expect(screen.getByText('To Do')).toBeTruthy();
|
|
85
|
-
expect(screen.queryByLabelText('Title')).toBeNull(); // no editable input
|
|
86
|
-
expect(screen.getByRole('button', { name: /Open row/i })).toBeTruthy(); // per-row edit
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('Add calls onAdd (host opens the full form) instead of inserting a blank row', () => {
|
|
90
|
-
const onAdd = vi.fn();
|
|
91
|
-
const onChange = vi.fn();
|
|
92
|
-
render(
|
|
93
|
-
<GridField value={[]} onChange={onChange} field={listField} displayMode="list" onRowExpand={() => {}} onAdd={onAdd} />,
|
|
94
|
-
);
|
|
95
|
-
fireEvent.click(screen.getByTestId('line-items-add'));
|
|
96
|
-
expect(onAdd).toHaveBeenCalledTimes(1);
|
|
97
|
-
expect(onChange).not.toHaveBeenCalled(); // did NOT insert a blank inline row
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('editing a text cell emits the raw string', () => {
|
|
102
|
-
const onChange = vi.fn();
|
|
103
|
-
render(<GridField value={[{ description: '', amount: null }]} onChange={onChange} field={field} />);
|
|
104
|
-
// [0] = the data row ([1] would be the always-present trailing ghost row).
|
|
105
|
-
fireEvent.change(screen.getAllByLabelText('Description')[0], { target: { value: 'Taxi' } });
|
|
106
|
-
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: null }]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('editing a currency cell coerces to a number', () => {
|
|
110
|
-
const onChange = vi.fn();
|
|
111
|
-
render(<GridField value={[{ description: 'Taxi', amount: null }]} onChange={onChange} field={field} />);
|
|
112
|
-
fireEvent.change(screen.getAllByLabelText('Amount')[0], { target: { value: '42.5' } });
|
|
113
|
-
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', amount: 42.5 }]);
|
|
114
|
-
});
|
|
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
|
-
|
|
342
|
-
describe('trailing ghost row (start-with-one + auto-append)', () => {
|
|
343
|
-
it('renders a trailing empty row so an empty grid still has one input line', () => {
|
|
344
|
-
render(<GridField value={[]} onChange={() => {}} field={field} />);
|
|
345
|
-
// No "No items" empty-state in grid mode — the ghost row IS the first line.
|
|
346
|
-
expect(screen.getByText('Description')).toBeTruthy();
|
|
347
|
-
expect(screen.getAllByLabelText('Description')).toHaveLength(1); // just the ghost
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it('typing in the ghost row materialises a new row (no Add click needed)', () => {
|
|
351
|
-
const onChange = vi.fn();
|
|
352
|
-
render(<GridField value={[{ description: 'A', amount: 1 }]} onChange={onChange} field={field} />);
|
|
353
|
-
const inputs = screen.getAllByLabelText('Description');
|
|
354
|
-
expect(inputs).toHaveLength(2); // data row + ghost
|
|
355
|
-
fireEvent.change(inputs[1], { target: { value: 'B' } }); // type in the ghost
|
|
356
|
-
expect(onChange).toHaveBeenCalledWith([
|
|
357
|
-
{ description: 'A', amount: 1 },
|
|
358
|
-
{ description: 'B', amount: null },
|
|
359
|
-
]);
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
describe('computed columns (amount = qty × unit_price)', () => {
|
|
364
|
-
const computedField = {
|
|
365
|
-
columns: [
|
|
366
|
-
{ field: 'product', label: 'Product', type: 'text' as const },
|
|
367
|
-
{ field: 'quantity', label: 'Qty', type: 'number' as const },
|
|
368
|
-
{ field: 'unit_price', label: 'Unit Price', type: 'currency' as const },
|
|
369
|
-
{ field: 'amount', label: 'Amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price', scale: 2 },
|
|
370
|
-
],
|
|
371
|
-
total_field: 'amount',
|
|
372
|
-
} as any;
|
|
373
|
-
|
|
374
|
-
it('renders a computed column read-only (no input) and recomputes on edit', () => {
|
|
375
|
-
const onChange = vi.fn();
|
|
376
|
-
render(<GridField value={[{ product: 'Widget', quantity: 3, unit_price: 10, amount: 30 }]} onChange={onChange} field={computedField} />);
|
|
377
|
-
// Amount is display-only — there is no editable Amount cell.
|
|
378
|
-
expect(screen.queryByLabelText('Amount')).toBeNull();
|
|
379
|
-
// Editing quantity recomputes amount in the emitted row.
|
|
380
|
-
fireEvent.change(screen.getAllByLabelText('Qty')[0], { target: { value: '4' } });
|
|
381
|
-
expect(onChange).toHaveBeenCalledWith([{ product: 'Widget', quantity: 4, unit_price: 10, amount: 40 }]);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it('shows a dash for a computed cell whose inputs are blank', () => {
|
|
385
|
-
render(<GridField value={[{ product: 'Widget', quantity: null, unit_price: null, amount: null }]} onChange={() => {}} field={computedField} />);
|
|
386
|
-
// The computed amount cell reads "—" until its inputs exist.
|
|
387
|
-
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
|
|
388
|
-
});
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
describe('keyboard navigation', () => {
|
|
392
|
-
it('Enter moves focus to the same column in the next row', () => {
|
|
393
|
-
render(<GridField value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]} onChange={() => {}} field={field} />);
|
|
394
|
-
const row0 = screen.getAllByLabelText('Description')[0];
|
|
395
|
-
row0.focus();
|
|
396
|
-
fireEvent.keyDown(row0, { key: 'Enter' });
|
|
397
|
-
expect(document.activeElement).toBe(screen.getAllByLabelText('Description')[1]);
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
it('removing a row emits the array without it', () => {
|
|
402
|
-
const onChange = vi.fn();
|
|
403
|
-
render(
|
|
404
|
-
<GridField
|
|
405
|
-
value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]}
|
|
406
|
-
onChange={onChange}
|
|
407
|
-
field={field}
|
|
408
|
-
/>,
|
|
409
|
-
);
|
|
410
|
-
const removeButtons = screen.getAllByLabelText('Remove row');
|
|
411
|
-
fireEvent.click(removeButtons[0]);
|
|
412
|
-
expect(onChange).toHaveBeenCalledWith([{ description: 'B', amount: 2 }]);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it('row action buttons are always visible in grid mode (not hover-revealed)', () => {
|
|
416
|
-
render(
|
|
417
|
-
<GridField
|
|
418
|
-
value={[{ description: 'A', amount: 1 }]}
|
|
419
|
-
onChange={() => {}}
|
|
420
|
-
field={field}
|
|
421
|
-
/>,
|
|
422
|
-
);
|
|
423
|
-
// Grid-mode rows previously gated these behind opacity-0/group-hover, so they
|
|
424
|
-
// were invisible until hover and unreachable on touch — they must always show.
|
|
425
|
-
expect(screen.getByLabelText('Remove row').className).not.toContain('opacity-0');
|
|
426
|
-
expect(screen.getByTestId('line-items-duplicate-0').className).not.toContain('opacity-0');
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it('readonly mode shows values and a summed total footer', () => {
|
|
430
|
-
render(
|
|
431
|
-
<GridField
|
|
432
|
-
value={[{ description: 'A', amount: 10 }, { description: 'B', amount: 20 }]}
|
|
433
|
-
onChange={() => {}}
|
|
434
|
-
field={field}
|
|
435
|
-
readonly
|
|
436
|
-
/>,
|
|
437
|
-
);
|
|
438
|
-
expect(screen.getByTestId('line-items-readonly')).toBeTruthy();
|
|
439
|
-
expect(screen.getByText('Total')).toBeTruthy();
|
|
440
|
-
expect(screen.getByText('30')).toBeTruthy();
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
describe('lookupAutofillPatch (item typeahead auto-fill)', () => {
|
|
444
|
-
const cols = [
|
|
445
|
-
{ field: 'product', type: 'lookup' as const, reference: 'product' },
|
|
446
|
-
{ field: 'description', type: 'text' as const },
|
|
447
|
-
{ field: 'quantity', type: 'number' as const },
|
|
448
|
-
{ field: 'unit_price', type: 'currency' as const },
|
|
449
|
-
{ field: 'amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price' },
|
|
450
|
-
];
|
|
451
|
-
const product = { value: 'p1', label: 'Widget A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, sku: 'WIDGET-A' };
|
|
452
|
-
|
|
453
|
-
it('sets the FK id and copies same-named sibling fields from the record', () => {
|
|
454
|
-
const patch = lookupAutofillPatch(cols, cols[0], product);
|
|
455
|
-
expect(patch).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 29.99 });
|
|
456
|
-
// quantity (not on the record) and computed amount are left to the row/compute.
|
|
457
|
-
expect(patch).not.toHaveProperty('quantity');
|
|
458
|
-
expect(patch).not.toHaveProperty('amount');
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it('copies only the FK id when autofill is disabled', () => {
|
|
462
|
-
const patch = lookupAutofillPatch(cols, { ...cols[0], autofill: false }, product);
|
|
463
|
-
expect(patch).toEqual({ product: 'p1' });
|
|
464
|
-
});
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
describe('P1 affordances (duplicate / validation)', () => {
|
|
468
|
-
it('duplicates a row (id stripped) directly below the original', () => {
|
|
469
|
-
const onChange = vi.fn();
|
|
470
|
-
render(<GridField value={[{ id: 'r1', description: 'A', amount: 5 }]} onChange={onChange} field={field} />);
|
|
471
|
-
fireEvent.click(screen.getByTestId('line-items-duplicate-0'));
|
|
472
|
-
expect(onChange).toHaveBeenCalledWith([
|
|
473
|
-
{ id: 'r1', description: 'A', amount: 5 },
|
|
474
|
-
{ description: 'A', amount: 5 }, // copy without the id → persists as a new record
|
|
475
|
-
]);
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it('flags a required, empty cell on a real row (not the ghost row)', () => {
|
|
479
|
-
const reqField = { columns: [{ field: 'description', label: 'Description', type: 'text' as const, required: true }] } as any;
|
|
480
|
-
render(<GridField value={[{ description: '' }]} onChange={() => {}} field={reqField} />);
|
|
481
|
-
// The data row's required-empty cell is flagged...
|
|
482
|
-
expect(screen.getByTestId('line-items-invalid-0-description')).toBeTruthy();
|
|
483
|
-
// ...but the trailing ghost row (index 1) is not.
|
|
484
|
-
expect(screen.queryByTestId('line-items-invalid-1-description')).toBeNull();
|
|
485
|
-
});
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
describe('file columns (upload in a grid cell — #2360)', () => {
|
|
489
|
-
const fileField = {
|
|
490
|
-
columns: [
|
|
491
|
-
{ field: 'description', label: 'Description', type: 'text' as const },
|
|
492
|
-
{ field: 'receipt', label: 'Receipt', type: 'file' as const },
|
|
493
|
-
],
|
|
494
|
-
} as any;
|
|
495
|
-
|
|
496
|
-
it('renders a real upload control in the cell, not a text input', () => {
|
|
497
|
-
render(<GridField value={[{ description: 'Taxi', receipt: null }]} onChange={() => {}} field={fileField} />);
|
|
498
|
-
// One upload button per row (data row + ghost), backed by a native file input.
|
|
499
|
-
expect(screen.getAllByRole('button', { name: 'Receipt' }).length).toBeGreaterThan(0);
|
|
500
|
-
expect(document.querySelector('input[type="file"]')).toBeTruthy();
|
|
501
|
-
expect(screen.queryByRole('textbox', { name: 'Receipt' })).toBeNull();
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
it('uploads a picked file and writes the file object into the row', async () => {
|
|
505
|
-
const onChange = vi.fn();
|
|
506
|
-
const adapter = {
|
|
507
|
-
name: 'test',
|
|
508
|
-
upload: async (f: File) => ({ url: 'https://cdn/receipt.png', name: f.name, size: f.size, mimeType: f.type }),
|
|
509
|
-
};
|
|
510
|
-
render(
|
|
511
|
-
<UploadProvider adapter={adapter as any}>
|
|
512
|
-
<GridField value={[{ description: 'Taxi', receipt: null }]} onChange={onChange} field={fileField} />
|
|
513
|
-
</UploadProvider>,
|
|
514
|
-
);
|
|
515
|
-
const file = new File(['x'], 'receipt.png', { type: 'image/png' });
|
|
516
|
-
const input = document.querySelectorAll('input[type="file"]')[0] as HTMLInputElement;
|
|
517
|
-
fireEvent.change(input, { target: { files: [file] } });
|
|
518
|
-
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
519
|
-
expect(onChange).toHaveBeenCalledWith([
|
|
520
|
-
{
|
|
521
|
-
description: 'Taxi',
|
|
522
|
-
receipt: {
|
|
523
|
-
name: 'receipt.png',
|
|
524
|
-
original_name: 'receipt.png',
|
|
525
|
-
size: file.size,
|
|
526
|
-
mime_type: 'image/png',
|
|
527
|
-
url: 'https://cdn/receipt.png',
|
|
528
|
-
},
|
|
529
|
-
},
|
|
530
|
-
]);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it('shows an uploaded file as a removable chip', () => {
|
|
534
|
-
const onChange = vi.fn();
|
|
535
|
-
render(
|
|
536
|
-
<GridField
|
|
537
|
-
value={[{ description: 'Taxi', receipt: { name: 'receipt.png', mime_type: 'image/png' } }]}
|
|
538
|
-
onChange={onChange}
|
|
539
|
-
field={fileField}
|
|
540
|
-
/>,
|
|
541
|
-
);
|
|
542
|
-
expect(screen.getByText('receipt.png')).toBeTruthy();
|
|
543
|
-
fireEvent.click(screen.getByLabelText('Remove receipt.png'));
|
|
544
|
-
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', receipt: null }]);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
it('passes the column accept list to the native picker', () => {
|
|
548
|
-
const withAccept = {
|
|
549
|
-
columns: [{ field: 'receipt', label: 'Receipt', type: 'file' as const, accept: ['image/*', '.pdf'] }],
|
|
550
|
-
} as any;
|
|
551
|
-
render(<GridField value={[]} onChange={() => {}} field={withAccept} />);
|
|
552
|
-
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
553
|
-
expect(input.getAttribute('accept')).toBe('image/*,.pdf');
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
it('renders the file name read-only in readonly mode', () => {
|
|
557
|
-
render(
|
|
558
|
-
<GridField
|
|
559
|
-
value={[{ description: 'Taxi', receipt: { name: 'receipt.png' } }]}
|
|
560
|
-
onChange={() => {}}
|
|
561
|
-
field={fileField}
|
|
562
|
-
readonly
|
|
563
|
-
/>,
|
|
564
|
-
);
|
|
565
|
-
expect(screen.getByText('receipt.png')).toBeTruthy();
|
|
566
|
-
expect(document.querySelector('input[type="file"]')).toBeNull();
|
|
567
|
-
});
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it('sumColumn ignores blanks and NaN', () => {
|
|
571
|
-
expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3);
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
describe('parent-scoped conditional rules (B2 follow-up — "paid invoice → lock lines")', () => {
|
|
575
|
-
const lockField = {
|
|
576
|
-
columns: [
|
|
577
|
-
{ field: 'product', label: 'Product', type: 'text' as const },
|
|
578
|
-
{ field: 'qty', label: 'Qty', type: 'number' as const, readonlyWhen: "parent.status == 'paid'" },
|
|
579
|
-
{ field: 'unit_price', label: 'Unit Price', type: 'currency' as const, readonlyWhen: "parent.status == 'paid'" },
|
|
580
|
-
],
|
|
581
|
-
} as any;
|
|
582
|
-
|
|
583
|
-
it('leaves cells editable when the parent rule is FALSE', () => {
|
|
584
|
-
render(
|
|
585
|
-
<GridField
|
|
586
|
-
value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
|
|
587
|
-
onChange={() => {}}
|
|
588
|
-
field={lockField}
|
|
589
|
-
contextRecord={{ status: 'draft' }}
|
|
590
|
-
/>,
|
|
591
|
-
);
|
|
592
|
-
expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(false);
|
|
593
|
-
expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(false);
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
it('locks cells whose readonlyWhen references the parent header', () => {
|
|
597
|
-
render(
|
|
598
|
-
<GridField
|
|
599
|
-
value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
|
|
600
|
-
onChange={() => {}}
|
|
601
|
-
field={lockField}
|
|
602
|
-
contextRecord={{ status: 'paid' }}
|
|
603
|
-
/>,
|
|
604
|
-
);
|
|
605
|
-
// The header is paid → quantity / unit price lock; product (no rule) stays editable.
|
|
606
|
-
expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(true);
|
|
607
|
-
expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(true);
|
|
608
|
-
expect((screen.getAllByLabelText('Product')[0] as HTMLInputElement).disabled).toBe(false);
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
it('re-evaluates per row, mixing the parent header with row data', () => {
|
|
612
|
-
const rowRule = {
|
|
613
|
-
columns: [
|
|
614
|
-
{ field: 'qty', label: 'Qty', type: 'number' as const },
|
|
615
|
-
// Locks only when the header is paid AND this row is already invoiced.
|
|
616
|
-
{ field: 'note', label: 'Note', type: 'text' as const, readonlyWhen: "parent.status == 'paid' && record.invoiced == true" },
|
|
617
|
-
],
|
|
618
|
-
} as any;
|
|
619
|
-
render(
|
|
620
|
-
<GridField
|
|
621
|
-
value={[{ qty: 1, note: 'a', invoiced: true }, { qty: 2, note: 'b', invoiced: false }]}
|
|
622
|
-
onChange={() => {}}
|
|
623
|
-
field={rowRule}
|
|
624
|
-
contextRecord={{ status: 'paid' }}
|
|
625
|
-
/>,
|
|
626
|
-
);
|
|
627
|
-
const notes = screen.getAllByLabelText('Note') as HTMLInputElement[];
|
|
628
|
-
expect(notes[0].disabled).toBe(true); // invoiced row → locked
|
|
629
|
-
expect(notes[1].disabled).toBe(false); // not-yet-invoiced row → editable
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
});
|