@cosmicdrift/kumiko-renderer 0.57.1 → 0.59.1
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.59.1",
|
|
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.57.2",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.57.2",
|
|
20
20
|
"react": "^19.2.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { DispatcherError, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { resolveExtensionEntityId, shouldNotifyCaller } from "../render-edit-logic";
|
|
4
|
+
|
|
5
|
+
const error: DispatcherError = {
|
|
6
|
+
code: "internal_error",
|
|
7
|
+
httpStatus: 500,
|
|
8
|
+
i18nKey: "kumiko.errors.internal",
|
|
9
|
+
message: "boom",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const success: SubmitResult<unknown> = { validationBlocked: false, isSuccess: true, data: {} };
|
|
13
|
+
const writeFailure: SubmitResult<unknown> = { validationBlocked: false, isSuccess: false, error };
|
|
14
|
+
const validationBlocked: SubmitResult<unknown> = { validationBlocked: true, isSuccess: false };
|
|
15
|
+
|
|
16
|
+
describe("resolveExtensionEntityId", () => {
|
|
17
|
+
test("explicit id prop wins over vm.id", () => {
|
|
18
|
+
expect(resolveExtensionEntityId("entity-42", "row-7")).toBe("entity-42");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// The #345/1 regression: mount fell back to vm.id but persist did not, so a
|
|
22
|
+
// custom-fields section mounted editable against the existing row yet saved
|
|
23
|
+
// to null → silent data loss. Both sites now resolve this same value.
|
|
24
|
+
test("undefined prop → vm.id fallback (update form carries the row id)", () => {
|
|
25
|
+
expect(resolveExtensionEntityId(undefined, "row-42")).toBe("row-42");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("undefined prop + no vm.id (create mode) → null", () => {
|
|
29
|
+
expect(resolveExtensionEntityId(undefined, null)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("explicit null prop forces null even when vm.id is present", () => {
|
|
33
|
+
expect(resolveExtensionEntityId(null, "row-42")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("shouldNotifyCaller", () => {
|
|
38
|
+
// The one case the #345/1 fix changes: a successful entity write whose
|
|
39
|
+
// extension persist failed must NOT notify (caller would navigate away and
|
|
40
|
+
// unmount the extension-error banner → silent custom-field data loss). The
|
|
41
|
+
// pre-fix code notified unconditionally, so this row flips false↔true.
|
|
42
|
+
test("entity success + extension persist failed → suppress callback", () => {
|
|
43
|
+
expect(shouldNotifyCaller(success, false)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("entity success + extensions persisted → notify", () => {
|
|
47
|
+
expect(shouldNotifyCaller(success, true)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("entity write failure → notify (caller needs the error result)", () => {
|
|
51
|
+
expect(shouldNotifyCaller(writeFailure, true)).toBe(true);
|
|
52
|
+
// extensions never run on a failed entity write, but the flag must not
|
|
53
|
+
// swallow the failure callback regardless of its value.
|
|
54
|
+
expect(shouldNotifyCaller(writeFailure, false)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("validation blocked → notify", () => {
|
|
58
|
+
expect(shouldNotifyCaller(validationBlocked, true)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
|
|
3
|
+
// Single source of truth for the extension-section entity-id. The section mount
|
|
4
|
+
// and persistExtensions MUST resolve the same id — otherwise a section mounts
|
|
5
|
+
// editable against one id (vm.id) while the persist step writes to (or skips)
|
|
6
|
+
// another (null), silently dropping the user's input. An explicit `null` prop
|
|
7
|
+
// forces "no entity" (no extension persistence); an omitted prop (undefined)
|
|
8
|
+
// falls back to vm.id (= values["id"]), which the update form carries for the
|
|
9
|
+
// existing row, so editing custom fields on that row actually persists.
|
|
10
|
+
export function resolveExtensionEntityId(
|
|
11
|
+
entityIdProp: string | null | undefined,
|
|
12
|
+
vmId: string | null,
|
|
13
|
+
): string | null {
|
|
14
|
+
return entityIdProp !== undefined ? entityIdProp : vmId;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// After a submit, decide whether to invoke the caller's onSubmit. The success
|
|
18
|
+
// callback typically navigates away, which unmounts the extension-error banner.
|
|
19
|
+
// Suppress the callback ONLY when the entity write succeeded but an extension-
|
|
20
|
+
// section persist failed: the user must stay on the form to see the banner and
|
|
21
|
+
// retry. Every other case still notifies the caller — entity failures and
|
|
22
|
+
// validation blocks carry information the caller needs.
|
|
23
|
+
export function shouldNotifyCaller(
|
|
24
|
+
result: SubmitResult<unknown>,
|
|
25
|
+
extensionsPersisted: boolean,
|
|
26
|
+
): boolean {
|
|
27
|
+
return !(result.isSuccess && !extensionsPersisted);
|
|
28
|
+
}
|
|
@@ -28,6 +28,7 @@ import { extensionSectionName, useExtensionSectionComponent } from "../app/exten
|
|
|
28
28
|
import { useForm } from "../hooks/use-form";
|
|
29
29
|
import { useTranslation } from "../i18n";
|
|
30
30
|
import { usePrimitives } from "../primitives";
|
|
31
|
+
import { resolveExtensionEntityId, shouldNotifyCaller } from "./render-edit-logic";
|
|
31
32
|
import { RenderField } from "./render-field";
|
|
32
33
|
|
|
33
34
|
// End-to-end renderer für einen entityEdit screen. Rendert aus-
|
|
@@ -40,12 +41,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
40
41
|
readonly entity: EntityDefinition;
|
|
41
42
|
readonly featureName: string;
|
|
42
43
|
readonly initial: TValues;
|
|
43
|
-
/** Echte entity-id für extension-section-Mounts (Set-Value-UI).
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
44
|
+
/** Echte entity-id für extension-section-Mounts (Set-Value-UI). Mount UND
|
|
45
|
+
* persistExtensions lösen sie über `resolveExtensionEntityId(entityIdProp,
|
|
46
|
+
* vm.id)` auf — denselben Wert, damit die Section nicht editierbar gegen eine
|
|
47
|
+
* id mountet während Persist gegen eine andere (oder gar nicht) schreibt.
|
|
48
|
+
* Weglassen (undefined) = Fallback auf `vm.id` (= values["id"]), das das
|
|
49
|
+
* Update-Form für die bestehende Row trägt. Explizites `null` = "keine
|
|
50
|
+
* entity" (create-mode / keine extension-Persistenz). */
|
|
49
51
|
readonly entityId?: string | null;
|
|
50
52
|
/** Bereits gespeicherte Extension-Werte (z.B. `record.customFields`) für
|
|
51
53
|
* extension-section-Mounts. Erlaubt der Section, den Bestand beim Edit
|
|
@@ -245,7 +247,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
245
247
|
// false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
|
|
246
248
|
// Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
|
|
247
249
|
async function persistExtensions(): Promise<boolean> {
|
|
248
|
-
const entityId = entityIdProp
|
|
250
|
+
const entityId = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
249
251
|
if (entityId === null) return true;
|
|
250
252
|
const results = await runExtensionSubmits({ entityId });
|
|
251
253
|
const failed = results.find((r) => !r.isSuccess);
|
|
@@ -304,14 +306,18 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
304
306
|
}
|
|
305
307
|
// Form-level Errors (ohne field-level details) landen im Banner.
|
|
306
308
|
// Field-Errors fließen über snapshot.errors in die einzelnen Fields.
|
|
309
|
+
let extensionsPersisted = true;
|
|
307
310
|
if (result.isSuccess) {
|
|
308
311
|
setFormError(null);
|
|
309
|
-
await persistExtensions();
|
|
312
|
+
extensionsPersisted = await persistExtensions();
|
|
310
313
|
} else if (!result.validationBlocked) {
|
|
311
314
|
const fieldIssues = result.error.details?.fields ?? [];
|
|
312
315
|
setFormError(fieldIssues.length === 0 ? result.error : null);
|
|
313
316
|
}
|
|
314
|
-
|
|
317
|
+
// skip: on entity-success-but-extension-failure the extension-error
|
|
318
|
+
// banner is showing — don't notify (the caller navigates away on success
|
|
319
|
+
// and would unmount it before the user sees the failure).
|
|
320
|
+
if (shouldNotifyCaller(result, extensionsPersisted)) onSubmit?.(result);
|
|
315
321
|
} finally {
|
|
316
322
|
setIsSubmitting(false);
|
|
317
323
|
}
|
|
@@ -376,7 +382,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
376
382
|
key={section.title}
|
|
377
383
|
section={section}
|
|
378
384
|
entityName={vm.entityName}
|
|
379
|
-
entityId={entityIdProp
|
|
385
|
+
entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
|
|
380
386
|
initialValues={extensionInitialValues}
|
|
381
387
|
/>
|
|
382
388
|
);
|
|
@@ -352,6 +352,18 @@ function ListHeaderSlotMount({
|
|
|
352
352
|
const header = screen.slots?.header;
|
|
353
353
|
const name = header !== undefined ? extensionSectionName(header) : undefined;
|
|
354
354
|
const Component = useExtensionSectionComponent(name);
|
|
355
|
+
// A declared-but-unregistered header slot renders nothing; warn so the author
|
|
356
|
+
// finds the typo / missing clientFeatures.extensionSectionComponents entry,
|
|
357
|
+
// mirroring the diagnostic banner in render-edit's ExtensionSectionMount
|
|
358
|
+
// (the toolbar has no room for a banner, so a dev-warn it is).
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (name !== undefined && Component === undefined) {
|
|
361
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
362
|
+
console.warn(
|
|
363
|
+
`[kumiko] List header slot component "${name}" on screen "${screen.id}" is not registered in clientFeatures.extensionSectionComponents — the header slot renders nothing.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}, [name, Component, screen.id]);
|
|
355
367
|
if (Component === undefined) return null;
|
|
356
368
|
return <Component entityName={screen.entity} entityId={null} />;
|
|
357
369
|
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -20,6 +20,9 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
20
20
|
"kumiko.actions.reload": "Neu laden",
|
|
21
21
|
"kumiko.actions.create": "Neu",
|
|
22
22
|
|
|
23
|
+
// Version — Update-Awareness-Banner (UpdateChecker).
|
|
24
|
+
"kumiko.version.update-available": "Eine neue Version ist verfügbar.",
|
|
25
|
+
|
|
23
26
|
// List — DataTable Toolbar, Empty-State, Search.
|
|
24
27
|
"kumiko.list.search-placeholder": "Suchen…",
|
|
25
28
|
"kumiko.list.empty.title": "Noch keine Einträge.",
|
|
@@ -99,6 +102,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
99
102
|
"kumiko.actions.reload": "Reload",
|
|
100
103
|
"kumiko.actions.create": "New",
|
|
101
104
|
|
|
105
|
+
"kumiko.version.update-available": "A new version is available.",
|
|
106
|
+
|
|
102
107
|
"kumiko.list.search-placeholder": "Search…",
|
|
103
108
|
"kumiko.list.empty.title": "No entries yet.",
|
|
104
109
|
"kumiko.list.empty.hint": "Create the first one to get started.",
|