@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,191 +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#4361, second path — `CurrencyField`'s `precision`.
|
|
11
|
-
*
|
|
12
|
-
* `formatAmount` passes the field's `precision` to BOTH `Intl` bounds, and the
|
|
13
|
-
* widget defaulted that precision to a literal `2`. So a JPY field that
|
|
14
|
-
* declared no precision rendered `¥1,234.50` for the same reason
|
|
15
|
-
* `formatCurrency` did, one layer up.
|
|
16
|
-
*
|
|
17
|
-
* The PM ruling on this card (issue #4361, claim comment) splits it in two,
|
|
18
|
-
* and both halves are pinned here:
|
|
19
|
-
*
|
|
20
|
-
* 1. **An explicitly authored `precision` WINS.** It is authored metadata and
|
|
21
|
-
* this repo's convention is that authored metadata keeps priority. A JPY
|
|
22
|
-
* field declaring `precision: 2` still renders `¥1,234.50` — whether that
|
|
23
|
-
* combination should be REJECTED at publish time is a spec question filed
|
|
24
|
-
* upstream (contract-first), not something the renderer decides by
|
|
25
|
-
* overriding the author.
|
|
26
|
-
* 2. **An ABSENT `precision` derives from the currency** instead of falling
|
|
27
|
-
* back to 2.
|
|
28
|
-
*
|
|
29
|
-
* MEASURED, per the ruling's stop condition — is "absent" distinguishable from
|
|
30
|
-
* "authored 2" by the time the renderer sees it? Yes:
|
|
31
|
-
*
|
|
32
|
-
* - `CurrencyFieldMetadata.precision` is `precision?: number` in
|
|
33
|
-
* `packages/types/src/field-types.ts` — optional, no default;
|
|
34
|
-
* - the field-level key is `z.ZodOptional<z.ZodNumber>` in
|
|
35
|
-
* `@objectstack/spec` (no `.default()`), so a parsed field metadata object
|
|
36
|
-
* does NOT carry a materialized 2;
|
|
37
|
-
* - the only `.default(2)` in the spec's currency surface is on
|
|
38
|
-
* `CurrencyConfigSchema.precision` — a DIFFERENT key on a different object
|
|
39
|
-
* (the `currencyConfig` block), which this widget never reads; and
|
|
40
|
-
* - the `?? 2` itself lived in the widget, at `CurrencyField.tsx`.
|
|
41
|
-
*
|
|
42
|
-
* So the renderer holds an `undefined` it can act on, and this half lands.
|
|
43
|
-
*
|
|
44
|
-
* The derived precision is the widget's ONE precision, so it reaches the edit
|
|
45
|
-
* affordances too (`step`, and the blur rounding) — pinned below. Leaving
|
|
46
|
-
* those at 2 would have produced a JPY field that displays whole yen but
|
|
47
|
-
* offers a 0.01 spinner step and rounds typed input to 1234.56 yen.
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
51
|
-
import React from 'react';
|
|
52
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
53
|
-
import '@testing-library/jest-dom';
|
|
54
|
-
import { LocalizationProvider } from '@object-ui/i18n';
|
|
55
|
-
import { CurrencyField } from '../widgets/CurrencyField';
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* ICU separates a currency CODE from the amount with U+00A0 (`KWD` + NBSP +
|
|
59
|
-
* `1.500`), while a SYMBOL is flush against it (`¥1,235`). `textContent` is
|
|
60
|
-
* raw, so normalizing keeps these pins about the DIGIT COUNT this card is
|
|
61
|
-
* about rather than about ICU's spacing. Written as an escape, never a pasted
|
|
62
|
-
* byte — a raw NBSP in source is invisible to readers and unfindable by grep.
|
|
63
|
-
*/
|
|
64
|
-
const normalizeNbsp = (s: string | null) => (s ?? '').replace(/\u00a0/g, ' ');
|
|
65
|
-
|
|
66
|
-
const renderField = (
|
|
67
|
-
value: number | null,
|
|
68
|
-
field: Record<string, unknown>,
|
|
69
|
-
opts: { readonly?: boolean; locale?: string; tenantCurrency?: string; onChange?: (v: any) => void } = {},
|
|
70
|
-
) =>
|
|
71
|
-
render(
|
|
72
|
-
<LocalizationProvider value={{ locale: opts.locale ?? 'en-US', currency: opts.tenantCurrency }}>
|
|
73
|
-
<CurrencyField
|
|
74
|
-
value={value as any}
|
|
75
|
-
onChange={opts.onChange ?? vi.fn()}
|
|
76
|
-
field={{ type: 'currency', ...field } as any}
|
|
77
|
-
readonly={opts.readonly}
|
|
78
|
-
/>
|
|
79
|
-
</LocalizationProvider>,
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
describe('CurrencyField — an ABSENT precision derives from the currency (objectui#4361)', () => {
|
|
83
|
-
it('a JPY field that declares no precision shows no cents', () => {
|
|
84
|
-
const { container } = renderField(1234.5, { currency: 'JPY' }, { readonly: true });
|
|
85
|
-
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('a KWD field that declares no precision shows all three digits', () => {
|
|
89
|
-
const { container } = renderField(1.5, { currency: 'KWD' }, { readonly: true });
|
|
90
|
-
expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('the tenant default currency reaches the derivation too (ADR-0053)', () => {
|
|
94
|
-
const { container } = renderField(1234.5, {}, { readonly: true, tenantCurrency: 'JPY' });
|
|
95
|
-
expect(normalizeNbsp(container.textContent)).toBe('¥1,235');
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('`currencyConfig.defaultCurrency` reaches it as well', () => {
|
|
99
|
-
const { container } = renderField(
|
|
100
|
-
1.5,
|
|
101
|
-
{ currencyConfig: { defaultCurrency: 'KWD' } },
|
|
102
|
-
{ readonly: true },
|
|
103
|
-
);
|
|
104
|
-
expect(normalizeNbsp(container.textContent)).toBe('KWD 1.500');
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('CurrencyField — an AUTHORED precision still wins (authored metadata keeps priority)', () => {
|
|
109
|
-
it('a JPY field declaring `precision: 2` keeps its two digits', () => {
|
|
110
|
-
// The renderer does not overrule the author. Whether publish-time
|
|
111
|
-
// validation should reject this combination is the upstream spec question
|
|
112
|
-
// filed alongside this card — deliberately NOT decided here.
|
|
113
|
-
const { container } = renderField(1234.5, { currency: 'JPY', precision: 2 }, { readonly: true });
|
|
114
|
-
expect(normalizeNbsp(container.textContent)).toBe('¥1,234.50');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('an authored `precision: 0` on a 2-digit currency still wins', () => {
|
|
118
|
-
// The other direction, so the pin cannot be satisfied by "authored wins
|
|
119
|
-
// only when it asks for MORE digits".
|
|
120
|
-
const { container } = renderField(1234.5, { currency: 'USD', precision: 0 }, { readonly: true });
|
|
121
|
-
expect(normalizeNbsp(container.textContent)).toBe('$1,235');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('an authored `precision: 3` on JPY wins', () => {
|
|
125
|
-
const { container } = renderField(1234.5, { currency: 'JPY', precision: 3 }, { readonly: true });
|
|
126
|
-
expect(normalizeNbsp(container.textContent)).toBe('¥1,234.500');
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe('CurrencyField — CONTROL: the 2-digit and no-currency cases are unchanged', () => {
|
|
131
|
-
it('a USD field with no declared precision renders exactly as before', () => {
|
|
132
|
-
// Derivation and the old literal agree at 2, which is the whole point of
|
|
133
|
-
// "byte-identical for USD".
|
|
134
|
-
const { container } = renderField(1234.5, { currency: 'USD' }, { readonly: true });
|
|
135
|
-
expect(normalizeNbsp(container.textContent)).toBe('$1,234.50');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('a USD field declaring `precision: 2` renders exactly as before', () => {
|
|
139
|
-
const { container } = renderField(1234.56, { currency: 'USD', precision: 2 }, { readonly: true });
|
|
140
|
-
expect(normalizeNbsp(container.textContent)).toBe('$1,234.56');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('a field with NO currency keeps the literal 2 — nothing to derive from', () => {
|
|
144
|
-
const { container } = renderField(1234.5, {}, { readonly: true });
|
|
145
|
-
expect(normalizeNbsp(container.textContent)).toBe('1,234.50');
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('a null value is still the empty marker, not a formatted zero', () => {
|
|
149
|
-
const { container } = renderField(null, { currency: 'JPY' }, { readonly: true });
|
|
150
|
-
expect(container.textContent).not.toContain('¥');
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe('CurrencyField — the derived precision is the widget\'s ONE precision', () => {
|
|
155
|
-
it('a JPY field with no declared precision offers a whole-unit spinner step', () => {
|
|
156
|
-
renderField(1234, { currency: 'JPY' });
|
|
157
|
-
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('a KWD field with no declared precision offers a three-digit step', () => {
|
|
161
|
-
renderField(1, { currency: 'KWD' });
|
|
162
|
-
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.001');
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('CONTROL: USD is still 0.01, authored or derived', () => {
|
|
166
|
-
const { unmount } = renderField(1, { currency: 'USD' });
|
|
167
|
-
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
|
|
168
|
-
unmount();
|
|
169
|
-
renderField(1, { currency: 'JPY', precision: 2 });
|
|
170
|
-
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0.01');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('blur rounds a typed amount to the currency\'s own unit', () => {
|
|
174
|
-
const onChange = vi.fn();
|
|
175
|
-
renderField(null, { currency: 'JPY' }, { onChange });
|
|
176
|
-
const input = screen.getByRole('spinbutton');
|
|
177
|
-
fireEvent.change(input, { target: { value: '1234.56' } });
|
|
178
|
-
fireEvent.blur(input, { target: { value: '1234.56' } });
|
|
179
|
-
// Before: 1234.56 yen — a sub-unit amount the currency cannot express.
|
|
180
|
-
expect(onChange).toHaveBeenLastCalledWith(1235);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('CONTROL: blur on a USD field still rounds to cents', () => {
|
|
184
|
-
const onChange = vi.fn();
|
|
185
|
-
renderField(null, { currency: 'USD' }, { onChange });
|
|
186
|
-
const input = screen.getByRole('spinbutton');
|
|
187
|
-
fireEvent.change(input, { target: { value: '1234.567' } });
|
|
188
|
-
fireEvent.blur(input, { target: { value: '1234.567' } });
|
|
189
|
-
expect(onChange).toHaveBeenLastCalledWith(1234.57);
|
|
190
|
-
});
|
|
191
|
-
});
|
|
@@ -1,198 +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#4414 — the edit-mode currency adornment has ONE channel.
|
|
11
|
-
*
|
|
12
|
-
* Before this card `CurrencyField.tsx` carried the same one-entry fact twice: a
|
|
13
|
-
* `CURRENCY_SYMBOLS` map that nothing read, and, two lines below it, the live
|
|
14
|
-
* `currency === 'USD' ? '$' : currency` ternary. The PM ruling (issue #4414,
|
|
15
|
-
* claim comment) says one channel survives, and that `Intl` — which already
|
|
16
|
-
* carries this knowledge — is the candidate to MEASURE first.
|
|
17
|
-
*
|
|
18
|
-
* Measured, and it fits: `Intl.NumberFormat(locale, { style: 'currency',
|
|
19
|
-
* currency }).formatToParts()` has a `currency` part, and that part is exactly
|
|
20
|
-
* the string the widget's OWN readonly branch already renders, because the
|
|
21
|
-
* readonly branch formats through `formatDisplayNumber(value, { locale,
|
|
22
|
-
* currency })` — the same `Intl` call. So the adornment was the last hand copy,
|
|
23
|
-
* and the widget disagreed with itself: at `en`, readonly JPY rendered
|
|
24
|
-
* `¥1,235` while the edit adornment beside it said `JPY`.
|
|
25
|
-
*
|
|
26
|
-
* The one place this helper deliberately DIVERGES from its sibling
|
|
27
|
-
* `currencyFractionDigits()`: the digit count is locale-independent (measured
|
|
28
|
-
* across eight locales in `currency.ts`), so that probe drops the locale. The
|
|
29
|
-
* SYMBOL is not — measured on node 22 / full-ICU:
|
|
30
|
-
*
|
|
31
|
-
* USD -> "$" (en, en-US, de-DE, ja-JP) | "US$" (en-GB, zh-CN) | "$US" (fr-FR)
|
|
32
|
-
* JPY -> "¥" (en, de-DE) | "¥" (ja-JP) | "JP¥" (en-GB, zh-CN) | "JPY" (fr-FR)
|
|
33
|
-
*
|
|
34
|
-
* so `currencySymbol()` takes the display locale as a parameter and caches on
|
|
35
|
-
* the PAIR. Keying the cache on the code alone would let whichever locale asked
|
|
36
|
-
* first answer for every locale after it — pinned below, because that is a
|
|
37
|
-
* silent wrong-answer bug and not a hypothetical.
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
41
|
-
import React from 'react';
|
|
42
|
-
import { render } from '@testing-library/react';
|
|
43
|
-
import '@testing-library/jest-dom';
|
|
44
|
-
import { LocalizationProvider } from '@object-ui/i18n';
|
|
45
|
-
import { CurrencyField } from '../widgets/CurrencyField';
|
|
46
|
-
import { currencySymbol } from '../currency';
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* ICU separates a currency CODE from the amount with U+00A0 and sets a SYMBOL
|
|
50
|
-
* flush against it; normalizing keeps the readonly-agreement pins about the
|
|
51
|
-
* SYMBOL rather than about ICU's spacing. Written as an escape, never a pasted
|
|
52
|
-
* byte.
|
|
53
|
-
*/
|
|
54
|
-
const normalizeNbsp = (s: string | null) => (s ?? '').replace(/\u00a0/g, ' ');
|
|
55
|
-
|
|
56
|
-
const renderField = (
|
|
57
|
-
field: Record<string, unknown>,
|
|
58
|
-
opts: { readonly?: boolean; locale?: string; tenantCurrency?: string } = {},
|
|
59
|
-
) =>
|
|
60
|
-
render(
|
|
61
|
-
<LocalizationProvider value={{ locale: opts.locale ?? 'en', currency: opts.tenantCurrency }}>
|
|
62
|
-
<CurrencyField
|
|
63
|
-
value={1234.5}
|
|
64
|
-
onChange={vi.fn()}
|
|
65
|
-
field={{ type: 'currency', ...field } as any}
|
|
66
|
-
readonly={opts.readonly}
|
|
67
|
-
/>
|
|
68
|
-
</LocalizationProvider>,
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
/** The edit-mode prefix adornment: the only `span` inside the wrapper. */
|
|
72
|
-
const adornment = (container: HTMLElement) =>
|
|
73
|
-
container.querySelector('div.relative > span')?.textContent ?? null;
|
|
74
|
-
|
|
75
|
-
describe('CurrencyField adornment — USD is byte-identical at the display-locale default (objectui#4414)', () => {
|
|
76
|
-
it("`en` — useDisplayLocale()'s own last resort — still renders exactly `$`", () => {
|
|
77
|
-
const { container } = renderField({ currency: 'USD' });
|
|
78
|
-
expect(adornment(container)).toBe('$');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('`en-US` still renders exactly `$`', () => {
|
|
82
|
-
const { container } = renderField({ currency: 'USD' }, { locale: 'en-US' });
|
|
83
|
-
expect(adornment(container)).toBe('$');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('the tenant default currency reaches the adornment too (ADR-0053)', () => {
|
|
87
|
-
const { container } = renderField({}, { tenantCurrency: 'USD' });
|
|
88
|
-
expect(adornment(container)).toBe('$');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('a field with NO currency still renders no adornment and no left padding', () => {
|
|
92
|
-
const { container } = renderField({});
|
|
93
|
-
expect(adornment(container)).toBeNull();
|
|
94
|
-
expect(container.querySelector('input')).not.toHaveClass('pl-8');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('a field WITH a currency still gets the left padding that clears the adornment', () => {
|
|
98
|
-
const { container } = renderField({ currency: 'USD' });
|
|
99
|
-
expect(container.querySelector('input')).toHaveClass('pl-8');
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe('CurrencyField adornment — the DOCUMENTED non-USD change: a real symbol replaces the bare code', () => {
|
|
104
|
-
// Before #4414 every one of these rendered the three-letter ISO code, because
|
|
105
|
-
// the ternary had exactly one entry. This is the deliberate improvement the
|
|
106
|
-
// ruling allowed for, not a side effect — and it is what the widget's own
|
|
107
|
-
// readonly branch has been rendering all along.
|
|
108
|
-
it.each([
|
|
109
|
-
['EUR', '€'],
|
|
110
|
-
['JPY', '¥'],
|
|
111
|
-
['GBP', '£'],
|
|
112
|
-
['INR', '₹'],
|
|
113
|
-
['KRW', '₩'],
|
|
114
|
-
])('%s now renders %s instead of the code', (currency, symbol) => {
|
|
115
|
-
const { container } = renderField({ currency });
|
|
116
|
-
expect(adornment(container)).toBe(symbol);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('CurrencyField adornment — currencies CLDR has no symbol for keep the code, byte-identical', () => {
|
|
121
|
-
// ICU answers with the code itself for these, which is exactly what the
|
|
122
|
-
// ternary produced. Nothing changes, and that is worth pinning: it is the
|
|
123
|
-
// half of the ruling's "symbol-vs-code nuance" that survives the change.
|
|
124
|
-
it.each(['KWD', 'BHD', 'CHF', 'ISK', 'CLP'])('%s still renders its bare code', (currency) => {
|
|
125
|
-
const { container } = renderField({ currency });
|
|
126
|
-
expect(adornment(container)).toBe(currency);
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe('CurrencyField adornment — it agrees with the SAME widget\'s readonly rendering', () => {
|
|
131
|
-
// The real invariant behind this card. The readonly branch already formats
|
|
132
|
-
// through the display locale, so before #4414 the widget contradicted itself
|
|
133
|
-
// for every currency with a symbol.
|
|
134
|
-
it.each([
|
|
135
|
-
['en', 'JPY'],
|
|
136
|
-
['en', 'EUR'],
|
|
137
|
-
['en-GB', 'USD'],
|
|
138
|
-
['fr-FR', 'USD'],
|
|
139
|
-
['ja-JP', 'JPY'],
|
|
140
|
-
['de-DE', 'USD'],
|
|
141
|
-
['en', 'KWD'],
|
|
142
|
-
])('%s / %s — the readonly text contains the adornment string', (locale, currency) => {
|
|
143
|
-
const edit = renderField({ currency }, { locale });
|
|
144
|
-
const symbol = adornment(edit.container);
|
|
145
|
-
expect(symbol).toBeTruthy();
|
|
146
|
-
const view = renderField({ currency }, { locale, readonly: true });
|
|
147
|
-
expect(normalizeNbsp(view.container.textContent)).toContain(symbol as string);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe('currencySymbol() — the symbol is locale-dependent, so the locale is a parameter', () => {
|
|
152
|
-
it('USD is not one string across locales', () => {
|
|
153
|
-
expect(currencySymbol('USD', 'en')).toBe('$');
|
|
154
|
-
expect(currencySymbol('USD', 'fr-FR')).toBe('$US');
|
|
155
|
-
expect(currencySymbol('USD', 'en-GB')).toBe('US$');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('JPY at ja-JP is the FULLWIDTH yen sign, a different codepoint from `en`', () => {
|
|
159
|
-
// U+FFE5 vs U+00A5 — visually near-identical, so written as escapes.
|
|
160
|
-
expect(currencySymbol('JPY', 'en')).toBe('\u00a5');
|
|
161
|
-
expect(currencySymbol('JPY', 'ja-JP')).toBe('\uffe5');
|
|
162
|
-
expect(currencySymbol('JPY', 'ja-JP')).not.toBe(currencySymbol('JPY', 'en'));
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('the cache is keyed on the PAIR — the first locale to ask must not answer for the rest', () => {
|
|
166
|
-
// The bug this pin exists for: copying `currencyFractionDigits`' code-only
|
|
167
|
-
// cache key would make these two calls return the same string, whichever
|
|
168
|
-
// ran first, for the rest of the process.
|
|
169
|
-
const first = currencySymbol('GBP', 'en-GB');
|
|
170
|
-
const second = currencySymbol('GBP', 'ar-KW');
|
|
171
|
-
expect(first).toBe('£');
|
|
172
|
-
expect(second).toBe('UK£');
|
|
173
|
-
// …and re-asking returns the cached value, not the other locale's.
|
|
174
|
-
expect(currencySymbol('GBP', 'en-GB')).toBe('£');
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
describe('currencySymbol() — neither a bad code nor a bad locale may escape as a throw', () => {
|
|
179
|
-
it('an invalid currency code falls back to the code as given', () => {
|
|
180
|
-
// `Intl` throws `RangeError: Invalid currency code` for these. The fallback
|
|
181
|
-
// is the code itself, which is both what the ternary produced and what
|
|
182
|
-
// `formatAmount`'s own bad-code branch renders beside it.
|
|
183
|
-
expect(currencySymbol('not-a-code', 'en')).toBe('not-a-code');
|
|
184
|
-
expect(currencySymbol('USDD', 'en')).toBe('USDD');
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('a malformed locale tag falls back to the code rather than crashing the widget', () => {
|
|
188
|
-
// `Intl` throws `RangeError: Incorrect locale information provided` for
|
|
189
|
-
// `en_US` (underscore) — the failure mode `currencyFractionDigits` dodged
|
|
190
|
-
// by dropping the locale entirely, which this helper cannot do.
|
|
191
|
-
expect(currencySymbol('USD', 'en_US')).toBe('USD');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('the widget renders the code instead of throwing when the code is junk', () => {
|
|
195
|
-
const { container } = renderField({ currency: 'not-a-code' });
|
|
196
|
-
expect(adornment(container)).toBe('not-a-code');
|
|
197
|
-
});
|
|
198
|
-
});
|
|
@@ -1,82 +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
|
-
import { describe, it, expect } from 'vitest';
|
|
10
|
-
import React from 'react';
|
|
11
|
-
import { render, screen } from '@testing-library/react';
|
|
12
|
-
import '@testing-library/jest-dom';
|
|
13
|
-
import { DateCellRenderer, formatRelativeDate } from '../index';
|
|
14
|
-
|
|
15
|
-
function daysAgo(n: number): string {
|
|
16
|
-
const d = new Date();
|
|
17
|
-
d.setDate(d.getDate() - n);
|
|
18
|
-
return d.toISOString();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe('formatRelativeDate', () => {
|
|
22
|
-
it('renders "Overdue Nd" only when dueLike is set', () => {
|
|
23
|
-
expect(formatRelativeDate(daysAgo(3), { dueLike: true })).toBe('Overdue 3d');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('renders neutral "N days ago" for a past date that is not due-like', () => {
|
|
27
|
-
expect(formatRelativeDate(daysAgo(3))).toBe('3 days ago');
|
|
28
|
-
expect(formatRelativeDate(daysAgo(3), { dueLike: false })).toBe('3 days ago');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('localizes the near-today window per locale (framework#3040)', () => {
|
|
32
|
-
expect(formatRelativeDate(daysAgo(0), { locale: 'zh-CN' })).toBe('今天');
|
|
33
|
-
expect(formatRelativeDate(daysAgo(-1), { locale: 'zh-CN' })).toBe('明天');
|
|
34
|
-
expect(formatRelativeDate(daysAgo(1), { locale: 'zh-CN' })).toBe('昨天');
|
|
35
|
-
expect(formatRelativeDate(daysAgo(-3), { locale: 'zh-CN' })).toBe('3天后');
|
|
36
|
-
expect(formatRelativeDate(daysAgo(3), { locale: 'zh-CN' })).toBe('3天前');
|
|
37
|
-
// English output is sentence-cased.
|
|
38
|
-
expect(formatRelativeDate(daysAgo(-1))).toBe('Tomorrow');
|
|
39
|
-
expect(formatRelativeDate(daysAgo(-3))).toBe('In 3 days');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('resolves the overdue phrase through the i18n translate fn when provided', () => {
|
|
43
|
-
const t = (key: string, params?: Record<string, unknown>) =>
|
|
44
|
-
key === 'fields.relativeDate.overdue' ? `逾期 ${params?.count} 天` : key;
|
|
45
|
-
expect(formatRelativeDate(daysAgo(3), { dueLike: true, t })).toBe('逾期 3 天');
|
|
46
|
-
// A t() that misses (returns the key) keeps the English fallback.
|
|
47
|
-
expect(formatRelativeDate(daysAgo(3), { dueLike: true, t: (k) => k })).toBe('Overdue 3d');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('degrades to English when the locale tag is invalid', () => {
|
|
51
|
-
expect(formatRelativeDate(daysAgo(3), { locale: 'not a locale' })).toBe('3 days ago');
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Nothing in this file mounts an `I18nProvider`, and that is load-bearing
|
|
57
|
-
* rather than incidental (objectui#4468): these cases are the pin on
|
|
58
|
-
* `useDisplayLocale()`'s LAST resort — the concrete `'en'` a renderer resolves
|
|
59
|
-
* to when neither a tenant locale nor an active UI language exists. The
|
|
60
|
-
* session-locale cases live in `date-locale-channel.test.tsx`; mounting a
|
|
61
|
-
* provider here would leave react-i18next's global instance on that language
|
|
62
|
-
* and quietly turn these into assertions about test ordering.
|
|
63
|
-
*/
|
|
64
|
-
describe('DateCellRenderer', () => {
|
|
65
|
-
it('does not label a past start_date as "Overdue" (regression: field-role-agnostic formatter)', () => {
|
|
66
|
-
render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'start_date' } as any} />);
|
|
67
|
-
expect(screen.getByText('6 days ago')).toBeInTheDocument();
|
|
68
|
-
expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('labels a past due_date as "Overdue Nd" and colors it red', () => {
|
|
72
|
-
render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'due_date' } as any} />);
|
|
73
|
-
const el = screen.getByText('Overdue 6d');
|
|
74
|
-
expect(el).toBeInTheDocument();
|
|
75
|
-
expect(el.className).toMatch(/text-red-600/);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('respects an explicit dueLike:true override regardless of field name', () => {
|
|
79
|
-
render(<DateCellRenderer value={daysAgo(2)} field={{ type: 'date', name: 'end_date', dueLike: true } as any} />);
|
|
80
|
-
expect(screen.getByText('Overdue 2d')).toBeInTheDocument();
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,146 +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#4033 (source thread objectstack#5067) — the number cell renderer
|
|
11
|
-
* used to build `new Intl.NumberFormat('en-US', …)` with grouping left at the
|
|
12
|
-
* Intl default, so a four-digit YEAR stored as `Field.number({ scale: 0 })`
|
|
13
|
-
* rendered as `2,026` in every locale and no field property could turn it off.
|
|
14
|
-
*
|
|
15
|
-
* Two independent pins live here:
|
|
16
|
-
*
|
|
17
|
-
* 1. **Ordinal grouping** — a declared `scale: 0` with no currency is an
|
|
18
|
-
* integer/ordinal surface, so grouping is suppressed. Interim default; a
|
|
19
|
-
* future spec-level presentation hint overrides it.
|
|
20
|
-
* 2. **Locale** — grouping and decimal marks follow the ACTIVE display locale,
|
|
21
|
-
* never a hardcoded `en-US`.
|
|
22
|
-
*
|
|
23
|
-
* The controls are as load-bearing as the fixes: currency keeps grouping, a
|
|
24
|
-
* non-zero `scale` keeps grouping, and an UNDECLARED scale keeps grouping
|
|
25
|
-
* (`scale` is optional in the spec and absent means "decimals unknown", not
|
|
26
|
-
* "integer" — see the renderer's own comment).
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { describe, it, expect } from 'vitest';
|
|
30
|
-
import React from 'react';
|
|
31
|
-
import { render, screen } from '@testing-library/react';
|
|
32
|
-
import '@testing-library/jest-dom';
|
|
33
|
-
import { LocalizationProvider } from '@object-ui/i18n';
|
|
34
|
-
import { NumberCellRenderer, CurrencyCellRenderer } from '../index';
|
|
35
|
-
|
|
36
|
-
const renderNumber = (
|
|
37
|
-
value: unknown,
|
|
38
|
-
field: Record<string, unknown> = {},
|
|
39
|
-
locale?: string,
|
|
40
|
-
) =>
|
|
41
|
-
render(
|
|
42
|
-
<LocalizationProvider value={{ locale }}>
|
|
43
|
-
<NumberCellRenderer value={value as any} field={{ type: 'number', ...field } as any} />
|
|
44
|
-
</LocalizationProvider>,
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
const renderCurrency = (
|
|
48
|
-
value: unknown,
|
|
49
|
-
field: Record<string, unknown> = {},
|
|
50
|
-
locale?: string,
|
|
51
|
-
) =>
|
|
52
|
-
render(
|
|
53
|
-
<LocalizationProvider value={{ locale }}>
|
|
54
|
-
<CurrencyCellRenderer value={value as any} field={{ type: 'currency', ...field } as any} />
|
|
55
|
-
</LocalizationProvider>,
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
describe('NumberCellRenderer — ordinal grouping (objectui#4033)', () => {
|
|
59
|
-
// The card's exact fixture: `year: Field.number({ scale: 0, min: 1900 })`.
|
|
60
|
-
it.each(['en-US', 'de-DE', 'zh-CN', 'fr-FR'])(
|
|
61
|
-
'renders a scale-0 year ungrouped in %s',
|
|
62
|
-
(locale) => {
|
|
63
|
-
renderNumber(2026, { scale: 0 }, locale);
|
|
64
|
-
expect(screen.getByText('2026')).toBeInTheDocument();
|
|
65
|
-
},
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
it('suppresses grouping for every scale-0 magnitude, not just four digits', () => {
|
|
69
|
-
renderNumber(1234567, { scale: 0 }, 'en-US');
|
|
70
|
-
expect(screen.getByText('1234567')).toBeInTheDocument();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('CONTROL: a non-zero scale keeps grouping', () => {
|
|
74
|
-
renderNumber(1234.5, { scale: 2 }, 'en-US');
|
|
75
|
-
expect(screen.getByText('1,234.50')).toBeInTheDocument();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('CONTROL: an UNDECLARED scale keeps grouping (absent ≠ integer)', () => {
|
|
79
|
-
renderNumber(2026, {}, 'en-US');
|
|
80
|
-
expect(screen.getByText('2,026')).toBeInTheDocument();
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe('NumberCellRenderer — active display locale (objectui#4033)', () => {
|
|
85
|
-
it('uses the active locale decimal + grouping marks (de-DE)', () => {
|
|
86
|
-
renderNumber(1234.5, { scale: 1 }, 'de-DE');
|
|
87
|
-
expect(screen.getByText('1.234,5')).toBeInTheDocument();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('uses the active locale decimal + grouping marks (en-US)', () => {
|
|
91
|
-
renderNumber(1234.5, { scale: 1 }, 'en-US');
|
|
92
|
-
expect(screen.getByText('1,234.5')).toBeInTheDocument();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('uses the active locale decimal + grouping marks (fr-FR)', () => {
|
|
96
|
-
// fr-FR groups with U+202F (NARROW NO-BREAK SPACE) and uses "," for the
|
|
97
|
-
// decimal mark — nothing an en-US formatter can produce.
|
|
98
|
-
//
|
|
99
|
-
// Asserted against raw `textContent`, NOT `getByText`: testing-library's
|
|
100
|
-
// default normalizer collapses `\s+` (which includes U+202F) down to an
|
|
101
|
-
// ASCII space, so a `getByText` assertion here can only ever prove "some
|
|
102
|
-
// whitespace" and would pass on the wrong separator. The escape sequence is
|
|
103
|
-
// written out rather than pasted — a raw U+202F in source renders as
|
|
104
|
-
// nothing and is unfindable by grep.
|
|
105
|
-
const { container } = renderNumber(1234.5, { scale: 1 }, 'fr-FR');
|
|
106
|
-
expect(container.textContent).toBe('1 234,5');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('does not take a malformed tenant locale down with it', () => {
|
|
110
|
-
// `locale` arrives from a server response (ADR-0053); a junk tag must
|
|
111
|
-
// degrade to the runtime default, never throw out of a grid cell.
|
|
112
|
-
renderNumber(1234.5, { scale: 1 }, 'not a locale');
|
|
113
|
-
expect(screen.getByText(/1.?234.5/)).toBeInTheDocument();
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
describe('CurrencyCellRenderer — CONTROL, unaffected by the ordinal default', () => {
|
|
118
|
-
it('keeps grouping and the currency symbol for a whole amount', () => {
|
|
119
|
-
renderCurrency(5000000, { currency: 'USD' }, 'en-US');
|
|
120
|
-
expect(screen.getByText('$5,000,000')).toBeInTheDocument();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('keeps grouping and the fractional part when the amount is not whole', () => {
|
|
124
|
-
renderCurrency(1234.5, { currency: 'USD' }, 'en-US');
|
|
125
|
-
// This control pinned the CURRENT behaviour when #4033 landed — `$1,234.5`,
|
|
126
|
-
// NOT the `$1,234.50` that `formatCurrency`'s own doc comment promises
|
|
127
|
-
// ("Salesforce convention: `$1,234.50` keeps cents"). That was a real
|
|
128
|
-
// defect, deliberately filed rather than folded in (objectui#4332) and
|
|
129
|
-
// pinned here so its fix would flip a watched test instead of silently
|
|
130
|
-
// changing an unwatched surface. #4332 is now fixed, so the expectation
|
|
131
|
-
// moves to the promised form; grouping — this control's actual subject —
|
|
132
|
-
// is unchanged, which is still what #4033 needs it to prove.
|
|
133
|
-
expect(screen.getByText('$1,234.50')).toBeInTheDocument();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('follows the active locale for currency too', () => {
|
|
137
|
-
renderCurrency(5000000, { currency: 'EUR' }, 'de-DE');
|
|
138
|
-
// de-DE: grouped with dots and a TRAILING symbol, vs en-US's leading one.
|
|
139
|
-
expect(screen.getByText(/^5\.000\.000\s*€$/)).toBeInTheDocument();
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('a currency field with NO resolved currency still groups (money, not an ordinal)', () => {
|
|
143
|
-
renderCurrency(5000000, {}, 'en-US');
|
|
144
|
-
expect(screen.getByText('5,000,000')).toBeInTheDocument();
|
|
145
|
-
});
|
|
146
|
-
});
|
|
@@ -1,60 +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
|
-
import { describe, it, expect } from 'vitest';
|
|
10
|
-
import React from 'react';
|
|
11
|
-
import { render, screen } from '@testing-library/react';
|
|
12
|
-
import '@testing-library/jest-dom';
|
|
13
|
-
import { NumberCellRenderer } from '../index';
|
|
14
|
-
|
|
15
|
-
const renderNumber = (value: unknown, field: Record<string, unknown> = {}) =>
|
|
16
|
-
render(<NumberCellRenderer value={value as any} field={{ type: 'number', ...field } as any} />);
|
|
17
|
-
|
|
18
|
-
describe('NumberCellRenderer', () => {
|
|
19
|
-
it('does not pad trailing zeros using `precision` (total digits, not decimal places)', () => {
|
|
20
|
-
// Regression: a decimal(10, 0) column exposes precision: 10, which used to
|
|
21
|
-
// render `1` as "1.0000000000".
|
|
22
|
-
renderNumber(1, { precision: 10 });
|
|
23
|
-
expect(screen.getByText('1')).toBeInTheDocument();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('renders an integer without decimals when no scale is declared', () => {
|
|
27
|
-
renderNumber(16);
|
|
28
|
-
expect(screen.getByText('16')).toBeInTheDocument();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('preserves the value\'s natural decimals when no scale is declared', () => {
|
|
32
|
-
renderNumber(16.5);
|
|
33
|
-
expect(screen.getByText('16.5')).toBeInTheDocument();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('pads to a fixed number of decimals when `scale` is declared', () => {
|
|
37
|
-
renderNumber(16, { scale: 2 });
|
|
38
|
-
expect(screen.getByText('16.00')).toBeInTheDocument();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('honours a scale of 3 with zero padding', () => {
|
|
42
|
-
renderNumber(3.14, { scale: 3 });
|
|
43
|
-
expect(screen.getByText('3.140')).toBeInTheDocument();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('rounds to the declared scale', () => {
|
|
47
|
-
renderNumber(3.14159, { scale: 2 });
|
|
48
|
-
expect(screen.getByText('3.14')).toBeInTheDocument();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('treats scale: 0 as an integer display', () => {
|
|
52
|
-
renderNumber(2, { scale: 0 });
|
|
53
|
-
expect(screen.getByText('2')).toBeInTheDocument();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('renders an empty indicator for nullish values', () => {
|
|
57
|
-
const { container } = renderNumber(null);
|
|
58
|
-
expect(container.textContent).not.toContain('0');
|
|
59
|
-
});
|
|
60
|
-
});
|