@cosmicdrift/kumiko-renderer 1.0.0 → 2.0.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.
Files changed (36) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/error-i18n-defaults.test.ts +13 -0
  3. package/src/__tests__/format-when.test.ts +12 -0
  4. package/src/__tests__/i18n.test.tsx +59 -0
  5. package/src/__tests__/qn.test.ts +44 -1
  6. package/src/__tests__/sort-by-accessor.test.ts +48 -0
  7. package/src/app/__tests__/config-edit-shim.test.ts +40 -0
  8. package/src/app/__tests__/screen-access-allows.test.ts +24 -0
  9. package/src/app/dashboard-body.tsx +32 -0
  10. package/src/app/extension-sections.tsx +11 -4
  11. package/src/app/kumiko-screen.tsx +408 -20
  12. package/src/app/projection-detail-shim.ts +72 -0
  13. package/src/app/projection-list-shim.ts +62 -0
  14. package/src/app/qn.ts +13 -0
  15. package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
  16. package/src/components/render-edit-logic.ts +8 -5
  17. package/src/components/render-edit.tsx +25 -2
  18. package/src/components/render-field.tsx +2 -1
  19. package/src/components/render-list.tsx +24 -2
  20. package/src/context/user-roles-context.tsx +27 -0
  21. package/src/format-when.ts +11 -0
  22. package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
  23. package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
  24. package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
  25. package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
  26. package/src/hooks/use-ai-text.ts +172 -0
  27. package/src/hooks/use-disclosure.ts +20 -0
  28. package/src/hooks/use-mutation.ts +61 -0
  29. package/src/hooks/use-query.ts +5 -2
  30. package/src/hooks/use-reference-lookup.ts +2 -1
  31. package/src/hooks/use-stream-handler.ts +134 -0
  32. package/src/i18n-defaults.ts +56 -0
  33. package/src/i18n.tsx +72 -31
  34. package/src/index.ts +31 -0
  35. package/src/primitives.tsx +70 -4
  36. package/src/sort-by-accessor.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "1.0.0",
3
+ "version": "2.0.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,9 +15,10 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "1.0.0",
19
- "@cosmicdrift/kumiko-headless": "1.0.0",
20
- "react": "^19.2.6"
18
+ "@cosmicdrift/kumiko-framework": "2.0.0",
19
+ "@cosmicdrift/kumiko-headless": "2.0.0",
20
+ "react": "^19.2.6",
21
+ "temporal-polyfill": "^0.3.2"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@testing-library/react": "^16.3.2",
@@ -56,4 +56,17 @@ describe("kumikoDefaultTranslations covers every error i18nKey", () => {
56
56
  expect(de?.["errors.download.urlMissing"]).toBeTruthy();
57
57
  expect(en?.["errors.download.urlMissing"]).toBeTruthy();
58
58
  });
59
+
60
+ // Client-emitted (dispatcher-live error-mapping) — network/abort never
61
+ // reach the server, so no error class mints these; still rendered through
62
+ // this last-resort bundle whenever a feature reads res.error.i18nKey raw.
63
+ test("dispatcher.errors.network has de+en default", () => {
64
+ expect(de?.["dispatcher.errors.network"]).toBeTruthy();
65
+ expect(en?.["dispatcher.errors.network"]).toBeTruthy();
66
+ });
67
+
68
+ test("dispatcher.errors.aborted has de+en default", () => {
69
+ expect(de?.["dispatcher.errors.aborted"]).toBeTruthy();
70
+ expect(en?.["dispatcher.errors.aborted"]).toBeTruthy();
71
+ });
59
72
  });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatWhen } from "../format-when";
3
+
4
+ describe("formatWhen", () => {
5
+ test("formats a parseable ISO timestamp", () => {
6
+ expect(formatWhen("2024-01-01T00:00:00.000Z")).not.toBe("2024-01-01T00:00:00.000Z");
7
+ });
8
+
9
+ test("falls back to the raw value on an unparseable input", () => {
10
+ expect(formatWhen("garbage")).toBe("garbage");
11
+ });
12
+ });
@@ -6,6 +6,7 @@ import {
6
6
  createStaticLocaleResolver,
7
7
  LocaleProvider,
8
8
  type TranslationsByLocale,
9
+ translationsByLocaleFromKeys,
9
10
  useLocale,
10
11
  useTranslation,
11
12
  } from "../i18n";
@@ -105,6 +106,64 @@ describe("useTranslation — re-render on locale change", () => {
105
106
  expect(getByTestId("msg").textContent).toBe("Hello");
106
107
  });
107
108
  });
109
+ describe("useTranslation — referential stability", () => {
110
+ // Prod-Incident 2026-07-07: admin-shell Overview-Screens hatten `t` in
111
+ // einem useEffect-Dependency-Array. Ein neues `t` pro Render triggerte
112
+ // einen Render/Effect-Endlos-Loop (~600 Queries/Sekunde). `t` (und der
113
+ // gesamte Context-Value) MUSS über Re-Renders hinweg stabil bleiben,
114
+ // solange sich Resolver/Bundles/Locale nicht ändern.
115
+ test("t keeps the same reference across re-renders when nothing changed", () => {
116
+ const resolver = createStaticLocaleResolver({ locale: "de" });
117
+ const { result, rerender } = renderHook(() => useTranslation(), {
118
+ wrapper: wrap(resolver),
119
+ });
120
+ const firstT = result.current;
121
+ rerender();
122
+ expect(result.current).toBe(firstT);
123
+ });
124
+ test("t stays stable across parent re-renders even with a fresh fallbackBundles literal per parent-render", () => {
125
+ // Realistischer Fall: eine App übergibt `fallbackBundles={[...]}` als
126
+ // Inline-Literal. Ohne Provider-seitige Memoization würde jeder
127
+ // Ahnen-Re-Render den Context-Value neu bauen. Hier prüfen wir nur
128
+ // den Provider-internen Memoization-Pfad bei stabilen Props.
129
+ const resolver = createStaticLocaleResolver({ locale: "de" });
130
+ const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" } }];
131
+ function Probe(): ReactNode {
132
+ const t = useTranslation();
133
+ (Probe as unknown as { lastT?: unknown }).lastT = t;
134
+ return null;
135
+ }
136
+ const { rerender } = render(
137
+ <LocaleProvider resolver={resolver} fallbackBundles={bundles}>
138
+ <Probe />
139
+ </LocaleProvider>,
140
+ );
141
+ const firstT = (Probe as unknown as { lastT?: unknown }).lastT;
142
+ rerender(
143
+ <LocaleProvider resolver={resolver} fallbackBundles={bundles}>
144
+ <Probe />
145
+ </LocaleProvider>,
146
+ );
147
+ expect((Probe as unknown as { lastT?: unknown }).lastT).toBe(firstT);
148
+ });
149
+ });
150
+ describe("translationsByLocaleFromKeys", () => {
151
+ test("pivots key-first source to locale-first bundles losslessly", () => {
152
+ const source = {
153
+ "app:nav.home": { de: "Start", en: "Home" },
154
+ "app:nav.settings": { de: "Einstellungen", en: "Settings" },
155
+ };
156
+ const byLocale = translationsByLocaleFromKeys(source);
157
+ expect(byLocale["de"]).toEqual({
158
+ "app:nav.home": "Start",
159
+ "app:nav.settings": "Einstellungen",
160
+ });
161
+ expect(byLocale["en"]).toEqual({
162
+ "app:nav.home": "Home",
163
+ "app:nav.settings": "Settings",
164
+ });
165
+ });
166
+ });
108
167
  describe("useLocale", () => {
109
168
  test("returns the resolver", () => {
110
169
  const resolver = createStaticLocaleResolver({ locale: "de" });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { lastSegment } from "../app/qn";
2
+ import { toKebab as serverToKebab } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { lastSegment, toKebab } from "../app/qn";
3
4
 
4
5
  describe("lastSegment", () => {
5
6
  test("strips feature-prefix from screen-QN", () => {
@@ -38,3 +39,45 @@ describe("lastSegment", () => {
38
39
  expect(lastSegment("publicstatus:screen:")).toBe("");
39
40
  });
40
41
  });
42
+
43
+ describe("toKebab", () => {
44
+ test("camelCase entity ids match server qualifyEntityName", () => {
45
+ expect(toKebab("driverModel")).toBe("driver-model");
46
+ expect(toKebab("statementUpload")).toBe("statement-upload");
47
+ });
48
+
49
+ test("already kebab unchanged", () => {
50
+ expect(toKebab("driver-model")).toBe("driver-model");
51
+ });
52
+
53
+ test("preserves colon segments", () => {
54
+ expect(toKebab("driverModel:list")).toBe("driver-model:list");
55
+ });
56
+
57
+ test("consecutive uppercase (acronym boundary)", () => {
58
+ expect(toKebab("SSEBroadcast")).toBe("sse-broadcast");
59
+ });
60
+
61
+ test("dot separators become dashes", () => {
62
+ expect(toKebab("billing-period.create")).toBe("billing-period-create");
63
+ });
64
+
65
+ // Drift guard: this file is a byte-identical copy of the server's toKebab
66
+ // (packages/framework/src/engine/qualified-name.ts) kept in sync only by
67
+ // convention/comment, not by import (avoids pulling server deps into the
68
+ // browser bundle). Table-driven against the server's own doc examples so a
69
+ // future edit to either copy that breaks parity fails loudly here.
70
+ test("matches the server implementation for its documented examples", () => {
71
+ const cases = [
72
+ "task.create",
73
+ "ticketAssigned",
74
+ "billing-period.create",
75
+ "monthlyReport",
76
+ "SSEBroadcast",
77
+ "driverModel:list",
78
+ ];
79
+ for (const input of cases) {
80
+ expect(toKebab(input)).toBe(serverToKebab(input));
81
+ }
82
+ });
83
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sortByAccessor } from "../sort-by-accessor";
3
+
4
+ type Row = { readonly name: string; readonly count: number };
5
+
6
+ function makeRows(): readonly Row[] {
7
+ return [
8
+ { name: "b", count: 2 },
9
+ { name: "a", count: 3 },
10
+ { name: "c", count: 1 },
11
+ ];
12
+ }
13
+
14
+ const accessors = {
15
+ name: (r: Row) => r.name,
16
+ count: (r: Row) => r.count,
17
+ };
18
+
19
+ describe("sortByAccessor", () => {
20
+ test("sort === null returns rows unchanged (same reference)", () => {
21
+ const rows = makeRows();
22
+ expect(sortByAccessor(rows, null, accessors)).toBe(rows);
23
+ });
24
+
25
+ test("an unknown field returns rows unchanged (same reference)", () => {
26
+ const rows = makeRows();
27
+ expect(sortByAccessor(rows, { field: "nope", dir: "asc" }, accessors)).toBe(rows);
28
+ });
29
+
30
+ test("sorts ascending by the given accessor", () => {
31
+ const rows = makeRows();
32
+ const sorted = sortByAccessor(rows, { field: "name", dir: "asc" }, accessors);
33
+ expect(sorted.map((r) => r.name)).toEqual(["a", "b", "c"]);
34
+ });
35
+
36
+ test("sorts descending by the given accessor", () => {
37
+ const rows = makeRows();
38
+ const sorted = sortByAccessor(rows, { field: "count", dir: "desc" }, accessors);
39
+ expect(sorted.map((r) => r.count)).toEqual([3, 2, 1]);
40
+ });
41
+
42
+ test("does not mutate the input array", () => {
43
+ const rows = makeRows();
44
+ const original = [...rows];
45
+ sortByAccessor(rows, { field: "name", dir: "asc" }, accessors);
46
+ expect(rows).toEqual(original);
47
+ });
48
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConfigEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "../config-edit-shim";
4
+
5
+ describe("synthesizeConfigEditEntity", () => {
6
+ test("wraps the inline fields as an EntityDefinition", () => {
7
+ const fields = { apiKey: { type: "text" } } as unknown as ConfigEditScreenDefinition["fields"];
8
+ expect(synthesizeConfigEditEntity(fields)).toEqual({ fields });
9
+ });
10
+ });
11
+
12
+ describe("synthesizeConfigEditScreen", () => {
13
+ test("stamps type: entityEdit and a pseudo entity name", () => {
14
+ const screen = {
15
+ id: "settings",
16
+ layout: { sections: [] },
17
+ } as unknown as ConfigEditScreenDefinition;
18
+
19
+ const result = synthesizeConfigEditScreen(screen);
20
+ expect(result.id).toBe("settings");
21
+ expect(result.type).toBe("entityEdit");
22
+ expect(result.entity).toBe("__config-edit__");
23
+ expect(result.layout).toBe(screen.layout);
24
+ expect(result).not.toHaveProperty("fieldLabels");
25
+ expect(result).not.toHaveProperty("access");
26
+ });
27
+
28
+ test("carries fieldLabels and access through when present", () => {
29
+ const screen = {
30
+ id: "settings",
31
+ layout: { sections: [] },
32
+ fieldLabels: { apiKey: "config.apiKey" },
33
+ access: { roles: ["Admin"] },
34
+ } as unknown as ConfigEditScreenDefinition;
35
+
36
+ const result = synthesizeConfigEditScreen(screen);
37
+ expect(result.fieldLabels).toEqual({ apiKey: "config.apiKey" });
38
+ expect(result.access).toEqual({ roles: ["Admin"] });
39
+ });
40
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { screenAccessAllows } from "../kumiko-screen";
3
+
4
+ describe("screenAccessAllows", () => {
5
+ test("allows when no access rule is set", () => {
6
+ expect(screenAccessAllows(undefined, undefined)).toBe(true);
7
+ });
8
+
9
+ test("openToAll: true allows regardless of roles", () => {
10
+ expect(screenAccessAllows({ openToAll: true }, undefined)).toBe(true);
11
+ });
12
+
13
+ test("roles-gated allows a matching role", () => {
14
+ expect(screenAccessAllows({ roles: ["Admin"] }, ["Admin"])).toBe(true);
15
+ });
16
+
17
+ test("roles-gated denies with no matching role", () => {
18
+ expect(screenAccessAllows({ roles: ["Admin"] }, ["Member"])).toBe(false);
19
+ });
20
+
21
+ test("roles-gated denies when userRoles is undefined", () => {
22
+ expect(screenAccessAllows({ roles: ["Admin"] }, undefined)).toBe(false);
23
+ });
24
+ });
@@ -0,0 +1,32 @@
1
+ // Dashboard-Body-Injection: der KumikoScreen-Switch ist plattform-agnostisch,
2
+ // die Dashboard-Panels (StatCard, Charts) sind es nicht — die Implementierung
3
+ // kommt vom Platform-Package (renderer-web registriert seine Web-Variante in
4
+ // createKumikoApp). Gleiches Muster wie CustomScreensProvider.
5
+
6
+ import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
8
+ import { type ComponentType, createContext, type ReactNode, useContext } from "react";
9
+
10
+ export type DashboardBodyProps = {
11
+ readonly screen: DashboardScreenDefinition;
12
+ readonly translate?: Translate;
13
+ };
14
+
15
+ const DashboardBodyContext = createContext<ComponentType<DashboardBodyProps> | undefined>(
16
+ undefined,
17
+ );
18
+
19
+ export type DashboardBodyProviderProps = {
20
+ readonly children: ReactNode;
21
+ readonly value: ComponentType<DashboardBodyProps>;
22
+ };
23
+
24
+ export function DashboardBodyProvider({ children, value }: DashboardBodyProviderProps): ReactNode {
25
+ return <DashboardBodyContext.Provider value={value}>{children}</DashboardBodyContext.Provider>;
26
+ }
27
+
28
+ /** Undefined wenn kein Platform-Package einen Dashboard-Body registriert
29
+ * hat — KumikoScreen zeigt dann seinen Placeholder-Banner. */
30
+ export function useDashboardBody(): ComponentType<DashboardBodyProps> | undefined {
31
+ return useContext(DashboardBodyContext);
32
+ }
@@ -1,8 +1,9 @@
1
1
  // Extension-Section-Components-Map: client-side Lookup für entityEdit
2
- // sections vom Type `extension`. RenderEdit schaut hier nach dem
3
- // `__component`-Namen aus section.component und mountet die passende
4
- // Component mit { entityName, entityId } die Bundled-Feature-Component
5
- // lädt + persistiert dann ihre eigenen Daten (z.B. custom-fields).
2
+ // sections vom Type `extension`, List-Header-Slots UND Dashboard-`custom`-
3
+ // Panels. Jeder Mount-Ort löst den `__component`-Namen aus einer
4
+ // PlatformComponent über dieselbe Registry auf und mountet die passende
5
+ // Component — die Bundled-Feature-/App-Component lädt + persistiert dann
6
+ // ihre eigenen Daten (z.B. custom-fields, oder ein eigenständiger Chart).
6
7
  //
7
8
  // Mounting analog zu CustomScreensProvider — createKumikoApp im
8
9
  // renderer-web sammelt alle clientFeatures.extensionSectionComponents und
@@ -39,6 +40,12 @@ export type ExtensionSectionProps = {
39
40
  * (useListUrlState) ansprechen kann — z.B. ein Tag-Filter der die Liste auf
40
41
  * eine id-Menge narrowed. In entityEdit-Sections undefined. */
41
42
  readonly screenId?: string;
43
+ /** Nur im Dashboard-`custom`-Panel gesetzt: der aktuell gewählte Wert des
44
+ * Screen-Filters (siehe DashboardFilterDefinition), gemerged wie bei jeder
45
+ * anderen Panel-Query. In allen anderen Mount-Orten undefined. Dashboard-
46
+ * Panels haben keine Entity — entityName/entityId tragen dort die
47
+ * screen.id bzw. null, siehe CustomPanelBody in dashboard-body.tsx. */
48
+ readonly filterParams?: Readonly<Record<string, unknown>>;
42
49
  };
43
50
 
44
51
  export type ExtensionSectionComponent = ComponentType<ExtensionSectionProps>;