@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.1
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.
- package/dist/app/action-form-shim.d.ts +14 -0
- package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -44
- package/dist/app/app-features-context.d.ts +8 -0
- package/dist/app/app-features-context.js +16 -0
- package/dist/app/column-renderers.d.ts +30 -0
- package/dist/app/column-renderers.js +34 -0
- package/dist/app/config-edit-shim.d.ts +11 -0
- package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
- package/dist/app/content-editors.d.ts +38 -0
- package/dist/app/content-editors.js +26 -0
- package/dist/app/content-preview.d.ts +11 -0
- package/dist/app/content-preview.js +30 -0
- package/dist/app/custom-screens.d.ts +11 -0
- package/dist/app/custom-screens.js +20 -0
- package/dist/app/dashboard-body.d.ts +16 -0
- package/dist/app/dashboard-body.js +11 -0
- package/dist/app/extension-form-submit.d.ts +32 -0
- package/dist/app/extension-form-submit.js +73 -0
- package/dist/app/extension-sections.d.ts +72 -0
- package/dist/app/extension-sections.js +34 -0
- package/dist/app/feature-schema.d.ts +4 -0
- package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
- package/dist/app/form-schema.d.ts +4 -0
- package/dist/app/form-schema.js +85 -0
- package/dist/app/kumiko-screen.d.ts +43 -0
- package/dist/app/kumiko-screen.js +2206 -0
- package/dist/app/layout-fields.d.ts +3 -0
- package/dist/app/layout-fields.js +20 -0
- package/dist/app/list-facets.d.ts +48 -0
- package/dist/app/list-facets.js +185 -0
- package/dist/app/nav.d.ts +61 -0
- package/dist/app/nav.js +139 -0
- package/dist/app/projection-detail-shim.d.ts +8 -0
- package/dist/app/projection-detail-shim.js +71 -0
- package/dist/app/projection-list-shim.d.ts +13 -0
- package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -38
- package/dist/app/qn.d.ts +7 -0
- package/{src/app/qn.ts → dist/app/qn.js} +9 -11
- package/dist/app/qualify-screen-id.d.ts +2 -0
- package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
- package/dist/app/reference-facet-bridge.d.ts +10 -0
- package/dist/app/reference-facet-bridge.js +22 -0
- package/dist/app/return-to.d.ts +31 -0
- package/dist/app/return-to.js +188 -0
- package/dist/app/row-actions.d.ts +77 -0
- package/dist/app/row-actions.js +416 -0
- package/dist/app/screen-access.d.ts +8 -0
- package/dist/app/screen-access.js +48 -0
- package/dist/app/secret-mint-body.d.ts +10 -0
- package/dist/app/secret-mint-body.js +140 -0
- package/dist/app/secrets-edit-body.d.ts +8 -0
- package/dist/app/secrets-edit-body.js +76 -0
- package/dist/app/use-embedded-screen.d.ts +6 -0
- package/dist/app/use-embedded-screen.js +19 -0
- package/dist/app/variable-chips.d.ts +7 -0
- package/dist/app/variable-chips.js +16 -0
- package/dist/app/write-failed-error.d.ts +6 -0
- package/dist/app/write-failed-error.js +21 -0
- package/dist/components/embedded-list-field.d.ts +10 -0
- package/dist/components/embedded-list-field.js +259 -0
- package/dist/components/grid-cell-for-field.d.ts +25 -0
- package/dist/components/grid-cell-for-field.js +31 -0
- package/dist/components/multi-select-checkboxes.d.ts +14 -0
- package/dist/components/multi-select-checkboxes.js +36 -0
- package/dist/components/reference-create-dialog.d.ts +13 -0
- package/dist/components/reference-create-dialog.js +46 -0
- package/dist/components/related-list-section.d.ts +25 -0
- package/dist/components/related-list-section.js +273 -0
- package/dist/components/render-edit-action-button.d.ts +22 -0
- package/dist/components/render-edit-action-button.js +43 -0
- package/dist/components/render-edit-logic.d.ts +6 -0
- package/dist/components/render-edit-logic.js +63 -0
- package/dist/components/render-edit-types.d.ts +209 -0
- package/dist/components/render-edit-types.js +1 -0
- package/dist/components/render-edit.d.ts +6 -0
- package/dist/components/render-edit.js +1169 -0
- package/dist/components/render-field.d.ts +38 -0
- package/dist/components/render-field.js +544 -0
- package/dist/components/render-list.d.ts +117 -0
- package/dist/components/render-list.js +232 -0
- package/dist/components/write-form-section.d.ts +17 -0
- package/dist/components/write-form-section.js +104 -0
- package/dist/context/dispatcher-context.d.ts +10 -0
- package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
- package/dist/context/draft-storage-context.d.ts +12 -0
- package/dist/context/draft-storage-context.js +18 -0
- package/dist/context/user-roles-context.d.ts +7 -0
- package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
- package/dist/format-when.d.ts +1 -0
- package/{src/format-when.ts → dist/format-when.js} +7 -7
- package/dist/hooks/reference-limits.d.ts +3 -0
- package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
- package/dist/hooks/use-ai-text.d.ts +52 -0
- package/dist/hooks/use-ai-text.js +90 -0
- package/dist/hooks/use-disclosure.d.ts +7 -0
- package/dist/hooks/use-disclosure.js +8 -0
- package/dist/hooks/use-form.d.ts +7 -0
- package/dist/hooks/use-form.js +36 -0
- package/dist/hooks/use-list-url-state.d.ts +43 -0
- package/dist/hooks/use-list-url-state.js +131 -0
- package/dist/hooks/use-mutation.d.ts +9 -0
- package/dist/hooks/use-mutation.js +37 -0
- package/dist/hooks/use-query.d.ts +14 -0
- package/dist/hooks/use-query.js +118 -0
- package/dist/hooks/use-reference-lookup.d.ts +14 -0
- package/dist/hooks/use-reference-lookup.js +63 -0
- package/dist/hooks/use-store.d.ts +3 -0
- package/dist/hooks/use-store.js +38 -0
- package/dist/hooks/use-stream-handler.d.ts +14 -0
- package/dist/hooks/use-stream-handler.js +93 -0
- package/dist/i18n-defaults.d.ts +2 -0
- package/dist/i18n-defaults.js +201 -0
- package/dist/i18n.d.ts +64 -0
- package/dist/i18n.js +166 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +55 -0
- package/dist/inside-drawer.d.ts +2 -0
- package/dist/inside-drawer.js +9 -0
- package/dist/page-header-slot.d.ts +2 -0
- package/dist/page-header-slot.js +9 -0
- package/dist/primitives.d.ts +1351 -0
- package/dist/primitives.js +73 -0
- package/dist/screen-fills-height.d.ts +3 -0
- package/dist/screen-fills-height.js +3 -0
- package/dist/share-links.d.ts +4 -0
- package/dist/share-links.js +11 -0
- package/dist/sort-by-accessor.d.ts +5 -0
- package/dist/sort-by-accessor.js +16 -0
- package/dist/sse/live-events.d.ts +23 -0
- package/dist/sse/live-events.js +28 -0
- package/dist/tokens.d.ts +83 -0
- package/dist/tokens.js +80 -0
- package/package.json +16 -9
- package/src/changes.json +52 -0
- package/src/__tests__/error-i18n-defaults.test.ts +0 -82
- package/src/__tests__/format-when.test.ts +0 -12
- package/src/__tests__/i18n.test.tsx +0 -283
- package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
- package/src/__tests__/qn.test.ts +0 -83
- package/src/__tests__/share-links.test.ts +0 -26
- package/src/__tests__/sort-by-accessor.test.ts +0 -48
- package/src/__tests__/stringify-nav-params.test.ts +0 -33
- package/src/__tests__/use-list-url-state.test.tsx +0 -250
- package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
- package/src/app/__tests__/action-form-shim.test.ts +0 -137
- package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
- package/src/app/__tests__/config-edit-shim.test.ts +0 -57
- package/src/app/__tests__/content-editors.test.tsx +0 -128
- package/src/app/__tests__/content-preview.test.tsx +0 -184
- package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
- package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
- package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
- package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -111
- package/src/app/__tests__/feature-schema.test.ts +0 -40
- package/src/app/__tests__/form-schema.test.ts +0 -247
- package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
- package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
- package/src/app/__tests__/nav.test.ts +0 -198
- package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
- package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
- package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
- package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
- package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
- package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
- package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
- package/src/app/__tests__/qualified-names.test.ts +0 -37
- package/src/app/__tests__/return-to.test.ts +0 -396
- package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
- package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
- package/src/app/__tests__/screen-access-allows.test.ts +0 -34
- package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
- package/src/app/__tests__/variable-chips.test.tsx +0 -70
- package/src/app/app-features-context.tsx +0 -25
- package/src/app/column-renderers.tsx +0 -68
- package/src/app/content-editors.tsx +0 -85
- package/src/app/content-preview.tsx +0 -67
- package/src/app/custom-screens.tsx +0 -29
- package/src/app/dashboard-body.tsx +0 -33
- package/src/app/extension-form-submit.tsx +0 -123
- package/src/app/extension-sections.tsx +0 -111
- package/src/app/form-schema.ts +0 -91
- package/src/app/kumiko-screen.tsx +0 -3614
- package/src/app/layout-fields.ts +0 -31
- package/src/app/list-facets.ts +0 -259
- package/src/app/nav.tsx +0 -258
- package/src/app/projection-detail-shim.ts +0 -85
- package/src/app/reference-facet-bridge.tsx +0 -58
- package/src/app/return-to.tsx +0 -234
- package/src/app/row-actions.ts +0 -639
- package/src/app/screen-access.ts +0 -63
- package/src/app/secret-mint-body.tsx +0 -259
- package/src/app/secrets-edit-body.tsx +0 -194
- package/src/app/use-embedded-screen.ts +0 -27
- package/src/app/variable-chips.tsx +0 -39
- package/src/app/write-failed-error.ts +0 -27
- package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
- package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
- package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
- package/src/components/__tests__/related-list-section.test.tsx +0 -1669
- package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
- package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
- package/src/components/__tests__/render-edit-logic.test.ts +0 -314
- package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
- package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
- package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
- package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
- package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
- package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
- package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
- package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
- package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
- package/src/components/__tests__/write-form-section.test.tsx +0 -375
- package/src/components/embedded-list-field.tsx +0 -354
- package/src/components/grid-cell-for-field.tsx +0 -60
- package/src/components/multi-select-checkboxes.tsx +0 -93
- package/src/components/reference-create-dialog.tsx +0 -91
- package/src/components/related-list-section.tsx +0 -467
- package/src/components/render-edit-action-button.tsx +0 -106
- package/src/components/render-edit-logic.ts +0 -85
- package/src/components/render-edit-types.ts +0 -216
- package/src/components/render-edit.tsx +0 -1700
- package/src/components/render-field.tsx +0 -942
- package/src/components/render-list.tsx +0 -561
- package/src/components/write-form-section.tsx +0 -199
- package/src/context/draft-storage-context.tsx +0 -39
- package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
- package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
- package/src/hooks/__tests__/use-form.test.tsx +0 -65
- package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
- package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
- package/src/hooks/use-ai-text.ts +0 -172
- package/src/hooks/use-disclosure.ts +0 -20
- package/src/hooks/use-form.ts +0 -78
- package/src/hooks/use-list-url-state.ts +0 -171
- package/src/hooks/use-mutation.ts +0 -61
- package/src/hooks/use-query.ts +0 -132
- package/src/hooks/use-reference-lookup.ts +0 -76
- package/src/hooks/use-store.ts +0 -48
- package/src/hooks/use-stream-handler.ts +0 -134
- package/src/i18n-defaults.ts +0 -220
- package/src/i18n.tsx +0 -286
- package/src/index.ts +0 -272
- package/src/primitives.tsx +0 -1446
- package/src/share-links.ts +0 -12
- package/src/sort-by-accessor.ts +0 -20
- package/src/sse/live-events.tsx +0 -56
- package/src/tokens.tsx +0 -170
|
@@ -1,3614 +0,0 @@
|
|
|
1
|
-
import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import type {
|
|
3
|
-
ActionFormRedirect,
|
|
4
|
-
ActionFormScreenDefinition,
|
|
5
|
-
ConfigEditScreenDefinition,
|
|
6
|
-
DashboardScreenDefinition,
|
|
7
|
-
EntityDefinition,
|
|
8
|
-
EntityEditScreenDefinition,
|
|
9
|
-
EntityListScreenDefinition,
|
|
10
|
-
MetricNavigate,
|
|
11
|
-
MetricSpec,
|
|
12
|
-
ProjectionDetailScreenDefinition,
|
|
13
|
-
ProjectionListScreenDefinition,
|
|
14
|
-
RowAction,
|
|
15
|
-
RowActionDrawer,
|
|
16
|
-
RowActionNavigate,
|
|
17
|
-
ScreenDefinition,
|
|
18
|
-
ToolbarAction,
|
|
19
|
-
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
20
|
-
import {
|
|
21
|
-
evalFieldCondition,
|
|
22
|
-
isWriteFormEditSection,
|
|
23
|
-
metricField,
|
|
24
|
-
parseRefTarget,
|
|
25
|
-
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
26
|
-
import type {
|
|
27
|
-
Command,
|
|
28
|
-
FormSnapshot,
|
|
29
|
-
FormValues,
|
|
30
|
-
ListRowViewModel,
|
|
31
|
-
SubmitResult,
|
|
32
|
-
Translate,
|
|
33
|
-
} from "@cosmicdrift/kumiko-headless";
|
|
34
|
-
import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
35
|
-
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
36
|
-
import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
|
|
37
|
-
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
38
|
-
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog";
|
|
39
|
-
import {
|
|
40
|
-
RenderEdit,
|
|
41
|
-
type RenderEditAction,
|
|
42
|
-
type RenderEditControls,
|
|
43
|
-
} from "../components/render-edit";
|
|
44
|
-
import {
|
|
45
|
-
needsActionConfirm,
|
|
46
|
-
RenderEditActionButton,
|
|
47
|
-
RenderEditActionConfirmDialog,
|
|
48
|
-
} from "../components/render-edit-action-button";
|
|
49
|
-
import { RenderList } from "../components/render-list";
|
|
50
|
-
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
|
|
51
|
-
import { useUserRoles } from "../context/user-roles-context";
|
|
52
|
-
import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
|
|
53
|
-
import { type UseQueryResult, useQuery } from "../hooks/use-query";
|
|
54
|
-
import { useOptionalTimeZone, useTranslation } from "../i18n";
|
|
55
|
-
import {
|
|
56
|
-
type DataTableDateRangeFacet,
|
|
57
|
-
type DataTableFacet,
|
|
58
|
-
type DataTableRowAction,
|
|
59
|
-
statusToneForValue,
|
|
60
|
-
usePrimitives,
|
|
61
|
-
} from "../primitives";
|
|
62
|
-
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
|
|
63
|
-
import { useAppFeatures } from "./app-features-context";
|
|
64
|
-
import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
|
|
65
|
-
import { useCustomScreenComponent } from "./custom-screens";
|
|
66
|
-
import { useDashboardBody } from "./dashboard-body";
|
|
67
|
-
import type { FeatureSchema } from "./feature-schema";
|
|
68
|
-
import { buildFormSchema } from "./form-schema";
|
|
69
|
-
import { layoutFieldNames } from "./layout-fields";
|
|
70
|
-
import {
|
|
71
|
-
buildDateRangePayload,
|
|
72
|
-
buildFilterFacets,
|
|
73
|
-
buildFilterPayload,
|
|
74
|
-
clampDateRange,
|
|
75
|
-
mergeReferenceFacetOptions,
|
|
76
|
-
type ResolvedFacetSpec,
|
|
77
|
-
readDateRange,
|
|
78
|
-
resolveDateRangeFacets,
|
|
79
|
-
resolveProjectionFacetSpecs,
|
|
80
|
-
} from "./list-facets";
|
|
81
|
-
import { type NavApi, type ScreenTarget, useInitialValuesHandoff, useNav } from "./nav";
|
|
82
|
-
import {
|
|
83
|
-
synthesizeProjectionDetailEntity,
|
|
84
|
-
synthesizeProjectionDetailScreen,
|
|
85
|
-
} from "./projection-detail-shim";
|
|
86
|
-
import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
|
|
87
|
-
import { lastSegment, toKebab } from "./qn";
|
|
88
|
-
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
|
|
89
|
-
import { ReferenceFacetBridges, type ReferenceFacetOption } from "./reference-facet-bridge";
|
|
90
|
-
import {
|
|
91
|
-
navigateToReturn,
|
|
92
|
-
navigateToReturnOr,
|
|
93
|
-
navigateWithReturnTo,
|
|
94
|
-
type ReturnHost,
|
|
95
|
-
ReturnHostProvider,
|
|
96
|
-
useReturnHost,
|
|
97
|
-
useReturnTarget,
|
|
98
|
-
} from "./return-to";
|
|
99
|
-
import {
|
|
100
|
-
buildDefaultEditRowAction,
|
|
101
|
-
buildProjectionRowActions,
|
|
102
|
-
buildProjectionToolbarActions,
|
|
103
|
-
buildRecordActions,
|
|
104
|
-
evalRowExtractor,
|
|
105
|
-
isWriteHandlerRowAction,
|
|
106
|
-
navigateActionSearchParams,
|
|
107
|
-
refetchAfterWrite,
|
|
108
|
-
runProjectionRowNavigate,
|
|
109
|
-
} from "./row-actions";
|
|
110
|
-
import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access";
|
|
111
|
-
import { SecretMintBody } from "./secret-mint-body";
|
|
112
|
-
import { SecretsEditBody } from "./secrets-edit-body";
|
|
113
|
-
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
114
|
-
|
|
115
|
-
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
116
|
-
// routes it to the right renderer based on `screen.type`. Command
|
|
117
|
-
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
118
|
-
// so the renderers stay command-agnostic — consistent with how the
|
|
119
|
-
// server-side dispatcher resolves QNs from ScreenDefinition + feature.
|
|
120
|
-
//
|
|
121
|
-
// The discriminator is `screen.type`, not a component-registry lookup
|
|
122
|
-
// with feature-provided overrides — that's M4's r.uiComponent feature.
|
|
123
|
-
|
|
124
|
-
export type KumikoScreenProps = {
|
|
125
|
-
readonly schema: FeatureSchema;
|
|
126
|
-
readonly qn: string;
|
|
127
|
-
readonly translate?: Translate;
|
|
128
|
-
// Optional entity-id / row-id. Meaningful for entityEdit screens — when
|
|
129
|
-
// set, the edit screen loads the existing record via the detail query and
|
|
130
|
-
// submits an update command (`write:<entity>:update`) instead of a create.
|
|
131
|
-
// Also required for projectionDetail screens (which row to fetch — no
|
|
132
|
-
// create mode exists there). For entityList/custom screens it's ignored.
|
|
133
|
-
readonly entityId?: string;
|
|
134
|
-
// Fires when the user clicks a row on an entityList screen. The
|
|
135
|
-
// second argument is the screen's entity name, threaded through so
|
|
136
|
-
// the caller's handler can navigate to `<edit screen for this
|
|
137
|
-
// entity>/{row.id}` without re-deriving it. Default wiring in
|
|
138
|
-
// createKumikoApp does exactly that; override to open a drawer,
|
|
139
|
-
// inline-expand, etc.
|
|
140
|
-
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
141
|
-
// Copy-Link-Action auf entityEdit-Update-Screens (Issue #912). Bereits
|
|
142
|
-
// vollständig gebunden (URL-Bau + Clipboard) — createKumikoApp/RoutedScreen
|
|
143
|
-
// liefert die Default-Impl, dieses platform-neutrale Package baut selbst
|
|
144
|
-
// keine URLs/Clipboard-Calls (guard-renderer-boundaries). Nur in
|
|
145
|
-
// update-mode sichtbar (entityId muss gesetzt sein).
|
|
146
|
-
readonly onCopyLink?: () => Promise<void> | void;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
export { qualifyScreenId };
|
|
150
|
-
|
|
151
|
-
/** Mirrors qualifyScreenId for nav QNs (registry form `<feature>:nav:<short-id>`).
|
|
152
|
-
* Callers building QNs (e.g. the WorkspaceShell resolver) should use this instead of string-concat, so a QN-schema change only touches one place. */
|
|
153
|
-
export function qualifyNavId(featureName: string, navId: string): string {
|
|
154
|
-
return `${featureName}:nav:${navId}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export { screenAccessAllows };
|
|
158
|
-
|
|
159
|
-
export function KumikoScreen({
|
|
160
|
-
schema,
|
|
161
|
-
qn,
|
|
162
|
-
translate,
|
|
163
|
-
entityId,
|
|
164
|
-
onRowClick,
|
|
165
|
-
onCopyLink,
|
|
166
|
-
}: KumikoScreenProps): ReactNode {
|
|
167
|
-
const { Banner, Text } = usePrimitives();
|
|
168
|
-
const userRoles = useUserRoles();
|
|
169
|
-
const outerHost = useReturnHost();
|
|
170
|
-
const screen = useMemo(
|
|
171
|
-
() =>
|
|
172
|
-
schema.screens.find(
|
|
173
|
-
(s: ScreenDefinition) => qualifyScreenId(schema.featureName, s.id) === qn,
|
|
174
|
-
),
|
|
175
|
-
[schema.featureName, schema.screens, qn],
|
|
176
|
-
);
|
|
177
|
-
// Computed before the early returns below — hooks must run unconditionally.
|
|
178
|
-
const ownHost = useMemo(
|
|
179
|
-
(): ReturnHost => ({
|
|
180
|
-
screenId: screen !== undefined ? lastSegment(screen.id) : "",
|
|
181
|
-
...(entityId !== undefined && { entityId }),
|
|
182
|
-
}),
|
|
183
|
-
[screen, entityId],
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
if (!screen) {
|
|
187
|
-
return (
|
|
188
|
-
<Banner padded variant="error" testId="kumiko-screen-not-found">
|
|
189
|
-
Screen not found: <Text variant="code">{qn}</Text>
|
|
190
|
-
</Banner>
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (!screenAccessAllows(screen.access, userRoles)) {
|
|
195
|
-
return (
|
|
196
|
-
<Banner padded variant="error" testId="kumiko-screen-access-denied">
|
|
197
|
-
Access denied: <Text variant="code">{qn}</Text>
|
|
198
|
-
</Banner>
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const body = renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink });
|
|
203
|
-
// An embedded screen (e.g. a dashboard panel) keeps its parent's host.
|
|
204
|
-
if (outerHost !== undefined) return body;
|
|
205
|
-
return <ReturnHostProvider value={ownHost}>{body}</ReturnHostProvider>;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function renderScreenBody({
|
|
209
|
-
schema,
|
|
210
|
-
screen,
|
|
211
|
-
translate,
|
|
212
|
-
entityId,
|
|
213
|
-
onRowClick,
|
|
214
|
-
onCopyLink,
|
|
215
|
-
}: {
|
|
216
|
-
readonly schema: FeatureSchema;
|
|
217
|
-
readonly screen: ScreenDefinition;
|
|
218
|
-
readonly translate?: Translate;
|
|
219
|
-
readonly entityId?: string;
|
|
220
|
-
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
221
|
-
readonly onCopyLink?: () => Promise<void> | void;
|
|
222
|
-
}): ReactNode {
|
|
223
|
-
switch (screen.type) {
|
|
224
|
-
case "entityEdit":
|
|
225
|
-
return (
|
|
226
|
-
<EntityEditScreen
|
|
227
|
-
schema={schema}
|
|
228
|
-
screen={screen}
|
|
229
|
-
translate={translate}
|
|
230
|
-
{...(entityId !== undefined && { entityId })}
|
|
231
|
-
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
232
|
-
/>
|
|
233
|
-
);
|
|
234
|
-
case "entityList":
|
|
235
|
-
return (
|
|
236
|
-
<EntityListScreen
|
|
237
|
-
schema={schema}
|
|
238
|
-
screen={screen}
|
|
239
|
-
translate={translate}
|
|
240
|
-
{...(onRowClick !== undefined && { onRowClick })}
|
|
241
|
-
/>
|
|
242
|
-
);
|
|
243
|
-
case "projectionList":
|
|
244
|
-
return (
|
|
245
|
-
<ProjectionListBody
|
|
246
|
-
schema={schema}
|
|
247
|
-
screen={screen}
|
|
248
|
-
translate={translate}
|
|
249
|
-
{...(onRowClick !== undefined && { onRowClick })}
|
|
250
|
-
/>
|
|
251
|
-
);
|
|
252
|
-
case "projectionDetail":
|
|
253
|
-
return (
|
|
254
|
-
// key on the record identifier alone (never the active tab, see
|
|
255
|
-
// ProjectionDetailBody's own hideSectionTitles/tab handling) forces
|
|
256
|
-
// a full remount when navigating from one record to the next —
|
|
257
|
-
// without it React keeps the RenderEdit form controller alive and
|
|
258
|
-
// the screen briefly shows the PREVIOUS record's fields (fw#2518).
|
|
259
|
-
<ProjectionDetailBody
|
|
260
|
-
key={entityId ?? "no-entity"}
|
|
261
|
-
schema={schema}
|
|
262
|
-
screen={screen}
|
|
263
|
-
translate={translate}
|
|
264
|
-
{...(entityId !== undefined && { entityId })}
|
|
265
|
-
/>
|
|
266
|
-
);
|
|
267
|
-
case "dashboard":
|
|
268
|
-
return (
|
|
269
|
-
<DashboardScreenBody
|
|
270
|
-
featureName={schema.featureName}
|
|
271
|
-
screen={screen}
|
|
272
|
-
translate={translate}
|
|
273
|
-
/>
|
|
274
|
-
);
|
|
275
|
-
case "actionForm":
|
|
276
|
-
return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
|
|
277
|
-
case "secretMint":
|
|
278
|
-
return <SecretMintBody schema={schema} screen={screen} translate={translate} />;
|
|
279
|
-
case "configEdit":
|
|
280
|
-
return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
|
|
281
|
-
case "secretsEdit":
|
|
282
|
-
return <SecretsEditBody screen={screen} translate={translate} />;
|
|
283
|
-
case "custom":
|
|
284
|
-
return <CustomScreenBody screenId={screen.id} featureName={schema.featureName} />;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Injection-Body für dashboard-Screens: die Panel-Implementierung (StatCard,
|
|
289
|
-
// Charts) ist plattform-spezifisch und kommt aus dem DashboardBody-Context
|
|
290
|
-
// (renderer-web registriert die Web-Variante in createKumikoApp).
|
|
291
|
-
function DashboardScreenBody({
|
|
292
|
-
featureName,
|
|
293
|
-
screen,
|
|
294
|
-
translate,
|
|
295
|
-
}: {
|
|
296
|
-
readonly featureName: string;
|
|
297
|
-
readonly screen: DashboardScreenDefinition;
|
|
298
|
-
readonly translate?: Translate;
|
|
299
|
-
}): ReactNode {
|
|
300
|
-
const { Banner, Text } = usePrimitives();
|
|
301
|
-
const Body = useDashboardBody();
|
|
302
|
-
if (Body === undefined) {
|
|
303
|
-
return (
|
|
304
|
-
<Banner padded variant="info" testId="kumiko-screen-dashboard-placeholder">
|
|
305
|
-
Dashboard screen <Text variant="code">{screen.id}</Text> — kein Dashboard-Body registriert
|
|
306
|
-
(createKumikoApp aus <Text variant="code">kumiko-renderer-web</Text> wired ihn automatisch).
|
|
307
|
-
</Banner>
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
return (
|
|
311
|
-
<Body
|
|
312
|
-
featureName={featureName}
|
|
313
|
-
screen={screen}
|
|
314
|
-
{...(translate !== undefined && { translate })}
|
|
315
|
-
/>
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Lookup-Body für custom-screens: schaut die Component aus dem
|
|
320
|
-
// CustomScreens-Context (gefüttert von clientFeatures.components in
|
|
321
|
-
// createKumikoApp). Wenn weder Provider gemounted noch screenId
|
|
322
|
-
// registriert ist, fällt es auf einen error-Banner zurück statt leer zu
|
|
323
|
-
// rendern — der Screen ist per URL erreichbar sobald das Server-Feature
|
|
324
|
-
// gemountet ist, unabhängig davon ob ein Client-Plugin existiert
|
|
325
|
-
// (kumiko-framework#2025).
|
|
326
|
-
function CustomScreenBody({
|
|
327
|
-
screenId,
|
|
328
|
-
featureName,
|
|
329
|
-
}: {
|
|
330
|
-
readonly screenId: string;
|
|
331
|
-
readonly featureName: string;
|
|
332
|
-
}): ReactNode {
|
|
333
|
-
const { Banner, Text } = usePrimitives();
|
|
334
|
-
const Component = useCustomScreenComponent(screenId);
|
|
335
|
-
if (Component === undefined) {
|
|
336
|
-
return (
|
|
337
|
-
<Banner padded variant="error" testId="kumiko-screen-custom-placeholder">
|
|
338
|
-
Custom screen <Text variant="code">{screenId}</Text> (Feature{" "}
|
|
339
|
-
<Text variant="code">{featureName}</Text>) hat keine Component in{" "}
|
|
340
|
-
<Text variant="code">clientFeatures.components</Text> — das Web-Client-Plugin des Features
|
|
341
|
-
(siehe <Text variant="code">{featureName}/web/client-plugin.tsx</Text>) fehlt in{" "}
|
|
342
|
-
<Text variant="code">clientFeatures</Text>.
|
|
343
|
-
</Banner>
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
return <Component />;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// ---- entity-edit ----
|
|
350
|
-
|
|
351
|
-
// Derives `<feature>:write:<entity>:<verb>` from the screen's entity
|
|
352
|
-
// and the schema's feature name. Matches qualifyEntityName / toKebab in
|
|
353
|
-
// packages/framework/src/engine/qualified-name.ts so camelCase entity
|
|
354
|
-
// ids (e.g. driverModel) resolve to server QNs (driver-model:create).
|
|
355
|
-
function entityWriteCommand(
|
|
356
|
-
featureName: string,
|
|
357
|
-
entity: string,
|
|
358
|
-
verb: "create" | "update" | "delete",
|
|
359
|
-
): string {
|
|
360
|
-
return `${toKebab(featureName)}:write:${toKebab(entity)}:${verb}`;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Default "success → zurück zur Liste"-Navigation. Findet den ersten
|
|
364
|
-
// entityList-Screen für die Entity und navigiert dahin. Wird von
|
|
365
|
-
// Create/Update/Delete genauso verwendet — alle drei haben "fertig
|
|
366
|
-
// editiert, raus hier" als sinnvolles Default-Verhalten.
|
|
367
|
-
function useNavigateToListAfter(schema: FeatureSchema, entityName: string): () => void {
|
|
368
|
-
const nav = useNav();
|
|
369
|
-
return useCallback(() => {
|
|
370
|
-
const list = schema.screens.find(
|
|
371
|
-
(s: ScreenDefinition) => s.type === "entityList" && s.entity === entityName,
|
|
372
|
-
);
|
|
373
|
-
if (!list) return;
|
|
374
|
-
// schema.screens.id ist QN-form (registry-stamped); nav.navigate
|
|
375
|
-
// erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
|
|
376
|
-
nav.navigate({ screenId: lastSegment(list.id) });
|
|
377
|
-
}, [nav, schema.screens, entityName]);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
|
|
381
|
-
// entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
|
|
382
|
-
// Returns undefined wenn kein Edit-Screen registriert ist — RenderList
|
|
383
|
-
// rendert dann keinen + Neu Button.
|
|
384
|
-
//
|
|
385
|
-
// Schema-Screens kommen mit qualifizierten ids ("publicstatus:screen:
|
|
386
|
-
// component-edit") aus der Registry; lastSegment strippt den Prefix
|
|
387
|
-
// für nav.navigate (siehe ./qn.ts für Doku).
|
|
388
|
-
function useNavigateToCreateFor(
|
|
389
|
-
schema: FeatureSchema,
|
|
390
|
-
entityName: string,
|
|
391
|
-
): (() => void) | undefined {
|
|
392
|
-
const nav = useNav();
|
|
393
|
-
const host = useReturnHost();
|
|
394
|
-
const editScreenId = useMemo(() => {
|
|
395
|
-
// allowCreate:false = update-only Edit-Screen (Create läuft über einen
|
|
396
|
-
// Lifecycle-Write) — der zählt nicht als „+ Neu"-Ziel. singleton:true
|
|
397
|
-
// öffnet ohne entityId den vorhandenen Record (EntityEditSingletonBody)
|
|
398
|
-
// statt eines leeren Create-Forms — zählt aus demselben Grund nicht.
|
|
399
|
-
const edit = schema.screens.find(
|
|
400
|
-
(s: ScreenDefinition) =>
|
|
401
|
-
s.type === "entityEdit" &&
|
|
402
|
-
s.entity === entityName &&
|
|
403
|
-
s.allowCreate !== false &&
|
|
404
|
-
s.singleton !== true,
|
|
405
|
-
);
|
|
406
|
-
return edit !== undefined ? lastSegment(edit.id) : undefined;
|
|
407
|
-
}, [schema.screens, entityName]);
|
|
408
|
-
const navigate = useCallback(() => {
|
|
409
|
-
if (editScreenId !== undefined) navigateWithReturnTo(nav, { screenId: editScreenId }, host);
|
|
410
|
-
}, [nav, editScreenId, host]);
|
|
411
|
-
return editScreenId !== undefined ? navigate : undefined;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// `defaultCurrency` is entityEdit-only — it enables the `{amount, currency}`
|
|
415
|
-
// write shape (fw#1923); config-edit's plain-number contract needs bare `0`.
|
|
416
|
-
// `moneyCurrencyOverrides` (fw#2933) replaces `defaultCurrency` per field name
|
|
417
|
-
// — used for a `money` field that declares `currency: { kind: "tenant" }`, so
|
|
418
|
-
// its empty initial value carries the tenant's own currency instead of the
|
|
419
|
-
// entity-wide default.
|
|
420
|
-
export function buildInitialValues(
|
|
421
|
-
fields: Readonly<Record<string, unknown>>,
|
|
422
|
-
defaultCurrency?: string,
|
|
423
|
-
moneyCurrencyOverrides?: Readonly<Record<string, string>>,
|
|
424
|
-
): Readonly<Record<string, unknown>> {
|
|
425
|
-
const out: Record<string, unknown> = {};
|
|
426
|
-
for (const [name, def] of Object.entries(fields)) {
|
|
427
|
-
const shape = def as { type?: string; default?: unknown; multiple?: boolean };
|
|
428
|
-
if (shape.default !== undefined) {
|
|
429
|
-
out[name] = shape.default;
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
if (shape.type === "embedded" && shape.multiple === true) {
|
|
433
|
-
out[name] = [];
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
if (shape.type === "money") {
|
|
437
|
-
const currency = moneyCurrencyOverrides?.[name] ?? defaultCurrency;
|
|
438
|
-
if (currency !== undefined) {
|
|
439
|
-
out[name] = { amount: 0, currency };
|
|
440
|
-
continue;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
out[name] =
|
|
444
|
-
shape.type === "boolean"
|
|
445
|
-
? false
|
|
446
|
-
: shape.type === "number" || shape.type === "money"
|
|
447
|
-
? 0
|
|
448
|
-
: shape.type === "multiSelect"
|
|
449
|
-
? []
|
|
450
|
-
: "";
|
|
451
|
-
}
|
|
452
|
-
return out;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// A `money` field opts a currently-empty value into the tenant-settings
|
|
456
|
-
// bundle's per-tenant currency (fw#2933) via `currency: { kind: "tenant" }`
|
|
457
|
-
// on its FieldDefinition. Read structurally — renderer only depends on the
|
|
458
|
-
// client-safe FieldDefinition subset, not the concrete MoneyFieldDef/
|
|
459
|
-
// MoneyCurrencySource types, same idiom as the other field-shape narrowings
|
|
460
|
-
// in this file (PrefillFieldShape etc.).
|
|
461
|
-
function tenantCurrencyMoneyFieldNames(
|
|
462
|
-
fields: Readonly<Record<string, unknown>>,
|
|
463
|
-
): readonly string[] {
|
|
464
|
-
const names: string[] = [];
|
|
465
|
-
for (const [name, def] of Object.entries(fields)) {
|
|
466
|
-
const shape = def as { readonly type?: string; readonly currency?: { readonly kind?: string } };
|
|
467
|
-
if (shape.type === "money" && shape.currency?.kind === "tenant") names.push(name);
|
|
468
|
-
}
|
|
469
|
-
return names;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
type TenantConfigValuesResponse = Readonly<
|
|
473
|
-
Record<string, { readonly value: string | number | boolean | undefined }>
|
|
474
|
-
>;
|
|
475
|
-
|
|
476
|
-
type TenantCurrencyResolution =
|
|
477
|
-
| { readonly status: "not-needed" }
|
|
478
|
-
| { readonly status: "loading" }
|
|
479
|
-
| { readonly status: "ready"; readonly currency: string };
|
|
480
|
-
|
|
481
|
-
// Resolves what a tenant-declared money field's currency should be, from the
|
|
482
|
-
// same `config:query:values` query ConfigEditBody already uses. Never falls
|
|
483
|
-
// back to "EUR" while the query is genuinely still in flight (fw#2933) — a
|
|
484
|
-
// screen with no tenant-declared money field, or a query that errored or
|
|
485
|
-
// returned no value for the key, resolves immediately instead of blocking.
|
|
486
|
-
function resolveTenantCurrency(
|
|
487
|
-
fieldNames: readonly string[],
|
|
488
|
-
query: UseQueryResult<TenantConfigValuesResponse>,
|
|
489
|
-
fallback: string,
|
|
490
|
-
): TenantCurrencyResolution {
|
|
491
|
-
if (fieldNames.length === 0) return { status: "not-needed" };
|
|
492
|
-
if (query.error) return { status: "ready", currency: fallback };
|
|
493
|
-
if (query.data === null) return { status: "loading" };
|
|
494
|
-
const raw = query.data[TENANT_CURRENCY_CONFIG_KEY]?.value;
|
|
495
|
-
return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// An entity-less form screen has no `entity.defaultCurrency` to fall back on,
|
|
499
|
-
// so a tenant-declared field whose config query errors or returns no value
|
|
500
|
-
// lands here — same last resort as the entityEdit path's `?? "EUR"`.
|
|
501
|
-
const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
|
|
502
|
-
|
|
503
|
-
// Shared by the update payload shape and the server-error path prefix so both can't drift.
|
|
504
|
-
const UPDATE_CHANGES_KEY = "changes";
|
|
505
|
-
|
|
506
|
-
// A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
|
|
507
|
-
// fw#2839) resolves with no query at all. Read structurally, same idiom as
|
|
508
|
-
// tenantCurrencyMoneyFieldNames above.
|
|
509
|
-
export function literalCurrencyOverrides(
|
|
510
|
-
fields: Readonly<Record<string, unknown>>,
|
|
511
|
-
): Readonly<Record<string, string>> {
|
|
512
|
-
const out: Record<string, string> = {};
|
|
513
|
-
for (const [name, def] of Object.entries(fields)) {
|
|
514
|
-
const shape = def as {
|
|
515
|
-
readonly type?: string;
|
|
516
|
-
readonly currency?: { readonly kind?: string; readonly code?: string };
|
|
517
|
-
};
|
|
518
|
-
const code = shape.currency?.code;
|
|
519
|
-
if (shape.type === "money" && shape.currency?.kind === "literal" && code !== undefined) {
|
|
520
|
-
out[name] = code;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
return out;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// Per-field currency for the money fields that declare their own source: a
|
|
527
|
-
// literal code resolves synchronously (fw#2839), a `{ kind: "tenant" }` field
|
|
528
|
-
// needs the tenant-settings config query and makes the caller hold the form
|
|
529
|
-
// until it lands (fw#2933) — seeding the fallback meanwhile would render, and
|
|
530
|
-
// on a fast click submit, the wrong currency. Callers pass the tenant field
|
|
531
|
-
// names themselves because an edit form only needs the fetch for fields the
|
|
532
|
-
// record has no stored value for.
|
|
533
|
-
function useMoneyCurrencyOverrides(
|
|
534
|
-
fields: Readonly<Record<string, unknown>>,
|
|
535
|
-
tenantFieldNames: readonly string[],
|
|
536
|
-
fallback: string,
|
|
537
|
-
): {
|
|
538
|
-
readonly overrides: Readonly<Record<string, string>> | undefined;
|
|
539
|
-
readonly loading: boolean;
|
|
540
|
-
} {
|
|
541
|
-
const query = useQuery<TenantConfigValuesResponse>(
|
|
542
|
-
"config:query:values",
|
|
543
|
-
{},
|
|
544
|
-
{ enabled: tenantFieldNames.length > 0 },
|
|
545
|
-
);
|
|
546
|
-
const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
|
|
547
|
-
const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
|
|
548
|
-
const overrides = useMemo(() => {
|
|
549
|
-
const out: Record<string, string> = { ...literalCurrencyOverrides(fields) };
|
|
550
|
-
if (resolvedTenantCurrency !== undefined) {
|
|
551
|
-
for (const name of tenantFieldNames) out[name] = resolvedTenantCurrency;
|
|
552
|
-
}
|
|
553
|
-
return Object.keys(out).length > 0 ? out : undefined;
|
|
554
|
-
}, [fields, tenantFieldNames, resolvedTenantCurrency]);
|
|
555
|
-
return { overrides, loading: resolution.status === "loading" };
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function multiSelectOptionValues(shape: {
|
|
559
|
-
readonly options?: readonly (string | { readonly value: string })[];
|
|
560
|
-
}): ReadonlySet<string> | undefined {
|
|
561
|
-
if (shape.options === undefined || shape.options.length === 0) return undefined;
|
|
562
|
-
return new Set(shape.options.map((o) => (typeof o === "string" ? o : o.value)));
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
type MoneyValue = { readonly amount: number; readonly currency: string };
|
|
566
|
-
|
|
567
|
-
// The currency now comes from a user-controlled URL param and reaches
|
|
568
|
-
// `Intl.NumberFormat`, which throws a RangeError on a malformed code — reject
|
|
569
|
-
// it here instead of crashing the form render.
|
|
570
|
-
const CURRENCY_CODE_PATTERN = /^[A-Za-z]{3}$/;
|
|
571
|
-
|
|
572
|
-
function isMoneyValue(value: unknown): value is MoneyValue {
|
|
573
|
-
if (typeof value !== "object" || value === null) return false;
|
|
574
|
-
const { amount, currency } = value as Partial<MoneyValue>;
|
|
575
|
-
return (
|
|
576
|
-
typeof amount === "number" &&
|
|
577
|
-
Number.isFinite(amount) &&
|
|
578
|
-
typeof currency === "string" &&
|
|
579
|
-
CURRENCY_CODE_PATTERN.test(currency)
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function parseJsonOrRaw(raw: string): unknown {
|
|
584
|
-
try {
|
|
585
|
-
return JSON.parse(raw);
|
|
586
|
-
} catch {
|
|
587
|
-
return raw;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function warnPrefillParam(message: string): void {
|
|
592
|
-
// biome-ignore lint/suspicious/noConsole: dev-warning for an authoring error
|
|
593
|
-
console.warn(`[kumiko] ${message}`);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// An actionForm has no entity and thus no defaultCurrency, so a bare-number
|
|
597
|
-
// prefill would stay a number the handler's zod schema rejects (fw#2763).
|
|
598
|
-
function coerceMoneyValue(
|
|
599
|
-
raw: unknown,
|
|
600
|
-
fieldName: string,
|
|
601
|
-
defaultCurrency?: string,
|
|
602
|
-
): MoneyValue | number | undefined {
|
|
603
|
-
if (raw === null || raw === undefined) return undefined;
|
|
604
|
-
const value = typeof raw === "string" ? parseJsonOrRaw(raw) : raw;
|
|
605
|
-
if (value === null) return undefined;
|
|
606
|
-
if (isMoneyValue(value)) return { amount: value.amount, currency: value.currency.toUpperCase() };
|
|
607
|
-
if (typeof value === "object") {
|
|
608
|
-
warnPrefillParam(
|
|
609
|
-
`money field "${fieldName}" got an object without a usable {amount, currency} shape — the prefill is ignored.`,
|
|
610
|
-
);
|
|
611
|
-
return undefined;
|
|
612
|
-
}
|
|
613
|
-
const amount = Number(typeof value === "number" ? value : raw);
|
|
614
|
-
if (!Number.isFinite(amount)) return undefined;
|
|
615
|
-
if (defaultCurrency !== undefined) return { amount, currency: defaultCurrency };
|
|
616
|
-
warnPrefillParam(
|
|
617
|
-
`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.`,
|
|
618
|
-
);
|
|
619
|
-
return amount;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
type EmbeddedCellShape = { readonly type?: string; readonly options?: readonly string[] };
|
|
623
|
-
|
|
624
|
-
const EMBEDDED_LIST_PREFILL_ROW_LIMIT = 500;
|
|
625
|
-
|
|
626
|
-
function coerceEmbeddedCell(value: unknown, cell: EmbeddedCellShape): unknown {
|
|
627
|
-
switch (cell.type) {
|
|
628
|
-
case "number":
|
|
629
|
-
case "decimal":
|
|
630
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
631
|
-
case "money":
|
|
632
|
-
// Row cells are signed minor units; the currency lives on the head, so a
|
|
633
|
-
// top-level `{amount, currency}` in major units would land 100x off.
|
|
634
|
-
return typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
|
|
635
|
-
case "boolean":
|
|
636
|
-
return typeof value === "boolean" ? value : undefined;
|
|
637
|
-
case "select":
|
|
638
|
-
return typeof value === "string" && (cell.options ?? []).includes(value) ? value : undefined;
|
|
639
|
-
default:
|
|
640
|
-
return typeof value === "string" ? value : undefined;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// A partially coerced list is the hazard the prefill exists to avoid: the
|
|
645
|
-
// handler replaces the list whole, so half a list overwrites the rest.
|
|
646
|
-
function coerceEmbeddedListRows(
|
|
647
|
-
raw: string,
|
|
648
|
-
fieldName: string,
|
|
649
|
-
schema: Readonly<Record<string, EmbeddedCellShape>>,
|
|
650
|
-
maxItems?: number,
|
|
651
|
-
): readonly Readonly<Record<string, unknown>>[] | undefined {
|
|
652
|
-
const parsed = parseJsonOrRaw(raw);
|
|
653
|
-
if (!Array.isArray(parsed)) {
|
|
654
|
-
warnPrefillParam(
|
|
655
|
-
`embedded-list field "${fieldName}" was prefilled with something that is not a JSON array — the prefill is ignored.`,
|
|
656
|
-
);
|
|
657
|
-
return undefined;
|
|
658
|
-
}
|
|
659
|
-
if (parsed.length > (maxItems ?? EMBEDDED_LIST_PREFILL_ROW_LIMIT)) {
|
|
660
|
-
warnPrefillParam(
|
|
661
|
-
`embedded-list field "${fieldName}" was prefilled with ${parsed.length} rows, more than it accepts — the prefill is ignored.`,
|
|
662
|
-
);
|
|
663
|
-
return undefined;
|
|
664
|
-
}
|
|
665
|
-
const rows: Readonly<Record<string, unknown>>[] = [];
|
|
666
|
-
for (const candidate of parsed) {
|
|
667
|
-
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
668
|
-
warnPrefillParam(
|
|
669
|
-
`embedded-list field "${fieldName}" was prefilled with a row that is not an object — the prefill is ignored.`,
|
|
670
|
-
);
|
|
671
|
-
return undefined;
|
|
672
|
-
}
|
|
673
|
-
const cellEntries: [string, unknown][] = [];
|
|
674
|
-
for (const [cellName, cell] of Object.entries(schema)) {
|
|
675
|
-
const cellValue = (candidate as Record<string, unknown>)[cellName];
|
|
676
|
-
if (cellValue === undefined || cellValue === null) continue;
|
|
677
|
-
const coerced = coerceEmbeddedCell(cellValue, cell);
|
|
678
|
-
if (coerced === undefined) {
|
|
679
|
-
warnPrefillParam(
|
|
680
|
-
`embedded-list field "${fieldName}" was prefilled with a cell "${cellName}" the sub-schema rejects — the prefill is ignored.`,
|
|
681
|
-
);
|
|
682
|
-
return undefined;
|
|
683
|
-
}
|
|
684
|
-
cellEntries.push([cellName, coerced]);
|
|
685
|
-
}
|
|
686
|
-
rows.push(Object.fromEntries(cellEntries));
|
|
687
|
-
}
|
|
688
|
-
return rows;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
type PrefillFieldShape = {
|
|
692
|
-
readonly type?: string;
|
|
693
|
-
readonly sensitive?: boolean;
|
|
694
|
-
readonly format?: string;
|
|
695
|
-
readonly options?: readonly (string | { readonly value: string })[];
|
|
696
|
-
readonly multiple?: boolean;
|
|
697
|
-
readonly schema?: Readonly<Record<string, EmbeddedCellShape>>;
|
|
698
|
-
readonly maxItems?: number;
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
export type InitialValueSources = {
|
|
702
|
-
readonly searchParams: Readonly<Record<string, string>>;
|
|
703
|
-
// Only these URL query keys may prefill — the target screen's
|
|
704
|
-
// `urlPrefillFields` from buildAppSchema. Absent means none: a crafted link
|
|
705
|
-
// must not seed fields no declared navigate `params` names.
|
|
706
|
-
readonly urlPrefillFields: readonly string[] | undefined;
|
|
707
|
-
readonly renderableFields?: ReadonlySet<string>;
|
|
708
|
-
readonly defaultCurrency?: string;
|
|
709
|
-
// Per-field override of `defaultCurrency` for money fields declaring
|
|
710
|
-
// `currency: { kind: "tenant" }` (fw#2933) — see buildInitialValues.
|
|
711
|
-
readonly moneyCurrencyOverrides?: Readonly<Record<string, string>>;
|
|
712
|
-
// Drawer-kind row actions (fw#2710) prefill from the clicked row's
|
|
713
|
-
// already-typed values; wins over every other source.
|
|
714
|
-
readonly drawerOverrides?: Readonly<Record<string, unknown>>;
|
|
715
|
-
// useNavigateWithInitialValues — in-app only, never in the URL, so it
|
|
716
|
-
// bypasses urlPrefillFields; string values get the URL coercion.
|
|
717
|
-
readonly handoffValues?: Readonly<Record<string, unknown>>;
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
function coercePrefillString(
|
|
721
|
-
raw: string,
|
|
722
|
-
name: string,
|
|
723
|
-
shape: PrefillFieldShape,
|
|
724
|
-
fallback: unknown,
|
|
725
|
-
defaultCurrency: string | undefined,
|
|
726
|
-
moneyCurrencyOverrides: Readonly<Record<string, string>> | undefined,
|
|
727
|
-
): unknown {
|
|
728
|
-
if (shape.type === "number") {
|
|
729
|
-
const parsed = Number(raw);
|
|
730
|
-
return Number.isNaN(parsed) ? fallback : parsed;
|
|
731
|
-
}
|
|
732
|
-
if (shape.type === "money") {
|
|
733
|
-
const coerced = coerceMoneyValue(raw, name, moneyCurrencyOverrides?.[name] ?? defaultCurrency);
|
|
734
|
-
return coerced === undefined ? fallback : coerced;
|
|
735
|
-
}
|
|
736
|
-
if (shape.type === "boolean") return raw === "true";
|
|
737
|
-
if (shape.type === "multiSelect") {
|
|
738
|
-
// Row-action navigate stringifies arrays via String(arr) → "a,b" (or
|
|
739
|
-
// JSON when the navigate helper JSON.stringifies). Try JSON first
|
|
740
|
-
// (even without a "[" prefix), then comma-split; filter against
|
|
741
|
-
// field.options when present so unknown values never reach the form.
|
|
742
|
-
const optionValues = multiSelectOptionValues(shape);
|
|
743
|
-
const splitComma = (): string[] =>
|
|
744
|
-
raw === ""
|
|
745
|
-
? []
|
|
746
|
-
: raw
|
|
747
|
-
.split(",")
|
|
748
|
-
.map((s) => s.trim())
|
|
749
|
-
.filter(Boolean);
|
|
750
|
-
let values: string[];
|
|
751
|
-
try {
|
|
752
|
-
const parsed: unknown = JSON.parse(raw);
|
|
753
|
-
if (Array.isArray(parsed)) {
|
|
754
|
-
values = parsed.map((v) => String(v));
|
|
755
|
-
} else if (typeof parsed === "string") {
|
|
756
|
-
values = [parsed];
|
|
757
|
-
} else {
|
|
758
|
-
values = splitComma();
|
|
759
|
-
}
|
|
760
|
-
} catch {
|
|
761
|
-
values = splitComma();
|
|
762
|
-
}
|
|
763
|
-
return optionValues !== undefined ? values.filter((v) => optionValues.has(v)) : values;
|
|
764
|
-
}
|
|
765
|
-
if (shape.type === "embedded" && shape.multiple === true) {
|
|
766
|
-
return coerceEmbeddedListRows(raw, name, shape.schema ?? {}, shape.maxItems) ?? fallback;
|
|
767
|
-
}
|
|
768
|
-
return raw;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
export function mergeSearchParamsIntoInitial(
|
|
772
|
-
fields: Readonly<Record<string, unknown>>,
|
|
773
|
-
sources: InitialValueSources,
|
|
774
|
-
): Record<string, unknown> {
|
|
775
|
-
const {
|
|
776
|
-
searchParams,
|
|
777
|
-
renderableFields,
|
|
778
|
-
defaultCurrency,
|
|
779
|
-
moneyCurrencyOverrides,
|
|
780
|
-
drawerOverrides,
|
|
781
|
-
handoffValues,
|
|
782
|
-
} = sources;
|
|
783
|
-
const urlPrefillFields = new Set(sources.urlPrefillFields ?? []);
|
|
784
|
-
const defaults = buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrides) as Record<
|
|
785
|
-
string,
|
|
786
|
-
unknown
|
|
787
|
-
>;
|
|
788
|
-
const merged: Record<string, unknown> = { ...defaults };
|
|
789
|
-
for (const [name, fieldDef] of Object.entries(fields)) {
|
|
790
|
-
if (renderableFields !== undefined && !renderableFields.has(name)) continue;
|
|
791
|
-
const shape = fieldDef as PrefillFieldShape;
|
|
792
|
-
// Neither gate is lifted by an allowlist entry or a handoff.
|
|
793
|
-
if (shape.sensitive === true) continue;
|
|
794
|
-
if (shape.format === "password") continue;
|
|
795
|
-
if (drawerOverrides !== undefined && name in drawerOverrides) {
|
|
796
|
-
merged[name] = drawerOverrides[name];
|
|
797
|
-
continue;
|
|
798
|
-
}
|
|
799
|
-
const handedOff =
|
|
800
|
-
handoffValues !== undefined && Object.hasOwn(handoffValues, name)
|
|
801
|
-
? handoffValues[name]
|
|
802
|
-
: undefined;
|
|
803
|
-
if (handedOff !== undefined) {
|
|
804
|
-
merged[name] =
|
|
805
|
-
typeof handedOff === "string"
|
|
806
|
-
? coercePrefillString(
|
|
807
|
-
handedOff,
|
|
808
|
-
name,
|
|
809
|
-
shape,
|
|
810
|
-
defaults[name],
|
|
811
|
-
defaultCurrency,
|
|
812
|
-
moneyCurrencyOverrides,
|
|
813
|
-
)
|
|
814
|
-
: handedOff;
|
|
815
|
-
continue;
|
|
816
|
-
}
|
|
817
|
-
if (!urlPrefillFields.has(name)) continue;
|
|
818
|
-
const raw = searchParams[name];
|
|
819
|
-
if (raw === undefined) continue;
|
|
820
|
-
merged[name] = coercePrefillString(
|
|
821
|
-
raw,
|
|
822
|
-
name,
|
|
823
|
-
shape,
|
|
824
|
-
defaults[name],
|
|
825
|
-
defaultCurrency,
|
|
826
|
-
moneyCurrencyOverrides,
|
|
827
|
-
);
|
|
828
|
-
}
|
|
829
|
-
return merged;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function EntityEditScreen({
|
|
833
|
-
schema,
|
|
834
|
-
screen,
|
|
835
|
-
translate,
|
|
836
|
-
entityId,
|
|
837
|
-
onCopyLink,
|
|
838
|
-
}: {
|
|
839
|
-
readonly schema: FeatureSchema;
|
|
840
|
-
readonly screen: EntityEditScreenDefinition;
|
|
841
|
-
readonly translate?: Translate;
|
|
842
|
-
readonly entityId?: string;
|
|
843
|
-
readonly onCopyLink?: () => Promise<void> | void;
|
|
844
|
-
}): ReactNode {
|
|
845
|
-
const { Banner, Text } = usePrimitives();
|
|
846
|
-
const entity = schema.entities[screen.entity];
|
|
847
|
-
if (!entity) {
|
|
848
|
-
return (
|
|
849
|
-
<Banner padded variant="error" testId="kumiko-screen-entity-missing">
|
|
850
|
-
Entity <Text variant="code">{screen.entity}</Text> referenced by screen{" "}
|
|
851
|
-
<Text variant="code">{screen.id}</Text> not registered in the schema.
|
|
852
|
-
</Banner>
|
|
853
|
-
);
|
|
854
|
-
}
|
|
855
|
-
// Split into create-body / update-body so the update-only hooks
|
|
856
|
-
// (useQuery(detail)) don't fire in create mode, and vice versa.
|
|
857
|
-
// Same shape as EntityListScreen.
|
|
858
|
-
if (entityId !== undefined) {
|
|
859
|
-
return (
|
|
860
|
-
<EntityEditUpdateBody
|
|
861
|
-
schema={schema}
|
|
862
|
-
screen={screen}
|
|
863
|
-
entity={entity}
|
|
864
|
-
entityId={entityId}
|
|
865
|
-
{...(translate !== undefined && { translate })}
|
|
866
|
-
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
867
|
-
/>
|
|
868
|
-
);
|
|
869
|
-
}
|
|
870
|
-
if (screen.singleton === true) {
|
|
871
|
-
return (
|
|
872
|
-
<EntityEditSingletonBody
|
|
873
|
-
schema={schema}
|
|
874
|
-
screen={screen}
|
|
875
|
-
entity={entity}
|
|
876
|
-
{...(translate !== undefined && { translate })}
|
|
877
|
-
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
878
|
-
/>
|
|
879
|
-
);
|
|
880
|
-
}
|
|
881
|
-
return (
|
|
882
|
-
<EntityEditCreateOrDisabled
|
|
883
|
-
schema={schema}
|
|
884
|
-
screen={screen}
|
|
885
|
-
entity={entity}
|
|
886
|
-
{...(translate !== undefined && { translate })}
|
|
887
|
-
/>
|
|
888
|
-
);
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
function EntityEditCreateBody({
|
|
892
|
-
schema,
|
|
893
|
-
screen,
|
|
894
|
-
entity,
|
|
895
|
-
translate,
|
|
896
|
-
onSaved,
|
|
897
|
-
}: {
|
|
898
|
-
readonly schema: FeatureSchema;
|
|
899
|
-
readonly screen: EntityEditScreenDefinition;
|
|
900
|
-
readonly entity: EntityDefinition;
|
|
901
|
-
readonly translate?: Translate;
|
|
902
|
-
// Singleton screens (kumiko-screen#1944) run without a wrapping
|
|
903
|
-
// entityList screen, so `navigateToList` below is a silent no-op there —
|
|
904
|
-
// without this callback, a successful create left the form stuck showing
|
|
905
|
-
// the just-submitted values, and a second submit created a duplicate
|
|
906
|
-
// record. Wired by EntityEditSingletonBody to re-run its list(limit:1)
|
|
907
|
-
// query, which flips the branch to EntityEditUpdateBody once it sees the
|
|
908
|
-
// new row.
|
|
909
|
-
readonly onSaved?: () => void;
|
|
910
|
-
}): ReactNode {
|
|
911
|
-
const { Banner } = usePrimitives();
|
|
912
|
-
const nav = useNav();
|
|
913
|
-
const handoffValues = useInitialValuesHandoff(screen.id);
|
|
914
|
-
const appFeatures = useAppFeatures();
|
|
915
|
-
const returnTargetParam = useReturnTarget(screen.id);
|
|
916
|
-
// A singleton stays on its own screen after saving (onSaved/refetch) —
|
|
917
|
-
// returnTo would fight that.
|
|
918
|
-
const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
|
|
919
|
-
const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
|
|
920
|
-
// A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
|
|
921
|
-
// its empty-value currency from the tenant-settings config key instead of
|
|
922
|
-
// entityDefaultCurrency — `enabled` keeps the query a no-op for entities
|
|
923
|
-
// without any such field.
|
|
924
|
-
const tenantCurrencyFieldNames = useMemo(
|
|
925
|
-
() => tenantCurrencyMoneyFieldNames(entity.fields),
|
|
926
|
-
[entity.fields],
|
|
927
|
-
);
|
|
928
|
-
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
929
|
-
entity.fields,
|
|
930
|
-
tenantCurrencyFieldNames,
|
|
931
|
-
entityDefaultCurrency,
|
|
932
|
-
);
|
|
933
|
-
const initial = useMemo(
|
|
934
|
-
() =>
|
|
935
|
-
mergeSearchParamsIntoInitial(entity.fields, {
|
|
936
|
-
searchParams: nav.searchParams,
|
|
937
|
-
urlPrefillFields: screen.urlPrefillFields,
|
|
938
|
-
renderableFields: layoutFieldNames(screen),
|
|
939
|
-
defaultCurrency: entityDefaultCurrency,
|
|
940
|
-
...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
|
|
941
|
-
...(handoffValues !== undefined && { handoffValues }),
|
|
942
|
-
}) as FormValues,
|
|
943
|
-
[
|
|
944
|
-
entity.fields,
|
|
945
|
-
nav.searchParams,
|
|
946
|
-
screen,
|
|
947
|
-
entityDefaultCurrency,
|
|
948
|
-
moneyCurrencyOverrides,
|
|
949
|
-
handoffValues,
|
|
950
|
-
],
|
|
951
|
-
);
|
|
952
|
-
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
953
|
-
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
954
|
-
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
955
|
-
const handleCancel = useCallback(
|
|
956
|
-
() => navigateToReturnOr(nav, returnTarget, navigateToList),
|
|
957
|
-
[nav, returnTarget, navigateToList],
|
|
958
|
-
);
|
|
959
|
-
// Create's write-handler success payload doesn't flatly expose a parent FK
|
|
960
|
-
// (`{ kind, id, data, … }`), so an object-form redirect's `idFrom` falls
|
|
961
|
-
// back to the values just submitted to the handler.
|
|
962
|
-
const controlsRef = useRef<RenderEditControls<FormValues> | null>(null);
|
|
963
|
-
const handleControlsReady = useCallback((controls: RenderEditControls<FormValues>) => {
|
|
964
|
-
controlsRef.current = controls;
|
|
965
|
-
}, []);
|
|
966
|
-
const handleSubmitted = useCallback(
|
|
967
|
-
(result: SubmitResult<unknown>) => {
|
|
968
|
-
if (!result.isSuccess) return;
|
|
969
|
-
const redirect = screen.redirect;
|
|
970
|
-
if (redirect !== undefined) {
|
|
971
|
-
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
972
|
-
navigateToReturn(nav, returnTarget);
|
|
973
|
-
} else if (typeof redirect === "string") {
|
|
974
|
-
// String form unchanged: always carries the newly created
|
|
975
|
-
// record's own id, regardless of the target screen's type. The
|
|
976
|
-
// object form resolves like actionForm's — a child record's own id
|
|
977
|
-
// is useless for a parent-detail redirect.
|
|
978
|
-
const entityId = extractCreatedId(result.data);
|
|
979
|
-
nav.navigate({
|
|
980
|
-
screenId: lastSegment(redirect),
|
|
981
|
-
...(entityId !== undefined && { entityId }),
|
|
982
|
-
});
|
|
983
|
-
} else {
|
|
984
|
-
const { screenId, entityId } = resolveRedirectTarget(
|
|
985
|
-
redirect,
|
|
986
|
-
result.data,
|
|
987
|
-
schema,
|
|
988
|
-
appFeatures,
|
|
989
|
-
controlsRef.current?.getValues(),
|
|
990
|
-
);
|
|
991
|
-
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
992
|
-
}
|
|
993
|
-
} else {
|
|
994
|
-
navigateToReturnOr(nav, returnTarget, navigateToList);
|
|
995
|
-
onSaved?.();
|
|
996
|
-
}
|
|
997
|
-
},
|
|
998
|
-
[nav, screen.redirect, schema, appFeatures, navigateToList, onSaved, returnTarget],
|
|
999
|
-
);
|
|
1000
|
-
// Never seed a tenant-declared money field with entityDefaultCurrency while
|
|
1001
|
-
// its real tenant currency is still in flight (fw#2933) — wait instead.
|
|
1002
|
-
if (currencyLoading) {
|
|
1003
|
-
return (
|
|
1004
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1005
|
-
Loading…
|
|
1006
|
-
</Banner>
|
|
1007
|
-
);
|
|
1008
|
-
}
|
|
1009
|
-
// Deliberately no `actions` prop here: `screen.actions` targets an
|
|
1010
|
-
// EXISTING record (publish/archive/duplicate and friends), which the
|
|
1011
|
-
// create branch has none of yet — see EntityEditUpdateForm for the
|
|
1012
|
-
// wired-up counterpart.
|
|
1013
|
-
return (
|
|
1014
|
-
<RenderEdit
|
|
1015
|
-
screen={screen}
|
|
1016
|
-
entity={entity}
|
|
1017
|
-
featureName={schema.featureName}
|
|
1018
|
-
initial={initial}
|
|
1019
|
-
schema={formSchema}
|
|
1020
|
-
writeCommand={writeCommand}
|
|
1021
|
-
onSubmit={handleSubmitted}
|
|
1022
|
-
onControlsReady={handleControlsReady}
|
|
1023
|
-
onCancel={handleCancel}
|
|
1024
|
-
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1025
|
-
{...(translate !== undefined && { translate })}
|
|
1026
|
-
/>
|
|
1027
|
-
);
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// Update body: loads the existing record via `<feature>:query:<entity>:detail`,
|
|
1031
|
-
// then mounts a form pre-filled with the server values and dispatches
|
|
1032
|
-
// `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
|
|
1033
|
-
// snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
|
|
1034
|
-
function EntityEditUpdateBody({
|
|
1035
|
-
schema,
|
|
1036
|
-
screen,
|
|
1037
|
-
entity,
|
|
1038
|
-
entityId,
|
|
1039
|
-
translate,
|
|
1040
|
-
onCopyLink,
|
|
1041
|
-
autoReloadOnSave,
|
|
1042
|
-
onDeleted,
|
|
1043
|
-
}: {
|
|
1044
|
-
readonly schema: FeatureSchema;
|
|
1045
|
-
readonly screen: EntityEditScreenDefinition;
|
|
1046
|
-
readonly entity: EntityDefinition;
|
|
1047
|
-
readonly entityId: string;
|
|
1048
|
-
readonly translate?: Translate;
|
|
1049
|
-
readonly onCopyLink?: () => Promise<void> | void;
|
|
1050
|
-
// Singleton screens (kumiko-screen#1944): `navigateToList` below is a
|
|
1051
|
-
// no-op there (no wrapping entityList screen), so a successful update
|
|
1052
|
-
// left the form holding the now-stale `recordVersion` it captured at
|
|
1053
|
-
// mount — the next submit hit a version conflict. Set by
|
|
1054
|
-
// EntityEditSingletonBody to re-fetch this body's own detail query after
|
|
1055
|
-
// a successful save, remounting the form (via the version-keyed `key`
|
|
1056
|
-
// below) with the fresh version.
|
|
1057
|
-
readonly autoReloadOnSave?: boolean;
|
|
1058
|
-
// Wired by EntityEditSingletonBody to re-run its list(limit:1) query
|
|
1059
|
-
// after a successful delete, so the branch flips back to the create form
|
|
1060
|
-
// instead of the update form continuing to display the deleted record.
|
|
1061
|
-
readonly onDeleted?: () => void;
|
|
1062
|
-
}): ReactNode {
|
|
1063
|
-
const { Banner, Text } = usePrimitives();
|
|
1064
|
-
const t = useTranslation();
|
|
1065
|
-
const effectiveTranslate = translate ?? t;
|
|
1066
|
-
const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
|
|
1067
|
-
const detailQuery = useQuery<Readonly<Record<string, unknown>>>(detailQn, { id: entityId });
|
|
1068
|
-
|
|
1069
|
-
if (detailQuery.loading && detailQuery.data === null) {
|
|
1070
|
-
return (
|
|
1071
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1072
|
-
Loading…
|
|
1073
|
-
</Banner>
|
|
1074
|
-
);
|
|
1075
|
-
}
|
|
1076
|
-
if (detailQuery.error) {
|
|
1077
|
-
return (
|
|
1078
|
-
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
1079
|
-
{dispatcherErrorText(detailQuery.error, effectiveTranslate)}
|
|
1080
|
-
</Banner>
|
|
1081
|
-
);
|
|
1082
|
-
}
|
|
1083
|
-
const record = detailQuery.data;
|
|
1084
|
-
if (!record) {
|
|
1085
|
-
return (
|
|
1086
|
-
<Banner padded variant="error" testId="kumiko-screen-record-missing">
|
|
1087
|
-
Record <Text variant="code">{entityId}</Text> not found.
|
|
1088
|
-
</Banner>
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
// Record-version als React-key: bei "Neu laden" refetched detail,
|
|
1092
|
-
// liefert neue version, und das Form remountet komplett. Ohne den
|
|
1093
|
-
// Key-Wechsel bliebe der useForm-Controller lifetime-scoped auf
|
|
1094
|
-
// der ursprünglichen version sitzen — der buildPayload würde
|
|
1095
|
-
// weiter die stale version stampen und der Konflikt-Recovery wäre
|
|
1096
|
-
// kaputt.
|
|
1097
|
-
const recordKey = (record as { version?: number }).version ?? 1;
|
|
1098
|
-
return (
|
|
1099
|
-
<EntityEditUpdateForm
|
|
1100
|
-
key={`${entityId}:${recordKey}`}
|
|
1101
|
-
schema={schema}
|
|
1102
|
-
screen={screen}
|
|
1103
|
-
entity={entity}
|
|
1104
|
-
entityId={entityId}
|
|
1105
|
-
record={record}
|
|
1106
|
-
onReload={detailQuery.refetch}
|
|
1107
|
-
{...(translate !== undefined && { translate })}
|
|
1108
|
-
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
1109
|
-
{...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() })}
|
|
1110
|
-
{...(onDeleted !== undefined && { onDeleted })}
|
|
1111
|
-
/>
|
|
1112
|
-
);
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
function EntityEditUpdateForm({
|
|
1116
|
-
schema,
|
|
1117
|
-
screen,
|
|
1118
|
-
entity,
|
|
1119
|
-
entityId,
|
|
1120
|
-
record,
|
|
1121
|
-
onReload,
|
|
1122
|
-
translate,
|
|
1123
|
-
onCopyLink,
|
|
1124
|
-
onSaved,
|
|
1125
|
-
onDeleted,
|
|
1126
|
-
}: {
|
|
1127
|
-
readonly schema: FeatureSchema;
|
|
1128
|
-
readonly screen: EntityEditScreenDefinition;
|
|
1129
|
-
readonly entity: EntityDefinition;
|
|
1130
|
-
readonly entityId: string;
|
|
1131
|
-
readonly record: Readonly<Record<string, unknown>>;
|
|
1132
|
-
readonly onReload: () => Promise<void> | void;
|
|
1133
|
-
readonly translate?: Translate;
|
|
1134
|
-
readonly onCopyLink?: () => Promise<void> | void;
|
|
1135
|
-
// See EntityEditUpdateBody's autoReloadOnSave/onDeleted docs — both are
|
|
1136
|
-
// no-ops unless the caller (singleton screens) wires them.
|
|
1137
|
-
readonly onSaved?: () => void;
|
|
1138
|
-
readonly onDeleted?: () => void;
|
|
1139
|
-
}): ReactNode {
|
|
1140
|
-
const { Banner } = usePrimitives();
|
|
1141
|
-
// Seed the form with the server values for the entity's declared
|
|
1142
|
-
// fields; anything else (id, tenant_id, created_at…) stays out of
|
|
1143
|
-
// the form and lives in the closure. The record's `version` is
|
|
1144
|
-
// captured once and stamped into every update payload — if a
|
|
1145
|
-
// concurrent writer bumps it, the server returns a version-conflict
|
|
1146
|
-
// error and the user reloads.
|
|
1147
|
-
const recordVersion = (record as { version?: number }).version ?? 1;
|
|
1148
|
-
const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
|
|
1149
|
-
// A money field declaring `currency: { kind: "tenant" }` (fw#2933) resolves
|
|
1150
|
-
// its empty-value currency from the tenant-settings config key instead of
|
|
1151
|
-
// entityDefaultCurrency — `enabled` keeps the query a no-op for entities
|
|
1152
|
-
// without any such field. A record's own stored value (below) always wins
|
|
1153
|
-
// over this, regardless of the declaration.
|
|
1154
|
-
//
|
|
1155
|
-
// Only fields the record itself has no value for need the fetch at all —
|
|
1156
|
-
// an already-set tenant-declared field keeps its own stored currency and
|
|
1157
|
-
// must never block the form on this query.
|
|
1158
|
-
const tenantCurrencyFieldNames = useMemo(
|
|
1159
|
-
() =>
|
|
1160
|
-
tenantCurrencyMoneyFieldNames(entity.fields).filter(
|
|
1161
|
-
(name) => record[name] === null || record[name] === undefined,
|
|
1162
|
-
),
|
|
1163
|
-
[entity.fields, record],
|
|
1164
|
-
);
|
|
1165
|
-
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
1166
|
-
entity.fields,
|
|
1167
|
-
tenantCurrencyFieldNames,
|
|
1168
|
-
entityDefaultCurrency,
|
|
1169
|
-
);
|
|
1170
|
-
const initial = useMemo(() => {
|
|
1171
|
-
const out: Record<string, unknown> = {};
|
|
1172
|
-
for (const name of Object.keys(entity.fields)) {
|
|
1173
|
-
out[name] =
|
|
1174
|
-
record[name] ??
|
|
1175
|
-
buildInitialValues(
|
|
1176
|
-
{ [name]: entity.fields[name] },
|
|
1177
|
-
entityDefaultCurrency,
|
|
1178
|
-
moneyCurrencyOverrides,
|
|
1179
|
-
)[name];
|
|
1180
|
-
}
|
|
1181
|
-
return out as FormValues;
|
|
1182
|
-
}, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
|
|
1183
|
-
|
|
1184
|
-
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
1185
|
-
|
|
1186
|
-
// Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
|
|
1187
|
-
// damit sie beim Edit den Bestand zeigen statt write-only zu sein.
|
|
1188
|
-
const extensionInitialValues = useMemo((): Readonly<Record<string, unknown>> | undefined => {
|
|
1189
|
-
const cf = record["customFields"];
|
|
1190
|
-
return cf !== null && typeof cf === "object" && !Array.isArray(cf)
|
|
1191
|
-
? (cf as Record<string, unknown>)
|
|
1192
|
-
: undefined;
|
|
1193
|
-
}, [record]);
|
|
1194
|
-
|
|
1195
|
-
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "update");
|
|
1196
|
-
const deleteCommand = entityWriteCommand(schema.featureName, screen.entity, "delete");
|
|
1197
|
-
const buildPayload = useMemo(
|
|
1198
|
-
() =>
|
|
1199
|
-
(snap: { readonly changes: Readonly<Record<string, unknown>> }): unknown => ({
|
|
1200
|
-
id: entityId,
|
|
1201
|
-
version: recordVersion,
|
|
1202
|
-
[UPDATE_CHANGES_KEY]: snap.changes,
|
|
1203
|
-
}),
|
|
1204
|
-
[entityId, recordVersion],
|
|
1205
|
-
);
|
|
1206
|
-
|
|
1207
|
-
const nav = useNav();
|
|
1208
|
-
const appFeatures = useAppFeatures();
|
|
1209
|
-
const dispatcher = useDispatcher();
|
|
1210
|
-
const t = useTranslation();
|
|
1211
|
-
const effectiveTranslate = translate ?? t;
|
|
1212
|
-
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
1213
|
-
const userRoles = useUserRoles();
|
|
1214
|
-
const host = useReturnHost();
|
|
1215
|
-
const returnTargetParam = useReturnTarget(screen.id);
|
|
1216
|
-
// A singleton stays on its own screen after saving (onSaved/refetch) —
|
|
1217
|
-
// returnTo would fight that.
|
|
1218
|
-
const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
|
|
1219
|
-
const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
|
|
1220
|
-
useDrawerAction(schema);
|
|
1221
|
-
// Header action buttons (fw entityEdit-actions) — same shape/dispatch
|
|
1222
|
-
// pattern as ProjectionDetailBody.headerActions above (the edited record
|
|
1223
|
-
// stands in for the "row"), minus the cross-feature defaultEditAction
|
|
1224
|
-
// lookup that doesn't apply here (this screen already IS the edit form).
|
|
1225
|
-
const buildSectionActions = useCallback(
|
|
1226
|
-
(actions: readonly RowAction[]): readonly RenderEditAction[] | undefined =>
|
|
1227
|
-
buildRecordActions({
|
|
1228
|
-
actions,
|
|
1229
|
-
record,
|
|
1230
|
-
translate: effectiveTranslate,
|
|
1231
|
-
nav,
|
|
1232
|
-
host,
|
|
1233
|
-
dispatcher,
|
|
1234
|
-
openDrawer,
|
|
1235
|
-
onWriteSuccess: onReload,
|
|
1236
|
-
defaultScreenTargetEntityId: entityId,
|
|
1237
|
-
}),
|
|
1238
|
-
[record, effectiveTranslate, nav, host, dispatcher, openDrawer, onReload, entityId],
|
|
1239
|
-
);
|
|
1240
|
-
const headerActions = useMemo(
|
|
1241
|
-
(): readonly RenderEditAction[] | undefined => buildSectionActions(screen.actions ?? []),
|
|
1242
|
-
[buildSectionActions, screen.actions],
|
|
1243
|
-
);
|
|
1244
|
-
const handleSubmitted = useCallback(
|
|
1245
|
-
(result: SubmitResult<unknown>) => {
|
|
1246
|
-
if (!result.isSuccess) return;
|
|
1247
|
-
const redirect = screen.redirect;
|
|
1248
|
-
if (redirect !== undefined) {
|
|
1249
|
-
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
1250
|
-
navigateToReturn(nav, returnTarget);
|
|
1251
|
-
} else if (typeof redirect === "string") {
|
|
1252
|
-
// String form unchanged: navigates without an entityId, same as
|
|
1253
|
-
// before the object form existed. The object form resolves like
|
|
1254
|
-
// actionForm's — the update handler's success payload usually
|
|
1255
|
-
// reports only this record's own id (event-store-executor-write.ts),
|
|
1256
|
-
// so a parent FK named by `idFrom` falls back to the already-loaded
|
|
1257
|
-
// `record`.
|
|
1258
|
-
nav.navigate({ screenId: lastSegment(redirect) });
|
|
1259
|
-
} else {
|
|
1260
|
-
const { screenId, entityId: targetEntityId } = resolveRedirectTarget(
|
|
1261
|
-
redirect,
|
|
1262
|
-
result.data,
|
|
1263
|
-
schema,
|
|
1264
|
-
appFeatures,
|
|
1265
|
-
record,
|
|
1266
|
-
);
|
|
1267
|
-
nav.navigate({
|
|
1268
|
-
screenId,
|
|
1269
|
-
...(targetEntityId !== undefined && { entityId: targetEntityId }),
|
|
1270
|
-
});
|
|
1271
|
-
}
|
|
1272
|
-
} else {
|
|
1273
|
-
navigateToReturnOr(nav, returnTarget, navigateToList);
|
|
1274
|
-
onSaved?.();
|
|
1275
|
-
}
|
|
1276
|
-
},
|
|
1277
|
-
[nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget],
|
|
1278
|
-
);
|
|
1279
|
-
const handleCancel = useCallback(
|
|
1280
|
-
() => navigateToReturnOr(nav, returnTarget, navigateToList),
|
|
1281
|
-
[nav, returnTarget, navigateToList],
|
|
1282
|
-
);
|
|
1283
|
-
const handleDelete = useCallback(async () => {
|
|
1284
|
-
const res = await dispatcher.write(deleteCommand, { id: entityId });
|
|
1285
|
-
if (res.isSuccess) {
|
|
1286
|
-
// Never return onto the just-deleted record.
|
|
1287
|
-
const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
|
|
1288
|
-
navigateToReturnOr(nav, target, navigateToList);
|
|
1289
|
-
onDeleted?.();
|
|
1290
|
-
}
|
|
1291
|
-
}, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
|
|
1292
|
-
|
|
1293
|
-
// Never seed a tenant-declared money field with entityDefaultCurrency while
|
|
1294
|
-
// its real tenant currency is still in flight (fw#2933) — wait instead. A
|
|
1295
|
-
// stored (non-empty) value is unaffected — it never reaches this branch's
|
|
1296
|
-
// fallback because `initial` above already prefers `record[name]`.
|
|
1297
|
-
if (currencyLoading) {
|
|
1298
|
-
return (
|
|
1299
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1300
|
-
Loading…
|
|
1301
|
-
</Banner>
|
|
1302
|
-
);
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
return (
|
|
1306
|
-
<>
|
|
1307
|
-
<RenderEdit
|
|
1308
|
-
screen={screen}
|
|
1309
|
-
entity={entity}
|
|
1310
|
-
featureName={schema.featureName}
|
|
1311
|
-
initial={initial}
|
|
1312
|
-
// Real route id for the extension section (set-value UI): the update
|
|
1313
|
-
// form deliberately keeps `id` out of the form values, so the section
|
|
1314
|
-
// needs it explicitly — otherwise it renders in create mode.
|
|
1315
|
-
entityId={entityId}
|
|
1316
|
-
// Stored customFields for the extension section, so editing shows the
|
|
1317
|
-
// persisted values instead of behaving write-only.
|
|
1318
|
-
extensionInitialValues={extensionInitialValues}
|
|
1319
|
-
schema={formSchema}
|
|
1320
|
-
writeCommand={writeCommand}
|
|
1321
|
-
payloadMode="changes"
|
|
1322
|
-
buildPayload={buildPayload}
|
|
1323
|
-
serverFieldPathPrefix={`${UPDATE_CHANGES_KEY}.`}
|
|
1324
|
-
onSubmit={handleSubmitted}
|
|
1325
|
-
// allowDelete:false marks an entity without a CRUD delete (history is
|
|
1326
|
-
// kept) — without this gate the button dispatched against an
|
|
1327
|
-
// unregistered `<entity>:delete` handler.
|
|
1328
|
-
{...(screen.allowDelete !== false && { onDelete: handleDelete })}
|
|
1329
|
-
onCancel={handleCancel}
|
|
1330
|
-
onReload={() => void onReload()}
|
|
1331
|
-
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1332
|
-
{...(translate !== undefined && { translate })}
|
|
1333
|
-
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
1334
|
-
{...(headerActions !== undefined && { actions: headerActions })}
|
|
1335
|
-
buildSectionActions={buildSectionActions}
|
|
1336
|
-
/>
|
|
1337
|
-
<DrawerHost
|
|
1338
|
-
schema={schema}
|
|
1339
|
-
drawerAction={drawerAction}
|
|
1340
|
-
drawerScreen={drawerScreen}
|
|
1341
|
-
{...(drawerInitialValues !== undefined && { drawerInitialValues })}
|
|
1342
|
-
userRoles={userRoles}
|
|
1343
|
-
{...(translate !== undefined && { translate })}
|
|
1344
|
-
onClose={closeDrawer}
|
|
1345
|
-
onSuccess={() => {
|
|
1346
|
-
closeDrawer();
|
|
1347
|
-
void onReload();
|
|
1348
|
-
}}
|
|
1349
|
-
/>
|
|
1350
|
-
</>
|
|
1351
|
-
);
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
// Singleton entities (`singleton: true`, exactly one record per tenant):
|
|
1355
|
-
// EntityEditScreen reaches here only without an entityId. Resolve the
|
|
1356
|
-
// existing record via list(limit:1) before deciding create vs update —
|
|
1357
|
-
// otherwise every visit without an id would create another record.
|
|
1358
|
-
function EntityEditSingletonBody({
|
|
1359
|
-
schema,
|
|
1360
|
-
screen,
|
|
1361
|
-
entity,
|
|
1362
|
-
translate,
|
|
1363
|
-
onCopyLink,
|
|
1364
|
-
}: {
|
|
1365
|
-
readonly schema: FeatureSchema;
|
|
1366
|
-
readonly screen: EntityEditScreenDefinition;
|
|
1367
|
-
readonly entity: EntityDefinition;
|
|
1368
|
-
readonly translate?: Translate;
|
|
1369
|
-
readonly onCopyLink?: () => Promise<void> | void;
|
|
1370
|
-
}): ReactNode {
|
|
1371
|
-
const { Banner } = usePrimitives();
|
|
1372
|
-
const t = useTranslation();
|
|
1373
|
-
const effectiveTranslate = translate ?? t;
|
|
1374
|
-
const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
|
|
1375
|
-
const listQuery = useQuery<PagedRows>(listQn, { limit: 1 });
|
|
1376
|
-
|
|
1377
|
-
if (listQuery.loading && listQuery.data === null) {
|
|
1378
|
-
return (
|
|
1379
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1380
|
-
Loading…
|
|
1381
|
-
</Banner>
|
|
1382
|
-
);
|
|
1383
|
-
}
|
|
1384
|
-
if (listQuery.error) {
|
|
1385
|
-
return (
|
|
1386
|
-
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
1387
|
-
{dispatcherErrorText(listQuery.error, effectiveTranslate)}
|
|
1388
|
-
</Banner>
|
|
1389
|
-
);
|
|
1390
|
-
}
|
|
1391
|
-
const rawExistingId = listQuery.data?.rows[0]?.["id"];
|
|
1392
|
-
const existingId = typeof rawExistingId === "string" ? rawExistingId : undefined;
|
|
1393
|
-
if (existingId !== undefined) {
|
|
1394
|
-
return (
|
|
1395
|
-
<EntityEditUpdateBody
|
|
1396
|
-
schema={schema}
|
|
1397
|
-
screen={screen}
|
|
1398
|
-
entity={entity}
|
|
1399
|
-
entityId={existingId}
|
|
1400
|
-
{...(translate !== undefined && { translate })}
|
|
1401
|
-
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
1402
|
-
autoReloadOnSave
|
|
1403
|
-
onDeleted={() => void listQuery.refetch()}
|
|
1404
|
-
/>
|
|
1405
|
-
);
|
|
1406
|
-
}
|
|
1407
|
-
return (
|
|
1408
|
-
<EntityEditCreateOrDisabled
|
|
1409
|
-
schema={schema}
|
|
1410
|
-
screen={screen}
|
|
1411
|
-
entity={entity}
|
|
1412
|
-
{...(translate !== undefined && { translate })}
|
|
1413
|
-
onSaved={() => void listQuery.refetch()}
|
|
1414
|
-
/>
|
|
1415
|
-
);
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
// Shared no-entityId tail for both the plain create path and the
|
|
1419
|
-
// singleton path's empty-table fallback: `allowCreate: false` blocks
|
|
1420
|
-
// both the same way, since a create submit there would hit an
|
|
1421
|
-
// unregistered `<entity>:create` handler.
|
|
1422
|
-
function EntityEditCreateOrDisabled({
|
|
1423
|
-
schema,
|
|
1424
|
-
screen,
|
|
1425
|
-
entity,
|
|
1426
|
-
translate,
|
|
1427
|
-
onSaved,
|
|
1428
|
-
}: {
|
|
1429
|
-
readonly schema: FeatureSchema;
|
|
1430
|
-
readonly screen: EntityEditScreenDefinition;
|
|
1431
|
-
readonly entity: EntityDefinition;
|
|
1432
|
-
readonly translate?: Translate;
|
|
1433
|
-
readonly onSaved?: () => void;
|
|
1434
|
-
}): ReactNode {
|
|
1435
|
-
const { Banner, Text } = usePrimitives();
|
|
1436
|
-
if (screen.allowCreate === false) {
|
|
1437
|
-
return (
|
|
1438
|
-
<Banner padded variant="error" testId="kumiko-screen-create-disabled">
|
|
1439
|
-
Screen <Text variant="code">{screen.id}</Text> is update-only (allowCreate: false) — open it
|
|
1440
|
-
from a row action with an entity id.
|
|
1441
|
-
</Banner>
|
|
1442
|
-
);
|
|
1443
|
-
}
|
|
1444
|
-
return (
|
|
1445
|
-
<EntityEditCreateBody
|
|
1446
|
-
schema={schema}
|
|
1447
|
-
screen={screen}
|
|
1448
|
-
entity={entity}
|
|
1449
|
-
{...(translate !== undefined && { translate })}
|
|
1450
|
-
{...(onSaved !== undefined && { onSaved })}
|
|
1451
|
-
/>
|
|
1452
|
-
);
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
// ---- entity-list ----
|
|
1456
|
-
|
|
1457
|
-
function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
|
|
1458
|
-
return `${toKebab(featureName)}:query:${toKebab(entity)}:${verb}`;
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
// Server-side entity-query-handlers return the paged envelope
|
|
1462
|
-
// `{ rows, nextCursor, total? }`. Narrow the useQuery generic to that
|
|
1463
|
-
// shape so RenderList gets plain rows. `total` ist optional — Server
|
|
1464
|
-
// liefert es nur wenn der Caller `totalCount: true` setzt.
|
|
1465
|
-
type PagedRows = {
|
|
1466
|
-
readonly rows: Readonly<Record<string, unknown>>[];
|
|
1467
|
-
readonly nextCursor: string | null;
|
|
1468
|
-
readonly total?: number;
|
|
1469
|
-
};
|
|
1470
|
-
|
|
1471
|
-
// Payload for the server-side list query handler (LIST_PAYLOAD_SCHEMA):
|
|
1472
|
-
// search/sort/sortDirection/limit + offset/totalCount for pager mode OR
|
|
1473
|
-
// cursor for infinite scroll. Shared by EntityListBody and
|
|
1474
|
-
// ProjectionListBody so the two branches can't drift on this shape
|
|
1475
|
-
// (fw#2165) — entity-only additions (screen.filter, faceted filters) are
|
|
1476
|
-
// layered on top by the caller instead of living in here.
|
|
1477
|
-
export function buildListQueryPayload(state: {
|
|
1478
|
-
readonly limit: number;
|
|
1479
|
-
readonly search: string;
|
|
1480
|
-
readonly sort: ListSort | null;
|
|
1481
|
-
readonly usePager: boolean;
|
|
1482
|
-
readonly page: number;
|
|
1483
|
-
readonly useInfinite: boolean;
|
|
1484
|
-
readonly cursor: string | undefined;
|
|
1485
|
-
}): Record<string, unknown> {
|
|
1486
|
-
const payload: Record<string, unknown> = { limit: state.limit };
|
|
1487
|
-
if (state.search !== "") payload["search"] = state.search;
|
|
1488
|
-
if (state.sort !== null) {
|
|
1489
|
-
payload["sort"] = state.sort.field;
|
|
1490
|
-
payload["sortDirection"] = state.sort.dir;
|
|
1491
|
-
}
|
|
1492
|
-
if (state.usePager) {
|
|
1493
|
-
// page=1 → offset=0, page=2 → offset=limit, etc. Server clamps itself
|
|
1494
|
-
// when offset >= total.
|
|
1495
|
-
const offset = (state.page - 1) * state.limit;
|
|
1496
|
-
if (offset > 0) payload["offset"] = offset;
|
|
1497
|
-
// totalCount: extra COUNT(*) so the pager can render "Page X of Y".
|
|
1498
|
-
payload["totalCount"] = true;
|
|
1499
|
-
} else if (state.useInfinite && state.cursor !== undefined) {
|
|
1500
|
-
payload["cursor"] = state.cursor;
|
|
1501
|
-
}
|
|
1502
|
-
return payload;
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
// entityList adapter — one DataTableFacet per filterable select/boolean
|
|
1506
|
-
// entity field, labels via the standard field/option i18n convention.
|
|
1507
|
-
function resolveEntityFacetSpecs(
|
|
1508
|
-
fields: Readonly<Record<string, unknown>>,
|
|
1509
|
-
featureName: string,
|
|
1510
|
-
entityName: string,
|
|
1511
|
-
translate: Translate,
|
|
1512
|
-
): ResolvedFacetSpec[] {
|
|
1513
|
-
const out: ResolvedFacetSpec[] = [];
|
|
1514
|
-
for (const [field, rawDef] of Object.entries(fields)) {
|
|
1515
|
-
// entity.fields ist am Renderer-Layer schwach getypt (Record<string,
|
|
1516
|
-
// unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
|
|
1517
|
-
const def = rawDef as {
|
|
1518
|
-
type?: string;
|
|
1519
|
-
filterable?: boolean;
|
|
1520
|
-
options?: readonly string[];
|
|
1521
|
-
entity?: string;
|
|
1522
|
-
labelField?: string;
|
|
1523
|
-
};
|
|
1524
|
-
if (def.filterable !== true) continue;
|
|
1525
|
-
const label = translate(fieldLabelKey(featureName, entityName, field));
|
|
1526
|
-
if (def.type === "select" && Array.isArray(def.options)) {
|
|
1527
|
-
out.push({
|
|
1528
|
-
field,
|
|
1529
|
-
type: "select",
|
|
1530
|
-
label,
|
|
1531
|
-
options: def.options.map((value) => ({
|
|
1532
|
-
value,
|
|
1533
|
-
label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
|
|
1534
|
-
})),
|
|
1535
|
-
});
|
|
1536
|
-
} else if (def.type === "boolean") {
|
|
1537
|
-
out.push({
|
|
1538
|
-
field,
|
|
1539
|
-
type: "boolean",
|
|
1540
|
-
label,
|
|
1541
|
-
options: [
|
|
1542
|
-
{
|
|
1543
|
-
value: "true",
|
|
1544
|
-
label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
|
|
1545
|
-
},
|
|
1546
|
-
{
|
|
1547
|
-
value: "false",
|
|
1548
|
-
label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
|
|
1549
|
-
},
|
|
1550
|
-
],
|
|
1551
|
-
});
|
|
1552
|
-
} else if (def.type === "reference" && def.entity !== undefined) {
|
|
1553
|
-
const target = parseRefTarget(def.entity, featureName);
|
|
1554
|
-
out.push({
|
|
1555
|
-
field,
|
|
1556
|
-
type: "reference",
|
|
1557
|
-
label,
|
|
1558
|
-
options: [],
|
|
1559
|
-
reference: {
|
|
1560
|
-
refEntity: target.entityName,
|
|
1561
|
-
refFeature: target.featureName,
|
|
1562
|
-
labelField: def.labelField ?? "id",
|
|
1563
|
-
},
|
|
1564
|
-
});
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
return out;
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
// ---- drawer-kind actions: ToolbarAction (fw#2225) + RowAction (fw#2710) ----
|
|
1571
|
-
//
|
|
1572
|
-
// Shared across EntityListBody, ProjectionListBody, ProjectionDetailBody and
|
|
1573
|
-
// EntityEditUpdateForm: state for "which drawer-kind action is currently
|
|
1574
|
-
// open" plus a host component that mounts the referenced actionForm inside
|
|
1575
|
-
// the Drawer primitive. Reuses ActionFormBody (no second, parallel form
|
|
1576
|
-
// renderer) with onSuccess/onCancelOverride so submit-success closes the
|
|
1577
|
-
// drawer + refetches the host screen instead of navigating, mirroring what
|
|
1578
|
-
// a full-page actionForm would do via `redirect`. The only difference
|
|
1579
|
-
// between the toolbar and row variants is prefill: a row action extracts
|
|
1580
|
-
// `initialValues` from the clicked row (see buildProjectionRowActions /
|
|
1581
|
-
// EntityListBody's rowActions builder); the toolbar variant has none.
|
|
1582
|
-
type DrawerLikeAction = Extract<ToolbarAction, { kind: "drawer" }> | RowActionDrawer;
|
|
1583
|
-
|
|
1584
|
-
function useDrawerAction(schema: FeatureSchema): {
|
|
1585
|
-
readonly drawerAction: DrawerLikeAction | null;
|
|
1586
|
-
readonly drawerScreen: ActionFormScreenDefinition | undefined;
|
|
1587
|
-
readonly drawerInitialValues: Readonly<Record<string, unknown>> | undefined;
|
|
1588
|
-
readonly openDrawer: (
|
|
1589
|
-
action: DrawerLikeAction,
|
|
1590
|
-
initialValues?: Readonly<Record<string, unknown>>,
|
|
1591
|
-
) => void;
|
|
1592
|
-
readonly closeDrawer: () => void;
|
|
1593
|
-
} {
|
|
1594
|
-
const [drawerState, setDrawerState] = useState<{
|
|
1595
|
-
readonly action: DrawerLikeAction;
|
|
1596
|
-
readonly initialValues: Readonly<Record<string, unknown>> | undefined;
|
|
1597
|
-
} | null>(null);
|
|
1598
|
-
const drawerAction = drawerState?.action ?? null;
|
|
1599
|
-
const drawerScreen = useMemo(() => {
|
|
1600
|
-
if (drawerAction === null) return undefined;
|
|
1601
|
-
// Same same-feature, short-id resolution as runNavigate — the drawer's
|
|
1602
|
-
// `screen` reference is scoped to schema.screens, not cross-feature.
|
|
1603
|
-
return schema.screens.find(
|
|
1604
|
-
(s): s is ActionFormScreenDefinition =>
|
|
1605
|
-
s.type === "actionForm" && lastSegment(s.id) === drawerAction.screen,
|
|
1606
|
-
);
|
|
1607
|
-
}, [drawerAction, schema.screens]);
|
|
1608
|
-
const openDrawer = useCallback(
|
|
1609
|
-
(action: DrawerLikeAction, initialValues?: Readonly<Record<string, unknown>>) =>
|
|
1610
|
-
setDrawerState({ action, initialValues }),
|
|
1611
|
-
[],
|
|
1612
|
-
);
|
|
1613
|
-
const closeDrawer = useCallback(() => setDrawerState(null), []);
|
|
1614
|
-
return {
|
|
1615
|
-
drawerAction,
|
|
1616
|
-
drawerScreen,
|
|
1617
|
-
drawerInitialValues: drawerState?.initialValues,
|
|
1618
|
-
openDrawer,
|
|
1619
|
-
closeDrawer,
|
|
1620
|
-
};
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
function DrawerHost({
|
|
1624
|
-
schema,
|
|
1625
|
-
drawerAction,
|
|
1626
|
-
drawerScreen,
|
|
1627
|
-
drawerInitialValues,
|
|
1628
|
-
userRoles,
|
|
1629
|
-
translate,
|
|
1630
|
-
onClose,
|
|
1631
|
-
onSuccess,
|
|
1632
|
-
}: {
|
|
1633
|
-
readonly schema: FeatureSchema;
|
|
1634
|
-
readonly drawerAction: DrawerLikeAction | null;
|
|
1635
|
-
readonly drawerScreen: ActionFormScreenDefinition | undefined;
|
|
1636
|
-
readonly drawerInitialValues?: Readonly<Record<string, unknown>>;
|
|
1637
|
-
readonly userRoles: readonly string[] | undefined;
|
|
1638
|
-
readonly translate?: Translate;
|
|
1639
|
-
readonly onClose: () => void;
|
|
1640
|
-
readonly onSuccess: () => void;
|
|
1641
|
-
}): ReactNode {
|
|
1642
|
-
const { Drawer, Banner, Text } = usePrimitives();
|
|
1643
|
-
const t = useTranslation();
|
|
1644
|
-
const effectiveTranslate = translate ?? t;
|
|
1645
|
-
// Drawer is an optional Core-Primitive (additive rollout) — same "skip +
|
|
1646
|
-
// warn once" precedent as rowActions without a mounted DispatcherProvider
|
|
1647
|
-
// above, instead of crashing when a web app hasn't upgraded its
|
|
1648
|
-
// createKumikoApp wiring yet.
|
|
1649
|
-
useEffect(() => {
|
|
1650
|
-
if (drawerAction !== null && Drawer === undefined) {
|
|
1651
|
-
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
1652
|
-
console.warn(
|
|
1653
|
-
`[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.`,
|
|
1654
|
-
);
|
|
1655
|
-
}
|
|
1656
|
-
}, [drawerAction, Drawer]);
|
|
1657
|
-
|
|
1658
|
-
if (drawerAction === null || Drawer === undefined) return null;
|
|
1659
|
-
|
|
1660
|
-
// Access mirrors kind:"navigate" exactly: the toolbar button itself stays
|
|
1661
|
-
// visible either way (same as navigate — access is enforced at the
|
|
1662
|
-
// target, not by hiding the trigger), but the drawer shows the same
|
|
1663
|
-
// "Access denied" state KumikoScreen's top-level gate would show for a
|
|
1664
|
-
// direct hit on that screen, never the form.
|
|
1665
|
-
const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
|
|
1666
|
-
|
|
1667
|
-
return (
|
|
1668
|
-
<Drawer
|
|
1669
|
-
open={true}
|
|
1670
|
-
onOpenChange={(open) => {
|
|
1671
|
-
if (!open) onClose();
|
|
1672
|
-
}}
|
|
1673
|
-
title={effectiveTranslate(drawerAction.label)}
|
|
1674
|
-
testId={`toolbar-drawer-${drawerAction.id}`}
|
|
1675
|
-
>
|
|
1676
|
-
{drawerScreen === undefined ? (
|
|
1677
|
-
<Banner padded variant="error" testId="kumiko-toolbar-drawer-not-found">
|
|
1678
|
-
Screen not found: <Text variant="code">{drawerAction.screen}</Text>
|
|
1679
|
-
</Banner>
|
|
1680
|
-
) : !allowed ? (
|
|
1681
|
-
<Banner padded variant="error" testId="kumiko-toolbar-drawer-access-denied">
|
|
1682
|
-
Access denied: <Text variant="code">{drawerAction.screen}</Text>
|
|
1683
|
-
</Banner>
|
|
1684
|
-
) : (
|
|
1685
|
-
<ActionFormBody
|
|
1686
|
-
schema={schema}
|
|
1687
|
-
screen={drawerScreen}
|
|
1688
|
-
{...(translate !== undefined && { translate })}
|
|
1689
|
-
{...(drawerInitialValues !== undefined && { initialOverrides: drawerInitialValues })}
|
|
1690
|
-
onSuccess={onSuccess}
|
|
1691
|
-
onCancelOverride={onClose}
|
|
1692
|
-
/>
|
|
1693
|
-
)}
|
|
1694
|
-
</Drawer>
|
|
1695
|
-
);
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
function EntityListScreen({
|
|
1699
|
-
schema,
|
|
1700
|
-
screen,
|
|
1701
|
-
translate,
|
|
1702
|
-
onRowClick,
|
|
1703
|
-
}: {
|
|
1704
|
-
readonly schema: FeatureSchema;
|
|
1705
|
-
readonly screen: EntityListScreenDefinition;
|
|
1706
|
-
readonly translate?: Translate;
|
|
1707
|
-
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
1708
|
-
}): ReactNode {
|
|
1709
|
-
const { Banner, Text } = usePrimitives();
|
|
1710
|
-
const entity = schema.entities[screen.entity];
|
|
1711
|
-
if (!entity) {
|
|
1712
|
-
return (
|
|
1713
|
-
<Banner padded variant="error" testId="kumiko-screen-entity-missing">
|
|
1714
|
-
Entity <Text variant="code">{screen.entity}</Text> referenced by screen{" "}
|
|
1715
|
-
<Text variant="code">{screen.id}</Text> not registered in the schema.
|
|
1716
|
-
</Banner>
|
|
1717
|
-
);
|
|
1718
|
-
}
|
|
1719
|
-
// Entity resolved — mount the inner component so useQuery only fires
|
|
1720
|
-
// when there's actually something to render. A missing entity is a
|
|
1721
|
-
// dev-error state; no point hitting the server for it.
|
|
1722
|
-
return (
|
|
1723
|
-
<EntityListBody
|
|
1724
|
-
schema={schema}
|
|
1725
|
-
screen={screen}
|
|
1726
|
-
entity={entity}
|
|
1727
|
-
{...(translate !== undefined && { translate })}
|
|
1728
|
-
{...(onRowClick !== undefined && { onRowClick })}
|
|
1729
|
-
/>
|
|
1730
|
-
);
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
function EntityListBody({
|
|
1734
|
-
schema,
|
|
1735
|
-
screen,
|
|
1736
|
-
entity,
|
|
1737
|
-
translate,
|
|
1738
|
-
onRowClick,
|
|
1739
|
-
}: {
|
|
1740
|
-
readonly schema: FeatureSchema;
|
|
1741
|
-
readonly screen: EntityListScreenDefinition;
|
|
1742
|
-
readonly entity: EntityDefinition;
|
|
1743
|
-
readonly translate?: Translate;
|
|
1744
|
-
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
1745
|
-
}): ReactNode {
|
|
1746
|
-
const featureName = schema.featureName;
|
|
1747
|
-
const onCreate = useNavigateToCreateFor(schema, screen.entity);
|
|
1748
|
-
const { Banner } = usePrimitives();
|
|
1749
|
-
const queryType = entityQueryCommand(featureName, screen.entity, "list");
|
|
1750
|
-
const nav = useNav();
|
|
1751
|
-
const host = useReturnHost();
|
|
1752
|
-
const userRoles = useUserRoles();
|
|
1753
|
-
const appFeatures = useAppFeatures();
|
|
1754
|
-
const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
|
|
1755
|
-
useDrawerAction(schema);
|
|
1756
|
-
const defaultEditScreen = useMemo(
|
|
1757
|
-
() => findEditScreenFor(screen.entity, appFeatures, userRoles),
|
|
1758
|
-
[appFeatures, screen.entity, userRoles],
|
|
1759
|
-
);
|
|
1760
|
-
|
|
1761
|
-
// URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
|
|
1762
|
-
// (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
|
|
1763
|
-
// damit zwei Lists auf derselben Route nicht über dieselben
|
|
1764
|
-
// Query-Keys streiten. Default-Sort aus der Screen-Def gewinnt nur
|
|
1765
|
-
// wenn URL keinen sort hat — Author-Default vs User-Choice.
|
|
1766
|
-
const urlState = useListUrlState(screen.id);
|
|
1767
|
-
const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
|
|
1768
|
-
const limit = screen.pageSize ?? 50;
|
|
1769
|
-
const paginationMode = screen.pagination ?? "pages";
|
|
1770
|
-
const usePager = paginationMode === "pages";
|
|
1771
|
-
const useInfinite = paginationMode === "infinite";
|
|
1772
|
-
|
|
1773
|
-
// Infinite-Scroll: lokaler State akkumuliert rows über mehrere
|
|
1774
|
-
// useQuery-Aufrufe (statt replacen wie bei Pager). cursor wechselt
|
|
1775
|
-
// wenn der User den Bottom-Sentinel erreicht; bei sort/q-Change
|
|
1776
|
-
// resetten wir alles, da die Insert-Order der akkumulierten Rows
|
|
1777
|
-
// sonst inkonsistent ist mit der neuen Sortierung.
|
|
1778
|
-
const [accumulated, setAccumulated] = useState<readonly Readonly<Record<string, unknown>>[]>([]);
|
|
1779
|
-
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
|
1780
|
-
const [hasMore, setHasMore] = useState(true);
|
|
1781
|
-
// Ref damit der useEffect-Cleanup nicht alten state sieht.
|
|
1782
|
-
const sortQRef = useRef<string>("");
|
|
1783
|
-
const sortQKey = `${urlState.q}|${effectiveSort?.field ?? ""}|${effectiveSort?.dir ?? ""}`;
|
|
1784
|
-
// Bei sort/q-Wechsel rows + cursor reseten — vor dem nächsten useQuery
|
|
1785
|
-
// damit der Reload mit cursor=undefined startet.
|
|
1786
|
-
useEffect(() => {
|
|
1787
|
-
if (!useInfinite) return;
|
|
1788
|
-
if (sortQRef.current === sortQKey) return;
|
|
1789
|
-
sortQRef.current = sortQKey;
|
|
1790
|
-
setAccumulated([]);
|
|
1791
|
-
setCursor(undefined);
|
|
1792
|
-
setHasMore(true);
|
|
1793
|
-
}, [useInfinite, sortQKey]);
|
|
1794
|
-
|
|
1795
|
-
// User-gewählte Faceted-Filter aus dem URL-State → payload.filters.
|
|
1796
|
-
// typeOf liest den Field-Type direkt aus entity.fields (nicht gated auf
|
|
1797
|
-
// filterable — bewusst permissiv, siehe buildFilterPayload-Doc).
|
|
1798
|
-
const filterPayload = useMemo(
|
|
1799
|
-
() =>
|
|
1800
|
-
buildFilterPayload(urlState.filters, (field) => {
|
|
1801
|
-
// entity.fields ist am Renderer-Layer schwach getypt (vom Schema
|
|
1802
|
-
// deserialisiert) — Boundary-Cast wie buildInitialValues.
|
|
1803
|
-
const def = entity.fields[field] as { type?: string } | undefined;
|
|
1804
|
-
return def?.type;
|
|
1805
|
-
}),
|
|
1806
|
-
[urlState.filters, entity.fields],
|
|
1807
|
-
);
|
|
1808
|
-
|
|
1809
|
-
// Entity-only additions (screen.filter, faceted filters) layer on top of
|
|
1810
|
-
// the shared buildListQueryPayload — projectionList has neither.
|
|
1811
|
-
const queryPayload = useMemo(() => {
|
|
1812
|
-
const payload = buildListQueryPayload({
|
|
1813
|
-
limit,
|
|
1814
|
-
search: urlState.q,
|
|
1815
|
-
sort: effectiveSort,
|
|
1816
|
-
usePager,
|
|
1817
|
-
page: urlState.page,
|
|
1818
|
-
useInfinite,
|
|
1819
|
-
cursor,
|
|
1820
|
-
});
|
|
1821
|
-
// Screen-Filter (Tier 2.7c) — vom Author am Schema deklariert,
|
|
1822
|
-
// unabhängig vom User-q-Search. Mehrere Buckets derselben Entity
|
|
1823
|
-
// ("Upcoming" / "Active" / "Past") nutzen unterschiedliche filter
|
|
1824
|
-
// bei gleichem Query-Handler.
|
|
1825
|
-
if (screen.filter !== undefined) {
|
|
1826
|
-
payload["filter"] = screen.filter;
|
|
1827
|
-
}
|
|
1828
|
-
if (filterPayload.length > 0) {
|
|
1829
|
-
payload["filters"] = filterPayload;
|
|
1830
|
-
}
|
|
1831
|
-
return payload;
|
|
1832
|
-
}, [
|
|
1833
|
-
limit,
|
|
1834
|
-
urlState.q,
|
|
1835
|
-
effectiveSort,
|
|
1836
|
-
screen.filter,
|
|
1837
|
-
filterPayload,
|
|
1838
|
-
usePager,
|
|
1839
|
-
urlState.page,
|
|
1840
|
-
useInfinite,
|
|
1841
|
-
cursor,
|
|
1842
|
-
]);
|
|
1843
|
-
|
|
1844
|
-
const rowsQuery = useQuery<PagedRows>(queryType, queryPayload, { live: true });
|
|
1845
|
-
|
|
1846
|
-
const refreshRowsAfterWrite = useCallback(async () => {
|
|
1847
|
-
if (useInfinite) {
|
|
1848
|
-
setAccumulated([]);
|
|
1849
|
-
setCursor(undefined);
|
|
1850
|
-
setHasMore(true);
|
|
1851
|
-
return;
|
|
1852
|
-
}
|
|
1853
|
-
await refetchAfterWrite(rowsQuery.refetch);
|
|
1854
|
-
}, [useInfinite, rowsQuery.refetch]);
|
|
1855
|
-
|
|
1856
|
-
// Infinite-Scroll: bei jedem erfolgreichen Result die rows appenden +
|
|
1857
|
-
// hasMore aus nextCursor ableiten. Live-Updates (postgres NOTIFY) und
|
|
1858
|
-
// initiale Loads laufen beide hier durch — das useEffect-Dep-Array
|
|
1859
|
-
// pinnt die Dedup auf dem letzten verarbeiteten data-Pointer.
|
|
1860
|
-
const lastDataRef = useRef<PagedRows | null>(null);
|
|
1861
|
-
useEffect(() => {
|
|
1862
|
-
if (!useInfinite) return;
|
|
1863
|
-
const data = rowsQuery.data;
|
|
1864
|
-
if (data === null) return;
|
|
1865
|
-
if (data === lastDataRef.current) return;
|
|
1866
|
-
lastDataRef.current = data;
|
|
1867
|
-
setAccumulated((prev) => {
|
|
1868
|
-
// Wenn cursor undefined ist, ist das die erste Page nach
|
|
1869
|
-
// sort/q-Change → komplett ersetzen statt anhängen. Sonst dedupe
|
|
1870
|
-
// auf id falls Live-Updates einen Eintrag in der nächsten Page
|
|
1871
|
-
// bringen die schon angehängt war.
|
|
1872
|
-
if (cursor === undefined) return data.rows;
|
|
1873
|
-
const seen = new Set(prev.map((r) => r["id"] as string));
|
|
1874
|
-
const fresh = data.rows.filter((r) => !seen.has(r["id"] as string));
|
|
1875
|
-
return [...prev, ...fresh];
|
|
1876
|
-
});
|
|
1877
|
-
setHasMore(data.nextCursor !== null);
|
|
1878
|
-
}, [rowsQuery.data, useInfinite, cursor]);
|
|
1879
|
-
|
|
1880
|
-
const loadMore = useCallback(() => {
|
|
1881
|
-
if (!useInfinite) return;
|
|
1882
|
-
if (rowsQuery.loading) return;
|
|
1883
|
-
const data = rowsQuery.data;
|
|
1884
|
-
if (data?.nextCursor === undefined || data.nextCursor === null) return;
|
|
1885
|
-
setCursor(data.nextCursor);
|
|
1886
|
-
}, [useInfinite, rowsQuery.loading, rowsQuery.data]);
|
|
1887
|
-
|
|
1888
|
-
// RowActions: Schema-Form (handler-QN + i18n-Key) → Resolved-Form
|
|
1889
|
-
// (dispatcher-Call + translated Strings). dispatcher.write kennt den
|
|
1890
|
-
// User intern (JWT-Cookie). Schema kann sowohl raw-Strings als auch
|
|
1891
|
-
// i18n-Keys enthalten — translate() returnt den Key wenn das Bundle
|
|
1892
|
-
// ihn nicht kennt (Convention überall im Renderer).
|
|
1893
|
-
// Hooks-Reihenfolge: ALLE Hooks vor early-return für loading/error,
|
|
1894
|
-
// sonst kollidieren die Hook-Slots zwischen Renders.
|
|
1895
|
-
const t = useTranslation();
|
|
1896
|
-
const effectiveTranslate = translate ?? t;
|
|
1897
|
-
|
|
1898
|
-
// Faceted-Filter: ein Dropdown pro filterable select/boolean/reference-Feld.
|
|
1899
|
-
// Labels + select-Option-Labels über dieselbe i18n-Konvention wie die
|
|
1900
|
-
// Spalten-Header (fieldLabelKey / :option:<value>); reference-Felder laden
|
|
1901
|
-
// ihre Optionen asynchron über ReferenceFacetBridges.
|
|
1902
|
-
const entityFacetSpecs = useMemo(
|
|
1903
|
-
() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate),
|
|
1904
|
-
[entity.fields, featureName, screen.entity, effectiveTranslate],
|
|
1905
|
-
);
|
|
1906
|
-
const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState<
|
|
1907
|
-
Record<string, readonly ReferenceFacetOption[]>
|
|
1908
|
-
>({});
|
|
1909
|
-
const handleEntityFacetOptions = useCallback(
|
|
1910
|
-
(field: string, options: readonly ReferenceFacetOption[]) =>
|
|
1911
|
-
setEntityReferenceFacetOptions((prev) =>
|
|
1912
|
-
prev[field] === options ? prev : { ...prev, [field]: options },
|
|
1913
|
-
),
|
|
1914
|
-
[],
|
|
1915
|
-
);
|
|
1916
|
-
const filterFacets = useMemo<DataTableFacet[]>(
|
|
1917
|
-
() =>
|
|
1918
|
-
buildFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions)),
|
|
1919
|
-
[entityFacetSpecs, entityReferenceFacetOptions],
|
|
1920
|
-
);
|
|
1921
|
-
|
|
1922
|
-
// Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
|
|
1923
|
-
// bleibt rowActions undefined statt zu crashen. Echte Apps haben
|
|
1924
|
-
// den Provider via createKumikoApp — wenn nicht, ist es vermutlich
|
|
1925
|
-
// ein Setup-Fehler, also einmal warnen damit der Author das findet
|
|
1926
|
-
// (sonst rendert die Action-Spalte still nichts und der "warum sind
|
|
1927
|
-
// meine Buttons weg?"-Debug ist teuer).
|
|
1928
|
-
const dispatcher = useOptionalDispatcher();
|
|
1929
|
-
const hasRowActions = screen.rowActions !== undefined && screen.rowActions.length > 0;
|
|
1930
|
-
useEffect(() => {
|
|
1931
|
-
if (hasRowActions && dispatcher === undefined) {
|
|
1932
|
-
// biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
|
|
1933
|
-
console.warn(
|
|
1934
|
-
`[kumiko] Screen "${screen.id}" deklariert rowActions, aber kein <DispatcherProvider> ist mounted — die Action-Spalte wird nicht gerendert. createKumikoApp() wired den Provider automatisch.`,
|
|
1935
|
-
);
|
|
1936
|
-
}
|
|
1937
|
-
}, [hasRowActions, dispatcher, screen.id]);
|
|
1938
|
-
// Discriminated Union: writeHandler (default) dispatched einen Server-
|
|
1939
|
-
// Handler, navigate ruft nav.navigate() ggf. mit URL-Search-Params aus
|
|
1940
|
-
// params(row). nav lebt schon weiter unten in EntityListBody-Scope.
|
|
1941
|
-
// Gemeinsame navigate-Ausführung: sowohl das "…"-Aktionsmenü (rowActions) als
|
|
1942
|
-
// auch der Row-Body-Klick (rowClick) laufen hierdurch — ein Pfad, damit beide
|
|
1943
|
-
// nicht auseinanderdriften.
|
|
1944
|
-
const runNavigate = useCallback(
|
|
1945
|
-
(action: RowActionNavigate, row: ListRowViewModel) => {
|
|
1946
|
-
if (action.entity !== undefined) {
|
|
1947
|
-
// Entity-Targets (fw#2228) lösen sich erst in der NavApi-Impl gegen
|
|
1948
|
-
// ALLE Features auf (resolveTarget in renderer-web/nav.tsx) — dieses
|
|
1949
|
-
// Package kennt nur `schema` (das eine aufrufende Feature) und kann
|
|
1950
|
-
// detailFor cross-feature nicht selbst nachschlagen.
|
|
1951
|
-
const explicit =
|
|
1952
|
-
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1953
|
-
// Same-entity only — cross-entity targets must declare entityId (boot-validator).
|
|
1954
|
-
const fallback =
|
|
1955
|
-
action.entity === screen.entity ? String(row.values["id"] ?? "") : undefined;
|
|
1956
|
-
const id = explicit ?? fallback ?? "";
|
|
1957
|
-
if (id === "") return;
|
|
1958
|
-
const params = navigateActionSearchParams(action, row.values);
|
|
1959
|
-
navigateWithReturnTo(nav, { entity: action.entity, id }, host, params);
|
|
1960
|
-
} else if (action.screen !== undefined) {
|
|
1961
|
-
// Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
|
|
1962
|
-
// entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
|
|
1963
|
-
// bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
|
|
1964
|
-
const targetIsEntityEdit = schema.screens.some(
|
|
1965
|
-
(s) =>
|
|
1966
|
-
s.type === "entityEdit" &&
|
|
1967
|
-
s.entity === screen.entity &&
|
|
1968
|
-
lastSegment(s.id) === action.screen,
|
|
1969
|
-
);
|
|
1970
|
-
const explicit =
|
|
1971
|
-
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1972
|
-
const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
|
|
1973
|
-
const entityId = explicit ?? fallback;
|
|
1974
|
-
const target: ScreenTarget = {
|
|
1975
|
-
screenId: action.screen,
|
|
1976
|
-
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1977
|
-
};
|
|
1978
|
-
// setSearchParams nimmt string|null — komplexe Werte zu String (der Reader
|
|
1979
|
-
// kennt via URL nur Strings). Known-edge: zielt die Action auf den
|
|
1980
|
-
// AKTUELLEN pathname, mergen die Params auf den alten ?-String (für
|
|
1981
|
-
// Row-Actions praktisch nicht erreichbar, Pfad differiert).
|
|
1982
|
-
const params = navigateActionSearchParams(action, row.values);
|
|
1983
|
-
navigateWithReturnTo(nav, target, host, params);
|
|
1984
|
-
}
|
|
1985
|
-
},
|
|
1986
|
-
[nav, schema.screens, screen.entity, host],
|
|
1987
|
-
);
|
|
1988
|
-
|
|
1989
|
-
const rowActions = useMemo(() => {
|
|
1990
|
-
const declared = screen.rowActions ?? [];
|
|
1991
|
-
// Prepended unless a declared rowAction already has id "edit" — declared wins.
|
|
1992
|
-
const declaredHasEdit = declared.some((a) => a.id === "edit");
|
|
1993
|
-
const defaultEditRowAction = declaredHasEdit
|
|
1994
|
-
? undefined
|
|
1995
|
-
: buildDefaultEditRowAction(defaultEditScreen);
|
|
1996
|
-
const effectiveActions: readonly RowAction[] =
|
|
1997
|
-
defaultEditRowAction !== undefined ? [defaultEditRowAction, ...declared] : declared;
|
|
1998
|
-
if (effectiveActions.length === 0) return undefined;
|
|
1999
|
-
return effectiveActions
|
|
2000
|
-
.map((action: RowAction): DataTableRowAction | null => {
|
|
2001
|
-
// navigate-Variante braucht keinen Dispatcher; nav ist
|
|
2002
|
-
// immer da (Provider von createKumikoApp).
|
|
2003
|
-
if (action.kind === "navigate") {
|
|
2004
|
-
const navigateAction = action;
|
|
2005
|
-
const actionVisible = action.visible;
|
|
2006
|
-
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
2007
|
-
return {
|
|
2008
|
-
id: action.id,
|
|
2009
|
-
label: effectiveTranslate(action.label),
|
|
2010
|
-
...(action.style !== undefined && { style: action.style }),
|
|
2011
|
-
confirmRequired: false,
|
|
2012
|
-
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2013
|
-
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
2014
|
-
...(actionVisible !== undefined && {
|
|
2015
|
-
isVisible: (row: ListRowViewModel) => evalFieldCondition(actionVisible, row.values),
|
|
2016
|
-
}),
|
|
2017
|
-
};
|
|
2018
|
-
}
|
|
2019
|
-
if (action.kind === "drawer") {
|
|
2020
|
-
const drawerAction = action;
|
|
2021
|
-
const actionVisible = action.visible;
|
|
2022
|
-
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
2023
|
-
return {
|
|
2024
|
-
id: action.id,
|
|
2025
|
-
label: effectiveTranslate(action.label),
|
|
2026
|
-
...(action.style !== undefined && { style: action.style }),
|
|
2027
|
-
confirmRequired: false,
|
|
2028
|
-
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2029
|
-
onTrigger: (row: ListRowViewModel) => {
|
|
2030
|
-
const initialValues =
|
|
2031
|
-
drawerAction.params !== undefined
|
|
2032
|
-
? evalRowExtractor(drawerAction.params, row.values)
|
|
2033
|
-
: undefined;
|
|
2034
|
-
openDrawer(drawerAction, initialValues);
|
|
2035
|
-
},
|
|
2036
|
-
...(actionVisible !== undefined && {
|
|
2037
|
-
isVisible: (row: ListRowViewModel) => evalFieldCondition(actionVisible, row.values),
|
|
2038
|
-
}),
|
|
2039
|
-
};
|
|
2040
|
-
}
|
|
2041
|
-
if (dispatcher === undefined) return null;
|
|
2042
|
-
if (!isWriteHandlerRowAction(action)) return null;
|
|
2043
|
-
const writeAction = action;
|
|
2044
|
-
const writeActionVisible = writeAction.visible;
|
|
2045
|
-
return {
|
|
2046
|
-
id: writeAction.id,
|
|
2047
|
-
label: effectiveTranslate(writeAction.label),
|
|
2048
|
-
style: writeAction.style,
|
|
2049
|
-
icon: resolveActionIcon(writeAction.id, writeAction.icon),
|
|
2050
|
-
confirm:
|
|
2051
|
-
writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
|
|
2052
|
-
confirmLabel:
|
|
2053
|
-
writeAction.confirmLabel !== undefined
|
|
2054
|
-
? effectiveTranslate(writeAction.confirmLabel)
|
|
2055
|
-
: undefined,
|
|
2056
|
-
onTrigger: async (row: ListRowViewModel) => {
|
|
2057
|
-
const buildPayload = writeAction.payload;
|
|
2058
|
-
const payload =
|
|
2059
|
-
buildPayload !== undefined
|
|
2060
|
-
? evalRowExtractor(buildPayload, row.values)
|
|
2061
|
-
: { id: row.values["id"] };
|
|
2062
|
-
const result = await dispatcher.write(writeAction.handler, payload);
|
|
2063
|
-
// write() wirft nicht — Failure-Result MUSS hier zum Error
|
|
2064
|
-
// werden, sonst schließt der Confirm-Dialog kommentarlos und
|
|
2065
|
-
// der User sieht "nichts passiert" (Prod-Bug 2026-06-07).
|
|
2066
|
-
if (!result.isSuccess) {
|
|
2067
|
-
throw new WriteFailedError(
|
|
2068
|
-
result.error,
|
|
2069
|
-
dispatcherErrorText(result.error, effectiveTranslate),
|
|
2070
|
-
);
|
|
2071
|
-
}
|
|
2072
|
-
await refreshRowsAfterWrite();
|
|
2073
|
-
},
|
|
2074
|
-
isVisible:
|
|
2075
|
-
writeActionVisible !== undefined
|
|
2076
|
-
? (row: ListRowViewModel) => evalFieldCondition(writeActionVisible, row.values)
|
|
2077
|
-
: undefined,
|
|
2078
|
-
};
|
|
2079
|
-
})
|
|
2080
|
-
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
2081
|
-
}, [
|
|
2082
|
-
screen.rowActions,
|
|
2083
|
-
defaultEditScreen,
|
|
2084
|
-
effectiveTranslate,
|
|
2085
|
-
dispatcher,
|
|
2086
|
-
runNavigate,
|
|
2087
|
-
refreshRowsAfterWrite,
|
|
2088
|
-
openDrawer,
|
|
2089
|
-
]);
|
|
2090
|
-
|
|
2091
|
-
// ToolbarActions: Schema → Resolved-Form (analog rowActions), shared with
|
|
2092
|
-
// projectionList and relatedList sections via buildProjectionToolbarActions.
|
|
2093
|
-
const toolbarActions = useMemo(
|
|
2094
|
-
() =>
|
|
2095
|
-
buildProjectionToolbarActions({
|
|
2096
|
-
toolbarActions: screen.toolbarActions,
|
|
2097
|
-
translate: effectiveTranslate,
|
|
2098
|
-
dispatcher,
|
|
2099
|
-
nav,
|
|
2100
|
-
refetch: refreshRowsAfterWrite,
|
|
2101
|
-
openDrawer,
|
|
2102
|
-
host,
|
|
2103
|
-
}),
|
|
2104
|
-
[
|
|
2105
|
-
screen.toolbarActions,
|
|
2106
|
-
effectiveTranslate,
|
|
2107
|
-
nav,
|
|
2108
|
-
host,
|
|
2109
|
-
dispatcher,
|
|
2110
|
-
refreshRowsAfterWrite,
|
|
2111
|
-
openDrawer,
|
|
2112
|
-
],
|
|
2113
|
-
);
|
|
2114
|
-
|
|
2115
|
-
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
2116
|
-
return (
|
|
2117
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
2118
|
-
Loading…
|
|
2119
|
-
</Banner>
|
|
2120
|
-
);
|
|
2121
|
-
}
|
|
2122
|
-
if (rowsQuery.error) {
|
|
2123
|
-
return (
|
|
2124
|
-
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
2125
|
-
{dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
|
|
2126
|
-
</Banner>
|
|
2127
|
-
);
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
// RenderList's onRowClick is a 1-arg callback; KumikoScreen's
|
|
2131
|
-
// 2-arg shape (row, entityName) is the public surface — thread the
|
|
2132
|
-
// screen's entity through here so callers don't have to re-derive
|
|
2133
|
-
// it.
|
|
2134
|
-
// Row-Body-Klick: eine explizit als rowClick markierte navigate-rowAction
|
|
2135
|
-
// gewinnt (deklarierte Author-Absicht "Klick auf die Zeile tut X"). Ohne Flag
|
|
2136
|
-
// bleibt der Pfad unverändert — create-app's entityEdit-Default bzw. ein vom
|
|
2137
|
-
// App-Author gesetztes onRowClick.
|
|
2138
|
-
const rowClickAction = screen.rowActions?.find(
|
|
2139
|
-
(a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
|
|
2140
|
-
);
|
|
2141
|
-
const wrappedOnRowClick = rowClickAction
|
|
2142
|
-
? (row: ListRowViewModel) => runNavigate(rowClickAction, row)
|
|
2143
|
-
: onRowClick !== undefined
|
|
2144
|
-
? (row: ListRowViewModel) => onRowClick(row, screen.entity)
|
|
2145
|
-
: undefined;
|
|
2146
|
-
|
|
2147
|
-
// Searchable default: explicit author choice wins, otherwise auto-on when
|
|
2148
|
-
// the entity has searchable fields (dead toolbar slot otherwise — the
|
|
2149
|
-
// server search index has nothing to filter). Gated on
|
|
2150
|
-
// schema.searchAdapterMissing !== true regardless of source (explicit or
|
|
2151
|
-
// auto) — a search bar the server can't serve would 422 at request-time
|
|
2152
|
-
// (#2032); matches the boot-time check in api/server.ts (#2051).
|
|
2153
|
-
const searchable =
|
|
2154
|
-
schema.searchAdapterMissing !== true &&
|
|
2155
|
-
(screen.searchable ??
|
|
2156
|
-
Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true));
|
|
2157
|
-
|
|
2158
|
-
// Pager-Props nur bei pagination="pages" zusammenstellen. Server-
|
|
2159
|
-
// total kommt async — bis es da ist, rendert RenderList die Tabelle
|
|
2160
|
-
// ohne Pager (Pager hat eine eigene total>0-Guard).
|
|
2161
|
-
const total = rowsQuery.data?.total;
|
|
2162
|
-
const pager =
|
|
2163
|
-
usePager && total !== undefined
|
|
2164
|
-
? {
|
|
2165
|
-
page: urlState.page,
|
|
2166
|
-
limit,
|
|
2167
|
-
total,
|
|
2168
|
-
onPageChange: urlState.setPage,
|
|
2169
|
-
}
|
|
2170
|
-
: undefined;
|
|
2171
|
-
|
|
2172
|
-
// Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
|
|
2173
|
-
// useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
|
|
2174
|
-
const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
|
|
2175
|
-
|
|
2176
|
-
return (
|
|
2177
|
-
<>
|
|
2178
|
-
<ReferenceFacetBridges specs={entityFacetSpecs} onOptions={handleEntityFacetOptions} />
|
|
2179
|
-
<RenderList
|
|
2180
|
-
screen={screen}
|
|
2181
|
-
entity={entity}
|
|
2182
|
-
rows={renderRows}
|
|
2183
|
-
featureName={featureName}
|
|
2184
|
-
searchable={searchable}
|
|
2185
|
-
searchValue={urlState.q}
|
|
2186
|
-
onSearchChange={urlState.setQ}
|
|
2187
|
-
sort={effectiveSort}
|
|
2188
|
-
onSortChange={urlState.setSort}
|
|
2189
|
-
screenPadding
|
|
2190
|
-
{...(pager !== undefined && { pager })}
|
|
2191
|
-
{...(rowActions !== undefined && { rowActions })}
|
|
2192
|
-
{...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
|
|
2193
|
-
{...(useInfinite && {
|
|
2194
|
-
onReachEnd: loadMore,
|
|
2195
|
-
loadingMore: rowsQuery.loading,
|
|
2196
|
-
hasMore,
|
|
2197
|
-
})}
|
|
2198
|
-
{...(onCreate !== undefined && { onCreate })}
|
|
2199
|
-
{...(translate !== undefined && { translate })}
|
|
2200
|
-
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
2201
|
-
{...(filterFacets.length > 0 && {
|
|
2202
|
-
filterFacets,
|
|
2203
|
-
filterValues: urlState.filters,
|
|
2204
|
-
onFilterChange: urlState.setFilter,
|
|
2205
|
-
onFilterReset: urlState.clearFilters,
|
|
2206
|
-
})}
|
|
2207
|
-
/>
|
|
2208
|
-
<DrawerHost
|
|
2209
|
-
schema={schema}
|
|
2210
|
-
drawerAction={drawerAction}
|
|
2211
|
-
drawerScreen={drawerScreen}
|
|
2212
|
-
{...(drawerInitialValues !== undefined && { drawerInitialValues })}
|
|
2213
|
-
userRoles={userRoles}
|
|
2214
|
-
{...(translate !== undefined && { translate })}
|
|
2215
|
-
onClose={closeDrawer}
|
|
2216
|
-
onSuccess={() => {
|
|
2217
|
-
closeDrawer();
|
|
2218
|
-
void rowsQuery.refetch();
|
|
2219
|
-
}}
|
|
2220
|
-
/>
|
|
2221
|
-
</>
|
|
2222
|
-
);
|
|
2223
|
-
}
|
|
2224
|
-
|
|
2225
|
-
// ---- projection-list ----
|
|
2226
|
-
|
|
2227
|
-
// Like entityList, but the list query comes DIRECTLY from `screen.query`
|
|
2228
|
-
// (instead of being derived from an entity) — cross-feature capable.
|
|
2229
|
-
// search/sort/pagination reuse the same URL-state + buildListQueryPayload
|
|
2230
|
-
// wiring as EntityListBody; which of them are actually offered is derived
|
|
2231
|
-
// from the query handler's Zod schema at buildAppSchema time (fw#2165), not
|
|
2232
|
-
// declared here. Pages-mode pagination only — infinite-scroll accumulation
|
|
2233
|
-
// isn't wired for projectionList.
|
|
2234
|
-
function ProjectionListBody({
|
|
2235
|
-
schema,
|
|
2236
|
-
screen,
|
|
2237
|
-
translate,
|
|
2238
|
-
onRowClick,
|
|
2239
|
-
}: {
|
|
2240
|
-
readonly schema: FeatureSchema;
|
|
2241
|
-
readonly screen: ProjectionListScreenDefinition;
|
|
2242
|
-
readonly translate?: Translate;
|
|
2243
|
-
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
2244
|
-
}): ReactNode {
|
|
2245
|
-
const { Banner, Text } = usePrimitives();
|
|
2246
|
-
const t = useTranslation();
|
|
2247
|
-
const nav = useNav();
|
|
2248
|
-
const host = useReturnHost();
|
|
2249
|
-
const dispatcher = useOptionalDispatcher();
|
|
2250
|
-
const effectiveTranslate = translate ?? t;
|
|
2251
|
-
const userRoles = useUserRoles();
|
|
2252
|
-
const appFeatures = useAppFeatures();
|
|
2253
|
-
const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
|
|
2254
|
-
useDrawerAction(schema);
|
|
2255
|
-
const defaultEditScreen = useMemo(() => {
|
|
2256
|
-
const detailFor = screen.detailFor;
|
|
2257
|
-
if (detailFor === undefined) return undefined;
|
|
2258
|
-
return findEditScreenFor(detailFor, appFeatures, userRoles);
|
|
2259
|
-
}, [appFeatures, screen.detailFor, userRoles]);
|
|
2260
|
-
|
|
2261
|
-
// searchable/sortable/paginated are derived at buildAppSchema time from the
|
|
2262
|
-
// query handler's Zod schema (fw#2165) — not authored on the screen.
|
|
2263
|
-
const searchable = screen.searchable ?? false;
|
|
2264
|
-
const sortable = screen.sortable ?? false;
|
|
2265
|
-
const paginated = screen.paginated ?? false;
|
|
2266
|
-
const entity = useMemo(
|
|
2267
|
-
() => synthesizeProjectionEntity(screen.columns, sortable),
|
|
2268
|
-
[screen.columns, sortable],
|
|
2269
|
-
);
|
|
2270
|
-
const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
|
|
2271
|
-
|
|
2272
|
-
const urlState = useListUrlState(screen.id);
|
|
2273
|
-
// Gated on the derived capability, not just on whether URL-state happens to
|
|
2274
|
-
// carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
|
|
2275
|
-
// screen) must not smuggle a param the query's Zod schema doesn't accept.
|
|
2276
|
-
const activeSearch = searchable ? urlState.q : "";
|
|
2277
|
-
const activeSort = sortable ? (urlState.sort ?? screen.defaultSort ?? null) : null;
|
|
2278
|
-
const limit = screen.pageSize ?? 50;
|
|
2279
|
-
// Pages-mode only (see header comment) — an author-set pagination:
|
|
2280
|
-
// "infinite" on a paginated projectionList is silently a no-op today.
|
|
2281
|
-
const usePager = paginated && (screen.pagination ?? "pages") === "pages";
|
|
2282
|
-
|
|
2283
|
-
// Facets (fw#2224) — a projectionList has no entity, so screen.facets is
|
|
2284
|
-
// the only field inventory; resolveProjectionFacetSpecs reshapes +
|
|
2285
|
-
// translates its explicit labels into the same ResolvedFacetSpec the
|
|
2286
|
-
// entityList adapter produces, so both feed the same
|
|
2287
|
-
// buildFilterFacets/buildFilterPayload.
|
|
2288
|
-
const facetSpecs = useMemo(
|
|
2289
|
-
() => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate, schema.featureName),
|
|
2290
|
-
[screen.facets, effectiveTranslate, schema.featureName],
|
|
2291
|
-
);
|
|
2292
|
-
const [projectionReferenceFacetOptions, setProjectionReferenceFacetOptions] = useState<
|
|
2293
|
-
Record<string, readonly ReferenceFacetOption[]>
|
|
2294
|
-
>({});
|
|
2295
|
-
const handleProjectionFacetOptions = useCallback(
|
|
2296
|
-
(field: string, options: readonly ReferenceFacetOption[]) =>
|
|
2297
|
-
setProjectionReferenceFacetOptions((prev) =>
|
|
2298
|
-
prev[field] === options ? prev : { ...prev, [field]: options },
|
|
2299
|
-
),
|
|
2300
|
-
[],
|
|
2301
|
-
);
|
|
2302
|
-
const resolvedFacetSpecs = useMemo(
|
|
2303
|
-
() => mergeReferenceFacetOptions(facetSpecs, projectionReferenceFacetOptions),
|
|
2304
|
-
[facetSpecs, projectionReferenceFacetOptions],
|
|
2305
|
-
);
|
|
2306
|
-
const filterPayload = useMemo(
|
|
2307
|
-
() =>
|
|
2308
|
-
buildFilterPayload(
|
|
2309
|
-
urlState.filters,
|
|
2310
|
-
(field) => facetSpecs.find((spec) => spec.field === field)?.type,
|
|
2311
|
-
),
|
|
2312
|
-
[urlState.filters, facetSpecs],
|
|
2313
|
-
);
|
|
2314
|
-
|
|
2315
|
-
// dateRange facets (fw#3104) bypass `filters` entirely — their two bounds
|
|
2316
|
-
// become the top-level query params the facet declares, resolved from a
|
|
2317
|
-
// calendar date to an instant in the viewer's zone.
|
|
2318
|
-
const timeZone = useOptionalTimeZone();
|
|
2319
|
-
const dateRangeSpecs = useMemo(
|
|
2320
|
-
() => resolveDateRangeFacets(screen.facets, effectiveTranslate),
|
|
2321
|
-
[screen.facets, effectiveTranslate],
|
|
2322
|
-
);
|
|
2323
|
-
const dateRangePayload = useMemo(
|
|
2324
|
-
() => buildDateRangePayload(dateRangeSpecs, urlState.filters, timeZone),
|
|
2325
|
-
[dateRangeSpecs, urlState.filters, timeZone],
|
|
2326
|
-
);
|
|
2327
|
-
|
|
2328
|
-
const queryPayload = useMemo(() => {
|
|
2329
|
-
const payload = buildListQueryPayload({
|
|
2330
|
-
limit,
|
|
2331
|
-
search: activeSearch,
|
|
2332
|
-
sort: activeSort,
|
|
2333
|
-
usePager,
|
|
2334
|
-
page: urlState.page,
|
|
2335
|
-
useInfinite: false,
|
|
2336
|
-
cursor: undefined,
|
|
2337
|
-
});
|
|
2338
|
-
// Gated on screen.filter/screen.facets being declared (not just on
|
|
2339
|
-
// urlState/filterPayload happening to carry a value) — same rule as
|
|
2340
|
-
// activeSearch/activeSort above: a stale URL must not smuggle a param
|
|
2341
|
-
// the bound query's Zod schema doesn't accept (fw#2165 pattern).
|
|
2342
|
-
if (screen.filter !== undefined) {
|
|
2343
|
-
payload["filter"] = screen.filter;
|
|
2344
|
-
}
|
|
2345
|
-
if (screen.facets !== undefined && filterPayload.length > 0) {
|
|
2346
|
-
payload["filters"] = filterPayload;
|
|
2347
|
-
}
|
|
2348
|
-
Object.assign(payload, dateRangePayload);
|
|
2349
|
-
return payload;
|
|
2350
|
-
}, [
|
|
2351
|
-
limit,
|
|
2352
|
-
activeSearch,
|
|
2353
|
-
activeSort,
|
|
2354
|
-
usePager,
|
|
2355
|
-
urlState.page,
|
|
2356
|
-
screen.filter,
|
|
2357
|
-
screen.facets,
|
|
2358
|
-
filterPayload,
|
|
2359
|
-
dateRangePayload,
|
|
2360
|
-
]);
|
|
2361
|
-
|
|
2362
|
-
const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
|
|
2363
|
-
|
|
2364
|
-
const filterFacets = useMemo<DataTableFacet[]>(
|
|
2365
|
-
() => buildFilterFacets(resolvedFacetSpecs),
|
|
2366
|
-
[resolvedFacetSpecs],
|
|
2367
|
-
);
|
|
2368
|
-
const dateRangeFacets = useMemo<DataTableDateRangeFacet[]>(
|
|
2369
|
-
() =>
|
|
2370
|
-
dateRangeSpecs.map((spec) => ({
|
|
2371
|
-
field: spec.field,
|
|
2372
|
-
label: spec.label,
|
|
2373
|
-
...readDateRange(urlState.filters, spec.field),
|
|
2374
|
-
})),
|
|
2375
|
-
[dateRangeSpecs, urlState.filters],
|
|
2376
|
-
);
|
|
2377
|
-
const handleDateRangeChange = useCallback(
|
|
2378
|
-
(field: string, bound: "from" | "to", value: string) => {
|
|
2379
|
-
urlState.setDateRange(
|
|
2380
|
-
field,
|
|
2381
|
-
clampDateRange(readDateRange(urlState.filters, field), bound, value),
|
|
2382
|
-
);
|
|
2383
|
-
},
|
|
2384
|
-
[urlState.setDateRange, urlState.filters],
|
|
2385
|
-
);
|
|
2386
|
-
|
|
2387
|
-
// Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
|
|
2388
|
-
// resolution happens in the NavApi impl. Unlike there: NO row["id"]
|
|
2389
|
-
// fallback — projectionList rows come from an arbitrary query projection
|
|
2390
|
-
// with no guaranteed "id" field. The boot validator therefore enforces an
|
|
2391
|
-
// explicit entityId for projectionList entity targets. Same helper
|
|
2392
|
-
// relatedList's rowActions reuse (related-list-section.tsx) — a
|
|
2393
|
-
// projectionDetail relatedList row has the identical "no guaranteed id"
|
|
2394
|
-
// shape.
|
|
2395
|
-
const runNavigate = useCallback(
|
|
2396
|
-
(action: RowActionNavigate, row: ListRowViewModel) =>
|
|
2397
|
-
runProjectionRowNavigate(nav, action, row, host),
|
|
2398
|
-
[nav, host],
|
|
2399
|
-
);
|
|
2400
|
-
|
|
2401
|
-
const defaultEditRowAction = useMemo(
|
|
2402
|
-
() => buildDefaultEditRowAction(defaultEditScreen),
|
|
2403
|
-
[defaultEditScreen],
|
|
2404
|
-
);
|
|
2405
|
-
|
|
2406
|
-
const rowActions = useMemo(
|
|
2407
|
-
() =>
|
|
2408
|
-
buildProjectionRowActions({
|
|
2409
|
-
rowActions: screen.rowActions,
|
|
2410
|
-
translate: effectiveTranslate,
|
|
2411
|
-
dispatcher,
|
|
2412
|
-
nav,
|
|
2413
|
-
refetch: rowsQuery.refetch,
|
|
2414
|
-
openDrawer,
|
|
2415
|
-
defaultEditRowAction,
|
|
2416
|
-
host,
|
|
2417
|
-
}),
|
|
2418
|
-
[
|
|
2419
|
-
screen.rowActions,
|
|
2420
|
-
effectiveTranslate,
|
|
2421
|
-
dispatcher,
|
|
2422
|
-
nav,
|
|
2423
|
-
rowsQuery.refetch,
|
|
2424
|
-
openDrawer,
|
|
2425
|
-
defaultEditRowAction,
|
|
2426
|
-
host,
|
|
2427
|
-
],
|
|
2428
|
-
);
|
|
2429
|
-
|
|
2430
|
-
const toolbarActions = useMemo(
|
|
2431
|
-
() =>
|
|
2432
|
-
buildProjectionToolbarActions({
|
|
2433
|
-
toolbarActions: screen.toolbarActions,
|
|
2434
|
-
translate: effectiveTranslate,
|
|
2435
|
-
dispatcher,
|
|
2436
|
-
nav,
|
|
2437
|
-
refetch: rowsQuery.refetch,
|
|
2438
|
-
openDrawer,
|
|
2439
|
-
host,
|
|
2440
|
-
}),
|
|
2441
|
-
[
|
|
2442
|
-
screen.toolbarActions,
|
|
2443
|
-
effectiveTranslate,
|
|
2444
|
-
nav,
|
|
2445
|
-
host,
|
|
2446
|
-
dispatcher,
|
|
2447
|
-
rowsQuery.refetch,
|
|
2448
|
-
openDrawer,
|
|
2449
|
-
],
|
|
2450
|
-
);
|
|
2451
|
-
|
|
2452
|
-
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
2453
|
-
return (
|
|
2454
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
2455
|
-
Loading…
|
|
2456
|
-
</Banner>
|
|
2457
|
-
);
|
|
2458
|
-
}
|
|
2459
|
-
if (rowsQuery.error) {
|
|
2460
|
-
return (
|
|
2461
|
-
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
2462
|
-
{dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
|
|
2463
|
-
</Banner>
|
|
2464
|
-
);
|
|
2465
|
-
}
|
|
2466
|
-
|
|
2467
|
-
// rowsQuery.data is typed as PagedRows but the query handler is free to
|
|
2468
|
-
// return anything at runtime (fw#2216: a bare array silently rendered an
|
|
2469
|
-
// empty table instead of surfacing the mismatch); a system-boundary cast
|
|
2470
|
-
// is needed to inspect the actual wire shape before trusting it.
|
|
2471
|
-
const rawRowsData = rowsQuery.data as unknown as { readonly rows?: unknown } | null;
|
|
2472
|
-
if (rawRowsData !== null && !Array.isArray(rawRowsData.rows)) {
|
|
2473
|
-
return (
|
|
2474
|
-
<Banner padded variant="error" testId="kumiko-screen-projection-list-bad-shape">
|
|
2475
|
-
Screen <Text variant="code">{screen.id}</Text> query{" "}
|
|
2476
|
-
<Text variant="code">{screen.query}</Text> did not return a PagedRows envelope; a
|
|
2477
|
-
projectionList query must return <Text variant="code">{"{ rows, nextCursor }"}</Text>.
|
|
2478
|
-
</Banner>
|
|
2479
|
-
);
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
const rowClickAction = screen.rowActions?.find(
|
|
2483
|
-
(a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
|
|
2484
|
-
);
|
|
2485
|
-
const wrappedOnRowClick = rowClickAction
|
|
2486
|
-
? (row: ListRowViewModel) => runNavigate(rowClickAction, row)
|
|
2487
|
-
: onRowClick !== undefined
|
|
2488
|
-
? (row: ListRowViewModel) => onRowClick(row, listScreen.entity)
|
|
2489
|
-
: undefined;
|
|
2490
|
-
|
|
2491
|
-
// Same pager-construction as EntityListBody: no Pager UI until the
|
|
2492
|
-
// server-provided `total` arrives (guards pagination="pages" without
|
|
2493
|
-
// totalCount support, see fw#2165 report on solon's leaseOverviewHandler).
|
|
2494
|
-
const total = rowsQuery.data?.total;
|
|
2495
|
-
const pager =
|
|
2496
|
-
usePager && total !== undefined
|
|
2497
|
-
? { page: urlState.page, limit, total, onPageChange: urlState.setPage }
|
|
2498
|
-
: undefined;
|
|
2499
|
-
|
|
2500
|
-
return (
|
|
2501
|
-
<>
|
|
2502
|
-
<ReferenceFacetBridges specs={facetSpecs} onOptions={handleProjectionFacetOptions} />
|
|
2503
|
-
<RenderList
|
|
2504
|
-
screen={listScreen}
|
|
2505
|
-
entity={entity}
|
|
2506
|
-
rows={rowsQuery.data?.rows ?? []}
|
|
2507
|
-
featureName={schema.featureName}
|
|
2508
|
-
searchable={searchable}
|
|
2509
|
-
searchValue={urlState.q}
|
|
2510
|
-
onSearchChange={urlState.setQ}
|
|
2511
|
-
sort={activeSort}
|
|
2512
|
-
onSortChange={urlState.setSort}
|
|
2513
|
-
screenPadding
|
|
2514
|
-
{...(pager !== undefined && { pager })}
|
|
2515
|
-
{...(rowActions !== undefined && { rowActions })}
|
|
2516
|
-
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
2517
|
-
{...(translate !== undefined && { translate })}
|
|
2518
|
-
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
2519
|
-
{...(filterFacets.length > 0 && {
|
|
2520
|
-
filterFacets,
|
|
2521
|
-
onFilterChange: urlState.setFilter,
|
|
2522
|
-
})}
|
|
2523
|
-
{...(dateRangeFacets.length > 0 && {
|
|
2524
|
-
dateRangeFacets,
|
|
2525
|
-
onDateRangeChange: handleDateRangeChange,
|
|
2526
|
-
})}
|
|
2527
|
-
{...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
|
|
2528
|
-
filterValues: urlState.filters,
|
|
2529
|
-
onFilterReset: urlState.clearFilters,
|
|
2530
|
-
})}
|
|
2531
|
-
/>
|
|
2532
|
-
<DrawerHost
|
|
2533
|
-
schema={schema}
|
|
2534
|
-
drawerAction={drawerAction}
|
|
2535
|
-
drawerScreen={drawerScreen}
|
|
2536
|
-
{...(drawerInitialValues !== undefined && { drawerInitialValues })}
|
|
2537
|
-
userRoles={userRoles}
|
|
2538
|
-
{...(translate !== undefined && { translate })}
|
|
2539
|
-
onClose={closeDrawer}
|
|
2540
|
-
onSuccess={() => {
|
|
2541
|
-
closeDrawer();
|
|
2542
|
-
void rowsQuery.refetch();
|
|
2543
|
-
}}
|
|
2544
|
-
/>
|
|
2545
|
-
</>
|
|
2546
|
-
);
|
|
2547
|
-
}
|
|
2548
|
-
|
|
2549
|
-
function metricLabelKey(
|
|
2550
|
-
metric: MetricSpec,
|
|
2551
|
-
fieldLabels: Readonly<Record<string, string>> | undefined,
|
|
2552
|
-
): string | undefined {
|
|
2553
|
-
if (typeof metric !== "string" && metric.label !== undefined) return metric.label;
|
|
2554
|
-
return fieldLabels?.[metricField(metric)];
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
|
-
function metricNavigateSpec(metric: MetricSpec): MetricNavigate | undefined {
|
|
2558
|
-
return typeof metric === "string" ? undefined : metric.navigate;
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
// Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
|
|
2562
|
-
// navigate execution — a metric click has the same entity/screen/params
|
|
2563
|
-
// shape as a RowActionNavigate, minus the fields (id, label, style) that
|
|
2564
|
-
// function never reads.
|
|
2565
|
-
function runMetricNavigate(
|
|
2566
|
-
nav: NavApi,
|
|
2567
|
-
navigate: MetricNavigate,
|
|
2568
|
-
record: Readonly<Record<string, unknown>>,
|
|
2569
|
-
host: ReturnHost | undefined,
|
|
2570
|
-
): void {
|
|
2571
|
-
// tab alone (no screen/entity) stays on the current record and just
|
|
2572
|
-
// activates that tab — no route change, so runProjectionRowNavigate
|
|
2573
|
-
// (which always navigates) doesn't apply here.
|
|
2574
|
-
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
2575
|
-
if (navigate.tab !== undefined) nav.setSearchParams({ tab: navigate.tab });
|
|
2576
|
-
return;
|
|
2577
|
-
}
|
|
2578
|
-
const base = { kind: "navigate" as const, id: "metric-navigate", label: "" };
|
|
2579
|
-
const action: RowActionNavigate | undefined =
|
|
2580
|
-
navigate.entity !== undefined
|
|
2581
|
-
? {
|
|
2582
|
-
...base,
|
|
2583
|
-
entity: navigate.entity,
|
|
2584
|
-
...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
|
|
2585
|
-
...(navigate.params !== undefined && { params: navigate.params }),
|
|
2586
|
-
...(navigate.tab !== undefined && { tab: navigate.tab }),
|
|
2587
|
-
}
|
|
2588
|
-
: navigate.screen !== undefined
|
|
2589
|
-
? {
|
|
2590
|
-
...base,
|
|
2591
|
-
screen: navigate.screen,
|
|
2592
|
-
...(navigate.entityId !== undefined && { entityId: navigate.entityId }),
|
|
2593
|
-
...(navigate.params !== undefined && { params: navigate.params }),
|
|
2594
|
-
...(navigate.tab !== undefined && { tab: navigate.tab }),
|
|
2595
|
-
}
|
|
2596
|
-
: undefined;
|
|
2597
|
-
if (action === undefined) return;
|
|
2598
|
-
runProjectionRowNavigate(nav, action, { id: "", values: record }, host);
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
// Absolute http(s) check for RecordHeaderSpec.subtitleHref — deliberately
|
|
2602
|
-
// stricter than isSafeHref (which also allows relative paths and mailto:),
|
|
2603
|
-
// since a relative/mailto value here should render as plain text, not a
|
|
2604
|
-
// "_blank" external link.
|
|
2605
|
-
function isAbsoluteHttpUrl(value: string): boolean {
|
|
2606
|
-
return /^https?:\/\//i.test(value) && isSafeHref(value);
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
function resolveSubtitleHref(
|
|
2610
|
-
header: ProjectionDetailScreenDefinition["header"],
|
|
2611
|
-
record: Readonly<Record<string, unknown>>,
|
|
2612
|
-
): string | undefined {
|
|
2613
|
-
if (header?.subtitleHref === undefined) return undefined;
|
|
2614
|
-
const value = record[header.subtitleHref];
|
|
2615
|
-
return typeof value === "string" && isAbsoluteHttpUrl(value) ? value : undefined;
|
|
2616
|
-
}
|
|
2617
|
-
|
|
2618
|
-
// Projection-Detail-Body — read-only single-row inspector über eine explizite
|
|
2619
|
-
// Query statt einer Entity (siehe projection-detail-shim.ts für die Schulden-
|
|
2620
|
-
// Doku). Fetcht selbst über `screen.query` + `idParam` (analog zu
|
|
2621
|
-
// EntityEditUpdateBody, das die Query-QN aus der Entity-Convention ableitet —
|
|
2622
|
-
// hier ist die QN Author-explizit, wie bei ProjectionListBody). Rendert über
|
|
2623
|
-
// RenderEdit MIT customSubmit-No-Op statt writeCommand: hasEditableSection()
|
|
2624
|
-
// blendet den Save-Button aus (jedes Feld ist readOnly), und selbst ein
|
|
2625
|
-
// natives Form-Submit (Enter-Keypress) würde ohne customSubmit gegen
|
|
2626
|
-
// controller.submit() ohne submit-config throwen — der No-Op macht diesen
|
|
2627
|
-
// Pfad harmlos statt ihn dem Zufall zu überlassen.
|
|
2628
|
-
// The primary header action always stays a visible text button — `edit` if
|
|
2629
|
-
// declared, else the first `style: "primary"` action, else the first action
|
|
2630
|
-
// at all. One step longer than row actions' `primaryRowAction` (renderer-web
|
|
2631
|
-
// primitives/index.tsx): rows don't declare `style: "primary"`.
|
|
2632
|
-
function primaryHeaderAction(actions: readonly RenderEditAction[]): RenderEditAction | undefined {
|
|
2633
|
-
return (
|
|
2634
|
-
actions.find((a) => a.id === "edit") ?? actions.find((a) => a.style === "primary") ?? actions[0]
|
|
2635
|
-
);
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
// Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
|
|
2639
|
-
// `ActionOverflowMenu` primitive collapse to the primary button plus a menu
|
|
2640
|
-
// for the rest — same rule as RowActionsCell for table rows. Owns confirm
|
|
2641
|
-
// state for menu items: RenderEditActionButton already owns a per-button
|
|
2642
|
-
// confirm dialog for the always-visible primary action, but a menu item has
|
|
2643
|
-
// no button of its own to carry one.
|
|
2644
|
-
function HeaderActionsBar({
|
|
2645
|
-
actions,
|
|
2646
|
-
Button,
|
|
2647
|
-
Dialog,
|
|
2648
|
-
ActionOverflowMenu,
|
|
2649
|
-
onError,
|
|
2650
|
-
}: {
|
|
2651
|
-
readonly actions: readonly RenderEditAction[];
|
|
2652
|
-
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
2653
|
-
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
2654
|
-
readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
|
|
2655
|
-
readonly onError: (text: string | null) => void;
|
|
2656
|
-
}): ReactNode {
|
|
2657
|
-
const [pendingAction, setPendingAction] = useState<RenderEditAction | null>(null);
|
|
2658
|
-
const trigger = async (action: RenderEditAction): Promise<void> => {
|
|
2659
|
-
onError(null);
|
|
2660
|
-
try {
|
|
2661
|
-
await action.onPress();
|
|
2662
|
-
} catch (e) {
|
|
2663
|
-
onError(e instanceof Error ? e.message : String(e));
|
|
2664
|
-
}
|
|
2665
|
-
};
|
|
2666
|
-
if (actions.length <= 2 || ActionOverflowMenu === undefined) {
|
|
2667
|
-
return (
|
|
2668
|
-
<>
|
|
2669
|
-
{actions.map((action) => (
|
|
2670
|
-
<RenderEditActionButton
|
|
2671
|
-
key={action.id}
|
|
2672
|
-
action={action}
|
|
2673
|
-
Button={Button}
|
|
2674
|
-
Dialog={Dialog}
|
|
2675
|
-
onError={onError}
|
|
2676
|
-
/>
|
|
2677
|
-
))}
|
|
2678
|
-
</>
|
|
2679
|
-
);
|
|
2680
|
-
}
|
|
2681
|
-
const primary = primaryHeaderAction(actions);
|
|
2682
|
-
const rest = actions.filter((a) => a.id !== primary?.id);
|
|
2683
|
-
return (
|
|
2684
|
-
<>
|
|
2685
|
-
{primary !== undefined && (
|
|
2686
|
-
<RenderEditActionButton
|
|
2687
|
-
key={primary.id}
|
|
2688
|
-
action={primary}
|
|
2689
|
-
Button={Button}
|
|
2690
|
-
Dialog={Dialog}
|
|
2691
|
-
onError={onError}
|
|
2692
|
-
/>
|
|
2693
|
-
)}
|
|
2694
|
-
<ActionOverflowMenu
|
|
2695
|
-
label="More actions"
|
|
2696
|
-
testId="kumiko-screen-projection-detail-actions-overflow"
|
|
2697
|
-
items={rest.map((action) => ({
|
|
2698
|
-
id: action.id,
|
|
2699
|
-
label: action.label,
|
|
2700
|
-
...(action.icon !== undefined && { icon: action.icon }),
|
|
2701
|
-
variant: action.style === "danger" ? ("danger" as const) : ("default" as const),
|
|
2702
|
-
onSelect: () => {
|
|
2703
|
-
if (needsActionConfirm(action)) {
|
|
2704
|
-
setPendingAction(action);
|
|
2705
|
-
} else {
|
|
2706
|
-
void trigger(action);
|
|
2707
|
-
}
|
|
2708
|
-
},
|
|
2709
|
-
}))}
|
|
2710
|
-
/>
|
|
2711
|
-
{pendingAction !== null && (
|
|
2712
|
-
<RenderEditActionConfirmDialog
|
|
2713
|
-
action={pendingAction}
|
|
2714
|
-
open={true}
|
|
2715
|
-
onOpenChange={(open) => {
|
|
2716
|
-
if (!open) setPendingAction(null);
|
|
2717
|
-
}}
|
|
2718
|
-
onConfirm={async () => {
|
|
2719
|
-
const action = pendingAction;
|
|
2720
|
-
setPendingAction(null);
|
|
2721
|
-
await trigger(action);
|
|
2722
|
-
}}
|
|
2723
|
-
Dialog={Dialog}
|
|
2724
|
-
/>
|
|
2725
|
-
)}
|
|
2726
|
-
</>
|
|
2727
|
-
);
|
|
2728
|
-
}
|
|
2729
|
-
|
|
2730
|
-
function ProjectionDetailBody({
|
|
2731
|
-
schema,
|
|
2732
|
-
screen,
|
|
2733
|
-
translate,
|
|
2734
|
-
entityId,
|
|
2735
|
-
}: {
|
|
2736
|
-
readonly schema: FeatureSchema;
|
|
2737
|
-
// detailFor sits on the ScreenDefinition intersection, not on the
|
|
2738
|
-
// projectionDetail variant itself (screen.ts:832) — widen the prop type
|
|
2739
|
-
// to keep reading it here instead of re-deriving it from schema.screens.
|
|
2740
|
-
readonly screen: ProjectionDetailScreenDefinition & { readonly detailFor?: string };
|
|
2741
|
-
readonly translate?: Translate;
|
|
2742
|
-
readonly entityId?: string;
|
|
2743
|
-
}): ReactNode {
|
|
2744
|
-
const {
|
|
2745
|
-
Banner,
|
|
2746
|
-
Button,
|
|
2747
|
-
Dialog,
|
|
2748
|
-
Text,
|
|
2749
|
-
Grid,
|
|
2750
|
-
GridCell,
|
|
2751
|
-
Card,
|
|
2752
|
-
Tabs,
|
|
2753
|
-
StatusBadge,
|
|
2754
|
-
Metric,
|
|
2755
|
-
Link,
|
|
2756
|
-
ActionOverflowMenu,
|
|
2757
|
-
} = usePrimitives();
|
|
2758
|
-
const t = useTranslation();
|
|
2759
|
-
const effectiveTranslate = translate ?? t;
|
|
2760
|
-
const nav = useNav();
|
|
2761
|
-
const idParam = screen.idParam ?? "id";
|
|
2762
|
-
// Singleton screens ignore whatever id the path happens to carry — the
|
|
2763
|
-
// server picks the row from session/context, so forwarding a path id
|
|
2764
|
-
// (even a stray/spoofed one) into the query or into extension sections'
|
|
2765
|
-
// entityId fallback (resolveExtensionEntityId in render-edit.tsx) would
|
|
2766
|
-
// let a client-controlled value leak where none is meant to reach.
|
|
2767
|
-
const effectiveEntityId = screen.singleton === true ? undefined : entityId;
|
|
2768
|
-
const isTabsMode = screen.layout.mode === "tabs";
|
|
2769
|
-
const activeSection = useMemo(() => {
|
|
2770
|
-
if (!isTabsMode || Tabs === undefined) return undefined;
|
|
2771
|
-
const tabParam = nav.searchParams["tab"];
|
|
2772
|
-
return (
|
|
2773
|
-
screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]
|
|
2774
|
-
);
|
|
2775
|
-
}, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
|
|
2776
|
-
const hasTabs = isTabsMode && Tabs !== undefined && activeSection !== undefined;
|
|
2777
|
-
// Tabs is an optional Core-Primitive: without it the screen falls back to
|
|
2778
|
-
// the stacked all-sections layout instead of silently truncating to section 1.
|
|
2779
|
-
useEffect(() => {
|
|
2780
|
-
if (isTabsMode && Tabs === undefined) {
|
|
2781
|
-
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
2782
|
-
console.warn(
|
|
2783
|
-
`[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.`,
|
|
2784
|
-
);
|
|
2785
|
-
}
|
|
2786
|
-
}, [isTabsMode, Tabs, screen.id]);
|
|
2787
|
-
const entity = useMemo(() => synthesizeProjectionDetailEntity(screen.layout), [screen.layout]);
|
|
2788
|
-
const detailScreen = useMemo(() => {
|
|
2789
|
-
const source =
|
|
2790
|
-
activeSection !== undefined
|
|
2791
|
-
? { ...screen, layout: { ...screen.layout, sections: [activeSection] } }
|
|
2792
|
-
: screen;
|
|
2793
|
-
return synthesizeProjectionDetailScreen(source);
|
|
2794
|
-
}, [screen, activeSection]);
|
|
2795
|
-
const detailQuery = useQuery<Readonly<Record<string, unknown>>>(
|
|
2796
|
-
screen.query,
|
|
2797
|
-
effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {},
|
|
2798
|
-
);
|
|
2799
|
-
|
|
2800
|
-
// A writeForm section's handler creates a new record (see EditWriteFormSection's
|
|
2801
|
-
// doc) — both the outer record (header/metrics/other sections) and a sibling
|
|
2802
|
-
// relatedList section's own independently-fetched rows must reflect it. A
|
|
2803
|
-
// query refetch alone only refreshes `record` below; bumping this into
|
|
2804
|
-
// <RenderEdit>'s `key` forces a full remount so RelatedListSection's own
|
|
2805
|
-
// useQuery call re-runs too (it has no `live` subscription of its own).
|
|
2806
|
-
const [reloadNonce, setReloadNonce] = useState(0);
|
|
2807
|
-
const reloadDetail = useCallback(async () => {
|
|
2808
|
-
await detailQuery.refetch();
|
|
2809
|
-
setReloadNonce((n) => n + 1);
|
|
2810
|
-
}, [detailQuery.refetch]);
|
|
2811
|
-
|
|
2812
|
-
// Header actions render directly via RenderEditActionButton (fw#2713,
|
|
2813
|
-
// headerActions moved out of RenderEdit's footer) — this component owns
|
|
2814
|
-
// the failure-surfacing state RenderEdit used to own for them.
|
|
2815
|
-
const [actionError, setActionError] = useState<string | null>(null);
|
|
2816
|
-
|
|
2817
|
-
// Default edit action (fw#2166): resolved cross-feature over ALL mounted
|
|
2818
|
-
// features, not just this feature's own schema — detailFor itself is
|
|
2819
|
-
// resolved cross-feature by the boot-validator (detail-screens.ts), and
|
|
2820
|
-
// the motivating case is a projectionDetail whose query belongs to one
|
|
2821
|
-
// feature while the entity's entityEdit screen lives in another. Unlike
|
|
2822
|
-
// useNavigateToCreateFor this must NOT filter on allowCreate/singleton —
|
|
2823
|
-
// those gate create-targets, we're resolving an update-target by a
|
|
2824
|
-
// known id.
|
|
2825
|
-
const appFeatures = useAppFeatures();
|
|
2826
|
-
const userRoles = useUserRoles();
|
|
2827
|
-
const dispatcher = useOptionalDispatcher();
|
|
2828
|
-
const host = useReturnHost();
|
|
2829
|
-
const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } =
|
|
2830
|
-
useDrawerAction(schema);
|
|
2831
|
-
const editScreen = useMemo(() => {
|
|
2832
|
-
const detailFor = screen.detailFor;
|
|
2833
|
-
if (detailFor === undefined) return undefined;
|
|
2834
|
-
return findEditScreenFor(detailFor, appFeatures, userRoles);
|
|
2835
|
-
}, [appFeatures, screen.detailFor, userRoles]);
|
|
2836
|
-
const defaultEditAction = useMemo((): RenderEditAction | undefined => {
|
|
2837
|
-
if (editScreen === undefined) return undefined;
|
|
2838
|
-
// editScreen.id is registry-qualified ("feature:screen:contact-edit");
|
|
2839
|
-
// nav.navigate expects the short form (see useNavigateToCreateFor above).
|
|
2840
|
-
const targetScreenId = lastSegment(editScreen.id);
|
|
2841
|
-
return {
|
|
2842
|
-
id: "edit",
|
|
2843
|
-
label: effectiveTranslate("kumiko.actions.edit"),
|
|
2844
|
-
icon: resolveActionIcon("edit"),
|
|
2845
|
-
onPress: () => {
|
|
2846
|
-
const target: ScreenTarget = {
|
|
2847
|
-
screenId: targetScreenId,
|
|
2848
|
-
...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
|
|
2849
|
-
};
|
|
2850
|
-
navigateWithReturnTo(nav, target, host);
|
|
2851
|
-
},
|
|
2852
|
-
};
|
|
2853
|
-
}, [editScreen, effectiveTranslate, nav, effectiveEntityId, host]);
|
|
2854
|
-
|
|
2855
|
-
// a navigate-screen action targeting an entityEdit screen for the
|
|
2856
|
-
// SAME entity this projectionDetail shows (screen.detailFor) defaults its
|
|
2857
|
-
// entityId to the shown record's own id — without this fallback the target
|
|
2858
|
-
// opens an empty create-form instead of the shown record, silently.
|
|
2859
|
-
// Searched cross-feature, consistent with editScreen above.
|
|
2860
|
-
const sameEntityScreenId = useCallback(
|
|
2861
|
-
(targetScreen: string): string | undefined => {
|
|
2862
|
-
const record = detailQuery.data ?? {};
|
|
2863
|
-
const targetIsEntityEditSameEntity =
|
|
2864
|
-
screen.detailFor !== undefined &&
|
|
2865
|
-
appFeatures.some((feature) =>
|
|
2866
|
-
feature.screens.some(
|
|
2867
|
-
(s) =>
|
|
2868
|
-
s.type === "entityEdit" &&
|
|
2869
|
-
s.entity === screen.detailFor &&
|
|
2870
|
-
lastSegment(s.id) === targetScreen,
|
|
2871
|
-
),
|
|
2872
|
-
);
|
|
2873
|
-
return targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
|
|
2874
|
-
},
|
|
2875
|
-
[detailQuery.data, screen.detailFor, appFeatures],
|
|
2876
|
-
);
|
|
2877
|
-
const buildSectionActions = useCallback(
|
|
2878
|
-
(actions: readonly RowAction[]): readonly RenderEditAction[] | undefined =>
|
|
2879
|
-
buildRecordActions({
|
|
2880
|
-
actions,
|
|
2881
|
-
record: detailQuery.data ?? {},
|
|
2882
|
-
translate: effectiveTranslate,
|
|
2883
|
-
nav,
|
|
2884
|
-
host,
|
|
2885
|
-
dispatcher,
|
|
2886
|
-
openDrawer,
|
|
2887
|
-
onWriteSuccess: detailQuery.refetch,
|
|
2888
|
-
sameEntityScreenId,
|
|
2889
|
-
}),
|
|
2890
|
-
[
|
|
2891
|
-
detailQuery.data,
|
|
2892
|
-
detailQuery.refetch,
|
|
2893
|
-
effectiveTranslate,
|
|
2894
|
-
nav,
|
|
2895
|
-
host,
|
|
2896
|
-
dispatcher,
|
|
2897
|
-
openDrawer,
|
|
2898
|
-
sameEntityScreenId,
|
|
2899
|
-
],
|
|
2900
|
-
);
|
|
2901
|
-
const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
|
|
2902
|
-
const declaredHasEdit = screen.actions?.some((a) => a.id === "edit") === true;
|
|
2903
|
-
const resolved = buildRecordActions({
|
|
2904
|
-
actions: screen.actions ?? [],
|
|
2905
|
-
record: detailQuery.data ?? {},
|
|
2906
|
-
translate: effectiveTranslate,
|
|
2907
|
-
nav,
|
|
2908
|
-
host,
|
|
2909
|
-
dispatcher,
|
|
2910
|
-
openDrawer,
|
|
2911
|
-
onWriteSuccess: detailQuery.refetch,
|
|
2912
|
-
sameEntityScreenId,
|
|
2913
|
-
});
|
|
2914
|
-
if (defaultEditAction === undefined || declaredHasEdit) return resolved;
|
|
2915
|
-
return [defaultEditAction, ...(resolved ?? [])];
|
|
2916
|
-
}, [
|
|
2917
|
-
screen.actions,
|
|
2918
|
-
defaultEditAction,
|
|
2919
|
-
effectiveTranslate,
|
|
2920
|
-
nav,
|
|
2921
|
-
host,
|
|
2922
|
-
dispatcher,
|
|
2923
|
-
detailQuery.data,
|
|
2924
|
-
detailQuery.refetch,
|
|
2925
|
-
openDrawer,
|
|
2926
|
-
sameEntityScreenId,
|
|
2927
|
-
]);
|
|
2928
|
-
|
|
2929
|
-
if (effectiveEntityId === undefined && screen.singleton !== true) {
|
|
2930
|
-
return (
|
|
2931
|
-
<Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
|
|
2932
|
-
Screen <Text variant="code">{screen.id}</Text> (projectionDetail) needs a row id in the path
|
|
2933
|
-
— open it from a row action.
|
|
2934
|
-
</Banner>
|
|
2935
|
-
);
|
|
2936
|
-
}
|
|
2937
|
-
if (detailQuery.loading && detailQuery.data === null) {
|
|
2938
|
-
return (
|
|
2939
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
2940
|
-
Loading…
|
|
2941
|
-
</Banner>
|
|
2942
|
-
);
|
|
2943
|
-
}
|
|
2944
|
-
if (detailQuery.error) {
|
|
2945
|
-
return (
|
|
2946
|
-
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
2947
|
-
{dispatcherErrorText(detailQuery.error, effectiveTranslate)}
|
|
2948
|
-
</Banner>
|
|
2949
|
-
);
|
|
2950
|
-
}
|
|
2951
|
-
const record = detailQuery.data;
|
|
2952
|
-
if (!record) {
|
|
2953
|
-
return (
|
|
2954
|
-
<Banner padded variant="error" testId="kumiko-screen-record-missing">
|
|
2955
|
-
{screen.singleton === true ? (
|
|
2956
|
-
"Record not found."
|
|
2957
|
-
) : (
|
|
2958
|
-
<>
|
|
2959
|
-
Record <Text variant="code">{entityId}</Text> not found.
|
|
2960
|
-
</>
|
|
2961
|
-
)}
|
|
2962
|
-
</Banner>
|
|
2963
|
-
);
|
|
2964
|
-
}
|
|
2965
|
-
const hasHeader = screen.header !== undefined;
|
|
2966
|
-
const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
|
|
2967
|
-
// ?? [] rather than threading `headerActions !== undefined` through every
|
|
2968
|
-
// use below — an empty array is a safe no-op for .map/.length.
|
|
2969
|
-
const headerActionsList = headerActions ?? [];
|
|
2970
|
-
const hasHeaderActions = headerActionsList.length > 0;
|
|
2971
|
-
// Grouped into the head Card alongside title/status/metrics (fw#2713):
|
|
2972
|
-
// these are actions on the record the head shows, not on whichever tab is
|
|
2973
|
-
// open, so they must stay in place across tab switches instead of
|
|
2974
|
-
// trailing the active tab's content in the card footer.
|
|
2975
|
-
const headerActionsContent = hasHeaderActions && (
|
|
2976
|
-
<Grid columns="auto" testId="kumiko-screen-projection-detail-actions">
|
|
2977
|
-
<HeaderActionsBar
|
|
2978
|
-
actions={headerActionsList}
|
|
2979
|
-
Button={Button}
|
|
2980
|
-
Dialog={Dialog}
|
|
2981
|
-
ActionOverflowMenu={ActionOverflowMenu}
|
|
2982
|
-
onError={setActionError}
|
|
2983
|
-
/>
|
|
2984
|
-
</Grid>
|
|
2985
|
-
);
|
|
2986
|
-
const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
|
|
2987
|
-
// Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
|
|
2988
|
-
// this shares the same page padding/width as the record's edit card below
|
|
2989
|
-
// it, instead of sitting flush against the screen edge (fw record-screen
|
|
2990
|
-
// header polish).
|
|
2991
|
-
const header = screen.header;
|
|
2992
|
-
const subtitleHref = resolveSubtitleHref(header, record);
|
|
2993
|
-
// Plain Text, not Heading — the Card title slot already renders as its own
|
|
2994
|
-
// heading element (h3); nesting another heading (h1) inside it is invalid HTML.
|
|
2995
|
-
const headerTitleSlot =
|
|
2996
|
-
header !== undefined ? (
|
|
2997
|
-
header.status !== undefined ? (
|
|
2998
|
-
<Grid columns="auto">
|
|
2999
|
-
<Text testId="kumiko-screen-projection-detail-title">
|
|
3000
|
-
{String(record[header.title] ?? "")}
|
|
3001
|
-
</Text>
|
|
3002
|
-
{StatusBadge !== undefined ? (
|
|
3003
|
-
<StatusBadge
|
|
3004
|
-
value={String(record[header.status] ?? "")}
|
|
3005
|
-
tone={statusToneForValue(String(record[header.status] ?? ""))}
|
|
3006
|
-
testId="kumiko-screen-projection-detail-status"
|
|
3007
|
-
/>
|
|
3008
|
-
) : (
|
|
3009
|
-
<Text testId="kumiko-screen-projection-detail-status">
|
|
3010
|
-
{String(record[header.status] ?? "")}
|
|
3011
|
-
</Text>
|
|
3012
|
-
)}
|
|
3013
|
-
</Grid>
|
|
3014
|
-
) : (
|
|
3015
|
-
<Text testId="kumiko-screen-projection-detail-title">
|
|
3016
|
-
{String(record[header.title] ?? "")}
|
|
3017
|
-
</Text>
|
|
3018
|
-
)
|
|
3019
|
-
) : undefined;
|
|
3020
|
-
const headerSubtitleSlot =
|
|
3021
|
-
header?.subtitle !== undefined ? (
|
|
3022
|
-
subtitleHref !== undefined ? (
|
|
3023
|
-
<Link href={subtitleHref} target="_blank" testId="kumiko-screen-projection-detail-subtitle">
|
|
3024
|
-
{String(record[header.subtitle] ?? "")}
|
|
3025
|
-
</Link>
|
|
3026
|
-
) : (
|
|
3027
|
-
<Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
|
|
3028
|
-
{String(record[header.subtitle] ?? "")}
|
|
3029
|
-
</Text>
|
|
3030
|
-
)
|
|
3031
|
-
) : undefined;
|
|
3032
|
-
// slots.header shares the header card with the actions (one card, actions
|
|
3033
|
-
// top right) instead of rendering as an unframed block above it.
|
|
3034
|
-
const renderHeaderContent = (headerSlot: ReactNode | undefined): ReactNode => (
|
|
3035
|
-
<>
|
|
3036
|
-
{(hasHeaderCard || headerSlot !== undefined) && (
|
|
3037
|
-
<Card
|
|
3038
|
-
slots={{
|
|
3039
|
-
...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
|
|
3040
|
-
...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
|
|
3041
|
-
...(headerSlot !== undefined && { headerContent: headerSlot }),
|
|
3042
|
-
...(hasHeaderActions && { headerActions: headerActionsContent }),
|
|
3043
|
-
}}
|
|
3044
|
-
>
|
|
3045
|
-
{hasMetrics && (
|
|
3046
|
-
<Grid
|
|
3047
|
-
columns={screen.metrics?.length ?? 1}
|
|
3048
|
-
testId="kumiko-screen-projection-detail-metrics"
|
|
3049
|
-
>
|
|
3050
|
-
{screen.metrics?.map((metric) => {
|
|
3051
|
-
const field = metricField(metric);
|
|
3052
|
-
const labelKey = metricLabelKey(metric, screen.fieldLabels);
|
|
3053
|
-
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
|
|
3054
|
-
const value = String(record[field] ?? "");
|
|
3055
|
-
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
3056
|
-
const navigate = metricNavigateSpec(metric);
|
|
3057
|
-
const onPress =
|
|
3058
|
-
navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
3059
|
-
? () => runMetricNavigate(nav, navigate, record, host)
|
|
3060
|
-
: undefined;
|
|
3061
|
-
return Metric !== undefined ? (
|
|
3062
|
-
<Metric
|
|
3063
|
-
key={field}
|
|
3064
|
-
label={label}
|
|
3065
|
-
value={value}
|
|
3066
|
-
testId={testId}
|
|
3067
|
-
{...(onPress !== undefined && { onPress })}
|
|
3068
|
-
/>
|
|
3069
|
-
) : (
|
|
3070
|
-
<GridCell key={field}>
|
|
3071
|
-
<Text variant="small" testId={`${testId}-label`}>
|
|
3072
|
-
{label}
|
|
3073
|
-
</Text>
|
|
3074
|
-
<Text testId={`${testId}-value`}>{value}</Text>
|
|
3075
|
-
</GridCell>
|
|
3076
|
-
);
|
|
3077
|
-
})}
|
|
3078
|
-
</Grid>
|
|
3079
|
-
)}
|
|
3080
|
-
{actionError !== null && (
|
|
3081
|
-
<Banner variant="error" testId="render-edit-action-error">
|
|
3082
|
-
{actionError}
|
|
3083
|
-
</Banner>
|
|
3084
|
-
)}
|
|
3085
|
-
</Card>
|
|
3086
|
-
)}
|
|
3087
|
-
{hasTabs && activeSection !== undefined && (
|
|
3088
|
-
<Tabs
|
|
3089
|
-
testId="kumiko-screen-projection-detail-tabs"
|
|
3090
|
-
items={screen.layout.sections.map((section) => {
|
|
3091
|
-
const countField = isWriteFormEditSection(section) ? undefined : section.countField;
|
|
3092
|
-
const countValue = countField !== undefined ? record[countField] : undefined;
|
|
3093
|
-
const count =
|
|
3094
|
-
typeof countValue === "number" && Number.isFinite(countValue)
|
|
3095
|
-
? countValue
|
|
3096
|
-
: undefined;
|
|
3097
|
-
return {
|
|
3098
|
-
id: section.id ?? "",
|
|
3099
|
-
label: effectiveTranslate(section.title ?? section.id ?? ""),
|
|
3100
|
-
...(count !== undefined && { count }),
|
|
3101
|
-
};
|
|
3102
|
-
})}
|
|
3103
|
-
activeId={activeSection.id ?? ""}
|
|
3104
|
-
onSelect={(id) => nav.setSearchParams({ tab: id })}
|
|
3105
|
-
/>
|
|
3106
|
-
)}
|
|
3107
|
-
</>
|
|
3108
|
-
);
|
|
3109
|
-
return (
|
|
3110
|
-
<>
|
|
3111
|
-
<RenderEdit
|
|
3112
|
-
key={`${entityId}:${reloadNonce}`}
|
|
3113
|
-
screen={detailScreen}
|
|
3114
|
-
entity={entity}
|
|
3115
|
-
featureName={schema.featureName}
|
|
3116
|
-
initial={record as FormValues}
|
|
3117
|
-
entityId={effectiveEntityId}
|
|
3118
|
-
customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
|
|
3119
|
-
onReload={reloadDetail}
|
|
3120
|
-
onRelatedListDrawerAction={openDrawer}
|
|
3121
|
-
{...(translate !== undefined && { translate })}
|
|
3122
|
-
{...(hasTabs && { hideSectionTitles: true })}
|
|
3123
|
-
{...((hasHeaderCard || hasTabs || screen.slots?.header !== undefined) && {
|
|
3124
|
-
headerRegion: renderHeaderContent,
|
|
3125
|
-
})}
|
|
3126
|
-
buildSectionActions={buildSectionActions}
|
|
3127
|
-
valueDisplay={screen.valueDisplay ?? "text"}
|
|
3128
|
-
/>
|
|
3129
|
-
<DrawerHost
|
|
3130
|
-
schema={schema}
|
|
3131
|
-
drawerAction={drawerAction}
|
|
3132
|
-
drawerScreen={drawerScreen}
|
|
3133
|
-
{...(drawerInitialValues !== undefined && { drawerInitialValues })}
|
|
3134
|
-
userRoles={userRoles}
|
|
3135
|
-
{...(translate !== undefined && { translate })}
|
|
3136
|
-
onClose={closeDrawer}
|
|
3137
|
-
onSuccess={() => {
|
|
3138
|
-
closeDrawer();
|
|
3139
|
-
void reloadDetail();
|
|
3140
|
-
}}
|
|
3141
|
-
/>
|
|
3142
|
-
</>
|
|
3143
|
-
);
|
|
3144
|
-
}
|
|
3145
|
-
// ---- actionForm (Tier 2.7d) ----
|
|
3146
|
-
|
|
3147
|
-
function redirectScreenTarget(
|
|
3148
|
-
redirect: NonNullable<ActionFormScreenDefinition["redirect"]>,
|
|
3149
|
-
): string {
|
|
3150
|
-
return typeof redirect === "string" ? redirect : redirect.screen;
|
|
3151
|
-
}
|
|
3152
|
-
|
|
3153
|
-
// Checks this schema first, then every mounted feature — the target may live
|
|
3154
|
-
// in another (cross-feature) feature than the redirect's own screen.
|
|
3155
|
-
function findRedirectTargetScreen(
|
|
3156
|
-
redirect: string | ActionFormRedirect,
|
|
3157
|
-
schema: FeatureSchema,
|
|
3158
|
-
appFeatures: readonly FeatureSchema[],
|
|
3159
|
-
): ScreenDefinition | undefined {
|
|
3160
|
-
const redirectScreen = redirectScreenTarget(redirect);
|
|
3161
|
-
const targetId = lastSegment(redirectScreen);
|
|
3162
|
-
const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
|
|
3163
|
-
return (
|
|
3164
|
-
schema.screens.find((s) => lastSegment(s.id) === targetId) ??
|
|
3165
|
-
(targetFeatureName !== undefined
|
|
3166
|
-
? appFeatures
|
|
3167
|
-
.find((f) => f.featureName === targetFeatureName)
|
|
3168
|
-
?.screens.find((s) => lastSegment(s.id) === targetId)
|
|
3169
|
-
: appFeatures.flatMap((f) => f.screens).find((s) => lastSegment(s.id) === targetId))
|
|
3170
|
-
);
|
|
3171
|
-
}
|
|
3172
|
-
|
|
3173
|
-
// A redirect to a record screen ("show the result") is a deliberate forward
|
|
3174
|
-
// navigation and keeps precedence over returnTo.
|
|
3175
|
-
function redirectTargetsRecord(
|
|
3176
|
-
redirect: string | ActionFormRedirect,
|
|
3177
|
-
schema: FeatureSchema,
|
|
3178
|
-
appFeatures: readonly FeatureSchema[],
|
|
3179
|
-
): boolean {
|
|
3180
|
-
const target = findRedirectTargetScreen(redirect, schema, appFeatures);
|
|
3181
|
-
return (
|
|
3182
|
-
target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail")
|
|
3183
|
-
);
|
|
3184
|
-
}
|
|
3185
|
-
|
|
3186
|
-
// Type-guard form narrows `returnTarget` for the caller's subsequent
|
|
3187
|
-
// navigateToReturn(nav, returnTarget) call.
|
|
3188
|
-
function returnToWinsOverRedirect(
|
|
3189
|
-
returnTarget: ScreenTarget | undefined,
|
|
3190
|
-
redirect: string | ActionFormRedirect,
|
|
3191
|
-
schema: FeatureSchema,
|
|
3192
|
-
appFeatures: readonly FeatureSchema[],
|
|
3193
|
-
): returnTarget is ScreenTarget {
|
|
3194
|
-
return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
// Resolves an object-form redirect to a nav target — shared by
|
|
3198
|
-
// actionForm and entityEdit (create + update) so the id
|
|
3199
|
-
// carries over identically regardless of which screen type triggered it.
|
|
3200
|
-
// `carriesId` gates entityId on the TARGET screen type: a redirect to a
|
|
3201
|
-
// list screen never gets an id attached, matching actionForm's original
|
|
3202
|
-
// behavior. The id itself prefers the write-handler's success payload
|
|
3203
|
-
// (`submittedData`) and falls back to `fallbackRecord` — the entityEdit
|
|
3204
|
-
// update path's already-loaded record, or the create path's just-submitted
|
|
3205
|
-
// form values — both of which carry fields (e.g. a parent FK) the CRUD
|
|
3206
|
-
// write executor's success payload doesn't flatly expose.
|
|
3207
|
-
function resolveRedirectTarget(
|
|
3208
|
-
redirect: string | ActionFormRedirect,
|
|
3209
|
-
submittedData: unknown,
|
|
3210
|
-
schema: FeatureSchema,
|
|
3211
|
-
appFeatures: readonly FeatureSchema[],
|
|
3212
|
-
fallbackRecord?: Readonly<Record<string, unknown>>,
|
|
3213
|
-
): { readonly screenId: string; readonly entityId: string | undefined } {
|
|
3214
|
-
const redirectScreen = redirectScreenTarget(redirect);
|
|
3215
|
-
const idField = typeof redirect === "string" ? "id" : redirect.idFrom;
|
|
3216
|
-
const targetId = lastSegment(redirectScreen);
|
|
3217
|
-
const target = findRedirectTargetScreen(redirect, schema, appFeatures);
|
|
3218
|
-
const carriesId =
|
|
3219
|
-
target !== undefined && (target.type === "entityEdit" || target.type === "projectionDetail");
|
|
3220
|
-
if (!carriesId) return { screenId: targetId, entityId: undefined };
|
|
3221
|
-
const entityId =
|
|
3222
|
-
extractIdField(submittedData, idField) ??
|
|
3223
|
-
(fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
|
|
3224
|
-
return { screenId: targetId, entityId };
|
|
3225
|
-
}
|
|
3226
|
-
|
|
3227
|
-
// Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
|
|
3228
|
-
// RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
|
|
3229
|
-
// Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
|
|
3230
|
-
// Form-Mechanik (useForm, RenderEdit, DefaultInput, Banner, Submit-
|
|
3231
|
-
// Button) ist identisch zu entityEdit, nur der Submit-Pfad wechselt
|
|
3232
|
-
// vom CRUD-verb auf den Author-deklarierten handler-QN +
|
|
3233
|
-
// payloadMode="values" (alle Form-Werte schicken statt nur Changes).
|
|
3234
|
-
function ActionFormBody({
|
|
3235
|
-
schema,
|
|
3236
|
-
screen,
|
|
3237
|
-
translate,
|
|
3238
|
-
initialOverrides,
|
|
3239
|
-
onSuccess,
|
|
3240
|
-
onCancelOverride,
|
|
3241
|
-
}: {
|
|
3242
|
-
readonly schema: FeatureSchema;
|
|
3243
|
-
readonly screen: ActionFormScreenDefinition;
|
|
3244
|
-
readonly translate?: Translate;
|
|
3245
|
-
/** Row-drawer usage (RowAction kind:"drawer", fw#2710): prefill extracted
|
|
3246
|
-
* from the clicked row, applied on top of searchParams through the same
|
|
3247
|
-
* renderableFields/sensitive gates (see mergeSearchParamsIntoInitial). */
|
|
3248
|
-
readonly initialOverrides?: Readonly<Record<string, unknown>>;
|
|
3249
|
-
/** Drawer-hosted usage (toolbarAction kind:"drawer", fw#2225): called
|
|
3250
|
-
* instead of the redirect-based navigation on successful submit, so the
|
|
3251
|
-
* host closes the drawer + refetches its list regardless of whether
|
|
3252
|
-
* `screen.redirect` is set — a full-page redirect would navigate away
|
|
3253
|
-
* from the list the drawer sits on top of. */
|
|
3254
|
-
readonly onSuccess?: () => void;
|
|
3255
|
-
/** Drawer-hosted usage: replaces the cancelTarget/redirect-based Cancel
|
|
3256
|
-
* handler so Cancel closes the drawer instead of navigating. */
|
|
3257
|
-
readonly onCancelOverride?: () => void;
|
|
3258
|
-
}): ReactNode {
|
|
3259
|
-
const nav = useNav();
|
|
3260
|
-
const appFeatures = useAppFeatures();
|
|
3261
|
-
const { Banner } = usePrimitives();
|
|
3262
|
-
// Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
|
|
3263
|
-
const returnTarget = useReturnTarget(screen.id);
|
|
3264
|
-
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
3265
|
-
const synthScreen = useMemo(() => synthesizeActionFormScreen(screen), [screen]);
|
|
3266
|
-
const pendingHandoff = useInitialValuesHandoff(screen.id);
|
|
3267
|
-
// A drawer-hosted form is not a navigation target, so it never takes a handoff.
|
|
3268
|
-
const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
|
|
3269
|
-
// This screen has no entity, so a money field here declares its own currency
|
|
3270
|
-
// source (fw#2839, boot-enforced) — without one the form would seed a bare
|
|
3271
|
-
// `0` the handler's schema rejects. ACTION_FORM_CURRENCY_FALLBACK only
|
|
3272
|
-
// applies when a tenant-declared field's config query errors or holds no
|
|
3273
|
-
// value, matching the entityEdit path's behaviour (fw#2937).
|
|
3274
|
-
const tenantCurrencyFieldNames = useMemo(
|
|
3275
|
-
() => tenantCurrencyMoneyFieldNames(screen.fields),
|
|
3276
|
-
[screen.fields],
|
|
3277
|
-
);
|
|
3278
|
-
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
3279
|
-
screen.fields,
|
|
3280
|
-
tenantCurrencyFieldNames,
|
|
3281
|
-
ACTION_FORM_CURRENCY_FALLBACK,
|
|
3282
|
-
);
|
|
3283
|
-
const initial = useMemo(
|
|
3284
|
-
() =>
|
|
3285
|
-
mergeSearchParamsIntoInitial(screen.fields, {
|
|
3286
|
-
searchParams: nav.searchParams,
|
|
3287
|
-
urlPrefillFields: screen.urlPrefillFields,
|
|
3288
|
-
renderableFields: layoutFieldNames(synthScreen),
|
|
3289
|
-
...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
|
|
3290
|
-
...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
|
|
3291
|
-
...(handoffValues !== undefined && { handoffValues }),
|
|
3292
|
-
}) as FormValues,
|
|
3293
|
-
[
|
|
3294
|
-
screen.fields,
|
|
3295
|
-
screen.urlPrefillFields,
|
|
3296
|
-
nav.searchParams,
|
|
3297
|
-
synthScreen,
|
|
3298
|
-
moneyCurrencyOverrides,
|
|
3299
|
-
initialOverrides,
|
|
3300
|
-
handoffValues,
|
|
3301
|
-
],
|
|
3302
|
-
);
|
|
3303
|
-
const handleSubmitted = useCallback(
|
|
3304
|
-
(result: SubmitResult<unknown>) => {
|
|
3305
|
-
if (!result.isSuccess) return;
|
|
3306
|
-
if (onSuccess !== undefined) {
|
|
3307
|
-
onSuccess();
|
|
3308
|
-
return;
|
|
3309
|
-
}
|
|
3310
|
-
// Without a redirect the form stays put; returnTo only replaces an existing
|
|
3311
|
-
// navigation, and never one to a record screen (see redirectTargetsRecord).
|
|
3312
|
-
if (screen.redirect !== undefined) {
|
|
3313
|
-
if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
|
|
3314
|
-
navigateToReturn(nav, returnTarget);
|
|
3315
|
-
} else {
|
|
3316
|
-
const { screenId, entityId } = resolveRedirectTarget(
|
|
3317
|
-
screen.redirect,
|
|
3318
|
-
result.data,
|
|
3319
|
-
schema,
|
|
3320
|
-
appFeatures,
|
|
3321
|
-
);
|
|
3322
|
-
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
3323
|
-
}
|
|
3324
|
-
}
|
|
3325
|
-
},
|
|
3326
|
-
[nav, screen.redirect, onSuccess, schema, appFeatures, returnTarget],
|
|
3327
|
-
);
|
|
3328
|
-
// Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
|
|
3329
|
-
// sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
|
|
3330
|
-
// gewinnt über redirect; `false` schaltet den Button explizit ab
|
|
3331
|
-
// (Single-Action-Screens, wo Cancel nur Submit-ohne-Senden wäre).
|
|
3332
|
-
// returnTo wins over the declared target when both are present.
|
|
3333
|
-
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
3334
|
-
if (onCancelOverride !== undefined) return onCancelOverride;
|
|
3335
|
-
const target =
|
|
3336
|
-
screen.cancelTarget ??
|
|
3337
|
-
(screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
|
|
3338
|
-
if (target === undefined || target === false) return undefined;
|
|
3339
|
-
return () =>
|
|
3340
|
-
navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
|
|
3341
|
-
}, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
|
|
3342
|
-
// Rendering the form before a tenant-declared currency lands would show the
|
|
3343
|
-
// fallback and, on a fast click, submit it (fw#2933) — hold instead.
|
|
3344
|
-
if (currencyLoading) {
|
|
3345
|
-
return (
|
|
3346
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
3347
|
-
Loading…
|
|
3348
|
-
</Banner>
|
|
3349
|
-
);
|
|
3350
|
-
}
|
|
3351
|
-
return (
|
|
3352
|
-
<RenderEdit
|
|
3353
|
-
screen={synthScreen}
|
|
3354
|
-
entity={synthEntity}
|
|
3355
|
-
featureName={schema.featureName}
|
|
3356
|
-
initial={initial}
|
|
3357
|
-
// Extension-Sections im actionForm sehen die initialen Form-Values
|
|
3358
|
-
// (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
|
|
3359
|
-
// — anders als im entityEdit gibt es hier keinen record, aus dem
|
|
3360
|
-
// sie Kontext ziehen könnten.
|
|
3361
|
-
extensionInitialValues={initial}
|
|
3362
|
-
writeCommand={screen.handler}
|
|
3363
|
-
payloadMode="values"
|
|
3364
|
-
onSubmit={handleSubmitted}
|
|
3365
|
-
{...(handleCancel !== undefined && { onCancel: handleCancel })}
|
|
3366
|
-
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
3367
|
-
{...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle })}
|
|
3368
|
-
{...(translate !== undefined && { translate })}
|
|
3369
|
-
/>
|
|
3370
|
-
);
|
|
3371
|
-
}
|
|
3372
|
-
|
|
3373
|
-
// ---- config-edit ----
|
|
3374
|
-
//
|
|
3375
|
-
// Settings-Form gegen das bundled config-Feature. Liest beim Mount
|
|
3376
|
-
// `config:query:values` (returned ALLE Keys die der User lesen darf
|
|
3377
|
-
// als `{ [qualifiedKey]: { value, scope } }`); schreibt beim Save
|
|
3378
|
-
// pro geändertem Feld einen `config:write:set` Call. Singleton-pro-
|
|
3379
|
-
// Tenant kommt by-design vom config-feature (key+tenantId Unique-
|
|
3380
|
-
// Constraint) — kein Bridge-Hack, keine extra Aggregate.
|
|
3381
|
-
//
|
|
3382
|
-
// Parallel-Aufbau zu ActionFormBody: synthesisierte Entity + EntityEdit-
|
|
3383
|
-
// Screen damit RenderEdit reused werden kann; Layout/Field-Rendering/
|
|
3384
|
-
// Banner/Submit-Button-State sind identisch zu entityEdit. Der einzige
|
|
3385
|
-
// Pfad-Unterschied ist customSubmit das mehrere config:write:set-Calls
|
|
3386
|
-
// orchestriert.
|
|
3387
|
-
//
|
|
3388
|
-
// Response enthält seit config-seeding auch `source` (z.B. "system-row")
|
|
3389
|
-
// für das ConfigSourceBadge. Type wird lokal gehalten da er nur hier
|
|
3390
|
-
// relevant ist — kein eigener Export nötig.
|
|
3391
|
-
type ConfigValueResponse = Readonly<
|
|
3392
|
-
Record<string, { value: string | number | boolean | undefined; scope: string; source: string }>
|
|
3393
|
-
>;
|
|
3394
|
-
|
|
3395
|
-
// A money-typed config-edit field renders through RenderField's entityEdit
|
|
3396
|
-
// `{amount, currency}` payload shape (render-field.tsx, #1923), but
|
|
3397
|
-
// `ConfigKeyType` (write-helpers.ts validateType) only ever knows
|
|
3398
|
-
// number/boolean/text/select — a config value is always a bare scalar.
|
|
3399
|
-
// Unwrap back to the amount before it hits config:write:set.
|
|
3400
|
-
function unwrapMoneyValue(value: unknown): unknown {
|
|
3401
|
-
if (typeof value === "object" && value !== null && "amount" in value) {
|
|
3402
|
-
const amount = (value as { amount?: unknown }).amount;
|
|
3403
|
-
if (typeof amount === "number") return amount;
|
|
3404
|
-
}
|
|
3405
|
-
return value;
|
|
3406
|
-
}
|
|
3407
|
-
|
|
3408
|
-
function ConfigEditBody({
|
|
3409
|
-
schema,
|
|
3410
|
-
screen,
|
|
3411
|
-
translate,
|
|
3412
|
-
}: {
|
|
3413
|
-
readonly schema: FeatureSchema;
|
|
3414
|
-
readonly screen: ConfigEditScreenDefinition;
|
|
3415
|
-
readonly translate?: Translate;
|
|
3416
|
-
}): ReactNode {
|
|
3417
|
-
const { Banner, ConfigCascadeView } = usePrimitives();
|
|
3418
|
-
const t = useTranslation();
|
|
3419
|
-
const effectiveTranslate = translate ?? t;
|
|
3420
|
-
const dispatcher = useDispatcher();
|
|
3421
|
-
|
|
3422
|
-
// Detail-Load: config:query:values returnt ALLE Keys des Tenants.
|
|
3423
|
-
// Wir mappen via screen.configKeys von short → qualified-name auf
|
|
3424
|
-
// unsere Form-Field-Werte.
|
|
3425
|
-
const valuesQuery = useQuery<ConfigValueResponse>("config:query:values", {});
|
|
3426
|
-
|
|
3427
|
-
const cascadeQuery = useQuery<Record<string, ConfigCascade>>("config:query:cascade", {});
|
|
3428
|
-
|
|
3429
|
-
const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
|
|
3430
|
-
const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
|
|
3431
|
-
|
|
3432
|
-
// Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
|
|
3433
|
-
// abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
|
|
3434
|
-
// wir den Field-Default (createTextField/createNumberField/...).
|
|
3435
|
-
// String-coerce nur für Text-Fields; andere Types sollten in der
|
|
3436
|
-
// Response bereits Native-Type sein.
|
|
3437
|
-
const initial = useMemo<FormValues | null>(() => {
|
|
3438
|
-
if (valuesQuery.data === null) return null;
|
|
3439
|
-
const out: Record<string, unknown> = {};
|
|
3440
|
-
const defaults = buildInitialValues(screen.fields) as Record<string, unknown>; // @cast-boundary render-helper
|
|
3441
|
-
for (const [shortName, fieldDef] of Object.entries(screen.fields)) {
|
|
3442
|
-
const qualified = screen.configKeys[shortName];
|
|
3443
|
-
if (qualified === undefined) {
|
|
3444
|
-
// Author hat ein Field deklariert ohne Mapping — nimm Default.
|
|
3445
|
-
// Boot-Validator pinnt das, sollte nie zur Runtime greifen.
|
|
3446
|
-
out[shortName] = defaults[shortName];
|
|
3447
|
-
continue;
|
|
3448
|
-
}
|
|
3449
|
-
const stored = valuesQuery.data[qualified]?.value;
|
|
3450
|
-
if (stored === undefined) {
|
|
3451
|
-
out[shortName] = defaults[shortName];
|
|
3452
|
-
continue;
|
|
3453
|
-
}
|
|
3454
|
-
const ftype = (fieldDef as { type?: string }).type;
|
|
3455
|
-
// Field-Type-Coercion: config-Werte sind string|number|boolean,
|
|
3456
|
-
// aber der Form-State erwartet das passende Field-Native-Type.
|
|
3457
|
-
if (ftype === "number" || ftype === "money") {
|
|
3458
|
-
out[shortName] = typeof stored === "number" ? stored : Number(stored);
|
|
3459
|
-
} else if (ftype === "boolean") {
|
|
3460
|
-
out[shortName] = typeof stored === "boolean" ? stored : stored === "true";
|
|
3461
|
-
} else {
|
|
3462
|
-
out[shortName] = typeof stored === "string" ? stored : String(stored);
|
|
3463
|
-
}
|
|
3464
|
-
}
|
|
3465
|
-
return out as FormValues;
|
|
3466
|
-
}, [valuesQuery.data, screen.fields, screen.configKeys]);
|
|
3467
|
-
|
|
3468
|
-
// Cascade-Lookup: qualifiedKey → ConfigCascade für die
|
|
3469
|
-
// Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
|
|
3470
|
-
// damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
|
|
3471
|
-
// `config:query:values` mocken) nicht durch das Cascade-Rendering
|
|
3472
|
-
// crashen.
|
|
3473
|
-
const cascades = useMemo<Record<string, ConfigCascade>>(() => {
|
|
3474
|
-
if (!cascadeQuery.data) return {};
|
|
3475
|
-
const out: Record<string, ConfigCascade> = {};
|
|
3476
|
-
for (const [shortName, qualified] of Object.entries(screen.configKeys)) {
|
|
3477
|
-
const cascade = cascadeQuery.data[qualified];
|
|
3478
|
-
if (cascade && Array.isArray(cascade.levels)) out[shortName] = cascade;
|
|
3479
|
-
}
|
|
3480
|
-
return out;
|
|
3481
|
-
}, [cascadeQuery.data, screen.configKeys]);
|
|
3482
|
-
|
|
3483
|
-
// Multi-Write Submit: ein einzelner /api/batch Call mit N
|
|
3484
|
-
// config:write:set Commands. Server-side ist batch atomic
|
|
3485
|
-
// (transaktional: alle Writes in einer DB-TX, all-or-nothing) und
|
|
3486
|
-
// browser-side ist es genau eine HTTP-Roundtrip — kein Race zwischen
|
|
3487
|
-
// mehreren in-flight fetches die der Browser bei page.reload mid-
|
|
3488
|
-
// submit aborten könnte. Promise.all von N separaten dispatcher.write-
|
|
3489
|
-
// Calls war fragil: server bekommt + commited alle N, aber das
|
|
3490
|
-
// Browser-Connection-Pool gibt sporadisch "Failed to fetch" für
|
|
3491
|
-
// einzelne Responses zurück, customSubmit returnt failure obwohl der
|
|
3492
|
-
// Write durch ist, das Form bleibt dirty.
|
|
3493
|
-
const customSubmit = useCallback(
|
|
3494
|
-
async (snapshot: FormSnapshot<FormValues>): Promise<SubmitResult<unknown>> => {
|
|
3495
|
-
const commands: Command[] = [];
|
|
3496
|
-
for (const [shortName, value] of Object.entries(snapshot.changes)) {
|
|
3497
|
-
const qualified = screen.configKeys[shortName];
|
|
3498
|
-
if (qualified === undefined) continue;
|
|
3499
|
-
const ftype = (screen.fields[shortName] as { type?: string } | undefined)?.type;
|
|
3500
|
-
commands.push({
|
|
3501
|
-
type: "config:write:set",
|
|
3502
|
-
payload: {
|
|
3503
|
-
key: qualified,
|
|
3504
|
-
value: ftype === "money" ? unwrapMoneyValue(value) : value,
|
|
3505
|
-
scope: screen.scope,
|
|
3506
|
-
},
|
|
3507
|
-
});
|
|
3508
|
-
}
|
|
3509
|
-
if (commands.length === 0) {
|
|
3510
|
-
return { validationBlocked: false, isSuccess: true, data: undefined };
|
|
3511
|
-
}
|
|
3512
|
-
const result = await dispatcher.batch(commands);
|
|
3513
|
-
if (!result.isSuccess) {
|
|
3514
|
-
return { validationBlocked: false, isSuccess: false, error: result.error };
|
|
3515
|
-
}
|
|
3516
|
-
// Cascade-Disclosure + Maskenwerte nach dem Write nachziehen, sonst
|
|
3517
|
-
// bleibt die Anzeige stale bis Reload — gleiche refetch-Calls wie onReset.
|
|
3518
|
-
// allSettled: der Write ist schon committed, ein gescheitertes Refetch
|
|
3519
|
-
// (stale Anzeige) darf das Erfolgsergebnis nicht in einen Fehler kippen.
|
|
3520
|
-
await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
|
|
3521
|
-
return { validationBlocked: false, isSuccess: true, data: undefined };
|
|
3522
|
-
},
|
|
3523
|
-
[
|
|
3524
|
-
dispatcher,
|
|
3525
|
-
screen.configKeys,
|
|
3526
|
-
screen.fields,
|
|
3527
|
-
screen.scope,
|
|
3528
|
-
valuesQuery.refetch,
|
|
3529
|
-
cascadeQuery.refetch,
|
|
3530
|
-
],
|
|
3531
|
-
);
|
|
3532
|
-
|
|
3533
|
-
// Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
|
|
3534
|
-
// unter dem Input — zwei getrennte Render-Slots teilen sich den
|
|
3535
|
-
// expanded-State, der daher hier (pro Feld) statt in der Komponente lebt.
|
|
3536
|
-
const [expandedFields, setExpandedFields] = useState<ReadonlySet<string>>(new Set());
|
|
3537
|
-
const toggleExpanded = useCallback((fieldName: string) => {
|
|
3538
|
-
setExpandedFields((prev) => {
|
|
3539
|
-
const next = new Set(prev);
|
|
3540
|
-
if (next.has(fieldName)) next.delete(fieldName);
|
|
3541
|
-
else next.add(fieldName);
|
|
3542
|
-
return next;
|
|
3543
|
-
});
|
|
3544
|
-
}, []);
|
|
3545
|
-
|
|
3546
|
-
if (valuesQuery.loading && valuesQuery.data === null) {
|
|
3547
|
-
return (
|
|
3548
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
3549
|
-
Loading…
|
|
3550
|
-
</Banner>
|
|
3551
|
-
);
|
|
3552
|
-
}
|
|
3553
|
-
if (valuesQuery.error) {
|
|
3554
|
-
return (
|
|
3555
|
-
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
3556
|
-
{dispatcherErrorText(valuesQuery.error, effectiveTranslate)}
|
|
3557
|
-
</Banner>
|
|
3558
|
-
);
|
|
3559
|
-
}
|
|
3560
|
-
if (initial === null) {
|
|
3561
|
-
return (
|
|
3562
|
-
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
3563
|
-
Loading…
|
|
3564
|
-
</Banner>
|
|
3565
|
-
);
|
|
3566
|
-
}
|
|
3567
|
-
return (
|
|
3568
|
-
<RenderEdit
|
|
3569
|
-
screen={synthScreen}
|
|
3570
|
-
entity={synthEntity}
|
|
3571
|
-
featureName={schema.featureName}
|
|
3572
|
-
initial={initial}
|
|
3573
|
-
customSubmit={customSubmit}
|
|
3574
|
-
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
3575
|
-
{...(translate !== undefined && { translate })}
|
|
3576
|
-
labelAppendix={(fieldName: string) => {
|
|
3577
|
-
const cascade = cascades[fieldName];
|
|
3578
|
-
if (cascade === undefined) return undefined;
|
|
3579
|
-
return (
|
|
3580
|
-
<ConfigCascadeView
|
|
3581
|
-
slot="trigger"
|
|
3582
|
-
cascade={cascade}
|
|
3583
|
-
screenScope={screen.scope}
|
|
3584
|
-
expanded={expandedFields.has(fieldName)}
|
|
3585
|
-
onToggle={() => toggleExpanded(fieldName)}
|
|
3586
|
-
/>
|
|
3587
|
-
);
|
|
3588
|
-
}}
|
|
3589
|
-
fieldAppendix={(fieldName: string) => {
|
|
3590
|
-
const cascade = cascades[fieldName];
|
|
3591
|
-
// Panel nur rendern wenn aufgeklappt — sonst kein leerer Abstand
|
|
3592
|
-
// unter dem Input.
|
|
3593
|
-
if (cascade === undefined || !expandedFields.has(fieldName)) return undefined;
|
|
3594
|
-
return (
|
|
3595
|
-
<ConfigCascadeView
|
|
3596
|
-
slot="panel"
|
|
3597
|
-
cascade={cascade}
|
|
3598
|
-
screenScope={screen.scope}
|
|
3599
|
-
expanded
|
|
3600
|
-
qualifiedKey={screen.configKeys[fieldName]}
|
|
3601
|
-
onReset={async (key, scope) => {
|
|
3602
|
-
await dispatcher.write("config:write:reset", { key, scope });
|
|
3603
|
-
await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
|
|
3604
|
-
}}
|
|
3605
|
-
/>
|
|
3606
|
-
);
|
|
3607
|
-
}}
|
|
3608
|
-
/>
|
|
3609
|
-
);
|
|
3610
|
-
}
|
|
3611
|
-
|
|
3612
|
-
// Re-export the ScreenDefinition type so callers don't reach into
|
|
3613
|
-
// framework/ui-types for a prop-type they need to narrow on.
|
|
3614
|
-
export type { ScreenDefinition };
|