@cosmicdrift/kumiko-renderer 0.334.0 → 0.336.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 (43) hide show
  1. package/dist/app/content-preview.js +7 -9
  2. package/dist/app/extension-sections.d.ts +6 -0
  3. package/dist/app/form-schema.d.ts +2 -1
  4. package/dist/app/form-schema.js +11 -2
  5. package/dist/app/kumiko-screen.d.ts +5 -0
  6. package/dist/app/kumiko-screen.js +196 -65
  7. package/dist/app/nav.d.ts +5 -0
  8. package/dist/app/nav.js +17 -4
  9. package/dist/app/reference-facet-bridge.js +8 -1
  10. package/dist/app/row-actions.d.ts +12 -0
  11. package/dist/app/row-actions.js +24 -7
  12. package/dist/app/screen-access.js +13 -9
  13. package/dist/app/secret-mint-body.js +40 -6
  14. package/dist/app/secrets-edit-body.js +71 -44
  15. package/dist/components/embedded-list-field.js +2 -4
  16. package/dist/components/multi-select-checkboxes.js +4 -1
  17. package/dist/components/reference-create-dialog.js +9 -4
  18. package/dist/components/related-list-section.js +7 -5
  19. package/dist/components/render-edit-action-button.d.ts +1 -1
  20. package/dist/components/render-edit-action-button.js +5 -1
  21. package/dist/components/render-edit-logic.d.ts +2 -1
  22. package/dist/components/render-edit-logic.js +8 -5
  23. package/dist/components/render-edit-types.d.ts +10 -1
  24. package/dist/components/render-edit.js +189 -54
  25. package/dist/components/render-field.js +10 -13
  26. package/dist/components/render-list.js +6 -6
  27. package/dist/components/write-form-section.js +12 -20
  28. package/dist/hooks/use-reference-lookup.d.ts +7 -0
  29. package/dist/hooks/use-reference-lookup.js +10 -0
  30. package/dist/hooks/use-report-step-complete.d.ts +2 -0
  31. package/dist/hooks/use-report-step-complete.js +13 -0
  32. package/dist/i18n.js +8 -4
  33. package/dist/index.d.ts +6 -2
  34. package/dist/index.js +4 -1
  35. package/dist/primitives.d.ts +30 -8
  36. package/dist/primitives.js +5 -0
  37. package/dist/session/session-ended.d.ts +16 -0
  38. package/dist/session/session-ended.js +73 -0
  39. package/dist/sort-by-accessor.d.ts +6 -2
  40. package/dist/sort-by-accessor.js +13 -3
  41. package/dist/sse/live-events.d.ts +1 -1
  42. package/package.json +5 -5
  43. package/src/changes.json +54 -0
@@ -1,4 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ // Read-only render of a content editor's format with the collection's
3
+ // example variableSchema values substituted in for `{{name}}` — reuses the
4
+ // same registered editor the collection edits with (readOnly), so "rich"
5
+ // renders formatted HTML and "plain"/"markdown" render as text through the
6
+ // exact same component, no separate render path per format.
7
+ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
8
  import { useId } from "react";
3
9
  import { useContentEditor } from "./content-editors.js";
4
10
  const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
@@ -8,14 +14,6 @@ const noop = () => { };
8
14
  export function substituteVariables(content, variables) {
9
15
  return content.replace(VARIABLE_PATTERN, (match, name) => Object.hasOwn(variables, name) ? (variables[name] ?? match) : match);
10
16
  }
11
- function escapeHtml(value) {
12
- return value
13
- .replace(/&/g, "&")
14
- .replace(/</g, "&lt;")
15
- .replace(/>/g, "&gt;")
16
- .replace(/"/g, "&quot;")
17
- .replace(/'/g, "&#39;");
18
- }
19
17
  export function ContentPreview({ content, variables, contentFormat, }) {
20
18
  const Editor = useContentEditor(contentFormat);
21
19
  const id = useId();
@@ -24,7 +22,7 @@ export function ContentPreview({ content, variables, contentFormat, }) {
24
22
  // render as an unescaped entity (`&`). "plain"/"markdown" content is text,
25
23
  // no escaping wanted there.
26
24
  const safeVariables = contentFormat === "rich"
27
- ? Object.fromEntries(Object.entries(variables).map(([name, value]) => [name, escapeHtml(value)]))
25
+ ? Object.fromEntries(Object.entries(variables).map(([name, value]) => [name, escapeHtmlAttr(value)]))
28
26
  : variables;
29
27
  return (_jsx(Editor, { id: id, value: substituteVariables(content, safeVariables), onChange: noop, variables: [], readOnly: true }));
30
28
  }
@@ -55,6 +55,12 @@ export type ExtensionSectionProps = {
55
55
  readonly index: number;
56
56
  readonly isLast: boolean;
57
57
  };
58
+ /** Only set for extension steps of a `layout.mode: "wizard"` entityEdit
59
+ * editing an existing record (update mode); undefined everywhere else.
60
+ * Report through `useReportStepComplete(reportStepComplete, complete)`
61
+ * (`null` while the step's data loads) with whether the step already holds
62
+ * its data, so the step bar shows the step as done. */
63
+ readonly reportStepComplete?: (complete: boolean) => void;
58
64
  };
59
65
  export type ExtensionSectionComponent = ComponentType<ExtensionSectionProps>;
60
66
  export type ExtensionSectionsMap = Readonly<Record<string, ExtensionSectionComponent>>;
@@ -1,4 +1,5 @@
1
1
  import type { EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import * as z from "zod";
3
+ export declare function isPresent(value: unknown): boolean;
3
4
  export declare const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
4
- export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition): z.ZodType;
5
+ export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
@@ -4,7 +4,7 @@ import * as z from "zod";
4
4
  import { layoutEditFields } from "./layout-fields.js";
5
5
  // `required` means "has a value", not "is truthy" — `false` and `0` count
6
6
  // as present, only the actually-empty representations don't.
7
- function isPresent(value) {
7
+ export function isPresent(value) {
8
8
  if (value === undefined || value === null || value === "")
9
9
  return false;
10
10
  if (Array.isArray(value) && value.length === 0)
@@ -39,7 +39,12 @@ export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
39
39
  // checked here: `visible`. `runValidate` already filters issues on hidden
40
40
  // fields via `computeFieldStates(options.fields, …)` (form-controller.ts:163,181),
41
41
  // fed by `deriveFormFields(screen)` in render-edit.tsx.
42
- export function buildFormSchema(entity, screen) {
42
+ //
43
+ // `unchangedFrom` is for payloadMode "changes" (update): a field still equal to
44
+ // its loaded value never reaches the payload, so the server never presence-
45
+ // checks it — a legacy row with a now-required empty field must stay saveable
46
+ // when the user edits something else.
47
+ export function buildFormSchema(entity, screen, unchangedFrom) {
43
48
  const fields = layoutEditFields(screen);
44
49
  return z
45
50
  .object({})
@@ -73,6 +78,10 @@ export function buildFormSchema(entity, screen) {
73
78
  continue;
74
79
  if (isPresent(record[spec.field]))
75
80
  continue;
81
+ if (unchangedFrom !== undefined &&
82
+ JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field])) {
83
+ continue;
84
+ }
76
85
  ctx.addIssue({
77
86
  code: "custom",
78
87
  path: [spec.field],
@@ -20,7 +20,12 @@ export declare function qualifyNavId(featureName: string, navId: string): string
20
20
  export { screenAccessAllows };
21
21
  export declare function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCopyLink, }: KumikoScreenProps): ReactNode;
22
22
  export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): Readonly<Record<string, unknown>>;
23
+ export declare function tenantCurrencyMoneyFieldNames(fields: Readonly<Record<string, unknown>>): readonly string[];
23
24
  export declare function literalCurrencyOverrides(fields: Readonly<Record<string, unknown>>): Readonly<Record<string, string>>;
25
+ export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string): {
26
+ readonly overrides: Readonly<Record<string, string>> | undefined;
27
+ readonly loading: boolean;
28
+ };
24
29
  export type InitialValueSources = {
25
30
  readonly searchParams: Readonly<Record<string, string>>;
26
31
  readonly urlPrefillFields: readonly string[] | undefined;
@@ -37,7 +37,7 @@ import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-scr
37
37
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
38
38
  import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
39
39
  import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
40
- 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";
41
41
  import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access.js";
42
42
  import { SecretMintBody } from "./secret-mint-body.js";
43
43
  import { SecretsEditBody } from "./secrets-edit-body.js";
@@ -135,16 +135,19 @@ function entityWriteCommand(featureName, entity, verb) {
135
135
  // entityList-Screen für die Entity und navigiert dahin. Wird von
136
136
  // Create/Update/Delete genauso verwendet — alle drei haben "fertig
137
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
+ }
138
141
  function useNavigateToListAfter(schema, entityName) {
139
142
  const nav = useNav();
140
143
  return useCallback(() => {
141
- const list = schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
144
+ const list = findEntityListScreen(schema, entityName);
142
145
  if (!list)
143
146
  return;
144
147
  // schema.screens.id ist QN-form (registry-stamped); nav.navigate
145
148
  // erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
146
149
  nav.navigate({ screenId: lastSegment(list.id) });
147
- }, [nav, schema.screens, entityName]);
150
+ }, [nav, schema, entityName]);
148
151
  }
149
152
  // Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
150
153
  // entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
@@ -223,7 +226,7 @@ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrid
223
226
  // client-safe FieldDefinition subset, not the concrete MoneyFieldDef/
224
227
  // MoneyCurrencySource types, same idiom as the other field-shape narrowings
225
228
  // in this file (PrefillFieldShape etc.).
226
- function tenantCurrencyMoneyFieldNames(fields) {
229
+ export function tenantCurrencyMoneyFieldNames(fields) {
227
230
  const names = [];
228
231
  for (const [name, def] of Object.entries(fields)) {
229
232
  const shape = def;
@@ -251,6 +254,7 @@ function resolveTenantCurrency(fieldNames, query, fallback) {
251
254
  // so a tenant-declared field whose config query errors or returns no value
252
255
  // lands here — same last resort as the entityEdit path's `?? "EUR"`.
253
256
  const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
257
+ const HEADER_CARD_DEFAULT_WIDTH = "4xl";
254
258
  // Shared by the update payload shape and the server-error path prefix so both can't drift.
255
259
  const UPDATE_CHANGES_KEY = "changes";
256
260
  // A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
@@ -274,7 +278,7 @@ export function literalCurrencyOverrides(fields) {
274
278
  // on a fast click submit, the wrong currency. Callers pass the tenant field
275
279
  // names themselves because an edit form only needs the fetch for fields the
276
280
  // record has no stored value for.
277
- function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
281
+ export function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
278
282
  const query = useQuery("config:query:values", {}, { enabled: tenantFieldNames.length > 0 });
279
283
  const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
280
284
  const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
@@ -481,7 +485,11 @@ export function mergeSearchParamsIntoInitial(fields, sources) {
481
485
  continue;
482
486
  if (shape.format === "password")
483
487
  continue;
484
- 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) {
485
493
  merged[name] = drawerOverrides[name];
486
494
  continue;
487
495
  }
@@ -662,7 +670,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
662
670
  }
663
671
  return out;
664
672
  }, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
665
- const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
673
+ const formSchema = useMemo(() => buildFormSchema(entity, screen, initial), [entity, screen, initial]);
666
674
  // Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
667
675
  // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
668
676
  const extensionInitialValues = useMemo(() => {
@@ -695,6 +703,24 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
695
703
  // pattern as ProjectionDetailBody.headerActions above (the edited record
696
704
  // stands in for the "row"), minus the cross-feature defaultEditAction
697
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]);
698
724
  const buildSectionActions = useCallback((actions) => buildRecordActions({
699
725
  actions,
700
726
  record,
@@ -704,33 +730,38 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
704
730
  dispatcher,
705
731
  openDrawer,
706
732
  onWriteSuccess: onReload,
733
+ onRecordLeft: handleRecordLeft,
707
734
  defaultScreenTargetEntityId: entityId,
708
- }), [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
+ ]);
709
747
  const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
710
748
  const handleSubmitted = useCallback((result) => {
711
749
  if (!result.isSuccess)
712
750
  return;
713
751
  const redirect = screen.redirect;
714
752
  if (redirect !== undefined) {
715
- if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
716
- navigateToReturn(nav, returnTarget);
717
- }
718
- else if (typeof redirect === "string") {
719
- // String form unchanged: navigates without an entityId, same as
720
- // before the object form existed. The object form resolves like
721
- // actionForm's — the update handler's success payload usually
722
- // reports only this record's own id (event-store-executor-write.ts),
723
- // so a parent FK named by `idFrom` falls back to the already-loaded
724
- // `record`.
725
- nav.navigate({ screenId: lastSegment(redirect) });
726
- }
727
- else {
728
- const { screenId, entityId: targetEntityId } = resolveRedirectTarget(redirect, result.data, schema, appFeatures, record);
729
- nav.navigate({
730
- screenId,
731
- ...(targetEntityId !== undefined && { entityId: targetEntityId }),
732
- });
733
- }
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
+ });
734
765
  }
735
766
  else {
736
767
  navigateToReturnOr(nav, returnTarget, navigateToList);
@@ -1236,26 +1267,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1236
1267
  };
1237
1268
  }
1238
1269
  if (action.kind === "drawer") {
1239
- const drawerAction = action;
1240
- const actionVisible = action.visible;
1241
- const actionIcon = resolveActionIcon(action.id, action.icon);
1242
- return {
1243
- id: action.id,
1244
- label: effectiveTranslate(action.label),
1245
- ...(action.style !== undefined && { style: action.style }),
1246
- ...(action.display !== undefined && { display: action.display }),
1247
- confirmRequired: false,
1248
- ...(actionIcon !== undefined && { icon: actionIcon }),
1249
- onTrigger: (row) => {
1250
- const initialValues = drawerAction.params !== undefined
1251
- ? evalRowExtractor(drawerAction.params, row.values)
1252
- : undefined;
1253
- openDrawer(drawerAction, initialValues);
1254
- },
1255
- ...(actionVisible !== undefined && {
1256
- isVisible: (row) => evalFieldCondition(actionVisible, row.values),
1257
- }),
1258
- };
1270
+ return buildDrawerRowAction(action, effectiveTranslate, openDrawer);
1259
1271
  }
1260
1272
  if (dispatcher === undefined)
1261
1273
  return null;
@@ -1840,7 +1852,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1840
1852
  // useQuery call re-runs too (it has no `live` subscription of its own).
1841
1853
  const [reloadNonce, setReloadNonce] = useState(0);
1842
1854
  const reloadDetail = useCallback(async () => {
1843
- await detailQuery.refetch();
1855
+ await refetchAfterWrite(detailQuery.refetch);
1844
1856
  setReloadNonce((n) => n + 1);
1845
1857
  }, [detailQuery.refetch]);
1846
1858
  // Header actions render directly via RenderEditActionButton (fw#2713,
@@ -1859,7 +1871,49 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1859
1871
  const userRoles = useUserRoles();
1860
1872
  const dispatcher = useOptionalDispatcher();
1861
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;
1862
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
+ ]);
1863
1917
  const editScreen = useMemo(() => {
1864
1918
  const detailFor = screen.detailFor;
1865
1919
  if (detailFor === undefined)
@@ -1907,6 +1961,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1907
1961
  dispatcher,
1908
1962
  openDrawer,
1909
1963
  onWriteSuccess: detailQuery.refetch,
1964
+ onRecordLeft: handleRecordLeft,
1910
1965
  sameEntityScreenId,
1911
1966
  }), [
1912
1967
  detailQuery.data,
@@ -1916,6 +1971,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1916
1971
  host,
1917
1972
  dispatcher,
1918
1973
  openDrawer,
1974
+ handleRecordLeft,
1919
1975
  sameEntityScreenId,
1920
1976
  ]);
1921
1977
  const headerActions = useMemo(() => {
@@ -1929,6 +1985,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1929
1985
  dispatcher,
1930
1986
  openDrawer,
1931
1987
  onWriteSuccess: detailQuery.refetch,
1988
+ onRecordLeft: handleRecordLeft,
1932
1989
  sameEntityScreenId,
1933
1990
  });
1934
1991
  if (defaultEditAction === undefined || declaredHasEdit)
@@ -1944,8 +2001,25 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1944
2001
  detailQuery.data,
1945
2002
  detailQuery.refetch,
1946
2003
  openDrawer,
2004
+ handleRecordLeft,
1947
2005
  sameEntityScreenId,
1948
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]);
1949
2023
  if (effectiveEntityId === undefined && screen.singleton !== true) {
1950
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."] }));
1951
2025
  }
@@ -1959,18 +2033,11 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1959
2033
  if (!record) {
1960
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."] })) }));
1961
2035
  }
1962
- const hasHeader = screen.header !== undefined;
1963
- const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
1964
- // ?? [] rather than threading `headerActions !== undefined` through every
1965
- // use below — an empty array is a safe no-op for .map/.length.
1966
- const headerActionsList = headerActions ?? [];
1967
- const hasHeaderActions = headerActionsList.length > 0;
1968
2036
  // Grouped into the head Card alongside title/status/metrics (fw#2713):
1969
2037
  // these are actions on the record the head shows, not on whichever tab is
1970
2038
  // open, so they must stay in place across tab switches instead of
1971
2039
  // trailing the active tab's content in the card footer.
1972
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 }) }));
1973
- const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
1974
2041
  // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
1975
2042
  // this shares the same page padding/width as the record's edit card below
1976
2043
  // it, instead of sitting flush against the screen edge (fw record-screen
@@ -2032,7 +2099,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2032
2099
  ...(count !== undefined && { count }),
2033
2100
  };
2034
2101
  }), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
2035
- 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 ||
2036
2103
  hasTabs ||
2037
2104
  screen.slots?.header !== undefined ||
2038
2105
  screen.recordTitleField !== undefined) && {
@@ -2046,18 +2113,24 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2046
2113
  function redirectScreenTarget(redirect) {
2047
2114
  return typeof redirect === "string" ? redirect : redirect.screen;
2048
2115
  }
2049
- // Checks this schema first, then every mounted feature — the target may live
2050
- // 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.
2051
2121
  function findRedirectTargetScreen(redirect, schema, appFeatures) {
2052
2122
  const redirectScreen = redirectScreenTarget(redirect);
2053
2123
  const targetId = lastSegment(redirectScreen);
2054
2124
  const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
2055
- return (schema.screens.find((s) => lastSegment(s.id) === targetId) ??
2056
- (targetFeatureName !== undefined
2057
- ? appFeatures
2058
- .find((f) => f.featureName === targetFeatureName)
2059
- ?.screens.find((s) => lastSegment(s.id) === targetId)
2060
- : 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);
2061
2134
  }
2062
2135
  // A redirect to a record screen ("show the result") is a deliberate forward
2063
2136
  // navigation and keeps precedence over returnTo.
@@ -2092,6 +2165,33 @@ function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fal
2092
2165
  (fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
2093
2166
  return { screenId: targetId, entityId };
2094
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
+ }
2095
2195
  // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
2096
2196
  // RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
2097
2197
  // Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
@@ -2203,6 +2303,12 @@ const CONFIG_SCOPE_SOURCE = {
2203
2303
  tenant: "tenant-row",
2204
2304
  system: "system-row",
2205
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
+ }
2206
2312
  function ConfigEditBody({ schema, screen, translate, }) {
2207
2313
  const { ConfigCascadeView } = usePrimitives();
2208
2314
  const t = useTranslation();
@@ -2235,6 +2341,15 @@ function ConfigEditBody({ schema, screen, translate, }) {
2235
2341
  continue;
2236
2342
  }
2237
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
+ }
2238
2353
  if (stored === undefined) {
2239
2354
  out[shortName] = defaults[shortName];
2240
2355
  continue;
@@ -2253,7 +2368,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
2253
2368
  }
2254
2369
  }
2255
2370
  return out;
2256
- }, [valuesQuery.data, screen.fields, screen.configKeys]);
2371
+ }, [valuesQuery.data, screen.fields, screen.configKeys, screen.scope]);
2257
2372
  // Cascade-Lookup: qualifiedKey → ConfigCascade für die
2258
2373
  // Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
2259
2374
  // damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
@@ -2288,6 +2403,22 @@ function ConfigEditBody({ schema, screen, translate, }) {
2288
2403
  continue;
2289
2404
  const fieldDef = screen.fields[shortName];
2290
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
+ }
2291
2422
  // A query-backed select is cleared to "" when its dependent value became
2292
2423
  // invalid. config:write:set would store that as an empty override (the
2293
2424
  // badge shows "Mandant" with no value); reset falls back to the inherited
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;
package/dist/app/nav.js CHANGED
@@ -9,8 +9,21 @@ import { lastSegment } from "./qn.js";
9
9
  // Alles nach den ersten 2/3 Segmenten wird ignoriert — kumikos
10
10
  // Navigation-Grammatik nested nicht weiter. Nested-Routes wäre eine
11
11
  // Spec-Änderung, nicht ein URL-Shape-Unfall.
12
+ function decodeSegments(segments) {
13
+ try {
14
+ return segments.map((segment) => decodeURIComponent(segment));
15
+ }
16
+ catch {
17
+ // Malformed percent-escape ("%zz") — treat the URL as no route.
18
+ return undefined;
19
+ }
20
+ }
12
21
  export function parsePath(pathname, hasWorkspaces) {
13
- const parts = pathname.split("/").filter((p) => p !== "");
22
+ const rawParts = pathname.split("/").filter((p) => p !== "");
23
+ // Only the segments the grammar uses are decoded; ignored trailing ones may be junk.
24
+ const parts = decodeSegments(rawParts.slice(0, hasWorkspaces === true ? 3 : 2));
25
+ if (parts === undefined)
26
+ return undefined;
14
27
  if (hasWorkspaces === true) {
15
28
  const [workspaceId, screenId, entityId] = parts;
16
29
  if (workspaceId === undefined || workspaceId === "")
@@ -41,17 +54,17 @@ export function formatPath(target) {
41
54
  segments.push(target.screenId);
42
55
  if (target.entityId !== undefined)
43
56
  segments.push(target.entityId);
44
- return `/${segments.join("/")}`;
57
+ return `/${segments.map((segment) => encodeURIComponent(segment)).join("/")}`;
45
58
  }
46
59
  // Shared entity→detailFor-screen lookup — resolveTarget's ObjectTarget
47
60
  // branch and hasDetailScreen (create-app's row-click default, fw#2164) both
48
61
  // need "is there a detail screen for this entity", one to resolve it, the
49
62
  // other to just check before falling back to a different default.
50
- function findDetailForScreen(features, entity) {
63
+ export function findDetailForScreen(features, entity) {
51
64
  for (const feature of features) {
52
65
  for (const screen of feature.screens) {
53
66
  if (screen.detailFor === entity)
54
- return { featureName: feature.featureName, screenId: screen.id };
67
+ return { featureName: feature.featureName, screenId: screen.id, screen };
55
68
  }
56
69
  }
57
70
  return undefined;
@@ -2,14 +2,21 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
2
  // One bridge component per reference facet, mirroring ReferenceLookupBridge —
3
3
  // facet count varies per screen, so the hook can't be called in a loop.
4
4
  import { useEffect } from "react";
5
+ import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
5
6
  import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
6
7
  function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, onOptions, }) {
7
8
  const lookup = useReferenceLookup(refFeature, refEntity, labelField);
8
9
  useEffect(() => {
9
10
  if (lookup.loading)
10
11
  return;
12
+ // The lookup is one capped list fetch; a full page means the facet may be
13
+ // missing values the user wants to filter by.
14
+ if (lookup.map.size >= REFERENCE_LIST_LOOKUP_LIMIT) {
15
+ // biome-ignore lint/suspicious/noConsole: dev-warning, facet options are silently truncated
16
+ console.warn(`[kumiko] reference facet "${field}" shows only the first ${REFERENCE_LIST_LOOKUP_LIMIT} ${refEntity} rows; further values cannot be filtered by`);
17
+ }
11
18
  onOptions(field, [...lookup.map.entries()].map(([value, label]) => ({ value, label })));
12
- }, [lookup.loading, lookup.map, field, onOptions]);
19
+ }, [lookup.loading, lookup.map, field, refEntity, onOptions]);
13
20
  return null;
14
21
  }
15
22
  // One invisible bridge per "reference" facet spec, each publishing its