@cosmicdrift/kumiko-renderer 0.203.0 → 0.204.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.203.0",
3
+ "version": "0.204.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.203.0",
19
- "@cosmicdrift/kumiko-headless": "0.203.0",
18
+ "@cosmicdrift/kumiko-framework": "0.204.1",
19
+ "@cosmicdrift/kumiko-headless": "0.204.1",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -2,19 +2,19 @@ import type {
2
2
  EditFieldSpec,
3
3
  EntityEditScreenDefinition,
4
4
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
- import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { isFieldsEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
 
7
7
  // Normalized field specs actually rendered by the screen's layout, extension
8
- // sections skipped. Both this and `layoutFieldNames` key off "rendered by
9
- // the layout" for the same reason: a field the user never sees gets no
10
- // chance to review/correct a value nor to fix a presence error
8
+ // (and relatedList) sections skipped. Both this and `layoutFieldNames` key
9
+ // off "rendered by the layout" for the same reason: a field the user never
10
+ // sees gets no chance to review/correct a value nor to fix a presence error
11
11
  // (search-param merge, #1708; presence schema in form-schema.ts).
12
12
  export function layoutEditFields(
13
13
  screen: EntityEditScreenDefinition,
14
14
  ): readonly Exclude<EditFieldSpec, string>[] {
15
15
  const specs: Exclude<EditFieldSpec, string>[] = [];
16
16
  for (const section of screen.layout.sections) {
17
- if (isExtensionEditSection(section)) continue;
17
+ if (!isFieldsEditSection(section)) continue;
18
18
  for (const spec of section.fields) {
19
19
  specs.push(normalizeEditField(spec));
20
20
  }
@@ -27,7 +27,7 @@ import type {
27
27
  ProjectionDetailScreenDefinition,
28
28
  } from "@cosmicdrift/kumiko-framework/ui-types";
29
29
  import {
30
- isExtensionEditSection,
30
+ isFieldsEditSection,
31
31
  normalizeEditField,
32
32
  PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
33
33
  } from "@cosmicdrift/kumiko-framework/ui-types";
@@ -38,7 +38,8 @@ import {
38
38
  export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefinition {
39
39
  const fields: Record<string, { type: "text" }> = {};
40
40
  for (const section of layout.sections) {
41
- if (isExtensionEditSection(section)) continue; // rejected at boot, defensive here
41
+ // relatedList and extension sections carry no `fields` to synthesize.
42
+ if (!isFieldsEditSection(section)) continue;
42
43
  for (const spec of section.fields) {
43
44
  fields[normalizeEditField(spec).field] = { type: "text" };
44
45
  }
@@ -52,7 +53,8 @@ export function synthesizeProjectionDetailScreen(
52
53
  screen: ProjectionDetailScreenDefinition,
53
54
  ): EntityEditScreenDefinition {
54
55
  const sections = screen.layout.sections.map((section) => {
55
- if (isExtensionEditSection(section)) return section; // rejected at boot, defensive here
56
+ // relatedList and extension sections pass through unchanged.
57
+ if (!isFieldsEditSection(section)) return section;
56
58
  return {
57
59
  ...section,
58
60
  fields: section.fields.map((spec) => ({ ...normalizeEditField(spec), readOnly: true })),
@@ -0,0 +1,116 @@
1
+ import type {
2
+ EntityDefinition,
3
+ EntityListScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import type {
7
+ EditRelatedListSectionViewModel,
8
+ ListRowViewModel,
9
+ Translate,
10
+ } from "@cosmicdrift/kumiko-headless";
11
+ import { type ReactNode, useMemo } from "react";
12
+ import { useNav } from "../app/nav";
13
+ import { dispatcherErrorText } from "../app/write-failed-error";
14
+ import { useQuery } from "../hooks/use-query";
15
+ import { useTranslation } from "../i18n";
16
+ import { usePrimitives } from "../primitives";
17
+ import { RenderList } from "./render-list";
18
+
19
+ const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
20
+
21
+ // Same paged envelope as ProjectionListBody's PagedRows (kumiko-screen.tsx) —
22
+ // duplicated locally because that type isn't exported (projectionList and
23
+ // relatedList are independent query call-sites, not a shared abstraction).
24
+ type PagedRows = {
25
+ readonly rows: Readonly<Record<string, unknown>>[];
26
+ readonly nextCursor: string | null;
27
+ readonly total?: number;
28
+ };
29
+
30
+ // Minimal EntityDefinition from the section's own columns — same shape as
31
+ // projection-list-shim's synthesizeProjectionEntity, but relatedList columns
32
+ // aren't sortable (no sort UI on this section, see RelatedListSection below).
33
+ function synthesizeRelatedListEntity(
34
+ columns: EditRelatedListSectionViewModel["columns"],
35
+ ): EntityDefinition {
36
+ const fields: Record<string, { type: "text" }> = {};
37
+ for (const col of columns) {
38
+ fields[normalizeListColumn(col).field] = { type: "text" };
39
+ }
40
+ return { fields } as unknown as EntityDefinition;
41
+ }
42
+
43
+ export function RelatedListSection({
44
+ section,
45
+ parentId,
46
+ featureName,
47
+ translate,
48
+ }: {
49
+ readonly section: EditRelatedListSectionViewModel;
50
+ readonly parentId: string;
51
+ readonly featureName: string;
52
+ readonly translate?: Translate;
53
+ }): ReactNode {
54
+ const { Banner, Section } = usePrimitives();
55
+ const t = useTranslation();
56
+ const effectiveTranslate = translate ?? t;
57
+ const nav = useNav();
58
+
59
+ const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
60
+ const listScreen = useMemo(
61
+ (): EntityListScreenDefinition => ({
62
+ // Empty id → RenderList's own toolbarTitle resolves to "" (its
63
+ // `screen:${id}.title` lookup misses and falls back to `id`) — this
64
+ // component renders the visible heading itself via `Section` below,
65
+ // so RenderList's toolbar carries none.
66
+ id: "",
67
+ type: "entityList",
68
+ entity: RELATED_LIST_PSEUDO_ENTITY,
69
+ columns: section.columns,
70
+ }),
71
+ [section.columns],
72
+ );
73
+
74
+ const payload = useMemo(
75
+ () => ({
76
+ [section.parentParam ?? "id"]: parentId,
77
+ ...(section.pageSize !== undefined && { limit: section.pageSize }),
78
+ }),
79
+ [section.parentParam, section.pageSize, parentId],
80
+ );
81
+
82
+ const rowsQuery = useQuery<PagedRows>(section.query, payload);
83
+
84
+ const rowClick = section.rowClick;
85
+ const onRowClick =
86
+ rowClick !== undefined
87
+ ? (row: ListRowViewModel) => {
88
+ const id = String(row.values[rowClick.idColumn ?? "id"] ?? "");
89
+ if (id === "") return;
90
+ nav.navigate({ entity: rowClick.entity, id });
91
+ }
92
+ : undefined;
93
+
94
+ return (
95
+ <Section title={section.title} testId={`related-list-${section.title}`}>
96
+ {rowsQuery.loading && rowsQuery.data === null ? (
97
+ <Banner padded variant="loading" testId="related-list-loading">
98
+ Loading…
99
+ </Banner>
100
+ ) : rowsQuery.error ? (
101
+ <Banner padded variant="error" testId="related-list-error">
102
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
103
+ </Banner>
104
+ ) : (
105
+ <RenderList
106
+ screen={listScreen}
107
+ entity={entity}
108
+ rows={rowsQuery.data?.rows ?? []}
109
+ featureName={featureName}
110
+ translate={effectiveTranslate}
111
+ {...(onRowClick !== undefined && { onRowClick })}
112
+ />
113
+ )}
114
+ </Section>
115
+ );
116
+ }
@@ -40,9 +40,9 @@ export function shouldNotifyCaller(
40
40
  return !(result.isSuccess && !extensionsPersisted);
41
41
  }
42
42
 
43
- // Extension sections skip the `fields` filter (their own field set, unrelated
44
- // to `field`-name filtering); a `fields` section left with zero fields after
45
- // filtering is dropped, not rendered empty.
43
+ // Extension and relatedList sections skip the `fields` filter (neither has a
44
+ // `field`-name set that filtering applies to); a `fields` section left with
45
+ // zero fields after filtering is dropped, not rendered empty.
46
46
  export function filterEditSections(
47
47
  sections: readonly EditSectionViewModel[],
48
48
  fieldsFilter: readonly string[] | undefined,
@@ -51,7 +51,7 @@ export function filterEditSections(
51
51
  const filterSet = new Set(fieldsFilter);
52
52
  const result: EditSectionViewModel[] = [];
53
53
  for (const section of sections) {
54
- if (section.kind === "extension") {
54
+ if (section.kind === "extension" || section.kind === "relatedList") {
55
55
  result.push(section);
56
56
  continue;
57
57
  }
@@ -5,7 +5,7 @@ import type {
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  import {
7
7
  evalFieldCondition,
8
- isExtensionEditSection,
8
+ isFieldsEditSection,
9
9
  normalizeEditField,
10
10
  } from "@cosmicdrift/kumiko-framework/ui-types";
11
11
  import type {
@@ -31,6 +31,7 @@ import { formatWhen } from "../format-when";
31
31
  import { useForm } from "../hooks/use-form";
32
32
  import { useTranslation } from "../i18n";
33
33
  import { usePrimitives } from "../primitives";
34
+ import { RelatedListSection } from "./related-list-section";
34
35
  import {
35
36
  filterEditSections,
36
37
  hasEditableSection,
@@ -253,7 +254,8 @@ function deriveFormFields<TValues extends FormValues, TCtx>(
253
254
  ): Record<string, FieldConditions<TValues, TCtx>> {
254
255
  const out: Record<string, FieldConditions<TValues, TCtx>> = {};
255
256
  for (const section of screen.layout.sections) {
256
- if (isExtensionEditSection(section)) continue;
257
+ // relatedList carries no `fields` for the form-condition map either.
258
+ if (!isFieldsEditSection(section)) continue;
257
259
  for (const spec of section.fields) {
258
260
  const normalized = normalizeEditField(spec);
259
261
  out[normalized.field] = {
@@ -786,14 +788,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
786
788
  const filteredSections = useMemo(
787
789
  // A fully-hidden "fields" section (every field in it currently
788
790
  // condition-hidden) must not occupy a wizard step; it would render
789
- // empty and block Back/Next on nothing. Extension sections carry no
790
- // `visible` (they own their own dirty/save lifecycle), so they always
791
- // pass through. A section with no fields at all (e.g. a review-only
792
- // step) has `visible: fields.some(...)` = false vacuously; that's "no
793
- // fields to hide", not "hidden", so it stays too (fw#1901).
791
+ // empty and block Back/Next on nothing. Extension and relatedList
792
+ // sections carry no `visible` (they own their own lifecycle / run
793
+ // their own query), so they always pass through. A section with no
794
+ // fields at all (e.g. a review-only step) has `visible: fields.some(...)`
795
+ // = false vacuously; that's "no fields to hide", not "hidden", so it
796
+ // stays too (fw#1901).
794
797
  () =>
795
798
  filterEditSections(vm.sections, fieldsFilter).filter(
796
- (section) => section.kind === "extension" || section.fields.length === 0 || section.visible,
799
+ (section) =>
800
+ section.kind === "extension" ||
801
+ section.kind === "relatedList" ||
802
+ section.fields.length === 0 ||
803
+ section.visible,
797
804
  ),
798
805
  [vm.sections, fieldsFilter],
799
806
  );
@@ -1302,6 +1309,22 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1302
1309
  </WizardStepGroup>
1303
1310
  );
1304
1311
  }
1312
+ if (section.kind === "relatedList") {
1313
+ // parentId is the displayed record's id — without it there's no
1314
+ // parent row whose related rows could be queried (fw#2166).
1315
+ // Rejected at boot in wizard layouts, so no WizardStepGroup here.
1316
+ const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
1317
+ if (parentId === null) return null;
1318
+ return (
1319
+ <RelatedListSection
1320
+ key={section.title}
1321
+ section={section}
1322
+ parentId={parentId}
1323
+ featureName={featureName}
1324
+ translate={translate}
1325
+ />
1326
+ );
1327
+ }
1305
1328
  if (!section.visible) return null;
1306
1329
  // Section-Header unterdrücken wenn er den Form-Titel der
1307
1330
  // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export { lastSegment } from "./app/qn";
77
77
  export type { VariableChipsProps } from "./app/variable-chips";
78
78
  export { VariableChips } from "./app/variable-chips";
79
79
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
80
+ export { RelatedListSection } from "./components/related-list-section";
80
81
  export type {
81
82
  RenderEditAction,
82
83
  RenderEditChangeState,