@cosmicdrift/kumiko-renderer 0.261.0 → 0.263.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 +142 -87
- 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.263.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.263.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.263.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.263.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
|
});
|
|
@@ -33,7 +33,11 @@ import type {
|
|
|
33
33
|
import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
34
34
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
35
35
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
RenderEdit,
|
|
38
|
+
type RenderEditAction,
|
|
39
|
+
type RenderEditControls,
|
|
40
|
+
} from "../components/render-edit";
|
|
37
41
|
import {
|
|
38
42
|
needsActionConfirm,
|
|
39
43
|
RenderEditActionButton,
|
|
@@ -65,7 +69,7 @@ import {
|
|
|
65
69
|
type ResolvedFacetSpec,
|
|
66
70
|
resolveProjectionFacetSpecs,
|
|
67
71
|
} from "./list-facets";
|
|
68
|
-
import { type NavApi, useNav } from "./nav";
|
|
72
|
+
import { type NavApi, useInitialValuesHandoff, useNav } from "./nav";
|
|
69
73
|
import {
|
|
70
74
|
synthesizeProjectionDetailEntity,
|
|
71
75
|
synthesizeProjectionDetailScreen,
|
|
@@ -522,87 +526,116 @@ function coerceEmbeddedListRows(
|
|
|
522
526
|
return rows;
|
|
523
527
|
}
|
|
524
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
|
+
|
|
525
605
|
export function mergeSearchParamsIntoInitial(
|
|
526
606
|
fields: Readonly<Record<string, unknown>>,
|
|
527
|
-
|
|
528
|
-
renderableFields?: ReadonlySet<string>,
|
|
529
|
-
defaultCurrency?: string,
|
|
530
|
-
// Drawer-kind row actions (fw#2710) prefill directly from the clicked
|
|
531
|
-
// row's already-typed values — no URL round-trip, so no string coercion.
|
|
532
|
-
// Goes through the same renderableFields/sensitive gates as searchParams
|
|
533
|
-
// (a `params` extractor could still name a hidden or sensitive field) and
|
|
534
|
-
// wins over searchParams for a name present in both.
|
|
535
|
-
overrides?: Readonly<Record<string, unknown>>,
|
|
607
|
+
sources: InitialValueSources,
|
|
536
608
|
): Record<string, unknown> {
|
|
609
|
+
const { searchParams, renderableFields, defaultCurrency, drawerOverrides, handoffValues } =
|
|
610
|
+
sources;
|
|
611
|
+
const urlPrefillFields = new Set(sources.urlPrefillFields ?? []);
|
|
537
612
|
const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
|
|
538
613
|
const merged: Record<string, unknown> = { ...defaults };
|
|
539
614
|
for (const [name, fieldDef] of Object.entries(fields)) {
|
|
540
615
|
if (renderableFields !== undefined && !renderableFields.has(name)) continue;
|
|
541
|
-
const shape = fieldDef as
|
|
542
|
-
|
|
543
|
-
sensitive?: boolean;
|
|
544
|
-
format?: string;
|
|
545
|
-
options?: readonly (string | { readonly value: string })[];
|
|
546
|
-
multiple?: boolean;
|
|
547
|
-
schema?: Readonly<Record<string, EmbeddedCellShape>>;
|
|
548
|
-
maxItems?: number;
|
|
549
|
-
};
|
|
616
|
+
const shape = fieldDef as PrefillFieldShape;
|
|
617
|
+
// Neither gate is lifted by an allowlist entry or a handoff.
|
|
550
618
|
if (shape.sensitive === true) continue;
|
|
551
|
-
// A password field must never be prefilled from the URL, same as sensitive.
|
|
552
619
|
if (shape.format === "password") continue;
|
|
553
|
-
if (
|
|
554
|
-
merged[name] =
|
|
620
|
+
if (drawerOverrides !== undefined && name in drawerOverrides) {
|
|
621
|
+
merged[name] = drawerOverrides[name];
|
|
555
622
|
continue;
|
|
556
623
|
}
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
} else if (shape.type === "money") {
|
|
563
|
-
const coerced = coerceMoneyValue(raw, name, defaultCurrency);
|
|
564
|
-
merged[name] = coerced === undefined ? defaults[name] : coerced;
|
|
565
|
-
} else if (shape.type === "boolean") {
|
|
566
|
-
merged[name] = raw === "true";
|
|
567
|
-
} else if (shape.type === "multiSelect") {
|
|
568
|
-
// Row-action navigate stringifies arrays via String(arr) → "a,b" (or
|
|
569
|
-
// JSON when the navigate helper JSON.stringifies). Try JSON first
|
|
570
|
-
// (even without a "[" prefix), then comma-split; filter against
|
|
571
|
-
// field.options when present so unknown values never reach the form.
|
|
572
|
-
const optionValues = multiSelectOptionValues(shape);
|
|
573
|
-
let values: string[];
|
|
574
|
-
try {
|
|
575
|
-
const parsed: unknown = JSON.parse(raw);
|
|
576
|
-
if (Array.isArray(parsed)) {
|
|
577
|
-
values = parsed.map((v) => String(v));
|
|
578
|
-
} else if (typeof parsed === "string") {
|
|
579
|
-
values = [parsed];
|
|
580
|
-
} else {
|
|
581
|
-
values =
|
|
582
|
-
raw === ""
|
|
583
|
-
? []
|
|
584
|
-
: raw
|
|
585
|
-
.split(",")
|
|
586
|
-
.map((s) => s.trim())
|
|
587
|
-
.filter(Boolean);
|
|
588
|
-
}
|
|
589
|
-
} catch {
|
|
590
|
-
values =
|
|
591
|
-
raw === ""
|
|
592
|
-
? []
|
|
593
|
-
: raw
|
|
594
|
-
.split(",")
|
|
595
|
-
.map((s) => s.trim())
|
|
596
|
-
.filter(Boolean);
|
|
597
|
-
}
|
|
624
|
+
const handedOff =
|
|
625
|
+
handoffValues !== undefined && Object.hasOwn(handoffValues, name)
|
|
626
|
+
? handoffValues[name]
|
|
627
|
+
: undefined;
|
|
628
|
+
if (handedOff !== undefined) {
|
|
598
629
|
merged[name] =
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
} else {
|
|
604
|
-
merged[name] = raw;
|
|
630
|
+
typeof handedOff === "string"
|
|
631
|
+
? coercePrefillString(handedOff, name, shape, defaults[name], defaultCurrency)
|
|
632
|
+
: handedOff;
|
|
633
|
+
continue;
|
|
605
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);
|
|
606
639
|
}
|
|
607
640
|
return merged;
|
|
608
641
|
}
|
|
@@ -687,20 +720,29 @@ function EntityEditCreateBody({
|
|
|
687
720
|
readonly onSaved?: () => void;
|
|
688
721
|
}): ReactNode {
|
|
689
722
|
const nav = useNav();
|
|
723
|
+
const handoffValues = useInitialValuesHandoff(screen.id);
|
|
690
724
|
const appFeatures = useAppFeatures();
|
|
691
725
|
const initial = useMemo(
|
|
692
726
|
() =>
|
|
693
|
-
mergeSearchParamsIntoInitial(
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
layoutFieldNames(screen),
|
|
697
|
-
entity.defaultCurrency ?? "EUR",
|
|
698
|
-
|
|
699
|
-
|
|
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],
|
|
700
735
|
);
|
|
701
736
|
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
702
737
|
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
703
738
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
739
|
+
// Create's write-handler success payload doesn't flatly expose a parent FK
|
|
740
|
+
// (`{ kind, id, data, … }`), so an object-form redirect's `idFrom` falls
|
|
741
|
+
// back to the values just submitted to the handler.
|
|
742
|
+
const controlsRef = useRef<RenderEditControls<FormValues> | null>(null);
|
|
743
|
+
const handleControlsReady = useCallback((controls: RenderEditControls<FormValues>) => {
|
|
744
|
+
controlsRef.current = controls;
|
|
745
|
+
}, []);
|
|
704
746
|
const handleSubmitted = useCallback(
|
|
705
747
|
(result: SubmitResult<unknown>) => {
|
|
706
748
|
if (!result.isSuccess) return;
|
|
@@ -722,6 +764,7 @@ function EntityEditCreateBody({
|
|
|
722
764
|
result.data,
|
|
723
765
|
schema,
|
|
724
766
|
appFeatures,
|
|
767
|
+
controlsRef.current?.getValues(),
|
|
725
768
|
);
|
|
726
769
|
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
727
770
|
return;
|
|
@@ -744,6 +787,7 @@ function EntityEditCreateBody({
|
|
|
744
787
|
schema={formSchema}
|
|
745
788
|
writeCommand={writeCommand}
|
|
746
789
|
onSubmit={handleSubmitted}
|
|
790
|
+
onControlsReady={handleControlsReady}
|
|
747
791
|
onCancel={navigateToList}
|
|
748
792
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
749
793
|
{...(translate !== undefined && { translate })}
|
|
@@ -2959,8 +3003,9 @@ function redirectScreenTarget(
|
|
|
2959
3003
|
// list screen never gets an id attached, matching actionForm's original
|
|
2960
3004
|
// behavior. The id itself prefers the write-handler's success payload
|
|
2961
3005
|
// (`submittedData`) and falls back to `fallbackRecord` — the entityEdit
|
|
2962
|
-
// update path's already-loaded record,
|
|
2963
|
-
//
|
|
3006
|
+
// update path's already-loaded record, or the create path's just-submitted
|
|
3007
|
+
// form values — both of which carry fields (e.g. a parent FK) the CRUD
|
|
3008
|
+
// write executor's success payload doesn't flatly expose.
|
|
2964
3009
|
function resolveRedirectTarget(
|
|
2965
3010
|
redirect: string | ActionFormRedirect,
|
|
2966
3011
|
submittedData: unknown,
|
|
@@ -3024,16 +3069,26 @@ function ActionFormBody({
|
|
|
3024
3069
|
const appFeatures = useAppFeatures();
|
|
3025
3070
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
3026
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;
|
|
3027
3075
|
const initial = useMemo(
|
|
3028
3076
|
() =>
|
|
3029
|
-
mergeSearchParamsIntoInitial(
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
layoutFieldNames(synthScreen),
|
|
3033
|
-
undefined,
|
|
3034
|
-
|
|
3035
|
-
) as FormValues,
|
|
3036
|
-
[
|
|
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
|
+
],
|
|
3037
3092
|
);
|
|
3038
3093
|
const handleSubmitted = useCallback(
|
|
3039
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
|