@cosmicdrift/kumiko-renderer 0.398.0 → 0.399.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-facet-specs.js +2 -0
- package/dist/app/entity-list-screen.js +3 -3
- package/dist/app/list-facets.d.ts +7 -4
- package/dist/app/list-facets.js +23 -6
- package/dist/app/projection-list-body.js +1 -1
- package/dist/app/reference-facet-bridge.d.ts +14 -2
- package/dist/app/reference-facet-bridge.js +37 -18
- package/dist/app/use-projection-list-query.d.ts +2 -2
- package/dist/app/use-projection-list-query.js +2 -1
- package/dist/components/related-list-section.js +3 -3
- package/dist/components/render-edit-fields-section.js +1 -1
- package/dist/data-table-facet-types.d.ts +5 -0
- package/dist/i18n-defaults.js +8 -0
- package/dist/web/layout/notification-bell.d.ts +5 -0
- package/dist/web/layout/notification-bell.js +39 -0
- package/dist/web/layout/shell-header.js +4 -1
- package/dist/web/layout/use-in-app-inbox.d.ts +15 -0
- package/dist/web/layout/use-in-app-inbox.js +61 -0
- package/dist/web/primitives/facet-filter.js +4 -0
- package/dist/web/primitives/facet-search-filter.d.ts +9 -0
- package/dist/web/primitives/facet-search-filter.js +31 -0
- package/dist/web/sse/live-events.js +14 -3
- package/package.json +5 -5
- package/src/changes.json +18 -0
|
@@ -13,6 +13,7 @@ export function resolveEntityFacetSpecs(fields, featureName, entityName, transla
|
|
|
13
13
|
options: propertyOf(rawDef, "options"),
|
|
14
14
|
entity: propertyOf(rawDef, "entity"),
|
|
15
15
|
labelField: propertyOf(rawDef, "labelField"),
|
|
16
|
+
optionsQuery: propertyOf(rawDef, "optionsQuery"),
|
|
16
17
|
};
|
|
17
18
|
if (def.filterable !== true)
|
|
18
19
|
continue;
|
|
@@ -57,6 +58,7 @@ export function resolveEntityFacetSpecs(fields, featureName, entityName, transla
|
|
|
57
58
|
refEntity: target.entityName,
|
|
58
59
|
refFeature: target.featureName,
|
|
59
60
|
labelField: typeof def.labelField === "string" ? def.labelField : "id",
|
|
61
|
+
...(typeof def.optionsQuery === "string" && { optionsQuery: def.optionsQuery }),
|
|
60
62
|
},
|
|
61
63
|
});
|
|
62
64
|
}
|
|
@@ -25,7 +25,7 @@ import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./l
|
|
|
25
25
|
import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
|
|
26
26
|
import { useNav } from "./nav.js";
|
|
27
27
|
import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
|
|
28
|
-
import { ReferenceFacetBridges } from "./reference-facet-bridge.js";
|
|
28
|
+
import { ReferenceFacetBridges, withReferenceFacetState, } from "./reference-facet-bridge.js";
|
|
29
29
|
import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
|
|
30
30
|
import { buildClientActionRowAction, buildDefaultEditRowAction, buildDrawerRowAction, buildProjectionToolbarActions, buildWriteHandlerRowAction, findRowClickAction, navigateActionSearchParams, runDrawerRowAction, } from "./row-actions.js";
|
|
31
31
|
import { findEditScreenFor } from "./screen-access.js";
|
|
@@ -154,7 +154,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
154
154
|
await refreshListAfterWrite();
|
|
155
155
|
}, [refreshFacetCounts, refreshListAfterWrite]);
|
|
156
156
|
const [entityReferenceFacetOptions, setEntityReferenceFacetOptions] = useState({});
|
|
157
|
-
const handleEntityFacetOptions = useCallback((field,
|
|
157
|
+
const handleEntityFacetOptions = useCallback((field, state) => setEntityReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
|
|
158
158
|
const [facetCounts, setFacetCounts] = useState({});
|
|
159
159
|
const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
|
|
160
160
|
const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
|
|
@@ -395,7 +395,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
|
|
|
395
395
|
// arrives async; until it is there RenderList renders the table without a
|
|
396
396
|
// pager (the pager has its own total>0 guard).
|
|
397
397
|
const pager = listPager({ usePager, page: listRows.page, limit, urlState });
|
|
398
|
-
return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs,
|
|
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: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
|
|
399
399
|
description: effectiveTranslate(screen.description),
|
|
400
400
|
}), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
|
|
401
401
|
? { screenPadding: false, chromeless: true }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntityListFacetConfig, ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
2
|
import type { Translate } from "../headless/index.ts";
|
|
3
3
|
import type { DataTableFacet, DataTableFacetChip } from "../primitives.tsx";
|
|
4
|
+
import type { ReferenceFacetState } from "./reference-facet-bridge.tsx";
|
|
4
5
|
export type ResolvedFacetSpec = {
|
|
5
6
|
readonly field: string;
|
|
6
7
|
readonly type: "select" | "boolean" | "reference";
|
|
@@ -17,12 +18,14 @@ export type ResolvedFacetSpec = {
|
|
|
17
18
|
readonly refEntity: string;
|
|
18
19
|
readonly refFeature: string;
|
|
19
20
|
readonly labelField: string;
|
|
21
|
+
/** Author handler that serves the options instead of the entity list. */
|
|
22
|
+
readonly optionsQuery?: string;
|
|
20
23
|
};
|
|
24
|
+
/** Reference facets only: set once the bridge resolved it. Makes the facet searchable. */
|
|
25
|
+
readonly onSearchChange?: (query: string) => void;
|
|
26
|
+
readonly searching?: boolean;
|
|
21
27
|
};
|
|
22
|
-
export declare function mergeReferenceFacetOptions(specs: readonly ResolvedFacetSpec[],
|
|
23
|
-
readonly value: string;
|
|
24
|
-
readonly label: string;
|
|
25
|
-
}[]>>): ResolvedFacetSpec[];
|
|
28
|
+
export declare function mergeReferenceFacetOptions(specs: readonly ResolvedFacetSpec[], stateByField: Readonly<Record<string, ReferenceFacetState>>): ResolvedFacetSpec[];
|
|
26
29
|
export declare function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[];
|
|
27
30
|
type FacetConfigByField = Readonly<Record<string, EntityListFacetConfig | false>>;
|
|
28
31
|
export declare function buildFacetChips(spec: ResolvedFacetSpec, config: EntityListFacetConfig, translate: Translate): DataTableFacetChip[];
|
package/dist/app/list-facets.js
CHANGED
|
@@ -4,13 +4,30 @@ import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
4
4
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
5
5
|
// Fills in a "reference" spec's initially-empty `options` from the
|
|
6
6
|
// field-keyed options ReferenceFacetBridges resolved, shared by every caller.
|
|
7
|
-
export function mergeReferenceFacetOptions(specs,
|
|
8
|
-
return specs.map((spec) =>
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
export function mergeReferenceFacetOptions(specs, stateByField) {
|
|
8
|
+
return specs.map((spec) => {
|
|
9
|
+
const state = stateByField[spec.field];
|
|
10
|
+
return spec.reference !== undefined && state !== undefined
|
|
11
|
+
? {
|
|
12
|
+
...spec,
|
|
13
|
+
options: state.options,
|
|
14
|
+
onSearchChange: state.onSearchChange,
|
|
15
|
+
searching: state.searching,
|
|
16
|
+
}
|
|
17
|
+
: spec;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function facetBase(spec) {
|
|
21
|
+
return {
|
|
22
|
+
field: spec.field,
|
|
23
|
+
label: spec.label,
|
|
24
|
+
options: spec.options,
|
|
25
|
+
...(spec.onSearchChange !== undefined && { onSearchChange: spec.onSearchChange }),
|
|
26
|
+
...(spec.searching !== undefined && { searching: spec.searching }),
|
|
27
|
+
};
|
|
11
28
|
}
|
|
12
29
|
export function buildFilterFacets(specs) {
|
|
13
|
-
return specs.map(
|
|
30
|
+
return specs.map(facetBase);
|
|
14
31
|
}
|
|
15
32
|
// Chips are the field's own options plus the declared extra choices
|
|
16
33
|
// ("Running" bundle, "All"), in the order the config's `position` asks for.
|
|
@@ -39,7 +56,7 @@ export function buildEntityFilterFacets(specs, configs, translate, counts) {
|
|
|
39
56
|
const config = configs?.[spec.field];
|
|
40
57
|
if (config === false)
|
|
41
58
|
continue;
|
|
42
|
-
const base =
|
|
59
|
+
const base = facetBase(spec);
|
|
43
60
|
if (config?.display !== "chips") {
|
|
44
61
|
out.push(base);
|
|
45
62
|
continue;
|
|
@@ -108,7 +108,7 @@ export function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
|
|
|
108
108
|
limit: query.limit,
|
|
109
109
|
urlState: query.urlState,
|
|
110
110
|
});
|
|
111
|
-
return (_jsxs(_Fragment, { children: [refetchErrorBanner, actions.toolbarGateQueries, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs,
|
|
111
|
+
return (_jsxs(_Fragment, { children: [refetchErrorBanner, actions.toolbarGateQueries, _jsx(ListGroupPageBridges, { ...data.listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: query.facetSpecs, selected: query.urlState.filters, onState: query.handleProjectionFacetOptions }), renderProjectionList({
|
|
112
112
|
schema,
|
|
113
113
|
screen,
|
|
114
114
|
translate,
|
|
@@ -4,7 +4,19 @@ export type ReferenceFacetOption = {
|
|
|
4
4
|
readonly value: string;
|
|
5
5
|
readonly label: string;
|
|
6
6
|
};
|
|
7
|
-
|
|
7
|
+
/** What a bridge publishes for its facet: the options for the current search term plus the
|
|
8
|
+
* handle that changes the term. */
|
|
9
|
+
export type ReferenceFacetState = {
|
|
10
|
+
readonly options: readonly ReferenceFacetOption[];
|
|
11
|
+
readonly onSearchChange: (query: string) => void;
|
|
12
|
+
readonly searching: boolean;
|
|
13
|
+
};
|
|
14
|
+
/** Reducer step for the callers' state: same state keeps the previous object, so an unchanged
|
|
15
|
+
* bridge does not re-render the list. */
|
|
16
|
+
export declare function withReferenceFacetState(previous: Readonly<Record<string, ReferenceFacetState>>, field: string, state: ReferenceFacetState): Readonly<Record<string, ReferenceFacetState>>;
|
|
17
|
+
export declare function ReferenceFacetBridges({ specs, selected, onState, }: {
|
|
8
18
|
readonly specs: readonly ResolvedFacetSpec[];
|
|
9
|
-
|
|
19
|
+
/** The facet selections of the list (field to chosen ids), so chosen values keep their label. */
|
|
20
|
+
readonly selected: Readonly<Record<string, readonly string[]>>;
|
|
21
|
+
readonly onState: (field: string, state: ReferenceFacetState) => void;
|
|
10
22
|
}): ReactNode;
|
|
@@ -1,29 +1,48 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// One bridge component per reference facet, mirroring ReferenceLookupBridge —
|
|
3
3
|
// facet count varies per screen, so the hook can't be called in a loop.
|
|
4
|
-
import { useEffect } from "react";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
6
|
+
import { useQuery } from "../hooks/use-query.js";
|
|
7
|
+
import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
|
|
8
|
+
/** Reducer step for the callers' state: same state keeps the previous object, so an unchanged
|
|
9
|
+
* bridge does not re-render the list. */
|
|
10
|
+
export function withReferenceFacetState(previous, field, state) {
|
|
11
|
+
const before = previous[field];
|
|
12
|
+
const unchanged = before?.options === state.options &&
|
|
13
|
+
before.searching === state.searching &&
|
|
14
|
+
before.onSearchChange === state.onSearchChange;
|
|
15
|
+
return unchanged ? previous : { ...previous, [field]: state };
|
|
16
|
+
}
|
|
17
|
+
function ReferenceFacetOptionsBridge({ field, refEntity, refFeature, labelField, optionsQuery, selected, onState, }) {
|
|
18
|
+
const { queryQn, labelKey } = referenceOptionSource({
|
|
19
|
+
refLabelField: labelField,
|
|
20
|
+
...(optionsQuery !== undefined && { refOptionsQuery: optionsQuery }),
|
|
21
|
+
}, refFeature, refEntity);
|
|
22
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
23
|
+
const payload = useMemo(() => searchTerm === ""
|
|
24
|
+
? { limit: REFERENCE_COMBOBOX_LIMIT }
|
|
25
|
+
: { limit: REFERENCE_COMBOBOX_LIMIT, search: searchTerm }, [searchTerm]);
|
|
26
|
+
const listed = useQuery(queryQn, payload);
|
|
27
|
+
// The first page can miss a chosen value; without its row the chip would show the raw id.
|
|
28
|
+
const chosen = useQuery(queryQn, storedReferenceRowsPayload(optionsQuery !== undefined ? { refOptionsQuery: optionsQuery } : {}, selected, REFERENCE_COMBOBOX_LIMIT), { enabled: selected.length > 0 });
|
|
29
|
+
const onSearchChange = useCallback((query) => setSearchTerm(query), []);
|
|
30
|
+
const options = useMemo(() => withUnlistedSelectedRows(listed.data?.rows ?? [], chosen.data?.rows ?? []).map((row) => {
|
|
31
|
+
const value = String(row["id"] ?? "");
|
|
32
|
+
return { value, label: String(row[labelKey] ?? value) };
|
|
33
|
+
}), [listed.data, chosen.data, labelKey]);
|
|
34
|
+
const searching = listed.loading;
|
|
10
35
|
useEffect(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// The lookup is one capped list fetch; a full page means the facet may be
|
|
14
|
-
// missing values the user wants to filter by.
|
|
15
|
-
if (lookup.map.size >= REFERENCE_LIST_LOOKUP_LIMIT) {
|
|
16
|
-
rendererWarn(`[kumiko] reference facet "${field}" shows only the first ${REFERENCE_LIST_LOOKUP_LIMIT} ${refEntity} rows; further values cannot be filtered by`);
|
|
17
|
-
}
|
|
18
|
-
onOptions(field, [...lookup.map.entries()].map(([value, label]) => ({ value, label })));
|
|
19
|
-
}, [lookup.loading, lookup.map, field, refEntity, onOptions]);
|
|
36
|
+
onState(field, { options, onSearchChange, searching });
|
|
37
|
+
}, [field, options, onSearchChange, searching, onState]);
|
|
20
38
|
return null;
|
|
21
39
|
}
|
|
22
40
|
// One invisible bridge per "reference" facet spec, each publishing its
|
|
23
|
-
// resolved options back via `
|
|
24
|
-
export function ReferenceFacetBridges({ specs,
|
|
41
|
+
// resolved options back via `onState` once loaded.
|
|
42
|
+
export function ReferenceFacetBridges({ specs, selected, onState, }) {
|
|
25
43
|
const referenceSpecs = specs.filter((spec) => spec.reference !== undefined);
|
|
26
44
|
if (referenceSpecs.length === 0)
|
|
27
45
|
return null;
|
|
28
|
-
return (_jsx(_Fragment, { children: referenceSpecs.map((spec) => (_jsx(ReferenceFacetOptionsBridge, { field: spec.field, refEntity: spec.reference?.refEntity ?? "", refFeature: spec.reference?.refFeature ?? "", labelField: spec.reference?.labelField ?? "id",
|
|
46
|
+
return (_jsx(_Fragment, { children: referenceSpecs.map((spec) => (_jsx(ReferenceFacetOptionsBridge, { field: spec.field, refEntity: spec.reference?.refEntity ?? "", refFeature: spec.reference?.refFeature ?? "", labelField: spec.reference?.labelField ?? "id", optionsQuery: spec.reference?.optionsQuery, selected: selected[spec.field] ?? NO_SELECTION, onState: onState }, spec.field))) }));
|
|
29
47
|
}
|
|
48
|
+
const NO_SELECTION = [];
|
|
@@ -2,7 +2,7 @@ import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framewo
|
|
|
2
2
|
import type { Translate } from "../headless/index.ts";
|
|
3
3
|
import { type ListUrlStateApi } from "../hooks/use-list-url-state.ts";
|
|
4
4
|
import type { synthesizeProjectionEntity } from "./projection-list-shim.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { type ReferenceFacetState } from "./reference-facet-bridge.tsx";
|
|
6
6
|
type ProjectionListQueryInput = {
|
|
7
7
|
readonly screen: ProjectionListScreenDefinition;
|
|
8
8
|
readonly featureName: string;
|
|
@@ -19,7 +19,7 @@ export declare function useProjectionListQuery(input: ProjectionListQueryInput):
|
|
|
19
19
|
limit: number;
|
|
20
20
|
usePager: boolean;
|
|
21
21
|
facetSpecs: import("./list-facets.ts").ResolvedFacetSpec[];
|
|
22
|
-
handleProjectionFacetOptions: (field: string,
|
|
22
|
+
handleProjectionFacetOptions: (field: string, state: ReferenceFacetState) => void;
|
|
23
23
|
resolvedFacetSpecs: import("./list-facets.ts").ResolvedFacetSpec[];
|
|
24
24
|
dateRangeFacets: import("../data-table-facet-types.ts").DataTableDateRangeFacet[];
|
|
25
25
|
handleDateRangeChange: (field: string, bound: "from" | "to", value: string) => void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState } from "react";
|
|
2
2
|
import { useListUrlState } from "../hooks/use-list-url-state.js";
|
|
3
3
|
import { buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "./list-facets.js";
|
|
4
|
+
import { withReferenceFacetState } from "./reference-facet-bridge.js";
|
|
4
5
|
import { buildListQueryPayload } from "./screen-shared.js";
|
|
5
6
|
import { useProjectionDateRange } from "./use-projection-date-range.js";
|
|
6
7
|
// Same predicate that makes a header clickable: a stale link onto a
|
|
@@ -30,7 +31,7 @@ export function useProjectionListQuery(input) {
|
|
|
30
31
|
// buildFilterFacets/buildFilterPayload.
|
|
31
32
|
const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(screen.facets, effectiveTranslate, featureName), [screen.facets, effectiveTranslate, featureName]);
|
|
32
33
|
const [projectionReferenceFacetOptions, setProjectionReferenceFacetOptions] = useState({});
|
|
33
|
-
const handleProjectionFacetOptions = useCallback((field,
|
|
34
|
+
const handleProjectionFacetOptions = useCallback((field, state) => setProjectionReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
|
|
34
35
|
const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, projectionReferenceFacetOptions), [facetSpecs, projectionReferenceFacetOptions]);
|
|
35
36
|
const filterPayload = useMemo(() => buildFilterPayload(urlState.filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type), [urlState.filters, facetSpecs]);
|
|
36
37
|
const { dateRangePayload, dateRangeFacets, handleDateRangeChange } = useProjectionDateRange(screen.facets, effectiveTranslate, urlState);
|
|
@@ -8,7 +8,7 @@ import { useListBulk } from "../app/list-bulk-actions.js";
|
|
|
8
8
|
import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
|
|
9
9
|
import { useNav } from "../app/nav.js";
|
|
10
10
|
import { SYNTHESIZED_FIELD_PII_REASON } from "../app/projection-list-shim.js";
|
|
11
|
-
import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
|
|
11
|
+
import { ReferenceFacetBridges, withReferenceFacetState, } from "../app/reference-facet-bridge.js";
|
|
12
12
|
import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
|
|
13
13
|
import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, findRowClickAction, runDrawerRowAction, runProjectionRowNavigate, } from "../app/row-actions.js";
|
|
14
14
|
import { findEditScreenFor } from "../app/screen-access.js";
|
|
@@ -142,7 +142,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
142
142
|
const { search, filters, sort, setSearch, setFilters, setSort, listTable, groupField } = useRelatedListState(section, nav.route?.screenId);
|
|
143
143
|
const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
|
|
144
144
|
const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
|
|
145
|
-
const handleFacetOptions = useCallback((field,
|
|
145
|
+
const handleFacetOptions = useCallback((field, state) => setReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
|
|
146
146
|
const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions), [facetSpecs, referenceFacetOptions]);
|
|
147
147
|
const filterPayload = useMemo(() => buildFilterPayload(filters, (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type), [filters, resolvedFacetSpecs]);
|
|
148
148
|
const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
|
|
@@ -332,7 +332,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
332
332
|
// `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
|
|
333
333
|
// Section card and document-flow height since they render a visible title
|
|
334
334
|
// and aren't confined to a tab panel.
|
|
335
|
-
const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs,
|
|
335
|
+
const bridges = (_jsx(ReferenceFacetBridges, { specs: facetSpecs, selected: filters, onState: handleFacetOptions }));
|
|
336
336
|
if (embedded === true) {
|
|
337
337
|
const countSuffix = listRows.hasData
|
|
338
338
|
? ` · ${aggregates.serverGroups?.totalCount ?? listRows.rows.length}`
|
|
@@ -10,7 +10,7 @@ function renderTabbedGroupsSection(rc, section, sectionKey) {
|
|
|
10
10
|
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
11
11
|
// combination in tabs mode, so `sectionActionsEl` is always
|
|
12
12
|
// undefined here at runtime.
|
|
13
|
-
return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
|
|
13
|
+
return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, ...(section.description !== undefined && { slots: { subtitle: section.description } }), children: groupsGrid }, sectionKey));
|
|
14
14
|
}
|
|
15
15
|
function renderTabbedFieldsSection(rc, section, sectionKey) {
|
|
16
16
|
if (section.groups !== undefined)
|
|
@@ -16,6 +16,11 @@ export type DataTableFacet = {
|
|
|
16
16
|
readonly showCounts?: boolean;
|
|
17
17
|
/** Chips only: hide chips whose `count` is 0 (unless pressed). */
|
|
18
18
|
readonly hideEmpty?: boolean;
|
|
19
|
+
/** Present: the options come from a server-side search (reference facets), so the dropdown
|
|
20
|
+
* shows a search field and reports the typed term instead of filtering `options` itself. */
|
|
21
|
+
readonly onSearchChange?: (query: string) => void;
|
|
22
|
+
/** The search is loading; the option list may still show the previous term's rows. */
|
|
23
|
+
readonly searching?: boolean;
|
|
19
24
|
};
|
|
20
25
|
export type DataTableFacetChip = {
|
|
21
26
|
readonly id: string;
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -242,6 +242,14 @@ export const kumikoDefaultTranslations = {
|
|
|
242
242
|
"kumiko.nav.expand": "Expand",
|
|
243
243
|
"kumiko.nav.collapse": "Collapse",
|
|
244
244
|
"kumiko.nav.search": "Search navigation…",
|
|
245
|
+
"kumiko.notifications.label": "Notifications",
|
|
246
|
+
"kumiko.notifications.labelUnread": "Notifications, {count} unread",
|
|
247
|
+
"kumiko.notifications.title": "Notifications",
|
|
248
|
+
"kumiko.notifications.empty": "No notifications yet.",
|
|
249
|
+
"kumiko.notifications.markAllRead": "Mark all as read",
|
|
250
|
+
"kumiko.notifications.loadMore": "Load more",
|
|
251
|
+
"kumiko.notifications.unread": "Unread",
|
|
252
|
+
"kumiko.notifications.loadFailed": "Notifications could not be loaded.",
|
|
245
253
|
"kumiko.nav.language": "Language",
|
|
246
254
|
"kumiko.workspace.switch": "Switch workspace",
|
|
247
255
|
// Workspace-Switcher fallback label when activeId points at no visible workspace.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Bell } from "lucide-react";
|
|
3
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { lastSegment, useLocale, useNav, useTranslation } from "../../index.js";
|
|
6
|
+
import { useRelativeTimeFormatter } from "../app/relative-time.js";
|
|
7
|
+
import { cn } from "../lib/cn.js";
|
|
8
|
+
import { useInAppInbox } from "./use-in-app-inbox.js";
|
|
9
|
+
const MAX_BADGE_COUNT = 99;
|
|
10
|
+
const triggerClass = "relative inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-input " +
|
|
11
|
+
"bg-background text-foreground shadow-xs transition-colors hover:bg-accent " +
|
|
12
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
13
|
+
const panelClass = "z-50 flex max-h-[28rem] w-[min(22rem,calc(100vw-1.5rem))] flex-col overflow-hidden rounded-md border " +
|
|
14
|
+
"bg-popover text-popover-foreground shadow-md";
|
|
15
|
+
export function NotificationBell({ screens, }) {
|
|
16
|
+
const t = useTranslation();
|
|
17
|
+
const nav = useNav();
|
|
18
|
+
const locale = useLocale().locale();
|
|
19
|
+
const formatRelative = useRelativeTimeFormatter(locale);
|
|
20
|
+
const [open, setOpen] = useState(false);
|
|
21
|
+
const inbox = useInAppInbox(open);
|
|
22
|
+
const badge = inbox.unreadCount > MAX_BADGE_COUNT ? `${MAX_BADGE_COUNT}+` : String(inbox.unreadCount);
|
|
23
|
+
const label = inbox.unreadCount > 0
|
|
24
|
+
? t("kumiko.notifications.labelUnread", { count: badge })
|
|
25
|
+
: t("kumiko.notifications.label");
|
|
26
|
+
const openRow = async (row) => {
|
|
27
|
+
if (!row.isRead)
|
|
28
|
+
await inbox.markRead(row.id);
|
|
29
|
+
const target = row.target;
|
|
30
|
+
if (target === null)
|
|
31
|
+
return;
|
|
32
|
+
if (!screens.some((screen) => screen.id === target.screenId))
|
|
33
|
+
return;
|
|
34
|
+
const screenId = lastSegment(target.screenId);
|
|
35
|
+
setOpen(false);
|
|
36
|
+
nav.navigate(target.entityId === undefined ? { screenId } : { screenId, entityId: target.entityId });
|
|
37
|
+
};
|
|
38
|
+
return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": label, "data-kumiko-layout": "notification-bell", className: triggerClass, children: [_jsx(Bell, { className: "size-4", "aria-hidden": "true" }), inbox.unreadCount > 0 && (_jsx("span", { "data-kumiko-layout": "notification-bell-badge", className: "absolute -right-1.5 -top-1.5 flex min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-4 text-destructive-foreground", children: badge }))] }) }), _jsx(PopoverPrimitive.Portal, { children: _jsxs(PopoverPrimitive.Content, { align: "end", sideOffset: 6, className: panelClass, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border px-3 py-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: t("kumiko.notifications.title") }), inbox.unreadCount > 0 && (_jsx("button", { type: "button", className: "text-xs text-primary hover:underline", onClick: () => void inbox.markAllRead(), children: t("kumiko.notifications.markAllRead") }))] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [inbox.failed && (_jsx("p", { role: "alert", className: "px-3 py-4 text-sm text-destructive", children: t("kumiko.notifications.loadFailed") })), !inbox.failed && !inbox.loading && inbox.rows.length === 0 && (_jsx("p", { className: "px-3 py-6 text-center text-sm text-muted-foreground", children: t("kumiko.notifications.empty") })), _jsx("ul", { children: inbox.rows.map((row) => (_jsx("li", { children: _jsxs("button", { type: "button", "data-unread": row.isRead ? undefined : "true", className: "flex w-full items-start gap-2 border-b border-border px-3 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none", onClick: () => void openRow(row), children: [_jsx("span", { "aria-hidden": "true", className: cn("mt-1.5 size-2 shrink-0 rounded-full", row.isRead ? "bg-transparent" : "bg-primary") }), !row.isRead && (_jsx("span", { className: "sr-only", children: t("kumiko.notifications.unread") })), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: cn("block truncate text-sm", !row.isRead && "font-semibold"), children: row.title }), row.body !== null && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: row.body })), _jsx("span", { className: "block text-xs text-muted-foreground", children: formatRelative(Date.parse(row.createdAt)) })] })] }) }, row.id))) }), inbox.hasMore && (_jsx("button", { type: "button", className: "w-full px-3 py-2 text-sm text-primary hover:bg-accent", onClick: () => void inbox.loadMore(), children: t("kumiko.notifications.loadMore") }))] })] }) })] }));
|
|
39
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
7
7
|
// The `data-kumiko-layout="shell-header"` marker drives `--shell-header-height`
|
|
8
8
|
// in styles.css (:has() selector) — the single source for the header height
|
|
9
9
|
// that Drawer's `belowHeader` prop also reads.
|
|
10
|
+
import { IN_APP_NOTIFICATION_FEATURE } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
10
11
|
import { MoreHorizontal } from "lucide-react";
|
|
11
12
|
import { Fragment, useEffect, useId, useMemo, useRef, useState, } from "react";
|
|
12
13
|
import { resolveNavigation } from "../../headless/index.js";
|
|
@@ -17,6 +18,7 @@ import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbP
|
|
|
17
18
|
import { SidebarTrigger } from "../ui/sidebar.js";
|
|
18
19
|
import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
|
|
19
20
|
import { buildNavRegistrySliceForApp } from "./nav-tree.js";
|
|
21
|
+
import { NotificationBell } from "./notification-bell.js";
|
|
20
22
|
import { usePageHeaderSlot } from "./page-header-slot.js";
|
|
21
23
|
import { activeNavLabel, resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
|
|
22
24
|
import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
|
|
@@ -35,6 +37,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
|
|
|
35
37
|
return resolveNavigation({ source, ...(user !== undefined && { user }) });
|
|
36
38
|
}, [appSchema, user, allowedNavQns]);
|
|
37
39
|
const allScreens = useMemo(() => appSchema.features.flatMap((f) => f.screens), [appSchema.features]);
|
|
40
|
+
const hasInAppChannel = appSchema.features.some((feature) => feature.featureName === IN_APP_NOTIFICATION_FEATURE);
|
|
38
41
|
const slot = usePageHeaderSlot();
|
|
39
42
|
const standInScreenId = slot?.breadcrumbScreenId;
|
|
40
43
|
const screenId = standInScreenId ?? nav.route?.screenId;
|
|
@@ -78,7 +81,7 @@ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
|
|
|
78
81
|
e.preventDefault();
|
|
79
82
|
nav.navigate({ screenId });
|
|
80
83
|
}, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
|
|
81
|
-
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
84
|
+
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), user !== undefined && hasInAppChannel && _jsx(NotificationBell, { screens: allScreens }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
82
85
|
}
|
|
83
86
|
// A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
|
|
84
87
|
// nodes with their own popovers, and the panel stays mounted while closed so their
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type InAppInboxRow } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
|
+
export declare const IN_APP_INBOX_PAGE_SIZE = 20;
|
|
3
|
+
export type InAppInbox = {
|
|
4
|
+
readonly unreadCount: number;
|
|
5
|
+
readonly rows: readonly InAppInboxRow[];
|
|
6
|
+
readonly hasMore: boolean;
|
|
7
|
+
readonly loading: boolean;
|
|
8
|
+
readonly failed: boolean;
|
|
9
|
+
readonly loadMore: () => Promise<void>;
|
|
10
|
+
readonly markRead: (id: number) => Promise<void>;
|
|
11
|
+
readonly markAllRead: () => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
/** Headless state of the notification bell: the unread count always, the list only while `open`.
|
|
14
|
+
* The delivered signal refetches both; no polling. */
|
|
15
|
+
export declare function useInAppInbox(open: boolean): InAppInbox;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { IN_APP_NOTIFICATION_DELIVERED_EVENT, IN_APP_NOTIFICATION_QUERIES, IN_APP_NOTIFICATION_WRITES, } from "@cosmicdrift/kumiko-types/in-app-notification-types";
|
|
2
|
+
import { useCallback, useEffect, useState } from "react";
|
|
3
|
+
import { useDispatcher } from "../../context/dispatcher-context.js";
|
|
4
|
+
import { useMutation } from "../../hooks/use-mutation.js";
|
|
5
|
+
import { useQuery } from "../../hooks/use-query.js";
|
|
6
|
+
import { useLiveEvents } from "../../sse/live-events.js";
|
|
7
|
+
export const IN_APP_INBOX_PAGE_SIZE = 20;
|
|
8
|
+
/** Headless state of the notification bell: the unread count always, the list only while `open`.
|
|
9
|
+
* The delivered signal refetches both; no polling. */
|
|
10
|
+
export function useInAppInbox(open) {
|
|
11
|
+
const dispatcher = useDispatcher();
|
|
12
|
+
const subscribe = useLiveEvents();
|
|
13
|
+
const count = useQuery(IN_APP_NOTIFICATION_QUERIES.unreadCount, {});
|
|
14
|
+
const firstPage = useQuery(IN_APP_NOTIFICATION_QUERIES.inbox, { limit: IN_APP_INBOX_PAGE_SIZE }, { enabled: open });
|
|
15
|
+
const [olderRows, setOlderRows] = useState([]);
|
|
16
|
+
const [olderCursor, setOlderCursor] = useState(undefined);
|
|
17
|
+
const markReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markRead);
|
|
18
|
+
const markAllReadWrite = useMutation(IN_APP_NOTIFICATION_WRITES.markAllRead);
|
|
19
|
+
const refetchCount = count.refetch;
|
|
20
|
+
const refetchFirstPage = firstPage.refetch;
|
|
21
|
+
const refetchAll = useCallback(async () => {
|
|
22
|
+
setOlderRows([]);
|
|
23
|
+
setOlderCursor(undefined);
|
|
24
|
+
await Promise.all([refetchCount(), open ? refetchFirstPage() : Promise.resolve()]);
|
|
25
|
+
}, [refetchCount, refetchFirstPage, open]);
|
|
26
|
+
useEffect(() => subscribe(IN_APP_NOTIFICATION_DELIVERED_EVENT, () => void refetchAll()), [subscribe, refetchAll]);
|
|
27
|
+
const firstRows = firstPage.data?.rows ?? [];
|
|
28
|
+
const nextCursor = olderCursor === undefined ? (firstPage.data?.nextCursor ?? null) : olderCursor;
|
|
29
|
+
const loadMore = useCallback(async () => {
|
|
30
|
+
// skip: no older page left
|
|
31
|
+
if (nextCursor === null)
|
|
32
|
+
return;
|
|
33
|
+
const result = await dispatcher.query(IN_APP_NOTIFICATION_QUERIES.inbox, {
|
|
34
|
+
limit: IN_APP_INBOX_PAGE_SIZE,
|
|
35
|
+
cursor: nextCursor,
|
|
36
|
+
});
|
|
37
|
+
// skip: keep the loaded rows, the next click retries
|
|
38
|
+
if (!result.isSuccess)
|
|
39
|
+
return;
|
|
40
|
+
setOlderRows((previous) => [...previous, ...result.data.rows]);
|
|
41
|
+
setOlderCursor(result.data.nextCursor);
|
|
42
|
+
}, [dispatcher, nextCursor]);
|
|
43
|
+
const markRead = useCallback(async (id) => {
|
|
44
|
+
await markReadWrite.mutate({ id });
|
|
45
|
+
await refetchAll();
|
|
46
|
+
}, [markReadWrite.mutate, refetchAll]);
|
|
47
|
+
const markAllRead = useCallback(async () => {
|
|
48
|
+
await markAllReadWrite.mutate({});
|
|
49
|
+
await refetchAll();
|
|
50
|
+
}, [markAllReadWrite.mutate, refetchAll]);
|
|
51
|
+
return {
|
|
52
|
+
unreadCount: count.data?.count ?? 0,
|
|
53
|
+
rows: [...firstRows, ...olderRows],
|
|
54
|
+
hasMore: nextCursor !== null,
|
|
55
|
+
loading: firstPage.loading,
|
|
56
|
+
failed: firstPage.error !== null,
|
|
57
|
+
loadMore,
|
|
58
|
+
markRead,
|
|
59
|
+
markAllRead,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -6,6 +6,7 @@ import { Button as UiButton } from "../ui/button.js";
|
|
|
6
6
|
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "./dropdown-menu.js";
|
|
7
7
|
import "../react-css-custom-properties.js";
|
|
8
8
|
import { DefaultButton } from "./button.js";
|
|
9
|
+
import { FacetSearchFilter } from "./facet-search-filter.js";
|
|
9
10
|
// ---- DataTable (shadcn: Table) ----
|
|
10
11
|
function sameValueSet(a, b) {
|
|
11
12
|
return a.length === b.length && a.every((value) => b.includes(value));
|
|
@@ -26,6 +27,9 @@ export function FacetFilter({ facet, selected, onChange, dense = false, }) {
|
|
|
26
27
|
if (facet.chips !== undefined) {
|
|
27
28
|
return _jsx(FacetChips, { facet: facet, selected: selected, onChange: onChange });
|
|
28
29
|
}
|
|
30
|
+
if (facet.onSearchChange !== undefined) {
|
|
31
|
+
return (_jsx(FacetSearchFilter, { facet: facet, onSearchChange: facet.onSearchChange, selected: selected, onChange: onChange, dense: dense }));
|
|
32
|
+
}
|
|
29
33
|
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "border-border-strong px-2.5"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
|
|
30
34
|
}
|
|
31
35
|
// Time-range filter: two native <input type="date"> instead of a
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type DataTableFacet } from "../../index.ts";
|
|
3
|
+
export declare function FacetSearchFilter({ facet, onSearchChange, selected, onChange, dense, }: {
|
|
4
|
+
facet: DataTableFacet;
|
|
5
|
+
onSearchChange: (query: string) => void;
|
|
6
|
+
selected: readonly string[];
|
|
7
|
+
onChange: (field: string, values: readonly string[]) => void;
|
|
8
|
+
dense: boolean;
|
|
9
|
+
}): ReactNode;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Facet dropdown with a server-side search field, for reference facets whose option list is a
|
|
3
|
+
// capped page. A Radix DropdownMenu would swallow the typed keys for its typeahead, so this is a
|
|
4
|
+
// Popover + cmdk list like ComboboxInput, with the facet's own trigger button.
|
|
5
|
+
import { Command } from "cmdk";
|
|
6
|
+
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
|
7
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
import { REFERENCE_SEARCH_DEBOUNCE_MS, useTranslation } from "../../index.js";
|
|
10
|
+
import { cn } from "../lib/cn.js";
|
|
11
|
+
import { Badge } from "../ui/badge.js";
|
|
12
|
+
import { Button as UiButton } from "../ui/button.js";
|
|
13
|
+
export function FacetSearchFilter({ facet, onSearchChange, selected, onChange, dense, }) {
|
|
14
|
+
const t = useTranslation();
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const timer = setTimeout(() => onSearchChange(searchTerm), REFERENCE_SEARCH_DEBOUNCE_MS);
|
|
19
|
+
return () => clearTimeout(timer);
|
|
20
|
+
}, [searchTerm, onSearchChange]);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!open && searchTerm !== "")
|
|
23
|
+
setSearchTerm("");
|
|
24
|
+
}, [open, searchTerm]);
|
|
25
|
+
const toggle = (value) => {
|
|
26
|
+
onChange(facet.field, selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);
|
|
27
|
+
};
|
|
28
|
+
return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "border-border-strong px-2.5"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { align: "start", sideOffset: 4, className: "z-50 w-64 rounded-md border bg-popover p-0 text-popover-foreground shadow-md outline-none", children: _jsxs(Command, { shouldFilter: false, children: [_jsxs("div", { className: "relative", children: [_jsx(Command.Input, { value: searchTerm, onValueChange: setSearchTerm, placeholder: t("kumiko.combobox.search-placeholder"), "data-testid": `facet-${facet.field}-search`, className: "flex h-9 w-full border-0 border-b border-border bg-transparent px-3 py-1 text-sm outline-none placeholder:text-muted-foreground" }), facet.searching === true && (_jsx(Loader2, { className: "absolute right-2 top-2.5 h-4 w-4 animate-spin text-muted-foreground" }))] }), _jsxs(Command.List, { className: "max-h-64 overflow-y-auto p-1", children: [_jsx(Command.Empty, { className: "py-3 text-center text-sm text-muted-foreground", children: facet.searching === true
|
|
29
|
+
? t("kumiko.combobox.loading")
|
|
30
|
+
: t("kumiko.combobox.empty") }), facet.options.map((opt) => (_jsxs(Command.Item, { value: opt.value, onSelect: () => toggle(opt.value), "data-testid": `facet-${facet.field}-${opt.value}`, className: "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground", children: [selected.includes(opt.value) && (_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Check, { className: "h-4 w-4" }) })), opt.label] }, opt.value)))] })] }) }) })] }));
|
|
31
|
+
}
|
|
@@ -78,10 +78,21 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
78
78
|
// addEventListener(entityName, ...) gate below only filters which frames
|
|
79
79
|
// arrive at all, not which subscriber a given frame is for. Without this
|
|
80
80
|
// filter, an `invoice` subscriber would also fire on every `user` frame.
|
|
81
|
-
|
|
81
|
+
// Frames without an aggregateType (channel-in-app:event:delivered) are named after their
|
|
82
|
+
// event type, so the frame name is the subscription key and the signal is synthesized.
|
|
83
|
+
const handleEvent = (raw, frameName) => {
|
|
82
84
|
let parsed;
|
|
83
85
|
try {
|
|
84
|
-
|
|
86
|
+
const body = JSON.parse(raw);
|
|
87
|
+
const aggregateType = propertyOf(body, "aggregateType");
|
|
88
|
+
parsed =
|
|
89
|
+
typeof aggregateType === "string"
|
|
90
|
+
? body
|
|
91
|
+
: {
|
|
92
|
+
aggregateType: frameName,
|
|
93
|
+
eventType: frameName,
|
|
94
|
+
createdAt: Temporal.Instant.fromEpochMilliseconds(now()).toString(),
|
|
95
|
+
};
|
|
85
96
|
}
|
|
86
97
|
catch {
|
|
87
98
|
// skip: malformed SSE payload, drop it rather than crash all subscribers
|
|
@@ -109,7 +120,7 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
109
120
|
connection.addEventListener(entityName, (e) => {
|
|
110
121
|
const data = propertyOf(e, "data");
|
|
111
122
|
if (typeof data === "string")
|
|
112
|
-
handleEvent(data);
|
|
123
|
+
handleEvent(data, entityName);
|
|
113
124
|
});
|
|
114
125
|
wiredEntities.add(entityName);
|
|
115
126
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.399.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.399.0",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.399.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.399.0",
|
|
83
|
+
"@cosmicdrift/kumiko-testing": "0.399.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,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.399.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Notification bell in the shell header",
|
|
6
|
+
"detail": "With the channel-in-app feature in the schema and a signed-in user, ShellHeader renders a bell with an unread badge (99+ from 100) and a popover list with title, body, relative time, mark all read and load more. A click marks the entry read and opens its target screen when the schema has it. The delivered signal refetches the count and the open list; there is no polling. SSE frames without an aggregateType now reach subscribers keyed by the frame name. New keys `kumiko.notifications.*` in the default bundle and in all four locale packages."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.399.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Tab description is rendered on groups tabs",
|
|
12
|
+
"detail": "The `description` of a section with tabbed groups was dropped; only fields and relatedList tabs showed it. The outer card of a groups tab now renders it as its subtitle, also when a relatedList tab has no rows."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.399.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "Searchable reference facets",
|
|
18
|
+
"detail": "A reference facet in entityList, projectionList and relatedList loads a page of options and a search field narrows them on the server (300 ms debounce), so values beyond the first page can be filtered by. Chosen values keep their label from a separate by-id request, also after a reload from the URL. A reference field with `optionsQuery` uses that handler for the facet. The 200-row warning is gone; `DataTableFacet` gets optional `onSearchChange` and `searching`."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.398.0",
|
|
4
22
|
"type": "fix",
|