@object-ui/fields 17.3.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 +1655 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +23 -3
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +35 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +651 -0
- package/dist/index.d.ts +474 -85
- package/dist/index.js +5418 -5023
- package/dist/widgets/AddressField.d.ts +4 -12
- 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 +49 -152
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +68 -6
- 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 +21 -1
- 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 +34 -10
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +26 -6
- 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 +88 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/richTextDisplay.d.ts +80 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +189 -0
- package/dist/widgets/types.d.ts +8 -3
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -14
- 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/CharacterCount.d.ts +0 -56
- 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
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -191
- package/src/__tests__/DateCellRenderer.test.tsx +0 -73
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- 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 -598
- 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 -908
- package/src/currency.ts +0 -14
- package/src/datetime-widgets.test.tsx +0 -93
- package/src/field-type-alias.ts +0 -100
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.tsx +0 -2503
- package/src/standard-widgets.test.tsx +0 -357
- package/src/widgets/AddressField.tsx +0 -132
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -52
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -136
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -95
- package/src/widgets/DateField.tsx +0 -31
- package/src/widgets/DateTimeField.tsx +0 -37
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -494
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -33
- package/src/widgets/FullscreenFieldEditor.tsx +0 -271
- package/src/widgets/GeolocationField.tsx +0 -167
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -406
- package/src/widgets/GridField.tsx +0 -966
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.tsx +0 -255
- 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 -1291
- 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 -142
- 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 -83
- 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 -111
- package/src/widgets/RatingField.tsx +0 -81
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1316
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -162
- 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 -58
- 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 -142
- 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/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/openNativePicker.ts +0 -18
- 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 -175
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -150
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -264
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
|
@@ -1,406 +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
|
-
describe('trailing ghost row (start-with-one + auto-append)', () => {
|
|
117
|
-
it('renders a trailing empty row so an empty grid still has one input line', () => {
|
|
118
|
-
render(<GridField value={[]} onChange={() => {}} field={field} />);
|
|
119
|
-
// No "No items" empty-state in grid mode — the ghost row IS the first line.
|
|
120
|
-
expect(screen.getByText('Description')).toBeTruthy();
|
|
121
|
-
expect(screen.getAllByLabelText('Description')).toHaveLength(1); // just the ghost
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('typing in the ghost row materialises a new row (no Add click needed)', () => {
|
|
125
|
-
const onChange = vi.fn();
|
|
126
|
-
render(<GridField value={[{ description: 'A', amount: 1 }]} onChange={onChange} field={field} />);
|
|
127
|
-
const inputs = screen.getAllByLabelText('Description');
|
|
128
|
-
expect(inputs).toHaveLength(2); // data row + ghost
|
|
129
|
-
fireEvent.change(inputs[1], { target: { value: 'B' } }); // type in the ghost
|
|
130
|
-
expect(onChange).toHaveBeenCalledWith([
|
|
131
|
-
{ description: 'A', amount: 1 },
|
|
132
|
-
{ description: 'B', amount: null },
|
|
133
|
-
]);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('computed columns (amount = qty × unit_price)', () => {
|
|
138
|
-
const computedField = {
|
|
139
|
-
columns: [
|
|
140
|
-
{ field: 'product', label: 'Product', type: 'text' as const },
|
|
141
|
-
{ field: 'quantity', label: 'Qty', type: 'number' as const },
|
|
142
|
-
{ field: 'unit_price', label: 'Unit Price', type: 'currency' as const },
|
|
143
|
-
{ field: 'amount', label: 'Amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price', scale: 2 },
|
|
144
|
-
],
|
|
145
|
-
total_field: 'amount',
|
|
146
|
-
} as any;
|
|
147
|
-
|
|
148
|
-
it('renders a computed column read-only (no input) and recomputes on edit', () => {
|
|
149
|
-
const onChange = vi.fn();
|
|
150
|
-
render(<GridField value={[{ product: 'Widget', quantity: 3, unit_price: 10, amount: 30 }]} onChange={onChange} field={computedField} />);
|
|
151
|
-
// Amount is display-only — there is no editable Amount cell.
|
|
152
|
-
expect(screen.queryByLabelText('Amount')).toBeNull();
|
|
153
|
-
// Editing quantity recomputes amount in the emitted row.
|
|
154
|
-
fireEvent.change(screen.getAllByLabelText('Qty')[0], { target: { value: '4' } });
|
|
155
|
-
expect(onChange).toHaveBeenCalledWith([{ product: 'Widget', quantity: 4, unit_price: 10, amount: 40 }]);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('shows a dash for a computed cell whose inputs are blank', () => {
|
|
159
|
-
render(<GridField value={[{ product: 'Widget', quantity: null, unit_price: null, amount: null }]} onChange={() => {}} field={computedField} />);
|
|
160
|
-
// The computed amount cell reads "—" until its inputs exist.
|
|
161
|
-
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe('keyboard navigation', () => {
|
|
166
|
-
it('Enter moves focus to the same column in the next row', () => {
|
|
167
|
-
render(<GridField value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]} onChange={() => {}} field={field} />);
|
|
168
|
-
const row0 = screen.getAllByLabelText('Description')[0];
|
|
169
|
-
row0.focus();
|
|
170
|
-
fireEvent.keyDown(row0, { key: 'Enter' });
|
|
171
|
-
expect(document.activeElement).toBe(screen.getAllByLabelText('Description')[1]);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('removing a row emits the array without it', () => {
|
|
176
|
-
const onChange = vi.fn();
|
|
177
|
-
render(
|
|
178
|
-
<GridField
|
|
179
|
-
value={[{ description: 'A', amount: 1 }, { description: 'B', amount: 2 }]}
|
|
180
|
-
onChange={onChange}
|
|
181
|
-
field={field}
|
|
182
|
-
/>,
|
|
183
|
-
);
|
|
184
|
-
const removeButtons = screen.getAllByLabelText('Remove row');
|
|
185
|
-
fireEvent.click(removeButtons[0]);
|
|
186
|
-
expect(onChange).toHaveBeenCalledWith([{ description: 'B', amount: 2 }]);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('row action buttons are always visible in grid mode (not hover-revealed)', () => {
|
|
190
|
-
render(
|
|
191
|
-
<GridField
|
|
192
|
-
value={[{ description: 'A', amount: 1 }]}
|
|
193
|
-
onChange={() => {}}
|
|
194
|
-
field={field}
|
|
195
|
-
/>,
|
|
196
|
-
);
|
|
197
|
-
// Grid-mode rows previously gated these behind opacity-0/group-hover, so they
|
|
198
|
-
// were invisible until hover and unreachable on touch — they must always show.
|
|
199
|
-
expect(screen.getByLabelText('Remove row').className).not.toContain('opacity-0');
|
|
200
|
-
expect(screen.getByTestId('line-items-duplicate-0').className).not.toContain('opacity-0');
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it('readonly mode shows values and a summed total footer', () => {
|
|
204
|
-
render(
|
|
205
|
-
<GridField
|
|
206
|
-
value={[{ description: 'A', amount: 10 }, { description: 'B', amount: 20 }]}
|
|
207
|
-
onChange={() => {}}
|
|
208
|
-
field={field}
|
|
209
|
-
readonly
|
|
210
|
-
/>,
|
|
211
|
-
);
|
|
212
|
-
expect(screen.getByTestId('line-items-readonly')).toBeTruthy();
|
|
213
|
-
expect(screen.getByText('Total')).toBeTruthy();
|
|
214
|
-
expect(screen.getByText('30')).toBeTruthy();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('lookupAutofillPatch (item typeahead auto-fill)', () => {
|
|
218
|
-
const cols = [
|
|
219
|
-
{ field: 'product', type: 'lookup' as const, reference: 'product' },
|
|
220
|
-
{ field: 'description', type: 'text' as const },
|
|
221
|
-
{ field: 'quantity', type: 'number' as const },
|
|
222
|
-
{ field: 'unit_price', type: 'currency' as const },
|
|
223
|
-
{ field: 'amount', type: 'currency' as const, computed: true, expr: 'record.quantity * record.unit_price' },
|
|
224
|
-
];
|
|
225
|
-
const product = { value: 'p1', label: 'Widget A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, sku: 'WIDGET-A' };
|
|
226
|
-
|
|
227
|
-
it('sets the FK id and copies same-named sibling fields from the record', () => {
|
|
228
|
-
const patch = lookupAutofillPatch(cols, cols[0], product);
|
|
229
|
-
expect(patch).toEqual({ product: 'p1', description: 'Standard widget', unit_price: 29.99 });
|
|
230
|
-
// quantity (not on the record) and computed amount are left to the row/compute.
|
|
231
|
-
expect(patch).not.toHaveProperty('quantity');
|
|
232
|
-
expect(patch).not.toHaveProperty('amount');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it('copies only the FK id when autofill is disabled', () => {
|
|
236
|
-
const patch = lookupAutofillPatch(cols, { ...cols[0], autofill: false }, product);
|
|
237
|
-
expect(patch).toEqual({ product: 'p1' });
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
describe('P1 affordances (duplicate / validation)', () => {
|
|
242
|
-
it('duplicates a row (id stripped) directly below the original', () => {
|
|
243
|
-
const onChange = vi.fn();
|
|
244
|
-
render(<GridField value={[{ id: 'r1', description: 'A', amount: 5 }]} onChange={onChange} field={field} />);
|
|
245
|
-
fireEvent.click(screen.getByTestId('line-items-duplicate-0'));
|
|
246
|
-
expect(onChange).toHaveBeenCalledWith([
|
|
247
|
-
{ id: 'r1', description: 'A', amount: 5 },
|
|
248
|
-
{ description: 'A', amount: 5 }, // copy without the id → persists as a new record
|
|
249
|
-
]);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('flags a required, empty cell on a real row (not the ghost row)', () => {
|
|
253
|
-
const reqField = { columns: [{ field: 'description', label: 'Description', type: 'text' as const, required: true }] } as any;
|
|
254
|
-
render(<GridField value={[{ description: '' }]} onChange={() => {}} field={reqField} />);
|
|
255
|
-
// The data row's required-empty cell is flagged...
|
|
256
|
-
expect(screen.getByTestId('line-items-invalid-0-description')).toBeTruthy();
|
|
257
|
-
// ...but the trailing ghost row (index 1) is not.
|
|
258
|
-
expect(screen.queryByTestId('line-items-invalid-1-description')).toBeNull();
|
|
259
|
-
});
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
describe('file columns (upload in a grid cell — #2360)', () => {
|
|
263
|
-
const fileField = {
|
|
264
|
-
columns: [
|
|
265
|
-
{ field: 'description', label: 'Description', type: 'text' as const },
|
|
266
|
-
{ field: 'receipt', label: 'Receipt', type: 'file' as const },
|
|
267
|
-
],
|
|
268
|
-
} as any;
|
|
269
|
-
|
|
270
|
-
it('renders a real upload control in the cell, not a text input', () => {
|
|
271
|
-
render(<GridField value={[{ description: 'Taxi', receipt: null }]} onChange={() => {}} field={fileField} />);
|
|
272
|
-
// One upload button per row (data row + ghost), backed by a native file input.
|
|
273
|
-
expect(screen.getAllByRole('button', { name: 'Receipt' }).length).toBeGreaterThan(0);
|
|
274
|
-
expect(document.querySelector('input[type="file"]')).toBeTruthy();
|
|
275
|
-
expect(screen.queryByRole('textbox', { name: 'Receipt' })).toBeNull();
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('uploads a picked file and writes the file object into the row', async () => {
|
|
279
|
-
const onChange = vi.fn();
|
|
280
|
-
const adapter = {
|
|
281
|
-
name: 'test',
|
|
282
|
-
upload: async (f: File) => ({ url: 'https://cdn/receipt.png', name: f.name, size: f.size, mimeType: f.type }),
|
|
283
|
-
};
|
|
284
|
-
render(
|
|
285
|
-
<UploadProvider adapter={adapter as any}>
|
|
286
|
-
<GridField value={[{ description: 'Taxi', receipt: null }]} onChange={onChange} field={fileField} />
|
|
287
|
-
</UploadProvider>,
|
|
288
|
-
);
|
|
289
|
-
const file = new File(['x'], 'receipt.png', { type: 'image/png' });
|
|
290
|
-
const input = document.querySelectorAll('input[type="file"]')[0] as HTMLInputElement;
|
|
291
|
-
fireEvent.change(input, { target: { files: [file] } });
|
|
292
|
-
await waitFor(() => expect(onChange).toHaveBeenCalled());
|
|
293
|
-
expect(onChange).toHaveBeenCalledWith([
|
|
294
|
-
{
|
|
295
|
-
description: 'Taxi',
|
|
296
|
-
receipt: {
|
|
297
|
-
name: 'receipt.png',
|
|
298
|
-
original_name: 'receipt.png',
|
|
299
|
-
size: file.size,
|
|
300
|
-
mime_type: 'image/png',
|
|
301
|
-
url: 'https://cdn/receipt.png',
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
]);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it('shows an uploaded file as a removable chip', () => {
|
|
308
|
-
const onChange = vi.fn();
|
|
309
|
-
render(
|
|
310
|
-
<GridField
|
|
311
|
-
value={[{ description: 'Taxi', receipt: { name: 'receipt.png', mime_type: 'image/png' } }]}
|
|
312
|
-
onChange={onChange}
|
|
313
|
-
field={fileField}
|
|
314
|
-
/>,
|
|
315
|
-
);
|
|
316
|
-
expect(screen.getByText('receipt.png')).toBeTruthy();
|
|
317
|
-
fireEvent.click(screen.getByLabelText('Remove receipt.png'));
|
|
318
|
-
expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', receipt: null }]);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('passes the column accept list to the native picker', () => {
|
|
322
|
-
const withAccept = {
|
|
323
|
-
columns: [{ field: 'receipt', label: 'Receipt', type: 'file' as const, accept: ['image/*', '.pdf'] }],
|
|
324
|
-
} as any;
|
|
325
|
-
render(<GridField value={[]} onChange={() => {}} field={withAccept} />);
|
|
326
|
-
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
327
|
-
expect(input.getAttribute('accept')).toBe('image/*,.pdf');
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it('renders the file name read-only in readonly mode', () => {
|
|
331
|
-
render(
|
|
332
|
-
<GridField
|
|
333
|
-
value={[{ description: 'Taxi', receipt: { name: 'receipt.png' } }]}
|
|
334
|
-
onChange={() => {}}
|
|
335
|
-
field={fileField}
|
|
336
|
-
readonly
|
|
337
|
-
/>,
|
|
338
|
-
);
|
|
339
|
-
expect(screen.getByText('receipt.png')).toBeTruthy();
|
|
340
|
-
expect(document.querySelector('input[type="file"]')).toBeNull();
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it('sumColumn ignores blanks and NaN', () => {
|
|
345
|
-
expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
describe('parent-scoped conditional rules (B2 follow-up — "paid invoice → lock lines")', () => {
|
|
349
|
-
const lockField = {
|
|
350
|
-
columns: [
|
|
351
|
-
{ field: 'product', label: 'Product', type: 'text' as const },
|
|
352
|
-
{ field: 'qty', label: 'Qty', type: 'number' as const, readonlyWhen: "parent.status == 'paid'" },
|
|
353
|
-
{ field: 'unit_price', label: 'Unit Price', type: 'currency' as const, readonlyWhen: "parent.status == 'paid'" },
|
|
354
|
-
],
|
|
355
|
-
} as any;
|
|
356
|
-
|
|
357
|
-
it('leaves cells editable when the parent rule is FALSE', () => {
|
|
358
|
-
render(
|
|
359
|
-
<GridField
|
|
360
|
-
value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
|
|
361
|
-
onChange={() => {}}
|
|
362
|
-
field={lockField}
|
|
363
|
-
contextRecord={{ status: 'draft' }}
|
|
364
|
-
/>,
|
|
365
|
-
);
|
|
366
|
-
expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(false);
|
|
367
|
-
expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(false);
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it('locks cells whose readonlyWhen references the parent header', () => {
|
|
371
|
-
render(
|
|
372
|
-
<GridField
|
|
373
|
-
value={[{ product: 'Widget', qty: 2, unit_price: 10 }]}
|
|
374
|
-
onChange={() => {}}
|
|
375
|
-
field={lockField}
|
|
376
|
-
contextRecord={{ status: 'paid' }}
|
|
377
|
-
/>,
|
|
378
|
-
);
|
|
379
|
-
// The header is paid → quantity / unit price lock; product (no rule) stays editable.
|
|
380
|
-
expect((screen.getAllByLabelText('Qty')[0] as HTMLInputElement).disabled).toBe(true);
|
|
381
|
-
expect((screen.getAllByLabelText('Unit Price')[0] as HTMLInputElement).disabled).toBe(true);
|
|
382
|
-
expect((screen.getAllByLabelText('Product')[0] as HTMLInputElement).disabled).toBe(false);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it('re-evaluates per row, mixing the parent header with row data', () => {
|
|
386
|
-
const rowRule = {
|
|
387
|
-
columns: [
|
|
388
|
-
{ field: 'qty', label: 'Qty', type: 'number' as const },
|
|
389
|
-
// Locks only when the header is paid AND this row is already invoiced.
|
|
390
|
-
{ field: 'note', label: 'Note', type: 'text' as const, readonlyWhen: "parent.status == 'paid' && record.invoiced == true" },
|
|
391
|
-
],
|
|
392
|
-
} as any;
|
|
393
|
-
render(
|
|
394
|
-
<GridField
|
|
395
|
-
value={[{ qty: 1, note: 'a', invoiced: true }, { qty: 2, note: 'b', invoiced: false }]}
|
|
396
|
-
onChange={() => {}}
|
|
397
|
-
field={rowRule}
|
|
398
|
-
contextRecord={{ status: 'paid' }}
|
|
399
|
-
/>,
|
|
400
|
-
);
|
|
401
|
-
const notes = screen.getAllByLabelText('Note') as HTMLInputElement[];
|
|
402
|
-
expect(notes[0].disabled).toBe(true); // invoiced row → locked
|
|
403
|
-
expect(notes[1].disabled).toBe(false); // not-yet-invoiced row → editable
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
});
|