@cosmicdrift/kumiko-renderer 0.237.1 → 0.238.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.237.1",
3
+ "version": "0.238.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.237.1",
19
- "@cosmicdrift/kumiko-headless": "0.237.1",
18
+ "@cosmicdrift/kumiko-framework": "0.238.0",
19
+ "@cosmicdrift/kumiko-headless": "0.238.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.237.1"
30
+ "@cosmicdrift/kumiko-locale-de": "0.238.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -0,0 +1,147 @@
1
+ // fw#2662: projectionDetail fields have no EntityDefinition to carry a real
2
+ // "reference" field type (the shim hardcodes every field as "text"), so a
3
+ // declared reference field used to render the raw id. This renders the real
4
+ // path (KumikoScreen -> ProjectionDetailScreen -> RenderEdit -> RenderField
5
+ // -> ReadOnlyReferenceValue) under a stub dispatcher.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
9
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
10
+ import { render, waitFor } from "@testing-library/react";
11
+ import type { ComponentType, ReactNode } from "react";
12
+ import { DispatcherProvider } from "../../context/dispatcher-context";
13
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
14
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
15
+ import { type CorePrimitives, PrimitivesProvider, type TextProps } from "../../primitives";
16
+ import type { FeatureSchema } from "../feature-schema";
17
+ import { KumikoScreen } from "../kumiko-screen";
18
+ import type { NavApi } from "../nav";
19
+ import { NavProvider } from "../nav";
20
+
21
+ const noop = (): ReactNode => null;
22
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
23
+ // passChildren would drop testId — ReadOnlyReferenceValue/readOnlyDisplayText
24
+ // render their resolved text into `<Text testId="field-value-<field>">`, and
25
+ // the assertions below read it back by that testid.
26
+ const TestText: ComponentType<TextProps> = ({ children, testId }) => (
27
+ <span data-testid={testId}>{children}</span>
28
+ );
29
+
30
+ const testPrimitives: CorePrimitives = {
31
+ Button: noop,
32
+ Banner: passChildren,
33
+ Field: passChildren,
34
+ Input: noop,
35
+ DataTable: noop,
36
+ Form: passChildren,
37
+ Section: passChildren,
38
+ Card: passChildren,
39
+ Grid: passChildren,
40
+ GridCell: passChildren,
41
+ Text: TestText,
42
+ Heading: noop,
43
+ Dialog: noop,
44
+ Modal: noop,
45
+ Lightbox: noop,
46
+ ConfigSourceBadge: noop,
47
+ ConfigCascadeView: noop,
48
+ Link: noop,
49
+ };
50
+
51
+ function stubDispatcher(): Dispatcher {
52
+ return {
53
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
54
+ query: (async (type: string) => {
55
+ if (type === "sessions:query:session:detail") {
56
+ return {
57
+ isSuccess: true,
58
+ data: { id: "sess-1", userId: "u-1", ip: "10.0.0.1" },
59
+ };
60
+ }
61
+ if (type === "user:query:user:list") {
62
+ return {
63
+ isSuccess: true,
64
+ data: { rows: [{ id: "u-1", displayName: "Jane Doe" }], nextCursor: null },
65
+ };
66
+ }
67
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
68
+ }) as unknown as Dispatcher["query"],
69
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
70
+ statusStore: {
71
+ getState: () => "online",
72
+ subscribe: () => () => {},
73
+ } as unknown as Dispatcher["statusStore"],
74
+ async *stream() {},
75
+ pendingWrites: () => [],
76
+ pendingFiles: () => [],
77
+ };
78
+ }
79
+
80
+ const detailScreen: ProjectionDetailScreenDefinition = {
81
+ id: "session-detail",
82
+ type: "projectionDetail",
83
+ query: "sessions:query:session:detail",
84
+ layout: {
85
+ sections: [
86
+ {
87
+ fields: [{ field: "userId", refEntity: "user:user", refLabelField: "displayName" }, "ip"],
88
+ },
89
+ ],
90
+ },
91
+ };
92
+
93
+ function buildSchema(): FeatureSchema {
94
+ return {
95
+ featureName: "sessions",
96
+ entities: {},
97
+ screens: [detailScreen],
98
+ } as FeatureSchema;
99
+ }
100
+
101
+ const staticNav: NavApi = {
102
+ route: { screenId: "sessions:screen:session-detail" },
103
+ navigate: () => {},
104
+ replace: () => {},
105
+ hrefFor: () => "",
106
+ searchParams: {},
107
+ setSearchParams: () => {},
108
+ };
109
+
110
+ function renderDetailScreen(): ReturnType<typeof render> {
111
+ return render(
112
+ <LocaleProvider
113
+ resolver={createStaticLocaleResolver({ locale: "en" })}
114
+ fallbackBundles={[kumikoDefaultTranslations]}
115
+ >
116
+ <DispatcherProvider dispatcher={stubDispatcher()}>
117
+ <NavProvider value={staticNav}>
118
+ <PrimitivesProvider value={testPrimitives}>
119
+ <KumikoScreen
120
+ schema={buildSchema()}
121
+ qn="sessions:screen:session-detail"
122
+ entityId="sess-1"
123
+ />
124
+ </PrimitivesProvider>
125
+ </NavProvider>
126
+ </DispatcherProvider>
127
+ </LocaleProvider>,
128
+ );
129
+ }
130
+
131
+ describe("projectionDetail reference field resolves labels (fw#2662)", () => {
132
+ test("a field with refEntity metadata shows the resolved display name instead of the raw id", async () => {
133
+ const { getByTestId } = renderDetailScreen();
134
+
135
+ await waitFor(() => {
136
+ expect(getByTestId("field-value-userId").textContent).toBe("Jane Doe");
137
+ });
138
+ });
139
+
140
+ test("non-regression: a field without refEntity metadata renders its raw value unchanged", async () => {
141
+ const { getByTestId } = renderDetailScreen();
142
+
143
+ await waitFor(() => {
144
+ expect(getByTestId("field-value-ip").textContent).toBe("10.0.0.1");
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,161 @@
1
+ // fw#2662: projectionList columns have no EntityDefinition to carry a real
2
+ // "reference" field type (the shim hardcodes every field as "text"), so a
3
+ // declared reference column used to render the raw GUID. This renders the
4
+ // real path (KumikoScreen -> ProjectionListScreen -> RenderList ->
5
+ // useReferenceLookup) under a stub dispatcher, and reads the resolved cell
6
+ // text back off the reference column's injected runtime renderer.
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
10
+ import type { Dispatcher, RuntimeRenderer } from "@cosmicdrift/kumiko-headless";
11
+ import { render, waitFor } from "@testing-library/react";
12
+ import type { ComponentType, ReactNode } from "react";
13
+ import { DispatcherProvider } from "../../context/dispatcher-context";
14
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
15
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
16
+ import { type CorePrimitives, type DataTableProps, PrimitivesProvider } from "../../primitives";
17
+ import type { FeatureSchema } from "../feature-schema";
18
+ import { KumikoScreen } from "../kumiko-screen";
19
+ import type { NavApi } from "../nav";
20
+ import { NavProvider } from "../nav";
21
+
22
+ const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000";
23
+ const REAL_TENANT_ID = "11111111-1111-4111-8111-111111111111";
24
+
25
+ let capturedProps: DataTableProps | undefined;
26
+ const captureDataTable: ComponentType<DataTableProps> = (props) => {
27
+ capturedProps = props;
28
+ return null;
29
+ };
30
+ const getCapturedProps = (): DataTableProps | undefined => capturedProps;
31
+ const noop = (): ReactNode => null;
32
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
33
+
34
+ const testPrimitives: CorePrimitives = {
35
+ Button: noop,
36
+ Banner: passChildren,
37
+ Field: passChildren,
38
+ Input: noop,
39
+ DataTable: captureDataTable,
40
+ Form: passChildren,
41
+ Section: passChildren,
42
+ Card: passChildren,
43
+ Grid: passChildren,
44
+ GridCell: passChildren,
45
+ Text: passChildren,
46
+ Heading: noop,
47
+ Dialog: noop,
48
+ Modal: noop,
49
+ Lightbox: noop,
50
+ ConfigSourceBadge: noop,
51
+ ConfigCascadeView: noop,
52
+ Link: noop,
53
+ };
54
+
55
+ function stubDispatcher(): Dispatcher {
56
+ return {
57
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
58
+ query: (async (type: string) => {
59
+ if (type === "delivery:query:log:list") {
60
+ return {
61
+ isSuccess: true,
62
+ data: {
63
+ rows: [
64
+ { id: "row-1", tenantId: REAL_TENANT_ID },
65
+ { id: "row-2", tenantId: SYSTEM_TENANT_ID },
66
+ ],
67
+ nextCursor: null,
68
+ },
69
+ };
70
+ }
71
+ if (type === "tenant:query:tenant:list") {
72
+ return {
73
+ isSuccess: true,
74
+ data: { rows: [{ id: REAL_TENANT_ID, name: "Acme Inc" }], nextCursor: null },
75
+ };
76
+ }
77
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
78
+ }) as unknown as Dispatcher["query"],
79
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
80
+ statusStore: {
81
+ getState: () => "online",
82
+ subscribe: () => () => {},
83
+ } as unknown as Dispatcher["statusStore"],
84
+ async *stream() {},
85
+ pendingWrites: () => [],
86
+ pendingFiles: () => [],
87
+ };
88
+ }
89
+
90
+ function buildSchema(screen: ProjectionListScreenDefinition): FeatureSchema {
91
+ return {
92
+ featureName: "delivery",
93
+ entities: {},
94
+ screens: [screen],
95
+ } as FeatureSchema;
96
+ }
97
+
98
+ const staticNav: NavApi = {
99
+ route: { screenId: "delivery:screen:log" },
100
+ navigate: () => {},
101
+ replace: () => {},
102
+ hrefFor: () => "",
103
+ searchParams: {},
104
+ setSearchParams: () => {},
105
+ };
106
+
107
+ function renderLogScreen(): void {
108
+ const screen: ProjectionListScreenDefinition = {
109
+ id: "log",
110
+ type: "projectionList",
111
+ query: "delivery:query:log:list",
112
+ columns: [
113
+ {
114
+ field: "tenantId",
115
+ label: "delivery.log.col.tenantId",
116
+ refEntity: "tenant:tenant",
117
+ refLabelField: "name",
118
+ },
119
+ ],
120
+ };
121
+ render(
122
+ <LocaleProvider
123
+ resolver={createStaticLocaleResolver({ locale: "en" })}
124
+ fallbackBundles={[kumikoDefaultTranslations]}
125
+ >
126
+ <DispatcherProvider dispatcher={stubDispatcher()}>
127
+ <NavProvider value={staticNav}>
128
+ <PrimitivesProvider value={testPrimitives}>
129
+ <KumikoScreen schema={buildSchema(screen)} qn="delivery:screen:log" />
130
+ </PrimitivesProvider>
131
+ </NavProvider>
132
+ </DispatcherProvider>
133
+ </LocaleProvider>,
134
+ );
135
+ }
136
+
137
+ function referenceRenderer(): RuntimeRenderer {
138
+ const col = getCapturedProps()?.columns.find((c) => c.field === "tenantId");
139
+ if (typeof col?.renderer !== "function") throw new Error("reference column has no renderer yet");
140
+ return col.renderer as RuntimeRenderer;
141
+ }
142
+
143
+ describe("projectionList reference column resolves labels (fw#2662)", () => {
144
+ test("a real tenant id resolves to its name instead of the raw GUID", async () => {
145
+ capturedProps = undefined;
146
+ renderLogScreen();
147
+
148
+ await waitFor(() => {
149
+ expect(referenceRenderer()(REAL_TENANT_ID, { tenantId: REAL_TENANT_ID })).toBe("Acme Inc");
150
+ });
151
+ });
152
+
153
+ test("SYSTEM_TENANT_ID resolves to the system label instead of the raw GUID", async () => {
154
+ capturedProps = undefined;
155
+ renderLogScreen();
156
+
157
+ await waitFor(() => {
158
+ expect(referenceRenderer()(SYSTEM_TENANT_ID, { tenantId: SYSTEM_TENANT_ID })).toBe("System");
159
+ });
160
+ });
161
+ });
@@ -975,7 +975,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
975
975
  {onCancel !== undefined && (
976
976
  <Button
977
977
  type="button"
978
- variant="link"
978
+ variant="secondary"
979
979
  icon="x"
980
980
  onClick={() => onCancel()}
981
981
  testId="render-edit-cancel"
@@ -3,6 +3,7 @@ import {
3
3
  type FieldIconKey,
4
4
  type FieldRenderer,
5
5
  isFormatSpec,
6
+ SYSTEM_REFERENCE_LABELS,
6
7
  } from "@cosmicdrift/kumiko-framework/ui-types";
7
8
  import {
8
9
  applyFormatSpec,
@@ -403,6 +404,7 @@ function ReadOnlyReferenceValue({
403
404
  readonly featureName: string;
404
405
  }): ReactNode {
405
406
  const { Text } = usePrimitives();
407
+ const t = useTranslation();
406
408
  const refEntity = field.refEntity ?? "";
407
409
  const refFeature = field.refFeature ?? featureName;
408
410
  const labelField = field.refLabelField ?? "id";
@@ -420,7 +422,13 @@ function ReadOnlyReferenceValue({
420
422
  : [];
421
423
  if (ids.length === 0) return <Text testId={`field-value-${field.field}`}>—</Text>;
422
424
  const rows = queryResult.data?.rows ?? [];
425
+ // System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row —
426
+ // same central fallback as useReferenceLookup (render-list.tsx), so a
427
+ // reference field showing one on the detail path also gets a label
428
+ // instead of the raw id.
429
+ const systemLabel = SYSTEM_REFERENCE_LABELS[`${refFeature}:${refEntity}`];
423
430
  const labels = ids.map((id) => {
431
+ if (systemLabel !== undefined && id === systemLabel.id) return t(systemLabel.labelKey);
424
432
  const row = rows.find((r) => String(r["id"] ?? "") === id);
425
433
  return row !== undefined ? String(row[labelField] ?? id) : id;
426
434
  });
@@ -17,8 +17,10 @@
17
17
  // internen Cache shared zwischen List + Edit-Form für die gleiche
18
18
  // Entity, Live-Updates kommen via SSE (use-query-live).
19
19
 
20
+ import { SYSTEM_REFERENCE_LABELS } from "@cosmicdrift/kumiko-framework/ui-types";
20
21
  import { useMemo } from "react";
21
22
  import { toKebab } from "../app/qn";
23
+ import { useTranslation } from "../i18n";
22
24
  import { REFERENCE_LIST_LOOKUP_LIMIT } from "./reference-limits";
23
25
  import { useQuery } from "./use-query";
24
26
 
@@ -40,6 +42,8 @@ export function useReferenceLookup(
40
42
  const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
41
43
  limit: REFERENCE_LIST_LOOKUP_LIMIT,
42
44
  });
45
+ const translate = useTranslation();
46
+ const systemLabel = SYSTEM_REFERENCE_LABELS[`${featureName}:${refEntity}`];
43
47
  const map = useMemo(() => {
44
48
  const out = new Map<string, string>();
45
49
  for (const row of result.data?.rows ?? []) {
@@ -49,7 +53,10 @@ export function useReferenceLookup(
49
53
  const label = row[labelField] ?? id;
50
54
  out.set(idStr, String(label));
51
55
  }
56
+ // System-scope ids (e.g. SYSTEM_TENANT_ID) never have a backing row, so
57
+ // the bulk lookup above never covers them — inject the label directly.
58
+ if (systemLabel !== undefined) out.set(systemLabel.id, translate(systemLabel.labelKey));
52
59
  return out;
53
- }, [result.data, labelField]);
60
+ }, [result.data, labelField, systemLabel, translate]);
54
61
  return { map, loading: result.loading };
55
62
  }
@@ -64,6 +64,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
64
64
  "kumiko.list.end-of-list": "— End of list —",
65
65
  "kumiko.list.sort.label": "Sort",
66
66
  "kumiko.list.sort.unsorted": "Unsorted",
67
+ "kumiko.reference.system-tenant": "System",
67
68
 
68
69
  "kumiko.pager.status": "{from}–{to} of {total}",
69
70
  "kumiko.pager.previousPage": "Previous page",