@cosmicdrift/kumiko-renderer 0.402.0 → 0.403.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/entity-list-screen.js +30 -5
- package/dist/app/named-filter-facet.d.ts +12 -0
- package/dist/app/named-filter-facet.js +30 -0
- package/dist/app/named-filter-remove.d.ts +8 -0
- package/dist/app/named-filter-remove.js +9 -0
- package/dist/app/projection-list-body.js +2 -1
- package/dist/app/projection-list-render-props.d.ts +1 -1
- package/dist/app/projection-list-render-props.js +4 -3
- package/dist/app/projection-list-render.js +2 -1
- package/dist/app/use-projection-list-data.js +8 -1
- package/dist/app/use-projection-list-query.d.ts +1 -0
- package/dist/app/use-projection-list-query.js +5 -0
- package/dist/hooks/use-list-url-state.d.ts +7 -1
- package/dist/hooks/use-list-url-state.js +26 -5
- package/dist/web/app/dashboard-panels.js +20 -3
- package/dist/web/widgets/stat.d.ts +9 -2
- package/dist/web/widgets/stat.js +11 -4
- package/package.json +5 -5
- package/src/changes.json +6 -0
|
@@ -23,6 +23,8 @@ import { FacetCountBridges } from "./facet-count-bridge.js";
|
|
|
23
23
|
import { useListBulk } from "./list-bulk-actions.js";
|
|
24
24
|
import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
|
|
25
25
|
import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
|
|
26
|
+
import { buildNamedFilterFacet, routeFacetChange, withNamedFilterValues, } from "./named-filter-facet.js";
|
|
27
|
+
import { NamedFilterRemove } from "./named-filter-remove.js";
|
|
26
28
|
import { useNav } from "./nav.js";
|
|
27
29
|
import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
|
|
28
30
|
import { ReferenceFacetBridges, withReferenceFacetState, } from "./reference-facet-bridge.js";
|
|
@@ -64,6 +66,8 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
64
66
|
// author default vs user choice.
|
|
65
67
|
const defaultFilters = useMemo(() => normalizeDefaultFilters(screen.defaultFilters), [screen.defaultFilters]);
|
|
66
68
|
const urlState = useListUrlState(screen.id, defaultFilters);
|
|
69
|
+
// Gated on the screen offering named filters, so a stale URL cannot send a param the list rejects.
|
|
70
|
+
const activeNamedFilter = screen.namedFilters === undefined ? undefined : urlState.namedFilter;
|
|
67
71
|
const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
|
|
68
72
|
const limit = urlState.pageSize ?? screen.pageSize ?? 50;
|
|
69
73
|
const paginationMode = screen.pagination ?? "pages";
|
|
@@ -111,8 +115,11 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
111
115
|
const filters = [...filterPayload, ...range.rangeFilters];
|
|
112
116
|
if (filters.length > 0)
|
|
113
117
|
payload["filters"] = filters;
|
|
118
|
+
if (activeNamedFilter !== undefined)
|
|
119
|
+
payload["namedFilter"] = activeNamedFilter;
|
|
114
120
|
return payload;
|
|
115
121
|
}, [
|
|
122
|
+
activeNamedFilter,
|
|
116
123
|
limit,
|
|
117
124
|
urlState.q,
|
|
118
125
|
effectiveSort,
|
|
@@ -157,7 +164,22 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
157
164
|
const handleEntityFacetOptions = useCallback((field, state) => setEntityReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
|
|
158
165
|
const [facetCounts, setFacetCounts] = useState({});
|
|
159
166
|
const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
|
|
160
|
-
const filterFacets = useMemo(() =>
|
|
167
|
+
const filterFacets = useMemo(() => {
|
|
168
|
+
const namedFacet = buildNamedFilterFacet(screen.namedFilters, effectiveTranslate);
|
|
169
|
+
return [
|
|
170
|
+
...(namedFacet !== undefined ? [namedFacet] : []),
|
|
171
|
+
...buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts),
|
|
172
|
+
];
|
|
173
|
+
}, [
|
|
174
|
+
entityFacetSpecs,
|
|
175
|
+
entityReferenceFacetOptions,
|
|
176
|
+
screen.facets,
|
|
177
|
+
screen.namedFilters,
|
|
178
|
+
effectiveTranslate,
|
|
179
|
+
facetCounts,
|
|
180
|
+
]);
|
|
181
|
+
const filterValues = useMemo(() => withNamedFilterValues(urlState.filters, activeNamedFilter), [urlState.filters, activeNamedFilter]);
|
|
182
|
+
const onFilterChange = useMemo(() => routeFacetChange(urlState), [urlState]);
|
|
161
183
|
const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
|
|
162
184
|
const filters = [
|
|
163
185
|
...buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type),
|
|
@@ -171,9 +193,11 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
171
193
|
...(urlState.q !== "" && { search: urlState.q }),
|
|
172
194
|
...(screen.filter !== undefined && { filter: screen.filter }),
|
|
173
195
|
...(filters.length > 0 && { filters }),
|
|
196
|
+
...(activeNamedFilter !== undefined && { namedFilter: activeNamedFilter }),
|
|
174
197
|
},
|
|
175
198
|
};
|
|
176
199
|
}), [
|
|
200
|
+
activeNamedFilter,
|
|
177
201
|
entityFacetSpecs,
|
|
178
202
|
screen.facets,
|
|
179
203
|
screen.filter,
|
|
@@ -362,7 +386,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
362
386
|
return _jsx(QueryLoadingBanner, {});
|
|
363
387
|
}
|
|
364
388
|
if (listRows.error && !listRows.hasData) {
|
|
365
|
-
return (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry }));
|
|
389
|
+
return (_jsxs(_Fragment, { children: [_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry }), _jsx(NamedFilterRemove, { active: activeNamedFilter !== undefined, translate: effectiveTranslate, onRemove: () => urlState.setNamedFilter(undefined) })] }));
|
|
366
390
|
}
|
|
367
391
|
// A failed refetch keeps the last good data on screen.
|
|
368
392
|
const refetchErrorBanner = listRows.error ? (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry })) : null;
|
|
@@ -395,15 +419,16 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
395
419
|
// arrives async; until it is there RenderList renders the table without a
|
|
396
420
|
// pager (the pager has its own total>0 guard).
|
|
397
421
|
const pager = listPager({ usePager, page: listRows.page, limit, urlState });
|
|
398
|
-
return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, selected: urlState.filters, onState: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed:
|
|
422
|
+
return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, selected: urlState.filters, onState: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: activeNamedFilter !== undefined ||
|
|
423
|
+
isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
|
|
399
424
|
description: effectiveTranslate(screen.description),
|
|
400
425
|
}), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
|
|
401
426
|
? { screenPadding: false, chromeless: true }
|
|
402
|
-
: { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...listRows.infinite, ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && { filterFacets, onFilterChange
|
|
427
|
+
: { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...listRows.infinite, ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && { filterFacets, onFilterChange }), ...(range.rangeFacets.length > 0 && {
|
|
403
428
|
rangeFacets: range.rangeFacets,
|
|
404
429
|
onRangeChange: range.onRangeChange,
|
|
405
430
|
}), ...((filterFacets.length > 0 || range.rangeFacets.length > 0) && {
|
|
406
|
-
filterValues
|
|
431
|
+
filterValues,
|
|
407
432
|
onFilterReset: urlState.clearFilters,
|
|
408
433
|
}), ...listTable.props, ...(expandableSection !== undefined && {
|
|
409
434
|
expandedRowIds,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { NamedListFilterRef } from "@cosmicdrift/kumiko-types/named-list-filters";
|
|
2
|
+
import type { Translate } from "../headless/index.ts";
|
|
3
|
+
import type { ListUrlStateApi } from "../hooks/use-list-url-state.ts";
|
|
4
|
+
import type { DataTableFacet } from "../primitives.tsx";
|
|
5
|
+
export declare const NAMED_FILTER_FACET_FIELD = "__namedFilter";
|
|
6
|
+
type NamedFilterUrlState = Pick<ListUrlStateApi, "namedFilter" | "setFilter" | "setNamedFilter">;
|
|
7
|
+
/** One toggle chip per named filter, rendered by the existing facet chip row. */
|
|
8
|
+
export declare function buildNamedFilterFacet(refs: readonly NamedListFilterRef[] | undefined, translate: Translate): DataTableFacet | undefined;
|
|
9
|
+
export declare function withNamedFilterValues(filters: Readonly<Record<string, readonly string[]>>, namedFilter: string | undefined): Readonly<Record<string, readonly string[]>>;
|
|
10
|
+
/** Pressing the active chip again removes it. */
|
|
11
|
+
export declare function routeFacetChange(urlState: NamedFilterUrlState): (field: string, values: readonly string[]) => void;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Not a real field: keeps the named filter out of the `.f.<field>` facet namespace.
|
|
2
|
+
export const NAMED_FILTER_FACET_FIELD = "__namedFilter";
|
|
3
|
+
/** One toggle chip per named filter, rendered by the existing facet chip row. */
|
|
4
|
+
export function buildNamedFilterFacet(refs, translate) {
|
|
5
|
+
if (refs === undefined || refs.length === 0)
|
|
6
|
+
return undefined;
|
|
7
|
+
return {
|
|
8
|
+
field: NAMED_FILTER_FACET_FIELD,
|
|
9
|
+
label: refs.map((ref) => translate(ref.label)).join(" / "),
|
|
10
|
+
options: refs.map((ref) => ({ value: ref.id, label: translate(ref.label) })),
|
|
11
|
+
chips: refs.map((ref) => ({ id: ref.id, label: translate(ref.label), values: [ref.id] })),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function withNamedFilterValues(filters, namedFilter) {
|
|
15
|
+
return namedFilter === undefined
|
|
16
|
+
? filters
|
|
17
|
+
: { ...filters, [NAMED_FILTER_FACET_FIELD]: [namedFilter] };
|
|
18
|
+
}
|
|
19
|
+
/** Pressing the active chip again removes it. */
|
|
20
|
+
export function routeFacetChange(urlState) {
|
|
21
|
+
return (field, values) => {
|
|
22
|
+
if (field !== NAMED_FILTER_FACET_FIELD) {
|
|
23
|
+
urlState.setFilter(field, values);
|
|
24
|
+
// skip: ordinary facet handled above
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const picked = values[0];
|
|
28
|
+
urlState.setNamedFilter(picked === urlState.namedFilter ? undefined : picked);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Translate } from "../headless/index.ts";
|
|
3
|
+
/** Keeps a stale or forbidden named filter removable while the list shows its error state. */
|
|
4
|
+
export declare function NamedFilterRemove({ active, translate, onRemove, }: {
|
|
5
|
+
readonly active: boolean;
|
|
6
|
+
readonly translate: Translate;
|
|
7
|
+
readonly onRemove: () => void;
|
|
8
|
+
}): ReactNode;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { usePrimitives } from "../primitives.js";
|
|
3
|
+
/** Keeps a stale or forbidden named filter removable while the list shows its error state. */
|
|
4
|
+
export function NamedFilterRemove({ active, translate, onRemove, }) {
|
|
5
|
+
const { Button } = usePrimitives();
|
|
6
|
+
if (!active)
|
|
7
|
+
return null;
|
|
8
|
+
return (_jsx(Button, { variant: "secondary", onClick: onRemove, testId: "named-filter-remove", children: translate("kumiko.list.filter.reset") }));
|
|
9
|
+
}
|
|
@@ -8,6 +8,7 @@ import { usePrimitives } from "../primitives.js";
|
|
|
8
8
|
import { useDrawerAction } from "./action-form-body.js";
|
|
9
9
|
import { useAppFeatures } from "./app-features-context.js";
|
|
10
10
|
import { useClientActionRuntime } from "./client-actions.js";
|
|
11
|
+
import { NamedFilterRemove } from "./named-filter-remove.js";
|
|
11
12
|
import { useNav } from "./nav.js";
|
|
12
13
|
import { renderProjectionDrawerHost } from "./projection-drawer-host.js";
|
|
13
14
|
import { renderBadShapeBanner, renderProjectionList, renderQueryRetryBanner, } from "./projection-list-render.js";
|
|
@@ -80,7 +81,7 @@ export function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
|
|
|
80
81
|
return _jsx(QueryLoadingBanner, {});
|
|
81
82
|
}
|
|
82
83
|
if (listRows.error && !listRows.hasData) {
|
|
83
|
-
return renderQueryRetryBanner(listRows, listRows.error, effectiveTranslate);
|
|
84
|
+
return (_jsxs(_Fragment, { children: [renderQueryRetryBanner(listRows, listRows.error, effectiveTranslate), _jsx(NamedFilterRemove, { active: query.activeNamedFilter !== undefined, translate: effectiveTranslate, onRemove: () => query.urlState.setNamedFilter(undefined) })] }));
|
|
84
85
|
}
|
|
85
86
|
// A failed refetch keeps the last good data on screen.
|
|
86
87
|
const refetchErrorBanner = listRows.error
|
|
@@ -50,7 +50,7 @@ export declare function projectionFacetProps(filterFacets: ReturnType<typeof use
|
|
|
50
50
|
dateRangeFacets?: import("../data-table-facet-types.ts").DataTableDateRangeFacet[] | undefined;
|
|
51
51
|
onDateRangeChange?: ((field: string, bound: "from" | "to", value: string) => void) | undefined;
|
|
52
52
|
};
|
|
53
|
-
export declare function projectionFilterStateProps(filterFacets: ReturnType<typeof useProjectionListData>["filterFacets"], dateRangeFacets: ReturnType<typeof useProjectionListQuery>["dateRangeFacets"], urlState: ListUrlStateApi): {
|
|
53
|
+
export declare function projectionFilterStateProps(filterFacets: ReturnType<typeof useProjectionListData>["filterFacets"], dateRangeFacets: ReturnType<typeof useProjectionListQuery>["dateRangeFacets"], urlState: ListUrlStateApi, activeNamedFilter: string | undefined): {
|
|
54
54
|
filterValues?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
55
55
|
onFilterReset?: (() => void) | undefined;
|
|
56
56
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { screenFillsHeight } from "../screen-fills-height.js";
|
|
2
|
+
import { routeFacetChange, withNamedFilterValues } from "./named-filter-facet.js";
|
|
2
3
|
export function projectionLeadProps(screen, effectiveTranslate, emptyContent) {
|
|
3
4
|
return {
|
|
4
5
|
...(screen.description !== undefined && {
|
|
@@ -29,7 +30,7 @@ export function projectionFacetProps(filterFacets, dateRangeFacets, onDateRangeC
|
|
|
29
30
|
return {
|
|
30
31
|
...(filterFacets.length > 0 && {
|
|
31
32
|
filterFacets,
|
|
32
|
-
onFilterChange: urlState
|
|
33
|
+
onFilterChange: routeFacetChange(urlState),
|
|
33
34
|
}),
|
|
34
35
|
...(dateRangeFacets.length > 0 && {
|
|
35
36
|
dateRangeFacets,
|
|
@@ -37,10 +38,10 @@ export function projectionFacetProps(filterFacets, dateRangeFacets, onDateRangeC
|
|
|
37
38
|
}),
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
|
-
export function projectionFilterStateProps(filterFacets, dateRangeFacets, urlState) {
|
|
41
|
+
export function projectionFilterStateProps(filterFacets, dateRangeFacets, urlState, activeNamedFilter) {
|
|
41
42
|
return {
|
|
42
43
|
...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
|
|
43
|
-
filterValues: urlState.filters,
|
|
44
|
+
filterValues: withNamedFilterValues(urlState.filters, activeNamedFilter),
|
|
44
45
|
onFilterReset: urlState.clearFilters,
|
|
45
46
|
}),
|
|
46
47
|
};
|
|
@@ -16,5 +16,6 @@ export function renderProjectionList(model) {
|
|
|
16
16
|
const { urlState } = query;
|
|
17
17
|
const { filterFacets, listRows, listTable } = data;
|
|
18
18
|
const { dateRangeFacets } = query;
|
|
19
|
-
return (_jsx(RenderList, { screen: definitions.listScreen, entity: definitions.entity, rows: listRows.rows, featureName: schema.featureName, searchable: definitions.searchable, narrowed:
|
|
19
|
+
return (_jsx(RenderList, { screen: definitions.listScreen, entity: definitions.entity, rows: listRows.rows, featureName: schema.featureName, searchable: definitions.searchable, narrowed: query.activeNamedFilter !== undefined ||
|
|
20
|
+
isListNarrowed(query.activeSearch, urlState.filters), ...projectionLeadProps(screen, effectiveTranslate, actions.emptyContent), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: query.activeSort, onSortChange: urlState.setSort, ...projectionLayoutProps(isEmbeddedScreen, screen, definitions.listScreen, pager, actions.rowActions), ...projectionActionProps(actions, translate, wrappedOnRowClick), ...projectionFacetProps(filterFacets, dateRangeFacets, query.handleDateRangeChange, urlState), ...listTable.props, ...projectionFilterStateProps(filterFacets, dateRangeFacets, urlState, query.activeNamedFilter) }));
|
|
20
21
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { buildFilterFacets } from "./list-facets.js";
|
|
3
|
+
import { buildNamedFilterFacet } from "./named-filter-facet.js";
|
|
3
4
|
import { useScreenListRows } from "./use-screen-list-rows.js";
|
|
4
5
|
import { useScreenListTable } from "./use-screen-list-table.js";
|
|
5
6
|
export function useProjectionListData(input) {
|
|
@@ -21,6 +22,12 @@ export function useProjectionListData(input) {
|
|
|
21
22
|
infinite: false,
|
|
22
23
|
aggregates: listTable.aggregates,
|
|
23
24
|
});
|
|
24
|
-
const filterFacets = useMemo(() =>
|
|
25
|
+
const filterFacets = useMemo(() => {
|
|
26
|
+
const namedFacet = buildNamedFilterFacet(screen.namedFilters, effectiveTranslate);
|
|
27
|
+
return [
|
|
28
|
+
...(namedFacet !== undefined ? [namedFacet] : []),
|
|
29
|
+
...buildFilterFacets(resolvedFacetSpecs),
|
|
30
|
+
];
|
|
31
|
+
}, [resolvedFacetSpecs, screen.namedFilters, effectiveTranslate]);
|
|
25
32
|
return { listTable, listRows, filterFacets };
|
|
26
33
|
}
|
|
@@ -24,5 +24,6 @@ export declare function useProjectionListQuery(input: ProjectionListQueryInput):
|
|
|
24
24
|
dateRangeFacets: import("../data-table-facet-types.ts").DataTableDateRangeFacet[];
|
|
25
25
|
handleDateRangeChange: (field: string, bound: "from" | "to", value: string) => void;
|
|
26
26
|
queryPayload: Record<string, unknown>;
|
|
27
|
+
activeNamedFilter: string | undefined;
|
|
27
28
|
};
|
|
28
29
|
export {};
|
|
@@ -15,6 +15,7 @@ function resolveActiveSort(urlSort, input) {
|
|
|
15
15
|
export function useProjectionListQuery(input) {
|
|
16
16
|
const { screen, featureName, effectiveTranslate, searchable, paginated } = input;
|
|
17
17
|
const urlState = useListUrlState(screen.id);
|
|
18
|
+
const activeNamedFilter = screen.namedFilters === undefined ? undefined : urlState.namedFilter;
|
|
18
19
|
// Gated on the derived capability, not just on whether URL-state happens to
|
|
19
20
|
// carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
|
|
20
21
|
// screen) must not smuggle a param the query's Zod schema doesn't accept.
|
|
@@ -55,9 +56,12 @@ export function useProjectionListQuery(input) {
|
|
|
55
56
|
if (screen.facets !== undefined && filterPayload.length > 0) {
|
|
56
57
|
payload["filters"] = filterPayload;
|
|
57
58
|
}
|
|
59
|
+
if (activeNamedFilter !== undefined)
|
|
60
|
+
payload["namedFilter"] = activeNamedFilter;
|
|
58
61
|
Object.assign(payload, dateRangePayload);
|
|
59
62
|
return payload;
|
|
60
63
|
}, [
|
|
64
|
+
activeNamedFilter,
|
|
61
65
|
limit,
|
|
62
66
|
activeSearch,
|
|
63
67
|
activeSort,
|
|
@@ -80,5 +84,6 @@ export function useProjectionListQuery(input) {
|
|
|
80
84
|
dateRangeFacets,
|
|
81
85
|
handleDateRangeChange,
|
|
82
86
|
queryPayload,
|
|
87
|
+
activeNamedFilter,
|
|
83
88
|
};
|
|
84
89
|
}
|
|
@@ -15,6 +15,8 @@ export type ListUrlState = {
|
|
|
15
15
|
/** Active faceted filters: field → selected values (strings; callers coerce
|
|
16
16
|
* them according to the field type). Empty when no filter is set. */
|
|
17
17
|
readonly filters: Readonly<Record<string, readonly string[]>>;
|
|
18
|
+
/** Id of the active named list filter; undefined when none is set. */
|
|
19
|
+
readonly namedFilter: string | undefined;
|
|
18
20
|
};
|
|
19
21
|
export type ListUrlStateApi = ListUrlState & {
|
|
20
22
|
/** Atomically sets sort + dir. null means unsorted and clears both keys. */
|
|
@@ -29,7 +31,9 @@ export type ListUrlStateApi = ListUrlState & {
|
|
|
29
31
|
/** Sets the selected values for a facet field. An empty array clears the key
|
|
30
32
|
* and resets the page, like sort/search changes. */
|
|
31
33
|
readonly setFilter: (field: string, values: readonly string[]) => void;
|
|
32
|
-
/**
|
|
34
|
+
/** Sets or (undefined) clears the named list filter and resets the page. */
|
|
35
|
+
readonly setNamedFilter: (id: string | undefined) => void;
|
|
36
|
+
/** Clears all active faceted filters and the named filter (for a reset button). */
|
|
33
37
|
readonly clearFilters: () => void;
|
|
34
38
|
/** Sets both bounds of a dateRange facet in ONE params update — two
|
|
35
39
|
* setFilter calls would overwrite each other, since the second still sees
|
|
@@ -43,4 +47,6 @@ export type ListUrlStateApi = ListUrlState & {
|
|
|
43
47
|
export declare const PAGE_SIZE_OPTIONS: readonly number[];
|
|
44
48
|
/** URL key of a list facet filter. Its value is the comma-joined selected values. */
|
|
45
49
|
export declare function listFilterUrlKey(screenId: string, field: string): string;
|
|
50
|
+
/** URL key of the named list filter id. Distinct from the `f.` facet prefix. */
|
|
51
|
+
export declare function listNamedFilterUrlKey(screenId: string): string;
|
|
46
52
|
export declare function useListUrlState(screenId: string, defaultFilters?: Readonly<Record<string, readonly string[]>>): ListUrlStateApi;
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// <screenId>.f.<field> — faceted filter: comma-joined selected values;
|
|
12
12
|
// NO_FILTER_URL_VALUE = "no filter", chosen deliberately
|
|
13
13
|
// for a field that has a declared default
|
|
14
|
+
// <screenId>.filter — id of a named list filter (EntityDefinition.namedFilters);
|
|
15
|
+
// while set, the screen's defaultFilters do not apply
|
|
14
16
|
//
|
|
15
17
|
// Uses setSearchParams (replaceState, not push) so sort/filter toggles do not
|
|
16
18
|
// flood the browser history.
|
|
@@ -38,6 +40,10 @@ function key(screenId, suffix) {
|
|
|
38
40
|
export function listFilterUrlKey(screenId, field) {
|
|
39
41
|
return key(screenId, `f.${field}`);
|
|
40
42
|
}
|
|
43
|
+
/** URL key of the named list filter id. Distinct from the `f.` facet prefix. */
|
|
44
|
+
export function listNamedFilterUrlKey(screenId) {
|
|
45
|
+
return key(screenId, "filter");
|
|
46
|
+
}
|
|
41
47
|
function parseDir(value) {
|
|
42
48
|
return value === "asc" || value === "desc" ? value : undefined;
|
|
43
49
|
}
|
|
@@ -60,6 +66,10 @@ export function useListUrlState(screenId, defaultFilters) {
|
|
|
60
66
|
const q = params[key(screenId, "q")] ?? "";
|
|
61
67
|
const page = parsePage(params[key(screenId, "page")]);
|
|
62
68
|
const pageSize = parsePageSize(params[key(screenId, "size")]);
|
|
69
|
+
const namedFilterParam = params[listNamedFilterUrlKey(screenId)];
|
|
70
|
+
const namedFilter = namedFilterParam === "" ? undefined : namedFilterParam;
|
|
71
|
+
// The named filter defines the view, so author defaults must not narrow it further.
|
|
72
|
+
const effectiveDefaultFilters = namedFilter === undefined ? defaultFilters : undefined;
|
|
63
73
|
const filterPrefix = listFilterUrlKey(screenId, "");
|
|
64
74
|
const filters = useMemo(() => {
|
|
65
75
|
const out = {};
|
|
@@ -73,15 +83,15 @@ export function useListUrlState(screenId, defaultFilters) {
|
|
|
73
83
|
// commas; use repeated keys if comma-containing values are ever needed.
|
|
74
84
|
out[field] = v === NO_FILTER_URL_VALUE ? [] : v.split(",").filter((s) => s !== "");
|
|
75
85
|
}
|
|
76
|
-
for (const [field, values] of Object.entries(
|
|
86
|
+
for (const [field, values] of Object.entries(effectiveDefaultFilters ?? {})) {
|
|
77
87
|
if (!Object.hasOwn(out, field))
|
|
78
88
|
out[field] = values;
|
|
79
89
|
}
|
|
80
90
|
return out;
|
|
81
|
-
}, [params, filterPrefix,
|
|
82
|
-
const clearedFilterValue = useCallback((field) =>
|
|
91
|
+
}, [params, filterPrefix, effectiveDefaultFilters]);
|
|
92
|
+
const clearedFilterValue = useCallback((field) => effectiveDefaultFilters !== undefined && Object.hasOwn(effectiveDefaultFilters, field)
|
|
83
93
|
? NO_FILTER_URL_VALUE
|
|
84
|
-
: null, [
|
|
94
|
+
: null, [effectiveDefaultFilters]);
|
|
85
95
|
const setSort = useCallback((next) => {
|
|
86
96
|
// Atomically reset the page on every sort change; otherwise the user could
|
|
87
97
|
// land on page 5 of the old ordering, which is visually confusing. The
|
|
@@ -124,8 +134,17 @@ export function useListUrlState(screenId, defaultFilters) {
|
|
|
124
134
|
[key(screenId, "page")]: null,
|
|
125
135
|
});
|
|
126
136
|
}, [nav, screenId]);
|
|
137
|
+
const setNamedFilter = useCallback((id) => {
|
|
138
|
+
nav.setSearchParams({
|
|
139
|
+
[listNamedFilterUrlKey(screenId)]: id ?? null,
|
|
140
|
+
[key(screenId, "page")]: null,
|
|
141
|
+
});
|
|
142
|
+
}, [nav, screenId]);
|
|
127
143
|
const clearFilters = useCallback(() => {
|
|
128
|
-
const updates = {
|
|
144
|
+
const updates = {
|
|
145
|
+
[key(screenId, "page")]: null,
|
|
146
|
+
[listNamedFilterUrlKey(screenId)]: null,
|
|
147
|
+
};
|
|
129
148
|
for (const field of Object.keys(filters)) {
|
|
130
149
|
updates[key(screenId, `f.${field}`)] = clearedFilterValue(field);
|
|
131
150
|
}
|
|
@@ -137,11 +156,13 @@ export function useListUrlState(screenId, defaultFilters) {
|
|
|
137
156
|
page,
|
|
138
157
|
pageSize,
|
|
139
158
|
filters,
|
|
159
|
+
namedFilter,
|
|
140
160
|
setSort,
|
|
141
161
|
setQ,
|
|
142
162
|
setPage,
|
|
143
163
|
setPageSize,
|
|
144
164
|
setFilter,
|
|
165
|
+
setNamedFilter,
|
|
145
166
|
clearFilters,
|
|
146
167
|
setDateRange,
|
|
147
168
|
};
|
|
@@ -5,6 +5,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
5
5
|
import { useEffect, useState } from "react";
|
|
6
6
|
import { rendererWarn } from "../../dev-console.js";
|
|
7
7
|
import { EMPTY_VALUE_PLACEHOLDER } from "../../headless/index.js";
|
|
8
|
+
import { listNamedFilterUrlKey } from "../../hooks/use-list-url-state.js";
|
|
8
9
|
import { extensionSectionName, lastSegment, useExtensionSectionComponent, useNav, usePrimitives, useTranslation, useUserRoles, } from "../../index.js";
|
|
9
10
|
import { initialWindowSelection, } from "../widgets/chart-window.js";
|
|
10
11
|
import { chartToneColor, SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
|
|
@@ -41,6 +42,8 @@ function fallbackText(raw, placeholder) {
|
|
|
41
42
|
}
|
|
42
43
|
export function StatPanelBody({ panel, label, screenId, screenParams, translate, variant, }) {
|
|
43
44
|
const formats = useDashboardFormats();
|
|
45
|
+
const nav = useNav();
|
|
46
|
+
const link = statNavigationLink(nav, panel.navigate);
|
|
44
47
|
// Resolved HERE (not in a separate always-rendered child) so `icon` on
|
|
45
48
|
// <StatCard> is `undefined` — not a React element that renders empty —
|
|
46
49
|
// when the icon name isn't registered. StatCard gates its accent chip on
|
|
@@ -54,7 +57,9 @@ export function StatPanelBody({ panel, label, screenId, screenParams, translate,
|
|
|
54
57
|
`"${iconName}", which is not registered in clientFeatures.extensionSectionComponents.`);
|
|
55
58
|
}
|
|
56
59
|
}, [panel.icon, panel.id, iconName, Icon, screenId]);
|
|
57
|
-
const { data, error, loading, refetch } = useDashboardQuery(panel.query,
|
|
60
|
+
const { data, error, loading, refetch } = useDashboardQuery(panel.query,
|
|
61
|
+
// A navigate tile counts exactly what its target list can reproduce from the URL.
|
|
62
|
+
panel.navigate === undefined ? panelPayload(panel, screenParams) : (panel.params ?? {}));
|
|
58
63
|
useReportPanelLoaded(data);
|
|
59
64
|
if (loading && data === null)
|
|
60
65
|
return _jsx(LoadingState, { rows: 2 });
|
|
@@ -86,9 +91,21 @@ export function StatPanelBody({ panel, label, screenId, screenParams, translate,
|
|
|
86
91
|
const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
|
|
87
92
|
const icon = Icon !== undefined ? (_jsx(Icon, { entityName: screenId, entityId: null, screenId: screenId, filterParams: screenParams.filterParams })) : undefined;
|
|
88
93
|
if (variant === "strip") {
|
|
89
|
-
return (_jsx(StatStripCell, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
94
|
+
return (_jsx(StatStripCell, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId, ...(link !== undefined && { link }) }));
|
|
90
95
|
}
|
|
91
|
-
return (_jsx(StatCard, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
96
|
+
return (_jsx(StatCard, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId, ...(link !== undefined && { link }) }));
|
|
97
|
+
}
|
|
98
|
+
function statNavigationLink(nav, navigate) {
|
|
99
|
+
if (navigate === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const target = { screenId: lastSegment(navigate.screen) };
|
|
102
|
+
const options = navigate.namedFilter === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: { searchParams: { [listNamedFilterUrlKey(target.screenId)]: navigate.namedFilter } };
|
|
105
|
+
return {
|
|
106
|
+
href: nav.hrefFor(target, options),
|
|
107
|
+
onPress: () => nav.navigate(target, options),
|
|
108
|
+
};
|
|
92
109
|
}
|
|
93
110
|
function readDelta(panel, record) {
|
|
94
111
|
if (panel.deltaField === undefined || panel.deltaDirectionField === undefined)
|
|
@@ -2,6 +2,11 @@ import type { DashboardStatTone } from "@cosmicdrift/kumiko-types/screen";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
export type StatTone = DashboardStatTone;
|
|
4
4
|
export declare const STAT_CARD_GRID_CLASSES = "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3";
|
|
5
|
+
/** Makes a tile a link; `onPress` is the SPA navigation, `href` serves modified clicks and a11y. */
|
|
6
|
+
export type StatLink = {
|
|
7
|
+
readonly href: string;
|
|
8
|
+
readonly onPress: () => void;
|
|
9
|
+
};
|
|
5
10
|
export type StatDelta = {
|
|
6
11
|
readonly value: string;
|
|
7
12
|
readonly direction: "up" | "down";
|
|
@@ -13,7 +18,7 @@ export declare function Sparkline({ points, className, }: {
|
|
|
13
18
|
}): ReactNode;
|
|
14
19
|
/** Icon-bearing metric tile with an optional delta chip, trend line,
|
|
15
20
|
* and sparkline. `icon` is a ready-made node (the app supplies its SVG). */
|
|
16
|
-
export declare function StatCard({ icon, label, value, sub, tone, accentColor, delta, trend, spark, testId, children, }: {
|
|
21
|
+
export declare function StatCard({ icon, label, value, sub, tone, accentColor, delta, trend, spark, testId, link, children, }: {
|
|
17
22
|
readonly icon?: ReactNode;
|
|
18
23
|
readonly label: string;
|
|
19
24
|
readonly value: string;
|
|
@@ -24,13 +29,14 @@ export declare function StatCard({ icon, label, value, sub, tone, accentColor, d
|
|
|
24
29
|
readonly trend?: string;
|
|
25
30
|
readonly spark?: readonly number[];
|
|
26
31
|
readonly testId?: string;
|
|
32
|
+
readonly link?: StatLink;
|
|
27
33
|
/** Extra content below the built-in fields (e.g. a usage meter) — kept
|
|
28
34
|
* inside the same card chrome instead of stacking a second bordered box. */
|
|
29
35
|
readonly children?: ReactNode;
|
|
30
36
|
}): ReactNode;
|
|
31
37
|
/** Flat KPI cell for a strip (divider instead of card): label, value with
|
|
32
38
|
* sparkline beside it, sub line. */
|
|
33
|
-
export declare function StatStripCell({ icon, label, value, sub, tone, accentColor, delta, spark, testId, }: {
|
|
39
|
+
export declare function StatStripCell({ icon, label, value, sub, tone, accentColor, delta, spark, testId, link, }: {
|
|
34
40
|
/** Chip before the label, colored like StatCard's (`accentColor` or the tone). */
|
|
35
41
|
readonly icon?: ReactNode;
|
|
36
42
|
readonly label: string;
|
|
@@ -41,6 +47,7 @@ export declare function StatStripCell({ icon, label, value, sub, tone, accentCol
|
|
|
41
47
|
readonly delta?: StatDelta;
|
|
42
48
|
readonly spark?: readonly number[];
|
|
43
49
|
readonly testId?: string;
|
|
50
|
+
readonly link?: StatLink;
|
|
44
51
|
}): ReactNode;
|
|
45
52
|
/** Compact metric tile (without an icon) — for dense KPI grids.
|
|
46
53
|
* StatCard is the icon-bearing variant. */
|
package/dist/web/widgets/stat.js
CHANGED
|
@@ -18,6 +18,12 @@ const TONE_VALUE = {
|
|
|
18
18
|
warn: "text-destructive",
|
|
19
19
|
negative: "text-status-bad",
|
|
20
20
|
};
|
|
21
|
+
function StatLinkFrame({ link, testId, children, }) {
|
|
22
|
+
const { Link } = usePrimitives();
|
|
23
|
+
if (link === undefined)
|
|
24
|
+
return children;
|
|
25
|
+
return (_jsx(Link, { href: link.href, onPress: link.onPress, className: "block min-w-0 text-inherit hover:no-underline", ...(testId !== undefined && { testId: `${testId}-link` }), children: children }));
|
|
26
|
+
}
|
|
21
27
|
// Inline SVG sparkline (no chart dependency for a 28px curve): line +
|
|
22
28
|
// subtle area gradient, colored via currentColor (caller sets text-tone).
|
|
23
29
|
export function Sparkline({ points, className, }) {
|
|
@@ -47,18 +53,19 @@ function StatIconChip({ icon, tone, accentColor, }) {
|
|
|
47
53
|
}
|
|
48
54
|
/** Icon-bearing metric tile with an optional delta chip, trend line,
|
|
49
55
|
* and sparkline. `icon` is a ready-made node (the app supplies its SVG). */
|
|
50
|
-
export function StatCard({ icon, label, value, sub, tone = "default", accentColor, delta, trend, spark, testId, children, }) {
|
|
56
|
+
export function StatCard({ icon, label, value, sub, tone = "default", accentColor, delta, trend, spark, testId, link, children, }) {
|
|
51
57
|
const { Card } = usePrimitives();
|
|
52
58
|
// StatCard is a reusable small-tile widget (a metrics dashboard grid, not
|
|
53
59
|
// a whole-section frame) — an extension section mounting several of
|
|
54
60
|
// these renders a grid of tiles inside its own host Card, not a
|
|
55
61
|
// duplicate outer frame around the section's full content.
|
|
56
|
-
|
|
62
|
+
const tile = (_jsxs(Card, { options: { padded: false }, className: "p-4", testId: testId, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 basis-[7rem] items-center gap-2 text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { title: label, className: "min-w-0 line-clamp-2 text-xs font-medium", children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsx("div", { className: "mt-2 text-xl font-semibold tabular-nums text-foreground", children: value }), sub !== undefined && _jsx("div", { className: "mt-0.5 text-xs text-muted-foreground", children: sub }), trend !== undefined && (_jsx("div", { className: "mt-0.5 text-xs font-medium text-foreground/80", children: trend })), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("mt-2 h-7 w-full", TONE_VALUE[tone]) })), children !== undefined && _jsx("div", { className: "mt-3", children: children })] }));
|
|
63
|
+
return (_jsx(StatLinkFrame, { link: link, testId: testId, children: tile }));
|
|
57
64
|
}
|
|
58
65
|
/** Flat KPI cell for a strip (divider instead of card): label, value with
|
|
59
66
|
* sparkline beside it, sub line. */
|
|
60
|
-
export function StatStripCell({ icon, label, value, sub, tone = "default", accentColor, delta, spark, testId, }) {
|
|
61
|
-
return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-2 gap-y-1", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: cn("text-xl font-semibold tabular-nums", TONE_VALUE[tone]), children: value }), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("h-7 w-20 shrink-0", TONE_VALUE[tone]) }))] }), sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: sub })] }));
|
|
67
|
+
export function StatStripCell({ icon, label, value, sub, tone = "default", accentColor, delta, spark, testId, link, }) {
|
|
68
|
+
return (_jsx(StatLinkFrame, { link: link, testId: testId, children: _jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-2 gap-y-1", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground", children: [icon !== undefined && (_jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor })), _jsx("span", { children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: cn("text-xl font-semibold tabular-nums", TONE_VALUE[tone]), children: value }), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("h-7 w-20 shrink-0", TONE_VALUE[tone]) }))] }), sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: sub })] }) }));
|
|
62
69
|
}
|
|
63
70
|
/** Compact metric tile (without an icon) — for dense KPI grids.
|
|
64
71
|
* StatCard is the icon-bearing variant. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.403.0",
|
|
4
4
|
"description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
51
|
+
"@cosmicdrift/kumiko-framework": "0.403.0",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.403.0",
|
|
53
53
|
"@types/qrcode": "^1.5.5",
|
|
54
54
|
"class-variance-authority": "^0.7.1",
|
|
55
55
|
"clsx": "^2.1.1",
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
83
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
82
|
+
"@cosmicdrift/kumiko-locale-de": "0.403.0",
|
|
83
|
+
"@cosmicdrift/kumiko-testing": "0.403.0",
|
|
84
84
|
"@tailwindcss/cli": "^4.3.3",
|
|
85
85
|
"@testing-library/react": "^16.3.3",
|
|
86
86
|
"@testing-library/user-event": "^14.6.8",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.403.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Lists and dashboard stats take named filters",
|
|
6
|
+
"detail": "entityList and projectionList take namedFilters [{ id, label }], shown as a removable chip and kept in the URL as <screenId>.filter; it reaches the list, facet and count queries, and defaultFilters are skipped while it is active. A dashboard stat takes navigate { screen, namedFilter } and becomes a link to the filtered list. The boot validator checks that ids exist, that a projectionList query accepts namedFilter, and that the tile counts with the target list's own query and namedFilter."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.402.0",
|
|
4
10
|
"type": "improvement",
|