@cosmicdrift/kumiko-renderer 0.262.0 → 0.264.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/package.json +4 -4
- package/src/__tests__/merge-search-params-into-initial.test.ts +145 -38
- package/src/app/__tests__/entity-edit-create-search-params.test.tsx +135 -41
- package/src/app/kumiko-screen.tsx +125 -84
- package/src/app/nav.tsx +72 -2
- package/src/app/secret-mint-body.tsx +9 -7
- package/src/components/__tests__/render-field-number-unit.test.tsx +36 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.264.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.264.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.264.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.264.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -12,34 +12,133 @@ type FieldDef = {
|
|
|
12
12
|
maxItems?: number;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
function mergeWithAllFieldsUrlPrefillable(
|
|
16
|
+
fields: Record<string, FieldDef>,
|
|
17
|
+
searchParams: Record<string, string>,
|
|
18
|
+
renderableFields?: ReadonlySet<string>,
|
|
19
|
+
defaultCurrency?: string,
|
|
20
|
+
): Record<string, unknown> {
|
|
21
|
+
return mergeSearchParamsIntoInitial(fields, {
|
|
22
|
+
searchParams,
|
|
23
|
+
urlPrefillFields: Object.keys(fields),
|
|
24
|
+
...(renderableFields !== undefined && { renderableFields }),
|
|
25
|
+
...(defaultCurrency !== undefined && { defaultCurrency }),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("mergeSearchParamsIntoInitial — urlPrefillFields allowlist", () => {
|
|
30
|
+
const fields: Record<string, FieldDef> = {
|
|
31
|
+
leaseId: { type: "text" },
|
|
32
|
+
iban: { type: "text", default: "DE00" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
test("a URL param for a declared field prefills it", () => {
|
|
36
|
+
const result = mergeSearchParamsIntoInitial(fields, {
|
|
37
|
+
searchParams: { leaseId: "l-1" },
|
|
38
|
+
urlPrefillFields: ["leaseId"],
|
|
39
|
+
});
|
|
40
|
+
expect(result["leaseId"]).toBe("l-1");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a crafted link cannot prefill a field no navigate params declares", () => {
|
|
44
|
+
const result = mergeSearchParamsIntoInitial(fields, {
|
|
45
|
+
searchParams: { leaseId: "l-1", iban: "attacker-iban" },
|
|
46
|
+
urlPrefillFields: ["leaseId"],
|
|
47
|
+
});
|
|
48
|
+
expect(result["leaseId"]).toBe("l-1");
|
|
49
|
+
expect(result["iban"]).toBe("DE00");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("no urlPrefillFields (screen not from buildAppSchema) prefills nothing from the URL", () => {
|
|
53
|
+
const result = mergeSearchParamsIntoInitial(fields, {
|
|
54
|
+
searchParams: { leaseId: "l-1", iban: "attacker-iban" },
|
|
55
|
+
urlPrefillFields: undefined,
|
|
56
|
+
});
|
|
57
|
+
expect(result["leaseId"]).toBe("");
|
|
58
|
+
expect(result["iban"]).toBe("DE00");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("sensitive and password fields stay blocked even when allowlisted", () => {
|
|
62
|
+
const gated: Record<string, FieldDef> = {
|
|
63
|
+
secret: { type: "text", sensitive: true },
|
|
64
|
+
apiToken: { type: "text", format: "password", default: "unset" },
|
|
65
|
+
};
|
|
66
|
+
const result = mergeSearchParamsIntoInitial(gated, {
|
|
67
|
+
searchParams: { secret: "s", apiToken: "kpat_leak" },
|
|
68
|
+
urlPrefillFields: ["secret", "apiToken"],
|
|
69
|
+
});
|
|
70
|
+
expect(result["secret"]).toBe("");
|
|
71
|
+
expect(result["apiToken"]).toBe("unset");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("mergeSearchParamsIntoInitial — handoffValues", () => {
|
|
76
|
+
const fields: Record<string, FieldDef> = {
|
|
77
|
+
iban: { type: "text" },
|
|
78
|
+
amount: { type: "number", default: 0 },
|
|
79
|
+
secret: { type: "text", sensitive: true },
|
|
80
|
+
apiToken: { type: "text", format: "password" },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
test("handed-off values prefill fields outside urlPrefillFields, strings coerced by field type", () => {
|
|
84
|
+
const result = mergeSearchParamsIntoInitial(fields, {
|
|
85
|
+
searchParams: {},
|
|
86
|
+
urlPrefillFields: [],
|
|
87
|
+
handoffValues: { iban: "DE12", amount: "42" },
|
|
88
|
+
});
|
|
89
|
+
expect(result["iban"]).toBe("DE12");
|
|
90
|
+
expect(result["amount"]).toBe(42);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("handed-off values never reach sensitive or password fields", () => {
|
|
94
|
+
const result = mergeSearchParamsIntoInitial(fields, {
|
|
95
|
+
searchParams: {},
|
|
96
|
+
urlPrefillFields: [],
|
|
97
|
+
handoffValues: { secret: "s", apiToken: "kpat_leak" },
|
|
98
|
+
});
|
|
99
|
+
expect(result["secret"]).toBe("");
|
|
100
|
+
expect(result["apiToken"]).toBe("");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("drawer overrides win over a handoff for the same field", () => {
|
|
104
|
+
const result = mergeSearchParamsIntoInitial(fields, {
|
|
105
|
+
searchParams: {},
|
|
106
|
+
urlPrefillFields: [],
|
|
107
|
+
drawerOverrides: { iban: "from-row" },
|
|
108
|
+
handoffValues: { iban: "from-handoff" },
|
|
109
|
+
});
|
|
110
|
+
expect(result["iban"]).toBe("from-row");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("mergeSearchParamsIntoInitial — coercion (every field URL-prefillable)", () => {
|
|
16
115
|
test("raw string param merges in as-is for a text field", () => {
|
|
17
116
|
const fields: Record<string, FieldDef> = { name: { type: "text" } };
|
|
18
|
-
const result =
|
|
117
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { name: "Alice" });
|
|
19
118
|
expect(result["name"]).toBe("Alice");
|
|
20
119
|
});
|
|
21
120
|
|
|
22
121
|
test("number-type field coerces a numeric string", () => {
|
|
23
122
|
const fields: Record<string, FieldDef> = { age: { type: "number" } };
|
|
24
|
-
const result =
|
|
123
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { age: "42" });
|
|
25
124
|
expect(result["age"]).toBe(42);
|
|
26
125
|
});
|
|
27
126
|
|
|
28
127
|
test("invalid number string falls back to field default", () => {
|
|
29
128
|
const fields: Record<string, FieldDef> = { count: { type: "number", default: 7 } };
|
|
30
|
-
const result =
|
|
129
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { count: "not-a-number" });
|
|
31
130
|
expect(result["count"]).toBe(7);
|
|
32
131
|
});
|
|
33
132
|
|
|
34
133
|
test("boolean field coerces 'true' and 'false'", () => {
|
|
35
134
|
const fields: Record<string, FieldDef> = { active: { type: "boolean" } };
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
135
|
+
expect(mergeWithAllFieldsUrlPrefillable(fields, { active: "true" })["active"]).toBe(true);
|
|
136
|
+
expect(mergeWithAllFieldsUrlPrefillable(fields, { active: "false" })["active"]).toBe(false);
|
|
38
137
|
});
|
|
39
138
|
|
|
40
139
|
test("sensitive field is skipped even when a matching searchParam exists", () => {
|
|
41
140
|
const fields: Record<string, FieldDef> = { password: { type: "text", sensitive: true } };
|
|
42
|
-
const result =
|
|
141
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { password: "secret" });
|
|
43
142
|
expect(result["password"]).toBe("");
|
|
44
143
|
});
|
|
45
144
|
|
|
@@ -47,31 +146,31 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
47
146
|
const fields: Record<string, FieldDef> = {
|
|
48
147
|
apiToken: { type: "text", format: "password", default: "unset" },
|
|
49
148
|
};
|
|
50
|
-
const result =
|
|
149
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { apiToken: "kpat_leak" });
|
|
51
150
|
expect(result["apiToken"]).toBe("unset");
|
|
52
151
|
});
|
|
53
152
|
|
|
54
153
|
test("field with no matching searchParam keeps its buildInitialValues default", () => {
|
|
55
154
|
const fields: Record<string, FieldDef> = { total: { type: "number", default: 100 } };
|
|
56
|
-
const result =
|
|
155
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, {});
|
|
57
156
|
expect(result["total"]).toBe(100);
|
|
58
157
|
});
|
|
59
158
|
|
|
60
159
|
test("money-type field without a defaultCurrency coerces a bare numeric string (legacy callers, e.g. config-edit/action-form)", () => {
|
|
61
160
|
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
62
|
-
const result =
|
|
161
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "19.99" });
|
|
63
162
|
expect(result["price"]).toBe(19.99);
|
|
64
163
|
});
|
|
65
164
|
|
|
66
165
|
test("money-type field WITH a defaultCurrency merges the entityEdit payload shape (#1923)", () => {
|
|
67
166
|
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
68
|
-
const result =
|
|
167
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "19.99" }, undefined, "USD");
|
|
69
168
|
expect(result["price"]).toEqual({ amount: 19.99, currency: "USD" });
|
|
70
169
|
});
|
|
71
170
|
|
|
72
171
|
test("money-type field WITH a defaultCurrency but no matching searchParam still defaults to the object shape", () => {
|
|
73
172
|
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
74
|
-
const result =
|
|
173
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, {}, undefined, "USD");
|
|
75
174
|
expect(result["price"]).toEqual({ amount: 0, currency: "USD" });
|
|
76
175
|
});
|
|
77
176
|
|
|
@@ -80,7 +179,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
80
179
|
status: { type: "text", default: "draft" },
|
|
81
180
|
ownerId: { type: "text" },
|
|
82
181
|
};
|
|
83
|
-
const result =
|
|
182
|
+
const result = mergeWithAllFieldsUrlPrefillable(
|
|
84
183
|
fields,
|
|
85
184
|
{ status: "approved", ownerId: "user-123" },
|
|
86
185
|
new Set(["status"]),
|
|
@@ -91,16 +190,16 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
91
190
|
|
|
92
191
|
test("no renderableFields set given (undefined): behaves as before, all fields eligible", () => {
|
|
93
192
|
const fields: Record<string, FieldDef> = { ownerId: { type: "text" } };
|
|
94
|
-
const result =
|
|
193
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { ownerId: "user-123" });
|
|
95
194
|
expect(result["ownerId"]).toBe("user-123");
|
|
96
195
|
});
|
|
97
196
|
|
|
98
197
|
test("multiSelect coerces comma-separated searchParam to string[]", () => {
|
|
99
198
|
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
100
|
-
expect(
|
|
199
|
+
expect(mergeWithAllFieldsUrlPrefillable(fields, { roles: "TenantAdmin" })["roles"]).toEqual([
|
|
101
200
|
"TenantAdmin",
|
|
102
201
|
]);
|
|
103
|
-
expect(
|
|
202
|
+
expect(mergeWithAllFieldsUrlPrefillable(fields, { roles: "Admin,User" })["roles"]).toEqual([
|
|
104
203
|
"Admin",
|
|
105
204
|
"User",
|
|
106
205
|
]);
|
|
@@ -109,19 +208,21 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
109
208
|
test("multiSelect coerces JSON-array searchParam to string[]", () => {
|
|
110
209
|
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
111
210
|
expect(
|
|
112
|
-
|
|
211
|
+
mergeWithAllFieldsUrlPrefillable(fields, { roles: JSON.stringify(["Admin", "Editor"]) })[
|
|
212
|
+
"roles"
|
|
213
|
+
],
|
|
113
214
|
).toEqual(["Admin", "Editor"]);
|
|
114
215
|
});
|
|
115
216
|
|
|
116
217
|
test("multiSelect defaults to [] when unset", () => {
|
|
117
218
|
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
118
|
-
expect(
|
|
219
|
+
expect(mergeWithAllFieldsUrlPrefillable(fields, {})["roles"]).toEqual([]);
|
|
119
220
|
});
|
|
120
221
|
|
|
121
222
|
test("multiSelect prefers JSON parse even without '[' prefix for quoted strings", () => {
|
|
122
223
|
const fields: Record<string, FieldDef> = { tags: { type: "multiSelect" } };
|
|
123
224
|
expect(
|
|
124
|
-
|
|
225
|
+
mergeWithAllFieldsUrlPrefillable(fields, { tags: JSON.stringify("Berlin, Germany") })["tags"],
|
|
125
226
|
).toEqual(["Berlin, Germany"]);
|
|
126
227
|
});
|
|
127
228
|
|
|
@@ -130,13 +231,15 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
130
231
|
roles: { type: "multiSelect", options: ["Admin", "User"] },
|
|
131
232
|
};
|
|
132
233
|
expect(
|
|
133
|
-
|
|
234
|
+
mergeWithAllFieldsUrlPrefillable(fields, { roles: JSON.stringify(["Admin", "Hacker"]) })[
|
|
235
|
+
"roles"
|
|
236
|
+
],
|
|
134
237
|
).toEqual(["Admin"]);
|
|
135
238
|
});
|
|
136
239
|
|
|
137
240
|
test("money-type field parses a JSON {amount, currency} param without a defaultCurrency (fw#2763)", () => {
|
|
138
241
|
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
139
|
-
const result =
|
|
242
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, {
|
|
140
243
|
price: JSON.stringify({ amount: 19.99, currency: "CHF" }),
|
|
141
244
|
});
|
|
142
245
|
expect(result["price"]).toEqual({ amount: 19.99, currency: "CHF" });
|
|
@@ -144,7 +247,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
144
247
|
|
|
145
248
|
test("money-type field: explicit JSON currency wins over defaultCurrency", () => {
|
|
146
249
|
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
147
|
-
const result =
|
|
250
|
+
const result = mergeWithAllFieldsUrlPrefillable(
|
|
148
251
|
fields,
|
|
149
252
|
{ price: JSON.stringify({ amount: 19.99, currency: "CHF" }) },
|
|
150
253
|
undefined,
|
|
@@ -157,7 +260,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
157
260
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
158
261
|
try {
|
|
159
262
|
const fields: Record<string, FieldDef> = { price: { type: "money" } };
|
|
160
|
-
const result =
|
|
263
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "19.99" });
|
|
161
264
|
expect(result["price"]).toBe(19.99);
|
|
162
265
|
expect(warnSpy).toHaveBeenCalled();
|
|
163
266
|
expect(warnSpy.mock.calls[0]?.[0]).toContain("price");
|
|
@@ -172,7 +275,9 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
172
275
|
const fields: Record<string, FieldDef> = {
|
|
173
276
|
price: { type: "money", default: { amount: 0, currency: "EUR" } },
|
|
174
277
|
};
|
|
175
|
-
const result =
|
|
278
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, {
|
|
279
|
+
price: JSON.stringify({ amount: 5 }),
|
|
280
|
+
});
|
|
176
281
|
expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
|
|
177
282
|
expect(warnSpy).toHaveBeenCalled();
|
|
178
283
|
} finally {
|
|
@@ -186,7 +291,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
186
291
|
const fields: Record<string, FieldDef> = {
|
|
187
292
|
price: { type: "money", default: { amount: 0, currency: "EUR" } },
|
|
188
293
|
};
|
|
189
|
-
const result =
|
|
294
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, {
|
|
190
295
|
price: JSON.stringify({ amount: 5, currency: "<script>" }),
|
|
191
296
|
});
|
|
192
297
|
expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
|
|
@@ -202,7 +307,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
202
307
|
const fields: Record<string, FieldDef> = {
|
|
203
308
|
price: { type: "money", default: { amount: 0, currency: "EUR" } },
|
|
204
309
|
};
|
|
205
|
-
const result =
|
|
310
|
+
const result = mergeWithAllFieldsUrlPrefillable(fields, { price: "1e999" });
|
|
206
311
|
expect(result["price"]).toEqual({ amount: 0, currency: "EUR" });
|
|
207
312
|
} finally {
|
|
208
313
|
warnSpy.mockRestore();
|
|
@@ -230,12 +335,14 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
230
335
|
{ accountId: "bank", amount: 1299, qty: 2, posted: true, kind: "debit" },
|
|
231
336
|
{ accountId: "cash", amount: -1299, qty: 1, posted: false, kind: "credit" },
|
|
232
337
|
];
|
|
233
|
-
const result =
|
|
338
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
339
|
+
lines: JSON.stringify(rows),
|
|
340
|
+
});
|
|
234
341
|
expect(result["lines"]).toEqual(rows);
|
|
235
342
|
});
|
|
236
343
|
|
|
237
344
|
test("money cells stay signed minor-unit integers", () => {
|
|
238
|
-
const result =
|
|
345
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
239
346
|
lines: JSON.stringify([{ accountId: "bank", amount: -4200 }]),
|
|
240
347
|
});
|
|
241
348
|
expect(result["lines"]).toEqual([{ accountId: "bank", amount: -4200 }]);
|
|
@@ -244,7 +351,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
244
351
|
test("a fractional money cell rejects the whole prefill and warns", () => {
|
|
245
352
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
246
353
|
try {
|
|
247
|
-
const result =
|
|
354
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
248
355
|
lines: JSON.stringify([{ accountId: "bank", amount: 12.99 }]),
|
|
249
356
|
});
|
|
250
357
|
expect(result["lines"]).toEqual([]);
|
|
@@ -258,7 +365,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
258
365
|
test("a non-JSON param leaves the field empty and warns", () => {
|
|
259
366
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
260
367
|
try {
|
|
261
|
-
const result =
|
|
368
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), { lines: "not-json-at-all" });
|
|
262
369
|
expect(result["lines"]).toEqual([]);
|
|
263
370
|
expect(warnSpy).toHaveBeenCalled();
|
|
264
371
|
} finally {
|
|
@@ -269,7 +376,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
269
376
|
test("a JSON object instead of an array leaves the field empty and warns", () => {
|
|
270
377
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
271
378
|
try {
|
|
272
|
-
const result =
|
|
379
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
273
380
|
lines: JSON.stringify({ accountId: "bank" }),
|
|
274
381
|
});
|
|
275
382
|
expect(result["lines"]).toEqual([]);
|
|
@@ -282,7 +389,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
282
389
|
test("a row that is not an object leaves the field empty and warns", () => {
|
|
283
390
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
284
391
|
try {
|
|
285
|
-
const result =
|
|
392
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
286
393
|
lines: JSON.stringify([1, 2]),
|
|
287
394
|
});
|
|
288
395
|
expect(result["lines"]).toEqual([]);
|
|
@@ -295,7 +402,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
295
402
|
test("a select cell outside the declared options rejects the prefill and warns", () => {
|
|
296
403
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
297
404
|
try {
|
|
298
|
-
const result =
|
|
405
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
299
406
|
lines: JSON.stringify([{ accountId: "bank", kind: "sudo" }]),
|
|
300
407
|
});
|
|
301
408
|
expect(result["lines"]).toEqual([]);
|
|
@@ -306,14 +413,14 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
306
413
|
});
|
|
307
414
|
|
|
308
415
|
test("undeclared row keys are dropped instead of reaching the form", () => {
|
|
309
|
-
const result =
|
|
416
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
310
417
|
lines: JSON.stringify([{ accountId: "bank", secretFlag: "yes" }]),
|
|
311
418
|
});
|
|
312
419
|
expect(result["lines"]).toEqual([{ accountId: "bank" }]);
|
|
313
420
|
});
|
|
314
421
|
|
|
315
422
|
test("a __proto__ key in a row pollutes nothing", () => {
|
|
316
|
-
const result =
|
|
423
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {
|
|
317
424
|
lines: '[{"accountId":"bank","__proto__":{"polluted":true}}]',
|
|
318
425
|
});
|
|
319
426
|
expect(result["lines"]).toEqual([{ accountId: "bank" }]);
|
|
@@ -324,7 +431,7 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
324
431
|
test("more rows than maxItems leaves the field empty and warns", () => {
|
|
325
432
|
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
326
433
|
try {
|
|
327
|
-
const result =
|
|
434
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField({ maxItems: 2 }), {
|
|
328
435
|
lines: JSON.stringify([{ accountId: "a" }, { accountId: "b" }, { accountId: "c" }]),
|
|
329
436
|
});
|
|
330
437
|
expect(result["lines"]).toEqual([]);
|
|
@@ -335,12 +442,12 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
335
442
|
});
|
|
336
443
|
|
|
337
444
|
test("an embedded list without a matching param defaults to an empty array", () => {
|
|
338
|
-
const result =
|
|
445
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField(), {});
|
|
339
446
|
expect(result["lines"]).toEqual([]);
|
|
340
447
|
});
|
|
341
448
|
|
|
342
449
|
test("a sensitive embedded list ignores the param", () => {
|
|
343
|
-
const result =
|
|
450
|
+
const result = mergeWithAllFieldsUrlPrefillable(linesField({ sensitive: true }), {
|
|
344
451
|
lines: JSON.stringify([{ accountId: "bank" }]),
|
|
345
452
|
});
|
|
346
453
|
expect(result["lines"]).toEqual([]);
|
|
@@ -12,14 +12,14 @@ import type {
|
|
|
12
12
|
EntityEditScreenDefinition,
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
14
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
15
|
-
import { render } from "@testing-library/react";
|
|
16
|
-
import type
|
|
15
|
+
import { act, render } from "@testing-library/react";
|
|
16
|
+
import { type ComponentType, type ReactNode, useState } from "react";
|
|
17
17
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
18
18
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
19
19
|
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
20
20
|
import type { FeatureSchema } from "../feature-schema";
|
|
21
21
|
import { KumikoScreen } from "../kumiko-screen";
|
|
22
|
-
import { NavProvider } from "../nav";
|
|
22
|
+
import { type NavApi, NavProvider, type NavTarget, useNavigateWithInitialValues } from "../nav";
|
|
23
23
|
|
|
24
24
|
const captured: Record<string, InputProps | undefined> = {};
|
|
25
25
|
const captureInput: ComponentType<InputProps> = (props) => {
|
|
@@ -65,18 +65,29 @@ function stubDispatcher(): Dispatcher {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
function buildSchema(): FeatureSchema {
|
|
68
|
+
function buildSchema(urlPrefillFields: readonly string[] | undefined): FeatureSchema {
|
|
69
69
|
const entity: EntityDefinition = {
|
|
70
70
|
fields: {
|
|
71
71
|
name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
|
|
72
72
|
floorCount: { type: "number", required: false, sortable: false },
|
|
73
|
+
ownerEmail: { type: "text", maxLength: 200, required: false, searchable: false },
|
|
74
|
+
accessCode: {
|
|
75
|
+
type: "text",
|
|
76
|
+
maxLength: 200,
|
|
77
|
+
required: false,
|
|
78
|
+
searchable: false,
|
|
79
|
+
sensitive: true,
|
|
80
|
+
},
|
|
73
81
|
},
|
|
74
82
|
};
|
|
75
83
|
const screen: EntityEditScreenDefinition = {
|
|
76
84
|
id: "unit-edit",
|
|
77
85
|
type: "entityEdit",
|
|
78
86
|
entity: "unit",
|
|
79
|
-
layout: {
|
|
87
|
+
layout: {
|
|
88
|
+
sections: [{ columns: 1, fields: ["name", "floorCount", "ownerEmail", "accessCode"] }],
|
|
89
|
+
},
|
|
90
|
+
...(urlPrefillFields !== undefined && { urlPrefillFields }),
|
|
80
91
|
};
|
|
81
92
|
return {
|
|
82
93
|
featureName: "housing",
|
|
@@ -85,29 +96,41 @@ function buildSchema(): FeatureSchema {
|
|
|
85
96
|
} as FeatureSchema;
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
function staticNav(searchParams: Record<string, string>): NavApi {
|
|
100
|
+
return {
|
|
101
|
+
route: { screenId: "housing:unit-edit" },
|
|
102
|
+
navigate: () => {},
|
|
103
|
+
replace: () => {},
|
|
104
|
+
hrefFor: () => "",
|
|
105
|
+
searchParams,
|
|
106
|
+
setSearchParams: () => {},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderWithNav(nav: NavApi, schema: FeatureSchema): void {
|
|
111
|
+
render(
|
|
112
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
|
|
113
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
114
|
+
<NavProvider value={nav}>
|
|
115
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
116
|
+
<KumikoScreen schema={schema} qn="housing:screen:unit-edit" />
|
|
117
|
+
</PrimitivesProvider>
|
|
118
|
+
</NavProvider>
|
|
119
|
+
</DispatcherProvider>
|
|
120
|
+
</LocaleProvider>,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resetCaptured(): void {
|
|
125
|
+
for (const key of Object.keys(captured)) delete captured[key];
|
|
126
|
+
}
|
|
127
|
+
|
|
88
128
|
describe("EntityEditCreateBody — navigate params as initial values (#1680)", () => {
|
|
89
129
|
test("URL searchParams from rowAction navigate prefill the create form", () => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
95
|
-
<NavProvider
|
|
96
|
-
value={{
|
|
97
|
-
route: { screenId: "housing:unit-edit" },
|
|
98
|
-
navigate: () => {},
|
|
99
|
-
replace: () => {},
|
|
100
|
-
hrefFor: () => "",
|
|
101
|
-
searchParams: { name: "Erdgeschoss", floorCount: "3" },
|
|
102
|
-
setSearchParams: () => {},
|
|
103
|
-
}}
|
|
104
|
-
>
|
|
105
|
-
<PrimitivesProvider value={testPrimitives}>
|
|
106
|
-
<KumikoScreen schema={buildSchema()} qn="housing:screen:unit-edit" />
|
|
107
|
-
</PrimitivesProvider>
|
|
108
|
-
</NavProvider>
|
|
109
|
-
</DispatcherProvider>
|
|
110
|
-
</LocaleProvider>,
|
|
130
|
+
resetCaptured();
|
|
131
|
+
renderWithNav(
|
|
132
|
+
staticNav({ name: "Erdgeschoss", floorCount: "3" }),
|
|
133
|
+
buildSchema(["name", "floorCount"]),
|
|
111
134
|
);
|
|
112
135
|
|
|
113
136
|
expect(captured["name"]?.value).toBe("Erdgeschoss");
|
|
@@ -115,28 +138,99 @@ describe("EntityEditCreateBody — navigate params as initial values (#1680)", (
|
|
|
115
138
|
});
|
|
116
139
|
|
|
117
140
|
test("without a matching searchParam the field keeps its default (empty)", () => {
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
resetCaptured();
|
|
142
|
+
renderWithNav(staticNav({}), buildSchema(["name"]));
|
|
143
|
+
|
|
144
|
+
expect(captured["name"]?.value).toBe("");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a crafted link cannot prefill a field outside urlPrefillFields", () => {
|
|
148
|
+
resetCaptured();
|
|
149
|
+
renderWithNav(
|
|
150
|
+
staticNav({ name: "Erdgeschoss", ownerEmail: "attacker@example.com" }),
|
|
151
|
+
buildSchema(["name"]),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(captured["name"]?.value).toBe("Erdgeschoss");
|
|
155
|
+
expect(captured["ownerEmail"]?.value).toBe("");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a screen without urlPrefillFields takes nothing from the URL", () => {
|
|
159
|
+
resetCaptured();
|
|
160
|
+
renderWithNav(staticNav({ name: "Erdgeschoss" }), buildSchema(undefined));
|
|
161
|
+
|
|
162
|
+
expect(captured["name"]?.value).toBe("");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a sensitive field stays empty even when urlPrefillFields names it", () => {
|
|
166
|
+
resetCaptured();
|
|
167
|
+
renderWithNav(staticNav({ accessCode: "1234" }), buildSchema(["accessCode"]));
|
|
168
|
+
|
|
169
|
+
expect(captured["accessCode"]?.value).toBe("");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("EntityEditCreateBody — useNavigateWithInitialValues", () => {
|
|
174
|
+
function HandoffApp({
|
|
175
|
+
schema,
|
|
176
|
+
initialValues,
|
|
177
|
+
}: {
|
|
178
|
+
readonly schema: FeatureSchema;
|
|
179
|
+
readonly initialValues: Readonly<Record<string, unknown>>;
|
|
180
|
+
}): ReactNode {
|
|
181
|
+
const [screenId, setScreenId] = useState("home");
|
|
182
|
+
const nav: NavApi = {
|
|
183
|
+
route: { screenId },
|
|
184
|
+
navigate: (target: NavTarget) => {
|
|
185
|
+
if ("screenId" in target) setScreenId(target.screenId);
|
|
186
|
+
},
|
|
187
|
+
replace: () => {},
|
|
188
|
+
hrefFor: () => "",
|
|
189
|
+
searchParams: {},
|
|
190
|
+
setSearchParams: () => {
|
|
191
|
+
throw new Error("the handoff must not write the query string");
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
return (
|
|
120
195
|
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
|
|
121
196
|
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
122
|
-
<NavProvider
|
|
123
|
-
value={{
|
|
124
|
-
route: { screenId: "housing:unit-edit" },
|
|
125
|
-
navigate: () => {},
|
|
126
|
-
replace: () => {},
|
|
127
|
-
hrefFor: () => "",
|
|
128
|
-
searchParams: {},
|
|
129
|
-
setSearchParams: () => {},
|
|
130
|
-
}}
|
|
131
|
-
>
|
|
197
|
+
<NavProvider value={nav}>
|
|
132
198
|
<PrimitivesProvider value={testPrimitives}>
|
|
133
|
-
|
|
199
|
+
{screenId === "unit-edit" ? (
|
|
200
|
+
<KumikoScreen schema={schema} qn="housing:screen:unit-edit" />
|
|
201
|
+
) : (
|
|
202
|
+
<Opener initialValues={initialValues} />
|
|
203
|
+
)}
|
|
134
204
|
</PrimitivesProvider>
|
|
135
205
|
</NavProvider>
|
|
136
206
|
</DispatcherProvider>
|
|
137
|
-
</LocaleProvider
|
|
207
|
+
</LocaleProvider>
|
|
138
208
|
);
|
|
209
|
+
}
|
|
139
210
|
|
|
140
|
-
|
|
211
|
+
let openForm: (() => void) | undefined;
|
|
212
|
+
function Opener({
|
|
213
|
+
initialValues,
|
|
214
|
+
}: {
|
|
215
|
+
readonly initialValues: Readonly<Record<string, unknown>>;
|
|
216
|
+
}): ReactNode {
|
|
217
|
+
const navigateWithInitialValues = useNavigateWithInitialValues();
|
|
218
|
+
openForm = () => navigateWithInitialValues({ screenId: "unit-edit" }, initialValues);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("handed-off values prefill fields outside urlPrefillFields, but never a sensitive one", () => {
|
|
223
|
+
resetCaptured();
|
|
224
|
+
render(
|
|
225
|
+
<HandoffApp
|
|
226
|
+
schema={buildSchema([])}
|
|
227
|
+
initialValues={{ ownerEmail: "owner@example.com", floorCount: "2", accessCode: "1234" }}
|
|
228
|
+
/>,
|
|
229
|
+
);
|
|
230
|
+
act(() => openForm?.());
|
|
231
|
+
|
|
232
|
+
expect(captured["ownerEmail"]?.value).toBe("owner@example.com");
|
|
233
|
+
expect(captured["floorCount"]?.value).toBe(2);
|
|
234
|
+
expect(captured["accessCode"]?.value).toBe("");
|
|
141
235
|
});
|
|
142
236
|
});
|
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
type ResolvedFacetSpec,
|
|
70
70
|
resolveProjectionFacetSpecs,
|
|
71
71
|
} from "./list-facets";
|
|
72
|
-
import { type NavApi, useNav } from "./nav";
|
|
72
|
+
import { type NavApi, useInitialValuesHandoff, useNav } from "./nav";
|
|
73
73
|
import {
|
|
74
74
|
synthesizeProjectionDetailEntity,
|
|
75
75
|
synthesizeProjectionDetailScreen,
|
|
@@ -526,87 +526,116 @@ function coerceEmbeddedListRows(
|
|
|
526
526
|
return rows;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
type PrefillFieldShape = {
|
|
530
|
+
readonly type?: string;
|
|
531
|
+
readonly sensitive?: boolean;
|
|
532
|
+
readonly format?: string;
|
|
533
|
+
readonly options?: readonly (string | { readonly value: string })[];
|
|
534
|
+
readonly multiple?: boolean;
|
|
535
|
+
readonly schema?: Readonly<Record<string, EmbeddedCellShape>>;
|
|
536
|
+
readonly maxItems?: number;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
export type InitialValueSources = {
|
|
540
|
+
readonly searchParams: Readonly<Record<string, string>>;
|
|
541
|
+
// Only these URL query keys may prefill — the target screen's
|
|
542
|
+
// `urlPrefillFields` from buildAppSchema. Absent means none: a crafted link
|
|
543
|
+
// must not seed fields no declared navigate `params` names.
|
|
544
|
+
readonly urlPrefillFields: readonly string[] | undefined;
|
|
545
|
+
readonly renderableFields?: ReadonlySet<string>;
|
|
546
|
+
readonly defaultCurrency?: string;
|
|
547
|
+
// Drawer-kind row actions (fw#2710) prefill from the clicked row's
|
|
548
|
+
// already-typed values; wins over every other source.
|
|
549
|
+
readonly drawerOverrides?: Readonly<Record<string, unknown>>;
|
|
550
|
+
// useNavigateWithInitialValues — in-app only, never in the URL, so it
|
|
551
|
+
// bypasses urlPrefillFields; string values get the URL coercion.
|
|
552
|
+
readonly handoffValues?: Readonly<Record<string, unknown>>;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
function coercePrefillString(
|
|
556
|
+
raw: string,
|
|
557
|
+
name: string,
|
|
558
|
+
shape: PrefillFieldShape,
|
|
559
|
+
fallback: unknown,
|
|
560
|
+
defaultCurrency: string | undefined,
|
|
561
|
+
): unknown {
|
|
562
|
+
if (shape.type === "number") {
|
|
563
|
+
const parsed = Number(raw);
|
|
564
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
565
|
+
}
|
|
566
|
+
if (shape.type === "money") {
|
|
567
|
+
const coerced = coerceMoneyValue(raw, name, defaultCurrency);
|
|
568
|
+
return coerced === undefined ? fallback : coerced;
|
|
569
|
+
}
|
|
570
|
+
if (shape.type === "boolean") return raw === "true";
|
|
571
|
+
if (shape.type === "multiSelect") {
|
|
572
|
+
// Row-action navigate stringifies arrays via String(arr) → "a,b" (or
|
|
573
|
+
// JSON when the navigate helper JSON.stringifies). Try JSON first
|
|
574
|
+
// (even without a "[" prefix), then comma-split; filter against
|
|
575
|
+
// field.options when present so unknown values never reach the form.
|
|
576
|
+
const optionValues = multiSelectOptionValues(shape);
|
|
577
|
+
const splitComma = (): string[] =>
|
|
578
|
+
raw === ""
|
|
579
|
+
? []
|
|
580
|
+
: raw
|
|
581
|
+
.split(",")
|
|
582
|
+
.map((s) => s.trim())
|
|
583
|
+
.filter(Boolean);
|
|
584
|
+
let values: string[];
|
|
585
|
+
try {
|
|
586
|
+
const parsed: unknown = JSON.parse(raw);
|
|
587
|
+
if (Array.isArray(parsed)) {
|
|
588
|
+
values = parsed.map((v) => String(v));
|
|
589
|
+
} else if (typeof parsed === "string") {
|
|
590
|
+
values = [parsed];
|
|
591
|
+
} else {
|
|
592
|
+
values = splitComma();
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
values = splitComma();
|
|
596
|
+
}
|
|
597
|
+
return optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
|
|
598
|
+
}
|
|
599
|
+
if (shape.type === "embedded" && shape.multiple === true) {
|
|
600
|
+
return coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems) ?? fallback;
|
|
601
|
+
}
|
|
602
|
+
return raw;
|
|
603
|
+
}
|
|
604
|
+
|
|
529
605
|
export function mergeSearchParamsIntoInitial(
|
|
530
606
|
fields: Readonly<Record<string, unknown>>,
|
|
531
|
-
|
|
532
|
-
renderableFields?: ReadonlySet<string>,
|
|
533
|
-
defaultCurrency?: string,
|
|
534
|
-
// Drawer-kind row actions (fw#2710) prefill directly from the clicked
|
|
535
|
-
// row's already-typed values — no URL round-trip, so no string coercion.
|
|
536
|
-
// Goes through the same renderableFields/sensitive gates as searchParams
|
|
537
|
-
// (a `params` extractor could still name a hidden or sensitive field) and
|
|
538
|
-
// wins over searchParams for a name present in both.
|
|
539
|
-
overrides?: Readonly<Record<string, unknown>>,
|
|
607
|
+
sources: InitialValueSources,
|
|
540
608
|
): Record<string, unknown> {
|
|
609
|
+
const { searchParams, renderableFields, defaultCurrency, drawerOverrides, handoffValues } =
|
|
610
|
+
sources;
|
|
611
|
+
const urlPrefillFields = new Set(sources.urlPrefillFields ?? []);
|
|
541
612
|
const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
|
|
542
613
|
const merged: Record<string, unknown> = { ...defaults };
|
|
543
614
|
for (const [name, fieldDef] of Object.entries(fields)) {
|
|
544
615
|
if (renderableFields !== undefined && !renderableFields.has(name)) continue;
|
|
545
|
-
const shape = fieldDef as
|
|
546
|
-
|
|
547
|
-
sensitive?: boolean;
|
|
548
|
-
format?: string;
|
|
549
|
-
options?: readonly (string | { readonly value: string })[];
|
|
550
|
-
multiple?: boolean;
|
|
551
|
-
schema?: Readonly<Record<string, EmbeddedCellShape>>;
|
|
552
|
-
maxItems?: number;
|
|
553
|
-
};
|
|
616
|
+
const shape = fieldDef as PrefillFieldShape;
|
|
617
|
+
// Neither gate is lifted by an allowlist entry or a handoff.
|
|
554
618
|
if (shape.sensitive === true) continue;
|
|
555
|
-
// A password field must never be prefilled from the URL, same as sensitive.
|
|
556
619
|
if (shape.format === "password") continue;
|
|
557
|
-
if (
|
|
558
|
-
merged[name] =
|
|
620
|
+
if (drawerOverrides !== undefined && name in drawerOverrides) {
|
|
621
|
+
merged[name] = drawerOverrides[name];
|
|
559
622
|
continue;
|
|
560
623
|
}
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
} else if (shape.type === "money") {
|
|
567
|
-
const coerced = coerceMoneyValue(raw, name, defaultCurrency);
|
|
568
|
-
merged[name] = coerced === undefined ? defaults[name] : coerced;
|
|
569
|
-
} else if (shape.type === "boolean") {
|
|
570
|
-
merged[name] = raw === "true";
|
|
571
|
-
} else if (shape.type === "multiSelect") {
|
|
572
|
-
// Row-action navigate stringifies arrays via String(arr) → "a,b" (or
|
|
573
|
-
// JSON when the navigate helper JSON.stringifies). Try JSON first
|
|
574
|
-
// (even without a "[" prefix), then comma-split; filter against
|
|
575
|
-
// field.options when present so unknown values never reach the form.
|
|
576
|
-
const optionValues = multiSelectOptionValues(shape);
|
|
577
|
-
let values: string[];
|
|
578
|
-
try {
|
|
579
|
-
const parsed: unknown = JSON.parse(raw);
|
|
580
|
-
if (Array.isArray(parsed)) {
|
|
581
|
-
values = parsed.map((v) => String(v));
|
|
582
|
-
} else if (typeof parsed === "string") {
|
|
583
|
-
values = [parsed];
|
|
584
|
-
} else {
|
|
585
|
-
values =
|
|
586
|
-
raw === ""
|
|
587
|
-
? []
|
|
588
|
-
: raw
|
|
589
|
-
.split(",")
|
|
590
|
-
.map((s) => s.trim())
|
|
591
|
-
.filter(Boolean);
|
|
592
|
-
}
|
|
593
|
-
} catch {
|
|
594
|
-
values =
|
|
595
|
-
raw === ""
|
|
596
|
-
? []
|
|
597
|
-
: raw
|
|
598
|
-
.split(",")
|
|
599
|
-
.map((s) => s.trim())
|
|
600
|
-
.filter(Boolean);
|
|
601
|
-
}
|
|
624
|
+
const handedOff =
|
|
625
|
+
handoffValues !== undefined && Object.hasOwn(handoffValues, name)
|
|
626
|
+
? handoffValues[name]
|
|
627
|
+
: undefined;
|
|
628
|
+
if (handedOff !== undefined) {
|
|
602
629
|
merged[name] =
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
} else {
|
|
608
|
-
merged[name] = raw;
|
|
630
|
+
typeof handedOff === "string"
|
|
631
|
+
? coercePrefillString(handedOff, name, shape, defaults[name], defaultCurrency)
|
|
632
|
+
: handedOff;
|
|
633
|
+
continue;
|
|
609
634
|
}
|
|
635
|
+
if (!urlPrefillFields.has(name)) continue;
|
|
636
|
+
const raw = searchParams[name];
|
|
637
|
+
if (raw === undefined) continue;
|
|
638
|
+
merged[name] = coercePrefillString(raw, name, shape, defaults[name], defaultCurrency);
|
|
610
639
|
}
|
|
611
640
|
return merged;
|
|
612
641
|
}
|
|
@@ -691,16 +720,18 @@ function EntityEditCreateBody({
|
|
|
691
720
|
readonly onSaved?: () => void;
|
|
692
721
|
}): ReactNode {
|
|
693
722
|
const nav = useNav();
|
|
723
|
+
const handoffValues = useInitialValuesHandoff(screen.id);
|
|
694
724
|
const appFeatures = useAppFeatures();
|
|
695
725
|
const initial = useMemo(
|
|
696
726
|
() =>
|
|
697
|
-
mergeSearchParamsIntoInitial(
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
layoutFieldNames(screen),
|
|
701
|
-
entity.defaultCurrency ?? "EUR",
|
|
702
|
-
|
|
703
|
-
|
|
727
|
+
mergeSearchParamsIntoInitial(entity.fields, {
|
|
728
|
+
searchParams: nav.searchParams,
|
|
729
|
+
urlPrefillFields: screen.urlPrefillFields,
|
|
730
|
+
renderableFields: layoutFieldNames(screen),
|
|
731
|
+
defaultCurrency: entity.defaultCurrency ?? "EUR",
|
|
732
|
+
...(handoffValues !== undefined && { handoffValues }),
|
|
733
|
+
}) as FormValues,
|
|
734
|
+
[entity.fields, nav.searchParams, screen, entity.defaultCurrency, handoffValues],
|
|
704
735
|
);
|
|
705
736
|
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
706
737
|
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
@@ -3038,16 +3069,26 @@ function ActionFormBody({
|
|
|
3038
3069
|
const appFeatures = useAppFeatures();
|
|
3039
3070
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
3040
3071
|
const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
|
|
3072
|
+
const pendingHandoff = useInitialValuesHandoff(screen.id);
|
|
3073
|
+
// A drawer-hosted form is not a navigation target, so it never takes a handoff.
|
|
3074
|
+
const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
|
|
3041
3075
|
const initial = useMemo(
|
|
3042
3076
|
() =>
|
|
3043
|
-
mergeSearchParamsIntoInitial(
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
layoutFieldNames(synthScreen),
|
|
3047
|
-
undefined,
|
|
3048
|
-
|
|
3049
|
-
) as FormValues,
|
|
3050
|
-
[
|
|
3077
|
+
mergeSearchParamsIntoInitial(screen.fields, {
|
|
3078
|
+
searchParams: nav.searchParams,
|
|
3079
|
+
urlPrefillFields: screen.urlPrefillFields,
|
|
3080
|
+
renderableFields: layoutFieldNames(synthScreen),
|
|
3081
|
+
...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
|
|
3082
|
+
...(handoffValues !== undefined && { handoffValues }),
|
|
3083
|
+
}) as FormValues,
|
|
3084
|
+
[
|
|
3085
|
+
screen.fields,
|
|
3086
|
+
screen.urlPrefillFields,
|
|
3087
|
+
nav.searchParams,
|
|
3088
|
+
synthScreen,
|
|
3089
|
+
initialOverrides,
|
|
3090
|
+
handoffValues,
|
|
3091
|
+
],
|
|
3051
3092
|
);
|
|
3052
3093
|
const handleSubmitted = useCallback(
|
|
3053
3094
|
(result: SubmitResult<unknown>) => {
|
package/src/app/nav.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { createContext, type ReactNode, useContext } from "react";
|
|
1
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
|
2
2
|
import type { FeatureSchema } from "./feature-schema";
|
|
3
|
+
import { lastSegment } from "./qn";
|
|
3
4
|
|
|
4
5
|
// Navigation-Contract, plattform-neutral. Types + Context + Hook leben
|
|
5
6
|
// hier; die konkrete Implementation (window.history im Web,
|
|
@@ -173,8 +174,77 @@ export type NavProviderProps = {
|
|
|
173
174
|
readonly value: NavApi;
|
|
174
175
|
};
|
|
175
176
|
|
|
177
|
+
type InitialValuesHandoff = {
|
|
178
|
+
readonly screenId: string;
|
|
179
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// A mutable slot instead of state: offering must not re-render the app, and
|
|
183
|
+
// the target form reads it once on mount.
|
|
184
|
+
type InitialValuesHandoffSlot = { current: InitialValuesHandoff | undefined };
|
|
185
|
+
|
|
186
|
+
const InitialValuesHandoffContext = createContext<InitialValuesHandoffSlot | undefined>(undefined);
|
|
187
|
+
|
|
176
188
|
export function NavProvider({ children, value }: NavProviderProps): ReactNode {
|
|
177
|
-
|
|
189
|
+
const [handoffSlot] = useState<InitialValuesHandoffSlot>(() => ({ current: undefined }));
|
|
190
|
+
const routeScreenId = value.route?.screenId;
|
|
191
|
+
// An offer whose form never mounted (e.g. already on that screen) must not
|
|
192
|
+
// resurface on a later, unrelated visit — drop it once the route moves elsewhere.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const pending = handoffSlot.current;
|
|
195
|
+
if (pending === undefined || routeScreenId === undefined) return;
|
|
196
|
+
if (lastSegment(routeScreenId) !== pending.screenId) handoffSlot.current = undefined;
|
|
197
|
+
}, [handoffSlot, routeScreenId]);
|
|
198
|
+
return (
|
|
199
|
+
<NavContext.Provider value={value}>
|
|
200
|
+
<InitialValuesHandoffContext.Provider value={handoffSlot}>
|
|
201
|
+
{children}
|
|
202
|
+
</InitialValuesHandoffContext.Provider>
|
|
203
|
+
</NavContext.Provider>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Navigates to a form screen (actionForm, secretMint, entityEdit-create) with
|
|
208
|
+
// initial values carried in memory — never in the URL, so a link cannot
|
|
209
|
+
// forge them and they are not limited to the screen's urlPrefillFields.
|
|
210
|
+
// sensitive and password fields are still never prefilled.
|
|
211
|
+
export function useNavigateWithInitialValues(): (
|
|
212
|
+
target: ScreenTarget,
|
|
213
|
+
initialValues: Readonly<Record<string, unknown>>,
|
|
214
|
+
) => void {
|
|
215
|
+
const nav = useNav();
|
|
216
|
+
const handoffSlot = useContext(InitialValuesHandoffContext);
|
|
217
|
+
return useCallback(
|
|
218
|
+
(target, initialValues) => {
|
|
219
|
+
if (handoffSlot !== undefined) {
|
|
220
|
+
handoffSlot.current = { screenId: lastSegment(target.screenId), values: initialValues };
|
|
221
|
+
}
|
|
222
|
+
nav.navigate(target);
|
|
223
|
+
},
|
|
224
|
+
[nav, handoffSlot],
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function useInitialValuesHandoff(
|
|
229
|
+
screenId: string,
|
|
230
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
231
|
+
const handoffSlot = useContext(InitialValuesHandoffContext);
|
|
232
|
+
const [values] = useState(() => {
|
|
233
|
+
const pending = handoffSlot?.current;
|
|
234
|
+
return pending !== undefined && pending.screenId === lastSegment(screenId)
|
|
235
|
+
? pending.values
|
|
236
|
+
: undefined;
|
|
237
|
+
});
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (
|
|
240
|
+
handoffSlot !== undefined &&
|
|
241
|
+
values !== undefined &&
|
|
242
|
+
handoffSlot.current?.values === values
|
|
243
|
+
) {
|
|
244
|
+
handoffSlot.current = undefined;
|
|
245
|
+
}
|
|
246
|
+
}, [handoffSlot, values]);
|
|
247
|
+
return values;
|
|
178
248
|
}
|
|
179
249
|
|
|
180
250
|
export function useNav(): NavApi {
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import type { FeatureSchema } from "./feature-schema";
|
|
13
13
|
import { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-screen";
|
|
14
14
|
import { layoutFieldNames } from "./layout-fields";
|
|
15
|
-
import { useNav } from "./nav";
|
|
15
|
+
import { useInitialValuesHandoff, useNav } from "./nav";
|
|
16
16
|
import { lastSegment } from "./qn";
|
|
17
17
|
|
|
18
18
|
export type SecretMintBodyProps = {
|
|
@@ -62,14 +62,16 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
62
62
|
const effectiveTranslate = translate ?? t;
|
|
63
63
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
64
64
|
const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
|
|
65
|
+
const handoffValues = useInitialValuesHandoff(screen.id);
|
|
65
66
|
const initial = useMemo(
|
|
66
67
|
() =>
|
|
67
|
-
mergeSearchParamsIntoInitial(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
layoutFieldNames(synthScreen),
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
mergeSearchParamsIntoInitial(screen.fields, {
|
|
69
|
+
searchParams: nav.searchParams,
|
|
70
|
+
urlPrefillFields: screen.urlPrefillFields,
|
|
71
|
+
renderableFields: layoutFieldNames(synthScreen),
|
|
72
|
+
...(handoffValues !== undefined && { handoffValues }),
|
|
73
|
+
}) as FormValues,
|
|
74
|
+
[screen.fields, screen.urlPrefillFields, nav.searchParams, synthScreen, handoffValues],
|
|
73
75
|
);
|
|
74
76
|
const [revealed, setRevealed] = useState<Readonly<Record<string, unknown>> | null>(null);
|
|
75
77
|
const [done, setDone] = useState(false);
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// never a value conversion.
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
buildAppSchema,
|
|
10
|
+
createRegistry,
|
|
11
|
+
defineFeature,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
13
|
import type {
|
|
9
14
|
EntityDefinition,
|
|
10
15
|
EntityEditScreenDefinition,
|
|
@@ -189,3 +194,34 @@ describe("RenderField — editable number unit suffix", () => {
|
|
|
189
194
|
expect(field.unit).toBeUndefined();
|
|
190
195
|
});
|
|
191
196
|
});
|
|
197
|
+
|
|
198
|
+
describe("RenderField — number unit through the shipped client schema", () => {
|
|
199
|
+
function projectedVehicleEntity(
|
|
200
|
+
mileageUnit: string | { readonly field: string },
|
|
201
|
+
): EntityDefinition {
|
|
202
|
+
const feature = defineFeature("fleet", (r) => {
|
|
203
|
+
r.entity("vehicle", buildEntity(mileageUnit));
|
|
204
|
+
});
|
|
205
|
+
const app = buildAppSchema(createRegistry([feature]));
|
|
206
|
+
const entity = app.features[0]?.entities["vehicle"];
|
|
207
|
+
if (entity === undefined) throw new Error("expected the projected vehicle entity");
|
|
208
|
+
// Mirrors the browser-injection pipeline, which ships the schema as JSON.
|
|
209
|
+
return JSON.parse(JSON.stringify(entity)) as EntityDefinition;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
test("static unit survives buildAppSchema and reaches Input.unit", () => {
|
|
213
|
+
const field = renderMileageField(projectedVehicleEntity("mi"), { mileage: 58 });
|
|
214
|
+
if (field.kind !== "number") throw new Error("expected a number Input");
|
|
215
|
+
expect(field.unit).toBe("mi");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("sibling-field unit survives buildAppSchema and resolves from the row", () => {
|
|
219
|
+
const field = renderMileageField(
|
|
220
|
+
projectedVehicleEntity({ field: "mileageUnit" }),
|
|
221
|
+
{ mileage: 58, mileageUnit: "km" },
|
|
222
|
+
{ mileage: 58, mileageUnit: "km" },
|
|
223
|
+
);
|
|
224
|
+
if (field.kind !== "number") throw new Error("expected a number Input");
|
|
225
|
+
expect(field.unit).toBe("km");
|
|
226
|
+
});
|
|
227
|
+
});
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
type ComponentType,
|
|
52
52
|
createContext,
|
|
53
53
|
type FormEvent,
|
|
54
|
+
type KeyboardEvent,
|
|
54
55
|
type ReactNode,
|
|
55
56
|
type Ref,
|
|
56
57
|
useContext,
|
|
@@ -60,6 +61,11 @@ export type { RuntimeRenderer };
|
|
|
60
61
|
|
|
61
62
|
// ---- Prop-Types (die Primitive-Contract-Oberfläche) ----
|
|
62
63
|
|
|
64
|
+
/** Escape hatch for `data-*` test hooks (e.g. an E2E selector) that don't
|
|
65
|
+
* warrant a dedicated typed prop. Web merges these onto the rendered
|
|
66
|
+
* element; native impls ignore it (precedent: `className`). */
|
|
67
|
+
export type DataAttributes = Readonly<Record<`data-${string}`, string>>;
|
|
68
|
+
|
|
63
69
|
/** Standard-Button. `loading` zeigt einen Spinner statt der Children
|
|
64
70
|
* und sollte mit `disabled` kombiniert werden, wenn die Action wirklich
|
|
65
71
|
* blockiert bis das Loading durch ist (z.B. async submit). Native-
|
|
@@ -105,6 +111,7 @@ export type ButtonProps = {
|
|
|
105
111
|
readonly icon?: NavIconKey;
|
|
106
112
|
/** Icon after the label (e.g. "Continue →"). */
|
|
107
113
|
readonly iconEnd?: NavIconKey;
|
|
114
|
+
readonly dataAttributes?: DataAttributes;
|
|
108
115
|
};
|
|
109
116
|
|
|
110
117
|
/** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
|
|
@@ -121,6 +128,7 @@ export type LinkProps = {
|
|
|
121
128
|
readonly className?: string;
|
|
122
129
|
readonly children: ReactNode;
|
|
123
130
|
readonly testId?: string;
|
|
131
|
+
readonly dataAttributes?: DataAttributes;
|
|
124
132
|
};
|
|
125
133
|
|
|
126
134
|
/** Banner für inline-Message ODER Page-State (z.B. "Loading…",
|
|
@@ -197,6 +205,11 @@ export type InputProps =
|
|
|
197
205
|
readonly readOnly?: boolean;
|
|
198
206
|
/** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
|
|
199
207
|
readonly icon?: FieldIconKey;
|
|
208
|
+
readonly dataAttributes?: DataAttributes;
|
|
209
|
+
/** Raw keydown access (e.g. Enter-to-submit at the field instead of
|
|
210
|
+
* the surrounding Form). Web forwards it, native impls ignore it
|
|
211
|
+
* (precedent: `ButtonProps.ref`). */
|
|
212
|
+
readonly onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
|
200
213
|
}
|
|
201
214
|
| {
|
|
202
215
|
readonly kind: "email";
|
|
@@ -479,6 +492,12 @@ export type InputProps =
|
|
|
479
492
|
/** Ctrl/Cmd+Enter submits instead of inserting a newline. Plain
|
|
480
493
|
* Enter still inserts a newline. */
|
|
481
494
|
readonly onSubmitShortcut?: () => void;
|
|
495
|
+
readonly dataAttributes?: DataAttributes;
|
|
496
|
+
/** Raw keydown access (e.g. plain-Enter-submits/Shift+Enter-newline at
|
|
497
|
+
* the field instead of the surrounding Form). Composes with
|
|
498
|
+
* `onSubmitShortcut` — both fire on the same keystroke when set. Web
|
|
499
|
+
* forwards it, native impls ignore it (precedent: `ButtonProps.ref`). */
|
|
500
|
+
readonly onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
482
501
|
};
|
|
483
502
|
|
|
484
503
|
// Sort-Wire-Format. `null`-State unterscheidet "User hat noch nichts
|
|
@@ -1033,6 +1052,7 @@ export type CardProps = {
|
|
|
1033
1052
|
readonly children?: ReactNode;
|
|
1034
1053
|
readonly className?: string;
|
|
1035
1054
|
readonly testId?: string;
|
|
1055
|
+
readonly dataAttributes?: DataAttributes;
|
|
1036
1056
|
};
|
|
1037
1057
|
|
|
1038
1058
|
/** Determinate progress bar (e.g. wizard step progress). `value` is a
|