@protobase/ui 0.22.4 → 0.22.6
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/app.js +11 -6
- package/dist/app/breadcrumb-for.js +2 -1
- package/dist/app/current-address.d.ts +2 -1
- package/dist/app/current-address.js +7 -5
- package/dist/app/displays/map-display.js +1 -0
- package/dist/app/displays/use-map-records.js +2 -2
- package/dist/app/list-page.js +14 -5
- package/dist/app/nav-from-meta.js +31 -12
- package/dist/app/use-app-address.js +3 -3
- 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-Bp_H43C6.js +69 -0
- package/dist/shell/assets/index-BtU8c1DC.css +2 -0
- package/dist/shell/assets/{index-DylcQP59.js → index-q2Lohp8c.js} +1 -1
- package/dist/shell/assets/{maplibre-CY0vkBw4.js → maplibre-L-NOV9Ww.js} +1 -1
- package/dist/shell/assets/{markdown-content-D3r5sNwd.js → markdown-content-CM_VWSfv.js} +1 -1
- package/dist/shell/assets/{record-map-C9XvJmY1.js → record-map-CElcOkuO.js} +1 -1
- package/dist/shell/assets/{records-map-BtQTl7lD.js → records-map-pk9uGJBc.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BIRhZOyK.js → shared-protobase-ui-DvT94uLD.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-BM5iQ8yZ.js +0 -69
- package/dist/shell/assets/index-DhW8t8wq.css +0 -2
package/dist/app/app.js
CHANGED
|
@@ -29,6 +29,8 @@ import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
|
|
|
29
29
|
import { breadcrumbFor } from "./breadcrumb-for.js";
|
|
30
30
|
import { ComposedPage } from "./pages/composed-page.js";
|
|
31
31
|
import { CreateRoute } from "./create/create-route.js";
|
|
32
|
+
import { userMenuFromMeta } from "./user-menu-from-meta.js";
|
|
33
|
+
import { navFromMeta, navItemId } from "./nav-from-meta.js";
|
|
32
34
|
import { ListPage } from "./list-page.js";
|
|
33
35
|
import { MembersPage } from "../organizations/members-page.js";
|
|
34
36
|
import { InvitationsPage } from "../organizations/invitations-page.js";
|
|
@@ -37,8 +39,6 @@ import { OrganizationPage } from "../organizations/organization-page.js";
|
|
|
37
39
|
import { OrganizationsPage } from "../organizations/organizations-page.js";
|
|
38
40
|
import { actingUser } from "../roles/acting-user.js";
|
|
39
41
|
import { useActiveRole } from "../roles/use-active-role.js";
|
|
40
|
-
import { userMenuFromMeta } from "./user-menu-from-meta.js";
|
|
41
|
-
import { navFromMeta } from "./nav-from-meta.js";
|
|
42
42
|
import { ProjectLogo } from "./project-logo.js";
|
|
43
43
|
import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
|
|
44
44
|
import { PluginNotice } from "../plugins/plugin-notice.js";
|
|
@@ -50,8 +50,13 @@ import { StaffBanner } from "./staff-banner.js";
|
|
|
50
50
|
import { useNavRecent } from "./use-nav-recent.js";
|
|
51
51
|
import { useCallback, useEffect, useState } from "react";
|
|
52
52
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
53
|
+
import { viewOfList } from "@protobase/schema/internal";
|
|
53
54
|
//#region src/app/app.tsx
|
|
54
|
-
const placeOf = (address) =>
|
|
55
|
+
const placeOf = (address) => {
|
|
56
|
+
if (address?.kind === "page") return address.page;
|
|
57
|
+
if (address?.kind === "list") return navItemId(address.resource, address.list);
|
|
58
|
+
return address?.resource;
|
|
59
|
+
};
|
|
55
60
|
const recordOf = (address) => address?.kind === "record" ? address : void 0;
|
|
56
61
|
const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
57
62
|
const meta = useAdminMeta();
|
|
@@ -67,7 +72,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
67
72
|
const search = useGlobalSearch();
|
|
68
73
|
const theme = useTheme();
|
|
69
74
|
const recent = useNavRecent(meta, basePath, {
|
|
70
|
-
resource:
|
|
75
|
+
resource: shown?.resource,
|
|
71
76
|
key: shown?.key
|
|
72
77
|
});
|
|
73
78
|
const groups = navFromMeta(meta, basePath, recent);
|
|
@@ -92,7 +97,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
92
97
|
]);
|
|
93
98
|
const resource = address?.kind === "page" ? void 0 : address?.resource;
|
|
94
99
|
const drawnFrom = resource ? sourceStatusOf(meta, resource) : void 0;
|
|
95
|
-
const view = resource ? meta.views[resource] : void 0;
|
|
100
|
+
const view = resource ? viewOfList(meta.views[resource], address?.kind === "list" ? address.list : void 0) : void 0;
|
|
96
101
|
const group = groups.find((entry) => entry.items.some((item) => item.id === place));
|
|
97
102
|
const model = resource ? meta.resources[resource] : void 0;
|
|
98
103
|
const record = useOpenedRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
|
|
@@ -183,7 +188,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
183
188
|
}, address.resource) : address.kind === "record" ? /* @__PURE__ */ jsx(RecordPage, {
|
|
184
189
|
resource: address.resource,
|
|
185
190
|
recordKey: address.key
|
|
186
|
-
}, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, address.resource);
|
|
191
|
+
}, `${address.resource}/${address.key}`) : /* @__PURE__ */ jsx(ListPage, { address }, navItemId(address.resource, address.list));
|
|
187
192
|
const appShell = /* @__PURE__ */ jsx(AppShell, {
|
|
188
193
|
pageScroll: true,
|
|
189
194
|
sidebarMode: sidebarMode ?? "text-small",
|
|
@@ -15,7 +15,8 @@ const breadcrumbFor = ({ basePath, address, group, page, resourceLabel, recordTi
|
|
|
15
15
|
label: resourceLabel,
|
|
16
16
|
href: appPath({
|
|
17
17
|
kind: "list",
|
|
18
|
-
resource: address.resource
|
|
18
|
+
resource: address.resource,
|
|
19
|
+
...address.kind === "list" && address.list && { list: address.list }
|
|
19
20
|
}, basePath)
|
|
20
21
|
}] : [];
|
|
21
22
|
if (address.kind === "list") return [...groupCrumb, ...resource];
|
|
@@ -7,6 +7,7 @@ import { AppAddress, CalendarView, FilterLayout, MapView, ResourceModel, ViewMod
|
|
|
7
7
|
export type ListAddress = {
|
|
8
8
|
kind: 'list';
|
|
9
9
|
resource: string;
|
|
10
|
+
list?: string;
|
|
10
11
|
display: string;
|
|
11
12
|
filter: string;
|
|
12
13
|
orderBy: string;
|
|
@@ -31,5 +32,5 @@ export type CurrentAddress = ListAddress | {
|
|
|
31
32
|
page: string;
|
|
32
33
|
};
|
|
33
34
|
/** What can change about the list the app shows; `undefined` returns a value to its default. */
|
|
34
|
-
export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource'>>;
|
|
35
|
+
export type ListChange = Partial<Omit<ListAddress, 'kind' | 'resource' | 'list'>>;
|
|
35
36
|
//#endregion
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { defaultDisplay, listDefaultDisplay } from "./displays/list-displays.js";
|
|
2
2
|
import { defaultOrderBy } from "./list-order.js";
|
|
3
|
-
import { encodeKey } from "@protobase/schema/internal";
|
|
3
|
+
import { encodeKey, viewOfList } from "@protobase/schema/internal";
|
|
4
4
|
//#region src/app/current-address.ts
|
|
5
5
|
/**
|
|
6
6
|
* The resource whose records a list shows, with its view: the list's own, or, when its view has `listOf`, the resource
|
|
7
7
|
* that relation points at, if the caller can see it.
|
|
8
8
|
*/
|
|
9
|
-
const listedResource = (resources, views, resource) => {
|
|
9
|
+
const listedResource = (resources, views, resource, list) => {
|
|
10
10
|
const model = resources[resource];
|
|
11
|
-
const
|
|
11
|
+
const own = viewOfList(views[resource], list);
|
|
12
|
+
const listOf = own?.listOf;
|
|
12
13
|
const target = listOf === void 0 ? void 0 : model?.fields[listOf]?.relation?.resource;
|
|
13
14
|
return target !== void 0 && resources[target] ? {
|
|
14
15
|
model: resources[target],
|
|
@@ -16,7 +17,7 @@ const listedResource = (resources, views, resource) => {
|
|
|
16
17
|
listOf: true
|
|
17
18
|
} : {
|
|
18
19
|
model,
|
|
19
|
-
view:
|
|
20
|
+
view: own,
|
|
20
21
|
listOf: false
|
|
21
22
|
};
|
|
22
23
|
};
|
|
@@ -38,7 +39,7 @@ const currentAddress = (read, defaults) => {
|
|
|
38
39
|
};
|
|
39
40
|
if (read?.kind !== "list") return read;
|
|
40
41
|
return {
|
|
41
|
-
...defaults(read.resource),
|
|
42
|
+
...defaults(read.resource, read.list),
|
|
42
43
|
...read
|
|
43
44
|
};
|
|
44
45
|
};
|
|
@@ -52,6 +53,7 @@ const changedList = (read, change, defaults) => {
|
|
|
52
53
|
return {
|
|
53
54
|
kind: "list",
|
|
54
55
|
resource: read.resource,
|
|
56
|
+
...read.list && { list: read.list },
|
|
55
57
|
...differs("filter") && { filter: next.filter },
|
|
56
58
|
...differs("display") && { display: next.display },
|
|
57
59
|
...differs("orderBy") && { orderBy: next.orderBy },
|
|
@@ -12,7 +12,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
|
12
12
|
* many. A new area or filter keeps the previous answer until its own arrives, and an answer for an area the map has
|
|
13
13
|
* left is never shown.
|
|
14
14
|
*/
|
|
15
|
-
const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
|
|
15
|
+
const useMapRecords = ({ model, view, display, fields, filter, orderBy, area }) => {
|
|
16
16
|
const client = useClient();
|
|
17
17
|
const { resources, views } = useAdminMeta();
|
|
18
18
|
const position = {
|
|
@@ -32,7 +32,7 @@ const useMapRecords = ({ model, display, fields, filter, orderBy, area }) => {
|
|
|
32
32
|
filter: inside,
|
|
33
33
|
orderBy,
|
|
34
34
|
labels: listLabels(resources, views, model, fields),
|
|
35
|
-
photos: listPhotos(
|
|
35
|
+
photos: listPhotos(view)
|
|
36
36
|
});
|
|
37
37
|
return {
|
|
38
38
|
rows: withLabels(resources, model, fields, withPhotos(model, result.items, result.photos), result.labels).map((record) => displayRow(model, record)),
|
package/dist/app/list-page.js
CHANGED
|
@@ -50,6 +50,7 @@ import { useListScroll } from "./list-scroll.js";
|
|
|
50
50
|
import { ListChart } from "./list-chart.js";
|
|
51
51
|
import { ListGroupChart } from "./list-group-chart.js";
|
|
52
52
|
import { ListSearch } from "./list-search.js";
|
|
53
|
+
import { navItemId } from "./nav-from-meta.js";
|
|
53
54
|
import { withRelatedDotsColumns } from "./related-dots/related-dots-columns.js";
|
|
54
55
|
import { SavedList } from "./saved/saved-list.js";
|
|
55
56
|
import { SaveSearchButton } from "./searches/save-search-button.js";
|
|
@@ -62,7 +63,7 @@ import { useMemo, useState } from "react";
|
|
|
62
63
|
import { Download, Plus, SlidersHorizontal } from "lucide-react";
|
|
63
64
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
64
65
|
import { createWhere } from "@protobase/schema";
|
|
65
|
-
import { isGroupChart, tableDisplay } from "@protobase/schema/internal";
|
|
66
|
+
import { isGroupChart, tableDisplay, viewOfList } from "@protobase/schema/internal";
|
|
66
67
|
//#region src/app/list-page.tsx
|
|
67
68
|
const where$1 = createWhere();
|
|
68
69
|
const searchText = (extras) => extras.find((clause) => clause.kind === "search");
|
|
@@ -86,12 +87,20 @@ const ListPage = ({ address }) => {
|
|
|
86
87
|
"."
|
|
87
88
|
]
|
|
88
89
|
});
|
|
89
|
-
const view = views[address.resource];
|
|
90
|
+
const view = viewOfList(views[address.resource], address.list);
|
|
91
|
+
if (address.list !== void 0 && !view) return /* @__PURE__ */ jsxs(Notice, {
|
|
92
|
+
title: "Not found",
|
|
93
|
+
children: [
|
|
94
|
+
"There is no list called ",
|
|
95
|
+
address.list,
|
|
96
|
+
"."
|
|
97
|
+
]
|
|
98
|
+
});
|
|
90
99
|
if (view?.searchesOf) return /* @__PURE__ */ jsx(SavedSearches, {
|
|
91
100
|
model,
|
|
92
101
|
view
|
|
93
102
|
});
|
|
94
|
-
const listed = listedResource(resources, views, address.resource);
|
|
103
|
+
const listed = listedResource(resources, views, address.resource, address.list);
|
|
95
104
|
if (view && listed.listOf) {
|
|
96
105
|
const target = listed.model;
|
|
97
106
|
return /* @__PURE__ */ jsx(SavedList, {
|
|
@@ -118,7 +127,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
118
127
|
const meta = useAdminMeta();
|
|
119
128
|
const { set, listTo, go, href } = useAppAddress();
|
|
120
129
|
const resource = model.name;
|
|
121
|
-
const view = meta.views[resource];
|
|
130
|
+
const view = viewOfList(meta.views[resource], address.list);
|
|
122
131
|
const fields = useMemo(() => widgetFields(view, model), [view, model]);
|
|
123
132
|
const { fields: fieldTypes } = useProjectUi();
|
|
124
133
|
const columns = useMemo(() => withRelatedDotsColumns(withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), model, view, meta.resources, meta.views), [
|
|
@@ -377,7 +386,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
377
386
|
onRun: (rows) => remove.startBulk(rows.map(target))
|
|
378
387
|
}] : []],
|
|
379
388
|
rowNumbers: false,
|
|
380
|
-
pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, resource) : void 0,
|
|
389
|
+
pinKey: auth.kind === "signed-in" ? pinnedStorageKey(auth.user.id, navItemId(resource, address.list)) : void 0,
|
|
381
390
|
scroll: "parent",
|
|
382
391
|
className: "flex-1"
|
|
383
392
|
}) : /* @__PURE__ */ jsx("div", { className: "flex-1 rounded-lg border bg-surface" });
|
|
@@ -5,6 +5,8 @@ import { userMenuResources } from "./user-menu-from-meta.js";
|
|
|
5
5
|
import { LayoutDashboard } from "lucide-react";
|
|
6
6
|
import { appPath } from "@protobase/schema";
|
|
7
7
|
//#region src/app/nav-from-meta.ts
|
|
8
|
+
/** The sidebar item of a resource's list, or of one of its named lists. */
|
|
9
|
+
const navItemId = (resource, list) => list === void 0 ? resource : `${resource}/${list}`;
|
|
8
10
|
const groupLabels = {
|
|
9
11
|
sales: "Sales",
|
|
10
12
|
crm: "CRM",
|
|
@@ -18,7 +20,8 @@ const groupLabels = {
|
|
|
18
20
|
* the user menu leave an entry out, `nav.order` sorts within a group, and `recent` (from `useNavRecent`) makes an entry
|
|
19
21
|
* collapsible. Groups with pages come first, so a dashboard leads the sidebar. The resources of a source that cannot be
|
|
20
22
|
* read are listed under its label, each with the problem. A visitor also gets the resources whose views ask them to sign
|
|
21
|
-
* in, where those views place them, under "Account" without a group.
|
|
23
|
+
* in, where those views place them, under "Account" without a group. Each named list of a view is an entry of its
|
|
24
|
+
* own, placed by its own `nav`.
|
|
22
25
|
*/
|
|
23
26
|
const navFromMeta = (meta, basePath = "", recent = {}) => {
|
|
24
27
|
const groups = /* @__PURE__ */ new Map();
|
|
@@ -45,15 +48,20 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
|
|
|
45
48
|
}
|
|
46
49
|
for (const model of Object.values(meta.resources)) {
|
|
47
50
|
const view = meta.views[model.name];
|
|
48
|
-
if (!view ||
|
|
51
|
+
if (!view || inMenu.has(model.name)) continue;
|
|
49
52
|
const schema = model.table.schema ?? "public";
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const defaultGroup = view.nav?.group ?? groupLabels[schema] ?? humanize(schema);
|
|
54
|
+
const add = (groupName, item, order) => {
|
|
55
|
+
const group = groups.get(groupName) ?? {
|
|
56
|
+
label: groupName,
|
|
57
|
+
items: []
|
|
58
|
+
};
|
|
59
|
+
group.items.push(item);
|
|
60
|
+
groups.set(groupName, group);
|
|
61
|
+
orders.set(item.id, order ?? Number.MAX_SAFE_INTEGER);
|
|
54
62
|
};
|
|
55
|
-
|
|
56
|
-
id: model.name,
|
|
63
|
+
if (!view.nav?.hidden) add(defaultGroup, {
|
|
64
|
+
id: navItemId(model.name),
|
|
57
65
|
label: view.names?.plural ?? humanize(model.name),
|
|
58
66
|
icon: iconFor(model.name),
|
|
59
67
|
href: appPath({
|
|
@@ -61,9 +69,20 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
|
|
|
61
69
|
resource: model.name
|
|
62
70
|
}, basePath),
|
|
63
71
|
...recent[model.name] && { recent: recent[model.name] }
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
}, view.nav?.order);
|
|
73
|
+
for (const named of view.lists ?? []) {
|
|
74
|
+
if (named.nav?.hidden) continue;
|
|
75
|
+
add(named.nav?.group ?? defaultGroup, {
|
|
76
|
+
id: navItemId(model.name, named.name),
|
|
77
|
+
label: named.names.plural,
|
|
78
|
+
icon: iconFor(model.name),
|
|
79
|
+
href: appPath({
|
|
80
|
+
kind: "list",
|
|
81
|
+
resource: model.name,
|
|
82
|
+
list: named.name
|
|
83
|
+
}, basePath)
|
|
84
|
+
}, named.nav?.order);
|
|
85
|
+
}
|
|
67
86
|
}
|
|
68
87
|
for (const entry of Object.values(meta.signIn ?? {})) {
|
|
69
88
|
if (entry.nav?.hidden || inMenu.has(entry.resource)) continue;
|
|
@@ -108,4 +127,4 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
|
|
|
108
127
|
}));
|
|
109
128
|
};
|
|
110
129
|
//#endregion
|
|
111
|
-
export { navFromMeta };
|
|
130
|
+
export { navFromMeta, navItemId };
|
|
@@ -23,8 +23,8 @@ const useAppAddress = () => {
|
|
|
23
23
|
query,
|
|
24
24
|
pageNames
|
|
25
25
|
]);
|
|
26
|
-
const defaults = useCallback((resource) => {
|
|
27
|
-
const listed = listedResource(resources, views, resource);
|
|
26
|
+
const defaults = useCallback((resource, list) => {
|
|
27
|
+
const listed = listedResource(resources, views, resource, list);
|
|
28
28
|
return listDefaults(listed.model, listed.view, xs);
|
|
29
29
|
}, [
|
|
30
30
|
resources,
|
|
@@ -35,7 +35,7 @@ const useAppAddress = () => {
|
|
|
35
35
|
const go = useCallback((to, options) => navigate(appPath(to), options), [navigate]);
|
|
36
36
|
const listTo = useCallback((change) => {
|
|
37
37
|
if (read?.kind !== "list") throw new Error("listTo and set change the list the app shows; use go to move to another address");
|
|
38
|
-
return changedList(read, change, defaults(read.resource));
|
|
38
|
+
return changedList(read, change, defaults(read.resource, read.list));
|
|
39
39
|
}, [read, defaults]);
|
|
40
40
|
return {
|
|
41
41
|
address,
|
|
@@ -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);
|