@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,305 +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#4468 — every date branch threads the ACTIVE locale.
|
|
11
|
-
*
|
|
12
|
-
* ── The measured split ───────────────────────────────────────────────────
|
|
13
|
-
* On a `zh` console the same task row rendered `逾期 6 天` in one column and
|
|
14
|
-
* `In 3 days` in the next, and the datetime column read `8/11/2026 12:00 am`.
|
|
15
|
-
* Not a missing translation: the field labels around it were fully Chinese.
|
|
16
|
-
*
|
|
17
|
-
* The cause is that the date renderers had TWO locale channels and only one
|
|
18
|
-
* of them followed the user's language:
|
|
19
|
-
*
|
|
20
|
-
* - the OVERDUE phrase resolves through `useFieldTranslate()` → the ACTIVE
|
|
21
|
-
* UI language → `逾期 6 天`;
|
|
22
|
-
* - every `Intl` branch (relative past, relative future, the >±7d absolute
|
|
23
|
-
* fallback) took its tag from `useLocalization().locale` — the TENANT's
|
|
24
|
-
* regional default (ADR-0053), which is `undefined` on any workspace that
|
|
25
|
-
* has not configured one. `Intl` with `undefined` reads the MACHINE's
|
|
26
|
-
* locale, so those branches rendered en-US next to the Chinese one;
|
|
27
|
-
* - `DateTimeCellRenderer` passed no tag at all, hence `8/11/2026 12:00 am`.
|
|
28
|
-
*
|
|
29
|
-
* The one resolver is `useDisplayLocale()` (tenant locale → active UI
|
|
30
|
-
* language → `'en'`), which `@object-ui/i18n` already owns and whose own
|
|
31
|
-
* docstring claimed `DateCellRenderer` "already formats from this channel" —
|
|
32
|
-
* it did not. `'zh'` is a valid BCP-47 tag, so there is no `'zh'`→`zh-CN`
|
|
33
|
-
* mapping anywhere: `useDisplayLocale()` IS the single mapping point.
|
|
34
|
-
*
|
|
35
|
-
* ── Directions ───────────────────────────────────────────────────────────
|
|
36
|
-
* Reverting the renderers to `origin/main` and keeping this file turns every
|
|
37
|
-
* `zh session` case RED and leaves every `en session` case GREEN. The `en`
|
|
38
|
-
* cases are green on BOTH sides on purpose — that is the must-not-change
|
|
39
|
-
* half: the English output has to be byte-identical after the change, since
|
|
40
|
-
* `'en'` and the CI machine's `en-US` agree on all of these forms.
|
|
41
|
-
*
|
|
42
|
-
* `已逾期`/`Overdue Nd` is likewise green on both sides: it never used the
|
|
43
|
-
* broken channel, and this file pins that the fix did not disturb it.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
import { describe, it, expect, afterEach } from 'vitest';
|
|
47
|
-
import React from 'react';
|
|
48
|
-
import { render, screen, cleanup } from '@testing-library/react';
|
|
49
|
-
import '@testing-library/jest-dom';
|
|
50
|
-
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
|
|
51
|
-
import { DateCellRenderer, DateTimeCellRenderer } from '../index';
|
|
52
|
-
import { DateField } from '../widgets/DateField';
|
|
53
|
-
import { DateTimeField } from '../widgets/DateTimeField';
|
|
54
|
-
import { FormulaField } from '../widgets/FormulaField';
|
|
55
|
-
import { GridField } from '../widgets/GridField';
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* `n` days from today, pinned to local noon so a run that starts near
|
|
59
|
-
* midnight can never land the value on the neighbouring day.
|
|
60
|
-
*/
|
|
61
|
-
function daysFromNow(n: number): string {
|
|
62
|
-
const d = new Date();
|
|
63
|
-
d.setDate(d.getDate() + n);
|
|
64
|
-
d.setHours(12, 0, 0, 0);
|
|
65
|
-
return d.toISOString();
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* A fixed instant, built from LOCAL parts so the rendered day/time is the
|
|
70
|
-
* same in every timezone the suite might run in. This is the exact value
|
|
71
|
-
* shape the card captured: `8/11/2026 12:00 am` on an en machine.
|
|
72
|
-
*/
|
|
73
|
-
const FIXED_INSTANT = new Date(2026, 7, 11, 0, 0, 0).toISOString();
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* A session: the UI language the user picked, plus the tenant's regional
|
|
77
|
-
* default (usually absent — the state the card was measured in).
|
|
78
|
-
*
|
|
79
|
-
* `persistLanguage={false}` keeps each case on its own language instead of
|
|
80
|
-
* inheriting whatever the previous one wrote to `localStorage`.
|
|
81
|
-
*/
|
|
82
|
-
function renderSession(
|
|
83
|
-
language: string,
|
|
84
|
-
node: React.ReactNode,
|
|
85
|
-
tenantLocale?: string,
|
|
86
|
-
) {
|
|
87
|
-
return render(
|
|
88
|
-
<I18nProvider
|
|
89
|
-
config={{ defaultLanguage: language, detectBrowserLanguage: false }}
|
|
90
|
-
persistLanguage={false}
|
|
91
|
-
>
|
|
92
|
-
<LocalizationProvider value={{ locale: tenantLocale }}>{node}</LocalizationProvider>
|
|
93
|
-
</I18nProvider>,
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const dateField = (name: string) => ({ type: 'date', name }) as any;
|
|
98
|
-
|
|
99
|
-
afterEach(() => cleanup());
|
|
100
|
-
|
|
101
|
-
describe('zh session — every date branch renders Chinese (objectui#4468)', () => {
|
|
102
|
-
it('future-relative: the branch the card caught as "In 3 days"', () => {
|
|
103
|
-
renderSession('zh', <DateCellRenderer value={daysFromNow(3)} field={dateField('end_date')} />);
|
|
104
|
-
expect(screen.getByText('3天后')).toBeInTheDocument();
|
|
105
|
-
expect(screen.queryByText('In 3 days')).not.toBeInTheDocument();
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('past-relative, non-due: the branch the card caught as "6 days ago"', () => {
|
|
109
|
-
renderSession('zh', <DateCellRenderer value={daysFromNow(-6)} field={dateField('start_date')} />);
|
|
110
|
-
expect(screen.getByText('6天前')).toBeInTheDocument();
|
|
111
|
-
expect(screen.queryByText('6 days ago')).not.toBeInTheDocument();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('near-today: today / tomorrow / yesterday', () => {
|
|
115
|
-
renderSession('zh', <DateCellRenderer value={daysFromNow(0)} field={dateField('start_date')} />);
|
|
116
|
-
expect(screen.getByText('今天')).toBeInTheDocument();
|
|
117
|
-
cleanup();
|
|
118
|
-
|
|
119
|
-
renderSession('zh', <DateCellRenderer value={daysFromNow(1)} field={dateField('start_date')} />);
|
|
120
|
-
expect(screen.getByText('明天')).toBeInTheDocument();
|
|
121
|
-
cleanup();
|
|
122
|
-
|
|
123
|
-
renderSession('zh', <DateCellRenderer value={daysFromNow(-1)} field={dateField('start_date')} />);
|
|
124
|
-
expect(screen.getByText('昨天')).toBeInTheDocument();
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('absolute fallback beyond the ±7-day window', () => {
|
|
128
|
-
const value = daysFromNow(30);
|
|
129
|
-
// The formatter drops the year when it is the current one, so the
|
|
130
|
-
// expectation is built the same way rather than hardcoding a month that
|
|
131
|
-
// depends on the day this suite runs.
|
|
132
|
-
const d = new Date(value);
|
|
133
|
-
const sameYear = d.getFullYear() === new Date().getFullYear();
|
|
134
|
-
const expected = d.toLocaleDateString('zh', {
|
|
135
|
-
year: sameYear ? undefined : 'numeric',
|
|
136
|
-
month: 'short',
|
|
137
|
-
day: 'numeric',
|
|
138
|
-
});
|
|
139
|
-
const enForm = d.toLocaleDateString('en', {
|
|
140
|
-
year: sameYear ? undefined : 'numeric',
|
|
141
|
-
month: 'short',
|
|
142
|
-
day: 'numeric',
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
renderSession('zh', <DateCellRenderer value={value} field={dateField('start_date')} />);
|
|
146
|
-
expect(screen.getByText(expected)).toBeInTheDocument();
|
|
147
|
-
expect(screen.queryByText(enForm)).not.toBeInTheDocument();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('absolute datetime: the branch the card caught as "8/11/2026 12:00 am"', () => {
|
|
151
|
-
const { container } = renderSession(
|
|
152
|
-
'zh',
|
|
153
|
-
<DateTimeCellRenderer value={FIXED_INSTANT} field={{ type: 'datetime', name: 'created_at' } as any} />,
|
|
154
|
-
);
|
|
155
|
-
expect(container.textContent).toContain('2026/8/11');
|
|
156
|
-
expect(container.textContent).toContain('上午12:00');
|
|
157
|
-
expect(container.textContent).not.toContain('8/11/2026');
|
|
158
|
-
expect(container.textContent).not.toContain('12:00 am');
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('read-only DateField / DateTimeField widgets', () => {
|
|
162
|
-
const { container } = renderSession(
|
|
163
|
-
'zh',
|
|
164
|
-
<DateField value={FIXED_INSTANT} onChange={() => {}} field={dateField('start_date')} readonly />,
|
|
165
|
-
);
|
|
166
|
-
expect(container.textContent).toContain('2026/8/11');
|
|
167
|
-
cleanup();
|
|
168
|
-
|
|
169
|
-
const dt = renderSession(
|
|
170
|
-
'zh',
|
|
171
|
-
<DateTimeField
|
|
172
|
-
value={FIXED_INSTANT}
|
|
173
|
-
onChange={() => {}}
|
|
174
|
-
field={{ type: 'datetime', name: 'created_at' } as any}
|
|
175
|
-
readonly
|
|
176
|
-
/>,
|
|
177
|
-
);
|
|
178
|
-
expect(dt.container.textContent).toContain('2026/8/11');
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* The sub-grid (`GridField`) read-only face. Its cases live here rather than
|
|
183
|
-
* beside the rest of `widgets/GridField.test.tsx`: mounting an `I18nProvider`
|
|
184
|
-
* in that file changes what its later PROVIDER-LESS renders resolve, and one
|
|
185
|
-
* of them asserts a translated `aria-label`.
|
|
186
|
-
*/
|
|
187
|
-
it('the sub-grid read-only table', () => {
|
|
188
|
-
const temporalField = {
|
|
189
|
-
columns: [
|
|
190
|
-
{ field: 'merchant', label: 'Merchant', type: 'text' as const },
|
|
191
|
-
{ field: 'incurred_on', label: 'Incurred On', type: 'date' as const },
|
|
192
|
-
],
|
|
193
|
-
} as any;
|
|
194
|
-
renderSession(
|
|
195
|
-
'zh',
|
|
196
|
-
<GridField
|
|
197
|
-
value={[{ merchant: 'Chipotle', incurred_on: '2026-06-17T00:00:00.000Z' }]}
|
|
198
|
-
onChange={() => {}}
|
|
199
|
-
field={temporalField}
|
|
200
|
-
readonly
|
|
201
|
-
/>,
|
|
202
|
-
);
|
|
203
|
-
const table = screen.getByTestId('line-items-readonly');
|
|
204
|
-
expect(table.textContent).toContain('2026/6/17');
|
|
205
|
-
expect(table.textContent).not.toContain('6/17/2026');
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('a formula field returning a date', () => {
|
|
209
|
-
const { container } = renderSession(
|
|
210
|
-
'zh',
|
|
211
|
-
<FormulaField
|
|
212
|
-
value={FIXED_INSTANT}
|
|
213
|
-
onChange={() => {}}
|
|
214
|
-
field={{ type: 'formula', name: 'computed_on', return_type: 'date' } as any}
|
|
215
|
-
/>,
|
|
216
|
-
);
|
|
217
|
-
expect(container.textContent).toContain('2026/8/11');
|
|
218
|
-
expect(container.textContent).not.toContain('8/11/2026');
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
describe('en session — output is byte-identical (must-not-change)', () => {
|
|
223
|
-
it('future-relative', () => {
|
|
224
|
-
renderSession('en', <DateCellRenderer value={daysFromNow(3)} field={dateField('end_date')} />);
|
|
225
|
-
expect(screen.getByText('In 3 days')).toBeInTheDocument();
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('past-relative, non-due', () => {
|
|
229
|
-
renderSession('en', <DateCellRenderer value={daysFromNow(-6)} field={dateField('start_date')} />);
|
|
230
|
-
expect(screen.getByText('6 days ago')).toBeInTheDocument();
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('near-today', () => {
|
|
234
|
-
renderSession('en', <DateCellRenderer value={daysFromNow(0)} field={dateField('start_date')} />);
|
|
235
|
-
expect(screen.getByText('Today')).toBeInTheDocument();
|
|
236
|
-
cleanup();
|
|
237
|
-
|
|
238
|
-
renderSession('en', <DateCellRenderer value={daysFromNow(1)} field={dateField('start_date')} />);
|
|
239
|
-
expect(screen.getByText('Tomorrow')).toBeInTheDocument();
|
|
240
|
-
cleanup();
|
|
241
|
-
|
|
242
|
-
renderSession('en', <DateCellRenderer value={daysFromNow(-1)} field={dateField('start_date')} />);
|
|
243
|
-
expect(screen.getByText('Yesterday')).toBeInTheDocument();
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('absolute datetime', () => {
|
|
247
|
-
const { container } = renderSession(
|
|
248
|
-
'en',
|
|
249
|
-
<DateTimeCellRenderer value={FIXED_INSTANT} field={{ type: 'datetime', name: 'created_at' } as any} />,
|
|
250
|
-
);
|
|
251
|
-
expect(container.textContent).toContain('8/11/2026');
|
|
252
|
-
expect(container.textContent).toContain('12:00 am');
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('read-only DateField', () => {
|
|
256
|
-
const { container } = renderSession(
|
|
257
|
-
'en',
|
|
258
|
-
<DateField value={FIXED_INSTANT} onChange={() => {}} field={dateField('start_date')} readonly />,
|
|
259
|
-
);
|
|
260
|
-
expect(container.textContent).toContain('8/11/2026');
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
describe('the already-localized overdue branch is undisturbed (green both sides)', () => {
|
|
265
|
-
it('resolves through the translate fn, not through Intl — zh', () => {
|
|
266
|
-
renderSession('zh', <DateCellRenderer value={daysFromNow(-6)} field={dateField('due_date')} />);
|
|
267
|
-
const el = screen.getByText('逾期 6 天');
|
|
268
|
-
expect(el).toBeInTheDocument();
|
|
269
|
-
expect(el.className).toMatch(/text-red-600/);
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it('resolves through the translate fn, not through Intl — en', () => {
|
|
273
|
-
renderSession('en', <DateCellRenderer value={daysFromNow(-6)} field={dateField('due_date')} />);
|
|
274
|
-
expect(screen.getByText('Overdue 6d')).toBeInTheDocument();
|
|
275
|
-
});
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
describe('channel precedence is unchanged (green both sides)', () => {
|
|
279
|
-
/**
|
|
280
|
-
* `useDisplayLocale()` puts the TENANT locale above the UI language: an org
|
|
281
|
-
* that configured `en` means it, even for a user reading Chinese chrome.
|
|
282
|
-
* This is the one case the old code got right, and it must survive.
|
|
283
|
-
*/
|
|
284
|
-
it('an explicit tenant locale outranks the active UI language', () => {
|
|
285
|
-
renderSession('zh', <DateCellRenderer value={daysFromNow(3)} field={dateField('end_date')} />, 'en');
|
|
286
|
-
expect(screen.getByText('In 3 days')).toBeInTheDocument();
|
|
287
|
-
expect(screen.queryByText('3天后')).not.toBeInTheDocument();
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* The third step of the precedence — the provider-less `'en'` last resort —
|
|
292
|
-
* is pinned in `DateCellRenderer.test.tsx`, deliberately NOT here.
|
|
293
|
-
*
|
|
294
|
-
* It cannot be measured in this file: `useObjectTranslation()` outside a
|
|
295
|
-
* provider reports `i18n.language` from react-i18next's GLOBAL instance, and
|
|
296
|
-
* every `I18nProvider` mounted above leaves that global on the language it
|
|
297
|
-
* was given. So a provider-less render placed after these cases resolves
|
|
298
|
-
* `'zh'` — which is the state react-i18next is in, not the fallback under
|
|
299
|
-
* test. Written here it would assert a fact about test ordering.
|
|
300
|
-
*
|
|
301
|
-
* `DateCellRenderer.test.tsx` mounts no provider at all, so its
|
|
302
|
-
* `6 days ago` / `Overdue 6d` / `Tomorrow` cases ARE that pin, and they hold
|
|
303
|
-
* for the real reason.
|
|
304
|
-
*/
|
|
305
|
-
});
|
|
@@ -1,169 +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
|
-
* The SDUI path delivers field metadata on ONE key — objectui#3233.
|
|
11
|
-
*
|
|
12
|
-
* `SchemaRenderer` hands every registered component the authored node as
|
|
13
|
-
* `schema`. That key is the universal SDUI contract (`element:*`, `page:*`,
|
|
14
|
-
* `object-grid`, `ReportRenderer` …) and is NOT retired. What was retired is
|
|
15
|
-
* `schema` on the *field-widget* contract: a widget used to receive the node
|
|
16
|
-
* under `schema` here and the metadata under `field` from the form renderer,
|
|
17
|
-
* which is why ~30 widgets resolved their config as `field || schema`.
|
|
18
|
-
*
|
|
19
|
-
* The translation now happens once, at the registration seam
|
|
20
|
-
* (`withFieldCarrier`), so downstream `field` is the only carrier. That is only
|
|
21
|
-
* correct if the node reaches the widget UNCHANGED — "swap the key, don't drop
|
|
22
|
-
* the cargo". The first test proves it by OBJECT IDENTITY: a spy sits where the
|
|
23
|
-
* registry entry sits, records the object `SchemaRenderer` passed as `schema`,
|
|
24
|
-
* and the widget's `field` must be that exact object. (Identity against the
|
|
25
|
-
* authored literal would be wrong to assert — `SchemaRenderer` shallow-copies
|
|
26
|
-
* the node while evaluating expressions, so the object it delivers is its own.)
|
|
27
|
-
*
|
|
28
|
-
* The last test pins that the REAL registration path goes through the adapter
|
|
29
|
-
* at all: an adapter nobody applied would leave every SDUI-hosted widget
|
|
30
|
-
* reading `undefined`, which is exactly the silent failure this convergence is
|
|
31
|
-
* accused of risking.
|
|
32
|
-
*
|
|
33
|
-
* The sibling half of the invariant — the form path through
|
|
34
|
-
* `renderFieldComponent` — is pinned in `packages/components/src/renderers/
|
|
35
|
-
* form/__tests__/form-field-carrier.test.tsx`.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import React, { Suspense } from 'react';
|
|
39
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
40
|
-
import { render, screen, cleanup } from '@testing-library/react';
|
|
41
|
-
import { ComponentRegistry } from '@object-ui/core';
|
|
42
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
43
|
-
|
|
44
|
-
import { withFieldCarrier } from '../withFieldCarrier';
|
|
45
|
-
import { registerField } from '../index';
|
|
46
|
-
// Module scope, not a hook: `registerField('boolean')` registers
|
|
47
|
-
// `React.lazy(() => import('./widgets/BooleanField'))`, and under a saturated
|
|
48
|
-
// transform pipeline a first `import()` can outlive RTL's 1000ms budget. The
|
|
49
|
-
// specifier matches `fieldWidgetMap`'s exactly, so ESM's cache makes that
|
|
50
|
-
// factory resolve immediately (AGENTS.md 测试纪律 / objectui#3010).
|
|
51
|
-
import '../widgets/BooleanField';
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Capture holder, not bare module variables: `react-hooks/globals` forbids
|
|
55
|
-
* REASSIGNING an outer binding from a component body, which is what a probe
|
|
56
|
-
* has to do. Mutating a property of a stable object is the repo's usual shape
|
|
57
|
-
* for this (see plugin-detail's `RelatedList` probes).
|
|
58
|
-
*/
|
|
59
|
-
const captured: { props: Record<string, any> | null; node: unknown } = {
|
|
60
|
-
props: null,
|
|
61
|
-
node: null,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
function CarrierProbe(props: any) {
|
|
65
|
-
captured.props = props;
|
|
66
|
-
return <div data-testid="carrier-probe">{props.field?.label ?? 'NO FIELD'}</div>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const AdaptedProbe = withFieldCarrier(CarrierProbe);
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Sits exactly where the registry entry sits, so it sees the host's props
|
|
73
|
-
* verbatim, then forwards them to the adapter under test. This is what makes
|
|
74
|
-
* the identity assertion possible: `captured.node` and the widget's `field`
|
|
75
|
-
* are observed within a single render pass.
|
|
76
|
-
*/
|
|
77
|
-
function CarrierSpy(props: any) {
|
|
78
|
-
captured.node = props.schema;
|
|
79
|
-
return <AdaptedProbe {...props} />;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
beforeEach(() => {
|
|
83
|
-
captured.props = null;
|
|
84
|
-
captured.node = null;
|
|
85
|
-
ComponentRegistry.register('carrierprobe', CarrierSpy, {
|
|
86
|
-
namespace: 'field',
|
|
87
|
-
skipFallback: true,
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
afterEach(() => {
|
|
92
|
-
cleanup();
|
|
93
|
-
vi.restoreAllMocks();
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('SDUI path — the authored node arrives as `field` (objectui#3233)', () => {
|
|
97
|
-
it('hands the widget the very object it used to read through `schema`', () => {
|
|
98
|
-
const node = {
|
|
99
|
-
type: 'field:carrierprobe',
|
|
100
|
-
name: 'city',
|
|
101
|
-
label: 'City',
|
|
102
|
-
placeholder: 'Where?',
|
|
103
|
-
options: [{ label: 'Beijing', value: 'bj' }],
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
render(<SchemaRenderer schema={node as any} />);
|
|
107
|
-
|
|
108
|
-
expect(screen.getByTestId('carrier-probe')).toHaveTextContent('City');
|
|
109
|
-
expect(captured.props).not.toBeNull();
|
|
110
|
-
expect(captured.node).toBeTruthy();
|
|
111
|
-
// The payload-equivalence proof: same object, by reference. A
|
|
112
|
-
// reconstruction would still render "City" while quietly dropping whatever
|
|
113
|
-
// the copy did not know to carry.
|
|
114
|
-
expect(captured.props!.field).toBe(captured.node);
|
|
115
|
-
// …and it really is the whole authored node, not a narrowed projection.
|
|
116
|
-
expect(captured.props!.field.name).toBe('city');
|
|
117
|
-
expect(captured.props!.field.placeholder).toBe('Where?');
|
|
118
|
-
expect(captured.props!.field.options).toEqual([{ label: 'Beijing', value: 'bj' }]);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('consumes `schema` rather than forwarding it to the widget', () => {
|
|
122
|
-
// Key ABSENCE, not `undefined`. Two reasons it must not be forwarded: a
|
|
123
|
-
// widget could resolve `field || schema` again (the second contract
|
|
124
|
-
// survives in practice while the type claims it is gone), and widgets
|
|
125
|
-
// spread their leftover props onto DOM nodes, where an object-valued
|
|
126
|
-
// `schema` attribute is a React warning.
|
|
127
|
-
render(
|
|
128
|
-
<SchemaRenderer schema={{ type: 'field:carrierprobe', name: 'city', label: 'City' } as any} />,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
expect(captured.props).not.toBeNull();
|
|
132
|
-
expect('schema' in captured.props!).toBe(false);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('lets an explicit `field` win when a host supplies both', () => {
|
|
136
|
-
// The form renderer passes `field` (resolved metadata) and never `schema`.
|
|
137
|
-
// If some host ever passed both, the resolved metadata is the better
|
|
138
|
-
// answer — the raw node is the fallback, not the override.
|
|
139
|
-
const node = { type: 'field:carrierprobe', name: 'from-node', label: 'From node' };
|
|
140
|
-
const metadata = { name: 'from-field', label: 'From field' };
|
|
141
|
-
|
|
142
|
-
render(<AdaptedProbe schema={node} field={metadata} />);
|
|
143
|
-
|
|
144
|
-
expect(captured.props!.field).toBe(metadata);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('applies the adapter on the REAL registration path (`registerField`)', async () => {
|
|
148
|
-
// The seam is only worth anything if `registerField` goes through it.
|
|
149
|
-
// `BooleanField` reads `widget` / `name` off its metadata carrier and
|
|
150
|
-
// nowhere else: `widget: 'checkbox'` selects the Checkbox over the Switch,
|
|
151
|
-
// and `name` becomes the control's id. Both are node keys, so seeing them
|
|
152
|
-
// honoured here proves the node reached `field` end to end — through the
|
|
153
|
-
// real registry entry, the real SchemaRenderer, and the real widget.
|
|
154
|
-
registerField('boolean');
|
|
155
|
-
|
|
156
|
-
render(
|
|
157
|
-
<Suspense fallback={<div>loading</div>}>
|
|
158
|
-
<SchemaRenderer
|
|
159
|
-
schema={
|
|
160
|
-
{ type: 'field:boolean', name: 'is_active', label: 'Active', widget: 'checkbox' } as any
|
|
161
|
-
}
|
|
162
|
-
/>
|
|
163
|
-
</Suspense>,
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
const control = await screen.findByRole('checkbox');
|
|
167
|
-
expect(control).toHaveAttribute('id', 'is_active');
|
|
168
|
-
});
|
|
169
|
-
});
|
|
@@ -1,158 +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#4332 — `formatCurrency` dropped a real cents digit.
|
|
11
|
-
*
|
|
12
|
-
* The symbol branch passed `minimumFractionDigits: 0` against a
|
|
13
|
-
* wholeness-switched `maximumFractionDigits`, handing Intl the RANGE `[0, 2]`.
|
|
14
|
-
* Intl then emits the shortest representation in range, so a genuine cents
|
|
15
|
-
* value of `.50` printed as `.5`: `$1,234.5`, `$19.9`, `$0.5` — money on a
|
|
16
|
-
* record page reading as a data error rather than a formatting one.
|
|
17
|
-
*
|
|
18
|
-
* The fix is one width for both bounds (`isWhole ? 0 : 2`), which also makes
|
|
19
|
-
* the function's three branches agree: the no-currency branch already routed
|
|
20
|
-
* through `formatNumber` (both bounds = the width), and the bad-currency
|
|
21
|
-
* fallback already used `toFixed`. Only the symbol branch disagreed.
|
|
22
|
-
*
|
|
23
|
-
* What is pinned here, and why each half matters:
|
|
24
|
-
*
|
|
25
|
-
* 1. **Fractional amounts keep both cents digits** — the defect.
|
|
26
|
-
* 2. **Whole amounts still drop `.00`** — the OTHER half of the wholeness
|
|
27
|
-
* switch, which the doc comment promises just as explicitly ("Salesforce
|
|
28
|
-
* convention: `$1,234.50` keeps cents; `$1,234` does not") and which
|
|
29
|
-
* objectui#4033 independently pinned through the renderer. Widening the
|
|
30
|
-
* minimum to a constant 2 would have "fixed" this card by breaking that
|
|
31
|
-
* one, so it is a control, not a decoration.
|
|
32
|
-
* 3. **The branches that were already correct are unchanged** — no-currency,
|
|
33
|
-
* bad-currency fallback, and `formatCompactCurrency` (a different function
|
|
34
|
-
* with its own `maximumFractionDigits: 1` compact policy).
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import { describe, it, expect } from 'vitest';
|
|
38
|
-
import React from 'react';
|
|
39
|
-
import { render, screen } from '@testing-library/react';
|
|
40
|
-
import '@testing-library/jest-dom';
|
|
41
|
-
import { LocalizationProvider } from '@object-ui/i18n';
|
|
42
|
-
import {
|
|
43
|
-
formatCurrency,
|
|
44
|
-
formatCompactCurrency,
|
|
45
|
-
formatNumber,
|
|
46
|
-
CurrencyCellRenderer,
|
|
47
|
-
} from '../index';
|
|
48
|
-
|
|
49
|
-
describe('formatCurrency — a fractional amount keeps both cents digits (objectui#4332)', () => {
|
|
50
|
-
it("renders the card's fixture as the doc comment promises", () => {
|
|
51
|
-
expect(formatCurrency(1234.5, 'USD', 'en-US')).toBe('$1,234.50');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it.each([
|
|
55
|
-
[19.9, '$19.90'],
|
|
56
|
-
[0.5, '$0.50'],
|
|
57
|
-
[1234.5, '$1,234.50'],
|
|
58
|
-
])('an amount ending in a zero cent digit keeps it: %s', (value, expected) => {
|
|
59
|
-
// The issue's impact list — every price whose cents end in 0 was truncated
|
|
60
|
-
// by one digit (`$19.9`, `$0.5`).
|
|
61
|
-
expect(formatCurrency(value as number, 'USD', 'en-US')).toBe(expected as string);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('keeps the sign in front of a truncated-then-restored amount', () => {
|
|
65
|
-
expect(formatCurrency(-1234.5, 'USD', 'en-US')).toBe('-$1,234.50');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('a fractional amount that ROUNDS to zero cents still shows them', () => {
|
|
69
|
-
// Sub-cent precision: `1234.001` is not whole, so it keeps cents and
|
|
70
|
-
// rounds into them. Before the fix the `[0, 2]` range rounded to `.00` and
|
|
71
|
-
// then trimmed it away, printing a bare `$1,234` for a value that is NOT
|
|
72
|
-
// the whole number that spelling claims. Deliberate: wholeness — not the
|
|
73
|
-
// rounded output — decides whether cents are shown.
|
|
74
|
-
expect(formatCurrency(1234.001, 'USD', 'en-US')).toBe('$1,234.00');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('still caps at two digits (the maximum is unchanged)', () => {
|
|
78
|
-
expect(formatCurrency(1234.567, 'USD', 'en-US')).toBe('$1,234.57');
|
|
79
|
-
expect(formatCurrency(1234.56, 'USD', 'en-US')).toBe('$1,234.56');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('follows the active locale while doing it (objectui#4033 is not disturbed)', () => {
|
|
83
|
-
// de-DE: dot grouping, comma decimal mark, TRAILING symbol separated by
|
|
84
|
-
// U+00A0. Matched loosely on the separator (written as an escape, never a
|
|
85
|
-
// raw byte) so an ICU spacing change cannot fail the digits this pins.
|
|
86
|
-
expect(formatCurrency(1234.5, 'EUR', 'de-DE')).toMatch(/^1\.234,50\s*€$/);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe('formatCurrency — CONTROL: the whole-number half of the switch is untouched', () => {
|
|
91
|
-
it.each([
|
|
92
|
-
[1234, '$1,234'],
|
|
93
|
-
[5000000, '$5,000,000'],
|
|
94
|
-
[0, '$0'],
|
|
95
|
-
])('a whole amount still drops `.00`: %s', (value, expected) => {
|
|
96
|
-
// The doc comment promises this as explicitly as it promises the cents,
|
|
97
|
-
// and objectui#4033 pinned it through `CurrencyCellRenderer`. A constant
|
|
98
|
-
// `minimumFractionDigits: 2` would turn all three of these into `.00`.
|
|
99
|
-
expect(formatCurrency(value as number, 'USD', 'en-US')).toBe(expected as string);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('a non-finite amount is unaffected (it is not "whole")', () => {
|
|
103
|
-
expect(formatCurrency(Number.NaN, 'USD', 'en-US')).toBe('$NaN');
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe('formatCurrency — CONTROL: the branches that were already correct', () => {
|
|
108
|
-
it('the no-currency branch is unchanged — it never had the defect', () => {
|
|
109
|
-
// It routes through `formatNumber`, which sets BOTH bounds to the width it
|
|
110
|
-
// is given, so it already rendered `1,234.50` while the symbol branch
|
|
111
|
-
// rendered `$1,234.5`. That disagreement is what the fix removes.
|
|
112
|
-
expect(formatCurrency(1234.5, undefined, 'en-US')).toBe('1,234.50');
|
|
113
|
-
expect(formatCurrency(1234.5, undefined, 'en-US')).toBe(formatNumber(1234.5, 2, 'en-US'));
|
|
114
|
-
// A whole amount with no currency is still money and keeps its separators
|
|
115
|
-
// (objectui#4033: `formatNumber` passes no `scale`, so the ordinal
|
|
116
|
-
// no-grouping policy cannot fire here).
|
|
117
|
-
expect(formatCurrency(5000000, undefined, 'en-US')).toBe('5,000,000');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('the bad-currency fallback is unchanged — it always used toFixed', () => {
|
|
121
|
-
// `Intl` throws `RangeError: Invalid currency code` for a non-ISO-4217
|
|
122
|
-
// code, and `formatDisplayNumber`'s locale retry re-throws it rather than
|
|
123
|
-
// swallowing it, so this lands in `formatCurrency`'s own catch.
|
|
124
|
-
expect(formatCurrency(1234.5, 'NOT_A_CODE', 'en-US')).toBe('NOT_A_CODE 1234.50');
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('formatCompactCurrency is a different function and keeps its own policy', () => {
|
|
128
|
-
// Compact notation caps at ONE fraction digit and strips a trailing `.0`;
|
|
129
|
-
// it never had the `[0, max]` range, and this card does not touch it.
|
|
130
|
-
expect(formatCompactCurrency(1234.5, 'USD', 'en-US')).toBe('$1.2K');
|
|
131
|
-
expect(formatCompactCurrency(150000, 'USD', 'en-US')).toBe('$150K');
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('CurrencyCellRenderer — the user-visible surface (objectui#4332)', () => {
|
|
136
|
-
const renderCurrency = (value: unknown, field: Record<string, unknown>, locale?: string) =>
|
|
137
|
-
render(
|
|
138
|
-
<LocalizationProvider value={{ locale }}>
|
|
139
|
-
<CurrencyCellRenderer value={value as any} field={{ type: 'currency', ...field } as any} />
|
|
140
|
-
</LocalizationProvider>,
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
it('a grid cell shows the cents digit the amount actually has', () => {
|
|
144
|
-
renderCurrency(1234.5, { currency: 'USD' }, 'en-US');
|
|
145
|
-
expect(screen.getByText('$1,234.50')).toBeInTheDocument();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('CONTROL: a declared `scale` is inert on the currency cell path', () => {
|
|
149
|
-
// Measured, not assumed: `CurrencyCellRenderer` reads `currency` and never
|
|
150
|
-
// `scale`, and `formatCurrency` takes no scale argument — on this path
|
|
151
|
-
// `scale` is neither a display width nor (money always groups) a grouping
|
|
152
|
-
// input. So a currency field declaring `scale: 0` renders exactly like one
|
|
153
|
-
// declaring nothing, before and after this card. Pinned so that a future
|
|
154
|
-
// change making `scale` load-bearing here has to say so out loud.
|
|
155
|
-
renderCurrency(1234.5, { currency: 'USD', scale: 0 }, 'en-US');
|
|
156
|
-
expect(screen.getByText('$1,234.50')).toBeInTheDocument();
|
|
157
|
-
});
|
|
158
|
-
});
|