@object-ui/fields 17.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 (105) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/dist/FieldEditWidget.d.ts +2 -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/index.cjs +3 -3
  6. package/dist/index.js +290 -292
  7. package/dist/widgets/AddressField.d.ts +2 -2
  8. package/dist/widgets/AutoNumberField.d.ts +2 -2
  9. package/dist/widgets/AvatarField.d.ts +2 -2
  10. package/dist/widgets/BooleanField.d.ts +2 -2
  11. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  12. package/dist/widgets/CheckboxesField.d.ts +2 -2
  13. package/dist/widgets/CodeField.d.ts +2 -2
  14. package/dist/widgets/ColorField.d.ts +2 -2
  15. package/dist/widgets/CurrencyField.d.ts +2 -2
  16. package/dist/widgets/DateField.d.ts +2 -2
  17. package/dist/widgets/DateTimeField.d.ts +2 -2
  18. package/dist/widgets/EmailField.d.ts +2 -2
  19. package/dist/widgets/FileField.d.ts +2 -2
  20. package/dist/widgets/FilterConditionField.d.ts +2 -2
  21. package/dist/widgets/FormulaField.d.ts +2 -2
  22. package/dist/widgets/GeolocationField.d.ts +2 -2
  23. package/dist/widgets/GridField.d.ts +2 -2
  24. package/dist/widgets/ImageField.d.ts +2 -2
  25. package/dist/widgets/LocationField.d.ts +2 -2
  26. package/dist/widgets/LookupField.d.ts +2 -2
  27. package/dist/widgets/MasterDetailField.d.ts +2 -2
  28. package/dist/widgets/MultiSelectField.d.ts +2 -2
  29. package/dist/widgets/NumberField.d.ts +2 -2
  30. package/dist/widgets/ObjectField.d.ts +2 -2
  31. package/dist/widgets/ObjectRefField.d.ts +2 -2
  32. package/dist/widgets/PasswordField.d.ts +2 -2
  33. package/dist/widgets/PercentField.d.ts +2 -2
  34. package/dist/widgets/PhoneField.d.ts +2 -2
  35. package/dist/widgets/QRCodeField.d.ts +2 -2
  36. package/dist/widgets/RadioField.d.ts +2 -2
  37. package/dist/widgets/RatingField.d.ts +2 -2
  38. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  39. package/dist/widgets/RichTextField.d.ts +2 -2
  40. package/dist/widgets/SelectField.d.ts +2 -2
  41. package/dist/widgets/SignatureField.d.ts +2 -2
  42. package/dist/widgets/SliderField.d.ts +2 -2
  43. package/dist/widgets/SummaryField.d.ts +2 -2
  44. package/dist/widgets/TagsField.d.ts +2 -2
  45. package/dist/widgets/TextAreaField.d.ts +2 -2
  46. package/dist/widgets/TextField.d.ts +2 -2
  47. package/dist/widgets/TimeField.d.ts +2 -2
  48. package/dist/widgets/UrlField.d.ts +2 -2
  49. package/dist/widgets/UserField.d.ts +2 -2
  50. package/dist/widgets/VectorField.d.ts +2 -2
  51. package/dist/widgets/file-value.d.ts +20 -33
  52. package/dist/widgets/types.d.ts +119 -3
  53. package/package.json +8 -8
  54. package/src/FieldEditWidget.tsx +6 -4
  55. package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
  56. package/src/__tests__/widget-props-contract.test.tsx +129 -0
  57. package/src/complex-widgets.test.tsx +3 -3
  58. package/src/datetime-widgets.test.tsx +2 -2
  59. package/src/index.tsx +3 -1
  60. package/src/widgets/AddressField.tsx +2 -2
  61. package/src/widgets/AutoNumberField.tsx +2 -2
  62. package/src/widgets/AvatarField.tsx +2 -2
  63. package/src/widgets/BooleanField.tsx +2 -2
  64. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  65. package/src/widgets/CheckboxesField.tsx +4 -4
  66. package/src/widgets/CodeField.tsx +2 -2
  67. package/src/widgets/ColorField.tsx +2 -2
  68. package/src/widgets/CurrencyField.tsx +2 -2
  69. package/src/widgets/DateField.tsx +2 -2
  70. package/src/widgets/DateTimeField.tsx +2 -2
  71. package/src/widgets/EmailField.tsx +2 -2
  72. package/src/widgets/FileField.tsx +2 -2
  73. package/src/widgets/FilterConditionField.tsx +3 -3
  74. package/src/widgets/FormulaField.tsx +2 -2
  75. package/src/widgets/GeolocationField.tsx +2 -2
  76. package/src/widgets/GridField.tsx +3 -3
  77. package/src/widgets/ImageField.tsx +2 -2
  78. package/src/widgets/LocationField.tsx +2 -2
  79. package/src/widgets/LookupField.tsx +10 -10
  80. package/src/widgets/MasterDetailField.tsx +2 -2
  81. package/src/widgets/MultiSelectField.tsx +4 -4
  82. package/src/widgets/NumberField.tsx +2 -2
  83. package/src/widgets/ObjectField.tsx +2 -2
  84. package/src/widgets/ObjectRefField.tsx +4 -4
  85. package/src/widgets/PasswordField.tsx +2 -2
  86. package/src/widgets/PercentField.tsx +2 -2
  87. package/src/widgets/PhoneField.tsx +2 -2
  88. package/src/widgets/QRCodeField.tsx +2 -2
  89. package/src/widgets/RadioField.tsx +4 -4
  90. package/src/widgets/RatingField.tsx +2 -2
  91. package/src/widgets/RecipientPickerField.tsx +4 -4
  92. package/src/widgets/RichTextField.tsx +2 -2
  93. package/src/widgets/SelectField.tsx +5 -5
  94. package/src/widgets/SignatureField.tsx +2 -2
  95. package/src/widgets/SliderField.tsx +2 -2
  96. package/src/widgets/SummaryField.tsx +2 -2
  97. package/src/widgets/TagsField.tsx +3 -3
  98. package/src/widgets/TextAreaField.tsx +2 -2
  99. package/src/widgets/TextField.tsx +2 -2
  100. package/src/widgets/TimeField.tsx +2 -2
  101. package/src/widgets/UrlField.tsx +2 -2
  102. package/src/widgets/UserField.tsx +2 -2
  103. package/src/widgets/VectorField.tsx +2 -2
  104. package/src/widgets/file-value.ts +21 -5
  105. package/src/widgets/types.ts +129 -6
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "17.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,25 +24,25 @@
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": "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"
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",
42
43
  "react-dom": "^18.0.0 || ^19.0.0"
43
44
  },
44
45
  "devDependencies": {
45
- "@objectstack/spec": "^17.0.0-rc.0",
46
46
  "@types/react": "19.2.17",
47
47
  "@types/react-dom": "19.2.3",
48
48
  "@vitejs/plugin-react": "^6.0.4",
@@ -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
@@ -54,7 +54,7 @@ import { mapFieldTypeToFormType } from './field-type-alias';
54
54
  * Rich/heavy types (file, image, lookup, richtext, …) are intentionally absent
55
55
  * so callers fall back to their own simpler editor.
56
56
  */
57
- const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetProps<any>>> = {
57
+ const EDIT_WIDGETS: Record<string, React.ComponentType<FieldWidgetComponentProps<any>>> = {
58
58
  text: TextField,
59
59
  textarea: TextAreaField,
60
60
  number: NumberField,
@@ -180,10 +180,12 @@ export function FieldEditWidget({
180
180
  value,
181
181
  onChange,
182
182
  readonly,
183
- }: FieldWidgetProps<any>): React.ReactElement | null {
183
+ }: FieldWidgetComponentProps<any>): React.ReactElement | null {
184
184
  const resolved = field?.type ? resolveInlineEditType(field.type) : undefined;
185
185
  const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined;
186
186
  if (!Widget) return null;
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.
187
189
  const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {};
188
- return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...(compactProps as any)} />;
190
+ return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...compactProps} />;
189
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
+ });
@@ -0,0 +1,129 @@
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
+ * `FieldWidgetComponentProps` is a CLOSED contract (objectui#3221).
11
+ *
12
+ * It used to end in `[key: string]: any`, which made every check over it
13
+ * vacuous: a type claiming to have every key can never be reported as missing
14
+ * one (objectstack#4075). Three consequences, one test each below:
15
+ *
16
+ * 1. a key the type does not declare is a compile error, not a silent `any`
17
+ * — which is what makes the `error` / `errorMessage` divergence
18
+ * (objectui#3222) decidable by the compiler at all;
19
+ * 2. a MISSPELLED prop (`readOnly` for `readonly`) is rejected;
20
+ * 3. the pass-through keys hosts genuinely forward still type-check, and
21
+ * still reach the rendered control at runtime — closing the type must not
22
+ * have been paid for by dropping real behaviour.
23
+ *
24
+ * (1) and (2) are compile-time: a violation fails `pnpm --filter
25
+ * @object-ui/fields type-check`, not this run. `@ts-expect-error` inverts
26
+ * that — the line fails to compile if the error it expects ever STOPS
27
+ * happening, i.e. if the index signature comes back.
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { render, screen } from '@testing-library/react';
32
+
33
+ import { TextField } from '../widgets/TextField';
34
+ import { BooleanField } from '../widgets/BooleanField';
35
+ import type { FieldWidgetComponentProps } from '../widgets/types';
36
+ import type { FieldMetadata } from '@object-ui/types';
37
+
38
+ type Assert<T extends true> = T;
39
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
40
+ type Extends<A, B> = [A] extends [B] ? true : false;
41
+
42
+ const field = { name: 'title', label: 'Title', type: 'text' } as unknown as FieldMetadata;
43
+
44
+ describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
45
+ it('has no `[key: string]` index signature', () => {
46
+ // The single assertion the whole change turns on. While the index
47
+ // signature existed this was `true`, and every check below was vacuous.
48
+ type _NotOpen = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
49
+ expect(true).toBe(true);
50
+ });
51
+
52
+ it('rejects keys it does not declare', () => {
53
+ const props = {} as FieldWidgetComponentProps<string>;
54
+
55
+ // The spec's `FieldWidgetPropsSchema` declares both; this type declares
56
+ // neither. Reading them used to give `any` and `undefined` forever.
57
+ // Whether to ADD them is objectui#3222 — deliberately not decided here.
58
+ // @ts-expect-error `required` is not part of this contract
59
+ void props.required;
60
+ // @ts-expect-error `error` is not part of this contract — this type's slot is `errorMessage`
61
+ void props.error;
62
+
63
+ // …and the class of bug the index signature hid best: a typo.
64
+ // @ts-expect-error the prop is `readonly`, not React's DOM `readOnly`
65
+ void props.readOnly;
66
+ // @ts-expect-error the prop is `onChange`
67
+ void props.onchange;
68
+
69
+ expect(true).toBe(true);
70
+ });
71
+
72
+ it('still accepts the pass-through keys hosts actually forward', () => {
73
+ // Closing the type is only correct if the real call sites still compile.
74
+ // Each key here has a named producer: the form renderer, the inline-edit
75
+ // host, or the line-item grid. Deleting one from the type breaks this.
76
+ const passThrough: FieldWidgetComponentProps<string> = {
77
+ value: '',
78
+ onChange: () => {},
79
+ field,
80
+ // form renderer (`renderFieldComponent`)
81
+ schema: field,
82
+ dataSource: {},
83
+ dependentValues: { country: 'cn' },
84
+ dependsOn: ['country'],
85
+ emptyHint: 'Select country first',
86
+ name: 'title',
87
+ // inline-edit hosts / line-item grid
88
+ compact: true,
89
+ autoFocus: true,
90
+ onSelectRecord: () => {},
91
+ onCreateNew: () => {},
92
+ // DOM / a11y
93
+ id: 'title-input',
94
+ 'aria-label': 'Title',
95
+ 'data-testid': 'field-title',
96
+ };
97
+ expect(passThrough.compact).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe('closing the type kept the pass-through behaviour', () => {
102
+ it('forwards a11y and data attributes to the rendered input', () => {
103
+ render(
104
+ <TextField
105
+ value="hello"
106
+ onChange={() => {}}
107
+ field={field}
108
+ aria-label="Title"
109
+ data-testid="title-input"
110
+ />,
111
+ );
112
+ const input = screen.getByTestId('title-input');
113
+ expect(input).toHaveAttribute('aria-label', 'Title');
114
+ expect(input).toHaveValue('hello');
115
+ });
116
+
117
+ it('forwards `disabled` — a declared key, not an index-signature accident', () => {
118
+ render(
119
+ <BooleanField
120
+ value={false}
121
+ onChange={() => {}}
122
+ field={{ name: 'active', label: 'Active', type: 'boolean' } as unknown as FieldMetadata}
123
+ disabled
124
+ data-testid="active-switch"
125
+ />,
126
+ );
127
+ expect(screen.getByTestId('active-switch')).toBeDisabled();
128
+ });
129
+ });
@@ -6,7 +6,7 @@ import { FieldEditWidget } from './FieldEditWidget';
6
6
  import { MasterDetailField } from './widgets/MasterDetailField';
7
7
  import { GridField } from './widgets/GridField';
8
8
  import { FileField } from './widgets/FileField';
9
- import type { FieldWidgetProps } from './widgets/types';
9
+ import type { FieldWidgetComponentProps } from './widgets/types';
10
10
 
11
11
  // ------------- Mocks & Setup -------------
12
12
 
@@ -15,7 +15,7 @@ const mockField = {
15
15
  label: 'Test Field',
16
16
  } as any;
17
17
 
18
- const baseProps: FieldWidgetProps<any> = {
18
+ const baseProps: FieldWidgetComponentProps<any> = {
19
19
  field: mockField,
20
20
  value: undefined,
21
21
  onChange: vi.fn(),
@@ -78,7 +78,7 @@ describe('Complex & Relationship Widgets', () => {
78
78
  reference_field: 'name',
79
79
  } as any;
80
80
 
81
- const dynamicProps: FieldWidgetProps<any> = {
81
+ const dynamicProps: FieldWidgetComponentProps<any> = {
82
82
  ...baseProps,
83
83
  field: dynamicField,
84
84
  dataSource: mockDataSource,
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
3
3
  import { DateField } from './widgets/DateField';
4
4
  import { DateTimeField } from './widgets/DateTimeField';
5
5
  import { TimeField } from './widgets/TimeField';
6
- import type { FieldWidgetProps } from './widgets/types';
6
+ import type { FieldWidgetComponentProps } from './widgets/types';
7
7
 
8
8
  const mockField = {
9
9
  name: 'test_field',
@@ -11,7 +11,7 @@ const mockField = {
11
11
  type: 'date',
12
12
  } as any;
13
13
 
14
- const baseProps: FieldWidgetProps<string> = {
14
+ const baseProps: FieldWidgetComponentProps<string> = {
15
15
  field: mockField,
16
16
  value: '',
17
17
  onChange: vi.fn(),
package/src/index.tsx CHANGED
@@ -2048,7 +2048,9 @@ function createFieldRenderer(FieldWidget: React.ComponentType<any>) {
2048
2048
  readonly: schema?.readonly || schema?.readOnly,
2049
2049
  help: schema?.help,
2050
2050
  description: schema?.description,
2051
- defaultValue: schema?.defaultValue || schema?.value,
2051
+ // `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
2052
+ // and `||` silently threw it away (same falsy-as-empty class as cloud#972).
2053
+ defaultValue: schema?.defaultValue ?? schema?.value,
2052
2054
  ...schema,
2053
2055
  };
2054
2056
 
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Input, Label, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * Address data structure
@@ -17,7 +17,7 @@ export interface AddressValue {
17
17
  * Address field widget - provides a structured address input
18
18
  * Supports street, city, state, zip code, and country
19
19
  */
20
- export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<AddressValue>) {
20
+ export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<AddressValue>) {
21
21
  const address = value || {};
22
22
 
23
23
  const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * AutoNumberField - Read-only auto-generated field
7
7
  * Values are automatically generated by the system and cannot be edited
8
8
  */
9
- export function AutoNumberField({ value, field, ...props }: FieldWidgetProps<string | number>) {
9
+ export function AutoNumberField({ value, field, ...props }: FieldWidgetComponentProps<string | number>) {
10
10
  if (value == null) {
11
11
  return <EmptyValue className={props.className} />;
12
12
  }
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
3
3
  import { Upload, X } from 'lucide-react';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * Avatar field widget - provides an avatar/profile picture uploader
8
8
  * Supports image URLs or file uploads
9
9
  */
10
- export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  const [isHovered, setIsHovered] = React.useState(false);
12
12
  const fileInputRef = React.useRef<HTMLInputElement>(null);
13
13
 
@@ -1,12 +1,12 @@
1
1
  import React, { useId } from 'react';
2
2
  import { Switch, Checkbox, Label } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * BooleanField - Toggle input supporting switch and checkbox variants
7
7
  * Renders as Switch or Checkbox based on field widget configuration
8
8
  */
9
- export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<boolean>) {
9
+ export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
10
10
  const config = (field || (props as any).schema) as any;
11
11
  // Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
12
12
  const widget = config?.widget;
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import { Badge, EmptyValue, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
4
  import type { DataSource, QueryParams } from '@object-ui/types';
5
- import { FieldWidgetProps } from './types';
5
+ import { FieldWidgetComponentProps } from './types';
6
6
  import { useFieldTranslation } from './useFieldTranslation';
7
7
 
8
8
  /**
@@ -86,12 +86,12 @@ export function CapabilityMultiSelectField({
86
86
  readonly,
87
87
  className,
88
88
  ...props
89
- }: FieldWidgetProps<string | string[]>) {
89
+ }: FieldWidgetComponentProps<string | string[]>) {
90
90
  const { t } = useFieldTranslation();
91
91
  const ctx = React.useContext(SchemaRendererContext);
92
92
  const dataSource: DataSource | null =
93
- (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
94
- const disabled = (props as any).disabled as boolean | undefined;
93
+ (props.dataSource as any) ?? (ctx as any)?.dataSource ?? null;
94
+ const disabled = props.disabled;
95
95
 
96
96
  const [caps, setCaps] = React.useState<Capability[] | null>(null);
97
97
 
@@ -1,7 +1,7 @@
1
1
  import React, { useId, useEffect } from 'react';
2
2
  import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
3
3
  import type { OptionLike } from '@object-ui/core';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
  import { useCascadingOptions } from './useCascadingOptions';
6
6
 
7
7
  type Option = OptionLike;
@@ -30,12 +30,12 @@ export function CheckboxesField({
30
30
  emptyHint: _emptyHint,
31
31
  dataSource: _dataSource,
32
32
  ...props
33
- }: FieldWidgetProps<string[]>) {
33
+ }: FieldWidgetComponentProps<string[]>) {
34
34
  const config = (field || schema) as any;
35
35
  const rawOptions: Option[] = config?.options || [];
36
36
  const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
37
37
  const groupId = useId();
38
- const fieldName = (props as any).name || config?.name || (props as any).id || '';
38
+ const fieldName = props.name || config?.name || props.id || '';
39
39
 
40
40
  const dependsOn = config?.dependsOn ?? dependsOnProp;
41
41
  const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
@@ -107,7 +107,7 @@ export function CheckboxesField({
107
107
  id={id}
108
108
  checked={selected.includes(value)}
109
109
  onCheckedChange={(checked) => toggle(value, !!checked)}
110
- disabled={(props as any).disabled}
110
+ disabled={props.disabled}
111
111
  data-testid={`checkboxes-option-${value}`}
112
112
  />
113
113
  <Label htmlFor={id} className="font-normal">{opt.label}</Label>
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Textarea, cn, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * Code field widget - provides a code editor with syntax highlighting
7
7
  * Uses a simple textarea with monospace font
8
8
  * For advanced code editing, use the @object-ui/plugin-editor component
9
9
  */
10
- export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  const config = field || (props as any).schema;
12
12
  // Get code-specific configuration from field metadata
13
13
  const language = (config as any)?.language ?? 'javascript';