@cosmicdrift/kumiko-renderer 0.355.0 → 0.356.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.
@@ -8,8 +8,9 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
8
8
  import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
9
9
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
10
10
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
11
- import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
11
+ import { needsActionConfirm, RenderEditActionButton, } from "../components/render-edit-action-button.js";
12
12
  import { RenderList } from "../components/render-list.js";
13
+ import { useActionMenuItems } from "../components/use-action-menu-items.js";
13
14
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
14
15
  import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
15
16
  import { useUserRoles } from "../context/user-roles-context.js";
@@ -877,7 +878,8 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
877
878
  sameEntityScreenId,
878
879
  entityId,
879
880
  ]);
880
- const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
881
+ const headerActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement !== "menu")), [buildSectionActions, screen.actions]);
882
+ const headerMenuActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement === "menu")), [buildSectionActions, screen.actions]);
881
883
  const handleSubmitted = useCallback((result) => {
882
884
  if (!result.isSuccess)
883
885
  return;
@@ -926,7 +928,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
926
928
  entityId: entityId,
927
929
  // Stored customFields for the extension section, so editing shows the
928
930
  // persisted values instead of behaving write-only.
929
- extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
931
+ extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), ...(headerMenuActions !== undefined && { menuActions: headerMenuActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
930
932
  closeDrawer();
931
933
  void onReload();
932
934
  } })] }));
@@ -1894,47 +1896,10 @@ function primaryHeaderAction(actions) {
1894
1896
  // no button of its own to carry one.
1895
1897
  function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
1896
1898
  const t = useTranslation();
1897
- const [pendingAction, setPendingAction] = useState(null);
1898
- // Menu items have no button of their own to carry RenderEditActionButton's
1899
- // busy state; without it the menu can be reopened mid-write and fire the
1900
- // same writeHandler twice.
1901
- const [busyActionId, setBusyActionId] = useState(null);
1902
- const trigger = async (action) => {
1903
- onError(null);
1904
- setBusyActionId(action.id);
1905
- try {
1906
- await action.onPress();
1907
- }
1908
- catch (e) {
1909
- onError(e instanceof Error ? e.message : String(e));
1910
- }
1911
- finally {
1912
- setBusyActionId(null);
1913
- }
1914
- };
1915
- const toMenuItem = (action) => ({
1916
- id: action.id,
1917
- label: action.label,
1918
- ...(action.icon !== undefined && { icon: action.icon }),
1919
- variant: action.style === "danger" ? "danger" : "default",
1920
- ...(busyActionId !== null && { disabled: true }),
1921
- onSelect: () => {
1922
- if (needsActionConfirm(action)) {
1923
- setPendingAction(action);
1924
- }
1925
- else {
1926
- void trigger(action);
1927
- }
1928
- },
1899
+ const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
1900
+ Dialog,
1901
+ onError,
1929
1902
  });
1930
- const pendingActionDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
1931
- if (!open)
1932
- setPendingAction(null);
1933
- }, onConfirm: async () => {
1934
- const action = pendingAction;
1935
- setPendingAction(null);
1936
- await trigger(action);
1937
- }, Dialog: Dialog }));
1938
1903
  if (compact && PageHeader !== undefined) {
1939
1904
  const primary = primaryHeaderAction(actions);
1940
1905
  // A bare icon for a destructive or confirm-gated action (e.g. "x" for terminate)
@@ -64,6 +64,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
64
64
  * same split as `onCopyLink`) — RenderEdit only wires the button, its
65
65
  * busy state and its confirm dialog. */
66
66
  readonly actions?: readonly RenderEditAction[];
67
+ /** Record actions declared with `placement: "menu"`, resolved like `actions`.
68
+ * Rendered in the header "…" menu ahead of copy-link/delete; without a
69
+ * header menu (drawer, card form, shell without a header slot) they fall
70
+ * back to buttons next to `actions`. */
71
+ readonly menuActions?: readonly RenderEditAction[];
67
72
  /** Opens a relatedList section row's drawer-kind action (fw#2710).
68
73
  * RenderEdit has no `schema` to resolve the target actionForm itself —
69
74
  * the caller (ProjectionDetailBody, which does have schema) supplies the
@@ -3,6 +3,7 @@ import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION
3
3
  import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
4
4
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
5
  import { AllFieldsRequiredProvider } from "./render-field.js";
6
+ import { useActionMenuItems } from "./use-action-menu-items.js";
6
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
8
  import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
8
9
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
@@ -223,7 +224,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
223
224
  return translate(template, values);
224
225
  }
225
226
  export function RenderEdit(props) {
226
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
227
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
227
228
  const i18nScreenId = props.i18nScreenId ?? screen.id;
228
229
  const { customSubmit } = props;
229
230
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
@@ -300,6 +301,7 @@ export function RenderEdit(props) {
300
301
  setHasExtensionRegistrations(hasRegistrations);
301
302
  });
302
303
  const { Button, Banner, Card, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar, WizardStepGroup, Tabs, PageHeader, ActionOverflowMenu, } = usePrimitives();
304
+ const { toMenuItem: menuActionToItem, confirmDialog: menuActionConfirmDialog } = useActionMenuItems({ Dialog, onError: setActionError });
303
305
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
304
306
  const pageHeaderCompact = usePageHeaderCompact();
305
307
  const insideDrawer = useInsideDrawer();
@@ -1041,6 +1043,7 @@ export function RenderEdit(props) {
1041
1043
  // action.
1042
1044
  const hasSecondaryFormActions = (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
1043
1045
  (actions !== undefined && actions.length > 0) ||
1046
+ (!menuPageHeaderActions && menuActions !== undefined && menuActions.length > 0) ||
1044
1047
  (onCancel !== undefined && !cancelInHeaderMenu);
1045
1048
  // Teil-C collapse rule: driven by the custom `actions` group only — a
1046
1049
  // resolved true also folds the Copy-Link button (same visual group in the
@@ -1048,6 +1051,7 @@ export function RenderEdit(props) {
1048
1051
  const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
1049
1052
  const headerMenuItems = menuPageHeaderActions
1050
1053
  ? [
1054
+ ...(menuActions ?? []).map(menuActionToItem),
1051
1055
  ...(onCopyLink !== undefined
1052
1056
  ? [
1053
1057
  {
@@ -1091,7 +1095,8 @@ export function RenderEdit(props) {
1091
1095
  setLinkCopied(true);
1092
1096
  }, children: iconOnlyMidActions
1093
1097
  ? null
1094
- : translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink") })), actions?.map((action) => (_jsx(RenderEditActionButton, { action: action, iconOnly: iconOnlyMidActions, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), onCancel !== undefined && !cancelInHeaderMenu && (_jsx(Button, { type: "button", variant: "secondary", onClick: () => onCancel(), testId: "render-edit-cancel", children: translate("kumiko.actions.cancel") }))] }));
1098
+ : translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink") })), actions?.map((action) => (_jsx(RenderEditActionButton, { action: action, iconOnly: iconOnlyMidActions, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), !menuPageHeaderActions &&
1099
+ menuActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), onCancel !== undefined && !cancelInHeaderMenu && (_jsx(Button, { type: "button", variant: "secondary", onClick: () => onCancel(), testId: "render-edit-cancel", children: translate("kumiko.actions.cancel") }))] }));
1095
1100
  const headerSlot = screen.slots?.header;
1096
1101
  const headerSlotMount = headerSlot !== undefined ? (_jsx(EditSlotMount, { slot: headerSlot, slotName: "header", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values })) : undefined;
1097
1102
  // A function headerRegion places the slot itself (projectionDetail puts it
@@ -1227,7 +1232,7 @@ export function RenderEdit(props) {
1227
1232
  })
1228
1233
  : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
1229
1234
  })();
1230
- return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
1235
+ return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [menuActionConfirmDialog, titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
1231
1236
  pageHeaderCompact && { overflowItems: headerMenuItems }), ...(headerMenuItems.length > 0 &&
1232
1237
  !pageHeaderCompact &&
1233
1238
  ActionOverflowMenu !== undefined && {
@@ -0,0 +1,10 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ActionMenuItemSpec, usePrimitives } from "../primitives.js";
3
+ import type { RenderEditAction } from "./render-edit-types.js";
4
+ export declare function useActionMenuItems({ Dialog, onError, }: {
5
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
6
+ readonly onError: (text: string | null) => void;
7
+ }): {
8
+ readonly toMenuItem: (action: RenderEditAction) => ActionMenuItemSpec;
9
+ readonly confirmDialog: ReactNode;
10
+ };
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { needsActionConfirm, RenderEditActionConfirmDialog } from "./render-edit-action-button.js";
4
+ // A menu item has no button of its own to carry RenderEditActionButton's busy
5
+ // and confirm state, so this hook owns both for every item it creates.
6
+ export function useActionMenuItems({ Dialog, onError, }) {
7
+ const [pendingAction, setPendingAction] = useState(null);
8
+ // Without it the menu can be reopened mid-write and fire the same writeHandler twice.
9
+ const [busyActionId, setBusyActionId] = useState(null);
10
+ const trigger = async (action) => {
11
+ onError(null);
12
+ setBusyActionId(action.id);
13
+ try {
14
+ await action.onPress();
15
+ }
16
+ catch (e) {
17
+ onError(e instanceof Error ? e.message : String(e));
18
+ }
19
+ finally {
20
+ setBusyActionId(null);
21
+ }
22
+ };
23
+ const toMenuItem = (action) => ({
24
+ id: action.id,
25
+ label: action.label,
26
+ ...(action.icon !== undefined && { icon: action.icon }),
27
+ variant: action.style === "danger" ? "danger" : "default",
28
+ ...(busyActionId !== null && { disabled: true }),
29
+ onSelect: () => {
30
+ if (needsActionConfirm(action)) {
31
+ setPendingAction(action);
32
+ }
33
+ else {
34
+ void trigger(action);
35
+ }
36
+ },
37
+ });
38
+ const confirmDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
39
+ if (!open)
40
+ setPendingAction(null);
41
+ }, onConfirm: async () => {
42
+ const action = pendingAction;
43
+ setPendingAction(null);
44
+ await trigger(action);
45
+ }, Dialog: Dialog }));
46
+ return { toMenuItem, confirmDialog };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.355.0",
3
+ "version": "0.356.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.355.0",
19
- "@cosmicdrift/kumiko-headless": "0.355.0",
20
- "@cosmicdrift/kumiko-types": "0.355.0",
18
+ "@cosmicdrift/kumiko-framework": "0.356.0",
19
+ "@cosmicdrift/kumiko-headless": "0.356.0",
20
+ "@cosmicdrift/kumiko-types": "0.356.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.355.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.356.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.356.0",
4
+ "type": "improvement",
5
+ "title": "entityEdit actions can go into the header \"...\" menu via placement: \"menu\""
6
+ },
2
7
  {
3
8
  "version": "0.354.0",
4
9
  "type": "fix",