@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.
- 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/create/create-values.js +2 -1
- package/dist/app/field-editor.js +29 -11
- package/dist/app/history/describe-change.js +44 -0
- package/dist/app/history/history-section.js +92 -0
- package/dist/app/history/use-record-history.js +34 -0
- package/dist/app/list-group-chart.js +37 -0
- package/dist/app/list-page.js +10 -3
- package/dist/app/pages/blocks/chart.js +26 -0
- package/dist/app/pages/render-node.js +2 -0
- package/dist/app/record-page.js +43 -7
- package/dist/app/relation-options.js +44 -0
- package/dist/app/relation-picker.js +114 -35
- package/dist/data/api-provider.d.ts +10 -0
- package/dist/data/query-keys.js +13 -0
- package/dist/data/use-aggregates.js +14 -1
- package/dist/format/date.js +15 -2
- package/dist/index.js +2 -2
- package/dist/live/field-values.js +1 -0
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-BBx2Bkop.js +69 -0
- package/dist/shell/assets/index-Dc6xgHsz.css +2 -0
- package/dist/shell/assets/{index-CmmYBR7Y.js → index-iXIDNHEm.js} +1 -1
- package/dist/shell/assets/{maplibre-Siom2Zwj.js → maplibre-DxWag0Wp.js} +1 -1
- package/dist/shell/assets/{markdown-content-ChyGEubR.js → markdown-content-Bco0XDqw.js} +1 -1
- package/dist/shell/assets/{record-map-B_QnHF99.js → record-map-Ca1fz02Q.js} +1 -1
- package/dist/shell/assets/{records-map-CcHhEDBC.js → records-map-JooppeKw.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-zRmreKZS.js → shared-protobase-ui-BZs06oKf.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-xQmLJM07.js +0 -69
- 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 };
|
package/dist/app/list-page.js
CHANGED
|
@@ -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(
|
|
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,
|
package/dist/app/record-page.js
CHANGED
|
@@ -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
|
|
61
|
-
* user who may only read the resource gets, shows every field: the record rather
|
|
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:
|
|
193
|
-
relationTo:
|
|
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) =>
|
|
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 {
|
|
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
|
})
|
|
@@ -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>;
|
package/dist/data/query-keys.js
CHANGED
|
@@ -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 };
|
package/dist/format/date.js
CHANGED
|
@@ -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