@object-ui/fields 16.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 +626 -0
- package/dist/FieldEditWidget.d.ts +9 -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/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +14 -11
- package/dist/index.js +2426 -2100
- 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 +34 -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/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -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/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/RecipientPickerField.test.d.ts +0 -0
- 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/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
- package/dist/widgets/file-value.d.ts +99 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +119 -3
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/package.json +9 -8
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +50 -8
- 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/field-type-alias.ts +100 -0
- package/src/index.tsx +97 -134
- 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 +12 -9
- 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 +90 -59
- package/src/widgets/FilterConditionField.tsx +112 -12
- 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 +73 -35
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LocationField.tsx +2 -2
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +116 -19
- package/src/widgets/MasterDetailField.tsx +2 -2
- package/src/widgets/MultiSelectField.tsx +10 -7
- package/src/widgets/NumberField.tsx +2 -2
- package/src/widgets/ObjectField.tsx +2 -2
- package/src/widgets/ObjectRefField.tsx +13 -8
- package/src/widgets/PasswordField.tsx +2 -2
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- 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 +10 -7
- package/src/widgets/RatingField.tsx +2 -2
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +40 -20
- 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 +6 -3
- 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/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +246 -0
- package/src/widgets/types.ts +129 -6
- package/src/widgets/useFieldTranslation.ts +30 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { isFileIdToken } from '@objectstack/spec/data';
|
|
2
|
+
/** A file value normalised for rendering, whatever form it arrived in. */
|
|
3
|
+
export interface FileValueView {
|
|
4
|
+
/** `sys_file` id, when the value carries one. */
|
|
5
|
+
id?: string;
|
|
6
|
+
/** Best available display name. Never empty. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Resolvable URL, when the value carries one. A bare reference does not. */
|
|
9
|
+
url?: string;
|
|
10
|
+
size?: number;
|
|
11
|
+
mimeType?: string;
|
|
12
|
+
/** The value as it arrived, so callers can pass it through untouched. */
|
|
13
|
+
raw: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A minted file id: uuid/nanoid-shaped, and crucially not a URL — a URL always
|
|
17
|
+
* carries `:`, `/` or `.`, so it can never match.
|
|
18
|
+
*
|
|
19
|
+
* This IS the platform's arbiter now, not a mirror of it (objectui#3161,
|
|
20
|
+
* objectstack#4115 ledger batch 7). The declaration that stood here said it
|
|
21
|
+
* "mirrors the platform's `isFileIdToken`" while being a character-for-character
|
|
22
|
+
* copy of that function's body under that function's own name — the shape
|
|
23
|
+
* objectui#3003 argued about and objectui#3169 caught again in
|
|
24
|
+
* `isAggregatedViewContainer`: every value test and every behaviour test passes
|
|
25
|
+
* against a faithful copy, so reference identity is the only check that can tell
|
|
26
|
+
* a re-export from a fork. It is asserted in
|
|
27
|
+
* `__tests__/spec-symbol-batch7.test.ts`.
|
|
28
|
+
*
|
|
29
|
+
* Why it matters that this one is shared rather than duplicated: the regex is a
|
|
30
|
+
* WIRE decision. Widening it server-side (say, ids grow past 64 chars) while a
|
|
31
|
+
* copy here keeps the old bound turns every new id into "not a reference", and
|
|
32
|
+
* the widget then submits the legacy inline blob against a backend that expects
|
|
33
|
+
* a reference. That failure surfaces as a broken thumbnail, nowhere near a
|
|
34
|
+
* regex.
|
|
35
|
+
*/
|
|
36
|
+
export { isFileIdToken };
|
|
37
|
+
/**
|
|
38
|
+
* The `sys_file` id a value refers to, or `undefined`.
|
|
39
|
+
*
|
|
40
|
+
* `file_id ?? id` matches the rule `serializeParamValues` already applies to
|
|
41
|
+
* action params — one extraction rule for both surfaces.
|
|
42
|
+
*/
|
|
43
|
+
export declare function fileIdOf(value: unknown): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The stable download endpoint a bare `sys_file` id resolves to.
|
|
46
|
+
*
|
|
47
|
+
* `createObjectStackUploadAdapter` stores exactly this URL (`${basePath}/files/
|
|
48
|
+
* :id`, default base `/api/v1/storage`) as a completed upload's value, and the
|
|
49
|
+
* endpoint 302-redirects to a freshly-signed short-lived URL on every request —
|
|
50
|
+
* so it can be used directly as an `<img src>`. Building it here means a value
|
|
51
|
+
* still in its bare-reference form (the backend read path didn't expand it —
|
|
52
|
+
* seen on the edit-form data path, objectui image thumbnails rendering broken)
|
|
53
|
+
* resolves to a real URL instead of an empty `src`.
|
|
54
|
+
*/
|
|
55
|
+
export declare const FILE_STORAGE_BASE_PATH = "/api/v1/storage";
|
|
56
|
+
/** The stable download URL for a `sys_file` id. */
|
|
57
|
+
export declare function fileUrlFromId(id: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Normalise one file value for display.
|
|
60
|
+
*
|
|
61
|
+
* @param fallbackName shown when the value carries no usable name — pass a
|
|
62
|
+
* translated string so the widget stays localised.
|
|
63
|
+
*/
|
|
64
|
+
export declare function readFileValue(value: unknown, fallbackName?: string): FileValueView;
|
|
65
|
+
/** Normalise a field value (single or `multiple`) to an array of views. */
|
|
66
|
+
export declare function readFileValues(value: unknown, fallbackName?: string): FileValueView[];
|
|
67
|
+
/** Does this value look like an image, by MIME type? */
|
|
68
|
+
export declare function isImageValue(view: FileValueView): boolean;
|
|
69
|
+
/** The subset of an upload result these helpers read. */
|
|
70
|
+
export interface UploadResultLike {
|
|
71
|
+
url: string;
|
|
72
|
+
name: string;
|
|
73
|
+
size: number;
|
|
74
|
+
mimeType: string;
|
|
75
|
+
meta?: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* What to store in the field for a completed upload.
|
|
79
|
+
*
|
|
80
|
+
* Returns the bare `sys_file` id when the adapter surfaced one — the reference
|
|
81
|
+
* form — and the legacy inline blob when it did not, so a deployment whose
|
|
82
|
+
* upload adapter or backend predates file-as-reference keeps working unchanged.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fileValueForSubmit(result: UploadResultLike, originalName?: string): string | Record<string, unknown>;
|
|
85
|
+
/**
|
|
86
|
+
* The display view of a just-completed upload.
|
|
87
|
+
*
|
|
88
|
+
* Submitting a bare id means the field value no longer carries the name, size
|
|
89
|
+
* or URL needed to render it, and the enriched form only comes back on the next
|
|
90
|
+
* read. Widgets keep these views keyed by id so an upload appears immediately
|
|
91
|
+
* instead of showing a bare token until a refetch.
|
|
92
|
+
*/
|
|
93
|
+
export declare function uploadResultView(result: UploadResultLike, originalName?: string): FileValueView;
|
|
94
|
+
/**
|
|
95
|
+
* Merge locally-known upload views over a field value's own views, matched by
|
|
96
|
+
* id. A value that is still a bare reference picks up the name/URL captured at
|
|
97
|
+
* upload time; anything already enriched by the backend is left alone.
|
|
98
|
+
*/
|
|
99
|
+
export declare function withRecentUploads(views: FileValueView[], recent: Record<string, FileValueView>): FileValueView[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
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": "
|
|
3
|
+
"version": "17.2.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,18 +24,19 @@
|
|
|
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": "
|
|
34
|
-
"@object-ui/core": "
|
|
35
|
-
"@object-ui/i18n": "
|
|
36
|
-
"@object-ui/providers": "
|
|
37
|
-
"@object-ui/react": "
|
|
38
|
-
"@object-ui/types": "
|
|
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",
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"devDependencies": {
|
|
45
46
|
"@types/react": "19.2.17",
|
|
46
47
|
"@types/react-dom": "19.2.3",
|
|
47
|
-
"@vitejs/plugin-react": "^6.0.
|
|
48
|
+
"@vitejs/plugin-react": "^6.0.4",
|
|
48
49
|
"typescript": "^6.0.3",
|
|
49
50
|
"vite": "^8.1.5",
|
|
50
51
|
"vite-plugin-dts": "^5.0.3"
|
|
@@ -7,12 +7,33 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { FieldType } from '@objectstack/spec/data';
|
|
10
11
|
import {
|
|
11
12
|
FORM_FIELD_TYPES,
|
|
12
13
|
INLINE_EXCLUDED_FIELD_TYPES,
|
|
13
14
|
hasFieldEditWidget,
|
|
15
|
+
isInlineExcludedFieldType,
|
|
16
|
+
mapFieldTypeToFormType,
|
|
14
17
|
} from './index';
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A field type the form can render — either a direct widget-map key, or a spec
|
|
21
|
+
* spelling that resolves onto one through the alias table (`secret` →
|
|
22
|
+
* `field:password`, `autonumber` → `field:auto_number`, …).
|
|
23
|
+
*
|
|
24
|
+
* `FORM_FIELD_TYPES` alone is `Object.keys(fieldWidgetMap)`, which does NOT
|
|
25
|
+
* include the alias-only spellings — see the note at index.tsx's
|
|
26
|
+
* `resolveFormWidgetType`. Using it as the definition of "a real type" made the
|
|
27
|
+
* staleness check below reject `secret`, a genuine spec field type the form
|
|
28
|
+
* renders, purely because it arrives via an alias.
|
|
29
|
+
*/
|
|
30
|
+
function isRenderableFormType(t: string): boolean {
|
|
31
|
+
// `text` is the alias table's fallback, so a non-`field:text` result means an
|
|
32
|
+
// explicit entry exists. `text` itself is a direct widget key, so it is
|
|
33
|
+
// already covered by the first clause and no real type is missed here.
|
|
34
|
+
return FORM_FIELD_TYPES.includes(t) || mapFieldTypeToFormType(t) !== 'field:text';
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
/**
|
|
17
38
|
* Drift-guard: the inline cell editor reuses the form's field widgets, but the
|
|
18
39
|
* two lists were hand-maintained separately and drifted — `lookup` (and other
|
|
@@ -34,11 +55,21 @@ describe('inline editor ↔ form widget parity', () => {
|
|
|
34
55
|
});
|
|
35
56
|
|
|
36
57
|
it('the exclusion set lists only real form types (no stale entries)', () => {
|
|
37
|
-
const
|
|
38
|
-
const stale = [...INLINE_EXCLUDED_FIELD_TYPES].filter((t) => !known.has(t));
|
|
58
|
+
const stale = [...INLINE_EXCLUDED_FIELD_TYPES].filter((t) => !isRenderableFormType(t));
|
|
39
59
|
expect(stale).toEqual([]);
|
|
40
60
|
});
|
|
41
61
|
|
|
62
|
+
it('credential types are never inline-editable', () => {
|
|
63
|
+
// The grid's fallback for a type with no inline editor is a PLAIN TEXT input.
|
|
64
|
+
// Both of these are masked on read, so that input would show the mask as the
|
|
65
|
+
// value and write it straight back; `secret` also round-trips through an
|
|
66
|
+
// encrypted store (ADR-0100), so the cell holds an opaque ref, not the value.
|
|
67
|
+
for (const t of ['password', 'secret']) {
|
|
68
|
+
expect(hasFieldEditWidget(t), `${t} must not have an inline editor`).toBe(false);
|
|
69
|
+
expect(INLINE_EXCLUDED_FIELD_TYPES.has(t), `${t} must be explicitly excluded`).toBe(true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
42
73
|
it('relational fields use the standard picker inline (regression: lookup was a text box)', () => {
|
|
43
74
|
for (const t of ['lookup', 'master_detail', 'user', 'owner']) {
|
|
44
75
|
expect(hasFieldEditWidget(t)).toBe(true);
|
|
@@ -59,3 +90,44 @@ describe('inline editor ↔ form widget parity', () => {
|
|
|
59
90
|
}
|
|
60
91
|
});
|
|
61
92
|
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* #2942 — the guard above iterates FORM_FIELD_TYPES (the form widget-map
|
|
96
|
+
* keys), so a SPEC field type that reaches the form only through the alias
|
|
97
|
+
* table could sit in neither set and silently fall back to the grid's plain
|
|
98
|
+
* text input: `json`, `composite`, `record`, `repeater`, `tree`, `video`,
|
|
99
|
+
* `audio`, `autonumber` all did. This pins the contract at the spec boundary:
|
|
100
|
+
* every `FieldType` member must resolve (alias-aware) to an inline editor or
|
|
101
|
+
* a documented exclusion.
|
|
102
|
+
*/
|
|
103
|
+
describe('inline editor ↔ SPEC FieldType parity (#2942)', () => {
|
|
104
|
+
const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options)
|
|
105
|
+
? [...(FieldType as { options: readonly string[] }).options]
|
|
106
|
+
: [];
|
|
107
|
+
|
|
108
|
+
it('reads a non-empty enum from the spec', () => {
|
|
109
|
+
expect(specTypes, 'could not read FieldType.options from the spec').not.toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('every spec field type resolves to an inline decision (editor or exclusion)', () => {
|
|
113
|
+
const undecided = specTypes.filter(
|
|
114
|
+
(t) => !hasFieldEditWidget(t) && !isInlineExcludedFieldType(t),
|
|
115
|
+
);
|
|
116
|
+
// If this fails: a spec field type would fall back to a plain text input
|
|
117
|
+
// inline. Give it a widget (EDIT_WIDGETS / the alias table) or a
|
|
118
|
+
// documented exclusion (INLINE_EXCLUDED_FIELD_TYPES).
|
|
119
|
+
expect(undecided).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('the structured/computed spec spellings are closed corruption paths', () => {
|
|
123
|
+
// Excluded (alias-aware): editing these through a text box corrupts the value.
|
|
124
|
+
for (const t of ['composite', 'record', 'repeater', 'video', 'audio', 'autonumber']) {
|
|
125
|
+
expect(isInlineExcludedFieldType(t), `${t} must be excluded from inline editing`).toBe(true);
|
|
126
|
+
expect(hasFieldEditWidget(t), `${t} must not resolve to an editor`).toBe(false);
|
|
127
|
+
}
|
|
128
|
+
// Editable through their form widgets: json → code editor, tree → lookup picker.
|
|
129
|
+
for (const t of ['json', 'tree']) {
|
|
130
|
+
expect(hasFieldEditWidget(t), `${t} must resolve to its form widget`).toBe(true);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
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
|
|
@@ -44,6 +44,9 @@ import { LocationField } from './widgets/LocationField';
|
|
|
44
44
|
import { GeolocationField } from './widgets/GeolocationField';
|
|
45
45
|
import { CodeField } from './widgets/CodeField';
|
|
46
46
|
import { QRCodeField } from './widgets/QRCodeField';
|
|
47
|
+
// The FORM's spec-alias table (`json` → `field:code`, `tree` → `field:lookup`,
|
|
48
|
+
// …) — inline resolution reuses it so spec spellings get the form's decision.
|
|
49
|
+
import { mapFieldTypeToFormType } from './field-type-alias';
|
|
47
50
|
|
|
48
51
|
/**
|
|
49
52
|
* Field types that edit in place with a dedicated widget. Keyed by the raw
|
|
@@ -51,7 +54,7 @@ import { QRCodeField } from './widgets/QRCodeField';
|
|
|
51
54
|
* Rich/heavy types (file, image, lookup, richtext, …) are intentionally absent
|
|
52
55
|
* so callers fall back to their own simpler editor.
|
|
53
56
|
*/
|
|
54
|
-
const EDIT_WIDGETS: Record<string, React.ComponentType<
|
|
57
|
+
const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetComponentProps<any>>> = {
|
|
55
58
|
text: TextField,
|
|
56
59
|
textarea: TextAreaField,
|
|
57
60
|
number: NumberField,
|
|
@@ -102,7 +105,13 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
|
|
|
102
105
|
// Binary / attachment — edited from the record form, shown read-only in the grid.
|
|
103
106
|
'file', 'image', 'avatar', 'signature',
|
|
104
107
|
// Heavy / full editors — better in the record form than a cell.
|
|
105
|
-
'markdown', 'html', 'richtext',
|
|
108
|
+
'markdown', 'html', 'richtext',
|
|
109
|
+
// Credentials — never inline-editable. Both are masked on read, so the cell has
|
|
110
|
+
// no real value to seed an editor with, and the grid's fallback is a PLAIN TEXT
|
|
111
|
+
// input: it would render the mask as if it were the value and write it straight
|
|
112
|
+
// back. `secret` additionally round-trips through an encrypted store (ADR-0100,
|
|
113
|
+
// data/field.zod.ts) — the cell holds an opaque ref, not the secret.
|
|
114
|
+
'password', 'secret',
|
|
106
115
|
// Containers / non-authorable — a sub-form / sub-grid / embedding vector
|
|
107
116
|
// doesn't belong in a single cell.
|
|
108
117
|
'object', 'grid', 'vector',
|
|
@@ -116,9 +125,39 @@ export const DISCRETE_EDIT_TYPES = new Set<string>([
|
|
|
116
125
|
'boolean', 'toggle', 'select', 'status', 'radio', 'rating',
|
|
117
126
|
]);
|
|
118
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Resolve a field type to its inline-editing key: the raw type when the
|
|
130
|
+
* widget/exclusion tables name it directly, otherwise its form-alias target
|
|
131
|
+
* (`mapFieldTypeToFormType`, `field:` prefix stripped).
|
|
132
|
+
*
|
|
133
|
+
* The tables are keyed by the FORM's spellings, but spec field types reach
|
|
134
|
+
* this layer in the SPEC's spellings — `json`, `tree`, `composite`, `record`,
|
|
135
|
+
* `repeater`, `video`, `audio`, `autonumber` are aliases in the form map and
|
|
136
|
+
* were keys in NEITHER table here, so the grid silently fell back to a plain
|
|
137
|
+
* text input for all of them: typing over structured JSON or a hierarchy ref
|
|
138
|
+
* is a value-corruption path (#2942). Resolving through the same alias table
|
|
139
|
+
* the form uses gives each spec type the form's own decision: `json` → the
|
|
140
|
+
* code editor, `tree` → the lookup picker, and the container/computed/binary
|
|
141
|
+
* families → their documented exclusions.
|
|
142
|
+
*/
|
|
143
|
+
function resolveInlineEditType(type: string): string {
|
|
144
|
+
if (type in EDIT_WIDGETS || INLINE_EXCLUDED_FIELD_TYPES.has(type)) return type;
|
|
145
|
+
return mapFieldTypeToFormType(type).replace(/^field:/, '');
|
|
146
|
+
}
|
|
147
|
+
|
|
119
148
|
/** True when a field type has a dedicated in-place edit widget. */
|
|
120
149
|
export function hasFieldEditWidget(type: string | undefined): boolean {
|
|
121
|
-
return !!type && type in EDIT_WIDGETS;
|
|
150
|
+
return !!type && resolveInlineEditType(type) in EDIT_WIDGETS;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* True when a field type is deliberately NOT inline-editable (alias-aware:
|
|
155
|
+
* `composite` resolves to the excluded `object`, `video` to `file`, …).
|
|
156
|
+
* Grid hosts must treat these as read-only cells — falling back to a plain
|
|
157
|
+
* text editor is exactly the corruption path the exclusion exists to close.
|
|
158
|
+
*/
|
|
159
|
+
export function isInlineExcludedFieldType(type: string | undefined): boolean {
|
|
160
|
+
return !!type && INLINE_EXCLUDED_FIELD_TYPES.has(resolveInlineEditType(type));
|
|
122
161
|
}
|
|
123
162
|
|
|
124
163
|
/**
|
|
@@ -141,9 +180,12 @@ export function FieldEditWidget({
|
|
|
141
180
|
value,
|
|
142
181
|
onChange,
|
|
143
182
|
readonly,
|
|
144
|
-
}:
|
|
145
|
-
const
|
|
183
|
+
}: FieldWidgetComponentProps<any>): React.ReactElement | null {
|
|
184
|
+
const resolved = field?.type ? resolveInlineEditType(field.type) : undefined;
|
|
185
|
+
const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined;
|
|
146
186
|
if (!Widget) return null;
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
189
|
+
const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {};
|
|
190
|
+
return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...compactProps} />;
|
|
149
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
|
+
});
|