@magicvr/schema-ui-protocol 0.2.1 → 0.2.3
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/lib/fetch-timeout.js +43 -0
- package/package.json +5 -5
- package/protocol/app-manifest.js +677 -0
- package/protocol/conformance/actions-outcome.js +106 -0
- package/protocol/conformance/component-format.js +21 -0
- package/protocol/conformance/query-serialize.js +188 -0
- package/protocol/conformance/request-construction.js +695 -0
- package/protocol/conformance/request-lifecycle.js +57 -0
- package/protocol/conformance/response-mapping.js +170 -0
- package/protocol/conformance/runtime-defaults.js +75 -0
- package/protocol/conformance/runtime-schema-validate.js +82 -0
- package/protocol/conformance/schema-validate.js +119 -0
- package/protocol/conformance/search-table.js +107 -0
- package/protocol/conformance/static-data.js +87 -0
- package/protocol/conformance/table-sort.js +217 -0
- package/protocol/conformance/upload-orchestration.js +174 -0
- package/protocol/conformance/version-negotiate.js +155 -0
- package/protocol/index.d.ts +2 -2
- package/protocol/index.js +8 -0
- package/protocol/load-page.js +88 -0
- package/index.js +0 -7654
- package/protocol/conformance-claim.json +0 -81
- package/protocol/conformance-claim.json.sha256 +0 -1
- package/protocol/conformance-local-report.json +0 -60
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-lifecycle fixture adapter (latest-wins / hide-drop).
|
|
3
|
+
*/
|
|
4
|
+
export function runRequestLifecycle(initialState, events) {
|
|
5
|
+
let generation = 0;
|
|
6
|
+
let active = true;
|
|
7
|
+
let state = initialState;
|
|
8
|
+
let mounted = true;
|
|
9
|
+
/** Generations that were cancelled by hide (not reactivated by show). */
|
|
10
|
+
const cancelled = new Set();
|
|
11
|
+
const committed = [];
|
|
12
|
+
for (const event of events) {
|
|
13
|
+
switch (event.type) {
|
|
14
|
+
case "start": {
|
|
15
|
+
if (!mounted) {
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
generation += 1;
|
|
19
|
+
active = true;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
case "hide": {
|
|
23
|
+
cancelled.add(generation);
|
|
24
|
+
active = false;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case "show": {
|
|
28
|
+
// Show does not reactivate a hidden in-flight generation.
|
|
29
|
+
active = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case "unmount": {
|
|
33
|
+
mounted = false;
|
|
34
|
+
active = false;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "response": {
|
|
38
|
+
if (!mounted) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
if (cancelled.has(event.generation)) {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (event.generation !== generation) {
|
|
45
|
+
// Older generation ignored when a newer start exists.
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
state = event.state;
|
|
49
|
+
committed.push({ generation: event.generation, state: event.state });
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { generation, active, state, committed };
|
|
57
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* response-mapping fixture adapter (schema-ui-docs v2.7.0 / ADR-0005).
|
|
3
|
+
*/
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
/** Own-property path walk; prototype properties do not count as present. */
|
|
8
|
+
function getPath(root, path) {
|
|
9
|
+
if (path === "") {
|
|
10
|
+
return { found: true, value: root };
|
|
11
|
+
}
|
|
12
|
+
const parts = path.split(".");
|
|
13
|
+
let current = root;
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, part)) {
|
|
16
|
+
return { found: false, value: undefined };
|
|
17
|
+
}
|
|
18
|
+
current = current[part];
|
|
19
|
+
}
|
|
20
|
+
return { found: true, value: current };
|
|
21
|
+
}
|
|
22
|
+
function mappingIsEmptyObject(mapping) {
|
|
23
|
+
return isRecord(mapping) && Object.keys(mapping).length === 0;
|
|
24
|
+
}
|
|
25
|
+
function supportsMapping(component) {
|
|
26
|
+
return (component === "table" ||
|
|
27
|
+
component === "chart" ||
|
|
28
|
+
component === "formRecord" ||
|
|
29
|
+
component === "recordView");
|
|
30
|
+
}
|
|
31
|
+
function wireOk(value, wire) {
|
|
32
|
+
if (wire === undefined || value === null) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
switch (wire) {
|
|
36
|
+
case "string":
|
|
37
|
+
return typeof value === "string";
|
|
38
|
+
case "boolean":
|
|
39
|
+
return typeof value === "boolean";
|
|
40
|
+
case "array":
|
|
41
|
+
return Array.isArray(value);
|
|
42
|
+
case "number":
|
|
43
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
44
|
+
default:
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function mapResponse(input) {
|
|
49
|
+
const component = input.component;
|
|
50
|
+
// Explicit null mapping invalid for any component that receives localMapping.
|
|
51
|
+
if (Object.prototype.hasOwnProperty.call(input, "localMapping") && input.localMapping === null) {
|
|
52
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "localMapping" };
|
|
53
|
+
}
|
|
54
|
+
if (!supportsMapping(component)) {
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(input, "localMapping") ||
|
|
56
|
+
Object.prototype.hasOwnProperty.call(input, "datasourceMapping")) {
|
|
57
|
+
// text/statCard reject mapping support (path reported as localMapping per fixtures).
|
|
58
|
+
if (input.localMapping === null) {
|
|
59
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "localMapping" };
|
|
60
|
+
}
|
|
61
|
+
return { ok: false, code: "RESPONSE_MAPPING_NOT_SUPPORTED", path: "localMapping" };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (component === "formRecord" || component === "recordView") {
|
|
65
|
+
const mapping = input.responseMapping;
|
|
66
|
+
if (!isRecord(mapping) || mappingIsEmptyObject(mapping)) {
|
|
67
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "responseMapping" };
|
|
68
|
+
}
|
|
69
|
+
const response = input.response;
|
|
70
|
+
const fieldWireTypes = input.fieldWireTypes ?? {};
|
|
71
|
+
const values = {};
|
|
72
|
+
const skipped = {};
|
|
73
|
+
for (const [target, path] of Object.entries(mapping)) {
|
|
74
|
+
if (typeof path !== "string") {
|
|
75
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "responseMapping" };
|
|
76
|
+
}
|
|
77
|
+
const resolved = getPath(response, path);
|
|
78
|
+
if (!resolved.found) {
|
|
79
|
+
values[target] = null;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const wire = fieldWireTypes[target];
|
|
83
|
+
if (!wireOk(resolved.value, wire)) {
|
|
84
|
+
skipped[target] = "FIELD_WIRE_TYPE_MISMATCH";
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
values[target] = resolved.value;
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(skipped).length > 0) {
|
|
90
|
+
return { ok: true, values, skipped };
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, values };
|
|
93
|
+
}
|
|
94
|
+
if (component === "chart") {
|
|
95
|
+
const response = input.response;
|
|
96
|
+
if (Array.isArray(response)) {
|
|
97
|
+
return { ok: true, data: { list: response } };
|
|
98
|
+
}
|
|
99
|
+
// fall through to mapping if present
|
|
100
|
+
}
|
|
101
|
+
// table (and chart with mapping)
|
|
102
|
+
let mapping;
|
|
103
|
+
const hasLocal = Object.prototype.hasOwnProperty.call(input, "localMapping");
|
|
104
|
+
const hasDs = Object.prototype.hasOwnProperty.call(input, "datasourceMapping");
|
|
105
|
+
if (hasLocal) {
|
|
106
|
+
if (!isRecord(input.localMapping)) {
|
|
107
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "localMapping" };
|
|
108
|
+
}
|
|
109
|
+
if (mappingIsEmptyObject(input.localMapping)) {
|
|
110
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "localMapping" };
|
|
111
|
+
}
|
|
112
|
+
mapping = input.localMapping;
|
|
113
|
+
}
|
|
114
|
+
else if (hasDs) {
|
|
115
|
+
mapping = input.datasourceMapping;
|
|
116
|
+
}
|
|
117
|
+
const response = input.response;
|
|
118
|
+
const paginationMode = input.paginationMode;
|
|
119
|
+
if (!mapping) {
|
|
120
|
+
// Default table mapping
|
|
121
|
+
if (component === "table") {
|
|
122
|
+
if (!isRecord(response)) {
|
|
123
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "response" };
|
|
124
|
+
}
|
|
125
|
+
const list = response.list;
|
|
126
|
+
if (!Array.isArray(list)) {
|
|
127
|
+
return { ok: false, code: "RESPONSE_MAPPING_PATH_MISSING", path: "list" };
|
|
128
|
+
}
|
|
129
|
+
if (paginationMode === "server") {
|
|
130
|
+
if (typeof response.total !== "number") {
|
|
131
|
+
return { ok: false, code: "RESPONSE_MAPPING_TYPE_MISMATCH", path: "total" };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, data: { list, total: response.total } };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, data: { list } };
|
|
136
|
+
}
|
|
137
|
+
if (component === "chart" && Array.isArray(response)) {
|
|
138
|
+
return { ok: true, data: { list: response } };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const listPath = mapping?.list;
|
|
142
|
+
if (typeof listPath !== "string") {
|
|
143
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "localMapping" };
|
|
144
|
+
}
|
|
145
|
+
const listResolved = getPath(response, listPath);
|
|
146
|
+
if (!listResolved.found) {
|
|
147
|
+
return { ok: false, code: "RESPONSE_MAPPING_PATH_MISSING", path: listPath };
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(listResolved.value)) {
|
|
150
|
+
return { ok: false, code: "RESPONSE_MAPPING_TYPE_MISMATCH", path: listPath };
|
|
151
|
+
}
|
|
152
|
+
if (paginationMode === "server") {
|
|
153
|
+
const totalPath = mapping?.total;
|
|
154
|
+
if (typeof totalPath !== "string") {
|
|
155
|
+
return { ok: false, code: "INVALID_RESPONSE_MAPPING", path: "localMapping" };
|
|
156
|
+
}
|
|
157
|
+
const totalResolved = getPath(response, totalPath);
|
|
158
|
+
if (!totalResolved.found) {
|
|
159
|
+
return { ok: false, code: "RESPONSE_MAPPING_PATH_MISSING", path: totalPath };
|
|
160
|
+
}
|
|
161
|
+
if (typeof totalResolved.value !== "number") {
|
|
162
|
+
return { ok: false, code: "RESPONSE_MAPPING_TYPE_MISMATCH", path: totalPath };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
data: { list: listResolved.value, total: totalResolved.value },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, data: { list: listResolved.value } };
|
|
170
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime-defaults fixture adapter (schema-ui-docs v2.7.0).
|
|
3
|
+
*/
|
|
4
|
+
export function applyRuntimeDefaults(input) {
|
|
5
|
+
const kind = input.kind;
|
|
6
|
+
if (kind === "requestConfig") {
|
|
7
|
+
const requiresNetwork = input.requiresNetwork === true;
|
|
8
|
+
const baseURL = input.baseURL;
|
|
9
|
+
if (requiresNetwork && (baseURL === undefined || baseURL === null || baseURL === "")) {
|
|
10
|
+
return { ok: false, code: "MISSING_BASE_URL" };
|
|
11
|
+
}
|
|
12
|
+
return { ok: true };
|
|
13
|
+
}
|
|
14
|
+
if (kind === "defaults") {
|
|
15
|
+
const target = input.target;
|
|
16
|
+
const value = (input.value ?? {});
|
|
17
|
+
if (target === "dataRef") {
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
value: {
|
|
21
|
+
...value,
|
|
22
|
+
method: value.method ?? "GET",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (target === "uploadAction") {
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
value: {
|
|
30
|
+
...value,
|
|
31
|
+
method: value.method ?? "POST",
|
|
32
|
+
retryPolicy: value.retryPolicy ?? "never",
|
|
33
|
+
fieldName: value.fieldName ?? "file",
|
|
34
|
+
multiple: value.multiple ?? false,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { ok: false, code: "INVALID_DEFAULTS_TARGET" };
|
|
39
|
+
}
|
|
40
|
+
if (kind === "component") {
|
|
41
|
+
const type = input.type;
|
|
42
|
+
const installed = new Set(input.installedTypes ?? []);
|
|
43
|
+
if (!installed.has(type)) {
|
|
44
|
+
return { ok: false, code: "UNKNOWN_COMPONENT_TYPE" };
|
|
45
|
+
}
|
|
46
|
+
const requiredProps = input.requiredProps ?? [];
|
|
47
|
+
const props = input.props ?? {};
|
|
48
|
+
for (const prop of requiredProps) {
|
|
49
|
+
if (!Object.prototype.hasOwnProperty.call(props, prop)) {
|
|
50
|
+
return { ok: false, code: "INVALID_COMPONENT", path: `props.${prop}` };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
if (kind === "formFieldInit") {
|
|
56
|
+
const fields = input.fields ?? [];
|
|
57
|
+
const recordValues = input.recordValues ?? {};
|
|
58
|
+
const reactionWrites = input.reactionWrites ?? [];
|
|
59
|
+
const values = {};
|
|
60
|
+
for (const field of fields) {
|
|
61
|
+
const name = field.field;
|
|
62
|
+
if (field.defaultValue !== undefined) {
|
|
63
|
+
values[name] = field.defaultValue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const [key, value] of Object.entries(recordValues)) {
|
|
67
|
+
values[key] = value;
|
|
68
|
+
}
|
|
69
|
+
for (const write of reactionWrites) {
|
|
70
|
+
values[write.field] = write.value;
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, values };
|
|
73
|
+
}
|
|
74
|
+
return { ok: false, code: "UNKNOWN_KIND" };
|
|
75
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe structural validation for vendored page/node/action/reaction
|
|
3
|
+
* schemas. Mirrors `protocol/conformance/schema-validate.ts` but imports the
|
|
4
|
+
* pinned `docs/schemas/*.json` at build time (Vite `@schemas` alias) instead of
|
|
5
|
+
* reading them from disk, so the runtime loader can enforce D-VAL in the
|
|
6
|
+
* browser. The schema set is identical, so runtime and test-time validators
|
|
7
|
+
* stay aligned and neither redefines upstream node/page semantics.
|
|
8
|
+
*/
|
|
9
|
+
import Ajv from "ajv";
|
|
10
|
+
import actionSchema from "@schemas/action.schema.json";
|
|
11
|
+
import nodeSchema from "@schemas/node.schema.json";
|
|
12
|
+
import pageSchema from "@schemas/page.schema.json";
|
|
13
|
+
import reactionSchema from "@schemas/reaction.schema.json";
|
|
14
|
+
let cached = null;
|
|
15
|
+
function buildValidators() {
|
|
16
|
+
const ajv = new Ajv({
|
|
17
|
+
allErrors: true,
|
|
18
|
+
strict: false,
|
|
19
|
+
validateSchema: false,
|
|
20
|
+
});
|
|
21
|
+
// Register by both $id and the relative filenames used in $ref so cross-schema
|
|
22
|
+
// references (page -> node -> reaction) resolve exactly like schema-validate.ts.
|
|
23
|
+
// GOAL-018 local extension: custom nodes carry a top-level component key.
|
|
24
|
+
// The extension is applied at validation time so the upstream-pinned
|
|
25
|
+
// node.schema.json artifact (I-PROTO-004, schema-ui-docs@2.9.0) stays
|
|
26
|
+
// byte-identical — this is NOT an upstream protocol change.
|
|
27
|
+
// The upstream node.schema.json types do not know the local extension;
|
|
28
|
+
// cast through unknown to keep the spread clean (local-only addition).
|
|
29
|
+
const extendedNodeSchema = {
|
|
30
|
+
...nodeSchema,
|
|
31
|
+
properties: {
|
|
32
|
+
...(nodeSchema.properties ?? {}),
|
|
33
|
+
component: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "GOAL-018 local extension: custom node component key (renderer custom-components registry)",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
ajv.addSchema(extendedNodeSchema);
|
|
40
|
+
ajv.addSchema(extendedNodeSchema, "node.schema.json");
|
|
41
|
+
ajv.addSchema(pageSchema);
|
|
42
|
+
ajv.addSchema(pageSchema, "page.schema.json");
|
|
43
|
+
ajv.addSchema(actionSchema);
|
|
44
|
+
ajv.addSchema(actionSchema, "action.schema.json");
|
|
45
|
+
ajv.addSchema(reactionSchema);
|
|
46
|
+
ajv.addSchema(reactionSchema, "reaction.schema.json");
|
|
47
|
+
return {
|
|
48
|
+
node: ajv.getSchema(nodeSchema.$id) ?? ajv.compile(nodeSchema),
|
|
49
|
+
page: ajv.getSchema(pageSchema.$id) ?? ajv.compile(pageSchema),
|
|
50
|
+
action: ajv.getSchema(actionSchema.$id) ?? ajv.compile(actionSchema),
|
|
51
|
+
reaction: ajv.getSchema(reactionSchema.$id) ?? ajv.compile(reactionSchema),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function getValidators() {
|
|
55
|
+
if (!cached) {
|
|
56
|
+
cached = buildValidators();
|
|
57
|
+
}
|
|
58
|
+
return cached;
|
|
59
|
+
}
|
|
60
|
+
function mapErrors(errors) {
|
|
61
|
+
if (!errors) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return errors.map((error) => ({
|
|
65
|
+
path: error.instancePath || "/",
|
|
66
|
+
message: error.message ?? "invalid",
|
|
67
|
+
keyword: error.keyword,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Structural validation of a fetched page document against the pinned page/node
|
|
72
|
+
* schemas. `ok: false` means the document must fail closed and never reach the
|
|
73
|
+
* renderer.
|
|
74
|
+
*/
|
|
75
|
+
export function validatePageDocument(document) {
|
|
76
|
+
const validate = getValidators().page;
|
|
77
|
+
const ok = validate(document);
|
|
78
|
+
return {
|
|
79
|
+
ok,
|
|
80
|
+
errors: ok ? [] : mapErrors(validate.errors),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural validation entry for vendored node/page/action/reaction schemas.
|
|
3
|
+
* Uses Ajv draft-07 against pinned schema-ui-docs@2.7.0 artifacts in docs/schemas/.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import Ajv from "ajv";
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const SCHEMAS_DIR = join(__dirname, "../../../../../docs/schemas");
|
|
11
|
+
function loadSchema(name) {
|
|
12
|
+
const bytes = readFileSync(join(SCHEMAS_DIR, name), "utf8");
|
|
13
|
+
return JSON.parse(bytes);
|
|
14
|
+
}
|
|
15
|
+
let cached = null;
|
|
16
|
+
function buildValidators() {
|
|
17
|
+
const ajv = new Ajv({
|
|
18
|
+
allErrors: true,
|
|
19
|
+
strict: false,
|
|
20
|
+
validateSchema: false,
|
|
21
|
+
});
|
|
22
|
+
const node = loadSchema("node.schema.json");
|
|
23
|
+
const page = loadSchema("page.schema.json");
|
|
24
|
+
const action = loadSchema("action.schema.json");
|
|
25
|
+
const reaction = loadSchema("reaction.schema.json");
|
|
26
|
+
// Register by both $id and relative filenames used in $ref.
|
|
27
|
+
ajv.addSchema(node);
|
|
28
|
+
ajv.addSchema(node, "node.schema.json");
|
|
29
|
+
ajv.addSchema(page);
|
|
30
|
+
ajv.addSchema(page, "page.schema.json");
|
|
31
|
+
ajv.addSchema(action);
|
|
32
|
+
ajv.addSchema(action, "action.schema.json");
|
|
33
|
+
ajv.addSchema(reaction);
|
|
34
|
+
ajv.addSchema(reaction, "reaction.schema.json");
|
|
35
|
+
return {
|
|
36
|
+
node: ajv.getSchema(node.$id) ?? ajv.compile(node),
|
|
37
|
+
page: ajv.getSchema(page.$id) ?? ajv.compile(page),
|
|
38
|
+
action: ajv.getSchema(action.$id) ?? ajv.compile(action),
|
|
39
|
+
reaction: ajv.getSchema(reaction.$id) ?? ajv.compile(reaction),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function getValidators() {
|
|
43
|
+
if (!cached) {
|
|
44
|
+
cached = { validators: buildValidators() };
|
|
45
|
+
}
|
|
46
|
+
return cached.validators;
|
|
47
|
+
}
|
|
48
|
+
function mapErrors(errors) {
|
|
49
|
+
if (!errors) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return errors.map((error) => ({
|
|
53
|
+
path: error.instancePath || "/",
|
|
54
|
+
message: error.message ?? "invalid",
|
|
55
|
+
keyword: error.keyword,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
export function validateAgainstSchema(kind, document) {
|
|
59
|
+
const validate = getValidators()[kind];
|
|
60
|
+
const ok = validate(document);
|
|
61
|
+
return {
|
|
62
|
+
ok,
|
|
63
|
+
errors: ok ? [] : mapErrors(validate.errors),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Minimal valid page document using §5 whitelist types (for structural smoke). */
|
|
67
|
+
export function sampleWhitelistedPage() {
|
|
68
|
+
return {
|
|
69
|
+
meta: {
|
|
70
|
+
pageId: "sample-form",
|
|
71
|
+
title: "Sample Form",
|
|
72
|
+
protocolVersion: "2.7",
|
|
73
|
+
requiredCapabilities: ["form.controls.extended", "form.controls.advanced"],
|
|
74
|
+
},
|
|
75
|
+
body: {
|
|
76
|
+
type: "form",
|
|
77
|
+
props: {
|
|
78
|
+
fields: [
|
|
79
|
+
{ type: "input", field: "name", label: "Name" },
|
|
80
|
+
{ type: "textarea", field: "notes", label: "Notes" },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
children: [
|
|
84
|
+
{
|
|
85
|
+
type: "section",
|
|
86
|
+
props: { title: "Details" },
|
|
87
|
+
children: [
|
|
88
|
+
{ type: "text", props: { content: "Hello" } },
|
|
89
|
+
{
|
|
90
|
+
type: "table",
|
|
91
|
+
props: {
|
|
92
|
+
columns: [{ field: "id", label: "ID" }],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: "actionButton",
|
|
97
|
+
props: { label: "Save" },
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: "recordView",
|
|
101
|
+
props: { title: "Record" },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: "grid",
|
|
107
|
+
children: [{ type: "text", props: { content: "Cell" } }],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: "tabs",
|
|
111
|
+
props: {
|
|
112
|
+
items: [{ key: "a", label: "A" }],
|
|
113
|
+
},
|
|
114
|
+
children: [{ type: "text", props: { content: "Tab A" } }],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search-table fixture adapter — four-layer query merge + selection events.
|
|
3
|
+
*/
|
|
4
|
+
import { serializeQuery } from "./query-serialize.js";
|
|
5
|
+
function isScalarKey(value) {
|
|
6
|
+
return (typeof value === "string" ||
|
|
7
|
+
typeof value === "number" ||
|
|
8
|
+
typeof value === "boolean");
|
|
9
|
+
}
|
|
10
|
+
function dedupeKeys(keys) {
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const key of keys) {
|
|
14
|
+
if (!isScalarKey(key)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const token = `${typeof key}:${String(key)}`;
|
|
18
|
+
if (seen.has(token)) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
seen.add(token);
|
|
22
|
+
out.push(key);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function buildUrl(baseUrl, staticParams, state) {
|
|
27
|
+
const sources = [];
|
|
28
|
+
const staticPairs = Object.entries(staticParams);
|
|
29
|
+
if (staticPairs.length > 0) {
|
|
30
|
+
sources.push(staticPairs);
|
|
31
|
+
}
|
|
32
|
+
const statePairs = [];
|
|
33
|
+
for (const [key, value] of Object.entries(state.filters)) {
|
|
34
|
+
statePairs.push([key, value]);
|
|
35
|
+
}
|
|
36
|
+
statePairs.push(["page", state.page]);
|
|
37
|
+
statePairs.push(["pageSize", state.pageSize]);
|
|
38
|
+
if (state.sort !== null && state.sort !== undefined) {
|
|
39
|
+
statePairs.push(["sort", state.sort]);
|
|
40
|
+
}
|
|
41
|
+
sources.push(statePairs);
|
|
42
|
+
const result = serializeQuery(baseUrl, sources);
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
throw new Error(result.code);
|
|
45
|
+
}
|
|
46
|
+
return result.url;
|
|
47
|
+
}
|
|
48
|
+
export function runSearchTable(input) {
|
|
49
|
+
const baseUrl = input.baseUrl;
|
|
50
|
+
const staticParams = input.staticParams ?? {};
|
|
51
|
+
let state = {
|
|
52
|
+
filters: { ...(input.state?.filters ?? {}) },
|
|
53
|
+
page: input.state?.page ?? 1,
|
|
54
|
+
pageSize: input.state?.pageSize ?? 20,
|
|
55
|
+
sort: input.state?.sort ?? null,
|
|
56
|
+
};
|
|
57
|
+
let selection = input.selection
|
|
58
|
+
? {
|
|
59
|
+
keys: [...(input.selection.keys ?? [])],
|
|
60
|
+
count: input.selection.count ?? 0,
|
|
61
|
+
}
|
|
62
|
+
: undefined;
|
|
63
|
+
const event = input.event;
|
|
64
|
+
const selectionEvent = input.selectionEvent;
|
|
65
|
+
if (event) {
|
|
66
|
+
switch (event.type) {
|
|
67
|
+
case "submitSearch": {
|
|
68
|
+
state = {
|
|
69
|
+
...state,
|
|
70
|
+
filters: { ...(event.filters ?? {}) },
|
|
71
|
+
page: 1,
|
|
72
|
+
};
|
|
73
|
+
if (selection) {
|
|
74
|
+
selection = { keys: [], count: 0 };
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "clearSearch": {
|
|
79
|
+
state = { ...state, filters: {}, page: 1 };
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "changePage": {
|
|
83
|
+
state = { ...state, page: event.page };
|
|
84
|
+
if (selection) {
|
|
85
|
+
selection = { keys: [], count: 0 };
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "changeSort": {
|
|
90
|
+
state = { ...state, sort: event.sort, page: 1 };
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (selectionEvent?.type === "setKeys") {
|
|
98
|
+
const keys = dedupeKeys(selectionEvent.keys ?? []);
|
|
99
|
+
selection = { keys, count: keys.length };
|
|
100
|
+
}
|
|
101
|
+
const url = buildUrl(baseUrl, staticParams, state);
|
|
102
|
+
const result = { state, url };
|
|
103
|
+
if (selection !== undefined || input.selection !== undefined || selectionEvent) {
|
|
104
|
+
result.selection = selection ?? { keys: [], count: 0 };
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|