@object-ui/react 17.1.0 → 17.3.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,204 @@
1
1
  # @object-ui/react
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - d915c47: Relation fields (`lookup` / `master_detail` / `user` / `tree`) are now usable in action and conditional-formatting predicates: they bind as the stored foreign key on every surface, and the fields a predicate reads are included in the query projection (#3501).
8
+
9
+ Before this, one predicate over one relation field had four different fates, decided by things its author does not control. `$expand` **replaces** the id in place with the whole related record, and a view expands exactly the relations it shows as COLUMNS — so `record.owner == "U1"` was **true** where the column was absent, **false** where it was displayed, and a **fault** where the field was neither displayed nor projected (a list's `$select` was built from its columns alone, and CEL treats an absent key as a fault, not as null). A fault is fail-CLOSED on the row kebab and the selection bar and fail-OPEN on the lenient paths, so the same authoring mistake hid the button from everyone on one surface and showed it to everyone on the next, with nothing on screen to point at either. The server, meanwhile, only ever sees the id — so client and server could not agree, which is the one thing ADR-0036 / ADR-0058 exist to guarantee.
10
+
11
+ Two changes close it. `toPredicateRecord` (new, `@object-ui/core`) collapses expanded relation values back to their ids when a record is bound for evaluation — driven by the object's own field types, not by sniffing for an `id` key, so a `json` field that happens to carry one is untouched. It is threaded through `evalRowPredicate` / `resolveConditionalFormatting` (via a new `fields` option), `useRowPredicate`, `partitionBulkRows`, and both `page:header` evaluators, with the object schema supplied by `ObjectGrid` / `ListView` / `ObjectKanban` / the record context. Kanban card formatting is threaded the same way, so a rule cannot match on the grid view of a list and silently never match on its board. Display is unaffected — a detail-page title still renders the related record's name, and the schema-only `kanban-ui` entry point (which has no object schema to offer) keeps using the payload verbatim. `collectPredicateFieldRefs` / `listViewPredicates` (new) harvest the `record.x` / `data.x` references out of a view's conditional formatting, row-action defs, bulk-action defs, promoted object actions and `userActions` overrides, and add them to `$select` — intersected with the object's declared fields plus the platform columns every object carries (`isProjectableField`), because an unknown key is not ignored by every backend. No `$expand` is added: a predicate wants the foreign key, which is what an unexpanded relation already is.
12
+
13
+ - 175bd79: `toPredicateInput` is now re-exported from `@object-ui/core` instead of being reimplemented in `@object-ui/react`. Behaviour is byte-for-byte identical — the renderer-side copy in `packages/react/src/hooks/useExpression.ts` had item-for-item the same semantics as the canonical `packages/core/src/evaluator/predicateInput.ts` (booleans short-circuit, bare strings and non-`cel` dialects become `${…}`, a `{ dialect: 'cel', source }` envelope survives so `useCondition` routes it to the canonical `@objectstack/formula` engine, empty/absent predicates become `undefined`), and every existing import path (`import { toPredicateInput } from '@object-ui/react'`) keeps working with an unchanged signature. What changes is that there is now ONE implementation rather than two held in step by a parity table: #3314 is the record of what two normalizations do when left alone — they drift, and the same `visible:` predicate reaches different verdicts depending on whether the action was surfaced by `ActionEngine.getActionsForLocation` or rendered standalone. The 14-shape normalization parity table degenerated once both of its columns called the same function, so it is replaced by a single identity assertion (`react`'s export IS `core`'s function object); the engine-path-vs-renderer-path verdict parity suite is untouched and still proves the two call paths agree (#3367).
14
+ - Updated dependencies [18cd432]
15
+ - Updated dependencies [d915c47]
16
+ - Updated dependencies [b71fc92]
17
+ - Updated dependencies [65516ba]
18
+ - Updated dependencies [94c5b7c]
19
+ - Updated dependencies [ca0fa8f]
20
+ - Updated dependencies [3889ffb]
21
+ - Updated dependencies [5781fb1]
22
+ - Updated dependencies [7e2406a]
23
+ - Updated dependencies [9e9e9a9]
24
+ - Updated dependencies [4eeb932]
25
+ - Updated dependencies [5c856ec]
26
+ - Updated dependencies [23018cc]
27
+ - Updated dependencies [68b6a28]
28
+ - Updated dependencies [0554e88]
29
+ - Updated dependencies [d915c47]
30
+ - Updated dependencies [f44d872]
31
+ - Updated dependencies [28b2e65]
32
+ - Updated dependencies [509104a]
33
+ - Updated dependencies [825bbe3]
34
+ - Updated dependencies [6195841]
35
+ - Updated dependencies [5dd0127]
36
+ - Updated dependencies [a415684]
37
+ - Updated dependencies [a4cff5b]
38
+ - Updated dependencies [5af2852]
39
+ - Updated dependencies [f833d3a]
40
+ - Updated dependencies [a6ec93d]
41
+ - Updated dependencies [2a9513d]
42
+ - Updated dependencies [d22ae31]
43
+ - Updated dependencies [c7ed4c3]
44
+ - Updated dependencies [2409e1d]
45
+ - Updated dependencies [789fe3e]
46
+ - @object-ui/core@17.3.0
47
+ - @object-ui/types@17.3.0
48
+ - @object-ui/i18n@17.3.0
49
+ - @object-ui/data-objectstack@17.3.0
50
+
51
+ ## 17.2.0
52
+
53
+ ### Minor Changes
54
+
55
+ - a889e31: A record's approval band now shows the quorum / per-group tally the server already computes.
56
+
57
+ The showcase's `showcase_committee_quorum` node declares `behavior: 'quorum'` with
58
+ `minApprovals: 2` over three approvers, and even ships a pre-rendered
59
+ `"Committee Sign-off (2 of 3)"` label; `showcase_expense_signoff` declares
60
+ `per_group` (会签) with named manager / finance groups. On the business record
61
+ the approval band rendered none of it — the lock badge, the recall button and
62
+ the approve/reject actions were all correct, but a two-of-three committee step
63
+ looked exactly like a one-approver step. An approver could not see whether their
64
+ own click finalized the node or was one of three, which is the single fact a
65
+ quorum node exists to express (objectstack#4478).
66
+
67
+ Nothing was wrong on the wire, and nothing here papers over the server. The
68
+ framework computes `decision_progress` — `{ behavior, got, need, groups? }`,
69
+ derived from the node's own `node_config_json` snapshot, so the count a client
70
+ shows is the count the engine will enforce. **It attaches that block in
71
+ `getRequest` only**: `listRequests` deliberately skips it, because the
72
+ `sys_approval_action` tally it costs is per row and a list read may return
73
+ hundreds. The record header's `useRecordApprovals` reads
74
+ `GET /approvals/requests?object=…&recordId=…` — the list route — so the
75
+ enrichment was never in the payload it had. The hook now follows up with one
76
+ single read for the ONE pending row and folds the result onto it; a failed or
77
+ mismatched follow-up leaves the row exactly as the list sent it, so a display-only
78
+ enrichment can never take the approval panel down and no tally is ever invented.
79
+
80
+ `InlineEditProvider` carries the block through as `approvalProgress`, and the
81
+ DetailView approval band renders it beside the existing badge: a labelled
82
+ `role="progressbar"` with one tick per required approval for `quorum` /
83
+ `unanimous`, and for `per_group` a chip per group marking which have signed
84
+ (`finance 1/1` ✓, `manager 0/1`). Group names come from the flow author's own
85
+ config, so they need no locale strings; the three new label keys are added to all
86
+ ten packs. `first_response` nodes carry no `decision_progress` and are unchanged —
87
+ one decision is the whole step there, and a "1 of 1" bar would be noise.
88
+
89
+ Scored `minor` rather than `patch`: this is new observable rendering plus a new
90
+ public `approvalProgress` prop / `ApprovalProgress` type on `@object-ui/react`,
91
+ not a behavior correction inside an existing surface.
92
+
93
+ - 09d30a4: Stop declaring 18 `@object-ui/auth` / `@object-ui/components` / `@object-ui/react`
94
+ symbols under names `@objectstack/spec` owns (objectui#3159, objectstack#4115
95
+ batch 5).
96
+
97
+ **Breaking for importers of all three packages** — six exported names changed,
98
+ because the spec exports the same name for a _different_ thing:
99
+
100
+ | package | was | now | what the spec's same-named export actually is |
101
+ | :----------- | :--------------------------- | :----------------------- | :----------------------------------------------------------------------------- |
102
+ | `auth` | `AuthSession` | `AuthClientSession` | the SERVER's session record (`{ id, userId, expiresAt: ISO string, token? }`) |
103
+ | `auth` | `AuthProviderConfig` | `AuthProviderOptions` | an OAuth/OIDC provider registration (`{ id, clientId, clientSecret, scope? }`) |
104
+ | `components` | `FilterCondition` | `FilterBuilderCondition` | the recursive ObjectQL predicate AST (`$and`/`$or`/`$not`) |
105
+ | `components` | `Field` | `FieldContainer` | an object FIELD's metadata and its builder namespace |
106
+ | `react` | `ConflictResolutionStrategy` | `ConflictResolution` | the metadata-MERGE policy (`error \| priority \| first-wins \| last-wins`) |
107
+
108
+ The `react` rename is the odd one out: the new name is the **spec's own** name
109
+ for the union that hook always used, so it is a re-export rather than a dialect.
110
+
111
+ Eleven more keep their names and are now **imported or derived from the spec**
112
+ instead of re-declared: `TenancyPosture`, `DelegableScope` (+`DelegableAdminScope`),
113
+ `AuthUser`, `ShareLinkPermission`, `ShareLinkAudience`, `ShareLink`, `SortItem`,
114
+ `OfflineStrategy`, `OfflineCacheConfig`, `OfflineSyncConfig`, `OfflineConfig`,
115
+ `NavigationConfig`.
116
+
117
+ **Three of the copies were losing information, not just duplicating it.**
118
+
119
+ - `AuthUser` never declared the spec's `positions` or `tenantId` — the
120
+ authorization inputs. Its `[key: string]: unknown` index signature meant the
121
+ omission was invisible at every call site _and_ to any structural comparison
122
+ (the objectstack#4075 mechanism). It now `extends` the spec principal, so the
123
+ display-only fields (`image`, `role`, `roles`, `emailVerified`) are the delta
124
+ and the spec's keys arrive on their own.
125
+ - `useNavigationOverlay`'s copy carried the note _"inline … to avoid importing
126
+ from `@object-ui/types` (which may not be a direct dependency of
127
+ `@object-ui/react`)"_. The vocabulary belongs to `@objectstack/spec`, which
128
+ **is** a direct dependency — the same expired "kept local to avoid a
129
+ dependency" comment objectui#3169 found in `@object-ui/app-shell`.
130
+ - `useOffline` and `usePerformance` both opened with _"Types aligned with
131
+ `@objectstack/spec` v2.0.7"_. The installed spec is 17.0.0-rc.1.
132
+
133
+ `ShareLink` derives from the spec row **minus `password_hash`** — omitted rather
134
+ than optional, because it is the credential itself and typing it in a browser
135
+ package is an invitation to render it. `password_protected` (the boolean the UI
136
+ needs in its place) is the one local addition.
137
+
138
+ The config types derive from each schema's **input** side, not `z.infer`.
139
+ `useOffline(config: OfflineConfig = {})` defaults to the empty object, which the
140
+ output type — every `.default()`ed key required — would reject outright.
141
+
142
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
143
+ `@object-ui/components`: its public type surface now references the spec.
144
+
145
+ Scored `minor`, not `major`, per this repo's fixed-group rule — objectui's major
146
+ tracks `@objectstack`, so breaking changes of our own ship as minor with the
147
+ semantics spelled out above (see AGENTS.md §版本号策略). A `major` here would carry
148
+ all 39 packages of the fixed group to `18.0.0` and off objectstack's 17.x line.
149
+
150
+ ### Patch Changes
151
+
152
+ - ea96284: `useMetadataItem` no longer spins forever outside a `<MetadataProvider>` — the "graceful fallback" was the thing that made those consumers impossible to mount.
153
+
154
+ `useMetadata()` built its no-provider fallback **inline on every call**, so outside a provider
155
+ every render produced a new `getItem`. `useMetadataItem` lists `getItem` in its effect deps and,
156
+ on the no-name path, called `setState({ item: null, loading: false, error: null })` with a fresh
157
+ object each run. New identity → effect re-runs → new state object → re-render → new identity:
158
+ an unbreakable loop, synchronous enough to hang inside `render()` rather than fail.
159
+
160
+ So the fallback documented as the graceful path for consumers mounted outside a provider —
161
+ "common in unit tests that only need to assert on rendering" — was precisely what made them
162
+ unmountable. `record:alert` and `record:quick_actions` both call `useMetadataItem`
163
+ unconditionally; each pinned a core and grew unbounded (8.6 GB before the first kill) on a
164
+ `render()` that never returned.
165
+
166
+ Two changes, at the cause and one layer in:
167
+
168
+ - The fallback is a frozen module-level singleton, so its identity is stable across renders.
169
+ - The clear-state path bails out when the state is already cleared, instead of installing an
170
+ equal-but-new object. That covers the same loop arriving by another route — any caller whose
171
+ context value is rebuilt per render, which this interface explicitly invites ("hand-rolled
172
+ context values in tests keep working").
173
+
174
+ Found by `apps/console/src/__tests__/record-block-record-reach.test.tsx` (objectui#3149), which
175
+ could not mount either block until this was fixed.
176
+
177
+ - Updated dependencies [4ae0ac4]
178
+ - Updated dependencies [696e3c1]
179
+ - Updated dependencies [bca45cc]
180
+ - Updated dependencies [a889e31]
181
+ - Updated dependencies [4bf612c]
182
+ - Updated dependencies [335041c]
183
+ - Updated dependencies [b414983]
184
+ - Updated dependencies [256f8cc]
185
+ - Updated dependencies [c5ccbd5]
186
+ - Updated dependencies [d9668a7]
187
+ - Updated dependencies [4b470b9]
188
+ - Updated dependencies [cb82705]
189
+ - Updated dependencies [f572849]
190
+ - Updated dependencies [d3584c6]
191
+ - Updated dependencies [a8ad6c0]
192
+ - Updated dependencies [444457c]
193
+ - Updated dependencies [850033c]
194
+ - Updated dependencies [022e4c3]
195
+ - Updated dependencies [009e25d]
196
+ - Updated dependencies [726b89c]
197
+ - @object-ui/types@17.2.0
198
+ - @object-ui/core@17.2.0
199
+ - @object-ui/i18n@17.2.0
200
+ - @object-ui/data-objectstack@17.2.0
201
+
3
202
  ## 17.1.0
4
203
 
5
204
  ### Minor Changes
@@ -7,30 +7,50 @@ export function useAdapter() {
7
7
  return useContext(AdapterCtx);
8
8
  }
9
9
  export const MetadataCtx = createContext(null);
10
+ /** Shared frozen empty list for the fallback below — see {@link NO_METADATA_PROVIDER}. */
11
+ const NO_ITEMS = Object.freeze([]);
12
+ /**
13
+ * The no-provider fallback, as a MODULE-LEVEL SINGLETON.
14
+ *
15
+ * Identity matters here, not just shape. `useMetadata()` used to build this
16
+ * object inline on every call, so outside a `<MetadataProvider>` every render
17
+ * produced a new `getItem` — and `useMetadataItem` lists `getItem` in its
18
+ * effect deps and calls `setState({...})` with a fresh object on every run. New
19
+ * identity → effect re-runs → new state object → re-render → new identity: an
20
+ * unbreakable render loop, synchronous enough to hang inside `render()`.
21
+ *
22
+ * So the fallback documented as the graceful path for consumers mounted outside
23
+ * a provider was the one thing that made those consumers impossible to mount —
24
+ * `record:alert` and `record:quick_actions` both call `useMetadataItem`
25
+ * unconditionally, and both spin. Found by
26
+ * `apps/console/src/__tests__/record-block-record-reach.test.tsx`
27
+ * (objectui#3149), which could not mount them at all until this was frozen.
28
+ *
29
+ * Frozen so a consumer cannot mutate the shared fallback out from under every
30
+ * other consumer of it.
31
+ */
32
+ const NO_METADATA_PROVIDER = Object.freeze({
33
+ apps: NO_ITEMS,
34
+ objects: NO_ITEMS,
35
+ dashboards: NO_ITEMS,
36
+ reports: NO_ITEMS,
37
+ pages: NO_ITEMS,
38
+ loading: false,
39
+ error: null,
40
+ refresh: async () => { },
41
+ invalidate: () => { },
42
+ ensureType: async () => [],
43
+ getItem: async () => null,
44
+ getItemsByType: () => [],
45
+ getTypeStatus: () => 'ready',
46
+ });
10
47
  export function useMetadata() {
11
48
  const ctx = useContext(MetadataCtx);
12
- if (!ctx) {
13
- // Graceful fallback: when a consumer is rendered outside a MetadataProvider
14
- // (common in unit tests that only need to assert on rendering), return an
15
- // empty no-op implementation rather than crash. Production code paths
16
- // should always wrap in <MetadataProvider>.
17
- return {
18
- apps: [],
19
- objects: [],
20
- dashboards: [],
21
- reports: [],
22
- pages: [],
23
- loading: false,
24
- error: null,
25
- refresh: async () => { },
26
- invalidate: () => { },
27
- ensureType: async () => [],
28
- getItem: async () => null,
29
- getItemsByType: () => [],
30
- getTypeStatus: () => 'ready',
31
- };
32
- }
33
- return ctx;
49
+ // Graceful fallback: a consumer rendered outside a MetadataProvider (unit
50
+ // tests that only assert on rendering, standalone previews) gets an empty
51
+ // no-op implementation rather than a crash. Production code paths should
52
+ // always wrap in <MetadataProvider>.
53
+ return ctx ?? NO_METADATA_PROVIDER;
34
54
  }
35
55
  export function useMetadataItem(type, name) {
36
56
  const { getItem } = useMetadata();
@@ -41,7 +61,15 @@ export function useMetadataItem(type, name) {
41
61
  });
42
62
  useEffect(() => {
43
63
  if (!name) {
44
- setState({ item: null, loading: false, error: null });
64
+ // Bail out when already cleared instead of installing a fresh object.
65
+ // Belt-and-braces against the loop the frozen fallback above fixes at the
66
+ // source: `getItem` is an effect dep, so ANY caller whose context value
67
+ // is rebuilt per render — a hand-rolled one in a test, which this
68
+ // interface explicitly invites — would otherwise re-run this effect,
69
+ // re-set an equal-but-new state object, and re-render forever.
70
+ setState((s) => (s.item === null && !s.loading && s.error === null
71
+ ? s
72
+ : { item: null, loading: false, error: null }));
45
73
  return;
46
74
  }
47
75
  let cancelled = false;
@@ -22,6 +22,34 @@
22
22
  * context for the draft/editing flags and drives `saving`/`error`/`reset`.
23
23
  */
24
24
  import React from 'react';
25
+ /**
26
+ * Server-computed decision aggregation of the record's pending approval node
27
+ * (framework#3266, `decision_progress` on `GET /approvals/requests/:id`).
28
+ *
29
+ * A multi-approver node does NOT finalize on the first decision: `unanimous`
30
+ * needs everyone, `quorum` needs `minApprovals` of the slate, `per_group` (会签)
31
+ * needs each named group to sign. Without the tally, an approver looking at the
32
+ * record cannot tell whether their own click completes the step or is one of
33
+ * three — the fact the whole node is about (objectstack#4478). The server does
34
+ * the counting so every client renders the same "2 of 3" the engine enforces.
35
+ *
36
+ * Absent for `first_response` nodes (one decision finalizes, so there is no
37
+ * progress to show) and for a backend that predates the enrichment.
38
+ */
39
+ export interface ApprovalProgress {
40
+ behavior: 'unanimous' | 'quorum' | 'per_group';
41
+ /** Approvals recorded so far — satisfied GROUPS when `behavior` is `per_group`. */
42
+ got: number;
43
+ /** Approvals required to finalize — total GROUPS when `behavior` is `per_group`. */
44
+ need: number;
45
+ /** Per-group tally, `per_group` only. */
46
+ groups?: Array<{
47
+ group: string;
48
+ got: number;
49
+ need: number;
50
+ satisfied: boolean;
51
+ }>;
52
+ }
25
53
  export interface InlineEditContextValue {
26
54
  /** True while the record is in inline-edit mode. */
27
55
  editing: boolean;
@@ -58,6 +86,15 @@ export interface InlineEditContextValue {
58
86
  * still get a coherent band. Defaults to `false`.
59
87
  */
60
88
  approvalPending: boolean;
89
+ /**
90
+ * Quorum / per-group tally of the pending approval node, when it aggregates
91
+ * more than one decision (objectstack#4478). Threaded verbatim from the
92
+ * host's approvals read so the band renders the server's count instead of
93
+ * re-deriving the engine's tally rules. Undefined when no approval is
94
+ * running, when the node finalizes on the first response, or when the host
95
+ * doesn't resolve approvals at all.
96
+ */
97
+ approvalProgress?: ApprovalProgress;
61
98
  /**
62
99
  * Human-readable reason for the approval lock, surfaced as the band's
63
100
  * tooltip. Optional — consumers fall back to their own localized default
@@ -109,6 +146,12 @@ export interface InlineEditProviderProps {
109
146
  * only know about the lock rendering exactly as before.
110
147
  */
111
148
  approvalPending?: boolean;
149
+ /**
150
+ * The pending node's server-computed decision tally (objectstack#4478).
151
+ * Surfaced verbatim so the band can show "2 of 3" / per-group ticks. Omitted
152
+ * for `first_response` nodes and for hosts that don't read approvals.
153
+ */
154
+ approvalProgress?: ApprovalProgress;
112
155
  /** Optional human-readable lock reason, surfaced as the band tooltip. */
113
156
  lockedReason?: string;
114
157
  children: React.ReactNode;
@@ -24,7 +24,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
24
24
  */
25
25
  import React from 'react';
26
26
  const InlineEditContext = React.createContext(null);
27
- export const InlineEditProvider = ({ canEdit = true, locked = false, approvalPending, lockedReason, children, }) => {
27
+ export const InlineEditProvider = ({ canEdit = true, locked = false, approvalPending, approvalProgress, lockedReason, children, }) => {
28
28
  // A locked record always has an approval running; the converse is what
29
29
  // #2902 added. Defaulting this way means an un-migrated host keeps its
30
30
  // previous behavior with no code change.
@@ -62,6 +62,7 @@ export const InlineEditProvider = ({ canEdit = true, locked = false, approvalPen
62
62
  canEdit,
63
63
  locked,
64
64
  approvalPending: pending,
65
+ approvalProgress,
65
66
  lockedReason,
66
67
  draft,
67
68
  autoFocusField,
@@ -73,7 +74,7 @@ export const InlineEditProvider = ({ canEdit = true, locked = false, approvalPen
73
74
  reset: teardown,
74
75
  setSaving,
75
76
  setError,
76
- }), [editing, canEdit, locked, pending, lockedReason, draft, autoFocusField, saving, error, enter, setField, teardown]);
77
+ }), [editing, canEdit, locked, pending, approvalProgress, lockedReason, draft, autoFocusField, saving, error, enter, setField, teardown]);
77
78
  return _jsx(InlineEditContext.Provider, { value: value, children: children });
78
79
  };
79
80
  /**
@@ -26,24 +26,36 @@ export declare function PredicateScopeProvider({ scope, children, }: {
26
26
  export declare function usePredicateScope(): Record<string, any>;
27
27
  /**
28
28
  * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
29
- * `hidden`) into the `${expr}` template form expected by `useCondition`.
29
+ * `hidden`) into the form `useCondition` expects — the canonical helper to use
30
+ * in renderers so we never end up with `${[object Object]}` after JS
31
+ * template-literal interpolation.
30
32
  *
31
- * Accepts:
32
- * - `boolean` → returned as-is (predicate hooks short-circuit on booleans).
33
- * - `string` → wrapped as `${string}` (legacy DX shorthand).
34
- * - `Expression` envelope `{ dialect, source }` (new format from
35
- * `@objectstack/spec`'s normalized predicate inputs) → unwrapped, then
36
- * wrapped as `${source}`. Both `cel` and `template` dialects already use
37
- * compatible variable syntax (`record.x`, etc.).
38
- * - `null` / `undefined` / empty → `undefined` (default visible/enabled).
33
+ * **Re-exported from `@object-ui/core`, not reimplemented here.** There is
34
+ * exactly ONE implementation — `packages/core/src/evaluator/predicateInput.ts`
35
+ * — shared by the engine path (`ActionEngine.getActionsForLocation`) and the
36
+ * renderer path (`action-button` / `action-menu` / `action-bar` / …). See that
37
+ * file for the accepted input shapes and for why a `cel` envelope must survive
38
+ * normalization (#2661 / #3314).
39
39
  *
40
- * This is the canonical helper to use in renderers so we never end up with
41
- * `${[object Object]}` after JS template-literal interpolation.
40
+ * ## Why a re-export and not a twin (#3367)
41
+ *
42
+ * This module used to carry an independent renderer-side implementation with
43
+ * item-for-item identical semantics, held in step with the canonical one by a
44
+ * 14-shape normalization parity table. That table was a *guardrail against
45
+ * drift*, not a single source of truth — and #3314 is the record of what two
46
+ * normalizations do when left alone: they drift, and the same `visible:`
47
+ * predicate reaches different verdicts depending on which path surfaced the
48
+ * action. A re-export cannot drift, so the parity suite now pins identity
49
+ * (`react`'s export IS `core`'s function) instead of enumerating shapes, and
50
+ * keeps pinning the two PATHS verdict-for-verdict.
51
+ *
52
+ * Routing hook code through the `@object-ui/core` barrel is not a new coupling:
53
+ * this module already imports `ExpressionEvaluator` / `evalRowPredicate` from
54
+ * it, and `@object-ui/core` is a declared dependency of `@object-ui/react`
55
+ * (the reverse direction is the forbidden one — core declares "Zero React
56
+ * dependencies").
42
57
  */
43
- export declare function toPredicateInput(value: unknown): string | boolean | {
44
- dialect: 'cel';
45
- source: string;
46
- } | undefined;
58
+ export { toPredicateInput } from '@object-ui/core';
47
59
  /**
48
60
  * Hook for evaluating expressions with dynamic context
49
61
  *
@@ -85,4 +97,11 @@ export declare function useRowPredicate(pred: unknown, row: Record<string, any>
85
97
  fallback?: boolean;
86
98
  warnOnError?: boolean;
87
99
  label?: string;
100
+ /**
101
+ * The object's field definitions. Supplying them binds a relation field as
102
+ * the stored foreign key instead of whatever `$expand` substituted for it
103
+ * on this surface, so `record.owner == os.user.id` reaches the server's
104
+ * verdict — see `toPredicateRecord`.
105
+ */
106
+ fields?: unknown;
88
107
  }): boolean;
@@ -40,41 +40,36 @@ export function usePredicateScope() {
40
40
  }
41
41
  /**
42
42
  * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` /
43
- * `hidden`) into the `${expr}` template form expected by `useCondition`.
43
+ * `hidden`) into the form `useCondition` expects — the canonical helper to use
44
+ * in renderers so we never end up with `${[object Object]}` after JS
45
+ * template-literal interpolation.
44
46
  *
45
- * Accepts:
46
- * - `boolean` → returned as-is (predicate hooks short-circuit on booleans).
47
- * - `string` → wrapped as `${string}` (legacy DX shorthand).
48
- * - `Expression` envelope `{ dialect, source }` (new format from
49
- * `@objectstack/spec`'s normalized predicate inputs) → unwrapped, then
50
- * wrapped as `${source}`. Both `cel` and `template` dialects already use
51
- * compatible variable syntax (`record.x`, etc.).
52
- * - `null` / `undefined` / empty → `undefined` (default visible/enabled).
47
+ * **Re-exported from `@object-ui/core`, not reimplemented here.** There is
48
+ * exactly ONE implementation — `packages/core/src/evaluator/predicateInput.ts`
49
+ * — shared by the engine path (`ActionEngine.getActionsForLocation`) and the
50
+ * renderer path (`action-button` / `action-menu` / `action-bar` / …). See that
51
+ * file for the accepted input shapes and for why a `cel` envelope must survive
52
+ * normalization (#2661 / #3314).
53
53
  *
54
- * This is the canonical helper to use in renderers so we never end up with
55
- * `${[object Object]}` after JS template-literal interpolation.
54
+ * ## Why a re-export and not a twin (#3367)
55
+ *
56
+ * This module used to carry an independent renderer-side implementation with
57
+ * item-for-item identical semantics, held in step with the canonical one by a
58
+ * 14-shape normalization parity table. That table was a *guardrail against
59
+ * drift*, not a single source of truth — and #3314 is the record of what two
60
+ * normalizations do when left alone: they drift, and the same `visible:`
61
+ * predicate reaches different verdicts depending on which path surfaced the
62
+ * action. A re-export cannot drift, so the parity suite now pins identity
63
+ * (`react`'s export IS `core`'s function) instead of enumerating shapes, and
64
+ * keeps pinning the two PATHS verdict-for-verdict.
65
+ *
66
+ * Routing hook code through the `@object-ui/core` barrel is not a new coupling:
67
+ * this module already imports `ExpressionEvaluator` / `evalRowPredicate` from
68
+ * it, and `@object-ui/core` is a declared dependency of `@object-ui/react`
69
+ * (the reverse direction is the forbidden one — core declares "Zero React
70
+ * dependencies").
56
71
  */
57
- export function toPredicateInput(value) {
58
- if (value === null || value === undefined || value === '')
59
- return undefined;
60
- if (typeof value === 'boolean')
61
- return value;
62
- if (typeof value === 'string')
63
- return `\${${value}}`;
64
- if (typeof value === 'object' && typeof value.source === 'string') {
65
- const src = value.source;
66
- if (!src)
67
- return undefined;
68
- // #2661 — preserve a CEL-dialect envelope so `useCondition` routes it to the
69
- // canonical `@objectstack/formula` engine (identical verdict to the server),
70
- // instead of collapsing it to a `${source}` string on the legacy JS path.
71
- // Every other dialect (template / unset) keeps the legacy `${…}` behavior.
72
- if (value.dialect === 'cel')
73
- return { dialect: 'cel', source: src };
74
- return `\${${src}}`;
75
- }
76
- return undefined;
77
- }
72
+ export { toPredicateInput } from '@object-ui/core';
78
73
  /**
79
74
  * Hook for evaluating expressions with dynamic context
80
75
  *
@@ -160,6 +155,7 @@ export function useCondition(condition, context = {}, options) {
160
155
  export function useRowPredicate(pred, row, options) {
161
156
  const scope = usePredicateScope();
162
157
  const fallback = options?.fallback ?? true;
158
+ const fields = options?.fields;
163
159
  return useMemo(() => {
164
160
  // A boolean predicate short-circuits (no expression to evaluate).
165
161
  if (typeof pred === 'boolean')
@@ -171,6 +167,7 @@ export function useRowPredicate(pred, row, options) {
171
167
  scope,
172
168
  warnOnError: options?.warnOnError,
173
169
  label: options?.label,
170
+ fields: fields,
174
171
  });
175
- }, [pred, row, scope, fallback, options?.warnOnError, options?.label]);
172
+ }, [pred, row, scope, fallback, options?.warnOnError, options?.label, fields]);
176
173
  }
@@ -5,21 +5,34 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ import type { NavigationConfigSchema } from '@objectstack/spec/ui';
9
+ import type { SpecAuthoredInput } from '../spec-input';
8
10
  /**
9
- * Inline ViewNavigationConfig to avoid importing from @object-ui/types
10
- * (which may not be a direct dependency of @object-ui/react).
11
- * Mirrors the canonical definition in @object-ui/types/objectql.ts.
11
+ * The spec's `NavigationConfigSchema`, authoring side, with `mode` required.
12
+ *
13
+ * This was a hand copy carrying the note "inline … to avoid importing from
14
+ * @object-ui/types (which may not be a direct dependency of @object-ui/react)".
15
+ * That reasoning is about the wrong package: the vocabulary belongs to
16
+ * `@objectstack/spec`, which IS a direct dependency of this one — the same
17
+ * expired "kept local to avoid a dependency" comment objectui#3169 found in
18
+ * `@object-ui/app-shell`, where the dependency had likewise been there all
19
+ * along. Check `package.json` before believing such a note (objectstack#4115).
20
+ *
21
+ * The ONE divergence: the spec defaults `mode`, so its authoring side makes it
22
+ * optional; this hook dispatches on `mode` and its callers always supply one,
23
+ * so it is required here. Every other key is the spec's, by reference. Pinned
24
+ * by `__tests__/offline-nav-performance-spec-parity.test.ts`.
25
+ *
26
+ * Two per-key notes the hand copy carried, kept here because a derived alias
27
+ * has no members to hang them on:
28
+ * - `size` is the coarse overlay bucket added by #2578; `resolveOverlayWidth`
29
+ * below maps it through {@link OVERLAY_SIZE_WIDTHS}.
30
+ * - `width` is DEPRECATED by #2578 in favour of `size`. It still wins when
31
+ * present, because app-shell pre-resolves `size` into it.
12
32
  */
13
- export interface NavigationConfig {
14
- mode: 'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none';
15
- view?: string;
16
- preventNavigation?: boolean;
17
- openNewTab?: boolean;
18
- /** Spec `NavigationConfig.size` (#2578) — coarse overlay bucket. */
19
- size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
20
- /** @deprecated [#2578 → `size`] explicit pixel/percent width. */
21
- width?: string | number;
22
- }
33
+ export type NavigationConfig = Omit<SpecAuthoredInput<typeof NavigationConfigSchema>, 'mode'> & {
34
+ mode: NonNullable<SpecAuthoredInput<typeof NavigationConfigSchema>['mode']>;
35
+ };
23
36
  /**
24
37
  * Resolve the overlay width from a NavigationConfig: an explicit `width` wins
25
38
  * (app-shell pre-resolves `size` into it); otherwise a declared bucket maps
@@ -5,14 +5,25 @@
5
5
  * This source code is licensed under the MIT license found in the
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
+ import type { OfflineStrategy, ConflictResolution, PersistStorage, EvictionPolicy, OfflineCacheConfigSchema, OfflineConfigSchema, SyncConfigSchema } from '@objectstack/spec/ui';
9
+ import type { SpecAuthoredInput } from '../spec-input';
8
10
  /** Offline strategy determines how data is fetched when connectivity is limited. */
9
- export type OfflineStrategy = 'cache_first' | 'network_first' | 'stale_while_revalidate' | 'network_only' | 'cache_only';
10
- /** Conflict resolution strategy for sync operations. */
11
- export type ConflictResolutionStrategy = 'manual' | 'client_wins' | 'server_wins' | 'last_write_wins';
11
+ export type { OfflineStrategy };
12
+ /**
13
+ * Conflict resolution strategy for sync operations.
14
+ *
15
+ * Renamed from `ConflictResolutionStrategy` — that name belongs to a DIFFERENT
16
+ * spec export (`@objectstack/spec/api`: `error | priority | first-wins |
17
+ * last-wins`, the metadata-merge policy). The spec's name for the union this
18
+ * hook actually uses is `ConflictResolution`, so the fix was to take the spec's
19
+ * own name and its binding at once, the same move `FieldGroup` →
20
+ * `ObjectFieldGroup` made in objectui#3169.
21
+ */
22
+ export type { ConflictResolution };
12
23
  /** Persist storage backend. */
13
- export type PersistStorageType = 'indexeddb' | 'localstorage' | 'sqlite';
24
+ export type PersistStorageType = PersistStorage;
14
25
  /** Eviction policy for cache management. */
15
- export type EvictionPolicyType = 'lru' | 'lfu' | 'fifo';
26
+ export type EvictionPolicyType = EvictionPolicy;
16
27
  /** Sync state of the offline system. */
17
28
  export type SyncState = 'idle' | 'syncing' | 'error' | 'offline';
18
29
  /** A queued mutation waiting to be synced. */
@@ -23,31 +34,38 @@ export interface QueuedMutation {
23
34
  resource: string;
24
35
  data?: Record<string, unknown>;
25
36
  }
26
- /** Cache configuration aligned with OfflineCacheConfigSchema. */
27
- export interface OfflineCacheConfig {
28
- maxSize?: number;
29
- ttl?: number;
30
- persistStorage?: PersistStorageType;
31
- evictionPolicy?: EvictionPolicyType;
32
- }
33
- /** Sync configuration aligned with SyncConfigSchema. */
34
- export interface OfflineSyncConfig {
35
- strategy?: OfflineStrategy;
36
- conflictResolution?: ConflictResolutionStrategy;
37
- retryInterval?: number;
38
- maxRetries?: number;
39
- batchSize?: number;
40
- }
41
- /** Top-level offline configuration aligned with OfflineConfigSchema. */
42
- export interface OfflineConfig {
43
- enabled?: boolean;
44
- strategy?: OfflineStrategy;
45
- cache?: OfflineCacheConfig;
46
- sync?: OfflineSyncConfig;
47
- offlineIndicator?: boolean;
48
- offlineMessage?: string;
49
- queueMaxSize?: number;
50
- }
37
+ /**
38
+ * Cache configuration — the AUTHORING side of the spec's
39
+ * `OfflineCacheConfigSchema`.
40
+ *
41
+ * `persistStorage` and `evictionPolicy` carry `.default()`s, so they are
42
+ * optional to write and present after a parse. This hook is handed what an app
43
+ * author wrote, hence the input side; see {@link SpecAuthoredInput}.
44
+ */
45
+ export type OfflineCacheConfig = SpecAuthoredInput<typeof OfflineCacheConfigSchema>;
46
+ /**
47
+ * Sync configuration — the AUTHORING side of the spec's `SyncConfigSchema`.
48
+ *
49
+ * `strategy` and `conflictResolution` carry `.default()`s, so the output type
50
+ * makes them required; this hook is handed what an app author wrote.
51
+ */
52
+ export type OfflineSyncConfig = SpecAuthoredInput<typeof SyncConfigSchema>;
53
+ /**
54
+ * Top-level offline configuration — the AUTHORING side of the spec's
55
+ * `OfflineConfigSchema`.
56
+ *
57
+ * This name stays with `@object-ui/react` (objectui#3156 / objectui#3159): the
58
+ * `OfflineConfig` that used to sit in `@object-ui/types` was a service-worker
59
+ * ROUTE cache and has been renamed `PWAOfflineConfig`, so there is no
60
+ * cross-package clash left — this hook's config is the spec's concept, key for
61
+ * key, and takes the spec's binding.
62
+ *
63
+ * Input side, not `z.infer`, and here the reason is visible in the signature:
64
+ * `useOffline(config: OfflineConfig = {})` defaults to the empty object, which
65
+ * the output type — where `enabled`, `strategy` and `offlineIndicator` are all
66
+ * required once their `.default()`s have run — would reject outright.
67
+ */
68
+ export type OfflineConfig = SpecAuthoredInput<typeof OfflineConfigSchema>;
51
69
  /** Result returned by the useOffline hook. */
52
70
  export interface OfflineResult {
53
71
  /** Whether the browser is currently online. */
@@ -72,9 +90,9 @@ export interface OfflineResult {
72
90
  offlineMessage: string;
73
91
  }
74
92
  /**
75
- * Hook for offline mode detection and sync queue management.
76
- * Implements OfflineConfigSchema, SyncConfigSchema, and ConflictResolutionSchema
77
- * from @objectstack/spec v2.0.7.
93
+ * Hook for offline mode detection and sync queue management. Its config types
94
+ * ARE the spec's `OfflineConfigSchema` / `SyncConfigSchema` / `ConflictResolution`
95
+ * (authoring side) — see {@link OfflineConfig}.
78
96
  *
79
97
  * @example
80
98
  * ```tsx
@@ -66,9 +66,9 @@ const DEFAULTS = {
66
66
  queueMaxSize: 100,
67
67
  };
68
68
  /**
69
- * Hook for offline mode detection and sync queue management.
70
- * Implements OfflineConfigSchema, SyncConfigSchema, and ConflictResolutionSchema
71
- * from @objectstack/spec v2.0.7.
69
+ * Hook for offline mode detection and sync queue management. Its config types
70
+ * ARE the spec's `OfflineConfigSchema` / `SyncConfigSchema` / `ConflictResolution`
71
+ * (authoring side) — see {@link OfflineConfig}.
72
72
  *
73
73
  * @example
74
74
  * ```tsx
@@ -13,7 +13,7 @@ export interface VirtualScrollConfig {
13
13
  itemHeight?: number;
14
14
  overscan?: number;
15
15
  }
16
- /** Performance configuration aligned with PerformanceConfigSchema. */
16
+ /** Performance configuration. */
17
17
  export interface PerformanceConfig {
18
18
  /** Whether to lazy-load components/data. */
19
19
  lazyLoad?: boolean;
@@ -55,8 +55,8 @@ export interface PerformanceResult {
55
55
  debounce: <T extends (...args: unknown[]) => void>(fn: T) => T;
56
56
  }
57
57
  /**
58
- * Hook for performance monitoring and configuration aligned with
59
- * PerformanceConfigSchema from @objectstack/spec v2.0.7.
58
+ * Hook for performance monitoring and configuration. Its config type IS the
59
+ * spec's `PerformanceConfigSchema` (authoring side) — see {@link PerformanceConfig}.
60
60
  *
61
61
  * Provides resolved config values, Web Vitals metrics, and utility functions
62
62
  * (debounce, render marking) for performance-aware components.
@@ -52,8 +52,8 @@ function getLCP() {
52
52
  // usePerformance hook
53
53
  // ---------------------------------------------------------------------------
54
54
  /**
55
- * Hook for performance monitoring and configuration aligned with
56
- * PerformanceConfigSchema from @objectstack/spec v2.0.7.
55
+ * Hook for performance monitoring and configuration. Its config type IS the
56
+ * spec's `PerformanceConfigSchema` (authoring side) — see {@link PerformanceConfig}.
57
57
  *
58
58
  * Provides resolved config values, Web Vitals metrics, and utility functions
59
59
  * (debounce, render marking) for performance-aware components.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * The AUTHORING ("input") type of a `@objectstack/spec` zod schema.
10
+ *
11
+ * ## Why not `z.infer`
12
+ *
13
+ * `z.infer` is the OUTPUT type — what `.parse()` returns, with every
14
+ * `.default()` already applied and therefore REQUIRED. The hooks in this
15
+ * package are handed configuration an app author wrote by hand, so the input
16
+ * side is the one that describes what they may legally omit. Deriving a config
17
+ * type from `z.infer` turns every defaulted key into a required one and makes
18
+ * the schema's own defaults unusable — the `_input`/`_output` trap the spec
19
+ * symbol guard's header warns about, and the one that already bit
20
+ * `ObjectFieldGroup` in objectui#3169.
21
+ *
22
+ * Rule of thumb: is this module WRITING the metadata, or READING what has
23
+ * already been parsed? Writing → input. Reading → `z.infer`.
24
+ *
25
+ * ## Why it reads `_zod`, not `z.input`
26
+ *
27
+ * `z.input<typeof S>` needs a `zod` import, and `@object-ui/react` deliberately
28
+ * does not depend on zod — it renders plain objects and never parses. The
29
+ * schema's own type already carries both sides in `_zod`, so the input type is
30
+ * reachable through the spec's binding alone, with no new dependency and no
31
+ * copy of the shape.
32
+ */
33
+ export type SpecAuthoredInput<S extends {
34
+ _zod: {
35
+ input: unknown;
36
+ };
37
+ }> = S['_zod']['input'];
@@ -0,0 +1,8 @@
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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/react",
3
- "version": "17.1.0",
3
+ "version": "17.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "React bindings and SchemaRenderer component for Object UI",
@@ -24,20 +24,20 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@objectstack/spec": "^17.0.0-rc.0",
27
+ "@objectstack/spec": "^17.0.0-rc.2",
28
28
  "react-hook-form": "^7.83.0",
29
- "@object-ui/core": "17.1.0",
30
- "@object-ui/data-objectstack": "17.1.0",
31
- "@object-ui/i18n": "17.1.0",
32
- "@object-ui/types": "17.1.0"
29
+ "@object-ui/core": "17.3.0",
30
+ "@object-ui/data-objectstack": "17.3.0",
31
+ "@object-ui/i18n": "17.3.0",
32
+ "@object-ui/types": "17.3.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^18.0.0 || ^19.0.0",
36
36
  "react-dom": "^18.0.0 || ^19.0.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/react": "19.2.17",
40
- "@types/react-dom": "19.2.3",
39
+ "@types/react": "19.2.18",
40
+ "@types/react-dom": "19.2.4",
41
41
  "typescript": "^6.0.3"
42
42
  },
43
43
  "keywords": [
@@ -65,7 +65,7 @@
65
65
  "scripts": {
66
66
  "build": "tsc",
67
67
  "test": "vitest run",
68
- "type-check": "tsc --noEmit",
68
+ "type-check": "tsc --noEmit && tsc -p tsconfig.typetests.json",
69
69
  "lint": "eslint ."
70
70
  }
71
71
  }