@object-ui/fields 17.3.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
* The props that make a group-labelled widget's rendered surface the thing its
|
|
10
|
+
* host label actually NAMES, on the surfaces that are not controls
|
|
11
|
+
* (objectui#3990).
|
|
12
|
+
*
|
|
13
|
+
* ## What it is for
|
|
14
|
+
*
|
|
15
|
+
* A widget declared `labelling: 'group'` (objectui#3961) is told by the form
|
|
16
|
+
* renderer: your label publishes an `id` and drops its `for`, so name yourself
|
|
17
|
+
* by IDREF. The renderer hands down exactly two facts that address the field as
|
|
18
|
+
* a WHOLE:
|
|
19
|
+
*
|
|
20
|
+
* - `id` — the host's control id (`…-form-item`), the id the label's `for`
|
|
21
|
+
* used to point at;
|
|
22
|
+
* - `aria-labelledby` — the IDREF of that visible label.
|
|
23
|
+
*
|
|
24
|
+
* Every editable branch already consumes them inside its wider `toDomProps`
|
|
25
|
+
* spread. The branches that returned EARLY did not — a field-level
|
|
26
|
+
* `readonly: true`, or an option list with zero offered options — so the label
|
|
27
|
+
* published an id that no element in the document referenced. Measured on
|
|
28
|
+
* `origin/main`, one field per row, reading the host label against the DOM:
|
|
29
|
+
*
|
|
30
|
+
* ```
|
|
31
|
+
* multiselect readonly+value consumers=0 byLabelText=0 namedByRole=[]
|
|
32
|
+
* multiselect readonly+empty consumers=0 byLabelText=0 namedByRole=[]
|
|
33
|
+
* multiselect zeroOptions consumers=0 byLabelText=0 namedByRole=[]
|
|
34
|
+
* multiselect editable consumers=1 byLabelText=1 namedByRole=[group:1]
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* All seven group-labelled types measured the same, in every readonly state.
|
|
38
|
+
* This is quieter than the dangling `for` #3961 replaced: a `for` that resolves
|
|
39
|
+
* to nothing can at least be swept for, while "a label published an id and
|
|
40
|
+
* nobody consumed it" looks like healthy markup.
|
|
41
|
+
*
|
|
42
|
+
* ## Why this narrow pair, and not the whole `toDomProps` result
|
|
43
|
+
*
|
|
44
|
+
* Everything else in the DOM pass-through addresses a CONTROL, and a readonly
|
|
45
|
+
* display has none: `aria-describedby` / `aria-required` are announced when
|
|
46
|
+
* focus lands on something focusable, `disabled` / `tabIndex` / the focus
|
|
47
|
+
* handlers only mean something on an interactive element, and `name` is
|
|
48
|
+
* DOM-legal on form controls only — on a `div` it is exactly the leak
|
|
49
|
+
* objectui#3291 sweeps for. Spreading the full result would also put the host's
|
|
50
|
+
* `className` on top of the widget's own, because the widgets whose readonly
|
|
51
|
+
* surface is a placeholder or a plain container (`EmptyValue`, `FileField`,
|
|
52
|
+
* `AddressField`) keep `className` inside `props`.
|
|
53
|
+
*
|
|
54
|
+
* ## Why `role` travels with the two keys
|
|
55
|
+
*
|
|
56
|
+
* `aria-labelledby` on a role-less `div` / `span` names NOTHING — `generic`
|
|
57
|
+
* prohibits an author name — so the pair is inert without a role that supports
|
|
58
|
+
* naming. `group` is the one that fits a readonly surface: it names a set of
|
|
59
|
+
* values (chips, checked labels, stars, file names, a formatted address)
|
|
60
|
+
* without promising the interactivity `textbox` or `radiogroup` would. It is
|
|
61
|
+
* deliberately NOT the role the same widget answers with while editable:
|
|
62
|
+
* `RadioField`'s readonly branch renders the chosen label as text with no radios
|
|
63
|
+
* left in it, and `FileField`'s renders file names with no dropzone button.
|
|
64
|
+
*
|
|
65
|
+
* The role is emitted ONLY when a host actually named the field, mirroring
|
|
66
|
+
* every editable branch's `isLabelledGroup` test: standalone rendering (the
|
|
67
|
+
* grid's inline cell editor, a bare SDUI node) hands down neither key, so every
|
|
68
|
+
* value here is `undefined`, React emits no attribute, and that markup stays
|
|
69
|
+
* byte-identical to what it was.
|
|
70
|
+
*/
|
|
71
|
+
export interface HostGroupProps {
|
|
72
|
+
/** The host's control id — the id its label would have pointed `for` at. */
|
|
73
|
+
id?: string;
|
|
74
|
+
/** IDREF of the host's visible label. */
|
|
75
|
+
'aria-labelledby'?: string;
|
|
76
|
+
/** `'group'` when — and only when — a host named this surface. */
|
|
77
|
+
role?: 'group';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Read the two whole-field keys out of a widget's leftover props, plus the role
|
|
81
|
+
* that makes them mean something. See {@link HostGroupProps}.
|
|
82
|
+
*
|
|
83
|
+
* Routed through {@link toDomProps} rather than reading `props` directly so the
|
|
84
|
+
* pair keeps ONE gate: those keys reach an element only if the DOM pass-through
|
|
85
|
+
* whitelist still forwards them, and its two compile-time assertions bind that
|
|
86
|
+
* whitelist to the props contract in both directions.
|
|
87
|
+
*/
|
|
88
|
+
export declare function toHostGroupProps(props: {
|
|
89
|
+
id?: string;
|
|
90
|
+
'aria-labelledby'?: string;
|
|
91
|
+
}): HostGroupProps;
|
|
@@ -46,8 +46,14 @@ export interface UseRecordQueryOptions {
|
|
|
46
46
|
* `$filter` — already merged by the caller (base `lookup_filters`, dependent
|
|
47
47
|
* lookup chain, candidate hygiene like `banned != true`, …). Compared by
|
|
48
48
|
* value, so a referentially-new-but-equal object each render will not loop.
|
|
49
|
+
*
|
|
50
|
+
* Either the `QueryParams.$filter` record form or a lowered ObjectQL AST node
|
|
51
|
+
* (an array), since the picker's merge now produces both (#3831). Typed
|
|
52
|
+
* `unknown` rather than `Record< string, any >` so an array cannot slip
|
|
53
|
+
* through a type that silently accepts it; emptiness is decided by
|
|
54
|
+
* {@link hasFilter}, not by `Object.keys`.
|
|
49
55
|
*/
|
|
50
|
-
filter?:
|
|
56
|
+
filter?: unknown;
|
|
51
57
|
/** `$expand` — related entities to include (e.g. `['primary_business_unit_id']`). */
|
|
52
58
|
expand?: string[];
|
|
53
59
|
/** `$searchFields` — narrow the server searchable set (ADR-0061). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.5.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,31 +24,35 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@objectstack/spec": "^17.0.0-rc.
|
|
27
|
+
"@objectstack/spec": "^17.0.0-rc.6",
|
|
28
28
|
"clsx": "^2.1.1",
|
|
29
|
-
"lucide-react": "^1.
|
|
29
|
+
"lucide-react": "^1.29.0",
|
|
30
30
|
"react-markdown": "^10.1.0",
|
|
31
31
|
"rehype-sanitize": "^6.0.0",
|
|
32
32
|
"remark-gfm": "^4.0.1",
|
|
33
33
|
"tailwind-merge": "^3.6.0",
|
|
34
|
-
"@object-ui/components": "17.
|
|
35
|
-
"@object-ui/core": "17.
|
|
36
|
-
"@object-ui/i18n": "17.
|
|
37
|
-
"@object-ui/providers": "17.
|
|
38
|
-
"@object-ui/react": "17.
|
|
39
|
-
"@object-ui/types": "17.
|
|
34
|
+
"@object-ui/components": "17.5.0",
|
|
35
|
+
"@object-ui/core": "17.5.0",
|
|
36
|
+
"@object-ui/i18n": "17.5.0",
|
|
37
|
+
"@object-ui/providers": "17.5.0",
|
|
38
|
+
"@object-ui/react": "17.5.0",
|
|
39
|
+
"@object-ui/types": "17.5.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"react": "^18.0.0 || ^19.0.0",
|
|
43
43
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
46
47
|
"@types/react": "19.2.18",
|
|
47
48
|
"@types/react-dom": "19.2.4",
|
|
48
49
|
"@vitejs/plugin-react": "^6.0.5",
|
|
50
|
+
"postcss": "^8.5.26",
|
|
51
|
+
"tailwindcss": "^4.3.3",
|
|
49
52
|
"typescript": "^6.0.3",
|
|
50
|
-
"vite": "^8.2.
|
|
51
|
-
"vite-plugin-dts": "^5.0.3"
|
|
53
|
+
"vite": "^8.2.1",
|
|
54
|
+
"vite-plugin-dts": "^5.0.3",
|
|
55
|
+
"@object-ui/test-support": "17.4.0"
|
|
52
56
|
},
|
|
53
57
|
"publishConfig": {
|
|
54
58
|
"access": "public"
|
|
@@ -77,9 +81,9 @@
|
|
|
77
81
|
},
|
|
78
82
|
"homepage": "https://www.objectui.org/docs/fields",
|
|
79
83
|
"scripts": {
|
|
80
|
-
"build": "tsc && vite build",
|
|
84
|
+
"build": "tsc && vite build && node scripts/build-css.mjs",
|
|
81
85
|
"clean": "rm -rf dist",
|
|
82
|
-
"type-check": "tsc --noEmit",
|
|
86
|
+
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
83
87
|
"test": "vitest run",
|
|
84
88
|
"lint": "eslint ."
|
|
85
89
|
}
|
package/src/FieldEditWidget.tsx
CHANGED
|
@@ -174,12 +174,19 @@ const COMPACT_EDIT_TYPES = new Set<string>(['lookup', 'master_detail', 'user', '
|
|
|
174
174
|
* form uses — as a controlled `{ value, onChange, field }` input. Returns
|
|
175
175
|
* `null` for types without a registered widget so the caller can fall back to
|
|
176
176
|
* a plain editor.
|
|
177
|
+
*
|
|
178
|
+
* `autoFocus` is forwarded because an inline-edit host enters edit mode ON a
|
|
179
|
+
* field and expects the caret to land there (objectui#4220 — the detail page's
|
|
180
|
+
* delegation): each widget's own `toDomProps` whitelist already carries it onto
|
|
181
|
+
* the real focusable control, so nothing here needs to know which element that
|
|
182
|
+
* is. A host that passes nothing is unaffected.
|
|
177
183
|
*/
|
|
178
184
|
export function FieldEditWidget({
|
|
179
185
|
field,
|
|
180
186
|
value,
|
|
181
187
|
onChange,
|
|
182
188
|
readonly,
|
|
189
|
+
autoFocus,
|
|
183
190
|
}: FieldWidgetComponentProps<any>): React.ReactElement | null {
|
|
184
191
|
const resolved = field?.type ? resolveInlineEditType(field.type) : undefined;
|
|
185
192
|
const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined;
|
|
@@ -187,5 +194,14 @@ export function FieldEditWidget({
|
|
|
187
194
|
// `compact` is a declared widget prop (objectui#3221 closed this type), so
|
|
188
195
|
// the spread no longer needs an `any` escape hatch to get past it.
|
|
189
196
|
const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {};
|
|
190
|
-
return
|
|
197
|
+
return (
|
|
198
|
+
<Widget
|
|
199
|
+
field={field}
|
|
200
|
+
value={value}
|
|
201
|
+
onChange={onChange}
|
|
202
|
+
readonly={readonly}
|
|
203
|
+
autoFocus={autoFocus}
|
|
204
|
+
{...compactProps}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
191
207
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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#4037 (migrated from objectstack#5019): a `Field.address` value read
|
|
11
|
+
* as stringified JSON on the record detail page — `{"street":"中策路 1 号",…}` —
|
|
12
|
+
* because the DISPLAY registry mapped `address` to `JsonCellRenderer`, while
|
|
13
|
+
* `location` next to it in the same field group had a real formatter. The
|
|
14
|
+
* create/edit dialog rendered the same field correctly, so the gap was
|
|
15
|
+
* display-side only.
|
|
16
|
+
*
|
|
17
|
+
* Every case below resolves its renderer through `getCellRenderer` — the
|
|
18
|
+
* display resolver the detail page itself calls — rather than importing
|
|
19
|
+
* `AddressCellRenderer` directly, so these tests fail on the JSON signature if
|
|
20
|
+
* the registry entry ever regresses, not merely if the formatter changes.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import React from 'react';
|
|
25
|
+
import { render, screen } from '@testing-library/react';
|
|
26
|
+
import '@testing-library/jest-dom';
|
|
27
|
+
import { getCellRenderer, resolveCellRendererType } from '../index';
|
|
28
|
+
|
|
29
|
+
/** Resolve + render exactly the way `DetailSection` builds a read-mode value. */
|
|
30
|
+
function renderThroughDisplayRegistry(type: string, value: unknown) {
|
|
31
|
+
const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type);
|
|
32
|
+
return render(<Renderer value={value as any} field={{ type } as any} />);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The stringified-object signature this issue is about. */
|
|
36
|
+
function expectNoJsonSignature(text: string | null) {
|
|
37
|
+
expect(text).not.toMatch(/[{}]/);
|
|
38
|
+
expect(text).not.toMatch(/"(street|city|state|postalCode|country)"/);
|
|
39
|
+
expect(text).not.toMatch(/undefined/);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The record from the issue's evidence table. */
|
|
43
|
+
const ISSUE_ADDRESS = {
|
|
44
|
+
street: '中策路 1 号',
|
|
45
|
+
city: '杭州',
|
|
46
|
+
state: '浙江',
|
|
47
|
+
postalCode: '310000',
|
|
48
|
+
country: '中国',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('address display renderer (objectui#4037)', () => {
|
|
52
|
+
it('renders a populated address as a formatted line, not stringified JSON', () => {
|
|
53
|
+
const { container } = renderThroughDisplayRegistry('address', ISSUE_ADDRESS);
|
|
54
|
+
expectNoJsonSignature(container.textContent);
|
|
55
|
+
expect(
|
|
56
|
+
screen.getByText('中策路 1 号, 杭州, 浙江 310000, 中国'),
|
|
57
|
+
).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('resolves `address` through the display registry the way it resolves `location`', () => {
|
|
61
|
+
// The issue's decisive comparison: one address field beside one location
|
|
62
|
+
// field, same record, same page. Both must be formatted; neither may fall
|
|
63
|
+
// through to object stringification.
|
|
64
|
+
const address = renderThroughDisplayRegistry('address', ISSUE_ADDRESS);
|
|
65
|
+
const location = renderThroughDisplayRegistry('location', { lat: 30.2741, lng: 120.1551 });
|
|
66
|
+
expectNoJsonSignature(address.container.textContent);
|
|
67
|
+
expectNoJsonSignature(location.container.textContent);
|
|
68
|
+
expect(location.container.textContent).toContain('30.2741');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('carries the full value: every authored sub-field appears', () => {
|
|
72
|
+
const { container } = renderThroughDisplayRegistry('address', ISSUE_ADDRESS);
|
|
73
|
+
for (const part of Object.values(ISSUE_ADDRESS)) {
|
|
74
|
+
expect(container.textContent).toContain(part);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('address display renderer — partial values render gracefully', () => {
|
|
80
|
+
it('drops missing parts instead of leaving dangling separators', () => {
|
|
81
|
+
const { container } = renderThroughDisplayRegistry('address', {
|
|
82
|
+
street: '中策路 1 号',
|
|
83
|
+
city: '杭州',
|
|
84
|
+
});
|
|
85
|
+
expect(screen.getByText('中策路 1 号, 杭州')).toBeInTheDocument();
|
|
86
|
+
expectNoJsonSignature(container.textContent);
|
|
87
|
+
expect(container.textContent).not.toMatch(/,\s*$/);
|
|
88
|
+
expect(container.textContent).not.toMatch(/,\s*,/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('renders a street-only address as just the street', () => {
|
|
92
|
+
renderThroughDisplayRegistry('address', { street: '中策路 1 号' });
|
|
93
|
+
expect(screen.getByText('中策路 1 号')).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps state and postal code in one group, either alone', () => {
|
|
97
|
+
const stateOnly = renderThroughDisplayRegistry('address', { city: 'SF', state: 'CA' });
|
|
98
|
+
expect(stateOnly.container.textContent).toBe('SF, CA');
|
|
99
|
+
const postalOnly = renderThroughDisplayRegistry('address', { city: 'SF', postalCode: '94102' });
|
|
100
|
+
expect(postalOnly.container.textContent).toBe('SF, 94102');
|
|
101
|
+
const both = renderThroughDisplayRegistry('address', {
|
|
102
|
+
city: 'SF',
|
|
103
|
+
state: 'CA',
|
|
104
|
+
postalCode: '94102',
|
|
105
|
+
});
|
|
106
|
+
expect(both.container.textContent).toBe('SF, CA 94102');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('ignores whitespace-only parts rather than spacing over them', () => {
|
|
110
|
+
const { container } = renderThroughDisplayRegistry('address', {
|
|
111
|
+
street: ' ',
|
|
112
|
+
city: '杭州',
|
|
113
|
+
country: '',
|
|
114
|
+
});
|
|
115
|
+
expect(container.textContent).toBe('杭州');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reads a legacy `zipCode` postal code (objectstack#5143 data)', () => {
|
|
119
|
+
// Records written by builds up to 17.0.0-rc.1 stored the postal code under
|
|
120
|
+
// `zipCode`. The input widget reads it; the detail page must not drop it.
|
|
121
|
+
const { container } = renderThroughDisplayRegistry('address', {
|
|
122
|
+
city: 'San Francisco',
|
|
123
|
+
state: 'CA',
|
|
124
|
+
zipCode: '94102',
|
|
125
|
+
});
|
|
126
|
+
expect(container.textContent).toBe('San Francisco, CA 94102');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('address display renderer — nothing is silently swallowed', () => {
|
|
131
|
+
it('shows the empty placeholder for a null value and for `{}`', () => {
|
|
132
|
+
const nullValue = renderThroughDisplayRegistry('address', null);
|
|
133
|
+
expect(nullValue.container.querySelector('[data-slot="empty-value"]')).not.toBeNull();
|
|
134
|
+
const emptyObject = renderThroughDisplayRegistry('address', {});
|
|
135
|
+
expect(emptyObject.container.querySelector('[data-slot="empty-value"]')).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps an unrecognized shape visible as JSON rather than blanking it', () => {
|
|
139
|
+
// Strictly better than today, never worse: a value the formatter cannot
|
|
140
|
+
// read retains the current compact-JSON rendering instead of disappearing.
|
|
141
|
+
const { container } = renderThroughDisplayRegistry('address', { unexpected: 'data' });
|
|
142
|
+
expect(container.textContent).toContain('unexpected');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('passes a plain string address straight through', () => {
|
|
146
|
+
renderThroughDisplayRegistry('address', '中策路 1 号, 杭州');
|
|
147
|
+
expect(screen.getByText('中策路 1 号, 杭州')).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('address display renderer — controls (unchanged surfaces)', () => {
|
|
152
|
+
it('leaves `location` and `geolocation` formatting untouched', () => {
|
|
153
|
+
const location = renderThroughDisplayRegistry('location', { lat: 30.2741, lng: 120.1551 });
|
|
154
|
+
expect(location.container.textContent).toBe('30.2741, 120.1551');
|
|
155
|
+
const geo = renderThroughDisplayRegistry('geolocation', { latitude: 30.2741, longitude: 120.1551 });
|
|
156
|
+
expect(geo.container.textContent).toBe('30.2741, 120.1551');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('leaves genuinely structural types stringifying as JSON', () => {
|
|
160
|
+
// `address` moved out of the JSON bucket; `json` / `object` stay in it.
|
|
161
|
+
for (const type of ['json', 'object', 'composite', 'record']) {
|
|
162
|
+
const { container } = renderThroughDisplayRegistry(type, { a: 1 });
|
|
163
|
+
expect(container.textContent).toBe('{"a":1}');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
});
|