@cosmicdrift/kumiko-renderer 0.402.0 → 0.404.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/sse/live-events.d.ts +3 -0
- package/dist/web/sse/live-events.js +36 -1
- 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 +12 -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)
|
|
@@ -10,6 +10,8 @@ export type CreateEventSourceLiveEventsOptions = {
|
|
|
10
10
|
readonly onSessionEnded?: () => void;
|
|
11
11
|
/** Reconnect-timer seam; defaults to the global setTimeout/clearTimeout. */
|
|
12
12
|
readonly timers?: LiveEventTimers;
|
|
13
|
+
/** Stall-watchdog timer seam; defaults to the global setTimeout/clearTimeout. */
|
|
14
|
+
readonly stallTimers?: LiveEventTimers;
|
|
13
15
|
/** Jitter source in [0, 1); defaults to Math.random. */
|
|
14
16
|
readonly random?: () => number;
|
|
15
17
|
/** Clock for synthetic resync events; defaults to Date.now. */
|
|
@@ -21,6 +23,7 @@ export type LiveEventTimers = {
|
|
|
21
23
|
};
|
|
22
24
|
export declare const RECONNECT_BASE_DELAY_MS = 1000;
|
|
23
25
|
export declare const RECONNECT_MAX_DELAY_MS = 30000;
|
|
26
|
+
export declare const SSE_STALL_TIMEOUT_MS = 30000;
|
|
24
27
|
export declare function reconnectDelayMs(attempt: number, random: number): number;
|
|
25
28
|
/** Returns an EventSource-backed `LiveEventSubscriber`.
|
|
26
29
|
* Typically called once during app bootstrap and passed as a value to
|
|
@@ -3,6 +3,9 @@ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
|
3
3
|
import { isSessionEndedError, LIVE_RESYNC_EVENT_TYPE, } from "../../index.js";
|
|
4
4
|
export const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
5
5
|
export const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
6
|
+
// Two missed server heartbeats (15 s each, SSE_HEARTBEAT_INTERVAL_MS in framework sse-route.ts).
|
|
7
|
+
// A KEDA interceptor can leave the stream hanging without an `error` event (kedacore/http-add-on#1803).
|
|
8
|
+
export const SSE_STALL_TIMEOUT_MS = 30_000;
|
|
6
9
|
const defaultTimers = {
|
|
7
10
|
set: (callback, delayMs) => {
|
|
8
11
|
const handle = setTimeout(callback, delayMs);
|
|
@@ -67,6 +70,7 @@ async function isSessionEndedRejection(url) {
|
|
|
67
70
|
export function createEventSourceLiveEvents(options = {}) {
|
|
68
71
|
const url = options.url ?? "/api/sse";
|
|
69
72
|
const timers = options.timers ?? defaultTimers;
|
|
73
|
+
const stallTimers = options.stallTimers ?? defaultTimers;
|
|
70
74
|
const random = options.random ?? Math.random;
|
|
71
75
|
const now = options.now ?? Date.now;
|
|
72
76
|
const subscribers = new Set();
|
|
@@ -74,6 +78,25 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
74
78
|
const wiredEntities = new Set();
|
|
75
79
|
let cancelReconnect;
|
|
76
80
|
let reconnectAttempt = 0;
|
|
81
|
+
let cancelStallWatchdog;
|
|
82
|
+
const disarmStallWatchdog = () => {
|
|
83
|
+
cancelStallWatchdog?.();
|
|
84
|
+
cancelStallWatchdog = undefined;
|
|
85
|
+
};
|
|
86
|
+
const armStallWatchdog = (connection) => {
|
|
87
|
+
// skip: a frame from a connection that is no longer the current one
|
|
88
|
+
if (source !== connection)
|
|
89
|
+
return;
|
|
90
|
+
disarmStallWatchdog();
|
|
91
|
+
cancelStallWatchdog = stallTimers.set(() => {
|
|
92
|
+
cancelStallWatchdog = undefined;
|
|
93
|
+
// skip: a late timer of a connection that was already replaced
|
|
94
|
+
if (source !== connection)
|
|
95
|
+
return;
|
|
96
|
+
connection.close();
|
|
97
|
+
scheduleReconnect();
|
|
98
|
+
}, SSE_STALL_TIMEOUT_MS);
|
|
99
|
+
};
|
|
77
100
|
// `subscribers` is a flat Set across all entities — the browser-side
|
|
78
101
|
// addEventListener(entityName, ...) gate below only filters which frames
|
|
79
102
|
// arrive at all, not which subscriber a given frame is for. Without this
|
|
@@ -119,6 +142,7 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
119
142
|
return;
|
|
120
143
|
connection.addEventListener(entityName, (e) => {
|
|
121
144
|
const data = propertyOf(e, "data");
|
|
145
|
+
armStallWatchdog(connection);
|
|
122
146
|
if (typeof data === "string")
|
|
123
147
|
handleEvent(data, entityName);
|
|
124
148
|
});
|
|
@@ -142,14 +166,22 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
142
166
|
sessionEndedSignalled = true;
|
|
143
167
|
onSessionEnded?.();
|
|
144
168
|
};
|
|
169
|
+
armStallWatchdog(connection);
|
|
170
|
+
connection.addEventListener("ping", () => armStallWatchdog(connection));
|
|
145
171
|
connection.addEventListener("open", () => {
|
|
172
|
+
armStallWatchdog(connection);
|
|
146
173
|
reconnectAttempt = 0;
|
|
147
174
|
emitResync();
|
|
148
175
|
});
|
|
149
176
|
connection.addEventListener(SESSION_ENDED_EVENT, (e) => {
|
|
150
177
|
const code = sessionEndedFrameCode(e);
|
|
151
|
-
if (code !== undefined && isSessionEndedError({ code, httpStatus: 401 }))
|
|
178
|
+
if (code !== undefined && isSessionEndedError({ code, httpStatus: 401 })) {
|
|
179
|
+
disarmStallWatchdog();
|
|
152
180
|
signalSessionEnded();
|
|
181
|
+
// skip: a reconnect after a session end would only be refused again
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
armStallWatchdog(connection);
|
|
153
185
|
});
|
|
154
186
|
connection.addEventListener("error", () => {
|
|
155
187
|
// skip: CONNECTING means a dropped stream the browser retries itself;
|
|
@@ -161,10 +193,12 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
161
193
|
if (source !== connection)
|
|
162
194
|
return;
|
|
163
195
|
if (sessionEnded) {
|
|
196
|
+
disarmStallWatchdog();
|
|
164
197
|
signalSessionEnded();
|
|
165
198
|
// skip: the session ended, reconnecting would only be refused again
|
|
166
199
|
return;
|
|
167
200
|
}
|
|
201
|
+
disarmStallWatchdog();
|
|
168
202
|
scheduleReconnect();
|
|
169
203
|
});
|
|
170
204
|
});
|
|
@@ -196,6 +230,7 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
196
230
|
cancelReconnect = undefined;
|
|
197
231
|
}
|
|
198
232
|
reconnectAttempt = 0;
|
|
233
|
+
disarmStallWatchdog();
|
|
199
234
|
// skip: already closed, nothing to close
|
|
200
235
|
if (source === undefined)
|
|
201
236
|
return;
|
|
@@ -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.404.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.404.0",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.404.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.404.0",
|
|
83
|
+
"@cosmicdrift/kumiko-testing": "0.404.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,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.404.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "SSE client reconnects after a stalled stream",
|
|
6
|
+
"detail": "createEventSourceLiveEvents arms a 30 s watchdog (two missed server heartbeats) that is reset by open, ping and every entity or session-ended frame. On expiry it closes the connection and reconnects through the existing backoff path, which resyncs all listeners. The timer is cleared on close and error. The seam is the new stallTimers option."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.403.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Lists and dashboard stats take named filters",
|
|
12
|
+
"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."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.402.0",
|
|
4
16
|
"type": "improvement",
|