@cosmicdrift/kumiko-renderer 0.195.0 → 0.196.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.196.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.196.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.196.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Bug found while archiving a cost-type in solon (kumiko-framework#113-adjacent):
|
|
2
|
+
// a `writeHandler` row-action on an entityList screen writes successfully
|
|
3
|
+
// (HTTP 200, projection updated) but the list keeps showing the old state,
|
|
4
|
+
// because nothing ever refetches the rows query after the write resolves.
|
|
5
|
+
// Actions with a `redirect`/navigate target hide this — the screen remount
|
|
6
|
+
// reloads everything. This test renders the real list path (KumikoScreen →
|
|
7
|
+
// EntityListScreen → EntityListBody → RenderList) under a stub dispatcher
|
|
8
|
+
// that counts `query()` calls, and proves the rows query refetches after a
|
|
9
|
+
// successful row-action write, but NOT after a failed one (no double-fetch,
|
|
10
|
+
// no silent refetch loop).
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import type {
|
|
14
|
+
EntityDefinition,
|
|
15
|
+
EntityListScreenDefinition,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
18
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
19
|
+
import type { ComponentType, ReactNode } from "react";
|
|
20
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
21
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
22
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
23
|
+
import {
|
|
24
|
+
type CorePrimitives,
|
|
25
|
+
type DataTableProps,
|
|
26
|
+
type DataTableRowAction,
|
|
27
|
+
PrimitivesProvider,
|
|
28
|
+
} from "../../primitives";
|
|
29
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
30
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
31
|
+
import { NavProvider } from "../nav";
|
|
32
|
+
|
|
33
|
+
let capturedRowActions: readonly DataTableRowAction[] | undefined;
|
|
34
|
+
const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
35
|
+
capturedRowActions = props.rowActions;
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
const noop = (): ReactNode => null;
|
|
39
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
40
|
+
|
|
41
|
+
const testPrimitives: CorePrimitives = {
|
|
42
|
+
Button: noop,
|
|
43
|
+
Banner: passChildren,
|
|
44
|
+
Field: passChildren,
|
|
45
|
+
Input: noop,
|
|
46
|
+
DataTable: captureDataTable,
|
|
47
|
+
Form: passChildren,
|
|
48
|
+
Section: passChildren,
|
|
49
|
+
Card: passChildren,
|
|
50
|
+
Grid: passChildren,
|
|
51
|
+
GridCell: passChildren,
|
|
52
|
+
Text: passChildren,
|
|
53
|
+
Heading: noop,
|
|
54
|
+
Dialog: noop,
|
|
55
|
+
Modal: noop,
|
|
56
|
+
Lightbox: noop,
|
|
57
|
+
ConfigSourceBadge: noop,
|
|
58
|
+
ConfigCascadeView: noop,
|
|
59
|
+
Link: noop,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let queryCallCount = 0;
|
|
63
|
+
let writeIsSuccess = true;
|
|
64
|
+
|
|
65
|
+
function stubDispatcher(): Dispatcher {
|
|
66
|
+
return {
|
|
67
|
+
write: (async () => {
|
|
68
|
+
if (!writeIsSuccess) {
|
|
69
|
+
return {
|
|
70
|
+
isSuccess: false,
|
|
71
|
+
error: {
|
|
72
|
+
code: "unknown",
|
|
73
|
+
httpStatus: 500,
|
|
74
|
+
i18nKey: "kumiko:error:unknown",
|
|
75
|
+
message: "boom",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { isSuccess: true, data: {} };
|
|
80
|
+
}) as unknown as Dispatcher["write"],
|
|
81
|
+
query: (async () => {
|
|
82
|
+
queryCallCount += 1;
|
|
83
|
+
return {
|
|
84
|
+
isSuccess: true,
|
|
85
|
+
data: { rows: [{ id: "unit-1", status: "active" }], nextCursor: null, total: 1 },
|
|
86
|
+
};
|
|
87
|
+
}) as unknown as Dispatcher["query"],
|
|
88
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
89
|
+
statusStore: {
|
|
90
|
+
getState: () => "online",
|
|
91
|
+
subscribe: () => () => {},
|
|
92
|
+
} as unknown as Dispatcher["statusStore"],
|
|
93
|
+
async *stream() {},
|
|
94
|
+
pendingWrites: () => [],
|
|
95
|
+
pendingFiles: () => [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildSchema(): FeatureSchema {
|
|
100
|
+
const entity: EntityDefinition = {
|
|
101
|
+
fields: {
|
|
102
|
+
status: {
|
|
103
|
+
type: "text",
|
|
104
|
+
maxLength: 50,
|
|
105
|
+
required: false,
|
|
106
|
+
searchable: false,
|
|
107
|
+
sortable: false,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const listScreen: EntityListScreenDefinition = {
|
|
112
|
+
id: "unit-list",
|
|
113
|
+
type: "entityList",
|
|
114
|
+
entity: "unit",
|
|
115
|
+
columns: ["status"],
|
|
116
|
+
rowActions: [
|
|
117
|
+
{
|
|
118
|
+
id: "archive",
|
|
119
|
+
label: "Archive",
|
|
120
|
+
handler: "units:write:unit:archive",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
featureName: "units",
|
|
126
|
+
entities: { unit: entity },
|
|
127
|
+
screens: [listScreen],
|
|
128
|
+
} as FeatureSchema;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderListScreen(): void {
|
|
132
|
+
render(
|
|
133
|
+
<LocaleProvider
|
|
134
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
135
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
136
|
+
>
|
|
137
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
138
|
+
<NavProvider
|
|
139
|
+
value={{
|
|
140
|
+
route: { screenId: "units:unit-list" },
|
|
141
|
+
navigate: () => {},
|
|
142
|
+
replace: () => {},
|
|
143
|
+
hrefFor: () => "",
|
|
144
|
+
searchParams: {},
|
|
145
|
+
setSearchParams: () => {},
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
149
|
+
<KumikoScreen schema={buildSchema()} qn="units:screen:unit-list" />
|
|
150
|
+
</PrimitivesProvider>
|
|
151
|
+
</NavProvider>
|
|
152
|
+
</DispatcherProvider>
|
|
153
|
+
</LocaleProvider>,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function requireArchiveAction(): DataTableRowAction {
|
|
158
|
+
const action = capturedRowActions?.find((a) => a.id === "archive");
|
|
159
|
+
if (!action) throw new Error("expected the 'archive' row action to be captured");
|
|
160
|
+
return action;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe("entityList row-action writeHandler refetches the rows query", () => {
|
|
164
|
+
test("a successful row-action write triggers exactly one refetch", async () => {
|
|
165
|
+
capturedRowActions = undefined;
|
|
166
|
+
queryCallCount = 0;
|
|
167
|
+
writeIsSuccess = true;
|
|
168
|
+
|
|
169
|
+
renderListScreen();
|
|
170
|
+
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
expect(capturedRowActions).toBeDefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const countAfterMount = queryCallCount;
|
|
176
|
+
expect(countAfterMount).toBeGreaterThan(0);
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
await requireArchiveAction().onTrigger({ id: "unit-1", values: { id: "unit-1" } });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(queryCallCount).toBe(countAfterMount + 1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Give any accidental extra refetch a chance to land before asserting
|
|
187
|
+
// there wasn't one (no double-fetch, no refetch loop).
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
189
|
+
expect(queryCallCount).toBe(countAfterMount + 1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a failed row-action write does not refetch the rows query", async () => {
|
|
193
|
+
capturedRowActions = undefined;
|
|
194
|
+
queryCallCount = 0;
|
|
195
|
+
writeIsSuccess = false;
|
|
196
|
+
|
|
197
|
+
renderListScreen();
|
|
198
|
+
|
|
199
|
+
await waitFor(() => {
|
|
200
|
+
expect(capturedRowActions).toBeDefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const countAfterMount = queryCallCount;
|
|
204
|
+
expect(countAfterMount).toBeGreaterThan(0);
|
|
205
|
+
|
|
206
|
+
await act(async () => {
|
|
207
|
+
await expect(
|
|
208
|
+
requireArchiveAction().onTrigger({ id: "unit-1", values: { id: "unit-1" } }),
|
|
209
|
+
).rejects.toThrow();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
213
|
+
expect(queryCallCount).toBe(countAfterMount);
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/app/form-schema.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
EntityEditScreenDefinition,
|
|
4
4
|
FieldDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
-
import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import { evalFieldCondition, NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
7
|
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { layoutEditFields } from "./layout-fields";
|
|
@@ -16,23 +16,13 @@ function isPresent(value: unknown): boolean {
|
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// types is caught loudly at boot — validateNoWidgetRequiredField in
|
|
27
|
-
// packages/framework/src/engine/boot-validator/screens.ts, which mirrors
|
|
28
|
-
// this set (framework can't import renderer, so it can't import this
|
|
29
|
-
// constant directly — keep both in sync).
|
|
30
|
-
const FIELD_TYPES_WITHOUT_WIDGET: ReadonlySet<FieldDefinition["type"]> = new Set([
|
|
31
|
-
"jsonb",
|
|
32
|
-
"embedded",
|
|
33
|
-
"files",
|
|
34
|
-
"images",
|
|
35
|
-
]);
|
|
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
|
+
function isEmbeddedListField(field: FieldDefinition): boolean {
|
|
24
|
+
return field.type === "embedded" && field.multiple === true;
|
|
25
|
+
}
|
|
36
26
|
|
|
37
27
|
// Renders raw to the user without a de+en default in i18n-defaults.ts.
|
|
38
28
|
export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
|
|
@@ -86,7 +76,10 @@ export function buildFormSchema(
|
|
|
86
76
|
const isRequired =
|
|
87
77
|
spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
|
|
88
78
|
if (!isRequired) continue;
|
|
89
|
-
|
|
79
|
+
// Embedded LIST fields get their own EmbeddedListField grid widget
|
|
80
|
+
// (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
|
|
81
|
+
// entry must not exempt them from the presence check below.
|
|
82
|
+
if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field)) continue;
|
|
90
83
|
if (isPresent(record[spec.field])) continue;
|
|
91
84
|
ctx.addIssue({
|
|
92
85
|
code: "custom",
|
|
@@ -1136,6 +1136,9 @@ function EntityListBody({
|
|
|
1136
1136
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1137
1137
|
);
|
|
1138
1138
|
}
|
|
1139
|
+
// Refetch — without a redirect nothing else remounts the screen,
|
|
1140
|
+
// so the list would otherwise keep showing stale rows.
|
|
1141
|
+
await rowsQuery.refetch();
|
|
1139
1142
|
},
|
|
1140
1143
|
isVisible:
|
|
1141
1144
|
writeActionVisible !== undefined
|
|
@@ -1144,7 +1147,7 @@ function EntityListBody({
|
|
|
1144
1147
|
};
|
|
1145
1148
|
})
|
|
1146
1149
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
1147
|
-
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate]);
|
|
1150
|
+
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, rowsQuery.refetch]);
|
|
1148
1151
|
|
|
1149
1152
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
1150
1153
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
@@ -1186,11 +1189,13 @@ function EntityListBody({
|
|
|
1186
1189
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1187
1190
|
);
|
|
1188
1191
|
}
|
|
1192
|
+
// Same refetch as rowActions above.
|
|
1193
|
+
await rowsQuery.refetch();
|
|
1189
1194
|
},
|
|
1190
1195
|
};
|
|
1191
1196
|
})
|
|
1192
1197
|
.filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
|
|
1193
|
-
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
|
|
1198
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
|
|
1194
1199
|
|
|
1195
1200
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1196
1201
|
return (
|
|
@@ -1375,6 +1380,8 @@ function ProjectionListBody({
|
|
|
1375
1380
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1376
1381
|
);
|
|
1377
1382
|
}
|
|
1383
|
+
// Same refetch as EntityListBody's rowActions above.
|
|
1384
|
+
await rowsQuery.refetch();
|
|
1378
1385
|
},
|
|
1379
1386
|
...(writeVisible !== undefined && {
|
|
1380
1387
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
@@ -1382,7 +1389,7 @@ function ProjectionListBody({
|
|
|
1382
1389
|
});
|
|
1383
1390
|
}
|
|
1384
1391
|
return out.length > 0 ? out : undefined;
|
|
1385
|
-
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher]);
|
|
1392
|
+
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
|
|
1386
1393
|
|
|
1387
1394
|
const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
|
|
1388
1395
|
if (screen.toolbarActions === undefined) return undefined;
|
|
@@ -1419,11 +1426,13 @@ function ProjectionListBody({
|
|
|
1419
1426
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1420
1427
|
);
|
|
1421
1428
|
}
|
|
1429
|
+
// Same refetch as rowActions above.
|
|
1430
|
+
await rowsQuery.refetch();
|
|
1422
1431
|
},
|
|
1423
1432
|
});
|
|
1424
1433
|
}
|
|
1425
1434
|
return out.length > 0 ? out : undefined;
|
|
1426
|
-
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
|
|
1435
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
|
|
1427
1436
|
|
|
1428
1437
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1429
1438
|
return (
|
|
@@ -473,8 +473,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
473
473
|
// on every parent render, not just on a snapshot change, risking a loop if
|
|
474
474
|
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
475
475
|
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
476
|
-
|
|
477
|
-
|
|
476
|
+
// Guards against redelivering to the SAME callback identity on every
|
|
477
|
+
// render (an inline-arrow onControlsReady would otherwise refire the
|
|
478
|
+
// effect below on every render since the callback itself is now a dep).
|
|
479
|
+
// Tracks the actual prop, not a ref snapshot, so a caller that swaps in a
|
|
480
|
+
// real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
|
|
481
|
+
// gets delivered to for THIS mount instead of never (fw#1899).
|
|
482
|
+
const deliveredControlsToRef = useRef<typeof onControlsReady>(undefined);
|
|
478
483
|
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
479
484
|
scopeFieldNamesRef.current = scopeFieldNames;
|
|
480
485
|
const scopedValidate = useCallback(
|
|
@@ -499,7 +504,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
499
504
|
const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
500
505
|
useEffect(() => {
|
|
501
506
|
return () => {
|
|
502
|
-
if (draftSaveTimerRef.current !== null)
|
|
507
|
+
if (draftSaveTimerRef.current !== null) {
|
|
508
|
+
clearTimeout(draftSaveTimerRef.current);
|
|
509
|
+
// Flush the pending debounced save instead of dropping it — a
|
|
510
|
+
// navigation/unmount inside the debounce window would otherwise
|
|
511
|
+
// silently discard the last patch() (discardDraft nulls this ref
|
|
512
|
+
// first on the post-submit path, so this can't resurrect a
|
|
513
|
+
// just-submitted draft).
|
|
514
|
+
saveDraftRef.current(currentStepRef.current);
|
|
515
|
+
}
|
|
503
516
|
};
|
|
504
517
|
}, []);
|
|
505
518
|
|
|
@@ -529,9 +542,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
529
542
|
// available on the very first onChange call, not just from the second
|
|
530
543
|
// keystroke onward.
|
|
531
544
|
useEffect(() => {
|
|
532
|
-
|
|
533
|
-
if (
|
|
534
|
-
|
|
545
|
+
if (onControlsReady === undefined) return;
|
|
546
|
+
if (deliveredControlsToRef.current === onControlsReady) return;
|
|
547
|
+
deliveredControlsToRef.current = onControlsReady;
|
|
548
|
+
onControlsReady({
|
|
535
549
|
patch: patchAndScheduleDraftSave,
|
|
536
550
|
validate: scopedValidate,
|
|
537
551
|
getValues: () => controller.getSnapshot().values,
|
|
@@ -539,9 +553,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
539
553
|
});
|
|
540
554
|
// controller is mount-lifetime-stable (see useForm's comment on its own
|
|
541
555
|
// useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
|
|
542
|
-
// useCallback over mount-stable deps) —
|
|
543
|
-
//
|
|
544
|
-
|
|
556
|
+
// useCallback over mount-stable deps) — onControlsReady is the only dep
|
|
557
|
+
// that can legitimately change post-mount, and the guard above stops an
|
|
558
|
+
// unstable inline-arrow identity from redelivering on every render.
|
|
559
|
+
}, [onControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
545
560
|
|
|
546
561
|
const schemaRef = useRef(schema);
|
|
547
562
|
schemaRef.current = schema;
|
|
@@ -647,6 +662,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
647
662
|
// (see draftCandidates below) — adopt it the same way a single
|
|
648
663
|
// auto-adopted candidate would be.
|
|
649
664
|
function adoptDraft(candidate: DraftCandidate): void {
|
|
665
|
+
// Locked state (#1896/fw#1909): `disabled` means "no write possible" —
|
|
666
|
+
// repointing draftKey and patching in the candidate's values is exactly
|
|
667
|
+
// that, so a locked form must not adopt a draft.
|
|
668
|
+
if (disabled) return;
|
|
650
669
|
const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
|
|
651
670
|
draftStorage.setDraftId(screen.id, adoptedId);
|
|
652
671
|
setDraftId(adoptedId);
|
|
@@ -666,7 +685,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
666
685
|
);
|
|
667
686
|
|
|
668
687
|
const filteredSections = useMemo(
|
|
669
|
-
|
|
688
|
+
// A fully-hidden "fields" section (every field in it currently
|
|
689
|
+
// condition-hidden) must not occupy a wizard step; it would render
|
|
690
|
+
// empty and block Back/Next on nothing. Extension sections carry no
|
|
691
|
+
// `visible` (they own their own dirty/save lifecycle), so they always
|
|
692
|
+
// pass through. A section with no fields at all (e.g. a review-only
|
|
693
|
+
// step) has `visible: fields.some(...)` = false vacuously; that's "no
|
|
694
|
+
// fields to hide", not "hidden", so it stays too (fw#1901).
|
|
695
|
+
() =>
|
|
696
|
+
filterEditSections(vm.sections, fieldsFilter).filter(
|
|
697
|
+
(section) => section.kind === "extension" || section.fields.length === 0 || section.visible,
|
|
698
|
+
),
|
|
670
699
|
[vm.sections, fieldsFilter],
|
|
671
700
|
);
|
|
672
701
|
|
|
@@ -889,10 +918,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
889
918
|
let extensionsPersisted = true;
|
|
890
919
|
if (result.isSuccess) {
|
|
891
920
|
setFormError(null);
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
//
|
|
895
|
-
|
|
921
|
+
// `isNoOp: true` (payloadMode "changes", pre-filled form submitted
|
|
922
|
+
// untouched) means controller.submit() never called dispatcher.write
|
|
923
|
+
// — nothing to discard, and discarding here would delete a draft the
|
|
924
|
+
// form never persisted (fw#1978). Awaited, not fire-and-forget:
|
|
925
|
+
// `onSubmit` typically navigates away and unmounts this form, which
|
|
926
|
+
// would abort an in-flight discard and leave the draft behind after
|
|
927
|
+
// a successful submit.
|
|
928
|
+
if (result.isNoOp !== true) await discardDraft();
|
|
896
929
|
extensionsPersisted = await persistExtensions();
|
|
897
930
|
} else if (result.validationBlocked) {
|
|
898
931
|
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
@@ -959,6 +992,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
959
992
|
type="button"
|
|
960
993
|
variant="danger"
|
|
961
994
|
testId="render-edit-delete"
|
|
995
|
+
disabled={disabled}
|
|
962
996
|
onClick={() => setConfirmDeleteOpen(true)}
|
|
963
997
|
>
|
|
964
998
|
{translate("kumiko.actions.delete")}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -89,6 +89,11 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
89
89
|
// Widgets — StepBar screen-reader text for completed steps whose number is visually replaced by a checkmark.
|
|
90
90
|
"kumiko.widget.step-bar.done": "Erledigt",
|
|
91
91
|
|
|
92
|
+
// Widgets — Drawer resize handle + maximize toggle aria-labels.
|
|
93
|
+
"kumiko.widget.drawer.restore": "Drawer-Breite zurücksetzen",
|
|
94
|
+
"kumiko.widget.drawer.maximize": "Drawer maximieren",
|
|
95
|
+
"kumiko.widget.drawer.resize": "Drawer-Größe ändern",
|
|
96
|
+
|
|
92
97
|
// Nav — Sidebar Tree (Toggle-aria-Labels).
|
|
93
98
|
"kumiko.nav.expand": "Aufklappen",
|
|
94
99
|
"kumiko.nav.collapse": "Zuklappen",
|
|
@@ -269,6 +274,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
269
274
|
|
|
270
275
|
"kumiko.widget.step-bar.done": "Done",
|
|
271
276
|
|
|
277
|
+
"kumiko.widget.drawer.restore": "Restore drawer width",
|
|
278
|
+
"kumiko.widget.drawer.maximize": "Maximize drawer width",
|
|
279
|
+
"kumiko.widget.drawer.resize": "Resize drawer",
|
|
280
|
+
|
|
272
281
|
"kumiko.nav.expand": "Expand",
|
|
273
282
|
"kumiko.nav.collapse": "Collapse",
|
|
274
283
|
"kumiko.nav.search": "Search navigation…",
|