@protobase/ui 0.22.3 → 0.22.5
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/charts/chart-view.js +2 -0
- package/dist/app/charts/group-bars.js +3 -6
- package/dist/app/charts/group-donut.js +3 -6
- package/dist/app/current-address.js +4 -4
- package/dist/app/displays/board-allowed.js +26 -0
- package/dist/app/displays/board-display.js +2 -1
- package/dist/app/list-link.js +23 -0
- package/dist/app/pages/blocks/stat.js +3 -5
- package/dist/filter-panel/filter-menu.js +1 -1
- package/dist/filter-panel/filter-panel.js +23 -8
- package/dist/filter-panel/more-filters-menu.js +54 -0
- package/dist/filter-panel/secondary-filters.js +46 -0
- package/dist/live/use-filter-config.js +2 -1
- package/dist/live/view-fields.js +14 -8
- package/dist/package.js +1 -1
- package/dist/primitives/menu-item.js +4 -0
- package/dist/record-view/action-menu.js +3 -3
- package/dist/shell/assets/app-B8Qi1ILW.js +69 -0
- package/dist/shell/assets/index-BtU8c1DC.css +2 -0
- package/dist/shell/assets/{index-K4WBo5hS.js → index-CYnTbGSR.js} +1 -1
- package/dist/shell/assets/{maplibre-CPwgBgXC.js → maplibre-CTx-d05j.js} +1 -1
- package/dist/shell/assets/{markdown-content-kdNPOEJy.js → markdown-content-5Vkd2-Cc.js} +1 -1
- package/dist/shell/assets/{record-map-C57pIfol.js → record-map-BB2PjZOY.js} +1 -1
- package/dist/shell/assets/{records-map-B8_M8kjM.js → records-map-Bu_7ef5n.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BRE7ZUBU.js → shared-protobase-ui-BJt0RQhj.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-3MBLkJAB.js +0 -69
- package/dist/shell/assets/index-DhW8t8wq.css +0 -2
|
@@ -65,10 +65,12 @@ const ChartView = ({ model, view, groupBy, type = "bar", sum, average, filter =
|
|
|
65
65
|
const slices = valueSlices(groups, field, view?.fields[groupBy], measure, filter);
|
|
66
66
|
return type === "donut" ? /* @__PURE__ */ jsx(GroupDonut, {
|
|
67
67
|
resource: model.name,
|
|
68
|
+
view,
|
|
68
69
|
slices,
|
|
69
70
|
measure
|
|
70
71
|
}) : /* @__PURE__ */ jsx(GroupBars, {
|
|
71
72
|
resource: model.name,
|
|
73
|
+
view,
|
|
72
74
|
slices,
|
|
73
75
|
measure
|
|
74
76
|
});
|
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import { AppLink } from "../use-app-address.js";
|
|
2
2
|
import { measureText } from "./chart-groups.js";
|
|
3
|
+
import { filteredListAddress } from "../list-link.js";
|
|
3
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
5
|
//#region src/app/charts/group-bars.tsx
|
|
5
6
|
/**
|
|
6
7
|
* A ranked list of a field's values, each a bar as long as its number and a link to its records. Bars in rows read on
|
|
7
8
|
* a phone as well as on a desk, and carry their label and value as text, so no colour has to say what a bar is.
|
|
8
9
|
*/
|
|
9
|
-
const GroupBars = ({ resource, slices, measure }) => {
|
|
10
|
+
const GroupBars = ({ resource, view, slices, measure }) => {
|
|
10
11
|
const largest = Math.max(0, ...slices.map((slice) => slice.value ?? 0));
|
|
11
12
|
return /* @__PURE__ */ jsx("ul", {
|
|
12
13
|
className: "flex flex-col gap-1",
|
|
13
14
|
children: slices.map((slice) => {
|
|
14
15
|
const share = largest > 0 && slice.value !== null ? Math.max(0, slice.value) / largest : 0;
|
|
15
16
|
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
|
|
16
|
-
to:
|
|
17
|
-
kind: "list",
|
|
18
|
-
resource,
|
|
19
|
-
filter: slice.filter
|
|
20
|
-
},
|
|
17
|
+
to: filteredListAddress(view, resource, slice.filter),
|
|
21
18
|
className: "group grid grid-cols-[minmax(6rem,35%)_1fr_auto] items-center gap-3 rounded-md px-1 py-1 hover:bg-muted",
|
|
22
19
|
title: measure.kind === "count" ? void 0 : `${measureText(slice.count, { kind: "count" })} records`,
|
|
23
20
|
children: [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AppLink } from "../use-app-address.js";
|
|
2
2
|
import { measureText } from "./chart-groups.js";
|
|
3
|
+
import { filteredListAddress } from "../list-link.js";
|
|
3
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
5
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
|
5
6
|
//#region src/app/charts/group-donut.tsx
|
|
@@ -22,7 +23,7 @@ const SliceTooltip = ({ active, payload, measure }) => {
|
|
|
22
23
|
* A donut of how a total shares out over a field's values, with a legend that names each, gives its number and share,
|
|
23
24
|
* and links to its records: the legend, not the colour, says which slice is which.
|
|
24
25
|
*/
|
|
25
|
-
const GroupDonut = ({ resource, slices, measure }) => {
|
|
26
|
+
const GroupDonut = ({ resource, view, slices, measure }) => {
|
|
26
27
|
const drawn = slices.filter((slice) => slice.value !== null && slice.value > 0);
|
|
27
28
|
const whole = drawn.reduce((sum, slice) => sum + slice.value, 0);
|
|
28
29
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -51,11 +52,7 @@ const GroupDonut = ({ resource, slices, measure }) => {
|
|
|
51
52
|
}), /* @__PURE__ */ jsx("ul", {
|
|
52
53
|
className: "flex w-full min-w-0 flex-col gap-0.5",
|
|
53
54
|
children: slices.map((slice) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
|
|
54
|
-
to:
|
|
55
|
-
kind: "list",
|
|
56
|
-
resource,
|
|
57
|
-
filter: slice.filter
|
|
58
|
-
},
|
|
55
|
+
to: filteredListAddress(view, resource, slice.filter),
|
|
59
56
|
className: "group flex items-center gap-2 rounded-md px-1 py-0.5 hover:bg-muted",
|
|
60
57
|
children: [
|
|
61
58
|
/* @__PURE__ */ jsx("span", {
|
|
@@ -24,7 +24,8 @@ const listedResource = (resources, views, resource) => {
|
|
|
24
24
|
const listDefaults = (model, view, xs = false) => ({
|
|
25
25
|
display: model ? listDefaultDisplay(model, view, xs) : defaultDisplay(view, xs),
|
|
26
26
|
orderBy: model ? defaultOrderBy(model, view) : "",
|
|
27
|
-
filterLayout: "bar"
|
|
27
|
+
filterLayout: "bar",
|
|
28
|
+
filter: view?.list?.filter ?? ""
|
|
28
29
|
});
|
|
29
30
|
/** The address as read from the location, with a list's defaults filled in and a record's key as text. */
|
|
30
31
|
const currentAddress = (read, defaults) => {
|
|
@@ -38,11 +39,10 @@ const currentAddress = (read, defaults) => {
|
|
|
38
39
|
if (read?.kind !== "list") return read;
|
|
39
40
|
return {
|
|
40
41
|
...defaults(read.resource),
|
|
41
|
-
filter: "",
|
|
42
42
|
...read
|
|
43
43
|
};
|
|
44
44
|
};
|
|
45
|
-
/** The list address after `change`, leaving out every value that is empty or its default, so `/<resource>` stays clean. */
|
|
45
|
+
/** The list address after `change`, leaving out every value that is empty or its default (a cleared filter is written as an empty `filter=` when the default is not empty), so `/<resource>` stays clean. */
|
|
46
46
|
const changedList = (read, change, defaults) => {
|
|
47
47
|
const next = {
|
|
48
48
|
...read,
|
|
@@ -52,7 +52,7 @@ const changedList = (read, change, defaults) => {
|
|
|
52
52
|
return {
|
|
53
53
|
kind: "list",
|
|
54
54
|
resource: read.resource,
|
|
55
|
-
...
|
|
55
|
+
...differs("filter") && { filter: next.filter },
|
|
56
56
|
...differs("display") && { display: next.display },
|
|
57
57
|
...differs("orderBy") && { orderBy: next.orderBy },
|
|
58
58
|
...differs("filterLayout") && { filterLayout: next.filterLayout },
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseFilter } from "@protobase/schema";
|
|
2
|
+
//#region src/app/displays/board-allowed.ts
|
|
3
|
+
const flatten = (expr) => expr.kind === "and" ? expr.args.flatMap(flatten) : [expr];
|
|
4
|
+
const text = (value) => value.kind === "string" ? String(value.value) : void 0;
|
|
5
|
+
const allowedBy = (clause, by) => {
|
|
6
|
+
if (clause.kind === "compare" && clause.op === "=" && clause.field.path.join(".") === by) {
|
|
7
|
+
const value = text(clause.value);
|
|
8
|
+
return value === void 0 ? void 0 : [value];
|
|
9
|
+
}
|
|
10
|
+
if (clause.kind === "in" && clause.field.path.join(".") === by) {
|
|
11
|
+
const values = clause.values.map(text);
|
|
12
|
+
return values.every((value) => value !== void 0) ? values : void 0;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The columns of a board `filter` does not rule out: those its top-level `by = "v"` and `in(by, ...)` clauses allow. Any
|
|
17
|
+
* other clause leaves every column, so a column is left out only when no record in it can match.
|
|
18
|
+
*/
|
|
19
|
+
const columnsAllowed = (filter, by, columns) => {
|
|
20
|
+
const parsed = parseFilter(filter);
|
|
21
|
+
if (!parsed.ok || !parsed.ast) return columns;
|
|
22
|
+
const clauses = flatten(parsed.ast).map((clause) => allowedBy(clause, by)).filter((values) => values !== void 0);
|
|
23
|
+
return columns.filter((column) => clauses.every((values) => column.value !== null && values.includes(column.value)));
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { columnsAllowed };
|
|
@@ -4,6 +4,7 @@ import { can } from "../permissions.js";
|
|
|
4
4
|
import { RecordCard } from "./record-card.js";
|
|
5
5
|
import { collision, columnCoordinates, columnId } from "./board-dnd.js";
|
|
6
6
|
import { BoardColumnView } from "./board-column.js";
|
|
7
|
+
import { columnsAllowed } from "./board-allowed.js";
|
|
7
8
|
import { useBoardMove } from "./use-board-move.js";
|
|
8
9
|
import { useRef, useState } from "react";
|
|
9
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -14,7 +15,7 @@ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useS
|
|
|
14
15
|
* a card dragged, or moved with the keyboard, to another column saves the record with that column's value.
|
|
15
16
|
*/
|
|
16
17
|
const BoardDisplay = ({ model, view, display, fields, filter, orderBy, limit }) => {
|
|
17
|
-
const columns = boardColumns(model, view, display);
|
|
18
|
+
const columns = columnsAllowed(filter, display.by, boardColumns(model, view, display));
|
|
18
19
|
const card = boardCard(model, view, display);
|
|
19
20
|
const permissions = usePermissions(model.name);
|
|
20
21
|
const movable = can(permissions, "update") && !model.fields[display.by]?.readOnly;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/app/list-link.ts
|
|
2
|
+
/**
|
|
3
|
+
* The list of `resource` opened on exactly the records `filter` counted or showed: with no filter, an empty one when
|
|
4
|
+
* the view's list opens on a default filter, so the link does not narrow what it counted; otherwise a clean path.
|
|
5
|
+
*/
|
|
6
|
+
const filteredListAddress = (view, resource, filter) => {
|
|
7
|
+
if (filter) return {
|
|
8
|
+
kind: "list",
|
|
9
|
+
resource,
|
|
10
|
+
filter
|
|
11
|
+
};
|
|
12
|
+
if (view?.list?.filter) return {
|
|
13
|
+
kind: "list",
|
|
14
|
+
resource,
|
|
15
|
+
filter: ""
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
kind: "list",
|
|
19
|
+
resource
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { filteredListAddress };
|
|
@@ -5,12 +5,14 @@ import { useLayoutRecord } from "../record-scope.js";
|
|
|
5
5
|
import { useCount } from "../use-layout-data.js";
|
|
6
6
|
import { propsOf } from "./block-props.js";
|
|
7
7
|
import { CardFrame } from "./card-frame.js";
|
|
8
|
+
import { filteredListAddress } from "../../list-link.js";
|
|
8
9
|
import { FieldValue } from "../field-value.js";
|
|
9
10
|
import { usePathValue } from "../use-path-value.js";
|
|
10
11
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
12
|
//#region src/app/pages/blocks/stat.tsx
|
|
12
13
|
const big = "text-2xl font-semibold tracking-tight tabular-nums";
|
|
13
14
|
const Count = ({ model, filter }) => {
|
|
15
|
+
const { views } = useAdminMeta();
|
|
14
16
|
const count = useCount(model, filter);
|
|
15
17
|
if (count.isPending) return /* @__PURE__ */ jsx(Skeleton, { className: "h-8 w-16" });
|
|
16
18
|
if (count.error) return /* @__PURE__ */ jsx("span", {
|
|
@@ -18,11 +20,7 @@ const Count = ({ model, filter }) => {
|
|
|
18
20
|
children: "Could not count"
|
|
19
21
|
});
|
|
20
22
|
return /* @__PURE__ */ jsx(AppLink, {
|
|
21
|
-
to:
|
|
22
|
-
kind: "list",
|
|
23
|
-
resource: model.name,
|
|
24
|
-
filter
|
|
25
|
-
},
|
|
23
|
+
to: filteredListAddress(views[model.name], model.name, filter),
|
|
26
24
|
className: `${big} hover:text-primary-text after:absolute after:inset-0 after:content-['']`,
|
|
27
25
|
children: count.data.toLocaleString("en-IE")
|
|
28
26
|
});
|
|
@@ -38,7 +38,7 @@ const FilterMenu = ({ label, activeCount, width = 240, defaultOpen = false, chil
|
|
|
38
38
|
}), open && /* @__PURE__ */ jsx("div", {
|
|
39
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"),
|
|
40
40
|
style: { width },
|
|
41
|
-
children
|
|
41
|
+
children: typeof children === "function" ? children(() => setOpen(false)) : children
|
|
42
42
|
})]
|
|
43
43
|
});
|
|
44
44
|
};
|
|
@@ -5,16 +5,22 @@ import { FacetCombobox } from "./facet-combobox.js";
|
|
|
5
5
|
import { FacetGroup } from "./facet-group.js";
|
|
6
6
|
import { FilterMenu } from "./filter-menu.js";
|
|
7
7
|
import { FilterSection } from "./filter-section.js";
|
|
8
|
+
import { MoreFiltersMenu } from "./more-filters-menu.js";
|
|
8
9
|
import { RangeFilter } from "./range-filter.js";
|
|
10
|
+
import { activeFilterKeys, barFilters, mergeKeys } from "./secondary-filters.js";
|
|
9
11
|
import { ToggleRow } from "./toggle-row.js";
|
|
10
12
|
import { ToolbarRow, toolbarShows } from "./toolbar-row.js";
|
|
11
13
|
import { useFilterState } from "./use-filter-state.js";
|
|
12
|
-
import { Fragment } from "react";
|
|
14
|
+
import { Fragment, useEffect, useState } from "react";
|
|
13
15
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
16
|
//#region src/filter-panel/filter-panel.tsx
|
|
15
17
|
const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabels, defaultOpenMenu, fullWidth, leading, trailing }) => {
|
|
16
18
|
const [state, setState] = useFilterState(value, defaultValue, onChange);
|
|
17
19
|
const chips = activeChips(config, state);
|
|
20
|
+
const activeKeys = activeFilterKeys(chips);
|
|
21
|
+
const [revealed, setRevealed] = useState(() => activeKeys);
|
|
22
|
+
const [justPicked, setJustPicked] = useState();
|
|
23
|
+
useEffect(() => setRevealed((current) => mergeKeys(current, activeKeys)), [activeKeys.join(",")]);
|
|
18
24
|
const chipsView = /* @__PURE__ */ jsx(ActiveFilterChips, {
|
|
19
25
|
chips,
|
|
20
26
|
lockedLabels,
|
|
@@ -87,24 +93,29 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
|
|
|
87
93
|
})
|
|
88
94
|
]
|
|
89
95
|
});
|
|
96
|
+
const { shown, more } = barFilters(config, revealed);
|
|
97
|
+
const onPick = (key) => {
|
|
98
|
+
setRevealed((current) => mergeKeys(current, [key]));
|
|
99
|
+
setJustPicked(key);
|
|
100
|
+
};
|
|
90
101
|
const menus = [
|
|
91
|
-
...config.facets.map((group) => /* @__PURE__ */ jsx(FilterMenu, {
|
|
102
|
+
...config.facets.filter((group) => shown.has(`facet:${group.id}`)).map((group) => /* @__PURE__ */ jsx(FilterMenu, {
|
|
92
103
|
label: group.label,
|
|
93
104
|
activeCount: (state.facets[group.id] ?? []).length,
|
|
94
105
|
width: group.combobox ? 280 : void 0,
|
|
95
|
-
defaultOpen: defaultOpenMenu === group.id
|
|
106
|
+
defaultOpen: defaultOpenMenu === group.id || justPicked === `facet:${group.id}`,
|
|
96
107
|
children: facetView(group, true)
|
|
97
108
|
}, group.id)),
|
|
98
|
-
...ranges.map((range) => /* @__PURE__ */ jsx(FilterMenu, {
|
|
109
|
+
...ranges.filter((range) => shown.has(`range:${range.id}`)).map((range) => /* @__PURE__ */ jsx(FilterMenu, {
|
|
99
110
|
label: range.label,
|
|
100
111
|
activeCount: state.ranges?.[range.id] ? 1 : 0,
|
|
101
112
|
width: 280,
|
|
102
|
-
defaultOpen: defaultOpenMenu === range.id
|
|
113
|
+
defaultOpen: defaultOpenMenu === range.id || justPicked === `range:${range.id}`,
|
|
103
114
|
children: rangeView(range)
|
|
104
115
|
}, `range:${range.id}`)),
|
|
105
|
-
...dateView ? [/* @__PURE__ */ jsx(Fragment, { children: dateView }, "date")] : []
|
|
116
|
+
...dateView && shown.has("date") ? [/* @__PURE__ */ jsx(Fragment, { children: dateView }, "date")] : []
|
|
106
117
|
];
|
|
107
|
-
const toggles = (config.toggles ?? []).map((toggle) => /* @__PURE__ */ jsx("div", {
|
|
118
|
+
const toggles = (config.toggles ?? []).filter((toggle) => shown.has(`toggle:${toggle.id}`)).map((toggle) => /* @__PURE__ */ jsx("div", {
|
|
108
119
|
className: "flex items-center gap-2 text-muted-foreground",
|
|
109
120
|
children: /* @__PURE__ */ jsx(ToggleRow, {
|
|
110
121
|
label: toggle.label,
|
|
@@ -115,7 +126,11 @@ const FilterPanel = ({ config, layout, value, defaultValue, onChange, lockedLabe
|
|
|
115
126
|
const groups = [
|
|
116
127
|
leading ? [/* @__PURE__ */ jsx(Fragment, { children: leading }, "leading")] : [],
|
|
117
128
|
menus,
|
|
118
|
-
toggles
|
|
129
|
+
toggles,
|
|
130
|
+
more.length > 0 ? [/* @__PURE__ */ jsx(MoreFiltersMenu, {
|
|
131
|
+
items: more,
|
|
132
|
+
onPick
|
|
133
|
+
}, "more")] : []
|
|
119
134
|
];
|
|
120
135
|
if (!toolbarShows(groups, trailing) && chips.length === 0 && !lockedLabels?.length) return null;
|
|
121
136
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { menuItemClass } from "../primitives/menu-item.js";
|
|
2
|
+
import { FilterMenu } from "./filter-menu.js";
|
|
3
|
+
import { menuKeyTarget } from "../primitives/menu-keys.js";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
//#region src/filter-panel/more-filters-menu.tsx
|
|
7
|
+
const Items = ({ items, onPick, close }) => {
|
|
8
|
+
const buttons = useRef([]);
|
|
9
|
+
useEffect(() => buttons.current[0]?.focus(), []);
|
|
10
|
+
const onKeyDown = (event) => {
|
|
11
|
+
if (event.key === "Escape") return close();
|
|
12
|
+
const target = menuKeyTarget(event.key, buttons.current.indexOf(document.activeElement), items.length);
|
|
13
|
+
if (target === void 0) return;
|
|
14
|
+
event.preventDefault();
|
|
15
|
+
buttons.current[target]?.focus();
|
|
16
|
+
};
|
|
17
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
18
|
+
role: "menu",
|
|
19
|
+
"aria-label": "More filters",
|
|
20
|
+
onKeyDown,
|
|
21
|
+
className: "-mx-3 -my-3",
|
|
22
|
+
children: items.map((item, index) => /* @__PURE__ */ jsx("li", {
|
|
23
|
+
role: "none",
|
|
24
|
+
children: /* @__PURE__ */ jsx("button", {
|
|
25
|
+
ref: (button) => {
|
|
26
|
+
buttons.current[index] = button;
|
|
27
|
+
},
|
|
28
|
+
type: "button",
|
|
29
|
+
role: "menuitem",
|
|
30
|
+
className: menuItemClass,
|
|
31
|
+
onClick: () => {
|
|
32
|
+
close();
|
|
33
|
+
onPick(item.key);
|
|
34
|
+
},
|
|
35
|
+
children: item.label
|
|
36
|
+
})
|
|
37
|
+
}, item.key))
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
/** The secondary filters the bar does not show yet, in a menu at its end; picking one puts it in the bar. */
|
|
41
|
+
const MoreFiltersMenu = ({ items, onPick }) => {
|
|
42
|
+
if (items.length === 0) return null;
|
|
43
|
+
return /* @__PURE__ */ jsx(FilterMenu, {
|
|
44
|
+
label: "More filters",
|
|
45
|
+
activeCount: 0,
|
|
46
|
+
children: (close) => /* @__PURE__ */ jsx(Items, {
|
|
47
|
+
items,
|
|
48
|
+
onPick,
|
|
49
|
+
close
|
|
50
|
+
})
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { MoreFiltersMenu };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/filter-panel/secondary-filters.ts
|
|
2
|
+
/** The filter a chip belongs to: `facet:<id>`, `range:<id>`, `toggle:<id>` or `date`. A filter is active exactly when it has a chip. */
|
|
3
|
+
const activeFilterKeys = (chips) => [...new Set(chips.map((chip) => chip.key.split(":").slice(0, 2).join(":")))];
|
|
4
|
+
/** `revealed` itself when it holds every key already, else a copy with the missing keys appended. */
|
|
5
|
+
const mergeKeys = (revealed, keys) => {
|
|
6
|
+
const missing = keys.filter((key) => !revealed.includes(key));
|
|
7
|
+
return missing.length === 0 ? revealed : [...revealed, ...missing];
|
|
8
|
+
};
|
|
9
|
+
/** The filter keys the bar draws, and the secondary filters left for the "More filters" menu, in the order the bar draws them. */
|
|
10
|
+
const barFilters = (config, revealed) => {
|
|
11
|
+
const all = [
|
|
12
|
+
...config.facets.map((group) => ({
|
|
13
|
+
key: `facet:${group.id}`,
|
|
14
|
+
label: group.label,
|
|
15
|
+
secondary: group.secondary
|
|
16
|
+
})),
|
|
17
|
+
...(config.ranges ?? []).map((range) => ({
|
|
18
|
+
key: `range:${range.id}`,
|
|
19
|
+
label: range.label,
|
|
20
|
+
secondary: range.secondary
|
|
21
|
+
})),
|
|
22
|
+
...config.datePresets ? [{
|
|
23
|
+
key: "date",
|
|
24
|
+
label: "Date",
|
|
25
|
+
secondary: config.dateSecondary
|
|
26
|
+
}] : [],
|
|
27
|
+
...(config.toggles ?? []).map((toggle) => ({
|
|
28
|
+
key: `toggle:${toggle.id}`,
|
|
29
|
+
label: toggle.label,
|
|
30
|
+
secondary: toggle.secondary
|
|
31
|
+
}))
|
|
32
|
+
];
|
|
33
|
+
const shown = /* @__PURE__ */ new Set();
|
|
34
|
+
const more = [];
|
|
35
|
+
for (const filter of all) if (!filter.secondary || revealed.includes(filter.key)) shown.add(filter.key);
|
|
36
|
+
else more.push({
|
|
37
|
+
key: filter.key,
|
|
38
|
+
label: filter.label
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
shown,
|
|
42
|
+
more
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
export { activeFilterKeys, barFilters, mergeKeys };
|
|
@@ -122,7 +122,8 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
122
122
|
...skeleton.dateField && {
|
|
123
123
|
datePresets: skeleton.datePresets ?? true,
|
|
124
124
|
dateField: skeleton.dateField,
|
|
125
|
-
dateKind: skeleton.dateKind
|
|
125
|
+
dateKind: skeleton.dateKind,
|
|
126
|
+
...skeleton.dateSecondary && { dateSecondary: true }
|
|
126
127
|
},
|
|
127
128
|
toggles: skeleton.toggles?.map((toggle) => ({
|
|
128
129
|
...toggle,
|
package/dist/live/view-fields.js
CHANGED
|
@@ -11,14 +11,16 @@ const isNumber = (model, field) => {
|
|
|
11
11
|
};
|
|
12
12
|
/** A filter on a number field is a range slider, whichever widget the view asked for. */
|
|
13
13
|
const rangeOf = (widget, model) => widget.kind === "range" || widget.kind === "facets" && isNumber(model, widget.field);
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const secondaryOf = (widget) => widget.secondary ? { secondary: true } : {};
|
|
15
|
+
const emptyRange = (widget) => ({
|
|
16
|
+
id: widget.field,
|
|
17
|
+
label: widget.field,
|
|
17
18
|
min: 0,
|
|
18
19
|
max: 0,
|
|
19
20
|
step: 1,
|
|
20
21
|
histogram: [],
|
|
21
|
-
format: String
|
|
22
|
+
format: String,
|
|
23
|
+
...secondaryOf(widget)
|
|
22
24
|
});
|
|
23
25
|
/**
|
|
24
26
|
* The widgets of a view in the shape `filter-string` talks to; options and counts are filled in later. Facets on a
|
|
@@ -34,7 +36,8 @@ const widgetFields = (view, model) => {
|
|
|
34
36
|
label: widget.field,
|
|
35
37
|
options: [],
|
|
36
38
|
searchable: widget.search,
|
|
37
|
-
...model?.fields[widget.field]?.type === "relation" && { combobox: true }
|
|
39
|
+
...model?.fields[widget.field]?.type === "relation" && { combobox: true },
|
|
40
|
+
...secondaryOf(widget)
|
|
38
41
|
}];
|
|
39
42
|
if (widget.kind === "flags") return [{
|
|
40
43
|
id: widget.fields.join("|"),
|
|
@@ -44,19 +47,22 @@ const widgetFields = (view, model) => {
|
|
|
44
47
|
label: field,
|
|
45
48
|
count: 0
|
|
46
49
|
})),
|
|
47
|
-
flags: true
|
|
50
|
+
flags: true,
|
|
51
|
+
...secondaryOf(widget)
|
|
48
52
|
}];
|
|
49
53
|
return [];
|
|
50
54
|
}),
|
|
51
|
-
ranges: widgets.flatMap((widget) => widget.kind !== "flags" && rangeOf(widget, model) ? [emptyRange(widget
|
|
55
|
+
ranges: widgets.flatMap((widget) => widget.kind !== "flags" && rangeOf(widget, model) ? [emptyRange(widget)] : []),
|
|
52
56
|
...date && {
|
|
53
57
|
dateField: date.field,
|
|
58
|
+
...date.secondary && { dateSecondary: true },
|
|
54
59
|
dateKind: model?.fields[date.field]?.type === "date" ? "date" : "timestamp",
|
|
55
60
|
...date.presets.length > 0 && { datePresets: date.presets }
|
|
56
61
|
},
|
|
57
62
|
toggles: widgets.flatMap((widget) => widget.kind === "toggle" ? [{
|
|
58
63
|
id: widget.field,
|
|
59
|
-
label: widget.field
|
|
64
|
+
label: widget.field,
|
|
65
|
+
...secondaryOf(widget)
|
|
60
66
|
}] : [])
|
|
61
67
|
};
|
|
62
68
|
};
|
package/dist/package.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
|
+
import "../primitives/menu-item.js";
|
|
2
3
|
import { usePopoverMenu } from "../primitives/popover-menu.js";
|
|
3
4
|
import { MoreVertical } from "lucide-react";
|
|
4
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
import { createPortal } from "react-dom";
|
|
6
7
|
//#region src/record-view/action-menu.tsx
|
|
7
|
-
const itemClass = "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60";
|
|
8
8
|
/**
|
|
9
9
|
* The actions of an action rail as a menu behind a "..." button, as a mobile screen's top bar holds them: each item the
|
|
10
10
|
* rail's, a link where the rail draws one, disabled with its reason when it cannot run. `className` styles the button.
|
|
@@ -39,7 +39,7 @@ const ActionMenu = ({ actions, label = "Actions", className }) => {
|
|
|
39
39
|
role: "none",
|
|
40
40
|
onClick: () => setOpen(false),
|
|
41
41
|
children: render ? render({
|
|
42
|
-
className:
|
|
42
|
+
className: "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60",
|
|
43
43
|
children,
|
|
44
44
|
role: "menuitem"
|
|
45
45
|
}) : /* @__PURE__ */ jsx("button", {
|
|
@@ -48,7 +48,7 @@ const ActionMenu = ({ actions, label = "Actions", className }) => {
|
|
|
48
48
|
onClick: onRun,
|
|
49
49
|
disabled: Boolean(blocked) || running,
|
|
50
50
|
title: blocked,
|
|
51
|
-
className:
|
|
51
|
+
className: "flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-muted disabled:cursor-default disabled:opacity-60",
|
|
52
52
|
children
|
|
53
53
|
})
|
|
54
54
|
}, name);
|