@protobase/ui 0.12.11 → 0.12.12
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/filter-panel/combobox-options.js +12 -0
- package/dist/filter-panel/facet-combobox.js +131 -0
- package/dist/filter-panel/facet-group.js +1 -1
- package/dist/filter-panel/filter-menu.js +7 -2
- package/dist/filter-panel/filter-panel.js +33 -24
- package/dist/filter-panel/filter-state.js +23 -11
- package/dist/filter-panel/range-filter.js +3 -3
- package/dist/index.js +1 -1
- package/dist/live/filter-string.js +18 -9
- package/dist/live/reference-options.js +14 -0
- package/dist/live/use-filter-config.js +64 -37
- package/dist/live/use-reference-search.js +53 -0
- package/dist/live/view-fields.js +31 -14
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-BKltU-WD.js +65 -0
- package/dist/shell/assets/index-CP0s3cFI.css +2 -0
- package/dist/shell/assets/{index-B7XSEKuk.js → index-D1ngvDop.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-DhQ8wFhe.js → shared-protobase-ui-Cv4rF2fz.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-B2rZN97s.js +0 -65
- package/dist/shell/assets/index-otHceRXj.css +0 -2
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { matchesSearch } from "./filter-state.js";
|
|
2
|
+
//#region src/filter-panel/combobox-options.ts
|
|
3
|
+
/** The options a combobox lists for its search text: as given when the group searches itself, else those whose name matches. */
|
|
4
|
+
const comboboxOptions = (group, query) => group.onSearch ? group.options : group.options.filter((option) => matchesSearch(option.label, query));
|
|
5
|
+
/** The option the arrow keys move to, wrapping around at either end; -1 when there are none. */
|
|
6
|
+
const moveActive = (active, count, key) => {
|
|
7
|
+
if (count === 0) return -1;
|
|
8
|
+
if (key === "ArrowDown") return active < 0 ? 0 : (active + 1) % count;
|
|
9
|
+
return active < 0 ? count - 1 : (active - 1 + count) % count;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { comboboxOptions, moveActive };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { formatInt } from "../format/number.js";
|
|
3
|
+
import { facetLabel } from "./filter-state.js";
|
|
4
|
+
import { comboboxOptions, moveActive } from "./combobox-options.js";
|
|
5
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
6
|
+
import { Check, Search, X } from "lucide-react";
|
|
7
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/filter-panel/facet-combobox.tsx
|
|
9
|
+
/** Chooses several values of a facet: the choices sit in the box as removable tokens, typing searches the options. */
|
|
10
|
+
const FacetCombobox = ({ config, selected, onToggle, inline = false }) => {
|
|
11
|
+
const id = useId();
|
|
12
|
+
const input = useRef(null);
|
|
13
|
+
const [query, setQuery] = useState("");
|
|
14
|
+
const [focused, setFocused] = useState(false);
|
|
15
|
+
const [active, setActive] = useState(-1);
|
|
16
|
+
const open = inline || focused;
|
|
17
|
+
const options = comboboxOptions(config, query);
|
|
18
|
+
const onSearch = useRef(config.onSearch);
|
|
19
|
+
onSearch.current = config.onSearch;
|
|
20
|
+
useEffect(() => () => onSearch.current?.(""), []);
|
|
21
|
+
const search = (text) => {
|
|
22
|
+
setQuery(text);
|
|
23
|
+
setActive(-1);
|
|
24
|
+
config.onSearch?.(text);
|
|
25
|
+
};
|
|
26
|
+
const onKeyDown = (event) => {
|
|
27
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
28
|
+
event.preventDefault();
|
|
29
|
+
setActive(moveActive(active, options.length, event.key));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (event.key === "Enter" && options[active]) {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
onToggle(options[active].value);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (event.key === "Backspace" && query === "" && selected.length > 0) {
|
|
38
|
+
onToggle(selected[selected.length - 1]);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (event.key === "Escape" && query !== "") {
|
|
42
|
+
event.stopPropagation();
|
|
43
|
+
search("");
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
47
|
+
className: "flex flex-col gap-1",
|
|
48
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
49
|
+
className: "flex min-h-8 cursor-text flex-wrap items-center gap-1 rounded-md border border-border-strong bg-background px-2 py-1 shadow-sm focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20",
|
|
50
|
+
onClick: () => input.current?.focus(),
|
|
51
|
+
children: [
|
|
52
|
+
/* @__PURE__ */ jsx(Search, { className: "size-3.5 shrink-0 text-muted-foreground" }),
|
|
53
|
+
selected.map((value) => /* @__PURE__ */ jsxs("span", {
|
|
54
|
+
className: "inline-flex h-5 max-w-full items-center gap-0.5 rounded bg-primary-soft pl-1.5 pr-0.5 text-xs font-medium text-primary-text",
|
|
55
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
56
|
+
className: "truncate",
|
|
57
|
+
children: facetLabel(config, value)
|
|
58
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
59
|
+
type: "button",
|
|
60
|
+
"aria-label": `Remove ${facetLabel(config, value)}`,
|
|
61
|
+
className: "rounded p-0.5 hover:bg-primary-border/60",
|
|
62
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
63
|
+
onClick: () => onToggle(value),
|
|
64
|
+
children: /* @__PURE__ */ jsx(X, { className: "size-3" })
|
|
65
|
+
})]
|
|
66
|
+
}, value)),
|
|
67
|
+
/* @__PURE__ */ jsx("input", {
|
|
68
|
+
ref: input,
|
|
69
|
+
autoFocus: inline,
|
|
70
|
+
role: "combobox",
|
|
71
|
+
"aria-label": config.label,
|
|
72
|
+
"aria-expanded": open,
|
|
73
|
+
"aria-controls": `${id}-options`,
|
|
74
|
+
"aria-autocomplete": "list",
|
|
75
|
+
"aria-activedescendant": options[active] ? `${id}-${active}` : void 0,
|
|
76
|
+
placeholder: selected.length > 0 ? "" : `Search ${config.label.toLowerCase()}`,
|
|
77
|
+
className: "h-6 min-w-16 flex-1 bg-transparent text-[13px] text-foreground outline-none placeholder:text-faint-foreground",
|
|
78
|
+
value: query,
|
|
79
|
+
onChange: (event) => search(event.target.value),
|
|
80
|
+
onKeyDown,
|
|
81
|
+
onFocus: () => setFocused(true),
|
|
82
|
+
onBlur: () => {
|
|
83
|
+
setFocused(false);
|
|
84
|
+
setActive(-1);
|
|
85
|
+
if (!inline && query !== "") search("");
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
]
|
|
89
|
+
}), open && /* @__PURE__ */ jsxs("ul", {
|
|
90
|
+
id: `${id}-options`,
|
|
91
|
+
role: "listbox",
|
|
92
|
+
"aria-label": config.label,
|
|
93
|
+
"aria-multiselectable": true,
|
|
94
|
+
className: cn("flex max-h-60 flex-col gap-px overflow-y-auto", !inline && "rounded-md border bg-background p-1 shadow-sm"),
|
|
95
|
+
children: [options.map((option, index) => {
|
|
96
|
+
const chosen = selected.includes(option.value);
|
|
97
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
98
|
+
id: `${id}-${index}`,
|
|
99
|
+
role: "option",
|
|
100
|
+
"aria-selected": chosen,
|
|
101
|
+
tabIndex: -1,
|
|
102
|
+
className: cn("flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-muted", index === active && "bg-muted"),
|
|
103
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
104
|
+
onClick: () => onToggle(option.value),
|
|
105
|
+
children: [
|
|
106
|
+
/* @__PURE__ */ jsx("span", {
|
|
107
|
+
className: cn("inline-flex size-4 shrink-0 items-center justify-center rounded border", chosen ? "border-primary bg-primary text-primary-foreground" : "border-border-strong bg-background"),
|
|
108
|
+
children: chosen && /* @__PURE__ */ jsx(Check, {
|
|
109
|
+
className: "size-3",
|
|
110
|
+
strokeWidth: 3
|
|
111
|
+
})
|
|
112
|
+
}),
|
|
113
|
+
/* @__PURE__ */ jsx("span", {
|
|
114
|
+
className: "flex-1 truncate",
|
|
115
|
+
children: option.label
|
|
116
|
+
}),
|
|
117
|
+
option.count !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
118
|
+
className: "text-xs tabular-nums text-faint-foreground",
|
|
119
|
+
children: formatInt(option.count)
|
|
120
|
+
})
|
|
121
|
+
]
|
|
122
|
+
}, option.value);
|
|
123
|
+
}), options.length === 0 && /* @__PURE__ */ jsx("li", {
|
|
124
|
+
className: "px-1.5 py-1 text-xs text-faint-foreground",
|
|
125
|
+
children: "No matches"
|
|
126
|
+
})]
|
|
127
|
+
})]
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
export { FacetCombobox };
|
|
@@ -31,7 +31,7 @@ const FacetGroup = ({ config, selected, onToggle }) => {
|
|
|
31
31
|
className: "flex-1 truncate",
|
|
32
32
|
children: option.label
|
|
33
33
|
}),
|
|
34
|
-
/* @__PURE__ */ jsx("span", {
|
|
34
|
+
option.count !== void 0 && /* @__PURE__ */ jsx("span", {
|
|
35
35
|
className: "text-xs tabular-nums text-faint-foreground",
|
|
36
36
|
children: formatInt(option.count)
|
|
37
37
|
})
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
3
|
import { ChevronDown } from "lucide-react";
|
|
4
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
//#region src/filter-panel/filter-menu.tsx
|
|
6
6
|
const FilterMenu = ({ label, activeCount, width = 240, defaultOpen = false, children }) => {
|
|
7
7
|
const [open, setOpen] = useState(defaultOpen);
|
|
8
|
+
const [alignEnd, setAlignEnd] = useState(false);
|
|
8
9
|
const root = useRef(null);
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
if (!open || !root.current) return;
|
|
12
|
+
setAlignEnd(root.current.getBoundingClientRect().left + width > document.documentElement.clientWidth);
|
|
13
|
+
}, [open, width]);
|
|
9
14
|
useEffect(() => {
|
|
10
15
|
if (!open) return;
|
|
11
16
|
const close = (event) => {
|
|
@@ -31,7 +36,7 @@ const FilterMenu = ({ label, activeCount, width = 240, defaultOpen = false, chil
|
|
|
31
36
|
/* @__PURE__ */ jsx(ChevronDown, { className: "size-3.5 text-muted-foreground" })
|
|
32
37
|
]
|
|
33
38
|
}), open && /* @__PURE__ */ jsx("div", {
|
|
34
|
-
className: "absolute
|
|
39
|
+
className: cn("absolute top-full z-30 mt-1.5 rounded-lg border bg-background p-3 shadow-pop", alignEnd ? "right-0" : "left-0"),
|
|
35
40
|
style: { width },
|
|
36
41
|
children
|
|
37
42
|
})]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ActiveFilterChips } from "./active-filter-chips.js";
|
|
2
2
|
import { DatePresets } from "./date-presets.js";
|
|
3
|
-
import { activeChips, emptyFilterState, removeChip, toggleFacetValue, toggleFlag } from "./filter-state.js";
|
|
3
|
+
import { activeChips, emptyFilterState, removeChip, setRange, toggleFacetValue, toggleFlag } from "./filter-state.js";
|
|
4
|
+
import { FacetCombobox } from "./facet-combobox.js";
|
|
4
5
|
import { FacetGroup } from "./facet-group.js";
|
|
5
6
|
import { FilterMenu } from "./filter-menu.js";
|
|
6
7
|
import { FilterSection } from "./filter-section.js";
|
|
@@ -20,18 +21,25 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
|
|
|
20
21
|
onRemove: (key) => setState(removeChip(state, key)),
|
|
21
22
|
onClear: () => setState(emptyFilterState)
|
|
22
23
|
});
|
|
23
|
-
const facetView = (group
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
const facetView = (group, inline = false) => {
|
|
25
|
+
const selected = state.facets[group.id] ?? [];
|
|
26
|
+
const onToggle = (option) => setState(toggleFacetValue(state, group.id, option));
|
|
27
|
+
return group.combobox ? /* @__PURE__ */ jsx(FacetCombobox, {
|
|
28
|
+
config: group,
|
|
29
|
+
selected,
|
|
30
|
+
onToggle,
|
|
31
|
+
inline
|
|
32
|
+
}) : /* @__PURE__ */ jsx(FacetGroup, {
|
|
33
|
+
config: group,
|
|
34
|
+
selected,
|
|
35
|
+
onToggle
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const ranges = config.ranges ?? [];
|
|
39
|
+
const rangeView = (range) => /* @__PURE__ */ jsx(RangeFilter, {
|
|
40
|
+
config: range,
|
|
41
|
+
value: state.ranges?.[range.id],
|
|
42
|
+
onChange: (span) => setState(setRange(state, range.id, span))
|
|
35
43
|
});
|
|
36
44
|
const dateView = config.datePresets && /* @__PURE__ */ jsx(DatePresets, {
|
|
37
45
|
value: state.date,
|
|
@@ -64,10 +72,10 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
|
|
|
64
72
|
title: group.label,
|
|
65
73
|
children: facetView(group)
|
|
66
74
|
}, group.id)),
|
|
67
|
-
|
|
68
|
-
title:
|
|
69
|
-
children: rangeView
|
|
70
|
-
}),
|
|
75
|
+
ranges.map((range) => /* @__PURE__ */ jsx(FilterSection, {
|
|
76
|
+
title: range.label,
|
|
77
|
+
children: rangeView(range)
|
|
78
|
+
}, range.id)),
|
|
71
79
|
dateView && /* @__PURE__ */ jsx(FilterSection, {
|
|
72
80
|
title: "Date",
|
|
73
81
|
children: dateView
|
|
@@ -82,16 +90,17 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
|
|
|
82
90
|
...config.facets.map((group) => /* @__PURE__ */ jsx(FilterMenu, {
|
|
83
91
|
label: group.label,
|
|
84
92
|
activeCount: (state.facets[group.id] ?? []).length,
|
|
93
|
+
width: group.combobox ? 280 : void 0,
|
|
85
94
|
defaultOpen: defaultOpenMenu === group.id,
|
|
86
|
-
children: facetView(group)
|
|
95
|
+
children: facetView(group, true)
|
|
87
96
|
}, group.id)),
|
|
88
|
-
...
|
|
89
|
-
label:
|
|
90
|
-
activeCount: state.range ? 1 : 0,
|
|
97
|
+
...ranges.map((range) => /* @__PURE__ */ jsx(FilterMenu, {
|
|
98
|
+
label: range.label,
|
|
99
|
+
activeCount: state.ranges?.[range.id] ? 1 : 0,
|
|
91
100
|
width: 280,
|
|
92
|
-
defaultOpen: defaultOpenMenu ===
|
|
93
|
-
children: rangeView
|
|
94
|
-
},
|
|
101
|
+
defaultOpen: defaultOpenMenu === range.id,
|
|
102
|
+
children: rangeView(range)
|
|
103
|
+
}, `range:${range.id}`)),
|
|
95
104
|
...dateView ? [/* @__PURE__ */ jsx(Fragment, { children: dateView }, "date")] : []
|
|
96
105
|
];
|
|
97
106
|
const toggles = (config.toggles ?? []).map((toggle) => /* @__PURE__ */ jsx("div", {
|
|
@@ -19,16 +19,31 @@ const toggleFlag = (state, id) => ({
|
|
|
19
19
|
...state,
|
|
20
20
|
toggles: state.toggles.includes(id) ? state.toggles.filter((entry) => entry !== id) : [...state.toggles, id]
|
|
21
21
|
});
|
|
22
|
+
/** Sets or, with `undefined`, clears the span of one range. */
|
|
23
|
+
const setRange = (state, id, value) => {
|
|
24
|
+
const { [id]: _, ...others } = state.ranges ?? {};
|
|
25
|
+
return {
|
|
26
|
+
...state,
|
|
27
|
+
ranges: value ? {
|
|
28
|
+
...others,
|
|
29
|
+
[id]: value
|
|
30
|
+
} : others
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/** A facet value's name: its option's label, else a name the group knows for it, else the value itself. */
|
|
34
|
+
const facetLabel = (group, value) => group.options.find((option) => option.value === value)?.label ?? group.labels?.[value] ?? value;
|
|
22
35
|
const activeChips = (config, state) => {
|
|
23
36
|
const facetChips = config.facets.flatMap((group) => (state.facets[group.id] ?? []).map((value) => ({
|
|
24
37
|
key: `facet:${group.id}:${value}`,
|
|
25
|
-
label: `${group.label}: ${group
|
|
38
|
+
label: `${group.label}: ${facetLabel(group, value)}`
|
|
26
39
|
})));
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
const rangeChips = (config.ranges ?? []).flatMap((range) => {
|
|
41
|
+
const span = state.ranges?.[range.id];
|
|
42
|
+
return span ? [{
|
|
43
|
+
key: `range:${range.id}`,
|
|
44
|
+
label: `${range.label}: ${range.format(span[0])} – ${range.format(span[1])}`
|
|
45
|
+
}] : [];
|
|
46
|
+
});
|
|
32
47
|
const dateChips = state.date ? [{
|
|
33
48
|
key: "date",
|
|
34
49
|
label: datePresetLabels[state.date] ?? state.date
|
|
@@ -45,19 +60,16 @@ const activeChips = (config, state) => {
|
|
|
45
60
|
];
|
|
46
61
|
};
|
|
47
62
|
const removeChip = (state, key) => {
|
|
48
|
-
if (key === "range") return {
|
|
49
|
-
...state,
|
|
50
|
-
range: void 0
|
|
51
|
-
};
|
|
52
63
|
if (key === "date") return {
|
|
53
64
|
...state,
|
|
54
65
|
date: void 0
|
|
55
66
|
};
|
|
56
67
|
const [kind, group, value] = key.split(":");
|
|
68
|
+
if (kind === "range") return setRange(state, group, void 0);
|
|
57
69
|
if (kind === "toggle") return toggleFlag(state, group);
|
|
58
70
|
if (kind === "facet") return toggleFacetValue(state, group, value);
|
|
59
71
|
throw new Error(`Unknown filter chip key: ${key}`);
|
|
60
72
|
};
|
|
61
73
|
const matchesSearch = (label, query) => label.toLowerCase().includes(query.trim().toLowerCase());
|
|
62
74
|
//#endregion
|
|
63
|
-
export { activeChips, emptyFilterState, matchesSearch, removeChip, toggleFacetValue, toggleFlag };
|
|
75
|
+
export { activeChips, emptyFilterState, facetLabel, matchesSearch, removeChip, setRange, toggleFacetValue, toggleFlag };
|
|
@@ -13,14 +13,14 @@ const RangeFilter = ({ config, value, onChange }) => {
|
|
|
13
13
|
children: [
|
|
14
14
|
/* @__PURE__ */ jsxs("div", {
|
|
15
15
|
className: "px-[7px]",
|
|
16
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
17
|
-
className: "flex h-12 items-end gap-0.5",
|
|
16
|
+
children: [histogram.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
17
|
+
className: "mb-1 flex h-12 items-end gap-0.5",
|
|
18
18
|
children: histogram.map((count, index) => /* @__PURE__ */ jsx("span", {
|
|
19
19
|
className: cn("flex-1 rounded-t-sm", barInRange(index, histogram.length, min, max, selected) ? "bg-primary/70" : "bg-border"),
|
|
20
20
|
style: { height: `${Math.max(6, count / peak * 100)}%` }
|
|
21
21
|
}, index))
|
|
22
22
|
}), /* @__PURE__ */ jsxs("div", {
|
|
23
|
-
className: "relative
|
|
23
|
+
className: "relative h-4",
|
|
24
24
|
children: [/* @__PURE__ */ jsx("span", { className: "absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-border" }), /* @__PURE__ */ jsx("span", {
|
|
25
25
|
className: "absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary",
|
|
26
26
|
style: {
|
package/dist/index.js
CHANGED
|
@@ -19,8 +19,8 @@ import { useAssistant } from "./assistant-dock/use-assistant.js";
|
|
|
19
19
|
import { useAssistantClient } from "./assistant-dock/use-assistant-client.js";
|
|
20
20
|
import { useMeta } from "./data/use-meta.js";
|
|
21
21
|
import { AppLink, useAppAddress } from "./app/use-app-address.js";
|
|
22
|
-
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
23
22
|
import { useListDisplay } from "./app/displays/display-context.js";
|
|
23
|
+
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
24
24
|
import { useLayoutRecord } from "./app/pages/record-scope.js";
|
|
25
25
|
import { uiFromModule } from "./app/pages/ui-from-module.js";
|
|
26
26
|
import { mergeUi } from "./app/pages/merge-ui.js";
|
|
@@ -62,12 +62,15 @@ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
|
|
|
62
62
|
if (values.length === 0) return [];
|
|
63
63
|
return [values.length === 1 ? w.eq(group.id, values[0]) : w.in(group.id, values)];
|
|
64
64
|
});
|
|
65
|
-
const
|
|
65
|
+
const ranges = (config.ranges ?? []).flatMap((range) => {
|
|
66
|
+
const span = state.ranges?.[range.id];
|
|
67
|
+
return span ? [w.gte(range.id, span[0]), w.lte(range.id, span[1])] : [];
|
|
68
|
+
});
|
|
66
69
|
const date = config.dateField && state.date ? dateClauses(config.dateField, state.date, now, config.dateKind) : [];
|
|
67
70
|
const toggles = (config.toggles ?? []).filter((toggle) => state.toggles.includes(toggle.id)).map((toggle) => w.eq(toggle.id, true));
|
|
68
71
|
return [
|
|
69
72
|
...facets,
|
|
70
|
-
...
|
|
73
|
+
...ranges,
|
|
71
74
|
...date,
|
|
72
75
|
...toggles
|
|
73
76
|
];
|
|
@@ -103,7 +106,8 @@ const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
|
|
|
103
106
|
const extras = [];
|
|
104
107
|
const facetIds = new Set(config.facets.map((group) => group.id));
|
|
105
108
|
const toggleIds = new Set((config.toggles ?? []).map((toggle) => toggle.id));
|
|
106
|
-
const
|
|
109
|
+
const rangeIds = new Set((config.ranges ?? []).map((range) => range.id));
|
|
110
|
+
const bounds = /* @__PURE__ */ new Map();
|
|
107
111
|
let dateClause;
|
|
108
112
|
let todayEnd;
|
|
109
113
|
for (const clause of parsed.ast ? flatten(parsed.ast) : []) if (clause.kind === "compare" && facetIds.has(fieldName(clause)) && clause.op === "=" && literalText(clause.value) !== void 0) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], literalText(clause.value)];
|
|
@@ -113,18 +117,23 @@ const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
|
|
|
113
117
|
dateClause = clause;
|
|
114
118
|
} else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && periodStart("today", now).startsWith(clause.value.value.slice(0, 10))) todayEnd = clause;
|
|
115
119
|
else if (clause.kind === "compare" && toggleIds.has(fieldName(clause)) && clause.op === "=" && clause.value.kind === "boolean" && clause.value.value) state.toggles.push(fieldName(clause));
|
|
116
|
-
else if (clause.kind === "compare" &&
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
else if (clause.kind === "compare" && rangeIds.has(fieldName(clause)) && (clause.op === ">=" || clause.op === "<=") && clause.value.kind === "number") {
|
|
121
|
+
const range = bounds.get(fieldName(clause)) ?? { clauses: [] };
|
|
122
|
+
if (clause.op === ">=") range.low = clause.value.value;
|
|
123
|
+
else range.high = clause.value.value;
|
|
124
|
+
range.clauses.push(clause);
|
|
125
|
+
bounds.set(fieldName(clause), range);
|
|
120
126
|
} else extras.push(clause);
|
|
121
127
|
if (state.date === "yesterday" && !todayEnd) {
|
|
122
128
|
extras.push(dateClause);
|
|
123
129
|
state.date = void 0;
|
|
124
130
|
}
|
|
125
131
|
if (todayEnd && state.date !== "yesterday") extras.push(todayEnd);
|
|
126
|
-
if (
|
|
127
|
-
|
|
132
|
+
for (const [id, range] of bounds) if (range.low !== void 0 && range.high !== void 0) state.ranges = {
|
|
133
|
+
...state.ranges,
|
|
134
|
+
[id]: [range.low, range.high]
|
|
135
|
+
};
|
|
136
|
+
else extras.push(...range.clauses);
|
|
128
137
|
return {
|
|
129
138
|
ok: true,
|
|
130
139
|
state,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/live/reference-options.ts
|
|
2
|
+
/** Records found by a search as options, with the count of each that the facet counts know. */
|
|
3
|
+
const matchOptions = (matches, counted) => {
|
|
4
|
+
const counts = new Map(counted.map((option) => [option.value, option.count]));
|
|
5
|
+
return matches.map(([value, label]) => ({
|
|
6
|
+
value,
|
|
7
|
+
label,
|
|
8
|
+
...counts.get(value) !== void 0 && { count: counts.get(value) }
|
|
9
|
+
}));
|
|
10
|
+
};
|
|
11
|
+
/** The keys a facet needs names for: the values it counted and those chosen, which may lie outside the counted ones. */
|
|
12
|
+
const labelKeys = (counted, chosen) => [.../* @__PURE__ */ new Set([...counted, ...chosen])];
|
|
13
|
+
//#endregion
|
|
14
|
+
export { labelKeys, matchOptions };
|
|
@@ -3,21 +3,28 @@ import { keys } from "../data/query-keys.js";
|
|
|
3
3
|
import { useClient } from "../data/api-provider.js";
|
|
4
4
|
import { humanize } from "./naming.js";
|
|
5
5
|
import { fetchLabels } from "./relation-labels.js";
|
|
6
|
+
import { setRange } from "../filter-panel/filter-state.js";
|
|
6
7
|
import { stateToText } from "./filter-string.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
8
|
+
import { labelKeys, matchOptions } from "./reference-options.js";
|
|
9
|
+
import { useReferenceSearch } from "./use-reference-search.js";
|
|
10
|
+
import { rangeHistogram, widgetFields } from "./view-fields.js";
|
|
9
11
|
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
10
12
|
import { enumLabel } from "@protobase/schema";
|
|
11
13
|
//#region src/live/use-filter-config.ts
|
|
14
|
+
const histogramBuckets = 24;
|
|
12
15
|
/**
|
|
13
|
-
* The panel's config for a view: facet counts come from `:facets
|
|
14
|
-
* other active filter applied but not its own, so choosing one value does not hide the rest.
|
|
16
|
+
* The panel's config for a view: facet counts come from `:facets` and each range's bounds from `:histogram`, each
|
|
17
|
+
* computed with every other active filter applied but not its own, so choosing one value does not hide the rest.
|
|
15
18
|
*/
|
|
16
19
|
const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
17
20
|
const client = useClient();
|
|
18
21
|
const queryClient = useQueryClient();
|
|
19
22
|
const skeleton = widgetFields(view, model);
|
|
20
23
|
const label = (name) => view?.fields[name]?.label ?? humanize(name);
|
|
24
|
+
const targetOf = (name) => {
|
|
25
|
+
const field = model.fields[name];
|
|
26
|
+
return field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
|
|
27
|
+
};
|
|
21
28
|
const facetQueries = useQueries({ queries: skeleton.facets.map((group) => {
|
|
22
29
|
const filter = stateToText(skeleton, {
|
|
23
30
|
...state,
|
|
@@ -35,46 +42,66 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
35
42
|
};
|
|
36
43
|
}) });
|
|
37
44
|
const labelQueries = useQueries({ queries: skeleton.facets.map((group, index) => {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const ids = (facetQueries[index]?.data ?? []).map((facet) => String(facet.value));
|
|
45
|
+
const target = targetOf(group.id);
|
|
46
|
+
const ids = labelKeys((facetQueries[index]?.data ?? []).flatMap((facet) => facet.value === null ? [] : [String(facet.value)]), state.facets[group.id] ?? []);
|
|
41
47
|
return {
|
|
42
48
|
queryKey: keys.labels(`facet:${model.name}.${group.id}`, ids.join(",")),
|
|
43
49
|
enabled: Boolean(target) && ids.length > 0,
|
|
44
50
|
queryFn: async () => [...await fetchLabels(queryClient, client, target, ids, views[target.name])]
|
|
45
51
|
};
|
|
46
52
|
}) });
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
},
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
53
|
+
const searches = useReferenceSearch(skeleton.facets.map((group) => ({
|
|
54
|
+
id: group.id,
|
|
55
|
+
target: group.combobox ? targetOf(group.id) : void 0
|
|
56
|
+
})), views);
|
|
57
|
+
const ranges = skeleton.ranges ?? [];
|
|
58
|
+
const histogramQueries = useQueries({ queries: ranges.map((range) => {
|
|
59
|
+
const filter = stateToText(skeleton, setRange(state, range.id, void 0), extras);
|
|
60
|
+
const buckets = rangeHistogram(view, range.id) ? histogramBuckets : 1;
|
|
61
|
+
return {
|
|
62
|
+
queryKey: keys.histogram(model.name, range.id, buckets, filter),
|
|
63
|
+
queryFn: () => client.histogram(model.name, range.id, {
|
|
64
|
+
filter,
|
|
65
|
+
buckets
|
|
66
|
+
})
|
|
67
|
+
};
|
|
68
|
+
}) });
|
|
69
|
+
const facetGroup = (group, index) => {
|
|
70
|
+
const field = model.fields[group.id];
|
|
71
|
+
const names = new Map(labelQueries[index]?.data ?? []);
|
|
72
|
+
const counted = (facetQueries[index]?.data ?? []).flatMap((facet) => facet.value === null ? [] : [{
|
|
73
|
+
value: String(facet.value),
|
|
74
|
+
label: field?.type === "enum" ? enumLabel(String(facet.value), view?.fields[group.id]?.valueLabels) : names.get(String(facet.value)) ?? String(facet.value),
|
|
75
|
+
count: facet.count
|
|
76
|
+
}]);
|
|
77
|
+
const search = searches[index];
|
|
78
|
+
return {
|
|
79
|
+
...group,
|
|
80
|
+
label: label(group.id),
|
|
81
|
+
options: search.matches ? matchOptions(search.matches, counted) : counted,
|
|
82
|
+
...names.size > 0 && { labels: Object.fromEntries(names) },
|
|
83
|
+
...search.onSearch && { onSearch: search.onSearch }
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
const rangeConfig = (range, index) => {
|
|
87
|
+
const data = histogramQueries[index]?.data;
|
|
88
|
+
if (!data || data.min === null || data.max === null) return [];
|
|
89
|
+
const hints = view?.fields[range.id];
|
|
90
|
+
const prefix = hints?.prefix;
|
|
91
|
+
const suffix = hints?.suffix ?? "";
|
|
92
|
+
return [{
|
|
93
|
+
...range,
|
|
94
|
+
label: label(range.id),
|
|
95
|
+
min: Math.floor(data.min),
|
|
96
|
+
max: Math.ceil(data.max),
|
|
97
|
+
step: Math.max(1, Math.round((data.max - data.min) / 100)),
|
|
98
|
+
histogram: rangeHistogram(view, range.id) ? data.buckets.map((bucket) => bucket.count) : [],
|
|
76
99
|
format: (value) => `${prefix === "€" ? formatMoney(value * 100).replace(".00", "") : String(value)}${suffix}`
|
|
77
|
-
}
|
|
100
|
+
}];
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
facets: skeleton.facets.map(facetGroup),
|
|
104
|
+
ranges: ranges.flatMap(rangeConfig),
|
|
78
105
|
...skeleton.dateField && {
|
|
79
106
|
datePresets: skeleton.datePresets ?? true,
|
|
80
107
|
dateField: skeleton.dateField,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useClient } from "../data/api-provider.js";
|
|
2
|
+
import { labelFieldOf } from "./model-helpers.js";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { keepPreviousData, useQueries } from "@tanstack/react-query";
|
|
5
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
6
|
+
//#region src/live/use-reference-search.ts
|
|
7
|
+
const where = createWhere();
|
|
8
|
+
/**
|
|
9
|
+
* The search text typed into each facet's combobox and the records of its target resource that match it. A facet
|
|
10
|
+
* without a target, or whose target has no search fields or a composite key, gets nothing: its options are filtered
|
|
11
|
+
* by name instead.
|
|
12
|
+
*/
|
|
13
|
+
const useReferenceSearch = (targets, views) => {
|
|
14
|
+
const client = useClient();
|
|
15
|
+
const [texts, setTexts] = useState({});
|
|
16
|
+
const searchable = (target) => Boolean(target?.search?.length) && target.primaryKey.length === 1;
|
|
17
|
+
const results = useQueries({ queries: targets.map(({ id, target }) => {
|
|
18
|
+
const text = (texts[id] ?? "").trim();
|
|
19
|
+
return {
|
|
20
|
+
queryKey: [
|
|
21
|
+
"reference-search",
|
|
22
|
+
id,
|
|
23
|
+
target?.name ?? "",
|
|
24
|
+
text
|
|
25
|
+
],
|
|
26
|
+
enabled: searchable(target) && text !== "",
|
|
27
|
+
placeholderData: keepPreviousData,
|
|
28
|
+
queryFn: async () => {
|
|
29
|
+
const keyField = target.primaryKey[0];
|
|
30
|
+
const labelField = labelFieldOf(target, views[target.name]);
|
|
31
|
+
return (await client.list(target.name, {
|
|
32
|
+
filter: printFilter(where.search(text)),
|
|
33
|
+
fields: [keyField, labelField],
|
|
34
|
+
pageSize: 20
|
|
35
|
+
})).items.map((item) => [String(item[keyField]), String(item[labelField])]);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}) });
|
|
39
|
+
return targets.map(({ id, target }, index) => {
|
|
40
|
+
if (!searchable(target)) return {};
|
|
41
|
+
const onSearch = (text) => setTexts((current) => ({
|
|
42
|
+
...current,
|
|
43
|
+
[id]: text
|
|
44
|
+
}));
|
|
45
|
+
if ((texts[id] ?? "").trim() === "") return { onSearch };
|
|
46
|
+
return {
|
|
47
|
+
onSearch,
|
|
48
|
+
matches: results[index]?.data ?? []
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
export { useReferenceSearch };
|