@cosmicdrift/kumiko-renderer 0.331.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.
- package/dist/app/facet-count-bridge.d.ts +10 -0
- package/dist/app/facet-count-bridge.js +20 -0
- package/dist/app/kumiko-screen.js +87 -10
- package/dist/app/list-facets.d.ts +14 -2
- package/dist/app/list-facets.js +74 -0
- package/dist/app/row-actions.js +50 -40
- package/dist/components/related-list-section.js +41 -4
- package/dist/components/render-edit-types.d.ts +4 -1
- package/dist/components/render-edit.js +38 -4
- package/dist/components/render-list.d.ts +4 -1
- package/dist/components/render-list.js +23 -4
- package/dist/hooks/use-list-url-state.d.ts +2 -1
- package/dist/hooks/use-list-url-state.js +23 -9
- package/dist/i18n-defaults.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/primitives.d.ts +49 -1
- package/package.json +5 -5
- package/src/changes.json +14 -0
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@ import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget
|
|
|
3
3
|
import { 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
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
|
|
8
8
|
import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
|
|
9
9
|
import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
|
|
@@ -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 =
|
|
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.
|
|
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
|
|
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
|
|
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 && {
|
|
@@ -1621,6 +1655,46 @@ function runMetricNavigate(nav, navigate, record, host) {
|
|
|
1621
1655
|
function isAbsoluteHttpUrl(value) {
|
|
1622
1656
|
return /^https?:\/\//i.test(value) && isSafeHref(value);
|
|
1623
1657
|
}
|
|
1658
|
+
// hrefFor throws for an entity without a detailFor screen; outside
|
|
1659
|
+
// createKumikoApp the feature list is empty, so navigateTargetAllows can't
|
|
1660
|
+
// rule that out beforehand. The NavApi owns target resolution, so asking it
|
|
1661
|
+
// and falling back to plain text covers both cases.
|
|
1662
|
+
function hrefForNavigate(nav, navigate, record) {
|
|
1663
|
+
const entityId = navigate.entityId !== undefined ? String(record[navigate.entityId] ?? "") : undefined;
|
|
1664
|
+
try {
|
|
1665
|
+
if (navigate.entity !== undefined) {
|
|
1666
|
+
return entityId === undefined || entityId === ""
|
|
1667
|
+
? undefined
|
|
1668
|
+
: nav.hrefFor({ entity: navigate.entity, id: entityId });
|
|
1669
|
+
}
|
|
1670
|
+
if (navigate.screen !== undefined) {
|
|
1671
|
+
return nav.hrefFor({
|
|
1672
|
+
screenId: navigate.screen,
|
|
1673
|
+
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
catch {
|
|
1678
|
+
return undefined;
|
|
1679
|
+
}
|
|
1680
|
+
return undefined;
|
|
1681
|
+
}
|
|
1682
|
+
function buildSubtitleParts(parts, record, nav, appFeatures, userRoles, host) {
|
|
1683
|
+
return parts.flatMap((part) => {
|
|
1684
|
+
const field = typeof part === "string" ? part : part.field;
|
|
1685
|
+
const text = String(record[field] ?? "");
|
|
1686
|
+
if (text === "")
|
|
1687
|
+
return [];
|
|
1688
|
+
const navigate = typeof part === "string" ? undefined : part.navigate;
|
|
1689
|
+
if (navigate === undefined || !navigateTargetAllows(navigate, appFeatures, userRoles)) {
|
|
1690
|
+
return [{ field, text }];
|
|
1691
|
+
}
|
|
1692
|
+
const href = hrefForNavigate(nav, navigate, record);
|
|
1693
|
+
if (href === undefined)
|
|
1694
|
+
return [{ field, text }];
|
|
1695
|
+
return [{ field, text, href, onPress: () => runMetricNavigate(nav, navigate, record, host) }];
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1624
1698
|
function headerStatusTone(header, record) {
|
|
1625
1699
|
if (header.status === undefined)
|
|
1626
1700
|
return undefined;
|
|
@@ -1900,7 +1974,10 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
1900
1974
|
// Plain Text, not Heading — the Card title slot already renders as its own
|
|
1901
1975
|
// heading element (h3); nesting another heading (h1) inside it is invalid HTML.
|
|
1902
1976
|
const headerTitleSlot = header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: String(record[header.status] ?? "") }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
|
|
1903
|
-
const
|
|
1977
|
+
const subtitleParts = header !== undefined && typeof header.subtitle === "object"
|
|
1978
|
+
? buildSubtitleParts(header.subtitle, record, nav, appFeatures, userRoles, host)
|
|
1979
|
+
: undefined;
|
|
1980
|
+
const headerSubtitleSlot = subtitleParts !== undefined ? (subtitleParts.length > 0 ? (_jsx(Text, { testId: "kumiko-screen-projection-detail-subtitle", children: subtitleParts.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && (_jsx(Text, { variant: "muted", decorative: true, children: " · " })), part.href !== undefined ? (_jsx(Link, { href: part.href, ...(part.onPress !== undefined && { onPress: part.onPress }), className: "underline", testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text })) : (_jsx(Text, { testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text }))] }, part.field))) })) : undefined) : typeof header?.subtitle === "string" ? (subtitleHref !== undefined ? (_jsx(Link, { href: subtitleHref, target: "_blank", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") })) : (_jsx(Text, { variant: "muted", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") }))) : undefined;
|
|
1904
1981
|
// slots.header shares the header card with the actions (one card, actions
|
|
1905
1982
|
// top right) instead of rendering as an unframed block above it.
|
|
1906
1983
|
const metricItems = screen.metrics?.map((metric) => {
|
|
@@ -2097,7 +2174,7 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
|
|
|
2097
2174
|
// sie Kontext ziehen könnten.
|
|
2098
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 && {
|
|
2099
2176
|
submitLabel: screen.submitLabel ?? submitLabelFallback,
|
|
2100
|
-
}), ...(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 }) }));
|
|
2101
2178
|
}
|
|
2102
2179
|
// A money-typed config-edit field renders through RenderField's entityEdit
|
|
2103
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 {};
|
package/dist/app/list-facets.js
CHANGED
|
@@ -12,6 +12,80 @@ export function mergeReferenceFacetOptions(specs, optionsByField) {
|
|
|
12
12
|
export function buildFilterFacets(specs) {
|
|
13
13
|
return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
|
|
14
14
|
}
|
|
15
|
+
// Chips are the field's own options plus the declared extra choices
|
|
16
|
+
// ("Running" bundle, "All"), in the order the config's `position` asks for.
|
|
17
|
+
export function buildFacetChips(spec, config, translate) {
|
|
18
|
+
const extras = config.extraOptions ?? [];
|
|
19
|
+
const toChip = (extra) => ({
|
|
20
|
+
id: extra.id,
|
|
21
|
+
label: translate(extra.label),
|
|
22
|
+
values: extra.values,
|
|
23
|
+
});
|
|
24
|
+
return [
|
|
25
|
+
...extras.filter((extra) => extra.position !== "end").map(toChip),
|
|
26
|
+
...spec.options.map((option) => ({
|
|
27
|
+
id: option.value,
|
|
28
|
+
label: option.label,
|
|
29
|
+
values: [option.value],
|
|
30
|
+
})),
|
|
31
|
+
...extras.filter((extra) => extra.position === "end").map(toChip),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
// entityList facet controls: `false` drops the control, `display: "chips"`
|
|
35
|
+
// turns it into toggle chips; counts (when requested) are merged in by chip.
|
|
36
|
+
export function buildEntityFilterFacets(specs, configs, translate, counts) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const spec of specs) {
|
|
39
|
+
const config = configs?.[spec.field];
|
|
40
|
+
if (config === false)
|
|
41
|
+
continue;
|
|
42
|
+
const base = { field: spec.field, label: spec.label, options: spec.options };
|
|
43
|
+
if (config?.display !== "chips") {
|
|
44
|
+
out.push(base);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out.push({
|
|
48
|
+
...base,
|
|
49
|
+
chips: buildFacetChips(spec, config, translate).map((chip) => {
|
|
50
|
+
const count = counts[facetCountKey(spec.field, chip.id)];
|
|
51
|
+
return count === undefined ? chip : { ...chip, count };
|
|
52
|
+
}),
|
|
53
|
+
...(config.showCounts === true && { showCounts: true }),
|
|
54
|
+
...(config.hideEmpty === true && { hideEmpty: true }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function facetCountKey(field, chipId) {
|
|
60
|
+
return `${field}\u0000${chipId}`;
|
|
61
|
+
}
|
|
62
|
+
// One count request per chip of a facet that shows counts or hides empty
|
|
63
|
+
// chips. The chip's own values replace that facet's selection; other facets
|
|
64
|
+
// keep theirs.
|
|
65
|
+
export function buildFacetCountRequests(specs, configs, translate) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const spec of specs) {
|
|
68
|
+
const config = configs?.[spec.field];
|
|
69
|
+
if (config === undefined || config === false || config.display !== "chips")
|
|
70
|
+
continue;
|
|
71
|
+
if (config.showCounts !== true && config.hideEmpty !== true)
|
|
72
|
+
continue;
|
|
73
|
+
for (const chip of buildFacetChips(spec, config, translate)) {
|
|
74
|
+
out.push({ key: facetCountKey(spec.field, chip.id), field: spec.field, values: chip.values });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
// `defaultFilters` values are option values or a boolean; the URL-state shape
|
|
80
|
+
// is string[] (booleans as "true"/"false", like buildFilterPayload expects).
|
|
81
|
+
export function normalizeDefaultFilters(defaults) {
|
|
82
|
+
if (defaults === undefined)
|
|
83
|
+
return {};
|
|
84
|
+
return Object.fromEntries(Object.entries(defaults).map(([field, value]) => [
|
|
85
|
+
field,
|
|
86
|
+
typeof value === "boolean" ? [String(value)] : value,
|
|
87
|
+
]));
|
|
88
|
+
}
|
|
15
89
|
// User-selected faceted filters from URL-state → payload.filters. Boolean
|
|
16
90
|
// fields coerce "true"/"false" strings to real booleans (DB column is
|
|
17
91
|
// boolean); everything else stays string[] under op:"in" (multi-select
|
package/dist/app/row-actions.js
CHANGED
|
@@ -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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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,
|
|
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 &&
|
|
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
|
|
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 }),
|
|
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
|
|
@@ -7,8 +7,13 @@ import { useTranslation } from "../i18n.js";
|
|
|
7
7
|
import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
|
|
8
8
|
import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
9
9
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
10
|
+
// Fire-and-forget actions (schema navigate/drawer) can leave the toolbar for the
|
|
11
|
+
// page header without losing the confirm dialog or busy/error surfacing.
|
|
12
|
+
function isHeaderPromotableToolbarAction(action) {
|
|
13
|
+
return (action.style === "primary" && action.confirm === undefined && action.confirmRequired === false);
|
|
14
|
+
}
|
|
10
15
|
export function RenderList(props) {
|
|
11
|
-
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;
|
|
12
17
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
13
18
|
// wären Column-Header raw i18n-Keys.
|
|
14
19
|
const t = useTranslation();
|
|
@@ -133,8 +138,22 @@ export function RenderList(props) {
|
|
|
133
138
|
const hasHeaderSlot = screen.slots?.header !== undefined;
|
|
134
139
|
const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
|
|
135
140
|
const createButton = onCreate !== undefined && createInPageHeader && pageHeaderCompact ? (_jsx(Button, { variant: "primary", icon: "plus", size: "icon", ariaLabel: effectiveCreateLabel, title: effectiveCreateLabel, onClick: onCreate, testId: "render-list-create" })) : onCreate !== undefined ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-create", className: "px-3.5", children: effectiveCreateLabel })) : undefined;
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
// Without onCreate (e.g. a navigate action as the list's only CTA) the primary
|
|
142
|
+
// toolbar action takes the header slot on phones, so it is not a text button
|
|
143
|
+
// squeezed next to the search field. Desktop keeps it in the toolbar.
|
|
144
|
+
const promotedToolbarAction = onCreate === undefined &&
|
|
145
|
+
PageHeader !== undefined &&
|
|
146
|
+
pageHeaderSlotAvailable &&
|
|
147
|
+
pageHeaderCompact
|
|
148
|
+
? toolbarActions?.find(isHeaderPromotableToolbarAction)
|
|
149
|
+
: undefined;
|
|
150
|
+
const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(Button, { variant: "primary", icon: promotedToolbarAction.icon ?? "plus", size: "icon", ariaLabel: promotedToolbarAction.label, title: promotedToolbarAction.label, onClick: () => void promotedToolbarAction.onTrigger(), testId: `render-list-toolbar-action-${promotedToolbarAction.id}` })) : undefined;
|
|
151
|
+
const toolbarActionsInToolbar = toolbarActions?.filter((a) => a !== promotedToolbarAction) ?? [];
|
|
152
|
+
const hasToolbarActionsInToolbar = toolbarActionsInToolbar.length > 0;
|
|
153
|
+
const toolbarEnd = hasHeaderSlot ||
|
|
154
|
+
hasToolbarActionsInToolbar ||
|
|
155
|
+
(onCreate !== undefined && !createInPageHeader) ? (_jsxs(_Fragment, { children: [hasHeaderSlot && _jsx(ListHeaderSlotMount, { screen: screen }), hasToolbarActionsInToolbar &&
|
|
156
|
+
toolbarActionsInToolbar.map((a) => (_jsx(ToolbarActionView, { action: a, iconOnly: toolbarIconOnly, Button: Button, Dialog: Dialog, Banner: Banner }, a.id))), !createInPageHeader && createButton] })) : undefined;
|
|
138
157
|
// Empty-State: Default zeigt Heading + Description + optional CTA-
|
|
139
158
|
// Button. Caller kann via emptyState-Prop komplett überschreiben.
|
|
140
159
|
// Wenn weder onCreate noch ein Override gegeben ist, fällt die
|
|
@@ -149,7 +168,7 @@ export function RenderList(props) {
|
|
|
149
168
|
const resolvedTitle = translate(titleKey);
|
|
150
169
|
const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
|
|
151
170
|
// ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
|
|
152
|
-
return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), 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" })] }));
|
|
153
172
|
}
|
|
154
173
|
// Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
|
|
155
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 ?
|
|
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}`)] =
|
|
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,
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -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",
|
|
@@ -68,6 +69,9 @@ export const kumikoDefaultTranslations = {
|
|
|
68
69
|
"kumiko.list.count.one": "1 entry",
|
|
69
70
|
"kumiko.list.count.other": "{count} entries",
|
|
70
71
|
"kumiko.list.row-actions.more": "More actions",
|
|
72
|
+
"kumiko.page-header.actions": "Page actions",
|
|
73
|
+
"kumiko.theme.dark": "Dark theme",
|
|
74
|
+
"kumiko.theme.light": "Light theme",
|
|
71
75
|
"kumiko.list.row.collapse": "Collapse {title}",
|
|
72
76
|
"kumiko.list.row.expand": "Expand {title}",
|
|
73
77
|
"kumiko.list.sort.by": "Sorted by {column}",
|
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";
|
package/dist/primitives.d.ts
CHANGED
|
@@ -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 };
|
|
@@ -43,6 +43,8 @@ export type ButtonProps = {
|
|
|
43
43
|
readonly title?: string;
|
|
44
44
|
/** Toggle state for toggle buttons — web sets `aria-pressed`; leave unset for plain buttons. */
|
|
45
45
|
readonly pressed?: boolean;
|
|
46
|
+
/** Disclosure state for buttons that open a menu or panel — web sets `aria-expanded`. */
|
|
47
|
+
readonly expanded?: boolean;
|
|
46
48
|
/** Breite — default="auto" (inhaltsbreit). "full" streckt CTA-Buttons auf
|
|
47
49
|
* die Container-Breite (Karten/Panels). Andere Breiten sind Layout-Sache
|
|
48
50
|
* des Containers, kein Button-Prop (Kit hält arbiträres Sizing draußen). */
|
|
@@ -77,6 +79,9 @@ export type LinkProps = {
|
|
|
77
79
|
* `rel` is set explicitly. */
|
|
78
80
|
readonly target?: "_blank" | "_self" | "_parent" | "_top";
|
|
79
81
|
readonly rel?: string;
|
|
82
|
+
/** Plain left-click handler (SPA navigation); `href` stays set for
|
|
83
|
+
* modifier/middle clicks, which the browser handles itself. */
|
|
84
|
+
readonly onPress?: () => void;
|
|
80
85
|
/** Layout-Zusätze (self-center, text-xs) — Web merged via cn(),
|
|
81
86
|
* Native-Impls ignorieren es (Präzedenz: CardProps.className). */
|
|
82
87
|
readonly className?: string;
|
|
@@ -489,6 +494,10 @@ export type DataTableRowAction = {
|
|
|
489
494
|
* drives both the icon-left-of-text render and the icon-only collapse
|
|
490
495
|
* rule (see `shouldRenderActionsIconOnly`). */
|
|
491
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;
|
|
492
501
|
};
|
|
493
502
|
/** Teil-C action-icon collapse rule: a group of more than two actions where
|
|
494
503
|
* every member carries an icon renders icon-only instead of wall-to-wall
|
|
@@ -508,6 +517,22 @@ export type DataTableFacet = {
|
|
|
508
517
|
readonly value: string;
|
|
509
518
|
readonly label: string;
|
|
510
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;
|
|
511
536
|
};
|
|
512
537
|
export type DataTableDateRangeFacet = {
|
|
513
538
|
/** The facet's namespace (its date field) — a key, not a filter value. */
|
|
@@ -517,9 +542,19 @@ export type DataTableDateRangeFacet = {
|
|
|
517
542
|
readonly from: string;
|
|
518
543
|
readonly to: string;
|
|
519
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
|
+
};
|
|
520
552
|
export type DataTableProps = {
|
|
521
553
|
readonly columns: readonly ListColumnViewModel[];
|
|
522
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;
|
|
523
558
|
readonly onRowClick?: (row: ListRowViewModel) => void;
|
|
524
559
|
/** Aktuelle Sortierung (oder null = unsorted). Wenn columns ein
|
|
525
560
|
* `sortable: true`-Feld haben und onSortChange gesetzt ist, rendert
|
|
@@ -903,6 +938,8 @@ export type GridCellProps = {
|
|
|
903
938
|
* foreground). Custom-Impls mappen auf ihren TypeScale. */
|
|
904
939
|
export type TextProps = {
|
|
905
940
|
readonly variant?: "body" | "small" | "code" | "required-mark" | "muted";
|
|
941
|
+
/** Pure ornament (e.g. a separator glyph): hidden from assistive tech. */
|
|
942
|
+
readonly decorative?: boolean;
|
|
906
943
|
readonly children: ReactNode;
|
|
907
944
|
readonly testId?: string;
|
|
908
945
|
};
|
|
@@ -973,6 +1010,8 @@ export type DrawerProps = {
|
|
|
973
1010
|
readonly open: boolean;
|
|
974
1011
|
readonly onOpenChange: (open: boolean) => void;
|
|
975
1012
|
readonly title?: string;
|
|
1013
|
+
/** Muted line under the title. */
|
|
1014
|
+
readonly subtitle?: string;
|
|
976
1015
|
readonly children: ReactNode;
|
|
977
1016
|
readonly testId?: string;
|
|
978
1017
|
};
|
|
@@ -1091,6 +1130,14 @@ export type StepBarProps = {
|
|
|
1091
1130
|
/** Vertical only: rail heading ("Step 4 of 6") and optional description. */
|
|
1092
1131
|
readonly heading?: string;
|
|
1093
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
|
+
};
|
|
1094
1141
|
readonly testId?: string;
|
|
1095
1142
|
readonly compactTestId?: string;
|
|
1096
1143
|
};
|
|
@@ -1185,6 +1232,7 @@ export type ActionMenuItemSpec = {
|
|
|
1185
1232
|
readonly icon?: IconKey;
|
|
1186
1233
|
readonly disabled?: boolean;
|
|
1187
1234
|
readonly variant?: "default" | "danger";
|
|
1235
|
+
readonly testId?: string;
|
|
1188
1236
|
readonly onSelect: () => void;
|
|
1189
1237
|
};
|
|
1190
1238
|
export type ActionOverflowMenuProps = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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,18 @@
|
|
|
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
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.332.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "projectionDetail header subtitle can show several parts, each optionally linking to the referenced record",
|
|
13
|
+
"detail": "`header.subtitle` accepts a list of parts (field name or `{ field, navigate }`). Empty parts drop out, the rest are joined by a \"·\" separator, and a part with `navigate` links to the referenced record (entity or screen target, only when reachable). The Link primitive gets an optional `onPress` for SPA navigation, Text an optional `decorative` flag. `subtitleHref` stays valid with the string form only; the boot validator rejects the combination with a list.",
|
|
14
|
+
"migration": "No code change needed."
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"version": "0.331.0",
|
|
4
18
|
"type": "improvement",
|