@cosmicdrift/kumiko-renderer 0.21.1 → 0.23.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.21.1",
3
+ "version": "0.23.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>",
@@ -0,0 +1,62 @@
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).
6
+ //
7
+ // Mounting analog zu CustomScreensProvider — createKumikoApp im
8
+ // renderer-web sammelt alle clientFeatures.extensionSectionComponents und
9
+ // mountet den Provider; Tests die einzelne Sections prüfen wollen
10
+ // mounten den Provider direkt.
11
+
12
+ import type { PlatformComponent } from "@cosmicdrift/kumiko-framework/ui-types";
13
+ import { type ComponentType, createContext, type ReactNode, useContext } from "react";
14
+
15
+ /** Extrahiert den `__component`-Namen aus einer PlatformComponent. Liest
16
+ * react- und native-Branch (die App-Author registriert beide unter dem
17
+ * gleichen Namen); returnt den ersten gefundenen string. Pure narrowing
18
+ * via `in` — kein cast, kein assertion. */
19
+ export function extensionSectionName(component: PlatformComponent): string | undefined {
20
+ for (const branch of [component.react, component.native]) {
21
+ if (branch !== null && typeof branch === "object" && "__component" in branch) {
22
+ const candidate = branch.__component;
23
+ if (typeof candidate === "string") return candidate;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ export type ExtensionSectionProps = {
30
+ readonly entityName: string;
31
+ readonly entityId: string | null;
32
+ };
33
+
34
+ export type ExtensionSectionComponent = ComponentType<ExtensionSectionProps>;
35
+
36
+ export type ExtensionSectionsMap = Readonly<Record<string, ExtensionSectionComponent>>;
37
+
38
+ const ExtensionSectionsContext = createContext<ExtensionSectionsMap | undefined>(undefined);
39
+
40
+ export type ExtensionSectionsProviderProps = {
41
+ readonly children: ReactNode;
42
+ readonly value: ExtensionSectionsMap;
43
+ };
44
+
45
+ export function ExtensionSectionsProvider({
46
+ children,
47
+ value,
48
+ }: ExtensionSectionsProviderProps): ReactNode {
49
+ return (
50
+ <ExtensionSectionsContext.Provider value={value}>{children}</ExtensionSectionsContext.Provider>
51
+ );
52
+ }
53
+
54
+ /** Schaut die Component für einen extension-section-Namen nach. Returnt
55
+ * undefined wenn weder Provider gemounted noch der Name in der Map
56
+ * registriert ist — der Caller (RenderEdit) zeigt dann seinen
57
+ * Placeholder-Banner. */
58
+ export function useExtensionSectionComponent(name: string): ExtensionSectionComponent | undefined {
59
+ const map = useContext(ExtensionSectionsContext);
60
+ if (map === undefined) return undefined;
61
+ return map[name];
62
+ }
@@ -2,9 +2,10 @@ import type {
2
2
  EntityDefinition,
3
3
  EntityEditScreenDefinition,
4
4
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
- import { normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  import type {
7
7
  DispatcherError,
8
+ EditExtensionSectionViewModel,
8
9
  EditFieldViewModel,
9
10
  EditSectionViewModel,
10
11
  FieldConditions,
@@ -17,6 +18,7 @@ import type {
17
18
  import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
18
19
  import { type ReactNode, useMemo, useState } from "react";
19
20
  import type { z } from "zod";
21
+ import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
20
22
  import { useForm } from "../hooks/use-form";
21
23
  import { useTranslation } from "../i18n";
22
24
  import { usePrimitives } from "../primitives";
@@ -67,6 +69,7 @@ function deriveFormFields<TValues extends FormValues, TCtx>(
67
69
  ): Record<string, FieldConditions<TValues, TCtx>> {
68
70
  const out: Record<string, FieldConditions<TValues, TCtx>> = {};
69
71
  for (const section of screen.layout.sections) {
72
+ if (isExtensionEditSection(section)) continue;
70
73
  for (const spec of section.fields) {
71
74
  const normalized = normalizeEditField(spec);
72
75
  out[normalized.field] = {
@@ -85,6 +88,48 @@ function deriveFormFields<TValues extends FormValues, TCtx>(
85
88
  return out;
86
89
  }
87
90
 
91
+ // Resolves an extension-section's `{ react: { __component: "X" } }` marker
92
+ // to a registered React component via ExtensionSectionsProvider (filled in
93
+ // createKumikoApp from clientFeatures.extensionSectionComponents) and
94
+ // mounts it with the host entity name + id. Hook lives in its own
95
+ // component so we don't call `use*` inside vm.sections.map (rules-of-
96
+ // hooks would punish reordering sections between renders).
97
+ function ExtensionSectionMount({
98
+ section,
99
+ entityName,
100
+ entityId,
101
+ }: {
102
+ readonly section: EditExtensionSectionViewModel;
103
+ readonly entityName: string;
104
+ readonly entityId: string | null;
105
+ }): ReactNode {
106
+ const { Banner, Section, Text } = usePrimitives();
107
+ const name = extensionSectionName(section.component);
108
+ const Component = useExtensionSectionComponent(name ?? "");
109
+ if (Component === undefined) {
110
+ return (
111
+ <Section
112
+ key={section.title}
113
+ title={section.title}
114
+ testId={`section-extension-${section.title}`}
115
+ >
116
+ <Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
117
+ <Text>
118
+ Extension section component{" "}
119
+ <Text variant="code">{name ?? "(no __component name)"}</Text> not registered in
120
+ clientFeatures.extensionSectionComponents.
121
+ </Text>
122
+ </Banner>
123
+ </Section>
124
+ );
125
+ }
126
+ return (
127
+ <Section title={section.title} testId={`section-extension-${section.title}`}>
128
+ <Component entityName={entityName} entityId={entityId} />
129
+ </Section>
130
+ );
131
+ }
132
+
88
133
  export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
89
134
  props: RenderEditProps<TValues, TCtx>,
90
135
  ): ReactNode {
@@ -258,29 +303,41 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
258
303
  actions={formActions}
259
304
  testId="render-edit-form"
260
305
  >
261
- {vm.sections.map((section: EditSectionViewModel) => (
262
- <Section key={section.title} title={section.title} testId={`section-${section.title}`}>
263
- <Grid columns={section.columns}>
264
- {section.fields.map((field: EditFieldViewModel) => (
265
- <GridCellForField
266
- key={field.field}
267
- field={field}
268
- columns={section.columns}
269
- issues={snapshot.errors[field.field]}
270
- onChange={(v) => {
271
- (controller.setField as (k: string, v: unknown) => void)(field.field, v);
272
- }}
273
- GridCell={GridCell}
274
- featureName={featureName}
275
- {...(fieldAppendix !== undefined && {
276
- labelAppendix: fieldAppendix(field.field),
277
- fieldAppendix: fieldAppendix(field.field),
278
- })}
279
- />
280
- ))}
281
- </Grid>
282
- </Section>
283
- ))}
306
+ {vm.sections.map((section: EditSectionViewModel) => {
307
+ if (section.kind === "extension") {
308
+ return (
309
+ <ExtensionSectionMount
310
+ key={section.title}
311
+ section={section}
312
+ entityName={vm.entityName}
313
+ entityId={vm.id}
314
+ />
315
+ );
316
+ }
317
+ return (
318
+ <Section key={section.title} title={section.title} testId={`section-${section.title}`}>
319
+ <Grid columns={section.columns}>
320
+ {section.fields.map((field: EditFieldViewModel) => (
321
+ <GridCellForField
322
+ key={field.field}
323
+ field={field}
324
+ columns={section.columns}
325
+ issues={snapshot.errors[field.field]}
326
+ onChange={(v) => {
327
+ (controller.setField as (k: string, v: unknown) => void)(field.field, v);
328
+ }}
329
+ GridCell={GridCell}
330
+ featureName={featureName}
331
+ {...(fieldAppendix !== undefined && {
332
+ labelAppendix: fieldAppendix(field.field),
333
+ fieldAppendix: fieldAppendix(field.field),
334
+ })}
335
+ />
336
+ ))}
337
+ </Grid>
338
+ </Section>
339
+ );
340
+ })}
284
341
  {formError !== null && (
285
342
  <Banner
286
343
  variant="error"
package/src/index.ts CHANGED
@@ -18,6 +18,17 @@ export type {
18
18
  export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers";
19
19
  export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-screens";
20
20
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens";
21
+ export type {
22
+ ExtensionSectionComponent,
23
+ ExtensionSectionProps,
24
+ ExtensionSectionsMap,
25
+ ExtensionSectionsProviderProps,
26
+ } from "./app/extension-sections";
27
+ export {
28
+ ExtensionSectionsProvider,
29
+ extensionSectionName,
30
+ useExtensionSectionComponent,
31
+ } from "./app/extension-sections";
21
32
  export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app/feature-schema";
22
33
  export { isAppSchema, toAppSchema } from "./app/feature-schema";
23
34
  export type { KumikoScreenProps } from "./app/kumiko-screen";