@object-ui/fields 16.1.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 (44) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/dist/FieldEditWidget.d.ts +7 -0
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +5 -5
  5. package/dist/index.d.ts +14 -11
  6. package/dist/index.js +2428 -2100
  7. package/dist/widgets/FilterConditionField.d.ts +32 -0
  8. package/dist/widgets/ImageLightbox.d.ts +30 -0
  9. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  10. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  11. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  12. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  13. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  14. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  15. package/dist/widgets/file-value.d.ts +112 -0
  16. package/dist/widgets/file-value.test.d.ts +1 -0
  17. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  18. package/package.json +9 -8
  19. package/src/FieldEditWidget.test.ts +74 -2
  20. package/src/FieldEditWidget.tsx +44 -4
  21. package/src/field-type-alias.ts +100 -0
  22. package/src/index.tsx +94 -133
  23. package/src/widgets/CheckboxesField.tsx +8 -5
  24. package/src/widgets/FileField.tsx +88 -57
  25. package/src/widgets/FilterConditionField.tsx +109 -9
  26. package/src/widgets/ImageField.tsx +71 -33
  27. package/src/widgets/ImageLightbox.test.tsx +52 -0
  28. package/src/widgets/ImageLightbox.tsx +108 -0
  29. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  30. package/src/widgets/LookupField.idField.test.tsx +61 -0
  31. package/src/widgets/LookupField.tsx +106 -9
  32. package/src/widgets/MultiSelectField.tsx +6 -3
  33. package/src/widgets/ObjectRefField.tsx +9 -4
  34. package/src/widgets/PeoplePicker.test.tsx +20 -0
  35. package/src/widgets/PeoplePicker.tsx +15 -3
  36. package/src/widgets/RadioField.tsx +6 -3
  37. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  38. package/src/widgets/RecipientPickerField.tsx +36 -16
  39. package/src/widgets/TextAreaField.tsx +4 -1
  40. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  41. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  42. package/src/widgets/file-value.test.ts +230 -0
  43. package/src/widgets/file-value.ts +230 -0
  44. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -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;
@@ -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
@@ -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 {};
@@ -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 {};
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "16.1.0",
3
+ "version": "17.1.0",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,21 +30,22 @@
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.1.0",
34
- "@object-ui/core": "16.1.0",
35
- "@object-ui/i18n": "16.1.0",
36
- "@object-ui/providers": "16.1.0",
37
- "@object-ui/react": "16.1.0",
38
- "@object-ui/types": "16.1.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
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 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
  }
@@ -0,0 +1,100 @@
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
+ * Map field type to form component type
11
+ *
12
+ * @param fieldType - The ObjectQL field type identifier to convert
13
+ * (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
14
+ * @returns The normalized form field type string used in the form schema
15
+ * (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
16
+ */
17
+ export function mapFieldTypeToFormType(fieldType: string): string {
18
+ const typeMap: Record<string, string> = {
19
+ // Text-based fields
20
+ text: 'field:text',
21
+ textarea: 'field:textarea',
22
+ markdown: 'field:markdown', // Markdown editor (fallback to textarea)
23
+ html: 'field:html', // Rich text editor (fallback to textarea)
24
+ richtext: 'field:richtext', // WYSIWYG rich-text editor
25
+ secret: 'field:password', // encrypted-at-rest value — mask input like a password
26
+
27
+ // Numeric fields
28
+ number: 'field:number',
29
+ currency: 'field:currency',
30
+ percent: 'field:percent',
31
+ slider: 'field:slider',
32
+ progress: 'field:slider', // bounded 0..100 progress — edit via slider
33
+ rating: 'field:rating',
34
+
35
+ // Date/Time fields
36
+ date: 'field:date',
37
+ datetime: 'field:datetime',
38
+ time: 'field:time',
39
+
40
+ // Boolean
41
+ boolean: 'field:boolean',
42
+ toggle: 'field:boolean', // toggle is a boolean rendered as a switch
43
+
44
+ // Selection fields
45
+ select: 'field:select',
46
+ multiselect: 'field:multiselect',
47
+ radio: 'field:radio',
48
+ checkboxes: 'field:checkboxes',
49
+ tags: 'field:tags',
50
+ lookup: 'field:lookup',
51
+ master_detail: 'field:master_detail',
52
+ tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
53
+ // `user` is a lookup specialized to sys_user; `owner` mirrors it (record
54
+ // ownership). Both render via the UserField person-picker (delegates to the
55
+ // lookup picker). Without these they would fall through to `field:text`.
56
+ user: 'field:user',
57
+ owner: 'field:owner',
58
+
59
+ // Contact fields
60
+ email: 'field:email',
61
+ phone: 'field:phone',
62
+ url: 'field:url',
63
+
64
+ // File / media fields
65
+ file: 'field:file',
66
+ image: 'field:image',
67
+ avatar: 'field:avatar',
68
+ video: 'field:file', // uploads as a file
69
+ audio: 'field:file', // uploads as a file
70
+ signature: 'field:signature',
71
+
72
+ // Special / enhanced fields
73
+ password: 'field:password',
74
+ location: 'field:location', // Location/map field (fallback to input)
75
+ geolocation: 'field:geolocation',
76
+ address: 'field:address',
77
+ color: 'field:color',
78
+ code: 'field:code',
79
+ json: 'field:code', // JSON edited in the code editor
80
+ qrcode: 'field:qrcode',
81
+ vector: 'field:vector',
82
+
83
+ // Embedded structured values (stored as JSON on the row)
84
+ object: 'field:object',
85
+ composite: 'field:object', // embedded object
86
+ record: 'field:object', // name-keyed map
87
+ repeater: 'field:grid', // embedded array of rows
88
+
89
+ // Auto-generated/computed fields (typically read-only)
90
+ formula: 'field:formula',
91
+ summary: 'field:summary',
92
+ // `@objectstack/spec` spells this `autonumber`; the widget map key is
93
+ // `auto_number`. Map both spellings so a spec-typed field/param doesn't
94
+ // fall through to the plain text input.
95
+ autonumber: 'field:auto_number',
96
+ auto_number: 'field:auto_number',
97
+ };
98
+
99
+ return typeMap[fieldType] || 'field:text';
100
+ }