@cosmicdrift/kumiko-renderer 0.333.0 → 0.335.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.
Files changed (52) hide show
  1. package/dist/app/config-edit-shim.d.ts +7 -0
  2. package/dist/app/config-edit-shim.js +39 -0
  3. package/dist/app/content-preview.js +7 -9
  4. package/dist/app/extension-sections.d.ts +6 -0
  5. package/dist/app/form-schema.d.ts +2 -1
  6. package/dist/app/form-schema.js +11 -2
  7. package/dist/app/kumiko-screen.d.ts +5 -0
  8. package/dist/app/kumiko-screen.js +259 -101
  9. package/dist/app/nav.d.ts +5 -0
  10. package/dist/app/nav.js +17 -4
  11. package/dist/app/query-state-banners.d.ts +8 -0
  12. package/dist/app/query-state-banners.js +14 -0
  13. package/dist/app/reference-facet-bridge.js +8 -1
  14. package/dist/app/row-actions.d.ts +12 -0
  15. package/dist/app/row-actions.js +24 -7
  16. package/dist/app/screen-access.js +13 -9
  17. package/dist/app/secret-mint-body.js +41 -7
  18. package/dist/app/secrets-edit-body.js +89 -33
  19. package/dist/components/embedded-list-field.js +2 -4
  20. package/dist/components/grid-cell-for-field.d.ts +7 -2
  21. package/dist/components/grid-cell-for-field.js +2 -2
  22. package/dist/components/multi-select-checkboxes.js +4 -1
  23. package/dist/components/query-options-select.d.ts +12 -3
  24. package/dist/components/query-options-select.js +45 -2
  25. package/dist/components/reference-create-dialog.js +9 -4
  26. package/dist/components/related-list-section.js +8 -10
  27. package/dist/components/render-edit-action-button.d.ts +1 -1
  28. package/dist/components/render-edit-action-button.js +5 -1
  29. package/dist/components/render-edit-logic.d.ts +2 -1
  30. package/dist/components/render-edit-logic.js +8 -5
  31. package/dist/components/render-edit-types.d.ts +30 -1
  32. package/dist/components/render-edit.js +248 -76
  33. package/dist/components/render-field.d.ts +7 -1
  34. package/dist/components/render-field.js +40 -15
  35. package/dist/components/render-list.js +6 -6
  36. package/dist/components/write-form-section.d.ts +5 -1
  37. package/dist/components/write-form-section.js +29 -25
  38. package/dist/context/embedded-screen-context.d.ts +5 -0
  39. package/dist/context/embedded-screen-context.js +12 -0
  40. package/dist/hooks/use-reference-lookup.d.ts +7 -0
  41. package/dist/hooks/use-reference-lookup.js +10 -0
  42. package/dist/i18n-defaults.js +30 -5
  43. package/dist/i18n.js +8 -4
  44. package/dist/index.d.ts +5 -1
  45. package/dist/index.js +3 -0
  46. package/dist/primitives.d.ts +70 -19
  47. package/dist/session/session-ended.d.ts +16 -0
  48. package/dist/session/session-ended.js +73 -0
  49. package/dist/sort-by-accessor.d.ts +6 -2
  50. package/dist/sort-by-accessor.js +13 -3
  51. package/package.json +5 -5
  52. package/src/changes.json +40 -0
@@ -1,14 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
3
+ import { buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
4
4
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
5
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
6
6
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
7
8
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
8
9
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
9
10
  import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
10
11
  import { RenderList } from "../components/render-list.js";
11
12
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
13
+ import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
12
14
  import { useUserRoles } from "../context/user-roles-context.js";
13
15
  import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
14
16
  import { useQuery } from "../hooks/use-query.js";
@@ -19,7 +21,7 @@ import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../
19
21
  import { screenFillsHeight } from "../screen-fills-height.js";
20
22
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim.js";
21
23
  import { useAppFeatures } from "./app-features-context.js";
22
- import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim.js";
24
+ import { buildConfigEditSchema, synthesizeConfigEditEntity, synthesizeConfigEditScreen, } from "./config-edit-shim.js";
23
25
  import { useCustomScreenComponent } from "./custom-screens.js";
24
26
  import { useDashboardBody } from "./dashboard-body.js";
25
27
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
@@ -32,9 +34,10 @@ import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } f
32
34
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim.js";
33
35
  import { lastSegment, toKebab } from "./qn.js";
34
36
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id.js";
37
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
35
38
  import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
36
39
  import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
37
- import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
40
+ import { buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
38
41
  import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access.js";
39
42
  import { SecretMintBody } from "./secret-mint-body.js";
40
43
  import { SecretsEditBody } from "./secrets-edit-body.js";
@@ -132,16 +135,19 @@ function entityWriteCommand(featureName, entity, verb) {
132
135
  // entityList-Screen für die Entity und navigiert dahin. Wird von
133
136
  // Create/Update/Delete genauso verwendet — alle drei haben "fertig
134
137
  // editiert, raus hier" als sinnvolles Default-Verhalten.
138
+ function findEntityListScreen(schema, entityName) {
139
+ return schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
140
+ }
135
141
  function useNavigateToListAfter(schema, entityName) {
136
142
  const nav = useNav();
137
143
  return useCallback(() => {
138
- const list = schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
144
+ const list = findEntityListScreen(schema, entityName);
139
145
  if (!list)
140
146
  return;
141
147
  // schema.screens.id ist QN-form (registry-stamped); nav.navigate
142
148
  // erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
143
149
  nav.navigate({ screenId: lastSegment(list.id) });
144
- }, [nav, schema.screens, entityName]);
150
+ }, [nav, schema, entityName]);
145
151
  }
146
152
  // Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
147
153
  // entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
@@ -220,7 +226,7 @@ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrid
220
226
  // client-safe FieldDefinition subset, not the concrete MoneyFieldDef/
221
227
  // MoneyCurrencySource types, same idiom as the other field-shape narrowings
222
228
  // in this file (PrefillFieldShape etc.).
223
- function tenantCurrencyMoneyFieldNames(fields) {
229
+ export function tenantCurrencyMoneyFieldNames(fields) {
224
230
  const names = [];
225
231
  for (const [name, def] of Object.entries(fields)) {
226
232
  const shape = def;
@@ -248,6 +254,7 @@ function resolveTenantCurrency(fieldNames, query, fallback) {
248
254
  // so a tenant-declared field whose config query errors or returns no value
249
255
  // lands here — same last resort as the entityEdit path's `?? "EUR"`.
250
256
  const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
257
+ const HEADER_CARD_DEFAULT_WIDTH = "4xl";
251
258
  // Shared by the update payload shape and the server-error path prefix so both can't drift.
252
259
  const UPDATE_CHANGES_KEY = "changes";
253
260
  // A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
@@ -271,7 +278,7 @@ export function literalCurrencyOverrides(fields) {
271
278
  // on a fast click submit, the wrong currency. Callers pass the tenant field
272
279
  // names themselves because an edit form only needs the fetch for fields the
273
280
  // record has no stored value for.
274
- function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
281
+ export function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
275
282
  const query = useQuery("config:query:values", {}, { enabled: tenantFieldNames.length > 0 });
276
283
  const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
277
284
  const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
@@ -478,7 +485,11 @@ export function mergeSearchParamsIntoInitial(fields, sources) {
478
485
  continue;
479
486
  if (shape.format === "password")
480
487
  continue;
481
- if (drawerOverrides !== undefined && name in drawerOverrides) {
488
+ // An `undefined` override (row-extractor key whose row lacks the field)
489
+ // must not wipe the field default.
490
+ if (drawerOverrides !== undefined &&
491
+ Object.hasOwn(drawerOverrides, name) &&
492
+ drawerOverrides[name] !== undefined) {
482
493
  merged[name] = drawerOverrides[name];
483
494
  continue;
484
495
  }
@@ -659,7 +670,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
659
670
  }
660
671
  return out;
661
672
  }, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
662
- const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
673
+ const formSchema = useMemo(() => buildFormSchema(entity, screen, initial), [entity, screen, initial]);
663
674
  // Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
664
675
  // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
665
676
  const extensionInitialValues = useMemo(() => {
@@ -692,6 +703,24 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
692
703
  // pattern as ProjectionDetailBody.headerActions above (the edited record
693
704
  // stands in for the "row"), minus the cross-feature defaultEditAction
694
705
  // lookup that doesn't apply here (this screen already IS the edit form).
706
+ const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
707
+ // Never return onto the just-deleted record.
708
+ const target = deletedShownRecord && returnTarget?.entityId === entityId ? undefined : returnTarget;
709
+ if (action.redirect !== undefined) {
710
+ navigateAfterRedirect({
711
+ nav,
712
+ schema,
713
+ appFeatures,
714
+ returnTarget: target,
715
+ redirect: action.redirect,
716
+ resultData,
717
+ record,
718
+ });
719
+ return;
720
+ }
721
+ navigateToReturnOr(nav, target, navigateToList);
722
+ onDeleted?.();
723
+ }, [nav, schema, appFeatures, returnTarget, record, entityId, navigateToList, onDeleted]);
695
724
  const buildSectionActions = useCallback((actions) => buildRecordActions({
696
725
  actions,
697
726
  record,
@@ -701,33 +730,38 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
701
730
  dispatcher,
702
731
  openDrawer,
703
732
  onWriteSuccess: onReload,
733
+ onRecordLeft: handleRecordLeft,
704
734
  defaultScreenTargetEntityId: entityId,
705
- }), [record, effectiveTranslate, nav, host, dispatcher, openDrawer, onReload, entityId]);
735
+ defaultWritePayloadId: entityId,
736
+ }), [
737
+ record,
738
+ effectiveTranslate,
739
+ nav,
740
+ host,
741
+ dispatcher,
742
+ openDrawer,
743
+ onReload,
744
+ handleRecordLeft,
745
+ entityId,
746
+ ]);
706
747
  const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
707
748
  const handleSubmitted = useCallback((result) => {
708
749
  if (!result.isSuccess)
709
750
  return;
710
751
  const redirect = screen.redirect;
711
752
  if (redirect !== undefined) {
712
- if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
713
- navigateToReturn(nav, returnTarget);
714
- }
715
- else if (typeof redirect === "string") {
716
- // String form unchanged: navigates without an entityId, same as
717
- // before the object form existed. The object form resolves like
718
- // actionForm's — the update handler's success payload usually
719
- // reports only this record's own id (event-store-executor-write.ts),
720
- // so a parent FK named by `idFrom` falls back to the already-loaded
721
- // `record`.
722
- nav.navigate({ screenId: lastSegment(redirect) });
723
- }
724
- else {
725
- const { screenId, entityId: targetEntityId } = resolveRedirectTarget(redirect, result.data, schema, appFeatures, record);
726
- nav.navigate({
727
- screenId,
728
- ...(targetEntityId !== undefined && { entityId: targetEntityId }),
729
- });
730
- }
753
+ // The update handler's success payload usually reports only this
754
+ // record's own id (event-store-executor-write.ts), so a parent FK
755
+ // named by `idFrom` falls back to the already-loaded `record`.
756
+ navigateAfterRedirect({
757
+ nav,
758
+ schema,
759
+ appFeatures,
760
+ returnTarget,
761
+ redirect,
762
+ resultData: result.data,
763
+ record: overlayUpdatedProjection(record, result.data),
764
+ });
731
765
  }
732
766
  else {
733
767
  navigateToReturnOr(nav, returnTarget, navigateToList);
@@ -967,6 +1001,7 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
967
1001
  return (_jsx(EntityListBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
968
1002
  }
969
1003
  function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1004
+ const isEmbeddedScreen = useIsEmbeddedScreen();
970
1005
  const featureName = schema.featureName;
971
1006
  const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen);
972
1007
  const { Banner } = usePrimitives();
@@ -1232,26 +1267,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1232
1267
  };
1233
1268
  }
1234
1269
  if (action.kind === "drawer") {
1235
- const drawerAction = action;
1236
- const actionVisible = action.visible;
1237
- const actionIcon = resolveActionIcon(action.id, action.icon);
1238
- return {
1239
- id: action.id,
1240
- label: effectiveTranslate(action.label),
1241
- ...(action.style !== undefined && { style: action.style }),
1242
- ...(action.display !== undefined && { display: action.display }),
1243
- confirmRequired: false,
1244
- ...(actionIcon !== undefined && { icon: actionIcon }),
1245
- onTrigger: (row) => {
1246
- const initialValues = drawerAction.params !== undefined
1247
- ? evalRowExtractor(drawerAction.params, row.values)
1248
- : undefined;
1249
- openDrawer(drawerAction, initialValues);
1250
- },
1251
- ...(actionVisible !== undefined && {
1252
- isVisible: (row) => evalFieldCondition(actionVisible, row.values),
1253
- }),
1254
- };
1270
+ return buildDrawerRowAction(action, effectiveTranslate, openDrawer);
1255
1271
  }
1256
1272
  if (dispatcher === undefined)
1257
1273
  return null;
@@ -1392,7 +1408,9 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1392
1408
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1393
1409
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1394
1410
  const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1395
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, screenPadding: true, ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
1411
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1412
+ ? { screenPadding: false, chromeless: true }
1413
+ : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
1396
1414
  onReachEnd: loadMore,
1397
1415
  loadingMore: rowsQuery.loading,
1398
1416
  hasMore,
@@ -1420,6 +1438,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1420
1438
  // declared here. Pages-mode pagination only — infinite-scroll accumulation
1421
1439
  // isn't wired for projectionList.
1422
1440
  function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1441
+ const isEmbeddedScreen = useIsEmbeddedScreen();
1423
1442
  const { Banner, Text } = usePrimitives();
1424
1443
  const t = useTranslation();
1425
1444
  const nav = useNav();
@@ -1591,7 +1610,9 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1591
1610
  onPageSizeChange: urlState.setPageSize,
1592
1611
  }
1593
1612
  : undefined;
1594
- return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, screenPadding: true, ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1613
+ return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1614
+ ? { screenPadding: false, chromeless: true }
1615
+ : { screenPadding: true }), ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1595
1616
  filterFacets,
1596
1617
  onFilterChange: urlState.setFilter,
1597
1618
  }), ...(dateRangeFacets.length > 0 && {
@@ -1831,7 +1852,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1831
1852
  // useQuery call re-runs too (it has no `live` subscription of its own).
1832
1853
  const [reloadNonce, setReloadNonce] = useState(0);
1833
1854
  const reloadDetail = useCallback(async () => {
1834
- await detailQuery.refetch();
1855
+ await refetchAfterWrite(detailQuery.refetch);
1835
1856
  setReloadNonce((n) => n + 1);
1836
1857
  }, [detailQuery.refetch]);
1837
1858
  // Header actions render directly via RenderEditActionButton (fw#2713,
@@ -1850,7 +1871,49 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1850
1871
  const userRoles = useUserRoles();
1851
1872
  const dispatcher = useOptionalDispatcher();
1852
1873
  const host = useReturnHost();
1874
+ const returnTargetParam = useReturnTarget(screen.id);
1875
+ // A singleton has no record route to come back to — same as entityEdit.
1876
+ const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
1853
1877
  const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
1878
+ const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
1879
+ const record = detailQuery.data ?? {};
1880
+ // Never return onto the just-deleted record.
1881
+ const target = deletedShownRecord && returnTarget?.entityId === effectiveEntityId
1882
+ ? undefined
1883
+ : returnTarget;
1884
+ if (action.redirect !== undefined) {
1885
+ navigateAfterRedirect({
1886
+ nav,
1887
+ schema,
1888
+ appFeatures,
1889
+ returnTarget: target,
1890
+ redirect: action.redirect,
1891
+ resultData,
1892
+ record,
1893
+ });
1894
+ return;
1895
+ }
1896
+ navigateToReturnOr(nav, target, () => {
1897
+ const listScreen = screen.listScreenId ??
1898
+ (screen.detailFor !== undefined
1899
+ ? findEntityListScreen(schema, screen.detailFor)?.id
1900
+ : undefined);
1901
+ if (listScreen !== undefined)
1902
+ nav.navigate({ screenId: lastSegment(listScreen) });
1903
+ else
1904
+ void detailQuery.refetch();
1905
+ });
1906
+ }, [
1907
+ detailQuery.data,
1908
+ detailQuery.refetch,
1909
+ nav,
1910
+ schema,
1911
+ appFeatures,
1912
+ returnTarget,
1913
+ effectiveEntityId,
1914
+ screen.listScreenId,
1915
+ screen.detailFor,
1916
+ ]);
1854
1917
  const editScreen = useMemo(() => {
1855
1918
  const detailFor = screen.detailFor;
1856
1919
  if (detailFor === undefined)
@@ -1898,6 +1961,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1898
1961
  dispatcher,
1899
1962
  openDrawer,
1900
1963
  onWriteSuccess: detailQuery.refetch,
1964
+ onRecordLeft: handleRecordLeft,
1901
1965
  sameEntityScreenId,
1902
1966
  }), [
1903
1967
  detailQuery.data,
@@ -1907,6 +1971,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1907
1971
  host,
1908
1972
  dispatcher,
1909
1973
  openDrawer,
1974
+ handleRecordLeft,
1910
1975
  sameEntityScreenId,
1911
1976
  ]);
1912
1977
  const headerActions = useMemo(() => {
@@ -1920,6 +1985,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1920
1985
  dispatcher,
1921
1986
  openDrawer,
1922
1987
  onWriteSuccess: detailQuery.refetch,
1988
+ onRecordLeft: handleRecordLeft,
1923
1989
  sameEntityScreenId,
1924
1990
  });
1925
1991
  if (defaultEditAction === undefined || declaredHasEdit)
@@ -1935,8 +2001,25 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1935
2001
  detailQuery.data,
1936
2002
  detailQuery.refetch,
1937
2003
  openDrawer,
2004
+ handleRecordLeft,
1938
2005
  sameEntityScreenId,
1939
2006
  ]);
2007
+ const hasHeader = screen.header !== undefined;
2008
+ const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
2009
+ // ?? [] rather than threading `headerActions !== undefined` through every
2010
+ // use below — an empty array is a safe no-op for .map/.length.
2011
+ const headerActionsList = headerActions ?? [];
2012
+ const hasHeaderActions = headerActionsList.length > 0;
2013
+ const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
2014
+ // A header card (metric band, status, actions) needs more room than the
2015
+ // 640px form column; screens with only a record title keep the narrow one.
2016
+ const widensScreenFormColumn = hasHeaderCard &&
2017
+ !hasTabs &&
2018
+ screenFillsHeight(screen) &&
2019
+ detailScreen.layout.width === undefined;
2020
+ const renderedDetailScreen = useMemo(() => widensScreenFormColumn
2021
+ ? { ...detailScreen, layout: { ...detailScreen.layout, width: HEADER_CARD_DEFAULT_WIDTH } }
2022
+ : detailScreen, [detailScreen, widensScreenFormColumn]);
1940
2023
  if (effectiveEntityId === undefined && screen.singleton !== true) {
1941
2024
  return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-projection-detail-missing-id", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " (projectionDetail) needs a row id in the path \u2014 open it from a row action."] }));
1942
2025
  }
@@ -1950,18 +2033,11 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1950
2033
  if (!record) {
1951
2034
  return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: screen.singleton === true ? ("Record not found.") : (_jsxs(_Fragment, { children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] })) }));
1952
2035
  }
1953
- const hasHeader = screen.header !== undefined;
1954
- const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
1955
- // ?? [] rather than threading `headerActions !== undefined` through every
1956
- // use below — an empty array is a safe no-op for .map/.length.
1957
- const headerActionsList = headerActions ?? [];
1958
- const hasHeaderActions = headerActionsList.length > 0;
1959
2036
  // Grouped into the head Card alongside title/status/metrics (fw#2713):
1960
2037
  // these are actions on the record the head shows, not on whichever tab is
1961
2038
  // open, so they must stay in place across tab switches instead of
1962
2039
  // trailing the active tab's content in the card footer.
1963
2040
  const headerActionsContent = hasHeaderActions && (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError }) }));
1964
- const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
1965
2041
  // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
1966
2042
  // this shares the same page padding/width as the record's edit card below
1967
2043
  // it, instead of sitting flush against the screen edge (fw record-screen
@@ -2023,7 +2099,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2023
2099
  ...(count !== undefined && { count }),
2024
2100
  };
2025
2101
  }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
2026
- return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: detailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
2102
+ return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: renderedDetailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
2027
2103
  hasTabs ||
2028
2104
  screen.slots?.header !== undefined ||
2029
2105
  screen.recordTitleField !== undefined) && {
@@ -2037,18 +2113,24 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2037
2113
  function redirectScreenTarget(redirect) {
2038
2114
  return typeof redirect === "string" ? redirect : redirect.screen;
2039
2115
  }
2040
- // Checks this schema first, then every mounted feature — the target may live
2041
- // in another (cross-feature) feature than the redirect's own screen.
2116
+ // A qualified redirect names its feature, so the search space is THAT feature
2117
+ // only — the current schema's own screens must not shadow it with a same
2118
+ // short-id screen of another type. An unqualified redirect searches this
2119
+ // schema first (own screen wins), then every mounted feature. A named feature
2120
+ // that isn't mounted (no provider / isolated test) falls back to this schema.
2042
2121
  function findRedirectTargetScreen(redirect, schema, appFeatures) {
2043
2122
  const redirectScreen = redirectScreenTarget(redirect);
2044
2123
  const targetId = lastSegment(redirectScreen);
2045
2124
  const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
2046
- return (schema.screens.find((s) => lastSegment(s.id) === targetId) ??
2047
- (targetFeatureName !== undefined
2048
- ? appFeatures
2049
- .find((f) => f.featureName === targetFeatureName)
2050
- ?.screens.find((s) => lastSegment(s.id) === targetId)
2051
- : appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId)));
2125
+ const targetFeature = targetFeatureName !== undefined
2126
+ ? appFeatures.find((f) => f.featureName === targetFeatureName)
2127
+ : undefined;
2128
+ const searchSpace = targetFeature !== undefined
2129
+ ? targetFeature.screens
2130
+ : targetFeatureName === undefined
2131
+ ? [...schema.screens, ...appFeatures.flatMap((f) => f.screens)]
2132
+ : schema.screens;
2133
+ return searchSpace.find((s) => lastSegment(s.id) === targetId);
2052
2134
  }
2053
2135
  // A redirect to a record screen ("show the result") is a deliberate forward
2054
2136
  // navigation and keeps precedence over returnTo.
@@ -2083,6 +2165,33 @@ function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fal
2083
2165
  (fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
2084
2166
  return { screenId: targetId, entityId };
2085
2167
  }
2168
+ // Success navigation for a configured redirect — shared by entityEdit's
2169
+ // save and writeHandler record actions. The string form navigates without an
2170
+ // entityId; the object form resolves like actionForm's, with `record` as the
2171
+ // fallback id source.
2172
+ function navigateAfterRedirect(options) {
2173
+ const { nav, schema, appFeatures, returnTarget, redirect, resultData, record } = options;
2174
+ if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
2175
+ navigateToReturn(nav, returnTarget);
2176
+ return;
2177
+ }
2178
+ if (typeof redirect === "string") {
2179
+ nav.navigate({ screenId: lastSegment(redirect) });
2180
+ return;
2181
+ }
2182
+ const { screenId, entityId } = resolveRedirectTarget(redirect, resultData, schema, appFeatures, record);
2183
+ nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2184
+ }
2185
+ // The update payload nests the saved projection under `data`; a changed
2186
+ // parent FK lives there, not in the pre-edit `record`.
2187
+ function overlayUpdatedProjection(record, resultData) {
2188
+ if (typeof resultData !== "object" || resultData === null)
2189
+ return record;
2190
+ const projection = resultData.data;
2191
+ if (typeof projection !== "object" || projection === null)
2192
+ return record;
2193
+ return { ...record, ...projection };
2194
+ }
2086
2195
  // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
2087
2196
  // RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
2088
2197
  // Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
@@ -2189,8 +2298,19 @@ function unwrapMoneyValue(value) {
2189
2298
  }
2190
2299
  return value;
2191
2300
  }
2301
+ const CONFIG_SCOPE_SOURCE = {
2302
+ user: "user-row",
2303
+ tenant: "tenant-row",
2304
+ system: "system-row",
2305
+ };
2306
+ function isWriteOnlyTextField(fieldDef) {
2307
+ return fieldDef?.type === "text" && fieldDef.writeOnly === true;
2308
+ }
2309
+ function hasStoredValue(stored) {
2310
+ return stored !== undefined && stored !== null && stored !== "";
2311
+ }
2192
2312
  function ConfigEditBody({ schema, screen, translate, }) {
2193
- const { Banner, ConfigCascadeView } = usePrimitives();
2313
+ const { ConfigCascadeView } = usePrimitives();
2194
2314
  const t = useTranslation();
2195
2315
  const effectiveTranslate = translate ?? t;
2196
2316
  const dispatcher = useDispatcher();
@@ -2201,6 +2321,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2201
2321
  const cascadeQuery = useQuery("config:query:cascade", {});
2202
2322
  const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
2203
2323
  const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
2324
+ const boundsSchema = useMemo(() => buildConfigEditSchema(screen.fields), [screen.fields]);
2204
2325
  // Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
2205
2326
  // abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
2206
2327
  // wir den Field-Default (createTextField/createNumberField/...).
@@ -2220,6 +2341,15 @@ function ConfigEditBody({ schema, screen, translate, }) {
2220
2341
  continue;
2221
2342
  }
2222
2343
  const stored = valuesQuery.data[qualified]?.value;
2344
+ // The read carries a mask (or nothing); only whether a value exists is
2345
+ // meaningful, so the field gets the writeOnly wire shape true / null.
2346
+ // The read is the effective value: an inherited one is not "set" on this
2347
+ // scope, and a reset here would remove nothing.
2348
+ if (isWriteOnlyTextField(fieldDef)) {
2349
+ const ownedByThisScope = valuesQuery.data[qualified]?.source === CONFIG_SCOPE_SOURCE[screen.scope];
2350
+ out[shortName] = ownedByThisScope && hasStoredValue(stored) ? true : null;
2351
+ continue;
2352
+ }
2223
2353
  if (stored === undefined) {
2224
2354
  out[shortName] = defaults[shortName];
2225
2355
  continue;
@@ -2238,7 +2368,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2238
2368
  }
2239
2369
  }
2240
2370
  return out;
2241
- }, [valuesQuery.data, screen.fields, screen.configKeys]);
2371
+ }, [valuesQuery.data, screen.fields, screen.configKeys, screen.scope]);
2242
2372
  // Cascade-Lookup: qualifiedKey → ConfigCascade für die
2243
2373
  // Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
2244
2374
  // damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
@@ -2271,7 +2401,36 @@ function ConfigEditBody({ schema, screen, translate, }) {
2271
2401
  const qualified = screen.configKeys[shortName];
2272
2402
  if (qualified === undefined)
2273
2403
  continue;
2274
- const ftype = screen.fields[shortName]?.type;
2404
+ const fieldDef = screen.fields[shortName];
2405
+ const ftype = fieldDef?.type;
2406
+ if (isWriteOnlyTextField(screen.fields[shortName])) {
2407
+ // true (untouched/undone) and "" mean keep the stored secret; null removes it.
2408
+ if (value === null) {
2409
+ commands.push({
2410
+ type: "config:write:reset",
2411
+ payload: { key: qualified, scope: screen.scope },
2412
+ });
2413
+ }
2414
+ else if (typeof value === "string" && value !== "") {
2415
+ commands.push({
2416
+ type: "config:write:set",
2417
+ payload: { key: qualified, value, scope: screen.scope },
2418
+ });
2419
+ }
2420
+ continue;
2421
+ }
2422
+ // A query-backed select is cleared to "" when its dependent value became
2423
+ // invalid. config:write:set would store that as an empty override (the
2424
+ // badge shows "Mandant" with no value); reset falls back to the inherited
2425
+ // value. set can't do it: deleting the row is irreversible, reset is
2426
+ // the high-risk handler.
2427
+ if (ftype === "select" && fieldDef?.optionsQuery !== undefined && value === "") {
2428
+ commands.push({
2429
+ type: "config:write:reset",
2430
+ payload: { key: qualified, scope: screen.scope },
2431
+ });
2432
+ continue;
2433
+ }
2275
2434
  commands.push({
2276
2435
  type: "config:write:set",
2277
2436
  payload: {
@@ -2302,41 +2461,40 @@ function ConfigEditBody({ schema, screen, translate, }) {
2302
2461
  valuesQuery.refetch,
2303
2462
  cascadeQuery.refetch,
2304
2463
  ]);
2305
- // Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
2306
- // unter dem Input — zwei getrennte Render-Slots teilen sich den
2307
- // expanded-State, der daher hier (pro Feld) statt in der Komponente lebt.
2308
- const [expandedFields, setExpandedFields] = useState(new Set());
2309
- const toggleExpanded = useCallback((fieldName) => {
2310
- setExpandedFields((prev) => {
2311
- const next = new Set(prev);
2312
- if (next.has(fieldName))
2313
- next.delete(fieldName);
2314
- else
2315
- next.add(fieldName);
2316
- return next;
2317
- });
2318
- }, []);
2319
- if (valuesQuery.loading && valuesQuery.data === null) {
2320
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2321
- }
2464
+ if (valuesQuery.loading && valuesQuery.data === null)
2465
+ return _jsx(QueryLoadingBanner, {});
2322
2466
  if (valuesQuery.error) {
2323
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(valuesQuery.error, effectiveTranslate) }));
2467
+ return (_jsx(QueryErrorBanner, { error: valuesQuery.error, translate: effectiveTranslate, onRetry: valuesQuery.refetch }));
2324
2468
  }
2325
- if (initial === null) {
2326
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
2327
- }
2328
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
2469
+ if (initial === null)
2470
+ return _jsx(QueryLoadingBanner, {});
2471
+ // Cascade values of a select show the option label the select itself shows,
2472
+ // not the stored value (a UUID for optionsQuery selects).
2473
+ const cascadeValueRenderer = (fieldName) => {
2474
+ const fieldDef = screen.fields[fieldName];
2475
+ if (fieldDef?.type !== "select")
2476
+ return undefined;
2477
+ if (fieldDef.optionsQuery !== undefined) {
2478
+ const query = fieldDef.optionsQuery;
2479
+ const payload = resolveOptionsQueryPayload(fieldDef.optionsQueryPayload ?? {}, initial);
2480
+ return (value) => _jsx(QueryOptionLabel, { query: query, payload: payload, value: value });
2481
+ }
2482
+ if (fieldDef.options.length === 0)
2483
+ return undefined;
2484
+ return (value) => buildOptionLabels(effectiveTranslate, (option) => fieldOptionLabelKey(schema.featureName, synthScreen.entity, fieldName, option), [String(value)])[String(value)];
2485
+ };
2486
+ return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, schema: boundsSchema, customSubmit: customSubmit, dirtyFooter: true, validateOnChange: true, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), fieldAppendixPlacement: "below-control", fieldAccent: (fieldName) => cascades[fieldName]?.levels.find((level) => level.isActive)?.source ===
2487
+ CONFIG_SCOPE_SOURCE[screen.scope], markChangedFields: true, ...(screen.fieldDescriptions !== undefined && {
2488
+ fieldDescription: (fieldName) => {
2489
+ const key = screen.fieldDescriptions?.[fieldName];
2490
+ return key === undefined ? undefined : effectiveTranslate(key);
2491
+ },
2492
+ }), fieldAppendix: (fieldName) => {
2329
2493
  const cascade = cascades[fieldName];
2330
2494
  if (cascade === undefined)
2331
2495
  return undefined;
2332
- return (_jsx(ConfigCascadeView, { slot: "trigger", cascade: cascade, screenScope: screen.scope, expanded: expandedFields.has(fieldName), onToggle: () => toggleExpanded(fieldName) }));
2333
- }, fieldAppendix: (fieldName) => {
2334
- const cascade = cascades[fieldName];
2335
- // Panel nur rendern wenn aufgeklappt — sonst kein leerer Abstand
2336
- // unter dem Input.
2337
- if (cascade === undefined || !expandedFields.has(fieldName))
2338
- return undefined;
2339
- return (_jsx(ConfigCascadeView, { slot: "panel", cascade: cascade, screenScope: screen.scope, expanded: true, qualifiedKey: screen.configKeys[fieldName], onReset: async (key, scope) => {
2496
+ const renderValue = cascadeValueRenderer(fieldName);
2497
+ return (_jsx(ConfigCascadeView, { cascade: cascade, screenScope: screen.scope, qualifiedKey: screen.configKeys[fieldName], required: screen.requiredFields?.includes(fieldName) ?? false, ...(renderValue !== undefined && { renderValue }), onReset: async (key, scope) => {
2340
2498
  await dispatcher.write("config:write:reset", { key, scope });
2341
2499
  await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
2342
2500
  } }));
package/dist/app/nav.d.ts CHANGED
@@ -47,6 +47,11 @@ export type NavApi = {
47
47
  };
48
48
  export declare function parsePath(pathname: string, hasWorkspaces?: boolean): NavRoute | undefined;
49
49
  export declare function formatPath(target: ScreenTarget): string;
50
+ export declare function findDetailForScreen(features: readonly FeatureSchema[], entity: string): {
51
+ readonly featureName: string;
52
+ readonly screenId: string;
53
+ readonly screen: FeatureSchema["screens"][number];
54
+ } | undefined;
50
55
  /** Non-throwing existence check for findDetailForScreen — lets a caller pick
51
56
  * a different default (fw#2164's row-click fallback) instead of resolving. */
52
57
  export declare function hasDetailScreen(features: readonly FeatureSchema[], entity: string): boolean;