@protobase/ui 0.19.16 → 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 (30) hide show
  1. package/dist/app/charts/chart-groups.js +52 -0
  2. package/dist/app/charts/chart-view.js +75 -0
  3. package/dist/app/charts/group-bars.js +49 -0
  4. package/dist/app/charts/group-donut.js +84 -0
  5. package/dist/app/charts/period-chart.js +174 -0
  6. package/dist/app/list-group-chart.js +37 -0
  7. package/dist/app/list-page.js +10 -3
  8. package/dist/app/pages/blocks/chart.js +26 -0
  9. package/dist/app/pages/render-node.js +2 -0
  10. package/dist/app/relation-options.js +44 -0
  11. package/dist/app/relation-picker.js +114 -35
  12. package/dist/data/api-provider.d.ts +2 -0
  13. package/dist/data/query-keys.js +5 -0
  14. package/dist/data/use-aggregates.js +14 -1
  15. package/dist/index.js +2 -2
  16. package/dist/package.js +1 -1
  17. package/dist/shell/assets/app-B5h_q_JL.js +69 -0
  18. package/dist/shell/assets/{index-CmmYBR7Y.js → index-B0veahji.js} +1 -1
  19. package/dist/shell/assets/index-BZmb28OT.css +2 -0
  20. package/dist/shell/assets/{maplibre-Siom2Zwj.js → maplibre-CbjT6RKG.js} +1 -1
  21. package/dist/shell/assets/{markdown-content-ChyGEubR.js → markdown-content-BjRGtSOA.js} +1 -1
  22. package/dist/shell/assets/{record-map-B_QnHF99.js → record-map-rmkTnAs8.js} +1 -1
  23. package/dist/shell/assets/{records-map-CcHhEDBC.js → records-map-D1vLGY06.js} +1 -1
  24. package/dist/shell/assets/{shared-protobase-ui-zRmreKZS.js → shared-protobase-ui-PnkJqBqB.js} +1 -1
  25. package/dist/shell/index.html +4 -4
  26. package/dist/shell/protobase.shell.json +1 -1
  27. package/dist/tokens.css +17 -0
  28. package/package.json +4 -4
  29. package/dist/shell/assets/app-xQmLJM07.js +0 -69
  30. package/dist/shell/assets/index-CmJl5dIO.css +0 -2
@@ -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$1 = 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$1.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
  })
@@ -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>;
@@ -72,6 +72,11 @@ const keys = {
72
72
  granularity,
73
73
  filter
74
74
  ],
75
+ aggregate: (resource, params) => [
76
+ "aggregate",
77
+ resource,
78
+ params
79
+ ],
75
80
  histogram: (resource, field, buckets, filter) => [
76
81
  "histogram",
77
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/index.js CHANGED
@@ -37,14 +37,14 @@ import { Field } from "./record-view/field.js";
37
37
  import { Section } from "./record-view/section.js";
38
38
  import { Textarea } from "./primitives/textarea.js";
39
39
  import { AppLink, useAppAddress } from "./app/use-app-address.js";
40
+ import { useList } from "./data/use-list.js";
40
41
  import { ActionInputError } from "./app/pages/action-inputs/input-errors.js";
41
42
  import { useLayoutRecord } from "./app/pages/record-scope.js";
43
+ import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
42
44
  import { SignInPrompt } from "./auth/sign-in-prompt.js";
43
45
  import { useListDisplay } from "./app/displays/display-context.js";
44
46
  import { MenuSelect } from "./primitives/menu-select.js";
45
47
  import { Form } from "./app/create/form.js";
46
- import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
47
- import { useList } from "./data/use-list.js";
48
48
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
49
49
  import { App } from "./app/app.js";
50
50
  import { RecordLink } from "./app/displays/record-link.js";
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.19.16";
2
+ var version = "0.20.0";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };