@cosmicdrift/kumiko-renderer 0.220.1 → 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 +22 -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 +107 -51
- package/src/components/__tests__/render-edit-action-button.test.tsx +4 -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/i18n.tsx +42 -0
- package/src/index.ts +2 -0
- package/src/primitives.tsx +5 -7
|
@@ -352,6 +352,13 @@ export function buildInitialValues(
|
|
|
352
352
|
return out;
|
|
353
353
|
}
|
|
354
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
|
+
|
|
355
362
|
export function mergeSearchParamsIntoInitial(
|
|
356
363
|
fields: Readonly<Record<string, unknown>>,
|
|
357
364
|
searchParams: Readonly<Record<string, string>>,
|
|
@@ -362,7 +369,11 @@ export function mergeSearchParamsIntoInitial(
|
|
|
362
369
|
const merged: Record<string, unknown> = { ...defaults };
|
|
363
370
|
for (const [name, fieldDef] of Object.entries(fields)) {
|
|
364
371
|
if (renderableFields !== undefined && !renderableFields.has(name)) continue;
|
|
365
|
-
const shape = fieldDef as {
|
|
372
|
+
const shape = fieldDef as {
|
|
373
|
+
type?: string;
|
|
374
|
+
sensitive?: boolean;
|
|
375
|
+
options?: readonly (string | { readonly value: string })[];
|
|
376
|
+
};
|
|
366
377
|
if (shape.sensitive === true) continue;
|
|
367
378
|
const raw = searchParams[name];
|
|
368
379
|
if (raw === undefined) continue;
|
|
@@ -380,17 +391,28 @@ export function mergeSearchParamsIntoInitial(
|
|
|
380
391
|
merged[name] = raw === "true";
|
|
381
392
|
} else if (shape.type === "multiSelect") {
|
|
382
393
|
// Row-action navigate stringifies arrays via String(arr) → "a,b" (or
|
|
383
|
-
// JSON when the navigate helper JSON.stringifies).
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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);
|
|
391
413
|
}
|
|
392
|
-
}
|
|
393
|
-
|
|
414
|
+
} catch {
|
|
415
|
+
values =
|
|
394
416
|
raw === ""
|
|
395
417
|
? []
|
|
396
418
|
: raw
|
|
@@ -398,6 +420,8 @@ export function mergeSearchParamsIntoInitial(
|
|
|
398
420
|
.map((s) => s.trim())
|
|
399
421
|
.filter(Boolean);
|
|
400
422
|
}
|
|
423
|
+
merged[name] =
|
|
424
|
+
optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
|
|
401
425
|
} else {
|
|
402
426
|
merged[name] = raw;
|
|
403
427
|
}
|
|
@@ -910,6 +934,27 @@ function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[
|
|
|
910
934
|
// server). Deliberately NOT gated on the field being a *facet* — entityList
|
|
911
935
|
// passes through any field present in entity.fields, matching its
|
|
912
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
|
+
|
|
913
958
|
function buildFilterPayload(
|
|
914
959
|
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
915
960
|
typeOf: (field: string) => string | undefined,
|
|
@@ -987,24 +1032,25 @@ function resolveProjectionFacetSpecs(
|
|
|
987
1032
|
translate: Translate,
|
|
988
1033
|
): ResolvedFacetSpec[] {
|
|
989
1034
|
if (facets === undefined) return [];
|
|
1035
|
+
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
990
1036
|
return facets.map((facet) =>
|
|
991
1037
|
facet.type === "select"
|
|
992
1038
|
? {
|
|
993
1039
|
field: facet.field,
|
|
994
1040
|
type: "select",
|
|
995
|
-
label:
|
|
1041
|
+
label: tr(facet.label),
|
|
996
1042
|
options: facet.options.map((opt) => ({
|
|
997
1043
|
value: opt.value,
|
|
998
|
-
label:
|
|
1044
|
+
label: tr(opt.label),
|
|
999
1045
|
})),
|
|
1000
1046
|
}
|
|
1001
1047
|
: {
|
|
1002
1048
|
field: facet.field,
|
|
1003
1049
|
type: "boolean",
|
|
1004
|
-
label:
|
|
1050
|
+
label: tr(facet.label),
|
|
1005
1051
|
options: [
|
|
1006
|
-
{ value: "true", label:
|
|
1007
|
-
{ value: "false", label:
|
|
1052
|
+
{ value: "true", label: tr(facet.trueLabel) },
|
|
1053
|
+
{ value: "false", label: tr(facet.falseLabel) },
|
|
1008
1054
|
],
|
|
1009
1055
|
},
|
|
1010
1056
|
);
|
|
@@ -1255,6 +1301,16 @@ function EntityListBody({
|
|
|
1255
1301
|
|
|
1256
1302
|
const rowsQuery = useQuery<PagedRows>(queryType, queryPayload, { live: true });
|
|
1257
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
|
+
|
|
1258
1314
|
// Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
|
|
1259
1315
|
// hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
|
|
1260
1316
|
// initiale Loads laufen beide hier durch — das useEffect-Dep-Array
|
|
@@ -1339,7 +1395,11 @@ function EntityListBody({
|
|
|
1339
1395
|
// detailFor cross-feature nicht selbst nachschlagen.
|
|
1340
1396
|
const explicit =
|
|
1341
1397
|
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1342
|
-
|
|
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;
|
|
1343
1403
|
nav.navigate({ entity: action.entity, id });
|
|
1344
1404
|
} else if (action.screen !== undefined) {
|
|
1345
1405
|
// Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
|
|
@@ -1359,6 +1419,8 @@ function EntityListBody({
|
|
|
1359
1419
|
screenId: action.screen,
|
|
1360
1420
|
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1361
1421
|
});
|
|
1422
|
+
} else {
|
|
1423
|
+
return;
|
|
1362
1424
|
}
|
|
1363
1425
|
const params =
|
|
1364
1426
|
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
@@ -1367,12 +1429,7 @@ function EntityListBody({
|
|
|
1367
1429
|
// kennt via URL nur Strings). Known-edge: zielt die Action auf den
|
|
1368
1430
|
// AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
|
|
1369
1431
|
// Row-Actions praktisch nicht erreichbar, Pfad differiert).
|
|
1370
|
-
|
|
1371
|
-
for (const [k, v] of Object.entries(params)) {
|
|
1372
|
-
stringified[k] =
|
|
1373
|
-
v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
|
|
1374
|
-
}
|
|
1375
|
-
nav.setSearchParams(stringified);
|
|
1432
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
1376
1433
|
}
|
|
1377
1434
|
},
|
|
1378
1435
|
[nav, schema.screens, screen.entity],
|
|
@@ -1427,9 +1484,7 @@ function EntityListBody({
|
|
|
1427
1484
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1428
1485
|
);
|
|
1429
1486
|
}
|
|
1430
|
-
|
|
1431
|
-
// so the list would otherwise keep showing stale rows.
|
|
1432
|
-
await rowsQuery.refetch();
|
|
1487
|
+
await refreshRowsAfterWrite();
|
|
1433
1488
|
},
|
|
1434
1489
|
isVisible:
|
|
1435
1490
|
writeActionVisible !== undefined
|
|
@@ -1438,7 +1493,7 @@ function EntityListBody({
|
|
|
1438
1493
|
};
|
|
1439
1494
|
})
|
|
1440
1495
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
1441
|
-
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate,
|
|
1496
|
+
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
|
|
1442
1497
|
|
|
1443
1498
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
1444
1499
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
@@ -1488,13 +1543,19 @@ function EntityListBody({
|
|
|
1488
1543
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1489
1544
|
);
|
|
1490
1545
|
}
|
|
1491
|
-
|
|
1492
|
-
await rowsQuery.refetch();
|
|
1546
|
+
await refreshRowsAfterWrite();
|
|
1493
1547
|
},
|
|
1494
1548
|
};
|
|
1495
1549
|
})
|
|
1496
1550
|
.filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
|
|
1497
|
-
}, [
|
|
1551
|
+
}, [
|
|
1552
|
+
screen.toolbarActions,
|
|
1553
|
+
effectiveTranslate,
|
|
1554
|
+
nav,
|
|
1555
|
+
dispatcher,
|
|
1556
|
+
refreshRowsAfterWrite,
|
|
1557
|
+
openDrawer,
|
|
1558
|
+
]);
|
|
1498
1559
|
|
|
1499
1560
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1500
1561
|
return (
|
|
@@ -1718,6 +1779,7 @@ function ProjectionListBody({
|
|
|
1718
1779
|
// Validator erzwingt deshalb einen expliziten entityId für
|
|
1719
1780
|
// projectionList-entity-Targets.
|
|
1720
1781
|
const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
|
|
1782
|
+
if (id === "") return;
|
|
1721
1783
|
nav.navigate({ entity: action.entity, id });
|
|
1722
1784
|
} else if (action.screen !== undefined) {
|
|
1723
1785
|
const entityId =
|
|
@@ -1726,16 +1788,13 @@ function ProjectionListBody({
|
|
|
1726
1788
|
screenId: action.screen,
|
|
1727
1789
|
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1728
1790
|
});
|
|
1791
|
+
} else {
|
|
1792
|
+
return;
|
|
1729
1793
|
}
|
|
1730
1794
|
const params =
|
|
1731
1795
|
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
1732
1796
|
if (params !== undefined) {
|
|
1733
|
-
|
|
1734
|
-
for (const [k, v] of Object.entries(params)) {
|
|
1735
|
-
stringified[k] =
|
|
1736
|
-
v === null || v === undefined ? null : Array.isArray(v) ? JSON.stringify(v) : String(v);
|
|
1737
|
-
}
|
|
1738
|
-
nav.setSearchParams(stringified);
|
|
1797
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
1739
1798
|
}
|
|
1740
1799
|
},
|
|
1741
1800
|
[nav],
|
|
@@ -1788,7 +1847,7 @@ function ProjectionListBody({
|
|
|
1788
1847
|
);
|
|
1789
1848
|
}
|
|
1790
1849
|
// Same refetch as EntityListBody's rowActions above.
|
|
1791
|
-
await rowsQuery.refetch
|
|
1850
|
+
await refetchAfterWrite(rowsQuery.refetch);
|
|
1792
1851
|
},
|
|
1793
1852
|
...(writeVisible !== undefined && {
|
|
1794
1853
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
@@ -1843,7 +1902,7 @@ function ProjectionListBody({
|
|
|
1843
1902
|
);
|
|
1844
1903
|
}
|
|
1845
1904
|
// Same refetch as rowActions above.
|
|
1846
|
-
await rowsQuery.refetch
|
|
1905
|
+
await refetchAfterWrite(rowsQuery.refetch);
|
|
1847
1906
|
},
|
|
1848
1907
|
});
|
|
1849
1908
|
}
|
|
@@ -2031,16 +2090,7 @@ function ProjectionDetailBody({
|
|
|
2031
2090
|
const params =
|
|
2032
2091
|
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
2033
2092
|
if (params !== undefined) {
|
|
2034
|
-
|
|
2035
|
-
for (const [k, v] of Object.entries(params)) {
|
|
2036
|
-
stringified[k] =
|
|
2037
|
-
v === null || v === undefined
|
|
2038
|
-
? null
|
|
2039
|
-
: Array.isArray(v)
|
|
2040
|
-
? JSON.stringify(v)
|
|
2041
|
-
: String(v);
|
|
2042
|
-
}
|
|
2043
|
-
nav.setSearchParams(stringified);
|
|
2093
|
+
nav.setSearchParams(stringifyNavParams(params));
|
|
2044
2094
|
}
|
|
2045
2095
|
};
|
|
2046
2096
|
if (action.entity !== undefined) {
|
|
@@ -2056,6 +2106,7 @@ function ProjectionDetailBody({
|
|
|
2056
2106
|
label: effectiveTranslate(action.label),
|
|
2057
2107
|
...(action.style !== undefined && { style: action.style }),
|
|
2058
2108
|
onPress: () => {
|
|
2109
|
+
if (id === "") return;
|
|
2059
2110
|
nav.navigate({ entity: targetEntity, id });
|
|
2060
2111
|
runParams();
|
|
2061
2112
|
},
|
|
@@ -2235,14 +2286,19 @@ function ActionFormBody({
|
|
|
2235
2286
|
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
2236
2287
|
// "back to list" (typisch bei Create-style Aktionen).
|
|
2237
2288
|
if (screen.redirect !== undefined) {
|
|
2289
|
+
const targetId = lastSegment(screen.redirect);
|
|
2290
|
+
const target = schema.screens.find((s) => lastSegment(s.id) === targetId);
|
|
2238
2291
|
const entityId = extractCreatedId(result.data);
|
|
2292
|
+
const carriesId =
|
|
2293
|
+
target !== undefined &&
|
|
2294
|
+
(target.type === "entityEdit" || target.type === "projectionDetail");
|
|
2239
2295
|
nav.navigate({
|
|
2240
|
-
screenId:
|
|
2241
|
-
...(entityId !== undefined && { entityId }),
|
|
2296
|
+
screenId: targetId,
|
|
2297
|
+
...(carriesId && entityId !== undefined && { entityId }),
|
|
2242
2298
|
});
|
|
2243
2299
|
}
|
|
2244
2300
|
},
|
|
2245
|
-
[nav, screen.redirect, onSuccess],
|
|
2301
|
+
[nav, screen.redirect, onSuccess, schema.screens],
|
|
2246
2302
|
);
|
|
2247
2303
|
// Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
|
|
2248
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
|
});
|
|
@@ -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,
|