@declarion/react 0.15.0 → 0.22.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.
@@ -1,4 +1,17 @@
1
- import type { Action, ActionCondition, LocalizedString, Schema } from "../types/schema";
1
+ import type { Action, ActionCondition, Entity, LocalizedString, Schema } from "../types/schema";
2
+ /**
3
+ * rowDetailId builds the detail-route id for a row - the value an href's
4
+ * `${id}` placeholder and a detail screen's `:id` segment receive - addressing
5
+ * it by the entity's REAL primary key (any single field or a composite key), so
6
+ * an entity whose PK is not named `id` (e.g. `schedule.code`) resolves correctly
7
+ * instead of emitting an empty `${id}`. Falls back to a raw `id` lookup only
8
+ * when no entity is in scope (defensive; an href dispatch always has one) or the
9
+ * row has no resolvable PK yet (an optimistic placeholder).
10
+ *
11
+ * Single source of truth for row -> detail-id, shared by ResponsiveActionBar
12
+ * (action_layout dispatch) and ActionButton (flat href dispatch).
13
+ */
14
+ export declare function rowDetailId(row: Record<string, unknown> | undefined, entity: Entity | undefined): string;
2
15
  /**
3
16
  * Evaluates action conditions against row data.
4
17
  * Returns true if the action is available (all conditions pass).
@@ -141,29 +154,34 @@ export declare function filterLayoutForSlot(layout: ResolvedActionLayoutNode[],
141
154
  excludeIf?: (action: Action) => boolean;
142
155
  }): ResolvedActionLayoutNode[];
143
156
  /**
144
- * True when an action should be treated as requiring a row selection.
157
+ * True when an action needs at least one selected row to be meaningful, so a
158
+ * surface (the list toolbar) greys it while nothing is selected and enables it
159
+ * once a selection exists.
145
160
  *
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`.
161
+ * The server derives this from the handler's id-arity contract (a handler that
162
+ * cannot run without object IDs - not allow_no_objects, not invoke=unbound),
163
+ * association mode, or an href `${id}` row-link, and emits it as
164
+ * `requires_selection`. When the field is PRESENT it is authoritative.
153
165
  *
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".
166
+ * When ABSENT (an older server, or the `widget`/`ui_steps` modes the server
167
+ * does not derive) we fall back to the historical proxy: slot membership in
168
+ * `list_floatbar`. Absent MUST NOT be read as false, or every handler action
169
+ * would silently read as page-level against an older server.
161
170
  *
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.
171
+ * See docs/architecture.md "List Toolbar Selection Model" and plan
172
+ * 2026-06-22-toolbar-action-slots-and-id-arity.md.
165
173
  */
166
174
  export declare function actionRequiresSelection(action: Action): boolean;
175
+ /**
176
+ * True when the action declares the `list_floatbar` (selection bar) slot.
177
+ *
178
+ * This is the OLD body of actionRequiresSelection, kept ONLY to decide
179
+ * toolbar/floatbar de-duplication: an action that opts into the floatbar is
180
+ * rendered there on selection, so the static toolbar drops it to avoid a
181
+ * double-render. It is NOT the selection predicate - greying is driven by
182
+ * actionRequiresSelection (the accurate server signal).
183
+ */
184
+ export declare function actionInFloatbarSlot(action: Action): boolean;
167
185
  /**
168
186
  * Walks a ResolvedActionLayoutNode[] and sets `enabled: false` plus the
169
187
  * 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-C2A73D9b.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 };
@@ -384,6 +384,7 @@ export declare function extractPkValues(entity: Entity, record: Record<string, u
384
384
  export declare function pkValuePartsToId(values: string[]): string;
385
385
  export declare function pkValuesToId(pkValues: PkValues): string;
386
386
  export declare function pkValuesToDetailId(entity: Entity, pkValues: PkValues): string;
387
+ export declare function singlePkValues(entity: Entity, value: string): PkValues | undefined;
387
388
  export declare function pkValuesFromId(id: string, expectedCount: number): string[] | null;
388
389
  export declare function resolveDetailRoute(list: Pick<Screen, "detail_screen"> | undefined, entity: Pick<Entity, "detail_screen"> | undefined, allScreens: Record<string, {
389
390
  route?: string;
@@ -818,6 +819,16 @@ export interface Action {
818
819
  */
819
820
  resolved_hidden_defaults?: Record<string, unknown>;
820
821
  ui_steps?: UIStep[];
822
+ /**
823
+ * Server-derived: the action needs at least one selected row to be
824
+ * meaningful (the list toolbar greys it while nothing is selected). Derived
825
+ * from the handler's id-arity contract (cannot run without object IDs),
826
+ * association mode, or an href `${id}` row-link. ABSENT means the server did
827
+ * not derive it (older server, or widget/ui_steps modes) - consumers MUST
828
+ * fall back to the list_floatbar slot proxy, NOT treat absent as false. See
829
+ * actionRequiresSelection in lib/actions.ts.
830
+ */
831
+ requires_selection?: boolean;
821
832
  }
822
833
  export interface LanguageDef {
823
834
  code: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@declarion/react",
3
- "version": "0.15.0",
3
+ "version": "0.22.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": [
@@ -36,9 +41,15 @@
36
41
  "access": "public"
37
42
  },
38
43
  "peerDependencies": {
44
+ "@testing-library/react": "^16.3.0",
39
45
  "react": "^19.0.0",
40
46
  "react-dom": "^19.0.0"
41
47
  },
48
+ "peerDependenciesMeta": {
49
+ "@testing-library/react": {
50
+ "optional": true
51
+ }
52
+ },
42
53
  "dependencies": {
43
54
  "@dnd-kit/core": "^6.3.1",
44
55
  "@dnd-kit/sortable": "^10.0.0",
@@ -57,11 +68,11 @@
57
68
  "class-variance-authority": "^0.7.1",
58
69
  "clsx": "^2.1.1",
59
70
  "date-fns": "^4.4.0",
60
- "lucide-react": "^1.18.0",
71
+ "lucide-react": "^1.21.0",
61
72
  "mdast-util-to-string": "^4.0.0",
62
73
  "monaco-editor": "^0.55.1",
63
74
  "react-markdown": "^10.1.0",
64
- "react-router-dom": "^7.17.0",
75
+ "react-router-dom": "^7.18.0",
65
76
  "react-textarea-autosize": "^8.5.9",
66
77
  "recharts": "^3.8.1",
67
78
  "remark-breaks": "^4.0.0",
@@ -96,7 +107,7 @@
96
107
  "tailwindcss": "^4.3.1",
97
108
  "tsc-alias": "^1.8.17",
98
109
  "typescript": "^6.0.3",
99
- "typescript-eslint": "^8.61.1",
110
+ "typescript-eslint": "^8.62.0",
100
111
  "vite": "^8.0.16",
101
112
  "vitest": "^4.1.9"
102
113
  },
@@ -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
  }