@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
3
|
import { describe, it, expect, vi } from 'vitest';
|
|
3
4
|
import { DateField } from './widgets/DateField';
|
|
@@ -54,10 +55,15 @@ describe('Date/Time Widgets', () => {
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
it('calls onChange when value changes', () => {
|
|
57
|
-
const
|
|
58
|
+
const onChange = vi.fn();
|
|
59
|
+
const { container } = render(<DateTimeField {...baseProps} onChange={onChange} />);
|
|
58
60
|
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
59
61
|
fireEvent.change(input, { target: { value: '2023-01-01T12:00' } });
|
|
60
|
-
|
|
62
|
+
// The control speaks zone-less local wall clock; form state holds
|
|
63
|
+
// ISO, the shape the API hands back and `toDateTimeInputValue`
|
|
64
|
+
// reads (objectui#3127). Storing the naive string instead would put
|
|
65
|
+
// read and write on different timezone bases.
|
|
66
|
+
expect(onChange).toHaveBeenCalledWith(new Date('2023-01-01T12:00').toISOString());
|
|
61
67
|
});
|
|
62
68
|
|
|
63
69
|
it('renders formatted datetime in readonly mode', () => {
|
|
@@ -71,6 +77,92 @@ describe('Date/Time Widgets', () => {
|
|
|
71
77
|
});
|
|
72
78
|
});
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* objectui#3127 — the record's stored ISO value reached the native control
|
|
82
|
+
* verbatim, and `<input type="date">` / `<input type="datetime-local">`
|
|
83
|
+
* reject anything but their one exact shape SILENTLY: the attribute lands
|
|
84
|
+
* in the DOM, `.value` reads back `''`, and the user sees an empty box on a
|
|
85
|
+
* field that has a value. `.value` (not `getAttribute('value')`) is
|
|
86
|
+
* therefore the only assertion that can see this bug at all.
|
|
87
|
+
*/
|
|
88
|
+
describe('existing values round-trip through the native controls (#3127)', () => {
|
|
89
|
+
// The seeded showcase value. Whole minutes and no sub-second part, so
|
|
90
|
+
// the control (which has minute resolution) can represent it exactly
|
|
91
|
+
// and the round trip below is lossless in every timezone.
|
|
92
|
+
const storedIso = '2026-06-17T14:30:00.000Z';
|
|
93
|
+
|
|
94
|
+
it('DateTimeField shows a stored ISO instant instead of an empty box', () => {
|
|
95
|
+
const { container } = render(<DateTimeField {...baseProps} value={storedIso} />);
|
|
96
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
97
|
+
|
|
98
|
+
expect(input.value).not.toBe('');
|
|
99
|
+
expect(input.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
|
100
|
+
// The shown wall clock denotes the SAME instant as the record —
|
|
101
|
+
// asserted this way so the test holds in every timezone, which a
|
|
102
|
+
// hardcoded "2026-06-17T22:30" would not.
|
|
103
|
+
expect(new Date(input.value).getTime()).toBe(new Date(storedIso).getTime());
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('DateTimeField writes back on the same basis it reads', () => {
|
|
107
|
+
// Read and write must share one timezone basis. Displaying local
|
|
108
|
+
// while storing the naive local string is the worse bug: the value
|
|
109
|
+
// would silently drift by the viewer's offset on every edit.
|
|
110
|
+
const onChange = vi.fn();
|
|
111
|
+
const { container } = render(
|
|
112
|
+
<DateTimeField {...baseProps} onChange={onChange} value={storedIso} />
|
|
113
|
+
);
|
|
114
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
115
|
+
|
|
116
|
+
fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
|
|
117
|
+
|
|
118
|
+
const written = onChange.mock.calls[0][0];
|
|
119
|
+
// Zone-QUALIFIED, which the control's own output is not. Storing
|
|
120
|
+
// the raw `2026-06-18T09:15` is what puts the write on a different
|
|
121
|
+
// basis from the read: the server has no way to know whose 09:15
|
|
122
|
+
// it is, and resolves it to a different instant than the one the
|
|
123
|
+
// user picked.
|
|
124
|
+
expect(written).toMatch(/(Z|[+-]\d{2}:\d{2})$/);
|
|
125
|
+
expect(new Date(written).getTime()).toBe(new Date('2026-06-18T09:15').getTime());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('DateTimeField returning to the shown minute restores the exact stored value', () => {
|
|
129
|
+
// Guards the drift this fix could otherwise introduce: if the two
|
|
130
|
+
// conversions disagreed by the viewer's UTC offset, navigating away
|
|
131
|
+
// from a value and back would land somewhere else, and every open →
|
|
132
|
+
// fiddle → undo would quietly move the record.
|
|
133
|
+
// Held in real state, because a `vi.fn()` onChange would leave the
|
|
134
|
+
// control pinned to its initial prop and React would swallow the
|
|
135
|
+
// second edit as a no-op — the test would pass without exercising
|
|
136
|
+
// anything.
|
|
137
|
+
const seen: string[] = [];
|
|
138
|
+
const Host = () => {
|
|
139
|
+
const [v, setV] = React.useState<string>(storedIso);
|
|
140
|
+
seen.push(v);
|
|
141
|
+
return <DateTimeField {...baseProps} value={v} onChange={setV as any} />;
|
|
142
|
+
};
|
|
143
|
+
const { container } = render(<Host />);
|
|
144
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
145
|
+
const shown = input.value;
|
|
146
|
+
|
|
147
|
+
fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
|
|
148
|
+
fireEvent.change(input, { target: { value: shown } });
|
|
149
|
+
|
|
150
|
+
expect(seen[seen.length - 1]).toBe(storedIso);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('DateField shows the stored calendar day, ISO-shaped or not', () => {
|
|
154
|
+
// Both spellings the API may hand back for a `date` field. The day
|
|
155
|
+
// must survive verbatim — re-parsing the bare form as UTC midnight
|
|
156
|
+
// would move it to the 16th west of Greenwich.
|
|
157
|
+
for (const stored of ['2026-06-17', '2026-06-17T00:00:00.000Z']) {
|
|
158
|
+
const { container, unmount } = render(<DateField {...baseProps} value={stored} />);
|
|
159
|
+
const input = container.querySelector('input[type="date"]') as HTMLInputElement;
|
|
160
|
+
expect(input.value).toBe('2026-06-17');
|
|
161
|
+
unmount();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
74
166
|
describe('TimeField', () => {
|
|
75
167
|
it('renders time input in edit mode', () => {
|
|
76
168
|
const { container } = render(<TimeField {...baseProps} />);
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
* The widget id is a function of the (type, `multiple`) PAIR, not of the type
|
|
11
|
+
* string alone (objectui#3986).
|
|
12
|
+
*
|
|
13
|
+
* `mapFieldTypeToFormType` mapped `select` to `field:select` unconditionally,
|
|
14
|
+
* while `SelectField` delegated to `MultiSelectField` whenever the metadata said
|
|
15
|
+
* `multiple`. Three correct pieces, one gap between them: the component that
|
|
16
|
+
* RENDERED was `MultiSelectField`, but everything keyed on the widget id — above
|
|
17
|
+
* all the label-association declaration (`ComponentMeta.labelling`, resolved via
|
|
18
|
+
* `getMeta('select')`) — was still answering for the single-value combobox. The
|
|
19
|
+
* host label of a normal `{ type: 'select', multiple: true }` picklist therefore
|
|
20
|
+
* emitted a `for` at the chip row's wrapper `div`, where a `for` is inert.
|
|
21
|
+
*
|
|
22
|
+
* `select` cannot simply be declared `labelling: 'group'` instead: the
|
|
23
|
+
* single-value trigger IS labelable, and a bare `select` is a builtin the form
|
|
24
|
+
* renderer resolves without the registry at all (pinned in
|
|
25
|
+
* `form-group-label-association.test.ts`). The producer has to name the widget
|
|
26
|
+
* that renders, which is what this file pins.
|
|
27
|
+
*
|
|
28
|
+
* Metadata only — nothing renders here, so no widget module is loaded.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { mapFieldTypeToFormType } from './field-type-alias';
|
|
33
|
+
|
|
34
|
+
describe('select carries its arity in the widget id (objectui#3986)', () => {
|
|
35
|
+
it('maps select + multiple to the multi-value widget', () => {
|
|
36
|
+
// The one assertion that was `field:select` before this change.
|
|
37
|
+
expect(mapFieldTypeToFormType('select', { multiple: true })).toBe('field:multiselect');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps the single-value widget without multiple, and for multiple: false', () => {
|
|
41
|
+
// The guard direction: single-select must not move. Its trigger is a
|
|
42
|
+
// labelable `button[role=combobox]` and its `for` association works — taking
|
|
43
|
+
// that away would break a field that is fine to fix one that is not.
|
|
44
|
+
expect(mapFieldTypeToFormType('select')).toBe('field:select');
|
|
45
|
+
expect(mapFieldTypeToFormType('select', {})).toBe('field:select');
|
|
46
|
+
expect(mapFieldTypeToFormType('select', { multiple: false })).toBe('field:select');
|
|
47
|
+
expect(mapFieldTypeToFormType('select', { multiple: null })).toBe('field:select');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is idempotent for the type that is already inherently multi', () => {
|
|
51
|
+
// `multiselect` is a type in its own right; the arity override must not turn
|
|
52
|
+
// it into something else, whichever way the flag is set.
|
|
53
|
+
expect(mapFieldTypeToFormType('multiselect')).toBe('field:multiselect');
|
|
54
|
+
expect(mapFieldTypeToFormType('multiselect', { multiple: true })).toBe('field:multiselect');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The spec's multi-capable set is larger than the widget-moving set, and the
|
|
59
|
+
* difference is the whole reason this override is table-driven rather than a
|
|
60
|
+
* `config.multiple` branch sprinkled per type:
|
|
61
|
+
*
|
|
62
|
+
* `MULTI_CAPABLE_TYPES` = select / lookup / file / image (+ `radio` on the
|
|
63
|
+
* select branch, `user` storing like `lookup`)
|
|
64
|
+
*
|
|
65
|
+
* Every member except `select` renders BOTH arities inside one widget —
|
|
66
|
+
* `LookupField`, `FileField`, `ImageField` each read `multiple` themselves — so
|
|
67
|
+
* their widget id, and with it their `labelling` declaration, is correct for
|
|
68
|
+
* either arity. Moving them would point at widgets that do not exist.
|
|
69
|
+
*/
|
|
70
|
+
const UNMOVED_MULTI_CAPABLE = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'radio'] as const;
|
|
71
|
+
|
|
72
|
+
it.each(UNMOVED_MULTI_CAPABLE)('%s resolves identically with and without multiple', (type) => {
|
|
73
|
+
expect(mapFieldTypeToFormType(type, { multiple: true })).toBe(mapFieldTypeToFormType(type));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not invent a widget for an unknown type flagged multiple', () => {
|
|
77
|
+
// The fallback is unchanged: an unknown type is `field:text` at either arity,
|
|
78
|
+
// never `field:text-multiple`-anything.
|
|
79
|
+
expect(mapFieldTypeToFormType('something-unknown', { multiple: true })).toBe('field:text');
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/field-type-alias.ts
CHANGED
|
@@ -6,15 +6,63 @@
|
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The slice of a field definition the widget decision reads beyond `type`.
|
|
11
|
+
*
|
|
12
|
+
* Structural (not the spec `Field`) for the same reason the spec's own
|
|
13
|
+
* `ValueShapeFieldDef` is: every caller — object-schema metadata, a spec
|
|
14
|
+
* `FormFieldSchema` override, an action param def — can pass its own trimmed
|
|
15
|
+
* shape verbatim. `multiple` is the only key read; nothing else here is
|
|
16
|
+
* consulted, so a caller that has the whole metadata object may hand it over
|
|
17
|
+
* as-is.
|
|
18
|
+
*/
|
|
19
|
+
export interface FieldTypeMappingConfig {
|
|
20
|
+
/** `FieldSchema.multiple` — the field holds zero-or-more values. */
|
|
21
|
+
multiple?: boolean | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Field types whose `multiple: true` form is a DIFFERENT registered widget,
|
|
26
|
+
* rather than the same widget in another mode (objectui#3986).
|
|
27
|
+
*
|
|
28
|
+
* `select` is the only member, and the narrowness is measured, not assumed. The
|
|
29
|
+
* spec's `MULTI_CAPABLE_TYPES` is larger — select / lookup / file / image, with
|
|
30
|
+
* `radio` on the select branch and `user` storing like `lookup` — but every
|
|
31
|
+
* other member renders both arities INSIDE one widget: `LookupField`,
|
|
32
|
+
* `FileField` and `ImageField` each branch on `multiple` themselves, so their
|
|
33
|
+
* registry id, and with it their `labelling` declaration, is the same either
|
|
34
|
+
* way. A `select` declared `multiple: true` renders `MultiSelectField` instead:
|
|
35
|
+
* a different component, whose labelled surface is a chip row's container that
|
|
36
|
+
* a `<label for>` cannot address (it must be named by IDREF, which is what
|
|
37
|
+
* `field:multiselect`'s `labelling: 'group'` declares — objectui#3975/#3961).
|
|
38
|
+
*
|
|
39
|
+
* So the widget id has to carry the arity. When it did not, the host label was
|
|
40
|
+
* associated by the declaration registered under `select` — a single-value
|
|
41
|
+
* combobox that was NOT rendering — and the visible label of a normal
|
|
42
|
+
* `{ type: 'select', multiple: true }` picklist named nothing at all. One place
|
|
43
|
+
* decides which widget renders, so the declaration and the render cannot
|
|
44
|
+
* disagree again.
|
|
45
|
+
*/
|
|
46
|
+
const MULTI_VALUE_FORM_TYPES: Record<string, string> = {
|
|
47
|
+
select: 'field:multiselect',
|
|
48
|
+
};
|
|
49
|
+
|
|
9
50
|
/**
|
|
10
51
|
* Map field type to form component type
|
|
11
|
-
*
|
|
52
|
+
*
|
|
12
53
|
* @param fieldType - The ObjectQL field type identifier to convert
|
|
13
54
|
* (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
|
|
55
|
+
* @param config - The rest of the field definition, for the types whose widget
|
|
56
|
+
* identity depends on more than the type string. Only `multiple` is read; see
|
|
57
|
+
* {@link MULTI_VALUE_FORM_TYPES}. Omitting it maps the single-value form, which
|
|
58
|
+
* is what every non-multi-capable type resolves to anyway.
|
|
14
59
|
* @returns The normalized form field type string used in the form schema
|
|
15
60
|
* (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
|
|
16
61
|
*/
|
|
17
|
-
export function mapFieldTypeToFormType(
|
|
62
|
+
export function mapFieldTypeToFormType(
|
|
63
|
+
fieldType: string,
|
|
64
|
+
config?: FieldTypeMappingConfig,
|
|
65
|
+
): string {
|
|
18
66
|
const typeMap: Record<string, string> = {
|
|
19
67
|
// Text-based fields
|
|
20
68
|
text: 'field:text',
|
|
@@ -96,5 +144,13 @@ export function mapFieldTypeToFormType(fieldType: string): string {
|
|
|
96
144
|
auto_number: 'field:auto_number',
|
|
97
145
|
};
|
|
98
146
|
|
|
147
|
+
// The arity override comes FIRST and is table-driven: only a type listed in
|
|
148
|
+
// `MULTI_VALUE_FORM_TYPES` has a second widget to move to, so `multiple` on
|
|
149
|
+
// any other type (a multi `lookup`, a multi `file`) resolves exactly as
|
|
150
|
+
// before and keeps reaching the widget that handles both arities itself.
|
|
151
|
+
if (config?.multiple && MULTI_VALUE_FORM_TYPES[fieldType]) {
|
|
152
|
+
return MULTI_VALUE_FORM_TYPES[fieldType];
|
|
153
|
+
}
|
|
154
|
+
|
|
99
155
|
return typeMap[fieldType] || 'field:text';
|
|
100
156
|
}
|
package/src/index.css
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind entry for `@object-ui/fields` — BUILD-TIME ONLY.
|
|
3
|
+
*
|
|
4
|
+
* Consumers never compile this file. They import the compiled artifact:
|
|
5
|
+
*
|
|
6
|
+
* @import '@object-ui/components/style.css';
|
|
7
|
+
* @import '@object-ui/fields/style.css'; <- ./dist/index.css, built from this
|
|
8
|
+
*
|
|
9
|
+
* The `@reference` below is what makes the "narrow" shape possible
|
|
10
|
+
* (objectui#4059): it gives this compilation `@object-ui/components`' theme
|
|
11
|
+
* tokens, its class-based `dark` variant and its `tailwindcss-animate` plugin
|
|
12
|
+
* WITHOUT emitting a single byte of them. So the sheet built from this entry
|
|
13
|
+
* carries utilities only — no preflight, no `@theme` `:root` block, no base
|
|
14
|
+
* layer. `@object-ui/components`' own stylesheet owns all of that, and
|
|
15
|
+
* `scripts/build-css.mjs` then subtracts every rule that sheet already ships.
|
|
16
|
+
*
|
|
17
|
+
* That is why the import ORDER above is not cosmetic: this sheet is a
|
|
18
|
+
* supplement to the components sheet, not a replacement for it.
|
|
19
|
+
*
|
|
20
|
+
* This file has no `@import 'tailwindcss'` on purpose — that would pull in
|
|
21
|
+
* preflight and re-emit the shared theme, which is exactly the ~160 kB
|
|
22
|
+
* duplication the narrow variant exists to avoid.
|
|
23
|
+
*/
|
|
24
|
+
@reference '../../components/src/index.css';
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* `source(none)` is load-bearing, not tidiness. Tailwind's automatic source
|
|
28
|
+
* detection resolves against the PROCESS CWD, and `@reference` above pulls in
|
|
29
|
+
* components' entry, whose own `@import 'tailwindcss'` turns that detection on.
|
|
30
|
+
* Without `source(none)` the candidate set therefore depends on where the build
|
|
31
|
+
* was launched from: run from this package it scanned `packages/fields` and
|
|
32
|
+
* produced a 21 kB sheet, run from the repo root it scanned the whole workspace
|
|
33
|
+
* and produced a 287 kB one full of other packages' utilities. Same commit, same
|
|
34
|
+
* command, two different published artifacts.
|
|
35
|
+
*
|
|
36
|
+
* With it, the ONLY inputs are the explicit `@source` lines below, and the
|
|
37
|
+
* output is byte-identical from any working directory.
|
|
38
|
+
*/
|
|
39
|
+
@import 'tailwindcss/utilities.css' layer(utilities) source(none);
|
|
40
|
+
|
|
41
|
+
/* Only shipped source: `*.test.tsx` never reaches a consumer, so utilities used
|
|
42
|
+
solely by tests must not be compiled into the published sheet. */
|
|
43
|
+
@source './**/*.{ts,tsx}';
|
|
44
|
+
@source not './**/*.test.{ts,tsx}';
|