@cosmicdrift/kumiko-renderer 0.334.0 → 0.335.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/content-preview.js +7 -9
- package/dist/app/extension-sections.d.ts +6 -0
- package/dist/app/form-schema.d.ts +2 -1
- package/dist/app/form-schema.js +11 -2
- package/dist/app/kumiko-screen.d.ts +5 -0
- package/dist/app/kumiko-screen.js +196 -65
- package/dist/app/nav.d.ts +5 -0
- package/dist/app/nav.js +17 -4
- package/dist/app/reference-facet-bridge.js +8 -1
- package/dist/app/row-actions.d.ts +12 -0
- package/dist/app/row-actions.js +24 -7
- package/dist/app/screen-access.js +13 -9
- package/dist/app/secret-mint-body.js +40 -6
- package/dist/app/secrets-edit-body.js +71 -44
- package/dist/components/embedded-list-field.js +2 -4
- package/dist/components/multi-select-checkboxes.js +4 -1
- package/dist/components/reference-create-dialog.js +9 -4
- package/dist/components/related-list-section.js +7 -5
- package/dist/components/render-edit-action-button.d.ts +1 -1
- package/dist/components/render-edit-action-button.js +5 -1
- package/dist/components/render-edit-logic.d.ts +2 -1
- package/dist/components/render-edit-logic.js +8 -5
- package/dist/components/render-edit-types.d.ts +10 -1
- package/dist/components/render-edit.js +187 -53
- package/dist/components/render-field.js +10 -13
- package/dist/components/render-list.js +6 -6
- package/dist/components/write-form-section.js +12 -20
- package/dist/hooks/use-reference-lookup.d.ts +7 -0
- package/dist/hooks/use-reference-lookup.js +10 -0
- package/dist/i18n.js +8 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/primitives.d.ts +16 -7
- package/dist/session/session-ended.d.ts +16 -0
- package/dist/session/session-ended.js +73 -0
- package/dist/sort-by-accessor.d.ts +6 -2
- package/dist/sort-by-accessor.js +13 -3
- package/package.json +5 -5
- package/src/changes.json +40 -0
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Read-only render of a content editor's format with the collection's
|
|
3
|
+
// example variableSchema values substituted in for `{{name}}` — reuses the
|
|
4
|
+
// same registered editor the collection edits with (readOnly), so "rich"
|
|
5
|
+
// renders formatted HTML and "plain"/"markdown" render as text through the
|
|
6
|
+
// exact same component, no separate render path per format.
|
|
7
|
+
import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
2
8
|
import { useId } from "react";
|
|
3
9
|
import { useContentEditor } from "./content-editors.js";
|
|
4
10
|
const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
|
|
@@ -8,14 +14,6 @@ const noop = () => { };
|
|
|
8
14
|
export function substituteVariables(content, variables) {
|
|
9
15
|
return content.replace(VARIABLE_PATTERN, (match, name) => Object.hasOwn(variables, name) ? (variables[name] ?? match) : match);
|
|
10
16
|
}
|
|
11
|
-
function escapeHtml(value) {
|
|
12
|
-
return value
|
|
13
|
-
.replace(/&/g, "&")
|
|
14
|
-
.replace(/</g, "<")
|
|
15
|
-
.replace(/>/g, ">")
|
|
16
|
-
.replace(/"/g, """)
|
|
17
|
-
.replace(/'/g, "'");
|
|
18
|
-
}
|
|
19
17
|
export function ContentPreview({ content, variables, contentFormat, }) {
|
|
20
18
|
const Editor = useContentEditor(contentFormat);
|
|
21
19
|
const id = useId();
|
|
@@ -24,7 +22,7 @@ export function ContentPreview({ content, variables, contentFormat, }) {
|
|
|
24
22
|
// render as an unescaped entity (`&`). "plain"/"markdown" content is text,
|
|
25
23
|
// no escaping wanted there.
|
|
26
24
|
const safeVariables = contentFormat === "rich"
|
|
27
|
-
? Object.fromEntries(Object.entries(variables).map(([name, value]) => [name,
|
|
25
|
+
? Object.fromEntries(Object.entries(variables).map(([name, value]) => [name, escapeHtmlAttr(value)]))
|
|
28
26
|
: variables;
|
|
29
27
|
return (_jsx(Editor, { id: id, value: substituteVariables(content, safeVariables), onChange: noop, variables: [], readOnly: true }));
|
|
30
28
|
}
|
|
@@ -55,6 +55,12 @@ export type ExtensionSectionProps = {
|
|
|
55
55
|
readonly index: number;
|
|
56
56
|
readonly isLast: boolean;
|
|
57
57
|
};
|
|
58
|
+
/** Only set for extension steps of a `layout.mode: "wizard"` entityEdit
|
|
59
|
+
* editing an existing record (update mode); undefined everywhere else.
|
|
60
|
+
* Call it (typically from an effect) with whether the step already holds
|
|
61
|
+
* its data, so the step bar shows the step as done. Repeated calls with
|
|
62
|
+
* the same value are cheap. */
|
|
63
|
+
readonly reportStepComplete?: (complete: boolean) => void;
|
|
58
64
|
};
|
|
59
65
|
export type ExtensionSectionComponent = ComponentType<ExtensionSectionProps>;
|
|
60
66
|
export type ExtensionSectionsMap = Readonly<Record<string, ExtensionSectionComponent>>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
export declare function isPresent(value: unknown): boolean;
|
|
3
4
|
export declare const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
|
|
4
|
-
export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition): z.ZodType;
|
|
5
|
+
export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
|
package/dist/app/form-schema.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as z from "zod";
|
|
|
4
4
|
import { layoutEditFields } from "./layout-fields.js";
|
|
5
5
|
// `required` means "has a value", not "is truthy" — `false` and `0` count
|
|
6
6
|
// as present, only the actually-empty representations don't.
|
|
7
|
-
function isPresent(value) {
|
|
7
|
+
export function isPresent(value) {
|
|
8
8
|
if (value === undefined || value === null || value === "")
|
|
9
9
|
return false;
|
|
10
10
|
if (Array.isArray(value) && value.length === 0)
|
|
@@ -39,7 +39,12 @@ export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
|
|
|
39
39
|
// checked here: `visible`. `runValidate` already filters issues on hidden
|
|
40
40
|
// fields via `computeFieldStates(options.fields, …)` (form-controller.ts:163,181),
|
|
41
41
|
// fed by `deriveFormFields(screen)` in render-edit.tsx.
|
|
42
|
-
|
|
42
|
+
//
|
|
43
|
+
// `unchangedFrom` is for payloadMode "changes" (update): a field still equal to
|
|
44
|
+
// its loaded value never reaches the payload, so the server never presence-
|
|
45
|
+
// checks it — a legacy row with a now-required empty field must stay saveable
|
|
46
|
+
// when the user edits something else.
|
|
47
|
+
export function buildFormSchema(entity, screen, unchangedFrom) {
|
|
43
48
|
const fields = layoutEditFields(screen);
|
|
44
49
|
return z
|
|
45
50
|
.object({})
|
|
@@ -73,6 +78,10 @@ export function buildFormSchema(entity, screen) {
|
|
|
73
78
|
continue;
|
|
74
79
|
if (isPresent(record[spec.field]))
|
|
75
80
|
continue;
|
|
81
|
+
if (unchangedFrom !== undefined &&
|
|
82
|
+
JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field])) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
76
85
|
ctx.addIssue({
|
|
77
86
|
code: "custom",
|
|
78
87
|
path: [spec.field],
|
|
@@ -20,7 +20,12 @@ export declare function qualifyNavId(featureName: string, navId: string): string
|
|
|
20
20
|
export { screenAccessAllows };
|
|
21
21
|
export declare function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCopyLink, }: KumikoScreenProps): ReactNode;
|
|
22
22
|
export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): Readonly<Record<string, unknown>>;
|
|
23
|
+
export declare function tenantCurrencyMoneyFieldNames(fields: Readonly<Record<string, unknown>>): readonly string[];
|
|
23
24
|
export declare function literalCurrencyOverrides(fields: Readonly<Record<string, unknown>>): Readonly<Record<string, string>>;
|
|
25
|
+
export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string): {
|
|
26
|
+
readonly overrides: Readonly<Record<string, string>> | undefined;
|
|
27
|
+
readonly loading: boolean;
|
|
28
|
+
};
|
|
24
29
|
export type InitialValueSources = {
|
|
25
30
|
readonly searchParams: Readonly<Record<string, string>>;
|
|
26
31
|
readonly urlPrefillFields: readonly string[] | undefined;
|
|
@@ -37,7 +37,7 @@ import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-scr
|
|
|
37
37
|
import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
|
|
38
38
|
import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
|
|
39
39
|
import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
|
|
40
|
-
import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
|
|
40
|
+
import { buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
|
|
41
41
|
import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access.js";
|
|
42
42
|
import { SecretMintBody } from "./secret-mint-body.js";
|
|
43
43
|
import { SecretsEditBody } from "./secrets-edit-body.js";
|
|
@@ -135,16 +135,19 @@ function entityWriteCommand(featureName, entity, verb) {
|
|
|
135
135
|
// entityList-Screen für die Entity und navigiert dahin. Wird von
|
|
136
136
|
// Create/Update/Delete genauso verwendet — alle drei haben "fertig
|
|
137
137
|
// editiert, raus hier" als sinnvolles Default-Verhalten.
|
|
138
|
+
function findEntityListScreen(schema, entityName) {
|
|
139
|
+
return schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
|
|
140
|
+
}
|
|
138
141
|
function useNavigateToListAfter(schema, entityName) {
|
|
139
142
|
const nav = useNav();
|
|
140
143
|
return useCallback(() => {
|
|
141
|
-
const list = schema
|
|
144
|
+
const list = findEntityListScreen(schema, entityName);
|
|
142
145
|
if (!list)
|
|
143
146
|
return;
|
|
144
147
|
// schema.screens.id ist QN-form (registry-stamped); nav.navigate
|
|
145
148
|
// erwartet Short-Form. Sonst landet die URL doppelt-qualifiziert.
|
|
146
149
|
nav.navigate({ screenId: lastSegment(list.id) });
|
|
147
|
-
}, [nav, schema
|
|
150
|
+
}, [nav, schema, entityName]);
|
|
148
151
|
}
|
|
149
152
|
// Default "+ Neu"-Navigation für die List-Toolbar: findet den ersten
|
|
150
153
|
// entityEdit-Screen ohne entityId-Anhang und navigiert dorthin.
|
|
@@ -223,7 +226,7 @@ export function buildInitialValues(fields, defaultCurrency, moneyCurrencyOverrid
|
|
|
223
226
|
// client-safe FieldDefinition subset, not the concrete MoneyFieldDef/
|
|
224
227
|
// MoneyCurrencySource types, same idiom as the other field-shape narrowings
|
|
225
228
|
// in this file (PrefillFieldShape etc.).
|
|
226
|
-
function tenantCurrencyMoneyFieldNames(fields) {
|
|
229
|
+
export function tenantCurrencyMoneyFieldNames(fields) {
|
|
227
230
|
const names = [];
|
|
228
231
|
for (const [name, def] of Object.entries(fields)) {
|
|
229
232
|
const shape = def;
|
|
@@ -251,6 +254,7 @@ function resolveTenantCurrency(fieldNames, query, fallback) {
|
|
|
251
254
|
// so a tenant-declared field whose config query errors or returns no value
|
|
252
255
|
// lands here — same last resort as the entityEdit path's `?? "EUR"`.
|
|
253
256
|
const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
|
|
257
|
+
const HEADER_CARD_DEFAULT_WIDTH = "4xl";
|
|
254
258
|
// Shared by the update payload shape and the server-error path prefix so both can't drift.
|
|
255
259
|
const UPDATE_CHANGES_KEY = "changes";
|
|
256
260
|
// A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
|
|
@@ -274,7 +278,7 @@ export function literalCurrencyOverrides(fields) {
|
|
|
274
278
|
// on a fast click submit, the wrong currency. Callers pass the tenant field
|
|
275
279
|
// names themselves because an edit form only needs the fetch for fields the
|
|
276
280
|
// record has no stored value for.
|
|
277
|
-
function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
|
|
281
|
+
export function useMoneyCurrencyOverrides(fields, tenantFieldNames, fallback) {
|
|
278
282
|
const query = useQuery("config:query:values", {}, { enabled: tenantFieldNames.length > 0 });
|
|
279
283
|
const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
|
|
280
284
|
const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
|
|
@@ -481,7 +485,11 @@ export function mergeSearchParamsIntoInitial(fields, sources) {
|
|
|
481
485
|
continue;
|
|
482
486
|
if (shape.format === "password")
|
|
483
487
|
continue;
|
|
484
|
-
|
|
488
|
+
// An `undefined` override (row-extractor key whose row lacks the field)
|
|
489
|
+
// must not wipe the field default.
|
|
490
|
+
if (drawerOverrides !== undefined &&
|
|
491
|
+
Object.hasOwn(drawerOverrides, name) &&
|
|
492
|
+
drawerOverrides[name] !== undefined) {
|
|
485
493
|
merged[name] = drawerOverrides[name];
|
|
486
494
|
continue;
|
|
487
495
|
}
|
|
@@ -662,7 +670,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
662
670
|
}
|
|
663
671
|
return out;
|
|
664
672
|
}, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
|
|
665
|
-
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
673
|
+
const formSchema = useMemo(() => buildFormSchema(entity, screen, initial), [entity, screen, initial]);
|
|
666
674
|
// Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
|
|
667
675
|
// damit sie beim Edit den Bestand zeigen statt write-only zu sein.
|
|
668
676
|
const extensionInitialValues = useMemo(() => {
|
|
@@ -695,6 +703,24 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
695
703
|
// pattern as ProjectionDetailBody.headerActions above (the edited record
|
|
696
704
|
// stands in for the "row"), minus the cross-feature defaultEditAction
|
|
697
705
|
// lookup that doesn't apply here (this screen already IS the edit form).
|
|
706
|
+
const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
|
|
707
|
+
// Never return onto the just-deleted record.
|
|
708
|
+
const target = deletedShownRecord && returnTarget?.entityId === entityId ? undefined : returnTarget;
|
|
709
|
+
if (action.redirect !== undefined) {
|
|
710
|
+
navigateAfterRedirect({
|
|
711
|
+
nav,
|
|
712
|
+
schema,
|
|
713
|
+
appFeatures,
|
|
714
|
+
returnTarget: target,
|
|
715
|
+
redirect: action.redirect,
|
|
716
|
+
resultData,
|
|
717
|
+
record,
|
|
718
|
+
});
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
navigateToReturnOr(nav, target, navigateToList);
|
|
722
|
+
onDeleted?.();
|
|
723
|
+
}, [nav, schema, appFeatures, returnTarget, record, entityId, navigateToList, onDeleted]);
|
|
698
724
|
const buildSectionActions = useCallback((actions) => buildRecordActions({
|
|
699
725
|
actions,
|
|
700
726
|
record,
|
|
@@ -704,33 +730,38 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
704
730
|
dispatcher,
|
|
705
731
|
openDrawer,
|
|
706
732
|
onWriteSuccess: onReload,
|
|
733
|
+
onRecordLeft: handleRecordLeft,
|
|
707
734
|
defaultScreenTargetEntityId: entityId,
|
|
708
|
-
|
|
735
|
+
defaultWritePayloadId: entityId,
|
|
736
|
+
}), [
|
|
737
|
+
record,
|
|
738
|
+
effectiveTranslate,
|
|
739
|
+
nav,
|
|
740
|
+
host,
|
|
741
|
+
dispatcher,
|
|
742
|
+
openDrawer,
|
|
743
|
+
onReload,
|
|
744
|
+
handleRecordLeft,
|
|
745
|
+
entityId,
|
|
746
|
+
]);
|
|
709
747
|
const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
|
|
710
748
|
const handleSubmitted = useCallback((result) => {
|
|
711
749
|
if (!result.isSuccess)
|
|
712
750
|
return;
|
|
713
751
|
const redirect = screen.redirect;
|
|
714
752
|
if (redirect !== undefined) {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
const { screenId, entityId: targetEntityId } = resolveRedirectTarget(redirect, result.data, schema, appFeatures, record);
|
|
729
|
-
nav.navigate({
|
|
730
|
-
screenId,
|
|
731
|
-
...(targetEntityId !== undefined && { entityId: targetEntityId }),
|
|
732
|
-
});
|
|
733
|
-
}
|
|
753
|
+
// The update handler's success payload usually reports only this
|
|
754
|
+
// record's own id (event-store-executor-write.ts), so a parent FK
|
|
755
|
+
// named by `idFrom` falls back to the already-loaded `record`.
|
|
756
|
+
navigateAfterRedirect({
|
|
757
|
+
nav,
|
|
758
|
+
schema,
|
|
759
|
+
appFeatures,
|
|
760
|
+
returnTarget,
|
|
761
|
+
redirect,
|
|
762
|
+
resultData: result.data,
|
|
763
|
+
record: overlayUpdatedProjection(record, result.data),
|
|
764
|
+
});
|
|
734
765
|
}
|
|
735
766
|
else {
|
|
736
767
|
navigateToReturnOr(nav, returnTarget, navigateToList);
|
|
@@ -1236,26 +1267,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1236
1267
|
};
|
|
1237
1268
|
}
|
|
1238
1269
|
if (action.kind === "drawer") {
|
|
1239
|
-
|
|
1240
|
-
const actionVisible = action.visible;
|
|
1241
|
-
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
1242
|
-
return {
|
|
1243
|
-
id: action.id,
|
|
1244
|
-
label: effectiveTranslate(action.label),
|
|
1245
|
-
...(action.style !== undefined && { style: action.style }),
|
|
1246
|
-
...(action.display !== undefined && { display: action.display }),
|
|
1247
|
-
confirmRequired: false,
|
|
1248
|
-
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1249
|
-
onTrigger: (row) => {
|
|
1250
|
-
const initialValues = drawerAction.params !== undefined
|
|
1251
|
-
? evalRowExtractor(drawerAction.params, row.values)
|
|
1252
|
-
: undefined;
|
|
1253
|
-
openDrawer(drawerAction, initialValues);
|
|
1254
|
-
},
|
|
1255
|
-
...(actionVisible !== undefined && {
|
|
1256
|
-
isVisible: (row) => evalFieldCondition(actionVisible, row.values),
|
|
1257
|
-
}),
|
|
1258
|
-
};
|
|
1270
|
+
return buildDrawerRowAction(action, effectiveTranslate, openDrawer);
|
|
1259
1271
|
}
|
|
1260
1272
|
if (dispatcher === undefined)
|
|
1261
1273
|
return null;
|
|
@@ -1840,7 +1852,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1840
1852
|
// useQuery call re-runs too (it has no `live` subscription of its own).
|
|
1841
1853
|
const [reloadNonce, setReloadNonce] = useState(0);
|
|
1842
1854
|
const reloadDetail = useCallback(async () => {
|
|
1843
|
-
await detailQuery.refetch
|
|
1855
|
+
await refetchAfterWrite(detailQuery.refetch);
|
|
1844
1856
|
setReloadNonce((n) => n + 1);
|
|
1845
1857
|
}, [detailQuery.refetch]);
|
|
1846
1858
|
// Header actions render directly via RenderEditActionButton (fw#2713,
|
|
@@ -1859,7 +1871,49 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1859
1871
|
const userRoles = useUserRoles();
|
|
1860
1872
|
const dispatcher = useOptionalDispatcher();
|
|
1861
1873
|
const host = useReturnHost();
|
|
1874
|
+
const returnTargetParam = useReturnTarget(screen.id);
|
|
1875
|
+
// A singleton has no record route to come back to — same as entityEdit.
|
|
1876
|
+
const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
|
|
1862
1877
|
const { drawerAction, drawerScreen, drawerInitialValues, openDrawer, closeDrawer } = useDrawerAction(schema);
|
|
1878
|
+
const handleRecordLeft = useCallback((action, resultData, deletedShownRecord) => {
|
|
1879
|
+
const record = detailQuery.data ?? {};
|
|
1880
|
+
// Never return onto the just-deleted record.
|
|
1881
|
+
const target = deletedShownRecord && returnTarget?.entityId === effectiveEntityId
|
|
1882
|
+
? undefined
|
|
1883
|
+
: returnTarget;
|
|
1884
|
+
if (action.redirect !== undefined) {
|
|
1885
|
+
navigateAfterRedirect({
|
|
1886
|
+
nav,
|
|
1887
|
+
schema,
|
|
1888
|
+
appFeatures,
|
|
1889
|
+
returnTarget: target,
|
|
1890
|
+
redirect: action.redirect,
|
|
1891
|
+
resultData,
|
|
1892
|
+
record,
|
|
1893
|
+
});
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
navigateToReturnOr(nav, target, () => {
|
|
1897
|
+
const listScreen = screen.listScreenId ??
|
|
1898
|
+
(screen.detailFor !== undefined
|
|
1899
|
+
? findEntityListScreen(schema, screen.detailFor)?.id
|
|
1900
|
+
: undefined);
|
|
1901
|
+
if (listScreen !== undefined)
|
|
1902
|
+
nav.navigate({ screenId: lastSegment(listScreen) });
|
|
1903
|
+
else
|
|
1904
|
+
void detailQuery.refetch();
|
|
1905
|
+
});
|
|
1906
|
+
}, [
|
|
1907
|
+
detailQuery.data,
|
|
1908
|
+
detailQuery.refetch,
|
|
1909
|
+
nav,
|
|
1910
|
+
schema,
|
|
1911
|
+
appFeatures,
|
|
1912
|
+
returnTarget,
|
|
1913
|
+
effectiveEntityId,
|
|
1914
|
+
screen.listScreenId,
|
|
1915
|
+
screen.detailFor,
|
|
1916
|
+
]);
|
|
1863
1917
|
const editScreen = useMemo(() => {
|
|
1864
1918
|
const detailFor = screen.detailFor;
|
|
1865
1919
|
if (detailFor === undefined)
|
|
@@ -1907,6 +1961,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1907
1961
|
dispatcher,
|
|
1908
1962
|
openDrawer,
|
|
1909
1963
|
onWriteSuccess: detailQuery.refetch,
|
|
1964
|
+
onRecordLeft: handleRecordLeft,
|
|
1910
1965
|
sameEntityScreenId,
|
|
1911
1966
|
}), [
|
|
1912
1967
|
detailQuery.data,
|
|
@@ -1916,6 +1971,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1916
1971
|
host,
|
|
1917
1972
|
dispatcher,
|
|
1918
1973
|
openDrawer,
|
|
1974
|
+
handleRecordLeft,
|
|
1919
1975
|
sameEntityScreenId,
|
|
1920
1976
|
]);
|
|
1921
1977
|
const headerActions = useMemo(() => {
|
|
@@ -1929,6 +1985,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1929
1985
|
dispatcher,
|
|
1930
1986
|
openDrawer,
|
|
1931
1987
|
onWriteSuccess: detailQuery.refetch,
|
|
1988
|
+
onRecordLeft: handleRecordLeft,
|
|
1932
1989
|
sameEntityScreenId,
|
|
1933
1990
|
});
|
|
1934
1991
|
if (defaultEditAction === undefined || declaredHasEdit)
|
|
@@ -1944,8 +2001,25 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1944
2001
|
detailQuery.data,
|
|
1945
2002
|
detailQuery.refetch,
|
|
1946
2003
|
openDrawer,
|
|
2004
|
+
handleRecordLeft,
|
|
1947
2005
|
sameEntityScreenId,
|
|
1948
2006
|
]);
|
|
2007
|
+
const hasHeader = screen.header !== undefined;
|
|
2008
|
+
const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
|
|
2009
|
+
// ?? [] rather than threading `headerActions !== undefined` through every
|
|
2010
|
+
// use below — an empty array is a safe no-op for .map/.length.
|
|
2011
|
+
const headerActionsList = headerActions ?? [];
|
|
2012
|
+
const hasHeaderActions = headerActionsList.length > 0;
|
|
2013
|
+
const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
|
|
2014
|
+
// A header card (metric band, status, actions) needs more room than the
|
|
2015
|
+
// 640px form column; screens with only a record title keep the narrow one.
|
|
2016
|
+
const widensScreenFormColumn = hasHeaderCard &&
|
|
2017
|
+
!hasTabs &&
|
|
2018
|
+
screenFillsHeight(screen) &&
|
|
2019
|
+
detailScreen.layout.width === undefined;
|
|
2020
|
+
const renderedDetailScreen = useMemo(() => widensScreenFormColumn
|
|
2021
|
+
? { ...detailScreen, layout: { ...detailScreen.layout, width: HEADER_CARD_DEFAULT_WIDTH } }
|
|
2022
|
+
: detailScreen, [detailScreen, widensScreenFormColumn]);
|
|
1949
2023
|
if (effectiveEntityId === undefined && screen.singleton !== true) {
|
|
1950
2024
|
return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-projection-detail-missing-id", children: ["Screen ", _jsx(Text, { variant: "code", children: screen.id }), " (projectionDetail) needs a row id in the path \u2014 open it from a row action."] }));
|
|
1951
2025
|
}
|
|
@@ -1959,18 +2033,11 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1959
2033
|
if (!record) {
|
|
1960
2034
|
return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: screen.singleton === true ? ("Record not found.") : (_jsxs(_Fragment, { children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] })) }));
|
|
1961
2035
|
}
|
|
1962
|
-
const hasHeader = screen.header !== undefined;
|
|
1963
|
-
const hasMetrics = screen.metrics !== undefined && screen.metrics.length > 0;
|
|
1964
|
-
// ?? [] rather than threading `headerActions !== undefined` through every
|
|
1965
|
-
// use below — an empty array is a safe no-op for .map/.length.
|
|
1966
|
-
const headerActionsList = headerActions ?? [];
|
|
1967
|
-
const hasHeaderActions = headerActionsList.length > 0;
|
|
1968
2036
|
// Grouped into the head Card alongside title/status/metrics (fw#2713):
|
|
1969
2037
|
// these are actions on the record the head shows, not on whichever tab is
|
|
1970
2038
|
// open, so they must stay in place across tab switches instead of
|
|
1971
2039
|
// trailing the active tab's content in the card footer.
|
|
1972
2040
|
const headerActionsContent = hasHeaderActions && (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError }) }));
|
|
1973
|
-
const hasHeaderCard = hasHeader || hasMetrics || hasHeaderActions;
|
|
1974
2041
|
// Rendered as RenderEdit's headerRegion (not as JSX siblings before it) so
|
|
1975
2042
|
// this shares the same page padding/width as the record's edit card below
|
|
1976
2043
|
// it, instead of sitting flush against the screen edge (fw record-screen
|
|
@@ -2032,7 +2099,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2032
2099
|
...(count !== undefined && { count }),
|
|
2033
2100
|
};
|
|
2034
2101
|
}), activeId: activeSection.id ?? "", onSelect: (id) => nav.setSearchParams({ tab: id }) }))] }));
|
|
2035
|
-
return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen:
|
|
2102
|
+
return (_jsxs(_Fragment, { children: [_jsx(RenderEdit, { screen: renderedDetailScreen, entity: entity, featureName: schema.featureName, initial: record, entityId: effectiveEntityId, customSubmit: async () => ({ isSuccess: true, validationBlocked: false, data: undefined }), onReload: reloadDetail, onRelatedListDrawerAction: openDrawer, ...(translate !== undefined && { translate }), ...(hasTabs && { hideSectionTitles: true }), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...((hasHeaderCard ||
|
|
2036
2103
|
hasTabs ||
|
|
2037
2104
|
screen.slots?.header !== undefined ||
|
|
2038
2105
|
screen.recordTitleField !== undefined) && {
|
|
@@ -2046,18 +2113,24 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2046
2113
|
function redirectScreenTarget(redirect) {
|
|
2047
2114
|
return typeof redirect === "string" ? redirect : redirect.screen;
|
|
2048
2115
|
}
|
|
2049
|
-
//
|
|
2050
|
-
//
|
|
2116
|
+
// A qualified redirect names its feature, so the search space is THAT feature
|
|
2117
|
+
// only — the current schema's own screens must not shadow it with a same
|
|
2118
|
+
// short-id screen of another type. An unqualified redirect searches this
|
|
2119
|
+
// schema first (own screen wins), then every mounted feature. A named feature
|
|
2120
|
+
// that isn't mounted (no provider / isolated test) falls back to this schema.
|
|
2051
2121
|
function findRedirectTargetScreen(redirect, schema, appFeatures) {
|
|
2052
2122
|
const redirectScreen = redirectScreenTarget(redirect);
|
|
2053
2123
|
const targetId = lastSegment(redirectScreen);
|
|
2054
2124
|
const targetFeatureName = featureNameFromQualifiedScreenId(redirectScreen);
|
|
2055
|
-
|
|
2056
|
-
(
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2125
|
+
const targetFeature = targetFeatureName !== undefined
|
|
2126
|
+
? appFeatures.find((f) => f.featureName === targetFeatureName)
|
|
2127
|
+
: undefined;
|
|
2128
|
+
const searchSpace = targetFeature !== undefined
|
|
2129
|
+
? targetFeature.screens
|
|
2130
|
+
: targetFeatureName === undefined
|
|
2131
|
+
? [...schema.screens, ...appFeatures.flatMap((f) => f.screens)]
|
|
2132
|
+
: schema.screens;
|
|
2133
|
+
return searchSpace.find((s) => lastSegment(s.id) === targetId);
|
|
2061
2134
|
}
|
|
2062
2135
|
// A redirect to a record screen ("show the result") is a deliberate forward
|
|
2063
2136
|
// navigation and keeps precedence over returnTo.
|
|
@@ -2092,6 +2165,33 @@ function resolveRedirectTarget(redirect, submittedData, schema, appFeatures, fal
|
|
|
2092
2165
|
(fallbackRecord !== undefined ? extractIdField(fallbackRecord, idField) : undefined);
|
|
2093
2166
|
return { screenId: targetId, entityId };
|
|
2094
2167
|
}
|
|
2168
|
+
// Success navigation for a configured redirect — shared by entityEdit's
|
|
2169
|
+
// save and writeHandler record actions. The string form navigates without an
|
|
2170
|
+
// entityId; the object form resolves like actionForm's, with `record` as the
|
|
2171
|
+
// fallback id source.
|
|
2172
|
+
function navigateAfterRedirect(options) {
|
|
2173
|
+
const { nav, schema, appFeatures, returnTarget, redirect, resultData, record } = options;
|
|
2174
|
+
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
2175
|
+
navigateToReturn(nav, returnTarget);
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
if (typeof redirect === "string") {
|
|
2179
|
+
nav.navigate({ screenId: lastSegment(redirect) });
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
const { screenId, entityId } = resolveRedirectTarget(redirect, resultData, schema, appFeatures, record);
|
|
2183
|
+
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
2184
|
+
}
|
|
2185
|
+
// The update payload nests the saved projection under `data`; a changed
|
|
2186
|
+
// parent FK lives there, not in the pre-edit `record`.
|
|
2187
|
+
function overlayUpdatedProjection(record, resultData) {
|
|
2188
|
+
if (typeof resultData !== "object" || resultData === null)
|
|
2189
|
+
return record;
|
|
2190
|
+
const projection = resultData.data;
|
|
2191
|
+
if (typeof projection !== "object" || projection === null)
|
|
2192
|
+
return record;
|
|
2193
|
+
return { ...record, ...projection };
|
|
2194
|
+
}
|
|
2095
2195
|
// Action-Form-Body — non-CRUD Write-Handler-driven Form. Re-uses
|
|
2096
2196
|
// RenderEdit über synthetisierte EntityDefinition + EntityEditScreen-
|
|
2097
2197
|
// Definition (siehe action-form-shim.ts für die Schulden-Doku). Die
|
|
@@ -2203,6 +2303,12 @@ const CONFIG_SCOPE_SOURCE = {
|
|
|
2203
2303
|
tenant: "tenant-row",
|
|
2204
2304
|
system: "system-row",
|
|
2205
2305
|
};
|
|
2306
|
+
function isWriteOnlyTextField(fieldDef) {
|
|
2307
|
+
return fieldDef?.type === "text" && fieldDef.writeOnly === true;
|
|
2308
|
+
}
|
|
2309
|
+
function hasStoredValue(stored) {
|
|
2310
|
+
return stored !== undefined && stored !== null && stored !== "";
|
|
2311
|
+
}
|
|
2206
2312
|
function ConfigEditBody({ schema, screen, translate, }) {
|
|
2207
2313
|
const { ConfigCascadeView } = usePrimitives();
|
|
2208
2314
|
const t = useTranslation();
|
|
@@ -2235,6 +2341,15 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2235
2341
|
continue;
|
|
2236
2342
|
}
|
|
2237
2343
|
const stored = valuesQuery.data[qualified]?.value;
|
|
2344
|
+
// The read carries a mask (or nothing); only whether a value exists is
|
|
2345
|
+
// meaningful, so the field gets the writeOnly wire shape true / null.
|
|
2346
|
+
// The read is the effective value: an inherited one is not "set" on this
|
|
2347
|
+
// scope, and a reset here would remove nothing.
|
|
2348
|
+
if (isWriteOnlyTextField(fieldDef)) {
|
|
2349
|
+
const ownedByThisScope = valuesQuery.data[qualified]?.source === CONFIG_SCOPE_SOURCE[screen.scope];
|
|
2350
|
+
out[shortName] = ownedByThisScope && hasStoredValue(stored) ? true : null;
|
|
2351
|
+
continue;
|
|
2352
|
+
}
|
|
2238
2353
|
if (stored === undefined) {
|
|
2239
2354
|
out[shortName] = defaults[shortName];
|
|
2240
2355
|
continue;
|
|
@@ -2253,7 +2368,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2253
2368
|
}
|
|
2254
2369
|
}
|
|
2255
2370
|
return out;
|
|
2256
|
-
}, [valuesQuery.data, screen.fields, screen.configKeys]);
|
|
2371
|
+
}, [valuesQuery.data, screen.fields, screen.configKeys, screen.scope]);
|
|
2257
2372
|
// Cascade-Lookup: qualifiedKey → ConfigCascade für die
|
|
2258
2373
|
// Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
|
|
2259
2374
|
// damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
|
|
@@ -2288,6 +2403,22 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2288
2403
|
continue;
|
|
2289
2404
|
const fieldDef = screen.fields[shortName];
|
|
2290
2405
|
const ftype = fieldDef?.type;
|
|
2406
|
+
if (isWriteOnlyTextField(screen.fields[shortName])) {
|
|
2407
|
+
// true (untouched/undone) and "" mean keep the stored secret; null removes it.
|
|
2408
|
+
if (value === null) {
|
|
2409
|
+
commands.push({
|
|
2410
|
+
type: "config:write:reset",
|
|
2411
|
+
payload: { key: qualified, scope: screen.scope },
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
else if (typeof value === "string" && value !== "") {
|
|
2415
|
+
commands.push({
|
|
2416
|
+
type: "config:write:set",
|
|
2417
|
+
payload: { key: qualified, value, scope: screen.scope },
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
2291
2422
|
// A query-backed select is cleared to "" when its dependent value became
|
|
2292
2423
|
// invalid. config:write:set would store that as an empty override (the
|
|
2293
2424
|
// badge shows "Mandant" with no value); reset falls back to the inherited
|
package/dist/app/nav.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export type NavApi = {
|
|
|
47
47
|
};
|
|
48
48
|
export declare function parsePath(pathname: string, hasWorkspaces?: boolean): NavRoute | undefined;
|
|
49
49
|
export declare function formatPath(target: ScreenTarget): string;
|
|
50
|
+
export declare function findDetailForScreen(features: readonly FeatureSchema[], entity: string): {
|
|
51
|
+
readonly featureName: string;
|
|
52
|
+
readonly screenId: string;
|
|
53
|
+
readonly screen: FeatureSchema["screens"][number];
|
|
54
|
+
} | undefined;
|
|
50
55
|
/** Non-throwing existence check for findDetailForScreen — lets a caller pick
|
|
51
56
|
* a different default (fw#2164's row-click fallback) instead of resolving. */
|
|
52
57
|
export declare function hasDetailScreen(features: readonly FeatureSchema[], entity: string): boolean;
|
package/dist/app/nav.js
CHANGED
|
@@ -9,8 +9,21 @@ import { lastSegment } from "./qn.js";
|
|
|
9
9
|
// Alles nach den ersten 2/3 Segmenten wird ignoriert — kumikos
|
|
10
10
|
// Navigation-Grammatik nested nicht weiter. Nested-Routes wäre eine
|
|
11
11
|
// Spec-Änderung, nicht ein URL-Shape-Unfall.
|
|
12
|
+
function decodeSegments(segments) {
|
|
13
|
+
try {
|
|
14
|
+
return segments.map((segment) => decodeURIComponent(segment));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Malformed percent-escape ("%zz") — treat the URL as no route.
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
12
21
|
export function parsePath(pathname, hasWorkspaces) {
|
|
13
|
-
const
|
|
22
|
+
const rawParts = pathname.split("/").filter((p) => p !== "");
|
|
23
|
+
// Only the segments the grammar uses are decoded; ignored trailing ones may be junk.
|
|
24
|
+
const parts = decodeSegments(rawParts.slice(0, hasWorkspaces === true ? 3 : 2));
|
|
25
|
+
if (parts === undefined)
|
|
26
|
+
return undefined;
|
|
14
27
|
if (hasWorkspaces === true) {
|
|
15
28
|
const [workspaceId, screenId, entityId] = parts;
|
|
16
29
|
if (workspaceId === undefined || workspaceId === "")
|
|
@@ -41,17 +54,17 @@ export function formatPath(target) {
|
|
|
41
54
|
segments.push(target.screenId);
|
|
42
55
|
if (target.entityId !== undefined)
|
|
43
56
|
segments.push(target.entityId);
|
|
44
|
-
return `/${segments.join("/")}`;
|
|
57
|
+
return `/${segments.map((segment) => encodeURIComponent(segment)).join("/")}`;
|
|
45
58
|
}
|
|
46
59
|
// Shared entity→detailFor-screen lookup — resolveTarget's ObjectTarget
|
|
47
60
|
// branch and hasDetailScreen (create-app's row-click default, fw#2164) both
|
|
48
61
|
// need "is there a detail screen for this entity", one to resolve it, the
|
|
49
62
|
// other to just check before falling back to a different default.
|
|
50
|
-
function findDetailForScreen(features, entity) {
|
|
63
|
+
export function findDetailForScreen(features, entity) {
|
|
51
64
|
for (const feature of features) {
|
|
52
65
|
for (const screen of feature.screens) {
|
|
53
66
|
if (screen.detailFor === entity)
|
|
54
|
-
return { featureName: feature.featureName, screenId: screen.id };
|
|
67
|
+
return { featureName: feature.featureName, screenId: screen.id, screen };
|
|
55
68
|
}
|
|
56
69
|
}
|
|
57
70
|
return undefined;
|
|
@@ -2,14 +2,21 @@ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
// One bridge component per reference facet, mirroring ReferenceLookupBridge —
|
|
3
3
|
// facet count varies per screen, so the hook can't be called in a loop.
|
|
4
4
|
import { useEffect } from "react";
|
|
5
|
+
import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
|
|
5
6
|
import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
|
|
6
7
|
function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, onOptions, }) {
|
|
7
8
|
const lookup = useReferenceLookup(refFeature, refEntity, labelField);
|
|
8
9
|
useEffect(() => {
|
|
9
10
|
if (lookup.loading)
|
|
10
11
|
return;
|
|
12
|
+
// The lookup is one capped list fetch; a full page means the facet may be
|
|
13
|
+
// missing values the user wants to filter by.
|
|
14
|
+
if (lookup.map.size >= REFERENCE_LIST_LOOKUP_LIMIT) {
|
|
15
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning, facet options are silently truncated
|
|
16
|
+
console.warn(`[kumiko] reference facet "${field}" shows only the first ${REFERENCE_LIST_LOOKUP_LIMIT} ${refEntity} rows; further values cannot be filtered by`);
|
|
17
|
+
}
|
|
11
18
|
onOptions(field, [...lookup.map.entries()].map(([value, label]) => ({ value, label })));
|
|
12
|
-
}, [lookup.loading, lookup.map, field, onOptions]);
|
|
19
|
+
}, [lookup.loading, lookup.map, field, refEntity, onOptions]);
|
|
13
20
|
return null;
|
|
14
21
|
}
|
|
15
22
|
// One invisible bridge per "reference" facet spec, each publishing its
|