@cosmicdrift/kumiko-renderer 0.1.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 +42 -0
- package/src/__tests__/i18n.test.tsx +127 -0
- package/src/__tests__/qn.test.ts +40 -0
- package/src/__tests__/use-list-url-state.test.tsx +161 -0
- package/src/app/action-form-shim.ts +50 -0
- package/src/app/column-renderers.tsx +64 -0
- package/src/app/config-edit-shim.ts +48 -0
- package/src/app/custom-screens.tsx +29 -0
- package/src/app/feature-schema.ts +59 -0
- package/src/app/kumiko-screen.tsx +1050 -0
- package/src/app/nav.tsx +124 -0
- package/src/app/qn.ts +23 -0
- package/src/components/render-edit.tsx +346 -0
- package/src/components/render-field.tsx +299 -0
- package/src/components/render-list.tsx +402 -0
- package/src/context/dispatcher-context.tsx +59 -0
- package/src/hooks/reference-limits.ts +18 -0
- package/src/hooks/use-form.ts +88 -0
- package/src/hooks/use-list-url-state.ts +113 -0
- package/src/hooks/use-query.ts +129 -0
- package/src/hooks/use-reference-lookup.ts +54 -0
- package/src/hooks/use-store.ts +47 -0
- package/src/i18n-defaults.ts +94 -0
- package/src/i18n.tsx +158 -0
- package/src/index.ts +104 -0
- package/src/primitives.tsx +528 -0
- package/src/sse/live-events.tsx +56 -0
- package/src/tokens.tsx +142 -0
package/src/app/nav.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
// Navigation-Contract, plattform-neutral. Types + Context + Hook leben
|
|
4
|
+
// hier; die konkrete Implementation (window.history im Web,
|
|
5
|
+
// react-navigation im Native) kommt via `<NavProvider value={...}>`
|
|
6
|
+
// vom Plattform-Package rein.
|
|
7
|
+
//
|
|
8
|
+
// Pfad-Format hat zwei Modi je nach App-Schema:
|
|
9
|
+
// * ohne Workspaces: `/<screenId>[/<entityId>]`
|
|
10
|
+
// * mit Workspaces: `/<workspaceId>/<screenId>[/<entityId>]`
|
|
11
|
+
// Der Mode wird beim parsePath() per `hasWorkspaces` Flag durchgereicht
|
|
12
|
+
// — Schema-Layer entscheidet, parsePath bleibt dumm/pure. formatPath()
|
|
13
|
+
// braucht keinen Hint: target.workspaceId vorhanden → Prefix; sonst flach.
|
|
14
|
+
|
|
15
|
+
export type NavRoute = {
|
|
16
|
+
// Active workspace short id, present iff the app runs in workspace
|
|
17
|
+
// mode (schema.workspaces non-empty). undefined for non-workspace apps.
|
|
18
|
+
readonly workspaceId?: string;
|
|
19
|
+
readonly screenId: string;
|
|
20
|
+
readonly entityId?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type NavTarget = {
|
|
24
|
+
// Optional in workspace-aware navigate calls. Omit for cross-workspace
|
|
25
|
+
// navigation (current workspace stays); set to switch workspaces in the
|
|
26
|
+
// same call as picking a screen — e.g. WorkspaceSwitcher does this.
|
|
27
|
+
readonly workspaceId?: string;
|
|
28
|
+
readonly screenId: string;
|
|
29
|
+
readonly entityId?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type NavApi = {
|
|
33
|
+
/** Current route — `undefined` when the URL is at the root / there's
|
|
34
|
+
* no route selected. Caller's initial fallback kicks in then. */
|
|
35
|
+
readonly route: NavRoute | undefined;
|
|
36
|
+
/** Push a new route. Platform-specific Impl writes to
|
|
37
|
+
* history/stack and notifies subscribers. */
|
|
38
|
+
readonly navigate: (target: NavTarget) => void;
|
|
39
|
+
/** Replace the current route in place. Same effect as navigate from
|
|
40
|
+
* the user's perspective, but doesn't add a history entry — used for
|
|
41
|
+
* mount-time URL fills (e.g. WorkspaceShell defaulting to `/admin/x`
|
|
42
|
+
* when the user typed `/`). Browser Back must take the user out of
|
|
43
|
+
* the app, not back to the original empty path. */
|
|
44
|
+
readonly replace: (target: NavTarget) => void;
|
|
45
|
+
/** Build the href a click on {target} would produce. Used by
|
|
46
|
+
* platform-specific Link-Komponenten (Web: `<a href>`; Native
|
|
47
|
+
* typically doesn't need this). */
|
|
48
|
+
readonly hrefFor: (target: NavTarget) => string;
|
|
49
|
+
/** Lese-Snapshot der aktuellen Search-Params (Browser: ?key=value-
|
|
50
|
+
* Pairs nach dem Pfad). Native-Impls die kein URL-Konzept haben
|
|
51
|
+
* liefern ein leeres Object. Wert ist ein Plain-Record (kein Map)
|
|
52
|
+
* damit React-Subscribers shallow-compare können. */
|
|
53
|
+
readonly searchParams: Readonly<Record<string, string>>;
|
|
54
|
+
/** Mergt Updates in die aktuellen Search-Params. Wert `null` löscht
|
|
55
|
+
* den Key. Ändert NICHT den Pfad. Plattform-Impls nutzen
|
|
56
|
+
* history.replaceState (kein Push) — Sort/Filter-Toggles sollen
|
|
57
|
+
* nicht die Back-Navigation fluten. Native-Impls können no-op'en
|
|
58
|
+
* wenn das Konzept nicht existiert. */
|
|
59
|
+
readonly setSearchParams: (updates: Readonly<Record<string, string | null>>) => void;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Pfad-Format:
|
|
63
|
+
// ohne workspaces: `/task-list`, `/task-edit/abc-123`, `/` → undefined
|
|
64
|
+
// mit workspaces: `/admin/task-list`, `/admin/task-edit/abc`,
|
|
65
|
+
// `/admin` → workspace-only (no screen yet),
|
|
66
|
+
// `/` → undefined.
|
|
67
|
+
// Alles nach den ersten 2/3 Segmenten wird ignoriert — kumikos
|
|
68
|
+
// Navigation-Grammatik nested nicht weiter. Nested-Routes wäre eine
|
|
69
|
+
// Spec-Änderung, nicht ein URL-Shape-Unfall.
|
|
70
|
+
export function parsePath(pathname: string, hasWorkspaces?: boolean): NavRoute | undefined {
|
|
71
|
+
const parts = pathname.split("/").filter((p) => p !== "");
|
|
72
|
+
if (hasWorkspaces === true) {
|
|
73
|
+
const [workspaceId, screenId, entityId] = parts;
|
|
74
|
+
if (workspaceId === undefined || workspaceId === "") return undefined;
|
|
75
|
+
if (screenId === undefined || screenId === "") {
|
|
76
|
+
// Workspace-only URL ("/admin") — caller resolves the default screen
|
|
77
|
+
// for that workspace. We carry workspaceId WITHOUT a screen so the
|
|
78
|
+
// shell can branch instead of making something up.
|
|
79
|
+
return { workspaceId, screenId: "" };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
workspaceId,
|
|
83
|
+
screenId,
|
|
84
|
+
...(entityId !== undefined && { entityId }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const [screenId, entityId] = parts;
|
|
88
|
+
if (screenId === undefined || screenId === "") return undefined;
|
|
89
|
+
return { screenId, ...(entityId !== undefined && { entityId }) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatPath(target: NavTarget): string {
|
|
93
|
+
// Workspace-Mode: prefix the workspace short id. Order matters —
|
|
94
|
+
// workspace before screen mirrors parsePath's segment order.
|
|
95
|
+
const segments: string[] = [];
|
|
96
|
+
if (target.workspaceId !== undefined) segments.push(target.workspaceId);
|
|
97
|
+
segments.push(target.screenId);
|
|
98
|
+
if (target.entityId !== undefined) segments.push(target.entityId);
|
|
99
|
+
return `/${segments.join("/")}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Context + Hook. Default ist `undefined` damit fehlender Provider
|
|
103
|
+
// laut kracht statt ein silent-no-op NavApi mit toten navigate()
|
|
104
|
+
// Aufrufen anzubieten.
|
|
105
|
+
const NavContext = createContext<NavApi | undefined>(undefined);
|
|
106
|
+
|
|
107
|
+
export type NavProviderProps = {
|
|
108
|
+
readonly children: ReactNode;
|
|
109
|
+
readonly value: NavApi;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function NavProvider({ children, value }: NavProviderProps): ReactNode {
|
|
113
|
+
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useNav(): NavApi {
|
|
117
|
+
const api = useContext(NavContext);
|
|
118
|
+
if (api === undefined) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
"useNav: no <NavProvider> mounted above this component. Plattform-Packages (z.B. @cosmicdrift/kumiko-renderer-web) liefern eine Default-Impl über createKumikoApp.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return api;
|
|
124
|
+
}
|
package/src/app/qn.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Qualified-Name-Helper. Kumiko's Registry stempelt beim Boot den
|
|
2
|
+
// Feature-Prefix auf jede Screen/Nav/Workspace-id ein:
|
|
3
|
+
//
|
|
4
|
+
// r.screen({ id: "task-list", ... })
|
|
5
|
+
// → registry-stored.id === "tasks:screen:task-list"
|
|
6
|
+
// → schema.screens (ans Browser) hat id === "tasks:screen:task-list"
|
|
7
|
+
//
|
|
8
|
+
// Der Renderer arbeitet aber mit Short-Form ids — formatPath schreibt
|
|
9
|
+
// sie 1:1 in die URL, parsePath liest sie 1:1 raus. Beim Übergang von
|
|
10
|
+
// Schema (QN-Form) → nav.navigate (Short-Form) muss der Prefix weg,
|
|
11
|
+
// sonst landet die URL doppelt-qualifiziert
|
|
12
|
+
// ("/tasks:screen:task-list" + Re-Lookup → "tasks:screen:tasks:screen:
|
|
13
|
+
// task-list" → 404).
|
|
14
|
+
//
|
|
15
|
+
// `lastSegment` ist die Inverse von qualifyScreenId/Nav/Workspace —
|
|
16
|
+
// nimmt den letzten ":"-getrennten Teil. Robust gegen Strings ohne
|
|
17
|
+
// ":" (returnt sie unverändert, damit App-Author-Code mit Short-Form-
|
|
18
|
+
// ids in eigenen Stellen weiter passt).
|
|
19
|
+
|
|
20
|
+
export function lastSegment(qn: string): string {
|
|
21
|
+
const idx = qn.lastIndexOf(":");
|
|
22
|
+
return idx < 0 ? qn : qn.slice(idx + 1);
|
|
23
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityDefinition,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
import { normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import type {
|
|
7
|
+
DispatcherError,
|
|
8
|
+
EditFieldViewModel,
|
|
9
|
+
FieldConditions,
|
|
10
|
+
FieldIssue,
|
|
11
|
+
FormSnapshot,
|
|
12
|
+
FormValues,
|
|
13
|
+
SubmitResult,
|
|
14
|
+
Translate,
|
|
15
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
16
|
+
import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
|
|
17
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
18
|
+
import type { z } from "zod";
|
|
19
|
+
import { useForm } from "../hooks/use-form";
|
|
20
|
+
import { useTranslation } from "../i18n";
|
|
21
|
+
import { usePrimitives } from "../primitives";
|
|
22
|
+
import { RenderField } from "./render-field";
|
|
23
|
+
|
|
24
|
+
// End-to-end renderer für einen entityEdit screen. Rendert aus-
|
|
25
|
+
// schließlich über Primitives — kein raw HTML. Ein Native-Renderer
|
|
26
|
+
// der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
|
|
27
|
+
// Änderungen.
|
|
28
|
+
|
|
29
|
+
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
30
|
+
readonly screen: EntityEditScreenDefinition;
|
|
31
|
+
readonly entity: EntityDefinition;
|
|
32
|
+
readonly featureName: string;
|
|
33
|
+
readonly initial: TValues;
|
|
34
|
+
/** Standard single-write Submit-Pfad. Ignoriert wenn `customSubmit`
|
|
35
|
+
* gesetzt ist (configEdit-Screens dispatchen mehrere Writes pro
|
|
36
|
+
* Submit, da macht writeCommand keinen Sinn). */
|
|
37
|
+
readonly writeCommand?: string;
|
|
38
|
+
/** Override für die Submit-Pipeline. Wenn gesetzt, läuft erst
|
|
39
|
+
* controller.validate() und dann customSubmit(snapshot) statt
|
|
40
|
+
* controller.submit(). On-success rebased der Form-State so dass
|
|
41
|
+
* isUnchanged/isDirty wieder false werden — ohne das blieben
|
|
42
|
+
* Save-Button und Banner stale. */
|
|
43
|
+
readonly customSubmit?: (snapshot: FormSnapshot<TValues>) => Promise<SubmitResult<unknown>>;
|
|
44
|
+
readonly translate?: Translate;
|
|
45
|
+
readonly ctx?: TCtx;
|
|
46
|
+
readonly schema?: z.ZodType;
|
|
47
|
+
readonly onSubmit?: (result: SubmitResult<unknown>) => void;
|
|
48
|
+
readonly payloadMode?: "values" | "changes";
|
|
49
|
+
readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
|
|
50
|
+
readonly onDelete?: () => Promise<void> | void;
|
|
51
|
+
readonly onCancel?: () => void;
|
|
52
|
+
readonly onReload?: () => void;
|
|
53
|
+
/** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
|
|
54
|
+
* Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
|
|
55
|
+
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
56
|
+
* werden kann ("Genehmigen" / "Versenden" / etc.). */
|
|
57
|
+
readonly submitLabel?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function deriveFormFields<TValues extends FormValues, TCtx>(
|
|
61
|
+
screen: EntityEditScreenDefinition,
|
|
62
|
+
): Record<string, FieldConditions<TValues, TCtx>> {
|
|
63
|
+
const out: Record<string, FieldConditions<TValues, TCtx>> = {};
|
|
64
|
+
for (const section of screen.layout.sections) {
|
|
65
|
+
for (const spec of section.fields) {
|
|
66
|
+
const normalized = normalizeEditField(spec);
|
|
67
|
+
out[normalized.field] = {
|
|
68
|
+
...(normalized.visible !== undefined && {
|
|
69
|
+
visible: normalized.visible as FieldConditions<TValues, TCtx>["visible"],
|
|
70
|
+
}),
|
|
71
|
+
...(normalized.readOnly !== undefined && {
|
|
72
|
+
readonly: normalized.readOnly as FieldConditions<TValues, TCtx>["readonly"],
|
|
73
|
+
}),
|
|
74
|
+
...(normalized.required !== undefined && {
|
|
75
|
+
required: normalized.required as FieldConditions<TValues, TCtx>["required"],
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
84
|
+
props: RenderEditProps<TValues, TCtx>,
|
|
85
|
+
): ReactNode {
|
|
86
|
+
const {
|
|
87
|
+
screen,
|
|
88
|
+
entity,
|
|
89
|
+
featureName,
|
|
90
|
+
initial,
|
|
91
|
+
writeCommand,
|
|
92
|
+
translate: translateProp,
|
|
93
|
+
ctx,
|
|
94
|
+
schema,
|
|
95
|
+
onSubmit,
|
|
96
|
+
payloadMode = "values",
|
|
97
|
+
buildPayload,
|
|
98
|
+
onDelete,
|
|
99
|
+
onCancel,
|
|
100
|
+
onReload,
|
|
101
|
+
submitLabel,
|
|
102
|
+
} = props;
|
|
103
|
+
const { customSubmit } = props;
|
|
104
|
+
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
105
|
+
// konsumieren wir den i18next-Context direkt. Sonst wären Field-
|
|
106
|
+
// Labels ohne Caller-Wiring raw-Keys (`feature:entity:foo:field:title`).
|
|
107
|
+
// useTranslation throwt ohne LocaleProvider — das ist ok, weil RenderEdit
|
|
108
|
+
// ohnehin nur in einem mounted Kumiko-App-Tree läuft.
|
|
109
|
+
const t = useTranslation();
|
|
110
|
+
const translate = translateProp ?? t;
|
|
111
|
+
|
|
112
|
+
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
113
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
114
|
+
const [formError, setFormError] = useState<DispatcherError | null>(null);
|
|
115
|
+
const { Button, Banner, Dialog, Form, Section, Grid, GridCell, Text } = usePrimitives();
|
|
116
|
+
|
|
117
|
+
const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
|
|
118
|
+
|
|
119
|
+
// Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
|
|
120
|
+
// customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
|
|
121
|
+
// weil wir controller.submit() eh nicht rufen.
|
|
122
|
+
const submitConfig =
|
|
123
|
+
writeCommand !== undefined
|
|
124
|
+
? {
|
|
125
|
+
type: writeCommand,
|
|
126
|
+
payloadMode,
|
|
127
|
+
...(buildPayload !== undefined && { buildPayload }),
|
|
128
|
+
}
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
131
|
+
const { controller, snapshot } = useForm<TValues, TCtx>({
|
|
132
|
+
initial,
|
|
133
|
+
fields,
|
|
134
|
+
...(schema !== undefined && { schema }),
|
|
135
|
+
...(ctx !== undefined && { ctx }),
|
|
136
|
+
...(submitConfig !== undefined && { submit: submitConfig }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const vm = useMemo(
|
|
140
|
+
() =>
|
|
141
|
+
computeEditViewModel({
|
|
142
|
+
screen,
|
|
143
|
+
entity,
|
|
144
|
+
values: snapshot.values,
|
|
145
|
+
translate,
|
|
146
|
+
featureName,
|
|
147
|
+
ctx,
|
|
148
|
+
}),
|
|
149
|
+
[screen, entity, snapshot.values, translate, featureName, ctx],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
async function handleSubmit(): Promise<void> {
|
|
153
|
+
setIsSubmitting(true);
|
|
154
|
+
try {
|
|
155
|
+
let result: SubmitResult<unknown>;
|
|
156
|
+
if (customSubmit !== undefined) {
|
|
157
|
+
// customSubmit-Pfad (z.B. configEdit, das pro Field einen
|
|
158
|
+
// separaten Write feuert). Erst client-side Validation, dann
|
|
159
|
+
// an den Caller; on-success rebased der Form-State explizit
|
|
160
|
+
// weil controller.submit() das normalerweise selbst macht und
|
|
161
|
+
// ohne customSubmit's Hilfe weiß der Controller nichts vom
|
|
162
|
+
// erfolgreichen Submit (isUnchanged blieb sonst false).
|
|
163
|
+
//
|
|
164
|
+
// WICHTIG: snapshot direkt vom Controller holen statt aus
|
|
165
|
+
// React-State. Bei rapid fill→click kann React-Batching die
|
|
166
|
+
// Input-State-Updates noch nicht commited haben, wenn der
|
|
167
|
+
// submit-Click fire'd. handleSubmit's Closure würde dann mit
|
|
168
|
+
// stale snapshot.changes={} laufen, customSubmit fired keine
|
|
169
|
+
// Writes, returnt success, Form rebase → User glaubt "saved"
|
|
170
|
+
// aber gar nichts ist passiert. controller.getSnapshot() ist
|
|
171
|
+
// immer aktuell — der Controller ist die Source-of-Truth, die
|
|
172
|
+
// React-State ist nur ein Mirror für's Rendering.
|
|
173
|
+
const valid = controller.validate();
|
|
174
|
+
if (!valid) {
|
|
175
|
+
// Field-Order matters: validationBlocked-true ist eine eigene
|
|
176
|
+
// Variante in der SubmitResult-Union (NICHT mit data/error
|
|
177
|
+
// gemixt), TS narrowt das nur ohne den Discriminator-Fight.
|
|
178
|
+
const blocked: SubmitResult<unknown> = {
|
|
179
|
+
validationBlocked: true,
|
|
180
|
+
isSuccess: false,
|
|
181
|
+
};
|
|
182
|
+
result = blocked;
|
|
183
|
+
} else {
|
|
184
|
+
result = await customSubmit(controller.getSnapshot());
|
|
185
|
+
if (result.isSuccess) controller.rebase();
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
result = await controller.submit();
|
|
189
|
+
}
|
|
190
|
+
// Form-level Errors (ohne field-level details) landen im Banner.
|
|
191
|
+
// Field-Errors fließen über snapshot.errors in die einzelnen Fields.
|
|
192
|
+
if (result.isSuccess) {
|
|
193
|
+
setFormError(null);
|
|
194
|
+
} else if (!result.validationBlocked) {
|
|
195
|
+
const fieldIssues = result.error.details?.fields ?? [];
|
|
196
|
+
setFormError(fieldIssues.length === 0 ? result.error : null);
|
|
197
|
+
}
|
|
198
|
+
onSubmit?.(result);
|
|
199
|
+
} finally {
|
|
200
|
+
setIsSubmitting(false);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
|
|
205
|
+
// Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
|
|
206
|
+
// Save groß ist; rot + Confirm-Dialog sind ausreichend Schutz
|
|
207
|
+
// gegen Fehlklicks. Save bleibt rechts (primary affordance).
|
|
208
|
+
const formActions = (
|
|
209
|
+
<>
|
|
210
|
+
{onDelete !== undefined && (
|
|
211
|
+
<Button
|
|
212
|
+
type="button"
|
|
213
|
+
variant="danger"
|
|
214
|
+
testId="render-edit-delete"
|
|
215
|
+
onClick={() => setConfirmDeleteOpen(true)}
|
|
216
|
+
>
|
|
217
|
+
{translate("kumiko.actions.delete")}
|
|
218
|
+
</Button>
|
|
219
|
+
)}
|
|
220
|
+
{onCancel !== undefined && (
|
|
221
|
+
<Button
|
|
222
|
+
type="button"
|
|
223
|
+
variant="secondary"
|
|
224
|
+
onClick={() => onCancel()}
|
|
225
|
+
testId="render-edit-cancel"
|
|
226
|
+
>
|
|
227
|
+
{translate("kumiko.actions.cancel")}
|
|
228
|
+
</Button>
|
|
229
|
+
)}
|
|
230
|
+
<Button
|
|
231
|
+
type="submit"
|
|
232
|
+
disabled={snapshot.isUnchanged || isSubmitting}
|
|
233
|
+
loading={isSubmitting}
|
|
234
|
+
variant="primary"
|
|
235
|
+
testId="render-edit-submit"
|
|
236
|
+
>
|
|
237
|
+
{translate(submitLabel ?? "kumiko.actions.save")}
|
|
238
|
+
</Button>
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Title-Resolution analog zu RenderList: i18n-Key `screen:<id>.title`,
|
|
243
|
+
// mit screenId als Fallback wenn das Bundle den Key nicht kennt.
|
|
244
|
+
const titleKey = `screen:${screen.id}.title`;
|
|
245
|
+
const resolvedTitle = translate(titleKey);
|
|
246
|
+
const formTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Form
|
|
250
|
+
onSubmit={() => void handleSubmit()}
|
|
251
|
+
title={formTitle}
|
|
252
|
+
actions={formActions}
|
|
253
|
+
testId="render-edit-form"
|
|
254
|
+
>
|
|
255
|
+
{vm.sections.map((section) => (
|
|
256
|
+
<Section key={section.title} title={section.title} testId={`section-${section.title}`}>
|
|
257
|
+
<Grid columns={section.columns}>
|
|
258
|
+
{section.fields.map((field) => (
|
|
259
|
+
<GridCellForField
|
|
260
|
+
key={field.field}
|
|
261
|
+
field={field}
|
|
262
|
+
columns={section.columns}
|
|
263
|
+
issues={snapshot.errors[field.field]}
|
|
264
|
+
onChange={(v) => {
|
|
265
|
+
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
266
|
+
}}
|
|
267
|
+
GridCell={GridCell}
|
|
268
|
+
featureName={featureName}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
</Grid>
|
|
272
|
+
</Section>
|
|
273
|
+
))}
|
|
274
|
+
{formError !== null && (
|
|
275
|
+
<Banner
|
|
276
|
+
variant="error"
|
|
277
|
+
testId="render-edit-form-error"
|
|
278
|
+
actions={
|
|
279
|
+
formError.code === "version_conflict" && onReload !== undefined ? (
|
|
280
|
+
<Button
|
|
281
|
+
variant="secondary"
|
|
282
|
+
onClick={() => {
|
|
283
|
+
onReload();
|
|
284
|
+
setFormError(null);
|
|
285
|
+
}}
|
|
286
|
+
testId="render-edit-form-error-reload"
|
|
287
|
+
>
|
|
288
|
+
{translate("kumiko.actions.reload")}
|
|
289
|
+
</Button>
|
|
290
|
+
) : undefined
|
|
291
|
+
}
|
|
292
|
+
>
|
|
293
|
+
<Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
|
|
294
|
+
</Banner>
|
|
295
|
+
)}
|
|
296
|
+
{onDelete !== undefined && (
|
|
297
|
+
<Dialog
|
|
298
|
+
open={confirmDeleteOpen}
|
|
299
|
+
onOpenChange={setConfirmDeleteOpen}
|
|
300
|
+
title={translate("kumiko.actions.delete-confirm")}
|
|
301
|
+
confirmLabel={translate("kumiko.actions.delete")}
|
|
302
|
+
variant="danger"
|
|
303
|
+
onConfirm={async () => {
|
|
304
|
+
await onDelete();
|
|
305
|
+
}}
|
|
306
|
+
testId="render-edit-delete-dialog"
|
|
307
|
+
/>
|
|
308
|
+
)}
|
|
309
|
+
</Form>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Winziger Wrapper der die span-Logik kapselt und die Field-Cell in
|
|
314
|
+
// die Grid platziert. Eigene Component damit die map-Callback oben
|
|
315
|
+
// schlank bleibt.
|
|
316
|
+
type GridCellForFieldProps = {
|
|
317
|
+
readonly field: EditFieldViewModel;
|
|
318
|
+
readonly columns: number;
|
|
319
|
+
readonly issues: readonly FieldIssue[] | undefined;
|
|
320
|
+
readonly onChange: (value: unknown) => void;
|
|
321
|
+
readonly GridCell: ReturnType<typeof usePrimitives>["GridCell"];
|
|
322
|
+
/** Tier 2.7e-3: durchgereicht damit Reference-Felder die richtige
|
|
323
|
+
* Lookup-Query-QN bauen können (`<feature>:query:<refEntity>:list`). */
|
|
324
|
+
readonly featureName: string;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
function GridCellForField({
|
|
328
|
+
field,
|
|
329
|
+
columns,
|
|
330
|
+
issues,
|
|
331
|
+
onChange,
|
|
332
|
+
GridCell,
|
|
333
|
+
featureName,
|
|
334
|
+
}: GridCellForFieldProps): ReactNode {
|
|
335
|
+
const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
|
|
336
|
+
return (
|
|
337
|
+
<GridCell span={effectiveSpan}>
|
|
338
|
+
<RenderField
|
|
339
|
+
field={field}
|
|
340
|
+
{...(issues !== undefined && { issues })}
|
|
341
|
+
onChange={onChange}
|
|
342
|
+
featureName={featureName}
|
|
343
|
+
/>
|
|
344
|
+
</GridCell>
|
|
345
|
+
);
|
|
346
|
+
}
|