@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.
Files changed (80) hide show
  1. package/dist/app/app.js +2 -2
  2. package/dist/app/charts/chart-groups.js +52 -0
  3. package/dist/app/charts/chart-view.js +75 -0
  4. package/dist/app/charts/group-bars.js +49 -0
  5. package/dist/app/charts/group-donut.js +84 -0
  6. package/dist/app/charts/period-chart.js +174 -0
  7. package/dist/app/displays/board-column.js +114 -0
  8. package/dist/app/displays/board-columns.js +45 -0
  9. package/dist/app/displays/board-display.js +96 -0
  10. package/dist/app/displays/board-dnd.js +32 -0
  11. package/dist/app/displays/list-displays.js +13 -1
  12. package/dist/app/displays/record-card.js +16 -5
  13. package/dist/app/displays/use-board-column.js +23 -0
  14. package/dist/app/displays/use-board-move.js +60 -0
  15. package/dist/app/displays/virtual-cards.js +1 -0
  16. package/dist/app/embedded-list/embedded-list.js +9 -0
  17. package/dist/app/export/export-options.js +2 -2
  18. package/dist/app/gallery/use-gallery-photos.js +2 -2
  19. package/dist/app/import/lookups.js +2 -2
  20. package/dist/app/list-edit/cell-field-editor.js +61 -0
  21. package/dist/app/list-edit/use-list-cell-editor.js +30 -0
  22. package/dist/app/list-edit/use-list-edits.js +75 -0
  23. package/dist/app/list-group-chart.js +37 -0
  24. package/dist/app/list-page.js +38 -13
  25. package/dist/app/map/map-area.js +6 -6
  26. package/dist/app/pages/action-href.js +4 -2
  27. package/dist/app/pages/blocks/chart.js +26 -0
  28. package/dist/app/pages/render-node.js +2 -0
  29. package/dist/app/pages/use-run-action.js +4 -3
  30. package/dist/app/record-page.js +30 -16
  31. package/dist/app/record-preview.js +25 -0
  32. package/dist/app/record-row/record-row-body.js +2 -2
  33. package/dist/app/record-row/record-row.js +1 -0
  34. package/dist/app/related-dots/card-dots-lines.js +16 -0
  35. package/dist/app/related-dots/card-dots.js +34 -0
  36. package/dist/app/related-dots/dot-card.js +3 -3
  37. package/dist/app/related-dots/related-dots-columns.js +3 -1
  38. package/dist/app/related-dots/related-dots.js +22 -9
  39. package/dist/app/related-section.js +4 -4
  40. package/dist/app/relation-options.js +44 -0
  41. package/dist/app/relation-picker.js +114 -35
  42. package/dist/app/saved/saved-records.js +2 -2
  43. package/dist/app/search/search-model.js +2 -2
  44. package/dist/app/searches/saved-search.js +3 -3
  45. package/dist/app/searches/search-diff.js +8 -8
  46. package/dist/app/use-nav-recent.js +1 -2
  47. package/dist/data/api-provider.d.ts +4 -1
  48. package/dist/data/api-provider.js +12 -5
  49. package/dist/data/focus-refresh.js +44 -0
  50. package/dist/data/query-keys.js +18 -0
  51. package/dist/data/use-aggregates.js +14 -1
  52. package/dist/data/use-record.js +17 -2
  53. package/dist/data-grid/cell-moves.js +26 -0
  54. package/dist/data-grid/data-grid.js +32 -7
  55. package/dist/data-grid/grid-header.js +1 -1
  56. package/dist/data-grid/grid-row.js +17 -13
  57. package/dist/data-grid/page-cache.js +3 -0
  58. package/dist/data-grid/use-cell-editing.js +113 -0
  59. package/dist/data-grid/use-paged-rows.js +25 -4
  60. package/dist/index.js +2 -2
  61. package/dist/live/field-values.js +3 -1
  62. package/dist/live/related-column-rows.js +3 -3
  63. package/dist/live/relation-labels.js +2 -2
  64. package/dist/live/use-live-rows.js +17 -4
  65. package/dist/live/use-reference-search.js +2 -2
  66. package/dist/package.js +1 -1
  67. package/dist/shell/assets/app-B5h_q_JL.js +69 -0
  68. package/dist/shell/assets/{index-CzV9U_NJ.js → index-B0veahji.js} +1 -1
  69. package/dist/shell/assets/index-BZmb28OT.css +2 -0
  70. package/dist/shell/assets/{maplibre-G35v6TYU.js → maplibre-CbjT6RKG.js} +1 -1
  71. package/dist/shell/assets/{markdown-content-DGFnggSX.js → markdown-content-BjRGtSOA.js} +1 -1
  72. package/dist/shell/assets/{record-map-ZAC_U52Y.js → record-map-rmkTnAs8.js} +1 -1
  73. package/dist/shell/assets/{records-map-DLXOQVpM.js → records-map-D1vLGY06.js} +1 -1
  74. package/dist/shell/assets/{shared-protobase-ui-xByMDSPg.js → shared-protobase-ui-PnkJqBqB.js} +1 -1
  75. package/dist/shell/index.html +4 -4
  76. package/dist/shell/protobase.shell.json +1 -1
  77. package/dist/tokens.css +17 -0
  78. package/package.json +4 -4
  79. package/dist/shell/assets/app-WromtADN.js +0 -69
  80. 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 { useState } from "react";
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
- const where = createWhere();
10
- /** Picks a record of another resource: type to search (when the target has search fields), choose from the first matches. */
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 options = useQuery({
18
- queryKey: [
19
- "relation-options",
20
- target.name,
21
- text
22
- ],
23
- enabled: open,
24
- queryFn: () => client.list(target.name, {
25
- filter: text && target.search?.length ? printFilter(where.search(text)) : void 0,
26
- fields: [keyField, labelField],
27
- pageSize: 8
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: () => setTimeout(() => setOpen(false), 120),
38
- onChange: (event) => setText(event.target.value)
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
- options.data?.items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
44
- type: "button",
117
+ items.map((item, index) => /* @__PURE__ */ jsx("li", {
118
+ id: optionId(index),
45
119
  role: "option",
46
- "aria-selected": String(item[keyField]) === value,
47
- className: "block w-full truncate px-3 py-1.5 text-left hover:bg-muted",
48
- onMouseDown: (event) => event.preventDefault(),
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
- }) }, String(item[keyField]))),
56
- options.data && options.data.items.length === 0 && /* @__PURE__ */ jsx("li", {
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
- options.isPending && /* @__PURE__ */ jsx("li", {
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: false
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 };
@@ -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 };
@@ -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 };