@object-ui/fields 17.1.0 → 17.3.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 +786 -0
- package/dist/FieldEditWidget.d.ts +2 -2
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -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 +8 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2882 -2569
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +2 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +20 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- 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/TextAreaField.d.ts +45 -6
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- 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/file-value.d.ts +20 -33
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +215 -4
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/FieldEditWidget.tsx +6 -4
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +142 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/index.tsx +77 -27
- package/src/widgets/AddressField.tsx +32 -13
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +10 -3
- package/src/widgets/BooleanField.tsx +5 -5
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +35 -18
- package/src/widgets/CodeField.tsx +10 -3
- package/src/widgets/ColorField.tsx +12 -3
- package/src/widgets/CurrencyField.tsx +6 -5
- package/src/widgets/DateField.tsx +4 -4
- package/src/widgets/DateTimeField.tsx +4 -4
- package/src/widgets/EmailField.tsx +6 -6
- package/src/widgets/FileField.tsx +14 -4
- package/src/widgets/FilterConditionField.tsx +14 -3
- package/src/widgets/FormulaField.tsx +3 -3
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +22 -7
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +4 -4
- package/src/widgets/ImageField.tsx +10 -3
- package/src/widgets/LocationField.tsx +10 -3
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +50 -15
- package/src/widgets/MasterDetailField.tsx +3 -3
- package/src/widgets/MultiSelectField.tsx +34 -18
- package/src/widgets/NumberField.tsx +5 -5
- package/src/widgets/ObjectField.tsx +18 -9
- package/src/widgets/ObjectRefField.tsx +14 -4
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +5 -5
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +6 -5
- package/src/widgets/PhoneField.tsx +6 -6
- package/src/widgets/QRCodeField.tsx +10 -3
- package/src/widgets/RadioField.tsx +28 -18
- package/src/widgets/RatingField.tsx +19 -4
- package/src/widgets/RecipientPickerField.tsx +22 -4
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +152 -25
- package/src/widgets/SelectField.tsx +53 -22
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +3 -3
- package/src/widgets/SummaryField.tsx +3 -3
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +25 -4
- package/src/widgets/TextAreaField.tsx +172 -94
- package/src/widgets/TextField.tsx +5 -5
- package/src/widgets/TimeField.tsx +4 -4
- package/src/widgets/UrlField.tsx +6 -6
- package/src/widgets/UserField.tsx +3 -3
- package/src/widgets/VectorField.tsx +3 -3
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/file-value.ts +21 -5
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +230 -7
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
package/dist/widgets/types.d.ts
CHANGED
|
@@ -1,17 +1,228 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { AriaAttributes, FocusEventHandler, MouseEventHandler } from 'react';
|
|
2
|
+
import { DependsOnInput, FieldMetadata } from '@object-ui/types';
|
|
3
|
+
/**
|
|
4
|
+
* DOM pass-through: what a widget's `...props` spread may legitimately put on
|
|
5
|
+
* the element it renders.
|
|
6
|
+
*
|
|
7
|
+
* A NAMED type rather than an inline block of {@link FieldWidgetComponentProps}
|
|
8
|
+
* so the compiler can bind it to its runtime executor in BOTH directions
|
|
9
|
+
* (objectui#3291). `toDomProps` asserts:
|
|
10
|
+
*
|
|
11
|
+
* - every key it forwards is declared on the widget contract — deleting one
|
|
12
|
+
* here without deleting it there is a compile error;
|
|
13
|
+
* - every key of THIS type is one it forwards — adding one here without
|
|
14
|
+
* adding it to `DOM_PASS_THROUGH_KEYS` is a compile error too.
|
|
15
|
+
*
|
|
16
|
+
* The second direction is the one that matters for the failure mode this repo
|
|
17
|
+
* treats as first-class: DECLARED BUT NOT DELIVERED (objectui#3290's
|
|
18
|
+
* `aria-required` that never reached a control, objectui#3222's validation slot
|
|
19
|
+
* nobody produced). Without it, a key added here would type-check, read as
|
|
20
|
+
* supported, and silently never reach the DOM — and the leak test cannot see
|
|
21
|
+
* that class of bug, because it looks for attributes that ARRIVE, not for ones
|
|
22
|
+
* that go missing.
|
|
23
|
+
*
|
|
24
|
+
* `className` and `disabled` are deliberately NOT here. They are DOM-legal and
|
|
25
|
+
* `toDomProps` does forward them, but they are declared on the controlled-input
|
|
26
|
+
* block of {@link FieldWidgetComponentProps} because widgets also INTERPRET
|
|
27
|
+
* them (className is composed with the widget's own classes; disabled is OR-ed
|
|
28
|
+
* with readonly). They are therefore bound in the forward direction only.
|
|
29
|
+
*
|
|
30
|
+
* `AriaAttributes` and the `data-${string}` family are matched by prefix at
|
|
31
|
+
* runtime rather than key-by-key, so they are intersected in separately.
|
|
32
|
+
*
|
|
33
|
+
* Adding a key here is a contract change: say who produces it and who reads it.
|
|
34
|
+
*/
|
|
35
|
+
export type FieldWidgetDomProps = {
|
|
36
|
+
id?: string;
|
|
37
|
+
/** react-hook-form's field name, spread in by the form renderer. */
|
|
38
|
+
name?: string;
|
|
39
|
+
autoFocus?: boolean;
|
|
40
|
+
tabIndex?: number;
|
|
41
|
+
onBlur?: FocusEventHandler<HTMLElement>;
|
|
42
|
+
onFocus?: FocusEventHandler<HTMLElement>;
|
|
43
|
+
onClick?: MouseEventHandler<HTMLElement>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Props every field widget in this package receives at RUNTIME.
|
|
47
|
+
*
|
|
48
|
+
* Named `FieldWidgetComponentProps`, not `FieldWidgetProps` (objectui#3161,
|
|
49
|
+
* objectstack#4115 ledger batch 7), adopting the name `@object-ui/app-shell`
|
|
50
|
+
* settled on for the same split in objectui#3169: `@objectstack/spec/ui` owns
|
|
51
|
+
* `FieldWidgetProps` for the DECLARED widget-plugin contract — a zod object
|
|
52
|
+
* (`FieldWidgetPropsSchema`) a plugin manifest is validated against, with
|
|
53
|
+
* `field` narrowed to `{ name?, label?, type: <the FieldType enum> }` and
|
|
54
|
+
* `readonly` / `required` carrying `.default()`s. This is the React interface
|
|
55
|
+
* the widgets in this directory actually implement, against
|
|
56
|
+
* `@object-ui/types`'s much richer `FieldMetadata`.
|
|
57
|
+
*
|
|
58
|
+
* The two are NOT interchangeable, but they no longer disagree on the
|
|
59
|
+
* validation slot: this type used to call it `errorMessage` while the spec
|
|
60
|
+
* calls it `error`, so a widget written to the published contract read
|
|
61
|
+
* `undefined` forever. objectui#3222 resolved that in the direction the
|
|
62
|
+
* contract points — the slot below IS the spec's `error`, and the form
|
|
63
|
+
* renderer now produces it (see `error`'s own doc comment). Reading
|
|
64
|
+
* `props.required` off this type is still a compile error rather than a
|
|
65
|
+
* silent `any`; that key is deliberately NOT lowered here (see below).
|
|
66
|
+
*
|
|
67
|
+
* ## Why `required` is still absent, though the spec declares it
|
|
68
|
+
*
|
|
69
|
+
* The required MARKER is drawn exactly once, by the form renderer's
|
|
70
|
+
* `<FormLabel>` (the `*` with `aria-label="required"`, which the label
|
|
71
|
+
* association folds into the control's accessible name). Handing `required`
|
|
72
|
+
* to widgets would give that marker a second possible author, and the very
|
|
73
|
+
* next AI-written widget draws its own asterisk — the same double-display
|
|
74
|
+
* failure that keeps the validation TEXT out of the widget below. The a11y
|
|
75
|
+
* state a widget genuinely could carry is `aria-required` on the input, and
|
|
76
|
+
* that needs no new key at all: `AriaAttributes` is already part of this type
|
|
77
|
+
* and every widget already forwards it to its control. Tracked separately;
|
|
78
|
+
* do not add `required` here to "align" without that decision.
|
|
79
|
+
*
|
|
80
|
+
* ## Why there is no `[key: string]: any` (objectui#3221)
|
|
81
|
+
*
|
|
82
|
+
* This type used to end in an index signature, "load-bearing" for the widgets'
|
|
83
|
+
* `...props` spreads. It was also why every drift above was invisible: **a type
|
|
84
|
+
* that claims to have every key can never be reported as missing one** (the
|
|
85
|
+
* objectstack#4075 mechanism). `props.required` and `props.error` were legal
|
|
86
|
+
* reads typed `any` and always `undefined` at runtime; a misspelled prop
|
|
87
|
+
* (`readOnly` for `readonly`, `onchange` for `onChange`) compiled; and any
|
|
88
|
+
* structural/parity comparison against the type was useless *in principle*,
|
|
89
|
+
* which is why the batch-7 symbol guard was the only detector that could see
|
|
90
|
+
* the collision at all.
|
|
91
|
+
*
|
|
92
|
+
* It is replaced by the CLOSED set below: the controlled-input contract, the
|
|
93
|
+
* host plumbing the form renderer genuinely forwards, and a DOM pass-through
|
|
94
|
+
* allowance. `data-*` is an open family by design (it is open in HTML too) and
|
|
95
|
+
* is expressed as a template-literal index signature, which — unlike
|
|
96
|
+
* `[key: string]` — leaves `keyof` finite, so an undeclared prop still fails.
|
|
97
|
+
*
|
|
98
|
+
* ## Why there is no `schema` (objectui#3233)
|
|
99
|
+
*
|
|
100
|
+
* `schema` used to be declared here as a SECOND carrier for what `field`
|
|
101
|
+
* already means — `SchemaRenderer` handed the SDUI node down as `schema`, the
|
|
102
|
+
* form renderer forwarded `schema={props.field || props.schema || props}`
|
|
103
|
+
* *alongside* `field`, and ~30 widgets resolved their config as
|
|
104
|
+
* `field || schema`. One concept, two keys, two producers: exactly the
|
|
105
|
+
* consumer-side tolerance AGENTS.md #0.1 forbids, and exactly the shape that
|
|
106
|
+
* lets an AI-written widget pick the wrong spelling and still "work" under one
|
|
107
|
+
* host while reading `undefined` under another.
|
|
108
|
+
*
|
|
109
|
+
* v17 converged it at the PRODUCERS. `field` is the only carrier a widget ever
|
|
110
|
+
* sees; the SDUI node → `field` translation happens once, in this package's
|
|
111
|
+
* registry adapter (`withFieldCarrier`), not ~30 times in the widgets. Reading
|
|
112
|
+
* `props.schema` is now a compile error rather than a silent second contract.
|
|
113
|
+
*
|
|
114
|
+
* Adding a key here is a contract change: say who produces it and who reads it.
|
|
115
|
+
*/
|
|
116
|
+
export type FieldWidgetComponentProps<T = any> = {
|
|
3
117
|
value: T;
|
|
4
118
|
onChange: (val: T) => void;
|
|
119
|
+
/**
|
|
120
|
+
* The field's metadata, and the SINGLE carrier for it (objectui#3233).
|
|
121
|
+
*
|
|
122
|
+
* Deliberately the looser `@object-ui/types` shape rather than the spec's,
|
|
123
|
+
* to avoid a circular dependency for now.
|
|
124
|
+
*
|
|
125
|
+
* Every host produces it: the form renderer's `renderFieldComponent`, the
|
|
126
|
+
* inline-edit hosts (`FieldEditWidget`), `ActionParamDialog` /
|
|
127
|
+
* `BulkActionDialog`, and — since #3233 — the registry adapter that adapts
|
|
128
|
+
* `SchemaRenderer`'s SDUI node onto this contract (`withFieldCarrier` in
|
|
129
|
+
* `packages/fields/src/withFieldCarrier.tsx`). A widget reads `props.field`
|
|
130
|
+
* and nothing else; there is no second key to check.
|
|
131
|
+
*/
|
|
5
132
|
field: FieldMetadata;
|
|
6
133
|
readonly?: boolean;
|
|
7
134
|
disabled?: boolean;
|
|
8
135
|
className?: string;
|
|
9
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The active validation message for this field, named as
|
|
138
|
+
* `@objectstack/spec/ui`'s `FieldWidgetPropsSchema` names it (objectui#3222).
|
|
139
|
+
*
|
|
140
|
+
* **Producer**: the form renderer, from react-hook-form's
|
|
141
|
+
* `fieldState.error?.message` (`packages/components/src/renderers/form/
|
|
142
|
+
* form.tsx`). Before #3222 nothing in the repo produced it under EITHER
|
|
143
|
+
* spelling, so the seven widgets computing `aria-invalid={!!errorMessage}`
|
|
144
|
+
* were computing it from a permanent `undefined` — `aria-invalid` was never
|
|
145
|
+
* once set and a screen reader was never told the field had failed.
|
|
146
|
+
*
|
|
147
|
+
* **Consumer**: a widget reads this ONLY to drive `aria-invalid` on the
|
|
148
|
+
* control it renders. The message TEXT stays with the form renderer's
|
|
149
|
+
* `<FormMessage/>`; a widget that also renders it double-displays it.
|
|
150
|
+
*/
|
|
151
|
+
error?: string;
|
|
10
152
|
/**
|
|
11
153
|
* Upload widgets (`file`/`image`) fire this when their in-progress state
|
|
12
154
|
* flips, so a host can block submit until a presigned upload settles. Other
|
|
13
155
|
* widgets ignore it.
|
|
14
156
|
*/
|
|
15
157
|
onUploadingChange?: (uploading: boolean) => void;
|
|
16
|
-
|
|
158
|
+
/**
|
|
159
|
+
* DataSource for widgets that query records (lookup / user / object-ref /
|
|
160
|
+
* recipient-picker / grid). Injected by the form renderer for the field
|
|
161
|
+
* types that need it, and passed directly by inline-edit hosts. Option
|
|
162
|
+
* widgets destructure it purely to keep it off their DOM spread.
|
|
163
|
+
*
|
|
164
|
+
* Left structural (`unknown`): `@object-ui/fields` must not depend on a
|
|
165
|
+
* concrete adapter — every consumer narrows it itself.
|
|
166
|
+
*/
|
|
167
|
+
dataSource?: unknown;
|
|
168
|
+
/**
|
|
169
|
+
* Live sibling-field values driving cascading / role-gated options and
|
|
170
|
+
* dependent lookups (ADR-0058, #2215/#2284). The form renderer passes the
|
|
171
|
+
* in-progress record; widgets fall back to `SchemaRendererContext`.
|
|
172
|
+
*/
|
|
173
|
+
dependentValues?: Record<string, unknown>;
|
|
174
|
+
/**
|
|
175
|
+
* Controlling field(s) that gate this field's option list. Normally declared
|
|
176
|
+
* on the field metadata; accepted as a prop so a host can drive the gate for
|
|
177
|
+
* a field it synthesised. Field metadata wins when both are present.
|
|
178
|
+
*/
|
|
179
|
+
dependsOn?: DependsOnInput;
|
|
180
|
+
/**
|
|
181
|
+
* Controlling-field name → its human LABEL, for widgets that have to NAME a
|
|
182
|
+
* sibling field in user-visible copy (the dependent-lookup gate hint).
|
|
183
|
+
*
|
|
184
|
+
* Only the form renderer knows a field's label — the widget sees its own
|
|
185
|
+
* metadata and a `depends_on` list of API names. Without this map the gate
|
|
186
|
+
* sentence interpolated the raw API name into every locale, `en` included
|
|
187
|
+
* ("Select crm_account first"), which is an internal identifier leaking into
|
|
188
|
+
* the UI, not merely an untranslated word (objectstack#5407). This is the
|
|
189
|
+
* lookup-side counterpart of `emptyHint`, which the form already resolves to
|
|
190
|
+
* labels for the fixed-option widgets. A name with no entry falls back to
|
|
191
|
+
* itself, so a host that passes nothing renders exactly what it did before.
|
|
192
|
+
*/
|
|
193
|
+
dependsOnLabels?: Record<string, string>;
|
|
194
|
+
/**
|
|
195
|
+
* Hint shown when an option list cannot be filled — typically a
|
|
196
|
+
* dependency-gated list still waiting on its controlling field (#2284).
|
|
197
|
+
* Forwarded by the form renderer, which resolves the controlling fields to
|
|
198
|
+
* their human LABELS. **When supplied it wins**; the option widgets fall back
|
|
199
|
+
* to their own translated copy only when a host computed none (objectui#3231
|
|
200
|
+
* — they used to discard this prop and always render their own hardcoded
|
|
201
|
+
* English). See `OptionsEmptyState`, the single consumer.
|
|
202
|
+
*/
|
|
203
|
+
emptyHint?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Render as a single-line, borderless control for a grid cell (the inline
|
|
206
|
+
* editor and the line-item grid set it) instead of the full form layout.
|
|
207
|
+
*/
|
|
208
|
+
compact?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Receive the FULL selected record rather than just its id, so a host can
|
|
211
|
+
* auto-fill sibling fields from it (a line-item grid copying a product's
|
|
212
|
+
* price). When provided, the host owns the resulting value change.
|
|
213
|
+
*/
|
|
214
|
+
onSelectRecord?: (record: Record<string, any>) => void;
|
|
215
|
+
/**
|
|
216
|
+
* Offer a "create new" affordance in a record picker, carrying whatever the
|
|
217
|
+
* user had typed. Also declarable on the field metadata.
|
|
218
|
+
*/
|
|
219
|
+
onCreateNew?: (searchQuery: string) => void;
|
|
220
|
+
} & FieldWidgetDomProps & AriaAttributes & {
|
|
221
|
+
/**
|
|
222
|
+
* Arbitrary `data-*` attributes (test ids, analytics hooks). Open by design,
|
|
223
|
+
* but a template-literal key — `keyof` stays finite, so this does NOT
|
|
224
|
+
* reintroduce the index signature objectui#3221 removed: `props.required`
|
|
225
|
+
* is still an error, `props['data-testid']` is still fine.
|
|
226
|
+
*/
|
|
227
|
+
[dataAttribute: `data-${string}`]: string | number | boolean | undefined;
|
|
17
228
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Adapt `SchemaRenderer`'s SDUI carrier onto the field-widget contract —
|
|
4
|
+
* objectui#3233, the producer-side half of retiring `schema` from
|
|
5
|
+
* {@link import('./widgets/types').FieldWidgetComponentProps}.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this exists
|
|
8
|
+
*
|
|
9
|
+
* Two kinds of host render a registered field widget, and they name the
|
|
10
|
+
* metadata differently *for good reason*:
|
|
11
|
+
*
|
|
12
|
+
* - the **form renderer** (`renderFieldComponent` in
|
|
13
|
+
* `@object-ui/components`), the inline-edit hosts (`FieldEditWidget`) and
|
|
14
|
+
* the action dialogs pass **`field`** — the field metadata they already
|
|
15
|
+
* hold;
|
|
16
|
+
* - **`SchemaRenderer`** (`@object-ui/react`) passes **`schema`** — the
|
|
17
|
+
* authored node. That key is the UNIVERSAL SDUI contract *every* registered
|
|
18
|
+
* component receives (`element:*`, `page:*`, `object-grid`,
|
|
19
|
+
* `ReportRenderer` …). It is not being retired and must not be: it is what
|
|
20
|
+
* the renderer means by "the node you were rendered from".
|
|
21
|
+
*
|
|
22
|
+
* What WAS wrong is that both keys reached the widget, so ~30 widgets resolved
|
|
23
|
+
* their config as `field || schema` — one concept under two spellings, a second
|
|
24
|
+
* de-facto contract on the widget side (AGENTS.md #0.1) and precisely the shape
|
|
25
|
+
* that lets an AI-written widget pick one spelling and appear to work under one
|
|
26
|
+
* host while reading `undefined` under the other.
|
|
27
|
+
*
|
|
28
|
+
* The translation belongs at the seam, exactly once: here. Downstream `field`
|
|
29
|
+
* is the only carrier, and a widget reading `props.schema` no longer compiles.
|
|
30
|
+
*
|
|
31
|
+
* ## Guarantee
|
|
32
|
+
*
|
|
33
|
+
* Payload-preserving by construction: the node is forwarded **by reference**,
|
|
34
|
+
* so a widget's `field` is the very object it used to read through `schema`.
|
|
35
|
+
* `field` wins when a host supplies both (the form path always does, and it
|
|
36
|
+
* holds the resolved metadata rather than the raw node).
|
|
37
|
+
*
|
|
38
|
+
* `schema` is *consumed* here rather than forwarded — with the key gone from
|
|
39
|
+
* the contract, passing it on would only reach the DOM as a stray attribute
|
|
40
|
+
* through a widget's `...props` spread.
|
|
41
|
+
*
|
|
42
|
+
* ## For widget authors outside this repo
|
|
43
|
+
*
|
|
44
|
+
* Registering a widget raw means `SchemaRenderer` hands it `schema` and no
|
|
45
|
+
* `field`. Wrap it once at registration and the widget only ever implements
|
|
46
|
+
* `FieldWidgetComponentProps`:
|
|
47
|
+
*
|
|
48
|
+
* ```tsx
|
|
49
|
+
* import { withFieldCarrier } from '@object-ui/fields';
|
|
50
|
+
*
|
|
51
|
+
* ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), {
|
|
52
|
+
* namespace: 'field',
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function withFieldCarrier<P extends object>(FieldWidget: React.ComponentType<P>): React.FC<Record<string, any>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.3.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,30 +24,30 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@objectstack/spec": "^17.0.0-rc.2",
|
|
27
28
|
"clsx": "^2.1.1",
|
|
28
|
-
"lucide-react": "^1.
|
|
29
|
+
"lucide-react": "^1.28.0",
|
|
29
30
|
"react-markdown": "^10.1.0",
|
|
30
31
|
"rehype-sanitize": "^6.0.0",
|
|
31
32
|
"remark-gfm": "^4.0.1",
|
|
32
33
|
"tailwind-merge": "^3.6.0",
|
|
33
|
-
"@object-ui/components": "17.
|
|
34
|
-
"@object-ui/core": "17.
|
|
35
|
-
"@object-ui/i18n": "17.
|
|
36
|
-
"@object-ui/providers": "17.
|
|
37
|
-
"@object-ui/react": "17.
|
|
38
|
-
"@object-ui/types": "17.
|
|
34
|
+
"@object-ui/components": "17.3.0",
|
|
35
|
+
"@object-ui/core": "17.3.0",
|
|
36
|
+
"@object-ui/i18n": "17.3.0",
|
|
37
|
+
"@object-ui/providers": "17.3.0",
|
|
38
|
+
"@object-ui/react": "17.3.0",
|
|
39
|
+
"@object-ui/types": "17.3.0"
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"react": "^18.0.0 || ^19.0.0",
|
|
42
43
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@
|
|
46
|
-
"@types/react": "19.2.
|
|
47
|
-
"@
|
|
48
|
-
"@vitejs/plugin-react": "^6.0.4",
|
|
46
|
+
"@types/react": "19.2.18",
|
|
47
|
+
"@types/react-dom": "19.2.4",
|
|
48
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
49
49
|
"typescript": "^6.0.3",
|
|
50
|
-
"vite": "^8.
|
|
50
|
+
"vite": "^8.2.0",
|
|
51
51
|
"vite-plugin-dts": "^5.0.3"
|
|
52
52
|
},
|
|
53
53
|
"publishConfig": {
|
package/src/FieldEditWidget.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React from 'react';
|
|
10
|
-
import type {
|
|
10
|
+
import type { FieldWidgetComponentProps } from './widgets/types';
|
|
11
11
|
|
|
12
12
|
// The SAME dedicated widgets the form renders — reused for in-place editing
|
|
13
13
|
// (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
|
|
@@ -54,7 +54,7 @@ import { mapFieldTypeToFormType } from './field-type-alias';
|
|
|
54
54
|
* Rich/heavy types (file, image, lookup, richtext, …) are intentionally absent
|
|
55
55
|
* so callers fall back to their own simpler editor.
|
|
56
56
|
*/
|
|
57
|
-
const EDIT_WIDGETS: Record<string, React.ComponentType<
|
|
57
|
+
const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetComponentProps<any>>> = {
|
|
58
58
|
text: TextField,
|
|
59
59
|
textarea: TextAreaField,
|
|
60
60
|
number: NumberField,
|
|
@@ -180,10 +180,12 @@ export function FieldEditWidget({
|
|
|
180
180
|
value,
|
|
181
181
|
onChange,
|
|
182
182
|
readonly,
|
|
183
|
-
}:
|
|
183
|
+
}: FieldWidgetComponentProps<any>): React.ReactElement | null {
|
|
184
184
|
const resolved = field?.type ? resolveInlineEditType(field.type) : undefined;
|
|
185
185
|
const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined;
|
|
186
186
|
if (!Widget) return null;
|
|
187
|
+
// `compact` is a declared widget prop (objectui#3221 closed this type), so
|
|
188
|
+
// the spread no longer needs an `any` escape hatch to get past it.
|
|
187
189
|
const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {};
|
|
188
|
-
return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...
|
|
190
|
+
return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...compactProps} />;
|
|
189
191
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for objectstack-ai/objectui#3466: single-line cell renderers
|
|
3
|
+
* emitted a BARE INLINE `span.truncate`. An inline box has no width box, so
|
|
4
|
+
* `overflow:hidden` / `text-overflow:ellipsis` never engage — the value
|
|
5
|
+
* rendered at full content width and its tail was silently clipped by
|
|
6
|
+
* whatever ancestor happened to clip (the record-detail card edge), with no
|
|
7
|
+
* ellipsis and no way to read the full text (the detail row's `title` is the
|
|
8
|
+
* inline-edit hint, not the value). Same mechanism as the JSON cell fix in
|
|
9
|
+
* objectui#2578.
|
|
10
|
+
*
|
|
11
|
+
* Pin: every single-line value renderer emits a BLOCK-level, `max-w-full`
|
|
12
|
+
* truncating span that carries the full text in `title` (so hovering the
|
|
13
|
+
* value shows the full text, taking precedence over any ancestor `title`).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
16
|
+
import { render, screen } from '@testing-library/react';
|
|
17
|
+
import '@testing-library/jest-dom';
|
|
18
|
+
import React from 'react';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
TextCellRenderer,
|
|
22
|
+
LookupCellRenderer,
|
|
23
|
+
UserCellRenderer,
|
|
24
|
+
FileCellRenderer,
|
|
25
|
+
SelectCellRenderer,
|
|
26
|
+
} from '../index';
|
|
27
|
+
import { SchemaRendererProvider } from '@object-ui/react';
|
|
28
|
+
|
|
29
|
+
const LONG =
|
|
30
|
+
'A remarkably long referenced-record title that a detail card column can never fit on a single line without an ellipsis';
|
|
31
|
+
|
|
32
|
+
/** The block-level truncation contract every single-line value span must meet. */
|
|
33
|
+
function expectTruncating(el: HTMLElement, fullText: string) {
|
|
34
|
+
expect(el).toHaveClass('block', 'max-w-full', 'truncate');
|
|
35
|
+
expect(el).toHaveAttribute('title', fullText);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('cell renderers truncate for real and expose the full text (issue #3466)', () => {
|
|
39
|
+
it('TextCellRenderer: block-level truncating span with title fallback', () => {
|
|
40
|
+
render(<TextCellRenderer value={LONG} field={{ type: 'text' } as any} />);
|
|
41
|
+
expectTruncating(screen.getByText(LONG), LONG);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('LookupCellRenderer: expanded record object', () => {
|
|
45
|
+
const ds = { find: vi.fn(), findOne: vi.fn() } as any;
|
|
46
|
+
render(
|
|
47
|
+
<SchemaRendererProvider dataSource={ds}>
|
|
48
|
+
<LookupCellRenderer
|
|
49
|
+
value={{ id: 'rec1', name: LONG }}
|
|
50
|
+
field={{ type: 'lookup', reference_to: 'obj_a' } as any}
|
|
51
|
+
/>
|
|
52
|
+
</SchemaRendererProvider>,
|
|
53
|
+
);
|
|
54
|
+
expectTruncating(screen.getByText(LONG), LONG);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('LookupCellRenderer: primitive non-opaque value', () => {
|
|
58
|
+
const ds = { find: vi.fn(), findOne: vi.fn() } as any;
|
|
59
|
+
render(
|
|
60
|
+
<SchemaRendererProvider dataSource={ds}>
|
|
61
|
+
<LookupCellRenderer value={LONG} field={{ type: 'lookup' } as any} />
|
|
62
|
+
</SchemaRendererProvider>,
|
|
63
|
+
);
|
|
64
|
+
expectTruncating(screen.getByText(LONG), LONG);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('UserCellRenderer: display name beside the avatar', () => {
|
|
68
|
+
render(<UserCellRenderer value={{ name: LONG }} field={{ type: 'user' } as any} />);
|
|
69
|
+
expectTruncating(screen.getByText(LONG), LONG);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('FileCellRenderer: single file name', () => {
|
|
73
|
+
render(<FileCellRenderer value={{ name: LONG }} field={{ type: 'file' } as any} />);
|
|
74
|
+
expectTruncating(screen.getByText(LONG), LONG);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('SelectCellRenderer (dot): bounded container with title, shrinkable label', () => {
|
|
78
|
+
render(
|
|
79
|
+
<SelectCellRenderer
|
|
80
|
+
value="opt1"
|
|
81
|
+
field={{
|
|
82
|
+
type: 'select',
|
|
83
|
+
appearance: 'dot',
|
|
84
|
+
options: [{ value: 'opt1', label: LONG }],
|
|
85
|
+
} as any}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
const label = screen.getByText(LONG);
|
|
89
|
+
// The label shrinks inside the dot row; the row itself is width-bounded
|
|
90
|
+
// and carries the full text on hover.
|
|
91
|
+
expect(label).toHaveClass('min-w-0', 'truncate');
|
|
92
|
+
const row = label.parentElement!;
|
|
93
|
+
expect(row).toHaveClass('max-w-full');
|
|
94
|
+
expect(row).toHaveAttribute('title', LONG);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The SDUI path delivers field metadata on ONE key — objectui#3233.
|
|
11
|
+
*
|
|
12
|
+
* `SchemaRenderer` hands every registered component the authored node as
|
|
13
|
+
* `schema`. That key is the universal SDUI contract (`element:*`, `page:*`,
|
|
14
|
+
* `object-grid`, `ReportRenderer` …) and is NOT retired. What was retired is
|
|
15
|
+
* `schema` on the *field-widget* contract: a widget used to receive the node
|
|
16
|
+
* under `schema` here and the metadata under `field` from the form renderer,
|
|
17
|
+
* which is why ~30 widgets resolved their config as `field || schema`.
|
|
18
|
+
*
|
|
19
|
+
* The translation now happens once, at the registration seam
|
|
20
|
+
* (`withFieldCarrier`), so downstream `field` is the only carrier. That is only
|
|
21
|
+
* correct if the node reaches the widget UNCHANGED — "swap the key, don't drop
|
|
22
|
+
* the cargo". The first test proves it by OBJECT IDENTITY: a spy sits where the
|
|
23
|
+
* registry entry sits, records the object `SchemaRenderer` passed as `schema`,
|
|
24
|
+
* and the widget's `field` must be that exact object. (Identity against the
|
|
25
|
+
* authored literal would be wrong to assert — `SchemaRenderer` shallow-copies
|
|
26
|
+
* the node while evaluating expressions, so the object it delivers is its own.)
|
|
27
|
+
*
|
|
28
|
+
* The last test pins that the REAL registration path goes through the adapter
|
|
29
|
+
* at all: an adapter nobody applied would leave every SDUI-hosted widget
|
|
30
|
+
* reading `undefined`, which is exactly the silent failure this convergence is
|
|
31
|
+
* accused of risking.
|
|
32
|
+
*
|
|
33
|
+
* The sibling half of the invariant — the form path through
|
|
34
|
+
* `renderFieldComponent` — is pinned in `packages/components/src/renderers/
|
|
35
|
+
* form/__tests__/form-field-carrier.test.tsx`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import React, { Suspense } from 'react';
|
|
39
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
40
|
+
import { render, screen, cleanup } from '@testing-library/react';
|
|
41
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
42
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
43
|
+
|
|
44
|
+
import { withFieldCarrier } from '../withFieldCarrier';
|
|
45
|
+
import { registerField } from '../index';
|
|
46
|
+
// Module scope, not a hook: `registerField('boolean')` registers
|
|
47
|
+
// `React.lazy(() => import('./widgets/BooleanField'))`, and under a saturated
|
|
48
|
+
// transform pipeline a first `import()` can outlive RTL's 1000ms budget. The
|
|
49
|
+
// specifier matches `fieldWidgetMap`'s exactly, so ESM's cache makes that
|
|
50
|
+
// factory resolve immediately (AGENTS.md 测试纪律 / objectui#3010).
|
|
51
|
+
import '../widgets/BooleanField';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Capture holder, not bare module variables: `react-hooks/globals` forbids
|
|
55
|
+
* REASSIGNING an outer binding from a component body, which is what a probe
|
|
56
|
+
* has to do. Mutating a property of a stable object is the repo's usual shape
|
|
57
|
+
* for this (see plugin-detail's `RelatedList` probes).
|
|
58
|
+
*/
|
|
59
|
+
const captured: { props: Record<string, any> | null; node: unknown } = {
|
|
60
|
+
props: null,
|
|
61
|
+
node: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function CarrierProbe(props: any) {
|
|
65
|
+
captured.props = props;
|
|
66
|
+
return <div data-testid="carrier-probe">{props.field?.label ?? 'NO FIELD'}</div>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const AdaptedProbe = withFieldCarrier(CarrierProbe);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sits exactly where the registry entry sits, so it sees the host's props
|
|
73
|
+
* verbatim, then forwards them to the adapter under test. This is what makes
|
|
74
|
+
* the identity assertion possible: `captured.node` and the widget's `field`
|
|
75
|
+
* are observed within a single render pass.
|
|
76
|
+
*/
|
|
77
|
+
function CarrierSpy(props: any) {
|
|
78
|
+
captured.node = props.schema;
|
|
79
|
+
return <AdaptedProbe {...props} />;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
captured.props = null;
|
|
84
|
+
captured.node = null;
|
|
85
|
+
ComponentRegistry.register('carrierprobe', CarrierSpy, {
|
|
86
|
+
namespace: 'field',
|
|
87
|
+
skipFallback: true,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
cleanup();
|
|
93
|
+
vi.restoreAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('SDUI path — the authored node arrives as `field` (objectui#3233)', () => {
|
|
97
|
+
it('hands the widget the very object it used to read through `schema`', () => {
|
|
98
|
+
const node = {
|
|
99
|
+
type: 'field:carrierprobe',
|
|
100
|
+
name: 'city',
|
|
101
|
+
label: 'City',
|
|
102
|
+
placeholder: 'Where?',
|
|
103
|
+
options: [{ label: 'Beijing', value: 'bj' }],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
render(<SchemaRenderer schema={node as any} />);
|
|
107
|
+
|
|
108
|
+
expect(screen.getByTestId('carrier-probe')).toHaveTextContent('City');
|
|
109
|
+
expect(captured.props).not.toBeNull();
|
|
110
|
+
expect(captured.node).toBeTruthy();
|
|
111
|
+
// The payload-equivalence proof: same object, by reference. A
|
|
112
|
+
// reconstruction would still render "City" while quietly dropping whatever
|
|
113
|
+
// the copy did not know to carry.
|
|
114
|
+
expect(captured.props!.field).toBe(captured.node);
|
|
115
|
+
// …and it really is the whole authored node, not a narrowed projection.
|
|
116
|
+
expect(captured.props!.field.name).toBe('city');
|
|
117
|
+
expect(captured.props!.field.placeholder).toBe('Where?');
|
|
118
|
+
expect(captured.props!.field.options).toEqual([{ label: 'Beijing', value: 'bj' }]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('consumes `schema` rather than forwarding it to the widget', () => {
|
|
122
|
+
// Key ABSENCE, not `undefined`. Two reasons it must not be forwarded: a
|
|
123
|
+
// widget could resolve `field || schema` again (the second contract
|
|
124
|
+
// survives in practice while the type claims it is gone), and widgets
|
|
125
|
+
// spread their leftover props onto DOM nodes, where an object-valued
|
|
126
|
+
// `schema` attribute is a React warning.
|
|
127
|
+
render(
|
|
128
|
+
<SchemaRenderer schema={{ type: 'field:carrierprobe', name: 'city', label: 'City' } as any} />,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(captured.props).not.toBeNull();
|
|
132
|
+
expect('schema' in captured.props!).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('lets an explicit `field` win when a host supplies both', () => {
|
|
136
|
+
// The form renderer passes `field` (resolved metadata) and never `schema`.
|
|
137
|
+
// If some host ever passed both, the resolved metadata is the better
|
|
138
|
+
// answer — the raw node is the fallback, not the override.
|
|
139
|
+
const node = { type: 'field:carrierprobe', name: 'from-node', label: 'From node' };
|
|
140
|
+
const metadata = { name: 'from-field', label: 'From field' };
|
|
141
|
+
|
|
142
|
+
render(<AdaptedProbe schema={node} field={metadata} />);
|
|
143
|
+
|
|
144
|
+
expect(captured.props!.field).toBe(metadata);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('applies the adapter on the REAL registration path (`registerField`)', async () => {
|
|
148
|
+
// The seam is only worth anything if `registerField` goes through it.
|
|
149
|
+
// `BooleanField` reads `widget` / `name` off its metadata carrier and
|
|
150
|
+
// nowhere else: `widget: 'checkbox'` selects the Checkbox over the Switch,
|
|
151
|
+
// and `name` becomes the control's id. Both are node keys, so seeing them
|
|
152
|
+
// honoured here proves the node reached `field` end to end — through the
|
|
153
|
+
// real registry entry, the real SchemaRenderer, and the real widget.
|
|
154
|
+
registerField('boolean');
|
|
155
|
+
|
|
156
|
+
render(
|
|
157
|
+
<Suspense fallback={<div>loading</div>}>
|
|
158
|
+
<SchemaRenderer
|
|
159
|
+
schema={
|
|
160
|
+
{ type: 'field:boolean', name: 'is_active', label: 'Active', widget: 'checkbox' } as any
|
|
161
|
+
}
|
|
162
|
+
/>
|
|
163
|
+
</Suspense>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const control = await screen.findByRole('checkbox');
|
|
167
|
+
expect(control).toHaveAttribute('id', 'is_active');
|
|
168
|
+
});
|
|
169
|
+
});
|