@cosmicdrift/kumiko-renderer 0.332.0 → 0.333.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.
@@ -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
+ }
@@ -23,9 +23,10 @@ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config
23
23
  import { useCustomScreenComponent } from "./custom-screens.js";
24
24
  import { useDashboardBody } from "./dashboard-body.js";
25
25
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
26
+ import { FacetCountBridges } from "./facet-count-bridge.js";
26
27
  import { buildFormSchema } from "./form-schema.js";
27
28
  import { layoutFieldNames } from "./layout-fields.js";
28
- import { buildDateRangePayload, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
29
+ import { buildDateRangePayload, buildEntityFilterFacets, buildFacetCountRequests, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, normalizeDefaultFilters, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
29
30
  import { useInitialValuesHandoff, useNav } from "./nav.js";
30
31
  import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
31
32
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim.js";
@@ -319,10 +320,13 @@ function formatSummaryValue(value, locale) {
319
320
  }
320
321
  return value === null || value === undefined ? "" : String(value);
321
322
  }
323
+ function formatSummaryParams(prefill, locale) {
324
+ return Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
325
+ }
322
326
  function resolveActionFormSummary(summary, prefill, translate, locale) {
323
327
  if (summary === undefined)
324
328
  return undefined;
325
- const params = Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
329
+ const params = formatSummaryParams(prefill, locale);
326
330
  return {
327
331
  title: translate(summary.title, params),
328
332
  ...(summary.subtitle !== undefined && { subtitle: translate(summary.subtitle, params) }),
@@ -906,6 +910,7 @@ function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, u
906
910
  const { Drawer, Banner, Text, Dialog } = usePrimitives();
907
911
  const t = useTranslation();
908
912
  const effectiveTranslate = translate ?? t;
913
+ const locale = useLocale().locale();
909
914
  const [hasUnsavedInput, setHasUnsavedInput] = useState(false);
910
915
  const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
911
916
  const closeAndReset = useCallback(() => {
@@ -937,10 +942,13 @@ function DrawerHost({ schema, drawerAction, drawerScreen, drawerInitialValues, u
937
942
  // "Access denied" state KumikoScreen's top-level gate would show for a
938
943
  // direct hit on that screen, never the form.
939
944
  const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
945
+ const titleParams = formatSummaryParams(drawerInitialValues, locale);
940
946
  return (_jsxs(_Fragment, { children: [_jsx(Drawer, { open: true, onOpenChange: (open) => {
941
947
  if (!open)
942
948
  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 && {
949
+ }, title: effectiveTranslate(drawerAction.title ?? drawerAction.label, titleParams), ...(drawerAction.subtitle !== undefined && {
950
+ subtitle: effectiveTranslate(drawerAction.subtitle, titleParams),
951
+ }), 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
952
  initialOverrides: drawerInitialValues,
945
953
  }), onSuccess: () => {
946
954
  setHasUnsavedInput(false);
@@ -974,7 +982,8 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
974
982
  // damit zwei Lists auf derselben Route nicht über dieselben
975
983
  // Query-Keys streiten. Default-Sort aus der Screen-Def gewinnt nur
976
984
  // wenn URL keinen sort hat — Author-Default vs User-Choice.
977
- const urlState = useListUrlState(screen.id);
985
+ const defaultFilters = useMemo(() => normalizeDefaultFilters(screen.defaultFilters), [screen.defaultFilters]);
986
+ const urlState = useListUrlState(screen.id, defaultFilters);
978
987
  const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
979
988
  const limit = urlState.pageSize ?? screen.pageSize ?? 50;
980
989
  const paginationMode = screen.pagination ?? "pages";
@@ -1109,7 +1118,30 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1109
1118
  const entityFacetSpecs = useMemo(() => resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate), [entity.fields, featureName, screen.entity, effectiveTranslate]);
1110
1119
  const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
1111
1120
  const handleEntityFacetOptions = useCallback((field, options) => setEntityReferenceFacetOptions((prev) => prev[field] === options ? prev : { ...prev, [field]: options }), []);
1112
- const filterFacets = useMemo(() => buildFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions)), [entityFacetSpecs, entityReferenceFacetOptions]);
1121
+ const [facetCounts, setFacetCounts] = useState({});
1122
+ const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
1123
+ const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
1124
+ const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
1125
+ const filters = buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type);
1126
+ return {
1127
+ key: request.key,
1128
+ payload: {
1129
+ limit: 1,
1130
+ totalCount: true,
1131
+ ...(urlState.q !== "" && { search: urlState.q }),
1132
+ ...(screen.filter !== undefined && { filter: screen.filter }),
1133
+ ...(filters.length > 0 && { filters }),
1134
+ },
1135
+ };
1136
+ }), [
1137
+ entityFacetSpecs,
1138
+ screen.facets,
1139
+ screen.filter,
1140
+ effectiveTranslate,
1141
+ urlState.filters,
1142
+ urlState.q,
1143
+ entity.fields,
1144
+ ]);
1113
1145
  // Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
1114
1146
  // bleibt rowActions undefined statt zu crashen. Echte Apps haben
1115
1147
  // den Provider via createKumikoApp — wenn nicht, ist es vermutlich
@@ -1189,6 +1221,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1189
1221
  id: action.id,
1190
1222
  label: effectiveTranslate(action.label),
1191
1223
  ...(action.style !== undefined && { style: action.style }),
1224
+ ...(action.display !== undefined && { display: action.display }),
1192
1225
  confirmRequired: false,
1193
1226
  ...(navigateAction.rowClick === true && { rowClick: true }),
1194
1227
  ...(actionIcon !== undefined && { icon: actionIcon }),
@@ -1206,6 +1239,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1206
1239
  id: action.id,
1207
1240
  label: effectiveTranslate(action.label),
1208
1241
  ...(action.style !== undefined && { style: action.style }),
1242
+ ...(action.display !== undefined && { display: action.display }),
1209
1243
  confirmRequired: false,
1210
1244
  ...(actionIcon !== undefined && { icon: actionIcon }),
1211
1245
  onTrigger: (row) => {
@@ -1358,7 +1392,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1358
1392
  // Bei Infinite-Mode: rows kommen aus accumulated (über mehrere
1359
1393
  // useQuery-Calls gesammelt), bei pages/false aus dem aktuellen Result.
1360
1394
  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 && {
1395
+ 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, 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
1396
  onReachEnd: loadMore,
1363
1397
  loadingMore: rowsQuery.loading,
1364
1398
  hasMore,
@@ -1557,7 +1591,7 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1557
1591
  onPageSizeChange: urlState.setPageSize,
1558
1592
  }
1559
1593
  : 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 && {
1594
+ 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 }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(toolbarActions !== undefined && { toolbarActions }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && {
1561
1595
  filterFacets,
1562
1596
  onFilterChange: urlState.setFilter,
1563
1597
  }), ...(dateRangeFacets.length > 0 && {
@@ -2140,7 +2174,7 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2140
2174
  // sie Kontext ziehen könnten.
2141
2175
  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
2176
  submitLabel: screen.submitLabel ?? submitLabelFallback,
2143
- }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
2177
+ }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
2144
2178
  }
2145
2179
  // A money-typed config-edit field renders through RenderField's entityEdit
2146
2180
  // `{amount, currency}` payload shape (render-field.tsx, #1923), but
@@ -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
@@ -93,6 +93,7 @@ function buildNavigateRowAction(action, translate, nav, host) {
93
93
  id: action.id,
94
94
  label: translate(action.label),
95
95
  ...(action.style !== undefined && { style: action.style }),
96
+ ...(action.display !== undefined && { display: action.display }),
96
97
  confirmRequired: false,
97
98
  ...(action.rowClick === true && { rowClick: true }),
98
99
  ...(actionIcon !== undefined && { icon: actionIcon }),
@@ -125,6 +126,7 @@ function buildDrawerRowAction(action, translate, openDrawer) {
125
126
  id: action.id,
126
127
  label: translate(action.label),
127
128
  ...(action.style !== undefined && { style: action.style }),
129
+ ...(action.display !== undefined && { display: action.display }),
128
130
  confirmRequired: false,
129
131
  ...(actionIcon !== undefined && { icon: actionIcon }),
130
132
  onTrigger: (row) => {
@@ -149,6 +151,7 @@ function buildWriteHandlerRowAction(action, translate, refetch, dispatcher) {
149
151
  id: action.id,
150
152
  label: translate(action.label),
151
153
  ...(action.style !== undefined && { style: action.style }),
154
+ ...(action.display !== undefined && { display: action.display }),
152
155
  icon: resolveActionIcon(action.id, action.icon),
153
156
  ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
154
157
  ...(action.confirmLabel !== undefined && {
@@ -290,47 +293,52 @@ export function buildProjectionToolbarActions(options) {
290
293
  }
291
294
  return out.length > 0 ? out : undefined;
292
295
  }
293
- function buildNavigateRecordAction(action, options) {
294
- const { record, translate, nav, host, actionIcon, defaultScreenTargetEntityId, sameEntityScreenId, } = options;
295
- if (action.entity !== undefined) {
296
- const targetEntity = action.entity;
297
- const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
298
- return {
299
- id: action.id,
300
- label: translate(action.label),
301
- ...(action.style !== undefined && { style: action.style }),
302
- confirmRequired: false,
303
- ...(actionIcon !== undefined && { icon: actionIcon }),
304
- onPress: () => {
305
- // No entityId on record (id === "") → nothing to navigate to.
306
- if (id !== "") {
307
- const params = navigateActionSearchParams(action, record);
308
- navigateWithReturnTo(nav, { entity: targetEntity, id }, host, params);
309
- }
310
- },
311
- };
312
- }
313
- if (action.screen !== undefined) {
314
- const explicit = action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
315
- const fallback = sameEntityScreenId?.(action.screen) ?? defaultScreenTargetEntityId;
316
- const navEntityId = explicit ?? fallback;
317
- const targetScreen = action.screen;
318
- return {
319
- id: action.id,
320
- label: translate(action.label),
321
- ...(action.style !== undefined && { style: action.style }),
322
- confirmRequired: false,
323
- ...(actionIcon !== undefined && { icon: actionIcon }),
324
- onPress: () => {
325
- const target = {
326
- screenId: targetScreen,
327
- ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
328
- };
296
+ function navigateActionBase(action, translate, actionIcon) {
297
+ return {
298
+ id: action.id,
299
+ label: translate(action.label),
300
+ ...(action.style !== undefined && { style: action.style }),
301
+ ...(action.display !== undefined && { display: action.display }),
302
+ confirmRequired: false,
303
+ ...(actionIcon !== undefined && { icon: actionIcon }),
304
+ };
305
+ }
306
+ function buildNavigateEntityAction(action, targetEntity, options) {
307
+ const { record, translate, nav, host, actionIcon } = options;
308
+ const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
309
+ return {
310
+ ...navigateActionBase(action, translate, actionIcon),
311
+ onPress: () => {
312
+ // No entityId on record (id === "") → nothing to navigate to.
313
+ if (id !== "") {
329
314
  const params = navigateActionSearchParams(action, record);
330
- navigateWithReturnTo(nav, target, host, params);
331
- },
332
- };
333
- }
315
+ navigateWithReturnTo(nav, { entity: targetEntity, id }, host, params);
316
+ }
317
+ },
318
+ };
319
+ }
320
+ function buildNavigateScreenAction(action, targetScreen, options) {
321
+ const { record, translate, nav, host, actionIcon, defaultScreenTargetEntityId, sameEntityScreenId, } = options;
322
+ const explicit = action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
323
+ const fallback = sameEntityScreenId?.(targetScreen) ?? defaultScreenTargetEntityId;
324
+ const navEntityId = explicit ?? fallback;
325
+ return {
326
+ ...navigateActionBase(action, translate, actionIcon),
327
+ onPress: () => {
328
+ const target = {
329
+ screenId: targetScreen,
330
+ ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
331
+ };
332
+ const params = navigateActionSearchParams(action, record);
333
+ navigateWithReturnTo(nav, target, host, params);
334
+ },
335
+ };
336
+ }
337
+ function buildNavigateRecordAction(action, options) {
338
+ if (action.entity !== undefined)
339
+ return buildNavigateEntityAction(action, action.entity, options);
340
+ if (action.screen !== undefined)
341
+ return buildNavigateScreenAction(action, action.screen, options);
334
342
  return undefined;
335
343
  }
336
344
  function buildDrawerRecordAction(action, options) {
@@ -339,6 +347,7 @@ function buildDrawerRecordAction(action, options) {
339
347
  id: action.id,
340
348
  label: translate(action.label),
341
349
  ...(action.style !== undefined && { style: action.style }),
350
+ ...(action.display !== undefined && { display: action.display }),
342
351
  confirmRequired: false,
343
352
  ...(actionIcon !== undefined && { icon: actionIcon }),
344
353
  onPress: () => {
@@ -353,6 +362,7 @@ function buildWriteHandlerRecordAction(action, options) {
353
362
  id: action.id,
354
363
  label: translate(action.label),
355
364
  ...(action.style !== undefined && { style: action.style }),
365
+ ...(action.display !== undefined && { display: action.display }),
356
366
  ...(actionIcon !== undefined && { icon: actionIcon }),
357
367
  ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
358
368
  ...(action.confirmLabel !== undefined && {
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { normalizeListColumn, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { evalFieldCondition, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
5
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
@@ -12,9 +12,9 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
12
12
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
13
13
  import { useUserRoles } from "../context/user-roles-context.js";
14
14
  import { useQuery } from "../hooks/use-query.js";
15
- import { useTranslation } from "../i18n.js";
15
+ import { useLocale, useTranslation } from "../i18n.js";
16
16
  import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
17
- import { usePrimitives } from "../primitives.js";
17
+ import { usePrimitives, } from "../primitives.js";
18
18
  import { sortByAccessor } from "../sort-by-accessor.js";
19
19
  import { RenderEditActionButton } from "./render-edit-action-button.js";
20
20
  import { RenderList } from "./render-list.js";
@@ -47,11 +47,48 @@ function warnRelatedListEmptyStateDrawerDropped(actionId) {
47
47
  // biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
48
48
  console.warn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
49
49
  }
50
+ function buildRowGrouping(groupBy, translate, locale) {
51
+ return {
52
+ keyOf: (row) => String(row.values[groupBy.field] ?? ""),
53
+ startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key === String(groupBy.collapsedWhen),
54
+ headerLabel: (key, rows) => {
55
+ const dates = groupBy.dateField === undefined
56
+ ? []
57
+ : rows
58
+ .map((row) => String(row.values[groupBy.dateField ?? ""] ?? ""))
59
+ .filter((value) => value !== "")
60
+ .sort();
61
+ const latest = dates[dates.length - 1];
62
+ return translate(groupBy.labels?.[key] ?? groupBy.label, {
63
+ count: rows.length,
64
+ value: key,
65
+ lastDate: latest === undefined
66
+ ? ""
67
+ : new Date(latest).toLocaleDateString(locale, {
68
+ year: "numeric",
69
+ month: "2-digit",
70
+ day: "2-digit",
71
+ timeZone: "UTC",
72
+ }),
73
+ });
74
+ },
75
+ };
76
+ }
77
+ function buildRowTone(rule) {
78
+ if (rule === undefined)
79
+ return undefined;
80
+ return (row) => (evalFieldCondition(rule, { ...row.values }) ? rule.tone : undefined);
81
+ }
50
82
  export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, }) {
51
83
  const { Banner, Section, Card, FillContainer, Text, Button, Dialog } = usePrimitives();
52
84
  const [emptyStateActionError, setEmptyStateActionError] = useState(null);
53
85
  const t = useTranslation();
54
86
  const effectiveTranslate = translate ?? t;
87
+ const locale = useLocale().locale();
88
+ const rowGrouping = useMemo(() => section.groupBy === undefined
89
+ ? undefined
90
+ : buildRowGrouping(section.groupBy, effectiveTranslate, locale), [section.groupBy, effectiveTranslate, locale]);
91
+ const rowTone = useMemo(() => buildRowTone(section.rowTone), [section.rowTone]);
55
92
  const nav = useNav();
56
93
  const host = useReturnHost();
57
94
  const dispatcher = useOptionalDispatcher();
@@ -269,7 +306,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
269
306
  filterValues: filters,
270
307
  onFilterChange,
271
308
  onFilterReset,
272
- }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...(hideTitle === true && {
309
+ }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...(hideTitle === true && {
273
310
  scrollBody: true,
274
311
  screenPadding: false,
275
312
  chromeless: true,
@@ -1,4 +1,4 @@
1
- import type { EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { DispatcherError, FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import type { ReactNode } from "react";
4
4
  import type * as z from "zod";
@@ -73,6 +73,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
73
73
  readonly submitLabel?: string;
74
74
  /** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
75
75
  readonly submitVariant?: "primary" | "danger";
76
+ /** Extra footer buttons (actionForm `footerActions`): each sets its `patch`
77
+ * on the form values, then submits through the normal validate + write path. */
78
+ readonly footerActions?: readonly ActionFormFooterAction[];
76
79
  /** Context box above the drawer form (title + optional subtitle, already resolved). */
77
80
  readonly summary?: {
78
81
  readonly title: string;
@@ -6,6 +6,7 @@ import { AllFieldsRequiredProvider } from "./render-field.js";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
8
8
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
9
+ import { layoutEditFields } from "../app/layout-fields.js";
9
10
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
10
11
  import { useDraftStorage } from "../context/draft-storage-context.js";
11
12
  import { formatWhen } from "../format-when.js";
@@ -145,8 +146,23 @@ function formSectionDomId(sectionIndex) {
145
146
  export function resolveRecordTitle(value) {
146
147
  return typeof value === "string" && value.trim() !== "" ? value : undefined;
147
148
  }
149
+ const TITLE_PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
150
+ // Falls back to the plain title while any placeholder value is still empty,
151
+ // so the header never shows "Add " mid-typing.
152
+ function resolveTitleTemplate(template, values, translate, fallback) {
153
+ if (template === undefined)
154
+ return fallback;
155
+ const placeholders = [...translate(template).matchAll(TITLE_PLACEHOLDER_PATTERN)].map((match) => match[1] ?? "");
156
+ const isFilled = (name) => {
157
+ const value = values[name];
158
+ return value !== undefined && value !== null && String(value).trim() !== "";
159
+ };
160
+ if (!placeholders.every(isFilled))
161
+ return fallback;
162
+ return translate(template, values);
163
+ }
148
164
  export function RenderEdit(props) {
149
- const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, summary, labelAppendix, fieldAppendix, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, headerRegion, buildSectionActions, } = props;
165
+ const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, footerActions, summary, labelAppendix, fieldAppendix, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, headerRegion, buildSectionActions, } = props;
150
166
  const { customSubmit } = props;
151
167
  // Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
152
168
  // konsumieren wir den i18next-Context direkt. Sonst wären Field-
@@ -239,11 +255,15 @@ export function RenderEdit(props) {
239
255
  // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
240
256
  // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
241
257
  // weil wir controller.submit() eh nicht rufen.
258
+ const omitFields = useMemo(() => layoutEditFields(screen)
259
+ .filter((spec) => spec.submit === false)
260
+ .map((spec) => spec.field), [screen]);
242
261
  const submitConfig = writeCommand !== undefined
243
262
  ? {
244
263
  type: writeCommand,
245
264
  payloadMode,
246
265
  ...(buildPayload !== undefined && { buildPayload }),
266
+ ...(omitFields.length > 0 && { omitFields }),
247
267
  ...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
248
268
  ...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
249
269
  }
@@ -944,7 +964,12 @@ export function RenderEdit(props) {
944
964
  const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
945
965
  const formActions = (_jsxs(_Fragment, { children: [isWizard && currentStep > 0 && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-back", children: translate("kumiko.actions.back") })), footerSlot !== undefined && (_jsx(EditSlotMount, { slot: footerSlot, slotName: "footer", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values, hasUnsavedChanges: snapshot.isDirty || extensionDirty, wizardStep: isWizard ? { index: currentStep, isLast: isLastWizardStep } : undefined, ...(screen.slots?.footerPrimary === true && { [STICKY_PRIMARY_ACTION_PROP]: true }) })), isWizard && !isLastWizardStep && showsSubmit && !isCreateMode && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => void handleSubmit({ saveNow: true }), testId: "render-edit-wizard-save-close", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.wizard.save-and-close") })), isWizard && !isLastWizardStep && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", children: nextStepTitle !== undefined
946
966
  ? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
947
- : translate("kumiko.actions.next") })), showsSubmit && (!isWizard || isLastWizardStep) && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
967
+ : translate("kumiko.actions.next") })), showsSubmit &&
968
+ (!isWizard || isLastWizardStep) &&
969
+ footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
970
+ controller.setValues(action.patch); // @cast-boundary form-values
971
+ void handleSubmit();
972
+ }, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), showsSubmit && (!isWizard || isLastWizardStep) && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
948
973
  isSubmitting ||
949
974
  disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save")) }))] }));
950
975
  // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
@@ -976,7 +1001,8 @@ export function RenderEdit(props) {
976
1001
  }
977
1002
  return undefined;
978
1003
  };
979
- const formTitle = resolveScreenText("title") ?? screen.id;
1004
+ const plainFormTitle = resolveScreenText("title") ?? screen.id;
1005
+ const formTitle = resolveTitleTemplate(screen.titleTemplate, snapshot.values, translate, plainFormTitle);
980
1006
  // screen.description is head-card copy, not tab content — in tabs mode
981
1007
  // (hideSectionTitles) the head card already carries title/subtitle/status.
982
1008
  const formSubtitle = hideSectionTitles !== true
@@ -986,6 +1012,7 @@ export function RenderEdit(props) {
986
1012
  const usesFieldFlow = isScreenForm || insideDrawer;
987
1013
  const allRequiredHint = (_jsx(Text, { variant: "muted", testId: "render-edit-all-required-hint", children: translate("kumiko.form.all-required") }));
988
1014
  const stepRail = isWizard && isScreenForm && StepBar !== undefined;
1015
+ const upNextSection = screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined;
989
1016
  const changedFieldCount = isCreate ? 0 : Object.keys(snapshot.changes).length;
990
1017
  const unsavedCount = isCreate || !isScreenForm
991
1018
  ? 0
@@ -1023,7 +1050,14 @@ export function RenderEdit(props) {
1023
1050
  sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardJumpBack, orientation: "vertical", heading: translate("kumiko.wizard.step", {
1024
1051
  current: currentStep + 1,
1025
1052
  total: lastStepIndex + 1,
1026
- }), ...(formSubtitle !== undefined && { description: formSubtitle }), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
1053
+ }), ...(formSubtitle !== undefined && { description: formSubtitle }), subtitles: filteredSections.map((section) => section.kind === "fields" ? section.subtitle : undefined), ...(upNextSection !== undefined && {
1054
+ upNext: {
1055
+ heading: translate("kumiko.wizard.up-next"),
1056
+ title: upNextSection.title ?? "",
1057
+ ...(upNextSection.kind === "fields" &&
1058
+ upNextSection.subtitle !== undefined && { subtitle: upNextSection.subtitle }),
1059
+ },
1060
+ }), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
1027
1061
  }), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
1028
1062
  hasSecondaryFormActions && { secondaryActions: secondaryFormActions }), testId: "render-edit-form", stickyActions: isWizard || fillScreenHeight === true, ...(screen.layout.width !== undefined && { width: screen.layout.width }), ...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion }), ...(titleActionMount !== undefined && { titleAction: titleActionMount }), ...(fillHeight && { fillHeight }), ...(hideSectionTitles === true && { chromeless: true }), ...(summary !== undefined && { summary }), children: _jsxs(AllFieldsRequiredProvider, { value: showsAllRequiredHint, children: [showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
1029
1063
  ...draftCandidates.map((candidate) => (_jsx(Button, { type: "button", variant: "link", onClick: () => adoptDraft(candidate), testId: `render-edit-draft-pick-${candidate.id}`, children: formatWhen(candidate.savedAt) }, candidate.id))),
@@ -2,7 +2,7 @@ import type { EntityDefinition, EntityListScreenDefinition, IconKey } from "@cos
2
2
  import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import { type ReactNode } from "react";
4
4
  import type { ListSort } from "../hooks/use-list-url-state.js";
5
- import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableRowAction, type DataTableRowActionMode } from "../primitives.js";
5
+ import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableProps, type DataTableRowAction, type DataTableRowActionMode, type DataTableRowGrouping } from "../primitives.js";
6
6
  export type RenderListProps = {
7
7
  readonly screen: EntityListScreenDefinition;
8
8
  readonly entity: EntityDefinition;
@@ -65,6 +65,9 @@ export type RenderListProps = {
65
65
  * KumikoScreen derives it from the actions themselves; without it the
66
66
  * DataTable falls back to its adaptive default. */
67
67
  readonly rowActionMode?: DataTableRowActionMode;
68
+ /** Forwarded to `DataTableProps.rowGrouping` / `rowTone`. */
69
+ readonly rowGrouping?: DataTableRowGrouping;
70
+ readonly rowTone?: DataTableProps["rowTone"];
68
71
  /** Toolbar-Aktionen im List-Header — Resolved-Form (KumikoScreen baut
69
72
  * das aus EntityListScreenDefinition.toolbarActions: navigate-target
70
73
  * → useNav, handler-QN → dispatcher-Call). RenderList rendert die
@@ -13,7 +13,7 @@ function isHeaderPromotableToolbarAction(action) {
13
13
  return (action.style === "primary" && action.confirm === undefined && action.confirmRequired === false);
14
14
  }
15
15
  export function RenderList(props) {
16
- const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
16
+ const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
17
17
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
18
18
  // wären Column-Header raw i18n-Keys.
19
19
  const t = useTranslation();
@@ -168,7 +168,7 @@ export function RenderList(props) {
168
168
  const resolvedTitle = translate(titleKey);
169
169
  const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
170
170
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
171
- return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
171
+ return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
172
172
  }
173
173
  // Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
174
174
  // eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
@@ -1,4 +1,5 @@
1
1
  import type { DataTableSort, DataTableSortDir } from "../primitives.js";
2
+ export declare const NO_FILTER_URL_VALUE = "~";
2
3
  export type ListSortDir = DataTableSortDir;
3
4
  export type ListSort = DataTableSort;
4
5
  export type ListUrlState = {
@@ -40,4 +41,4 @@ export type ListUrlStateApi = ListUrlState & {
40
41
  }) => void;
41
42
  };
42
43
  export declare const PAGE_SIZE_OPTIONS: readonly number[];
43
- export declare function useListUrlState(screenId: string): ListUrlStateApi;
44
+ export declare function useListUrlState(screenId: string, defaultFilters?: Readonly<Record<string, readonly string[]>>): ListUrlStateApi;
@@ -8,12 +8,18 @@
8
8
  // <screenId>.dir — "asc" | "desc"
9
9
  // <screenId>.q — search term (URL-encoded)
10
10
  // <screenId>.page — 1-based page number (nur bei pagination="pages")
11
- // <screenId>.f.<field> — Faceted-Filter: comma-joined selected values
11
+ // <screenId>.f.<field> — Faceted-Filter: comma-joined selected values;
12
+ // NO_FILTER_URL_VALUE = "no filter", chosen on purpose
13
+ // for a field that has a declared default
12
14
  //
13
15
  // Schreibt mit setSearchParams (replaceState — kein push), damit
14
16
  // Sort/Filter-Toggles nicht die Browser-History fluten.
15
17
  import { useCallback, useMemo } from "react";
16
18
  import { useNav } from "../app/nav.js";
19
+ // A facet with a `defaultFilters` entry falls back to that default whenever
20
+ // its URL key is absent. Clearing it therefore has to leave a marker, or the
21
+ // default would come straight back.
22
+ export const NO_FILTER_URL_VALUE = "~";
17
23
  // `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
18
24
  // üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
19
25
  // pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
@@ -36,7 +42,7 @@ function parsePage(value) {
36
42
  const n = Number.parseInt(value, 10);
37
43
  return Number.isFinite(n) && n > 0 ? n : 1;
38
44
  }
39
- export function useListUrlState(screenId) {
45
+ export function useListUrlState(screenId, defaultFilters) {
40
46
  const nav = useNav();
41
47
  const params = nav.searchParams;
42
48
  const sort = useMemo(() => {
@@ -60,10 +66,17 @@ export function useListUrlState(screenId) {
60
66
  continue;
61
67
  // ponytail: comma-join — Facet-Werte sind select-Options/booleans
62
68
  // ohne Komma; upgrade auf repeated-keys falls je Komma-Werte nötig.
63
- out[field] = v.split(",").filter((s) => s !== "");
69
+ out[field] = v === NO_FILTER_URL_VALUE ? [] : v.split(",").filter((s) => s !== "");
70
+ }
71
+ for (const [field, values] of Object.entries(defaultFilters ?? {})) {
72
+ if (!Object.hasOwn(out, field))
73
+ out[field] = values;
64
74
  }
65
75
  return out;
66
- }, [params, filterPrefix]);
76
+ }, [params, filterPrefix, defaultFilters]);
77
+ const clearedFilterValue = useCallback((field) => defaultFilters !== undefined && Object.hasOwn(defaultFilters, field)
78
+ ? NO_FILTER_URL_VALUE
79
+ : null, [defaultFilters]);
67
80
  const setSort = useCallback((next) => {
68
81
  // Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
69
82
  // Page (sonst wäre der User auf "Seite 5 von alter Sortierung"
@@ -97,10 +110,10 @@ export function useListUrlState(screenId) {
97
110
  }, [nav, screenId]);
98
111
  const setFilter = useCallback((field, values) => {
99
112
  nav.setSearchParams({
100
- [key(screenId, `f.${field}`)]: values.length === 0 ? null : values.join(","),
113
+ [key(screenId, `f.${field}`)]: values.length === 0 ? clearedFilterValue(field) : values.join(","),
101
114
  [key(screenId, "page")]: null,
102
115
  });
103
- }, [nav, screenId]);
116
+ }, [nav, screenId, clearedFilterValue]);
104
117
  const setDateRange = useCallback((field, next) => {
105
118
  nav.setSearchParams({
106
119
  [key(screenId, `f.${field}.from`)]: next.from === "" ? null : next.from,
@@ -110,10 +123,11 @@ export function useListUrlState(screenId) {
110
123
  }, [nav, screenId]);
111
124
  const clearFilters = useCallback(() => {
112
125
  const updates = { [key(screenId, "page")]: null };
113
- for (const field of Object.keys(filters))
114
- updates[key(screenId, `f.${field}`)] = null;
126
+ for (const field of Object.keys(filters)) {
127
+ updates[key(screenId, `f.${field}`)] = clearedFilterValue(field);
128
+ }
115
129
  nav.setSearchParams(updates);
116
- }, [nav, screenId, filters]);
130
+ }, [nav, screenId, filters, clearedFilterValue]);
117
131
  return {
118
132
  sort,
119
133
  q,
@@ -23,6 +23,7 @@ export const kumikoDefaultTranslations = {
23
23
  "kumiko.actions.back": "Back",
24
24
  "kumiko.actions.finish": "Finish",
25
25
  "kumiko.wizard.step": "Step {current} of {total}",
26
+ "kumiko.wizard.up-next": "Up next",
26
27
  "kumiko.wizard.step-with-title": "Step {current} of {total} · {title}",
27
28
  "kumiko.version.update-available": "A new version is available.",
28
29
  "kumiko.toast.learn-more": "Learn more",
package/dist/index.d.ts CHANGED
@@ -67,7 +67,7 @@ export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTra
67
67
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
68
68
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
69
69
  export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
70
- export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputProps, JsonViewProps, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, WizardStepGroupProps, } from "./primitives.js";
70
+ export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableRowGrouping, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputProps, JsonViewProps, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, WizardStepGroupProps, } from "./primitives.js";
71
71
  export { PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, usePrimitives, } from "./primitives.js";
72
72
  export { buildWhatsAppShareUrl } from "./share-links.js";
73
73
  export { sortByAccessor } from "./sort-by-accessor.js";
@@ -1,5 +1,5 @@
1
1
  import type { ConfigCascade, ConfigScope, ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
2
- import type { FieldIconKey, FormWidth, IconKey, NavIconKey, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { FieldIconKey, FormWidth, IconKey, NavIconKey, RowActionDisplay, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { FieldIssue, ListColumnViewModel, ListRowViewModel, RuntimeRenderer } from "@cosmicdrift/kumiko-headless";
4
4
  import { type ComponentType, type FormEvent, type KeyboardEvent, type ReactNode, type Ref } from "react";
5
5
  export type { RuntimeRenderer };
@@ -494,6 +494,10 @@ export type DataTableRowAction = {
494
494
  * drives both the icon-left-of-text render and the icon-only collapse
495
495
  * rule (see `shouldRenderActionsIconOnly`). */
496
496
  readonly icon?: IconKey;
497
+ /** Author-chosen inline form (`RowAction.display`). Set: the action stays
498
+ * inline even in the adaptive layout and is exempt from the automatic
499
+ * icon-only collapse. */
500
+ readonly display?: RowActionDisplay;
497
501
  };
498
502
  /** Teil-C action-icon collapse rule: a group of more than two actions where
499
503
  * every member carries an icon renders icon-only instead of wall-to-wall
@@ -513,6 +517,22 @@ export type DataTableFacet = {
513
517
  readonly value: string;
514
518
  readonly label: string;
515
519
  }[];
520
+ /** Present: render the facet as a row of single-choice toggle chips instead
521
+ * of a dropdown. A chip is pressed while the selection equals its values. */
522
+ readonly chips?: readonly DataTableFacetChip[];
523
+ /** Chips only: show each chip's `count` next to its label. */
524
+ readonly showCounts?: boolean;
525
+ /** Chips only: hide chips whose `count` is 0 (unless pressed). */
526
+ readonly hideEmpty?: boolean;
527
+ };
528
+ export type DataTableFacetChip = {
529
+ readonly id: string;
530
+ /** Translated chip label. */
531
+ readonly label: string;
532
+ /** Selection a click sets; `[]` clears the facet. */
533
+ readonly values: readonly string[];
534
+ /** Matching row count, undefined while unknown. */
535
+ readonly count?: number;
516
536
  };
517
537
  export type DataTableDateRangeFacet = {
518
538
  /** The facet's namespace (its date field) — a key, not a filter value. */
@@ -522,9 +542,19 @@ export type DataTableDateRangeFacet = {
522
542
  readonly from: string;
523
543
  readonly to: string;
524
544
  };
545
+ /** Splits rows into collapsible groups (order of first appearance). A
546
+ * renderer without grouping support shows the flat list. */
547
+ export type DataTableRowGrouping = {
548
+ readonly keyOf: (row: ListRowViewModel) => string;
549
+ readonly headerLabel: (groupKey: string, rows: readonly ListRowViewModel[]) => string;
550
+ readonly startsCollapsed: (groupKey: string) => boolean;
551
+ };
525
552
  export type DataTableProps = {
526
553
  readonly columns: readonly ListColumnViewModel[];
527
554
  readonly rows: readonly ListRowViewModel[];
555
+ readonly rowGrouping?: DataTableRowGrouping;
556
+ /** Tints a row. Renderers without row tinting ignore it. */
557
+ readonly rowTone?: (row: ListRowViewModel) => SelectOptionTone | undefined;
528
558
  readonly onRowClick?: (row: ListRowViewModel) => void;
529
559
  /** Aktuelle Sortierung (oder null = unsorted). Wenn columns ein
530
560
  * `sortable: true`-Feld haben und onSortChange gesetzt ist, rendert
@@ -980,6 +1010,8 @@ export type DrawerProps = {
980
1010
  readonly open: boolean;
981
1011
  readonly onOpenChange: (open: boolean) => void;
982
1012
  readonly title?: string;
1013
+ /** Muted line under the title. */
1014
+ readonly subtitle?: string;
983
1015
  readonly children: ReactNode;
984
1016
  readonly testId?: string;
985
1017
  };
@@ -1098,6 +1130,14 @@ export type StepBarProps = {
1098
1130
  /** Vertical only: rail heading ("Step 4 of 6") and optional description. */
1099
1131
  readonly heading?: string;
1100
1132
  readonly description?: string;
1133
+ /** Vertical only: line under each step label, same order as `steps`. */
1134
+ readonly subtitles?: readonly (string | undefined)[];
1135
+ /** Vertical only: box under the rail announcing the next step. */
1136
+ readonly upNext?: {
1137
+ readonly heading: string;
1138
+ readonly title: string;
1139
+ readonly subtitle?: string;
1140
+ };
1101
1141
  readonly testId?: string;
1102
1142
  readonly compactTestId?: string;
1103
1143
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.332.0",
3
+ "version": "0.333.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.332.0",
19
- "@cosmicdrift/kumiko-headless": "0.332.0",
20
- "@cosmicdrift/kumiko-types": "0.332.0",
18
+ "@cosmicdrift/kumiko-framework": "0.333.0",
19
+ "@cosmicdrift/kumiko-headless": "0.333.0",
20
+ "@cosmicdrift/kumiko-types": "0.333.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.332.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.333.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.333.0",
4
+ "type": "improvement",
5
+ "title": "New optional screen options for facet chips, default filters, row action display, drawer titles, footer actions, grouped related lists and wizard side info",
6
+ "detail": "entityList: `facets` (per-field `display: \"chips\"`, `showCounts`, `hideEmpty`, `extraOptions`, or `false` to hide), `defaultFilters` (initial value only, the URL wins, \"no filter\" stays chosen) and `rowActionMode`. Row actions get `display: \"button\" | \"link\" | \"icon\"`; with `display` set an action stays inline next to the kebab. Drawer actions get `title` / `subtitle` (i18n keys, `{param}` filled from the row prefill). Edit fields get `submit: false` (kept out of the payload), actionForm gets `footerActions` (patch values, then submit), relatedList gets `groupBy` / `rowTone` / `rowActionMode`. Wizard sections get `subtitle`, `layout.wizard.aside.upNext` adds an \"up next\" box, entityEdit gets `titleTemplate`. `Button` gets a `pressed` style, the DataTable contract `rowGrouping` / `rowTone`, `StepBar` `subtitles` / `upNext`, `Drawer` `subtitle`.\n- Screenshot runner: `SCREENSHOT_DESKTOP_WIDTH` overrides the desktop viewport width (default 1920).",
7
+ "migration": "No code change needed."
8
+ },
2
9
  {
3
10
  "version": "0.332.0",
4
11
  "type": "improvement",