@cosmicdrift/kumiko-renderer 0.332.0 → 0.334.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/config-edit-shim.d.ts +7 -0
- package/dist/app/config-edit-shim.js +39 -0
- package/dist/app/facet-count-bridge.d.ts +10 -0
- package/dist/app/facet-count-bridge.js +20 -0
- package/dist/app/kumiko-screen.js +103 -42
- package/dist/app/list-facets.d.ts +14 -2
- package/dist/app/list-facets.js +74 -0
- package/dist/app/query-state-banners.d.ts +8 -0
- package/dist/app/query-state-banners.js +14 -0
- package/dist/app/row-actions.js +50 -40
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/app/secrets-edit-body.js +46 -17
- package/dist/components/grid-cell-for-field.d.ts +7 -2
- package/dist/components/grid-cell-for-field.js +2 -2
- package/dist/components/query-options-select.d.ts +12 -3
- package/dist/components/query-options-select.js +45 -2
- package/dist/components/related-list-section.js +41 -8
- package/dist/components/render-edit-types.d.ts +24 -1
- package/dist/components/render-edit.js +100 -28
- package/dist/components/render-field.d.ts +7 -1
- package/dist/components/render-field.js +32 -4
- package/dist/components/render-list.d.ts +4 -1
- package/dist/components/render-list.js +2 -2
- package/dist/components/write-form-section.d.ts +5 -1
- package/dist/components/write-form-section.js +17 -5
- package/dist/context/embedded-screen-context.d.ts +5 -0
- package/dist/context/embedded-screen-context.js +12 -0
- package/dist/hooks/use-list-url-state.d.ts +2 -1
- package/dist/hooks/use-list-url-state.js +23 -9
- package/dist/i18n-defaults.js +31 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +95 -13
- package/package.json +5 -5
- package/src/changes.json +7 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConfigEditScreenDefinition, EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import * as z from "zod";
|
|
2
3
|
/** Baut eine minimale EntityDefinition aus den Inline-Fields des
|
|
3
4
|
* ConfigEdit-Screens. RenderEdit + computeEditViewModel iterieren
|
|
4
5
|
* über entity.fields zur Render-Zeit; alle weiteren EntityDefinition-
|
|
@@ -9,3 +10,9 @@ export declare function synthesizeConfigEditEntity(fields: ConfigEditScreenDefin
|
|
|
9
10
|
* damit der Type-Constraint hält; entity wird auf den Pseudo-Namen
|
|
10
11
|
* gepinnt — RenderEdit liest das Feld nicht. */
|
|
11
12
|
export declare function synthesizeConfigEditScreen(screen: ConfigEditScreenDefinition): EntityEditScreenDefinition;
|
|
13
|
+
/** Client-side range check for number keys with declared bounds, so an
|
|
14
|
+
* out-of-range value surfaces on its field instead of as the server's
|
|
15
|
+
* form-level "Validation failed." banner. Only bounds: required/format stay
|
|
16
|
+
* server-authoritative like in buildFormSchema. `.passthrough()` keeps every
|
|
17
|
+
* form key visible to the superRefine. */
|
|
18
|
+
export declare function buildConfigEditSchema(fields: ConfigEditScreenDefinition["fields"]): z.ZodType;
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// zukünftiger Boot-Validator die schema.entities-Map cross-referenziert,
|
|
13
13
|
// brechen die Type-Lies hier silent. Dann ist Zeit für eine echte
|
|
14
14
|
// RenderConfigEdit-Komponente die direkt fields nimmt.
|
|
15
|
+
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
16
|
+
import * as z from "zod";
|
|
15
17
|
const CONFIG_EDIT_PSEUDO_ENTITY = "__config-edit__";
|
|
16
18
|
/** Baut eine minimale EntityDefinition aus den Inline-Fields des
|
|
17
19
|
* ConfigEdit-Screens. RenderEdit + computeEditViewModel iterieren
|
|
@@ -35,3 +37,40 @@ export function synthesizeConfigEditScreen(screen) {
|
|
|
35
37
|
...(screen.access !== undefined && { access: screen.access }),
|
|
36
38
|
};
|
|
37
39
|
}
|
|
40
|
+
/** Client-side range check for number keys with declared bounds, so an
|
|
41
|
+
* out-of-range value surfaces on its field instead of as the server's
|
|
42
|
+
* form-level "Validation failed." banner. Only bounds: required/format stay
|
|
43
|
+
* server-authoritative like in buildFormSchema. `.passthrough()` keeps every
|
|
44
|
+
* form key visible to the superRefine. */
|
|
45
|
+
export function buildConfigEditSchema(fields) {
|
|
46
|
+
return z
|
|
47
|
+
.object({})
|
|
48
|
+
.passthrough()
|
|
49
|
+
.superRefine((values, ctx) => {
|
|
50
|
+
// @cast-boundary form-values
|
|
51
|
+
const record = values;
|
|
52
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
53
|
+
if (field.type !== "number")
|
|
54
|
+
continue;
|
|
55
|
+
const value = record[name];
|
|
56
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
57
|
+
continue;
|
|
58
|
+
if (field.min !== undefined && value < field.min) {
|
|
59
|
+
ctx.addIssue({
|
|
60
|
+
code: "custom",
|
|
61
|
+
minimum: field.min,
|
|
62
|
+
params: { [I18N_KEY_PARAM]: "kumiko.validation.number.min" },
|
|
63
|
+
path: [name],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (field.max !== undefined && value > field.max) {
|
|
67
|
+
ctx.addIssue({
|
|
68
|
+
code: "custom",
|
|
69
|
+
maximum: field.max,
|
|
70
|
+
params: { [I18N_KEY_PARAM]: "kumiko.validation.number.max" },
|
|
71
|
+
path: [name],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type FacetCountQuery = {
|
|
3
|
+
readonly key: string;
|
|
4
|
+
readonly payload: Readonly<Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
export declare function FacetCountBridges({ queryType, queries, onCount, }: {
|
|
7
|
+
readonly queryType: string;
|
|
8
|
+
readonly queries: readonly FacetCountQuery[];
|
|
9
|
+
readonly onCount: (key: string, count: number) => void;
|
|
10
|
+
}): ReactNode;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// One invisible bridge per facet chip, mirroring ReferenceFacetBridges — the
|
|
3
|
+
// chip count varies per screen, so useQuery can't be called in a loop. Each
|
|
4
|
+
// bridge asks the list query for a single row plus `total`.
|
|
5
|
+
// ponytail: one extra list query per chip, refetched on every list
|
|
6
|
+
// invalidation; a server-side facet-count handler replaces this if it hurts.
|
|
7
|
+
import { useEffect } from "react";
|
|
8
|
+
import { useQuery } from "../hooks/use-query.js";
|
|
9
|
+
function FacetCountBridge({ countKey, queryType, payload, onCount, }) {
|
|
10
|
+
const result = useQuery(queryType, payload);
|
|
11
|
+
const total = result.data?.total;
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (total !== undefined)
|
|
14
|
+
onCount(countKey, total);
|
|
15
|
+
}, [total, countKey, onCount]);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
export function FacetCountBridges({ queryType, queries, onCount, }) {
|
|
19
|
+
return (_jsx(_Fragment, { children: queries.map((query) => (_jsx(FacetCountBridge, { countKey: query.key, queryType: queryType, payload: query.payload, onCount: onCount }, query.key))) }));
|
|
20
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { buildOptionLabels, computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
5
5
|
import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
|
|
6
6
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
|
|
7
8
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
|
|
8
9
|
import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
|
|
9
10
|
import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
|
|
10
11
|
import { RenderList } from "../components/render-list.js";
|
|
11
12
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
13
|
+
import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
|
|
12
14
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
13
15
|
import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
|
|
14
16
|
import { useQuery } from "../hooks/use-query.js";
|
|
@@ -19,18 +21,20 @@ import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../
|
|
|
19
21
|
import { screenFillsHeight } from "../screen-fills-height.js";
|
|
20
22
|
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim.js";
|
|
21
23
|
import { useAppFeatures } from "./app-features-context.js";
|
|
22
|
-
import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim.js";
|
|
24
|
+
import { buildConfigEditSchema, synthesizeConfigEditEntity, synthesizeConfigEditScreen, } from "./config-edit-shim.js";
|
|
23
25
|
import { useCustomScreenComponent } from "./custom-screens.js";
|
|
24
26
|
import { useDashboardBody } from "./dashboard-body.js";
|
|
25
27
|
import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
|
|
28
|
+
import { FacetCountBridges } from "./facet-count-bridge.js";
|
|
26
29
|
import { buildFormSchema } from "./form-schema.js";
|
|
27
30
|
import { layoutFieldNames } from "./layout-fields.js";
|
|
28
|
-
import { buildDateRangePayload, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
|
|
31
|
+
import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
|
|
29
32
|
import { useInitialValuesHandoff, useNav } from "./nav.js";
|
|
30
33
|
import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
|
|
31
34
|
import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim.js";
|
|
32
35
|
import { lastSegment, toKebab } from "./qn.js";
|
|
33
36
|
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id.js";
|
|
37
|
+
import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
|
|
34
38
|
import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
|
|
35
39
|
import { navigateToReturn, navigateToReturnOr, navigateWithReturnTo, ReturnHostProvider, useReturnHost, useReturnTarget, } from "./return-to.js";
|
|
36
40
|
import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, evalRowExtractor, isWriteHandlerRowAction, navigateActionSearchParams, refetchAfterWrite, runProjectionRowNavigate, } from "./row-actions.js";
|
|
@@ -319,10 +323,13 @@ function formatSummaryValue(value, locale) {
|
|
|
319
323
|
}
|
|
320
324
|
return value === null || value === undefined ? "" : String(value);
|
|
321
325
|
}
|
|
326
|
+
function formatSummaryParams(prefill, locale) {
|
|
327
|
+
return Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
|
|
328
|
+
}
|
|
322
329
|
function resolveActionFormSummary(summary, prefill, translate, locale) {
|
|
323
330
|
if (summary === undefined)
|
|
324
331
|
return undefined;
|
|
325
|
-
const params =
|
|
332
|
+
const params = formatSummaryParams(prefill, locale);
|
|
326
333
|
return {
|
|
327
334
|
title: translate(summary.title, params),
|
|
328
335
|
...(summary.subtitle !== undefined && { subtitle: translate(summary.subtitle, params) }),
|
|
@@ -906,6 +913,7 @@ function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, u
|
|
|
906
913
|
const { Drawer, Banner, Text, Dialog } = usePrimitives();
|
|
907
914
|
const t = useTranslation();
|
|
908
915
|
const effectiveTranslate = translate ?? t;
|
|
916
|
+
const locale = useLocale().locale();
|
|
909
917
|
const [hasUnsavedInput, setHasUnsavedInput] = useState(false);
|
|
910
918
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
|
|
911
919
|
const closeAndReset = useCallback(() => {
|
|
@@ -937,10 +945,13 @@ function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, u
|
|
|
937
945
|
// "Access denied" state KumikoScreen's top-level gate would show for a
|
|
938
946
|
// direct hit on that screen, never the form.
|
|
939
947
|
const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
|
|
948
|
+
const titleParams = formatSummaryParams(drawerInitialValues, locale);
|
|
940
949
|
return (_jsxs(_Fragment, { children: [_jsx(Drawer, { open: true, onOpenChange: (open) => {
|
|
941
950
|
if (!open)
|
|
942
951
|
requestClose();
|
|
943
|
-
}, title: effectiveTranslate(drawerAction.
|
|
952
|
+
}, title: effectiveTranslate(drawerAction.title ?? drawerAction.label, titleParams), ...(drawerAction.subtitle !== undefined && {
|
|
953
|
+
subtitle: effectiveTranslate(drawerAction.subtitle, titleParams),
|
|
954
|
+
}), testId: `toolbar-drawer-${drawerAction.id}`, children: _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(InsideDrawerProvider, { value: true, children: drawerScreen === undefined ? (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-toolbar-drawer-not-found", children: ["Screen not found: ", _jsx(Text, { variant: "code", children: drawerAction.screen })] })) : !allowed ? (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-toolbar-drawer-access-denied", children: ["Access denied: ", _jsx(Text, { variant: "code", children: drawerAction.screen })] })) : (_jsx(ActionFormBody, { schema: schema, screen: drawerScreen, ...(translate !== undefined && { translate }), ...(drawerInitialValues !== undefined && {
|
|
944
955
|
initialOverrides: drawerInitialValues,
|
|
945
956
|
}), onSuccess: () => {
|
|
946
957
|
setHasUnsavedInput(false);
|
|
@@ -959,6 +970,7 @@ function EntityListScreen({ schema, screen, translate, onRowClick, }) {
|
|
|
959
970
|
return (_jsx(EntityListBody, { schema: schema, screen: screen, entity: entity, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
|
|
960
971
|
}
|
|
961
972
|
function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
973
|
+
const isEmbeddedScreen = useIsEmbeddedScreen();
|
|
962
974
|
const featureName = schema.featureName;
|
|
963
975
|
const onCreate = useNavigateToCreateFor(schema, screen.entity, screen.createScreen);
|
|
964
976
|
const { Banner } = usePrimitives();
|
|
@@ -974,7 +986,8 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
974
986
|
// damit zwei Lists auf derselben Route nicht über dieselben
|
|
975
987
|
// Query-Keys streiten. Default-Sort aus der Screen-Def gewinnt nur
|
|
976
988
|
// wenn URL keinen sort hat — Author-Default vs User-Choice.
|
|
977
|
-
const
|
|
989
|
+
const defaultFilters = useMemo(() => normalizeDefaultFilters(screen.defaultFilters), [screen.defaultFilters]);
|
|
990
|
+
const urlState = useListUrlState(screen.id, defaultFilters);
|
|
978
991
|
const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
|
|
979
992
|
const limit = urlState.pageSize ?? screen.pageSize ?? 50;
|
|
980
993
|
const paginationMode = screen.pagination ?? "pages";
|
|
@@ -1109,7 +1122,30 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1109
1122
|
const entityFacetSpecs = useMemo(() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate), [entity.fields, featureName, screen.entity, effectiveTranslate]);
|
|
1110
1123
|
const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
|
|
1111
1124
|
const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
|
|
1112
|
-
const
|
|
1125
|
+
const [facetCounts, setFacetCounts] = useState({});
|
|
1126
|
+
const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
|
|
1127
|
+
const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
|
|
1128
|
+
const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
|
|
1129
|
+
const filters = buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type);
|
|
1130
|
+
return {
|
|
1131
|
+
key: request.key,
|
|
1132
|
+
payload: {
|
|
1133
|
+
limit: 1,
|
|
1134
|
+
totalCount: true,
|
|
1135
|
+
...(urlState.q !== "" && { search: urlState.q }),
|
|
1136
|
+
...(screen.filter !== undefined && { filter: screen.filter }),
|
|
1137
|
+
...(filters.length > 0 && { filters }),
|
|
1138
|
+
},
|
|
1139
|
+
};
|
|
1140
|
+
}), [
|
|
1141
|
+
entityFacetSpecs,
|
|
1142
|
+
screen.facets,
|
|
1143
|
+
screen.filter,
|
|
1144
|
+
effectiveTranslate,
|
|
1145
|
+
urlState.filters,
|
|
1146
|
+
urlState.q,
|
|
1147
|
+
entity.fields,
|
|
1148
|
+
]);
|
|
1113
1149
|
// Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
|
|
1114
1150
|
// bleibt rowActions undefined statt zu crashen. Echte Apps haben
|
|
1115
1151
|
// den Provider via createKumikoApp — wenn nicht, ist es vermutlich
|
|
@@ -1189,6 +1225,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1189
1225
|
id: action.id,
|
|
1190
1226
|
label: effectiveTranslate(action.label),
|
|
1191
1227
|
...(action.style !== undefined && { style: action.style }),
|
|
1228
|
+
...(action.display !== undefined && { display: action.display }),
|
|
1192
1229
|
confirmRequired: false,
|
|
1193
1230
|
...(navigateAction.rowClick === true && { rowClick: true }),
|
|
1194
1231
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
@@ -1206,6 +1243,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1206
1243
|
id: action.id,
|
|
1207
1244
|
label: effectiveTranslate(action.label),
|
|
1208
1245
|
...(action.style !== undefined && { style: action.style }),
|
|
1246
|
+
...(action.display !== undefined && { display: action.display }),
|
|
1209
1247
|
confirmRequired: false,
|
|
1210
1248
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1211
1249
|
onTrigger: (row) => {
|
|
@@ -1358,7 +1396,9 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1358
1396
|
// Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
|
|
1359
1397
|
// useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
|
|
1360
1398
|
const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
|
|
1361
|
-
return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort,
|
|
1399
|
+
return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, onOptions: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount }), _jsx(RenderList, { screen: screen, entity: entity, rows: renderRows, featureName: featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
|
|
1400
|
+
? { screenPadding: false, chromeless: true }
|
|
1401
|
+
: { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
|
|
1362
1402
|
onReachEnd: loadMore,
|
|
1363
1403
|
loadingMore: rowsQuery.loading,
|
|
1364
1404
|
hasMore,
|
|
@@ -1386,6 +1426,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
1386
1426
|
// declared here. Pages-mode pagination only — infinite-scroll accumulation
|
|
1387
1427
|
// isn't wired for projectionList.
|
|
1388
1428
|
function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
|
|
1429
|
+
const isEmbeddedScreen = useIsEmbeddedScreen();
|
|
1389
1430
|
const { Banner, Text } = usePrimitives();
|
|
1390
1431
|
const t = useTranslation();
|
|
1391
1432
|
const nav = useNav();
|
|
@@ -1557,7 +1598,9 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
|
|
|
1557
1598
|
onPageSizeChange: urlState.setPageSize,
|
|
1558
1599
|
}
|
|
1559
1600
|
: undefined;
|
|
1560
|
-
return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort,
|
|
1601
|
+
return (_jsxs(_Fragment, { children: [_jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleProjectionFacetOptions }), _jsx(RenderList, { screen: listScreen, entity: entity, rows: rowsQuery.data?.rows ?? [], featureName: schema.featureName, searchable: searchable, searchValue: urlState.q, onSearchChange: urlState.setQ, sort: activeSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
|
|
1602
|
+
? { screenPadding: false, chromeless: true }
|
|
1603
|
+
: { screenPadding: true }), ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
|
|
1561
1604
|
filterFacets,
|
|
1562
1605
|
onFilterChange: urlState.setFilter,
|
|
1563
1606
|
}), ...(dateRangeFacets.length > 0 && {
|
|
@@ -2140,7 +2183,7 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
|
|
|
2140
2183
|
// sie Kontext ziehen könnten.
|
|
2141
2184
|
extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
|
|
2142
2185
|
submitLabel: screen.submitLabel ?? submitLabelFallback,
|
|
2143
|
-
}), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
|
|
2186
|
+
}), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
|
|
2144
2187
|
}
|
|
2145
2188
|
// A money-typed config-edit field renders through RenderField's entityEdit
|
|
2146
2189
|
// `{amount, currency}` payload shape (render-field.tsx, #1923), but
|
|
@@ -2155,8 +2198,13 @@ function unwrapMoneyValue(value) {
|
|
|
2155
2198
|
}
|
|
2156
2199
|
return value;
|
|
2157
2200
|
}
|
|
2201
|
+
const CONFIG_SCOPE_SOURCE = {
|
|
2202
|
+
user: "user-row",
|
|
2203
|
+
tenant: "tenant-row",
|
|
2204
|
+
system: "system-row",
|
|
2205
|
+
};
|
|
2158
2206
|
function ConfigEditBody({ schema, screen, translate, }) {
|
|
2159
|
-
const {
|
|
2207
|
+
const { ConfigCascadeView } = usePrimitives();
|
|
2160
2208
|
const t = useTranslation();
|
|
2161
2209
|
const effectiveTranslate = translate ?? t;
|
|
2162
2210
|
const dispatcher = useDispatcher();
|
|
@@ -2167,6 +2215,7 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2167
2215
|
const cascadeQuery = useQuery("config:query:cascade", {});
|
|
2168
2216
|
const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
|
|
2169
2217
|
const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
|
|
2218
|
+
const boundsSchema = useMemo(() => buildConfigEditSchema(screen.fields), [screen.fields]);
|
|
2170
2219
|
// Initial-Values: pro Field-Name den Wert aus `values[qualifiedKey]`
|
|
2171
2220
|
// abholen. Fehlt der Key auf dem Server (= noch nie gesetzt), nutzen
|
|
2172
2221
|
// wir den Field-Default (createTextField/createNumberField/...).
|
|
@@ -2237,7 +2286,20 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2237
2286
|
const qualified = screen.configKeys[shortName];
|
|
2238
2287
|
if (qualified === undefined)
|
|
2239
2288
|
continue;
|
|
2240
|
-
const
|
|
2289
|
+
const fieldDef = screen.fields[shortName];
|
|
2290
|
+
const ftype = fieldDef?.type;
|
|
2291
|
+
// A query-backed select is cleared to "" when its dependent value became
|
|
2292
|
+
// invalid. config:write:set would store that as an empty override (the
|
|
2293
|
+
// badge shows "Mandant" with no value); reset falls back to the inherited
|
|
2294
|
+
// value. set can't do it: deleting the row is irreversible, reset is
|
|
2295
|
+
// the high-risk handler.
|
|
2296
|
+
if (ftype === "select" && fieldDef?.optionsQuery !== undefined && value === "") {
|
|
2297
|
+
commands.push({
|
|
2298
|
+
type: "config:write:reset",
|
|
2299
|
+
payload: { key: qualified, scope: screen.scope },
|
|
2300
|
+
});
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2241
2303
|
commands.push({
|
|
2242
2304
|
type: "config:write:set",
|
|
2243
2305
|
payload: {
|
|
@@ -2268,41 +2330,40 @@ function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
2268
2330
|
valuesQuery.refetch,
|
|
2269
2331
|
cascadeQuery.refetch,
|
|
2270
2332
|
]);
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
// expanded-State, der daher hier (pro Feld) statt in der Komponente lebt.
|
|
2274
|
-
const [expandedFields, setExpandedFields] = useState(new Set());
|
|
2275
|
-
const toggleExpanded = useCallback((fieldName) => {
|
|
2276
|
-
setExpandedFields((prev) => {
|
|
2277
|
-
const next = new Set(prev);
|
|
2278
|
-
if (next.has(fieldName))
|
|
2279
|
-
next.delete(fieldName);
|
|
2280
|
-
else
|
|
2281
|
-
next.add(fieldName);
|
|
2282
|
-
return next;
|
|
2283
|
-
});
|
|
2284
|
-
}, []);
|
|
2285
|
-
if (valuesQuery.loading && valuesQuery.data === null) {
|
|
2286
|
-
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
2287
|
-
}
|
|
2333
|
+
if (valuesQuery.loading && valuesQuery.data === null)
|
|
2334
|
+
return _jsx(QueryLoadingBanner, {});
|
|
2288
2335
|
if (valuesQuery.error) {
|
|
2289
|
-
return (_jsx(
|
|
2290
|
-
}
|
|
2291
|
-
if (initial === null) {
|
|
2292
|
-
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
2336
|
+
return (_jsx(QueryErrorBanner, { error: valuesQuery.error, translate: effectiveTranslate, onRetry: valuesQuery.refetch }));
|
|
2293
2337
|
}
|
|
2294
|
-
|
|
2338
|
+
if (initial === null)
|
|
2339
|
+
return _jsx(QueryLoadingBanner, {});
|
|
2340
|
+
// Cascade values of a select show the option label the select itself shows,
|
|
2341
|
+
// not the stored value (a UUID for optionsQuery selects).
|
|
2342
|
+
const cascadeValueRenderer = (fieldName) => {
|
|
2343
|
+
const fieldDef = screen.fields[fieldName];
|
|
2344
|
+
if (fieldDef?.type !== "select")
|
|
2345
|
+
return undefined;
|
|
2346
|
+
if (fieldDef.optionsQuery !== undefined) {
|
|
2347
|
+
const query = fieldDef.optionsQuery;
|
|
2348
|
+
const payload = resolveOptionsQueryPayload(fieldDef.optionsQueryPayload ?? {}, initial);
|
|
2349
|
+
return (value) => _jsx(QueryOptionLabel, { query: query, payload: payload, value: value });
|
|
2350
|
+
}
|
|
2351
|
+
if (fieldDef.options.length === 0)
|
|
2352
|
+
return undefined;
|
|
2353
|
+
return (value) => buildOptionLabels(effectiveTranslate, (option) => fieldOptionLabelKey(schema.featureName, synthScreen.entity, fieldName, option), [String(value)])[String(value)];
|
|
2354
|
+
};
|
|
2355
|
+
return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, schema: boundsSchema, customSubmit: customSubmit, dirtyFooter: true, validateOnChange: true, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), fieldAppendixPlacement: "below-control", fieldAccent: (fieldName) => cascades[fieldName]?.levels.find((level) => level.isActive)?.source ===
|
|
2356
|
+
CONFIG_SCOPE_SOURCE[screen.scope], markChangedFields: true, ...(screen.fieldDescriptions !== undefined && {
|
|
2357
|
+
fieldDescription: (fieldName) => {
|
|
2358
|
+
const key = screen.fieldDescriptions?.[fieldName];
|
|
2359
|
+
return key === undefined ? undefined : effectiveTranslate(key);
|
|
2360
|
+
},
|
|
2361
|
+
}), fieldAppendix: (fieldName) => {
|
|
2295
2362
|
const cascade = cascades[fieldName];
|
|
2296
2363
|
if (cascade === undefined)
|
|
2297
2364
|
return undefined;
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
const cascade = cascades[fieldName];
|
|
2301
|
-
// Panel nur rendern wenn aufgeklappt — sonst kein leerer Abstand
|
|
2302
|
-
// unter dem Input.
|
|
2303
|
-
if (cascade === undefined || !expandedFields.has(fieldName))
|
|
2304
|
-
return undefined;
|
|
2305
|
-
return (_jsx(ConfigCascadeView, { slot: "panel", cascade: cascade, screenScope: screen.scope, expanded: true, qualifiedKey: screen.configKeys[fieldName], onReset: async (key, scope) => {
|
|
2365
|
+
const renderValue = cascadeValueRenderer(fieldName);
|
|
2366
|
+
return (_jsx(ConfigCascadeView, { cascade: cascade, screenScope: screen.scope, qualifiedKey: screen.configKeys[fieldName], required: screen.requiredFields?.includes(fieldName) ?? false, ...(renderValue !== undefined && { renderValue }), onReset: async (key, scope) => {
|
|
2306
2367
|
await dispatcher.write("config:write:reset", { key, scope });
|
|
2307
2368
|
await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
|
|
2308
2369
|
} }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
1
|
+
import type { EntityListFacetConfig, ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import type { DataTableFacet } from "../primitives.js";
|
|
3
|
+
import type { DataTableFacet, DataTableFacetChip } from "../primitives.js";
|
|
4
4
|
export type ResolvedFacetSpec = {
|
|
5
5
|
readonly field: string;
|
|
6
6
|
readonly type: "select" | "boolean" | "reference";
|
|
@@ -24,6 +24,17 @@ export declare function mergeReferenceFacetOptions(specs: readonly ResolvedFacet
|
|
|
24
24
|
readonly label: string;
|
|
25
25
|
}[]>>): ResolvedFacetSpec[];
|
|
26
26
|
export declare function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[];
|
|
27
|
+
type FacetConfigByField = Readonly<Record<string, EntityListFacetConfig | false>>;
|
|
28
|
+
export declare function buildFacetChips(spec: ResolvedFacetSpec, config: EntityListFacetConfig, translate: Translate): DataTableFacetChip[];
|
|
29
|
+
export declare function buildEntityFilterFacets(specs: readonly ResolvedFacetSpec[], configs: FacetConfigByField | undefined, translate: Translate, counts: Readonly<Record<string, number>>): DataTableFacet[];
|
|
30
|
+
export declare function facetCountKey(field: string, chipId: string): string;
|
|
31
|
+
export type FacetCountRequest = {
|
|
32
|
+
readonly key: string;
|
|
33
|
+
readonly field: string;
|
|
34
|
+
readonly values: readonly string[];
|
|
35
|
+
};
|
|
36
|
+
export declare function buildFacetCountRequests(specs: readonly ResolvedFacetSpec[], configs: FacetConfigByField | undefined, translate: Translate): FacetCountRequest[];
|
|
37
|
+
export declare function normalizeDefaultFilters(defaults: Readonly<Record<string, readonly string[] | boolean>> | undefined): Readonly<Record<string, readonly string[]>>;
|
|
27
38
|
export declare function buildFilterPayload(urlFilters: Readonly<Record<string, readonly string[]>>, typeOf: (field: string) => string | undefined): {
|
|
28
39
|
field: string;
|
|
29
40
|
op: "in";
|
|
@@ -46,3 +57,4 @@ export declare function dateRangeUrlField(field: string, bound: DateRangeBound):
|
|
|
46
57
|
export declare function readDateRange(urlFilters: Readonly<Record<string, readonly string[]>>, field: string): DateRangeValue;
|
|
47
58
|
export declare function clampDateRange(current: DateRangeValue, bound: DateRangeBound, value: string): DateRangeValue;
|
|
48
59
|
export declare function buildDateRangePayload(specs: readonly ResolvedDateRangeFacet[], urlFilters: Readonly<Record<string, readonly string[]>>, timeZone: string): Record<string, string>;
|
|
60
|
+
export {};
|
package/dist/app/list-facets.js
CHANGED
|
@@ -12,6 +12,80 @@ export function mergeReferenceFacetOptions(specs, optionsByField) {
|
|
|
12
12
|
export function buildFilterFacets(specs) {
|
|
13
13
|
return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
|
|
14
14
|
}
|
|
15
|
+
// Chips are the field's own options plus the declared extra choices
|
|
16
|
+
// ("Running" bundle, "All"), in the order the config's `position` asks for.
|
|
17
|
+
export function buildFacetChips(spec, config, translate) {
|
|
18
|
+
const extras = config.extraOptions ?? [];
|
|
19
|
+
const toChip = (extra) => ({
|
|
20
|
+
id: extra.id,
|
|
21
|
+
label: translate(extra.label),
|
|
22
|
+
values: extra.values,
|
|
23
|
+
});
|
|
24
|
+
return [
|
|
25
|
+
...extras.filter((extra) => extra.position !== "end").map(toChip),
|
|
26
|
+
...spec.options.map((option) => ({
|
|
27
|
+
id: option.value,
|
|
28
|
+
label: option.label,
|
|
29
|
+
values: [option.value],
|
|
30
|
+
})),
|
|
31
|
+
...extras.filter((extra) => extra.position === "end").map(toChip),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
// entityList facet controls: `false` drops the control, `display: "chips"`
|
|
35
|
+
// turns it into toggle chips; counts (when requested) are merged in by chip.
|
|
36
|
+
export function buildEntityFilterFacets(specs, configs, translate, counts) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const spec of specs) {
|
|
39
|
+
const config = configs?.[spec.field];
|
|
40
|
+
if (config === false)
|
|
41
|
+
continue;
|
|
42
|
+
const base = { field: spec.field, label: spec.label, options: spec.options };
|
|
43
|
+
if (config?.display !== "chips") {
|
|
44
|
+
out.push(base);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out.push({
|
|
48
|
+
...base,
|
|
49
|
+
chips: buildFacetChips(spec, config, translate).map((chip) => {
|
|
50
|
+
const count = counts[facetCountKey(spec.field, chip.id)];
|
|
51
|
+
return count === undefined ? chip : { ...chip, count };
|
|
52
|
+
}),
|
|
53
|
+
...(config.showCounts === true && { showCounts: true }),
|
|
54
|
+
...(config.hideEmpty === true && { hideEmpty: true }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function facetCountKey(field, chipId) {
|
|
60
|
+
return `${field}\u0000${chipId}`;
|
|
61
|
+
}
|
|
62
|
+
// One count request per chip of a facet that shows counts or hides empty
|
|
63
|
+
// chips. The chip's own values replace that facet's selection; other facets
|
|
64
|
+
// keep theirs.
|
|
65
|
+
export function buildFacetCountRequests(specs, configs, translate) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const spec of specs) {
|
|
68
|
+
const config = configs?.[spec.field];
|
|
69
|
+
if (config === undefined || config === false || config.display !== "chips")
|
|
70
|
+
continue;
|
|
71
|
+
if (config.showCounts !== true && config.hideEmpty !== true)
|
|
72
|
+
continue;
|
|
73
|
+
for (const chip of buildFacetChips(spec, config, translate)) {
|
|
74
|
+
out.push({ key: facetCountKey(spec.field, chip.id), field: spec.field, values: chip.values });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
// `defaultFilters` values are option values or a boolean; the URL-state shape
|
|
80
|
+
// is string[] (booleans as "true"/"false", like buildFilterPayload expects).
|
|
81
|
+
export function normalizeDefaultFilters(defaults) {
|
|
82
|
+
if (defaults === undefined)
|
|
83
|
+
return {};
|
|
84
|
+
return Object.fromEntries(Object.entries(defaults).map(([field, value]) => [
|
|
85
|
+
field,
|
|
86
|
+
typeof value === "boolean" ? [String(value)] : value,
|
|
87
|
+
]));
|
|
88
|
+
}
|
|
15
89
|
// User-selected faceted filters from URL-state → payload.filters. Boolean
|
|
16
90
|
// fields coerce "true"/"false" strings to real booleans (DB column is
|
|
17
91
|
// boolean); everything else stays string[] under op:"in" (multi-select
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DispatcherError, Translate } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
export declare function QueryLoadingBanner(): ReactNode;
|
|
4
|
+
export declare function QueryErrorBanner({ error, translate, onRetry, }: {
|
|
5
|
+
readonly error: DispatcherError;
|
|
6
|
+
readonly translate: Translate;
|
|
7
|
+
readonly onRetry: () => void | Promise<void>;
|
|
8
|
+
}): ReactNode;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useTranslation } from "../i18n.js";
|
|
3
|
+
import { usePrimitives } from "../primitives.js";
|
|
4
|
+
import { dispatcherErrorText } from "./write-failed-error.js";
|
|
5
|
+
export function QueryLoadingBanner() {
|
|
6
|
+
const { Banner } = usePrimitives();
|
|
7
|
+
const t = useTranslation();
|
|
8
|
+
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: t("kumiko.widget.loading") }));
|
|
9
|
+
}
|
|
10
|
+
export function QueryErrorBanner({ error, translate, onRetry, }) {
|
|
11
|
+
const { Banner, Button } = usePrimitives();
|
|
12
|
+
const t = useTranslation();
|
|
13
|
+
return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", actions: _jsx(Button, { variant: "secondary", size: "sm", onClick: onRetry, testId: "kumiko-screen-retry", children: t("kumiko.actions.retry") }), children: dispatcherErrorText(error, translate) }));
|
|
14
|
+
}
|