@object-ui/react 13.2.0 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,160 @@
1
1
  # @object-ui/react
2
2
 
3
+ ## 14.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5523fc4: Dashboard-level filters — the three #2578 item-5 enhancements (framework#2501):
8
+
9
+ - **react**: nested `PageVariablesProvider`s now MERGE instead of shadowing
10
+ wholesale. A filtered dashboard embedded in a Page with its own `variables`
11
+ keeps the outer page variables readable inside widget subtrees (`page.*`);
12
+ an inner definition shadows only the SAME name; writes route to the scope
13
+ that defines the variable (writing an outer-defined name from inside the
14
+ nested subtree updates the outer provider); `resetVariables` stays local.
15
+ Names defined nowhere still write locally, exactly as before.
16
+ - **core**: `buildWidgetScopedFilter` accepts an optional `knownFields` set —
17
+ a DEFAULT binding whose target field is not on the widget's object is
18
+ skipped with a console warning instead of emitting a query the backend
19
+ empty-matches. Explicit `filterBindings` strings are always honoured (a
20
+ typo surfaces as a visibly empty widget, never a silently dropped filter).
21
+ Omitting `knownFields` preserves the previous unchecked behaviour.
22
+ - **plugin-dashboard**: `DashboardRenderer` feeds `knownFields` from
23
+ `dataSource.getObjectSchema` for inline `object` widgets (best-effort —
24
+ unchecked while metadata loads or when the source can't describe objects).
25
+ `optionsFrom` dynamic filter options now resolve DISTINCT values
26
+ server-side via a dataset GROUP BY (`queryDataset` with an inline draft)
27
+ when the data source supports it, falling back to the previous client-side
28
+ top-200 dedupe otherwise.
29
+
30
+ - dea65f7: Unify the list-view conditional tier onto the canonical CEL engine (#1584).
31
+
32
+ Conditional formatting (list / grid / kanban) and row-action `visible` /
33
+ `disabled` predicates are now evaluated by `@objectstack/formula`'s
34
+ `ExpressionEngine` — the same engine the server uses — instead of the legacy
35
+ JS-dialect `ExpressionEvaluator`, matching how `@objectstack/spec` already types
36
+ these surfaces (`ExpressionInputSchema` / CEL). The whole platform now speaks one
37
+ expression dialect (framework ADR-0058).
38
+
39
+ - `@object-ui/core`: new `evalRowPredicate` + `resolveConditionalFormatting`
40
+ helpers (next to `evalFieldPredicate`). One implementation of all three
41
+ formatting rule shapes; dialect routing (a `{ dialect: 'cel' }` envelope is
42
+ always CEL; a bare string is CEL unless it carries legacy-only syntax
43
+ (`${…}` / `===` / `?.` / `.includes()`), which routes to the old engine with a
44
+ one-time deprecation warning); the native `{ field, operator, value }` form is
45
+ translated to CEL.
46
+ - `@object-ui/react`: new `useRowPredicate` hook (canonical CEL, ambient
47
+ predicate scope merged).
48
+ - Consumers converged: `ListView.evaluateConditionalFormatting` (thin wrapper,
49
+ export kept), `ObjectGrid` row styling (inline copy removed), kanban card
50
+ styles, and the grid / data-table row-action menus. `plugin-view`'s kanban
51
+ branch now forwards top-level `conditionalFormatting` (previously dropped).
52
+ - Row-action `visible` fails **closed** (broken predicate → hidden + warn);
53
+ `disabled` fails soft. The CEL `in` operator (and list membership) now work in
54
+ row predicates — the legacy engine could not parse them.
55
+ - The legacy `FormField.condition: { field, equals/notEquals/in }` is retired to
56
+ a CEL translation (back-compat preserved); `FieldDesigner` migrated to
57
+ `visibleWhen`.
58
+
59
+ Fully back-compat: existing conditional-formatting rules, row-action predicates,
60
+ and form `condition` metadata keep working (translated / routed as needed).
61
+
62
+ - 5b52624: feat(detail): record-level inline edit — shared `InlineEditContext` + one atomic Save (objectui#2407 P1)
63
+
64
+ Lift the inline-edit session out of `DetailView`'s private state into a
65
+ record-level, shared context so a record page's surfaces can share ONE draft and
66
+ commit it in ONE atomic, cross-field-validated write (replacing the per-field
67
+ save loop).
68
+
69
+ - **`InlineEditContext` / `InlineEditProvider` / `useInlineEdit`** (@object-ui/react)
70
+ — pure UI state (`editing`, `canEdit`, `draft`, `autoFocusField`, `saving`,
71
+ `error` + `enter` / `setField` / `cancel` / `reset`). A _separate_ context from
72
+ `RecordContext` (mirrors `HighlightFieldsContext`) so per-keystroke draft churn
73
+ doesn't re-render other `record:*` consumers.
74
+ - **`<InlineEditSaveBar>`** (@object-ui/plugin-detail) — the record-level sticky
75
+ Save/Cancel bar. Commits the whole draft in ONE
76
+ `dataSource.update(obj, id, draft, { ifMatch: data.updated_at })` → `refresh()`;
77
+ a `409 CONCURRENT_UPDATE` reuses `<ConcurrentUpdateDialog>` (reload / overwrite).
78
+ A callback mode (`onFieldSave`) preserves the drawer's per-field persistence
79
+ contract with plugin-gantt/calendar/kanban.
80
+ - **`DetailView`** now consumes `useInlineEdit()` instead of owning inline-edit
81
+ state; its header/inline Save-Cancel bars and per-field batch-save are removed
82
+ (the approval-lock badge stays). Rendered without a provider it is simply
83
+ read-only.
84
+ - **`record:details`** and **`RecordDetailDrawer`** each wrap their `DetailView`
85
+ in an `<InlineEditProvider>` + `<InlineEditSaveBar>`. The object-lifecycle /
86
+ permission gate flows through `canEdit`; computed / readonly / system fields
87
+ and the OCC path are unchanged.
88
+
89
+ Guardrails preserved: computed (`formula`/`summary`/`rollup`/`auto_number`) +
90
+ `readonly` + system fields expose no editor; `canEdit` gate; OCC (`ifMatch` +
91
+ `ConcurrentUpdateDialog`); the atomic partial update carries only user-edited
92
+ keys (never computed/read-only). Editable highlights ride on top of this in P2.
93
+
94
+ - f94905d: remove(react): drop the unused `FormRenderer` / `FieldFactory` duplicate render path (#2545)
95
+
96
+ `FormRenderer` (and its captive `FieldFactory`) was an exported-but-dead second
97
+ form render path: zero runtime consumers anywhere in the repo — the only import
98
+ was its own test file. It duplicated `@object-ui/plugin-form`'s `ObjectForm`
99
+ (the path every app actually uses via the component registry) but had drifted
100
+ into a degraded variant: raw-HTML/Tailwind instead of the shared UI primitives,
101
+ a hard-coded Submit button, and no support for `submitBehavior` / `aria` /
102
+ `groups`.
103
+
104
+ **Breaking (ships as minor per the pre-1.0 launch-window convention):** the
105
+ public exports `FormRenderer`, `FormRendererProps`, `FieldFactory`,
106
+ `FieldFactoryProps`, and `ExtendedFormField` are removed from
107
+ `@object-ui/react`. Render forms through the `object-form` schema node
108
+ (`@object-ui/plugin-form` `ObjectForm`, reachable from a `FormViewSchema` via
109
+ `SchemaRenderer` / the spec bridge) instead. Closes Phase 4 of #2545.
110
+
111
+ ### Patch Changes
112
+
113
+ - Updated dependencies [82441e4]
114
+ - Updated dependencies [2efa9fd]
115
+ - Updated dependencies [0890fa7]
116
+ - Updated dependencies [2ded18c]
117
+ - Updated dependencies [e628d1f]
118
+ - Updated dependencies [5523fc4]
119
+ - Updated dependencies [887062c]
120
+ - Updated dependencies [23d65c3]
121
+ - Updated dependencies [9e2d58f]
122
+ - Updated dependencies [dea65f7]
123
+ - Updated dependencies [d5b1bc0]
124
+ - Updated dependencies [f0f10f5]
125
+ - @object-ui/i18n@14.1.0
126
+ - @object-ui/core@14.1.0
127
+ - @object-ui/types@14.1.0
128
+ - @object-ui/data-objectstack@14.1.0
129
+
130
+ ## 14.0.0
131
+
132
+ ### Minor Changes
133
+
134
+ - 86c69c3: ADR-0089: read the canonical `visibleWhen` conditional-visibility predicate in the form + page renderers.
135
+
136
+ `@objectstack/spec` now unifies conditional visibility under a single canonical key, `visibleWhen`, and folds the deprecated `visibleOn` (view form) / `visibility` (page component) aliases into it at parse. This updates ObjectUI to read the canonical key:
137
+
138
+ - **Page renderer** (`SchemaRenderer`) — evaluates `visibleWhen` first (show-when-truthy), then the deprecated `visibleOn` / `visibility` as a defensive read for raw / un-normalized metadata. `visibleWhen` is stripped from DOM props.
139
+ - **Spec→node bridges** — the page bridge maps a component's `visibleWhen ?? visibility` onto the node's canonical `visibleWhen`; the form-view bridge maps a field's `visibleWhen ?? visibleOn` onto the ObjectForm view-level predicate slot.
140
+ - **Form renderers** — the `@object-ui/react` `FormRenderer` prefers `visibleWhen` over the `visibleOn` alias. (`ObjectForm`/`form.tsx` already evaluated `visibleWhen`.)
141
+ - **Types** — the component base schema (`BaseSchema` / `base.zod`) gains the canonical `visibleWhen`; `visibleOn` is marked `@deprecated`.
142
+
143
+ Fully back-compat: existing `visibleOn` / `visibility` metadata keeps working through the alias reads.
144
+
145
+ ### Patch Changes
146
+
147
+ - Updated dependencies [443360a]
148
+ - Updated dependencies [c70bca7]
149
+ - Updated dependencies [86c69c3]
150
+ - Updated dependencies [05e56ca]
151
+ - Updated dependencies [5971cc4]
152
+ - Updated dependencies [6a74160]
153
+ - @object-ui/core@14.0.0
154
+ - @object-ui/i18n@14.0.0
155
+ - @object-ui/types@14.0.0
156
+ - @object-ui/data-objectstack@14.0.0
157
+
3
158
  ## 13.2.0
4
159
 
5
160
  ### Patch Changes
@@ -216,18 +216,25 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
216
216
  }
217
217
  newSchema.props = newProps;
218
218
  }
219
- // Evaluate visibility: visible / visibleOn / visibility / hidden / hiddenOn
219
+ // Evaluate visibility: visible / visibleWhen / visibleOn / visibility / hidden / hiddenOn
220
220
  const shouldHide = (() => {
221
221
  if (newSchema.visible !== undefined) {
222
222
  return !evaluator.evaluateCondition(newSchema.visible);
223
223
  }
224
+ // `visibleWhen` is the single canonical conditional-visibility predicate
225
+ // across every layer since ADR-0089 (show-when-truthy). The spec folds the
226
+ // deprecated `visibleOn` (view) / `visibility` (page) aliases into it at
227
+ // parse, so it is checked FIRST; the aliases below remain as a defensive
228
+ // read for any raw / un-normalized metadata reaching the renderer.
229
+ if (newSchema.visibleWhen !== undefined) {
230
+ return !evaluator.evaluateCondition(newSchema.visibleWhen);
231
+ }
232
+ // @deprecated ADR-0089 → `visibleWhen`.
224
233
  if (newSchema.visibleOn !== undefined) {
225
234
  return !evaluator.evaluateCondition(newSchema.visibleOn);
226
235
  }
227
- // `visibility` is the spec-canonical predicate (PageComponentSchema.visibility,
228
- // an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`. The
229
- // spec→node bridge preserves it verbatim on the node, so this is where it
230
- // finally gates rendering (previously it was inert and leaked as a DOM prop).
236
+ // @deprecated ADR-0089 → `visibleWhen` (was PageComponentSchema.visibility,
237
+ // an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`.
231
238
  if (newSchema.visibility !== undefined) {
232
239
  return !evaluator.evaluateCondition(newSchema.visibility);
233
240
  }
@@ -291,7 +298,7 @@ export const SchemaRenderer = forwardRef(({ schema, ...props }, _ref) => {
291
298
  // Note: We don't forward the ref to the Component because components in the registry
292
299
  // may not support refs. The SchemaRenderer itself can still receive refs for its own use.
293
300
  // Extract schema metadata properties that should NOT be passed as React props
294
- const { type: _type, children: _children, body: _body, schema: _schema, visible: _visible, visibleOn: _visibleOn, visibility: _visibility, hidden: _hidden, hiddenOn: _hiddenOn, disabled: _disabled, disabledOn: _disabledOn, _hidden: __hidden, // stripped: internal visibility flag
301
+ const { type: _type, children: _children, body: _body, schema: _schema, visible: _visible, visibleWhen: _visibleWhen, visibleOn: _visibleOn, visibility: _visibility, hidden: _hidden, hiddenOn: _hiddenOn, disabled: _disabled, disabledOn: _disabledOn, _hidden: __hidden, // stripped: internal visibility flag
295
302
  _disabled: __disabled, // stripped: internal disabled flag
296
303
  responsiveStyles: _responsiveStyles, // stripped: compiled to scoped CSS, not a DOM prop
297
304
  ...componentProps } = evaluatedSchema;
@@ -0,0 +1,74 @@
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
+ * InlineEditContext — record-level inline-edit session shared across the
9
+ * `record:*` renderers of a record page (objectui#2407 P1). Lifting the
10
+ * edit session out of `DetailView`'s private state lets the details body and
11
+ * (P2) the highlights strip share ONE draft + ONE atomic Save.
12
+ *
13
+ * This is a *separate* context from `RecordContext` (mirrors
14
+ * `HighlightFieldsContext`) on purpose: the draft mutates on every keystroke,
15
+ * and routing that churn through `RecordContext` would re-render every
16
+ * `record:*` consumer. Consumers that don't edit stay subscribed only to
17
+ * `RecordContext` and never re-render on draft changes.
18
+ *
19
+ * The context holds PURE UI state — it knows nothing about the DataSource,
20
+ * OCC, or how a draft is persisted. The atomic save + conflict handling live
21
+ * in `<InlineEditSaveBar>` (@object-ui/plugin-detail), which reads this
22
+ * context for the draft/editing flags and drives `saving`/`error`/`reset`.
23
+ */
24
+ import React from 'react';
25
+ export interface InlineEditContextValue {
26
+ /** True while the record is in inline-edit mode. */
27
+ editing: boolean;
28
+ /**
29
+ * Whether inline editing is allowed at all for this record — the object
30
+ * lifecycle / permission gate, decided by the host. When false, `enter()`
31
+ * is a no-op and consumers must not surface the edit affordance.
32
+ */
33
+ canEdit: boolean;
34
+ /**
35
+ * Draft of user-edited values. Holds ONLY the keys the user actually
36
+ * changed, so the save path never writes computed / read-only / untouched
37
+ * fields. Read a field's live value as `draft[name] ?? data[name]`.
38
+ */
39
+ draft: Record<string, any>;
40
+ /** Field to auto-focus when edit was entered from a specific field. */
41
+ autoFocusField: string | null;
42
+ /** True while an atomic save is in flight (driven by the save bar). */
43
+ saving: boolean;
44
+ /** Last save error message, or null (driven by the save bar). */
45
+ error: string | null;
46
+ /** Enter inline-edit mode, optionally focused on `field`. No-op when `!canEdit`. */
47
+ enter: (field?: string) => void;
48
+ /** Stage a single field edit into the draft. */
49
+ setField: (field: string, value: any) => void;
50
+ /** Exit edit mode and discard the draft (Cancel). */
51
+ cancel: () => void;
52
+ /** Exit edit mode and clear the draft — used after a successful save. */
53
+ reset: () => void;
54
+ /** Set the in-flight saving flag (used by the save bar). */
55
+ setSaving: (saving: boolean) => void;
56
+ /** Set or clear the save error message (used by the save bar). */
57
+ setError: (error: string | null) => void;
58
+ }
59
+ export interface InlineEditProviderProps {
60
+ /**
61
+ * Whether inline editing is allowed (object-lifecycle + permission gate).
62
+ * Threaded into `canEdit` so `enter()` and the edit affordance are gated at
63
+ * a single source. Defaults to `true`.
64
+ */
65
+ canEdit?: boolean;
66
+ children: React.ReactNode;
67
+ }
68
+ export declare const InlineEditProvider: React.FC<InlineEditProviderProps>;
69
+ /**
70
+ * Read the current inline-edit session. Returns `null` when called outside an
71
+ * `<InlineEditProvider>` — a `DetailView` rendered without a provider (bare /
72
+ * legacy usage) simply treats the record as read-only rather than throwing.
73
+ */
74
+ export declare function useInlineEdit(): InlineEditContextValue | null;
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * ObjectUI
4
+ * Copyright (c) 2024-present ObjectStack Inc.
5
+ *
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ *
9
+ * InlineEditContext — record-level inline-edit session shared across the
10
+ * `record:*` renderers of a record page (objectui#2407 P1). Lifting the
11
+ * edit session out of `DetailView`'s private state lets the details body and
12
+ * (P2) the highlights strip share ONE draft + ONE atomic Save.
13
+ *
14
+ * This is a *separate* context from `RecordContext` (mirrors
15
+ * `HighlightFieldsContext`) on purpose: the draft mutates on every keystroke,
16
+ * and routing that churn through `RecordContext` would re-render every
17
+ * `record:*` consumer. Consumers that don't edit stay subscribed only to
18
+ * `RecordContext` and never re-render on draft changes.
19
+ *
20
+ * The context holds PURE UI state — it knows nothing about the DataSource,
21
+ * OCC, or how a draft is persisted. The atomic save + conflict handling live
22
+ * in `<InlineEditSaveBar>` (@object-ui/plugin-detail), which reads this
23
+ * context for the draft/editing flags and drives `saving`/`error`/`reset`.
24
+ */
25
+ import React from 'react';
26
+ const InlineEditContext = React.createContext(null);
27
+ export const InlineEditProvider = ({ canEdit = true, children, }) => {
28
+ const [editing, setEditing] = React.useState(false);
29
+ const [draft, setDraft] = React.useState({});
30
+ const [autoFocusField, setAutoFocusField] = React.useState(null);
31
+ const [saving, setSaving] = React.useState(false);
32
+ const [error, setError] = React.useState(null);
33
+ const enter = React.useCallback((field) => {
34
+ // Object-lifecycle / permission gate: never enter edit on a record the
35
+ // host has marked non-editable, even if a stray affordance fires.
36
+ if (!canEdit)
37
+ return;
38
+ setAutoFocusField(field ?? null);
39
+ setEditing(true);
40
+ setError(null);
41
+ }, [canEdit]);
42
+ const setField = React.useCallback((field, value) => {
43
+ setDraft((prev) => ({ ...prev, [field]: value }));
44
+ }, []);
45
+ // Cancel and reset share the same teardown (clear draft, exit edit, clear
46
+ // transient state). They are named separately so call sites read
47
+ // intentionally — `cancel()` on the Cancel button, `reset()` after a
48
+ // successful save — and so the two can diverge later without touching callers.
49
+ const teardown = React.useCallback(() => {
50
+ setDraft({});
51
+ setEditing(false);
52
+ setAutoFocusField(null);
53
+ setSaving(false);
54
+ setError(null);
55
+ }, []);
56
+ const value = React.useMemo(() => ({
57
+ editing,
58
+ canEdit,
59
+ draft,
60
+ autoFocusField,
61
+ saving,
62
+ error,
63
+ enter,
64
+ setField,
65
+ cancel: teardown,
66
+ reset: teardown,
67
+ setSaving,
68
+ setError,
69
+ }), [editing, canEdit, draft, autoFocusField, saving, error, enter, setField, teardown]);
70
+ return _jsx(InlineEditContext.Provider, { value: value, children: children });
71
+ };
72
+ /**
73
+ * Read the current inline-edit session. Returns `null` when called outside an
74
+ * `<InlineEditProvider>` — a `DetailView` rendered without a provider (bare /
75
+ * legacy usage) simply treats the record as read-only rather than throwing.
76
+ */
77
+ export function useInlineEdit() {
78
+ return React.useContext(InlineEditContext);
79
+ }
@@ -6,6 +6,7 @@ export * from './NotificationContext';
6
6
  export * from './DndContext';
7
7
  export * from './AppShellContext';
8
8
  export * from './RecordContext';
9
+ export * from './InlineEditContext';
9
10
  export * from './DiscussionContext';
10
11
  export * from './DrillNavigationContext';
11
12
  export * from './RelatedRecordActionsContext';
@@ -6,6 +6,7 @@ export * from './NotificationContext';
6
6
  export * from './DndContext';
7
7
  export * from './AppShellContext';
8
8
  export * from './RecordContext';
9
+ export * from './InlineEditContext';
9
10
  export * from './DiscussionContext';
10
11
  export * from './DrillNavigationContext';
11
12
  export * from './RelatedRecordActionsContext';
@@ -51,15 +51,32 @@ export declare function toPredicateInput(value: unknown): string | boolean | und
51
51
  * ```
52
52
  */
53
53
  export declare function useExpression(expression: string | boolean | number | null | undefined, context?: Record<string, any>): any;
54
+ export declare function useCondition(condition: string | boolean | undefined, context?: Record<string, any>, options?: {
55
+ throwOnError?: boolean;
56
+ label?: string;
57
+ }): boolean;
54
58
  /**
55
- * Hook for evaluating conditional expressions
56
- * Returns a boolean value
59
+ * Evaluate a **row-scoped predicate** (a row-action `visible` / `disabled`, or a
60
+ * conditional-formatting `condition`) against a record on the canonical CEL
61
+ * engine — the same engine the server uses (issue #1584 / ADR-0058).
57
62
  *
58
- * @example
59
- * ```tsx
60
- * const isVisible = useCondition('${data.status === "active"}', { data });
61
- * ```
63
+ * Unlike {@link useCondition} (schema/widget tier, legacy `${}` dialect), this
64
+ * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
65
+ * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
66
+ * envelope is always CEL, and only a legacy-dialect string falls back to the
67
+ * old engine (with a deprecation warning). The row is bound as `record.*` and
68
+ * bare fields; the ambient predicate scope (`features` / `user` / …) is merged
69
+ * alongside so deployment-level gates keep resolving.
70
+ *
71
+ * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
72
+ * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
73
+ * @param row The row record to evaluate against.
74
+ * @param options `fallback` — value when the predicate is absent or faults
75
+ * (default `true`); `warnOnError` — log when a present predicate
76
+ * faults (fail-soft-but-logged, ADR-0058); `label` — for the log.
62
77
  */
63
- export declare function useCondition(condition: string | boolean | undefined, context?: Record<string, any>, options?: {
64
- throwOnError?: boolean;
78
+ export declare function useRowPredicate(pred: unknown, row: Record<string, any> | null | undefined, options?: {
79
+ fallback?: boolean;
80
+ warnOnError?: boolean;
81
+ label?: string;
65
82
  }): boolean;
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  import { createContext, createElement, useContext, useMemo } from 'react';
9
- import { ExpressionEvaluator } from '@object-ui/core';
9
+ import { ExpressionEvaluator, evalRowPredicate } from '@object-ui/core';
10
10
  /**
11
11
  * Global predicate scope — populated by host shells (e.g. app-shell's
12
12
  * `ExpressionProvider`) so renderers like action buttons can resolve
@@ -95,6 +95,8 @@ export function useExpression(expression, context = {}) {
95
95
  * const isVisible = useCondition('${data.status === "active"}', { data });
96
96
  * ```
97
97
  */
98
+ /** One-time registry for fail-closed predicate warnings (see useCondition). */
99
+ const _warnedConditions = new Set();
98
100
  export function useCondition(condition, context = {}, options) {
99
101
  const scope = usePredicateScope();
100
102
  // We evaluate directly without caching the evaluator to avoid issues with context changes
@@ -108,10 +110,59 @@ export function useCondition(condition, context = {}, options) {
108
110
  try {
109
111
  return evaluator.evaluateCondition(condition, { throwOnError: true });
110
112
  }
111
- catch {
113
+ catch (err) {
114
+ // A throwing predicate is almost always an authoring bug (wrong
115
+ // scope variable, bare field reference) — warn once per
116
+ // (label, predicate) so the silent hide is diagnosable (#2358)
117
+ // without spamming re-renders.
118
+ const src = typeof condition === 'string' ? condition : String(condition);
119
+ const key = `${options?.label ?? ''}::${src}`;
120
+ if (!_warnedConditions.has(key)) {
121
+ _warnedConditions.add(key);
122
+ const msg = err instanceof Error ? err.message : String(err);
123
+ console.warn(`[object-ui] ${options?.label ?? 'a component'} was hidden/disabled: ` +
124
+ `its predicate threw — ${msg}. Predicate: ${src}.`);
125
+ }
112
126
  return false;
113
127
  }
114
128
  }
115
129
  return evaluator.evaluateCondition(condition);
116
- }, [condition, context, scope, options?.throwOnError]);
130
+ }, [condition, context, scope, options?.throwOnError, options?.label]);
131
+ }
132
+ /**
133
+ * Evaluate a **row-scoped predicate** (a row-action `visible` / `disabled`, or a
134
+ * conditional-formatting `condition`) against a record on the canonical CEL
135
+ * engine — the same engine the server uses (issue #1584 / ADR-0058).
136
+ *
137
+ * Unlike {@link useCondition} (schema/widget tier, legacy `${}` dialect), this
138
+ * routes to `@object-ui/core`'s `evalRowPredicate`: a bare string is CEL (the
139
+ * spec contract for `ActionSchema.visible`), a `{ dialect: 'cel', source }`
140
+ * envelope is always CEL, and only a legacy-dialect string falls back to the
141
+ * old engine (with a deprecation warning). The row is bound as `record.*` and
142
+ * bare fields; the ambient predicate scope (`features` / `user` / …) is merged
143
+ * alongside so deployment-level gates keep resolving.
144
+ *
145
+ * @param pred The raw predicate: `boolean` (returned as-is), a CEL string,
146
+ * an `{ dialect, source }` envelope, or `null`/`undefined`/`''`.
147
+ * @param row The row record to evaluate against.
148
+ * @param options `fallback` — value when the predicate is absent or faults
149
+ * (default `true`); `warnOnError` — log when a present predicate
150
+ * faults (fail-soft-but-logged, ADR-0058); `label` — for the log.
151
+ */
152
+ export function useRowPredicate(pred, row, options) {
153
+ const scope = usePredicateScope();
154
+ const fallback = options?.fallback ?? true;
155
+ return useMemo(() => {
156
+ // A boolean predicate short-circuits (no expression to evaluate).
157
+ if (typeof pred === 'boolean')
158
+ return pred;
159
+ if (pred == null || pred === '')
160
+ return fallback;
161
+ return evalRowPredicate(pred, row ?? {}, {
162
+ fallback,
163
+ scope,
164
+ warnOnError: options?.warnOnError,
165
+ label: options?.label,
166
+ });
167
+ }, [pred, row, scope, fallback, options?.warnOnError, options?.label]);
117
168
  }
@@ -43,6 +43,16 @@ export interface PageVariablesProviderProps {
43
43
  * Initializes variables from their definitions and provides read/write access
44
44
  * to all child components via the usePageVariables hook.
45
45
  *
46
+ * Nesting MERGES contexts (objectui#2578): a nested provider (e.g. a filtered
47
+ * dashboard embedded in a Page that declares its own `variables`) no longer
48
+ * shadows the outer scope wholesale. Inside the nested subtree, `variables`
49
+ * exposes the outer values plus the inner ones (an inner definition with the
50
+ * SAME name shadows the outer one, deliberately), and writes route to the
51
+ * scope that DEFINES the variable — writing an outer-defined name from inside
52
+ * the nested subtree updates the outer provider, so both subtrees stay in
53
+ * sync. Names defined nowhere are written locally. `resetVariables` resets
54
+ * only the local scope's definitions.
55
+ *
46
56
  * @example
47
57
  * ```tsx
48
58
  * <PageVariablesProvider definitions={[
@@ -50,6 +50,16 @@ function initializeVariables(definitions) {
50
50
  * Initializes variables from their definitions and provides read/write access
51
51
  * to all child components via the usePageVariables hook.
52
52
  *
53
+ * Nesting MERGES contexts (objectui#2578): a nested provider (e.g. a filtered
54
+ * dashboard embedded in a Page that declares its own `variables`) no longer
55
+ * shadows the outer scope wholesale. Inside the nested subtree, `variables`
56
+ * exposes the outer values plus the inner ones (an inner definition with the
57
+ * SAME name shadows the outer one, deliberately), and writes route to the
58
+ * scope that DEFINES the variable — writing an outer-defined name from inside
59
+ * the nested subtree updates the outer provider, so both subtrees stay in
60
+ * sync. Names defined nowhere are written locally. `resetVariables` resets
61
+ * only the local scope's definitions.
62
+ *
53
63
  * @example
54
64
  * ```tsx
55
65
  * <PageVariablesProvider definitions={[
@@ -61,18 +71,34 @@ function initializeVariables(definitions) {
61
71
  * ```
62
72
  */
63
73
  export const PageVariablesProvider = ({ definitions, children, }) => {
64
- const [variables, setVariablesState] = useState(() => initializeVariables(definitions));
74
+ const parent = useContext(PageVariablesContext);
75
+ const [ownVariables, setVariablesState] = useState(() => initializeVariables(definitions));
76
+ const defs = useMemo(() => definitions ?? [], [definitions]);
77
+ // Names THIS provider owns (defines). Writes to a name an ancestor defines
78
+ // — and this provider doesn't — delegate upward; everything else (own
79
+ // names AND names defined nowhere) writes locally, so ad-hoc variables
80
+ // behave exactly as before.
81
+ const ownNames = useMemo(() => new Set(defs.map((d) => d.name)), [defs]);
65
82
  const setVariable = useCallback((name, value) => {
83
+ if (!ownNames.has(name) && parent && parent.definitions.some((d) => d.name === name)) {
84
+ parent.setVariable(name, value);
85
+ return;
86
+ }
66
87
  setVariablesState((prev) => ({ ...prev, [name]: value }));
67
- }, []);
88
+ }, [ownNames, parent]);
68
89
  const setVariables = useCallback((updates) => {
69
- setVariablesState((prev) => ({ ...prev, ...updates }));
70
- }, []);
90
+ for (const [name, value] of Object.entries(updates))
91
+ setVariable(name, value);
92
+ }, [setVariable]);
71
93
  const resetVariables = useCallback(() => {
72
94
  setVariablesState(initializeVariables(definitions));
73
95
  }, [definitions]);
74
- const defs = useMemo(() => definitions ?? [], [definitions]);
75
- const value = useMemo(() => ({ variables, definitions: defs, setVariable, setVariables, resetVariables }), [variables, defs, setVariable, setVariables, resetVariables]);
96
+ // Merged view: outer scope first, own values win on name collisions.
97
+ const variables = useMemo(() => (parent ? { ...parent.variables, ...ownVariables } : ownVariables), [parent, ownVariables]);
98
+ const mergedDefs = useMemo(() => parent
99
+ ? [...parent.definitions.filter((d) => !ownNames.has(d.name)), ...defs]
100
+ : defs, [parent, defs, ownNames]);
101
+ const value = useMemo(() => ({ variables, definitions: mergedDefs, setVariable, setVariables, resetVariables }), [variables, mergedDefs, setVariable, setVariables, resetVariables]);
76
102
  return (_jsx(PageVariablesContext.Provider, { value: value, children: children }));
77
103
  };
78
104
  PageVariablesProvider.displayName = 'PageVariablesProvider';
package/dist/index.d.ts CHANGED
@@ -8,7 +8,6 @@
8
8
  export * from './SchemaRenderer';
9
9
  export * from './hooks';
10
10
  export * from './context';
11
- export * from './components/form';
12
11
  export * from './LazyPluginLoader';
13
12
  export * from './spec-bridge';
14
13
  export * from './data-invalidation';
package/dist/index.js CHANGED
@@ -8,7 +8,6 @@
8
8
  export * from './SchemaRenderer';
9
9
  export * from './hooks'; // will be empty for now
10
10
  export * from './context'; // will be empty for now
11
- export * from './components/form';
12
11
  export * from './LazyPluginLoader';
13
12
  export * from './spec-bridge';
14
13
  export * from './data-invalidation';
@@ -8,6 +8,12 @@
8
8
  import type { BridgeFn } from '../types';
9
9
  interface FormField {
10
10
  field: string;
11
+ /** Field type (spec FormFieldSchema reuses Data.FieldType; auto-infers widget). */
12
+ type?: string;
13
+ /** Options for select/multiselect/radio/checkboxes fields. */
14
+ options?: any[];
15
+ /** Target object name for lookup/master_detail fields. */
16
+ reference?: string;
11
17
  label?: string;
12
18
  placeholder?: string;
13
19
  helpText?: string;
@@ -17,20 +23,51 @@ interface FormField {
17
23
  colSpan?: number;
18
24
  widget?: string;
19
25
  dependsOn?: string[];
26
+ /** Canonical conditional-visibility predicate (ADR-0089). */
27
+ visibleWhen?: string;
28
+ /** @deprecated ADR-0089 → `visibleWhen`. */
20
29
  visibleOn?: string;
21
30
  }
22
31
  interface FormSection {
32
+ /** Stable section identifier for i18n lookup (spec FormSectionSchema.name). */
33
+ name?: string;
23
34
  label?: string;
35
+ description?: string;
24
36
  collapsible?: boolean;
25
37
  collapsed?: boolean;
26
38
  columns?: number;
39
+ /** Section-level conditional-visibility predicate (ADR-0089). */
40
+ visibleWhen?: string;
27
41
  fields?: FormField[];
28
42
  }
43
+ /**
44
+ * The subset of `@objectstack/spec` FormViewSchema the bridge consumes.
45
+ * Every serializable spec key is either mapped onto the `object-form` node
46
+ * or listed here with an explicit reason for being ignored — the bridge must
47
+ * never silently drop spec configuration (#2545).
48
+ */
29
49
  interface FormViewSpec {
30
50
  type?: string;
51
+ layout?: string;
52
+ columns?: number;
53
+ title?: string;
54
+ description?: string;
55
+ defaultTab?: string;
56
+ tabPosition?: string;
57
+ allowSkip?: boolean;
58
+ showStepIndicator?: boolean;
59
+ splitDirection?: string;
60
+ splitSize?: number;
61
+ splitResizable?: boolean;
62
+ drawerSide?: string;
63
+ drawerWidth?: string;
64
+ modalSize?: string;
31
65
  data?: any;
32
66
  sections?: FormSection[];
33
- groups?: any;
67
+ /** Legacy alias of `sections` (spec: "Legacy support → alias to sections"). */
68
+ groups?: FormSection[];
69
+ /** Inline master-detail child collections. */
70
+ subforms?: any[];
34
71
  defaultSort?: any;
35
72
  sharing?: any;
36
73
  aria?: {
@@ -10,6 +10,12 @@ function mapField(field) {
10
10
  name: field.field,
11
11
  label: field.label ?? field.field,
12
12
  };
13
+ if (field.type)
14
+ mapped.type = field.type;
15
+ if (field.options)
16
+ mapped.options = field.options;
17
+ if (field.reference)
18
+ mapped.reference = field.reference;
13
19
  if (field.placeholder)
14
20
  mapped.placeholder = field.placeholder;
15
21
  if (field.helpText)
@@ -26,22 +32,33 @@ function mapField(field) {
26
32
  mapped.widget = field.widget;
27
33
  if (field.dependsOn)
28
34
  mapped.dependsOn = field.dependsOn;
29
- if (field.visibleOn)
30
- mapped.visibleOn = field.visibleOn;
35
+ // ADR-0089: `visibleWhen` is the canonical view-form-field visibility predicate
36
+ // (the spec folds the deprecated `visibleOn` into it at parse). Prefer it and
37
+ // fall back to `visibleOn` for raw / un-normalized metadata. The ObjectForm
38
+ // renderer reads this view-level predicate from the node's `visibleOn` slot.
39
+ const visiblePredicate = field.visibleWhen ?? field.visibleOn;
40
+ if (visiblePredicate)
41
+ mapped.visibleOn = visiblePredicate;
31
42
  return mapped;
32
43
  }
33
44
  function mapSection(section) {
34
45
  const mapped = {
35
46
  fields: (section.fields ?? []).map(mapField),
36
47
  };
48
+ if (section.name)
49
+ mapped.name = section.name;
37
50
  if (section.label)
38
51
  mapped.label = section.label;
52
+ if (section.description)
53
+ mapped.description = section.description;
39
54
  if (section.collapsible != null)
40
55
  mapped.collapsible = section.collapsible;
41
56
  if (section.collapsed != null)
42
57
  mapped.collapsed = section.collapsed;
43
58
  if (section.columns != null)
44
59
  mapped.columns = section.columns;
60
+ if (section.visibleWhen)
61
+ mapped.visibleWhen = section.visibleWhen;
45
62
  return mapped;
46
63
  }
47
64
  /** Maps spec formType to ObjectUI formType */
@@ -51,9 +68,35 @@ function mapFormType(type) {
51
68
  const validTypes = ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'];
52
69
  return validTypes.includes(type) ? type : undefined;
53
70
  }
71
+ /**
72
+ * Spec FormViewSchema keys carried onto the `object-form` node verbatim.
73
+ * All of them are declared with the same name (and semantics) on
74
+ * `ObjectFormSchema`, so no per-key mapping is needed — only presence checks.
75
+ */
76
+ const PASSTHROUGH_KEYS = [
77
+ 'layout',
78
+ 'columns',
79
+ 'title',
80
+ 'description',
81
+ 'defaultTab',
82
+ 'tabPosition',
83
+ 'allowSkip',
84
+ 'showStepIndicator',
85
+ 'splitDirection',
86
+ 'splitSize',
87
+ 'splitResizable',
88
+ 'drawerSide',
89
+ 'drawerWidth',
90
+ 'modalSize',
91
+ 'subforms',
92
+ ];
54
93
  /** Transforms a FormView spec into a Form SchemaNode */
55
94
  export const bridgeFormView = (spec, _context) => {
56
- const sections = (spec.sections ?? []).map(mapSection);
95
+ // Spec defines `groups` as a legacy alias of `sections`; normalize here so
96
+ // downstream renderers only ever see `sections` (ObjectForm never reads a
97
+ // `groups` key — before this normalization a groups-only spec silently
98
+ // rendered no sections at all, #2545).
99
+ const sections = (spec.sections ?? spec.groups ?? []).map(mapSection);
57
100
  const formType = mapFormType(spec.type);
58
101
  const node = {
59
102
  type: 'object-form',
@@ -64,8 +107,12 @@ export const bridgeFormView = (spec, _context) => {
64
107
  // P1.2 — formType mapping (tabbed, wizard, split, drawer, modal)
65
108
  if (formType)
66
109
  node.formType = formType;
67
- if (spec.groups)
68
- node.groups = spec.groups;
110
+ // #2545 — same-name spec keys (layout, title, tab/wizard/split/drawer/modal
111
+ // options, subforms) pass straight through onto the node.
112
+ for (const key of PASSTHROUGH_KEYS) {
113
+ if (spec[key] != null)
114
+ node[key] = spec[key];
115
+ }
69
116
  if (spec.defaultSort)
70
117
  node.defaultSort = spec.defaultSort;
71
118
  if (spec.submitBehavior)
@@ -15,6 +15,9 @@ interface PageComponent {
15
15
  style?: Record<string, any>;
16
16
  responsiveStyles?: Record<string, any>;
17
17
  className?: string;
18
+ /** Canonical conditional-visibility predicate (ADR-0089). */
19
+ visibleWhen?: string;
20
+ /** @deprecated ADR-0089 → `visibleWhen`. */
18
21
  visibility?: string;
19
22
  dataSource?: any;
20
23
  responsive?: any;
@@ -38,8 +38,13 @@ function mapComponent(component) {
38
38
  }
39
39
  if (component.events)
40
40
  node.events = component.events;
41
- if (component.visibility)
42
- node.visibility = component.visibility;
41
+ // ADR-0089: `visibleWhen` is the canonical conditional-visibility predicate;
42
+ // the spec folds the deprecated `visibility` alias into it at parse, so prefer
43
+ // it and fall back to `visibility` for raw / un-normalized metadata. SchemaRenderer
44
+ // reads `visibleWhen` first (show-when-truthy).
45
+ const visiblePredicate = component.visibleWhen ?? component.visibility;
46
+ if (visiblePredicate)
47
+ node.visibleWhen = visiblePredicate;
43
48
  if (component.dataSource)
44
49
  node.dataSource = component.dataSource;
45
50
  if (component.responsive)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/react",
3
- "version": "13.2.0",
3
+ "version": "14.1.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "React bindings and SchemaRenderer component for Object UI",
@@ -24,12 +24,12 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@objectstack/spec": "^14.3.0",
27
+ "@objectstack/spec": "^14.6.0",
28
28
  "react-hook-form": "^7.81.0",
29
- "@object-ui/core": "13.2.0",
30
- "@object-ui/data-objectstack": "13.2.0",
31
- "@object-ui/i18n": "13.2.0",
32
- "@object-ui/types": "13.2.0"
29
+ "@object-ui/core": "14.1.0",
30
+ "@object-ui/data-objectstack": "14.1.0",
31
+ "@object-ui/i18n": "14.1.0",
32
+ "@object-ui/types": "14.1.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^18.0.0 || ^19.0.0",
@@ -1,43 +0,0 @@
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 React from 'react';
9
- import { UseFormReturn } from 'react-hook-form';
10
- import type { FormField } from '@objectstack/spec/ui';
11
- /**
12
- * Extended form field with additional properties for complex widgets
13
- * These properties are not part of the standard FormField schema but may be
14
- * provided by specific implementations
15
- */
16
- export interface ExtendedFormField extends FormField {
17
- multiple?: boolean;
18
- accept?: string[];
19
- options?: Array<{
20
- label: string;
21
- value: string;
22
- disabled?: boolean;
23
- }>;
24
- }
25
- export interface FieldFactoryProps {
26
- /**
27
- * Field configuration from FormFieldSchema
28
- */
29
- field: FormField;
30
- /**
31
- * React Hook Form methods
32
- */
33
- methods: UseFormReturn<any>;
34
- /**
35
- * Whether the field is disabled
36
- */
37
- disabled?: boolean;
38
- }
39
- /**
40
- * FieldFactory component that renders different input types based on
41
- * the widget property or field type
42
- */
43
- export declare const FieldFactory: React.FC<FieldFactoryProps>;
@@ -1,164 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { resolveI18nLabel } from '../../utils/i18n';
3
- import { createSafeTranslation } from '@object-ui/i18n';
4
- const useSafeTranslation = createSafeTranslation({ 'common.selectOption': 'Select an option' }, 'common.selectOption');
5
- /**
6
- * FieldFactory component that renders different input types based on
7
- * the widget property or field type
8
- */
9
- export const FieldFactory = ({ field, methods, disabled = false, }) => {
10
- const { register, formState: { errors } } = methods;
11
- const { t } = useSafeTranslation();
12
- // Cast to extended field for properties not in base schema
13
- const extendedField = field;
14
- // Determine the widget type
15
- const widgetType = field.widget || 'text';
16
- const fieldName = field.field;
17
- const error = errors[fieldName];
18
- // Resolve I18nLabel values to plain strings
19
- const fieldLabel = resolveI18nLabel(field.label);
20
- const fieldPlaceholder = resolveI18nLabel(field.placeholder);
21
- const fieldHelpText = resolveI18nLabel(field.helpText);
22
- // Helper function to handle multiple select value conversion
23
- const handleMultipleSelectValue = (value) => {
24
- if (extendedField.multiple && value instanceof HTMLCollection) {
25
- return Array.from(value).map((opt) => opt.value);
26
- }
27
- return value;
28
- };
29
- // Handle conditional visibility
30
- // Fields are hidden when explicitly hidden or when visibleOn evaluates to false
31
- // Note: dependsOn is handled at the FormSectionRenderer level
32
- if (field.hidden) {
33
- return null;
34
- }
35
- // Common field wrapper
36
- const renderField = (input) => (_jsxs("div", { className: "space-y-2", children: [fieldLabel && (_jsxs("label", { htmlFor: fieldName, className: "block text-sm font-medium text-gray-700", children: [fieldLabel, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), input, fieldHelpText && (_jsx("p", { className: "text-sm text-gray-500", children: fieldHelpText })), error && (_jsx("p", { className: "text-sm text-red-600", children: error.message }))] }));
37
- // Render based on widget type
38
- switch (widgetType.toLowerCase()) {
39
- case 'text':
40
- case 'string':
41
- case 'email':
42
- case 'password':
43
- case 'url':
44
- case 'tel':
45
- return renderField(_jsx("input", { id: fieldName, type: widgetType === 'string' ? 'text' : widgetType, placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
46
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
47
- }) }));
48
- case 'number':
49
- case 'integer':
50
- case 'float':
51
- return renderField(_jsx("input", { id: fieldName, type: "number", placeholder: fieldPlaceholder, disabled: disabled, step: widgetType === 'integer' ? '1' : 'any', className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
52
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
53
- valueAsNumber: true,
54
- }) }));
55
- case 'checkbox':
56
- case 'boolean':
57
- return (_jsxs("div", { className: "flex items-start space-x-2", children: [_jsx("input", { id: fieldName, type: "checkbox", disabled: disabled, className: "mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500 disabled:opacity-50", ...register(fieldName) }), _jsxs("div", { className: "flex-1", children: [fieldLabel && (_jsxs("label", { htmlFor: fieldName, className: "text-sm font-medium text-gray-700", children: [fieldLabel, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] })), fieldHelpText && (_jsx("p", { className: "text-sm text-gray-500 mt-1", children: fieldHelpText })), error && (_jsx("p", { className: "text-sm text-red-600 mt-1", children: error.message }))] })] }));
58
- case 'textarea':
59
- return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder, disabled: disabled, rows: 4, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
60
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
61
- }) }));
62
- case 'select':
63
- case 'dropdown':
64
- return renderField(_jsxs("select", { id: fieldName, disabled: disabled, multiple: extendedField.multiple, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
65
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
66
- setValueAs: handleMultipleSelectValue,
67
- }), children: [!extendedField.multiple && _jsx("option", { value: "", children: fieldPlaceholder || t('common.selectOption') }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
68
- case 'date':
69
- return renderField(_jsx("input", { id: fieldName, type: "date", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
70
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
71
- }) }));
72
- case 'datetime':
73
- case 'datetime-local':
74
- return renderField(_jsx("input", { id: fieldName, type: "datetime-local", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
75
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
76
- }) }));
77
- case 'time':
78
- return renderField(_jsx("input", { id: fieldName, type: "time", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
79
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
80
- }) }));
81
- case 'currency':
82
- return renderField(_jsxs("div", { className: "relative", children: [_jsx("span", { className: "absolute left-3 top-2 text-gray-500", children: "$" }), _jsx("input", { id: fieldName, type: "number", placeholder: fieldPlaceholder, disabled: disabled, step: "0.01", className: "w-full pl-8 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
83
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
84
- valueAsNumber: true,
85
- }) })] }));
86
- case 'percent':
87
- return renderField(_jsxs("div", { className: "relative", children: [_jsx("input", { id: fieldName, type: "number", placeholder: fieldPlaceholder, disabled: disabled, step: "0.01", min: "0", max: "100", className: "w-full pr-8 pl-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
88
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
89
- valueAsNumber: true,
90
- }) }), _jsx("span", { className: "absolute right-3 top-2 text-gray-500", children: "%" })] }));
91
- case 'phone':
92
- return renderField(_jsx("input", { id: fieldName, type: "tel", placeholder: fieldPlaceholder || '+1 (555) 000-0000', disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
93
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
94
- }) }));
95
- case 'markdown':
96
- return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder || 'Enter markdown text...', disabled: disabled, rows: 8, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono", ...register(fieldName, {
97
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
98
- }) }));
99
- case 'html':
100
- return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder || 'Enter HTML...', disabled: disabled, rows: 8, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono", ...register(fieldName, {
101
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
102
- }) }));
103
- case 'file':
104
- return renderField(_jsx("input", { id: fieldName, type: "file", disabled: disabled, multiple: extendedField.multiple, accept: extendedField.accept?.join(','), className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100", ...register(fieldName, {
105
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
106
- }) }));
107
- case 'image':
108
- return renderField(_jsx("input", { id: fieldName, type: "file", disabled: disabled, multiple: extendedField.multiple, accept: extendedField.accept?.join(',') || 'image/*', className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100", ...register(fieldName, {
109
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
110
- }) }));
111
- case 'location':
112
- return renderField(_jsxs("div", { className: "space-y-2", children: [_jsx("input", { id: `${fieldName}-lat`, type: "number", placeholder: "Latitude", disabled: disabled, step: "any", className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(`${fieldName}.lat`, {
113
- required: field.required ? 'Latitude is required' : false,
114
- valueAsNumber: true,
115
- }) }), _jsx("input", { id: `${fieldName}-lng`, type: "number", placeholder: "Longitude", disabled: disabled, step: "any", className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(`${fieldName}.lng`, {
116
- required: field.required ? 'Longitude is required' : false,
117
- valueAsNumber: true,
118
- }) })] }));
119
- case 'lookup':
120
- case 'master_detail':
121
- return renderField(_jsxs("select", { id: fieldName, disabled: disabled, multiple: extendedField.multiple, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
122
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
123
- setValueAs: handleMultipleSelectValue,
124
- }), children: [!extendedField.multiple && _jsx("option", { value: "", children: fieldPlaceholder || t('common.selectOption') }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
125
- case 'user':
126
- case 'owner':
127
- return renderField(_jsxs("select", { id: fieldName, disabled: disabled, multiple: extendedField.multiple, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
128
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
129
- setValueAs: handleMultipleSelectValue,
130
- }), children: [!extendedField.multiple && _jsx("option", { value: "", children: fieldPlaceholder || 'Select user' }), extendedField.options?.map((option) => (_jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)))] }));
131
- case 'formula':
132
- case 'summary':
133
- case 'auto_number':
134
- // Read-only computed fields - display as disabled text input
135
- return renderField(_jsx("input", { id: fieldName, type: "text", disabled: true, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 cursor-not-allowed text-gray-600", ...register(fieldName) }));
136
- case 'object':
137
- return renderField(_jsx("textarea", { id: fieldName, placeholder: fieldPlaceholder || 'Enter JSON object...', disabled: disabled, rows: 6, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono text-sm", ...register(fieldName, {
138
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
139
- validate: (value) => {
140
- if (!value)
141
- return true;
142
- try {
143
- JSON.parse(value);
144
- return true;
145
- }
146
- catch (e) {
147
- return 'Invalid JSON format';
148
- }
149
- },
150
- }) }));
151
- case 'vector':
152
- // Vector fields are typically read-only or require specialized input
153
- return renderField(_jsx("input", { id: fieldName, type: "text", placeholder: fieldPlaceholder || 'Vector data (read-only)', disabled: true, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 cursor-not-allowed text-gray-600", ...register(fieldName) }));
154
- case 'grid':
155
- // Grid fields require complex table/grid editor - placeholder for now
156
- return renderField(_jsx("div", { className: "w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-600", children: _jsx("p", { className: "text-sm", children: "Grid editor not yet implemented" }) }));
157
- default:
158
- // Default to text input
159
- return renderField(_jsx("input", { id: fieldName, type: "text", placeholder: fieldPlaceholder, disabled: disabled, className: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed", ...register(fieldName, {
160
- required: field.required ? `${fieldLabel || fieldName} is required` : false,
161
- }) }));
162
- }
163
- };
164
- FieldFactory.displayName = 'FieldFactory';
@@ -1,40 +0,0 @@
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 React from 'react';
9
- import type { FormView } from '@objectstack/spec/ui';
10
- export interface FormRendererProps {
11
- /**
12
- * The FormView schema from @objectstack/spec
13
- */
14
- schema: FormView;
15
- /**
16
- * Initial form data
17
- */
18
- data?: Record<string, any>;
19
- /**
20
- * Form submission handler
21
- */
22
- onSubmit?: (data: Record<string, any>) => void | Promise<void>;
23
- /**
24
- * Form change handler
25
- */
26
- onChange?: (data: Record<string, any>) => void;
27
- /**
28
- * Custom class name for the form
29
- */
30
- className?: string;
31
- /**
32
- * Whether the form is disabled
33
- */
34
- disabled?: boolean;
35
- }
36
- /**
37
- * FormRenderer component that renders forms based on FormViewSchema
38
- * from @objectstack/spec
39
- */
40
- export declare const FormRenderer: React.FC<FormRendererProps>;
@@ -1,129 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * ObjectUI
4
- * Copyright (c) 2024-present ObjectStack Inc.
5
- *
6
- * This source code is licensed under the MIT license found in the
7
- * LICENSE file in the root directory of this source tree.
8
- */
9
- import React from 'react';
10
- import { useForm, FormProvider } from 'react-hook-form';
11
- import { FieldFactory } from './FieldFactory';
12
- import { ExpressionEvaluator } from '@object-ui/core';
13
- import { resolveI18nLabel } from '../../utils/i18n';
14
- /**
15
- * FormRenderer component that renders forms based on FormViewSchema
16
- * from @objectstack/spec
17
- */
18
- export const FormRenderer = ({ schema, data = {}, onSubmit, onChange, className = '', disabled = false, }) => {
19
- // Create form methods with react-hook-form
20
- const methods = useForm({
21
- defaultValues: data,
22
- mode: 'onChange',
23
- });
24
- const { handleSubmit, watch } = methods;
25
- // Watch for form changes
26
- React.useEffect(() => {
27
- if (onChange) {
28
- const subscription = watch((value) => {
29
- onChange(value);
30
- });
31
- return () => subscription.unsubscribe();
32
- }
33
- }, [watch, onChange]);
34
- // Handle form submission
35
- const onSubmitForm = handleSubmit(async (formData) => {
36
- if (onSubmit) {
37
- await onSubmit(formData);
38
- }
39
- });
40
- // Render sections or fields
41
- const renderContent = () => {
42
- if (!schema.sections || schema.sections.length === 0) {
43
- return null;
44
- }
45
- return (_jsx("div", { className: "space-y-6", children: schema.sections.map((section, index) => (_jsx(FormSectionRenderer, { section: section, methods: methods, disabled: disabled }, index))) }));
46
- };
47
- return (_jsx(FormProvider, { ...methods, children: _jsxs("form", { onSubmit: onSubmitForm, className: `space-y-6 ${className}`, children: [renderContent(), _jsx("div", { className: "flex justify-end gap-2", children: _jsx("button", { type: "submit", disabled: disabled, className: "px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed", children: "Submit" }) })] }) }));
48
- };
49
- // Grid column classes for different column counts
50
- const GRID_COLS = {
51
- 1: 'grid-cols-1',
52
- 2: 'grid-cols-1 md:grid-cols-2',
53
- 3: 'grid-cols-1 md:grid-cols-3',
54
- 4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
55
- };
56
- // Column span classes
57
- const COL_SPAN = {
58
- 1: 'col-span-1',
59
- 2: 'col-span-2',
60
- 3: 'col-span-3',
61
- 4: 'col-span-4',
62
- };
63
- /**
64
- * Evaluate whether a field should be visible based on visibleOn expression
65
- * and dependsOn parent field value.
66
- */
67
- function evaluateFieldVisibility(fieldConfig, formValues) {
68
- // If explicitly hidden, skip
69
- if (fieldConfig.hidden) {
70
- return false;
71
- }
72
- // Evaluate visibleOn expression
73
- if (fieldConfig.visibleOn) {
74
- try {
75
- const evaluator = new ExpressionEvaluator({ data: formValues, form: formValues });
76
- return evaluator.evaluateCondition(fieldConfig.visibleOn);
77
- }
78
- catch {
79
- // On error, default to visible
80
- return true;
81
- }
82
- }
83
- // Evaluate dependsOn: field is hidden if parent field has no value
84
- if (fieldConfig.dependsOn) {
85
- const parentValue = formValues[fieldConfig.dependsOn];
86
- return parentValue !== undefined && parentValue !== null && parentValue !== '';
87
- }
88
- return true;
89
- }
90
- /**
91
- * Renders a form section with grid layout
92
- */
93
- const FormSectionRenderer = ({ section, methods, disabled = false, }) => {
94
- const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
95
- // Determine grid columns based on section.columns
96
- const gridCols = GRID_COLS[section.columns || 1];
97
- // Collect whether there are any conditional fields
98
- const hasConditionalFields = React.useMemo(() => {
99
- return section.fields.some((field) => {
100
- if (typeof field === 'string')
101
- return false;
102
- return !!field.dependsOn || !!field.visibleOn;
103
- });
104
- }, [section.fields]);
105
- // Watch form values for conditional field evaluation
106
- // Only watch when there are fields with dependsOn or visibleOn to avoid
107
- // unnecessary re-renders in forms without conditional fields.
108
- const allFormValues = methods.watch();
109
- const formValues = hasConditionalFields ? allFormValues : undefined;
110
- const handleToggleCollapse = () => {
111
- if (section.collapsible) {
112
- setIsCollapsed(!isCollapsed);
113
- }
114
- };
115
- return (_jsxs("div", { className: "border rounded-lg p-4", children: [section.label && (_jsxs("div", { className: `flex items-center justify-between mb-4 ${section.collapsible ? 'cursor-pointer' : ''}`, onClick: handleToggleCollapse, children: [_jsx("h3", { className: "text-lg font-semibold", children: resolveI18nLabel(section.label) }), section.collapsible && (_jsx("span", { className: "text-sm text-gray-500", children: isCollapsed ? '▶' : '▼' }))] })), !isCollapsed && (_jsx("div", { className: `grid ${gridCols} gap-4`, children: section.fields.map((field, index) => {
116
- // Handle both string fields (legacy) and FormFieldSchema objects
117
- const fieldName = typeof field === 'string' ? field : field.field;
118
- const fieldConfig = typeof field === 'string' ? { field: fieldName } : field;
119
- // Evaluate visibility: static hidden, visibleOn expression, dependsOn parent
120
- if (!evaluateFieldVisibility(fieldConfig, formValues || {})) {
121
- return null;
122
- }
123
- // Calculate colSpan for grid
124
- const colSpan = fieldConfig.colSpan || 1;
125
- const colSpanClass = COL_SPAN[Math.min(colSpan, section.columns || 1)];
126
- return (_jsx("div", { className: colSpanClass, children: _jsx(FieldFactory, { field: fieldConfig, methods: methods, disabled: disabled || fieldConfig.readonly }) }, `${fieldName}-${index}`));
127
- }) }))] }));
128
- };
129
- FormRenderer.displayName = 'FormRenderer';
@@ -1,11 +0,0 @@
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 { FormRenderer } from './FormRenderer';
9
- export type { FormRendererProps } from './FormRenderer';
10
- export { FieldFactory } from './FieldFactory';
11
- export type { FieldFactoryProps, ExtendedFormField } from './FieldFactory';
@@ -1,9 +0,0 @@
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 { FormRenderer } from './FormRenderer';
9
- export { FieldFactory } from './FieldFactory';