@protobase/ui 0.19.15 → 0.20.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/app.js +2 -2
- package/dist/app/charts/chart-groups.js +52 -0
- package/dist/app/charts/chart-view.js +75 -0
- package/dist/app/charts/group-bars.js +49 -0
- package/dist/app/charts/group-donut.js +84 -0
- package/dist/app/charts/period-chart.js +174 -0
- package/dist/app/displays/board-column.js +114 -0
- package/dist/app/displays/board-columns.js +45 -0
- package/dist/app/displays/board-display.js +96 -0
- package/dist/app/displays/board-dnd.js +32 -0
- package/dist/app/displays/list-displays.js +13 -1
- package/dist/app/displays/record-card.js +16 -5
- package/dist/app/displays/use-board-column.js +23 -0
- package/dist/app/displays/use-board-move.js +60 -0
- package/dist/app/displays/virtual-cards.js +1 -0
- package/dist/app/embedded-list/embedded-list.js +9 -0
- package/dist/app/export/export-options.js +2 -2
- package/dist/app/gallery/use-gallery-photos.js +2 -2
- package/dist/app/import/lookups.js +2 -2
- package/dist/app/list-edit/cell-field-editor.js +61 -0
- package/dist/app/list-edit/use-list-cell-editor.js +30 -0
- package/dist/app/list-edit/use-list-edits.js +75 -0
- package/dist/app/list-group-chart.js +37 -0
- package/dist/app/list-page.js +38 -13
- package/dist/app/map/map-area.js +6 -6
- package/dist/app/pages/action-href.js +4 -2
- package/dist/app/pages/blocks/chart.js +26 -0
- package/dist/app/pages/render-node.js +2 -0
- package/dist/app/pages/use-run-action.js +4 -3
- package/dist/app/record-page.js +30 -16
- package/dist/app/record-preview.js +25 -0
- package/dist/app/record-row/record-row-body.js +2 -2
- package/dist/app/record-row/record-row.js +1 -0
- package/dist/app/related-dots/card-dots-lines.js +16 -0
- package/dist/app/related-dots/card-dots.js +34 -0
- package/dist/app/related-dots/dot-card.js +3 -3
- package/dist/app/related-dots/related-dots-columns.js +3 -1
- package/dist/app/related-dots/related-dots.js +22 -9
- package/dist/app/related-section.js +4 -4
- package/dist/app/relation-options.js +44 -0
- package/dist/app/relation-picker.js +114 -35
- package/dist/app/saved/saved-records.js +2 -2
- package/dist/app/search/search-model.js +2 -2
- package/dist/app/searches/saved-search.js +3 -3
- package/dist/app/searches/search-diff.js +8 -8
- package/dist/app/use-nav-recent.js +1 -2
- package/dist/data/api-provider.d.ts +4 -1
- package/dist/data/api-provider.js +12 -5
- package/dist/data/focus-refresh.js +44 -0
- package/dist/data/query-keys.js +18 -0
- package/dist/data/use-aggregates.js +14 -1
- package/dist/data/use-record.js +17 -2
- package/dist/data-grid/cell-moves.js +26 -0
- package/dist/data-grid/data-grid.js +32 -7
- package/dist/data-grid/grid-header.js +1 -1
- package/dist/data-grid/grid-row.js +17 -13
- package/dist/data-grid/page-cache.js +3 -0
- package/dist/data-grid/use-cell-editing.js +113 -0
- package/dist/data-grid/use-paged-rows.js +25 -4
- package/dist/index.js +2 -2
- package/dist/live/field-values.js +3 -1
- package/dist/live/related-column-rows.js +3 -3
- package/dist/live/relation-labels.js +2 -2
- package/dist/live/use-live-rows.js +17 -4
- package/dist/live/use-reference-search.js +2 -2
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-B5h_q_JL.js +69 -0
- package/dist/shell/assets/{index-CzV9U_NJ.js → index-B0veahji.js} +1 -1
- package/dist/shell/assets/index-BZmb28OT.css +2 -0
- package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-CbjT6RKG.js} +1 -1
- package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-BjRGtSOA.js} +1 -1
- package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-rmkTnAs8.js} +1 -1
- package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-D1vLGY06.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-PnkJqBqB.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/tokens.css +17 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-WromtADN.js +0 -69
- package/dist/shell/assets/index-DEEIUcYb.css +0 -2
|
@@ -11,7 +11,7 @@ import { jsx } from "react/jsx-runtime";
|
|
|
11
11
|
import { useQuery } from "@tanstack/react-query";
|
|
12
12
|
import { createWhere, printFilter } from "@protobase/schema";
|
|
13
13
|
//#region src/app/related-section.tsx
|
|
14
|
-
const where = createWhere();
|
|
14
|
+
const where$1 = createWhere();
|
|
15
15
|
/** The most join records a section reads to find its records; the list API's largest page. */
|
|
16
16
|
const maxThrough = 500;
|
|
17
17
|
const and = (...parts) => {
|
|
@@ -28,7 +28,7 @@ const useMatch = (item, recordKey) => {
|
|
|
28
28
|
const through = item.through;
|
|
29
29
|
const via = through ? resources[through.resource] : void 0;
|
|
30
30
|
const key = through?.key ?? via?.primaryKey[0];
|
|
31
|
-
const viaFilter = through ? and(printFilter(where.eq(through.field, recordKey)), through.filter) : "";
|
|
31
|
+
const viaFilter = through ? and(printFilter(where$1.eq(through.field, recordKey)), through.filter) : "";
|
|
32
32
|
const collected = useQuery({
|
|
33
33
|
queryKey: keys.list(via?.name ?? "", viaFilter, "", maxThrough, `related:${key}`),
|
|
34
34
|
enabled: Boolean(via && key),
|
|
@@ -42,7 +42,7 @@ const useMatch = (item, recordKey) => {
|
|
|
42
42
|
}
|
|
43
43
|
});
|
|
44
44
|
if (!through) return {
|
|
45
|
-
filter: and(printFilter(where.eq(item.field, recordKey)), item.filter),
|
|
45
|
+
filter: and(printFilter(where$1.eq(item.field, recordKey)), item.filter),
|
|
46
46
|
isPending: false,
|
|
47
47
|
error: null
|
|
48
48
|
};
|
|
@@ -52,7 +52,7 @@ const useMatch = (item, recordKey) => {
|
|
|
52
52
|
error: collected.error
|
|
53
53
|
};
|
|
54
54
|
return {
|
|
55
|
-
filter: collected.data.length === 0 ? null : and(printFilter(where.in(item.field, collected.data)), item.filter),
|
|
55
|
+
filter: collected.data.length === 0 ? null : and(printFilter(where$1.in(item.field, collected.data)), item.filter),
|
|
56
56
|
isPending: false,
|
|
57
57
|
error: null
|
|
58
58
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
2
|
+
//#region src/app/relation-options.ts
|
|
3
|
+
const where$1 = createWhere();
|
|
4
|
+
/** The words of typed text, as the list search splits it. */
|
|
5
|
+
const wordsOf = (text) => text.split(/\s+/).filter(Boolean);
|
|
6
|
+
/**
|
|
7
|
+
* How a relation picker finds the records that match typed text. A target with search fields uses the list's search;
|
|
8
|
+
* else, when its title field is a filterable text field, every word must appear in it, ignoring case, as the search
|
|
9
|
+
* matches one field. Otherwise, or once the server `refused` such a filter (the scan guard finds no index for it), the
|
|
10
|
+
* picker loads the records page by page and keeps the rows whose title matches (`local`).
|
|
11
|
+
*/
|
|
12
|
+
const relationOptionsQuery = (target, titleField, text, refused = false) => {
|
|
13
|
+
const words = wordsOf(text);
|
|
14
|
+
if (words.length === 0) return {
|
|
15
|
+
filter: void 0,
|
|
16
|
+
local: false
|
|
17
|
+
};
|
|
18
|
+
if (refused) return {
|
|
19
|
+
filter: void 0,
|
|
20
|
+
local: true
|
|
21
|
+
};
|
|
22
|
+
if (target.search?.length) return {
|
|
23
|
+
filter: printFilter(where$1.search(text)),
|
|
24
|
+
local: false
|
|
25
|
+
};
|
|
26
|
+
const field = target.fields[titleField];
|
|
27
|
+
if (field?.type !== "text" || !field.filterable) return {
|
|
28
|
+
filter: void 0,
|
|
29
|
+
local: true
|
|
30
|
+
};
|
|
31
|
+
const contains = words.map((word) => where$1.eq(titleField, `*${word.replaceAll("*", "")}*`));
|
|
32
|
+
return {
|
|
33
|
+
filter: printFilter(where$1.and(...contains)),
|
|
34
|
+
local: false
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/** Whether a title holds every word of the text, ignoring case: the picker's match on rows it filters itself. */
|
|
38
|
+
const titleMatches = (title, text) => {
|
|
39
|
+
if (title === null || title === void 0) return false;
|
|
40
|
+
const value = String(title).toLowerCase();
|
|
41
|
+
return wordsOf(text).every((word) => value.includes(word.toLowerCase()));
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { relationOptionsQuery, titleMatches };
|
|
@@ -1,63 +1,142 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
1
2
|
import { Input } from "../primitives/input.js";
|
|
2
|
-
import { useClient } from "../data/api-provider.js";
|
|
3
3
|
import { labelFieldOf } from "../live/model-helpers.js";
|
|
4
|
-
import {
|
|
4
|
+
import { humanize } from "../live/naming.js";
|
|
5
|
+
import { useList } from "../data/use-list.js";
|
|
6
|
+
import { relationOptionsQuery, titleMatches } from "./relation-options.js";
|
|
7
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
8
|
+
import { ApiError } from "@protobase/client";
|
|
5
9
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
-
import { useQuery } from "@tanstack/react-query";
|
|
7
|
-
import { createWhere, printFilter } from "@protobase/schema";
|
|
8
10
|
//#region src/app/relation-picker.tsx
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
/** Loads the next page while `sentinel` is in view of the list; it re-arms after each page, in case it still is. */
|
|
12
|
+
const useLoadMoreInView = (list, sentinel, loaded, loadMore) => {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!list || !sentinel) return;
|
|
15
|
+
const observer = new IntersectionObserver((entries) => entries.some((entry) => entry.isIntersecting) && loadMore(), { root: list });
|
|
16
|
+
observer.observe(sentinel);
|
|
17
|
+
return () => observer.disconnect();
|
|
18
|
+
}, [
|
|
19
|
+
list,
|
|
20
|
+
sentinel,
|
|
21
|
+
loaded,
|
|
22
|
+
loadMore
|
|
23
|
+
]);
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Picks a record of another resource: type to narrow the records by their search fields or title, scroll for more, and
|
|
27
|
+
* choose with the mouse or with the arrow keys and Enter; Escape closes the list.
|
|
28
|
+
*/
|
|
11
29
|
const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
|
|
12
|
-
const client = useClient();
|
|
13
30
|
const [text, setText] = useState("");
|
|
14
31
|
const [open, setOpen] = useState(false);
|
|
32
|
+
const [active, setActive] = useState(-1);
|
|
33
|
+
const [list, setList] = useState(null);
|
|
34
|
+
const [sentinel, setSentinel] = useState(null);
|
|
35
|
+
const listId = useId();
|
|
15
36
|
const keyField = target.primaryKey[0];
|
|
16
37
|
const labelField = labelFieldOf(target, view);
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
const [refused, setRefused] = useState(false);
|
|
39
|
+
const query = relationOptionsQuery(target, labelField, text, refused);
|
|
40
|
+
const rows = useList(target.name, {
|
|
41
|
+
filter: query.filter,
|
|
42
|
+
fields: [keyField, labelField],
|
|
43
|
+
enabled: open
|
|
44
|
+
});
|
|
45
|
+
const expensive = query.filter !== void 0 && rows.error instanceof ApiError && rows.error.slug === "expensive-query";
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (expensive) setRefused(true);
|
|
48
|
+
}, [expensive]);
|
|
49
|
+
const items = query.local ? rows.items.filter((item) => titleMatches(item[labelField], text)) : rows.items;
|
|
50
|
+
const more = Boolean(rows.hasNextPage);
|
|
51
|
+
const loading = rows.isPending || rows.isFetchingNextPage;
|
|
52
|
+
const { fetchNextPage } = rows;
|
|
53
|
+
useLoadMoreInView(list, sentinel, rows.items.length, useCallback(() => fetchNextPage({ cancelRefetch: false }), [fetchNextPage]));
|
|
54
|
+
const optionId = (index) => `${listId}-${index}`;
|
|
55
|
+
const input = useRef(null);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open && document.activeElement === input.current) input.current?.select();
|
|
58
|
+
}, [open]);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (open && active >= 0) document.getElementById(optionId(active))?.scrollIntoView({ block: "nearest" });
|
|
29
61
|
});
|
|
62
|
+
const close = () => {
|
|
63
|
+
setText("");
|
|
64
|
+
setActive(-1);
|
|
65
|
+
setOpen(false);
|
|
66
|
+
};
|
|
67
|
+
const choose = (item) => {
|
|
68
|
+
onChange(String(item[keyField]), String(item[labelField]));
|
|
69
|
+
close();
|
|
70
|
+
};
|
|
71
|
+
const onKeyDown = (event) => {
|
|
72
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
if (!open) return setOpen(true);
|
|
75
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
76
|
+
setActive((current) => Math.min(Math.max(current + step, 0), items.length - 1));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (event.key === "Enter" && open && items[active]) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
choose(items[active]);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (event.key === "Escape" && open) {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
event.stopPropagation();
|
|
87
|
+
close();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
30
90
|
return /* @__PURE__ */ jsxs("div", {
|
|
31
91
|
className: "relative",
|
|
32
92
|
children: [/* @__PURE__ */ jsx(Input, {
|
|
93
|
+
ref: input,
|
|
94
|
+
role: "combobox",
|
|
95
|
+
"aria-autocomplete": "list",
|
|
96
|
+
"aria-expanded": open,
|
|
97
|
+
"aria-controls": listId,
|
|
98
|
+
"aria-activedescendant": open && items[active] ? optionId(active) : void 0,
|
|
33
99
|
invalid,
|
|
34
|
-
placeholder: `Search ${target.name}`,
|
|
100
|
+
placeholder: `Search ${(view?.names?.plural ?? humanize(target.name)).toLowerCase()}`,
|
|
35
101
|
value: open ? text : label ?? value,
|
|
36
102
|
onFocus: () => setOpen(true),
|
|
37
|
-
onBlur:
|
|
38
|
-
|
|
103
|
+
onBlur: close,
|
|
104
|
+
onKeyDown,
|
|
105
|
+
onChange: (event) => {
|
|
106
|
+
setText(event.target.value);
|
|
107
|
+
setActive(event.target.value.trim() ? 0 : -1);
|
|
108
|
+
setOpen(true);
|
|
109
|
+
}
|
|
39
110
|
}), open && /* @__PURE__ */ jsxs("ul", {
|
|
111
|
+
ref: setList,
|
|
112
|
+
id: listId,
|
|
40
113
|
role: "listbox",
|
|
41
114
|
className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 shadow-pop",
|
|
115
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
42
116
|
children: [
|
|
43
|
-
|
|
44
|
-
|
|
117
|
+
items.map((item, index) => /* @__PURE__ */ jsx("li", {
|
|
118
|
+
id: optionId(index),
|
|
45
119
|
role: "option",
|
|
46
|
-
"aria-selected":
|
|
47
|
-
className: "
|
|
48
|
-
|
|
49
|
-
onClick: () =>
|
|
50
|
-
onChange(String(item[keyField]), String(item[labelField]));
|
|
51
|
-
setText("");
|
|
52
|
-
setOpen(false);
|
|
53
|
-
},
|
|
120
|
+
"aria-selected": index === active,
|
|
121
|
+
className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted", String(item[keyField]) === value && "font-medium"),
|
|
122
|
+
onMouseMove: () => index !== active && setActive(index),
|
|
123
|
+
onClick: () => choose(item),
|
|
54
124
|
children: String(item[labelField])
|
|
55
|
-
}
|
|
56
|
-
|
|
125
|
+
}, String(item[keyField]))),
|
|
126
|
+
more && /* @__PURE__ */ jsx("li", {
|
|
127
|
+
ref: setSentinel,
|
|
128
|
+
"aria-hidden": true,
|
|
129
|
+
className: "h-px"
|
|
130
|
+
}),
|
|
131
|
+
rows.error && !expensive && /* @__PURE__ */ jsx("li", {
|
|
132
|
+
className: "px-3 py-1.5 text-muted-foreground",
|
|
133
|
+
children: "Could not load the options"
|
|
134
|
+
}),
|
|
135
|
+
!more && !loading && !rows.error && items.length === 0 && /* @__PURE__ */ jsx("li", {
|
|
57
136
|
className: "px-3 py-1.5 text-muted-foreground",
|
|
58
137
|
children: "No matches"
|
|
59
138
|
}),
|
|
60
|
-
|
|
139
|
+
loading && /* @__PURE__ */ jsx("li", {
|
|
61
140
|
className: "px-3 py-1.5 text-muted-foreground",
|
|
62
141
|
children: "Loading"
|
|
63
142
|
})
|
|
@@ -2,7 +2,7 @@ import { recordKey } from "../../live/model-helpers.js";
|
|
|
2
2
|
import { createWhere } from "@protobase/schema";
|
|
3
3
|
import { encodeKey } from "@protobase/schema/internal";
|
|
4
4
|
//#region src/app/saved/saved-records.ts
|
|
5
|
-
const where = createWhere();
|
|
5
|
+
const where$1 = createWhere();
|
|
6
6
|
const relationValue = (value) => {
|
|
7
7
|
if (typeof value === "string" || typeof value === "number") return value;
|
|
8
8
|
if (value && typeof value === "object" && "id" in value) return relationValue(value.id);
|
|
@@ -32,7 +32,7 @@ const savedScope = (target, saved) => {
|
|
|
32
32
|
const [key] = target.primaryKey;
|
|
33
33
|
if (target.primaryKey.length !== 1 || key === void 0) throw new Error(`${target.name} has a composite key, so its saved records cannot be listed`);
|
|
34
34
|
const values = [...new Set(saved.map((entry) => entry.target))];
|
|
35
|
-
return values.length === 0 ? void 0 : where.in(key, values);
|
|
35
|
+
return values.length === 0 ? void 0 : where$1.in(key, values);
|
|
36
36
|
};
|
|
37
37
|
//#endregion
|
|
38
38
|
export { savedFor, savedRecordsOf, savedScope, withSaved, withoutSaved };
|
|
@@ -3,7 +3,7 @@ import { humanize } from "../../live/naming.js";
|
|
|
3
3
|
import { appPath, createWhere, enumLabel, printFilter } from "@protobase/schema";
|
|
4
4
|
import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
|
|
5
5
|
//#region src/app/search/search-model.ts
|
|
6
|
-
const where = createWhere();
|
|
6
|
+
const where$1 = createWhere();
|
|
7
7
|
const searchTargets = (meta) => Object.values(meta.resources).flatMap((model) => {
|
|
8
8
|
const view = meta.views[model.name];
|
|
9
9
|
if (!model.search?.length || !view || view.nav?.hidden || meta.permissions[model.name]?.read === false) return [];
|
|
@@ -22,7 +22,7 @@ const searchPlaceholder = (targets) => {
|
|
|
22
22
|
return `Search ${names[0]}, ${names[1]} and more`;
|
|
23
23
|
};
|
|
24
24
|
/** The list filter for the text: the server's partial-word `search(...)` over the resource's search fields. */
|
|
25
|
-
const searchFilter = (text) => printFilter(where.search(text.trim()));
|
|
25
|
+
const searchFilter = (text) => printFilter(where$1.search(text.trim()));
|
|
26
26
|
/** The fields a result's bold line joins: the view's `searchResult.title`, else the record's title field. */
|
|
27
27
|
const titleFields = (target) => {
|
|
28
28
|
const fields = target.view?.searchResult?.title;
|
|
@@ -4,7 +4,7 @@ import { textToState } from "../../live/filter-string.js";
|
|
|
4
4
|
import { rangeFormat, widgetFields } from "../../live/view-fields.js";
|
|
5
5
|
import { createWhere, enumLabel, parseFilter, printFilter } from "@protobase/schema";
|
|
6
6
|
//#region src/app/searches/saved-search.ts
|
|
7
|
-
const where = createWhere();
|
|
7
|
+
const where$1 = createWhere();
|
|
8
8
|
const span = {
|
|
9
9
|
start: 0,
|
|
10
10
|
end: 0
|
|
@@ -79,14 +79,14 @@ const facetNames = (config) => Object.fromEntries(config.facets.filter((group) =
|
|
|
79
79
|
*/
|
|
80
80
|
const unseenFilter = (text, field, seenAt) => {
|
|
81
81
|
if (typeof seenAt !== "string" || seenAt === "") return text;
|
|
82
|
-
const after = where.gt(field, {
|
|
82
|
+
const after = where$1.gt(field, {
|
|
83
83
|
kind: "timestamp",
|
|
84
84
|
value: new Date(seenAt).toISOString(),
|
|
85
85
|
span
|
|
86
86
|
});
|
|
87
87
|
const parsed = parseFilter(text);
|
|
88
88
|
if (!parsed.ok) throw new Error(`The saved filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
|
|
89
|
-
return printFilter(parsed.ast ? where.and(parsed.ast, after) : after);
|
|
89
|
+
return printFilter(parsed.ast ? where$1.and(parsed.ast, after) : after);
|
|
90
90
|
};
|
|
91
91
|
/** Whether a record whose `field` holds `value` is new to the user, as `unseenFilter` finds it: after `seenAt`, or any. */
|
|
92
92
|
const isUnseen = (value, seenAt) => {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createWhere, parseFilter, printFilter } from "@protobase/schema";
|
|
2
2
|
//#region src/app/searches/search-diff.ts
|
|
3
|
-
const where = createWhere();
|
|
3
|
+
const where$1 = createWhere();
|
|
4
4
|
const span = {
|
|
5
5
|
start: 0,
|
|
6
6
|
end: 0
|
|
7
7
|
};
|
|
8
|
-
const guarded = (atom, condition) => where.and(condition, {
|
|
8
|
+
const guarded = (atom, condition) => where$1.and(condition, {
|
|
9
9
|
kind: "present",
|
|
10
10
|
field: atom.field,
|
|
11
11
|
span
|
|
@@ -32,13 +32,13 @@ const surely = (expr) => {
|
|
|
32
32
|
/** The filter that holds exactly where `expr` fails, and fails, never unknown, everywhere else. */
|
|
33
33
|
const surelyNot = (expr) => {
|
|
34
34
|
switch (expr.kind) {
|
|
35
|
-
case "and": return where.or(...expr.args.map(surelyNot));
|
|
36
|
-
case "or": return where.and(...expr.args.map(surelyNot));
|
|
35
|
+
case "and": return where$1.or(...expr.args.map(surelyNot));
|
|
36
|
+
case "or": return where$1.and(...expr.args.map(surelyNot));
|
|
37
37
|
case "not": return surely(expr.arg);
|
|
38
38
|
case "search":
|
|
39
39
|
case "present":
|
|
40
|
-
case "isNull": return where.not(expr);
|
|
41
|
-
default: return guarded(expr, where.not(expr));
|
|
40
|
+
case "isNull": return where$1.not(expr);
|
|
41
|
+
default: return guarded(expr, where$1.not(expr));
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
44
|
const read = (text) => {
|
|
@@ -55,8 +55,8 @@ const addedFilter = (before, after) => {
|
|
|
55
55
|
const old = read(before);
|
|
56
56
|
if (!old) return null;
|
|
57
57
|
const next = read(after);
|
|
58
|
-
const notOld = where.not(surely(old));
|
|
59
|
-
return printFilter(next ? where.and(next, notOld) : notOld);
|
|
58
|
+
const notOld = where$1.not(surely(old));
|
|
59
|
+
return printFilter(next ? where$1.and(next, notOld) : notOld);
|
|
60
60
|
};
|
|
61
61
|
/** The filter of the records `before` finds and `after` no longer does; `null` when none can be. */
|
|
62
62
|
const removedFilter = (before, after) => addedFilter(after, before);
|
|
@@ -50,8 +50,7 @@ const useNavRecent = (meta, basePath, active) => {
|
|
|
50
50
|
useFollowRecords(groups);
|
|
51
51
|
const results = useQueries({ queries: groups.map((group) => ({
|
|
52
52
|
queryKey: groupKey(group),
|
|
53
|
-
queryFn: () => client.list(group.model.name, group.request)
|
|
54
|
-
refetchOnWindowFocus: true
|
|
53
|
+
queryFn: () => client.list(group.model.name, group.request)
|
|
55
54
|
})) });
|
|
56
55
|
return Object.fromEntries(groups.map(({ model, view, recent }, index) => {
|
|
57
56
|
const result = results[index];
|
|
@@ -22,6 +22,7 @@ export declare const useClient: () => {
|
|
|
22
22
|
limit?: number;
|
|
23
23
|
label?: string;
|
|
24
24
|
}) => Promise<import("@protobase/client").Facet[]>;
|
|
25
|
+
aggregate: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").AggregateParams) => Promise<import("@protobase/client").Aggregate>;
|
|
25
26
|
series: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").SeriesParams & {
|
|
26
27
|
filter?: import("@protobase/client").FilterInput;
|
|
27
28
|
}) => Promise<import("@protobase/client").Series>;
|
|
@@ -62,6 +63,7 @@ export declare const useResourceClient: (resource: string) => {
|
|
|
62
63
|
limit?: number;
|
|
63
64
|
label?: string;
|
|
64
65
|
}) => Promise<import("@protobase/client").Facet[]>;
|
|
66
|
+
aggregate: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").AggregateParams) => Promise<import("@protobase/client").Aggregate>;
|
|
65
67
|
series: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").SeriesParams & {
|
|
66
68
|
filter?: import("@protobase/client").FilterInput;
|
|
67
69
|
}) => Promise<import("@protobase/client").Series>;
|
|
@@ -97,7 +99,8 @@ export type ApiProviderProps = {
|
|
|
97
99
|
/**
|
|
98
100
|
* Owns the query cache and the API client; refetches `/meta` whenever `X-Meta-Version` changes. The version is
|
|
99
101
|
* compared only with the last one seen: it hashes the models and roles, while the `/meta` ETag hashes the whole
|
|
100
|
-
* per-caller body, so the two never match.
|
|
102
|
+
* per-caller body, so the two never match. The data on screen refetches whenever the window regains focus, unless a
|
|
103
|
+
* form holds unsaved changes (`usePauseFocusRefresh`).
|
|
101
104
|
*/
|
|
102
105
|
export declare const ApiProvider: ({ baseUrl, client: given, sources, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }: ApiProviderProps) => import("react").JSX.Element;
|
|
103
106
|
//#endregion
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { FocusRefreshContext, createFocusRefresh, listenToWindowFocus } from "./focus-refresh.js";
|
|
1
2
|
import { keys } from "./query-keys.js";
|
|
2
|
-
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
3
4
|
import { createClient } from "@protobase/client";
|
|
4
5
|
import { jsx } from "react/jsx-runtime";
|
|
5
|
-
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { QueryClient, QueryClientProvider, focusManager } from "@tanstack/react-query";
|
|
6
7
|
//#region src/data/api-provider.tsx
|
|
7
8
|
const ApiContext = createContext(null);
|
|
8
9
|
const SourceClientsContext = createContext(null);
|
|
@@ -44,14 +45,17 @@ const ClientProvider = ({ client, resourceClient, children }) => /* @__PURE__ */
|
|
|
44
45
|
/**
|
|
45
46
|
* Owns the query cache and the API client; refetches `/meta` whenever `X-Meta-Version` changes. The version is
|
|
46
47
|
* compared only with the last one seen: it hashes the models and roles, while the `/meta` ETag hashes the whole
|
|
47
|
-
* per-caller body, so the two never match.
|
|
48
|
+
* per-caller body, so the two never match. The data on screen refetches whenever the window regains focus, unless a
|
|
49
|
+
* form holds unsaved changes (`usePauseFocusRefresh`).
|
|
48
50
|
*/
|
|
49
51
|
const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: givenQueryClient, token, onUnauthenticated, children }) => {
|
|
52
|
+
const [focusRefresh] = useState(createFocusRefresh);
|
|
50
53
|
const [queryClient] = useState(() => givenQueryClient ?? new QueryClient({ defaultOptions: { queries: {
|
|
51
54
|
staleTime: 3e4,
|
|
52
55
|
retry: false,
|
|
53
|
-
refetchOnWindowFocus:
|
|
56
|
+
refetchOnWindowFocus: focusRefresh.refetchOnWindowFocus
|
|
54
57
|
} } }));
|
|
58
|
+
useEffect(() => focusManager.setEventListener(listenToWindowFocus), []);
|
|
55
59
|
const metaVersion = useRef(void 0);
|
|
56
60
|
const client = useMemo(() => given ?? createClient({
|
|
57
61
|
baseUrl,
|
|
@@ -104,7 +108,10 @@ const ApiProvider = ({ baseUrl, client: given, sources, fetch, queryClient: give
|
|
|
104
108
|
value: client,
|
|
105
109
|
children: /* @__PURE__ */ jsx(SourceClientsContext.Provider, {
|
|
106
110
|
value: sourceClient,
|
|
107
|
-
children
|
|
111
|
+
children: /* @__PURE__ */ jsx(FocusRefreshContext.Provider, {
|
|
112
|
+
value: focusRefresh,
|
|
113
|
+
children
|
|
114
|
+
})
|
|
108
115
|
})
|
|
109
116
|
})
|
|
110
117
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect } from "react";
|
|
2
|
+
//#region src/data/focus-refresh.ts
|
|
3
|
+
/**
|
|
4
|
+
* Whether the data on screen refetches when the window regains focus: always, unless a form on screen holds unsaved
|
|
5
|
+
* changes. A refetch under such a form would hand it a newer version of the record without the changes it was edited
|
|
6
|
+
* from, so its Save would no longer find a conflict.
|
|
7
|
+
*/
|
|
8
|
+
const createFocusRefresh = () => {
|
|
9
|
+
let paused = 0;
|
|
10
|
+
return {
|
|
11
|
+
refetchOnWindowFocus: () => paused > 0 ? false : "always",
|
|
12
|
+
/** Holds the refetches until the returned function is called. */
|
|
13
|
+
pause: () => {
|
|
14
|
+
paused += 1;
|
|
15
|
+
let released = false;
|
|
16
|
+
return () => {
|
|
17
|
+
if (released) return;
|
|
18
|
+
released = true;
|
|
19
|
+
paused -= 1;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
const FocusRefreshContext = createContext(null);
|
|
25
|
+
/** Holds the app's refetches on window focus while `paused`, such as while a form has unsaved changes. */
|
|
26
|
+
const usePauseFocusRefresh = (paused) => {
|
|
27
|
+
const refresh = useContext(FocusRefreshContext);
|
|
28
|
+
useEffect(() => paused && refresh ? refresh.pause() : void 0, [paused, refresh]);
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Tells TanStack Query the window regained focus on a return to the browser tab, as by default, and on a return to the
|
|
32
|
+
* browser window from another program, which leaves the tab visible.
|
|
33
|
+
*/
|
|
34
|
+
const listenToWindowFocus = (onFocus) => {
|
|
35
|
+
const listener = () => onFocus();
|
|
36
|
+
window.addEventListener("visibilitychange", listener, false);
|
|
37
|
+
window.addEventListener("focus", listener, false);
|
|
38
|
+
return () => {
|
|
39
|
+
window.removeEventListener("visibilitychange", listener);
|
|
40
|
+
window.removeEventListener("focus", listener);
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { FocusRefreshContext, createFocusRefresh, listenToWindowFocus, usePauseFocusRefresh };
|
package/dist/data/query-keys.js
CHANGED
|
@@ -25,6 +25,13 @@ const keys = {
|
|
|
25
25
|
pageSize,
|
|
26
26
|
page
|
|
27
27
|
],
|
|
28
|
+
/** Every `page` of one list. */
|
|
29
|
+
pages: (resource, filter, orderBy) => [
|
|
30
|
+
"page",
|
|
31
|
+
resource,
|
|
32
|
+
filter,
|
|
33
|
+
orderBy
|
|
34
|
+
],
|
|
28
35
|
/** A sidebar group's records; starts with `list`, so invalidating a resource's lists refreshes it too. */
|
|
29
36
|
navRecent: (resource, filter, orderBy, pageSize) => [
|
|
30
37
|
"list",
|
|
@@ -45,6 +52,12 @@ const keys = {
|
|
|
45
52
|
resource,
|
|
46
53
|
key
|
|
47
54
|
],
|
|
55
|
+
/** What a list row knows of a record, which its page shows until the record has loaded. */
|
|
56
|
+
recordPreview: (resource, key) => [
|
|
57
|
+
"record-preview",
|
|
58
|
+
resource,
|
|
59
|
+
key
|
|
60
|
+
],
|
|
48
61
|
facets: (resource, field, filter) => [
|
|
49
62
|
"facets",
|
|
50
63
|
resource,
|
|
@@ -59,6 +72,11 @@ const keys = {
|
|
|
59
72
|
granularity,
|
|
60
73
|
filter
|
|
61
74
|
],
|
|
75
|
+
aggregate: (resource, params) => [
|
|
76
|
+
"aggregate",
|
|
77
|
+
resource,
|
|
78
|
+
params
|
|
79
|
+
],
|
|
62
80
|
histogram: (resource, field, buckets, filter) => [
|
|
63
81
|
"histogram",
|
|
64
82
|
resource,
|
|
@@ -39,5 +39,18 @@ const useHistogram = (resource, field, filter, buckets = 20, enabled = true) =>
|
|
|
39
39
|
})
|
|
40
40
|
});
|
|
41
41
|
};
|
|
42
|
+
/** Totals per value or date bucket of a field (`:aggregate`), refetched whenever the resource is written. */
|
|
43
|
+
const useAggregate = (resource, params, enabled = true) => {
|
|
44
|
+
const client = useClient();
|
|
45
|
+
const wire = {
|
|
46
|
+
...params,
|
|
47
|
+
filter: filterText(params.filter) ?? ""
|
|
48
|
+
};
|
|
49
|
+
return useQuery({
|
|
50
|
+
queryKey: keys.aggregate(resource, JSON.stringify(wire)),
|
|
51
|
+
enabled,
|
|
52
|
+
queryFn: () => client.aggregate(resource, wire)
|
|
53
|
+
});
|
|
54
|
+
};
|
|
42
55
|
//#endregion
|
|
43
|
-
export { useFacets, useHistogram, useSeries };
|
|
56
|
+
export { useAggregate, useFacets, useHistogram, useSeries };
|
package/dist/data/use-record.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { keys } from "./query-keys.js";
|
|
2
2
|
import { useClient } from "./api-provider.js";
|
|
3
|
-
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import { encodeKey } from "@protobase/schema/internal";
|
|
5
5
|
//#region src/data/use-record.ts
|
|
6
6
|
const useRecord = (resource, key, enabled = true) => {
|
|
@@ -12,5 +12,20 @@ const useRecord = (resource, key, enabled = true) => {
|
|
|
12
12
|
queryFn: () => client.get(resource, key)
|
|
13
13
|
});
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* `useRecord` for the record's page: until the record has loaded the first time, `data` is what the list row it was
|
|
17
|
+
* opened from knows of it, when it was opened from one, with `isPlaceholderData` set: only the fields the list loaded.
|
|
18
|
+
*/
|
|
19
|
+
const useOpenedRecord = (resource, key, enabled = true) => {
|
|
20
|
+
const client = useClient();
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const encoded = encodeKey(key);
|
|
23
|
+
return useQuery({
|
|
24
|
+
queryKey: keys.record(resource, encoded),
|
|
25
|
+
enabled,
|
|
26
|
+
queryFn: () => client.get(resource, key),
|
|
27
|
+
placeholderData: () => queryClient.getQueryData(keys.recordPreview(resource, encoded))
|
|
28
|
+
});
|
|
29
|
+
};
|
|
15
30
|
//#endregion
|
|
16
|
-
export { useRecord };
|
|
31
|
+
export { useOpenedRecord, useRecord };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/data-grid/cell-moves.ts
|
|
2
|
+
const isMoveKey = (key) => key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight";
|
|
3
|
+
/** The cell an arrow key moves to from `at` among `total` rows of `columns`, or undefined at the grid's edge. */
|
|
4
|
+
const movedCell = (at, key, columns, total) => {
|
|
5
|
+
const column = columns.indexOf(at.column);
|
|
6
|
+
if (key === "ArrowUp") return at.index > 0 ? {
|
|
7
|
+
...at,
|
|
8
|
+
index: at.index - 1
|
|
9
|
+
} : void 0;
|
|
10
|
+
if (key === "ArrowDown") return at.index < total - 1 ? {
|
|
11
|
+
...at,
|
|
12
|
+
index: at.index + 1
|
|
13
|
+
} : void 0;
|
|
14
|
+
const next = columns[key === "ArrowLeft" ? column - 1 : column + 1];
|
|
15
|
+
return column >= 0 && next !== void 0 ? {
|
|
16
|
+
...at,
|
|
17
|
+
column: next
|
|
18
|
+
} : void 0;
|
|
19
|
+
};
|
|
20
|
+
/** The editable column after (`1`) or before (`-1`) `column` in its row, or undefined when there is none. */
|
|
21
|
+
const nextEditable = (column, columns, editable, direction) => {
|
|
22
|
+
const from = columns.indexOf(column);
|
|
23
|
+
return (direction === 1 ? columns.slice(from + 1) : columns.slice(0, Math.max(0, from)).reverse()).find(editable);
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { isMoveKey, movedCell, nextEditable };
|