@object-ui/react 14.0.0 → 15.0.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,141 @@
1
1
  # @object-ui/react
2
2
 
3
+ ## 15.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @object-ui/types@15.0.0
8
+ - @object-ui/core@15.0.0
9
+ - @object-ui/i18n@15.0.0
10
+ - @object-ui/data-objectstack@15.0.0
11
+
12
+ ## 14.1.0
13
+
14
+ ### Minor Changes
15
+
16
+ - 5523fc4: Dashboard-level filters — the three #2578 item-5 enhancements (framework#2501):
17
+
18
+ - **react**: nested `PageVariablesProvider`s now MERGE instead of shadowing
19
+ wholesale. A filtered dashboard embedded in a Page with its own `variables`
20
+ keeps the outer page variables readable inside widget subtrees (`page.*`);
21
+ an inner definition shadows only the SAME name; writes route to the scope
22
+ that defines the variable (writing an outer-defined name from inside the
23
+ nested subtree updates the outer provider); `resetVariables` stays local.
24
+ Names defined nowhere still write locally, exactly as before.
25
+ - **core**: `buildWidgetScopedFilter` accepts an optional `knownFields` set —
26
+ a DEFAULT binding whose target field is not on the widget's object is
27
+ skipped with a console warning instead of emitting a query the backend
28
+ empty-matches. Explicit `filterBindings` strings are always honoured (a
29
+ typo surfaces as a visibly empty widget, never a silently dropped filter).
30
+ Omitting `knownFields` preserves the previous unchecked behaviour.
31
+ - **plugin-dashboard**: `DashboardRenderer` feeds `knownFields` from
32
+ `dataSource.getObjectSchema` for inline `object` widgets (best-effort —
33
+ unchecked while metadata loads or when the source can't describe objects).
34
+ `optionsFrom` dynamic filter options now resolve DISTINCT values
35
+ server-side via a dataset GROUP BY (`queryDataset` with an inline draft)
36
+ when the data source supports it, falling back to the previous client-side
37
+ top-200 dedupe otherwise.
38
+
39
+ - dea65f7: Unify the list-view conditional tier onto the canonical CEL engine (#1584).
40
+
41
+ Conditional formatting (list / grid / kanban) and row-action `visible` /
42
+ `disabled` predicates are now evaluated by `@objectstack/formula`'s
43
+ `ExpressionEngine` — the same engine the server uses — instead of the legacy
44
+ JS-dialect `ExpressionEvaluator`, matching how `@objectstack/spec` already types
45
+ these surfaces (`ExpressionInputSchema` / CEL). The whole platform now speaks one
46
+ expression dialect (framework ADR-0058).
47
+
48
+ - `@object-ui/core`: new `evalRowPredicate` + `resolveConditionalFormatting`
49
+ helpers (next to `evalFieldPredicate`). One implementation of all three
50
+ formatting rule shapes; dialect routing (a `{ dialect: 'cel' }` envelope is
51
+ always CEL; a bare string is CEL unless it carries legacy-only syntax
52
+ (`${…}` / `===` / `?.` / `.includes()`), which routes to the old engine with a
53
+ one-time deprecation warning); the native `{ field, operator, value }` form is
54
+ translated to CEL.
55
+ - `@object-ui/react`: new `useRowPredicate` hook (canonical CEL, ambient
56
+ predicate scope merged).
57
+ - Consumers converged: `ListView.evaluateConditionalFormatting` (thin wrapper,
58
+ export kept), `ObjectGrid` row styling (inline copy removed), kanban card
59
+ styles, and the grid / data-table row-action menus. `plugin-view`'s kanban
60
+ branch now forwards top-level `conditionalFormatting` (previously dropped).
61
+ - Row-action `visible` fails **closed** (broken predicate → hidden + warn);
62
+ `disabled` fails soft. The CEL `in` operator (and list membership) now work in
63
+ row predicates — the legacy engine could not parse them.
64
+ - The legacy `FormField.condition: { field, equals/notEquals/in }` is retired to
65
+ a CEL translation (back-compat preserved); `FieldDesigner` migrated to
66
+ `visibleWhen`.
67
+
68
+ Fully back-compat: existing conditional-formatting rules, row-action predicates,
69
+ and form `condition` metadata keep working (translated / routed as needed).
70
+
71
+ - 5b52624: feat(detail): record-level inline edit — shared `InlineEditContext` + one atomic Save (objectui#2407 P1)
72
+
73
+ Lift the inline-edit session out of `DetailView`'s private state into a
74
+ record-level, shared context so a record page's surfaces can share ONE draft and
75
+ commit it in ONE atomic, cross-field-validated write (replacing the per-field
76
+ save loop).
77
+
78
+ - **`InlineEditContext` / `InlineEditProvider` / `useInlineEdit`** (@object-ui/react)
79
+ — pure UI state (`editing`, `canEdit`, `draft`, `autoFocusField`, `saving`,
80
+ `error` + `enter` / `setField` / `cancel` / `reset`). A _separate_ context from
81
+ `RecordContext` (mirrors `HighlightFieldsContext`) so per-keystroke draft churn
82
+ doesn't re-render other `record:*` consumers.
83
+ - **`<InlineEditSaveBar>`** (@object-ui/plugin-detail) — the record-level sticky
84
+ Save/Cancel bar. Commits the whole draft in ONE
85
+ `dataSource.update(obj, id, draft, { ifMatch: data.updated_at })` → `refresh()`;
86
+ a `409 CONCURRENT_UPDATE` reuses `<ConcurrentUpdateDialog>` (reload / overwrite).
87
+ A callback mode (`onFieldSave`) preserves the drawer's per-field persistence
88
+ contract with plugin-gantt/calendar/kanban.
89
+ - **`DetailView`** now consumes `useInlineEdit()` instead of owning inline-edit
90
+ state; its header/inline Save-Cancel bars and per-field batch-save are removed
91
+ (the approval-lock badge stays). Rendered without a provider it is simply
92
+ read-only.
93
+ - **`record:details`** and **`RecordDetailDrawer`** each wrap their `DetailView`
94
+ in an `<InlineEditProvider>` + `<InlineEditSaveBar>`. The object-lifecycle /
95
+ permission gate flows through `canEdit`; computed / readonly / system fields
96
+ and the OCC path are unchanged.
97
+
98
+ Guardrails preserved: computed (`formula`/`summary`/`rollup`/`auto_number`) +
99
+ `readonly` + system fields expose no editor; `canEdit` gate; OCC (`ifMatch` +
100
+ `ConcurrentUpdateDialog`); the atomic partial update carries only user-edited
101
+ keys (never computed/read-only). Editable highlights ride on top of this in P2.
102
+
103
+ - f94905d: remove(react): drop the unused `FormRenderer` / `FieldFactory` duplicate render path (#2545)
104
+
105
+ `FormRenderer` (and its captive `FieldFactory`) was an exported-but-dead second
106
+ form render path: zero runtime consumers anywhere in the repo — the only import
107
+ was its own test file. It duplicated `@object-ui/plugin-form`'s `ObjectForm`
108
+ (the path every app actually uses via the component registry) but had drifted
109
+ into a degraded variant: raw-HTML/Tailwind instead of the shared UI primitives,
110
+ a hard-coded Submit button, and no support for `submitBehavior` / `aria` /
111
+ `groups`.
112
+
113
+ **Breaking (ships as minor per the pre-1.0 launch-window convention):** the
114
+ public exports `FormRenderer`, `FormRendererProps`, `FieldFactory`,
115
+ `FieldFactoryProps`, and `ExtendedFormField` are removed from
116
+ `@object-ui/react`. Render forms through the `object-form` schema node
117
+ (`@object-ui/plugin-form` `ObjectForm`, reachable from a `FormViewSchema` via
118
+ `SchemaRenderer` / the spec bridge) instead. Closes Phase 4 of #2545.
119
+
120
+ ### Patch Changes
121
+
122
+ - Updated dependencies [82441e4]
123
+ - Updated dependencies [2efa9fd]
124
+ - Updated dependencies [0890fa7]
125
+ - Updated dependencies [2ded18c]
126
+ - Updated dependencies [e628d1f]
127
+ - Updated dependencies [5523fc4]
128
+ - Updated dependencies [887062c]
129
+ - Updated dependencies [23d65c3]
130
+ - Updated dependencies [9e2d58f]
131
+ - Updated dependencies [dea65f7]
132
+ - Updated dependencies [d5b1bc0]
133
+ - Updated dependencies [f0f10f5]
134
+ - @object-ui/i18n@14.1.0
135
+ - @object-ui/core@14.1.0
136
+ - @object-ui/types@14.1.0
137
+ - @object-ui/data-objectstack@14.1.0
138
+
3
139
  ## 14.0.0
4
140
 
5
141
  ### Minor Changes
@@ -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;
@@ -23,17 +29,45 @@ interface FormField {
23
29
  visibleOn?: string;
24
30
  }
25
31
  interface FormSection {
32
+ /** Stable section identifier for i18n lookup (spec FormSectionSchema.name). */
33
+ name?: string;
26
34
  label?: string;
35
+ description?: string;
27
36
  collapsible?: boolean;
28
37
  collapsed?: boolean;
29
38
  columns?: number;
39
+ /** Section-level conditional-visibility predicate (ADR-0089). */
40
+ visibleWhen?: string;
30
41
  fields?: FormField[];
31
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
+ */
32
49
  interface FormViewSpec {
33
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;
34
65
  data?: any;
35
66
  sections?: FormSection[];
36
- 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[];
37
71
  defaultSort?: any;
38
72
  sharing?: any;
39
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)
@@ -39,14 +45,20 @@ function mapSection(section) {
39
45
  const mapped = {
40
46
  fields: (section.fields ?? []).map(mapField),
41
47
  };
48
+ if (section.name)
49
+ mapped.name = section.name;
42
50
  if (section.label)
43
51
  mapped.label = section.label;
52
+ if (section.description)
53
+ mapped.description = section.description;
44
54
  if (section.collapsible != null)
45
55
  mapped.collapsible = section.collapsible;
46
56
  if (section.collapsed != null)
47
57
  mapped.collapsed = section.collapsed;
48
58
  if (section.columns != null)
49
59
  mapped.columns = section.columns;
60
+ if (section.visibleWhen)
61
+ mapped.visibleWhen = section.visibleWhen;
50
62
  return mapped;
51
63
  }
52
64
  /** Maps spec formType to ObjectUI formType */
@@ -56,9 +68,35 @@ function mapFormType(type) {
56
68
  const validTypes = ['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal'];
57
69
  return validTypes.includes(type) ? type : undefined;
58
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
+ ];
59
93
  /** Transforms a FormView spec into a Form SchemaNode */
60
94
  export const bridgeFormView = (spec, _context) => {
61
- 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);
62
100
  const formType = mapFormType(spec.type);
63
101
  const node = {
64
102
  type: 'object-form',
@@ -69,8 +107,12 @@ export const bridgeFormView = (spec, _context) => {
69
107
  // P1.2 — formType mapping (tabbed, wizard, split, drawer, modal)
70
108
  if (formType)
71
109
  node.formType = formType;
72
- if (spec.groups)
73
- 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
+ }
74
116
  if (spec.defaultSort)
75
117
  node.defaultSort = spec.defaultSort;
76
118
  if (spec.submitBehavior)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/react",
3
- "version": "14.0.0",
3
+ "version": "15.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "React bindings and SchemaRenderer component for Object UI",
@@ -26,10 +26,10 @@
26
26
  "dependencies": {
27
27
  "@objectstack/spec": "^14.6.0",
28
28
  "react-hook-form": "^7.81.0",
29
- "@object-ui/core": "14.0.0",
30
- "@object-ui/data-objectstack": "14.0.0",
31
- "@object-ui/i18n": "14.0.0",
32
- "@object-ui/types": "14.0.0"
29
+ "@object-ui/core": "15.0.0",
30
+ "@object-ui/data-objectstack": "15.0.0",
31
+ "@object-ui/i18n": "15.0.0",
32
+ "@object-ui/types": "15.0.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,132 +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 the conditional-visibility predicate. `visibleWhen` is canonical
73
- // (ADR-0089); `visibleOn` is the deprecated alias, kept as a fallback for raw
74
- // / un-normalized metadata.
75
- const visiblePredicate = fieldConfig.visibleWhen ?? fieldConfig.visibleOn;
76
- if (visiblePredicate) {
77
- try {
78
- const evaluator = new ExpressionEvaluator({ data: formValues, form: formValues });
79
- return evaluator.evaluateCondition(visiblePredicate);
80
- }
81
- catch {
82
- // On error, default to visible
83
- return true;
84
- }
85
- }
86
- // Evaluate dependsOn: field is hidden if parent field has no value
87
- if (fieldConfig.dependsOn) {
88
- const parentValue = formValues[fieldConfig.dependsOn];
89
- return parentValue !== undefined && parentValue !== null && parentValue !== '';
90
- }
91
- return true;
92
- }
93
- /**
94
- * Renders a form section with grid layout
95
- */
96
- const FormSectionRenderer = ({ section, methods, disabled = false, }) => {
97
- const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
98
- // Determine grid columns based on section.columns
99
- const gridCols = GRID_COLS[section.columns || 1];
100
- // Collect whether there are any conditional fields
101
- const hasConditionalFields = React.useMemo(() => {
102
- return section.fields.some((field) => {
103
- if (typeof field === 'string')
104
- return false;
105
- return !!field.dependsOn || !!field.visibleWhen || !!field.visibleOn;
106
- });
107
- }, [section.fields]);
108
- // Watch form values for conditional field evaluation
109
- // Only watch when there are fields with dependsOn or visibleOn to avoid
110
- // unnecessary re-renders in forms without conditional fields.
111
- const allFormValues = methods.watch();
112
- const formValues = hasConditionalFields ? allFormValues : undefined;
113
- const handleToggleCollapse = () => {
114
- if (section.collapsible) {
115
- setIsCollapsed(!isCollapsed);
116
- }
117
- };
118
- 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) => {
119
- // Handle both string fields (legacy) and FormFieldSchema objects
120
- const fieldName = typeof field === 'string' ? field : field.field;
121
- const fieldConfig = typeof field === 'string' ? { field: fieldName } : field;
122
- // Evaluate visibility: static hidden, visibleOn expression, dependsOn parent
123
- if (!evaluateFieldVisibility(fieldConfig, formValues || {})) {
124
- return null;
125
- }
126
- // Calculate colSpan for grid
127
- const colSpan = fieldConfig.colSpan || 1;
128
- const colSpanClass = COL_SPAN[Math.min(colSpan, section.columns || 1)];
129
- return (_jsx("div", { className: colSpanClass, children: _jsx(FieldFactory, { field: fieldConfig, methods: methods, disabled: disabled || fieldConfig.readonly }) }, `${fieldName}-${index}`));
130
- }) }))] }));
131
- };
132
- 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';