@cosmicdrift/kumiko-renderer 0.114.0 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.114.0",
3
+ "version": "1.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,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.114.0",
19
- "@cosmicdrift/kumiko-headless": "0.114.0",
18
+ "@cosmicdrift/kumiko-framework": "1.0.0",
19
+ "@cosmicdrift/kumiko-headless": "1.0.0",
20
20
  "react": "^19.2.6"
21
21
  },
22
22
  "devDependencies": {
@@ -5,7 +5,6 @@ import type {
5
5
  EntityDefinition,
6
6
  EntityEditScreenDefinition,
7
7
  EntityListScreenDefinition,
8
- ProjectionListScreenDefinition,
9
8
  RowAction,
10
9
  RowActionNavigate,
11
10
  RowActionWriteHandler,
@@ -36,7 +35,6 @@ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config
36
35
  import { useCustomScreenComponent } from "./custom-screens";
37
36
  import type { FeatureSchema } from "./feature-schema";
38
37
  import { useNav } from "./nav";
39
- import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
40
38
  import { lastSegment } from "./qn";
41
39
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
42
40
 
@@ -137,15 +135,6 @@ export function KumikoScreen({
137
135
  {...(onRowClick !== undefined && { onRowClick })}
138
136
  />
139
137
  );
140
- case "projectionList":
141
- return (
142
- <ProjectionListBody
143
- schema={schema}
144
- screen={screen}
145
- translate={translate}
146
- {...(onRowClick !== undefined && { onRowClick })}
147
- />
148
- );
149
138
  case "actionForm":
150
139
  return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
151
140
  case "configEdit":
@@ -1006,131 +995,6 @@ function EntityListBody({
1006
995
  );
1007
996
  }
1008
997
 
1009
- // ---- projection-list ----
1010
-
1011
- // Wie entityList, aber die List-Query kommt DIREKT aus `screen.query` (statt aus
1012
- // der Entity abgeleitet) — dadurch cross-feature-fähig. v1 bewusst schlank:
1013
- // rendert die Query-Rows mit den (explizit gelabelten) Columns + navigate-
1014
- // RowActions/Row-Klick. Kein Server-Sort/-Pagination/-Facetten (eine Projection-
1015
- // Query hat dafür keinen garantierten Contract) — die kommen bei Bedarf später.
1016
- function ProjectionListBody({
1017
- schema,
1018
- screen,
1019
- translate,
1020
- onRowClick,
1021
- }: {
1022
- readonly schema: FeatureSchema;
1023
- readonly screen: ProjectionListScreenDefinition;
1024
- readonly translate?: Translate;
1025
- readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
1026
- }): ReactNode {
1027
- const { Banner } = usePrimitives();
1028
- const t = useTranslation();
1029
- const nav = useNav();
1030
- const effectiveTranslate = translate ?? t;
1031
- const entity = useMemo(() => synthesizeProjectionEntity(screen.columns), [screen.columns]);
1032
- const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
1033
- const rowsQuery = useQuery<PagedRows>(screen.query, {}, { live: true });
1034
-
1035
- const runNavigate = useCallback(
1036
- (action: RowActionNavigate, row: ListRowViewModel) => {
1037
- const entityId =
1038
- action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1039
- nav.navigate({
1040
- screenId: action.screen,
1041
- ...(entityId !== undefined && entityId !== "" && { entityId }),
1042
- });
1043
- const params =
1044
- action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
1045
- if (params !== undefined) {
1046
- const stringified: Record<string, string | null> = {};
1047
- for (const [k, v] of Object.entries(params)) {
1048
- stringified[k] = v === null || v === undefined ? null : String(v);
1049
- }
1050
- nav.setSearchParams(stringified);
1051
- }
1052
- },
1053
- [nav],
1054
- );
1055
-
1056
- const rowActions = useMemo((): readonly DataTableRowAction[] | undefined => {
1057
- if (screen.rowActions === undefined) return undefined;
1058
- const out: DataTableRowAction[] = [];
1059
- for (const action of screen.rowActions) {
1060
- // v1: nur navigate (writeHandler-RowActions bräuchten den Dispatcher-Pfad).
1061
- if (action.kind !== "navigate") continue;
1062
- const navigateAction = action;
1063
- const visible = action.visible;
1064
- out.push({
1065
- id: action.id,
1066
- label: effectiveTranslate(action.label),
1067
- ...(action.style !== undefined && { style: action.style }),
1068
- onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
1069
- ...(visible !== undefined && {
1070
- isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
1071
- }),
1072
- });
1073
- }
1074
- return out.length > 0 ? out : undefined;
1075
- }, [screen.rowActions, effectiveTranslate, runNavigate]);
1076
-
1077
- const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
1078
- if (screen.toolbarActions === undefined) return undefined;
1079
- const out: ToolbarActionButton[] = [];
1080
- for (const action of screen.toolbarActions) {
1081
- // v1: nur navigate (writeHandler-Toolbar bräuchte den Dispatcher-Pfad).
1082
- if (action.kind !== "navigate") continue;
1083
- const target = action.screen;
1084
- out.push({
1085
- id: action.id,
1086
- label: effectiveTranslate(action.label),
1087
- ...(action.style !== undefined && { style: action.style }),
1088
- onTrigger: () => nav.navigate({ screenId: target }),
1089
- });
1090
- }
1091
- return out.length > 0 ? out : undefined;
1092
- }, [screen.toolbarActions, effectiveTranslate, nav]);
1093
-
1094
- if (rowsQuery.loading && rowsQuery.data === null) {
1095
- return (
1096
- <Banner padded variant="loading" testId="kumiko-screen-loading">
1097
- Loading…
1098
- </Banner>
1099
- );
1100
- }
1101
- if (rowsQuery.error) {
1102
- return (
1103
- <Banner padded variant="error" testId="kumiko-screen-error">
1104
- {rowsQuery.error.i18nKey}
1105
- </Banner>
1106
- );
1107
- }
1108
-
1109
- const rowClickAction = screen.rowActions?.find(
1110
- (a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
1111
- );
1112
- const wrappedOnRowClick = rowClickAction
1113
- ? (row: ListRowViewModel) => runNavigate(rowClickAction, row)
1114
- : onRowClick !== undefined
1115
- ? (row: ListRowViewModel) => onRowClick(row, listScreen.entity)
1116
- : undefined;
1117
-
1118
- return (
1119
- <RenderList
1120
- screen={listScreen}
1121
- entity={entity}
1122
- rows={rowsQuery.data?.rows ?? []}
1123
- featureName={schema.featureName}
1124
- searchable={screen.searchable ?? false}
1125
- sort={screen.defaultSort ?? null}
1126
- {...(rowActions !== undefined && { rowActions })}
1127
- {...(toolbarActions !== undefined && { toolbarActions })}
1128
- {...(translate !== undefined && { translate })}
1129
- {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
1130
- />
1131
- );
1132
- }
1133
-
1134
998
  // ---- actionForm (Tier 2.7d) ----
1135
999
 
1136
1000
  // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
@@ -1,14 +1,11 @@
1
1
  import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
2
2
 
3
- // Hides the Save button when no field is editable and no extension section
4
- // exists (extension carries its own dirty/save; a purely read-only form has nothing to submit).
5
- // Explicit-positive per kind (653/1), not `s.kind !== "fields"` a future
6
- // third EditSectionViewModel member would otherwise default to "editable"
7
- // without anyone deciding that on purpose.
3
+ // A read-only inspector form every field readOnly, no editable section has
4
+ // nothing to submit, so the renderer drops the Save button rather than show a
5
+ // dead disabled one. An extension section carries its own dirty/save, so it
6
+ // counts as editable.
8
7
  export function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean {
9
- return sections.some(
10
- (s) => s.kind === "extension" || (s.kind === "fields" && s.fields.some((f) => !f.readOnly)),
11
- );
8
+ return sections.some((s) => s.kind !== "fields" || s.fields.some((f) => !f.readOnly));
12
9
  }
13
10
 
14
11
  // Single source of truth for the extension-section entity-id. The section mount
@@ -247,9 +247,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
247
247
  [screen, entity, snapshot.values, translate, featureName],
248
248
  );
249
249
 
250
- // isFormEditable, not "hasEditableField" (653/2) — true for an extension
251
- // section with no fields of its own too (it carries its own dirty/save).
252
- const isFormEditable = hasEditableSection(vm.sections);
250
+ const hasEditableField = hasEditableSection(vm.sections);
253
251
 
254
252
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
255
253
  // false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
@@ -357,7 +355,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
357
355
  {translate("kumiko.actions.cancel")}
358
356
  </Button>
359
357
  )}
360
- {isFormEditable && (
358
+ {hasEditableField && (
361
359
  <Button
362
360
  type="submit"
363
361
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting}
@@ -1,62 +0,0 @@
1
- // Shim für ProjectionList-Renderer (analog action-form-shim.ts / config-edit-shim.ts).
2
- //
3
- // RenderList + computeListViewModel verlangen ein `entity: EntityDefinition` +
4
- // ein `screen: EntityListScreenDefinition`-Pair, nutzen davon aber nur
5
- // `entity.fields` (Spalten-Typ/Label) und die List-Felder des Screens
6
- // (columns/rowActions/…). Ein projectionList-Screen ist query-getrieben und hat
7
- // KEINE Entity — die zwei Helper hier shapen den Input ad-hoc um, damit die
8
- // bestehende List-Maschinerie reused werden kann. Die Query selbst wird NICHT
9
- // hierüber aufgelöst (der Body nimmt `screen.query` direkt).
10
- //
11
- // Selbe Schulden-Reservation wie bei den anderen Shims: greift die List-
12
- // Maschinerie künftig auf weitere EntityDefinition-Felder (transitions, idType,
13
- // derivedFields) oder cross-referenziert ein Boot-Validator die schema.entities-
14
- // Map, brechen die Type-Lies hier silent — dann ist Zeit für eine echte
15
- // query-native ListView-Komponente.
16
-
17
- import type {
18
- EntityDefinition,
19
- EntityListScreenDefinition,
20
- ListColumnSpec,
21
- ProjectionListScreenDefinition,
22
- } from "@cosmicdrift/kumiko-framework/ui-types";
23
- import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
24
-
25
- const PROJECTION_PSEUDO_ENTITY = "__projection__";
26
-
27
- /** Minimale EntityDefinition aus den Column-Feldern: jedes Feld ein Text-Feld,
28
- * nicht sortierbar (eine Projection-Query hat keinen garantierten Server-Sort).
29
- * computeListViewModel liest nur `fields[<col>].type` → Text reicht; die
30
- * Präsentation kommt aus dem Column-Renderer + explizitem Label. */
31
- export function synthesizeProjectionEntity(columns: readonly ListColumnSpec[]): EntityDefinition {
32
- const fields: Record<string, { type: "text"; sortable: false }> = {};
33
- for (const col of columns) {
34
- fields[normalizeListColumn(col).field] = { type: "text", sortable: false };
35
- }
36
- return { fields } as unknown as EntityDefinition;
37
- }
38
-
39
- /** Wandelt ein ProjectionListScreenDefinition in die EntityListScreen-Shape die
40
- * RenderList erwartet. `type` = "entityList" + Pseudo-Entity halten den Type-
41
- * Constraint; RenderList branched nicht auf `type` und liest `entity` nur für
42
- * Fehlermeldungen. Die List-Felder werden 1:1 durchgereicht. */
43
- export function synthesizeProjectionScreen(
44
- screen: ProjectionListScreenDefinition,
45
- ): EntityListScreenDefinition {
46
- return {
47
- id: screen.id,
48
- type: "entityList",
49
- entity: PROJECTION_PSEUDO_ENTITY,
50
- columns: screen.columns,
51
- ...(screen.rowRenderer !== undefined && { rowRenderer: screen.rowRenderer }),
52
- ...(screen.cardRenderer !== undefined && { cardRenderer: screen.cardRenderer }),
53
- ...(screen.rowActions !== undefined && { rowActions: screen.rowActions }),
54
- ...(screen.toolbarActions !== undefined && { toolbarActions: screen.toolbarActions }),
55
- ...(screen.pagination !== undefined && { pagination: screen.pagination }),
56
- ...(screen.pageSize !== undefined && { pageSize: screen.pageSize }),
57
- ...(screen.defaultSort !== undefined && { defaultSort: screen.defaultSort }),
58
- ...(screen.searchable !== undefined && { searchable: screen.searchable }),
59
- ...(screen.slots !== undefined && { slots: screen.slots }),
60
- ...(screen.access !== undefined && { access: screen.access }),
61
- };
62
- }