@object-ui/fields 17.2.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1533 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -0,0 +1,221 @@
|
|
|
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#4361 — currency formatting overrode each currency's own ISO 4217
|
|
11
|
+
* fraction-digit convention.
|
|
12
|
+
*
|
|
13
|
+
* `formatCurrency` picked its width from the VALUE's wholeness alone
|
|
14
|
+
* (`isWhole ? 0 : 2`) and handed that literal `2` to `Intl`, which overrides
|
|
15
|
+
* what `Intl` already knows about the currency being rendered. A yen amount
|
|
16
|
+
* was printed with cents the currency does not have (`¥1,234.50`) and a dinar
|
|
17
|
+
* amount with one digit fewer than it does have (`KWD 1.50`).
|
|
18
|
+
*
|
|
19
|
+
* The fix derives the currency's own digit count first
|
|
20
|
+
* (`Intl.NumberFormat(undefined, { style: 'currency', currency })
|
|
21
|
+
* .resolvedOptions().maximumFractionDigits`)
|
|
22
|
+
* and switches wholeness against THAT instead of the literal 2. Two halves,
|
|
23
|
+
* both pinned here:
|
|
24
|
+
*
|
|
25
|
+
* 1. **A fractional amount takes the currency's own width** — the defect.
|
|
26
|
+
* JPY -> 0, USD/EUR/CNY -> 2, KWD/BHD -> 3.
|
|
27
|
+
* 2. **A whole amount still drops the fraction entirely** — the Salesforce
|
|
28
|
+
* convention `formatCurrency`'s doc comment states, that objectui#4033
|
|
29
|
+
* pinned and objectui#4332 / PR #4362 re-pinned. It is not retired here,
|
|
30
|
+
* it is EXTENDED consistently: `KWD 1` renders `KWD 1`, not the
|
|
31
|
+
* `KWD 1.000` a bare `Intl` default would give. Dropping both bounds and
|
|
32
|
+
* letting `Intl` decide would have fixed half 1 by breaking this half, so
|
|
33
|
+
* the whole-amount cases below are controls, not decorations.
|
|
34
|
+
*
|
|
35
|
+
* The USD cases are the acceptance evidence for "byte-identical to today":
|
|
36
|
+
* the #4033 and #4332/#4362 pins pass UNCHANGED, and the ones repeated here
|
|
37
|
+
* are the same assertions run against the new derivation.
|
|
38
|
+
*
|
|
39
|
+
* Every expected string below was MEASURED against node 22 with full ICU
|
|
40
|
+
* (icu 78.2) rather than reasoned about; where ICU's spacing or symbol choice
|
|
41
|
+
* is what varies (and not the digit count this card is about), the assertion
|
|
42
|
+
* is a regex on the digits.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { describe, it, expect } from 'vitest';
|
|
46
|
+
import React from 'react';
|
|
47
|
+
import { render, screen } from '@testing-library/react';
|
|
48
|
+
import '@testing-library/jest-dom';
|
|
49
|
+
import { LocalizationProvider } from '@object-ui/i18n';
|
|
50
|
+
import {
|
|
51
|
+
formatCurrency,
|
|
52
|
+
formatCompactCurrency,
|
|
53
|
+
CurrencyCellRenderer,
|
|
54
|
+
} from '../index';
|
|
55
|
+
import { currencyFractionDigits } from '../currency';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ICU separates a currency CODE from the amount with U+00A0 (`KWD` + NBSP +
|
|
59
|
+
* `1.500`), while a currency SYMBOL is written flush against it (`$1,234.50`).
|
|
60
|
+
* Normalizing the NBSP keeps these pins about the DIGIT COUNT — which is what
|
|
61
|
+
* this card is about — rather than about ICU's spacing choices, the same reason
|
|
62
|
+
* objectui#4332's de-DE pin matched its separator loosely.
|
|
63
|
+
*
|
|
64
|
+
* Written as an escape rather than a pasted byte, per the repo's byte
|
|
65
|
+
* discipline: a raw NBSP in source is invisible to every reader and unfindable
|
|
66
|
+
* by grep in the spelling you would search for.
|
|
67
|
+
*/
|
|
68
|
+
const normalizeNbsp = (s: string) => s.replace(/\u00a0/g, ' ');
|
|
69
|
+
|
|
70
|
+
describe('the environment this card is measured in', () => {
|
|
71
|
+
it('has full ICU — a small-ICU build would make every pin below meaningless', () => {
|
|
72
|
+
// Written as an assertion rather than a comment: with a reduced ICU data
|
|
73
|
+
// set the currency digit table is not present and JPY/KWD would resolve to
|
|
74
|
+
// 2, so the fix would look correct while rendering the same wrong output.
|
|
75
|
+
// If this one fails, nothing else in this file is evidence of anything.
|
|
76
|
+
expect(currencyFractionDigits('JPY')).toBe(0);
|
|
77
|
+
expect(currencyFractionDigits('KWD')).toBe(3);
|
|
78
|
+
expect(currencyFractionDigits('USD')).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('formatCurrency — a fractional amount takes the CURRENCY\'s width (objectui#4361)', () => {
|
|
83
|
+
it('the card\'s two fixtures render as ISO 4217 says they should', () => {
|
|
84
|
+
// Before: `¥1,234.50` — cents on a currency that has none.
|
|
85
|
+
expect(formatCurrency(1234.5, 'JPY', 'en-US')).toBe('¥1,235');
|
|
86
|
+
// Before: `KWD 1.50` — one digit short of the three a dinar has.
|
|
87
|
+
expect(normalizeNbsp(formatCurrency(1.5, 'KWD', 'en-US'))).toBe('KWD 1.500');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it.each([
|
|
91
|
+
// 0-digit currencies: the fraction is rounded away, not truncated.
|
|
92
|
+
['JPY', 1234.5, '¥1,235'],
|
|
93
|
+
['CLP', 1234.5, 'CLP 1,235'],
|
|
94
|
+
['ISK', 99.5, 'ISK 100'],
|
|
95
|
+
// 3-digit currencies: the third digit is real and now survives.
|
|
96
|
+
['KWD', 1.5, 'KWD 1.500'],
|
|
97
|
+
['BHD', 2.5, 'BHD 2.500'],
|
|
98
|
+
['KWD', 1234.5678, 'KWD 1,234.568'],
|
|
99
|
+
// 2-digit currencies: unchanged, by construction.
|
|
100
|
+
['USD', 1234.5, '$1,234.50'],
|
|
101
|
+
['EUR', 1234.5, '€1,234.50'],
|
|
102
|
+
])('%s %s renders %s', (currency, value, expected) => {
|
|
103
|
+
expect(normalizeNbsp(formatCurrency(value as number, currency as string, 'en-US'))).toBe(
|
|
104
|
+
expected as string,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('a 0-digit currency rounds the amount, it does not drop the digits', () => {
|
|
109
|
+
// `1234.5` is not `1234`. Rounding is Intl's, and half-up here: the point
|
|
110
|
+
// of the pin is that the value is not silently floored.
|
|
111
|
+
expect(formatCurrency(1234.4, 'JPY', 'en-US')).toBe('¥1,234');
|
|
112
|
+
expect(formatCurrency(1234.5, 'JPY', 'en-US')).toBe('¥1,235');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('formatCurrency — CONTROL: the whole-number convention is extended, not retired', () => {
|
|
117
|
+
it.each([
|
|
118
|
+
// Salesforce convention, per currency: a whole amount shows no fraction.
|
|
119
|
+
['USD', 1234, '$1,234'],
|
|
120
|
+
['USD', 0, '$0'],
|
|
121
|
+
['EUR', 5000000, '€5,000,000'],
|
|
122
|
+
// 3-digit currency: a bare `Intl` default would print `KWD 1.000` here.
|
|
123
|
+
// The convention survives the fix and reaches currencies it never met.
|
|
124
|
+
['KWD', 1, 'KWD 1'],
|
|
125
|
+
['BHD', 250, 'BHD 250'],
|
|
126
|
+
// 0-digit currency: both halves of the switch agree at 0, so a whole yen
|
|
127
|
+
// amount is the one case that was already right and must stay right.
|
|
128
|
+
['JPY', 1234, '¥1,234'],
|
|
129
|
+
])('a whole %s amount drops the fraction: %s', (currency, value, expected) => {
|
|
130
|
+
expect(normalizeNbsp(formatCurrency(value as number, currency as string, 'en-US'))).toBe(
|
|
131
|
+
expected as string,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('a non-finite amount is not "whole" and still falls through unchanged', () => {
|
|
136
|
+
expect(formatCurrency(Number.NaN, 'JPY', 'en-US')).toBe('¥NaN');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('formatCurrency — CONTROL: the branches with no currency to derive from', () => {
|
|
141
|
+
it('the no-currency branch keeps the literal 2 — there is nothing to derive', () => {
|
|
142
|
+
// Deliberate: with no ISO code in hand there is no convention to follow,
|
|
143
|
+
// so this branch keeps the historical width. Unchanged by this card.
|
|
144
|
+
expect(formatCurrency(1234.5, undefined, 'en-US')).toBe('1,234.50');
|
|
145
|
+
expect(formatCurrency(5000000, undefined, 'en-US')).toBe('5,000,000');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('the bad-currency fallback is byte-identical to before the fix', () => {
|
|
149
|
+
// The probe throws `RangeError: Invalid currency code` for a non-ISO code
|
|
150
|
+
// exactly as `Intl` does, so it must NOT be allowed to escape and change
|
|
151
|
+
// this fallback's width. It falls back to 2 and `formatDisplayNumber`'s own
|
|
152
|
+
// throw then produces the same string objectui#4332 pinned.
|
|
153
|
+
expect(formatCurrency(1234.5, 'NOT_A_CODE', 'en-US')).toBe('NOT_A_CODE 1234.50');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('an unknown but well-formed code resolves to 2 without throwing', () => {
|
|
157
|
+
// `ZZZ` is not in the CLDR digit table; ICU answers 2 for anything it does
|
|
158
|
+
// not know, which is the right default and needs no special-casing here.
|
|
159
|
+
expect(currencyFractionDigits('ZZZ')).toBe(2);
|
|
160
|
+
expect(normalizeNbsp(formatCurrency(1.5, 'ZZZ', 'en-US'))).toBe('ZZZ 1.50');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('formatCompactCurrency is a different function and is untouched', () => {
|
|
164
|
+
// Compact notation has its own `maximumFractionDigits: 1` policy; this card
|
|
165
|
+
// does not reach it. `¥1.2K` for JPY is compact's answer, not ISO 4217's.
|
|
166
|
+
expect(formatCompactCurrency(1234.5, 'USD', 'en-US')).toBe('$1.2K');
|
|
167
|
+
expect(formatCompactCurrency(1234.5, 'JPY', 'en-US')).toBe('¥1.2K');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('formatCurrency — the display-locale path (a 0-digit and a 3-digit currency)', () => {
|
|
172
|
+
it('a 0-digit currency in its own locale', () => {
|
|
173
|
+
// ja-JP spells the symbol with the FULLWIDTH yen sign, en-US with the
|
|
174
|
+
// ordinary one; either is ICU's business. The digit count is this card's.
|
|
175
|
+
expect(formatCurrency(1234.5, 'JPY', 'ja-JP')).toMatch(/^[¥¥]1,235$/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a 3-digit currency in a comma-decimal locale', () => {
|
|
179
|
+
// de-DE: dot grouping, comma decimal mark, trailing code. Matched loosely
|
|
180
|
+
// on the separator so an ICU spacing change cannot fail the digits.
|
|
181
|
+
expect(formatCurrency(1.5, 'KWD', 'de-DE')).toMatch(/^1,500\s*KWD$/);
|
|
182
|
+
expect(formatCurrency(1234.5, 'JPY', 'de-DE')).toMatch(/^1\.235\s*¥$/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('CONTROL: a 2-digit currency in the same locales is unchanged (objectui#4033)', () => {
|
|
186
|
+
expect(formatCurrency(1234.5, 'EUR', 'de-DE')).toMatch(/^1\.234,50\s*€$/);
|
|
187
|
+
expect(formatCurrency(1234.5, 'CNY', 'zh-CN')).toBe('¥1,234.50');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('CurrencyCellRenderer — the user-visible surface (objectui#4361)', () => {
|
|
192
|
+
const renderCurrency = (value: unknown, field: Record<string, unknown>, locale?: string, tenantCurrency?: string) =>
|
|
193
|
+
render(
|
|
194
|
+
<LocalizationProvider value={{ locale, currency: tenantCurrency }}>
|
|
195
|
+
<CurrencyCellRenderer value={value as any} field={{ type: 'currency', ...field } as any} />
|
|
196
|
+
</LocalizationProvider>,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
it('a JPY grid cell shows no cents', () => {
|
|
200
|
+
renderCurrency(1234.5, { currency: 'JPY' }, 'en-US');
|
|
201
|
+
expect(screen.getByText('¥1,235')).toBeInTheDocument();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('a KWD grid cell shows all three fils digits', () => {
|
|
205
|
+
renderCurrency(1.5, { currency: 'KWD' }, 'en-US');
|
|
206
|
+
expect(screen.getByText('KWD 1.500')).toBeInTheDocument();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('the TENANT default currency reaches the same derivation (ADR-0053)', () => {
|
|
210
|
+
// `resolveFieldCurrency` falls through field -> currencyConfig -> tenant
|
|
211
|
+
// default, and the card's reachability rests on that third step: a JPY
|
|
212
|
+
// tenant with a currency field that declares nothing.
|
|
213
|
+
renderCurrency(1234.5, {}, 'en-US', 'JPY');
|
|
214
|
+
expect(screen.getByText('¥1,235')).toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('CONTROL: a USD grid cell is byte-identical to objectui#4332', () => {
|
|
218
|
+
renderCurrency(1234.5, { currency: 'USD' }, 'en-US');
|
|
219
|
+
expect(screen.getByText('$1,234.50')).toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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 group-labelling DECLARATION, at the registration boundary (objectui#3961).
|
|
11
|
+
*
|
|
12
|
+
* A widget whose labelled surface is not a labelable HTML element cannot be
|
|
13
|
+
* reached by a host's `<label for>` — the association has to go by IDREF
|
|
14
|
+
* (`aria-labelledby`). Which widgets those are is a fact only the widget package
|
|
15
|
+
* knows, so it is DECLARED (`ComponentMeta.labelling`) rather than guessed by the
|
|
16
|
+
* form renderer from whatever DOM a widget happens to render.
|
|
17
|
+
*
|
|
18
|
+
* This file pins the declaration itself, separately from the rendered outcome
|
|
19
|
+
* (`composite-group-label-e2e.test.tsx`), because the two can fail apart:
|
|
20
|
+
*
|
|
21
|
+
* • declared but not rendered → the widget's container never answers with a
|
|
22
|
+
* role, so the name lands on a bare `div` (the e2e file catches that);
|
|
23
|
+
* • rendered but not declared → the host keeps emitting `for`, the widget never
|
|
24
|
+
* receives an `aria-labelledby`, and the group semantics are dead code. THIS
|
|
25
|
+
* file is what fails then — an omission from the set is otherwise invisible,
|
|
26
|
+
* since a missing declaration degrades silently to the single-control path.
|
|
27
|
+
*
|
|
28
|
+
* Nothing renders here, so `registerField`'s `React.lazy` wrapper is never
|
|
29
|
+
* resolved: this reads metadata only.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
33
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
34
|
+
import { registerAllFields, mapFieldTypeToFormType } from '../index';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every field type whose host label must be associated by IDREF. Two shapes, one
|
|
38
|
+
* declaration — see `FIELD_TYPES_GROUP_LABELLED` in `../index`:
|
|
39
|
+
* real composites, plus `file`, whose single control is a `div[role="button"]`.
|
|
40
|
+
*
|
|
41
|
+
* `multiselect` (objectui#3975) is the seventh, added after #3961 shipped: its
|
|
42
|
+
* chip row is the same wrapper `div` holding the host id that `checkboxes` had.
|
|
43
|
+
* It is listed here rather than left to the e2e file alone because the omission
|
|
44
|
+
* of a declaration is exactly the failure that degrades silently.
|
|
45
|
+
*/
|
|
46
|
+
const GROUP_LABELLED = [
|
|
47
|
+
'address',
|
|
48
|
+
'geolocation',
|
|
49
|
+
'checkboxes',
|
|
50
|
+
'radio',
|
|
51
|
+
'rating',
|
|
52
|
+
'file',
|
|
53
|
+
'multiselect',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Single-control widgets: the host's `<label for>` reaches a real labelable
|
|
58
|
+
* element, so they must NOT be declared — that is the default path, and a stray
|
|
59
|
+
* `labelling: 'group'` here would take a working `for` association away from a
|
|
60
|
+
* control that needs it and hand it an `aria-labelledby` its markup ignores.
|
|
61
|
+
*/
|
|
62
|
+
const CONTROL_LABELLED = [
|
|
63
|
+
'text',
|
|
64
|
+
'textarea',
|
|
65
|
+
'number',
|
|
66
|
+
'boolean',
|
|
67
|
+
'date',
|
|
68
|
+
'datetime',
|
|
69
|
+
'email',
|
|
70
|
+
'phone',
|
|
71
|
+
'url',
|
|
72
|
+
'currency',
|
|
73
|
+
'percent',
|
|
74
|
+
'password',
|
|
75
|
+
'tags',
|
|
76
|
+
'color',
|
|
77
|
+
'code',
|
|
78
|
+
'user',
|
|
79
|
+
'lookup',
|
|
80
|
+
'select',
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
registerAllFields();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('field widgets declare how their label must be associated (objectui#3961)', () => {
|
|
88
|
+
it.each(GROUP_LABELLED)('%s declares labelling: "group"', (type) => {
|
|
89
|
+
expect(ComponentRegistry.getMeta(type, 'field')?.labelling).toBe('group');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each(CONTROL_LABELLED)('%s leaves labelling undeclared (⇒ "control")', (type) => {
|
|
93
|
+
// Absent, not `'control'`: one spelling of the default, and the form
|
|
94
|
+
// renderer reads any non-`'group'` value as the single-control path.
|
|
95
|
+
expect(ComponentRegistry.getMeta(type, 'field')?.labelling).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('declares group labelling for exactly the audited set — no more', () => {
|
|
99
|
+
// A widget added to `fieldWidgetMap` later inherits the single-control path,
|
|
100
|
+
// which is the safe default; if it is in fact a composite, the e2e pins
|
|
101
|
+
// (objectui#3952's "every label resolves to a real control") go red rather
|
|
102
|
+
// than this list quietly growing. This assertion is the other direction: it
|
|
103
|
+
// fails if someone declares a widget `group` without an audited group
|
|
104
|
+
// container to carry the name.
|
|
105
|
+
const declared = ComponentRegistry.getKnownTypes()
|
|
106
|
+
.filter((key) => key.startsWith('field:'))
|
|
107
|
+
.filter((key) => ComponentRegistry.getMeta(key.slice('field:'.length), 'field')?.labelling === 'group')
|
|
108
|
+
.map((key) => key.slice('field:'.length))
|
|
109
|
+
.sort();
|
|
110
|
+
|
|
111
|
+
expect(declared).toEqual([...GROUP_LABELLED].sort());
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The join between the two halves (objectui#3986). The declaration above is
|
|
117
|
+
* per-WIDGET; a form asks for a widget by the id its producer emitted. So the
|
|
118
|
+
* declaration only reaches the right widget if the producer names the widget that
|
|
119
|
+
* will actually render — and for `select` that depends on `multiple`, not on the
|
|
120
|
+
* type string alone.
|
|
121
|
+
*
|
|
122
|
+
* Asserting the pair here, rather than in either half alone, is what makes the
|
|
123
|
+
* failure visible: `mapFieldTypeToFormType`'s own test cannot know what
|
|
124
|
+
* `labelling` the id it returns carries, and the declaration test cannot know
|
|
125
|
+
* which id a `multiple: true` select resolves to. The gap between those two blind
|
|
126
|
+
* spots is exactly where this bug lived.
|
|
127
|
+
*/
|
|
128
|
+
describe('the widget id the object form emits carries the right declaration (objectui#3986)', () => {
|
|
129
|
+
const labellingOf = (formType: string) =>
|
|
130
|
+
ComponentRegistry.getMeta(formType.replace(/^field:/, ''), 'field')?.labelling;
|
|
131
|
+
|
|
132
|
+
it('select + multiple resolves to a widget declared `group`', () => {
|
|
133
|
+
// Before the producer fix this resolved to `field:select`, whose declaration
|
|
134
|
+
// is (correctly) absent — so the host kept emitting a `for` at the chip row's
|
|
135
|
+
// wrapper `div`, and the visible label named nothing.
|
|
136
|
+
const formType = mapFieldTypeToFormType('select', { multiple: true });
|
|
137
|
+
expect(formType).toBe('field:multiselect');
|
|
138
|
+
expect(labellingOf(formType)).toBe('group');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('a single-value select still resolves to an UNDECLARED widget', () => {
|
|
142
|
+
// The guard direction. `select`'s trigger is a labelable
|
|
143
|
+
// `button[role=combobox]`; declaring it `group` would strip a working `for`.
|
|
144
|
+
const formType = mapFieldTypeToFormType('select');
|
|
145
|
+
expect(formType).toBe('field:select');
|
|
146
|
+
expect(labellingOf(formType)).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
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#4336 — a lookup value rendered read-only was plain text: the record
|
|
11
|
+
* detail page showed `TYG1WX20260812001001` with a copy button and no way to
|
|
12
|
+
* reach the referenced work order, and the related list's lookup columns had
|
|
13
|
+
* the same dead cells (the reporter's own check: no `<a>` anywhere over the
|
|
14
|
+
* value, every `href` null).
|
|
15
|
+
*
|
|
16
|
+
* `LookupCellRenderer` is the ONE cell renderer both surfaces resolve through
|
|
17
|
+
* (`DetailSection` and `RelatedList` each call `getCellRenderer('lookup')`), so
|
|
18
|
+
* the link belongs here — same path, both halves.
|
|
19
|
+
*
|
|
20
|
+
* The href is NOT assembled here. This package has no router and no idea what
|
|
21
|
+
* an app's record route looks like; the host that owns record URLs publishes
|
|
22
|
+
* them through `RelatedRecordActionsContext` (`recordHref` / `openRecord`),
|
|
23
|
+
* which is the same builder the related list's row navigation already uses. No
|
|
24
|
+
* host → no link, and the value renders exactly as it did before.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
27
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
28
|
+
import '@testing-library/jest-dom';
|
|
29
|
+
import React from 'react';
|
|
30
|
+
|
|
31
|
+
import { LookupCellRenderer } from '../index';
|
|
32
|
+
import { RelatedRecordActionsProvider } from '@object-ui/react';
|
|
33
|
+
|
|
34
|
+
/** The reporter's shape: `$expand`-ed lookup value (display name + record id). */
|
|
35
|
+
const WORK_ORDER = { id: 'wo-1', name: 'TYG1WX20260812001001' };
|
|
36
|
+
const FIELD = { type: 'lookup', reference_to: 'mtc_work_order' } as any;
|
|
37
|
+
|
|
38
|
+
/** A host that can route to records, mirroring the console bridge's shape. */
|
|
39
|
+
function makeHost(overrides: Record<string, unknown> = {}) {
|
|
40
|
+
return {
|
|
41
|
+
resolve: () => ({}),
|
|
42
|
+
recordHref: (objectName: string, recordId: string | number) =>
|
|
43
|
+
`/apps/demo/${objectName}/record/${encodeURIComponent(String(recordId))}`,
|
|
44
|
+
openRecord: vi.fn(),
|
|
45
|
+
...overrides,
|
|
46
|
+
} as any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderWithHost(node: React.ReactElement, host: any = makeHost()) {
|
|
50
|
+
return render(
|
|
51
|
+
<RelatedRecordActionsProvider value={host}>{node}</RelatedRecordActionsProvider>,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('LookupCellRenderer — the value links to the referenced record (#4336)', () => {
|
|
56
|
+
it('expanded value: renders an anchor at the host-built record href', () => {
|
|
57
|
+
const { container } = renderWithHost(
|
|
58
|
+
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const anchor = container.querySelector('a');
|
|
62
|
+
expect(anchor).not.toBeNull();
|
|
63
|
+
expect(anchor).toHaveAttribute('href', '/apps/demo/mtc_work_order/record/wo-1');
|
|
64
|
+
// Display-name resolution is untouched — the anchor carries the same text.
|
|
65
|
+
expect(anchor).toHaveTextContent('TYG1WX20260812001001');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('primitive id value: links through the same host builder', () => {
|
|
69
|
+
const { container } = renderWithHost(
|
|
70
|
+
<LookupCellRenderer
|
|
71
|
+
value="wo-2"
|
|
72
|
+
field={{ ...FIELD, options: [{ value: 'wo-2', label: 'TYG1WX20260812001002' }] }}
|
|
73
|
+
/>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const anchor = container.querySelector('a');
|
|
77
|
+
expect(anchor).toHaveAttribute('href', '/apps/demo/mtc_work_order/record/wo-2');
|
|
78
|
+
expect(anchor).toHaveTextContent('TYG1WX20260812001002');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('multi-value: each referenced record gets its own anchor', () => {
|
|
82
|
+
const { container } = renderWithHost(
|
|
83
|
+
<LookupCellRenderer
|
|
84
|
+
value={[WORK_ORDER, { id: 'wo-3', name: 'TYG1WX20260812001003' }]}
|
|
85
|
+
field={FIELD}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const hrefs = Array.from(container.querySelectorAll('a')).map((a) => a.getAttribute('href'));
|
|
90
|
+
expect(hrefs).toEqual([
|
|
91
|
+
'/apps/demo/mtc_work_order/record/wo-1',
|
|
92
|
+
'/apps/demo/mtc_work_order/record/wo-3',
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('plain click navigates through the host (SPA) instead of reloading', () => {
|
|
97
|
+
const host = makeHost();
|
|
98
|
+
const rowClick = vi.fn();
|
|
99
|
+
const { container } = render(
|
|
100
|
+
<RelatedRecordActionsProvider value={host}>
|
|
101
|
+
{/* The surfaces around this cell carry their own click handlers — the
|
|
102
|
+
detail row copies the value, a related-list row opens the row's own
|
|
103
|
+
record. Activating the link must do neither. */}
|
|
104
|
+
<div onClick={rowClick}>
|
|
105
|
+
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />
|
|
106
|
+
</div>
|
|
107
|
+
</RelatedRecordActionsProvider>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const anchor = container.querySelector('a')!;
|
|
111
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
|
|
112
|
+
fireEvent(anchor, event);
|
|
113
|
+
|
|
114
|
+
expect(host.openRecord).toHaveBeenCalledWith('mtc_work_order', 'wo-1');
|
|
115
|
+
expect(event.defaultPrevented).toBe(true);
|
|
116
|
+
expect(rowClick).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('modifier click keeps the browser default (open in a new tab)', () => {
|
|
120
|
+
const host = makeHost();
|
|
121
|
+
const { container } = renderWithHost(
|
|
122
|
+
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />,
|
|
123
|
+
host,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const anchor = container.querySelector('a')!;
|
|
127
|
+
const event = new MouseEvent('click', {
|
|
128
|
+
bubbles: true,
|
|
129
|
+
cancelable: true,
|
|
130
|
+
button: 0,
|
|
131
|
+
metaKey: true,
|
|
132
|
+
});
|
|
133
|
+
fireEvent(anchor, event);
|
|
134
|
+
|
|
135
|
+
expect(host.openRecord).not.toHaveBeenCalled();
|
|
136
|
+
expect(event.defaultPrevented).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('LookupCellRenderer — must-not-change (#4336)', () => {
|
|
141
|
+
it('no host: renders exactly as before, with no anchor', () => {
|
|
142
|
+
const { container } = render(<LookupCellRenderer value={WORK_ORDER} field={FIELD} />);
|
|
143
|
+
|
|
144
|
+
expect(container.querySelector('a')).toBeNull();
|
|
145
|
+
expect(screen.getByText('TYG1WX20260812001001')).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('host cannot route to the target object: no anchor, same text', () => {
|
|
149
|
+
const { container } = renderWithHost(
|
|
150
|
+
<LookupCellRenderer value={WORK_ORDER} field={FIELD} />,
|
|
151
|
+
makeHost({ recordHref: () => null }),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(container.querySelector('a')).toBeNull();
|
|
155
|
+
expect(screen.getByText('TYG1WX20260812001001')).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('no reference target declared: no anchor', () => {
|
|
159
|
+
const { container } = renderWithHost(
|
|
160
|
+
<LookupCellRenderer value={WORK_ORDER} field={{ type: 'lookup' } as any} />,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(container.querySelector('a')).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('empty value: the placeholder stays a placeholder — no empty link', () => {
|
|
167
|
+
const { container } = renderWithHost(<LookupCellRenderer value={null} field={FIELD} />);
|
|
168
|
+
|
|
169
|
+
expect(container.querySelector('a')).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
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#4576, asserted where the two surfaces can actually be compared.
|
|
11
|
+
*
|
|
12
|
+
* The card's complaint is a CROSS-PACKAGE one: the same stored number renders
|
|
13
|
+
* `1.234,5 %` through `formatPercent` (this package, the list cell) and
|
|
14
|
+
* `1.234,5%` through `formatMeasure` (`@object-ui/core`, the dashboard measure).
|
|
15
|
+
* Neither package's own suite can see that — `core` sits below this one and
|
|
16
|
+
* cannot import `formatPercent` — so the agreement is pinned here, the one
|
|
17
|
+
* place both are in scope.
|
|
18
|
+
*
|
|
19
|
+
* This file adds no implementation. `formatPercent` was untouched by #4576; it
|
|
20
|
+
* has rendered through `Intl` since #4553 / PR #4565 and was the SIDE the measure
|
|
21
|
+
* formatter moved onto.
|
|
22
|
+
*
|
|
23
|
+
* ── objectui#4590 ──
|
|
24
|
+
* The last case in this file was a NOT-a-defect pin recording that the two
|
|
25
|
+
* surfaces still disagreed at rounding TIES. #4590 closed that at the
|
|
26
|
+
* `formatPercent` end (it now renders percentage points directly, through the
|
|
27
|
+
* same `style: 'percentPoints'` the measure uses), so that pin is now an
|
|
28
|
+
* AGREEMENT pin. It is declared in place, at the case itself.
|
|
29
|
+
*
|
|
30
|
+
* ── PREDICTIONS, written before the run ──
|
|
31
|
+
* RED before the fix: every locale whose percent convention has a space or a
|
|
32
|
+
* different sign — de, fr, es, ru, tr, ar. `formatMeasure` returned a literal
|
|
33
|
+
* '%' with no space, `formatPercent` returned the locale's convention.
|
|
34
|
+
* GREEN on both sides: en-US, ja-JP, zh-CN, whose convention is a bare
|
|
35
|
+
* trailing '%' — the must-not-change half, and the reason this was invisible in
|
|
36
|
+
* an English session for three cards running.
|
|
37
|
+
*
|
|
38
|
+
* Runner measured: node v22.22.2, ICU 78.2, machine locale en-US.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, it, expect } from 'vitest';
|
|
42
|
+
import { formatMeasure } from '@object-ui/core';
|
|
43
|
+
import { formatPercent } from '../index';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Strip the digits out of a rendered percentage, leaving only the CONVENTION —
|
|
47
|
+
* the sign, its position and any separator before it. Comparing conventions
|
|
48
|
+
* rather than whole strings is deliberate: the two formatters are reached with
|
|
49
|
+
* different scaling inputs and different decimal widths, and it is the
|
|
50
|
+
* convention, not the digits, that #4576 is about.
|
|
51
|
+
*/
|
|
52
|
+
function convention(rendered: string): string {
|
|
53
|
+
return rendered.replace(/[\d]/gu, '#').replace(/#+/gu, '#');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('a percent renders under ONE convention in a cell and in a measure (#4576)', () => {
|
|
57
|
+
it.each([
|
|
58
|
+
['de-DE'],
|
|
59
|
+
['fr-FR'],
|
|
60
|
+
['es-ES'],
|
|
61
|
+
['ru-RU'],
|
|
62
|
+
['tr-TR'],
|
|
63
|
+
['ar-EG'],
|
|
64
|
+
// must-not-change half — no space in the convention, identical before and after
|
|
65
|
+
['en-US'],
|
|
66
|
+
['ja-JP'],
|
|
67
|
+
['zh-CN'],
|
|
68
|
+
])('%s renders the same percent convention on both surfaces', (locale) => {
|
|
69
|
+
// The same underlying value on both paths: `formatPercent` takes the STORED
|
|
70
|
+
// value and scales it through `percentDisplayValue`; `formatMeasure` is
|
|
71
|
+
// told the column is already in whole percentage points.
|
|
72
|
+
const cell = formatPercent(0.805, 1, locale);
|
|
73
|
+
const measure = formatMeasure(80.5, '0.0%', undefined, 'whole', locale);
|
|
74
|
+
expect(convention(measure)).toBe(convention(cell));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('the German case the card was filed for, spelled out', () => {
|
|
78
|
+
// Both `1.234,5` followed by U+00A0 and the sign. Before #4576 the measure
|
|
79
|
+
// had no space.
|
|
80
|
+
expect(formatPercent(1234.5, 1, 'de-DE')).toBe('1.234,5\u00a0%');
|
|
81
|
+
expect(formatMeasure(1234.5, '0.0%', undefined, 'whole', 'de-DE')).toBe('1.234,5\u00a0%');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('MUST NOT CHANGE: English was always identical on both paths, and stays so', () => {
|
|
85
|
+
expect(formatPercent(1234.5, 1, 'en-US')).toBe('1,234.5%');
|
|
86
|
+
expect(formatMeasure(1234.5, '0.0%', undefined, 'whole', 'en-US')).toBe('1,234.5%');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('AGREEMENT pin — the two now round ties the same way (was NOT-a-defect, closed by #4590)', () => {
|
|
90
|
+
// ── PIN MOVED (objectui#4590) ──
|
|
91
|
+
// This case was a NOT-a-defect pin: it asserted the two surfaces DISAGREED
|
|
92
|
+
// at rounding ties, recorded honestly rather than omitted, and said the gap
|
|
93
|
+
// was #4565's residue rather than #4576's business. #4590 closed it at the
|
|
94
|
+
// `formatPercent` end, so the pin flips from recording a divergence to
|
|
95
|
+
// asserting the agreement.
|
|
96
|
+
//
|
|
97
|
+
// Before: formatPercent(1.005, 2, 'en-US') -> '1.00%' (the artefact)
|
|
98
|
+
// formatMeasure(1.005, …) -> '1.01%' (the faithful one)
|
|
99
|
+
// After: both -> '1.01%'.
|
|
100
|
+
//
|
|
101
|
+
// The cause was `formatPercent` dividing by 100 so `Intl`'s
|
|
102
|
+
// `style: 'percent'` could multiply back — lossy at ties, because the
|
|
103
|
+
// quotient's shortest decimal representation is not the authored one.
|
|
104
|
+
// `formatMeasure` had already moved onto `style: 'percentPoints'` in #4576;
|
|
105
|
+
// the cell now renders through the same option, so there is one route and
|
|
106
|
+
// no second rounding behaviour to keep in step.
|
|
107
|
+
expect(formatPercent(1.005, 2, 'en-US')).toBe('1.01%');
|
|
108
|
+
expect(formatMeasure(1.005, '0.00%', undefined, 'whole', 'en-US')).toBe('1.01%');
|
|
109
|
+
// …and asserted as an AGREEMENT rather than two coincidences, so a future
|
|
110
|
+
// divergence at either end fails here whatever the two happen to render.
|
|
111
|
+
expect(formatPercent(1.005, 2, 'en-US')).toBe(
|
|
112
|
+
formatMeasure(1.005, '0.00%', undefined, 'whole', 'en-US'),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|