@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.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 (97) hide show
  1. package/dist/app/action-form-body.d.ts +50 -0
  2. package/dist/app/action-form-body.js +275 -0
  3. package/dist/app/client-actions.js +2 -2
  4. package/dist/app/config-edit-body.d.ts +9 -0
  5. package/dist/app/config-edit-body.js +340 -0
  6. package/dist/app/entity-edit-prefill.d.ts +27 -0
  7. package/dist/app/entity-edit-prefill.js +377 -0
  8. package/dist/app/entity-edit-screen.d.ts +11 -0
  9. package/dist/app/entity-edit-screen.js +395 -0
  10. package/dist/app/entity-list-screen.d.ts +10 -0
  11. package/dist/app/entity-list-screen.js +547 -0
  12. package/dist/app/kumiko-screen.d.ts +1 -36
  13. package/dist/app/kumiko-screen.js +16 -2855
  14. package/dist/app/projection-detail-body.d.ts +12 -0
  15. package/dist/app/projection-detail-body.js +464 -0
  16. package/dist/app/projection-detail-shim.d.ts +1 -1
  17. package/dist/app/projection-detail-shim.js +5 -1
  18. package/dist/app/projection-list-body.d.ts +10 -0
  19. package/dist/app/projection-list-body.js +245 -0
  20. package/dist/app/projection-list-shim.d.ts +2 -1
  21. package/dist/app/projection-list-shim.js +4 -0
  22. package/dist/app/reference-facet-bridge.js +2 -2
  23. package/dist/app/row-actions.js +3 -4
  24. package/dist/app/screen-shared.d.ts +72 -0
  25. package/dist/app/screen-shared.js +325 -0
  26. package/dist/app/secret-mint-body.js +3 -3
  27. package/dist/components/edit-mounts.d.ts +33 -0
  28. package/dist/components/edit-mounts.js +53 -0
  29. package/dist/components/error-boundary.js +2 -2
  30. package/dist/components/reference-create-dialog.js +1 -1
  31. package/dist/components/related-list-section.js +2 -2
  32. package/dist/components/render-edit-helpers.d.ts +29 -0
  33. package/dist/components/render-edit-helpers.js +121 -0
  34. package/dist/components/render-edit.d.ts +1 -4
  35. package/dist/components/render-edit.js +41 -443
  36. package/dist/components/render-field.js +3 -4
  37. package/dist/components/render-list.js +2 -2
  38. package/dist/components/use-wizard-form-draft.d.ts +51 -0
  39. package/dist/components/use-wizard-form-draft.js +291 -0
  40. package/dist/dev-console.d.ts +3 -0
  41. package/dist/dev-console.js +15 -0
  42. package/dist/headless/contracts/primitives.js +14 -17
  43. package/dist/headless/format/index.js +3 -3
  44. package/dist/headless/view-model/edit.d.ts +1 -1
  45. package/dist/headless/view-model/edit.js +9 -16
  46. package/dist/headless/view-model/list.d.ts +1 -1
  47. package/dist/headless/view-model/list.js +3 -8
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +5 -5
  50. package/dist/live/error-mapping.js +3 -4
  51. package/dist/web/app/create-app.js +5 -8
  52. package/dist/web/app/dashboard-body.js +4 -599
  53. package/dist/web/app/dashboard-panels.d.ts +53 -0
  54. package/dist/web/app/dashboard-panels.js +373 -0
  55. package/dist/web/app/dashboard-shared.d.ts +90 -0
  56. package/dist/web/app/dashboard-shared.js +171 -0
  57. package/dist/web/app/dashboard-text.d.ts +6 -0
  58. package/dist/web/app/dashboard-text.js +65 -0
  59. package/dist/web/layout/nav-tree.js +3 -4
  60. package/dist/web/layout/target-resolver-stub.js +2 -2
  61. package/dist/web/primitives/banner.d.ts +4 -0
  62. package/dist/web/primitives/banner.js +28 -0
  63. package/dist/web/primitives/button.d.ts +7 -0
  64. package/dist/web/primitives/button.js +48 -0
  65. package/dist/web/primitives/card-styles.d.ts +8 -0
  66. package/dist/web/primitives/card-styles.js +18 -0
  67. package/dist/web/primitives/card.d.ts +4 -0
  68. package/dist/web/primitives/card.js +41 -0
  69. package/dist/web/primitives/contexts.d.ts +22 -0
  70. package/dist/web/primitives/contexts.js +50 -0
  71. package/dist/web/primitives/data-table-cell.d.ts +76 -0
  72. package/dist/web/primitives/data-table-cell.js +403 -0
  73. package/dist/web/primitives/data-table.d.ts +4 -0
  74. package/dist/web/primitives/data-table.js +279 -0
  75. package/dist/web/primitives/default-form.d.ts +4 -0
  76. package/dist/web/primitives/default-form.js +79 -0
  77. package/dist/web/primitives/facet-filter.d.ts +14 -0
  78. package/dist/web/primitives/facet-filter.js +39 -0
  79. package/dist/web/primitives/field.d.ts +63 -0
  80. package/dist/web/primitives/field.js +181 -0
  81. package/dist/web/primitives/form.d.ts +53 -0
  82. package/dist/web/primitives/form.js +252 -0
  83. package/dist/web/primitives/index.d.ts +7 -30
  84. package/dist/web/primitives/index.js +27 -2006
  85. package/dist/web/primitives/input.d.ts +4 -0
  86. package/dist/web/primitives/input.js +166 -0
  87. package/dist/web/primitives/layout-elements.d.ts +14 -0
  88. package/dist/web/primitives/layout-elements.js +194 -0
  89. package/dist/web/primitives/modal.js +1 -1
  90. package/dist/web/primitives/pager.d.ts +22 -0
  91. package/dist/web/primitives/pager.js +89 -0
  92. package/dist/web/primitives/row-actions.d.ts +13 -0
  93. package/dist/web/primitives/row-actions.js +178 -0
  94. package/dist/web/primitives/secret-reveal.d.ts +4 -0
  95. package/dist/web/primitives/secret-reveal.js +67 -0
  96. package/package.json +5 -5
  97. package/src/changes.json +6 -0
@@ -1,61 +1,28 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, isRecordLocked, isWriteFormEditSection, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
4
- import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
5
- import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
- import { isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
7
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
- import { withDerivedValues } from "../components/form-derivation-patch.js";
9
- import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
10
- import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
11
- import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
12
- import { RenderList } from "../components/render-list.js";
13
- import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
14
- import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
2
+ import { useEffect, useMemo, useRef } from "react";
15
3
  import { useUserRoles } from "../context/user-roles-context.js";
16
- import { useOptionalCurrentUser } from "../current-user.js";
17
- import { applyFormatSpec, buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "../headless/index.js";
18
- import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
19
- import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
20
4
  import { useQuery } from "../hooks/use-query.js";
21
- import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
22
- import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
23
- import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
24
- import { requestLeave } from "../leave-guard.js";
25
- import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
26
- import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../primitives.js";
27
- import { screenFillsHeight } from "../screen-fills-height.js";
28
- import { actionFormSubmittableFields, synthesizeActionFormEntity, synthesizeActionFormScreen, } from "./action-form-shim.js";
29
- import { ActionFormLoadStatus, ActionFormSuccessBanner, isSecretFormField, resolveActionFormRecordId, resolveSuccessFacts, useActionFormLoad, } from "./action-form-support.js";
30
- import { useAppFeatures } from "./app-features-context.js";
31
- import { useClientActionRuntime } from "./client-actions.js";
32
- import { buildConfigEditSchema, synthesizeConfigEditEntity, synthesizeConfigEditScreen, } from "./config-edit-shim.js";
5
+ import { useTranslation } from "../i18n.js";
6
+ import { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
7
+ import { usePrimitives } from "../primitives.js";
8
+ import { ActionFormBody } from "./action-form-body.js";
9
+ import { ConfigEditBody } from "./config-edit-body.js";
33
10
  import { useCustomScreenComponent } from "./custom-screens.js";
34
11
  import { useDashboardBody } from "./dashboard-body.js";
35
- import { applyEditPrefill } from "./edit-prefill.js";
36
- import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
37
- import { ErrorReference } from "./error-reference.js";
38
- import { FacetCountBridges } from "./facet-count-bridge.js";
39
- import { buildFormSchema } from "./form-schema.js";
40
- import { HeaderActionsBar } from "./header-actions-bar.js";
41
- import { hiddenRequiredFieldsFilled, layoutEditFields, layoutFieldNames } from "./layout-fields.js";
42
- import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
43
- import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
44
- import { useInitialValuesHandoff, useNav } from "./nav.js";
45
- import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
46
- import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim.js";
47
- import { lastSegment, toKebab } from "./qn.js";
48
- import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id.js";
49
- import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
50
- import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
51
- import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
52
- import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, findRowClickAction, navigateActionSearchParams, refetchAfterWrite, runDrawerRowAction, runProjectionRowNavigate, } from "./row-actions.js";
53
- import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access.js";
12
+ import { EntityEditScreen } from "./entity-edit-screen.js";
13
+ import { EntityListScreen } from "./entity-list-screen.js";
14
+ import { ProjectionDetailBody } from "./projection-detail-body.js";
15
+ import { ProjectionListBody } from "./projection-list-body.js";
16
+ import { lastSegment } from "./qn.js";
17
+ import { qualifyScreenId } from "./qualify-screen-id.js";
18
+ import { QueryLoadingBanner } from "./query-state-banners.js";
19
+ import { ReturnHostProvider, useReturnHost } from "./return-to.js";
20
+ import { screenAccessAllows } from "./screen-access.js";
21
+ import { ScreenAccessDeniedBanner, ScreenMissingState } from "./screen-shared.js";
54
22
  import { SecretMintBody } from "./secret-mint-body.js";
55
23
  import { SecretsEditBody } from "./secrets-edit-body.js";
56
24
  import { useEmbeddedScreen } from "./use-embedded-screen.js";
57
25
  import { evalVisibleWhenAfterShown } from "./visible-when.js";
58
- import { dispatcherErrorText, WriteFailedError } from "./write-failed-error.js";
59
26
  export { qualifyScreenId };
60
27
  /** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
61
28
  * Callers building QNs (e.g. the WorkspaceShell resolver) should use this instead of string-concat, so a QN-schema change only touches one place. */
@@ -122,33 +89,6 @@ function LockedScreenHeader({ standInScreenId }) {
122
89
  return null;
123
90
  return standInScreenId !== undefined ? (_jsx(PageHeader, { hideBreadcrumb: true, breadcrumbScreenId: standInScreenId })) : (_jsx(PageHeader, { hideBreadcrumb: true }));
124
91
  }
125
- function ScreenMissingHomeButton({ homeScreenId, testId, }) {
126
- const { Button } = usePrimitives();
127
- const t = useTranslation();
128
- const nav = useNav();
129
- return (_jsx(Button, { variant: "secondary", onClick: () => nav.navigate({ screenId: homeScreenId }), testId: testId, children: t("kumiko.screen-missing.home") }));
130
- }
131
- /** Empty state for a screen QN that is not in the schema (feature disabled for
132
- * the tenant, stale link, miswired screen). The QN only goes to the console. */
133
- export function ScreenMissingState({ screenQn, homeScreenId, testId = "kumiko-screen-not-found", }) {
134
- const { Card, Text } = usePrimitives();
135
- const t = useTranslation();
136
- useEffect(() => {
137
- console.warn(`[kumiko] screen "${screenQn}" is not in the schema (feature disabled, stale link or miswired screen).`);
138
- }, [screenQn]);
139
- return (_jsx(Card, { options: { padded: true }, slots: {
140
- title: _jsx(Text, { testId: `${testId}-title`, children: t("kumiko.screen-missing.title") }),
141
- subtitle: _jsx(Text, { variant: "muted", children: t("kumiko.screen-missing.hint") }),
142
- }, testId: testId, children: homeScreenId !== undefined ? (_jsx(ScreenMissingHomeButton, { homeScreenId: homeScreenId, testId: `${testId}-home` })) : null }));
143
- }
144
- function ScreenAccessDeniedBanner({ screenQn, testId, }) {
145
- const { Banner } = usePrimitives();
146
- const t = useTranslation();
147
- useEffect(() => {
148
- console.warn(`[kumiko] access to screen "${screenQn}" denied for the current roles.`);
149
- }, [screenQn]);
150
- return (_jsx(Banner, { padded: true, variant: "error", testId: testId, children: t("kumiko.screen-access-denied.title") }));
151
- }
152
92
  function ScreenUnavailableBanner() {
153
93
  const { Banner } = usePrimitives();
154
94
  const t = useTranslation();
@@ -216,2782 +156,3 @@ function CustomScreenBody({ screenId, featureName, }) {
216
156
  }
217
157
  return _jsx(Component, {});
218
158
  }
219
- // ---- entity-edit ----
220
- // Derives `<feature>:write:<entity>:<verb>` from the screen's entity
221
- // and the schema's feature name. Matches qualifyEntityName / toKebab in
222
- // packages/framework/src/engine/qualified-name.ts so camelCase entity
223
- // ids (e.g. driverModel) resolve to server QNs (driver-model:create).
224
- function entityWriteCommand(featureName, entity, verb) {
225
- return `${toKebab(featureName)}:write:${toKebab(entity)}:${verb}`;
226
- }
227
- // Default "success → zurück zur Liste"-Navigation. Findet den ersten
228
- // entityList-Screen für die Entity und navigiert dahin. Wird von
229
- // Create/Update/Delete genauso verwendet — alle drei haben "fertig
230
- // editiert, raus hier" als sinnvolles Default-Verhalten.
231
- function findEntityListScreen(schema, entityName) {
232
- return schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
233
- }
234
- // Unknown or deleted record id: a localized empty state with a way back
235
- // instead of a bare error banner. Composed from shared primitives so web and
236
- // native render it alike.
237
- function RecordNotFoundState({ translate, onBack, }) {
238
- const { Card, Text, Button } = usePrimitives();
239
- return (_jsxs(Card, { options: { padded: true }, testId: "kumiko-screen-record-missing", children: [_jsx(Text, { testId: "kumiko-screen-record-missing-title", children: translate("kumiko.record.not-found.title") }), _jsx(Text, { variant: "muted", children: translate("kumiko.record.not-found.hint") }), onBack !== undefined && (_jsx(Button, { variant: "secondary", onClick: onBack, testId: "kumiko-screen-record-missing-back", children: translate("kumiko.record.not-found.back") }))] }));
240
- }
241
- function useNavigateToListAfter(schema, entityName) {
242
- const nav = useNav();
243
- return useCallback(() => {
244
- const list = findEntityListScreen(schema, entityName);
245
- if (!list)
246
- return;
247
- // schema.screens.id ist QN-form (registry-stamped); nav.navigate
248
- // erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
249
- nav.navigate({ screenId: lastSegment(list.id) });
250
- }, [nav, schema, entityName]);
251
- }
252
- // Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
253
- // entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
254
- // Returns undefined wenn kein Edit-Screen registriert ist — RenderList
255
- // rendert dann keinen + Neu Button.
256
- //
257
- // Schema-Screens kommen mit qualifizierten ids ("publicstatus:screen:
258
- // component-edit") aus der Registry; lastSegment strippt den Prefix
259
- // für nav.navigate (siehe ./qn.ts für Doku).
260
- function pageSizeOptionsFor(currentLimit) {
261
- return PAGE_SIZE_OPTIONS.includes(currentLimit)
262
- ? PAGE_SIZE_OPTIONS
263
- : [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
264
- }
265
- function useNavigateToCreateFor(schema, entityName, createScreen, createUnavailable) {
266
- const nav = useNav();
267
- const host = useReturnHost();
268
- const editScreenId = useMemo(() => {
269
- if (createScreen !== undefined)
270
- return createScreen;
271
- // The role may not open the declared create target; falling back to the
272
- // entity's edit form would bypass the flow the app prescribes (e.g. a wizard).
273
- if (createUnavailable === true)
274
- return undefined;
275
- // allowCreate:false = update-only Edit-Screen (Create läuft über einen
276
- // Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
277
- // öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
278
- // statt eines leeren Create-Forms — zählt aus demselben Grund nicht.
279
- const edit = schema.screens.find((s) => s.type === "entityEdit" &&
280
- s.entity === entityName &&
281
- s.allowCreate !== false &&
282
- s.singleton !== true);
283
- return edit !== undefined ? lastSegment(edit.id) : undefined;
284
- }, [schema.screens, entityName, createScreen, createUnavailable]);
285
- const navigate = useCallback(() => {
286
- if (editScreenId !== undefined)
287
- navigateWithReturnTo(nav, { screenId: editScreenId }, host);
288
- }, [nav, editScreenId, host]);
289
- return editScreenId !== undefined ? navigate : undefined;
290
- }
291
- // `defaultCurrency` is entityEdit-only — it enables the `{amount, currency}`
292
- // write shape (fw#1923); config-edit's plain-number contract needs bare `0`.
293
- // `moneyCurrencyOverrides` (fw#2933) replaces `defaultCurrency` per field name
294
- // — used for a `money` field that declares `currency: { kind: "tenant" }`, so
295
- // its empty initial value carries the tenant's own currency instead of the
296
- // entity-wide default.
297
- function versionOf(record) {
298
- const version = record["version"];
299
- return typeof version === "number" ? version : 1;
300
- }
301
- export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides) {
302
- const out = {};
303
- for (const [name, def] of Object.entries(fields)) {
304
- const type = propertyOf(def, "type");
305
- const defaultValue = propertyOf(def, "default");
306
- if (defaultValue !== undefined) {
307
- out[name] = defaultValue;
308
- continue;
309
- }
310
- // An untouched optional number must not persist as a deliberate 0.
311
- if (type === "number" && propertyOf(def, "required") !== true) {
312
- out[name] = undefined;
313
- continue;
314
- }
315
- if (type === "embedded" && propertyOf(def, "multiple") === true) {
316
- out[name] = [];
317
- continue;
318
- }
319
- if (type === "money") {
320
- const currency = moneyCurrencyOverrides?.[name] ?? defaultCurrency;
321
- if (currency !== undefined) {
322
- out[name] = { amount: 0, currency };
323
- continue;
324
- }
325
- }
326
- out[name] =
327
- type === "boolean"
328
- ? false
329
- : type === "number" || type === "money"
330
- ? 0
331
- : type === "multiSelect"
332
- ? []
333
- : "";
334
- }
335
- return out;
336
- }
337
- // A `money` field opts a currently-empty value into the tenant-settings
338
- // bundle's per-tenant currency (fw#2933) via `currency: { kind: "tenant" }`
339
- // on its FieldDefinition. Read structurally — renderer only depends on the
340
- // client-safe FieldDefinition subset, not the concrete MoneyFieldDef/
341
- // MoneyCurrencySource types, same idiom as the other field-shape narrowings
342
- // in this file (PrefillFieldShape etc.).
343
- export function tenantCurrencyMoneyFieldNames(fields) {
344
- const names = [];
345
- for (const [name, def] of Object.entries(fields)) {
346
- if (propertyOf(def, "type") === "money" &&
347
- propertyOf(propertyOf(def, "currency"), "kind") === "tenant") {
348
- names.push(name);
349
- }
350
- }
351
- return names;
352
- }
353
- // Resolves what a tenant-declared money field's currency should be, from the
354
- // same `config:query:values` query ConfigEditBody already uses. Never falls
355
- // back to "EUR" while the query is genuinely still in flight (fw#2933) — a
356
- // screen with no tenant-declared money field, or a query that errored or
357
- // returned no value for the key, resolves immediately instead of blocking.
358
- function resolveTenantCurrency(fieldNames, query, fallback) {
359
- if (fieldNames.length === 0)
360
- return { status: "not-needed" };
361
- if (query.error) {
362
- return fallback === undefined
363
- ? { status: "unresolved" }
364
- : { status: "ready", currency: fallback };
365
- }
366
- if (query.data === null)
367
- return { status: "loading" };
368
- const raw = query.data[TENANT_CURRENCY_CONFIG_KEY]?.value;
369
- if (typeof raw === "string" && raw !== "")
370
- return { status: "ready", currency: raw };
371
- return fallback === undefined
372
- ? { status: "unresolved" }
373
- : { status: "ready", currency: fallback };
374
- }
375
- const HEADER_CARD_DEFAULT_WIDTH = "4xl";
376
- // Shared by the update payload shape and the server-error path prefix so both can't drift.
377
- const UPDATE_CHANGES_KEY = "changes";
378
- // A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
379
- // fw#2839) resolves with no query at all. Read structurally, same idiom as
380
- // tenantCurrencyMoneyFieldNames above.
381
- export function literalCurrencyOverrides(fields) {
382
- const out = {};
383
- for (const [name, def] of Object.entries(fields)) {
384
- const currency = propertyOf(def, "currency");
385
- const code = propertyOf(currency, "code");
386
- if (propertyOf(def, "type") === "money" &&
387
- propertyOf(currency, "kind") === "literal" &&
388
- typeof code === "string") {
389
- out[name] = code;
390
- }
391
- }
392
- return out;
393
- }
394
- // Per-field currency for the money fields that declare their own source: a
395
- // literal code resolves synchronously (fw#2839), a `{ kind: "tenant" }` field
396
- // needs the tenant-settings config query and makes the caller hold the form
397
- // until it lands (fw#2933) — seeding the fallback meanwhile would render, and
398
- // on a fast click submit, the wrong currency. Callers pass the tenant field
399
- // names themselves because an edit form only needs the fetch for fields the
400
- // record has no stored value for.
401
- export function useMoneyCurrencyOverrides(fields, tenantFieldNames,
402
- // `undefined` = no safe last resort (entity-less forms): an unresolvable
403
- // tenant currency is reported via `unresolved` instead of guessing a code.
404
- fallback) {
405
- const query = useQuery("config:query:values", { keys: [TENANT_CURRENCY_CONFIG_KEY] }, { enabled: tenantFieldNames.length > 0 });
406
- const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
407
- const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
408
- const overrides = useMemo(() => {
409
- const out = { ...literalCurrencyOverrides(fields) };
410
- if (resolvedTenantCurrency !== undefined) {
411
- for (const name of tenantFieldNames)
412
- out[name] = resolvedTenantCurrency;
413
- }
414
- return Object.keys(out).length > 0 ? out : undefined;
415
- }, [fields, tenantFieldNames, resolvedTenantCurrency]);
416
- return {
417
- overrides,
418
- loading: resolution.status === "loading",
419
- unresolved: resolution.status === "unresolved",
420
- };
421
- }
422
- function multiSelectOptionValues(shape) {
423
- if (shape.options === undefined || shape.options.length === 0)
424
- return undefined;
425
- return new Set(shape.options.map((o) => (typeof o === "string" ? o : o.value)));
426
- }
427
- // The currency now comes from a user-controlled URL param and reaches
428
- // `Intl.NumberFormat`, which throws a RangeError on a malformed code — reject
429
- // it here instead of crashing the form render.
430
- const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/;
431
- function isMoneyValue(value) {
432
- if (typeof value !== "object" || value === null)
433
- return false;
434
- const amount = propertyOf(value, "amount");
435
- const currency = propertyOf(value, "currency");
436
- return (typeof amount === "number" &&
437
- Number.isFinite(amount) &&
438
- typeof currency === "string" &&
439
- CURRENCY_CODE_PATTERN.test(currency));
440
- }
441
- const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
442
- function formatSummaryValue(value, locale) {
443
- if (isMoneyValue(value)) {
444
- return new Intl.NumberFormat(locale, { style: "currency", currency: value.currency }).format(value.amount);
445
- }
446
- if (typeof value === "number")
447
- return applyFormatSpec({ format: "number", locale }, value);
448
- if (typeof value === "string" && ISO_DATE_PATTERN.test(value)) {
449
- return applyFormatSpec({ format: "date", locale }, value);
450
- }
451
- return value === null || value === undefined ? "" : String(value);
452
- }
453
- function formatSummaryParams(prefill, locale) {
454
- return Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
455
- }
456
- // Placeholders of `successMessage`: formatted submitted values, with a
457
- // reference field resolved to the chosen record's label. A lookup that fails or
458
- // misses the row (the lookup list is capped) falls back to the raw id.
459
- async function resolveSuccessMessageParams(screen, featureName, values, dispatcher, locale) {
460
- const params = formatSummaryParams(values, locale);
461
- await Promise.all(Object.entries(screen.fields).map(async ([name, def]) => {
462
- const raw = values[name];
463
- if (def.type !== "reference" || typeof raw !== "string" || raw === "")
464
- return;
465
- const target = parseRefTarget(def.entity, featureName);
466
- const { queryQn, labelKey } = referenceLookupSource(target.featureName, target.entityName, def.labelField ?? "id");
467
- const lookup = await dispatcher.query(queryQn, { limit: REFERENCE_LIST_LOOKUP_LIMIT });
468
- if (!lookup.isSuccess)
469
- return;
470
- const row = lookup.data.rows.find((candidate) => candidate["id"] === raw);
471
- const label = row?.[labelKey];
472
- if (label !== undefined && label !== null)
473
- params[name] = String(label);
474
- }));
475
- return params;
476
- }
477
- function resolveActionFormSummary(summary, prefill, translate, locale) {
478
- if (summary === undefined)
479
- return undefined;
480
- const params = formatSummaryParams(prefill, locale);
481
- return {
482
- title: translate(summary.title, params),
483
- ...(summary.subtitle !== undefined && { subtitle: translate(summary.subtitle, params) }),
484
- };
485
- }
486
- function parseJsonOrRaw(raw) {
487
- try {
488
- return JSON.parse(raw);
489
- }
490
- catch {
491
- return raw;
492
- }
493
- }
494
- function warnPrefillParam(message) {
495
- // biome-ignore lint/suspicious/noConsole: dev-warning for an authoring error
496
- console.warn(`[kumiko] ${message}`);
497
- }
498
- // An actionForm has no entity and thus no defaultCurrency, so a bare-number
499
- // prefill would stay a number the handler's zod schema rejects (fw#2763).
500
- function coerceMoneyValue(raw, fieldName, defaultCurrency) {
501
- if (raw === null || raw === undefined)
502
- return undefined;
503
- const value = typeof raw === "string" ? parseJsonOrRaw(raw) : raw;
504
- if (value === null)
505
- return undefined;
506
- if (isMoneyValue(value))
507
- return { amount: value.amount, currency: value.currency.toUpperCase() };
508
- if (typeof value === "object") {
509
- warnPrefillParam(`money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`);
510
- return undefined;
511
- }
512
- if (typeof value === "string" && value.trim() === "")
513
- return undefined;
514
- const amount = Number(typeof value === "number" ? value : raw);
515
- if (!Number.isFinite(amount))
516
- return undefined;
517
- if (defaultCurrency !== undefined)
518
- return { amount, currency: defaultCurrency };
519
- warnPrefillParam(`money field "${fieldName}" was prefilled with a bare number and no currency is available — pass {"amount":<number>,"currency":"<ISO code>"} as the param value, otherwise the handler's schema rejects the submit.`);
520
- return amount;
521
- }
522
- // Mirrors isRepresentableAtScale in the framework's schema-builder, which the
523
- // renderer cannot import (server engine). Keep the tolerance identical so a
524
- // prefilled cell never passes here and fails the handler's schema.
525
- function isRepresentableAtScale(value, scale) {
526
- const scaled = value * 10 ** scale;
527
- const tolerance = Math.abs(scaled) * 8 * Number.EPSILON + Number.EPSILON;
528
- return Math.abs(scaled - Math.round(scaled)) <= tolerance;
529
- }
530
- // Server schema (`z.iso.datetime()`) accepts only a `Z` suffix, while
531
- // Temporal.Instant.from also takes numeric offsets.
532
- function isUtcIsoInstant(value) {
533
- if (!value.endsWith("Z"))
534
- return false;
535
- try {
536
- Temporal.Instant.from(value);
537
- return true;
538
- }
539
- catch {
540
- return false;
541
- }
542
- }
543
- const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
544
- // The server schema (`z.string().date()`) rejects calendar-invalid days like 2020-02-31.
545
- function isValidIsoDate(value) {
546
- if (!ISO_DATE_PATTERN.test(value))
547
- return false;
548
- try {
549
- Temporal.PlainDate.from(value, { overflow: "reject" });
550
- return true;
551
- }
552
- catch {
553
- return false;
554
- }
555
- }
556
- function isDecimalWithinSafeRange(value, scale) {
557
- return scale === undefined || Math.abs(value) <= Number.MAX_SAFE_INTEGER / 10 ** scale;
558
- }
559
- function coerceEmbeddedCell(value, cell) {
560
- switch (cell.type) {
561
- case "number":
562
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
563
- case "decimal":
564
- return typeof value === "number" &&
565
- Number.isFinite(value) &&
566
- (cell.scale === undefined ||
567
- (isRepresentableAtScale(value, cell.scale) &&
568
- isDecimalWithinSafeRange(value, cell.scale)))
569
- ? value
570
- : undefined;
571
- case "date":
572
- return typeof value === "string" && isValidIsoDate(value) ? value : undefined;
573
- case "timestamp":
574
- return typeof value === "string" && isUtcIsoInstant(value) ? value : undefined;
575
- case "money":
576
- // Row cells are signed minor units; the currency lives on the head, so a
577
- // top-level `{amount, currency}` in major units would land 100x off.
578
- return typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
579
- case "boolean":
580
- return typeof value === "boolean" ? value : undefined;
581
- case "select":
582
- return typeof value === "string" && (cell.options ?? []).includes(value) ? value : undefined;
583
- default:
584
- return typeof value === "string" ? value : undefined;
585
- }
586
- }
587
- // A partially coerced list is the hazard the prefill exists to avoid: the
588
- // handler replaces the list whole, so half a list overwrites the rest.
589
- function coerceEmbeddedListRows(raw, fieldName, schema, maxItems) {
590
- const parsed = parseJsonOrRaw(raw);
591
- if (!Array.isArray(parsed)) {
592
- warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with something that is not a JSON array — the prefill is ignored.`);
593
- return undefined;
594
- }
595
- if (parsed.length > (maxItems ?? EMBEDDED_LIST_PREFILL_ROW_LIMIT)) {
596
- warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with ${parsed.length} rows, more than it accepts — the prefill is ignored.`);
597
- return undefined;
598
- }
599
- const rows = [];
600
- for (const candidate of parsed) {
601
- if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
602
- warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with a row that is not an object — the prefill is ignored.`);
603
- return undefined;
604
- }
605
- const cellEntries = [];
606
- for (const [cellName, cell] of Object.entries(schema)) {
607
- const cellValue = propertyOf(candidate, cellName);
608
- if (cellValue === undefined || cellValue === null)
609
- continue;
610
- const coerced = coerceEmbeddedCell(cellValue, cell);
611
- if (coerced === undefined) {
612
- warnPrefillParam(`embedded-list field "${fieldName}" was prefilled with a cell "${cellName}" the sub-schema rejects — the prefill is ignored.`);
613
- return undefined;
614
- }
615
- cellEntries.push([cellName, coerced]);
616
- }
617
- rows.push(Object.fromEntries(cellEntries));
618
- }
619
- return rows;
620
- }
621
- function coercePrefillString(raw, name, shape, fallback, defaultCurrency, moneyCurrencyOverrides) {
622
- if (shape.type === "number") {
623
- if (raw.trim() === "")
624
- return fallback;
625
- const parsed = Number(raw);
626
- return Number.isFinite(parsed) ? parsed : fallback;
627
- }
628
- if (shape.type === "money") {
629
- const coerced = coerceMoneyValue(raw, name, moneyCurrencyOverrides?.[name] ?? defaultCurrency);
630
- return coerced === undefined ? fallback : coerced;
631
- }
632
- if (shape.type === "boolean")
633
- return raw === "true";
634
- if (shape.type === "multiSelect") {
635
- // Row-action navigate stringifies arrays via String(arr) → "a,b" (or
636
- // JSON when the navigate helper JSON.stringifies). Try JSON first
637
- // (even without a "[" prefix), then comma-split; filter against
638
- // field.options when present so unknown values never reach the form.
639
- const optionValues = multiSelectOptionValues(shape);
640
- const splitComma = () => raw === ""
641
- ? []
642
- : raw
643
- .split(",")
644
- .map((s) => s.trim())
645
- .filter(Boolean);
646
- let values;
647
- try {
648
- const parsed = JSON.parse(raw);
649
- if (Array.isArray(parsed)) {
650
- values = parsed.map((v) => String(v));
651
- }
652
- else if (typeof parsed === "string") {
653
- values = [parsed];
654
- }
655
- else {
656
- values = splitComma();
657
- }
658
- }
659
- catch {
660
- values = splitComma();
661
- }
662
- return optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
663
- }
664
- if (shape.type === "embedded" && shape.multiple === true) {
665
- return coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems) ?? fallback;
666
- }
667
- return raw;
668
- }
669
- export function mergeSearchParamsIntoInitial(fields, sources) {
670
- const { searchParams, renderableFields, defaultCurrency, moneyCurrencyOverrides, drawerOverrides, handoffValues, } = sources;
671
- const urlPrefillFields = new Set(sources.urlPrefillFields ?? []);
672
- const defaults = buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides);
673
- const merged = { ...defaults };
674
- for (const [name, fieldDef] of Object.entries(fields)) {
675
- if (renderableFields !== undefined && !renderableFields.has(name))
676
- continue;
677
- const shape = fieldDef;
678
- // Neither gate is lifted by an allowlist entry or a handoff.
679
- if (shape.sensitive === true)
680
- continue;
681
- if (shape.format === "password")
682
- continue;
683
- // An `undefined` override (row-extractor key whose row lacks the field)
684
- // must not wipe the field default.
685
- if (drawerOverrides !== undefined &&
686
- Object.hasOwn(drawerOverrides, name) &&
687
- drawerOverrides[name] !== undefined) {
688
- merged[name] = drawerOverrides[name];
689
- continue;
690
- }
691
- const handedOff = handoffValues !== undefined && Object.hasOwn(handoffValues, name)
692
- ? handoffValues[name]
693
- : undefined;
694
- if (handedOff !== undefined) {
695
- merged[name] =
696
- typeof handedOff === "string"
697
- ? coercePrefillString(handedOff, name, shape, defaults[name], defaultCurrency, moneyCurrencyOverrides)
698
- : handedOff;
699
- continue;
700
- }
701
- if (!urlPrefillFields.has(name))
702
- continue;
703
- const raw = searchParams[name];
704
- if (raw === undefined)
705
- continue;
706
- merged[name] = coercePrefillString(raw, name, shape, defaults[name], defaultCurrency, moneyCurrencyOverrides);
707
- }
708
- return merged;
709
- }
710
- function EntityEditScreen({ schema, screen, translate, entityId, onCopyLink, }) {
711
- const { Banner, Text } = usePrimitives();
712
- const entity = schema.entities[screen.entity];
713
- if (!entity) {
714
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-entity-missing", children: ["Entity ", _jsx(Text, { variant: "code", children: screen.entity }), " referenced by screen", " ", _jsx(Text, { variant: "code", children: screen.id }), " not registered in the schema."] }));
715
- }
716
- // Split into create-body / update-body so the update-only hooks
717
- // (useQuery(detail)) don't fire in create mode, and vice versa.
718
- // Same shape as EntityListScreen.
719
- if (entityId !== undefined) {
720
- return (_jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: entityId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }) }));
721
- }
722
- if (screen.singleton === true) {
723
- return (_jsx(EntityEditSingletonBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }) }));
724
- }
725
- return (_jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }) }));
726
- }
727
- function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
728
- const nav = useNav();
729
- const handoffValues = useInitialValuesHandoff(screen.id);
730
- const appFeatures = useAppFeatures();
731
- const returnTargetParam = useReturnTarget(screen.id);
732
- // A singleton stays on its own screen after saving (onSaved/refetch) —
733
- // returnTo would fight that.
734
- const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
735
- const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
736
- // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
737
- // its empty-value currency from the tenant-settings config key instead of
738
- // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
739
- // without any such field.
740
- const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields), [entity.fields]);
741
- const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
742
- const currentUser = useOptionalCurrentUser();
743
- const fallbackTimeZone = useOptionalTimeZone();
744
- const initial = useMemo(() => applyEditPrefill(mergeSearchParamsIntoInitial(entity.fields, {
745
- searchParams: nav.searchParams,
746
- urlPrefillFields: screen.urlPrefillFields,
747
- renderableFields: layoutFieldNames(screen),
748
- defaultCurrency: entityDefaultCurrency,
749
- ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
750
- ...(handoffValues !== undefined && { handoffValues }),
751
- }), layoutEditFields(screen), { now: new Date(), fallbackTimeZone, user: currentUser }), [
752
- entity.fields,
753
- nav.searchParams,
754
- screen,
755
- entityDefaultCurrency,
756
- moneyCurrencyOverrides,
757
- handoffValues,
758
- currentUser,
759
- fallbackTimeZone,
760
- ]);
761
- const formSchema = useMemo(() => buildFormSchema(entity, screen, "create"), [entity, screen]);
762
- const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
763
- const navigateToList = useNavigateToListAfter(schema, screen.entity);
764
- const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
765
- // Create's write-handler success payload doesn't flatly expose a parent FK
766
- // (`{ kind, id, data, … }`), so an object-form redirect's `idFrom` falls
767
- // back to the values just submitted to the handler.
768
- const controlsRef = useRef(null);
769
- const handleControlsReady = useCallback((controls) => {
770
- controlsRef.current = controls;
771
- }, []);
772
- const handleSubmitted = useCallback((result) => {
773
- if (!result.isSuccess)
774
- return;
775
- const redirect = screen.redirect;
776
- if (redirect !== undefined) {
777
- if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
778
- navigateToReturn(nav, returnTarget);
779
- }
780
- else if (typeof redirect === "string") {
781
- // String form unchanged: always carries the newly created
782
- // record's own id, regardless of the target screen's type. The
783
- // object form resolves like actionForm's — a child record's own id
784
- // is useless for a parent-detail redirect.
785
- const entityId = extractCreatedId(result.data);
786
- nav.navigate({
787
- screenId: lastSegment(redirect),
788
- ...(entityId !== undefined && { entityId }),
789
- });
790
- }
791
- else {
792
- navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, result.data, schema, appFeatures, controlsRef.current?.getValues()));
793
- }
794
- }
795
- else {
796
- navigateToReturnOr(nav, returnTarget, navigateToList);
797
- onSaved?.();
798
- }
799
- }, [nav, screen.redirect, schema, appFeatures, navigateToList, onSaved, returnTarget]);
800
- // Never seed a tenant-declared money field with entityDefaultCurrency while
801
- // its real tenant currency is still in flight (fw#2933) — wait instead.
802
- if (currencyLoading) {
803
- return _jsx(QueryLoadingBanner, {});
804
- }
805
- // Deliberately no `actions` prop here: `screen.actions` targets an
806
- // EXISTING record (publish/archive/duplicate and friends), which the
807
- // create branch has none of yet — see EntityEditUpdateForm for the
808
- // wired-up counterpart.
809
- return (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial, schema: formSchema, writeCommand: writeCommand, onSubmit: handleSubmitted, onControlsReady: handleControlsReady, onCancel: handleCancel, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }) }));
810
- }
811
- // Update body: loads the existing record via `<feature>:query:<entity>:detail`,
812
- // then mounts a form pre-filled with the server values and dispatches
813
- // `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
814
- // snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
815
- function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
816
- const t = useTranslation();
817
- const effectiveTranslate = translate ?? t;
818
- const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
819
- const detailQuery = useQuery(detailQn, { id: entityId });
820
- const nav = useNav();
821
- const navigateToList = useNavigateToListAfter(schema, screen.entity);
822
- const returnTargetParam = useReturnTarget(screen.id);
823
- const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
824
- if (detailQuery.loading && detailQuery.data === null) {
825
- return _jsx(QueryLoadingBanner, {});
826
- }
827
- if (detailQuery.error && detailQuery.data === null) {
828
- return (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch }));
829
- }
830
- // A failed refetch keeps the last good data on screen.
831
- const refetchErrorBanner = detailQuery.error ? (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch })) : null;
832
- const record = detailQuery.data;
833
- if (!record) {
834
- const onBack = returnTarget !== undefined || findEntityListScreen(schema, screen.entity) !== undefined
835
- ? () => navigateToReturnOr(nav, returnTarget, navigateToList)
836
- : undefined;
837
- return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(onBack !== undefined && { onBack }) }));
838
- }
839
- // Record-version als React-key: bei "Neu laden" refetched detail,
840
- // liefert neue version, und das Form remountet komplett. Ohne den
841
- // Key-Wechsel bliebe der useForm-Controller lifetime-scoped auf
842
- // der ursprünglichen version sitzen — der buildPayload würde
843
- // weiter die stale version stampen und der Konflikt-Recovery wäre
844
- // kaputt.
845
- const recordKey = versionOf(record);
846
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditUpdateForm, { schema: schema, screen: screen, entity: entity, entityId: entityId, record: record, onReload: detailQuery.refetch, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() }), ...(onDeleted !== undefined && { onDeleted }) }, `${entityId}:${recordKey}`)] }));
847
- }
848
- function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onReload, translate, onCopyLink, onSaved, onDeleted, }) {
849
- // Seed the form with the server values for the entity's declared
850
- // fields; anything else (id, tenant_id, created_at…) stays out of
851
- // the form and lives in the closure. The record's `version` is
852
- // captured once and stamped into every update payload — if a
853
- // concurrent writer bumps it, the server returns a version-conflict
854
- // error and the user reloads.
855
- const recordVersion = versionOf(record);
856
- const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
857
- // A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
858
- // its empty-value currency from the tenant-settings config key instead of
859
- // entityDefaultCurrency — `enabled` keeps the query a no-op for entities
860
- // without any such field. A record's own stored value (below) always wins
861
- // over this, regardless of the declaration.
862
- //
863
- // Only fields the record itself has no value for need the fetch at all —
864
- // an already-set tenant-declared field keeps its own stored currency and
865
- // must never block the form on this query.
866
- const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(entity.fields).filter((name) => record[name] === null || record[name] === undefined), [entity.fields, record]);
867
- const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
868
- const initial = useMemo(() => {
869
- const out = {};
870
- for (const name of Object.keys(entity.fields)) {
871
- out[name] =
872
- record[name] ??
873
- buildInitialValues({ [name]: entity.fields[name] }, entityDefaultCurrency, moneyCurrencyOverrides)[name];
874
- }
875
- return out;
876
- }, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
877
- const formSchema = useMemo(() => buildFormSchema(entity, screen, "update", initial), [entity, screen, initial]);
878
- // Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
879
- // damit sie beim Edit den Bestand zeigen statt write-only zu sein.
880
- const extensionInitialValues = useMemo(() => {
881
- const cf = record["customFields"];
882
- return isPlainObject(cf) ? cf : undefined;
883
- }, [record]);
884
- const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "update");
885
- const deleteCommand = entityWriteCommand(schema.featureName, screen.entity, "delete");
886
- const buildPayload = useMemo(() => (snap) => ({
887
- id: entityId,
888
- version: recordVersion,
889
- [UPDATE_CHANGES_KEY]: snap.changes,
890
- }), [entityId, recordVersion]);
891
- const [formDirty, setFormDirty] = useState(false);
892
- const nav = useNav();
893
- const appFeatures = useAppFeatures();
894
- const dispatcher = useDispatcher();
895
- const clientActions = useClientActionRuntime();
896
- const t = useTranslation();
897
- const effectiveTranslate = translate ?? t;
898
- const navigateToList = useNavigateToListAfter(schema, screen.entity);
899
- const userRoles = useUserRoles();
900
- const host = useReturnHost();
901
- const returnTargetParam = useReturnTarget(screen.id);
902
- // A singleton stays on its own screen after saving (onSaved/refetch) —
903
- // returnTo would fight that.
904
- const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
905
- const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
906
- // Header action buttons (fw entityEdit-actions) — same shape/dispatch
907
- // pattern as ProjectionDetailBody.headerActions above (the edited record
908
- // stands in for the "row"), minus the cross-feature defaultEditAction
909
- // lookup that doesn't apply here (this screen already IS the edit form).
910
- const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
911
- // Never return onto the just-deleted record.
912
- const target = deletedShownRecord && returnTarget?.entityId === entityId ? undefined : returnTarget;
913
- if (deletedShownRecord)
914
- onDeleted?.();
915
- if (action.redirect !== undefined) {
916
- navigateAfterRedirect({
917
- nav,
918
- schema,
919
- appFeatures,
920
- returnTarget: target,
921
- redirect: action.redirect,
922
- resultData,
923
- record,
924
- });
925
- return;
926
- }
927
- navigateToReturnOr(nav, target, () => {
928
- if (findEntityListScreen(schema, screen.entity) !== undefined)
929
- navigateToList();
930
- else if (onDeleted === undefined)
931
- void onReload();
932
- });
933
- }, [
934
- nav,
935
- schema,
936
- appFeatures,
937
- returnTarget,
938
- record,
939
- entityId,
940
- navigateToList,
941
- onDeleted,
942
- onReload,
943
- screen.entity,
944
- ]);
945
- const sameEntityScreenId = useCallback((targetScreen) => isEntityEditScreenOfOtherEntity(screen.entity, targetScreen, schema, appFeatures)
946
- ? undefined
947
- : entityId, [screen.entity, schema, appFeatures, entityId]);
948
- const buildSectionActions = useCallback((actions) => buildRecordActions({
949
- actions,
950
- record,
951
- translate: effectiveTranslate,
952
- nav,
953
- host,
954
- dispatcher,
955
- openDrawer,
956
- onWriteSuccess: onReload,
957
- onRecordLeft: handleRecordLeft,
958
- sameEntityScreenId,
959
- clientActions,
960
- defaultWritePayloadId: entityId,
961
- ...(formDirty && {
962
- discardChanges: {
963
- confirm: effectiveTranslate("kumiko.form.unsavedChangesActionConfirm"),
964
- confirmLabel: effectiveTranslate("kumiko.form.unsavedChangesActionConfirmLabel"),
965
- },
966
- }),
967
- }), [
968
- formDirty,
969
- record,
970
- effectiveTranslate,
971
- nav,
972
- host,
973
- dispatcher,
974
- openDrawer,
975
- onReload,
976
- handleRecordLeft,
977
- sameEntityScreenId,
978
- clientActions,
979
- entityId,
980
- ]);
981
- const headerActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement !== "menu")), [buildSectionActions, screen.actions]);
982
- const headerMenuActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement === "menu")), [buildSectionActions, screen.actions]);
983
- const handleSubmitted = useCallback((result) => {
984
- if (!result.isSuccess)
985
- return;
986
- const redirect = screen.redirect;
987
- if (redirect !== undefined) {
988
- // The update handler's success payload usually reports only this
989
- // record's own id (event-store-executor-write.ts), so a parent FK
990
- // named by `idFrom` falls back to the already-loaded `record`.
991
- navigateAfterRedirect({
992
- nav,
993
- schema,
994
- appFeatures,
995
- returnTarget,
996
- redirect,
997
- resultData: result.data,
998
- record: overlayUpdatedProjection(record, result.data),
999
- });
1000
- }
1001
- else {
1002
- navigateToReturnOr(nav, returnTarget, navigateToList);
1003
- onSaved?.();
1004
- }
1005
- }, [nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget]);
1006
- const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
1007
- const handleDelete = useCallback(async () => {
1008
- const res = await dispatcher.write(deleteCommand, { id: entityId });
1009
- if (!res.isSuccess)
1010
- return res.error;
1011
- // Never return onto the just-deleted record.
1012
- const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
1013
- navigateToReturnOr(nav, target, navigateToList);
1014
- onDeleted?.();
1015
- return undefined;
1016
- }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
1017
- // Never seed a tenant-declared money field with entityDefaultCurrency while
1018
- // its real tenant currency is still in flight (fw#2933) — wait instead. A
1019
- // stored (non-empty) value is unaffected — it never reaches this branch's
1020
- // fallback because `initial` above already prefers `record[name]`.
1021
- if (currencyLoading) {
1022
- return _jsx(QueryLoadingBanner, {});
1023
- }
1024
- // Evaluated against the stored record only, so typing in a form can never
1025
- // flip the screen between editable and view.
1026
- // Either condition shows the view: the server rejects saves of a locked record
1027
- // whatever the screen says, so a screen condition can only add read-only, never lift it.
1028
- // A record without the lock field counts as locked (isRecordLocked), matching the server.
1029
- const viewMode = (screen.readOnly !== undefined && evalFieldCondition(screen.readOnly, record)) ||
1030
- isRecordLocked(entity.lockedWhen, record);
1031
- return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: screen, entity: entity, featureName: schema.featureName, initial: initial, ...(viewMode && { viewMode: true }),
1032
- // Real route id for the extension section (set-value UI): the update
1033
- // form deliberately keeps `id` out of the form values, so the section
1034
- // needs it explicitly — otherwise it renders in create mode.
1035
- entityId: entityId,
1036
- // Stored customFields for the extension section, so editing shows the
1037
- // persisted values instead of behaving write-only.
1038
- extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, onDirtyChange: setFormDirty, ...(screen.allowDelete !== false && !viewMode && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), ...(headerMenuActions !== undefined && { menuActions: headerMenuActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1039
- closeDrawer();
1040
- void onReload();
1041
- } })] }));
1042
- }
1043
- // Singleton entities (`singleton: true`, exactly one record per tenant):
1044
- // EntityEditScreen reaches here only without an entityId. Resolve the
1045
- // existing record via list(limit:1) before deciding create vs update —
1046
- // otherwise every visit without an id would create another record.
1047
- function EntityEditSingletonBody({ schema, screen, entity, translate, onCopyLink, }) {
1048
- const t = useTranslation();
1049
- const effectiveTranslate = translate ?? t;
1050
- const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
1051
- const listQuery = useQuery(listQn, { limit: 1 });
1052
- if (listQuery.loading && listQuery.data === null) {
1053
- return _jsx(QueryLoadingBanner, {});
1054
- }
1055
- if (listQuery.error && listQuery.data === null) {
1056
- return (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch }));
1057
- }
1058
- // A failed refetch keeps the last good data on screen.
1059
- const refetchErrorBanner = listQuery.error ? (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch })) : null;
1060
- const rawExistingId = listQuery.data?.rows[0]?.["id"];
1061
- const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
1062
- if (existingId !== undefined) {
1063
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditUpdateBody, { schema: schema, screen: screen, entity: entity, entityId: existingId, ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), autoReloadOnSave: true, onDeleted: () => void listQuery.refetch() })] }));
1064
- }
1065
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(EntityEditCreateOrDisabled, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), onSaved: () => void listQuery.refetch() })] }));
1066
- }
1067
- // Shared no-entityId tail for both the plain create path and the
1068
- // singleton path's empty-table fallback: `allowCreate: false` blocks
1069
- // both the same way, since a create submit there would hit an
1070
- // unregistered `<entity>:create` handler.
1071
- function EntityEditCreateOrDisabled({ schema, screen, entity, translate, onSaved, }) {
1072
- const { Banner, Text } = usePrimitives();
1073
- if (screen.allowCreate === false) {
1074
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-create-disabled", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " is update-only (allowCreate: false) \u2014 open it from a row action with an entity id."] }));
1075
- }
1076
- return (_jsx(EntityEditCreateBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onSaved !== undefined && { onSaved }) }));
1077
- }
1078
- // ---- entity-list ----
1079
- function entityQueryCommand(featureName, entity, verb) {
1080
- return `${toKebab(featureName)}:query:${toKebab(entity)}:${verb}`;
1081
- }
1082
- // Payload for the server-side list query handler (LIST_PAYLOAD_SCHEMA):
1083
- // search/sort/sortDirection/limit + offset/totalCount for pager mode OR
1084
- // cursor for infinite scroll. Shared by EntityListBody and
1085
- // ProjectionListBody so the two branches can't drift on this shape
1086
- // (fw#2165) — entity-only additions (screen.filter, faceted filters) are
1087
- // layered on top by the caller instead of living in here.
1088
- export function buildListQueryPayload(state) {
1089
- const payload = { limit: state.limit };
1090
- if (state.search !== "")
1091
- payload["search"] = state.search;
1092
- if (state.sort !== null) {
1093
- payload["sort"] = state.sort.field;
1094
- payload["sortDirection"] = state.sort.dir;
1095
- }
1096
- if (state.usePager) {
1097
- // page=1 → offset=0, page=2 → offset=limit, etc. Server clamps itself
1098
- // when offset >= total.
1099
- const offset = (state.page - 1) * state.limit;
1100
- if (offset > 0)
1101
- payload["offset"] = offset;
1102
- // totalCount: extra COUNT(*) so the pager can render "Page X of Y".
1103
- payload["totalCount"] = true;
1104
- }
1105
- else if (state.useInfinite && state.cursor !== undefined) {
1106
- payload["cursor"] = state.cursor;
1107
- }
1108
- return payload;
1109
- }
1110
- // entityList adapter — one DataTableFacet per filterable select/boolean
1111
- // entity field, labels via the standard field/option i18n convention.
1112
- function resolveEntityFacetSpecs(fields, featureName, entityName, translate) {
1113
- const out = [];
1114
- for (const [field, rawDef] of Object.entries(fields)) {
1115
- // entity.fields ist am Renderer-Layer schwach getypt (Record<string,
1116
- // unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
1117
- const def = rawDef;
1118
- if (def.filterable !== true)
1119
- continue;
1120
- const label = translate(fieldLabelKey(featureName, entityName, field));
1121
- if (def.type === "select" && Array.isArray(def.options)) {
1122
- out.push({
1123
- field,
1124
- type: "select",
1125
- label,
1126
- options: def.options.map((value) => ({
1127
- value,
1128
- label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
1129
- })),
1130
- });
1131
- }
1132
- else if (def.type === "boolean") {
1133
- out.push({
1134
- field,
1135
- type: "boolean",
1136
- label,
1137
- options: [
1138
- {
1139
- value: "true",
1140
- label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
1141
- },
1142
- {
1143
- value: "false",
1144
- label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
1145
- },
1146
- ],
1147
- });
1148
- }
1149
- else if (def.type === "reference" && def.entity !== undefined) {
1150
- const target = parseRefTarget(def.entity, featureName);
1151
- out.push({
1152
- field,
1153
- type: "reference",
1154
- label,
1155
- options: [],
1156
- reference: {
1157
- refEntity: target.entityName,
1158
- refFeature: target.featureName,
1159
- labelField: def.labelField ?? "id",
1160
- },
1161
- });
1162
- }
1163
- }
1164
- return out;
1165
- }
1166
- function useDrawerAction(schema) {
1167
- const [drawerState, setDrawerState] = useState(null);
1168
- const drawerAction = drawerState?.action ?? null;
1169
- const drawerScreen = useMemo(() => {
1170
- if (drawerAction === null)
1171
- return undefined;
1172
- // Same same-feature, short-id resolution as runNavigate — the drawer's
1173
- // `screen` reference is scoped to schema.screens, not cross-feature.
1174
- return schema.screens.find((s) => s.type === "actionForm" && lastSegment(s.id) === drawerAction.screen);
1175
- }, [drawerAction, schema.screens]);
1176
- const openDrawer = useCallback((action, initialValues) => setDrawerState({ action, initialValues }), []);
1177
- const closeDrawer = useCallback(() => setDrawerState(null), []);
1178
- return {
1179
- drawerAction,
1180
- drawerScreen,
1181
- drawerInitialValues: drawerState?.initialValues,
1182
- openDrawer,
1183
- closeDrawer,
1184
- };
1185
- }
1186
- function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, userRoles, translate, onClose, onSuccess, }) {
1187
- const { Drawer, Dialog } = usePrimitives();
1188
- const t = useTranslation();
1189
- const effectiveTranslate = translate ?? t;
1190
- const locale = useLocale().locale();
1191
- const [hasUnsavedInput, setHasUnsavedInput] = useState(false);
1192
- const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
1193
- const closeAndReset = useCallback(() => {
1194
- setHasUnsavedInput(false);
1195
- setConfirmDiscardOpen(false);
1196
- onClose();
1197
- }, [onClose]);
1198
- const requestClose = useCallback(() => {
1199
- if (hasUnsavedInput)
1200
- setConfirmDiscardOpen(true);
1201
- else
1202
- closeAndReset();
1203
- }, [hasUnsavedInput, closeAndReset]);
1204
- // Drawer is an optional Core-Primitive (additive rollout) — same "skip +
1205
- // warn" precedent as rowActions without a mounted DispatcherProvider above,
1206
- // instead of crashing when a web app hasn't upgraded its createKumikoApp
1207
- // wiring yet. The warning repeats per mount and whenever the action changes.
1208
- // Dev-time setup error, so no dedupe.
1209
- useEffect(() => {
1210
- if (drawerAction !== null && Drawer === undefined) {
1211
- // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
1212
- console.warn(`[kumiko] toolbarAction "${drawerAction.id}" is kind:"drawer", but no <Drawer> primitive is registered — it will not open. createKumikoApp() from kumiko-renderer-web wires it automatically.`);
1213
- }
1214
- }, [drawerAction, Drawer]);
1215
- if (drawerAction === null || Drawer === undefined)
1216
- return null;
1217
- // Access mirrors kind:"navigate" exactly: the toolbar button itself stays
1218
- // visible either way (same as navigate — access is enforced at the
1219
- // target, not by hiding the trigger), but the drawer shows the same
1220
- // "Access denied" state KumikoScreen's top-level gate would show for a
1221
- // direct hit on that screen, never the form.
1222
- const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
1223
- const titleParams = formatSummaryParams(drawerInitialValues, locale);
1224
- return (_jsxs(_Fragment, { children: [_jsx(Drawer, { open: true, onOpenChange: (open) => {
1225
- if (!open)
1226
- requestClose();
1227
- }, title: effectiveTranslate(drawerAction.title ?? drawerAction.label, titleParams), ...(drawerAction.subtitle !== undefined && {
1228
- subtitle: effectiveTranslate(drawerAction.subtitle, titleParams),
1229
- }), testId: `toolbar-drawer-${drawerAction.id}`, children: _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(InsideDrawerProvider, { value: true, children: drawerScreen === undefined ? (_jsx(ScreenMissingState, { screenQn: drawerAction.screen, testId: "kumiko-toolbar-drawer-not-found" })) : !allowed ? (_jsx(ScreenAccessDeniedBanner, { screenQn: drawerAction.screen, testId: "kumiko-toolbar-drawer-access-denied" })) : (_jsx(ActionFormBody, { schema: schema, screen: drawerScreen, ...(translate !== undefined && { translate }), ...(drawerInitialValues !== undefined && {
1230
- initialOverrides: drawerInitialValues,
1231
- }), onSuccess: () => {
1232
- setHasUnsavedInput(false);
1233
- onSuccess();
1234
- }, onCancelOverride: requestClose, onDirtyChange: setHasUnsavedInput, submitLabelFallback: drawerAction.label })) }) }) }), _jsx(Dialog, { open: confirmDiscardOpen, onOpenChange: setConfirmDiscardOpen, title: t("kumiko.drawer.discard.title"), description: t("kumiko.drawer.discard.body"), confirmLabel: t("kumiko.drawer.discard.confirm"), cancelLabel: t("kumiko.drawer.discard.cancel"), variant: "danger", initialFocus: "cancel", onConfirm: closeAndReset, testId: "drawer-discard-dialog" })] }));
1235
- }
1236
- function EntityListScreen({ schema, screen, translate, onRowClick, }) {
1237
- const { Banner, Text } = usePrimitives();
1238
- const entity = schema.entities[screen.entity];
1239
- if (!entity) {
1240
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-entity-missing", children: ["Entity ", _jsx(Text, { variant: "code", children: screen.entity }), " referenced by screen", " ", _jsx(Text, { variant: "code", children: screen.id }), " not registered in the schema."] }));
1241
- }
1242
- // Entity resolved — mount the inner component so useQuery only fires
1243
- // when there's actually something to render. A missing entity is a
1244
- // dev-error state; no point hitting the server for it.
1245
- return (_jsx(EntityListBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
1246
- }
1247
- function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1248
- const isEmbeddedScreen = useIsEmbeddedScreen();
1249
- const featureName = schema.featureName;
1250
- const resolvedCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen, screen.createUnavailable);
1251
- const onCreate = screen.allowCreate === false ? undefined : resolvedCreate;
1252
- const queryType = entityQueryCommand(featureName, screen.entity, "list");
1253
- const nav = useNav();
1254
- const host = useReturnHost();
1255
- const userRoles = useUserRoles();
1256
- const appFeatures = useAppFeatures();
1257
- const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
1258
- const defaultEditScreen = useMemo(() => findEditScreenFor(screen.entity, appFeatures, userRoles), [appFeatures, screen.entity, userRoles]);
1259
- // URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
1260
- // (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
1261
- // damit zwei Lists auf derselben Route nicht über dieselben
1262
- // Query-Keys streiten. Default-Sort aus der Screen-Def gewinnt nur
1263
- // wenn URL keinen sort hat — Author-Default vs User-Choice.
1264
- const defaultFilters = useMemo(() => normalizeDefaultFilters(screen.defaultFilters), [screen.defaultFilters]);
1265
- const urlState = useListUrlState(screen.id, defaultFilters);
1266
- const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
1267
- const limit = urlState.pageSize ?? screen.pageSize ?? 50;
1268
- const paginationMode = screen.pagination ?? "pages";
1269
- const usePager = paginationMode === "pages";
1270
- const useInfinite = paginationMode === "infinite";
1271
- // Infinite-Scroll: lokaler State akkumuliert rows über mehrere
1272
- // useQuery-Aufrufe (statt replacen wie bei Pager). cursor wechselt
1273
- // wenn der User den Bottom-Sentinel erreicht; bei sort/q-Change
1274
- // resetten wir alles, da die Insert-Order der akkumulierten Rows
1275
- // sonst inkonsistent ist mit der neuen Sortierung.
1276
- const [accumulated, setAccumulated] = useState([]);
1277
- const [cursor, setCursor] = useState(undefined);
1278
- const [hasMore, setHasMore] = useState(true);
1279
- // Ref damit der useEffect-Cleanup nicht alten state sieht.
1280
- const sortQRef = useRef("");
1281
- const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
1282
- // Bei sort/q-Wechsel rows + cursor reseten — vor dem nächsten useQuery
1283
- // damit der Reload mit cursor=undefined startet.
1284
- useEffect(() => {
1285
- if (!useInfinite)
1286
- return;
1287
- if (sortQRef.current === sortQKey)
1288
- return;
1289
- sortQRef.current = sortQKey;
1290
- setAccumulated([]);
1291
- setCursor(undefined);
1292
- setHasMore(true);
1293
- }, [useInfinite, sortQKey]);
1294
- // User-gewählte Faceted-Filter aus dem URL-State → payload.filters.
1295
- // typeOf liest den Field-Type direkt aus entity.fields (nicht gated auf
1296
- // filterable — bewusst permissiv, siehe buildFilterPayload-Doc).
1297
- const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => {
1298
- // entity.fields ist am Renderer-Layer schwach getypt (vom Schema
1299
- // deserialisiert) — Boundary-Cast wie buildInitialValues.
1300
- const type = propertyOf(entity.fields[field], "type");
1301
- return typeof type === "string" ? type : undefined;
1302
- }), [urlState.filters, entity.fields]);
1303
- // Entity-only additions (screen.filter, faceted filters) layer on top of
1304
- // the shared buildListQueryPayload — projectionList has neither.
1305
- const queryPayload = useMemo(() => {
1306
- const payload = buildListQueryPayload({
1307
- limit,
1308
- search: urlState.q,
1309
- sort: effectiveSort,
1310
- usePager,
1311
- page: urlState.page,
1312
- useInfinite,
1313
- cursor,
1314
- });
1315
- // Screen-Filter (Tier 2.7c) — vom Author am Schema deklariert,
1316
- // unabhängig vom User-q-Search. Mehrere Buckets derselben Entity
1317
- // ("Upcoming" / "Active" / "Past") nutzen unterschiedliche filter
1318
- // bei gleichem Query-Handler.
1319
- if (screen.filter !== undefined) {
1320
- payload["filter"] = screen.filter;
1321
- }
1322
- if (filterPayload.length > 0) {
1323
- payload["filters"] = filterPayload;
1324
- }
1325
- return payload;
1326
- }, [
1327
- limit,
1328
- urlState.q,
1329
- effectiveSort,
1330
- screen.filter,
1331
- filterPayload,
1332
- usePager,
1333
- urlState.page,
1334
- useInfinite,
1335
- cursor,
1336
- ]);
1337
- const rowsQuery = useQuery(queryType, queryPayload, { live: true });
1338
- // Facet counts come from their own queries, which a write leaves untouched.
1339
- const [facetCountRefreshNonce, setFacetCountRefreshNonce] = useState(0);
1340
- const refreshFacetCounts = useCallback(() => setFacetCountRefreshNonce((n) => n + 1), []);
1341
- const refreshRowsAfterWrite = useCallback(async () => {
1342
- refreshFacetCounts();
1343
- if (useInfinite) {
1344
- setAccumulated([]);
1345
- setCursor(undefined);
1346
- setHasMore(true);
1347
- return;
1348
- }
1349
- await refetchAfterWrite(rowsQuery.refetch);
1350
- }, [useInfinite, rowsQuery.refetch, refreshFacetCounts]);
1351
- // Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
1352
- // hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
1353
- // initiale Loads laufen beide hier durch — das useEffect-Dep-Array
1354
- // pinnt die Dedup auf dem letzten verarbeiteten data-Pointer.
1355
- const lastDataRef = useRef(null);
1356
- useEffect(() => {
1357
- if (!useInfinite)
1358
- return;
1359
- const data = rowsQuery.data;
1360
- if (data === null)
1361
- return;
1362
- if (data === lastDataRef.current)
1363
- return;
1364
- lastDataRef.current = data;
1365
- setAccumulated((prev) => {
1366
- // Wenn cursor undefined ist, ist das die erste Page nach
1367
- // sort/q-Change → komplett ersetzen statt anhängen. Sonst dedupe
1368
- // auf id falls Live-Updates einen Eintrag in der nächsten Page
1369
- // bringen die schon angehängt war.
1370
- if (cursor === undefined)
1371
- return data.rows;
1372
- const seen = new Set(prev.map((r) => r["id"]));
1373
- const fresh = data.rows.filter((r) => !seen.has(r["id"]));
1374
- return [...prev, ...fresh];
1375
- });
1376
- setHasMore(data.nextCursor !== null);
1377
- }, [rowsQuery.data, useInfinite, cursor]);
1378
- const loadMore = useCallback(() => {
1379
- if (!useInfinite)
1380
- return;
1381
- if (rowsQuery.loading)
1382
- return;
1383
- const data = rowsQuery.data;
1384
- if (data?.nextCursor === undefined || data.nextCursor === null)
1385
- return;
1386
- setCursor(data.nextCursor);
1387
- }, [useInfinite, rowsQuery.loading, rowsQuery.data]);
1388
- // RowActions: Schema-Form (handler-QN + i18n-Key) → Resolved-Form
1389
- // (dispatcher-Call + translated Strings). dispatcher.write kennt den
1390
- // User intern (JWT-Cookie). Schema kann sowohl raw-Strings als auch
1391
- // i18n-Keys enthalten — translate() returnt den Key wenn das Bundle
1392
- // ihn nicht kennt (Convention überall im Renderer).
1393
- // Hooks-Reihenfolge: ALLE Hooks vor early-return für loading/error,
1394
- // sonst kollidieren die Hook-Slots zwischen Renders.
1395
- const t = useTranslation();
1396
- const effectiveTranslate = translate ?? t;
1397
- // Faceted-Filter: ein Dropdown pro filterable select/boolean/reference-Feld.
1398
- // Labels + select-Option-Labels über dieselbe i18n-Konvention wie die
1399
- // Spalten-Header (fieldLabelKey / :option:<value>); reference-Felder laden
1400
- // ihre Optionen asynchron über ReferenceFacetBridges.
1401
- const entityFacetSpecs = useMemo(() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate), [entity.fields, featureName, screen.entity, effectiveTranslate]);
1402
- const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
1403
- const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
1404
- const [facetCounts, setFacetCounts] = useState({});
1405
- const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
1406
- const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
1407
- const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
1408
- const filters = buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type);
1409
- return {
1410
- key: request.key,
1411
- payload: {
1412
- limit: 1,
1413
- totalCount: true,
1414
- ...(urlState.q !== "" && { search: urlState.q }),
1415
- ...(screen.filter !== undefined && { filter: screen.filter }),
1416
- ...(filters.length > 0 && { filters }),
1417
- },
1418
- };
1419
- }), [
1420
- entityFacetSpecs,
1421
- screen.facets,
1422
- screen.filter,
1423
- effectiveTranslate,
1424
- urlState.filters,
1425
- urlState.q,
1426
- entity.fields,
1427
- ]);
1428
- // Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
1429
- // bleibt rowActions undefined statt zu crashen. Echte Apps haben
1430
- // den Provider via createKumikoApp — wenn nicht, ist es vermutlich
1431
- // ein Setup-Fehler, also einmal warnen damit der Author das findet
1432
- // (sonst rendert die Action-Spalte still nichts und der "warum sind
1433
- // meine Buttons weg?"-Debug ist teuer).
1434
- const dispatcher = useOptionalDispatcher();
1435
- const clientActions = useClientActionRuntime();
1436
- const hasRowActions = screen.rowActions !== undefined && screen.rowActions.length > 0;
1437
- useEffect(() => {
1438
- if (hasRowActions && dispatcher === undefined) {
1439
- // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
1440
- console.warn(`[kumiko] Screen "${screen.id}" deklariert rowActions, aber kein <DispatcherProvider> ist mounted — die Action-Spalte wird nicht gerendert. createKumikoApp() wired den Provider automatisch.`);
1441
- }
1442
- }, [hasRowActions, dispatcher, screen.id]);
1443
- // Discriminated Union: writeHandler (default) dispatched einen Server-
1444
- // Handler, navigate ruft nav.navigate() ggf. mit URL-Search-Params aus
1445
- // params(row). nav lebt schon weiter unten in EntityListBody-Scope.
1446
- // Gemeinsame navigate-Ausführung: sowohl das "…"-Aktionsmenü (rowActions) als
1447
- // auch der Row-Body-Klick (rowClick) laufen hierdurch — ein Pfad, damit beide
1448
- // nicht auseinanderdriften.
1449
- const runNavigate = useCallback((action, row) => {
1450
- if (action.entity !== undefined) {
1451
- // Entity-Targets (fw#2228) lösen sich erst in der NavApi-Impl gegen
1452
- // ALLE Features auf (resolveTarget in renderer/web/nav.tsx) — dieses
1453
- // Package kennt nur `schema` (das eine aufrufende Feature) und kann
1454
- // detailFor cross-feature nicht selbst nachschlagen.
1455
- const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1456
- // Same-entity only — cross-entity targets must declare entityId (boot-validator).
1457
- const fallback = action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
1458
- const id = explicit ?? fallback ?? "";
1459
- if (id === "")
1460
- return;
1461
- const params = navigateActionSearchParams(action, row.values);
1462
- navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
1463
- }
1464
- else if (action.screen !== undefined) {
1465
- // Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
1466
- // entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
1467
- // bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
1468
- const targetIsEntityEdit = isEntityEditScreenOfEntity(screen.entity, action.screen, schema, appFeatures);
1469
- const explicit = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
1470
- const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
1471
- const entityId = explicit ?? fallback;
1472
- const target = {
1473
- screenId: action.screen,
1474
- ...(entityId !== undefined && entityId !== "" && { entityId }),
1475
- };
1476
- // setSearchParams nimmt string|null — komplexe Werte zu String (der Reader
1477
- // kennt via URL nur Strings). Known-edge: zielt die Action auf den
1478
- // AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
1479
- // Row-Actions praktisch nicht erreichbar, Pfad differiert).
1480
- const params = navigateActionSearchParams(action, row.values);
1481
- navigateWithReturnTo(nav, target, host, params);
1482
- }
1483
- }, [nav, schema, appFeatures, screen.entity, host]);
1484
- const rowActions = useMemo(() => {
1485
- const declared = screen.rowActions ?? [];
1486
- // Prepended unless a declared rowAction already has id "edit" — declared wins.
1487
- const declaredHasEdit = declared.some((a) => a.id === "edit");
1488
- const defaultEditRowAction = declaredHasEdit
1489
- ? undefined
1490
- : buildDefaultEditRowAction(defaultEditScreen);
1491
- const effectiveActions = defaultEditRowAction !== undefined ? [defaultEditRowAction, ...declared] : declared;
1492
- if (effectiveActions.length === 0)
1493
- return undefined;
1494
- return effectiveActions
1495
- .map((action) => {
1496
- // navigate-Variante braucht keinen Dispatcher; nav ist
1497
- // immer da (Provider von createKumikoApp).
1498
- if (action.kind === "navigate") {
1499
- const navigateAction = action;
1500
- const actionVisible = action.visible;
1501
- const actionIcon = resolveActionIcon(action.id, action.icon);
1502
- return {
1503
- id: action.id,
1504
- label: effectiveTranslate(action.label),
1505
- ...(action.style !== undefined && { style: action.style }),
1506
- ...(action.display !== undefined && { display: action.display }),
1507
- confirmRequired: false,
1508
- ...(navigateAction.rowClick === true && { rowClick: true }),
1509
- ...(actionIcon !== undefined && { icon: actionIcon }),
1510
- onTrigger: (row) => runNavigate(navigateAction, row),
1511
- ...(actionVisible !== undefined && {
1512
- isVisible: (row) => evalFieldCondition(actionVisible, row.values),
1513
- }),
1514
- };
1515
- }
1516
- if (action.kind === "drawer") {
1517
- return buildDrawerRowAction(action, effectiveTranslate, openDrawer);
1518
- }
1519
- if (action.kind === "clientAction") {
1520
- return buildClientActionRowAction(action, effectiveTranslate, clientActions, refreshRowsAfterWrite);
1521
- }
1522
- if (dispatcher === undefined)
1523
- return null;
1524
- const writeAction = action;
1525
- const writeActionVisible = writeAction.visible;
1526
- return {
1527
- id: writeAction.id,
1528
- label: effectiveTranslate(writeAction.label),
1529
- style: writeAction.style,
1530
- icon: resolveActionIcon(writeAction.id, writeAction.icon),
1531
- confirm: writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
1532
- confirmLabel: writeAction.confirmLabel !== undefined
1533
- ? effectiveTranslate(writeAction.confirmLabel)
1534
- : undefined,
1535
- onTrigger: async (row) => {
1536
- const buildPayload = writeAction.payload;
1537
- const payload = buildPayload !== undefined
1538
- ? evalRowExtractor(buildPayload, row.values)
1539
- : { id: row.values["id"] };
1540
- const result = await dispatcher.write(writeAction.handler, payload);
1541
- // write() wirft nicht — Failure-Result MUSS hier zum Error
1542
- // werden, sonst schließt der Confirm-Dialog kommentarlos und
1543
- // der User sieht "nichts passiert" (Prod-Bug 2026-06-07).
1544
- if (!result.isSuccess) {
1545
- throw new WriteFailedError(result.error, dispatcherErrorText(result.error, effectiveTranslate));
1546
- }
1547
- await refreshRowsAfterWrite();
1548
- },
1549
- isVisible: writeActionVisible !== undefined
1550
- ? (row) => evalFieldCondition(writeActionVisible, row.values)
1551
- : undefined,
1552
- };
1553
- })
1554
- .filter((a) => a !== null);
1555
- }, [
1556
- screen.rowActions,
1557
- defaultEditScreen,
1558
- effectiveTranslate,
1559
- dispatcher,
1560
- clientActions,
1561
- runNavigate,
1562
- refreshRowsAfterWrite,
1563
- openDrawer,
1564
- ]);
1565
- // ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
1566
- // projectionList and relatedList sections via buildProjectionToolbarActions.
1567
- const toolbarActions = useMemo(() => buildProjectionToolbarActions({
1568
- toolbarActions: screen.toolbarActions,
1569
- translate: effectiveTranslate,
1570
- dispatcher,
1571
- nav,
1572
- refetch: refreshRowsAfterWrite,
1573
- openDrawer,
1574
- host,
1575
- access: { appFeatures, userRoles },
1576
- }), [
1577
- screen.toolbarActions,
1578
- effectiveTranslate,
1579
- nav,
1580
- host,
1581
- dispatcher,
1582
- refreshRowsAfterWrite,
1583
- openDrawer,
1584
- appFeatures,
1585
- userRoles,
1586
- ]);
1587
- const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1588
- nav,
1589
- host,
1590
- dispatcher,
1591
- openDrawer,
1592
- onWriteSuccess: refreshRowsAfterWrite,
1593
- });
1594
- // Expandable rows: open state is keyed by row id, so it survives refetch,
1595
- // sort and page changes. Several rows can be open at once.
1596
- const expandableRow = screen.expandableRow;
1597
- const expandableSection = useMemo(() => expandableRow !== undefined
1598
- ? computeRelatedListSectionViewModel(expandableRow, effectiveTranslate)
1599
- : undefined, [expandableRow, effectiveTranslate]);
1600
- const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
1601
- const toggleRowExpanded = useCallback((rowId) => {
1602
- setExpandedRowIds((prev) => {
1603
- const next = new Set(prev);
1604
- if (next.has(rowId))
1605
- next.delete(rowId);
1606
- else
1607
- next.add(rowId);
1608
- return next;
1609
- });
1610
- }, []);
1611
- // Bumped after a write that ran outside the sub-list (drawer submit,
1612
- // title-row action) so open sub-lists remount and reload.
1613
- const [expansionNonce, setExpansionNonce] = useState(0);
1614
- const invalidateExpansions = useCallback(() => setExpansionNonce((n) => n + 1), []);
1615
- const renderExpandedRow = useCallback((row) => expandableSection === undefined ? null : (_jsx(EntityListExpandedRow, { section: expandableSection, row: row, featureName: featureName, ...(translate !== undefined && { translate }), expansionNonce: expansionNonce, openDrawer: openDrawer, onAfterWrite: refreshRowsAfterWrite, onSubListInvalidated: invalidateExpansions })), [
1616
- expandableSection,
1617
- featureName,
1618
- translate,
1619
- expansionNonce,
1620
- openDrawer,
1621
- refreshRowsAfterWrite,
1622
- invalidateExpansions,
1623
- ]);
1624
- if (rowsQuery.loading && rowsQuery.data === null) {
1625
- return _jsx(QueryLoadingBanner, {});
1626
- }
1627
- if (rowsQuery.error && rowsQuery.data === null) {
1628
- return (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch }));
1629
- }
1630
- // A failed refetch keeps the last good data on screen.
1631
- const refetchErrorBanner = rowsQuery.error ? (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch })) : null;
1632
- // RenderList's onRowClick is a 1-arg callback; KumikoScreen's
1633
- // 2-arg shape (row, entityName) is the public surface — thread the
1634
- // screen's entity through here so callers don't have to re-derive
1635
- // it.
1636
- // Row-Body-Klick: eine explizit als rowClick markierte navigate-rowAction
1637
- // gewinnt (deklarierte Author-Absicht "Klick auf die Zeile tut X"). Ohne Flag
1638
- // bleibt der Pfad unverändert — create-app's entityEdit-Default bzw. ein vom
1639
- // App-Author gesetztes onRowClick.
1640
- const rowClickAction = findRowClickAction(screen.rowActions);
1641
- const wrappedOnRowClick = rowClickAction
1642
- ? (row) => rowClickAction.kind === "drawer"
1643
- ? runDrawerRowAction(rowClickAction, openDrawer, row)
1644
- : runNavigate(rowClickAction, row)
1645
- : onRowClick !== undefined
1646
- ? (row) => onRowClick(row, screen.entity)
1647
- : undefined;
1648
- // Searchable default: explicit author choice wins, otherwise auto-on when
1649
- // the entity has searchable fields (dead toolbar slot otherwise — the
1650
- // server search index has nothing to filter). Gated on
1651
- // schema.searchAdapterMissing !== true regardless of source (explicit or
1652
- // auto) — a search bar the server can't serve would 422 at request-time
1653
- // (#2032); matches the boot-time check in api/server.ts (#2051).
1654
- const searchable = schema.searchAdapterMissing !== true &&
1655
- (screen.searchable ??
1656
- Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true));
1657
- // Pager-Props nur bei pagination="pages" zusammenstellen. Server-
1658
- // total kommt async — bis es da ist, rendert RenderList die Tabelle
1659
- // ohne Pager (Pager hat eine eigene total>0-Guard).
1660
- const total = rowsQuery.data?.total;
1661
- const pager = usePager && total !== undefined
1662
- ? {
1663
- page: urlState.page,
1664
- limit,
1665
- total,
1666
- onPageChange: urlState.setPage,
1667
- pageSizeOptions: pageSizeOptionsFor(limit),
1668
- onPageSizeChange: urlState.setPageSize,
1669
- }
1670
- : undefined;
1671
- // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1672
- // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1673
- const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
1674
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
1675
- description: effectiveTranslate(screen.description),
1676
- }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1677
- ? { screenPadding: false, chromeless: true }
1678
- : { 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 && {
1679
- onReachEnd: loadMore,
1680
- loadingMore: rowsQuery.loading,
1681
- hasMore,
1682
- }), ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1683
- filterFacets,
1684
- filterValues: urlState.filters,
1685
- onFilterChange: urlState.setFilter,
1686
- onFilterReset: urlState.clearFilters,
1687
- }), ...(expandableSection !== undefined && {
1688
- expandedRowIds,
1689
- onToggleRowExpanded: toggleRowExpanded,
1690
- renderExpandedRow,
1691
- }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1692
- closeDrawer();
1693
- invalidateExpansions();
1694
- refreshFacetCounts();
1695
- void rowsQuery.refetch();
1696
- } })] }));
1697
- }
1698
- // ---- projection-list ----
1699
- // Like entityList, but the list query comes DIRECTLY from `screen.query`
1700
- // (instead of being derived from an entity) — cross-feature capable.
1701
- // search/sort/pagination reuse the same URL-state + buildListQueryPayload
1702
- // wiring as EntityListBody; which of them are actually offered is derived
1703
- // from the query handler's Zod schema at buildAppSchema time (fw#2165), not
1704
- // declared here. Pages-mode pagination only — infinite-scroll accumulation
1705
- // isn't wired for projectionList.
1706
- function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1707
- const isEmbeddedScreen = useIsEmbeddedScreen();
1708
- const { Banner, Text } = usePrimitives();
1709
- const t = useTranslation();
1710
- const nav = useNav();
1711
- const host = useReturnHost();
1712
- const dispatcher = useOptionalDispatcher();
1713
- const clientActions = useClientActionRuntime();
1714
- const effectiveTranslate = translate ?? t;
1715
- const userRoles = useUserRoles();
1716
- const appFeatures = useAppFeatures();
1717
- const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
1718
- const defaultEditScreen = useMemo(() => {
1719
- const detailFor = screen.detailFor;
1720
- if (detailFor === undefined)
1721
- return undefined;
1722
- return findEditScreenFor(detailFor, appFeatures, userRoles);
1723
- }, [appFeatures, screen.detailFor, userRoles]);
1724
- // searchable/sortable/paginated are derived at buildAppSchema time from the
1725
- // query handler's Zod schema (fw#2165) — not authored on the screen.
1726
- const searchable = screen.searchable ?? false;
1727
- const sortable = screen.sortable ?? false;
1728
- const paginated = screen.paginated ?? false;
1729
- const entity = useMemo(() => synthesizeProjectionEntity(screen.columns, sortable), [screen.columns, sortable]);
1730
- const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
1731
- const urlState = useListUrlState(screen.id);
1732
- // Gated on the derived capability, not just on whether URL-state happens to
1733
- // carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
1734
- // screen) must not smuggle a param the query's Zod schema doesn't accept.
1735
- const activeSearch = searchable ? urlState.q : "";
1736
- // Same predicate that makes a header clickable: a stale link onto a
1737
- // sortable:false/unknown column must not reach the server (it answers 400).
1738
- const urlSort = urlState.sort;
1739
- const urlSortField = urlSort === null ? undefined : entity.fields[urlSort.field];
1740
- const urlSortAllowed = urlSortField !== undefined && "sortable" in urlSortField && urlSortField.sortable === true;
1741
- const activeSort = sortable
1742
- ? ((urlSortAllowed ? urlSort : null) ?? screen.defaultSort ?? null)
1743
- : null;
1744
- const limit = urlState.pageSize ?? screen.pageSize ?? 50;
1745
- // Pages-mode only (see header comment) — an author-set pagination:
1746
- // "infinite" on a paginated projectionList is silently a no-op today.
1747
- const usePager = paginated && (screen.pagination ?? "pages") === "pages";
1748
- // Facets (fw#2224) — a projectionList has no entity, so screen.facets is
1749
- // the only field inventory; resolveProjectionFacetSpecs reshapes +
1750
- // translates its explicit labels into the same ResolvedFacetSpec the
1751
- // entityList adapter produces, so both feed the same
1752
- // buildFilterFacets/buildFilterPayload.
1753
- const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate, schema.featureName), [screen.facets, effectiveTranslate, schema.featureName]);
1754
- const [projectionReferenceFacetOptions, setProjectionReferenceFacetOptions] = useState({});
1755
- const handleProjectionFacetOptions = useCallback((field, options) => setProjectionReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
1756
- const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, projectionReferenceFacetOptions), [facetSpecs, projectionReferenceFacetOptions]);
1757
- const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type), [urlState.filters, facetSpecs]);
1758
- // dateRange facets (fw#3104) bypass `filters` entirely — their two bounds
1759
- // become the top-level query params the facet declares, resolved from a
1760
- // calendar date to an instant in the viewer's zone.
1761
- const timeZone = useOptionalTimeZone();
1762
- const dateRangeSpecs = useMemo(() => resolveDateRangeFacets(screen.facets, effectiveTranslate), [screen.facets, effectiveTranslate]);
1763
- const dateRangePayload = useMemo(() => buildDateRangePayload(dateRangeSpecs, urlState.filters, timeZone), [dateRangeSpecs, urlState.filters, timeZone]);
1764
- const queryPayload = useMemo(() => {
1765
- const payload = buildListQueryPayload({
1766
- limit,
1767
- search: activeSearch,
1768
- sort: activeSort,
1769
- usePager,
1770
- page: urlState.page,
1771
- useInfinite: false,
1772
- cursor: undefined,
1773
- });
1774
- // Gated on screen.filter/screen.facets being declared (not just on
1775
- // urlState/filterPayload happening to carry a value) — same rule as
1776
- // activeSearch/activeSort above: a stale URL must not smuggle a param
1777
- // the bound query's Zod schema doesn't accept (fw#2165 pattern).
1778
- if (screen.filter !== undefined) {
1779
- payload["filter"] = screen.filter;
1780
- }
1781
- if (screen.facets !== undefined && filterPayload.length > 0) {
1782
- payload["filters"] = filterPayload;
1783
- }
1784
- Object.assign(payload, dateRangePayload);
1785
- return payload;
1786
- }, [
1787
- limit,
1788
- activeSearch,
1789
- activeSort,
1790
- usePager,
1791
- urlState.page,
1792
- screen.filter,
1793
- screen.facets,
1794
- filterPayload,
1795
- dateRangePayload,
1796
- ]);
1797
- const rowsQuery = useQuery(screen.query, queryPayload, { live: true });
1798
- const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
1799
- const dateRangeFacets = useMemo(() => dateRangeSpecs.map((spec) => ({
1800
- field: spec.field,
1801
- label: spec.label,
1802
- ...readDateRange(urlState.filters, spec.field),
1803
- })), [dateRangeSpecs, urlState.filters]);
1804
- const handleDateRangeChange = useCallback((field, bound, value) => {
1805
- urlState.setDateRange(field, clampDateRange(readDateRange(urlState.filters, field), bound, value));
1806
- }, [urlState.setDateRange, urlState.filters]);
1807
- // Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
1808
- // resolution happens in the NavApi impl. Unlike there: NO row["id"]
1809
- // fallback — projectionList rows come from an arbitrary query projection
1810
- // with no guaranteed "id" field. The boot validator therefore enforces an
1811
- // explicit entityId for projectionList entity targets. Same helper
1812
- // relatedList's rowActions reuse (related-list-section.tsx) — a
1813
- // projectionDetail relatedList row has the identical "no guaranteed id"
1814
- // shape.
1815
- const runNavigate = useCallback((action, row) => runProjectionRowNavigate(nav, action, row, host), [nav, host]);
1816
- const defaultEditRowAction = useMemo(() => buildDefaultEditRowAction(defaultEditScreen), [defaultEditScreen]);
1817
- const rowActions = useMemo(() => buildProjectionRowActions({
1818
- rowActions: screen.rowActions,
1819
- translate: effectiveTranslate,
1820
- dispatcher,
1821
- nav,
1822
- refetch: rowsQuery.refetch,
1823
- openDrawer,
1824
- defaultEditRowAction,
1825
- host,
1826
- clientActions,
1827
- }), [
1828
- screen.rowActions,
1829
- effectiveTranslate,
1830
- dispatcher,
1831
- clientActions,
1832
- nav,
1833
- rowsQuery.refetch,
1834
- openDrawer,
1835
- defaultEditRowAction,
1836
- host,
1837
- ]);
1838
- const toolbarActions = useMemo(() => buildProjectionToolbarActions({
1839
- toolbarActions: screen.toolbarActions,
1840
- translate: effectiveTranslate,
1841
- dispatcher,
1842
- nav,
1843
- refetch: rowsQuery.refetch,
1844
- openDrawer,
1845
- host,
1846
- access: { appFeatures, userRoles },
1847
- }), [
1848
- screen.toolbarActions,
1849
- effectiveTranslate,
1850
- nav,
1851
- host,
1852
- dispatcher,
1853
- rowsQuery.refetch,
1854
- openDrawer,
1855
- appFeatures,
1856
- userRoles,
1857
- ]);
1858
- const emptyContent = useListEmptyContent(screen.emptyState, effectiveTranslate, {
1859
- nav,
1860
- host,
1861
- dispatcher,
1862
- openDrawer,
1863
- onWriteSuccess: rowsQuery.refetch,
1864
- });
1865
- if (rowsQuery.loading && rowsQuery.data === null) {
1866
- return _jsx(QueryLoadingBanner, {});
1867
- }
1868
- if (rowsQuery.error && rowsQuery.data === null) {
1869
- return (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch }));
1870
- }
1871
- // A failed refetch keeps the last good data on screen.
1872
- const refetchErrorBanner = rowsQuery.error ? (_jsx(QueryErrorBanner, { error: rowsQuery.error, translate: effectiveTranslate, onRetry: rowsQuery.refetch })) : null;
1873
- // rowsQuery.data is typed as PagedRows but the query handler is free to
1874
- // return anything at runtime (fw#2216: a bare array silently rendered an
1875
- // empty table instead of surfacing the mismatch); a system-boundary cast
1876
- // is needed to inspect the actual wire shape before trusting it.
1877
- const rawRowsData = rowsQuery.data;
1878
- if (rawRowsData !== null && !Array.isArray(rawRowsData.rows)) {
1879
- 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 }" }), "."] }));
1880
- }
1881
- const rowClickAction = findRowClickAction(screen.rowActions);
1882
- const wrappedOnRowClick = rowClickAction
1883
- ? (row) => rowClickAction.kind === "drawer"
1884
- ? runDrawerRowAction(rowClickAction, openDrawer, row)
1885
- : runNavigate(rowClickAction, row)
1886
- : onRowClick !== undefined
1887
- ? (row) => onRowClick(row, listScreen.entity)
1888
- : undefined;
1889
- // Same pager-construction as EntityListBody: no Pager UI until the
1890
- // server-provided `total` arrives (guards pagination="pages" without
1891
- // totalCount support, see fw#2165 report on solon's leaseOverviewHandler).
1892
- const total = rowsQuery.data?.total;
1893
- const pager = usePager && total !== undefined
1894
- ? {
1895
- page: urlState.page,
1896
- limit,
1897
- total,
1898
- onPageChange: urlState.setPage,
1899
- pageSizeOptions: pageSizeOptionsFor(limit),
1900
- onPageSizeChange: urlState.setPageSize,
1901
- }
1902
- : undefined;
1903
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, narrowed: isListNarrowed(activeSearch, urlState.filters), ...(screen.description !== undefined && {
1904
- description: effectiveTranslate(screen.description),
1905
- }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
1906
- ? { screenPadding: false, chromeless: true }
1907
- : { 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 && {
1908
- filterFacets,
1909
- onFilterChange: urlState.setFilter,
1910
- }), ...(dateRangeFacets.length > 0 && {
1911
- dateRangeFacets,
1912
- onDateRangeChange: handleDateRangeChange,
1913
- }), ...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
1914
- filterValues: urlState.filters,
1915
- onFilterReset: urlState.clearFilters,
1916
- }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1917
- closeDrawer();
1918
- void rowsQuery.refetch();
1919
- } })] }));
1920
- }
1921
- // Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
1922
- // navigate execution — a metric click has the same entity/screen/params
1923
- // shape as a RowActionNavigate, minus the fields (id, label, style) that
1924
- // function never reads.
1925
- function runMetricNavigate(nav, navigate, record, host) {
1926
- // tab alone (no screen/entity) stays on the current record and just
1927
- // activates that tab — no route change, so runProjectionRowNavigate
1928
- // (which always navigates) doesn't apply here.
1929
- if (navigate.screen === undefined && navigate.entity === undefined) {
1930
- const { tab } = navigate;
1931
- if (tab !== undefined)
1932
- requestLeave(() => nav.setSearchParams({ tab }));
1933
- return;
1934
- }
1935
- const base = { kind: "navigate", id: "metric-navigate", label: "" };
1936
- const action = navigate.entity !== undefined
1937
- ? {
1938
- ...base,
1939
- entity: navigate.entity,
1940
- ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
1941
- ...(navigate.params !== undefined && { params: navigate.params }),
1942
- ...(navigate.tab !== undefined && { tab: navigate.tab }),
1943
- }
1944
- : navigate.screen !== undefined
1945
- ? {
1946
- ...base,
1947
- screen: navigate.screen,
1948
- ...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
1949
- ...(navigate.params !== undefined && { params: navigate.params }),
1950
- ...(navigate.tab !== undefined && { tab: navigate.tab }),
1951
- }
1952
- : undefined;
1953
- if (action === undefined)
1954
- return;
1955
- runProjectionRowNavigate(nav, action, { id: "", values: record }, host);
1956
- }
1957
- // Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
1958
- // stricter than isSafeHref (which also allows relative paths and mailto:),
1959
- // since a relative/mailto value here should render as plain text, not a
1960
- // "_blank" external link.
1961
- function isAbsoluteHttpUrl(value) {
1962
- return /^https?:\/\//i.test(value) && isSafeHref(value);
1963
- }
1964
- // hrefFor throws for an entity without a detailFor screen; outside
1965
- // createKumikoApp the feature list is empty, so navigateTargetAllows can't
1966
- // rule that out beforehand. The NavApi owns target resolution, so asking it
1967
- // and falling back to plain text covers both cases.
1968
- function hrefForNavigate(nav, navigate, record) {
1969
- const entityId = navigate.entityId !== undefined ? String(record[navigate.entityId] ?? "") : undefined;
1970
- try {
1971
- if (navigate.entity !== undefined) {
1972
- return entityId === undefined || entityId === ""
1973
- ? undefined
1974
- : nav.hrefFor({ entity: navigate.entity, id: entityId });
1975
- }
1976
- if (navigate.screen !== undefined) {
1977
- return nav.hrefFor({
1978
- screenId: navigate.screen,
1979
- ...(entityId !== undefined && entityId !== "" && { entityId }),
1980
- });
1981
- }
1982
- }
1983
- catch {
1984
- return undefined;
1985
- }
1986
- return undefined;
1987
- }
1988
- function buildSubtitleParts(parts, record, nav, appFeatures, userRoles, host) {
1989
- return parts.flatMap((part) => {
1990
- const field = typeof part === "string" ? part : part.field;
1991
- const text = String(record[field] ?? "");
1992
- if (text === "")
1993
- return [];
1994
- const navigate = typeof part === "string" ? undefined : part.navigate;
1995
- if (navigate === undefined || !navigateTargetAllows(navigate, appFeatures, userRoles)) {
1996
- return [{ field, text }];
1997
- }
1998
- const href = hrefForNavigate(nav, navigate, record);
1999
- if (href === undefined)
2000
- return [{ field, text }];
2001
- return [{ field, text, href, onPress: () => runMetricNavigate(nav, navigate, record, host) }];
2002
- });
2003
- }
2004
- function headerStatusTone(header, record) {
2005
- if (header.status === undefined)
2006
- return undefined;
2007
- const value = String(record[header.statusToneField ?? header.status] ?? "");
2008
- const declared = header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
2009
- ? header.statusTones[value]
2010
- : undefined;
2011
- const declaredTone = declared !== undefined ? statusToneForOptionTone(declared) : undefined;
2012
- return declaredTone ?? statusToneForValue(value);
2013
- }
2014
- function resolveSubtitleHref(header, record) {
2015
- if (header?.subtitleHref === undefined)
2016
- return undefined;
2017
- const value = record[header.subtitleHref];
2018
- return typeof value === "string" && isAbsoluteHttpUrl(value) ? value : undefined;
2019
- }
2020
- // Projection-Detail-Body — read-only single-row inspector über eine explizite
2021
- // Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
2022
- // Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
2023
- // EntityEditUpdateBody, das die Query-QN aus der Entity-Convention ableitet —
2024
- // hier ist die QN Author-explizit, wie bei ProjectionListBody). Rendert über
2025
- // RenderEdit MIT customSubmit-No-Op statt writeCommand: hasEditableSection()
2026
- // blendet den Save-Button aus (jedes Feld ist readOnly), und selbst ein
2027
- // natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
2028
- // controller.submit() ohne submit-config throwen — der No-Op macht diesen
2029
- // Pfad harmlos statt ihn dem Zufall zu überlassen.
2030
- function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2031
- const { Banner, Button, Dialog, Text, Grid, GridCell, Card, Tabs, StatusBadge, Metric, MetricBand, PageHeader, Link, ActionOverflowMenu, } = usePrimitives();
2032
- const t = useTranslation();
2033
- const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
2034
- const pageHeaderCompactContext = usePageHeaderCompact();
2035
- const effectiveTranslate = translate ?? t;
2036
- const nav = useNav();
2037
- const idParam = screen.idParam ?? "id";
2038
- // Singleton screens ignore whatever id the path happens to carry — the
2039
- // server picks the row from session/context, so forwarding a path id
2040
- // (even a stray/spoofed one) into the query or into extension sections'
2041
- // entityId fallback (resolveExtensionEntityId in render-edit.tsx) would
2042
- // let a client-controlled value leak where none is meant to reach.
2043
- const effectiveEntityId = screen.singleton === true ? undefined : entityId;
2044
- const isTabsMode = screen.layout.mode === "tabs";
2045
- const activeSection = useMemo(() => {
2046
- if (!isTabsMode || Tabs === undefined)
2047
- return undefined;
2048
- const tabParam = nav.searchParams["tab"];
2049
- return (screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]);
2050
- }, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
2051
- const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
2052
- // Tabs is an optional Core-Primitive: without it the screen falls back to
2053
- // the stacked all-sections layout instead of silently truncating to section 1.
2054
- useEffect(() => {
2055
- if (isTabsMode && Tabs === undefined) {
2056
- // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
2057
- console.warn(`[kumiko] screen "${screen.id}" uses layout.mode: "tabs", but no <Tabs> primitive is registered — the tab strip will not render. createKumikoApp() from kumiko-renderer-web wires it automatically.`);
2058
- }
2059
- }, [isTabsMode, Tabs, screen.id]);
2060
- const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
2061
- const detailScreen = useMemo(() => {
2062
- const source = activeSection !== undefined
2063
- ? { ...screen, layout: { ...screen.layout, sections: [activeSection] } }
2064
- : screen;
2065
- return synthesizeProjectionDetailScreen(source);
2066
- }, [screen, activeSection]);
2067
- const detailQuery = useQuery(screen.query, effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {});
2068
- // A writeForm section's handler creates a new record (see EditWriteFormSection's
2069
- // doc) — both the outer record (header/metrics/other sections) and a sibling
2070
- // relatedList section's own independently-fetched rows must reflect it. A
2071
- // query refetch alone only refreshes `record` below; bumping this as
2072
- // <RenderEdit>'s refreshToken makes RelatedListSection's own useQuery
2073
- // re-run too (it has no `live` subscription of its own). Refetch instead of
2074
- // remount (a `key`) so unsaved input in the other writeForm sections and
2075
- // the related lists' local state survive.
2076
- const [reloadNonce, setReloadNonce] = useState(0);
2077
- const reloadDetail = useCallback(async () => {
2078
- await refetchAfterWrite(detailQuery.refetch);
2079
- setReloadNonce((n) => n + 1);
2080
- }, [detailQuery.refetch]);
2081
- // Header actions render directly via RenderEditActionButton (fw#2713,
2082
- // headerActions moved out of RenderEdit's footer) — this component owns
2083
- // the failure-surfacing state RenderEdit used to own for them.
2084
- const [actionError, setActionError] = useState(null);
2085
- // Default edit action (fw#2166): resolved cross-feature over ALL mounted
2086
- // features, not just this feature's own schema — detailFor itself is
2087
- // resolved cross-feature by the boot-validator (detail-screens.ts), and
2088
- // the motivating case is a projectionDetail whose query belongs to one
2089
- // feature while the entity's entityEdit screen lives in another. Unlike
2090
- // useNavigateToCreateFor this must NOT filter on allowCreate/singleton —
2091
- // those gate create-targets, we're resolving an update-target by a
2092
- // known id.
2093
- const appFeatures = useAppFeatures();
2094
- const userRoles = useUserRoles();
2095
- const dispatcher = useOptionalDispatcher();
2096
- const clientActions = useClientActionRuntime();
2097
- const host = useReturnHost();
2098
- const returnTargetParam = useReturnTarget(screen.id);
2099
- // A singleton has no record route to come back to — same as entityEdit.
2100
- const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
2101
- const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
2102
- const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
2103
- const record = detailQuery.data ?? {};
2104
- // Never return onto the just-deleted record.
2105
- const target = deletedShownRecord && returnTarget?.entityId === effectiveEntityId
2106
- ? undefined
2107
- : returnTarget;
2108
- if (action.redirect !== undefined) {
2109
- navigateAfterRedirect({
2110
- nav,
2111
- schema,
2112
- appFeatures,
2113
- returnTarget: target,
2114
- redirect: action.redirect,
2115
- resultData,
2116
- record,
2117
- });
2118
- return;
2119
- }
2120
- navigateToReturnOr(nav, target, () => {
2121
- const listScreen = screen.listScreenId ??
2122
- (screen.detailFor !== undefined
2123
- ? findEntityListScreen(schema, screen.detailFor)?.id
2124
- : undefined);
2125
- if (listScreen !== undefined)
2126
- nav.navigate({ screenId: lastSegment(listScreen) });
2127
- else
2128
- void detailQuery.refetch();
2129
- });
2130
- }, [
2131
- detailQuery.data,
2132
- detailQuery.refetch,
2133
- nav,
2134
- schema,
2135
- appFeatures,
2136
- returnTarget,
2137
- effectiveEntityId,
2138
- screen.listScreenId,
2139
- screen.detailFor,
2140
- ]);
2141
- const editScreen = useMemo(() => {
2142
- const detailFor = screen.detailFor;
2143
- if (detailFor === undefined)
2144
- return undefined;
2145
- return findEditScreenFor(detailFor, appFeatures, userRoles);
2146
- }, [appFeatures, screen.detailFor, userRoles]);
2147
- const defaultEditAction = useMemo(() => {
2148
- if (editScreen === undefined)
2149
- return undefined;
2150
- // editScreen.id is registry-qualified ("feature:screen:contact-edit");
2151
- // nav.navigate expects the short form (see useNavigateToCreateFor above).
2152
- const targetScreenId = lastSegment(editScreen.id);
2153
- return {
2154
- id: "edit",
2155
- label: effectiveTranslate("kumiko.actions.edit"),
2156
- icon: resolveActionIcon("edit"),
2157
- onPress: () => {
2158
- const target = {
2159
- screenId: targetScreenId,
2160
- ...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
2161
- };
2162
- navigateWithReturnTo(nav, target, host);
2163
- },
2164
- };
2165
- }, [editScreen, effectiveTranslate, nav, effectiveEntityId, host]);
2166
- // a navigate-screen action targeting an entityEdit screen for the
2167
- // SAME entity this projectionDetail shows (screen.detailFor) defaults its
2168
- // entityId to the shown record's own id — without this fallback the target
2169
- // opens an empty create-form instead of the shown record, silently.
2170
- // Searched cross-feature, consistent with editScreen above.
2171
- const sameEntityScreenId = useCallback((targetScreen) => {
2172
- const record = detailQuery.data ?? {};
2173
- const targetIsEntityEditSameEntity = screen.detailFor !== undefined &&
2174
- appFeatures.some((feature) => feature.screens.some((s) => s.type === "entityEdit" &&
2175
- s.entity === screen.detailFor &&
2176
- lastSegment(s.id) === targetScreen));
2177
- return targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
2178
- }, [detailQuery.data, screen.detailFor, appFeatures]);
2179
- const buildSectionActions = useCallback((actions) => buildRecordActions({
2180
- actions,
2181
- record: detailQuery.data ?? {},
2182
- translate: effectiveTranslate,
2183
- nav,
2184
- host,
2185
- dispatcher,
2186
- openDrawer,
2187
- onWriteSuccess: reloadDetail,
2188
- onRecordLeft: handleRecordLeft,
2189
- sameEntityScreenId,
2190
- clientActions,
2191
- }), [
2192
- detailQuery.data,
2193
- reloadDetail,
2194
- effectiveTranslate,
2195
- nav,
2196
- host,
2197
- dispatcher,
2198
- clientActions,
2199
- openDrawer,
2200
- handleRecordLeft,
2201
- sameEntityScreenId,
2202
- ]);
2203
- const headerActions = useMemo(() => {
2204
- const declaredHasEdit = screen.actions?.some((a) => a.id === "edit") === true;
2205
- const resolved = buildRecordActions({
2206
- actions: screen.actions ?? [],
2207
- record: detailQuery.data ?? {},
2208
- translate: effectiveTranslate,
2209
- nav,
2210
- host,
2211
- dispatcher,
2212
- openDrawer,
2213
- onWriteSuccess: reloadDetail,
2214
- onRecordLeft: handleRecordLeft,
2215
- sameEntityScreenId,
2216
- clientActions,
2217
- });
2218
- if (defaultEditAction === undefined || declaredHasEdit)
2219
- return resolved;
2220
- return [defaultEditAction, ...(resolved ?? [])];
2221
- }, [
2222
- screen.actions,
2223
- defaultEditAction,
2224
- effectiveTranslate,
2225
- nav,
2226
- host,
2227
- dispatcher,
2228
- clientActions,
2229
- detailQuery.data,
2230
- reloadDetail,
2231
- openDrawer,
2232
- handleRecordLeft,
2233
- sameEntityScreenId,
2234
- ]);
2235
- const hasHeader = screen.header !== undefined;
2236
- const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
2237
- // ?? [] rather than threading `headerActions !== undefined` through every
2238
- // use below — an empty array is a safe no-op for .map/.length.
2239
- const headerActionsList = headerActions ?? [];
2240
- const hasHeaderActions = headerActionsList.length > 0;
2241
- const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
2242
- // A header card (metric band, status, actions) needs more room than the
2243
- // 640px form column; screens with only a record title keep the narrow one.
2244
- const widensScreenFormColumn = hasHeaderCard &&
2245
- !hasTabs &&
2246
- screenFillsHeight(screen) &&
2247
- detailScreen.layout.width === undefined;
2248
- const renderedDetailScreen = useMemo(() => widensScreenFormColumn
2249
- ? { ...detailScreen, layout: { ...detailScreen.layout, width: HEADER_CARD_DEFAULT_WIDTH } }
2250
- : detailScreen, [detailScreen, widensScreenFormColumn]);
2251
- if (effectiveEntityId === undefined && screen.singleton !== true) {
2252
- 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."] }));
2253
- }
2254
- if (detailQuery.loading && detailQuery.data === null) {
2255
- return _jsx(QueryLoadingBanner, {});
2256
- }
2257
- if (detailQuery.error && detailQuery.data === null) {
2258
- return (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch }));
2259
- }
2260
- // A failed refetch keeps the last good record on screen.
2261
- const refetchErrorBanner = detailQuery.error ? (_jsx(QueryErrorBanner, { error: detailQuery.error, translate: effectiveTranslate, onRetry: detailQuery.refetch })) : null;
2262
- const record = detailQuery.data;
2263
- if (!record) {
2264
- const backListScreen = screen.listScreenId ??
2265
- (screen.detailFor !== undefined
2266
- ? findEntityListScreen(schema, screen.detailFor)?.id
2267
- : undefined);
2268
- return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...((returnTarget !== undefined || backListScreen !== undefined) && {
2269
- onBack: () => navigateToReturnOr(nav, returnTarget, () => {
2270
- if (backListScreen !== undefined) {
2271
- nav.navigate({ screenId: lastSegment(backListScreen) });
2272
- }
2273
- }),
2274
- }) }));
2275
- }
2276
- // Grouped into the head Card alongside title/status/metrics (fw#2713):
2277
- // these are actions on the record the head shows, not on whichever tab is
2278
- // open, so they must stay in place across tab switches instead of
2279
- // trailing the active tab's content in the card footer.
2280
- 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, ...(screen.primaryAction !== undefined && { preferredPrimaryId: screen.primaryAction }) }) }));
2281
- // Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
2282
- // this shares the same page padding/width as the record's edit card below
2283
- // it, instead of sitting flush against the screen edge (fw record-screen
2284
- // header polish).
2285
- const recordTitle = screen.recordTitleField !== undefined
2286
- ? resolveRecordTitle(record[screen.recordTitleField])
2287
- : undefined;
2288
- const header = screen.header;
2289
- const subtitleHref = resolveSubtitleHref(header, record);
2290
- // Plain Text, not Heading — the Card title slot already renders as its own
2291
- // heading element (h3); nesting another heading (h1) inside it is invalid HTML.
2292
- const headerTitleSlot = header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: String(record[header.status] ?? "") }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
2293
- const subtitleParts = header !== undefined && typeof header.subtitle === "object"
2294
- ? buildSubtitleParts(header.subtitle, record, nav, appFeatures, userRoles, host)
2295
- : undefined;
2296
- const headerSubtitleSlot = subtitleParts !== undefined ? (subtitleParts.length > 0 ? (_jsx(Text, { testId: "kumiko-screen-projection-detail-subtitle", children: subtitleParts.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && (_jsx(Text, { variant: "muted", decorative: true, children: " · " })), part.href !== undefined ? (_jsx(Link, { href: part.href, ...(part.onPress !== undefined && { onPress: part.onPress }), className: "underline", testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text })) : (_jsx(Text, { testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text }))] }, part.field))) })) : undefined) : typeof header?.subtitle === "string" ? (subtitleHref !== undefined ? (_jsx(Link, { href: subtitleHref, target: "_blank", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") })) : (_jsx(Text, { variant: "muted", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") }))) : undefined;
2297
- // slots.header shares the header card with the actions (one card, actions
2298
- // top right) instead of rendering as an unframed block above it.
2299
- const metricItems = screen.metrics?.map((metric) => {
2300
- const field = metric.field;
2301
- const label = effectiveTranslate(metric.label);
2302
- const value = String(record[field] ?? "");
2303
- const testId = `kumiko-screen-projection-detail-metric-${field}`;
2304
- const navigate = metric.navigate;
2305
- const onPress = navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
2306
- ? () => runMetricNavigate(nav, navigate, record, host)
2307
- : undefined;
2308
- return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
2309
- });
2310
- // Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
2311
- const metricsBlock = (subtitle, lead) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, ...(lead !== undefined && { lead }), children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
2312
- const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
2313
- const pageHeaderCompact = pageHeaderCompactContext && usesPageHeaderSlot;
2314
- // The phone shell header clips a long status mid-word, so on phones the badge
2315
- // sits in the body instead of the shell header slot.
2316
- const statusBadge = header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
2317
- // Lives in the band's subtitle row so it shares the metrics' horizontal inset.
2318
- const bandSubtitle = pageHeaderCompact && statusBadge !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-status-row", children: statusBadge }), headerSubtitleSlot] })) : (headerSubtitleSlot);
2319
- const actionErrorBanner = actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] }));
2320
- const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(!pageHeaderCompact && statusBadge !== undefined && { status: statusBadge }), ...(hasHeaderActions && {
2321
- actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader, ...(screen.primaryAction !== undefined && {
2322
- preferredPrimaryId: screen.primaryAction,
2323
- }) }) })),
2324
- }) }), MetricBand === undefined && bandSubtitle, MetricBand === undefined && headerSlot, (hasMetrics || bandSubtitle !== undefined || headerSlot !== undefined) &&
2325
- (MetricBand !== undefined || hasMetrics) &&
2326
- metricsBlock(bandSubtitle, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
2327
- ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
2328
- ...(headerTitleSlot === undefined &&
2329
- recordTitle !== undefined && { title: recordTitle }),
2330
- ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
2331
- ...(headerSlot !== undefined && { headerContent: headerSlot }),
2332
- ...(hasHeaderActions && { headerActions: headerActionsContent }),
2333
- }, children: [hasMetrics && metricsBlock(), actionErrorBanner] }))), hasTabs && activeSection !== undefined && (_jsx(Tabs, { testId: "kumiko-screen-projection-detail-tabs", items: screen.layout.sections.map((section) => {
2334
- const countField = isWriteFormEditSection(section) ? undefined : section.countField;
2335
- const countValue = countField !== undefined ? record[countField] : undefined;
2336
- const count = typeof countValue === "number" && Number.isFinite(countValue)
2337
- ? countValue
2338
- : undefined;
2339
- return {
2340
- id: section.id ?? "",
2341
- label: effectiveTranslate(section.title ?? section.id ?? ""),
2342
- ...(count !== undefined && { count }),
2343
- };
2344
- }), activeId: activeSection.id ?? "", onSelect: (id) => requestLeave(() => nav.setSearchParams({ tab: id })) }))] }));
2345
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(RenderEdit, { refreshToken: reloadNonce, 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 ||
2346
- hasTabs ||
2347
- screen.slots?.header !== undefined ||
2348
- screen.recordTitleField !== undefined) && {
2349
- headerRegion: renderHeaderContent,
2350
- }), buildSectionActions: buildSectionActions, valueDisplay: screen.valueDisplay ?? "text" }, entityId), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
2351
- closeDrawer();
2352
- void reloadDetail();
2353
- } })] }));
2354
- }
2355
- function redirectScreenTarget(redirect) {
2356
- return typeof redirect === "string" ? redirect : redirect.screen;
2357
- }
2358
- // Same-entity detection for navigate defaults: only an entityEdit screen of the
2359
- // SAME entity may receive the current record's id (it opens in UPDATE mode, which
2360
- // is what the boot validator assumes). Searched across all features.
2361
- function isEntityEditScreenOfEntity(entity, targetScreen, schema, appFeatures) {
2362
- if (entity === undefined)
2363
- return false;
2364
- return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity === entity && lastSegment(s.id) === targetScreen));
2365
- }
2366
- // Record-scoped targets (actionForm, secretMint) take the shown record's id; an
2367
- // entityEdit of ANOTHER entity must not, or it would open that entity's UPDATE
2368
- // mode on the wrong id instead of create.
2369
- function isEntityEditScreenOfOtherEntity(entity, targetScreen, schema, appFeatures) {
2370
- return [schema, ...appFeatures].some((feature) => feature.screens.some((s) => s.type === "entityEdit" && s.entity !== entity && lastSegment(s.id) === targetScreen));
2371
- }
2372
- // A qualified redirect names its feature, so the search space is THAT feature
2373
- // only — the current schema's own screens must not shadow it with a same
2374
- // short-id screen of another type. An unqualified redirect searches this
2375
- // schema first (own screen wins), then every mounted feature. A named feature
2376
- // that isn't mounted (no provider / isolated test) falls back to this schema.
2377
- function findRedirectTargetScreen(redirect, schema, appFeatures) {
2378
- const redirectScreen = redirectScreenTarget(redirect);
2379
- const targetId = lastSegment(redirectScreen);
2380
- const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
2381
- const targetFeature = targetFeatureName !== undefined
2382
- ? appFeatures.find((f) => f.featureName === targetFeatureName)
2383
- : undefined;
2384
- const searchSpace = targetFeature !== undefined
2385
- ? targetFeature.screens
2386
- : targetFeatureName === undefined
2387
- ? [...schema.screens, ...appFeatures.flatMap((f) => f.screens)]
2388
- : schema.screens;
2389
- return searchSpace.find((s) => lastSegment(s.id) === targetId);
2390
- }
2391
- // A redirect to a record screen ("show the result") is a deliberate forward
2392
- // navigation and keeps precedence over returnTo.
2393
- function redirectTargetsRecord(redirect, schema, appFeatures) {
2394
- const target = findRedirectTargetScreen(redirect, schema, appFeatures);
2395
- return (target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail"));
2396
- }
2397
- // Type-guard form narrows `returnTarget` for the caller's subsequent
2398
- // navigateToReturn(nav, returnTarget) call.
2399
- function returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures) {
2400
- return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
2401
- }
2402
- // Resolves an object-form redirect to a nav target — shared by
2403
- // actionForm and entityEdit (create + update) so the id
2404
- // carries over identically regardless of which screen type triggered it.
2405
- // `carriesId` gates entityId on the TARGET screen type: a redirect to a
2406
- // list screen never gets an id attached, matching actionForm's original
2407
- // behavior. The id itself prefers the write-handler's success payload
2408
- // (`submittedData`) and falls back to `fallbackRecord` — the entityEdit
2409
- // update path's already-loaded record, or the create path's just-submitted
2410
- // form values — both of which carry fields (e.g. a parent FK) the CRUD
2411
- // write executor's success payload doesn't flatly expose.
2412
- function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fallbackRecord) {
2413
- const redirectScreen = redirectScreenTarget(redirect);
2414
- const idField = typeof redirect === "string" ? "id" : (redirect.idFrom ?? "id");
2415
- const targetId = lastSegment(redirectScreen);
2416
- const target = findRedirectTargetScreen(redirect, schema, appFeatures);
2417
- const carriesId = target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
2418
- if (!carriesId)
2419
- return { screenId: targetId, entityId: undefined, tab: undefined };
2420
- const entityId = extractIdField(submittedData, idField) ??
2421
- (fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
2422
- return {
2423
- screenId: targetId,
2424
- entityId,
2425
- tab: typeof redirect === "string" ? undefined : redirect.tab,
2426
- };
2427
- }
2428
- function navigateToRedirectTarget(nav, target) {
2429
- const navTarget = {
2430
- screenId: target.screenId,
2431
- ...(target.entityId !== undefined && { entityId: target.entityId }),
2432
- };
2433
- if (target.tab === undefined)
2434
- nav.navigate(navTarget);
2435
- else
2436
- nav.navigate(navTarget, { searchParams: { tab: target.tab } });
2437
- }
2438
- // Links of the success box. Targets resolve like `redirect`; a link whose target
2439
- // needs a record id (entityEdit, projectionDetail) but whose payload field holds
2440
- // no string id is dropped instead of navigating to an id-less record screen.
2441
- // A link without idFrom to a non-record screen always navigates id-less.
2442
- function resolveSuccessLinks(links, data, schema, appFeatures, nav, translate) {
2443
- const resolved = [];
2444
- (links ?? []).forEach((link, index) => {
2445
- const target = resolveRedirectTarget(link, data, schema, appFeatures);
2446
- if (target.entityId === undefined && redirectTargetsRecord(link, schema, appFeatures))
2447
- return;
2448
- const navTarget = {
2449
- screenId: target.screenId,
2450
- ...(target.entityId !== undefined && { entityId: target.entityId }),
2451
- };
2452
- const options = target.tab === undefined ? undefined : { searchParams: { tab: target.tab } };
2453
- resolved.push({
2454
- key: String(index),
2455
- label: translate(link.label),
2456
- href: nav.hrefFor(navTarget, options),
2457
- onPress: () => nav.navigate(navTarget, options),
2458
- });
2459
- });
2460
- return resolved;
2461
- }
2462
- // Success navigation for a configured redirect — shared by entityEdit's
2463
- // save and writeHandler record actions. The string form navigates without an
2464
- // entityId; the object form resolves like actionForm's, with `record` as the
2465
- // fallback id source.
2466
- function navigateAfterRedirect(options) {
2467
- const { nav, schema, appFeatures, returnTarget, redirect, resultData, record } = options;
2468
- if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
2469
- navigateToReturn(nav, returnTarget);
2470
- return;
2471
- }
2472
- if (typeof redirect === "string") {
2473
- nav.navigate({ screenId: lastSegment(redirect) });
2474
- return;
2475
- }
2476
- navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, resultData, schema, appFeatures, record));
2477
- }
2478
- // The update payload nests the saved projection under `data`; a changed
2479
- // parent FK lives there, not in the pre-edit `record`.
2480
- function overlayUpdatedProjection(record, resultData) {
2481
- if (typeof resultData !== "object" || resultData === null)
2482
- return record;
2483
- const projection = propertyOf(resultData, "data");
2484
- if (typeof projection !== "object" || projection === null)
2485
- return record;
2486
- return { ...record, ...projection };
2487
- }
2488
- // Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
2489
- // RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
2490
- // Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
2491
- // Form-Mechanik (useForm, RenderEdit, DefaultInput, Banner, Submit-
2492
- // Button) ist identisch zu entityEdit, nur der Submit-Pfad wechselt
2493
- // vom CRUD-verb auf den Author-deklarierten handler-QN +
2494
- // payloadMode="values" (alle Form-Werte schicken statt nur Changes).
2495
- function ActionFormBody({ schema, screen, translate, entityId, initialOverrides, onSuccess, onCancelOverride, onDirtyChange, submitLabelFallback, }) {
2496
- const nav = useNav();
2497
- const appFeatures = useAppFeatures();
2498
- const insideDrawer = useInsideDrawer();
2499
- const locale = useLocale().locale();
2500
- const { Banner } = usePrimitives();
2501
- // Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
2502
- const returnTarget = useReturnTarget(screen.id);
2503
- const submittableFields = useMemo(() => actionFormSubmittableFields(screen), [screen]);
2504
- const synthEntity = useMemo(() => synthesizeActionFormEntity(submittableFields), [submittableFields]);
2505
- const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
2506
- const pendingHandoff = useInitialValuesHandoff(screen.id);
2507
- // A drawer-hosted form is not a navigation target, so it never takes a handoff.
2508
- const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
2509
- // This screen has no entity, so a money field here declares its own currency
2510
- // source (fw#2839, boot-enforced) — without one the form would seed a bare
2511
- // `0` the handler's schema rejects. When a tenant-declared field's config
2512
- // query errors or holds no value there is no entity default to use, and
2513
- // submitting a guessed currency would mis-book the amount — block instead.
2514
- const tenantCurrencyFieldNames = useMemo(() => tenantCurrencyMoneyFieldNames(screen.fields), [screen.fields]);
2515
- const { overrides: moneyCurrencyOverrides, loading: currencyLoading, unresolved: currencyUnresolved, } = useMoneyCurrencyOverrides(screen.fields, tenantCurrencyFieldNames, undefined);
2516
- const loadableFieldNames = useMemo(() => Object.entries(submittableFields)
2517
- .filter(([, field]) => !isSecretFormField(field))
2518
- .map(([name]) => name), [submittableFields]);
2519
- const loadState = useActionFormLoad(screen.load, screen.load !== undefined
2520
- ? resolveActionFormRecordId({
2521
- routeEntityId: entityId,
2522
- drawerOverrides: initialOverrides,
2523
- searchParams: nav.searchParams,
2524
- })
2525
- : undefined, loadableFieldNames);
2526
- const loadedValues = loadState?.status === "ready" ? loadState.values : undefined;
2527
- const initial = useMemo(() => {
2528
- // With `load` the loaded record is the only source of values: a crafted link
2529
- // or a row's params must not seed fields the record would otherwise own.
2530
- const prefillSources = screen.load !== undefined
2531
- ? { searchParams: {}, urlPrefillFields: undefined }
2532
- : {
2533
- searchParams: nav.searchParams,
2534
- urlPrefillFields: screen.urlPrefillFields,
2535
- ...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
2536
- ...(handoffValues !== undefined && { handoffValues }),
2537
- };
2538
- return {
2539
- ...mergeSearchParamsIntoInitial(submittableFields, {
2540
- ...prefillSources,
2541
- renderableFields: layoutFieldNames(synthScreen),
2542
- ...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
2543
- }),
2544
- ...loadedValues,
2545
- };
2546
- }, [
2547
- submittableFields,
2548
- screen.load,
2549
- screen.urlPrefillFields,
2550
- nav.searchParams,
2551
- synthScreen,
2552
- moneyCurrencyOverrides,
2553
- initialOverrides,
2554
- handoffValues,
2555
- loadedValues,
2556
- ]);
2557
- const derivedInitial = useMemo(() => withDerivedValues(initial, screen.derive !== undefined ? screen : undefined), [initial, screen]);
2558
- const t = useTranslation();
2559
- const effectiveTranslate = translate ?? t;
2560
- const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
2561
- const dispatcher = useDispatcher();
2562
- const [success, setSuccess] = useState(undefined);
2563
- // Bumped after a successful submit with resetOnSuccess: remounts RenderEdit, which
2564
- // re-seeds the form from `initial` (prefill kept, typed values cleared).
2565
- const [formEpoch, setFormEpoch] = useState(0);
2566
- // A slow label lookup of an earlier submit must not overwrite the banner of a later one.
2567
- const successSequence = useRef(0);
2568
- useEffect(() => () => {
2569
- successSequence.current += 1;
2570
- }, []);
2571
- const handleSubmitted = useCallback((result, values) => {
2572
- successSequence.current += 1;
2573
- const sequence = successSequence.current;
2574
- setSuccess(undefined);
2575
- if (!result.isSuccess)
2576
- return;
2577
- if (onSuccess !== undefined) {
2578
- onSuccess();
2579
- return;
2580
- }
2581
- if (screen.redirect === undefined) {
2582
- const hasSuccessLinks = (screen.successLinks?.length ?? 0) > 0 || (screen.successFacts?.length ?? 0) > 0;
2583
- const publish = (text) => {
2584
- if (successSequence.current !== sequence)
2585
- return;
2586
- setSuccess({
2587
- text,
2588
- links: resolveSuccessLinks(screen.successLinks, result.data, schema, appFeatures, nav, effectiveTranslate),
2589
- facts: resolveSuccessFacts(screen.successFacts, result.data, effectiveTranslate, locale),
2590
- });
2591
- };
2592
- const messageKey = screen.successMessage;
2593
- if (messageKey !== undefined) {
2594
- const show = (params) => publish(effectiveTranslate(messageKey, params));
2595
- void resolveSuccessMessageParams(screen, schema.featureName, values, dispatcher, locale)
2596
- .then(show)
2597
- .catch(() => show(formatSummaryParams(values, locale)));
2598
- }
2599
- else if (hasSuccessLinks || screen.resetOnSuccess === true) {
2600
- publish(effectiveTranslate("kumiko.actionForm.success"));
2601
- }
2602
- if (screen.resetOnSuccess === true)
2603
- setFormEpoch((epoch) => epoch + 1);
2604
- }
2605
- // Without a redirect the form stays put; returnTo only replaces an existing
2606
- // navigation, and never one to a record screen (see redirectTargetsRecord).
2607
- if (screen.redirect !== undefined) {
2608
- if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
2609
- navigateToReturn(nav, returnTarget);
2610
- }
2611
- else {
2612
- navigateToRedirectTarget(nav, resolveRedirectTarget(screen.redirect, result.data, schema, appFeatures));
2613
- }
2614
- }
2615
- }, [
2616
- nav,
2617
- screen,
2618
- onSuccess,
2619
- schema,
2620
- appFeatures,
2621
- returnTarget,
2622
- dispatcher,
2623
- locale,
2624
- effectiveTranslate,
2625
- ]);
2626
- // The success box disappears with the next edit; a submit clears it itself.
2627
- const handleDirtyChange = useCallback((dirty) => {
2628
- if (dirty) {
2629
- successSequence.current += 1;
2630
- setSuccess(undefined);
2631
- }
2632
- onDirtyChange?.(dirty);
2633
- }, [onDirtyChange]);
2634
- // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2635
- // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
2636
- // gewinnt über redirect; `false` schaltet den Button explizit ab
2637
- // (Single-Action-Screens, wo Cancel nur Submit-ohne-Senden wäre).
2638
- // returnTo wins over the declared target when both are present.
2639
- const handleCancel = useMemo(() => {
2640
- if (onCancelOverride !== undefined)
2641
- return onCancelOverride;
2642
- const target = screen.cancelTarget ??
2643
- (screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
2644
- if (target === undefined || target === false)
2645
- return undefined;
2646
- return () => navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
2647
- }, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
2648
- // Rendering the form before a tenant-declared currency lands would show the
2649
- // fallback and, on a fast click, submit it (fw#2933) — hold instead.
2650
- if (currencyLoading) {
2651
- return _jsx(QueryLoadingBanner, {});
2652
- }
2653
- if (currencyUnresolved) {
2654
- 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." }));
2655
- }
2656
- if (loadState !== undefined && loadState.status !== "ready") {
2657
- return (_jsx(ActionFormLoadStatus, { state: loadState, screenId: screen.id, translate: effectiveTranslate }));
2658
- }
2659
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: derivedInitial, ...(screen.derive !== undefined && { formDerivation: screen }), actionFormScreenQn: qualifyScreenId(schema.featureName, screen.id),
2660
- // Extension-Sections im actionForm sehen die initialen Form-Values
2661
- // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2662
- // — anders als im entityEdit gibt es hier keinen record, aus dem
2663
- // sie Kontext ziehen könnten.
2664
- extensionInitialValues: initial, ...(screen.submitPrefilled === true && {
2665
- submitWithoutChanges: hiddenRequiredFieldsFilled(synthScreen, screen.fields, initial),
2666
- }), ...(success !== undefined && {
2667
- leadContent: _jsx(ActionFormSuccessBanner, { success: success }),
2668
- }), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), onDirtyChange: handleDirtyChange, ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2669
- submitLabel: screen.submitLabel ?? submitLabelFallback,
2670
- }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }, formEpoch));
2671
- }
2672
- // A money-typed config-edit field renders through RenderField's entityEdit
2673
- // `{amount, currency}` payload shape (render-field.tsx, #1923), but
2674
- // `ConfigKeyType` (write-helpers.ts validateType) only ever knows
2675
- // number/boolean/text/select — a config value is always a bare scalar.
2676
- // Unwrap back to the amount before it hits config:write:set.
2677
- function unwrapMoneyValue(value) {
2678
- if (typeof value === "object" && value !== null && "amount" in value) {
2679
- const amount = value.amount;
2680
- if (typeof amount === "number")
2681
- return amount;
2682
- }
2683
- return value;
2684
- }
2685
- const CONFIG_SCOPE_SOURCE = {
2686
- user: "user-row",
2687
- tenant: "tenant-row",
2688
- system: "system-row",
2689
- };
2690
- function isWriteOnlyTextField(fieldDef) {
2691
- return fieldDef?.type === "text" && fieldDef.writeOnly === true;
2692
- }
2693
- function hasStoredValue(stored) {
2694
- return stored !== undefined && stored !== null && stored !== "";
2695
- }
2696
- const CONFIG_WRITE_ISSUE_PATH = "value";
2697
- // write-helpers report every issue at `value`; in a batch that path is
2698
- // meaningless to the form, so point it at the field whose command failed.
2699
- function mapFailedConfigCommandIssuesToField(error, failedIndex, commandFieldNames) {
2700
- const fieldName = commandFieldNames[failedIndex];
2701
- const fields = error.details?.fields;
2702
- if (fieldName === undefined || fields === undefined)
2703
- return error;
2704
- return {
2705
- ...error,
2706
- details: {
2707
- ...error.details,
2708
- fields: fields.map((issue) => {
2709
- if (issue.path === CONFIG_WRITE_ISSUE_PATH)
2710
- return { ...issue, path: fieldName };
2711
- if (issue.path.startsWith(`${CONFIG_WRITE_ISSUE_PATH}.`)) {
2712
- return {
2713
- ...issue,
2714
- path: `${fieldName}${issue.path.slice(CONFIG_WRITE_ISSUE_PATH.length)}`,
2715
- };
2716
- }
2717
- return issue;
2718
- }),
2719
- },
2720
- };
2721
- }
2722
- // A money field without a declared default starts empty too (buildInitialValues
2723
- // maps it to 0, which a save would persist as a deliberate "0" override); numbers
2724
- // are handled there, except a required one, which config fields never are.
2725
- function buildConfigEditDefaults(fields) {
2726
- const defaults = buildInitialValues(fields); // @cast-boundary render-helper
2727
- for (const [shortName, fieldDef] of Object.entries(fields)) {
2728
- const { type, default: fieldDefault } = fieldDef; // @cast-boundary schema-walk
2729
- if ((type === "number" || type === "money") && fieldDefault === undefined) {
2730
- defaults[shortName] = undefined;
2731
- }
2732
- }
2733
- return defaults;
2734
- }
2735
- // A field hidden by its `visible` condition (a provider's settings while another
2736
- // provider is picked) is not saved: the form saves what it shows.
2737
- function hiddenLayoutFieldNames(screen, values) {
2738
- return new Set(layoutEditFields(screen)
2739
- .filter((spec) => spec.visible !== undefined && !evalFieldCondition(spec.visible, values))
2740
- .map((spec) => spec.field));
2741
- }
2742
- function isEmptyNumericFormValue(value) {
2743
- if (value === undefined || value === null || value === "")
2744
- return true;
2745
- return typeof value === "number" && !Number.isFinite(value);
2746
- }
2747
- function ConfigEditBody({ schema, screen, translate, }) {
2748
- const { ConfigCascadeView } = usePrimitives();
2749
- const t = useTranslation();
2750
- const effectiveTranslate = translate ?? t;
2751
- const dispatcher = useDispatcher();
2752
- // Detail-Load: config:query:values returnt ALLE Keys des Tenants.
2753
- // Wir mappen via screen.configKeys von short → qualified-name auf
2754
- // unsere Form-Field-Werte.
2755
- const valuesQuery = useQuery("config:query:values", {});
2756
- const cascadeQuery = useQuery("config:query:cascade", {});
2757
- const secretKeys = screen.secretKeys;
2758
- const secretsQuery = useQuery("secrets:query:list", {}, { enabled: secretKeys !== undefined });
2759
- const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
2760
- const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
2761
- const boundsSchema = useMemo(() => buildConfigEditSchema(screen.fields), [screen.fields]);
2762
- // Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
2763
- // abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
2764
- // wir den Field-Default (createTextField/createNumberField/...).
2765
- // String-coerce nur für Text-Fields; andere Types sollten in der
2766
- // Response bereits Native-Type sein.
2767
- const initial = useMemo(() => {
2768
- if (valuesQuery.data === null)
2769
- return null;
2770
- if (secretKeys !== undefined && secretsQuery.data === null)
2771
- return null;
2772
- const storedSecretKeys = new Set((secretsQuery.data ?? []).map((row) => row.key));
2773
- const out = {};
2774
- const defaults = buildConfigEditDefaults(screen.fields);
2775
- for (const [shortName, fieldDef] of Object.entries(screen.fields)) {
2776
- const secretKey = secretKeys?.[shortName];
2777
- if (secretKey !== undefined) {
2778
- out[shortName] = storedSecretKeys.has(secretKey) ? true : null;
2779
- continue;
2780
- }
2781
- const qualified = screen.configKeys[shortName];
2782
- if (qualified === undefined) {
2783
- // Author hat ein Field deklariert ohne Mapping — nimm Default.
2784
- // Boot-Validator pinnt das, sollte nie zur Runtime greifen.
2785
- out[shortName] = defaults[shortName];
2786
- continue;
2787
- }
2788
- const stored = valuesQuery.data[qualified]?.value;
2789
- // The read carries a mask (or nothing); only whether a value exists is
2790
- // meaningful, so the field gets the writeOnly wire shape true / null.
2791
- // The read is the effective value: an inherited one is not "set" on this
2792
- // scope, and a reset here would remove nothing.
2793
- if (isWriteOnlyTextField(fieldDef)) {
2794
- const ownedByThisScope = valuesQuery.data[qualified]?.source === CONFIG_SCOPE_SOURCE[screen.scope];
2795
- out[shortName] = ownedByThisScope && hasStoredValue(stored) ? true : null;
2796
- continue;
2797
- }
2798
- if (stored === undefined) {
2799
- out[shortName] = defaults[shortName];
2800
- continue;
2801
- }
2802
- const ftype = propertyOf(fieldDef, "type");
2803
- // Field-Type-Coercion: config-Werte sind string|number|boolean,
2804
- // aber der Form-State erwartet das passende Field-Native-Type.
2805
- if (ftype === "number" || ftype === "money") {
2806
- out[shortName] = typeof stored === "number" ? stored : Number(stored);
2807
- }
2808
- else if (ftype === "boolean") {
2809
- out[shortName] = typeof stored === "boolean" ? stored : stored === "true";
2810
- }
2811
- else {
2812
- out[shortName] = typeof stored === "string" ? stored : String(stored);
2813
- }
2814
- }
2815
- return out;
2816
- }, [
2817
- valuesQuery.data,
2818
- secretsQuery.data,
2819
- secretKeys,
2820
- screen.fields,
2821
- screen.configKeys,
2822
- screen.scope,
2823
- ]);
2824
- // Cascade-Lookup: qualifiedKey → ConfigCascade für die
2825
- // Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
2826
- // damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
2827
- // `config:query:values` mocken) nicht durch das Cascade-Rendering
2828
- // crashen.
2829
- const cascades = useMemo(() => {
2830
- if (!cascadeQuery.data)
2831
- return {};
2832
- const out = {};
2833
- for (const [shortName, qualified] of Object.entries(screen.configKeys)) {
2834
- const cascade = cascadeQuery.data[qualified];
2835
- if (cascade && Array.isArray(cascade.levels))
2836
- out[shortName] = cascade;
2837
- }
2838
- return out;
2839
- }, [cascadeQuery.data, screen.configKeys]);
2840
- // Multi-Write Submit: ein einzelner /api/batch Call mit N
2841
- // config:write:set Commands. Server-side ist batch atomic
2842
- // (transaktional: alle Writes in einer DB-TX, all-or-nothing) und
2843
- // browser-side ist es genau eine HTTP-Roundtrip — kein Race zwischen
2844
- // mehreren in-flight fetches die der Browser bei page.reload mid-
2845
- // submit aborten könnte. Promise.all von N separaten dispatcher.write-
2846
- // Calls war fragil: server bekommt + commited alle N, aber das
2847
- // Browser-Connection-Pool gibt sporadisch "Failed to fetch" für
2848
- // einzelne Responses zurück, customSubmit returnt failure obwohl der
2849
- // Write durch ist, das Form bleibt dirty.
2850
- const customSubmit = useCallback(async (snapshot) => {
2851
- const commands = [];
2852
- const commandFieldNames = [];
2853
- const hiddenFieldNames = hiddenLayoutFieldNames(synthScreen, snapshot.values);
2854
- for (const [shortName, value] of Object.entries(snapshot.changes)) {
2855
- if (hiddenFieldNames.has(shortName))
2856
- continue;
2857
- const secretKey = secretKeys?.[shortName];
2858
- if (secretKey !== undefined) {
2859
- // Same wire shape as a writeOnly config field: null removes, a string replaces.
2860
- if (value === null) {
2861
- commandFieldNames.push(shortName);
2862
- commands.push({ type: "secrets:write:delete", payload: { key: secretKey } });
2863
- }
2864
- else if (typeof value === "string" && value.trim() !== "") {
2865
- commandFieldNames.push(shortName);
2866
- commands.push({ type: "secrets:write:set", payload: { key: secretKey, value } });
2867
- }
2868
- continue;
2869
- }
2870
- const qualified = screen.configKeys[shortName];
2871
- if (qualified === undefined)
2872
- continue;
2873
- const fieldDef = screen.fields[shortName];
2874
- const ftype = fieldDef?.type;
2875
- if (isWriteOnlyTextField(screen.fields[shortName])) {
2876
- // true (untouched/undone) and "" mean keep the stored secret; null removes it.
2877
- if (value === null) {
2878
- commandFieldNames.push(shortName);
2879
- commands.push({
2880
- type: "config:write:reset",
2881
- payload: { key: qualified, scope: screen.scope },
2882
- });
2883
- }
2884
- else if (typeof value === "string" && value !== "") {
2885
- commandFieldNames.push(shortName);
2886
- commands.push({
2887
- type: "config:write:set",
2888
- payload: { key: qualified, value, scope: screen.scope },
2889
- });
2890
- }
2891
- continue;
2892
- }
2893
- // A query-backed select is cleared to "" when its dependent value became
2894
- // invalid. config:write:set would store that as an empty override (the
2895
- // badge shows "Mandant" with no value); reset falls back to the inherited
2896
- // value. set can't do it: deleting the row is irreversible, reset is
2897
- // the high-risk handler.
2898
- if (ftype === "select" && fieldDef?.optionsQuery !== undefined && value === "") {
2899
- commandFieldNames.push(shortName);
2900
- commands.push({
2901
- type: "config:write:reset",
2902
- payload: { key: qualified, scope: screen.scope },
2903
- });
2904
- continue;
2905
- }
2906
- // Cleared number/money: drop this scope's override instead of storing an empty value.
2907
- if ((ftype === "number" || ftype === "money") &&
2908
- isEmptyNumericFormValue(ftype === "money" ? unwrapMoneyValue(value) : value)) {
2909
- commandFieldNames.push(shortName);
2910
- commands.push({
2911
- type: "config:write:reset",
2912
- payload: { key: qualified, scope: screen.scope },
2913
- });
2914
- continue;
2915
- }
2916
- commandFieldNames.push(shortName);
2917
- commands.push({
2918
- type: "config:write:set",
2919
- payload: {
2920
- key: qualified,
2921
- value: ftype === "money" ? unwrapMoneyValue(value) : value,
2922
- scope: screen.scope,
2923
- },
2924
- });
2925
- }
2926
- if (commands.length === 0) {
2927
- return { validationBlocked: false, isSuccess: true, data: undefined };
2928
- }
2929
- const result = await dispatcher.batch(commands);
2930
- if (!result.isSuccess) {
2931
- return {
2932
- validationBlocked: false,
2933
- isSuccess: false,
2934
- error: mapFailedConfigCommandIssuesToField(result.error, result.failedIndex, commandFieldNames),
2935
- };
2936
- }
2937
- // Cascade-Disclosure + Maskenwerte nach dem Write nachziehen, sonst
2938
- // bleibt die Anzeige stale bis Reload — gleiche refetch-Calls wie onReset.
2939
- // allSettled: der Write ist schon committed, ein gescheitertes Refetch
2940
- // (stale Anzeige) darf das Erfolgsergebnis nicht in einen Fehler kippen.
2941
- await Promise.allSettled([
2942
- valuesQuery.refetch?.(),
2943
- cascadeQuery.refetch?.(),
2944
- ...(secretKeys !== undefined ? [secretsQuery.refetch?.()] : []),
2945
- ]);
2946
- return { validationBlocked: false, isSuccess: true, data: undefined };
2947
- }, [
2948
- dispatcher,
2949
- synthScreen,
2950
- secretKeys,
2951
- screen.configKeys,
2952
- screen.fields,
2953
- screen.scope,
2954
- valuesQuery.refetch,
2955
- cascadeQuery.refetch,
2956
- secretsQuery.refetch,
2957
- ]);
2958
- if (valuesQuery.loading && valuesQuery.data === null)
2959
- return _jsx(QueryLoadingBanner, {});
2960
- const failedQuery = valuesQuery.error ? valuesQuery : secretsQuery.error ? secretsQuery : null;
2961
- if (failedQuery?.error) {
2962
- return (_jsx(QueryErrorBanner, { error: failedQuery.error, translate: effectiveTranslate, onRetry: failedQuery.refetch }));
2963
- }
2964
- if (initial === null)
2965
- return _jsx(QueryLoadingBanner, {});
2966
- // Cascade values of a select show the option label the select itself shows,
2967
- // not the stored value (a UUID for optionsQuery selects).
2968
- const cascadeValueRenderer = (fieldName) => {
2969
- const fieldDef = screen.fields[fieldName];
2970
- if (fieldDef?.type !== "select")
2971
- return undefined;
2972
- if (fieldDef.optionsQuery !== undefined) {
2973
- const query = fieldDef.optionsQuery;
2974
- const payload = resolveOptionsQueryPayload(fieldDef.optionsQueryPayload ?? {}, initial);
2975
- return (value) => _jsx(QueryOptionLabel, { query: query, payload: payload, value: value });
2976
- }
2977
- if (fieldDef.options.length === 0)
2978
- return undefined;
2979
- return (value) => buildOptionLabels(effectiveTranslate, (option) => fieldOptionLabelKey(schema.featureName, synthScreen.entity, fieldName, option), [String(value)])[String(value)];
2980
- };
2981
- 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 ===
2982
- CONFIG_SCOPE_SOURCE[screen.scope], markChangedFields: true, ...(screen.fieldDescriptions !== undefined && {
2983
- fieldDescription: (fieldName) => {
2984
- const key = screen.fieldDescriptions?.[fieldName];
2985
- return key === undefined ? undefined : effectiveTranslate(key);
2986
- },
2987
- }), fieldAppendix: (fieldName) => {
2988
- const cascade = cascades[fieldName];
2989
- if (cascade === undefined)
2990
- return undefined;
2991
- const renderValue = cascadeValueRenderer(fieldName);
2992
- 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) => {
2993
- await dispatcher.write("config:write:reset", { key, scope });
2994
- await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
2995
- } }));
2996
- } }));
2997
- }