@object-ui/fields 16.1.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/FieldEditWidget.d.ts +9 -2
  3. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  4. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  5. package/dist/field-type-alias.d.ts +16 -0
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.d.ts +14 -11
  8. package/dist/index.js +2426 -2100
  9. package/dist/widgets/AddressField.d.ts +2 -2
  10. package/dist/widgets/AutoNumberField.d.ts +2 -2
  11. package/dist/widgets/AvatarField.d.ts +2 -2
  12. package/dist/widgets/BooleanField.d.ts +2 -2
  13. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  14. package/dist/widgets/CheckboxesField.d.ts +2 -2
  15. package/dist/widgets/CodeField.d.ts +2 -2
  16. package/dist/widgets/ColorField.d.ts +2 -2
  17. package/dist/widgets/CurrencyField.d.ts +2 -2
  18. package/dist/widgets/DateField.d.ts +2 -2
  19. package/dist/widgets/DateTimeField.d.ts +2 -2
  20. package/dist/widgets/EmailField.d.ts +2 -2
  21. package/dist/widgets/FileField.d.ts +2 -2
  22. package/dist/widgets/FilterConditionField.d.ts +34 -2
  23. package/dist/widgets/FormulaField.d.ts +2 -2
  24. package/dist/widgets/GeolocationField.d.ts +2 -2
  25. package/dist/widgets/GridField.d.ts +2 -2
  26. package/dist/widgets/ImageField.d.ts +2 -2
  27. package/dist/widgets/ImageLightbox.d.ts +30 -0
  28. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  29. package/dist/widgets/LocationField.d.ts +2 -2
  30. package/dist/widgets/LookupField.d.ts +2 -2
  31. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  32. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  33. package/dist/widgets/MasterDetailField.d.ts +2 -2
  34. package/dist/widgets/MultiSelectField.d.ts +2 -2
  35. package/dist/widgets/NumberField.d.ts +2 -2
  36. package/dist/widgets/ObjectField.d.ts +2 -2
  37. package/dist/widgets/ObjectRefField.d.ts +2 -2
  38. package/dist/widgets/PasswordField.d.ts +2 -2
  39. package/dist/widgets/PercentField.d.ts +2 -2
  40. package/dist/widgets/PhoneField.d.ts +2 -2
  41. package/dist/widgets/QRCodeField.d.ts +2 -2
  42. package/dist/widgets/RadioField.d.ts +2 -2
  43. package/dist/widgets/RatingField.d.ts +2 -2
  44. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  45. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  46. package/dist/widgets/RichTextField.d.ts +2 -2
  47. package/dist/widgets/SelectField.d.ts +2 -2
  48. package/dist/widgets/SignatureField.d.ts +2 -2
  49. package/dist/widgets/SliderField.d.ts +2 -2
  50. package/dist/widgets/SummaryField.d.ts +2 -2
  51. package/dist/widgets/TagsField.d.ts +2 -2
  52. package/dist/widgets/TextAreaField.d.ts +2 -2
  53. package/dist/widgets/TextField.d.ts +2 -2
  54. package/dist/widgets/TimeField.d.ts +2 -2
  55. package/dist/widgets/UrlField.d.ts +2 -2
  56. package/dist/widgets/UserField.d.ts +2 -2
  57. package/dist/widgets/VectorField.d.ts +2 -2
  58. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  59. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  60. package/dist/widgets/file-value.d.ts +99 -0
  61. package/dist/widgets/file-value.test.d.ts +1 -0
  62. package/dist/widgets/types.d.ts +119 -3
  63. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  64. package/package.json +9 -8
  65. package/src/FieldEditWidget.test.ts +74 -2
  66. package/src/FieldEditWidget.tsx +50 -8
  67. package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
  68. package/src/__tests__/widget-props-contract.test.tsx +129 -0
  69. package/src/complex-widgets.test.tsx +3 -3
  70. package/src/datetime-widgets.test.tsx +2 -2
  71. package/src/field-type-alias.ts +100 -0
  72. package/src/index.tsx +97 -134
  73. package/src/widgets/AddressField.tsx +2 -2
  74. package/src/widgets/AutoNumberField.tsx +2 -2
  75. package/src/widgets/AvatarField.tsx +2 -2
  76. package/src/widgets/BooleanField.tsx +2 -2
  77. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  78. package/src/widgets/CheckboxesField.tsx +12 -9
  79. package/src/widgets/CodeField.tsx +2 -2
  80. package/src/widgets/ColorField.tsx +2 -2
  81. package/src/widgets/CurrencyField.tsx +2 -2
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +2 -2
  85. package/src/widgets/FileField.tsx +90 -59
  86. package/src/widgets/FilterConditionField.tsx +112 -12
  87. package/src/widgets/FormulaField.tsx +2 -2
  88. package/src/widgets/GeolocationField.tsx +2 -2
  89. package/src/widgets/GridField.tsx +3 -3
  90. package/src/widgets/ImageField.tsx +73 -35
  91. package/src/widgets/ImageLightbox.test.tsx +52 -0
  92. package/src/widgets/ImageLightbox.tsx +108 -0
  93. package/src/widgets/LocationField.tsx +2 -2
  94. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  95. package/src/widgets/LookupField.idField.test.tsx +61 -0
  96. package/src/widgets/LookupField.tsx +116 -19
  97. package/src/widgets/MasterDetailField.tsx +2 -2
  98. package/src/widgets/MultiSelectField.tsx +10 -7
  99. package/src/widgets/NumberField.tsx +2 -2
  100. package/src/widgets/ObjectField.tsx +2 -2
  101. package/src/widgets/ObjectRefField.tsx +13 -8
  102. package/src/widgets/PasswordField.tsx +2 -2
  103. package/src/widgets/PeoplePicker.test.tsx +20 -0
  104. package/src/widgets/PeoplePicker.tsx +15 -3
  105. package/src/widgets/PercentField.tsx +2 -2
  106. package/src/widgets/PhoneField.tsx +2 -2
  107. package/src/widgets/QRCodeField.tsx +2 -2
  108. package/src/widgets/RadioField.tsx +10 -7
  109. package/src/widgets/RatingField.tsx +2 -2
  110. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  111. package/src/widgets/RecipientPickerField.tsx +40 -20
  112. package/src/widgets/RichTextField.tsx +2 -2
  113. package/src/widgets/SelectField.tsx +5 -5
  114. package/src/widgets/SignatureField.tsx +2 -2
  115. package/src/widgets/SliderField.tsx +2 -2
  116. package/src/widgets/SummaryField.tsx +2 -2
  117. package/src/widgets/TagsField.tsx +3 -3
  118. package/src/widgets/TextAreaField.tsx +6 -3
  119. package/src/widgets/TextField.tsx +2 -2
  120. package/src/widgets/TimeField.tsx +2 -2
  121. package/src/widgets/UrlField.tsx +2 -2
  122. package/src/widgets/UserField.tsx +2 -2
  123. package/src/widgets/VectorField.tsx +2 -2
  124. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  125. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  126. package/src/widgets/file-value.test.ts +230 -0
  127. package/src/widgets/file-value.ts +246 -0
  128. package/src/widgets/types.ts +129 -6
  129. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -0,0 +1,99 @@
1
+ import { isFileIdToken } from '@objectstack/spec/data';
2
+ /** A file value normalised for rendering, whatever form it arrived in. */
3
+ export interface FileValueView {
4
+ /** `sys_file` id, when the value carries one. */
5
+ id?: string;
6
+ /** Best available display name. Never empty. */
7
+ name: string;
8
+ /** Resolvable URL, when the value carries one. A bare reference does not. */
9
+ url?: string;
10
+ size?: number;
11
+ mimeType?: string;
12
+ /** The value as it arrived, so callers can pass it through untouched. */
13
+ raw: unknown;
14
+ }
15
+ /**
16
+ * A minted file id: uuid/nanoid-shaped, and crucially not a URL — a URL always
17
+ * carries `:`, `/` or `.`, so it can never match.
18
+ *
19
+ * This IS the platform's arbiter now, not a mirror of it (objectui#3161,
20
+ * objectstack#4115 ledger batch 7). The declaration that stood here said it
21
+ * "mirrors the platform's `isFileIdToken`" while being a character-for-character
22
+ * copy of that function's body under that function's own name — the shape
23
+ * objectui#3003 argued about and objectui#3169 caught again in
24
+ * `isAggregatedViewContainer`: every value test and every behaviour test passes
25
+ * against a faithful copy, so reference identity is the only check that can tell
26
+ * a re-export from a fork. It is asserted in
27
+ * `__tests__/spec-symbol-batch7.test.ts`.
28
+ *
29
+ * Why it matters that this one is shared rather than duplicated: the regex is a
30
+ * WIRE decision. Widening it server-side (say, ids grow past 64 chars) while a
31
+ * copy here keeps the old bound turns every new id into "not a reference", and
32
+ * the widget then submits the legacy inline blob against a backend that expects
33
+ * a reference. That failure surfaces as a broken thumbnail, nowhere near a
34
+ * regex.
35
+ */
36
+ export { isFileIdToken };
37
+ /**
38
+ * The `sys_file` id a value refers to, or `undefined`.
39
+ *
40
+ * `file_id ?? id` matches the rule `serializeParamValues` already applies to
41
+ * action params — one extraction rule for both surfaces.
42
+ */
43
+ export declare function fileIdOf(value: unknown): string | undefined;
44
+ /**
45
+ * The stable download endpoint a bare `sys_file` id resolves to.
46
+ *
47
+ * `createObjectStackUploadAdapter` stores exactly this URL (`${basePath}/files/
48
+ * :id`, default base `/api/v1/storage`) as a completed upload's value, and the
49
+ * endpoint 302-redirects to a freshly-signed short-lived URL on every request —
50
+ * so it can be used directly as an `<img src>`. Building it here means a value
51
+ * still in its bare-reference form (the backend read path didn't expand it —
52
+ * seen on the edit-form data path, objectui image thumbnails rendering broken)
53
+ * resolves to a real URL instead of an empty `src`.
54
+ */
55
+ export declare const FILE_STORAGE_BASE_PATH = "/api/v1/storage";
56
+ /** The stable download URL for a `sys_file` id. */
57
+ export declare function fileUrlFromId(id: string): string;
58
+ /**
59
+ * Normalise one file value for display.
60
+ *
61
+ * @param fallbackName shown when the value carries no usable name — pass a
62
+ * translated string so the widget stays localised.
63
+ */
64
+ export declare function readFileValue(value: unknown, fallbackName?: string): FileValueView;
65
+ /** Normalise a field value (single or `multiple`) to an array of views. */
66
+ export declare function readFileValues(value: unknown, fallbackName?: string): FileValueView[];
67
+ /** Does this value look like an image, by MIME type? */
68
+ export declare function isImageValue(view: FileValueView): boolean;
69
+ /** The subset of an upload result these helpers read. */
70
+ export interface UploadResultLike {
71
+ url: string;
72
+ name: string;
73
+ size: number;
74
+ mimeType: string;
75
+ meta?: Record<string, unknown>;
76
+ }
77
+ /**
78
+ * What to store in the field for a completed upload.
79
+ *
80
+ * Returns the bare `sys_file` id when the adapter surfaced one — the reference
81
+ * form — and the legacy inline blob when it did not, so a deployment whose
82
+ * upload adapter or backend predates file-as-reference keeps working unchanged.
83
+ */
84
+ export declare function fileValueForSubmit(result: UploadResultLike, originalName?: string): string | Record<string, unknown>;
85
+ /**
86
+ * The display view of a just-completed upload.
87
+ *
88
+ * Submitting a bare id means the field value no longer carries the name, size
89
+ * or URL needed to render it, and the enriched form only comes back on the next
90
+ * read. Widgets keep these views keyed by id so an upload appears immediately
91
+ * instead of showing a bare token until a refetch.
92
+ */
93
+ export declare function uploadResultView(result: UploadResultLike, originalName?: string): FileValueView;
94
+ /**
95
+ * Merge locally-known upload views over a field value's own views, matched by
96
+ * id. A value that is still a bare reference picks up the name/URL captured at
97
+ * upload time; anything already enriched by the backend is left alone.
98
+ */
99
+ export declare function withRecentUploads(views: FileValueView[], recent: Record<string, FileValueView>): FileValueView[];
@@ -0,0 +1 @@
1
+ export {};
@@ -1,7 +1,53 @@
1
- import { FieldMetadata } from '@object-ui/types';
2
- export type FieldWidgetProps<T = any> = {
1
+ import { AriaAttributes, FocusEventHandler, MouseEventHandler } from 'react';
2
+ import { DependsOnInput, FieldMetadata } from '@object-ui/types';
3
+ /**
4
+ * Props every field widget in this package receives at RUNTIME.
5
+ *
6
+ * Named `FieldWidgetComponentProps`, not `FieldWidgetProps` (objectui#3161,
7
+ * objectstack#4115 ledger batch 7), adopting the name `@object-ui/app-shell`
8
+ * settled on for the same split in objectui#3169: `@objectstack/spec/ui` owns
9
+ * `FieldWidgetProps` for the DECLARED widget-plugin contract — a zod object
10
+ * (`FieldWidgetPropsSchema`) a plugin manifest is validated against, with
11
+ * `field` narrowed to `{ name?, label?, type: <the FieldType enum> }` and
12
+ * `readonly` / `required` carrying `.default()`s. This is the React interface
13
+ * the widgets in this directory actually implement, against
14
+ * `@object-ui/types`'s much richer `FieldMetadata`.
15
+ *
16
+ * The two are NOT interchangeable, and the divergence is not cosmetic: the
17
+ * spec's contract names the error slot `error`, this one names it
18
+ * `errorMessage`, so a widget written to the spec's declared props renders no
19
+ * validation message here. That divergence is tracked in objectui#3222 and is
20
+ * deliberately NOT resolved here — but it is now *visible*: reading
21
+ * `props.error` or `props.required` off this type is a compile error rather
22
+ * than a silent `any`.
23
+ *
24
+ * ## Why there is no `[key: string]: any` (objectui#3221)
25
+ *
26
+ * This type used to end in an index signature, "load-bearing" for the widgets'
27
+ * `...props` spreads. It was also why every drift above was invisible: **a type
28
+ * that claims to have every key can never be reported as missing one** (the
29
+ * objectstack#4075 mechanism). `props.required` and `props.error` were legal
30
+ * reads typed `any` and always `undefined` at runtime; a misspelled prop
31
+ * (`readOnly` for `readonly`, `onchange` for `onChange`) compiled; and any
32
+ * structural/parity comparison against the type was useless *in principle*,
33
+ * which is why the batch-7 symbol guard was the only detector that could see
34
+ * the collision at all.
35
+ *
36
+ * It is replaced by the CLOSED set below: the controlled-input contract, the
37
+ * host plumbing the form renderer genuinely forwards, and a DOM pass-through
38
+ * allowance. `data-*` is an open family by design (it is open in HTML too) and
39
+ * is expressed as a template-literal index signature, which — unlike
40
+ * `[key: string]` — leaves `keyof` finite, so an undeclared prop still fails.
41
+ *
42
+ * Adding a key here is a contract change: say who produces it and who reads it.
43
+ */
44
+ export type FieldWidgetComponentProps<T = any> = {
3
45
  value: T;
4
46
  onChange: (val: T) => void;
47
+ /**
48
+ * The field's metadata. Deliberately the looser `@object-ui/types` shape
49
+ * rather than the spec's, to avoid a circular dependency for now.
50
+ */
5
51
  field: FieldMetadata;
6
52
  readonly?: boolean;
7
53
  disabled?: boolean;
@@ -13,5 +59,75 @@ export type FieldWidgetProps<T = any> = {
13
59
  * widgets ignore it.
14
60
  */
15
61
  onUploadingChange?: (uploading: boolean) => void;
16
- [key: string]: any;
62
+ /**
63
+ * The raw authored node the widget was rendered from. Two hosts supply it:
64
+ * `SchemaRenderer` (`<Component schema={schema} {...schema} />`) and the
65
+ * form renderer's `renderFieldComponent` (`schema={props.field || ...}`),
66
+ * which is why ~25 widgets read `field || schema` for their config.
67
+ *
68
+ * This is a second carrier for what `field` already means, i.e. a de-facto
69
+ * second contract (AGENTS.md #0.1) — declared here so it is at least
70
+ * visible; converging the two is tracked separately.
71
+ */
72
+ schema?: FieldMetadata | Record<string, unknown>;
73
+ /**
74
+ * DataSource for widgets that query records (lookup / user / object-ref /
75
+ * recipient-picker / grid). Injected by the form renderer for the field
76
+ * types that need it, and passed directly by inline-edit hosts. Option
77
+ * widgets destructure it purely to keep it off their DOM spread.
78
+ *
79
+ * Left structural (`unknown`): `@object-ui/fields` must not depend on a
80
+ * concrete adapter — every consumer narrows it itself.
81
+ */
82
+ dataSource?: unknown;
83
+ /**
84
+ * Live sibling-field values driving cascading / role-gated options and
85
+ * dependent lookups (ADR-0058, #2215/#2284). The form renderer passes the
86
+ * in-progress record; widgets fall back to `SchemaRendererContext`.
87
+ */
88
+ dependentValues?: Record<string, unknown>;
89
+ /**
90
+ * Controlling field(s) that gate this field's option list. Normally declared
91
+ * on the field metadata; accepted as a prop so a host can drive the gate for
92
+ * a field it synthesised. Field metadata wins when both are present.
93
+ */
94
+ dependsOn?: DependsOnInput;
95
+ /**
96
+ * Hint shown when a dependency-gated option list is still waiting on its
97
+ * controlling field. Forwarded by the form renderer; the option widgets in
98
+ * this package currently discard it and render their own message instead.
99
+ */
100
+ emptyHint?: string;
101
+ /**
102
+ * Render as a single-line, borderless control for a grid cell (the inline
103
+ * editor and the line-item grid set it) instead of the full form layout.
104
+ */
105
+ compact?: boolean;
106
+ /**
107
+ * Receive the FULL selected record rather than just its id, so a host can
108
+ * auto-fill sibling fields from it (a line-item grid copying a product's
109
+ * price). When provided, the host owns the resulting value change.
110
+ */
111
+ onSelectRecord?: (record: Record<string, any>) => void;
112
+ /**
113
+ * Offer a "create new" affordance in a record picker, carrying whatever the
114
+ * user had typed. Also declarable on the field metadata.
115
+ */
116
+ onCreateNew?: (searchQuery: string) => void;
117
+ id?: string;
118
+ /** react-hook-form's field name, spread in by the form renderer. */
119
+ name?: string;
120
+ autoFocus?: boolean;
121
+ tabIndex?: number;
122
+ onBlur?: FocusEventHandler<HTMLElement>;
123
+ onFocus?: FocusEventHandler<HTMLElement>;
124
+ onClick?: MouseEventHandler<HTMLElement>;
125
+ } & AriaAttributes & {
126
+ /**
127
+ * Arbitrary `data-*` attributes (test ids, analytics hooks). Open by design,
128
+ * but a template-literal key — `keyof` stays finite, so this does NOT
129
+ * reintroduce the index signature objectui#3221 removed: `props.required`
130
+ * is still an error, `props['data-testid']` is still fine.
131
+ */
132
+ [dataAttribute: `data-${string}`]: string | number | boolean | undefined;
17
133
  };
@@ -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.2.0",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,18 +24,19 @@
24
24
  "LICENSE"
25
25
  ],
26
26
  "dependencies": {
27
+ "@objectstack/spec": "^17.0.0-rc.1",
27
28
  "clsx": "^2.1.1",
28
29
  "lucide-react": "^1.25.0",
29
30
  "react-markdown": "^10.1.0",
30
31
  "rehype-sanitize": "^6.0.0",
31
32
  "remark-gfm": "^4.0.1",
32
33
  "tailwind-merge": "^3.6.0",
33
- "@object-ui/components": "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"
34
+ "@object-ui/components": "17.2.0",
35
+ "@object-ui/core": "17.2.0",
36
+ "@object-ui/i18n": "17.2.0",
37
+ "@object-ui/providers": "17.2.0",
38
+ "@object-ui/react": "17.2.0",
39
+ "@object-ui/types": "17.2.0"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "react": "^18.0.0 || ^19.0.0",
@@ -44,7 +45,7 @@
44
45
  "devDependencies": {
45
46
  "@types/react": "19.2.17",
46
47
  "@types/react-dom": "19.2.3",
47
- "@vitejs/plugin-react": "^6.0.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
+ });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import React from 'react';
10
- import type { FieldWidgetProps } from './widgets/types';
10
+ import type { FieldWidgetComponentProps } from './widgets/types';
11
11
 
12
12
  // The SAME dedicated widgets the form renders — reused for in-place editing
13
13
  // (e.g. the data grid's inline cell editor) so a select edits as a dropdown, a
@@ -44,6 +44,9 @@ import { LocationField } from './widgets/LocationField';
44
44
  import { GeolocationField } from './widgets/GeolocationField';
45
45
  import { CodeField } from './widgets/CodeField';
46
46
  import { QRCodeField } from './widgets/QRCodeField';
47
+ // The FORM's spec-alias table (`json` → `field:code`, `tree` → `field:lookup`,
48
+ // …) — inline resolution reuses it so spec spellings get the form's decision.
49
+ import { mapFieldTypeToFormType } from './field-type-alias';
47
50
 
48
51
  /**
49
52
  * Field types that edit in place with a dedicated widget. Keyed by the raw
@@ -51,7 +54,7 @@ import { QRCodeField } from './widgets/QRCodeField';
51
54
  * Rich/heavy types (file, image, lookup, richtext, …) are intentionally absent
52
55
  * so callers fall back to their own simpler editor.
53
56
  */
54
- const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetProps<any>>> = {
57
+ const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetComponentProps<any>>> = {
55
58
  text: TextField,
56
59
  textarea: TextAreaField,
57
60
  number: NumberField,
@@ -102,7 +105,13 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
102
105
  // Binary / attachment — edited from the record form, shown read-only in the grid.
103
106
  'file', 'image', 'avatar', 'signature',
104
107
  // Heavy / full editors — better in the record form than a cell.
105
- 'markdown', 'html', 'richtext', '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
  /**
@@ -141,9 +180,12 @@ export function FieldEditWidget({
141
180
  value,
142
181
  onChange,
143
182
  readonly,
144
- }: FieldWidgetProps<any>): React.ReactElement | null {
145
- const Widget = field?.type ? EDIT_WIDGETS[field.type] : undefined;
183
+ }: FieldWidgetComponentProps<any>): React.ReactElement | null {
184
+ const resolved = field?.type ? resolveInlineEditType(field.type) : undefined;
185
+ const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined;
146
186
  if (!Widget) return null;
147
- const compactProps = field?.type && COMPACT_EDIT_TYPES.has(field.type) ? { compact: true } : {};
148
- return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...(compactProps as any)} />;
187
+ // `compact` is a declared widget prop (objectui#3221 closed this type), so
188
+ // the spread no longer needs an `any` escape hatch to get past it.
189
+ const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {};
190
+ return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...compactProps} />;
149
191
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * `@object-ui/fields` ↔ `@objectstack/spec` symbol-collision guards
11
+ * (objectui#3161, objectstack#4115 ledger batch 7).
12
+ *
13
+ * Two symbols, two different verdicts:
14
+ *
15
+ * - `isFileIdToken` was a character-for-character copy of the spec's function
16
+ * under the spec's own name. It is a re-export now, and the gate below is
17
+ * REFERENCE IDENTITY — not behaviour. A faithful copy passes every behaviour
18
+ * test ever written for it (objectui#3003, re-proved on
19
+ * `isAggregatedViewContainer` in objectui#3169), so `toBe` is the only
20
+ * assertion that can tell a re-export from a fork.
21
+ *
22
+ * - `FieldWidgetProps` is now `FieldWidgetComponentProps`. The spec owns that
23
+ * name for the DECLARED widget-plugin props contract; this package's is the
24
+ * React interface its widgets implement. The pins record the divergence that
25
+ * made re-exporting impossible — and, since objectui#3221 closed the type,
26
+ * that the divergence is now *visible* rather than swallowed by a
27
+ * `[key: string]: any` index signature.
28
+ *
29
+ * The other direction — "the spec must not start owning the NEW names" — is not
30
+ * asserted here because `scripts/check-spec-symbol-derivation.mjs` already
31
+ * answers it on every CI run, for every name in the package rather than the
32
+ * handful a test would remember to list.
33
+ */
34
+
35
+ import { describe, it, expect } from 'vitest';
36
+ import { isFileIdToken as specIsFileIdToken } from '@objectstack/spec/data';
37
+ import type { FieldWidgetProps as SpecFieldWidgetProps } from '@objectstack/spec/ui';
38
+
39
+ import { isFileIdToken, fileIdOf } from '../widgets/file-value';
40
+ import type { FieldWidgetComponentProps } from '../widgets/types';
41
+
42
+ describe('isFileIdToken IS the spec function', () => {
43
+ it('is the same function object, not an equivalent one', () => {
44
+ // The whole point. Every value/behaviour assertion below this line would
45
+ // also pass against a re-forked copy; this one would not.
46
+ expect(isFileIdToken).toBe(specIsFileIdToken);
47
+ });
48
+
49
+ it('is the arbiter the rest of the module routes through', () => {
50
+ // `fileIdOf` must keep agreeing with the shared predicate, so a future
51
+ // "small local tweak" to the id shape cannot be reintroduced one caller at
52
+ // a time.
53
+ for (const value of ['abc123', 'AbC-_9', 'x'.repeat(64)]) {
54
+ expect(specIsFileIdToken(value)).toBe(true);
55
+ expect(fileIdOf(value)).toBe(value);
56
+ }
57
+ for (const value of ['https://example.com/f.png', '/api/v1/storage/files/1', 'a.b', 'x'.repeat(65)]) {
58
+ expect(specIsFileIdToken(value)).toBe(false);
59
+ expect(fileIdOf(value)).toBeUndefined();
60
+ }
61
+ });
62
+ });
63
+
64
+ /* -------------------------------------------------------------------------- */
65
+ /* Compile-time pins. A violation is a `tsc` error, not a runtime failure. */
66
+ /* This package's tsconfig.json includes its tests, so `type-check` compiles */
67
+ /* them (scripts/check-type-check-coverage.mjs keeps that true). */
68
+ /* -------------------------------------------------------------------------- */
69
+
70
+ type Assert<T extends true> = T;
71
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
72
+ type Extends<A, B> = [A] extends [B] ? true : false;
73
+ type IsAny<T> = 0 extends 1 & T ? true : false;
74
+ type HasKey<T, K extends string> = K extends keyof T ? true : false;
75
+
76
+ describe('FieldWidgetComponentProps is the RENDERED layer of the spec contract', () => {
77
+ it('is pinned at compile time', () => {
78
+ // The import above is itself half the tripwire: the day the spec stops
79
+ // exporting `FieldWidgetProps`, this file stops compiling and the rename's
80
+ // reason is up for re-triage (the plain name could come back).
81
+ type _SpecIsReal = Assert<Equal<IsAny<SpecFieldWidgetProps>, false>>;
82
+
83
+ // The divergence that makes a re-export impossible AND is a live defect
84
+ // worth naming: the declared contract calls the validation-message slot
85
+ // `error`; every widget in this package reads `errorMessage`. A widget
86
+ // written against the spec's declared props renders no message here, and
87
+ // nothing reports it. Recorded, not fixed, by the rename — objectui#3222.
88
+ type _SpecNamesItError = Assert<HasKey<SpecFieldWidgetProps, 'error'>>;
89
+ type _LocalNamesItErrorMessage = Assert<HasKey<FieldWidgetComponentProps, 'errorMessage'>>;
90
+
91
+ // …and the reason nobody noticed used to sit right here: three pins
92
+ // asserting that `[key: string]: any` was still present, and that
93
+ // `props.required` / `props.error` therefore read as `any`. objectui#3221
94
+ // removed that index signature, so those pins have gone red on purpose and
95
+ // are gone with it. What replaces them is the inverse claim — the type is
96
+ // now CLOSED, so the two keys the spec declares and this one does not can
97
+ // finally be reported as missing. That is what makes objectui#3222 (the
98
+ // `error` / `errorMessage` divergence) decidable by the compiler instead of
99
+ // by a symbol guard.
100
+ type _NoStringIndexSignature = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
101
+ type _RequiredIsAbsent = Assert<Equal<HasKey<FieldWidgetComponentProps, 'required'>, false>>;
102
+ type _ErrorIsAbsent = Assert<Equal<HasKey<FieldWidgetComponentProps, 'error'>, false>>;
103
+
104
+ // `data-*` stays open (it is open in HTML too), but as a template-literal
105
+ // key — the distinction that keeps `keyof` finite above. A pin, because
106
+ // widening it back to `[key: string]` would silently restore the defect
107
+ // while every assertion here still read as "closed".
108
+ type _DataAttributesStayOpen = Assert<Extends<'data-testid', keyof FieldWidgetComponentProps>>;
109
+
110
+ expect(true).toBe(true);
111
+ });
112
+ });