@object-ui/fields 17.1.0 → 17.2.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 +194 -0
- package/dist/FieldEditWidget.d.ts +2 -2
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +290 -292
- package/dist/widgets/AddressField.d.ts +2 -2
- 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/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/GeolocationField.d.ts +2 -2
- 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/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/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/RichTextField.d.ts +2 -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/TextAreaField.d.ts +2 -2
- 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/file-value.d.ts +20 -33
- package/dist/widgets/types.d.ts +119 -3
- package/package.json +8 -8
- package/src/FieldEditWidget.tsx +6 -4
- package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
- package/src/__tests__/widget-props-contract.test.tsx +129 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/index.tsx +3 -1
- package/src/widgets/AddressField.tsx +2 -2
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +2 -2
- package/src/widgets/BooleanField.tsx +2 -2
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CheckboxesField.tsx +4 -4
- package/src/widgets/CodeField.tsx +2 -2
- package/src/widgets/ColorField.tsx +2 -2
- package/src/widgets/CurrencyField.tsx +2 -2
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +2 -2
- package/src/widgets/FileField.tsx +2 -2
- package/src/widgets/FilterConditionField.tsx +3 -3
- package/src/widgets/FormulaField.tsx +2 -2
- package/src/widgets/GeolocationField.tsx +2 -2
- package/src/widgets/GridField.tsx +3 -3
- package/src/widgets/ImageField.tsx +2 -2
- package/src/widgets/LocationField.tsx +2 -2
- package/src/widgets/LookupField.tsx +10 -10
- package/src/widgets/MasterDetailField.tsx +2 -2
- package/src/widgets/MultiSelectField.tsx +4 -4
- package/src/widgets/NumberField.tsx +2 -2
- package/src/widgets/ObjectField.tsx +2 -2
- package/src/widgets/ObjectRefField.tsx +4 -4
- package/src/widgets/PasswordField.tsx +2 -2
- package/src/widgets/PercentField.tsx +2 -2
- package/src/widgets/PhoneField.tsx +2 -2
- package/src/widgets/QRCodeField.tsx +2 -2
- package/src/widgets/RadioField.tsx +4 -4
- package/src/widgets/RatingField.tsx +2 -2
- package/src/widgets/RecipientPickerField.tsx +4 -4
- package/src/widgets/RichTextField.tsx +2 -2
- package/src/widgets/SelectField.tsx +5 -5
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +2 -2
- package/src/widgets/SummaryField.tsx +2 -2
- package/src/widgets/TagsField.tsx +3 -3
- package/src/widgets/TextAreaField.tsx +2 -2
- package/src/widgets/TextField.tsx +2 -2
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +2 -2
- package/src/widgets/UserField.tsx +2 -2
- package/src/widgets/VectorField.tsx +2 -2
- package/src/widgets/file-value.ts +21 -5
- package/src/widgets/types.ts +129 -6
package/dist/widgets/types.d.ts
CHANGED
|
@@ -1,7 +1,53 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { AriaAttributes, FocusEventHandler, MouseEventHandler } from 'react';
|
|
2
|
+
import { DependsOnInput, FieldMetadata } from '@object-ui/types';
|
|
3
|
+
/**
|
|
4
|
+
* Props every field widget in this package receives at RUNTIME.
|
|
5
|
+
*
|
|
6
|
+
* Named `FieldWidgetComponentProps`, not `FieldWidgetProps` (objectui#3161,
|
|
7
|
+
* objectstack#4115 ledger batch 7), adopting the name `@object-ui/app-shell`
|
|
8
|
+
* settled on for the same split in objectui#3169: `@objectstack/spec/ui` owns
|
|
9
|
+
* `FieldWidgetProps` for the DECLARED widget-plugin contract — a zod object
|
|
10
|
+
* (`FieldWidgetPropsSchema`) a plugin manifest is validated against, with
|
|
11
|
+
* `field` narrowed to `{ name?, label?, type: <the FieldType enum> }` and
|
|
12
|
+
* `readonly` / `required` carrying `.default()`s. This is the React interface
|
|
13
|
+
* the widgets in this directory actually implement, against
|
|
14
|
+
* `@object-ui/types`'s much richer `FieldMetadata`.
|
|
15
|
+
*
|
|
16
|
+
* The two are NOT interchangeable, and the divergence is not cosmetic: the
|
|
17
|
+
* spec's contract names the error slot `error`, this one names it
|
|
18
|
+
* `errorMessage`, so a widget written to the spec's declared props renders no
|
|
19
|
+
* validation message here. That divergence is tracked in objectui#3222 and is
|
|
20
|
+
* deliberately NOT resolved here — but it is now *visible*: reading
|
|
21
|
+
* `props.error` or `props.required` off this type is a compile error rather
|
|
22
|
+
* than a silent `any`.
|
|
23
|
+
*
|
|
24
|
+
* ## Why there is no `[key: string]: any` (objectui#3221)
|
|
25
|
+
*
|
|
26
|
+
* This type used to end in an index signature, "load-bearing" for the widgets'
|
|
27
|
+
* `...props` spreads. It was also why every drift above was invisible: **a type
|
|
28
|
+
* that claims to have every key can never be reported as missing one** (the
|
|
29
|
+
* objectstack#4075 mechanism). `props.required` and `props.error` were legal
|
|
30
|
+
* reads typed `any` and always `undefined` at runtime; a misspelled prop
|
|
31
|
+
* (`readOnly` for `readonly`, `onchange` for `onChange`) compiled; and any
|
|
32
|
+
* structural/parity comparison against the type was useless *in principle*,
|
|
33
|
+
* which is why the batch-7 symbol guard was the only detector that could see
|
|
34
|
+
* the collision at all.
|
|
35
|
+
*
|
|
36
|
+
* It is replaced by the CLOSED set below: the controlled-input contract, the
|
|
37
|
+
* host plumbing the form renderer genuinely forwards, and a DOM pass-through
|
|
38
|
+
* allowance. `data-*` is an open family by design (it is open in HTML too) and
|
|
39
|
+
* is expressed as a template-literal index signature, which — unlike
|
|
40
|
+
* `[key: string]` — leaves `keyof` finite, so an undeclared prop still fails.
|
|
41
|
+
*
|
|
42
|
+
* Adding a key here is a contract change: say who produces it and who reads it.
|
|
43
|
+
*/
|
|
44
|
+
export type FieldWidgetComponentProps<T = any> = {
|
|
3
45
|
value: T;
|
|
4
46
|
onChange: (val: T) => void;
|
|
47
|
+
/**
|
|
48
|
+
* The field's metadata. Deliberately the looser `@object-ui/types` shape
|
|
49
|
+
* rather than the spec's, to avoid a circular dependency for now.
|
|
50
|
+
*/
|
|
5
51
|
field: FieldMetadata;
|
|
6
52
|
readonly?: boolean;
|
|
7
53
|
disabled?: boolean;
|
|
@@ -13,5 +59,75 @@ export type FieldWidgetProps<T = any> = {
|
|
|
13
59
|
* widgets ignore it.
|
|
14
60
|
*/
|
|
15
61
|
onUploadingChange?: (uploading: boolean) => void;
|
|
16
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The raw authored node the widget was rendered from. Two hosts supply it:
|
|
64
|
+
* `SchemaRenderer` (`<Component schema={schema} {...schema} />`) and the
|
|
65
|
+
* form renderer's `renderFieldComponent` (`schema={props.field || ...}`),
|
|
66
|
+
* which is why ~25 widgets read `field || schema` for their config.
|
|
67
|
+
*
|
|
68
|
+
* This is a second carrier for what `field` already means, i.e. a de-facto
|
|
69
|
+
* second contract (AGENTS.md #0.1) — declared here so it is at least
|
|
70
|
+
* visible; converging the two is tracked separately.
|
|
71
|
+
*/
|
|
72
|
+
schema?: FieldMetadata | Record<string, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* DataSource for widgets that query records (lookup / user / object-ref /
|
|
75
|
+
* recipient-picker / grid). Injected by the form renderer for the field
|
|
76
|
+
* types that need it, and passed directly by inline-edit hosts. Option
|
|
77
|
+
* widgets destructure it purely to keep it off their DOM spread.
|
|
78
|
+
*
|
|
79
|
+
* Left structural (`unknown`): `@object-ui/fields` must not depend on a
|
|
80
|
+
* concrete adapter — every consumer narrows it itself.
|
|
81
|
+
*/
|
|
82
|
+
dataSource?: unknown;
|
|
83
|
+
/**
|
|
84
|
+
* Live sibling-field values driving cascading / role-gated options and
|
|
85
|
+
* dependent lookups (ADR-0058, #2215/#2284). The form renderer passes the
|
|
86
|
+
* in-progress record; widgets fall back to `SchemaRendererContext`.
|
|
87
|
+
*/
|
|
88
|
+
dependentValues?: Record<string, unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* Controlling field(s) that gate this field's option list. Normally declared
|
|
91
|
+
* on the field metadata; accepted as a prop so a host can drive the gate for
|
|
92
|
+
* a field it synthesised. Field metadata wins when both are present.
|
|
93
|
+
*/
|
|
94
|
+
dependsOn?: DependsOnInput;
|
|
95
|
+
/**
|
|
96
|
+
* Hint shown when a dependency-gated option list is still waiting on its
|
|
97
|
+
* controlling field. Forwarded by the form renderer; the option widgets in
|
|
98
|
+
* this package currently discard it and render their own message instead.
|
|
99
|
+
*/
|
|
100
|
+
emptyHint?: string;
|
|
101
|
+
/**
|
|
102
|
+
* Render as a single-line, borderless control for a grid cell (the inline
|
|
103
|
+
* editor and the line-item grid set it) instead of the full form layout.
|
|
104
|
+
*/
|
|
105
|
+
compact?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Receive the FULL selected record rather than just its id, so a host can
|
|
108
|
+
* auto-fill sibling fields from it (a line-item grid copying a product's
|
|
109
|
+
* price). When provided, the host owns the resulting value change.
|
|
110
|
+
*/
|
|
111
|
+
onSelectRecord?: (record: Record<string, any>) => void;
|
|
112
|
+
/**
|
|
113
|
+
* Offer a "create new" affordance in a record picker, carrying whatever the
|
|
114
|
+
* user had typed. Also declarable on the field metadata.
|
|
115
|
+
*/
|
|
116
|
+
onCreateNew?: (searchQuery: string) => void;
|
|
117
|
+
id?: string;
|
|
118
|
+
/** react-hook-form's field name, spread in by the form renderer. */
|
|
119
|
+
name?: string;
|
|
120
|
+
autoFocus?: boolean;
|
|
121
|
+
tabIndex?: number;
|
|
122
|
+
onBlur?: FocusEventHandler<HTMLElement>;
|
|
123
|
+
onFocus?: FocusEventHandler<HTMLElement>;
|
|
124
|
+
onClick?: MouseEventHandler<HTMLElement>;
|
|
125
|
+
} & AriaAttributes & {
|
|
126
|
+
/**
|
|
127
|
+
* Arbitrary `data-*` attributes (test ids, analytics hooks). Open by design,
|
|
128
|
+
* but a template-literal key — `keyof` stays finite, so this does NOT
|
|
129
|
+
* reintroduce the index signature objectui#3221 removed: `props.required`
|
|
130
|
+
* is still an error, `props['data-testid']` is still fine.
|
|
131
|
+
*/
|
|
132
|
+
[dataAttribute: `data-${string}`]: string | number | boolean | undefined;
|
|
17
133
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.2.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,25 +24,25 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
+
"@objectstack/spec": "^17.0.0-rc.1",
|
|
27
28
|
"clsx": "^2.1.1",
|
|
28
29
|
"lucide-react": "^1.25.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.2.0",
|
|
35
|
+
"@object-ui/core": "17.2.0",
|
|
36
|
+
"@object-ui/i18n": "17.2.0",
|
|
37
|
+
"@object-ui/providers": "17.2.0",
|
|
38
|
+
"@object-ui/react": "17.2.0",
|
|
39
|
+
"@object-ui/types": "17.2.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
|
-
"@objectstack/spec": "^17.0.0-rc.0",
|
|
46
46
|
"@types/react": "19.2.17",
|
|
47
47
|
"@types/react-dom": "19.2.3",
|
|
48
48
|
"@vitejs/plugin-react": "^6.0.4",
|
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,112 @@
|
|
|
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
|
+
* `@object-ui/fields` ↔ `@objectstack/spec` symbol-collision guards
|
|
11
|
+
* (objectui#3161, objectstack#4115 ledger batch 7).
|
|
12
|
+
*
|
|
13
|
+
* Two symbols, two different verdicts:
|
|
14
|
+
*
|
|
15
|
+
* - `isFileIdToken` was a character-for-character copy of the spec's function
|
|
16
|
+
* under the spec's own name. It is a re-export now, and the gate below is
|
|
17
|
+
* REFERENCE IDENTITY — not behaviour. A faithful copy passes every behaviour
|
|
18
|
+
* test ever written for it (objectui#3003, re-proved on
|
|
19
|
+
* `isAggregatedViewContainer` in objectui#3169), so `toBe` is the only
|
|
20
|
+
* assertion that can tell a re-export from a fork.
|
|
21
|
+
*
|
|
22
|
+
* - `FieldWidgetProps` is now `FieldWidgetComponentProps`. The spec owns that
|
|
23
|
+
* name for the DECLARED widget-plugin props contract; this package's is the
|
|
24
|
+
* React interface its widgets implement. The pins record the divergence that
|
|
25
|
+
* made re-exporting impossible — and, since objectui#3221 closed the type,
|
|
26
|
+
* that the divergence is now *visible* rather than swallowed by a
|
|
27
|
+
* `[key: string]: any` index signature.
|
|
28
|
+
*
|
|
29
|
+
* The other direction — "the spec must not start owning the NEW names" — is not
|
|
30
|
+
* asserted here because `scripts/check-spec-symbol-derivation.mjs` already
|
|
31
|
+
* answers it on every CI run, for every name in the package rather than the
|
|
32
|
+
* handful a test would remember to list.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect } from 'vitest';
|
|
36
|
+
import { isFileIdToken as specIsFileIdToken } from '@objectstack/spec/data';
|
|
37
|
+
import type { FieldWidgetProps as SpecFieldWidgetProps } from '@objectstack/spec/ui';
|
|
38
|
+
|
|
39
|
+
import { isFileIdToken, fileIdOf } from '../widgets/file-value';
|
|
40
|
+
import type { FieldWidgetComponentProps } from '../widgets/types';
|
|
41
|
+
|
|
42
|
+
describe('isFileIdToken IS the spec function', () => {
|
|
43
|
+
it('is the same function object, not an equivalent one', () => {
|
|
44
|
+
// The whole point. Every value/behaviour assertion below this line would
|
|
45
|
+
// also pass against a re-forked copy; this one would not.
|
|
46
|
+
expect(isFileIdToken).toBe(specIsFileIdToken);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is the arbiter the rest of the module routes through', () => {
|
|
50
|
+
// `fileIdOf` must keep agreeing with the shared predicate, so a future
|
|
51
|
+
// "small local tweak" to the id shape cannot be reintroduced one caller at
|
|
52
|
+
// a time.
|
|
53
|
+
for (const value of ['abc123', 'AbC-_9', 'x'.repeat(64)]) {
|
|
54
|
+
expect(specIsFileIdToken(value)).toBe(true);
|
|
55
|
+
expect(fileIdOf(value)).toBe(value);
|
|
56
|
+
}
|
|
57
|
+
for (const value of ['https://example.com/f.png', '/api/v1/storage/files/1', 'a.b', 'x'.repeat(65)]) {
|
|
58
|
+
expect(specIsFileIdToken(value)).toBe(false);
|
|
59
|
+
expect(fileIdOf(value)).toBeUndefined();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/* -------------------------------------------------------------------------- */
|
|
65
|
+
/* Compile-time pins. A violation is a `tsc` error, not a runtime failure. */
|
|
66
|
+
/* This package's tsconfig.json includes its tests, so `type-check` compiles */
|
|
67
|
+
/* them (scripts/check-type-check-coverage.mjs keeps that true). */
|
|
68
|
+
/* -------------------------------------------------------------------------- */
|
|
69
|
+
|
|
70
|
+
type Assert<T extends true> = T;
|
|
71
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
72
|
+
type Extends<A, B> = [A] extends [B] ? true : false;
|
|
73
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
74
|
+
type HasKey<T, K extends string> = K extends keyof T ? true : false;
|
|
75
|
+
|
|
76
|
+
describe('FieldWidgetComponentProps is the RENDERED layer of the spec contract', () => {
|
|
77
|
+
it('is pinned at compile time', () => {
|
|
78
|
+
// The import above is itself half the tripwire: the day the spec stops
|
|
79
|
+
// exporting `FieldWidgetProps`, this file stops compiling and the rename's
|
|
80
|
+
// reason is up for re-triage (the plain name could come back).
|
|
81
|
+
type _SpecIsReal = Assert<Equal<IsAny<SpecFieldWidgetProps>, false>>;
|
|
82
|
+
|
|
83
|
+
// The divergence that makes a re-export impossible AND is a live defect
|
|
84
|
+
// worth naming: the declared contract calls the validation-message slot
|
|
85
|
+
// `error`; every widget in this package reads `errorMessage`. A widget
|
|
86
|
+
// written against the spec's declared props renders no message here, and
|
|
87
|
+
// nothing reports it. Recorded, not fixed, by the rename — objectui#3222.
|
|
88
|
+
type _SpecNamesItError = Assert<HasKey<SpecFieldWidgetProps, 'error'>>;
|
|
89
|
+
type _LocalNamesItErrorMessage = Assert<HasKey<FieldWidgetComponentProps, 'errorMessage'>>;
|
|
90
|
+
|
|
91
|
+
// …and the reason nobody noticed used to sit right here: three pins
|
|
92
|
+
// asserting that `[key: string]: any` was still present, and that
|
|
93
|
+
// `props.required` / `props.error` therefore read as `any`. objectui#3221
|
|
94
|
+
// removed that index signature, so those pins have gone red on purpose and
|
|
95
|
+
// are gone with it. What replaces them is the inverse claim — the type is
|
|
96
|
+
// now CLOSED, so the two keys the spec declares and this one does not can
|
|
97
|
+
// finally be reported as missing. That is what makes objectui#3222 (the
|
|
98
|
+
// `error` / `errorMessage` divergence) decidable by the compiler instead of
|
|
99
|
+
// by a symbol guard.
|
|
100
|
+
type _NoStringIndexSignature = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
|
|
101
|
+
type _RequiredIsAbsent = Assert<Equal<HasKey<FieldWidgetComponentProps, 'required'>, false>>;
|
|
102
|
+
type _ErrorIsAbsent = Assert<Equal<HasKey<FieldWidgetComponentProps, 'error'>, false>>;
|
|
103
|
+
|
|
104
|
+
// `data-*` stays open (it is open in HTML too), but as a template-literal
|
|
105
|
+
// key — the distinction that keeps `keyof` finite above. A pin, because
|
|
106
|
+
// widening it back to `[key: string]` would silently restore the defect
|
|
107
|
+
// while every assertion here still read as "closed".
|
|
108
|
+
type _DataAttributesStayOpen = Assert<Extends<'data-testid', keyof FieldWidgetComponentProps>>;
|
|
109
|
+
|
|
110
|
+
expect(true).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
* `FieldWidgetComponentProps` is a CLOSED contract (objectui#3221).
|
|
11
|
+
*
|
|
12
|
+
* It used to end in `[key: string]: any`, which made every check over it
|
|
13
|
+
* vacuous: a type claiming to have every key can never be reported as missing
|
|
14
|
+
* one (objectstack#4075). Three consequences, one test each below:
|
|
15
|
+
*
|
|
16
|
+
* 1. a key the type does not declare is a compile error, not a silent `any`
|
|
17
|
+
* — which is what makes the `error` / `errorMessage` divergence
|
|
18
|
+
* (objectui#3222) decidable by the compiler at all;
|
|
19
|
+
* 2. a MISSPELLED prop (`readOnly` for `readonly`) is rejected;
|
|
20
|
+
* 3. the pass-through keys hosts genuinely forward still type-check, and
|
|
21
|
+
* still reach the rendered control at runtime — closing the type must not
|
|
22
|
+
* have been paid for by dropping real behaviour.
|
|
23
|
+
*
|
|
24
|
+
* (1) and (2) are compile-time: a violation fails `pnpm --filter
|
|
25
|
+
* @object-ui/fields type-check`, not this run. `@ts-expect-error` inverts
|
|
26
|
+
* that — the line fails to compile if the error it expects ever STOPS
|
|
27
|
+
* happening, i.e. if the index signature comes back.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { render, screen } from '@testing-library/react';
|
|
32
|
+
|
|
33
|
+
import { TextField } from '../widgets/TextField';
|
|
34
|
+
import { BooleanField } from '../widgets/BooleanField';
|
|
35
|
+
import type { FieldWidgetComponentProps } from '../widgets/types';
|
|
36
|
+
import type { FieldMetadata } from '@object-ui/types';
|
|
37
|
+
|
|
38
|
+
type Assert<T extends true> = T;
|
|
39
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
40
|
+
type Extends<A, B> = [A] extends [B] ? true : false;
|
|
41
|
+
|
|
42
|
+
const field = { name: 'title', label: 'Title', type: 'text' } as unknown as FieldMetadata;
|
|
43
|
+
|
|
44
|
+
describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
|
|
45
|
+
it('has no `[key: string]` index signature', () => {
|
|
46
|
+
// The single assertion the whole change turns on. While the index
|
|
47
|
+
// signature existed this was `true`, and every check below was vacuous.
|
|
48
|
+
type _NotOpen = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
|
|
49
|
+
expect(true).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects keys it does not declare', () => {
|
|
53
|
+
const props = {} as FieldWidgetComponentProps<string>;
|
|
54
|
+
|
|
55
|
+
// The spec's `FieldWidgetPropsSchema` declares both; this type declares
|
|
56
|
+
// neither. Reading them used to give `any` and `undefined` forever.
|
|
57
|
+
// Whether to ADD them is objectui#3222 — deliberately not decided here.
|
|
58
|
+
// @ts-expect-error `required` is not part of this contract
|
|
59
|
+
void props.required;
|
|
60
|
+
// @ts-expect-error `error` is not part of this contract — this type's slot is `errorMessage`
|
|
61
|
+
void props.error;
|
|
62
|
+
|
|
63
|
+
// …and the class of bug the index signature hid best: a typo.
|
|
64
|
+
// @ts-expect-error the prop is `readonly`, not React's DOM `readOnly`
|
|
65
|
+
void props.readOnly;
|
|
66
|
+
// @ts-expect-error the prop is `onChange`
|
|
67
|
+
void props.onchange;
|
|
68
|
+
|
|
69
|
+
expect(true).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('still accepts the pass-through keys hosts actually forward', () => {
|
|
73
|
+
// Closing the type is only correct if the real call sites still compile.
|
|
74
|
+
// Each key here has a named producer: the form renderer, the inline-edit
|
|
75
|
+
// host, or the line-item grid. Deleting one from the type breaks this.
|
|
76
|
+
const passThrough: FieldWidgetComponentProps<string> = {
|
|
77
|
+
value: '',
|
|
78
|
+
onChange: () => {},
|
|
79
|
+
field,
|
|
80
|
+
// form renderer (`renderFieldComponent`)
|
|
81
|
+
schema: field,
|
|
82
|
+
dataSource: {},
|
|
83
|
+
dependentValues: { country: 'cn' },
|
|
84
|
+
dependsOn: ['country'],
|
|
85
|
+
emptyHint: 'Select country first',
|
|
86
|
+
name: 'title',
|
|
87
|
+
// inline-edit hosts / line-item grid
|
|
88
|
+
compact: true,
|
|
89
|
+
autoFocus: true,
|
|
90
|
+
onSelectRecord: () => {},
|
|
91
|
+
onCreateNew: () => {},
|
|
92
|
+
// DOM / a11y
|
|
93
|
+
id: 'title-input',
|
|
94
|
+
'aria-label': 'Title',
|
|
95
|
+
'data-testid': 'field-title',
|
|
96
|
+
};
|
|
97
|
+
expect(passThrough.compact).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('closing the type kept the pass-through behaviour', () => {
|
|
102
|
+
it('forwards a11y and data attributes to the rendered input', () => {
|
|
103
|
+
render(
|
|
104
|
+
<TextField
|
|
105
|
+
value="hello"
|
|
106
|
+
onChange={() => {}}
|
|
107
|
+
field={field}
|
|
108
|
+
aria-label="Title"
|
|
109
|
+
data-testid="title-input"
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
const input = screen.getByTestId('title-input');
|
|
113
|
+
expect(input).toHaveAttribute('aria-label', 'Title');
|
|
114
|
+
expect(input).toHaveValue('hello');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('forwards `disabled` — a declared key, not an index-signature accident', () => {
|
|
118
|
+
render(
|
|
119
|
+
<BooleanField
|
|
120
|
+
value={false}
|
|
121
|
+
onChange={() => {}}
|
|
122
|
+
field={{ name: 'active', label: 'Active', type: 'boolean' } as unknown as FieldMetadata}
|
|
123
|
+
disabled
|
|
124
|
+
data-testid="active-switch"
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
expect(screen.getByTestId('active-switch')).toBeDisabled();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -6,7 +6,7 @@ import { FieldEditWidget } from './FieldEditWidget';
|
|
|
6
6
|
import { MasterDetailField } from './widgets/MasterDetailField';
|
|
7
7
|
import { GridField } from './widgets/GridField';
|
|
8
8
|
import { FileField } from './widgets/FileField';
|
|
9
|
-
import type {
|
|
9
|
+
import type { FieldWidgetComponentProps } from './widgets/types';
|
|
10
10
|
|
|
11
11
|
// ------------- Mocks & Setup -------------
|
|
12
12
|
|
|
@@ -15,7 +15,7 @@ const mockField = {
|
|
|
15
15
|
label: 'Test Field',
|
|
16
16
|
} as any;
|
|
17
17
|
|
|
18
|
-
const baseProps:
|
|
18
|
+
const baseProps: FieldWidgetComponentProps<any> = {
|
|
19
19
|
field: mockField,
|
|
20
20
|
value: undefined,
|
|
21
21
|
onChange: vi.fn(),
|
|
@@ -78,7 +78,7 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
78
78
|
reference_field: 'name',
|
|
79
79
|
} as any;
|
|
80
80
|
|
|
81
|
-
const dynamicProps:
|
|
81
|
+
const dynamicProps: FieldWidgetComponentProps<any> = {
|
|
82
82
|
...baseProps,
|
|
83
83
|
field: dynamicField,
|
|
84
84
|
dataSource: mockDataSource,
|
|
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
3
3
|
import { DateField } from './widgets/DateField';
|
|
4
4
|
import { DateTimeField } from './widgets/DateTimeField';
|
|
5
5
|
import { TimeField } from './widgets/TimeField';
|
|
6
|
-
import type {
|
|
6
|
+
import type { FieldWidgetComponentProps } from './widgets/types';
|
|
7
7
|
|
|
8
8
|
const mockField = {
|
|
9
9
|
name: 'test_field',
|
|
@@ -11,7 +11,7 @@ const mockField = {
|
|
|
11
11
|
type: 'date',
|
|
12
12
|
} as any;
|
|
13
13
|
|
|
14
|
-
const baseProps:
|
|
14
|
+
const baseProps: FieldWidgetComponentProps<string> = {
|
|
15
15
|
field: mockField,
|
|
16
16
|
value: '',
|
|
17
17
|
onChange: vi.fn(),
|
package/src/index.tsx
CHANGED
|
@@ -2048,7 +2048,9 @@ function createFieldRenderer(FieldWidget: React.ComponentType<any>) {
|
|
|
2048
2048
|
readonly: schema?.readonly || schema?.readOnly,
|
|
2049
2049
|
help: schema?.help,
|
|
2050
2050
|
description: schema?.description,
|
|
2051
|
-
|
|
2051
|
+
// `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
|
|
2052
|
+
// and `||` silently threw it away (same falsy-as-empty class as cloud#972).
|
|
2053
|
+
defaultValue: schema?.defaultValue ?? schema?.value,
|
|
2052
2054
|
...schema,
|
|
2053
2055
|
};
|
|
2054
2056
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, Label, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Address data structure
|
|
@@ -17,7 +17,7 @@ export interface AddressValue {
|
|
|
17
17
|
* Address field widget - provides a structured address input
|
|
18
18
|
* Supports street, city, state, zip code, and country
|
|
19
19
|
*/
|
|
20
|
-
export function AddressField({ value, onChange, field, readonly, ...props }:
|
|
20
|
+
export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<AddressValue>) {
|
|
21
21
|
const address = value || {};
|
|
22
22
|
|
|
23
23
|
const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* AutoNumberField - Read-only auto-generated field
|
|
7
7
|
* Values are automatically generated by the system and cannot be edited
|
|
8
8
|
*/
|
|
9
|
-
export function AutoNumberField({ value, field, ...props }:
|
|
9
|
+
export function AutoNumberField({ value, field, ...props }: FieldWidgetComponentProps<string | number>) {
|
|
10
10
|
if (value == null) {
|
|
11
11
|
return <EmptyValue className={props.className} />;
|
|
12
12
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
|
|
3
3
|
import { Upload, X } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Avatar field widget - provides an avatar/profile picture uploader
|
|
8
8
|
* Supports image URLs or file uploads
|
|
9
9
|
*/
|
|
10
|
-
export function AvatarField({ value, onChange, field, readonly, ...props }:
|
|
10
|
+
export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
const [isHovered, setIsHovered] = React.useState(false);
|
|
12
12
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
13
13
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React, { useId } from 'react';
|
|
2
2
|
import { Switch, Checkbox, Label } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* BooleanField - Toggle input supporting switch and checkbox variants
|
|
7
7
|
* Renders as Switch or Checkbox based on field widget configuration
|
|
8
8
|
*/
|
|
9
|
-
export function BooleanField({ value, onChange, field, readonly, ...props }:
|
|
9
|
+
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
|
|
10
10
|
const config = (field || (props as any).schema) as any;
|
|
11
11
|
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
|
|
12
12
|
const widget = config?.widget;
|
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { DataSource, QueryParams } from '@object-ui/types';
|
|
5
|
-
import {
|
|
5
|
+
import { FieldWidgetComponentProps } from './types';
|
|
6
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -86,12 +86,12 @@ export function CapabilityMultiSelectField({
|
|
|
86
86
|
readonly,
|
|
87
87
|
className,
|
|
88
88
|
...props
|
|
89
|
-
}:
|
|
89
|
+
}: FieldWidgetComponentProps<string | string[]>) {
|
|
90
90
|
const { t } = useFieldTranslation();
|
|
91
91
|
const ctx = React.useContext(SchemaRendererContext);
|
|
92
92
|
const dataSource: DataSource | null =
|
|
93
|
-
(props as any)
|
|
94
|
-
const disabled =
|
|
93
|
+
(props.dataSource as any) ?? (ctx as any)?.dataSource ?? null;
|
|
94
|
+
const disabled = props.disabled;
|
|
95
95
|
|
|
96
96
|
const [caps, setCaps] = React.useState<Capability[] | null>(null);
|
|
97
97
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useId, useEffect } from 'react';
|
|
2
2
|
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
|
|
3
3
|
import type { OptionLike } from '@object-ui/core';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
import { useCascadingOptions } from './useCascadingOptions';
|
|
6
6
|
|
|
7
7
|
type Option = OptionLike;
|
|
@@ -30,12 +30,12 @@ export function CheckboxesField({
|
|
|
30
30
|
emptyHint: _emptyHint,
|
|
31
31
|
dataSource: _dataSource,
|
|
32
32
|
...props
|
|
33
|
-
}:
|
|
33
|
+
}: FieldWidgetComponentProps<string[]>) {
|
|
34
34
|
const config = (field || schema) as any;
|
|
35
35
|
const rawOptions: Option[] = config?.options || [];
|
|
36
36
|
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
37
37
|
const groupId = useId();
|
|
38
|
-
const fieldName =
|
|
38
|
+
const fieldName = props.name || config?.name || props.id || '';
|
|
39
39
|
|
|
40
40
|
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
41
41
|
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
@@ -107,7 +107,7 @@ export function CheckboxesField({
|
|
|
107
107
|
id={id}
|
|
108
108
|
checked={selected.includes(value)}
|
|
109
109
|
onCheckedChange={(checked) => toggle(value, !!checked)}
|
|
110
|
-
disabled={
|
|
110
|
+
disabled={props.disabled}
|
|
111
111
|
data-testid={`checkboxes-option-${value}`}
|
|
112
112
|
/>
|
|
113
113
|
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Textarea, cn, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Code field widget - provides a code editor with syntax highlighting
|
|
7
7
|
* Uses a simple textarea with monospace font
|
|
8
8
|
* For advanced code editing, use the @object-ui/plugin-editor component
|
|
9
9
|
*/
|
|
10
|
-
export function CodeField({ value, onChange, field, readonly, ...props }:
|
|
10
|
+
export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
const config = field || (props as any).schema;
|
|
12
12
|
// Get code-specific configuration from field metadata
|
|
13
13
|
const language = (config as any)?.language ?? 'javascript';
|