@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.
- package/dist-lib/ActionRunner-C2A73D9b.js +8348 -0
- package/dist-lib/{DraggableChildrenTable-I1gyEEIJ.js → DraggableChildrenTable-CzbVcMu7.js} +54 -53
- package/dist-lib/{Grid-_7PlIyGn.js → Grid-8k-ysclX.js} +120 -120
- package/dist-lib/{MarkdownRenderer-Dbl3AFDQ.js → MarkdownRenderer--n1GTrwy.js} +1 -1
- package/dist-lib/api/data.d.ts +28 -0
- package/dist-lib/api/entities.d.ts +9 -2
- package/dist-lib/dataTableStyles-vwD1mVOP.js +22 -0
- package/dist-lib/declarion-react.css +1 -1
- package/dist-lib/hooks/useSSE.d.ts +1 -0
- package/dist-lib/index.js +4954 -7101
- package/dist-lib/lib/actions.d.ts +37 -19
- package/dist-lib/testing.d.ts +66 -0
- package/dist-lib/testing.js +147 -0
- package/dist-lib/types/schema.d.ts +11 -0
- package/package.json +16 -5
- package/dist-lib/dataTableStyles-WuxWH5x7.js +0 -5342
- package/dist-lib/rich-text-CJpzFOG5.js +0 -46
- package/dist-lib/value-YOCedRQq.js +0 -774
|
@@ -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
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
122
|
+
"test:coverage": "vitest run --coverage",
|
|
112
123
|
"preview": "vite preview"
|
|
113
124
|
}
|
|
114
125
|
}
|