@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 +136 -0
- package/dist/context/InlineEditContext.d.ts +74 -0
- package/dist/context/InlineEditContext.js +79 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +1 -0
- package/dist/hooks/useExpression.d.ts +25 -8
- package/dist/hooks/useExpression.js +54 -3
- package/dist/hooks/usePageVariables.d.ts +10 -0
- package/dist/hooks/usePageVariables.js +32 -6
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/spec-bridge/bridges/form-view.d.ts +35 -1
- package/dist/spec-bridge/bridges/form-view.js +45 -3
- package/package.json +5 -5
- package/dist/components/form/FieldFactory.d.ts +0 -43
- package/dist/components/form/FieldFactory.js +0 -164
- package/dist/components/form/FormRenderer.d.ts +0 -40
- package/dist/components/form/FormRenderer.js +0 -132
- package/dist/components/form/index.d.ts +0 -11
- package/dist/components/form/index.js +0 -9
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
|
+
}
|
package/dist/context/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/context/index.js
CHANGED
|
@@ -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
|
-
*
|
|
56
|
-
*
|
|
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
|
-
* @
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
const
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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": "
|
|
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": "
|
|
30
|
-
"@object-ui/data-objectstack": "
|
|
31
|
-
"@object-ui/i18n": "
|
|
32
|
-
"@object-ui/types": "
|
|
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';
|