@object-ui/fields 16.0.0 → 17.1.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 +630 -0
- package/README.md +45 -7
- package/dist/FieldEditWidget.d.ts +7 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +35 -11
- package/dist/index.js +2241 -1853
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.d.ts +8 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/MultiSelectField.d.ts +10 -2
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RadioField.d.ts +8 -1
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.d.ts +13 -9
- 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/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/file-value.d.ts +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +11 -10
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +137 -135
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +77 -11
- package/src/widgets/FileField.tsx +91 -52
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +106 -65
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +106 -9
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +78 -11
- package/src/widgets/ObjectRefField.tsx +9 -4
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +69 -9
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +8 -3
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +230 -0
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- package/src/widgets/useUploadingSignal.ts +20 -0
|
File without changes
|
|
@@ -4,5 +4,12 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* CheckboxesField - select zero or more values from a fixed option set, rendered
|
|
5
5
|
* as a list of checkboxes. The stored value is a string[]. Used for the
|
|
6
6
|
* `checkboxes` field type.
|
|
7
|
+
*
|
|
8
|
+
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
|
|
9
|
+
* gating as `MultiSelectField` (ADR-0058 / #2715), resolved through the shared
|
|
10
|
+
* {@link useCascadingOptions} hook: the offered boxes narrow against the live
|
|
11
|
+
* form record + `current_user`, the control is gated behind a "select the parent
|
|
12
|
+
* first" hint while a dependency is empty, and selections no longer offered
|
|
13
|
+
* (parent changed / predicate flipped) are pruned from the array.
|
|
7
14
|
*/
|
|
8
|
-
export declare function CheckboxesField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
15
|
+
export declare function CheckboxesField({ value, onChange, field, readonly, className, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
@@ -5,7 +5,7 @@ import { FieldWidgetProps } from './types';
|
|
|
5
5
|
* Supports single and multiple file uploads with configurable accepted file types.
|
|
6
6
|
* L2: File size validation, per-file progress indicators, error messages.
|
|
7
7
|
*/
|
|
8
|
-
export declare function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
8
|
+
export declare function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
9
9
|
/**
|
|
10
10
|
* FileCell — compact upload control for a line-item grid cell (objectui#2360).
|
|
11
11
|
*
|
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
2
|
import { FieldWidgetProps } from './types';
|
|
3
|
+
interface BuilderCondition {
|
|
4
|
+
id: string;
|
|
5
|
+
field: string;
|
|
6
|
+
operator: string;
|
|
7
|
+
value: any;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Builder condition → `$`-operator criteria. Exported for tests: this is the
|
|
11
|
+
* chokepoint where a builder token becomes a spec `FieldOperatorsSchema` key,
|
|
12
|
+
* and a wrong spelling here is rejected downstream by `convertFiltersToAST`
|
|
13
|
+
* rather than at authoring time. @internal
|
|
14
|
+
*/
|
|
15
|
+
export declare function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null;
|
|
16
|
+
/**
|
|
17
|
+
* Criteria → builder condition (the reverse of {@link condToMongo}). Returning
|
|
18
|
+
* `null` makes the builder refuse to load the rule ("criteria can't be
|
|
19
|
+
* represented"), so this must keep accepting spellings previously written.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null;
|
|
23
|
+
/**
|
|
24
|
+
* Would this criteria select EVERY record of the object?
|
|
25
|
+
*
|
|
26
|
+
* Mirrors the server's `isMatchAllCriteria` (objectstack `plugin-sharing`,
|
|
27
|
+
* #3896) closely enough to warn before the round-trip: blank, `{}`, `[]`, and
|
|
28
|
+
* the vacuous combinators. Deliberately conservative in the same direction —
|
|
29
|
+
* the cost of a false positive is one extra hint, the cost of a false negative
|
|
30
|
+
* is a save that fails with a toast. The server stays authoritative.
|
|
31
|
+
*
|
|
32
|
+
* @internal exported for tests
|
|
33
|
+
*/
|
|
34
|
+
export declare function isMatchAllCriteria(parsed: any): boolean;
|
|
3
35
|
export declare function FilterConditionField({ value, onChange, readonly, className, ...props }: FieldWidgetProps<string | object>): React.JSX.Element;
|
|
4
36
|
export default FilterConditionField;
|
|
@@ -4,4 +4,4 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* ImageField - Image upload widget with preview thumbnails
|
|
5
5
|
* Supports single and multiple image uploads with drag-and-drop and preview display
|
|
6
6
|
*/
|
|
7
|
-
export declare function ImageField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
7
|
+
export declare function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>): React.JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
import * as React from 'react';
|
|
9
|
+
export interface LightboxImage {
|
|
10
|
+
url: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ImageLightboxProps {
|
|
14
|
+
images: LightboxImage[];
|
|
15
|
+
/** Index of the image shown when the lightbox opens. */
|
|
16
|
+
index: number;
|
|
17
|
+
open: boolean;
|
|
18
|
+
onOpenChange: (open: boolean) => void;
|
|
19
|
+
/** Called when the user navigates to another image (prev/next, arrow keys). */
|
|
20
|
+
onIndexChange?: (index: number) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Full-screen image preview with prev/next navigation for a set of images.
|
|
24
|
+
*
|
|
25
|
+
* Controlled: the caller owns `open` and the current `index`. Built on the
|
|
26
|
+
* shared `Dialog`, so backdrop-click and Escape close it for free; this adds
|
|
27
|
+
* left/right arrow-key navigation and a position counter for the multi-image
|
|
28
|
+
* case. A single image simply omits the arrows and counter.
|
|
29
|
+
*/
|
|
30
|
+
export declare function ImageLightbox({ images, index, open, onOpenChange, onIndexChange }: ImageLightboxProps): React.JSX.Element | null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
File without changes
|
|
@@ -4,6 +4,14 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* MultiSelectField - select zero or more values from a fixed option set.
|
|
5
5
|
*
|
|
6
6
|
* Renders the configured options as toggleable chips. The stored value is a
|
|
7
|
-
* string[] (the selected option values). Used for the `multiselect` field type
|
|
7
|
+
* string[] (the selected option values). Used for the `multiselect` field type
|
|
8
|
+
* and for a `select` field declared `multiple: true`.
|
|
9
|
+
*
|
|
10
|
+
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
|
|
11
|
+
* gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
|
|
12
|
+
* shared {@link useCascadingOptions} hook: the offered chips narrow against the
|
|
13
|
+
* live form record + `current_user`, the control is gated behind a "select the
|
|
14
|
+
* parent first" hint while a dependency is empty, and selections no longer
|
|
15
|
+
* offered (parent changed / predicate flipped) are dropped from the array.
|
|
8
16
|
*/
|
|
9
|
-
export declare function MultiSelectField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
17
|
+
export declare function MultiSelectField({ value, onChange, field, readonly, className, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string[]>): React.JSX.Element;
|
|
File without changes
|
|
@@ -4,5 +4,12 @@ import { FieldWidgetProps } from './types';
|
|
|
4
4
|
* RadioField - choose exactly one value from a fixed option set, rendered as a
|
|
5
5
|
* radio group. The stored value is the selected option value (string). Used for
|
|
6
6
|
* the `radio` field type.
|
|
7
|
+
*
|
|
8
|
+
* Like the single `SelectField`, options support per-option `visibleWhen`
|
|
9
|
+
* cascading + `dependsOn` gating (ADR-0058 / #2715), resolved through the shared
|
|
10
|
+
* {@link useCascadingOptions} hook: the offered radios narrow against the live
|
|
11
|
+
* form record + `current_user`, the control is gated behind a "select the parent
|
|
12
|
+
* first" hint while a dependency is empty, and a value no longer offered (parent
|
|
13
|
+
* changed / predicate flipped) is cleared.
|
|
7
14
|
*/
|
|
8
|
-
export declare function RadioField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
15
|
+
export declare function RadioField({ value, onChange, field, readonly, className, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
|
|
File without changes
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
2
|
import { FieldWidgetProps } from './types';
|
|
3
3
|
/**
|
|
4
|
-
* SelectField -
|
|
4
|
+
* SelectField - dropdown selection widget.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* A field declared `multiple: true` selects zero-or-more values (spec:
|
|
7
|
+
* `multiple` is valid on `select`), so it renders the multi-value chip picker
|
|
8
|
+
* — the same widget the `multiselect` type uses. Delegating here (rather than
|
|
9
|
+
* only at a type-resolution layer) means every surface that renders the
|
|
10
|
+
* `select` widget — the object form, the inline grid editor, and
|
|
11
|
+
* `ActionParamDialog` — inherits multi-select identically, with no drift
|
|
12
|
+
* between them. Single-value selects keep the cascading dropdown below.
|
|
13
|
+
*
|
|
14
|
+
* Both branches resolve per-option `visibleWhen` cascading / role-gating through
|
|
15
|
+
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
|
|
16
|
+
* in lockstep.
|
|
13
17
|
*/
|
|
14
|
-
export declare function SelectField(
|
|
18
|
+
export declare function SelectField(props: FieldWidgetProps<any>): React.JSX.Element;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File field value shapes (ObjectStack ADR-0104 D3 wave 2).
|
|
3
|
+
*
|
|
4
|
+
* A `file`/`image`/`avatar`/`video`/`audio` field value reaches the UI in one
|
|
5
|
+
* of three forms, and every widget that renders one has to cope with all
|
|
6
|
+
* three — so the rules live here once rather than being re-derived per widget.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Reference** — a bare `sys_file` id string. The form the backend stores
|
|
9
|
+
* once file-as-reference is adopted.
|
|
10
|
+
* 2. **Expanded** — `{ id, name, size, mimeType, url }`, what the read path
|
|
11
|
+
* returns after resolving a stored reference. Note `mimeType`: this comes
|
|
12
|
+
* from the platform's spec-owned `FileValueSchema` and is **camelCase**.
|
|
13
|
+
* 3. **Legacy inline blob** — `{ file_id?, name, original_name, size,
|
|
14
|
+
* mime_type, url }`, the pre-reference shape this package used to build
|
|
15
|
+
* itself, with **snake_case** `mime_type`.
|
|
16
|
+
*
|
|
17
|
+
* That casing split is the whole reason this module exists: a widget reading
|
|
18
|
+
* only `mime_type` silently stops recognising images the moment the backend
|
|
19
|
+
* starts returning the expanded form, and the failure looks like "thumbnails
|
|
20
|
+
* disappeared" rather than anything pointing at a value shape.
|
|
21
|
+
*
|
|
22
|
+
* ## Submitting
|
|
23
|
+
*
|
|
24
|
+
* When the upload adapter surfaced a `fileId`, the widget submits the **bare
|
|
25
|
+
* id** — the reference form. When it did not (the object-URL fallback adapter,
|
|
26
|
+
* an older backend), it submits the legacy blob unchanged. So the same build
|
|
27
|
+
* works against a backend that has adopted references and one that has not.
|
|
28
|
+
* Action params already POST a bare fileId; this brings record field values
|
|
29
|
+
* onto the same contract.
|
|
30
|
+
*/
|
|
31
|
+
/** A file value normalised for rendering, whatever form it arrived in. */
|
|
32
|
+
export interface FileValueView {
|
|
33
|
+
/** `sys_file` id, when the value carries one. */
|
|
34
|
+
id?: string;
|
|
35
|
+
/** Best available display name. Never empty. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Resolvable URL, when the value carries one. A bare reference does not. */
|
|
38
|
+
url?: string;
|
|
39
|
+
size?: number;
|
|
40
|
+
mimeType?: string;
|
|
41
|
+
/** The value as it arrived, so callers can pass it through untouched. */
|
|
42
|
+
raw: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A minted file id: uuid/nanoid-shaped, and crucially not a URL — a URL always
|
|
46
|
+
* carries `:`, `/` or `.`, so it can never match. Mirrors the platform's
|
|
47
|
+
* `isFileIdToken`, which is the arbiter on the server side of the same question.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isFileIdToken(value: unknown): value is string;
|
|
50
|
+
/**
|
|
51
|
+
* The `sys_file` id a value refers to, or `undefined`.
|
|
52
|
+
*
|
|
53
|
+
* `file_id ?? id` matches the rule `serializeParamValues` already applies to
|
|
54
|
+
* action params — one extraction rule for both surfaces.
|
|
55
|
+
*/
|
|
56
|
+
export declare function fileIdOf(value: unknown): string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* The stable download endpoint a bare `sys_file` id resolves to.
|
|
59
|
+
*
|
|
60
|
+
* `createObjectStackUploadAdapter` stores exactly this URL (`${basePath}/files/
|
|
61
|
+
* :id`, default base `/api/v1/storage`) as a completed upload's value, and the
|
|
62
|
+
* endpoint 302-redirects to a freshly-signed short-lived URL on every request —
|
|
63
|
+
* so it can be used directly as an `<img src>`. Building it here means a value
|
|
64
|
+
* still in its bare-reference form (the backend read path didn't expand it —
|
|
65
|
+
* seen on the edit-form data path, objectui image thumbnails rendering broken)
|
|
66
|
+
* resolves to a real URL instead of an empty `src`.
|
|
67
|
+
*/
|
|
68
|
+
export declare const FILE_STORAGE_BASE_PATH = "/api/v1/storage";
|
|
69
|
+
/** The stable download URL for a `sys_file` id. */
|
|
70
|
+
export declare function fileUrlFromId(id: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Normalise one file value for display.
|
|
73
|
+
*
|
|
74
|
+
* @param fallbackName shown when the value carries no usable name — pass a
|
|
75
|
+
* translated string so the widget stays localised.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readFileValue(value: unknown, fallbackName?: string): FileValueView;
|
|
78
|
+
/** Normalise a field value (single or `multiple`) to an array of views. */
|
|
79
|
+
export declare function readFileValues(value: unknown, fallbackName?: string): FileValueView[];
|
|
80
|
+
/** Does this value look like an image, by MIME type? */
|
|
81
|
+
export declare function isImageValue(view: FileValueView): boolean;
|
|
82
|
+
/** The subset of an upload result these helpers read. */
|
|
83
|
+
export interface UploadResultLike {
|
|
84
|
+
url: string;
|
|
85
|
+
name: string;
|
|
86
|
+
size: number;
|
|
87
|
+
mimeType: string;
|
|
88
|
+
meta?: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What to store in the field for a completed upload.
|
|
92
|
+
*
|
|
93
|
+
* Returns the bare `sys_file` id when the adapter surfaced one — the reference
|
|
94
|
+
* form — and the legacy inline blob when it did not, so a deployment whose
|
|
95
|
+
* upload adapter or backend predates file-as-reference keeps working unchanged.
|
|
96
|
+
*/
|
|
97
|
+
export declare function fileValueForSubmit(result: UploadResultLike, originalName?: string): string | Record<string, unknown>;
|
|
98
|
+
/**
|
|
99
|
+
* The display view of a just-completed upload.
|
|
100
|
+
*
|
|
101
|
+
* Submitting a bare id means the field value no longer carries the name, size
|
|
102
|
+
* or URL needed to render it, and the enriched form only comes back on the next
|
|
103
|
+
* read. Widgets keep these views keyed by id so an upload appears immediately
|
|
104
|
+
* instead of showing a bare token until a refetch.
|
|
105
|
+
*/
|
|
106
|
+
export declare function uploadResultView(result: UploadResultLike, originalName?: string): FileValueView;
|
|
107
|
+
/**
|
|
108
|
+
* Merge locally-known upload views over a field value's own views, matched by
|
|
109
|
+
* id. A value that is still a bare reference picks up the name/URL captured at
|
|
110
|
+
* upload time; anything already enriched by the backend is left alone.
|
|
111
|
+
*/
|
|
112
|
+
export declare function withRecentUploads(views: FileValueView[], recent: Record<string, FileValueView>): FileValueView[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/widgets/types.d.ts
CHANGED
|
@@ -7,5 +7,11 @@ export type FieldWidgetProps<T = any> = {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
className?: string;
|
|
9
9
|
errorMessage?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Upload widgets (`file`/`image`) fire this when their in-progress state
|
|
12
|
+
* flips, so a host can block submit until a presigned upload settles. Other
|
|
13
|
+
* widgets ignore it.
|
|
14
|
+
*/
|
|
15
|
+
onUploadingChange?: (uploading: boolean) => void;
|
|
10
16
|
[key: string]: any;
|
|
11
17
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CascadingOptions, OptionLike, DependsOnInput } from '@object-ui/core';
|
|
2
|
+
/**
|
|
3
|
+
* Shared per-option cascading / role-gating resolution for the option widgets
|
|
4
|
+
* (`SelectField` single, `MultiSelectField`, `RadioField`) — the client half of
|
|
5
|
+
* ADR-0058 (#2284). Each option may carry a `visibleWhen` CEL predicate; the
|
|
6
|
+
* offered set narrows against the live form record (`record.country == 'cn'`) +
|
|
7
|
+
* the global predicate scope (`'admin' in current_user.positions`). A field
|
|
8
|
+
* declares which sibling fields drive its list via `dependsOn`; while any is
|
|
9
|
+
* empty the list is *gated* — callers surface a "select the parent first" hint
|
|
10
|
+
* rather than an unfiltered set, mirroring the dependent-lookup UX.
|
|
11
|
+
*
|
|
12
|
+
* This is the React wrapper that sources `record` (the live form values) and the
|
|
13
|
+
* predicate scope from context; the actual resolution is the pure
|
|
14
|
+
* {@link resolveCascadingOptions} in `@object-ui/core`, shared with the form
|
|
15
|
+
* renderer so gating/filtering can never drift between them (#2715).
|
|
16
|
+
*/
|
|
17
|
+
export type CascadingOptionsResult<T extends OptionLike> = CascadingOptions<T>;
|
|
18
|
+
export declare function useCascadingOptions<T extends OptionLike>(rawOptions: readonly T[], dependsOn: DependsOnInput, dependentValues: Record<string, unknown> | undefined): CascadingOptionsResult<T>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notify a parent whenever an upload widget's in-progress state flips. Used by
|
|
3
|
+
* hosts that must block a submit until the presigned upload resolves — e.g.
|
|
4
|
+
* `ActionParamDialog` disables Confirm so a `file`/`image` param can't be
|
|
5
|
+
* submitted mid-upload (the value only becomes the fileId once the upload
|
|
6
|
+
* settles). Fires with the latest callback and only when `uploading` actually
|
|
7
|
+
* changes, so an inline arrow prop doesn't thrash the parent every render.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useUploadingSignal(uploading: boolean, onUploadingChange?: (uploading: boolean) => void): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.1.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,28 +25,29 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"clsx": "^2.1.1",
|
|
28
|
-
"lucide-react": "^1.
|
|
28
|
+
"lucide-react": "^1.25.0",
|
|
29
29
|
"react-markdown": "^10.1.0",
|
|
30
30
|
"rehype-sanitize": "^6.0.0",
|
|
31
31
|
"remark-gfm": "^4.0.1",
|
|
32
32
|
"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": "
|
|
33
|
+
"@object-ui/components": "17.1.0",
|
|
34
|
+
"@object-ui/core": "17.1.0",
|
|
35
|
+
"@object-ui/i18n": "17.1.0",
|
|
36
|
+
"@object-ui/providers": "17.1.0",
|
|
37
|
+
"@object-ui/react": "17.1.0",
|
|
38
|
+
"@object-ui/types": "17.1.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"react": "^18.0.0 || ^19.0.0",
|
|
42
42
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
+
"@objectstack/spec": "^17.0.0-rc.0",
|
|
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
|
-
"vite": "^8.1.
|
|
50
|
+
"vite": "^8.1.5",
|
|
50
51
|
"vite-plugin-dts": "^5.0.3"
|
|
51
52
|
},
|
|
52
53
|
"publishConfig": {
|
|
@@ -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
|
@@ -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
|
|
@@ -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
|
/**
|
|
@@ -142,8 +181,9 @@ export function FieldEditWidget({
|
|
|
142
181
|
onChange,
|
|
143
182
|
readonly,
|
|
144
183
|
}: FieldWidgetProps<any>): React.ReactElement | null {
|
|
145
|
-
const
|
|
184
|
+
const resolved = field?.type ? resolveInlineEditType(field.type) : undefined;
|
|
185
|
+
const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined;
|
|
146
186
|
if (!Widget) return null;
|
|
147
|
-
const compactProps =
|
|
187
|
+
const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {};
|
|
148
188
|
return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...(compactProps as any)} />;
|
|
149
189
|
}
|