@cosmicdrift/kumiko-renderer-web 0.187.0 → 0.189.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__/config-edit.test.tsx +66 -0
- package/src/__tests__/entity-edit-redirect.test.tsx +166 -0
- package/src/__tests__/kumiko-screen.test.tsx +144 -0
- package/src/__tests__/primitives.test.tsx +23 -0
- package/src/__tests__/render-edit.test.tsx +1505 -1
- package/src/__tests__/test-utils.tsx +24 -0
- package/src/__tests__/wizard-form-validation.test.tsx +73 -0
- package/src/app/create-app.tsx +33 -20
- package/src/app/draft-storage.ts +59 -0
- package/src/index.ts +4 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +14 -1
- package/src/primitives/embedded-list-input.tsx +3 -2
- package/src/primitives/index.tsx +35 -1
- package/src/primitives/located-timestamp-input.tsx +1 -21
- package/src/primitives/money-input.tsx +5 -9
- package/src/primitives/tz-input.tsx +43 -0
- package/src/primitives/tz-options.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.189.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.189.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.189.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -38,6 +38,28 @@ const settingsScreen: ConfigEditScreenDefinition = {
|
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
// A money-typed field is a legitimate FieldDefinition (screen.fields reuses
|
|
42
|
+
// the entity field-shape, kumiko-framework#1923 render-field.tsx money case)
|
|
43
|
+
// even though ConfigKeyType (write-helpers.ts) never carries "money" — the
|
|
44
|
+
// server side of a config key is always a bare scalar.
|
|
45
|
+
const moneySettingsScreen: ConfigEditScreenDefinition = {
|
|
46
|
+
id: "money-settings",
|
|
47
|
+
type: "configEdit",
|
|
48
|
+
scope: "tenant",
|
|
49
|
+
configKeys: { priceLimit: "demo:config:price-limit" },
|
|
50
|
+
fields: {
|
|
51
|
+
priceLimit: { type: "money" },
|
|
52
|
+
// @cast-boundary inline schema-author shape — FieldDefinition union too narrow
|
|
53
|
+
} as ConfigEditScreenDefinition["fields"],
|
|
54
|
+
layout: { sections: [{ title: "Basics", fields: ["priceLimit"] }] },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const moneySchema: FeatureSchema = {
|
|
58
|
+
featureName: "demo",
|
|
59
|
+
entities: {},
|
|
60
|
+
screens: [moneySettingsScreen],
|
|
61
|
+
};
|
|
62
|
+
|
|
41
63
|
const schema: FeatureSchema = {
|
|
42
64
|
featureName: "demo",
|
|
43
65
|
entities: {},
|
|
@@ -138,6 +160,50 @@ describe("KumikoScreen / configEdit", () => {
|
|
|
138
160
|
});
|
|
139
161
|
});
|
|
140
162
|
|
|
163
|
+
// kumiko-framework#1923: RenderField's money case now always emits the
|
|
164
|
+
// entityEdit `{amount, currency}` payload shape. A config key's server
|
|
165
|
+
// value is always a bare scalar (ConfigKeyType has no "money" variant) —
|
|
166
|
+
// customSubmit must unwrap back to `.amount` before dispatching, or the
|
|
167
|
+
// batch's config:write:set value fails its `string | number | boolean`
|
|
168
|
+
// schema entirely (regression guard for that unwrap).
|
|
169
|
+
test("money field submit unwraps {amount, currency} back to a bare number", async () => {
|
|
170
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
171
|
+
isSuccess: true as const,
|
|
172
|
+
results: [],
|
|
173
|
+
}));
|
|
174
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
175
|
+
query: (async () => ({
|
|
176
|
+
isSuccess: true,
|
|
177
|
+
data: { "demo:config:price-limit": { value: 12.99, scope: "tenant" } },
|
|
178
|
+
})) as unknown as Dispatcher["query"],
|
|
179
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const user = userEvent.setup();
|
|
183
|
+
render(
|
|
184
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
185
|
+
<KumikoScreen schema={moneySchema} qn="demo:screen:money-settings" />
|
|
186
|
+
</DispatcherProvider>,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
190
|
+
const priceInput = screen.getByTestId("field-priceLimit").querySelector("input");
|
|
191
|
+
if (!priceInput) throw new Error("expected priceLimit input");
|
|
192
|
+
|
|
193
|
+
await user.clear(priceInput);
|
|
194
|
+
await user.type(priceInput, "15.00");
|
|
195
|
+
await user.click(screen.getByTestId("render-edit-submit"));
|
|
196
|
+
|
|
197
|
+
await waitFor(() => expect(batchSpy).toHaveBeenCalled());
|
|
198
|
+
const commands = batchSpy.mock.calls[0]?.[0];
|
|
199
|
+
if (!commands) throw new Error("batchSpy not called");
|
|
200
|
+
expect(commands).toHaveLength(1);
|
|
201
|
+
expect(commands[0]).toEqual({
|
|
202
|
+
type: "config:write:set",
|
|
203
|
+
payload: { key: "demo:config:price-limit", value: 15, scope: "tenant" },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
141
207
|
test("save-button disabled after successful submit (rebase fired)", async () => {
|
|
142
208
|
const dispatcher: Dispatcher = createMockDispatcher({
|
|
143
209
|
query: (async () => ({
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Issue #1942: entityEdit had no post-save redirect (only actionForm did) —
|
|
2
|
+
// RenderEdit only exposed onSubmit/onCancel, unreachable from a declarative
|
|
3
|
+
// screen. This exercises the real create/update submit path through
|
|
4
|
+
// KumikoScreen → EntityEditCreateBody/EntityEditUpdateForm, not just the
|
|
5
|
+
// type/boot-validator layer, proving a successful save actually calls
|
|
6
|
+
// nav.navigate(redirect) instead of the default "back to list", and that
|
|
7
|
+
// omitting redirect keeps the existing list-navigation behavior.
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type {
|
|
10
|
+
EntityDefinition,
|
|
11
|
+
FeatureSchema,
|
|
12
|
+
ScreenDefinition,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
15
|
+
import type { NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
18
|
+
|
|
19
|
+
const productEntity: EntityDefinition = {
|
|
20
|
+
fields: { name: { type: "text", required: false, searchable: false, sortable: false } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function buildSchema(redirect?: string): FeatureSchema {
|
|
24
|
+
const editScreen: ScreenDefinition = {
|
|
25
|
+
id: "product-edit",
|
|
26
|
+
type: "entityEdit",
|
|
27
|
+
entity: "product",
|
|
28
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
29
|
+
...(redirect !== undefined && { redirect }),
|
|
30
|
+
};
|
|
31
|
+
const listScreen: ScreenDefinition = {
|
|
32
|
+
id: "product-list",
|
|
33
|
+
type: "entityList",
|
|
34
|
+
entity: "product",
|
|
35
|
+
columns: ["name"],
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
featureName: "shop",
|
|
39
|
+
entities: { product: productEntity },
|
|
40
|
+
screens: [editScreen, listScreen],
|
|
41
|
+
} as FeatureSchema;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fillNameAndSubmit(): void {
|
|
45
|
+
const nameInput = screen.getByTestId("field-name").querySelector("input") as HTMLInputElement;
|
|
46
|
+
fireEvent.change(nameInput, { target: { value: "Widget" } });
|
|
47
|
+
const form = screen.getByTestId("render-edit-form");
|
|
48
|
+
fireEvent.submit(form);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("entityEdit redirect (#1942)", () => {
|
|
52
|
+
test("create: successful save with redirect set navigates there, not to the list", async () => {
|
|
53
|
+
const navigated: NavTarget[] = [];
|
|
54
|
+
render(
|
|
55
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
56
|
+
<NavProvider
|
|
57
|
+
value={{
|
|
58
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
59
|
+
navigate: (target) => navigated.push(target),
|
|
60
|
+
replace: () => {},
|
|
61
|
+
hrefFor: () => "",
|
|
62
|
+
searchParams: {},
|
|
63
|
+
setSearchParams: () => {},
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<KumikoScreen schema={buildSchema("product-detail")} qn="shop:screen:product-edit" />
|
|
67
|
+
</NavProvider>
|
|
68
|
+
</DispatcherProvider>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
fillNameAndSubmit();
|
|
72
|
+
|
|
73
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("create: no redirect set falls back to the entity's list screen", async () => {
|
|
77
|
+
const navigated: NavTarget[] = [];
|
|
78
|
+
render(
|
|
79
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
80
|
+
<NavProvider
|
|
81
|
+
value={{
|
|
82
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
83
|
+
navigate: (target) => navigated.push(target),
|
|
84
|
+
replace: () => {},
|
|
85
|
+
hrefFor: () => "",
|
|
86
|
+
searchParams: {},
|
|
87
|
+
setSearchParams: () => {},
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<KumikoScreen schema={buildSchema()} qn="shop:screen:product-edit" />
|
|
91
|
+
</NavProvider>
|
|
92
|
+
</DispatcherProvider>,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
fillNameAndSubmit();
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("create: redirect as fully-qualified cross-feature QN navigates via its short id (#1946)", async () => {
|
|
101
|
+
// redirect may name a screen in ANOTHER feature via `<feature>:screen:<id>`
|
|
102
|
+
// (boot-validator accepts the QN directly) — the renderer must strip it
|
|
103
|
+
// down to the short id before calling nav.navigate, since the runtime
|
|
104
|
+
// router resolves bare short ids app-wide, not raw QNs (see nav.tsx).
|
|
105
|
+
const navigated: NavTarget[] = [];
|
|
106
|
+
render(
|
|
107
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
108
|
+
<NavProvider
|
|
109
|
+
value={{
|
|
110
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
111
|
+
navigate: (target) => navigated.push(target),
|
|
112
|
+
replace: () => {},
|
|
113
|
+
hrefFor: () => "",
|
|
114
|
+
searchParams: {},
|
|
115
|
+
setSearchParams: () => {},
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<KumikoScreen
|
|
119
|
+
schema={buildSchema("statements:screen:statement-upload-list")}
|
|
120
|
+
qn="shop:screen:product-edit"
|
|
121
|
+
/>
|
|
122
|
+
</NavProvider>
|
|
123
|
+
</DispatcherProvider>,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
fillNameAndSubmit();
|
|
127
|
+
|
|
128
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "statement-upload-list" }]));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("update: successful save with redirect set navigates there, not to the list", async () => {
|
|
132
|
+
const navigated: NavTarget[] = [];
|
|
133
|
+
const dispatcher = createMockDispatcher({
|
|
134
|
+
query: (async () => ({
|
|
135
|
+
isSuccess: true,
|
|
136
|
+
data: { id: "42", version: 1, name: "Existing" },
|
|
137
|
+
})) as unknown as Dispatcher["query"],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
render(
|
|
141
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
142
|
+
<NavProvider
|
|
143
|
+
value={{
|
|
144
|
+
route: { screenId: "shop:screen:product-edit", entityId: "42" },
|
|
145
|
+
navigate: (target) => navigated.push(target),
|
|
146
|
+
replace: () => {},
|
|
147
|
+
hrefFor: () => "",
|
|
148
|
+
searchParams: {},
|
|
149
|
+
setSearchParams: () => {},
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<KumikoScreen
|
|
153
|
+
schema={buildSchema("product-detail")}
|
|
154
|
+
qn="shop:screen:product-edit"
|
|
155
|
+
entityId="42"
|
|
156
|
+
/>
|
|
157
|
+
</NavProvider>
|
|
158
|
+
</DispatcherProvider>,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
|
|
162
|
+
fillNameAndSubmit();
|
|
163
|
+
|
|
164
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -1404,6 +1404,50 @@ describe("KumikoScreen", () => {
|
|
|
1404
1404
|
expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
|
|
1405
1405
|
});
|
|
1406
1406
|
|
|
1407
|
+
test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
|
|
1408
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
1409
|
+
const dispatcher = makeDispatcher({
|
|
1410
|
+
write: (async () => ({
|
|
1411
|
+
isSuccess: true,
|
|
1412
|
+
data: { id: "x" },
|
|
1413
|
+
})) as unknown as Dispatcher["write"],
|
|
1414
|
+
});
|
|
1415
|
+
const memoryNav = {
|
|
1416
|
+
route: { screenId: "quick-add" },
|
|
1417
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
1418
|
+
replace: () => undefined,
|
|
1419
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
1420
|
+
searchParams: {},
|
|
1421
|
+
setSearchParams: () => undefined,
|
|
1422
|
+
};
|
|
1423
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
1424
|
+
id: "quick-add",
|
|
1425
|
+
type: "actionForm",
|
|
1426
|
+
handler: "tasks:write:task:quick-add",
|
|
1427
|
+
fields: { title: { type: "text", required: true } },
|
|
1428
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1429
|
+
redirect: "statements:screen:statement-upload-list",
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
1433
|
+
render(
|
|
1434
|
+
<NavProvider value={memoryNav}>
|
|
1435
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1436
|
+
<KumikoScreen
|
|
1437
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
1438
|
+
qn="tasks:screen:quick-add"
|
|
1439
|
+
/>
|
|
1440
|
+
</DispatcherProvider>
|
|
1441
|
+
</NavProvider>,
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1445
|
+
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1446
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1447
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1448
|
+
expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1407
1451
|
// cancelTarget (Bug-Bash 2026-06-07, Bug 9): redirect erzeugte
|
|
1408
1452
|
// automatisch einen Abbrechen-Button mit demselben Ziel wie der
|
|
1409
1453
|
// Submit-Redirect — auf Single-Action-Screens ("Test-Mail senden")
|
|
@@ -1814,6 +1858,106 @@ describe("KumikoScreen: update-only entityEdit (allowCreate/allowDelete)", () =>
|
|
|
1814
1858
|
});
|
|
1815
1859
|
});
|
|
1816
1860
|
|
|
1861
|
+
// --- singleton entityEdit (fw#1941) ---
|
|
1862
|
+
// Singleton entities (exactly one record per tenant) have no nav path
|
|
1863
|
+
// that supplies an entityId. `singleton: true` resolves the existing
|
|
1864
|
+
// record via list(limit:1) before deciding create vs update.
|
|
1865
|
+
describe("KumikoScreen: singleton entityEdit", () => {
|
|
1866
|
+
const singletonEdit: EntityEditScreenDefinition = {
|
|
1867
|
+
id: "task-edit",
|
|
1868
|
+
type: "entityEdit",
|
|
1869
|
+
entity: "task",
|
|
1870
|
+
singleton: true,
|
|
1871
|
+
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
1872
|
+
};
|
|
1873
|
+
const singletonSchema: FeatureSchema = {
|
|
1874
|
+
featureName: "tasks",
|
|
1875
|
+
entities: { task: taskEntity },
|
|
1876
|
+
screens: [singletonEdit],
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
test("kein vorhandener Record → rendert leeres Create-Form", async () => {
|
|
1880
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
1881
|
+
const dispatcher = makeDispatcher({
|
|
1882
|
+
query: (async (type: string, payload: unknown) => {
|
|
1883
|
+
queryCalls.push({ type, payload });
|
|
1884
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
1885
|
+
}) as unknown as Dispatcher["query"],
|
|
1886
|
+
});
|
|
1887
|
+
render(
|
|
1888
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1889
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
1890
|
+
</DispatcherProvider>,
|
|
1891
|
+
);
|
|
1892
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1893
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1894
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1895
|
+
expect(titleInput.value).toBe("");
|
|
1896
|
+
expect(queryCalls).toEqual([{ type: "tasks:query:task:list", payload: { limit: 1 } }]);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
test("vorhandener Record → lädt ihn (Update-Form, prefilled) statt Create", async () => {
|
|
1900
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
1901
|
+
const dispatcher = makeDispatcher({
|
|
1902
|
+
query: (async (type: string, payload: unknown) => {
|
|
1903
|
+
queryCalls.push({ type, payload });
|
|
1904
|
+
if (type.endsWith(":list")) {
|
|
1905
|
+
return {
|
|
1906
|
+
isSuccess: true,
|
|
1907
|
+
data: { rows: [{ id: "task-1", title: "loaded-title" }], nextCursor: null },
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
return {
|
|
1911
|
+
isSuccess: true,
|
|
1912
|
+
data: { id: "task-1", version: 1, title: "loaded-title", count: 0, done: false },
|
|
1913
|
+
};
|
|
1914
|
+
}) as unknown as Dispatcher["query"],
|
|
1915
|
+
});
|
|
1916
|
+
render(
|
|
1917
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1918
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
1919
|
+
</DispatcherProvider>,
|
|
1920
|
+
);
|
|
1921
|
+
// Two sequential loading cycles (list, then detail once the singleton
|
|
1922
|
+
// wrapper hands off to EntityEditUpdateBody) — default waitFor timeout
|
|
1923
|
+
// can be too tight under CI's shared-process test load.
|
|
1924
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull(), {
|
|
1925
|
+
timeout: 5000,
|
|
1926
|
+
});
|
|
1927
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1928
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1929
|
+
expect(titleInput.value).toBe("loaded-title");
|
|
1930
|
+
// Anchor: the id resolved from list(limit:1) reaches the detail query —
|
|
1931
|
+
// otherwise the form would render empty/create-mode instead of loading it.
|
|
1932
|
+
expect(queryCalls).toContainEqual({
|
|
1933
|
+
type: "tasks:query:task:detail",
|
|
1934
|
+
payload: { id: "task-1" },
|
|
1935
|
+
});
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test("allowCreate:false + leere Tabelle → Fehler-Banner statt Create-Form", async () => {
|
|
1939
|
+
const disabledSchema: FeatureSchema = {
|
|
1940
|
+
featureName: "tasks",
|
|
1941
|
+
entities: { task: taskEntity },
|
|
1942
|
+
screens: [{ ...singletonEdit, allowCreate: false }],
|
|
1943
|
+
};
|
|
1944
|
+
const dispatcher = makeDispatcher({
|
|
1945
|
+
query: (async () => ({
|
|
1946
|
+
isSuccess: true,
|
|
1947
|
+
data: { rows: [], nextCursor: null },
|
|
1948
|
+
})) as unknown as Dispatcher["query"],
|
|
1949
|
+
});
|
|
1950
|
+
render(
|
|
1951
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1952
|
+
<KumikoScreen schema={disabledSchema} qn="tasks:screen:task-edit" />
|
|
1953
|
+
</DispatcherProvider>,
|
|
1954
|
+
);
|
|
1955
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1956
|
+
expect(screen.getByTestId("kumiko-screen-create-disabled")).toBeTruthy();
|
|
1957
|
+
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1817
1961
|
// --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
|
|
1818
1962
|
// actionForm hat keinen record — Extension-Sections bekommen stattdessen
|
|
1819
1963
|
// die initialen Form-Values (inkl. searchParams-Prefill) als initialValues.
|
|
@@ -1040,6 +1040,29 @@ describe("Form", () => {
|
|
|
1040
1040
|
);
|
|
1041
1041
|
expect(screen.queryByTestId("form-actions")).toBeNull();
|
|
1042
1042
|
});
|
|
1043
|
+
|
|
1044
|
+
test("stickyActions: Footer bekommt mobile-fixed-Klassen (fw#1918)", () => {
|
|
1045
|
+
render(
|
|
1046
|
+
<Form
|
|
1047
|
+
onSubmit={() => undefined}
|
|
1048
|
+
actions={<button type="submit">Next</button>}
|
|
1049
|
+
testId="form"
|
|
1050
|
+
stickyActions
|
|
1051
|
+
>
|
|
1052
|
+
<div>content</div>
|
|
1053
|
+
</Form>,
|
|
1054
|
+
);
|
|
1055
|
+
expect(screen.getByTestId("form-actions").className).toContain("max-sm:fixed");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("ohne stickyActions: Footer bleibt im normalen Dokumentfluss", () => {
|
|
1059
|
+
render(
|
|
1060
|
+
<Form onSubmit={() => undefined} actions={<button type="submit">Save</button>} testId="form">
|
|
1061
|
+
<div>content</div>
|
|
1062
|
+
</Form>,
|
|
1063
|
+
);
|
|
1064
|
+
expect(screen.getByTestId("form-actions").className).not.toContain("max-sm:fixed");
|
|
1065
|
+
});
|
|
1043
1066
|
});
|
|
1044
1067
|
|
|
1045
1068
|
describe("Banner padded", () => {
|