@cosmicdrift/kumiko-renderer 0.57.2 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.57.2",
3
+ "version": "0.60.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.55.1",
19
- "@cosmicdrift/kumiko-headless": "0.55.1",
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). Der
44
- * Update-Body kennt sie aus der Route; ohne sie fiele die Section auf
45
- * `vm.id` (= values["id"]) zurück, das im Update-Form immer fehlt
46
- * (id ist keine deklarierte Form-Field, siehe EntityEditUpdateForm)
47
- * die Section bliebe dann fälschlich im create-mode. Weglassen
48
- * (undefined) = create-mode / kein extension-Kontext (vm.id-Fallback). */
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 ?? null;
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
- onSubmit?.(result);
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 !== undefined ? entityIdProp : vm.id}
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
  }
@@ -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.",