@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.
Files changed (35) hide show
  1. package/dist/app/config-edit-shim.d.ts +7 -0
  2. package/dist/app/config-edit-shim.js +39 -0
  3. package/dist/app/facet-count-bridge.d.ts +10 -0
  4. package/dist/app/facet-count-bridge.js +20 -0
  5. package/dist/app/kumiko-screen.js +103 -42
  6. package/dist/app/list-facets.d.ts +14 -2
  7. package/dist/app/list-facets.js +74 -0
  8. package/dist/app/query-state-banners.d.ts +8 -0
  9. package/dist/app/query-state-banners.js +14 -0
  10. package/dist/app/row-actions.js +50 -40
  11. package/dist/app/secret-mint-body.js +3 -3
  12. package/dist/app/secrets-edit-body.js +46 -17
  13. package/dist/components/grid-cell-for-field.d.ts +7 -2
  14. package/dist/components/grid-cell-for-field.js +2 -2
  15. package/dist/components/query-options-select.d.ts +12 -3
  16. package/dist/components/query-options-select.js +45 -2
  17. package/dist/components/related-list-section.js +41 -8
  18. package/dist/components/render-edit-types.d.ts +24 -1
  19. package/dist/components/render-edit.js +100 -28
  20. package/dist/components/render-field.d.ts +7 -1
  21. package/dist/components/render-field.js +32 -4
  22. package/dist/components/render-list.d.ts +4 -1
  23. package/dist/components/render-list.js +2 -2
  24. package/dist/components/write-form-section.d.ts +5 -1
  25. package/dist/components/write-form-section.js +17 -5
  26. package/dist/context/embedded-screen-context.d.ts +5 -0
  27. package/dist/context/embedded-screen-context.js +12 -0
  28. package/dist/hooks/use-list-url-state.d.ts +2 -1
  29. package/dist/hooks/use-list-url-state.js +23 -9
  30. package/dist/i18n-defaults.js +31 -5
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.js +1 -0
  33. package/dist/primitives.d.ts +95 -13
  34. package/package.json +5 -5
  35. 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 = Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
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.label), 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 && {
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 urlState = useListUrlState(screen.id);
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 filterFacets = useMemo(() => buildFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions)), [entityFacetSpecs, entityReferenceFacetOptions]);
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, screenPadding: true, ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...(useInfinite && {
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, screenPadding: true, ...(screenFillsHeight(listScreen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
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 { Banner, ConfigCascadeView } = usePrimitives();
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 ftype = screen.fields[shortName]?.type;
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
- // Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
2272
- // unter dem Input — zwei getrennte Render-Slots teilen sich den
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(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(valuesQuery.error, effectiveTranslate) }));
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
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, customSubmit: customSubmit, ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), labelAppendix: (fieldName) => {
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
- return (_jsx(ConfigCascadeView, { slot: "trigger", cascade: cascade, screenScope: screen.scope, expanded: expandedFields.has(fieldName), onToggle: () => toggleExpanded(fieldName) }));
2299
- }, fieldAppendix: (fieldName) => {
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 {};
@@ -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
+ }