@protobase/ui 0.19.16 → 0.21.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 (38) 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/create/create-values.js +2 -1
  7. package/dist/app/field-editor.js +29 -11
  8. package/dist/app/history/describe-change.js +44 -0
  9. package/dist/app/history/history-section.js +92 -0
  10. package/dist/app/history/use-record-history.js +34 -0
  11. package/dist/app/list-group-chart.js +37 -0
  12. package/dist/app/list-page.js +10 -3
  13. package/dist/app/pages/blocks/chart.js +26 -0
  14. package/dist/app/pages/render-node.js +2 -0
  15. package/dist/app/record-page.js +43 -7
  16. package/dist/app/relation-options.js +44 -0
  17. package/dist/app/relation-picker.js +114 -35
  18. package/dist/data/api-provider.d.ts +10 -0
  19. package/dist/data/query-keys.js +13 -0
  20. package/dist/data/use-aggregates.js +14 -1
  21. package/dist/format/date.js +15 -2
  22. package/dist/index.js +2 -2
  23. package/dist/live/field-values.js +1 -0
  24. package/dist/package.js +1 -1
  25. package/dist/shell/assets/app-BBx2Bkop.js +69 -0
  26. package/dist/shell/assets/index-Dc6xgHsz.css +2 -0
  27. package/dist/shell/assets/{index-CmmYBR7Y.js → index-iXIDNHEm.js} +1 -1
  28. package/dist/shell/assets/{maplibre-Siom2Zwj.js → maplibre-DxWag0Wp.js} +1 -1
  29. package/dist/shell/assets/{markdown-content-ChyGEubR.js → markdown-content-Bco0XDqw.js} +1 -1
  30. package/dist/shell/assets/{record-map-B_QnHF99.js → record-map-Ca1fz02Q.js} +1 -1
  31. package/dist/shell/assets/{records-map-CcHhEDBC.js → records-map-JooppeKw.js} +1 -1
  32. package/dist/shell/assets/{shared-protobase-ui-zRmreKZS.js → shared-protobase-ui-BZs06oKf.js} +1 -1
  33. package/dist/shell/index.html +4 -4
  34. package/dist/shell/protobase.shell.json +1 -1
  35. package/dist/tokens.css +17 -0
  36. package/package.json +4 -4
  37. package/dist/shell/assets/app-xQmLJM07.js +0 -69
  38. package/dist/shell/assets/index-CmJl5dIO.css +0 -2
@@ -0,0 +1,37 @@
1
+ import { humanize } from "../live/naming.js";
2
+ import { ChartView } from "./charts/chart-view.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/list-group-chart.tsx
5
+ const measureTitle = (chart, view, plural) => {
6
+ const name = chart.sum ?? chart.average;
7
+ if (name === void 0) return plural;
8
+ const label = view?.fields[name]?.label ?? humanize(name);
9
+ return chart.sum !== void 0 ? `${label}` : `Average ${label.toLowerCase()}`;
10
+ };
11
+ /** The view's chart per value of a field, above the list and following its filter. */
12
+ const ListGroupChart = ({ model, view, chart, plural, filter }) => {
13
+ const by = view?.fields[chart.groupBy]?.label ?? humanize(chart.groupBy);
14
+ return /* @__PURE__ */ jsxs("section", {
15
+ className: "rounded-lg border bg-background px-4 pb-3 pt-3",
16
+ "aria-label": `${measureTitle(chart, view, plural)} by ${by.toLowerCase()}`,
17
+ children: [/* @__PURE__ */ jsxs("h2", {
18
+ className: "mb-2 font-semibold",
19
+ children: [
20
+ measureTitle(chart, view, plural),
21
+ " by ",
22
+ by.toLowerCase()
23
+ ]
24
+ }), /* @__PURE__ */ jsx(ChartView, {
25
+ model,
26
+ view,
27
+ groupBy: chart.groupBy,
28
+ type: chart.type,
29
+ sum: chart.sum,
30
+ average: chart.average,
31
+ limit: chart.limit,
32
+ filter
33
+ })]
34
+ });
35
+ };
36
+ //#endregion
37
+ export { ListGroupChart };
@@ -45,6 +45,7 @@ import { useExportFlow } from "./export/use-export-flow.js";
45
45
  import { ListCards } from "./displays/list-cards.js";
46
46
  import { useListScroll } from "./list-scroll.js";
47
47
  import { ListChart } from "./list-chart.js";
48
+ import { ListGroupChart } from "./list-group-chart.js";
48
49
  import { ListSearch } from "./list-search.js";
49
50
  import { withRelatedDotsColumns } from "./related-dots/related-dots-columns.js";
50
51
  import { SavedList } from "./saved/saved-list.js";
@@ -55,7 +56,7 @@ import { useMemo, useState } from "react";
55
56
  import { Download, Plus, SlidersHorizontal } from "lucide-react";
56
57
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
57
58
  import { createWhere } from "@protobase/schema";
58
- import { tableDisplay } from "@protobase/schema/internal";
59
+ import { isGroupChart, tableDisplay } from "@protobase/schema/internal";
59
60
  //#region src/app/list-page.tsx
60
61
  const where$1 = createWhere();
61
62
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
@@ -422,13 +423,19 @@ const LoadedList = ({ model, address, scope, heading }) => {
422
423
  title: "Could not load rows",
423
424
  error: rows.error
424
425
  }),
425
- view?.chart && /* @__PURE__ */ jsx(ListChart, {
426
+ view?.chart && (isGroupChart(view.chart) ? /* @__PURE__ */ jsx(ListGroupChart, {
427
+ model,
428
+ view,
429
+ chart: view.chart,
430
+ plural,
431
+ filter: loadFilter
432
+ }) : /* @__PURE__ */ jsx(ListChart, {
426
433
  resource,
427
434
  chart: view.chart,
428
435
  title: `${plural} created`,
429
436
  noun: plural.toLowerCase(),
430
437
  filter: loadFilter
431
- }),
438
+ })),
432
439
  desktop ? desktopToolbar : phoneFilters,
433
440
  desktop && layout === "panel" ? /* @__PURE__ */ jsxs("div", {
434
441
  className: "flex min-h-0 flex-1 gap-4",
@@ -0,0 +1,26 @@
1
+ import { useAdminMeta } from "../../meta-gate.js";
2
+ import { propsOf } from "./block-props.js";
3
+ import { CardFrame } from "./card-frame.js";
4
+ import { ChartView } from "../../charts/chart-view.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/app/pages/blocks/chart.tsx
7
+ /** Counts, sums or averages of the records that match, per value or date bucket of a field, in a card. */
8
+ const ChartBlock = ({ node, slots }) => {
9
+ const { resource, title, description, actions: _actions, span: _span, ...chart } = propsOf(node);
10
+ const { resources, views } = useAdminMeta();
11
+ const model = resources[resource];
12
+ if (!model) return null;
13
+ return /* @__PURE__ */ jsx(CardFrame, {
14
+ title,
15
+ description,
16
+ actions: slots.actions,
17
+ className: "h-full",
18
+ children: /* @__PURE__ */ jsx(ChartView, {
19
+ model,
20
+ view: views[resource],
21
+ ...chart
22
+ })
23
+ });
24
+ };
25
+ //#endregion
26
+ export { ChartBlock };
@@ -4,6 +4,7 @@ import { useProjectUi } from "./project-ui.js";
4
4
  import { ActionBlock } from "./blocks/action.js";
5
5
  import { CardBlock } from "./blocks/card.js";
6
6
  import { CardRowBlock } from "./blocks/card-row.js";
7
+ import { ChartBlock } from "./blocks/chart.js";
7
8
  import { FieldBlock } from "./blocks/field.js";
8
9
  import { GridBlock } from "./blocks/grid.js";
9
10
  import { LinkBlock } from "./blocks/link.js";
@@ -30,6 +31,7 @@ const blocks = {
30
31
  Table: TableBlock,
31
32
  CardRow: CardRowBlock,
32
33
  RecordRow: RecordRowBlock,
34
+ Chart: ChartBlock,
33
35
  Progress: ProgressBlock,
34
36
  ModalForm: ModalFormBlock,
35
37
  Action: ActionBlock,
@@ -18,8 +18,9 @@ import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record
18
18
  import { RecordHeader } from "../record-view/record-header.js";
19
19
  import { SpecTable } from "../record-view/spec-table.js";
20
20
  import { KeyFacts } from "../record-view/key-facts.js";
21
- import { isRequiredOnEdit } from "../live/field-values.js";
21
+ import { fromDraft, isRequiredOnEdit } from "../live/field-values.js";
22
22
  import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
23
+ import { isChosen, keyValue } from "./create/create-values.js";
23
24
  import { ErrorBanner } from "./error-banner.js";
24
25
  import { displayValue } from "../live/display-value.js";
25
26
  import { filledFields } from "./filled-fields.js";
@@ -45,6 +46,7 @@ import { draftStorageKey } from "./draft-storage.js";
45
46
  import { useLines } from "./use-lines.js";
46
47
  import { useLinesDraft } from "./use-lines-draft.js";
47
48
  import { GallerySection } from "./gallery/gallery-section.js";
49
+ import { HistorySection } from "./history/history-section.js";
48
50
  import { useRecordEditor } from "./use-record-editor.js";
49
51
  import { useRelatedRecords } from "./use-related-records.js";
50
52
  import { RelatedSection } from "./related-section.js";
@@ -57,8 +59,9 @@ import { enumLabel } from "@protobase/schema";
57
59
  //#region src/app/record-page.tsx
58
60
  const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
59
61
  /**
60
- * The view's layout, or one section with the editable fields when the view has none. The read view, which is all a
61
- * user who may only read the resource gets, shows every field: the record rather than what can be changed.
62
+ * The view's layout, or one section with the editable fields when the view has none, then History when the resource
63
+ * keeps it. The read view, which is all a user who may only read the resource gets, shows every field: the record rather
64
+ * than what can be changed.
62
65
  */
63
66
  const layoutFor = (model, view, read) => {
64
67
  if (view && view.layout.length > 0) return view.layout;
@@ -68,7 +71,10 @@ const layoutFor = (model, view, read) => {
68
71
  kind: "section",
69
72
  title: "Details",
70
73
  fields: (editable.length > 0 && !read ? editable : shown).map((field) => field.name)
71
- }];
74
+ }, ...model.history ? [{
75
+ kind: "history",
76
+ title: "History"
77
+ }] : []];
72
78
  };
73
79
  const RecordPage = ({ resource, recordKey }) => {
74
80
  const { resources } = useAdminMeta();
@@ -152,6 +158,24 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
152
158
  onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
153
159
  });
154
160
  const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
161
+ const pickTarget = (name) => {
162
+ const field = model.fields[name];
163
+ const target = field?.relation ? meta.resources[field.relation.resource] : void 0;
164
+ if (!target || target.primaryKey.length !== 1 || name === model.tenant || model.primaryKey.includes(name)) return void 0;
165
+ return target;
166
+ };
167
+ const picked = Object.values(model.fields).filter((field) => pickTarget(field.name));
168
+ const drafted = {
169
+ ...stored.record,
170
+ ...Object.fromEntries(picked.map((field) => [field.name, fromDraft(field, editor.value(field.name))]))
171
+ };
172
+ const chosen = useRelatedRecords(model, view, meta.resources, meta.views, drafted, basePath);
173
+ const pick = (name, next) => {
174
+ const target = pickTarget(name);
175
+ if (!target || !isChosen(next)) return editor.change(name, next);
176
+ queryClient.setQueryData(keys.relatedLabel(target.name, next.key), next.label);
177
+ editor.change(name, keyValue(target, next.key));
178
+ };
155
179
  const { fields: fieldTypes } = useProjectUi();
156
180
  const layout = layoutFor(model, view, shown === "read").filter((item) => whenShown(item.when, editing));
157
181
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
@@ -167,6 +191,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
167
191
  const access = fieldAccess(stored.permissions, name);
168
192
  if (!field || access === "hidden") return null;
169
193
  const conflict = editor.conflicts[name];
194
+ const target = pickTarget(name);
170
195
  return /* @__PURE__ */ jsx(Field, {
171
196
  label: hint(name)?.label ?? humanize(name),
172
197
  required: shown === "form" && access !== "read" && isRequiredOnEdit(field),
@@ -189,13 +214,17 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
189
214
  hints: hint(name),
190
215
  value: editor.value(name),
191
216
  stored: stored.record[name],
192
- relationLabel: related.labels[name],
193
- relationTo: related.addresses[name],
217
+ relationLabel: chosen.labels[name],
218
+ relationTo: chosen.addresses[name],
219
+ relationPicker: target && {
220
+ target,
221
+ view: meta.views[target.name]
222
+ },
194
223
  locked: shown !== "form" || access === "read",
195
224
  invalid: Boolean(editor.errors[name]),
196
225
  reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
197
226
  resource: model.name,
198
- onChange: (next) => editor.change(name, next)
227
+ onChange: (next) => pick(name, next)
199
228
  }) : /* @__PURE__ */ jsx(Skeleton, { className: "h-9" })
200
229
  }, field.sensitive ? `${name}:${stored.etag}` : name);
201
230
  };
@@ -381,6 +410,13 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
381
410
  record: stored.record,
382
411
  recordKey
383
412
  }, `gallery:${index}`)];
413
+ if (item.kind === "history") return [/* @__PURE__ */ jsx(HistorySection, {
414
+ item,
415
+ model,
416
+ view,
417
+ recordKey,
418
+ etag: stored.etag
419
+ }, `history:${index}`)];
384
420
  if (item.kind !== "section") return [];
385
421
  const visibleFields = item.fields.filter((name) => model.fields[name] && visible(name));
386
422
  const facts = shown === "read" && item.presentation === "facts";
@@ -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$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
  })
@@ -16,12 +16,17 @@ export declare const useClient: () => {
16
16
  }) => Promise<void>;
17
17
  undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
18
18
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
19
+ history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
20
+ pageSize?: number;
21
+ pageToken?: string;
22
+ }) => Promise<import("@protobase/client").HistoryPage>;
19
23
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
20
24
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
21
25
  filter?: import("@protobase/client").FilterInput;
22
26
  limit?: number;
23
27
  label?: string;
24
28
  }) => Promise<import("@protobase/client").Facet[]>;
29
+ aggregate: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").AggregateParams) => Promise<import("@protobase/client").Aggregate>;
25
30
  series: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").SeriesParams & {
26
31
  filter?: import("@protobase/client").FilterInput;
27
32
  }) => Promise<import("@protobase/client").Series>;
@@ -56,12 +61,17 @@ export declare const useResourceClient: (resource: string) => {
56
61
  }) => Promise<void>;
57
62
  undelete: <R extends import("@protobase/client").ResourceRef>(resource: R, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[]) => Promise<import("@protobase/client").Stored<import("@protobase/client").RecordOf<R>>>;
58
63
  reveal: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], field: string) => Promise<unknown>;
64
+ history: (resource: import("@protobase/client").ResourceRef, key: import("@protobase/schema").KeyValue | readonly import("@protobase/schema").KeyValue[], { pageSize, pageToken }?: {
65
+ pageSize?: number;
66
+ pageToken?: string;
67
+ }) => Promise<import("@protobase/client").HistoryPage>;
59
68
  batchWrite: (ops: import("@protobase/client").BatchOp[]) => Promise<import("@protobase/client").BatchResult>;
60
69
  facets: (resource: import("@protobase/client").ResourceRef, field: string, o?: {
61
70
  filter?: import("@protobase/client").FilterInput;
62
71
  limit?: number;
63
72
  label?: string;
64
73
  }) => Promise<import("@protobase/client").Facet[]>;
74
+ aggregate: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").AggregateParams) => Promise<import("@protobase/client").Aggregate>;
65
75
  series: (resource: import("@protobase/client").ResourceRef, params: import("@protobase/client").SeriesParams & {
66
76
  filter?: import("@protobase/client").FilterInput;
67
77
  }) => Promise<import("@protobase/client").Series>;
@@ -58,6 +58,14 @@ const keys = {
58
58
  resource,
59
59
  key
60
60
  ],
61
+ /** A record's changes; starts with `record`, so refreshing the record refreshes them too. */
62
+ history: (resource, key, pageSize) => [
63
+ "record",
64
+ resource,
65
+ key,
66
+ "history",
67
+ pageSize
68
+ ],
61
69
  facets: (resource, field, filter) => [
62
70
  "facets",
63
71
  resource,
@@ -72,6 +80,11 @@ const keys = {
72
80
  granularity,
73
81
  filter
74
82
  ],
83
+ aggregate: (resource, params) => [
84
+ "aggregate",
85
+ resource,
86
+ params
87
+ ],
75
88
  histogram: (resource, field, buckets, filter) => [
76
89
  "histogram",
77
90
  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 };
@@ -25,6 +25,19 @@ const formatAgo = (seconds) => {
25
25
  if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
26
26
  return `${Math.floor(seconds / 86400)} d ago`;
27
27
  };
28
- new Intl.RelativeTimeFormat("en", { numeric: "auto" });
28
+ const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
29
+ const units = [
30
+ ["year", 31536e3],
31
+ ["month", 2592e3],
32
+ ["day", 86400],
33
+ ["hour", 3600],
34
+ ["minute", 60]
35
+ ];
36
+ /** "5 minutes ago", "yesterday", "3 years ago"; `seconds` is how long ago it was. */
37
+ const formatRelativeTime = (seconds) => {
38
+ const [unit, size] = units.find(([, size]) => seconds >= size) ?? ["second", 1];
39
+ if (unit === "second" && seconds < 45) return "just now";
40
+ return relative.format(-Math.floor(seconds / size), unit);
41
+ };
29
42
  //#endregion
30
- export { formatAgo, formatDate, formatDateTime, formatDayShort };
43
+ export { formatAgo, formatDate, formatDateTime, formatDayShort, formatRelativeTime };
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";
@@ -21,6 +21,7 @@ const toPatchValue = (field, draft) => {
21
21
  const text = String(draft).trim();
22
22
  if (text === "") return field.nullable ? null : "";
23
23
  if (field.type === "integer") return Number(text);
24
+ if (field.type === "relation") return typeof draft === "number" ? draft : text;
24
25
  if (textEdited.has(field.type) || field.type === "enum") return text;
25
26
  throw new Error(`Not implemented: editing ${field.type} fields`);
26
27
  };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.19.16";
2
+ var version = "0.21.0";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };