@cosmicdrift/kumiko-renderer 0.194.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 +3 -3
- package/src/app/__tests__/content-editors.test.tsx +16 -2
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +215 -0
- package/src/app/content-editors.tsx +12 -5
- package/src/app/content-preview.tsx +3 -1
- package/src/app/form-schema.ts +12 -19
- package/src/app/kumiko-screen.tsx +19 -5
- package/src/components/reference-create-dialog.tsx +1 -1
- package/src/components/render-edit.tsx +73 -24
- package/src/i18n-defaults.ts +9 -0
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"
|
|
@@ -58,7 +58,15 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
58
58
|
|
|
59
59
|
function Probe({ contentFormat }: { readonly contentFormat?: string }): ReactNode {
|
|
60
60
|
const Editor = useContentEditor(contentFormat);
|
|
61
|
-
return
|
|
61
|
+
return (
|
|
62
|
+
<Editor
|
|
63
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
64
|
+
value="hello"
|
|
65
|
+
onChange={() => {}}
|
|
66
|
+
variables={[]}
|
|
67
|
+
readOnly={false}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
describe("useContentEditor", () => {
|
|
@@ -102,7 +110,13 @@ describe("TextareaContentEditor", () => {
|
|
|
102
110
|
// non-admin viewing read-only content couldn't select/copy it. readOnly
|
|
103
111
|
// keeps the field focusable and selectable while blocking edits.
|
|
104
112
|
render(
|
|
105
|
-
<TextareaContentEditor
|
|
113
|
+
<TextareaContentEditor
|
|
114
|
+
id={CONTENT_EDITOR_ELEMENT_ID}
|
|
115
|
+
value="draft"
|
|
116
|
+
onChange={() => {}}
|
|
117
|
+
variables={[]}
|
|
118
|
+
readOnly={true}
|
|
119
|
+
/>,
|
|
106
120
|
{ wrapper: Wrapper },
|
|
107
121
|
);
|
|
108
122
|
const el = screen.getByTestId("ca-textarea") as HTMLTextAreaElement;
|
|
@@ -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
|
+
});
|
|
@@ -12,6 +12,12 @@ import { type ComponentType, createContext, type ReactNode, useContext } from "r
|
|
|
12
12
|
import { usePrimitives } from "../primitives";
|
|
13
13
|
|
|
14
14
|
export type ContentEditorProps = {
|
|
15
|
+
/** DOM id every registered editor must render onto its own focusable
|
|
16
|
+
* root element. Callers that wrap an editor in a `Field` (label +
|
|
17
|
+
* htmlFor) pass a stable id here so the label stays associated with
|
|
18
|
+
* whatever element actually renders — a fixed constant would break as
|
|
19
|
+
* soon as a registered editor swaps in for the textarea fallback. */
|
|
20
|
+
readonly id: string;
|
|
15
21
|
readonly value: string;
|
|
16
22
|
readonly onChange: (value: string) => void;
|
|
17
23
|
/** Variable names insertable as chips (from the collection's
|
|
@@ -24,10 +30,10 @@ export type ContentEditorProps = {
|
|
|
24
30
|
|
|
25
31
|
export type ContentEditorComponent = ComponentType<ContentEditorProps>;
|
|
26
32
|
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
/** Default id value for callers that don't generate their own (e.g. a
|
|
34
|
+
* standalone TextareaContentEditor render outside a multi-editor page).
|
|
35
|
+
* Registered editors don't apply this automatically — every caller passes
|
|
36
|
+
* its own id via ContentEditorProps.id. */
|
|
31
37
|
export const CONTENT_EDITOR_ELEMENT_ID = "content-editor-textarea";
|
|
32
38
|
|
|
33
39
|
export type ContentEditorsMap = Readonly<Record<string, ContentEditorComponent>>;
|
|
@@ -50,6 +56,7 @@ export function ContentEditorsProvider({
|
|
|
50
56
|
* Uses the primitives Input like every other form field, so it renders on
|
|
51
57
|
* every platform without requiring the platform's DOM/native equivalent. */
|
|
52
58
|
export function TextareaContentEditor({
|
|
59
|
+
id,
|
|
53
60
|
value,
|
|
54
61
|
onChange,
|
|
55
62
|
readOnly,
|
|
@@ -58,7 +65,7 @@ export function TextareaContentEditor({
|
|
|
58
65
|
return (
|
|
59
66
|
<Input
|
|
60
67
|
kind="textarea"
|
|
61
|
-
id={
|
|
68
|
+
id={id}
|
|
62
69
|
name={CONTENT_EDITOR_ELEMENT_ID}
|
|
63
70
|
value={value}
|
|
64
71
|
onChange={onChange}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// renders formatted HTML and "plain"/"markdown" render as text through the
|
|
5
5
|
// exact same component, no separate render path per format.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type ReactNode, useId } from "react";
|
|
8
8
|
import { useContentEditor } from "./content-editors";
|
|
9
9
|
|
|
10
10
|
const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
|
|
@@ -36,6 +36,7 @@ export function ContentPreview({
|
|
|
36
36
|
contentFormat,
|
|
37
37
|
}: ContentPreviewProps): ReactNode {
|
|
38
38
|
const Editor = useContentEditor(contentFormat);
|
|
39
|
+
const id = useId();
|
|
39
40
|
// "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
|
|
40
41
|
// an example value substituted in raw could break the markup (`<`) or
|
|
41
42
|
// render as an unescaped entity (`&`). "plain"/"markdown" content is text,
|
|
@@ -48,6 +49,7 @@ export function ContentPreview({
|
|
|
48
49
|
: variables;
|
|
49
50
|
return (
|
|
50
51
|
<Editor
|
|
52
|
+
id={id}
|
|
51
53
|
value={substituteVariables(content, safeVariables)}
|
|
52
54
|
onChange={noop}
|
|
53
55
|
variables={[]}
|
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",
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
} from "@cosmicdrift/kumiko-headless";
|
|
27
27
|
import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
|
|
28
28
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
29
|
+
import { extractCreatedId } from "../components/reference-create-dialog";
|
|
29
30
|
import { RenderEdit } from "../components/render-edit";
|
|
30
31
|
import { RenderList, type ToolbarActionButton } from "../components/render-list";
|
|
31
32
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
@@ -461,7 +462,11 @@ function EntityEditCreateBody({
|
|
|
461
462
|
(result: SubmitResult<unknown>) => {
|
|
462
463
|
if (!result.isSuccess) return;
|
|
463
464
|
if (screen.redirect !== undefined) {
|
|
464
|
-
|
|
465
|
+
const entityId = extractCreatedId(result.data);
|
|
466
|
+
nav.navigate({
|
|
467
|
+
screenId: lastSegment(screen.redirect),
|
|
468
|
+
...(entityId !== undefined && { entityId }),
|
|
469
|
+
});
|
|
465
470
|
return;
|
|
466
471
|
}
|
|
467
472
|
navigateToList();
|
|
@@ -1131,6 +1136,9 @@ function EntityListBody({
|
|
|
1131
1136
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1132
1137
|
);
|
|
1133
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();
|
|
1134
1142
|
},
|
|
1135
1143
|
isVisible:
|
|
1136
1144
|
writeActionVisible !== undefined
|
|
@@ -1139,7 +1147,7 @@ function EntityListBody({
|
|
|
1139
1147
|
};
|
|
1140
1148
|
})
|
|
1141
1149
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
1142
|
-
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate]);
|
|
1150
|
+
}, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, rowsQuery.refetch]);
|
|
1143
1151
|
|
|
1144
1152
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
1145
1153
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
@@ -1181,11 +1189,13 @@ function EntityListBody({
|
|
|
1181
1189
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1182
1190
|
);
|
|
1183
1191
|
}
|
|
1192
|
+
// Same refetch as rowActions above.
|
|
1193
|
+
await rowsQuery.refetch();
|
|
1184
1194
|
},
|
|
1185
1195
|
};
|
|
1186
1196
|
})
|
|
1187
1197
|
.filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
|
|
1188
|
-
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
|
|
1198
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
|
|
1189
1199
|
|
|
1190
1200
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1191
1201
|
return (
|
|
@@ -1370,6 +1380,8 @@ function ProjectionListBody({
|
|
|
1370
1380
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1371
1381
|
);
|
|
1372
1382
|
}
|
|
1383
|
+
// Same refetch as EntityListBody's rowActions above.
|
|
1384
|
+
await rowsQuery.refetch();
|
|
1373
1385
|
},
|
|
1374
1386
|
...(writeVisible !== undefined && {
|
|
1375
1387
|
isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
|
|
@@ -1377,7 +1389,7 @@ function ProjectionListBody({
|
|
|
1377
1389
|
});
|
|
1378
1390
|
}
|
|
1379
1391
|
return out.length > 0 ? out : undefined;
|
|
1380
|
-
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher]);
|
|
1392
|
+
}, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
|
|
1381
1393
|
|
|
1382
1394
|
const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
|
|
1383
1395
|
if (screen.toolbarActions === undefined) return undefined;
|
|
@@ -1414,11 +1426,13 @@ function ProjectionListBody({
|
|
|
1414
1426
|
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1415
1427
|
);
|
|
1416
1428
|
}
|
|
1429
|
+
// Same refetch as rowActions above.
|
|
1430
|
+
await rowsQuery.refetch();
|
|
1417
1431
|
},
|
|
1418
1432
|
});
|
|
1419
1433
|
}
|
|
1420
1434
|
return out.length > 0 ? out : undefined;
|
|
1421
|
-
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
|
|
1435
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
|
|
1422
1436
|
|
|
1423
1437
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1424
1438
|
return (
|
|
@@ -23,7 +23,7 @@ function entityWriteCommand(featureName: string, entity: string): string {
|
|
|
23
23
|
// The create handler's success payload is `{ kind: "save", id, ... }`
|
|
24
24
|
// (see event-store-executor-write.ts) but RenderEdit's onSubmit only
|
|
25
25
|
// types it as `unknown` — narrow defensively instead of casting through it.
|
|
26
|
-
function extractCreatedId(data: unknown): string | undefined {
|
|
26
|
+
export function extractCreatedId(data: unknown): string | undefined {
|
|
27
27
|
if (typeof data !== "object" || data === null) return undefined;
|
|
28
28
|
const id = (data as Record<string, unknown>)["id"];
|
|
29
29
|
return typeof id === "string" ? id : undefined;
|
|
@@ -25,7 +25,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
|
|
|
25
25
|
import type { z } from "zod";
|
|
26
26
|
import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
|
|
27
27
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
|
|
28
|
-
import {
|
|
28
|
+
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
29
29
|
import { useDraftStorage } from "../context/draft-storage-context";
|
|
30
30
|
import { formatWhen } from "../format-when";
|
|
31
31
|
import { useForm } from "../hooks/use-form";
|
|
@@ -80,6 +80,14 @@ function newDraftPrefix(screenId: string): string {
|
|
|
80
80
|
return `${screenId}:new:`;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
|
|
84
|
+
// without a polyfill — guard like dispatcher-live.ts's generateRequestId.
|
|
85
|
+
function mintDraftId(): string {
|
|
86
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
87
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
88
|
+
return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
// End-to-end renderer für einen entityEdit screen. Rendert aus-
|
|
84
92
|
// schließlich über Primitives — kein raw HTML. Ein Native-Renderer
|
|
85
93
|
// der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
|
|
@@ -343,10 +351,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
343
351
|
// ohnehin nur in einem mounted Kumiko-App-Tree läuft.
|
|
344
352
|
const t = useTranslation();
|
|
345
353
|
const translate = translateProp ?? t;
|
|
346
|
-
const dispatcher =
|
|
354
|
+
const dispatcher = useOptionalDispatcher();
|
|
347
355
|
|
|
348
356
|
const isWizard = screen.layout.mode === "wizard";
|
|
349
|
-
const draftEnabled = isWizard && screen.layout.draft === true;
|
|
357
|
+
const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
|
|
350
358
|
const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
|
|
351
359
|
const draftStorage = useDraftStorage();
|
|
352
360
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
@@ -465,8 +473,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
465
473
|
// on every parent render, not just on a snapshot change, risking a loop if
|
|
466
474
|
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
467
475
|
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
468
|
-
|
|
469
|
-
|
|
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);
|
|
470
483
|
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
471
484
|
scopeFieldNamesRef.current = scopeFieldNames;
|
|
472
485
|
const scopedValidate = useCallback(
|
|
@@ -491,7 +504,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
491
504
|
const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
492
505
|
useEffect(() => {
|
|
493
506
|
return () => {
|
|
494
|
-
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
|
+
}
|
|
495
516
|
};
|
|
496
517
|
}, []);
|
|
497
518
|
|
|
@@ -502,14 +523,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
502
523
|
const patchAndScheduleDraftSave = useCallback(
|
|
503
524
|
(partial: Partial<TValues>) => {
|
|
504
525
|
controller.setValues(partial);
|
|
505
|
-
if (!draftEnabled) return;
|
|
526
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
506
527
|
if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
|
|
507
528
|
draftSaveTimerRef.current = setTimeout(() => {
|
|
508
529
|
draftSaveTimerRef.current = null;
|
|
509
530
|
saveDraftRef.current(currentStepRef.current);
|
|
510
531
|
}, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
|
|
511
532
|
},
|
|
512
|
-
[controller, draftEnabled],
|
|
533
|
+
[controller, draftEnabled, dispatcher],
|
|
513
534
|
);
|
|
514
535
|
|
|
515
536
|
// Runs before the onChange effect below (declaration order = React
|
|
@@ -521,9 +542,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
521
542
|
// available on the very first onChange call, not just from the second
|
|
522
543
|
// keystroke onward.
|
|
523
544
|
useEffect(() => {
|
|
524
|
-
|
|
525
|
-
if (
|
|
526
|
-
|
|
545
|
+
if (onControlsReady === undefined) return;
|
|
546
|
+
if (deliveredControlsToRef.current === onControlsReady) return;
|
|
547
|
+
deliveredControlsToRef.current = onControlsReady;
|
|
548
|
+
onControlsReady({
|
|
527
549
|
patch: patchAndScheduleDraftSave,
|
|
528
550
|
validate: scopedValidate,
|
|
529
551
|
getValues: () => controller.getSnapshot().values,
|
|
@@ -531,9 +553,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
531
553
|
});
|
|
532
554
|
// controller is mount-lifetime-stable (see useForm's comment on its own
|
|
533
555
|
// useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
|
|
534
|
-
// useCallback over mount-stable deps) —
|
|
535
|
-
//
|
|
536
|
-
|
|
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]);
|
|
537
560
|
|
|
538
561
|
const schemaRef = useRef(schema);
|
|
539
562
|
schemaRef.current = schema;
|
|
@@ -556,7 +579,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
556
579
|
|
|
557
580
|
useEffect(() => {
|
|
558
581
|
// skip: this screen does not persist a draft.
|
|
559
|
-
if (!draftEnabled) return;
|
|
582
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
560
583
|
// skip: create-mode with no draftId yet — nothing to restore, either
|
|
561
584
|
// the list-fallback effect below finds one or the first step change
|
|
562
585
|
// mints a fresh one.
|
|
@@ -603,7 +626,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
603
626
|
// skip: this screen does not persist a draft, this is edit-mode, a
|
|
604
627
|
// draftId is already known (from storage or an earlier adoption), or
|
|
605
628
|
// this mount already ran the list lookup once (didListRef).
|
|
606
|
-
if (
|
|
629
|
+
if (
|
|
630
|
+
!draftEnabled ||
|
|
631
|
+
!isCreateMode ||
|
|
632
|
+
draftId !== null ||
|
|
633
|
+
didListRef.current ||
|
|
634
|
+
dispatcher === undefined
|
|
635
|
+
)
|
|
636
|
+
return;
|
|
607
637
|
didListRef.current = true;
|
|
608
638
|
let cancelled = false;
|
|
609
639
|
void (async () => {
|
|
@@ -632,6 +662,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
632
662
|
// (see draftCandidates below) — adopt it the same way a single
|
|
633
663
|
// auto-adopted candidate would be.
|
|
634
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;
|
|
635
669
|
const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
|
|
636
670
|
draftStorage.setDraftId(screen.id, adoptedId);
|
|
637
671
|
setDraftId(adoptedId);
|
|
@@ -651,7 +685,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
651
685
|
);
|
|
652
686
|
|
|
653
687
|
const filteredSections = useMemo(
|
|
654
|
-
|
|
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
|
+
),
|
|
655
699
|
[vm.sections, fieldsFilter],
|
|
656
700
|
);
|
|
657
701
|
|
|
@@ -696,10 +740,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
696
740
|
// computed inline instead of read from the memoized `draftKey`.
|
|
697
741
|
function saveDraft(stepIndex: number): void {
|
|
698
742
|
// skip: this screen does not persist a draft.
|
|
699
|
-
if (!draftEnabled) return;
|
|
743
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
700
744
|
let key = draftKey;
|
|
701
745
|
if (isCreateMode && draftId === null) {
|
|
702
|
-
const mintedId =
|
|
746
|
+
const mintedId = mintDraftId();
|
|
703
747
|
mintedDraftIdRef.current = mintedId;
|
|
704
748
|
draftStorage.setDraftId(screen.id, mintedId);
|
|
705
749
|
setDraftId(mintedId);
|
|
@@ -761,7 +805,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
761
805
|
|
|
762
806
|
async function discardDraft(): Promise<void> {
|
|
763
807
|
// skip: this screen does not persist a draft.
|
|
764
|
-
if (!draftEnabled) return;
|
|
808
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
765
809
|
// A pending debounced patch-save must not fire after discard — it would
|
|
766
810
|
// resurrect the draft it just deleted.
|
|
767
811
|
if (draftSaveTimerRef.current !== null) {
|
|
@@ -874,10 +918,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
874
918
|
let extensionsPersisted = true;
|
|
875
919
|
if (result.isSuccess) {
|
|
876
920
|
setFormError(null);
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
//
|
|
880
|
-
|
|
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();
|
|
881
929
|
extensionsPersisted = await persistExtensions();
|
|
882
930
|
} else if (result.validationBlocked) {
|
|
883
931
|
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
@@ -944,6 +992,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
944
992
|
type="button"
|
|
945
993
|
variant="danger"
|
|
946
994
|
testId="render-edit-delete"
|
|
995
|
+
disabled={disabled}
|
|
947
996
|
onClick={() => setConfirmDeleteOpen(true)}
|
|
948
997
|
>
|
|
949
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…",
|