@cosmicdrift/kumiko-renderer 0.220.0 → 0.221.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 +45 -1
- package/src/app/__tests__/content-editors.test.tsx +2 -1
- package/src/app/__tests__/content-preview.test.tsx +22 -0
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +208 -1
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +187 -1
- package/src/app/__tests__/form-schema.test.ts +9 -0
- package/src/app/__tests__/list-filter-facets.test.tsx +7 -4
- package/src/app/content-editors.tsx +3 -2
- package/src/app/content-preview.tsx +11 -3
- package/src/app/form-schema.ts +4 -6
- package/src/app/kumiko-screen.tsx +124 -35
- package/src/components/__tests__/render-edit-action-button.test.tsx +5 -0
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +52 -1
- package/src/components/render-edit-action-button.tsx +4 -0
- package/src/components/render-edit.tsx +100 -65
- package/src/hooks/__tests__/use-form.test.tsx +8 -4
- package/src/hooks/use-form.ts +5 -18
- package/src/i18n.tsx +42 -0
- package/src/index.ts +2 -0
- package/src/primitives.tsx +5 -7
package/src/app/form-schema.ts
CHANGED
|
@@ -16,10 +16,6 @@ function isPresent(value: unknown): boolean {
|
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// A statically-`required: true` field with no bound widget is caught
|
|
20
|
-
// loudly at boot — validateNoWidgetRequiredField in
|
|
21
|
-
// packages/framework/src/engine/boot-validator/screens.ts, sharing
|
|
22
|
-
// NO_WIDGET_FIELD_TYPES with the presence check below.
|
|
23
19
|
function isEmbeddedListField(field: FieldDefinition): boolean {
|
|
24
20
|
return field.type === "embedded" && field.multiple === true;
|
|
25
21
|
}
|
|
@@ -67,7 +63,7 @@ export function buildFormSchema(
|
|
|
67
63
|
const field = entity.fields[spec.field];
|
|
68
64
|
if (!field) continue;
|
|
69
65
|
// Not operable by the user — a presence error would be unresolvable,
|
|
70
|
-
// same reason as the
|
|
66
|
+
// same reason as the NO_WIDGET_FIELD_TYPES check below.
|
|
71
67
|
if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record)) continue;
|
|
72
68
|
// Screen-spec `required` overrides the entity default, mirroring
|
|
73
69
|
// `view-model/edit.ts` — the rendered form is the reference, and a
|
|
@@ -78,7 +74,9 @@ export function buildFormSchema(
|
|
|
78
74
|
if (!isRequired) continue;
|
|
79
75
|
// Embedded LIST fields get their own EmbeddedListField grid widget
|
|
80
76
|
// (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
|
|
81
|
-
// entry must not exempt them
|
|
77
|
+
// entry must not exempt them. A statically-`required: true` field
|
|
78
|
+
// with no bound widget is also caught at boot
|
|
79
|
+
// (validateNoWidgetRequiredField in boot-validator/screens.ts).
|
|
82
80
|
if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field)) continue;
|
|
83
81
|
if (isPresent(record[spec.field])) continue;
|
|
84
82
|
ctx.addIssue({
|
|
@@ -341,11 +341,24 @@ export function buildInitialValues(
|
|
|
341
341
|
continue;
|
|
342
342
|
}
|
|
343
343
|
out[name] =
|
|
344
|
-
shape.type === "boolean"
|
|
344
|
+
shape.type === "boolean"
|
|
345
|
+
? false
|
|
346
|
+
: shape.type === "number" || shape.type === "money"
|
|
347
|
+
? 0
|
|
348
|
+
: shape.type === "multiSelect"
|
|
349
|
+
? []
|
|
350
|
+
: "";
|
|
345
351
|
}
|
|
346
352
|
return out;
|
|
347
353
|
}
|
|
348
354
|
|
|
355
|
+
function multiSelectOptionValues(shape: {
|
|
356
|
+
readonly options?: readonly (string | { readonly value: string })[];
|
|
357
|
+
}): ReadonlySet<string> | undefined {
|
|
358
|
+
if (shape.options === undefined || shape.options.length === 0) return undefined;
|
|
359
|
+
return new Set(shape.options.map((o) => (typeof o === "string" ? o : o.value)));
|
|
360
|
+
}
|
|
361
|
+
|
|
349
362
|
export function mergeSearchParamsIntoInitial(
|
|
350
363
|
fields: Readonly<Record<string, unknown>>,
|
|
351
364
|
searchParams: Readonly<Record<string, string>>,
|
|
@@ -356,7 +369,11 @@ export function mergeSearchParamsIntoInitial(
|
|
|
356
369
|
const merged: Record<string, unknown> = { ...defaults };
|
|
357
370
|
for (const [name, fieldDef] of Object.entries(fields)) {
|
|
358
371
|
if (renderableFields !== undefined && !renderableFields.has(name)) continue;
|
|
359
|
-
const shape = fieldDef as {
|
|
372
|
+
const shape = fieldDef as {
|
|
373
|
+
type?: string;
|
|
374
|
+
sensitive?: boolean;
|
|
375
|
+
options?: readonly (string | { readonly value: string })[];
|
|
376
|
+
};
|
|
360
377
|
if (shape.sensitive === true) continue;
|
|
361
378
|
const raw = searchParams[name];
|
|
362
379
|
if (raw === undefined) continue;
|
|
@@ -372,6 +389,39 @@ export function mergeSearchParamsIntoInitial(
|
|
|
372
389
|
: parsed;
|
|
373
390
|
} else if (shape.type === "boolean") {
|
|
374
391
|
merged[name] = raw === "true";
|
|
392
|
+
} else if (shape.type === "multiSelect") {
|
|
393
|
+
// Row-action navigate stringifies arrays via String(arr) → "a,b" (or
|
|
394
|
+
// JSON when the navigate helper JSON.stringifies). Try JSON first
|
|
395
|
+
// (even without a "[" prefix), then comma-split; filter against
|
|
396
|
+
// field.options when present so unknown values never reach the form.
|
|
397
|
+
const optionValues = multiSelectOptionValues(shape);
|
|
398
|
+
let values: string[];
|
|
399
|
+
try {
|
|
400
|
+
const parsed: unknown = JSON.parse(raw);
|
|
401
|
+
if (Array.isArray(parsed)) {
|
|
402
|
+
values = parsed.map((v) => String(v));
|
|
403
|
+
} else if (typeof parsed === "string") {
|
|
404
|
+
values = [parsed];
|
|
405
|
+
} else {
|
|
406
|
+
values =
|
|
407
|
+
raw === ""
|
|
408
|
+
? []
|
|
409
|
+
: raw
|
|
410
|
+
.split(",")
|
|
411
|
+
.map((s) => s.trim())
|
|
412
|
+
.filter(Boolean);
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
values =
|
|
416
|
+
raw === ""
|
|
417
|
+
? []
|
|
418
|
+
: raw
|
|
419
|
+
.split(",")
|
|
420
|
+
.map((s) => s.trim())
|
|
421
|
+
.filter(Boolean);
|
|
422
|
+
}
|
|
423
|
+
merged[name] =
|
|
424
|
+
optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
|
|
375
425
|
} else {
|
|
376
426
|
merged[name] = raw;
|
|
377
427
|
}
|
|
@@ -884,6 +934,27 @@ function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[
|
|
|
884
934
|
// server). Deliberately NOT gated on the field being a *facet* — entityList
|
|
885
935
|
// passes through any field present in entity.fields, matching its
|
|
886
936
|
// pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
|
|
937
|
+
|
|
938
|
+
function stringifyNavParams(params: Record<string, unknown>): Record<string, string | null> {
|
|
939
|
+
const out: Record<string, string | null> = {};
|
|
940
|
+
for (const [k, v] of Object.entries(params)) {
|
|
941
|
+
out[k] =
|
|
942
|
+
v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
|
|
943
|
+
}
|
|
944
|
+
return out;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function isFacetI18nKey(label: string): boolean {
|
|
948
|
+
return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function refetchAfterWrite(refetch: () => Promise<unknown>): Promise<void> {
|
|
952
|
+
await refetch().catch((err: unknown) => {
|
|
953
|
+
// biome-ignore lint/suspicious/noConsole: refetch must not poison the write-action error path
|
|
954
|
+
console.error("kumiko-screen: refetch after write action failed", err);
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
887
958
|
function buildFilterPayload(
|
|
888
959
|
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
889
960
|
typeOf: (field: string) => string | undefined,
|
|
@@ -961,24 +1032,25 @@ function resolveProjectionFacetSpecs(
|
|
|
961
1032
|
translate: Translate,
|
|
962
1033
|
): ResolvedFacetSpec[] {
|
|
963
1034
|
if (facets === undefined) return [];
|
|
1035
|
+
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
964
1036
|
return facets.map((facet) =>
|
|
965
1037
|
facet.type === "select"
|
|
966
1038
|
? {
|
|
967
1039
|
field: facet.field,
|
|
968
1040
|
type: "select",
|
|
969
|
-
label:
|
|
1041
|
+
label: tr(facet.label),
|
|
970
1042
|
options: facet.options.map((opt) => ({
|
|
971
1043
|
value: opt.value,
|
|
972
|
-
label:
|
|
1044
|
+
label: tr(opt.label),
|
|
973
1045
|
})),
|
|
974
1046
|
}
|
|
975
1047
|
: {
|
|
976
1048
|
field: facet.field,
|
|
977
1049
|
type: "boolean",
|
|
978
|
-
label:
|
|
1050
|
+
label: tr(facet.label),
|
|
979
1051
|
options: [
|
|
980
|
-
{ value: "true", label:
|
|
981
|
-
{ value: "false", label:
|
|
1052
|
+
{ value: "true", label: tr(facet.trueLabel) },
|
|
1053
|
+
{ value: "false", label: tr(facet.falseLabel) },
|
|
982
1054
|
],
|
|
983
1055
|
},
|
|
984
1056
|
);
|
|
@@ -1229,6 +1301,16 @@ function EntityListBody({
|
|
|
1229
1301
|
|
|
1230
1302
|
const rowsQuery = useQuery<PagedRows>(queryType, queryPayload, { live: true });
|
|
1231
1303
|
|
|
1304
|
+
const refreshRowsAfterWrite = useCallback(async () => {
|
|
1305
|
+
if (useInfinite) {
|
|
1306
|
+
setAccumulated([]);
|
|
1307
|
+
setCursor(undefined);
|
|
1308
|
+
setHasMore(true);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
await refetchAfterWrite(rowsQuery.refetch);
|
|
1312
|
+
}, [useInfinite, rowsQuery.refetch]);
|
|
1313
|
+
|
|
1232
1314
|
// Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
|
|
1233
1315
|
// hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
|
|
1234
1316
|
// initiale Loads laufen beide hier durch — das useEffect-Dep-Array
|
|
@@ -1313,7 +1395,11 @@ function EntityListBody({
|
|
|
1313
1395
|
// detailFor cross-feature nicht selbst nachschlagen.
|
|
1314
1396
|
const explicit =
|
|
1315
1397
|
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1316
|
-
|
|
1398
|
+
// Same-entity only — cross-entity targets must declare entityId (boot-validator).
|
|
1399
|
+
const fallback =
|
|
1400
|
+
action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
|
|
1401
|
+
const id = explicit ?? fallback ?? "";
|
|
1402
|
+
if (id === "") return;
|
|
1317
1403
|
nav.navigate({ entity: action.entity, id });
|
|
1318
1404
|
} else if (action.screen !== undefined) {
|
|
1319
1405
|
// Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
|
|
@@ -1333,6 +1419,8 @@ function EntityListBody({
|
|
|
1333
1419
|
screenId: action.screen,
|
|
1334
1420
|
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1335
1421
|
});
|
|
1422
|
+
} else {
|
|
1423
|
+
return;
|
|
1336
1424
|
}
|
|
1337
1425
|
const params =
|
|
1338
1426
|
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
@@ -1341,11 +1429,7 @@ function EntityListBody({
|
|
|
1341
1429
|
// kennt via URL nur Strings). Known-edge: zielt die Action auf den
|
|
1342
1430
|
// AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
|
|
1343
1431
|
// Row-Actions praktisch nicht erreichbar, Pfad differiert).
|
|
1344
|
-
|
|
1345
|
-
for (const [k, v] of Object.entries(params)) {
|
|
1346
|
-
stringified[k] = v === null || v === undefined ? null : String(v);
|
|
1347
|
-
}
|
|
1348
|
-
nav.setSearchParams(stringified);
|
|
1432
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
1349
1433
|
}
|
|
1350
1434
|
},
|
|
1351
1435
|
[nav, schema.screens, screen.entity],
|
|
@@ -1400,9 +1484,7 @@ function EntityListBody({
|
|
|
1400
1484
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1401
1485
|
);
|
|
1402
1486
|
}
|
|
1403
|
-
|
|
1404
|
-
// so the list would otherwise keep showing stale rows.
|
|
1405
|
-
await rowsQuery.refetch();
|
|
1487
|
+
await refreshRowsAfterWrite();
|
|
1406
1488
|
},
|
|
1407
1489
|
isVisible:
|
|
1408
1490
|
writeActionVisible !== undefined
|
|
@@ -1411,7 +1493,7 @@ function EntityListBody({
|
|
|
1411
1493
|
};
|
|
1412
1494
|
})
|
|
1413
1495
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
1414
|
-
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate,
|
|
1496
|
+
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
|
|
1415
1497
|
|
|
1416
1498
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
1417
1499
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
@@ -1461,13 +1543,19 @@ function EntityListBody({
|
|
|
1461
1543
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1462
1544
|
);
|
|
1463
1545
|
}
|
|
1464
|
-
|
|
1465
|
-
await rowsQuery.refetch();
|
|
1546
|
+
await refreshRowsAfterWrite();
|
|
1466
1547
|
},
|
|
1467
1548
|
};
|
|
1468
1549
|
})
|
|
1469
1550
|
.filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
|
|
1470
|
-
}, [
|
|
1551
|
+
}, [
|
|
1552
|
+
screen.toolbarActions,
|
|
1553
|
+
effectiveTranslate,
|
|
1554
|
+
nav,
|
|
1555
|
+
dispatcher,
|
|
1556
|
+
refreshRowsAfterWrite,
|
|
1557
|
+
openDrawer,
|
|
1558
|
+
]);
|
|
1471
1559
|
|
|
1472
1560
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1473
1561
|
return (
|
|
@@ -1691,6 +1779,7 @@ function ProjectionListBody({
|
|
|
1691
1779
|
// Validator erzwingt deshalb einen expliziten entityId für
|
|
1692
1780
|
// projectionList-entity-Targets.
|
|
1693
1781
|
const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
|
|
1782
|
+
if (id === "") return;
|
|
1694
1783
|
nav.navigate({ entity: action.entity, id });
|
|
1695
1784
|
} else if (action.screen !== undefined) {
|
|
1696
1785
|
const entityId =
|
|
@@ -1699,15 +1788,13 @@ function ProjectionListBody({
|
|
|
1699
1788
|
screenId: action.screen,
|
|
1700
1789
|
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1701
1790
|
});
|
|
1791
|
+
} else {
|
|
1792
|
+
return;
|
|
1702
1793
|
}
|
|
1703
1794
|
const params =
|
|
1704
1795
|
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
1705
1796
|
if (params !== undefined) {
|
|
1706
|
-
|
|
1707
|
-
for (const [k, v] of Object.entries(params)) {
|
|
1708
|
-
stringified[k] = v === null || v === undefined ? null : String(v);
|
|
1709
|
-
}
|
|
1710
|
-
nav.setSearchParams(stringified);
|
|
1797
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
1711
1798
|
}
|
|
1712
1799
|
},
|
|
1713
1800
|
[nav],
|
|
@@ -1760,7 +1847,7 @@ function ProjectionListBody({
|
|
|
1760
1847
|
);
|
|
1761
1848
|
}
|
|
1762
1849
|
// Same refetch as EntityListBody's rowActions above.
|
|
1763
|
-
await rowsQuery.refetch
|
|
1850
|
+
await refetchAfterWrite(rowsQuery.refetch);
|
|
1764
1851
|
},
|
|
1765
1852
|
...(writeVisible !== undefined && {
|
|
1766
1853
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
@@ -1815,7 +1902,7 @@ function ProjectionListBody({
|
|
|
1815
1902
|
);
|
|
1816
1903
|
}
|
|
1817
1904
|
// Same refetch as rowActions above.
|
|
1818
|
-
await rowsQuery.refetch
|
|
1905
|
+
await refetchAfterWrite(rowsQuery.refetch);
|
|
1819
1906
|
},
|
|
1820
1907
|
});
|
|
1821
1908
|
}
|
|
@@ -2003,11 +2090,7 @@ function ProjectionDetailBody({
|
|
|
2003
2090
|
const params =
|
|
2004
2091
|
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
2005
2092
|
if (params !== undefined) {
|
|
2006
|
-
|
|
2007
|
-
for (const [k, v] of Object.entries(params)) {
|
|
2008
|
-
stringified[k] = v === null || v === undefined ? null : String(v);
|
|
2009
|
-
}
|
|
2010
|
-
nav.setSearchParams(stringified);
|
|
2093
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
2011
2094
|
}
|
|
2012
2095
|
};
|
|
2013
2096
|
if (action.entity !== undefined) {
|
|
@@ -2023,6 +2106,7 @@ function ProjectionDetailBody({
|
|
|
2023
2106
|
label: effectiveTranslate(action.label),
|
|
2024
2107
|
...(action.style !== undefined && { style: action.style }),
|
|
2025
2108
|
onPress: () => {
|
|
2109
|
+
if (id === "") return;
|
|
2026
2110
|
nav.navigate({ entity: targetEntity, id });
|
|
2027
2111
|
runParams();
|
|
2028
2112
|
},
|
|
@@ -2202,14 +2286,19 @@ function ActionFormBody({
|
|
|
2202
2286
|
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
2203
2287
|
// "back to list" (typisch bei Create-style Aktionen).
|
|
2204
2288
|
if (screen.redirect !== undefined) {
|
|
2289
|
+
const targetId = lastSegment(screen.redirect);
|
|
2290
|
+
const target = schema.screens.find((s) => lastSegment(s.id) === targetId);
|
|
2205
2291
|
const entityId = extractCreatedId(result.data);
|
|
2292
|
+
const carriesId =
|
|
2293
|
+
target !== undefined &&
|
|
2294
|
+
(target.type === "entityEdit" || target.type === "projectionDetail");
|
|
2206
2295
|
nav.navigate({
|
|
2207
|
-
screenId:
|
|
2208
|
-
...(entityId !== undefined && { entityId }),
|
|
2296
|
+
screenId: targetId,
|
|
2297
|
+
...(carriesId && entityId !== undefined && { entityId }),
|
|
2209
2298
|
});
|
|
2210
2299
|
}
|
|
2211
2300
|
},
|
|
2212
|
-
[nav, screen.redirect, onSuccess],
|
|
2301
|
+
[nav, screen.redirect, onSuccess, schema.screens],
|
|
2213
2302
|
);
|
|
2214
2303
|
// Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
|
|
2215
2304
|
// sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
|
|
@@ -117,6 +117,10 @@ describe("RenderEditActionButton", () => {
|
|
|
117
117
|
expect(rtlScreen.queryByTestId("render-edit-action-delete-dialog-description")).toBeNull();
|
|
118
118
|
expect(pressed).toBe(0);
|
|
119
119
|
|
|
120
|
+
expect(rtlScreen.getByTestId("render-edit-action-delete-dialog-confirm").textContent).toBe(
|
|
121
|
+
"Delete",
|
|
122
|
+
);
|
|
123
|
+
|
|
120
124
|
fireEvent.click(rtlScreen.getByTestId("render-edit-action-delete-dialog-confirm"));
|
|
121
125
|
await waitFor(() => expect(pressed).toBe(1));
|
|
122
126
|
});
|
|
@@ -138,6 +142,7 @@ describe("RenderEditActionButton", () => {
|
|
|
138
142
|
|
|
139
143
|
fireEvent.click(rtlScreen.getByTestId("render-edit-action-boom"));
|
|
140
144
|
await waitFor(() => expect(errors).toContain("action exploded"));
|
|
145
|
+
// Cleared at the start of trigger, then set on failure.
|
|
141
146
|
expect(errors[0]).toBeNull();
|
|
142
147
|
});
|
|
143
148
|
|
|
@@ -292,8 +292,33 @@ describe("RenderEdit — submit path", () => {
|
|
|
292
292
|
expect(submitted).toEqual({ validationBlocked: true, isSuccess: false });
|
|
293
293
|
expect(customCalls).toBe(0);
|
|
294
294
|
});
|
|
295
|
-
|
|
295
|
+
test("failed writeCommand with field issues suppresses the form-error banner", async () => {
|
|
296
|
+
const writeFailure: DispatcherError = {
|
|
297
|
+
code: "validation_failed",
|
|
298
|
+
httpStatus: 422,
|
|
299
|
+
i18nKey: "kumiko.errors.validation",
|
|
300
|
+
message: "validation failed",
|
|
301
|
+
details: {
|
|
302
|
+
fields: [{ path: "name", code: "too_small", i18nKey: "kumiko.errors.required" }],
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const { dispatcher } = stubDispatcher(async () => ({
|
|
306
|
+
isSuccess: false,
|
|
307
|
+
error: writeFailure,
|
|
308
|
+
}));
|
|
309
|
+
renderEdit(
|
|
310
|
+
oneFieldScreen,
|
|
311
|
+
{ writeCommand: "contacts:write:contact:update" },
|
|
312
|
+
buildEntity(),
|
|
313
|
+
dispatcher,
|
|
314
|
+
);
|
|
296
315
|
|
|
316
|
+
fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "Ada" } });
|
|
317
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
|
|
318
|
+
|
|
319
|
+
await waitFor(() => expect(rtlScreen.queryByTestId("render-edit-form-error")).toBeNull());
|
|
320
|
+
});
|
|
321
|
+
});
|
|
297
322
|
describe("RenderEdit — custom actions", () => {
|
|
298
323
|
test("renders an action button and runs its handler on click", async () => {
|
|
299
324
|
let pressed = 0;
|
|
@@ -388,4 +413,30 @@ describe("RenderEdit — writeCommand path", () => {
|
|
|
388
413
|
await waitFor(() => expect(rtlScreen.getByTestId("render-edit-form-error")).toBeTruthy());
|
|
389
414
|
expect(submitted?.isSuccess).toBe(false);
|
|
390
415
|
});
|
|
416
|
+
test("failed writeCommand with field issues suppresses the form-error banner", async () => {
|
|
417
|
+
const writeFailure: DispatcherError = {
|
|
418
|
+
code: "validation_failed",
|
|
419
|
+
httpStatus: 422,
|
|
420
|
+
i18nKey: "kumiko.errors.validation",
|
|
421
|
+
message: "validation failed",
|
|
422
|
+
details: {
|
|
423
|
+
fields: [{ path: "name", code: "too_small", i18nKey: "kumiko.errors.required" }],
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
const { dispatcher } = stubDispatcher(async () => ({
|
|
427
|
+
isSuccess: false,
|
|
428
|
+
error: writeFailure,
|
|
429
|
+
}));
|
|
430
|
+
renderEdit(
|
|
431
|
+
oneFieldScreen,
|
|
432
|
+
{ writeCommand: "contacts:write:contact:update" },
|
|
433
|
+
buildEntity(),
|
|
434
|
+
dispatcher,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
fireEvent.change(rtlScreen.getByLabelText(/name/i), { target: { value: "Ada" } });
|
|
438
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-submit"));
|
|
439
|
+
|
|
440
|
+
await waitFor(() => expect(rtlScreen.queryByTestId("render-edit-form-error")).toBeNull());
|
|
441
|
+
});
|
|
391
442
|
});
|
|
@@ -3,6 +3,10 @@ import { useState } from "react";
|
|
|
3
3
|
import type { usePrimitives } from "../primitives";
|
|
4
4
|
import type { RenderEditAction } from "./render-edit-types";
|
|
5
5
|
|
|
6
|
+
// One header action + its own busy/confirm state — same pattern as
|
|
7
|
+
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
8
|
+
// independently bound by the caller, there is no shared trigger pipeline
|
|
9
|
+
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
6
10
|
export function RenderEditActionButton({
|
|
7
11
|
action,
|
|
8
12
|
Button,
|