@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +630 -0
  2. package/README.md +45 -7
  3. package/dist/FieldEditWidget.d.ts +7 -0
  4. package/dist/field-type-alias.d.ts +16 -0
  5. package/dist/index.cjs +5 -5
  6. package/dist/index.d.ts +35 -11
  7. package/dist/index.js +2241 -1853
  8. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  9. package/dist/widgets/CheckboxesField.d.ts +8 -1
  10. package/dist/widgets/FileField.d.ts +1 -1
  11. package/dist/widgets/FilterConditionField.d.ts +32 -0
  12. package/dist/widgets/ImageField.d.ts +1 -1
  13. package/dist/widgets/ImageLightbox.d.ts +30 -0
  14. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  15. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  16. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  17. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  18. package/dist/widgets/MultiSelectField.d.ts +10 -2
  19. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  20. package/dist/widgets/RadioField.d.ts +8 -1
  21. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  22. package/dist/widgets/SelectField.d.ts +13 -9
  23. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  24. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  25. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  26. package/dist/widgets/file-value.d.ts +112 -0
  27. package/dist/widgets/file-value.test.d.ts +1 -0
  28. package/dist/widgets/types.d.ts +6 -0
  29. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  30. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  31. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  32. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  33. package/package.json +11 -10
  34. package/src/FieldEditWidget.test.ts +74 -2
  35. package/src/FieldEditWidget.tsx +44 -4
  36. package/src/field-type-alias.ts +100 -0
  37. package/src/field-type-coverage.test.ts +3 -1
  38. package/src/index.tsx +137 -135
  39. package/src/standard-widgets.test.tsx +44 -0
  40. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  41. package/src/widgets/CheckboxesField.tsx +77 -11
  42. package/src/widgets/FileField.tsx +91 -52
  43. package/src/widgets/FilterConditionField.tsx +109 -9
  44. package/src/widgets/ImageField.tsx +106 -65
  45. package/src/widgets/ImageLightbox.test.tsx +52 -0
  46. package/src/widgets/ImageLightbox.tsx +108 -0
  47. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  48. package/src/widgets/LookupField.idField.test.tsx +61 -0
  49. package/src/widgets/LookupField.tsx +106 -9
  50. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  51. package/src/widgets/MultiSelectField.tsx +78 -11
  52. package/src/widgets/ObjectRefField.tsx +9 -4
  53. package/src/widgets/PeoplePicker.test.tsx +20 -0
  54. package/src/widgets/PeoplePicker.tsx +15 -3
  55. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  56. package/src/widgets/RadioField.tsx +69 -9
  57. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  58. package/src/widgets/RecipientPickerField.tsx +36 -16
  59. package/src/widgets/SelectField.tsx +33 -31
  60. package/src/widgets/TextAreaField.tsx +8 -3
  61. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  62. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  63. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  64. package/src/widgets/deriveLookupColumns.ts +5 -15
  65. package/src/widgets/file-value.test.ts +230 -0
  66. package/src/widgets/file-value.ts +230 -0
  67. package/src/widgets/types.ts +6 -0
  68. package/src/widgets/useCascadingOptions.ts +46 -0
  69. package/src/widgets/useFieldTranslation.ts +30 -0
  70. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  71. 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 {};
@@ -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 - Dropdown selection widget with configurable options.
4
+ * SelectField - dropdown selection widget.
5
5
  *
6
- * Supports cascading / role-gated options (#2284): each option may carry a
7
- * `visibleWhen` CEL predicate, evaluated against the live form record +
8
- * `current_user`, so the offered set narrows as a controlling field changes
9
- * (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
10
- * field declares which sibling fields drive its list via `dependsOn`; while any
11
- * of those is empty the control is gated with a "select the parent first" hint,
12
- * mirroring the dependent-lookup UX.
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({ value, onChange, field, readonly, schema, dependentValues, dependsOn: dependsOnProp, emptyHint: _emptyHint, dataSource: _dataSource, ...props }: FieldWidgetProps<string>): React.JSX.Element;
18
+ export declare function SelectField(props: FieldWidgetProps<any>): React.JSX.Element;
@@ -0,0 +1,8 @@
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
+ export {};
@@ -0,0 +1,8 @@
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
+ export {};
@@ -10,6 +10,7 @@ interface ObjectSchemaLike {
10
10
  type?: string;
11
11
  label?: string;
12
12
  hidden?: boolean;
13
+ system?: boolean;
13
14
  }>;
14
15
  /**
15
16
  * ADR-0085 semantic role: the object's most important fields. The single
@@ -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 {};
@@ -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>;
@@ -1,5 +1,7 @@
1
1
  export declare const useFieldTranslation: () => {
2
2
  t: (key: string, options?: Record<string, unknown>) => string;
3
+ language: string;
3
4
  } | {
4
5
  t: import('i18next').TFunction<string, undefined>;
6
+ language: string;
5
7
  };
@@ -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": "16.0.0",
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.24.0",
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": "16.0.0",
34
- "@object-ui/core": "16.0.0",
35
- "@object-ui/i18n": "16.0.0",
36
- "@object-ui/providers": "16.0.0",
37
- "@object-ui/react": "16.0.0",
38
- "@object-ui/types": "16.0.0"
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.3",
48
+ "@vitejs/plugin-react": "^6.0.4",
48
49
  "typescript": "^6.0.3",
49
- "vite": "^8.1.4",
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 known = new Set(FORM_FIELD_TYPES);
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
+ });
@@ -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', 'password',
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 Widget = field?.type ? EDIT_WIDGETS[field.type] : undefined;
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 = field?.type && COMPACT_EDIT_TYPES.has(field.type) ? { compact: true } : {};
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
  }