@cosmicdrift/kumiko-renderer 0.257.0 → 0.258.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 +150 -0
- package/src/app/__tests__/action-form-shim.test.ts +49 -1
- package/src/app/__tests__/secret-mint-body.test.tsx +396 -0
- package/src/app/action-form-shim.ts +28 -10
- package/src/app/kumiko-screen.tsx +89 -4
- package/src/app/secret-mint-body.tsx +235 -0
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +16 -0
- package/src/components/__tests__/render-field-password-format.test.tsx +101 -0
- package/src/components/render-edit.tsx +36 -14
- package/src/components/render-field.tsx +11 -0
- package/src/i18n-defaults.ts +8 -0
- package/src/index.ts +2 -0
- package/src/primitives.tsx +42 -4
|
@@ -19,26 +19,29 @@ import type {
|
|
|
19
19
|
ActionFormScreenDefinition,
|
|
20
20
|
EntityDefinition,
|
|
21
21
|
EntityEditScreenDefinition,
|
|
22
|
+
FieldDefinition,
|
|
23
|
+
SecretMintConfirmStep,
|
|
24
|
+
SecretMintScreenDefinition,
|
|
22
25
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
23
26
|
|
|
24
27
|
const ACTION_FORM_PSEUDO_ENTITY = "__action-form__";
|
|
25
28
|
|
|
26
|
-
/** Baut eine minimale EntityDefinition aus den Inline-Fields
|
|
27
|
-
* ActionForm-Screens. RenderEdit + computeEditViewModel
|
|
28
|
-
* über entity.fields zur Render-Zeit; alle weiteren
|
|
29
|
-
* Felder bleiben undefined. */
|
|
29
|
+
/** Baut eine minimale EntityDefinition aus den Inline-Fields eines
|
|
30
|
+
* ActionForm- oder SecretMint-Screens. RenderEdit + computeEditViewModel
|
|
31
|
+
* iterieren über entity.fields zur Render-Zeit; alle weiteren
|
|
32
|
+
* EntityDefinition-Felder bleiben undefined. */
|
|
30
33
|
export function synthesizeActionFormEntity(
|
|
31
|
-
fields:
|
|
34
|
+
fields: Readonly<Record<string, FieldDefinition>>,
|
|
32
35
|
): EntityDefinition {
|
|
33
36
|
return { fields } as EntityDefinition;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
/** Wandelt ein ActionFormScreenDefinition
|
|
37
|
-
* Shape die RenderEdit erwartet. type wird auf
|
|
38
|
-
* damit der Type-Constraint hält; entity wird auf den
|
|
39
|
-
* gepinnt — RenderEdit liest das Feld nicht. */
|
|
39
|
+
/** Wandelt ein ActionFormScreenDefinition oder SecretMintScreenDefinition in
|
|
40
|
+
* die EntityEditScreen-Shape die RenderEdit erwartet. type wird auf
|
|
41
|
+
* "entityEdit" gesetzt damit der Type-Constraint hält; entity wird auf den
|
|
42
|
+
* Pseudo-Namen gepinnt — RenderEdit liest das Feld nicht. */
|
|
40
43
|
export function synthesizeActionFormScreen(
|
|
41
|
-
screen: ActionFormScreenDefinition,
|
|
44
|
+
screen: ActionFormScreenDefinition | SecretMintScreenDefinition,
|
|
42
45
|
): EntityEditScreenDefinition {
|
|
43
46
|
return {
|
|
44
47
|
id: screen.id,
|
|
@@ -49,3 +52,18 @@ export function synthesizeActionFormScreen(
|
|
|
49
52
|
...(screen.access !== undefined && { access: screen.access }),
|
|
50
53
|
};
|
|
51
54
|
}
|
|
55
|
+
|
|
56
|
+
/** Separate id (`${screen.id}:confirm`) keeps this confirm-form screen from
|
|
57
|
+
* colliding with the mint-form screen's draft key. */
|
|
58
|
+
export function synthesizeSecretMintConfirmScreen(
|
|
59
|
+
screen: SecretMintScreenDefinition,
|
|
60
|
+
confirm: SecretMintConfirmStep,
|
|
61
|
+
): EntityEditScreenDefinition {
|
|
62
|
+
return {
|
|
63
|
+
id: `${screen.id}:confirm`,
|
|
64
|
+
type: "entityEdit",
|
|
65
|
+
entity: ACTION_FORM_PSEUDO_ENTITY,
|
|
66
|
+
layout: confirm.layout,
|
|
67
|
+
...(screen.access !== undefined && { access: screen.access }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
stringifyNavParams,
|
|
83
83
|
} from "./row-actions";
|
|
84
84
|
import { screenAccessAllows } from "./screen-access";
|
|
85
|
+
import { SecretMintBody } from "./secret-mint-body";
|
|
85
86
|
import { SecretsEditBody } from "./secrets-edit-body";
|
|
86
87
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
87
88
|
|
|
@@ -211,6 +212,8 @@ export function KumikoScreen({
|
|
|
211
212
|
return <DashboardScreenBody screen={screen} translate={translate} />;
|
|
212
213
|
case "actionForm":
|
|
213
214
|
return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
|
|
215
|
+
case "secretMint":
|
|
216
|
+
return <SecretMintBody schema={schema} screen={screen} translate={translate} />;
|
|
214
217
|
case "configEdit":
|
|
215
218
|
return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
|
|
216
219
|
case "secretsEdit":
|
|
@@ -345,11 +348,15 @@ export function buildInitialValues(
|
|
|
345
348
|
): Readonly<Record<string, unknown>> {
|
|
346
349
|
const out: Record<string, unknown> = {};
|
|
347
350
|
for (const [name, def] of Object.entries(fields)) {
|
|
348
|
-
const shape = def as { type?: string; default?: unknown };
|
|
351
|
+
const shape = def as { type?: string; default?: unknown; multiple?: boolean };
|
|
349
352
|
if (shape.default !== undefined) {
|
|
350
353
|
out[name] = shape.default;
|
|
351
354
|
continue;
|
|
352
355
|
}
|
|
356
|
+
if (shape.type === "embedded" && shape.multiple === true) {
|
|
357
|
+
out[name] = [];
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
353
360
|
if (shape.type === "money" && defaultCurrency !== undefined) {
|
|
354
361
|
out[name] = { amount: 0, currency: defaultCurrency };
|
|
355
362
|
continue;
|
|
@@ -399,7 +406,7 @@ function parseJsonOrRaw(raw: string): unknown {
|
|
|
399
406
|
}
|
|
400
407
|
}
|
|
401
408
|
|
|
402
|
-
function
|
|
409
|
+
function warnPrefillParam(message: string): void {
|
|
403
410
|
// biome-ignore lint/suspicious/noConsole: dev-warning for an authoring error
|
|
404
411
|
console.warn(`[kumiko] ${message}`);
|
|
405
412
|
}
|
|
@@ -416,7 +423,7 @@ function coerceMoneyValue(
|
|
|
416
423
|
if (value === null) return undefined;
|
|
417
424
|
if (isMoneyValue(value)) return { amount: value.amount, currency: value.currency.toUpperCase() };
|
|
418
425
|
if (typeof value === "object") {
|
|
419
|
-
|
|
426
|
+
warnPrefillParam(
|
|
420
427
|
`money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`,
|
|
421
428
|
);
|
|
422
429
|
return undefined;
|
|
@@ -424,12 +431,81 @@ function coerceMoneyValue(
|
|
|
424
431
|
const amount = Number(typeof value === "number" ? value : raw);
|
|
425
432
|
if (!Number.isFinite(amount)) return undefined;
|
|
426
433
|
if (defaultCurrency !== undefined) return { amount, currency: defaultCurrency };
|
|
427
|
-
|
|
434
|
+
warnPrefillParam(
|
|
428
435
|
`money field "${fieldName}" was prefilled with a bare number and no currency is available — pass {"amount":<number>,"currency":"<ISO code>"} as the param value, otherwise the handler's schema rejects the submit.`,
|
|
429
436
|
);
|
|
430
437
|
return amount;
|
|
431
438
|
}
|
|
432
439
|
|
|
440
|
+
type EmbeddedCellShape = { readonly type?: string; readonly options?: readonly string[] };
|
|
441
|
+
|
|
442
|
+
const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
|
|
443
|
+
|
|
444
|
+
function coerceEmbeddedCell(value: unknown, cell: EmbeddedCellShape): unknown {
|
|
445
|
+
switch (cell.type) {
|
|
446
|
+
case "number":
|
|
447
|
+
case "decimal":
|
|
448
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
449
|
+
case "money":
|
|
450
|
+
// Row cells are signed minor units; the currency lives on the head, so a
|
|
451
|
+
// top-level `{amount, currency}` in major units would land 100x off.
|
|
452
|
+
return typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
|
|
453
|
+
case "boolean":
|
|
454
|
+
return typeof value === "boolean" ? value : undefined;
|
|
455
|
+
case "select":
|
|
456
|
+
return typeof value === "string" && (cell.options ?? []).includes(value) ? value : undefined;
|
|
457
|
+
default:
|
|
458
|
+
return typeof value === "string" ? value : undefined;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A partially coerced list is the hazard the prefill exists to avoid: the
|
|
463
|
+
// handler replaces the list whole, so half a list overwrites the rest.
|
|
464
|
+
function coerceEmbeddedListRows(
|
|
465
|
+
raw: string,
|
|
466
|
+
fieldName: string,
|
|
467
|
+
schema: Readonly<Record<string, EmbeddedCellShape>>,
|
|
468
|
+
maxItems?: number,
|
|
469
|
+
): readonly Readonly<Record<string, unknown>>[] | undefined {
|
|
470
|
+
const parsed = parseJsonOrRaw(raw);
|
|
471
|
+
if (!Array.isArray(parsed)) {
|
|
472
|
+
warnPrefillParam(
|
|
473
|
+
`embedded-list field "${fieldName}" was prefilled with something that is not a JSON array — the prefill is ignored.`,
|
|
474
|
+
);
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
if (parsed.length > (maxItems ?? EMBEDDED_LIST_PREFILL_ROW_LIMIT)) {
|
|
478
|
+
warnPrefillParam(
|
|
479
|
+
`embedded-list field "${fieldName}" was prefilled with ${parsed.length} rows, more than it accepts — the prefill is ignored.`,
|
|
480
|
+
);
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
const rows: Readonly<Record<string, unknown>>[] = [];
|
|
484
|
+
for (const candidate of parsed) {
|
|
485
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
486
|
+
warnPrefillParam(
|
|
487
|
+
`embedded-list field "${fieldName}" was prefilled with a row that is not an object — the prefill is ignored.`,
|
|
488
|
+
);
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
const cellEntries: [string, unknown][] = [];
|
|
492
|
+
for (const [cellName, cell] of Object.entries(schema)) {
|
|
493
|
+
const cellValue = (candidate as Record<string, unknown>)[cellName];
|
|
494
|
+
if (cellValue === undefined || cellValue === null) continue;
|
|
495
|
+
const coerced = coerceEmbeddedCell(cellValue, cell);
|
|
496
|
+
if (coerced === undefined) {
|
|
497
|
+
warnPrefillParam(
|
|
498
|
+
`embedded-list field "${fieldName}" was prefilled with a cell "${cellName}" the sub-schema rejects — the prefill is ignored.`,
|
|
499
|
+
);
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
cellEntries.push([cellName, coerced]);
|
|
503
|
+
}
|
|
504
|
+
rows.push(Object.fromEntries(cellEntries));
|
|
505
|
+
}
|
|
506
|
+
return rows;
|
|
507
|
+
}
|
|
508
|
+
|
|
433
509
|
export function mergeSearchParamsIntoInitial(
|
|
434
510
|
fields: Readonly<Record<string, unknown>>,
|
|
435
511
|
searchParams: Readonly<Record<string, string>>,
|
|
@@ -449,9 +525,15 @@ export function mergeSearchParamsIntoInitial(
|
|
|
449
525
|
const shape = fieldDef as {
|
|
450
526
|
type?: string;
|
|
451
527
|
sensitive?: boolean;
|
|
528
|
+
format?: string;
|
|
452
529
|
options?: readonly (string | { readonly value: string })[];
|
|
530
|
+
multiple?: boolean;
|
|
531
|
+
schema?: Readonly<Record<string, EmbeddedCellShape>>;
|
|
532
|
+
maxItems?: number;
|
|
453
533
|
};
|
|
454
534
|
if (shape.sensitive === true) continue;
|
|
535
|
+
// A password field must never be prefilled from the URL, same as sensitive.
|
|
536
|
+
if (shape.format === "password") continue;
|
|
455
537
|
if (overrides !== undefined && name in overrides) {
|
|
456
538
|
merged[name] = overrides[name];
|
|
457
539
|
continue;
|
|
@@ -499,6 +581,9 @@ export function mergeSearchParamsIntoInitial(
|
|
|
499
581
|
}
|
|
500
582
|
merged[name] =
|
|
501
583
|
optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
|
|
584
|
+
} else if (shape.type === "embedded" && shape.multiple === true) {
|
|
585
|
+
const rows = coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems);
|
|
586
|
+
merged[name] = rows ?? defaults[name];
|
|
502
587
|
} else {
|
|
503
588
|
merged[name] = raw;
|
|
504
589
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { SecretMintScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { RenderEdit } from "../components/render-edit";
|
|
5
|
+
import { useTranslation } from "../i18n";
|
|
6
|
+
import { usePrimitives } from "../primitives";
|
|
7
|
+
import {
|
|
8
|
+
synthesizeActionFormEntity,
|
|
9
|
+
synthesizeActionFormScreen,
|
|
10
|
+
synthesizeSecretMintConfirmScreen,
|
|
11
|
+
} from "./action-form-shim";
|
|
12
|
+
import type { FeatureSchema } from "./feature-schema";
|
|
13
|
+
import { buildInitialValues, mergeSearchParamsIntoInitial } from "./kumiko-screen";
|
|
14
|
+
import { layoutFieldNames } from "./layout-fields";
|
|
15
|
+
import { useNav } from "./nav";
|
|
16
|
+
import { lastSegment } from "./qn";
|
|
17
|
+
|
|
18
|
+
export type SecretMintBodyProps = {
|
|
19
|
+
readonly schema: FeatureSchema;
|
|
20
|
+
readonly screen: SecretMintScreenDefinition;
|
|
21
|
+
readonly translate?: Translate;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function extractRevealValue(data: unknown, field: string): unknown {
|
|
25
|
+
if (typeof data !== "object" || data === null) return undefined;
|
|
26
|
+
if (!Object.hasOwn(data, field)) return undefined;
|
|
27
|
+
return (data as Record<string, unknown>)[field];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractCarriedValues(
|
|
31
|
+
data: unknown,
|
|
32
|
+
carry: readonly string[],
|
|
33
|
+
): Readonly<Record<string, unknown>> {
|
|
34
|
+
const carried: Record<string, unknown> = {};
|
|
35
|
+
for (const field of carry) {
|
|
36
|
+
const value = extractRevealValue(data, field);
|
|
37
|
+
if (value === undefined) continue;
|
|
38
|
+
carried[field] = value;
|
|
39
|
+
}
|
|
40
|
+
return carried;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isBlank(value: unknown): boolean {
|
|
44
|
+
if (value === undefined || value === null || value === "") return true;
|
|
45
|
+
return Array.isArray(value) && value.length === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Mint form → one-time reveal → optional confirm → done. The revealed values
|
|
49
|
+
// live ONLY in this component's `revealed` state — never in the URL, a query
|
|
50
|
+
// cache, or nav — the write-handler's success payload is the only place the
|
|
51
|
+
// secret ever exists (fw#2548). onSubmit copies exclusively the fields
|
|
52
|
+
// declared in `screen.reveal.fields` out of that payload (a whitelist, never
|
|
53
|
+
// the payload as a whole) so an unrelated field (e.g. an internal "id") can
|
|
54
|
+
// never leak into the reveal. `screen.confirm` (fw#2838, e.g. TOTP enroll:
|
|
55
|
+
// scan the code, then enter one) works the same way for its own carried
|
|
56
|
+
// mint-payload fields — those live only in `carriedRef`, never in React state
|
|
57
|
+
// that gets rendered, never in the confirm form's own values.
|
|
58
|
+
export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProps): ReactNode {
|
|
59
|
+
const nav = useNav();
|
|
60
|
+
const { Card, Heading, Banner, Button, Text, Grid, GridCell, SecretReveal } = usePrimitives();
|
|
61
|
+
const t = useTranslation();
|
|
62
|
+
const effectiveTranslate = translate ?? t;
|
|
63
|
+
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
64
|
+
const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
|
|
65
|
+
const initial = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
mergeSearchParamsIntoInitial(
|
|
68
|
+
screen.fields,
|
|
69
|
+
nav.searchParams,
|
|
70
|
+
layoutFieldNames(synthScreen),
|
|
71
|
+
) as FormValues,
|
|
72
|
+
[screen.fields, nav.searchParams, synthScreen],
|
|
73
|
+
);
|
|
74
|
+
const [revealed, setRevealed] = useState<Readonly<Record<string, unknown>> | null>(null);
|
|
75
|
+
const [done, setDone] = useState(false);
|
|
76
|
+
// Never rendered, never merged into `revealed` or the confirm form's
|
|
77
|
+
// initial values — read only from `buildPayload` at confirm-submit time.
|
|
78
|
+
const carriedRef = useRef<Readonly<Record<string, unknown>>>({});
|
|
79
|
+
|
|
80
|
+
const confirm = screen.confirm;
|
|
81
|
+
const confirmEntity = useMemo(
|
|
82
|
+
() => (confirm !== undefined ? synthesizeActionFormEntity(confirm.fields) : undefined),
|
|
83
|
+
[confirm],
|
|
84
|
+
);
|
|
85
|
+
const confirmScreen = useMemo(
|
|
86
|
+
() => (confirm !== undefined ? synthesizeSecretMintConfirmScreen(screen, confirm) : undefined),
|
|
87
|
+
[screen, confirm],
|
|
88
|
+
);
|
|
89
|
+
const confirmInitial = useMemo(
|
|
90
|
+
() => (confirm !== undefined ? (buildInitialValues(confirm.fields) as FormValues) : undefined),
|
|
91
|
+
[confirm],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const handleSubmitted = useCallback(
|
|
95
|
+
(result: SubmitResult<unknown>) => {
|
|
96
|
+
if (!result.isSuccess) return;
|
|
97
|
+
const values: Record<string, unknown> = {};
|
|
98
|
+
for (const revealField of screen.reveal.fields) {
|
|
99
|
+
const value = extractRevealValue(result.data, revealField.field);
|
|
100
|
+
if (value === undefined) continue;
|
|
101
|
+
values[revealField.field] = value;
|
|
102
|
+
}
|
|
103
|
+
setRevealed(values);
|
|
104
|
+
carriedRef.current =
|
|
105
|
+
confirm?.carry !== undefined ? extractCarriedValues(result.data, confirm.carry) : {};
|
|
106
|
+
},
|
|
107
|
+
[screen.reveal.fields, confirm?.carry],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
111
|
+
const target = screen.cancelTarget ?? screen.redirect;
|
|
112
|
+
if (target === undefined || target === false) return undefined;
|
|
113
|
+
return () => nav.navigate({ screenId: lastSegment(target) });
|
|
114
|
+
}, [nav, screen.redirect, screen.cancelTarget]);
|
|
115
|
+
|
|
116
|
+
// Ends the reveal phase for both paths (the bare acknowledge button, and a
|
|
117
|
+
// successful confirm submit): clears the secret and the carried values, then
|
|
118
|
+
// either navigates (screen.redirect) or shows a done-state — never falls
|
|
119
|
+
// back to re-rendering the mint form, which would let a stray click mint
|
|
120
|
+
// (and invalidate) the secret again.
|
|
121
|
+
const finishMint = useCallback(() => {
|
|
122
|
+
setRevealed(null);
|
|
123
|
+
carriedRef.current = {};
|
|
124
|
+
if (screen.redirect !== undefined) {
|
|
125
|
+
nav.navigate({ screenId: lastSegment(screen.redirect) });
|
|
126
|
+
} else {
|
|
127
|
+
setDone(true);
|
|
128
|
+
}
|
|
129
|
+
}, [nav, screen.redirect]);
|
|
130
|
+
|
|
131
|
+
const handleConfirmSubmitted = useCallback(
|
|
132
|
+
(result: SubmitResult<unknown>) => {
|
|
133
|
+
if (result.isSuccess) finishMint();
|
|
134
|
+
},
|
|
135
|
+
[finishMint],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (done) {
|
|
139
|
+
return (
|
|
140
|
+
<Card>
|
|
141
|
+
<Banner variant="info" testId="kumiko-screen-secret-mint-done">
|
|
142
|
+
{effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done")}
|
|
143
|
+
</Banner>
|
|
144
|
+
</Card>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (revealed !== null) {
|
|
149
|
+
const values = screen.reveal.fields.flatMap((revealField) => {
|
|
150
|
+
const raw = revealed[revealField.field];
|
|
151
|
+
if (isBlank(raw)) return [];
|
|
152
|
+
const display = revealField.display ?? "code";
|
|
153
|
+
const value =
|
|
154
|
+
display === "list" && Array.isArray(raw)
|
|
155
|
+
? raw.map((v) => String(v)).join("\n")
|
|
156
|
+
: String(raw);
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
label: effectiveTranslate(revealField.label),
|
|
160
|
+
value,
|
|
161
|
+
copyable: revealField.copyable ?? true,
|
|
162
|
+
multiline: display === "list",
|
|
163
|
+
...(display === "qr" && { qr: true }),
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
});
|
|
167
|
+
return (
|
|
168
|
+
<Card testId="kumiko-screen-secret-mint-card">
|
|
169
|
+
<Heading variant="page">
|
|
170
|
+
{effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title")}
|
|
171
|
+
</Heading>
|
|
172
|
+
<Banner variant="warning" testId="kumiko-screen-secret-mint-warning">
|
|
173
|
+
{effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning")}
|
|
174
|
+
</Banner>
|
|
175
|
+
{SecretReveal !== undefined ? (
|
|
176
|
+
<SecretReveal
|
|
177
|
+
values={values}
|
|
178
|
+
copyLabel={effectiveTranslate("kumiko.secretMint.copy")}
|
|
179
|
+
copiedLabel={effectiveTranslate("kumiko.secretMint.copied")}
|
|
180
|
+
testId="kumiko-screen-secret-mint-reveal"
|
|
181
|
+
/>
|
|
182
|
+
) : (
|
|
183
|
+
<Grid columns={1} testId="kumiko-screen-secret-mint-reveal">
|
|
184
|
+
{values.map((v) => (
|
|
185
|
+
<GridCell key={v.label}>
|
|
186
|
+
<Text>{v.label}</Text>
|
|
187
|
+
<Text variant="code">{v.value}</Text>
|
|
188
|
+
</GridCell>
|
|
189
|
+
))}
|
|
190
|
+
</Grid>
|
|
191
|
+
)}
|
|
192
|
+
{confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined ? (
|
|
193
|
+
<RenderEdit
|
|
194
|
+
screen={confirmScreen}
|
|
195
|
+
entity={confirmEntity}
|
|
196
|
+
featureName={schema.featureName}
|
|
197
|
+
initial={confirmInitial ?? ({} as FormValues)}
|
|
198
|
+
writeCommand={confirm.handler}
|
|
199
|
+
payloadMode="values"
|
|
200
|
+
buildPayload={(snapshot) => ({ ...snapshot.values, ...carriedRef.current })}
|
|
201
|
+
onSubmit={handleConfirmSubmitted}
|
|
202
|
+
{...(handleCancel !== undefined && { onCancel: handleCancel })}
|
|
203
|
+
{...(translate !== undefined && { translate })}
|
|
204
|
+
{...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel })}
|
|
205
|
+
/>
|
|
206
|
+
) : (
|
|
207
|
+
<Button
|
|
208
|
+
type="button"
|
|
209
|
+
variant="primary"
|
|
210
|
+
onClick={finishMint}
|
|
211
|
+
testId="kumiko-screen-secret-mint-confirm"
|
|
212
|
+
>
|
|
213
|
+
{effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm")}
|
|
214
|
+
</Button>
|
|
215
|
+
)}
|
|
216
|
+
</Card>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<RenderEdit
|
|
222
|
+
screen={synthScreen}
|
|
223
|
+
entity={synthEntity}
|
|
224
|
+
featureName={schema.featureName}
|
|
225
|
+
initial={initial}
|
|
226
|
+
extensionInitialValues={initial}
|
|
227
|
+
writeCommand={screen.handler}
|
|
228
|
+
payloadMode="values"
|
|
229
|
+
onSubmit={handleSubmitted}
|
|
230
|
+
{...(handleCancel !== undefined && { onCancel: handleCancel })}
|
|
231
|
+
{...(translate !== undefined && { translate })}
|
|
232
|
+
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
@@ -320,6 +320,22 @@ describe("RenderEdit — submit path", () => {
|
|
|
320
320
|
|
|
321
321
|
await waitFor(() => expect(rtlScreen.queryByTestId("render-edit-form-error")).toBeNull());
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
// fw#2838: an input-less secretMint mint step declares an empty entity
|
|
325
|
+
// (fields: {}) and an empty layout (sections: []) — such a form can never
|
|
326
|
+
// go dirty, so the unchanged-gate must not permanently disable its submit.
|
|
327
|
+
test("a fieldless form's submit button is not disabled and stays visible", () => {
|
|
328
|
+
const zeroFieldScreen: EntityEditScreenDefinition = {
|
|
329
|
+
id: "trigger",
|
|
330
|
+
type: "entityEdit",
|
|
331
|
+
entity: "trigger",
|
|
332
|
+
layout: { sections: [] },
|
|
333
|
+
};
|
|
334
|
+
renderEdit(zeroFieldScreen, {}, { fields: {} });
|
|
335
|
+
|
|
336
|
+
const save = rtlScreen.getByTestId("render-edit-submit") as HTMLButtonElement;
|
|
337
|
+
expect(save.disabled).toBe(false);
|
|
338
|
+
});
|
|
323
339
|
});
|
|
324
340
|
describe("RenderEdit — custom actions", () => {
|
|
325
341
|
test("renders an action button and runs its handler on click", async () => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// fw#2548: TextFieldDef.format "password" is a pure render hint — the
|
|
2
|
+
// editable widget masks the input, and the read-only display never shows
|
|
3
|
+
// the plaintext value.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { render } from "@testing-library/react";
|
|
8
|
+
import type { ComponentType, ReactNode } from "react";
|
|
9
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
10
|
+
import {
|
|
11
|
+
type CorePrimitives,
|
|
12
|
+
type InputProps,
|
|
13
|
+
PrimitivesProvider,
|
|
14
|
+
type TextProps,
|
|
15
|
+
} from "../../primitives";
|
|
16
|
+
import { RenderField } from "../render-field";
|
|
17
|
+
|
|
18
|
+
let capturedInput: InputProps | undefined;
|
|
19
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
20
|
+
capturedInput = props;
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
let capturedText: TextProps | undefined;
|
|
24
|
+
const captureText: ComponentType<TextProps> = (props) => {
|
|
25
|
+
capturedText = props;
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
const noop = (): ReactNode => null;
|
|
29
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
30
|
+
|
|
31
|
+
const testPrimitives: CorePrimitives = {
|
|
32
|
+
Button: noop,
|
|
33
|
+
Banner: noop,
|
|
34
|
+
Field: passChildren,
|
|
35
|
+
Input: captureInput,
|
|
36
|
+
DataTable: noop,
|
|
37
|
+
Form: noop,
|
|
38
|
+
Section: noop,
|
|
39
|
+
Card: noop,
|
|
40
|
+
Grid: noop,
|
|
41
|
+
GridCell: noop,
|
|
42
|
+
Text: captureText,
|
|
43
|
+
Heading: noop,
|
|
44
|
+
Dialog: noop,
|
|
45
|
+
Modal: noop,
|
|
46
|
+
Lightbox: noop,
|
|
47
|
+
ConfigSourceBadge: noop,
|
|
48
|
+
ConfigCascadeView: noop,
|
|
49
|
+
Link: noop,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function textField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
|
|
53
|
+
return {
|
|
54
|
+
field: "apiToken",
|
|
55
|
+
label: "API token",
|
|
56
|
+
type: "text",
|
|
57
|
+
value: "s3cr3t",
|
|
58
|
+
visible: true,
|
|
59
|
+
readOnly: false,
|
|
60
|
+
required: false,
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderField(field: EditFieldViewModel, valueDisplay?: "form" | "text"): void {
|
|
66
|
+
capturedInput = undefined;
|
|
67
|
+
capturedText = undefined;
|
|
68
|
+
render(
|
|
69
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
|
|
70
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
71
|
+
<RenderField
|
|
72
|
+
field={field}
|
|
73
|
+
onChange={() => {}}
|
|
74
|
+
{...(valueDisplay !== undefined && { valueDisplay })}
|
|
75
|
+
/>
|
|
76
|
+
</PrimitivesProvider>
|
|
77
|
+
</LocaleProvider>,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("RenderField — format: 'password'", () => {
|
|
82
|
+
test("editable password field renders Input kind=password", () => {
|
|
83
|
+
renderField(textField({ format: "password" }));
|
|
84
|
+
expect(capturedInput?.kind).toBe("password");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a plain text field (no format) still renders Input kind=text", () => {
|
|
88
|
+
renderField(textField());
|
|
89
|
+
expect(capturedInput?.kind).toBe("text");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("read-only password field shows a fixed mask, never the plaintext", () => {
|
|
93
|
+
renderField(textField({ format: "password", readOnly: true }), "text");
|
|
94
|
+
expect(capturedText?.children).toBe("••••••••");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("read-only password field with an empty value shows the empty placeholder, not the mask", () => {
|
|
98
|
+
renderField(textField({ format: "password", readOnly: true, value: "" }), "text");
|
|
99
|
+
expect(capturedText?.children).toBe("—");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -628,6 +628,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
628
628
|
|
|
629
629
|
// true when editable fields exist or an extension opted into composed submit (fw#2359).
|
|
630
630
|
const isFormEditable = hasEditableSection(filteredSections);
|
|
631
|
+
// A fieldless form (input-less secretMint mint, fw#2838 — the secret is
|
|
632
|
+
// server-generated, the mint step is only its submit button) has no
|
|
633
|
+
// section to gate on: it IS the action, so it always shows the submit
|
|
634
|
+
// button, and it can never go dirty either.
|
|
635
|
+
const isFieldless = Object.keys(entity.fields).length === 0;
|
|
631
636
|
|
|
632
637
|
// A lone relatedList tab (hideSectionTitles is only ever set by the tabs
|
|
633
638
|
// layout, which also narrows filteredSections to that one active section)
|
|
@@ -704,10 +709,23 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
704
709
|
);
|
|
705
710
|
return;
|
|
706
711
|
}
|
|
712
|
+
const passwordFieldNames = new Set(
|
|
713
|
+
vm.sections.flatMap((section) =>
|
|
714
|
+
section.kind === "fields"
|
|
715
|
+
? section.fields.filter((f) => f.format === "password").map((f) => f.field)
|
|
716
|
+
: [],
|
|
717
|
+
),
|
|
718
|
+
);
|
|
719
|
+
// A password field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
|
|
720
|
+
const draftValues = Object.fromEntries(
|
|
721
|
+
Object.entries(controller.getSnapshot().values).filter(
|
|
722
|
+
([field]) => !passwordFieldNames.has(field),
|
|
723
|
+
),
|
|
724
|
+
);
|
|
707
725
|
void dispatcher
|
|
708
726
|
.write(FORM_DRAFT_SAVE, {
|
|
709
727
|
draftKey: key,
|
|
710
|
-
values:
|
|
728
|
+
values: draftValues,
|
|
711
729
|
stepIndex,
|
|
712
730
|
})
|
|
713
731
|
.catch((err: unknown) => {
|
|
@@ -1005,7 +1023,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1005
1023
|
const hasFormActions =
|
|
1006
1024
|
(isWizard && currentStep > 0) ||
|
|
1007
1025
|
(isWizard && !isLastWizardStep) ||
|
|
1008
|
-
((isFormEditable || hasExtensionRegistrations
|
|
1026
|
+
((isFormEditable || hasExtensionRegistrations || isFieldless) &&
|
|
1027
|
+
(!isWizard || isLastWizardStep));
|
|
1009
1028
|
const formActions = (
|
|
1010
1029
|
<>
|
|
1011
1030
|
{isWizard && currentStep > 0 && (
|
|
@@ -1029,18 +1048,21 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1029
1048
|
{translate("kumiko.actions.next")}
|
|
1030
1049
|
</Button>
|
|
1031
1050
|
)}
|
|
1032
|
-
{(isFormEditable || hasExtensionRegistrations
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1051
|
+
{(isFormEditable || hasExtensionRegistrations || isFieldless) &&
|
|
1052
|
+
(!isWizard || isLastWizardStep) && (
|
|
1053
|
+
<Button
|
|
1054
|
+
type="submit"
|
|
1055
|
+
disabled={
|
|
1056
|
+
(snapshot.isUnchanged && !extensionDirty && !isFieldless) || isSubmitting || disabled
|
|
1057
|
+
}
|
|
1058
|
+
loading={isSubmitting}
|
|
1059
|
+
variant={submitVariant ?? "primary"}
|
|
1060
|
+
icon="check"
|
|
1061
|
+
testId="render-edit-submit"
|
|
1062
|
+
>
|
|
1063
|
+
{translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
|
|
1064
|
+
</Button>
|
|
1065
|
+
)}
|
|
1044
1066
|
</>
|
|
1045
1067
|
);
|
|
1046
1068
|
|
|
@@ -751,6 +751,16 @@ function renderInput({
|
|
|
751
751
|
);
|
|
752
752
|
}
|
|
753
753
|
default: {
|
|
754
|
+
if (field.type === "text" && field.format === "password") {
|
|
755
|
+
return (
|
|
756
|
+
<Input
|
|
757
|
+
kind="password"
|
|
758
|
+
{...common}
|
|
759
|
+
value={stringValue(field.value)}
|
|
760
|
+
onChange={(v) => onChange(v)}
|
|
761
|
+
/>
|
|
762
|
+
);
|
|
763
|
+
}
|
|
754
764
|
// text + unknown scalar type → text input. If TextFieldDef.multiline
|
|
755
765
|
// is set (the view-model carries it), the renderer switches to
|
|
756
766
|
// textarea. longText always renders a textarea — that's the point of
|
|
@@ -867,6 +877,7 @@ function locatedValue(v: unknown): { at: string; tz: string; utc?: string } | ""
|
|
|
867
877
|
function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): string {
|
|
868
878
|
const { type, value } = field;
|
|
869
879
|
if (value === undefined || value === null || value === "") return "—";
|
|
880
|
+
if (type === "text" && field.format === "password") return "••••••••";
|
|
870
881
|
switch (type) {
|
|
871
882
|
case "boolean":
|
|
872
883
|
return applyFormatSpec({ format: "boolean" }, value);
|