@cosmicdrift/kumiko-renderer 0.373.0 → 0.374.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.
@@ -9,9 +9,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
9
9
  import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
10
10
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
11
11
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
12
- import { RenderEditActionButton } from "../components/render-edit-action-button.js";
13
12
  import { RenderList } from "../components/render-list.js";
14
- import { useActionMenuItems } from "../components/use-action-menu-items.js";
15
13
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
16
14
  import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
17
15
  import { useUserRoles } from "../context/user-roles-context.js";
@@ -26,7 +24,9 @@ import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlo
26
24
  import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../primitives.js";
27
25
  import { screenFillsHeight } from "../screen-fills-height.js";
28
26
  import { actionFormSubmittableFields, synthesizeActionFormEntity, synthesizeActionFormScreen, } from "./action-form-shim.js";
27
+ import { ActionFormLoadStatus, ActionFormSuccessBanner, isSecretFormField, resolveActionFormRecordId, useActionFormLoad, } from "./action-form-support.js";
29
28
  import { useAppFeatures } from "./app-features-context.js";
29
+ import { useClientActionRuntime } from "./client-actions.js";
30
30
  import { buildConfigEditSchema, synthesizeConfigEditEntity, synthesizeConfigEditScreen, } from "./config-edit-shim.js";
31
31
  import { useCustomScreenComponent } from "./custom-screens.js";
32
32
  import { useDashboardBody } from "./dashboard-body.js";
@@ -34,7 +34,7 @@ import { applyEditPrefill } from "./edit-prefill.js";
34
34
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
35
35
  import { FacetCountBridges } from "./facet-count-bridge.js";
36
36
  import { buildFormSchema } from "./form-schema.js";
37
- import { compactHeaderPrimary, orderPlainHeaderActions, primaryHeaderAction, } from "./header-actions.js";
37
+ import { HeaderActionsBar } from "./header-actions-bar.js";
38
38
  import { hiddenRequiredFieldsFilled, layoutEditFields, layoutFieldNames } from "./layout-fields.js";
39
39
  import { isListNarrowed, useListEmptyContent } from "./list-empty-content.js";
40
40
  import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
@@ -46,7 +46,7 @@ import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-scr
46
46
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
47
47
  import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
48
48
  import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
49
- import { buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
49
+ import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, findRowClickAction, navigateActionSearchParams, refetchAfterWrite, runDrawerRowAction, runProjectionRowNavigate, } from "./row-actions.js";
50
50
  import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access.js";
51
51
  import { SecretMintBody } from "./secret-mint-body.js";
52
52
  import { SecretsEditBody } from "./secrets-edit-body.js";
@@ -174,7 +174,7 @@ function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onC
174
174
  case "dashboard":
175
175
  return (_jsx(DashboardScreenBody, { featureName: schema.featureName, screen: screen, translate: translate }));
176
176
  case "actionForm":
177
- return _jsx(ActionFormBody, { schema: schema, screen: screen, translate: translate });
177
+ return (_jsx(ActionFormBody, { schema: schema, screen: screen, translate: translate, ...(entityId !== undefined && { entityId }) }));
178
178
  case "secretMint":
179
179
  return _jsx(SecretMintBody, { schema: schema, screen: screen, translate: translate });
180
180
  case "configEdit":
@@ -891,6 +891,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
891
891
  const nav = useNav();
892
892
  const appFeatures = useAppFeatures();
893
893
  const dispatcher = useDispatcher();
894
+ const clientActions = useClientActionRuntime();
894
895
  const t = useTranslation();
895
896
  const effectiveTranslate = translate ?? t;
896
897
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
@@ -954,6 +955,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
954
955
  onWriteSuccess: onReload,
955
956
  onRecordLeft: handleRecordLeft,
956
957
  sameEntityScreenId,
958
+ clientActions,
957
959
  defaultWritePayloadId: entityId,
958
960
  ...(formDirty && {
959
961
  discardChanges: {
@@ -972,6 +974,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
972
974
  onReload,
973
975
  handleRecordLeft,
974
976
  sameEntityScreenId,
977
+ clientActions,
975
978
  entityId,
976
979
  ]);
977
980
  const headerActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement !== "menu")), [buildSectionActions, screen.actions]);
@@ -1428,6 +1431,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1428
1431
  // (sonst rendert die Action-Spalte still nichts und der "warum sind
1429
1432
  // meine Buttons weg?"-Debug ist teuer).
1430
1433
  const dispatcher = useOptionalDispatcher();
1434
+ const clientActions = useClientActionRuntime();
1431
1435
  const hasRowActions = screen.rowActions !== undefined && screen.rowActions.length > 0;
1432
1436
  useEffect(() => {
1433
1437
  if (hasRowActions && dispatcher === undefined) {
@@ -1511,10 +1515,11 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1511
1515
  if (action.kind === "drawer") {
1512
1516
  return buildDrawerRowAction(action, effectiveTranslate, openDrawer);
1513
1517
  }
1518
+ if (action.kind === "clientAction") {
1519
+ return buildClientActionRowAction(action, effectiveTranslate, clientActions, refreshRowsAfterWrite);
1520
+ }
1514
1521
  if (dispatcher === undefined)
1515
1522
  return null;
1516
- if (!isWriteHandlerRowAction(action))
1517
- return null;
1518
1523
  const writeAction = action;
1519
1524
  const writeActionVisible = writeAction.visible;
1520
1525
  return {
@@ -1551,6 +1556,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1551
1556
  defaultEditScreen,
1552
1557
  effectiveTranslate,
1553
1558
  dispatcher,
1559
+ clientActions,
1554
1560
  runNavigate,
1555
1561
  refreshRowsAfterWrite,
1556
1562
  openDrawer,
@@ -1625,9 +1631,11 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1625
1631
  // gewinnt (deklarierte Author-Absicht "Klick auf die Zeile tut X"). Ohne Flag
1626
1632
  // bleibt der Pfad unverändert — create-app's entityEdit-Default bzw. ein vom
1627
1633
  // App-Author gesetztes onRowClick.
1628
- const rowClickAction = screen.rowActions?.find((a) => a.kind === "navigate" && a.rowClick === true);
1634
+ const rowClickAction = findRowClickAction(screen.rowActions);
1629
1635
  const wrappedOnRowClick = rowClickAction
1630
- ? (row) => runNavigate(rowClickAction, row)
1636
+ ? (row) => rowClickAction.kind === "drawer"
1637
+ ? runDrawerRowAction(rowClickAction, openDrawer, row)
1638
+ : runNavigate(rowClickAction, row)
1631
1639
  : onRowClick !== undefined
1632
1640
  ? (row) => onRowClick(row, screen.entity)
1633
1641
  : undefined;
@@ -1696,6 +1704,7 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1696
1704
  const nav = useNav();
1697
1705
  const host = useReturnHost();
1698
1706
  const dispatcher = useOptionalDispatcher();
1707
+ const clientActions = useClientActionRuntime();
1699
1708
  const effectiveTranslate = translate ?? t;
1700
1709
  const userRoles = useUserRoles();
1701
1710
  const appFeatures = useAppFeatures();
@@ -1808,10 +1817,12 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1808
1817
  openDrawer,
1809
1818
  defaultEditRowAction,
1810
1819
  host,
1820
+ clientActions,
1811
1821
  }), [
1812
1822
  screen.rowActions,
1813
1823
  effectiveTranslate,
1814
1824
  dispatcher,
1825
+ clientActions,
1815
1826
  nav,
1816
1827
  rowsQuery.refetch,
1817
1828
  openDrawer,
@@ -1856,9 +1867,11 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1856
1867
  if (rawRowsData !== null && !Array.isArray(rawRowsData.rows)) {
1857
1868
  return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-projection-list-bad-shape", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " query", " ", _jsx(Text, { variant: "code", children: screen.query }), " did not return a PagedRows envelope; a projectionList query must return ", _jsx(Text, { variant: "code", children: "{ rows, nextCursor }" }), "."] }));
1858
1869
  }
1859
- const rowClickAction = screen.rowActions?.find((a) => a.kind === "navigate" && a.rowClick === true);
1870
+ const rowClickAction = findRowClickAction(screen.rowActions);
1860
1871
  const wrappedOnRowClick = rowClickAction
1861
- ? (row) => runNavigate(rowClickAction, row)
1872
+ ? (row) => rowClickAction.kind === "drawer"
1873
+ ? runDrawerRowAction(rowClickAction, openDrawer, row)
1874
+ : runNavigate(rowClickAction, row)
1862
1875
  : onRowClick !== undefined
1863
1876
  ? (row) => onRowClick(row, listScreen.entity)
1864
1877
  : undefined;
@@ -2010,31 +2023,6 @@ function resolveSubtitleHref(header, record) {
2010
2023
  // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
2011
2024
  // controller.submit() ohne submit-config throwen — der No-Op macht diesen
2012
2025
  // Pfad harmlos statt ihn dem Zufall zu überlassen.
2013
- // Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
2014
- // `ActionOverflowMenu` primitive collapse to the primary button plus a menu
2015
- // for the rest — same rule as RowActionsCell for table rows. Owns confirm
2016
- // state for menu items: RenderEditActionButton already owns a per-button
2017
- // confirm dialog for the always-visible primary action, but a menu item has
2018
- // no button of its own to carry one.
2019
- function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, PageHeader, }) {
2020
- const t = useTranslation();
2021
- const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
2022
- Dialog,
2023
- onError,
2024
- });
2025
- if (compact && PageHeader !== undefined) {
2026
- const compactPrimary = compactHeaderPrimary(actions, preferredPrimaryId);
2027
- const overflowActions = actions.filter((a) => a !== compactPrimary?.action);
2028
- return (_jsxs(_Fragment, { children: [compactPrimary !== undefined && (_jsx(RenderEditActionButton, { action: compactPrimary.action, Button: Button, Dialog: Dialog, iconOnly: compactPrimary.iconOnly, compactLabel: true, onError: onError }, compactPrimary.action.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
2029
- }
2030
- const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
2031
- if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
2032
- return (_jsx(_Fragment, { children: orderPlainHeaderActions(actions, preferredPrimaryId).map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
2033
- }
2034
- const primary = primaryHeaderAction(actions, preferredPrimaryId);
2035
- const rest = actions.filter((a) => a.id !== primary?.id);
2036
- return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: "kumiko-screen-projection-detail-actions-overflow", items: rest.map(toMenuItem) }), pendingActionDialog] }));
2037
- }
2038
2026
  function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2039
2027
  const { Banner, Button, Dialog, Text, Grid, GridCell, Card, Tabs, StatusBadge, Metric, MetricBand, PageHeader, Link, ActionOverflowMenu, } = usePrimitives();
2040
2028
  const t = useTranslation();
@@ -2101,6 +2089,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2101
2089
  const appFeatures = useAppFeatures();
2102
2090
  const userRoles = useUserRoles();
2103
2091
  const dispatcher = useOptionalDispatcher();
2092
+ const clientActions = useClientActionRuntime();
2104
2093
  const host = useReturnHost();
2105
2094
  const returnTargetParam = useReturnTarget(screen.id);
2106
2095
  // A singleton has no record route to come back to — same as entityEdit.
@@ -2194,6 +2183,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2194
2183
  onWriteSuccess: detailQuery.refetch,
2195
2184
  onRecordLeft: handleRecordLeft,
2196
2185
  sameEntityScreenId,
2186
+ clientActions,
2197
2187
  }), [
2198
2188
  detailQuery.data,
2199
2189
  detailQuery.refetch,
@@ -2201,6 +2191,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2201
2191
  nav,
2202
2192
  host,
2203
2193
  dispatcher,
2194
+ clientActions,
2204
2195
  openDrawer,
2205
2196
  handleRecordLeft,
2206
2197
  sameEntityScreenId,
@@ -2218,6 +2209,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2218
2209
  onWriteSuccess: detailQuery.refetch,
2219
2210
  onRecordLeft: handleRecordLeft,
2220
2211
  sameEntityScreenId,
2212
+ clientActions,
2221
2213
  });
2222
2214
  if (defaultEditAction === undefined || declaredHasEdit)
2223
2215
  return resolved;
@@ -2229,6 +2221,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2229
2221
  nav,
2230
2222
  host,
2231
2223
  dispatcher,
2224
+ clientActions,
2232
2225
  detailQuery.data,
2233
2226
  detailQuery.refetch,
2234
2227
  openDrawer,
@@ -2354,7 +2347,6 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2354
2347
  void reloadDetail();
2355
2348
  } })] }));
2356
2349
  }
2357
- // ---- actionForm (Tier 2.7d) ----
2358
2350
  function redirectScreenTarget(redirect) {
2359
2351
  return typeof redirect === "string" ? redirect : redirect.screen;
2360
2352
  }
@@ -2414,7 +2406,7 @@ function returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures) {
2414
2406
  // write executor's success payload doesn't flatly expose.
2415
2407
  function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fallbackRecord) {
2416
2408
  const redirectScreen = redirectScreenTarget(redirect);
2417
- const idField = typeof redirect === "string" ? "id" : redirect.idFrom;
2409
+ const idField = typeof redirect === "string" ? "id" : (redirect.idFrom ?? "id");
2418
2410
  const targetId = lastSegment(redirectScreen);
2419
2411
  const target = findRedirectTargetScreen(redirect, schema, appFeatures);
2420
2412
  const carriesId = target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
@@ -2438,6 +2430,30 @@ function navigateToRedirectTarget(nav, target) {
2438
2430
  else
2439
2431
  nav.navigate(navTarget, { searchParams: { tab: target.tab } });
2440
2432
  }
2433
+ // Links of the success box. Targets resolve like `redirect`; a link whose target
2434
+ // needs a record id (entityEdit, projectionDetail) but whose payload field holds
2435
+ // no string id is dropped instead of navigating to an id-less record screen.
2436
+ // A link without idFrom to a non-record screen always navigates id-less.
2437
+ function resolveSuccessLinks(links, data, schema, appFeatures, nav, translate) {
2438
+ const resolved = [];
2439
+ (links ?? []).forEach((link, index) => {
2440
+ const target = resolveRedirectTarget(link, data, schema, appFeatures);
2441
+ if (target.entityId === undefined && redirectTargetsRecord(link, schema, appFeatures))
2442
+ return;
2443
+ const navTarget = {
2444
+ screenId: target.screenId,
2445
+ ...(target.entityId !== undefined && { entityId: target.entityId }),
2446
+ };
2447
+ const options = target.tab === undefined ? undefined : { searchParams: { tab: target.tab } };
2448
+ resolved.push({
2449
+ key: String(index),
2450
+ label: translate(link.label),
2451
+ href: nav.hrefFor(navTarget, options),
2452
+ onPress: () => nav.navigate(navTarget, options),
2453
+ });
2454
+ });
2455
+ return resolved;
2456
+ }
2441
2457
  // Success navigation for a configured redirect — shared by entityEdit's
2442
2458
  // save and writeHandler record actions. The string form navigates without an
2443
2459
  // entityId; the object form resolves like actionForm's, with `record` as the
@@ -2471,7 +2487,7 @@ function overlayUpdatedProjection(record, resultData) {
2471
2487
  // Button) ist identisch zu entityEdit, nur der Submit-Pfad wechselt
2472
2488
  // vom CRUD-verb auf den Author-deklarierten handler-QN +
2473
2489
  // payloadMode="values" (alle Form-Werte schicken statt nur Changes).
2474
- function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess, onCancelOverride, onDirtyChange, submitLabelFallback, }) {
2490
+ function ActionFormBody({ schema, screen, translate, entityId, initialOverrides, onSuccess, onCancelOverride, onDirtyChange, submitLabelFallback, }) {
2475
2491
  const nav = useNav();
2476
2492
  const appFeatures = useAppFeatures();
2477
2493
  const insideDrawer = useInsideDrawer();
@@ -2492,27 +2508,55 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2492
2508
  // submitting a guessed currency would mis-book the amount — block instead.
2493
2509
  const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(screen.fields), [screen.fields]);
2494
2510
  const { overrides: moneyCurrencyOverrides, loading: currencyLoading, unresolved: currencyUnresolved, } = useMoneyCurrencyOverrides(screen.fields, tenantCurrencyFieldNames, undefined);
2495
- const initial = useMemo(() => mergeSearchParamsIntoInitial(submittableFields, {
2496
- searchParams: nav.searchParams,
2497
- urlPrefillFields: screen.urlPrefillFields,
2498
- renderableFields: layoutFieldNames(synthScreen),
2499
- ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
2500
- ...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
2501
- ...(handoffValues !== undefined && { handoffValues }),
2502
- }), [
2511
+ const loadableFieldNames = useMemo(() => Object.entries(submittableFields)
2512
+ .filter(([, field]) => !isSecretFormField(field))
2513
+ .map(([name]) => name), [submittableFields]);
2514
+ const loadState = useActionFormLoad(screen.load, screen.load !== undefined
2515
+ ? resolveActionFormRecordId({
2516
+ routeEntityId: entityId,
2517
+ drawerOverrides: initialOverrides,
2518
+ searchParams: nav.searchParams,
2519
+ })
2520
+ : undefined, loadableFieldNames);
2521
+ const loadedValues = loadState?.status === "ready" ? loadState.values : undefined;
2522
+ const initial = useMemo(() => {
2523
+ // With `load` the loaded record is the only source of values: a crafted link
2524
+ // or a row's params must not seed fields the record would otherwise own.
2525
+ const prefillSources = screen.load !== undefined
2526
+ ? { searchParams: {}, urlPrefillFields: undefined }
2527
+ : {
2528
+ searchParams: nav.searchParams,
2529
+ urlPrefillFields: screen.urlPrefillFields,
2530
+ ...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
2531
+ ...(handoffValues !== undefined && { handoffValues }),
2532
+ };
2533
+ return {
2534
+ ...mergeSearchParamsIntoInitial(submittableFields, {
2535
+ ...prefillSources,
2536
+ renderableFields: layoutFieldNames(synthScreen),
2537
+ ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
2538
+ }),
2539
+ ...loadedValues,
2540
+ };
2541
+ }, [
2503
2542
  submittableFields,
2543
+ screen.load,
2504
2544
  screen.urlPrefillFields,
2505
2545
  nav.searchParams,
2506
2546
  synthScreen,
2507
2547
  moneyCurrencyOverrides,
2508
2548
  initialOverrides,
2509
2549
  handoffValues,
2550
+ loadedValues,
2510
2551
  ]);
2511
2552
  const t = useTranslation();
2512
2553
  const effectiveTranslate = translate ?? t;
2513
2554
  const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
2514
2555
  const dispatcher = useDispatcher();
2515
- const [successText, setSuccessText] = useState(undefined);
2556
+ const [success, setSuccess] = useState(undefined);
2557
+ // Bumped after a successful submit with resetOnSuccess: remounts RenderEdit, which
2558
+ // re-seeds the form from `initial` (prefill kept, typed values cleared).
2559
+ const [formEpoch, setFormEpoch] = useState(0);
2516
2560
  // A slow label lookup of an earlier submit must not overwrite the banner of a later one.
2517
2561
  const successSequence = useRef(0);
2518
2562
  useEffect(() => () => {
@@ -2521,23 +2565,35 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2521
2565
  const handleSubmitted = useCallback((result, values) => {
2522
2566
  successSequence.current += 1;
2523
2567
  const sequence = successSequence.current;
2524
- setSuccessText(undefined);
2568
+ setSuccess(undefined);
2525
2569
  if (!result.isSuccess)
2526
2570
  return;
2527
2571
  if (onSuccess !== undefined) {
2528
2572
  onSuccess();
2529
2573
  return;
2530
2574
  }
2531
- if (screen.successMessage !== undefined && screen.redirect === undefined) {
2532
- const messageKey = screen.successMessage;
2533
- const show = (params) => {
2534
- if (successSequence.current === sequence) {
2535
- setSuccessText(effectiveTranslate(messageKey, params));
2536
- }
2575
+ if (screen.redirect === undefined) {
2576
+ const hasSuccessLinks = (screen.successLinks?.length ?? 0) > 0;
2577
+ const publish = (text) => {
2578
+ if (successSequence.current !== sequence)
2579
+ return;
2580
+ setSuccess({
2581
+ text,
2582
+ links: resolveSuccessLinks(screen.successLinks, result.data, schema, appFeatures, nav, effectiveTranslate),
2583
+ });
2537
2584
  };
2538
- void resolveSuccessMessageParams(screen, schema.featureName, values, dispatcher, locale)
2539
- .then(show)
2540
- .catch(() => show(formatSummaryParams(values, locale)));
2585
+ const messageKey = screen.successMessage;
2586
+ if (messageKey !== undefined) {
2587
+ const show = (params) => publish(effectiveTranslate(messageKey, params));
2588
+ void resolveSuccessMessageParams(screen, schema.featureName, values, dispatcher, locale)
2589
+ .then(show)
2590
+ .catch(() => show(formatSummaryParams(values, locale)));
2591
+ }
2592
+ else if (hasSuccessLinks || screen.resetOnSuccess === true) {
2593
+ publish(effectiveTranslate("kumiko.actionForm.success"));
2594
+ }
2595
+ if (screen.resetOnSuccess === true)
2596
+ setFormEpoch((epoch) => epoch + 1);
2541
2597
  }
2542
2598
  // Without a redirect the form stays put; returnTo only replaces an existing
2543
2599
  // navigation, and never one to a record screen (see redirectTargetsRecord).
@@ -2560,6 +2616,14 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2560
2616
  locale,
2561
2617
  effectiveTranslate,
2562
2618
  ]);
2619
+ // The success box disappears with the next edit; a submit clears it itself.
2620
+ const handleDirtyChange = useCallback((dirty) => {
2621
+ if (dirty) {
2622
+ successSequence.current += 1;
2623
+ setSuccess(undefined);
2624
+ }
2625
+ onDirtyChange?.(dirty);
2626
+ }, [onDirtyChange]);
2563
2627
  // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2564
2628
  // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
2565
2629
  // gewinnt über redirect; `false` schaltet den Button explizit ab
@@ -2582,6 +2646,9 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2582
2646
  if (currencyUnresolved) {
2583
2647
  return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-currency-unresolved", children: "The tenant currency could not be loaded, so this form cannot be submitted." }));
2584
2648
  }
2649
+ if (loadState !== undefined && loadState.status !== "ready") {
2650
+ return (_jsx(ActionFormLoadStatus, { state: loadState, screenId: screen.id, translate: effectiveTranslate }));
2651
+ }
2585
2652
  return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial,
2586
2653
  // Extension-Sections im actionForm sehen die initialen Form-Values
2587
2654
  // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
@@ -2589,11 +2656,11 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2589
2656
  // sie Kontext ziehen könnten.
2590
2657
  extensionInitialValues: initial, ...(screen.submitPrefilled === true && {
2591
2658
  submitWithoutChanges: hiddenRequiredFieldsFilled(synthScreen, screen.fields, initial),
2592
- }), ...(successText !== undefined && {
2593
- leadContent: (_jsx(Banner, { variant: "info", testId: "action-form-success", children: successText })),
2594
- }), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2659
+ }), ...(success !== undefined && {
2660
+ leadContent: _jsx(ActionFormSuccessBanner, { success: success }),
2661
+ }), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), onDirtyChange: handleDirtyChange, ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2595
2662
  submitLabel: screen.submitLabel ?? submitLabelFallback,
2596
- }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
2663
+ }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }, formEpoch));
2597
2664
  }
2598
2665
  // A money-typed config-edit field renders through RenderField's entityEdit
2599
2666
  // `{amount, currency}` payload shape (render-field.tsx, #1923), but
@@ -2,7 +2,7 @@ import type { ListEmptyStateSpec } from "@cosmicdrift/kumiko-framework/ui-types"
2
2
  import type { Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import type { ListEmptyStateContent } from "../components/list-empty-state.tsx";
4
4
  import { buildRecordActions } from "./row-actions.ts";
5
- type ActionBindings = Pick<Parameters<typeof buildRecordActions>[0], "nav" | "host" | "dispatcher" | "openDrawer" | "onWriteSuccess">;
5
+ type ActionBindings = Pick<Parameters<typeof buildRecordActions>[0], "nav" | "host" | "dispatcher" | "openDrawer" | "onWriteSuccess" | "clientActions">;
6
6
  export declare function useListEmptyContent(spec: ListEmptyStateSpec | undefined, translate: Translate, bindings: ActionBindings): ListEmptyStateContent | undefined;
7
7
  export declare function isListNarrowed(search: string, filters: Readonly<Record<string, readonly string[]>>, defaultFilters?: Readonly<Record<string, readonly string[]>>): boolean;
8
8
  export {};
@@ -3,7 +3,7 @@ import { buildRecordActions } from "./row-actions.js";
3
3
  const NO_RECORD = {};
4
4
  // An empty list has no row, so the action is resolved against an empty record.
5
5
  export function useListEmptyContent(spec, translate, bindings) {
6
- const { nav, host, dispatcher, openDrawer, onWriteSuccess } = bindings;
6
+ const { nav, host, dispatcher, openDrawer, onWriteSuccess, clientActions } = bindings;
7
7
  return useMemo(() => {
8
8
  if (spec === undefined)
9
9
  return undefined;
@@ -18,13 +18,14 @@ export function useListEmptyContent(spec, translate, bindings) {
18
18
  dispatcher,
19
19
  openDrawer,
20
20
  onWriteSuccess,
21
+ clientActions,
21
22
  })?.[0];
22
23
  return {
23
24
  title: translate(spec.title),
24
25
  ...(spec.description !== undefined && { description: translate(spec.description) }),
25
26
  ...(action !== undefined && { action }),
26
27
  };
27
- }, [spec, translate, nav, host, dispatcher, openDrawer, onWriteSuccess]);
28
+ }, [spec, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, clientActions]);
28
29
  }
29
30
  // Facets with a default are only "narrowed" once the user moved off that default.
30
31
  export function isListNarrowed(search, filters, defaultFilters = {}) {
@@ -1,8 +1,9 @@
1
- import type { EntityEditScreenDefinition, RelatedListToolbarAction, RowAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ToolbarAction } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EntityEditScreenDefinition, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ToolbarAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import type { RenderEditAction } from "../components/render-edit-types.ts";
4
4
  import type { ToolbarActionButton } from "../components/render-list.tsx";
5
5
  import type { DataTableRowAction } from "../primitives.tsx";
6
+ import { type ClientActionRuntime } from "./client-actions.tsx";
6
7
  import type { NavApi } from "./nav.tsx";
7
8
  import { type ReturnHost } from "./return-to.tsx";
8
9
  export declare function buildDefaultEditRowAction(editScreen: EntityEditScreenDefinition | undefined, idColumn?: string): RowActionNavigate | undefined;
@@ -15,7 +16,12 @@ export declare function runProjectionRowNavigate(nav: NavApi, action: RowActionN
15
16
  type OpenDrawer = (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
16
17
  /** @internal test-only */
17
18
  export declare function resetDrawerActionWarningsForTests(): void;
19
+ export declare function runDrawerRowAction(action: RowActionDrawer, openDrawer: OpenDrawer, row: ListRowViewModel): void;
20
+ /** The action that claims the row-body click (`rowClick: true`); the boot
21
+ * validator allows at most one per list. */
22
+ export declare function findRowClickAction(rowActions: readonly RowAction[] | undefined): RowActionNavigate | RowActionDrawer | undefined;
18
23
  export declare function buildDrawerRowAction(action: RowActionDrawer, translate: Translate, openDrawer: OpenDrawer): DataTableRowAction;
24
+ export declare function buildClientActionRowAction(action: RowActionClientAction, translate: Translate, runtime: ClientActionRuntime | undefined, refetch: () => Promise<unknown>): DataTableRowAction;
19
25
  export declare function buildProjectionRowActions(options: {
20
26
  readonly rowActions: readonly RowAction[] | undefined;
21
27
  readonly translate: Translate;
@@ -30,6 +36,7 @@ export declare function buildProjectionRowActions(options: {
30
36
  /** Prepended unless a declared action already has id "edit" — declared wins. */
31
37
  readonly defaultEditRowAction?: RowActionNavigate;
32
38
  readonly host: ReturnHost | undefined;
39
+ readonly clientActions?: ClientActionRuntime;
33
40
  }): readonly DataTableRowAction[] | undefined;
34
41
  type OpenToolbarDrawer = (action: ToolbarAction & {
35
42
  readonly kind: "drawer";
@@ -93,5 +100,6 @@ export declare function buildRecordActions(options: {
93
100
  * action (they reload or remount the surrounding form). Set while that
94
101
  * form holds unsaved input so it is not lost silently. */
95
102
  readonly discardChanges?: DiscardChanges;
103
+ readonly clientActions?: ClientActionRuntime;
96
104
  }): readonly RenderEditAction[] | undefined;
97
105
  export {};
@@ -1,5 +1,6 @@
1
1
  import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
3
+ import { runClientAction } from "./client-actions.js";
3
4
  import { lastSegment } from "./qn.js";
4
5
  import { navigateWithReturnTo } from "./return-to.js";
5
6
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
@@ -119,8 +120,16 @@ function warnDrawerActionDropped(actionKind, actionId) {
119
120
  // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
120
121
  console.warn(`[kumiko] ${actionKind} "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`);
121
122
  }
123
+ export function runDrawerRowAction(action, openDrawer, row) {
124
+ openDrawer(action, action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined);
125
+ }
126
+ /** The action that claims the row-body click (`rowClick: true`); the boot
127
+ * validator allows at most one per list. */
128
+ export function findRowClickAction(rowActions) {
129
+ return rowActions?.find((a) => (a.kind === "navigate" || a.kind === "drawer") && a.rowClick === true);
130
+ }
122
131
  export function buildDrawerRowAction(action, translate, openDrawer) {
123
- const { visible, params } = action;
132
+ const { visible } = action;
124
133
  const actionIcon = resolveActionIcon(action.id, action.icon);
125
134
  return {
126
135
  id: action.id,
@@ -128,10 +137,9 @@ export function buildDrawerRowAction(action, translate, openDrawer) {
128
137
  ...(action.style !== undefined && { style: action.style }),
129
138
  ...(action.display !== undefined && { display: action.display }),
130
139
  confirmRequired: false,
140
+ ...(action.rowClick === true && { rowClick: true }),
131
141
  ...(actionIcon !== undefined && { icon: actionIcon }),
132
- onTrigger: (row) => {
133
- openDrawer(action, params !== undefined ? evalRowExtractor(params, row.values) : undefined);
134
- },
142
+ onTrigger: (row) => runDrawerRowAction(action, openDrawer, row),
135
143
  ...(visible !== undefined && {
136
144
  isVisible: (row) => evalFieldCondition(visible, row.values),
137
145
  }),
@@ -172,6 +180,36 @@ function buildWriteHandlerRowAction(action, translate, refetch, dispatcher) {
172
180
  }),
173
181
  };
174
182
  }
183
+ function clientActionPayload(action, values, defaultId) {
184
+ return action.payload !== undefined
185
+ ? evalRowExtractor(action.payload, values)
186
+ : { id: defaultId };
187
+ }
188
+ export function buildClientActionRowAction(action, translate, runtime, refetch) {
189
+ const { visible } = action;
190
+ return {
191
+ id: action.id,
192
+ label: translate(action.label),
193
+ ...(action.style !== undefined && { style: action.style }),
194
+ ...(action.display !== undefined && { display: action.display }),
195
+ icon: resolveActionIcon(action.id, action.icon),
196
+ ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
197
+ ...(action.confirmLabel !== undefined && { confirmLabel: translate(action.confirmLabel) }),
198
+ onTrigger: async (row) => {
199
+ const succeeded = await runClientAction({
200
+ action,
201
+ payload: clientActionPayload(action, row.values, row.values["id"]),
202
+ runtime,
203
+ translate,
204
+ });
205
+ if (succeeded)
206
+ await refetchAfterWrite(refetch);
207
+ },
208
+ ...(visible !== undefined && {
209
+ isVisible: (row) => evalFieldCondition(visible, row.values),
210
+ }),
211
+ };
212
+ }
175
213
  // Builds the DataTable-ready row-action set for a query-driven row source
176
214
  // (projectionList, relatedList) — navigate dispatches through
177
215
  // runProjectionRowNavigate, writeHandler dispatches through the shared
@@ -179,7 +217,7 @@ function buildWriteHandlerRowAction(action, translate, refetch, dispatcher) {
179
217
  // projectionList's rowActions and a projectionDetail relatedList section's
180
218
  // rowActions can't drift apart (fw editable-detail-screens).
181
219
  export function buildProjectionRowActions(options) {
182
- const { rowActions, translate, dispatcher, nav, refetch, openDrawer, defaultEditRowAction, host, } = options;
220
+ const { rowActions, translate, dispatcher, nav, refetch, openDrawer, defaultEditRowAction, host, clientActions, } = options;
183
221
  const effectiveActions = mergeDefaultEditAction(rowActions, defaultEditRowAction);
184
222
  if (effectiveActions.length === 0)
185
223
  return undefined;
@@ -197,6 +235,10 @@ export function buildProjectionRowActions(options) {
197
235
  out.push(buildDrawerRowAction(action, translate, openDrawer));
198
236
  continue;
199
237
  }
238
+ if (action.kind === "clientAction") {
239
+ out.push(buildClientActionRowAction(action, translate, clientActions, refetch));
240
+ continue;
241
+ }
200
242
  // writeHandler (default-kind) — a swallowed failure result must become a
201
243
  // thrown error (fw prod-bug 2026-06-07), same as every other write path.
202
244
  if (dispatcher === undefined)
@@ -369,6 +411,31 @@ function buildDrawerRecordAction(action, options) {
369
411
  },
370
412
  };
371
413
  }
414
+ function buildClientActionRecordAction(action, options) {
415
+ const { record, translate, actionIcon, runtime, onWriteSuccess, defaultWritePayloadId, discardChanges, } = options;
416
+ const confirm = withDiscardHint(action.confirm !== undefined ? translate(action.confirm) : undefined, discardChanges);
417
+ const confirmLabel = discardChanges?.confirmLabel ??
418
+ (action.confirmLabel !== undefined ? translate(action.confirmLabel) : undefined);
419
+ return {
420
+ id: action.id,
421
+ label: translate(action.label),
422
+ ...(action.style !== undefined && { style: action.style }),
423
+ ...(action.display !== undefined && { display: action.display }),
424
+ ...(actionIcon !== undefined && { icon: actionIcon }),
425
+ ...(confirm !== undefined && { confirm }),
426
+ ...(confirmLabel !== undefined && { confirmLabel }),
427
+ onPress: async () => {
428
+ const succeeded = await runClientAction({
429
+ action,
430
+ payload: clientActionPayload(action, record, defaultWritePayloadId ?? record["id"]),
431
+ runtime,
432
+ translate,
433
+ });
434
+ if (succeeded)
435
+ await onWriteSuccess();
436
+ },
437
+ };
438
+ }
372
439
  // Entity delete handler QN: `<feature>:write:<entity>:delete` (see
373
440
  // entityWriteCommand in kumiko-screen.tsx).
374
441
  const ENTITY_DELETE_HANDLER_PATTERN = /:write:[^:]+:delete$/;
@@ -417,7 +484,7 @@ function buildWriteHandlerRecordAction(action, options) {
417
484
  // resolved against the record currently being viewed/edited, independent of
418
485
  // any table row.
419
486
  export function buildRecordActions(options) {
420
- const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, onRecordLeft, defaultScreenTargetEntityId, sameEntityScreenId, defaultWritePayloadId, discardChanges, } = options;
487
+ const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, onRecordLeft, defaultScreenTargetEntityId, sameEntityScreenId, defaultWritePayloadId, discardChanges, clientActions, } = options;
421
488
  const out = [];
422
489
  for (const action of actions) {
423
490
  if (action.visible !== undefined && !evalFieldCondition(action.visible, record))
@@ -447,6 +514,18 @@ export function buildRecordActions(options) {
447
514
  }));
448
515
  continue;
449
516
  }
517
+ if (action.kind === "clientAction") {
518
+ out.push(buildClientActionRecordAction(action, {
519
+ record,
520
+ translate,
521
+ actionIcon,
522
+ runtime: clientActions,
523
+ onWriteSuccess,
524
+ defaultWritePayloadId,
525
+ discardChanges,
526
+ }));
527
+ continue;
528
+ }
450
529
  // writeHandler — skip without a dispatcher instead of crashing.
451
530
  if (dispatcher === undefined)
452
531
  continue;