@declarion/react 0.12.4 → 0.19.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.
@@ -141,29 +141,34 @@ export declare function filterLayoutForSlot(layout: ResolvedActionLayoutNode[],
141
141
  excludeIf?: (action: Action) => boolean;
142
142
  }): ResolvedActionLayoutNode[];
143
143
  /**
144
- * True when an action should be treated as requiring a row selection.
144
+ * True when an action needs at least one selected row to be meaningful, so a
145
+ * surface (the list toolbar) greys it while nothing is selected and enables it
146
+ * once a selection exists.
145
147
  *
146
- * THIS IS A PROXY, not the canonical rule. The canonical "requires
147
- * selection" answer is derived per dispatch mode (see docs/dsl.md §16.6.2:
148
- * `handler.invoke`, association semantics, `href ${id}`). The SDK cannot
149
- * evaluate that rule - the resolved-action wire format carries no `invoke`
150
- * value, and two of the five modes (`ui_steps`, `widget`) have no
151
- * derivation at all. So the SDK stands in slot membership: an action is
152
- * selection-mode iff its `visibility` includes `list_floatbar`.
148
+ * The server derives this from the handler's id-arity contract (a handler that
149
+ * cannot run without object IDs - not allow_no_objects, not invoke=unbound),
150
+ * association mode, or an href `${id}` row-link, and emits it as
151
+ * `requires_selection`. When the field is PRESENT it is authoritative.
153
152
  *
154
- * This matches the platform's authoring guidance - the loader's
155
- * `bulk_selection_required`-removal error tells authors to "use
156
- * visibility: [list_floatbar] for selection-mode actions" - so the proxy
157
- * agrees with the canonical rule for every guideline-following schema
158
- * (a mis-declared schema can diverge). Full rationale, the divergence
159
- * case, and the planned hybrid replacement: docs/architecture.md
160
- * "List Toolbar Selection Model".
153
+ * When ABSENT (an older server, or the `widget`/`ui_steps` modes the server
154
+ * does not derive) we fall back to the historical proxy: slot membership in
155
+ * `list_floatbar`. Absent MUST NOT be read as false, or every handler action
156
+ * would silently read as page-level against an older server.
161
157
  *
162
- * Drives list-toolbar state: a selection-mode action renders disabled
163
- * while no rows are selected, and moves to the floatbar (not the static
164
- * toolbar) once a selection exists.
158
+ * See docs/architecture.md "List Toolbar Selection Model" and plan
159
+ * 2026-06-22-toolbar-action-slots-and-id-arity.md.
165
160
  */
166
161
  export declare function actionRequiresSelection(action: Action): boolean;
162
+ /**
163
+ * True when the action declares the `list_floatbar` (selection bar) slot.
164
+ *
165
+ * This is the OLD body of actionRequiresSelection, kept ONLY to decide
166
+ * toolbar/floatbar de-duplication: an action that opts into the floatbar is
167
+ * rendered there on selection, so the static toolbar drops it to avoid a
168
+ * double-render. It is NOT the selection predicate - greying is driven by
169
+ * actionRequiresSelection (the accurate server signal).
170
+ */
171
+ export declare function actionInFloatbarSlot(action: Action): boolean;
167
172
  /**
168
173
  * Walks a ResolvedActionLayoutNode[] and sets `enabled: false` plus the
169
174
  * given `reason` on every leaf whose action satisfies `predicate`. Groups
@@ -0,0 +1,66 @@
1
+ import { type ReactElement } from "react";
2
+ import { type RenderResult } from "@testing-library/react";
3
+ import { QueryClient } from "@tanstack/react-query";
4
+ import { type ActionExecuteRequest, type ActionExecuteResponse } from "./api/data";
5
+ import type { Schema } from "./types/schema";
6
+ import type { ListMeta, ListResponse, Refs } from "./types/api";
7
+ /** A single action's stubbed outcome: the action's RESULT payload (what
8
+ * executeDeclarionAction unwraps from response.result), or a function of the
9
+ * dispatched body returning it. The harness always wraps it in a success
10
+ * envelope - it does NOT sniff for an envelope shape, so a domain result like
11
+ * { status: "closed" } keeps its result intact. Simulate a failure by throwing
12
+ * from a function result. The union avoids `unknown` so the function form keeps
13
+ * its typed `body` parameter at consumer call sites. */
14
+ export type ActionResult = ((body: ActionExecuteRequest) => unknown) | Record<string, unknown> | unknown[] | string | number | boolean | null;
15
+ /** Rows plus optional meta + response-level `$refs` (ref/expanded-column data)
16
+ * for one entity's list/count fixture. */
17
+ export interface EntityDataFixture {
18
+ rows: Record<string, unknown>[];
19
+ meta?: Partial<ListMeta>;
20
+ refs?: Refs;
21
+ }
22
+ export interface RenderWithDeclarionOptions {
23
+ /** Seeded into the schema query cache so useSchema resolves warm. Build with schemaFixture. */
24
+ schema?: Schema;
25
+ /** entity -> rows, { rows, meta }, or an entityDataFixture() ListResponse; resolves useEntityData / useEntityCount. */
26
+ entityData?: Record<string, Record<string, unknown>[] | EntityDataFixture | ListResponse>;
27
+ /** entity -> { [id]: row }; resolves useEntityItem. */
28
+ entityItems?: Record<string, Record<string, Record<string, unknown>>>;
29
+ /** action code -> result | (body) => result; resolves executeAction (and create/update/delete/restore). */
30
+ actionResults?: Record<string, ActionResult>;
31
+ /** Initial path for the memory router (e.g. "/replay/abc/compare"). Default "/". */
32
+ route?: string;
33
+ }
34
+ export interface RenderWithDeclarionResult extends RenderResult {
35
+ /** The live QueryClient, for advanced cache assertions or manual refetch. */
36
+ queryClient: QueryClient;
37
+ }
38
+ /** Build a valid Schema from a partial - fills the required empty maps so a
39
+ * consumer only declares the entities/screens its test needs. */
40
+ export declare function schemaFixture(partial?: Partial<Schema>): Schema;
41
+ /** Build the ListResponse envelope the real list hooks consume, from rows +
42
+ * optional meta overrides. Defaults meta to an exact count of the row set. */
43
+ export declare function entityDataFixture(rows: Record<string, unknown>[], meta?: Partial<ListMeta>, refs?: Refs): ListResponse;
44
+ /** Typed pass-through for an action-result map (ergonomic + future-proof seam). */
45
+ export declare function actionResults(map: Record<string, ActionResult>): Record<string, ActionResult>;
46
+ /**
47
+ * Build a FULL action-dispatch envelope for a stub - so a render test can drive
48
+ * ActionRunner's NON-success toasts (status "partial" / "skipped" / "accepted"
49
+ * with success / errors / skipped arrays), which a plain result payload cannot
50
+ * express. A plain actionResults value stays the result payload (wrapped as
51
+ * success); only an actionEnvelope() value is forwarded verbatim, so a domain
52
+ * result with a `status` field is never mistaken for an envelope.
53
+ */
54
+ export declare function actionEnvelope(env: Partial<ActionExecuteResponse> & {
55
+ status: string;
56
+ }): ActionResult;
57
+ /** Clear every harness seam + the seeded auth state. Auto-registered as an
58
+ * afterEach when the test runner exposes one (vitest/jest globals); exported
59
+ * so a runner without globals can call it explicitly. */
60
+ export declare function cleanupDeclarion(): void;
61
+ /**
62
+ * Render a consumer screen under the SDK's real providers with fixtures seeded
63
+ * through the read/action seams. Returns the testing-library result plus the
64
+ * live queryClient.
65
+ */
66
+ export declare function renderWithDeclarion(ui: ReactElement, opts?: RenderWithDeclarionOptions): RenderWithDeclarionResult;
@@ -0,0 +1,147 @@
1
+ import { Pt as e, ct as t, lt as n, t as r, tt as i, ut as a } from "./ActionRunner-F1HBBKyQ.js";
2
+ import { MemoryRouter as o } from "react-router-dom";
3
+ import { QueryClient as s, QueryClientProvider as c } from "@tanstack/react-query";
4
+ import { Toaster as l } from "sonner";
5
+ import { jsx as u, jsxs as d } from "react/jsx-runtime";
6
+ import { render as f } from "@testing-library/react";
7
+ //#region src/testing.tsx
8
+ function p(e = {}) {
9
+ return {
10
+ languages: [],
11
+ structures: {},
12
+ entities: {},
13
+ screens: {},
14
+ navigation: [],
15
+ actions: {},
16
+ enums: {},
17
+ parameters: {},
18
+ ...e
19
+ };
20
+ }
21
+ function m(e, t = {}, n) {
22
+ return {
23
+ data: e,
24
+ meta: {
25
+ total: t.total ?? e.length,
26
+ page: t.page ?? 1,
27
+ per_page: t.per_page ?? 50,
28
+ total_pages: t.total_pages ?? 1,
29
+ has_more: t.has_more ?? !1,
30
+ count_mode: t.count_mode ?? "exact"
31
+ },
32
+ ...n === void 0 ? {} : { $refs: n }
33
+ };
34
+ }
35
+ function h(e) {
36
+ return e;
37
+ }
38
+ var g = Symbol("declarion.testing.actionEnvelope");
39
+ function _(e) {
40
+ return {
41
+ ...e,
42
+ [g]: !0
43
+ };
44
+ }
45
+ function v(e) {
46
+ if (e && typeof e == "object" && e[g]) {
47
+ let t = { ...e };
48
+ return delete t[g], t;
49
+ }
50
+ return {
51
+ status: "success",
52
+ result: e
53
+ };
54
+ }
55
+ function y() {
56
+ let t = {
57
+ id: "test-tenant",
58
+ code: "test",
59
+ name: "Test",
60
+ role: "admin"
61
+ }, n = {
62
+ id: "test-user",
63
+ email: "test@example.com",
64
+ display_name: "Test User",
65
+ is_active: !0,
66
+ roles: ["admin"],
67
+ permissions: ["*"],
68
+ tenant: t,
69
+ created_at: "2026-01-01T00:00:00Z",
70
+ updated_at: "2026-01-01T00:00:00Z"
71
+ };
72
+ e.getState().setUser(n, t);
73
+ }
74
+ function b(e) {
75
+ if (e === void 0) return { rows: [] };
76
+ if (Array.isArray(e)) return { rows: e };
77
+ if (Array.isArray(e.data)) {
78
+ let t = e;
79
+ return {
80
+ rows: t.data,
81
+ meta: t.meta,
82
+ refs: t.$refs
83
+ };
84
+ }
85
+ return e;
86
+ }
87
+ function x(e) {
88
+ let r = e.entityData ?? {};
89
+ a((e, t) => {
90
+ let n = r[e], i = b(n), a = i.meta?.per_page ?? 50, o = i.meta?.total ?? i.rows.length;
91
+ return t.count === "only" ? {
92
+ data: [],
93
+ meta: {
94
+ page: 1,
95
+ per_page: a,
96
+ total: o,
97
+ total_pages: i.meta?.total_pages ?? Math.max(1, Math.ceil(o / a)),
98
+ count_mode: i.meta?.count_mode ?? "exact"
99
+ }
100
+ } : {
101
+ data: i.rows,
102
+ meta: {
103
+ page: i.meta?.page ?? 1,
104
+ per_page: a,
105
+ has_more: i.meta?.has_more ?? !1
106
+ },
107
+ ...i.refs === void 0 ? {} : { $refs: i.refs }
108
+ };
109
+ });
110
+ let o = e.entityItems ?? {};
111
+ n((e, t) => {
112
+ let n = typeof t == "string" ? t : i(t);
113
+ return { data: o[e]?.[n] };
114
+ });
115
+ let s = e.actionResults ?? {};
116
+ t(async (e, t) => {
117
+ if (!(e in s)) throw Error(`renderWithDeclarion: no action result registered for "${e}" (seed it via actionResults)`);
118
+ let n = s[e];
119
+ return v(typeof n == "function" ? await n(t) : n);
120
+ });
121
+ }
122
+ function S() {
123
+ a(null), n(null), t(null), e.setState({
124
+ user: null,
125
+ isAuthenticated: !1,
126
+ activeTenant: null,
127
+ tenants: []
128
+ });
129
+ }
130
+ function C(e, t = {}) {
131
+ let n = new s({ defaultOptions: { queries: {
132
+ retry: !1,
133
+ gcTime: Infinity
134
+ } } });
135
+ y(), n.setQueryData(["schema", !0], t.schema ?? p()), x(t);
136
+ let i = f(/* @__PURE__ */ d(c, {
137
+ client: n,
138
+ children: [/* @__PURE__ */ u(o, {
139
+ initialEntries: [t.route ?? "/"],
140
+ children: /* @__PURE__ */ u(r, { children: e })
141
+ }), /* @__PURE__ */ u(l, {})]
142
+ }));
143
+ return Object.assign(i, { queryClient: n });
144
+ }
145
+ typeof afterEach == "function" && afterEach(() => S());
146
+ //#endregion
147
+ export { _ as actionEnvelope, h as actionResults, S as cleanupDeclarion, m as entityDataFixture, C as renderWithDeclarion, p as schemaFixture };
@@ -68,6 +68,13 @@ export declare class ApiError extends Error {
68
68
  */
69
69
  export declare const CURSOR_SEMANTICS_MISMATCH = "cursor_semantics_mismatch";
70
70
  export declare const CURSOR_LOCALE_CHANGED = "cursor_locale_changed";
71
+ /**
72
+ * A cross-store (Postgres-hot / ClickHouse-cold) archive cursor that the server
73
+ * could not decode for this read - a malformed token, a tenant/entity mismatch,
74
+ * or a retired bespoke-archive cursor minted before the native-cursor release.
75
+ * Same recovery contract as the other two: drop `after`, refetch window 1.
76
+ */
77
+ export declare const ARCHIVE_CURSOR_INVALID = "archive_cursor_invalid";
71
78
  /** True if the error is a recoverable cursor-stale signal that warrants
72
79
  * refetching page 1 instead of surfacing to the user. */
73
80
  export declare function isCursorStaleError(err: unknown): boolean;
@@ -271,6 +278,13 @@ export interface V2Meta {
271
278
  limit: number;
272
279
  has_more: boolean;
273
280
  cursor?: string;
281
+ /** How `total` was produced: "exact" or "estimated". Absent when no count
282
+ * was requested. A cross-store cursor total is always "estimated". */
283
+ count_mode?: string;
284
+ /** True when a count WAS requested but abandoned because the cold-tier count
285
+ * would exceed the interactive scan bound (BC4b). The data window still
286
+ * loaded; render NO total ("~N") - distinct from a real `total: 0`. */
287
+ count_unknown?: boolean;
274
288
  }
275
289
  /**
276
290
  * Refs map: keyed by entity code (or $-prefixed virtual entity code),
@@ -316,6 +330,16 @@ export interface V2ListParams {
316
330
  limit?: number;
317
331
  after?: string;
318
332
  include_deleted?: boolean;
333
+ /** Request the (estimated) total alongside the window (`?count=with`). The
334
+ * cursor UI sends this on the FIRST window only; later windows omit it. */
335
+ count?: boolean;
336
+ /** Screen code, so the server applies that screen's `fixed_filters` (a
337
+ * cursor list of a `fixed_filters` view, e.g. the audit auth-log, needs
338
+ * the same server-side prefilter the offset list gets). */
339
+ screen?: string;
340
+ /** Return display-bounded (truncated) values for list cells, matching the
341
+ * offset list's `preview: true`. */
342
+ preview?: boolean;
319
343
  }
320
344
  /** Per-field resolved display unit codes. */
321
345
  export type Units = Record<string, string>;
@@ -363,6 +363,16 @@ export interface Entity {
363
363
  * docs/plans/2026-05-18-declarative-row-count.md.
364
364
  */
365
365
  count?: "exact" | "estimated";
366
+ /**
367
+ * Computed (server-side, never author-set) pagination mode the generated
368
+ * admin list MUST use: "offset" (numbered pages) or "cursor" (keyset /
369
+ * infinite scroll). "cursor" iff the entity declares `persistence.archive`
370
+ * AND the deployment has ClickHouse wired at boot - a cross-store hot+cold
371
+ * read cannot offset-paginate. Absent means "offset" (every plain Postgres /
372
+ * ClickHouse-only / ClickHouse-disabled-archive entity). The SDK branches the
373
+ * list UI on this. See docs/plans/2026-06-20-cross-store-list-cursor-pagination.md.
374
+ */
375
+ list_pagination?: "offset" | "cursor";
366
376
  }
367
377
  export interface ExportConfig {
368
378
  enabled?: boolean;
@@ -808,6 +818,16 @@ export interface Action {
808
818
  */
809
819
  resolved_hidden_defaults?: Record<string, unknown>;
810
820
  ui_steps?: UIStep[];
821
+ /**
822
+ * Server-derived: the action needs at least one selected row to be
823
+ * meaningful (the list toolbar greys it while nothing is selected). Derived
824
+ * from the handler's id-arity contract (cannot run without object IDs),
825
+ * association mode, or an href `${id}` row-link. ABSENT means the server did
826
+ * not derive it (older server, or widget/ui_steps modes) - consumers MUST
827
+ * fall back to the list_floatbar slot proxy, NOT treat absent as false. See
828
+ * actionRequiresSelection in lib/actions.ts.
829
+ */
830
+ requires_selection?: boolean;
811
831
  }
812
832
  export interface LanguageDef {
813
833
  code: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@declarion/react",
3
- "version": "0.12.4",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "React SDK for Declarion, the schema-driven business apps platform.",
@@ -25,6 +25,11 @@
25
25
  "import": "./dist-lib/vite/index.js",
26
26
  "default": "./dist-lib/vite/index.js"
27
27
  },
28
+ "./testing": {
29
+ "types": "./dist-lib/testing.d.ts",
30
+ "import": "./dist-lib/testing.js",
31
+ "default": "./dist-lib/testing.js"
32
+ },
28
33
  "./styles.css": "./dist-lib/declarion-react.css"
29
34
  },
30
35
  "files": [
@@ -37,7 +42,13 @@
37
42
  },
38
43
  "peerDependencies": {
39
44
  "react": "^19.0.0",
40
- "react-dom": "^19.0.0"
45
+ "react-dom": "^19.0.0",
46
+ "@testing-library/react": "^16.3.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@testing-library/react": {
50
+ "optional": true
51
+ }
41
52
  },
42
53
  "dependencies": {
43
54
  "@dnd-kit/core": "^6.3.1",
@@ -108,7 +119,7 @@
108
119
  "check:pkg": "publint run",
109
120
  "lint": "tsc --noEmit && eslint src --max-warnings=0",
110
121
  "test": "vitest run",
111
- "test:coverage": "vitest run --coverage --coverage.include='src/**'",
122
+ "test:coverage": "vitest run --coverage",
112
123
  "preview": "vite preview"
113
124
  }
114
125
  }