@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,224 +0,0 @@
|
|
|
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
|
-
* objectui#4590 — `formatPercent` rounds a tie the wrong way, because it renders
|
|
11
|
-
* a value that is ALREADY in percentage points through `Intl`'s
|
|
12
|
-
* `style: 'percent'`, which wants a FRACTION.
|
|
13
|
-
*
|
|
14
|
-
* The old body divided by 100 so `Intl` could multiply straight back:
|
|
15
|
-
*
|
|
16
|
-
* formatDisplayNumber(displayValue / 100, { style: 'percent', … })
|
|
17
|
-
*
|
|
18
|
-
* That round trip is not value-preserving. `Intl` formats from the SHORTEST
|
|
19
|
-
* decimal representation of the double it is handed, and `displayValue / 100`
|
|
20
|
-
* has a different shortest representation from `displayValue`: `1.005` is
|
|
21
|
-
* `1.005`, but `1.005 / 100` is `0.010049999999999999`, which percent-scales to
|
|
22
|
-
* `1.0049999999999999` and rounds DOWN. The DIVISION loses the digit, not the
|
|
23
|
-
* rounding. It is therefore a numeral defect and not a convention one — it
|
|
24
|
-
* reproduces identically in every locale, in Arabic-Indic digits too.
|
|
25
|
-
*
|
|
26
|
-
* The fix is the option #4576 / PR #4589 put in `@object-ui/core` for exactly
|
|
27
|
-
* this: `style: 'percentPoints'` formats the points DIRECTLY (`Intl`'s
|
|
28
|
-
* `style: 'unit'` / `unit: 'percent'` / `unitDisplay: 'narrow'`), which was
|
|
29
|
-
* measured to give a byte-identical percent affix to `style: 'percent'` across
|
|
30
|
-
* all 171 locale tags tested.
|
|
31
|
-
*
|
|
32
|
-
* ── PREDICTIONS, written before the run ─────────────────────────────────────
|
|
33
|
-
* Runner measured: node v22.22.2, ICU 78.2, machine locale en-US.
|
|
34
|
-
*
|
|
35
|
-
* RED before the fix — the numerals:
|
|
36
|
-
* formatPercent(1.005, 2, 'en-US') `1.00%` → `1.01%`
|
|
37
|
-
* formatPercent(1.025, 2, 'en-US') `1.02%` → `1.03%`
|
|
38
|
-
* formatPercent(1.45, 1, 'en-US') `1.4%` → `1.5%`
|
|
39
|
-
* formatPercent(1.055, 2, 'en-US') `1.05%` → `1.06%`
|
|
40
|
-
* formatPercent(99999.995, 2, 'en') `99,999.99%` → `100,000.00%`
|
|
41
|
-
* MAX_SAFE_INTEGER p0 `9,007,199,254,740,990%` → `…991%`
|
|
42
|
-
* 1e23 p0 `99,999,999,999,999,990,000,000%`
|
|
43
|
-
* → `100,000,000,000,000,000,000,000%`
|
|
44
|
-
* the same four ties in de-DE / tr-TR / ar-EG — the locale-independence
|
|
45
|
-
* claim, red in every one of them
|
|
46
|
-
*
|
|
47
|
-
* GREEN ON BOTH SIDES — the must-not-change set, and the STOP condition if
|
|
48
|
-
* any of it moves (this card is NUMERAL-only; a convention move is a defect
|
|
49
|
-
* in the fix, not a pin to update):
|
|
50
|
-
* the percent CONVENTION of all ten locales at 1234.5 p1 — de/fr/ru/sv's
|
|
51
|
-
* no-break space, tr's PREFIX sign, ar's U+066A + U+061C, en/ja/zh's bare
|
|
52
|
-
* suffix, fr's U+202F group separator, bn's Bengali digits
|
|
53
|
-
* the sign position and glyph on negatives (sv-SE's U+2212 included)
|
|
54
|
-
* the affix parity check against `Intl`'s own `style: 'percent'`
|
|
55
|
-
* `percentDisplayValue`'s fraction/whole scaling — upstream of the render
|
|
56
|
-
* and deliberately untouched by this card
|
|
57
|
-
*
|
|
58
|
-
* MEASURED, this call shape, old route vs new: 10 locales x 18 values x 4
|
|
59
|
-
* precisions = 720 combinations — 0 convention diffs, 130 numeral diffs (13 per
|
|
60
|
-
* locale, the SAME 13 in every locale). On the wide en-US grid the issue
|
|
61
|
-
* quantified (0.005 steps to 2000, precisions 0/1/2) 27,577 of 1,200,003 forms
|
|
62
|
-
* move. Every one is a last-digit-off-by-one.
|
|
63
|
-
*
|
|
64
|
-
* Every non-ASCII byte below is written as a `\u` escape, never pasted.
|
|
65
|
-
*/
|
|
66
|
-
|
|
67
|
-
import { describe, it, expect } from 'vitest';
|
|
68
|
-
import { formatPercent } from '../index';
|
|
69
|
-
|
|
70
|
-
/** U+00A0 NO-BREAK SPACE — de/fr/ru/sv put one before the percent sign. */
|
|
71
|
-
const NBSP = '\u00a0';
|
|
72
|
-
/** U+202F NARROW NO-BREAK SPACE — fr's group separator. */
|
|
73
|
-
const NNBSP = '\u202f';
|
|
74
|
-
/** U+066A ARABIC PERCENT SIGN, U+061C ARABIC LETTER MARK. */
|
|
75
|
-
const ARABIC_PERCENT = '\u066a';
|
|
76
|
-
const ALM = '\u061c';
|
|
77
|
-
|
|
78
|
-
describe('formatPercent rounds a tie half-up on the authored decimal (#4590)', () => {
|
|
79
|
-
/**
|
|
80
|
-
* MOVING PINS — the issue's measured table, verbatim. Each was the artefact
|
|
81
|
-
* of the `/ 100` round trip; the right-hand column is half-up on the decimal
|
|
82
|
-
* the author actually wrote.
|
|
83
|
-
*/
|
|
84
|
-
it.each([
|
|
85
|
-
[1.005, 2, '1.01%', '1.00%'],
|
|
86
|
-
[1.025, 2, '1.03%', '1.02%'],
|
|
87
|
-
[1.45, 1, '1.5%', '1.4%'],
|
|
88
|
-
[1.055, 2, '1.06%', '1.05%'],
|
|
89
|
-
])('formatPercent(%s, %s) is %s (was %s)', (value, precision, expected) => {
|
|
90
|
-
expect(formatPercent(value, precision, 'en-US')).toBe(expected);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* A tie that carries into a new digit COLUMN rather than just flipping the
|
|
95
|
-
* last one — the round trip lost the carry as well as the digit.
|
|
96
|
-
*/
|
|
97
|
-
it('carries across the grouping boundary: 99999.995 to 2 decimals', () => {
|
|
98
|
-
// was `99,999.99%`
|
|
99
|
-
expect(formatPercent(99999.995, 2, 'en-US')).toBe('100,000.00%');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* The defect is in the VALUE, not the convention, so it must reproduce in
|
|
104
|
-
* every locale — including one that writes the sign in front and one that
|
|
105
|
-
* uses neither ASCII digits nor an ASCII sign.
|
|
106
|
-
*/
|
|
107
|
-
it('the same ties move in every locale — this is a numeral defect', () => {
|
|
108
|
-
expect(formatPercent(1.005, 2, 'de-DE')).toBe(`1,01${NBSP}%`); // was `1,00 %`
|
|
109
|
-
expect(formatPercent(1.45, 1, 'de-DE')).toBe(`1,5${NBSP}%`); // was `1,4 %`
|
|
110
|
-
expect(formatPercent(1.005, 2, 'tr-TR')).toBe('%1,01'); // was `%1,00`
|
|
111
|
-
// U+0661 U+066B U+0660 U+0661 — Arabic-Indic `1`, decimal separator, `0`, `1`
|
|
112
|
-
expect(formatPercent(1.005, 2, 'ar-EG')).toBe(
|
|
113
|
-
`\u0661\u066b\u0660\u0661${ARABIC_PERCENT}${ALM}`,
|
|
114
|
-
); // was `\u0661\u066b\u0660\u0660` + the same affix
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
describe('formatPercent keeps every digit at the top of the double range (#4590)', () => {
|
|
119
|
-
/**
|
|
120
|
-
* MOVING PINS, before/after verbatim. `Number.MAX_SAFE_INTEGER` percentage
|
|
121
|
-
* points used to render with its last digit replaced by a zero, and 1e23 used
|
|
122
|
-
* to render as a number that is not 1e23 at all. Both are the same artefact
|
|
123
|
-
* as the ties, at the other end of the range: divide by 100 and the shortest
|
|
124
|
-
* representation of the quotient no longer round-trips.
|
|
125
|
-
*/
|
|
126
|
-
it('MAX_SAFE_INTEGER keeps its last digit', () => {
|
|
127
|
-
// was `9,007,199,254,740,990%` — a 1 turned into a 0
|
|
128
|
-
expect(formatPercent(Number.MAX_SAFE_INTEGER, 0, 'en-US')).toBe('9,007,199,254,740,991%');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('1e23 renders as 1e23', () => {
|
|
132
|
-
// was `99,999,999,999,999,990,000,000%`
|
|
133
|
-
expect(formatPercent(1e23, 0, 'en-US')).toBe('100,000,000,000,000,000,000,000%');
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('MUST NOT CHANGE: the locale percent CONVENTION (#4590 is numeral-only)', () => {
|
|
138
|
-
/**
|
|
139
|
-
* The affix is what `style: 'percentPoints'` promises to preserve, so this is
|
|
140
|
-
* the half that proves the fix did not become a convention change. Byte-exact
|
|
141
|
-
* strings, chosen at a magnitude and precision where no tie is in play — they
|
|
142
|
-
* are GREEN on both sides of the fix, and a diff here is a STOP condition.
|
|
143
|
-
*/
|
|
144
|
-
it.each([
|
|
145
|
-
['en-US', '1,234.5%'],
|
|
146
|
-
['de-DE', `1.234,5${NBSP}%`],
|
|
147
|
-
['fr-FR', `1${NNBSP}234,5${NBSP}%`],
|
|
148
|
-
['tr-TR', '%1.234,5'], // sign in FRONT
|
|
149
|
-
['ar-EG', `\u0661\u066c\u0662\u0663\u0664\u066b\u0665${ARABIC_PERCENT}${ALM}`],
|
|
150
|
-
['ja-JP', '1,234.5%'],
|
|
151
|
-
['zh-CN', '1,234.5%'],
|
|
152
|
-
['ru-RU', `1${NBSP}234,5${NBSP}%`],
|
|
153
|
-
['sv-SE', `1${NBSP}234,5${NBSP}%`],
|
|
154
|
-
['bn-IN', `\u09e7,\u09e8\u09e9\u09ea.\u09eb%`],
|
|
155
|
-
])('%s renders its own percent convention, unchanged', (locale, expected) => {
|
|
156
|
-
expect(formatPercent(1234.5, 1, locale)).toBe(expected);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Sign POSITION and sign GLYPH are convention too — Turkish puts the minus
|
|
161
|
-
* outside its prefixed percent sign, Arabic leads with U+061C, and Swedish
|
|
162
|
-
* uses U+2212 MINUS SIGN rather than ASCII hyphen-minus. None of it moves.
|
|
163
|
-
*/
|
|
164
|
-
it.each([
|
|
165
|
-
['en-US', '-45.5%'],
|
|
166
|
-
['de-DE', `-45,5${NBSP}%`],
|
|
167
|
-
['tr-TR', '-%45,5'],
|
|
168
|
-
['ar-EG', `${ALM}-\u0664\u0665\u066b\u0665${ARABIC_PERCENT}${ALM}`],
|
|
169
|
-
['sv-SE', `\u221245,5${NBSP}%`],
|
|
170
|
-
])('%s keeps its negative-sign convention', (locale, expected) => {
|
|
171
|
-
expect(formatPercent(-45.5, 1, locale)).toBe(expected);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* The parity claim asserted rather than trusted: the affix `formatPercent`
|
|
176
|
-
* now produces must be the one `Intl`'s OWN `style: 'percent'` produces for
|
|
177
|
-
* the same locale. Comparing the affix alone (digits stripped) is deliberate
|
|
178
|
-
* — the numerals are exactly what this card moves, so a whole-string compare
|
|
179
|
-
* would re-assert the defect.
|
|
180
|
-
*/
|
|
181
|
-
it('the affix is byte-identical to Intl style:percent, for every locale here', () => {
|
|
182
|
-
const affix = (rendered: string) => rendered.replace(/[\p{Nd}]/gu, '');
|
|
183
|
-
for (const locale of ['en-US', 'de-DE', 'fr-FR', 'tr-TR', 'ar-EG', 'ja-JP',
|
|
184
|
-
'zh-CN', 'ru-RU', 'sv-SE', 'bn-IN']) {
|
|
185
|
-
const cell = formatPercent(1234.5, 1, locale);
|
|
186
|
-
const intl = new Intl.NumberFormat(locale, {
|
|
187
|
-
style: 'percent',
|
|
188
|
-
minimumFractionDigits: 1,
|
|
189
|
-
maximumFractionDigits: 1,
|
|
190
|
-
}).format(12.345);
|
|
191
|
-
expect(affix(cell), `percent affix for ${locale}`).toBe(affix(intl));
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
describe('MUST NOT CHANGE: percent SCALING is upstream of the render (#4590)', () => {
|
|
197
|
-
/**
|
|
198
|
-
* `percentDisplayValue` (a stored fraction below 1 scales by 100, a value at
|
|
199
|
-
* or above 1 passes through) decides WHICH number gets rendered; this card
|
|
200
|
-
* changes only HOW that number is rendered. Pinned unmoved so a future reader
|
|
201
|
-
* cannot mistake the two halves for one.
|
|
202
|
-
*/
|
|
203
|
-
it('a stored fraction still scales, a stored whole number still does not', () => {
|
|
204
|
-
expect(formatPercent(0.8, 0, 'en-US')).toBe('80%');
|
|
205
|
-
expect(formatPercent(0.5, 0, 'en-US')).toBe('50%');
|
|
206
|
-
expect(formatPercent(0.075, 1, 'en-US')).toBe('7.5%');
|
|
207
|
-
expect(formatPercent(80, 0, 'en-US')).toBe('80%');
|
|
208
|
-
expect(formatPercent(100, 0, 'en-US')).toBe('100%');
|
|
209
|
-
// The boundary itself: 1 is NOT a fraction, so it stays 1%.
|
|
210
|
-
expect(formatPercent(1, 0, 'en-US')).toBe('1%');
|
|
211
|
-
// …which is why the tie cases above are authored just above 1.
|
|
212
|
-
expect(formatPercent(0.9999, 0, 'en-US')).toBe('100%');
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('a malformed locale tag still falls back instead of throwing', () => {
|
|
216
|
-
expect(() => formatPercent(80, 0, 'not a locale')).not.toThrow();
|
|
217
|
-
expect(formatPercent(80, 0, 'not a locale')).toContain('80');
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('is still callable with no locale and no precision', () => {
|
|
221
|
-
expect(formatPercent(80)).toBe('80%');
|
|
222
|
-
expect(formatPercent(80, 0)).toBe('80%');
|
|
223
|
-
});
|
|
224
|
-
});
|
|
@@ -1,91 +0,0 @@
|
|
|
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
|
-
* Registry collision guard.
|
|
11
|
-
*
|
|
12
|
-
* `@object-ui/components` registers display widgets (`text`, `image`,
|
|
13
|
-
* `avatar`, `html`, `grid`) under their bare names. `@object-ui/fields`
|
|
14
|
-
* registers form-input widgets that — by name — collide with those.
|
|
15
|
-
*
|
|
16
|
-
* The fix: field registrations for these 5 types pass `skipFallback: true`,
|
|
17
|
-
* so the bare lookup keeps returning the display widget while the field
|
|
18
|
-
* input is still reachable via the `field:<type>` namespace.
|
|
19
|
-
*
|
|
20
|
-
* Regression contract:
|
|
21
|
-
* 1. `FIELD_TYPES_SKIP_FALLBACK` must contain every colliding name.
|
|
22
|
-
* 2. After `registerAllFields()`, bare lookups must NOT resolve to a
|
|
23
|
-
* field renderer for any of those names.
|
|
24
|
-
* 3. The `field:<type>` namespaced lookup must still work, so form
|
|
25
|
-
* schemas using `{ type: 'text' }` still find an input renderer
|
|
26
|
-
* when explicitly requested.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { describe, it, expect, beforeAll } from 'vitest';
|
|
30
|
-
import { ComponentRegistry } from '@object-ui/core';
|
|
31
|
-
|
|
32
|
-
// Importing the fields entry runs `registerAllFields()` at module load.
|
|
33
|
-
// Importing the components entry runs display-widget registration the
|
|
34
|
-
// same way. Both must be loaded before we inspect the registry.
|
|
35
|
-
import '@object-ui/components';
|
|
36
|
-
import { registerAllFields } from '../index';
|
|
37
|
-
|
|
38
|
-
const COLLIDING_TYPES = ['text', 'html', 'image', 'avatar', 'grid'] as const;
|
|
39
|
-
|
|
40
|
-
describe('registry collision (fields ↔ components)', () => {
|
|
41
|
-
beforeAll(() => {
|
|
42
|
-
// Defensive: in case the auto-register at import time hasn't run yet
|
|
43
|
-
// under the test runner's module isolation.
|
|
44
|
-
registerAllFields();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it.each(COLLIDING_TYPES)(
|
|
48
|
-
'bare "%s" lookup is NOT clobbered by the field renderer',
|
|
49
|
-
(type) => {
|
|
50
|
-
// The bare lookup must resolve, and it must resolve to a display widget
|
|
51
|
-
// (or a FallbackComponent / null) — never to the lazy field renderer.
|
|
52
|
-
// The field renderer is a React.lazy() result; the display widget is a
|
|
53
|
-
// plain function component. We don't pin to a specific identity, but we
|
|
54
|
-
// DO pin that whatever the bare lookup returns, the namespaced
|
|
55
|
-
// `field:<type>` lookup returns something different.
|
|
56
|
-
const bare = ComponentRegistry.get(type);
|
|
57
|
-
const namespaced = ComponentRegistry.get(`field:${type}`);
|
|
58
|
-
|
|
59
|
-
// Namespaced field renderer must exist for every colliding type —
|
|
60
|
-
// that's the whole point of keeping the field accessible.
|
|
61
|
-
expect(
|
|
62
|
-
namespaced,
|
|
63
|
-
`field:${type} must be registered so forms can still use it`,
|
|
64
|
-
).toBeTruthy();
|
|
65
|
-
|
|
66
|
-
// The bare and namespaced renderers must NOT be identical. If they
|
|
67
|
-
// are, it means the field renderer wrote to the bare fallback and
|
|
68
|
-
// would render an empty <input> instead of the display widget.
|
|
69
|
-
expect(
|
|
70
|
-
bare,
|
|
71
|
-
`bare "${type}" must not be the same component as field:${type}`,
|
|
72
|
-
).not.toBe(namespaced);
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
it('non-colliding field types DO populate the bare fallback', () => {
|
|
77
|
-
// Sanity check: a field type with no display-widget collision (e.g.
|
|
78
|
-
// `email`, `password`, `number`) must still register both at the
|
|
79
|
-
// namespaced AND bare keys, otherwise we'd silently break forms.
|
|
80
|
-
for (const type of ['email', 'password', 'number', 'date', 'select']) {
|
|
81
|
-
expect(
|
|
82
|
-
ComponentRegistry.get(`field:${type}`),
|
|
83
|
-
`field:${type} must be registered`,
|
|
84
|
-
).toBeTruthy();
|
|
85
|
-
expect(
|
|
86
|
-
ComponentRegistry.get(type),
|
|
87
|
-
`bare "${type}" must also be reachable (no collision)`,
|
|
88
|
-
).toBeTruthy();
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
});
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { resolveFieldCurrency } from '../index';
|
|
3
|
-
|
|
4
|
-
describe('resolveFieldCurrency', () => {
|
|
5
|
-
it('prefers the field explicit currency over everything', () => {
|
|
6
|
-
expect(resolveFieldCurrency({ currency: 'JPY', currencyConfig: { defaultCurrency: 'EUR' } }, 'USD')).toBe('JPY');
|
|
7
|
-
});
|
|
8
|
-
it('falls back to currencyConfig.defaultCurrency, then legacy defaultCurrency', () => {
|
|
9
|
-
expect(resolveFieldCurrency({ currencyConfig: { defaultCurrency: 'EUR' } }, 'USD')).toBe('EUR');
|
|
10
|
-
expect(resolveFieldCurrency({ defaultCurrency: 'GBP' } as any, 'USD')).toBe('GBP');
|
|
11
|
-
});
|
|
12
|
-
it('falls back to the tenant default when the field omits its own', () => {
|
|
13
|
-
expect(resolveFieldCurrency({}, 'CNY')).toBe('CNY');
|
|
14
|
-
expect(resolveFieldCurrency(null, 'CNY')).toBe('CNY');
|
|
15
|
-
});
|
|
16
|
-
it('returns undefined when nothing is known (→ renderer shows a plain number)', () => {
|
|
17
|
-
expect(resolveFieldCurrency({})).toBeUndefined();
|
|
18
|
-
expect(resolveFieldCurrency(undefined, undefined)).toBeUndefined();
|
|
19
|
-
});
|
|
20
|
-
});
|
|
@@ -1,199 +0,0 @@
|
|
|
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
|
-
* End-to-end: BOTH select paths associate the form's visible label with the
|
|
11
|
-
* control the user operates (objectui#3976, the built-in half of #3306).
|
|
12
|
-
*
|
|
13
|
-
* There are two `select` paths through the form renderer and they do not share
|
|
14
|
-
* an implementation:
|
|
15
|
-
*
|
|
16
|
-
* - **registered widget** — `type: 'field:select'` (what `mapFieldTypeToFormType`
|
|
17
|
-
* emits for object-driven forms) resolves to this package's `SelectField`.
|
|
18
|
-
* objectui#3306 moved its DOM pass-through from Radix's `Select.Root` — which
|
|
19
|
-
* renders no element and silently drops what it does not recognise — onto
|
|
20
|
-
* `SelectTrigger`, the focusable `<button role="combobox">`.
|
|
21
|
-
* - **built-in branch** — a hand-written `type: 'select'` never reaches the
|
|
22
|
-
* registry at all (`'select'` is a `BUILTIN_FIELD_TYPES` member), so the form
|
|
23
|
-
* renderer's own `Select` branch renders it. That branch kept spreading onto
|
|
24
|
-
* Root until objectui#3976: the visible label pointed at an id no element
|
|
25
|
-
* carried, `getByLabelText` found nothing, and `aria-describedby` /
|
|
26
|
-
* `aria-invalid` / `aria-required` never reached the DOM either.
|
|
27
|
-
*
|
|
28
|
-
* The paths are pinned side by side, in one file, precisely because the fork is
|
|
29
|
-
* what caused the bug: #3306 fixed one half and the other silently kept the
|
|
30
|
-
* defect for as long as nobody wrote a bare `type: 'select'` into a test. The
|
|
31
|
-
* registered half here is a POSITIVE CONTROL — it must stay green through any
|
|
32
|
-
* change to the built-in branch.
|
|
33
|
-
*
|
|
34
|
-
* The widget is registered raw rather than through `registerAllFields()`, which
|
|
35
|
-
* wraps every loader in `React.lazy`: an unbounded module load inside a bounded
|
|
36
|
-
* `findBy`/`waitFor` window is the repo's known flake generator (AGENTS.md
|
|
37
|
-
* 测试纪律, objectui#3010). Same component, no Suspense boundary.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
|
41
|
-
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
|
42
|
-
import '@testing-library/jest-dom';
|
|
43
|
-
import { ComponentRegistry } from '@object-ui/core';
|
|
44
|
-
// Module scope: pulls in the form renderer's registration side effect.
|
|
45
|
-
import '@object-ui/components';
|
|
46
|
-
|
|
47
|
-
import { SelectField } from '../widgets/SelectField';
|
|
48
|
-
|
|
49
|
-
const OPTIONS = [
|
|
50
|
-
{ label: 'Alpha', value: 'alpha' },
|
|
51
|
-
{ label: 'Beta', value: 'beta' },
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
beforeAll(() => {
|
|
55
|
-
ComponentRegistry.register('select', SelectField as any, {
|
|
56
|
-
namespace: 'field',
|
|
57
|
-
skipFallback: true,
|
|
58
|
-
});
|
|
59
|
-
}, 30000);
|
|
60
|
-
|
|
61
|
-
beforeEach(() => {
|
|
62
|
-
if (!(Element.prototype as any).scrollIntoView) {
|
|
63
|
-
(Element.prototype as any).scrollIntoView = () => {};
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
afterEach(() => {
|
|
68
|
-
cleanup();
|
|
69
|
-
vi.restoreAllMocks();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
/** The real form renderer hosting one field. */
|
|
73
|
-
function renderForm(field: any, defaultValue: unknown = undefined) {
|
|
74
|
-
const Form = ComponentRegistry.get('form')!;
|
|
75
|
-
return render(
|
|
76
|
-
<Form
|
|
77
|
-
schema={{
|
|
78
|
-
type: 'form',
|
|
79
|
-
mode: 'create',
|
|
80
|
-
showSubmit: true,
|
|
81
|
-
showCancel: false,
|
|
82
|
-
submitLabel: 'Create',
|
|
83
|
-
defaultValues: { [field.name]: defaultValue },
|
|
84
|
-
fields: [field],
|
|
85
|
-
onSubmit: () => {},
|
|
86
|
-
}}
|
|
87
|
-
/>,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Every `<label for=…>` in the document, paired with its resolved target. */
|
|
92
|
-
function labelTargets(): Array<{ text: string; forId: string; resolves: boolean }> {
|
|
93
|
-
return Array.from(document.querySelectorAll('label'))
|
|
94
|
-
.map((el) => ({
|
|
95
|
-
text: (el.textContent ?? '').trim(),
|
|
96
|
-
forId: el.getAttribute('for') ?? '',
|
|
97
|
-
resolves: !!el.getAttribute('for') && !!document.getElementById(el.getAttribute('for')!),
|
|
98
|
-
}))
|
|
99
|
-
.filter((l) => l.forId !== '');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The trigger button, by role — fails loudly if it stops being a combobox. */
|
|
103
|
-
function trigger(name: string): Element {
|
|
104
|
-
const el = document.querySelector(`[data-field="${name}"] [role="combobox"]`);
|
|
105
|
-
if (!el) throw new Error(`no select trigger rendered for field "${name}"`);
|
|
106
|
-
return el;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The guarantees a select must give whichever path rendered it. Run twice, so
|
|
113
|
-
* neither path can drift away from the other again.
|
|
114
|
-
*/
|
|
115
|
-
const PATHS = [
|
|
116
|
-
['built-in branch (bare `type: select`, objectui#3976)', 'select'],
|
|
117
|
-
['registered widget (`field:select`, objectui#3306 — positive control)', 'field:select'],
|
|
118
|
-
] as const;
|
|
119
|
-
|
|
120
|
-
describe.each(PATHS)('%s', (_label, type) => {
|
|
121
|
-
const field = { name: 'choice', label: 'Choice', type, options: OPTIONS };
|
|
122
|
-
|
|
123
|
-
it('emits no label pointing at an id nothing carries', () => {
|
|
124
|
-
renderForm(field);
|
|
125
|
-
|
|
126
|
-
const labels = labelTargets();
|
|
127
|
-
expect(labels.length).toBeGreaterThan(0);
|
|
128
|
-
expect(labels.filter((l) => !l.resolves)).toEqual([]);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('resolves the visible label to the trigger button', () => {
|
|
132
|
-
renderForm(field);
|
|
133
|
-
|
|
134
|
-
// `getAllByLabelText` walks `for`→id, so it guards the association itself:
|
|
135
|
-
// on the built-in path this used to find NOTHING at all.
|
|
136
|
-
const labelled = screen.getAllByLabelText('Choice');
|
|
137
|
-
expect(labelled).toHaveLength(1);
|
|
138
|
-
expect(labelled[0]).toBe(trigger('choice'));
|
|
139
|
-
expect(labelled[0].tagName).toBe('BUTTON');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('gives the combobox the label as its accessible name', () => {
|
|
143
|
-
renderForm(field);
|
|
144
|
-
|
|
145
|
-
expect(screen.getByRole('combobox', { name: 'Choice' })).toBe(trigger('choice'));
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('carries the required state on the trigger', () => {
|
|
149
|
-
renderForm({ ...field, required: true });
|
|
150
|
-
|
|
151
|
-
expect(trigger('choice')).toHaveAttribute('aria-required', 'true');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('announces invalid on the trigger and links the message', async () => {
|
|
155
|
-
renderForm({ ...field, required: true }, null);
|
|
156
|
-
|
|
157
|
-
// A valid field SAYS it is valid rather than staying mute (#3222/#3306).
|
|
158
|
-
expect(trigger('choice')).toHaveAttribute('aria-invalid', 'false');
|
|
159
|
-
|
|
160
|
-
submit();
|
|
161
|
-
|
|
162
|
-
const message = await screen.findByText('Choice is required');
|
|
163
|
-
await waitFor(() => expect(trigger('choice')).toHaveAttribute('aria-invalid', 'true'));
|
|
164
|
-
expect(message.id).not.toBe('');
|
|
165
|
-
const describedBy = trigger('choice').getAttribute('aria-describedby') ?? '';
|
|
166
|
-
expect(describedBy.split(/\s+/)).toContain(message.id);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('keeps `name` on Root, where the submitted hidden select is', () => {
|
|
170
|
-
// The whitelist both paths share: `name` is the ONE key Root consumes — it
|
|
171
|
-
// forwards it to the hidden native `<select>` that takes part in form
|
|
172
|
-
// submission. On the trigger it would sit uselessly on a non-submitter.
|
|
173
|
-
renderForm(field);
|
|
174
|
-
|
|
175
|
-
expect(document.querySelector('[data-field="choice"] select[aria-hidden="true"]')).toHaveAttribute(
|
|
176
|
-
'name',
|
|
177
|
-
'choice',
|
|
178
|
-
);
|
|
179
|
-
expect(trigger('choice')).not.toHaveAttribute('name');
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
describe('the fork itself (objectui#3976 / #3231)', () => {
|
|
184
|
-
it('a bare `type: select` renders the BUILT-IN branch even though field:select resolves', () => {
|
|
185
|
-
// Measured, not inferred — this is the fact that made #3976 a built-in-side
|
|
186
|
-
// bug rather than a duplicate of #3306: `renderFieldComponent` skips the
|
|
187
|
-
// registry entirely for a `BUILTIN_FIELD_TYPES` member, so `SelectField` is
|
|
188
|
-
// NOT what renders a hand-written `type: 'select'`, even here where the real
|
|
189
|
-
// widget is registered under `field:select`.
|
|
190
|
-
expect(ComponentRegistry.get('field:select')).toBe(SelectField);
|
|
191
|
-
|
|
192
|
-
renderForm({ name: 'choice', label: 'Choice', type: 'select', options: OPTIONS });
|
|
193
|
-
|
|
194
|
-
// `SelectField` stamps this locator on the trigger it renders; the built-in
|
|
195
|
-
// branch does not. Same role, different implementation.
|
|
196
|
-
expect(document.querySelector('[data-testid="select-trigger-choice"]')).toBeNull();
|
|
197
|
-
expect(trigger('choice')).toBeInTheDocument();
|
|
198
|
-
});
|
|
199
|
-
});
|
|
@@ -1,126 +0,0 @@
|
|
|
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
|
-
* `@object-ui/fields` ↔ `@objectstack/spec` symbol-collision guards
|
|
11
|
-
* (objectui#3161, objectstack#4115 ledger batch 7).
|
|
12
|
-
*
|
|
13
|
-
* Two symbols, two different verdicts:
|
|
14
|
-
*
|
|
15
|
-
* - `isFileIdToken` was a character-for-character copy of the spec's function
|
|
16
|
-
* under the spec's own name. It is a re-export now, and the gate below is
|
|
17
|
-
* REFERENCE IDENTITY — not behaviour. A faithful copy passes every behaviour
|
|
18
|
-
* test ever written for it (objectui#3003, re-proved on
|
|
19
|
-
* `isAggregatedViewContainer` in objectui#3169), so `toBe` is the only
|
|
20
|
-
* assertion that can tell a re-export from a fork.
|
|
21
|
-
*
|
|
22
|
-
* - `FieldWidgetProps` is now `FieldWidgetComponentProps`. The spec owns that
|
|
23
|
-
* name for the DECLARED widget-plugin props contract; this package's is the
|
|
24
|
-
* React interface its widgets implement. The pins record what still makes
|
|
25
|
-
* re-exporting impossible (`field` is the far richer `FieldMetadata` here)
|
|
26
|
-
* and, since objectui#3222, what no longer does: the validation slot is the
|
|
27
|
-
* spec's `error` on both sides.
|
|
28
|
-
*
|
|
29
|
-
* The other direction — "the spec must not start owning the NEW names" — is not
|
|
30
|
-
* asserted here because `scripts/check-spec-symbol-derivation.mjs` already
|
|
31
|
-
* answers it on every CI run, for every name in the package rather than the
|
|
32
|
-
* handful a test would remember to list.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { describe, it, expect } from 'vitest';
|
|
36
|
-
import { isFileIdToken as specIsFileIdToken } from '@objectstack/spec/data';
|
|
37
|
-
import type { FieldWidgetProps as SpecFieldWidgetProps } from '@objectstack/spec/ui';
|
|
38
|
-
|
|
39
|
-
import { isFileIdToken, fileIdOf } from '../widgets/file-value';
|
|
40
|
-
import type { FieldWidgetComponentProps } from '../widgets/types';
|
|
41
|
-
|
|
42
|
-
describe('isFileIdToken IS the spec function', () => {
|
|
43
|
-
it('is the same function object, not an equivalent one', () => {
|
|
44
|
-
// The whole point. Every value/behaviour assertion below this line would
|
|
45
|
-
// also pass against a re-forked copy; this one would not.
|
|
46
|
-
expect(isFileIdToken).toBe(specIsFileIdToken);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('is the arbiter the rest of the module routes through', () => {
|
|
50
|
-
// `fileIdOf` must keep agreeing with the shared predicate, so a future
|
|
51
|
-
// "small local tweak" to the id shape cannot be reintroduced one caller at
|
|
52
|
-
// a time.
|
|
53
|
-
for (const value of ['abc123', 'AbC-_9', 'x'.repeat(64)]) {
|
|
54
|
-
expect(specIsFileIdToken(value)).toBe(true);
|
|
55
|
-
expect(fileIdOf(value)).toBe(value);
|
|
56
|
-
}
|
|
57
|
-
for (const value of ['https://example.com/f.png', '/api/v1/storage/files/1', 'a.b', 'x'.repeat(65)]) {
|
|
58
|
-
expect(specIsFileIdToken(value)).toBe(false);
|
|
59
|
-
expect(fileIdOf(value)).toBeUndefined();
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
/* -------------------------------------------------------------------------- */
|
|
65
|
-
/* Compile-time pins. A violation is a `tsc` error, not a runtime failure. */
|
|
66
|
-
/* This package's tsconfig.json includes its tests, so `type-check` compiles */
|
|
67
|
-
/* them (scripts/check-type-check-coverage.mjs keeps that true). */
|
|
68
|
-
/* -------------------------------------------------------------------------- */
|
|
69
|
-
|
|
70
|
-
type Assert<T extends true> = T;
|
|
71
|
-
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
72
|
-
type Extends<A, B> = [A] extends [B] ? true : false;
|
|
73
|
-
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
74
|
-
type HasKey<T, K extends string> = K extends keyof T ? true : false;
|
|
75
|
-
|
|
76
|
-
describe('FieldWidgetComponentProps is the RENDERED layer of the spec contract', () => {
|
|
77
|
-
it('is pinned at compile time', () => {
|
|
78
|
-
// The import above is itself half the tripwire: the day the spec stops
|
|
79
|
-
// exporting `FieldWidgetProps`, this file stops compiling and the rename's
|
|
80
|
-
// reason is up for re-triage (the plain name could come back).
|
|
81
|
-
type _SpecIsReal = Assert<Equal<IsAny<SpecFieldWidgetProps>, false>>;
|
|
82
|
-
|
|
83
|
-
// The validation-message slot no longer diverges. objectui#3161 could only
|
|
84
|
-
// RECORD that the declared contract called it `error` while every widget
|
|
85
|
-
// here read `errorMessage`; objectui#3222 resolved it in the direction the
|
|
86
|
-
// contract points — this package adopted `error`, and the form renderer
|
|
87
|
-
// started producing it, so the name and the delivery landed together.
|
|
88
|
-
// Both sides are pinned: the spec still owns the name, and this package
|
|
89
|
-
// still spells it the same way.
|
|
90
|
-
type _SpecNamesItError = Assert<HasKey<SpecFieldWidgetProps, 'error'>>;
|
|
91
|
-
type _LocalNamesItErrorToo = Assert<HasKey<FieldWidgetComponentProps, 'error'>>;
|
|
92
|
-
type _SameOptionalStringSlot = Assert<
|
|
93
|
-
Equal<FieldWidgetComponentProps['error'], SpecFieldWidgetProps['error']>
|
|
94
|
-
>;
|
|
95
|
-
// The old name is gone, not kept as an alias. An alias would be exactly the
|
|
96
|
-
// lenient second dialect AGENTS.md #0.1 forbids — and the reason a missed
|
|
97
|
-
// call site would have gone quiet again.
|
|
98
|
-
type _OldNameRetired = Assert<Equal<HasKey<FieldWidgetComponentProps, 'errorMessage'>, false>>;
|
|
99
|
-
|
|
100
|
-
// …and the reason nobody noticed used to sit right here: three pins
|
|
101
|
-
// asserting that `[key: string]: any` was still present, and that
|
|
102
|
-
// `props.required` / `props.error` therefore read as `any`. objectui#3221
|
|
103
|
-
// removed that index signature, so those pins have gone red on purpose and
|
|
104
|
-
// are gone with it. What replaces them is the inverse claim — the type is
|
|
105
|
-
// now CLOSED, so a key the spec declares and this one does not can finally
|
|
106
|
-
// be reported as missing. That is what made objectui#3222's rename
|
|
107
|
-
// decidable by the compiler instead of by a symbol guard.
|
|
108
|
-
type _NoStringIndexSignature = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
|
|
109
|
-
// `required` is the one key that stayed behind, and deliberately so
|
|
110
|
-
// (objectui#3222): the required marker has a single author — the form
|
|
111
|
-
// renderer's `<FormLabel>` — and lowering the flag into widget props gives
|
|
112
|
-
// it a second, i.e. the double-display failure that also keeps the
|
|
113
|
-
// validation TEXT out of the widget. The a11y state a widget could
|
|
114
|
-
// legitimately carry is `aria-required`, and `AriaAttributes` already
|
|
115
|
-
// supplies that key with no contract change at all.
|
|
116
|
-
type _RequiredIsAbsent = Assert<Equal<HasKey<FieldWidgetComponentProps, 'required'>, false>>;
|
|
117
|
-
|
|
118
|
-
// `data-*` stays open (it is open in HTML too), but as a template-literal
|
|
119
|
-
// key — the distinction that keeps `keyof` finite above. A pin, because
|
|
120
|
-
// widening it back to `[key: string]` would silently restore the defect
|
|
121
|
-
// while every assertion here still read as "closed".
|
|
122
|
-
type _DataAttributesStayOpen = Assert<Extends<'data-testid', keyof FieldWidgetComponentProps>>;
|
|
123
|
-
|
|
124
|
-
expect(true).toBe(true);
|
|
125
|
-
});
|
|
126
|
-
});
|