@object-ui/fields 17.3.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 +941 -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 +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- 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 +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- 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__/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__/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__/widget-dom-leak-e2e.test.tsx +29 -270
- 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 +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- 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 +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -0,0 +1,305 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
});
|