@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
|
@@ -50,17 +50,26 @@
|
|
|
50
50
|
* 4. **This repo runs happy-dom, not jsdom** (`vitest.config.mts`), whose IDL
|
|
51
51
|
* coverage has real gaps — `select[size]`, `option[label]`, `textarea[wrap]`
|
|
52
52
|
* and `col[span]` are all standard HTML that happy-dom does not reflect.
|
|
53
|
-
* See
|
|
54
|
-
*
|
|
55
|
-
* Every one of those four was found BY
|
|
53
|
+
* See `@object-ui/test-support`'s `dom-leak-judge` for how the judge is
|
|
54
|
+
* built, and its `HAPPY_DOM_IDL_GAPS` for each measured exception and its
|
|
55
|
+
* reason. Every one of those four was found BY a calibration fixture, not
|
|
56
56
|
* by guesswork.
|
|
57
57
|
*
|
|
58
|
-
* ## The judge proves itself
|
|
58
|
+
* ## The judge proves itself — next to the judge, not here
|
|
59
59
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
60
|
+
* `isKnownAttribute` / `findLeaks` / `leakReport` are imported from
|
|
61
|
+
* `@object-ui/test-support`, which is where they now live for BOTH DOM-leak
|
|
62
|
+
* gates (objectui#4434). They used to be defined in this file and copied into
|
|
63
|
+
* `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`, and the
|
|
64
|
+
* two copies had already drifted.
|
|
65
|
+
*
|
|
66
|
+
* The calibration fixtures moved with them: standard markup that must yield
|
|
67
|
+
* ZERO findings, and markup with planted fake attributes that must ALL be
|
|
68
|
+
* found, now unioned from both gates' fixtures and run once in
|
|
69
|
+
* `packages/test-support/src/__tests__/dom-leak-judge.test.tsx`. When a
|
|
70
|
+
* happy-dom upgrade changes IDL coverage, that fails loudly instead of this
|
|
71
|
+
* sweep going quietly blind — and it now fails in ONE place instead of needing
|
|
72
|
+
* to be discovered twice.
|
|
64
73
|
*
|
|
65
74
|
* ## Deliberate coverage boundary
|
|
66
75
|
*
|
|
@@ -84,6 +93,11 @@ import { ComponentRegistry } from '@object-ui/core';
|
|
|
84
93
|
// Module scope: pulls in the form renderer's registration side effect.
|
|
85
94
|
import '@object-ui/components';
|
|
86
95
|
import { SchemaRenderer } from '@object-ui/react';
|
|
96
|
+
// The attribute judge, shared with `packages/app-shell`'s sweep gate
|
|
97
|
+
// (objectui#4434). It used to be defined in this file and copied into that one;
|
|
98
|
+
// its calibration fixtures now live next to it and prove it for both gates.
|
|
99
|
+
// `@object-ui/test-support` is private and never published — see its README.
|
|
100
|
+
import { findLeaks, leakReport } from '@object-ui/test-support';
|
|
87
101
|
|
|
88
102
|
import { withFieldCarrier } from '../withFieldCarrier';
|
|
89
103
|
import { FORM_FIELD_TYPES } from '../index';
|
|
@@ -131,263 +145,6 @@ import { ObjectRefField } from '../widgets/ObjectRefField';
|
|
|
131
145
|
import { FilterConditionField } from '../widgets/FilterConditionField';
|
|
132
146
|
import { RecipientPickerField } from '../widgets/RecipientPickerField';
|
|
133
147
|
|
|
134
|
-
/* ════════════════════════════════════════════════════════════════════════════
|
|
135
|
-
* The judge: is this attribute one HTML actually defines?
|
|
136
|
-
* ══════════════════════════════════════════════════════════════════════════ */
|
|
137
|
-
|
|
138
|
-
/** Open families. `data-*` is the one open family the widget contract declares. */
|
|
139
|
-
const OPEN_PREFIXES = [
|
|
140
|
-
'data-',
|
|
141
|
-
'aria-',
|
|
142
|
-
// `cmdk-root` / `cmdk-input` / `cmdk-list` … are marks the cmdk library puts
|
|
143
|
-
// on ITS OWN DOM. Not prop pass-through, and present on every cmdk-based
|
|
144
|
-
// picker (`object-ref`, `lookup`, …) the moment it renders.
|
|
145
|
-
'cmdk-',
|
|
146
|
-
];
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Global HTML attributes. Most are also IDL properties and would be caught by
|
|
150
|
-
* the reflection check below; they are listed because a missing IDL for a
|
|
151
|
-
* genuinely global attribute would otherwise read as a leak.
|
|
152
|
-
*/
|
|
153
|
-
const GLOBAL_HTML_ATTRIBUTES = new Set([
|
|
154
|
-
'id', 'class', 'style', 'title', 'lang', 'dir', 'hidden', 'tabindex', 'role',
|
|
155
|
-
'slot', 'part', 'exportparts', 'itemid', 'itemprop', 'itemref', 'itemscope',
|
|
156
|
-
'itemtype', 'translate', 'draggable', 'spellcheck', 'autocapitalize',
|
|
157
|
-
'autocorrect', 'contenteditable', 'enterkeyhint', 'inputmode', 'accesskey',
|
|
158
|
-
'nonce', 'is', 'popover', 'inert', 'autofocus',
|
|
159
|
-
]);
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Attributes whose IDL property is spelled differently enough that the
|
|
163
|
-
* case-insensitive reflection match below cannot find them.
|
|
164
|
-
*/
|
|
165
|
-
const ATTRIBUTE_TO_IDL_ALIAS: Record<string, string> = {
|
|
166
|
-
'class': 'className',
|
|
167
|
-
'for': 'htmlFor',
|
|
168
|
-
'accept-charset': 'acceptCharset',
|
|
169
|
-
'http-equiv': 'httpEquiv',
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* MEASURED gaps in happy-dom's IDL, kept deliberately tiny — each entry is an
|
|
174
|
-
* attribute HTML defines that happy-dom's element does not reflect as a
|
|
175
|
-
* property, so reflection alone would report it as a leak.
|
|
176
|
-
*/
|
|
177
|
-
const HAPPY_DOM_IDL_GAPS: Record<string, Set<string>> = {
|
|
178
|
-
// `HTMLSelectElement.size` is standard; happy-dom does not define it.
|
|
179
|
-
select: new Set(['size']),
|
|
180
|
-
// `HTMLOptionElement.label` is standard; happy-dom does not define it.
|
|
181
|
-
option: new Set(['label']),
|
|
182
|
-
// `HTMLTextAreaElement.wrap` is standard; happy-dom does not define it.
|
|
183
|
-
textarea: new Set(['wrap']),
|
|
184
|
-
// `HTMLTableColElement.span` is standard; happy-dom does not define it.
|
|
185
|
-
col: new Set(['span']),
|
|
186
|
-
colgroup: new Set(['span']),
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* SVG needs its own list: the reflection trick does NOT hold for SVG under
|
|
191
|
-
* happy-dom (`SVGElement` reflects almost nothing), and lucide icons put a
|
|
192
|
-
* fixed set of presentation attributes on every icon they render.
|
|
193
|
-
*/
|
|
194
|
-
const SVG_ATTRIBUTES = new Set([
|
|
195
|
-
'xmlns', 'xmlns:xlink', 'version', 'viewbox', 'preserveaspectratio',
|
|
196
|
-
'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx',
|
|
197
|
-
'ry', 'd', 'points', 'transform', 'fill', 'fill-rule', 'fill-opacity',
|
|
198
|
-
'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
|
|
199
|
-
'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity',
|
|
200
|
-
'clip-path', 'clip-rule', 'mask', 'offset', 'stop-color', 'stop-opacity',
|
|
201
|
-
'gradientunits', 'gradienttransform', 'patternunits', 'text-anchor',
|
|
202
|
-
'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'vector-effect',
|
|
203
|
-
'shape-rendering', 'focusable', 'overflow', 'color',
|
|
204
|
-
]);
|
|
205
|
-
|
|
206
|
-
/** Lowercased IDL property names on a tag's prototype chain, cached per tag. */
|
|
207
|
-
const idlCache = new Map<string, Set<string>>();
|
|
208
|
-
|
|
209
|
-
function idlPropertiesFor(tagName: string): Set<string> {
|
|
210
|
-
const tag = tagName.toLowerCase();
|
|
211
|
-
const cached = idlCache.get(tag);
|
|
212
|
-
if (cached) return cached;
|
|
213
|
-
|
|
214
|
-
const names = new Set<string>();
|
|
215
|
-
const element = document.createElement(tag);
|
|
216
|
-
for (const own of Object.getOwnPropertyNames(element)) names.add(own.toLowerCase());
|
|
217
|
-
for (
|
|
218
|
-
let proto = Object.getPrototypeOf(element);
|
|
219
|
-
proto && proto !== Object.prototype;
|
|
220
|
-
proto = Object.getPrototypeOf(proto)
|
|
221
|
-
) {
|
|
222
|
-
for (const name of Object.getOwnPropertyNames(proto)) names.add(name.toLowerCase());
|
|
223
|
-
}
|
|
224
|
-
idlCache.set(tag, names);
|
|
225
|
-
return names;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* The rule: an attribute is legitimate when HTML/SVG defines it for that
|
|
230
|
-
* element, or when it belongs to an open family.
|
|
231
|
-
*
|
|
232
|
-
* The reflection check ("does the element's prototype chain carry a property
|
|
233
|
-
* with this name, case-insensitively?") is what makes this maintainable — it
|
|
234
|
-
* covers `readonly→readOnly`, `maxlength→maxLength`, `colspan→colSpan` and
|
|
235
|
-
* every other per-tag attribute automatically, instead of a hand-kept table
|
|
236
|
-
* per element type that would rot.
|
|
237
|
-
*/
|
|
238
|
-
function isKnownAttribute(element: Element, attribute: string): boolean {
|
|
239
|
-
const name = attribute.toLowerCase();
|
|
240
|
-
|
|
241
|
-
if (OPEN_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
|
|
242
|
-
|
|
243
|
-
// Inline event handlers (`onclick`) reflect as IDL properties on every
|
|
244
|
-
// element; React never emits them as attributes, so reaching one means a
|
|
245
|
-
// handler-shaped prop was stringified onto the DOM. Treat as a leak.
|
|
246
|
-
if (name.startsWith('on')) return false;
|
|
247
|
-
|
|
248
|
-
const tag = element.tagName.toLowerCase();
|
|
249
|
-
|
|
250
|
-
if (element.namespaceURI === 'http://www.w3.org/2000/svg') {
|
|
251
|
-
return SVG_ATTRIBUTES.has(name) || GLOBAL_HTML_ATTRIBUTES.has(name);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (GLOBAL_HTML_ATTRIBUTES.has(name)) return true;
|
|
255
|
-
if (HAPPY_DOM_IDL_GAPS[tag]?.has(name)) return true;
|
|
256
|
-
|
|
257
|
-
const idl = idlPropertiesFor(tag);
|
|
258
|
-
const alias = ATTRIBUTE_TO_IDL_ALIAS[name];
|
|
259
|
-
if (alias && idl.has(alias.toLowerCase())) return true;
|
|
260
|
-
return idl.has(name);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
interface Leak {
|
|
264
|
-
tag: string;
|
|
265
|
-
attribute: string;
|
|
266
|
-
value: string;
|
|
267
|
-
outerHTML: string;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** Every unexplained attribute on `root` and its descendants. */
|
|
271
|
-
function findLeaks(root: Element): Leak[] {
|
|
272
|
-
const leaks: Leak[] = [];
|
|
273
|
-
const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
|
|
274
|
-
for (const element of elements) {
|
|
275
|
-
for (const attribute of Array.from(element.attributes)) {
|
|
276
|
-
if (isKnownAttribute(element, attribute.name)) continue;
|
|
277
|
-
leaks.push({
|
|
278
|
-
tag: element.tagName.toLowerCase(),
|
|
279
|
-
attribute: attribute.name,
|
|
280
|
-
value: attribute.value,
|
|
281
|
-
outerHTML: element.outerHTML.slice(0, 400),
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return leaks;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Renders the finding as the assertion's "actual" value, so a failure names
|
|
290
|
-
* the widget, the element, the attribute, its value and the markup. A 46-widget
|
|
291
|
-
* sweep failing as `expected [] to equal [ …47 items ]` is unusable.
|
|
292
|
-
*/
|
|
293
|
-
function leakReport(widgetType: string, variant: string, leaks: Leak[]): string {
|
|
294
|
-
if (leaks.length === 0) return '';
|
|
295
|
-
const lines = leaks.map(
|
|
296
|
-
(leak) =>
|
|
297
|
-
` <${leak.tag}> leaked ${leak.attribute}="${leak.value}"\n` +
|
|
298
|
-
` in: ${leak.outerHTML}`,
|
|
299
|
-
);
|
|
300
|
-
return (
|
|
301
|
-
`field:${widgetType} [${variant}] leaked ${leaks.length} non-DOM ` +
|
|
302
|
-
`attribute(s):\n${lines.join('\n')}`
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/* ════════════════════════════════════════════════════════════════════════════
|
|
307
|
-
* The judge proves itself, BEFORE it is trusted on 46 widgets
|
|
308
|
-
* ══════════════════════════════════════════════════════════════════════════ */
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Ordinary, correct markup. Every attribute here is one HTML defines, several
|
|
312
|
-
* chosen precisely because their IDL name differs from the attribute
|
|
313
|
-
* (`readonly`/`maxlength`/`colspan`/`class`/`for`), plus the two happy-dom IDL
|
|
314
|
-
* gaps (`select[size]`, `option[label]`) and a cmdk mark.
|
|
315
|
-
*/
|
|
316
|
-
const CLEAN_FIXTURE = `
|
|
317
|
-
<div id="root" class="a b" title="t" role="group" tabindex="0" hidden
|
|
318
|
-
data-testid="x" aria-label="l" data-state="open">
|
|
319
|
-
<input type="text" name="n" value="v" placeholder="p" readonly maxlength="5"
|
|
320
|
-
minlength="1" required autofocus autocomplete="off" inputmode="text"
|
|
321
|
-
step="1" min="0" max="9" pattern=".*" list="dl" />
|
|
322
|
-
<textarea rows="3" cols="4" wrap="soft" readonly></textarea>
|
|
323
|
-
<select size="2" multiple><option label="L" value="v" selected></option></select>
|
|
324
|
-
<button type="button" disabled formnovalidate value="on">b</button>
|
|
325
|
-
<a href="#" target="_blank" rel="noopener" download hreflang="en"></a>
|
|
326
|
-
<label for="root">l</label>
|
|
327
|
-
<img src="s.png" alt="a" width="10" height="10" loading="lazy" decoding="async" />
|
|
328
|
-
<table><colgroup><col span="2" /></colgroup><tbody><tr>
|
|
329
|
-
<td colspan="2" rowspan="1" headers="h"></td></tr></tbody></table>
|
|
330
|
-
<div cmdk-root=""><div cmdk-list=""></div></div>
|
|
331
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"
|
|
332
|
-
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
333
|
-
stroke-linejoin="round" class="icon"><path d="M0 0h24v24H0z" /></svg>
|
|
334
|
-
</div>
|
|
335
|
-
`;
|
|
336
|
-
|
|
337
|
-
/** Every planted attribute here MUST be reported. */
|
|
338
|
-
const PLANTED_LEAKS: ReadonlyArray<readonly [string, string]> = [
|
|
339
|
-
// The renderer-only props that reached the DOM in the audit.
|
|
340
|
-
['schema', '[object Object]'],
|
|
341
|
-
['error', 'Title is required'],
|
|
342
|
-
['emptyhint', 'Select country first'],
|
|
343
|
-
['datasource', '[object Object]'],
|
|
344
|
-
['dependentvalues', '[object Object]'],
|
|
345
|
-
['dependson', 'country'],
|
|
346
|
-
['inputtype', 'text'],
|
|
347
|
-
['compact', 'true'],
|
|
348
|
-
['onselectrecord', 'function'],
|
|
349
|
-
// The SDUI-only extra.
|
|
350
|
-
['label', 'Title'],
|
|
351
|
-
// The open tail: arbitrary keys an author wrote on the field config.
|
|
352
|
-
['zzcanary', 'CANARY-STR'],
|
|
353
|
-
['zzcanaryobj', '[object Object]'],
|
|
354
|
-
['zzcanarynum', '42'],
|
|
355
|
-
['zzcanarycamel', 'CANARY-CAMEL'],
|
|
356
|
-
['reference_to', 'contacts'],
|
|
357
|
-
];
|
|
358
|
-
|
|
359
|
-
describe('the leak judge is calibrated (objectui#3291)', () => {
|
|
360
|
-
it('reports NOTHING on standard markup — no false positives', () => {
|
|
361
|
-
const host = document.createElement('div');
|
|
362
|
-
host.innerHTML = CLEAN_FIXTURE;
|
|
363
|
-
document.body.appendChild(host);
|
|
364
|
-
try {
|
|
365
|
-
const leaks = findLeaks(host);
|
|
366
|
-
expect(
|
|
367
|
-
leaks.map((l) => `<${l.tag}> ${l.attribute}="${l.value}"`).join('\n'),
|
|
368
|
-
).toBe('');
|
|
369
|
-
} finally {
|
|
370
|
-
host.remove();
|
|
371
|
-
}
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it('reports EVERY planted fake attribute — no false negatives', () => {
|
|
375
|
-
const host = document.createElement('div');
|
|
376
|
-
const planted = PLANTED_LEAKS.map(([name, value]) => `${name}="${value}"`).join(' ');
|
|
377
|
-
// On an <input>, so nothing can be excused by a permissive container.
|
|
378
|
-
host.innerHTML = `<input type="text" name="n" ${planted} />`;
|
|
379
|
-
document.body.appendChild(host);
|
|
380
|
-
try {
|
|
381
|
-
const found = new Set(findLeaks(host).map((leak) => leak.attribute));
|
|
382
|
-
const missed = PLANTED_LEAKS.map(([name]) => name).filter((name) => !found.has(name));
|
|
383
|
-
expect(missed).toEqual([]);
|
|
384
|
-
expect(found.size).toBe(PLANTED_LEAKS.length);
|
|
385
|
-
} finally {
|
|
386
|
-
host.remove();
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
});
|
|
390
|
-
|
|
391
148
|
/* ════════════════════════════════════════════════════════════════════════════
|
|
392
149
|
* Every registered field widget, both hosts
|
|
393
150
|
* ══════════════════════════════════════════════════════════════════════════ */
|
|
@@ -546,7 +303,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
|
|
|
546
303
|
it.each(types)('field:%s — form path, plain field', async (type) => {
|
|
547
304
|
renderForm(fieldConfig(type), false);
|
|
548
305
|
const row = await formRow();
|
|
549
|
-
expect(leakReport(type
|
|
306
|
+
expect(leakReport(`field:${type} [form/plain]`, findLeaks(row))).toBe('');
|
|
550
307
|
});
|
|
551
308
|
|
|
552
309
|
it.each(types)('field:%s — form path, author-written extra keys', async (type) => {
|
|
@@ -555,7 +312,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
|
|
|
555
312
|
// put on the field config arrives at the widget as a prop.
|
|
556
313
|
renderForm(fieldConfig(type, AUTHORED_EXTRAS), false);
|
|
557
314
|
const row = await formRow();
|
|
558
|
-
expect(leakReport(type
|
|
315
|
+
expect(leakReport(`field:${type} [form/authored-extras]`, findLeaks(row))).toBe('');
|
|
559
316
|
});
|
|
560
317
|
|
|
561
318
|
it.each(types)('field:%s — form path, after a real validation failure', async (type) => {
|
|
@@ -572,7 +329,9 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
|
|
|
572
329
|
);
|
|
573
330
|
});
|
|
574
331
|
|
|
575
|
-
expect(
|
|
332
|
+
expect(
|
|
333
|
+
leakReport(`field:${type} [form/validation-error]`, findLeaks(await formRow())),
|
|
334
|
+
).toBe('');
|
|
576
335
|
});
|
|
577
336
|
|
|
578
337
|
it.each(types)('field:%s — SDUI path, plain node', async (type) => {
|
|
@@ -583,7 +342,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
|
|
|
583
342
|
<SchemaRenderer schema={{ ...fieldConfig(type), type: `field:${type}` } as any} />,
|
|
584
343
|
);
|
|
585
344
|
await waitFor(() => expect(container.firstElementChild).toBeTruthy());
|
|
586
|
-
expect(leakReport(type
|
|
345
|
+
expect(leakReport(`field:${type} [sdui/plain]`, findLeaks(container))).toBe('');
|
|
587
346
|
});
|
|
588
347
|
|
|
589
348
|
it.each(types)('field:%s — SDUI path, author-written extra keys', async (type) => {
|
|
@@ -593,6 +352,6 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () =>
|
|
|
593
352
|
/>,
|
|
594
353
|
);
|
|
595
354
|
await waitFor(() => expect(container.firstElementChild).toBeTruthy());
|
|
596
|
-
expect(leakReport(type
|
|
355
|
+
expect(leakReport(`field:${type} [sdui/authored-extras]`, findLeaks(container))).toBe('');
|
|
597
356
|
});
|
|
598
357
|
});
|
|
@@ -191,9 +191,18 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
191
191
|
expect(mockDataSource.find).toHaveBeenCalledTimes(1);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
// Type in search
|
|
194
|
+
// Type in search. U+2026 now, and it comes from a PACK VALUE:
|
|
195
|
+
// objectui#4375 retired the `common.search` + '...' concatenation
|
|
196
|
+
// this used to pin, so the placeholder is `table.search`
|
|
197
|
+
// — read through `useFieldTranslation`, whose no-provider defaults map
|
|
198
|
+
// serves the `en` byte `Search…` in this test. It therefore moves with
|
|
199
|
+
// the pack from here on, and is covered by objectui#3878's glyph pin.
|
|
200
|
+
// `common.search` itself is retired from the ten packs and from this
|
|
201
|
+
// package's defaults map (objectui#4392); naming it bare rather than
|
|
202
|
+
// spelling a t() call around it is what keeps that retirement's
|
|
203
|
+
// revival pin readable — see common-search-retired-4392.test.ts.
|
|
195
204
|
await act(async () => {
|
|
196
|
-
fireEvent.change(screen.getByPlaceholderText('Search
|
|
205
|
+
fireEvent.change(screen.getByPlaceholderText('Search…'), {
|
|
197
206
|
target: { value: 'acme' },
|
|
198
207
|
});
|
|
199
208
|
});
|
|
@@ -462,7 +471,9 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
462
471
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
463
472
|
});
|
|
464
473
|
|
|
465
|
-
|
|
474
|
+
// U+2026 from the `table.search` pack value — see the note on the
|
|
475
|
+
// `$search` test above (objectui#4375).
|
|
476
|
+
const searchInput = screen.getByPlaceholderText('Search…');
|
|
466
477
|
|
|
467
478
|
// Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
|
|
468
479
|
await act(async () => {
|
|
@@ -480,9 +491,14 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
480
491
|
expect(onChange).toHaveBeenCalledWith('2');
|
|
481
492
|
});
|
|
482
493
|
|
|
483
|
-
it('resolves reference_to from nested field.field (
|
|
484
|
-
// Simulates
|
|
485
|
-
//
|
|
494
|
+
it('resolves reference_to from nested field.field (form metadata slot)', async () => {
|
|
495
|
+
// Simulates the `field.field` nesting the FORM renderer hands widgets:
|
|
496
|
+
// `renderFieldComponent` passes `field: field.field || field`, where
|
|
497
|
+
// `.field` is the declared metadata slot (objectui#3090) holding the
|
|
498
|
+
// real metadata (reference_to, reference_field, …). The test used to
|
|
499
|
+
// credit this shape to the docs-demo `createFieldRenderer` wrapper,
|
|
500
|
+
// which never produced it and no longer exists (objectui#3910); the
|
|
501
|
+
// live producer is the form path, so the coverage stands.
|
|
486
502
|
const onChange = vi.fn();
|
|
487
503
|
mockDataSource.find.mockResolvedValue({
|
|
488
504
|
data: [
|
package/src/currency.ts
CHANGED
|
@@ -12,3 +12,133 @@
|
|
|
12
12
|
* every field/cell renderer that already depends on it.
|
|
13
13
|
*/
|
|
14
14
|
export { resolveFieldCurrency } from '@object-ui/i18n';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fallback width for a currency whose digit count cannot be resolved: the
|
|
18
|
+
* literal both call sites used unconditionally before objectui#4361. It is
|
|
19
|
+
* reached only when there is no ISO code to ask about (an invalid code, or a
|
|
20
|
+
* runtime with no currency data at all), never as a policy.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_FRACTION_DIGITS = 2;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ISO 4217 minor-unit width, memoized per currency code.
|
|
26
|
+
*
|
|
27
|
+
* The probe is deliberately made with NO locale. Measured across
|
|
28
|
+
* en-US / de-DE / ja-JP / ar-KW / zh-CN / fr-FR / pl-PL / es-ES and the runtime
|
|
29
|
+
* default, every locale answers the same digit count for the same code (USD 2,
|
|
30
|
+
* JPY 0, KWD 3, BHD 3, CLP 0, ISK 0) — the count comes from CLDR's
|
|
31
|
+
* `currencyData`, which is keyed by the currency, not by who is reading it.
|
|
32
|
+
* Dropping the locale from the probe therefore loses nothing and removes a
|
|
33
|
+
* failure mode: a malformed locale tag makes `Intl.NumberFormat` throw
|
|
34
|
+
* `RangeError: Incorrect locale information provided`, which would turn a bad
|
|
35
|
+
* LOCALE into a wrong CURRENCY width. It also makes the code the whole cache
|
|
36
|
+
* key.
|
|
37
|
+
*/
|
|
38
|
+
const fractionDigitsByCurrency = new Map<string, number>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The number of fraction digits ISO 4217 gives `currency` — 0 for JPY/KRW/CLP,
|
|
42
|
+
* 2 for USD/EUR/CNY, 3 for KWD/BHD/OMR/TND — as ICU reports it.
|
|
43
|
+
*
|
|
44
|
+
* Returns {@link DEFAULT_FRACTION_DIGITS} when the code is not a currency code
|
|
45
|
+
* at all: `Intl` throws `RangeError: Invalid currency code` for those, and the
|
|
46
|
+
* throw must not escape, or a bad code would change the width used by the
|
|
47
|
+
* callers' own bad-currency fallbacks (objectui#4332 pinned those strings). A
|
|
48
|
+
* code that is well-formed but unknown to CLDR (e.g. `ZZZ`) does not throw —
|
|
49
|
+
* ICU answers 2 for anything outside its table, which is the same default.
|
|
50
|
+
*
|
|
51
|
+
* Memoized because the currency cell renderer runs once per grid cell: measured
|
|
52
|
+
* at 24.3us per uncached probe against 0.02us cached (200k iterations, node 22),
|
|
53
|
+
* so a 500-row grid pays ~12ms per render pass for a value that cannot change.
|
|
54
|
+
*/
|
|
55
|
+
export function currencyFractionDigits(currency: string): number {
|
|
56
|
+
const cached = fractionDigitsByCurrency.get(currency);
|
|
57
|
+
if (cached !== undefined) return cached;
|
|
58
|
+
let digits = DEFAULT_FRACTION_DIGITS;
|
|
59
|
+
try {
|
|
60
|
+
// `maximumFractionDigits` is typed `number | undefined` on
|
|
61
|
+
// `ResolvedNumberFormatOptions` — it is absent when a format resolves with
|
|
62
|
+
// significant-digit rounding instead of fraction-digit rounding, which a
|
|
63
|
+
// bare `style: 'currency'` never does. Coalesced rather than asserted so
|
|
64
|
+
// the value is a number by construction and not by claim.
|
|
65
|
+
digits =
|
|
66
|
+
new Intl.NumberFormat(undefined, { style: 'currency', currency }).resolvedOptions()
|
|
67
|
+
.maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
|
|
68
|
+
} catch {
|
|
69
|
+
// Not a currency code — keep the historical width and let the caller's own
|
|
70
|
+
// formatting attempt surface the bad code.
|
|
71
|
+
}
|
|
72
|
+
fractionDigitsByCurrency.set(currency, digits);
|
|
73
|
+
return digits;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Display symbols, memoized per **(locale, currency)** pair.
|
|
78
|
+
*
|
|
79
|
+
* The pair, not the code — and this is the one place this helper deliberately
|
|
80
|
+
* parts company with {@link currencyFractionDigits} above. The digit count is
|
|
81
|
+
* locale-independent (measured across eight locales, see that map's note), so
|
|
82
|
+
* its probe drops the locale and keys the cache on the code alone. The SYMBOL
|
|
83
|
+
* is genuinely locale-dependent, measured on the same runtime:
|
|
84
|
+
*
|
|
85
|
+
* - `USD` → `$` at `en` / `en-US` / `de-DE` / `ja-JP`, `US$` at `en-GB` and
|
|
86
|
+
* `zh-CN`, `$US` at `fr-FR`;
|
|
87
|
+
* - `JPY` → U+00A5 at `en`, the FULLWIDTH U+FFE5 at `ja-JP`, `JP¥` at
|
|
88
|
+
* `en-GB`, and the bare code `JPY` at `fr-FR`.
|
|
89
|
+
*
|
|
90
|
+
* Copying the code-only cache key would therefore let whichever locale asked
|
|
91
|
+
* first answer for every locale after it, for the life of the process — a
|
|
92
|
+
* silent wrong answer rather than a crash, so it is pinned by test.
|
|
93
|
+
*
|
|
94
|
+
* `|` is safe as the key separator: it appears in neither a BCP-47 tag nor an
|
|
95
|
+
* ISO 4217 code, so no two distinct pairs can collide on one key.
|
|
96
|
+
*/
|
|
97
|
+
const symbolByLocaleAndCurrency = new Map<string, string>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The string ICU renders in place of `currency` for a reader in `locale` — `$`
|
|
101
|
+
* for USD at `en`, `€` for EUR, U+00A5 for JPY — taken from the `currency` part
|
|
102
|
+
* of the very format the display path already uses.
|
|
103
|
+
*
|
|
104
|
+
* This is the ONE channel for the symbol (objectui#4414). It is not an
|
|
105
|
+
* independent opinion about how a currency should look: it is the same
|
|
106
|
+
* `Intl.NumberFormat(locale, { style: 'currency', currency })` that
|
|
107
|
+
* `formatDisplayNumber` formats amounts with, read one part at a time. So a
|
|
108
|
+
* widget that renders an amount in one mode and a bare symbol in another cannot
|
|
109
|
+
* disagree with itself — which is exactly what the hand-written
|
|
110
|
+
* `currency === 'USD' ? '$' : currency` ternary did, showing `JPY` beside a
|
|
111
|
+
* readonly `¥1,235`.
|
|
112
|
+
*
|
|
113
|
+
* Falls back to `currency` itself — the code as given — in the three cases
|
|
114
|
+
* where ICU cannot answer, because a bare code is a legitimate adornment (it is
|
|
115
|
+
* what ICU itself returns for KWD/BHD/CHF/ISK, and what
|
|
116
|
+
* `formatAmount`'s own bad-code branch renders beside it):
|
|
117
|
+
*
|
|
118
|
+
* 1. an invalid currency code — `Intl` throws `RangeError: Invalid currency
|
|
119
|
+
* code`;
|
|
120
|
+
* 2. a malformed locale tag (`en_US` with an underscore, say) — `Intl` throws
|
|
121
|
+
* `RangeError: Incorrect locale information provided`. `currencyFractionDigits`
|
|
122
|
+
* dodged this failure mode by dropping the locale from its probe; this
|
|
123
|
+
* helper needs the locale, so it catches instead. Neither throw may escape:
|
|
124
|
+
* this runs during render, so an escaped `RangeError` is a blank widget.
|
|
125
|
+
* 3. no `currency` part in the output at all — not observed for
|
|
126
|
+
* `style: 'currency'`, but coalesced rather than asserted.
|
|
127
|
+
*/
|
|
128
|
+
export function currencySymbol(currency: string, locale: string): string {
|
|
129
|
+
const key = `${locale}|${currency}`;
|
|
130
|
+
const cached = symbolByLocaleAndCurrency.get(key);
|
|
131
|
+
if (cached !== undefined) return cached;
|
|
132
|
+
let symbol = currency;
|
|
133
|
+
try {
|
|
134
|
+
// The formatted value is irrelevant — only the `currency` part is read — so
|
|
135
|
+
// the cheapest deterministic input is used.
|
|
136
|
+
const parts = new Intl.NumberFormat(locale, { style: 'currency', currency }).formatToParts(0);
|
|
137
|
+
symbol = parts.find((part) => part.type === 'currency')?.value ?? currency;
|
|
138
|
+
} catch {
|
|
139
|
+
// Bad code or bad locale — keep the code, and let the caller's own
|
|
140
|
+
// formatting attempt surface the real problem.
|
|
141
|
+
}
|
|
142
|
+
symbolByLocaleAndCurrency.set(key, symbol);
|
|
143
|
+
return symbol;
|
|
144
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
3
|
import { describe, it, expect, vi } from 'vitest';
|
|
3
4
|
import { DateField } from './widgets/DateField';
|
|
@@ -54,10 +55,15 @@ describe('Date/Time Widgets', () => {
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
it('calls onChange when value changes', () => {
|
|
57
|
-
const
|
|
58
|
+
const onChange = vi.fn();
|
|
59
|
+
const { container } = render(<DateTimeField {...baseProps} onChange={onChange} />);
|
|
58
60
|
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
59
61
|
fireEvent.change(input, { target: { value: '2023-01-01T12:00' } });
|
|
60
|
-
|
|
62
|
+
// The control speaks zone-less local wall clock; form state holds
|
|
63
|
+
// ISO, the shape the API hands back and `toDateTimeInputValue`
|
|
64
|
+
// reads (objectui#3127). Storing the naive string instead would put
|
|
65
|
+
// read and write on different timezone bases.
|
|
66
|
+
expect(onChange).toHaveBeenCalledWith(new Date('2023-01-01T12:00').toISOString());
|
|
61
67
|
});
|
|
62
68
|
|
|
63
69
|
it('renders formatted datetime in readonly mode', () => {
|
|
@@ -71,6 +77,92 @@ describe('Date/Time Widgets', () => {
|
|
|
71
77
|
});
|
|
72
78
|
});
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* objectui#3127 — the record's stored ISO value reached the native control
|
|
82
|
+
* verbatim, and `<input type="date">` / `<input type="datetime-local">`
|
|
83
|
+
* reject anything but their one exact shape SILENTLY: the attribute lands
|
|
84
|
+
* in the DOM, `.value` reads back `''`, and the user sees an empty box on a
|
|
85
|
+
* field that has a value. `.value` (not `getAttribute('value')`) is
|
|
86
|
+
* therefore the only assertion that can see this bug at all.
|
|
87
|
+
*/
|
|
88
|
+
describe('existing values round-trip through the native controls (#3127)', () => {
|
|
89
|
+
// The seeded showcase value. Whole minutes and no sub-second part, so
|
|
90
|
+
// the control (which has minute resolution) can represent it exactly
|
|
91
|
+
// and the round trip below is lossless in every timezone.
|
|
92
|
+
const storedIso = '2026-06-17T14:30:00.000Z';
|
|
93
|
+
|
|
94
|
+
it('DateTimeField shows a stored ISO instant instead of an empty box', () => {
|
|
95
|
+
const { container } = render(<DateTimeField {...baseProps} value={storedIso} />);
|
|
96
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
97
|
+
|
|
98
|
+
expect(input.value).not.toBe('');
|
|
99
|
+
expect(input.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
|
100
|
+
// The shown wall clock denotes the SAME instant as the record —
|
|
101
|
+
// asserted this way so the test holds in every timezone, which a
|
|
102
|
+
// hardcoded "2026-06-17T22:30" would not.
|
|
103
|
+
expect(new Date(input.value).getTime()).toBe(new Date(storedIso).getTime());
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('DateTimeField writes back on the same basis it reads', () => {
|
|
107
|
+
// Read and write must share one timezone basis. Displaying local
|
|
108
|
+
// while storing the naive local string is the worse bug: the value
|
|
109
|
+
// would silently drift by the viewer's offset on every edit.
|
|
110
|
+
const onChange = vi.fn();
|
|
111
|
+
const { container } = render(
|
|
112
|
+
<DateTimeField {...baseProps} onChange={onChange} value={storedIso} />
|
|
113
|
+
);
|
|
114
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
115
|
+
|
|
116
|
+
fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
|
|
117
|
+
|
|
118
|
+
const written = onChange.mock.calls[0][0];
|
|
119
|
+
// Zone-QUALIFIED, which the control's own output is not. Storing
|
|
120
|
+
// the raw `2026-06-18T09:15` is what puts the write on a different
|
|
121
|
+
// basis from the read: the server has no way to know whose 09:15
|
|
122
|
+
// it is, and resolves it to a different instant than the one the
|
|
123
|
+
// user picked.
|
|
124
|
+
expect(written).toMatch(/(Z|[+-]\d{2}:\d{2})$/);
|
|
125
|
+
expect(new Date(written).getTime()).toBe(new Date('2026-06-18T09:15').getTime());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('DateTimeField returning to the shown minute restores the exact stored value', () => {
|
|
129
|
+
// Guards the drift this fix could otherwise introduce: if the two
|
|
130
|
+
// conversions disagreed by the viewer's UTC offset, navigating away
|
|
131
|
+
// from a value and back would land somewhere else, and every open →
|
|
132
|
+
// fiddle → undo would quietly move the record.
|
|
133
|
+
// Held in real state, because a `vi.fn()` onChange would leave the
|
|
134
|
+
// control pinned to its initial prop and React would swallow the
|
|
135
|
+
// second edit as a no-op — the test would pass without exercising
|
|
136
|
+
// anything.
|
|
137
|
+
const seen: string[] = [];
|
|
138
|
+
const Host = () => {
|
|
139
|
+
const [v, setV] = React.useState<string>(storedIso);
|
|
140
|
+
seen.push(v);
|
|
141
|
+
return <DateTimeField {...baseProps} value={v} onChange={setV as any} />;
|
|
142
|
+
};
|
|
143
|
+
const { container } = render(<Host />);
|
|
144
|
+
const input = container.querySelector('input[type="datetime-local"]') as HTMLInputElement;
|
|
145
|
+
const shown = input.value;
|
|
146
|
+
|
|
147
|
+
fireEvent.change(input, { target: { value: '2026-06-18T09:15' } });
|
|
148
|
+
fireEvent.change(input, { target: { value: shown } });
|
|
149
|
+
|
|
150
|
+
expect(seen[seen.length - 1]).toBe(storedIso);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('DateField shows the stored calendar day, ISO-shaped or not', () => {
|
|
154
|
+
// Both spellings the API may hand back for a `date` field. The day
|
|
155
|
+
// must survive verbatim — re-parsing the bare form as UTC midnight
|
|
156
|
+
// would move it to the 16th west of Greenwich.
|
|
157
|
+
for (const stored of ['2026-06-17', '2026-06-17T00:00:00.000Z']) {
|
|
158
|
+
const { container, unmount } = render(<DateField {...baseProps} value={stored} />);
|
|
159
|
+
const input = container.querySelector('input[type="date"]') as HTMLInputElement;
|
|
160
|
+
expect(input.value).toBe('2026-06-17');
|
|
161
|
+
unmount();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
74
166
|
describe('TimeField', () => {
|
|
75
167
|
it('renders time input in edit mode', () => {
|
|
76
168
|
const { container } = render(<TimeField {...baseProps} />);
|