@cosmicdrift/kumiko-renderer 0.306.0 → 0.308.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.306.0",
3
+ "version": "0.308.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,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.306.0",
19
- "@cosmicdrift/kumiko-headless": "0.306.0",
20
- "@cosmicdrift/kumiko-types": "0.306.0",
18
+ "@cosmicdrift/kumiko-framework": "0.308.0",
19
+ "@cosmicdrift/kumiko-headless": "0.308.0",
20
+ "@cosmicdrift/kumiko-types": "0.308.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.306.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.308.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -5,7 +5,7 @@ import type {
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
6
  import { evalFieldCondition, NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-framework/ui-types";
7
7
  import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
8
- import { z } from "zod";
8
+ import * as z from "zod";
9
9
  import { layoutEditFields } from "./layout-fields";
10
10
 
11
11
  // `required` means "has a value", not "is truthy" — `false` and `0` count
@@ -32,6 +32,7 @@ import type {
32
32
  Translate,
33
33
  } from "@cosmicdrift/kumiko-headless";
34
34
  import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
35
+ import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
35
36
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
36
37
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
37
38
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
@@ -99,10 +100,10 @@ import {
99
100
  buildDefaultEditRowAction,
100
101
  buildProjectionRowActions,
101
102
  buildProjectionToolbarActions,
103
+ buildRecordActions,
102
104
  evalRowExtractor,
103
105
  isWriteHandlerRowAction,
104
106
  refetchAfterWrite,
105
- resolveActionIcon,
106
107
  runProjectionRowNavigate,
107
108
  stringifyNavParams,
108
109
  } from "./row-actions";
@@ -1218,127 +1219,25 @@ function EntityEditUpdateForm({
1218
1219
  // pattern as ProjectionDetailBody.headerActions above (the edited record
1219
1220
  // stands in for the "row"), minus the cross-feature defaultEditAction
1220
1221
  // lookup that doesn't apply here (this screen already IS the edit form).
1221
- const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
1222
- const out: RenderEditAction[] = [];
1223
- for (const action of screen.actions ?? []) {
1224
- if (action.visible !== undefined && !evalFieldCondition(action.visible, record)) {
1225
- continue;
1226
- }
1227
- if (action.kind === "navigate") {
1228
- const runParams = (): void => {
1229
- const params =
1230
- action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
1231
- if (params !== undefined) {
1232
- nav.setSearchParams(stringifyNavParams(params));
1233
- }
1234
- };
1235
- const actionIcon = resolveActionIcon(action.id, action.icon);
1236
- if (action.entity !== undefined) {
1237
- const targetEntity = action.entity;
1238
- const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
1239
- out.push({
1240
- id: action.id,
1241
- label: effectiveTranslate(action.label),
1242
- ...(action.style !== undefined && { style: action.style }),
1243
- confirmRequired: false,
1244
- ...(actionIcon !== undefined && { icon: actionIcon }),
1245
- onPress: () => {
1246
- if (id === "") return;
1247
- nav.navigate({ entity: targetEntity, id });
1248
- runParams();
1249
- },
1250
- });
1251
- } else if (action.screen !== undefined) {
1252
- // Default entityId for a screen-target: the currently edited
1253
- // record's own id — an entityEdit header action has no other
1254
- // "row" to derive one from (unlike ProjectionDetailBody, which
1255
- // needs the same-entity detailFor lookup instead).
1256
- const explicit =
1257
- action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
1258
- const navEntityId = explicit ?? entityId;
1259
- const targetScreen = action.screen;
1260
- out.push({
1261
- id: action.id,
1262
- label: effectiveTranslate(action.label),
1263
- ...(action.style !== undefined && { style: action.style }),
1264
- confirmRequired: false,
1265
- ...(actionIcon !== undefined && { icon: actionIcon }),
1266
- onPress: () => {
1267
- const target: ScreenTarget = {
1268
- screenId: targetScreen,
1269
- ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
1270
- };
1271
- const params =
1272
- action.params !== undefined
1273
- ? stringifyNavParams(evalRowExtractor(action.params, record))
1274
- : undefined;
1275
- navigateWithReturnTo(nav, target, host, params);
1276
- },
1277
- });
1278
- }
1279
- continue;
1280
- }
1281
- if (action.kind === "drawer") {
1282
- const drawerActionEntry = action;
1283
- const actionIcon = resolveActionIcon(action.id, action.icon);
1284
- out.push({
1285
- id: action.id,
1286
- label: effectiveTranslate(action.label),
1287
- ...(action.style !== undefined && { style: action.style }),
1288
- confirmRequired: false,
1289
- ...(actionIcon !== undefined && { icon: actionIcon }),
1290
- onPress: () => {
1291
- const initialValues =
1292
- drawerActionEntry.params !== undefined
1293
- ? evalRowExtractor(drawerActionEntry.params, record)
1294
- : undefined;
1295
- openDrawer(drawerActionEntry, initialValues);
1296
- },
1297
- });
1298
- continue;
1299
- }
1300
- // writeHandler — same dispatch/reload/failure-surfacing pattern as
1301
- // ProjectionDetailBody's headerActions above.
1302
- const writeAction = action;
1303
- out.push({
1304
- id: writeAction.id,
1305
- label: effectiveTranslate(writeAction.label),
1306
- ...(writeAction.style !== undefined && { style: writeAction.style }),
1307
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
1308
- ...(writeAction.confirm !== undefined && {
1309
- confirm: effectiveTranslate(writeAction.confirm),
1310
- }),
1311
- ...(writeAction.confirmLabel !== undefined && {
1312
- confirmLabel: effectiveTranslate(writeAction.confirmLabel),
1313
- }),
1314
- onPress: async () => {
1315
- const payload =
1316
- writeAction.payload !== undefined
1317
- ? evalRowExtractor(writeAction.payload, record)
1318
- : { id: entityId };
1319
- const result = await dispatcher.write(writeAction.handler, payload);
1320
- if (!result.isSuccess) {
1321
- throw new WriteFailedError(
1322
- result.error,
1323
- dispatcherErrorText(result.error, effectiveTranslate),
1324
- );
1325
- }
1326
- await onReload();
1327
- },
1328
- });
1329
- }
1330
- return out.length > 0 ? out : undefined;
1331
- }, [
1332
- screen.actions,
1333
- effectiveTranslate,
1334
- nav,
1335
- dispatcher,
1336
- record,
1337
- entityId,
1338
- onReload,
1339
- openDrawer,
1340
- host,
1341
- ]);
1222
+ const buildSectionActions = useCallback(
1223
+ (actions: readonly RowAction[]): readonly RenderEditAction[] | undefined =>
1224
+ buildRecordActions({
1225
+ actions,
1226
+ record,
1227
+ translate: effectiveTranslate,
1228
+ nav,
1229
+ host,
1230
+ dispatcher,
1231
+ openDrawer,
1232
+ onWriteSuccess: onReload,
1233
+ defaultScreenTargetEntityId: entityId,
1234
+ }),
1235
+ [record, effectiveTranslate, nav, host, dispatcher, openDrawer, onReload, entityId],
1236
+ );
1237
+ const headerActions = useMemo(
1238
+ (): readonly RenderEditAction[] | undefined => buildSectionActions(screen.actions ?? []),
1239
+ [buildSectionActions, screen.actions],
1240
+ );
1342
1241
  const handleSubmitted = useCallback(
1343
1242
  (result: SubmitResult<unknown>) => {
1344
1243
  if (!result.isSuccess) return;
@@ -1429,6 +1328,7 @@ function EntityEditUpdateForm({
1429
1328
  {...(translate !== undefined && { translate })}
1430
1329
  {...(onCopyLink !== undefined && { onCopyLink })}
1431
1330
  {...(headerActions !== undefined && { actions: headerActions })}
1331
+ buildSectionActions={buildSectionActions}
1432
1332
  />
1433
1333
  <DrawerHost
1434
1334
  schema={schema}
@@ -2954,144 +2854,69 @@ function ProjectionDetailBody({
2954
2854
  };
2955
2855
  }, [editScreen, effectiveTranslate, nav, effectiveEntityId, host]);
2956
2856
 
2857
+ // a navigate-screen action targeting an entityEdit screen for the
2858
+ // SAME entity this projectionDetail shows (screen.detailFor) defaults its
2859
+ // entityId to the shown record's own id — without this fallback the target
2860
+ // opens an empty create-form instead of the shown record, silently.
2861
+ // Searched cross-feature, consistent with editScreen above.
2862
+ const sameEntityScreenId = useCallback(
2863
+ (targetScreen: string): string | undefined => {
2864
+ const record = detailQuery.data ?? {};
2865
+ const targetIsEntityEditSameEntity =
2866
+ screen.detailFor !== undefined &&
2867
+ appFeatures.some((feature) =>
2868
+ feature.screens.some(
2869
+ (s) =>
2870
+ s.type === "entityEdit" &&
2871
+ s.entity === screen.detailFor &&
2872
+ lastSegment(s.id) === targetScreen,
2873
+ ),
2874
+ );
2875
+ return targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
2876
+ },
2877
+ [detailQuery.data, screen.detailFor, appFeatures],
2878
+ );
2879
+ const buildSectionActions = useCallback(
2880
+ (actions: readonly RowAction[]): readonly RenderEditAction[] | undefined =>
2881
+ buildRecordActions({
2882
+ actions,
2883
+ record: detailQuery.data ?? {},
2884
+ translate: effectiveTranslate,
2885
+ nav,
2886
+ host,
2887
+ dispatcher,
2888
+ openDrawer,
2889
+ onWriteSuccess: detailQuery.refetch,
2890
+ sameEntityScreenId,
2891
+ }),
2892
+ [
2893
+ detailQuery.data,
2894
+ detailQuery.refetch,
2895
+ effectiveTranslate,
2896
+ nav,
2897
+ host,
2898
+ dispatcher,
2899
+ openDrawer,
2900
+ sameEntityScreenId,
2901
+ ],
2902
+ );
2957
2903
  const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
2958
- const record = detailQuery.data ?? {};
2959
2904
  const declaredHasEdit = screen.actions?.some((a) => a.id === "edit") === true;
2960
- const out: RenderEditAction[] = [];
2961
- if (defaultEditAction !== undefined && !declaredHasEdit) {
2962
- out.push(defaultEditAction);
2963
- }
2964
- for (const action of screen.actions ?? []) {
2965
- if (action.visible !== undefined && !evalFieldCondition(action.visible, record)) {
2966
- continue;
2967
- }
2968
- if (action.kind === "navigate") {
2969
- const runParams = (): void => {
2970
- const params =
2971
- action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
2972
- if (params !== undefined) {
2973
- nav.setSearchParams(stringifyNavParams(params));
2974
- }
2975
- };
2976
- if (action.entity !== undefined) {
2977
- // Entity-Targets (fw#2228) — Auflösung passiert in der NavApi-Impl
2978
- // (siehe EntityListBody.runNavigate), nicht hier. KEIN record["id"]-
2979
- // Fallback: record kommt aus einer beliebigen Detail-Query ohne
2980
- // garantiertes "id"-Feld, der Boot-Validator erzwingt deshalb einen
2981
- // expliziten entityId für projectionDetail-entity-Targets.
2982
- const targetEntity = action.entity;
2983
- const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
2984
- const actionIcon = resolveActionIcon(action.id, action.icon);
2985
- out.push({
2986
- id: action.id,
2987
- label: effectiveTranslate(action.label),
2988
- ...(action.style !== undefined && { style: action.style }),
2989
- confirmRequired: false,
2990
- ...(actionIcon !== undefined && { icon: actionIcon }),
2991
- onPress: () => {
2992
- if (id === "") return;
2993
- nav.navigate({ entity: targetEntity, id });
2994
- runParams();
2995
- },
2996
- });
2997
- } else if (action.screen !== undefined) {
2998
- // Default entityId for an entityEdit target of the SAME entity
2999
- // (screen.detailFor plays entity's role here, like screen.entity
3000
- // does for entityList's runNavigate) — without this fallback the
3001
- // target opens an empty create-form instead of the shown record,
3002
- // silently. Searched cross-feature, consistent with editScreen above.
3003
- const explicit =
3004
- action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
3005
- const targetIsEntityEditSameEntity =
3006
- screen.detailFor !== undefined &&
3007
- appFeatures.some((feature) =>
3008
- feature.screens.some(
3009
- (s) =>
3010
- s.type === "entityEdit" &&
3011
- s.entity === screen.detailFor &&
3012
- lastSegment(s.id) === action.screen,
3013
- ),
3014
- );
3015
- const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
3016
- const navEntityId = explicit ?? fallback;
3017
- const targetScreen = action.screen;
3018
- const actionIcon = resolveActionIcon(action.id, action.icon);
3019
- out.push({
3020
- id: action.id,
3021
- label: effectiveTranslate(action.label),
3022
- ...(action.style !== undefined && { style: action.style }),
3023
- confirmRequired: false,
3024
- ...(actionIcon !== undefined && { icon: actionIcon }),
3025
- onPress: () => {
3026
- const target: ScreenTarget = {
3027
- screenId: targetScreen,
3028
- ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
3029
- };
3030
- const params =
3031
- action.params !== undefined
3032
- ? stringifyNavParams(evalRowExtractor(action.params, record))
3033
- : undefined;
3034
- navigateWithReturnTo(nav, target, host, params);
3035
- },
3036
- });
3037
- }
3038
- continue;
3039
- }
3040
- if (action.kind === "drawer") {
3041
- const drawerActionEntry = action;
3042
- const actionIcon = resolveActionIcon(action.id, action.icon);
3043
- out.push({
3044
- id: action.id,
3045
- label: effectiveTranslate(action.label),
3046
- ...(action.style !== undefined && { style: action.style }),
3047
- confirmRequired: false,
3048
- ...(actionIcon !== undefined && { icon: actionIcon }),
3049
- onPress: () => {
3050
- const initialValues =
3051
- drawerActionEntry.params !== undefined
3052
- ? evalRowExtractor(drawerActionEntry.params, record)
3053
- : undefined;
3054
- openDrawer(drawerActionEntry, initialValues);
3055
- },
3056
- });
3057
- continue;
3058
- }
3059
- // writeHandler — same dispatch/refetch/failure-surfacing pattern as
3060
- // ProjectionListBody's rowActions/toolbarActions above.
3061
- if (dispatcher === undefined) continue;
3062
- const writeAction = action;
3063
- out.push({
3064
- id: writeAction.id,
3065
- label: effectiveTranslate(writeAction.label),
3066
- ...(writeAction.style !== undefined && { style: writeAction.style }),
3067
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
3068
- ...(writeAction.confirm !== undefined && {
3069
- confirm: effectiveTranslate(writeAction.confirm),
3070
- }),
3071
- ...(writeAction.confirmLabel !== undefined && {
3072
- confirmLabel: effectiveTranslate(writeAction.confirmLabel),
3073
- }),
3074
- onPress: async () => {
3075
- const payload =
3076
- writeAction.payload !== undefined
3077
- ? evalRowExtractor(writeAction.payload, record)
3078
- : { id: record["id"] };
3079
- const result = await dispatcher.write(writeAction.handler, payload);
3080
- if (!result.isSuccess) {
3081
- throw new WriteFailedError(
3082
- result.error,
3083
- dispatcherErrorText(result.error, effectiveTranslate),
3084
- );
3085
- }
3086
- await detailQuery.refetch();
3087
- },
3088
- });
3089
- }
3090
- return out.length > 0 ? out : undefined;
2905
+ const resolved = buildRecordActions({
2906
+ actions: screen.actions ?? [],
2907
+ record: detailQuery.data ?? {},
2908
+ translate: effectiveTranslate,
2909
+ nav,
2910
+ host,
2911
+ dispatcher,
2912
+ openDrawer,
2913
+ onWriteSuccess: detailQuery.refetch,
2914
+ sameEntityScreenId,
2915
+ });
2916
+ if (defaultEditAction === undefined || declaredHasEdit) return resolved;
2917
+ return [defaultEditAction, ...(resolved ?? [])];
3091
2918
  }, [
3092
2919
  screen.actions,
3093
- screen.detailFor,
3094
- appFeatures,
3095
2920
  defaultEditAction,
3096
2921
  effectiveTranslate,
3097
2922
  nav,
@@ -3100,6 +2925,7 @@ function ProjectionDetailBody({
3100
2925
  detailQuery.data,
3101
2926
  detailQuery.refetch,
3102
2927
  openDrawer,
2928
+ sameEntityScreenId,
3103
2929
  ]);
3104
2930
 
3105
2931
  if (effectiveEntityId === undefined && screen.singleton !== true) {
@@ -3205,13 +3031,16 @@ function ProjectionDetailBody({
3205
3031
  </Text>
3206
3032
  )
3207
3033
  ) : undefined;
3208
- const headerContent = (
3034
+ // slots.header shares the header card with the actions (one card, actions
3035
+ // top right) instead of rendering as an unframed block above it.
3036
+ const renderHeaderContent = (headerSlot: ReactNode | undefined): ReactNode => (
3209
3037
  <>
3210
- {hasHeaderCard && (
3038
+ {(hasHeaderCard || headerSlot !== undefined) && (
3211
3039
  <Card
3212
3040
  slots={{
3213
3041
  ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
3214
3042
  ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
3043
+ ...(headerSlot !== undefined && { headerContent: headerSlot }),
3215
3044
  ...(hasHeaderActions && { headerActions: headerActionsContent }),
3216
3045
  }}
3217
3046
  >
@@ -3293,7 +3122,10 @@ function ProjectionDetailBody({
3293
3122
  onRelatedListDrawerAction={openDrawer}
3294
3123
  {...(translate !== undefined && { translate })}
3295
3124
  {...(hasTabs && { hideSectionTitles: true })}
3296
- {...((hasHeaderCard || hasTabs) && { headerRegion: headerContent })}
3125
+ {...((hasHeaderCard || hasTabs || screen.slots?.header !== undefined) && {
3126
+ headerRegion: renderHeaderContent,
3127
+ })}
3128
+ buildSectionActions={buildSectionActions}
3297
3129
  valueDisplay={screen.valueDisplay ?? "text"}
3298
3130
  />
3299
3131
  <DrawerHost