@protobase/ui 0.15.5 → 0.16.1
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/account/two-factor-setup.js +6 -5
- package/dist/app/app.js +1 -1
- package/dist/app/create/create-page.js +1 -0
- package/dist/app/create/create-record-form.js +1 -0
- package/dist/app/current-address.d.ts +5 -1
- package/dist/app/current-address.js +2 -1
- package/dist/app/displays/map-display.js +50 -34
- package/dist/app/displays/map-groups.js +12 -3
- package/dist/app/displays/measured-heights.js +23 -0
- package/dist/app/displays/record-card.js +29 -17
- package/dist/app/displays/virtual-cards.js +12 -14
- package/dist/app/embedded-list/embedded-display.js +7 -3
- package/dist/app/embedded-list/embedded-list.js +5 -4
- package/dist/app/field-editor.js +9 -1
- package/dist/app/list-page.js +5 -3
- package/dist/app/map/approximate-position.js +76 -0
- package/dist/app/map/area-features.js +80 -0
- package/dist/app/map/maplibre.js +11 -0
- package/dist/app/map/position-areas.js +78 -0
- package/dist/app/map/record-map.js +2 -7
- package/dist/app/map/record-position.js +5 -4
- package/dist/app/map/records-map.js +24 -7
- package/dist/app/map/theme-color.js +23 -0
- package/dist/app/nav-recent.js +1 -1
- package/dist/app/pages/blocks/card-frame.js +2 -2
- package/dist/app/pages/blocks/grid.js +10 -2
- package/dist/app/pages/blocks/link.js +1 -1
- package/dist/app/pages/blocks/record-row.js +51 -0
- package/dist/app/pages/blocks/stat.js +9 -5
- package/dist/app/pages/field-value.js +1 -1
- package/dist/app/pages/render-node.js +2 -0
- package/dist/app/record-page.js +5 -1
- package/dist/app/record-row/record-row-body.js +114 -0
- package/dist/app/record-row/record-row.js +93 -0
- package/dist/app/record-row/row-window.js +20 -0
- package/dist/app/record-row/saved-record-row.js +42 -0
- package/dist/app/record-row/use-row-scroll.js +51 -0
- package/dist/app/router.js +7 -8
- package/dist/app/saved/saved-records.js +1 -1
- package/dist/app/search/search-model.js +1 -1
- package/dist/app/searches/filter-chip-list.js +28 -0
- package/dist/app/searches/save-search-button.js +72 -8
- package/dist/app/searches/saved-search-rows.js +73 -0
- package/dist/app/searches/saved-search-section.js +91 -73
- package/dist/app/searches/saved-search.js +7 -3
- package/dist/app/searches/saved-searches.js +3 -5
- package/dist/app/searches/search-controls.js +13 -2
- package/dist/app/searches/search-diff.js +97 -0
- package/dist/app/searches/search-name-dialog.js +62 -0
- package/dist/app/searches/update-search-dialog.js +196 -0
- package/dist/app/searches/use-opened-search.js +15 -0
- package/dist/app/searches/use-save-search.js +68 -21
- package/dist/app/searches/use-search-row.js +56 -0
- package/dist/app/use-record-editor.js +5 -3
- package/dist/app-shell/app-shell.d.ts +1 -0
- package/dist/app-shell/app-shell.js +4 -3
- package/dist/app-shell/page-scroll.js +17 -1
- package/dist/app-shell/sidebar.d.ts +1 -0
- package/dist/app-shell/sidebar.js +4 -4
- package/dist/app-shell/top-bar.js +4 -4
- package/dist/auth/auth-gate.js +18 -9
- package/dist/auth/auth-provider.js +1 -0
- package/dist/auth/code-form.js +77 -0
- package/dist/auth/email-code-form.js +18 -77
- package/dist/auth/forgot-password-form.js +10 -10
- package/dist/auth/forgot-password-page.js +2 -2
- package/dist/auth/resend-code-button.js +29 -0
- package/dist/auth/sign-in-form.js +7 -8
- package/dist/auth/sign-in-page.js +3 -3
- package/dist/auth/sign-in-prompt.js +13 -9
- package/dist/auth/sign-up-code-form.js +22 -0
- package/dist/auth/sign-up-details-form.js +81 -111
- package/dist/auth/sign-up-page.js +17 -8
- package/dist/auth/two-factor-form.js +3 -3
- package/dist/auth/use-sign-in-address.js +21 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/lib/flip-guard.js +43 -0
- package/dist/lib/page-scroll-lock.js +20 -0
- package/dist/lib/use-focus-trap.js +4 -4
- package/dist/organizations/invitation-page.js +7 -6
- package/dist/package.js +1 -1
- package/dist/primitives/dialog.js +3 -2
- package/dist/primitives/otp-input.d.ts +21 -0
- package/dist/primitives/otp-input.js +117 -0
- package/dist/primitives/otp-value.js +23 -0
- package/dist/primitives/responsive-dialog.d.ts +3 -1
- package/dist/primitives/responsive-dialog.js +2 -1
- package/dist/primitives/route-link.d.ts +1 -0
- package/dist/primitives/route-link.js +20 -0
- package/dist/primitives/textarea.js +36 -0
- package/dist/record-view/field.js +56 -53
- package/dist/shell/assets/app-sDEk9LFu.js +66 -0
- package/dist/shell/assets/index-D0AzKE47.css +2 -0
- package/dist/shell/assets/{index-DxmdRnr8.js → index-L7nXyA_7.js} +1 -1
- package/dist/shell/assets/maplibre-AOh9A0NP.js +814 -0
- package/dist/shell/assets/record-map-DbwHSPf3.js +1 -0
- package/dist/shell/assets/records-map-2U_CQ4RG.js +1 -0
- package/dist/shell/assets/shared-protobase-ui-BMcStVNP.js +1 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/styles.css +15 -0
- package/dist/toasts/toast.js +22 -6
- package/package.json +4 -4
- package/dist/auth/code-input.js +0 -17
- package/dist/auth/sign-in-address.js +0 -47
- package/dist/shell/assets/app-BdEV1_xI.js +0 -66
- package/dist/shell/assets/index-ibaHR5wQ.css +0 -2
- package/dist/shell/assets/maplibre-gl-DFcSGTFJ.js +0 -1
- package/dist/shell/assets/record-map-CL9bBLic.js +0 -814
- package/dist/shell/assets/records-map-DgtD0cK5.js +0 -1
- package/dist/shell/assets/shared-protobase-ui-ibIDwOpE.js +0 -1
- /package/dist/shell/assets/{record-map-DBE96hFo.css → maplibre-DBE96hFo.css} +0 -0
|
@@ -73,8 +73,6 @@ const facetNames = (config) => Object.fromEntries(config.facets.filter((group) =
|
|
|
73
73
|
...group.labels,
|
|
74
74
|
...Object.fromEntries(group.options.map((option) => [option.value, option.label]))
|
|
75
75
|
}]));
|
|
76
|
-
/** The name a new search gets: its chips, one after the other. */
|
|
77
|
-
const searchName = (chips) => chips.map((chip) => chip.label).join(", ");
|
|
78
76
|
/**
|
|
79
77
|
* The search's filter narrowed to the records new since the user last opened its results: those whose `field` is after
|
|
80
78
|
* `seenAt`. Without a `seenAt` every record is new.
|
|
@@ -90,5 +88,11 @@ const unseenFilter = (text, field, seenAt) => {
|
|
|
90
88
|
if (!parsed.ok) throw new Error(`The saved filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
|
|
91
89
|
return printFilter(parsed.ast ? where.and(parsed.ast, after) : after);
|
|
92
90
|
};
|
|
91
|
+
/** Whether a record whose `field` holds `value` is new to the user, as `unseenFilter` finds it: after `seenAt`, or any. */
|
|
92
|
+
const isUnseen = (value, seenAt) => {
|
|
93
|
+
if (typeof seenAt !== "string" || seenAt === "") return true;
|
|
94
|
+
if (typeof value !== "string" || value === "") return false;
|
|
95
|
+
return new Date(value).getTime() > new Date(seenAt).getTime();
|
|
96
|
+
};
|
|
93
97
|
//#endregion
|
|
94
|
-
export { chosenRelations, facetNames, filterChips,
|
|
98
|
+
export { chosenRelations, facetNames, filterChips, isUnseen, unseenFilter };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
2
|
import { PageHeader } from "../../app-shell/page-header.js";
|
|
3
|
-
import { recordKey
|
|
3
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
4
4
|
import { humanize } from "../../live/naming.js";
|
|
5
5
|
import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
6
6
|
import { AppLink } from "../use-app-address.js";
|
|
@@ -28,7 +28,6 @@ const SavedSearches = ({ model, view }) => {
|
|
|
28
28
|
view
|
|
29
29
|
});
|
|
30
30
|
const plural = view.names?.plural ?? humanize(model.name);
|
|
31
|
-
const titleField = recordTitleField(model, view);
|
|
32
31
|
const frame = (body) => /* @__PURE__ */ jsxs("div", {
|
|
33
32
|
className: "flex min-h-0 flex-1 flex-col gap-3 px-5 py-4",
|
|
34
33
|
children: [
|
|
@@ -82,8 +81,7 @@ const SavedSearches = ({ model, view }) => {
|
|
|
82
81
|
className: "flex flex-col gap-4",
|
|
83
82
|
children: saved.data.map((row) => {
|
|
84
83
|
const key = encodeKey(recordKey(model, row));
|
|
85
|
-
const
|
|
86
|
-
const onDelete = can(permissions, "delete") ? () => void remove.start({
|
|
84
|
+
const onDelete = can(permissions, "delete") ? (title) => void remove.start({
|
|
87
85
|
key,
|
|
88
86
|
title,
|
|
89
87
|
etag: row.etag,
|
|
@@ -97,7 +95,7 @@ const SavedSearches = ({ model, view }) => {
|
|
|
97
95
|
targetView,
|
|
98
96
|
row,
|
|
99
97
|
recordKey: key,
|
|
100
|
-
|
|
98
|
+
canRename: can(permissions, "update"),
|
|
101
99
|
onDelete
|
|
102
100
|
}, key);
|
|
103
101
|
})
|
|
@@ -35,13 +35,24 @@ const Control = ({ model, view, name, value, onChange }) => {
|
|
|
35
35
|
})]
|
|
36
36
|
});
|
|
37
37
|
};
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const changed = (model, view, patch) => Object.entries(patch).map(([name, value]) => {
|
|
39
|
+
return `${view.fields[name]?.label ?? humanize(name)}: ${model.fields[name]?.type === "boolean" ? value === true ? "On" : "Off" : enumLabel(String(value), view.fields[name]?.valueLabels)}`;
|
|
40
|
+
}).join(", ");
|
|
41
|
+
/**
|
|
42
|
+
* A saved search's controls, each saved as soon as it changes, which a toast confirms; a refused change says why and shows
|
|
43
|
+
* the search as stored.
|
|
44
|
+
*/
|
|
45
|
+
const SearchControls = ({ model, view, recordKey, title, row, fields }) => {
|
|
40
46
|
const client = useClient();
|
|
41
47
|
const toast = useToast();
|
|
42
48
|
const refresh = useRefreshResource();
|
|
43
49
|
const change = useMutation({
|
|
44
50
|
mutationFn: (patch) => client.update(model.name, recordKey, patch, row.etag ?? "*"),
|
|
51
|
+
onSuccess: (_, patch) => toast.show({
|
|
52
|
+
state: "success",
|
|
53
|
+
title: `${title} saved`,
|
|
54
|
+
description: changed(model, view, patch)
|
|
55
|
+
}),
|
|
45
56
|
onError: (error) => toast.show({
|
|
46
57
|
state: "error",
|
|
47
58
|
title: "Could not change the search",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createWhere, parseFilter, printFilter } from "@protobase/schema";
|
|
2
|
+
//#region src/app/searches/search-diff.ts
|
|
3
|
+
const where = createWhere();
|
|
4
|
+
const span = {
|
|
5
|
+
start: 0,
|
|
6
|
+
end: 0
|
|
7
|
+
};
|
|
8
|
+
const guarded = (atom, condition) => where.and(condition, {
|
|
9
|
+
kind: "present",
|
|
10
|
+
field: atom.field,
|
|
11
|
+
span
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* The filter that holds exactly where `expr` holds, and fails, never unknown, everywhere else. The database compares a
|
|
15
|
+
* field without a value to unknown, and `NOT` of unknown is unknown too, so `NOT expr` alone would leave out every
|
|
16
|
+
* record `expr` cannot decide, where `NOT surely(expr)` takes them in.
|
|
17
|
+
*/
|
|
18
|
+
const surely = (expr) => {
|
|
19
|
+
switch (expr.kind) {
|
|
20
|
+
case "and":
|
|
21
|
+
case "or": return {
|
|
22
|
+
...expr,
|
|
23
|
+
args: expr.args.map(surely)
|
|
24
|
+
};
|
|
25
|
+
case "not": return surelyNot(expr.arg);
|
|
26
|
+
case "search":
|
|
27
|
+
case "present":
|
|
28
|
+
case "isNull": return expr;
|
|
29
|
+
default: return guarded(expr, expr);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** The filter that holds exactly where `expr` fails, and fails, never unknown, everywhere else. */
|
|
33
|
+
const surelyNot = (expr) => {
|
|
34
|
+
switch (expr.kind) {
|
|
35
|
+
case "and": return where.or(...expr.args.map(surelyNot));
|
|
36
|
+
case "or": return where.and(...expr.args.map(surelyNot));
|
|
37
|
+
case "not": return surely(expr.arg);
|
|
38
|
+
case "search":
|
|
39
|
+
case "present":
|
|
40
|
+
case "isNull": return where.not(expr);
|
|
41
|
+
default: return guarded(expr, where.not(expr));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const read = (text) => {
|
|
45
|
+
const parsed = parseFilter(text);
|
|
46
|
+
if (!parsed.ok) throw new Error(`The filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
|
|
47
|
+
return parsed.ast;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The filter of the records `after` finds and `before` does not, for the list API: `after AND NOT before`, with a record
|
|
51
|
+
* `before` cannot decide (a range on a field it has no value for) counted as not found by it. `null` when no record can
|
|
52
|
+
* be among them, as when `before` is empty and so finds every record.
|
|
53
|
+
*/
|
|
54
|
+
const addedFilter = (before, after) => {
|
|
55
|
+
const old = read(before);
|
|
56
|
+
if (!old) return null;
|
|
57
|
+
const next = read(after);
|
|
58
|
+
const notOld = where.not(surely(old));
|
|
59
|
+
return printFilter(next ? where.and(next, notOld) : notOld);
|
|
60
|
+
};
|
|
61
|
+
/** The filter of the records `before` finds and `after` no longer does; `null` when none can be. */
|
|
62
|
+
const removedFilter = (before, after) => addedFilter(after, before);
|
|
63
|
+
const identity = (chip) => chip.key.startsWith("extra:") ? `extra:${chip.label}` : chip.key;
|
|
64
|
+
/** The chips that differ between two filters' chips: those removed, then those changed, then those added. */
|
|
65
|
+
const filterChanges = (before, after) => {
|
|
66
|
+
const old = new Map(before.map((chip) => [identity(chip), chip]));
|
|
67
|
+
const next = new Map(after.map((chip) => [identity(chip), chip]));
|
|
68
|
+
const removed = before.filter((chip) => !next.has(identity(chip))).map((chip) => ({
|
|
69
|
+
kind: "removed",
|
|
70
|
+
label: chip.label
|
|
71
|
+
}));
|
|
72
|
+
const changed = after.flatMap((chip) => {
|
|
73
|
+
const was = old.get(identity(chip));
|
|
74
|
+
return was && was.label !== chip.label ? [{
|
|
75
|
+
kind: "changed",
|
|
76
|
+
label: chip.label,
|
|
77
|
+
before: was.label
|
|
78
|
+
}] : [];
|
|
79
|
+
});
|
|
80
|
+
const added = after.filter((chip) => !old.has(identity(chip))).map((chip) => ({
|
|
81
|
+
kind: "added",
|
|
82
|
+
label: chip.label
|
|
83
|
+
}));
|
|
84
|
+
return [
|
|
85
|
+
...removed,
|
|
86
|
+
...changed,
|
|
87
|
+
...added
|
|
88
|
+
];
|
|
89
|
+
};
|
|
90
|
+
/** The net change in the records a search finds: "5 more homes", "1 fewer home" or "As many homes". */
|
|
91
|
+
const netChange = (added, removed, singular, plural) => {
|
|
92
|
+
const net = added - removed;
|
|
93
|
+
if (net === 0) return `As many ${plural}`;
|
|
94
|
+
return `${Math.abs(net)} ${net > 0 ? "more" : "fewer"} ${Math.abs(net) === 1 ? singular : plural}`;
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { addedFilter, filterChanges, netChange, removedFilter };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Input } from "../../primitives/input.js";
|
|
2
|
+
import { Button } from "../../primitives/button.js";
|
|
3
|
+
import { ResponsiveDialog } from "../../primitives/responsive-dialog.js";
|
|
4
|
+
import { FilterChipList } from "./filter-chip-list.js";
|
|
5
|
+
import { useId, useState } from "react";
|
|
6
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/searches/search-name-dialog.tsx
|
|
8
|
+
/** Asks for a saved search's name, empty unless the field needs one, showing what the search filters on. */
|
|
9
|
+
const SearchNameDialog = ({ title, chips, label, required, initial = "", busy, onSave, onClose }) => {
|
|
10
|
+
const id = useId();
|
|
11
|
+
const [name, setName] = useState(initial);
|
|
12
|
+
const formId = `${id}-form`;
|
|
13
|
+
return /* @__PURE__ */ jsx(ResponsiveDialog, {
|
|
14
|
+
open: true,
|
|
15
|
+
title,
|
|
16
|
+
onClose,
|
|
17
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
18
|
+
onClick: onClose,
|
|
19
|
+
children: "Cancel"
|
|
20
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
21
|
+
type: "submit",
|
|
22
|
+
form: formId,
|
|
23
|
+
variant: "primary",
|
|
24
|
+
loading: busy,
|
|
25
|
+
children: "Save"
|
|
26
|
+
})] }),
|
|
27
|
+
children: /* @__PURE__ */ jsxs("form", {
|
|
28
|
+
id: formId,
|
|
29
|
+
className: "flex flex-col gap-4",
|
|
30
|
+
onSubmit: (event) => {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
onSave(name.trim() === "" ? null : name.trim());
|
|
33
|
+
},
|
|
34
|
+
children: [chips.length > 0 && /* @__PURE__ */ jsx(FilterChipList, { chips }), /* @__PURE__ */ jsxs("div", {
|
|
35
|
+
className: "flex flex-col gap-1.5",
|
|
36
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
37
|
+
className: "flex items-baseline justify-between gap-2 text-[13px]",
|
|
38
|
+
children: [/* @__PURE__ */ jsx("label", {
|
|
39
|
+
htmlFor: id,
|
|
40
|
+
className: "font-medium",
|
|
41
|
+
children: label
|
|
42
|
+
}), !required && /* @__PURE__ */ jsx("span", {
|
|
43
|
+
id: `${id}-hint`,
|
|
44
|
+
className: "text-xs text-muted-foreground",
|
|
45
|
+
children: "Optional"
|
|
46
|
+
})]
|
|
47
|
+
}), /* @__PURE__ */ jsx(Input, {
|
|
48
|
+
id,
|
|
49
|
+
value: name,
|
|
50
|
+
required,
|
|
51
|
+
pattern: required ? ".*\\S.*" : void 0,
|
|
52
|
+
"aria-describedby": required ? void 0 : `${id}-hint`,
|
|
53
|
+
autoFocus: true,
|
|
54
|
+
autoComplete: "off",
|
|
55
|
+
onChange: (event) => setName(event.target.value)
|
|
56
|
+
})]
|
|
57
|
+
})]
|
|
58
|
+
})
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { SearchNameDialog };
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { Button } from "../../primitives/button.js";
|
|
3
|
+
import { useClient } from "../../data/api-provider.js";
|
|
4
|
+
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { defaultOrderBy } from "../list-order.js";
|
|
6
|
+
import { ResponsiveDialog } from "../../primitives/responsive-dialog.js";
|
|
7
|
+
import { countQuery } from "../pages/use-layout-data.js";
|
|
8
|
+
import { EmbeddedList } from "../embedded-list/embedded-list.js";
|
|
9
|
+
import { addedFilter, filterChanges, netChange, removedFilter } from "./search-diff.js";
|
|
10
|
+
import { useFilterChips } from "./use-filter-chips.js";
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { useQuery } from "@tanstack/react-query";
|
|
14
|
+
import { tableDisplay } from "@protobase/schema/internal";
|
|
15
|
+
//#region src/app/searches/update-search-dialog.tsx
|
|
16
|
+
/** How many records each side of the diff shows; its count says how many there are in all. */
|
|
17
|
+
const shownLimit = 12;
|
|
18
|
+
const changeStyle = {
|
|
19
|
+
added: {
|
|
20
|
+
word: "Added",
|
|
21
|
+
className: "text-success-text"
|
|
22
|
+
},
|
|
23
|
+
removed: {
|
|
24
|
+
word: "Removed",
|
|
25
|
+
className: "text-danger-text"
|
|
26
|
+
},
|
|
27
|
+
changed: {
|
|
28
|
+
word: "Changed",
|
|
29
|
+
className: "text-primary-text"
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const FilterChanges = ({ changes }) => /* @__PURE__ */ jsx("ul", {
|
|
33
|
+
"aria-label": "Filter changes",
|
|
34
|
+
className: "divide-y rounded-md border text-[13px]",
|
|
35
|
+
children: changes.map((change) => /* @__PURE__ */ jsxs("li", {
|
|
36
|
+
className: "grid grid-cols-[72px_1fr] gap-3 px-3 py-2",
|
|
37
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
38
|
+
className: `font-medium ${changeStyle[change.kind].className}`,
|
|
39
|
+
children: changeStyle[change.kind].word
|
|
40
|
+
}), change.kind === "changed" ? /* @__PURE__ */ jsxs("span", { children: [
|
|
41
|
+
/* @__PURE__ */ jsx("span", {
|
|
42
|
+
className: "text-muted-foreground line-through",
|
|
43
|
+
children: change.before
|
|
44
|
+
}),
|
|
45
|
+
" ",
|
|
46
|
+
/* @__PURE__ */ jsx("span", {
|
|
47
|
+
className: "font-medium",
|
|
48
|
+
children: change.label
|
|
49
|
+
})
|
|
50
|
+
] }) : /* @__PURE__ */ jsx("span", {
|
|
51
|
+
className: change.kind === "removed" ? "text-muted-foreground line-through" : "font-medium",
|
|
52
|
+
children: change.label
|
|
53
|
+
})]
|
|
54
|
+
}, `${change.kind}:${change.label}`))
|
|
55
|
+
});
|
|
56
|
+
/** How many records `filter` finds, none for a filter that can find none (`null`). */
|
|
57
|
+
const useDiffCount = (model, filter) => {
|
|
58
|
+
const query = useQuery({
|
|
59
|
+
...countQuery(useClient(), model, filter ?? ""),
|
|
60
|
+
enabled: filter !== null
|
|
61
|
+
});
|
|
62
|
+
return filter === null ? 0 : query.data;
|
|
63
|
+
};
|
|
64
|
+
const count = (n, singular, plural) => `${n} ${n === 1 ? singular : plural}`;
|
|
65
|
+
/** One side of the diff: its title and count, and its first records as the list shows them. */
|
|
66
|
+
const RecordsSide = ({ model, view, title, filter, total, plural }) => {
|
|
67
|
+
const cards = view?.list?.displays?.find((display) => display.kind === "cards");
|
|
68
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
69
|
+
"aria-label": title,
|
|
70
|
+
className: "flex flex-col gap-2",
|
|
71
|
+
children: [
|
|
72
|
+
/* @__PURE__ */ jsxs("h3", {
|
|
73
|
+
className: "text-[13px] font-semibold",
|
|
74
|
+
children: [
|
|
75
|
+
title,
|
|
76
|
+
" ",
|
|
77
|
+
/* @__PURE__ */ jsxs("span", {
|
|
78
|
+
className: "font-normal text-muted-foreground",
|
|
79
|
+
children: [
|
|
80
|
+
"(",
|
|
81
|
+
total ?? "…",
|
|
82
|
+
")"
|
|
83
|
+
]
|
|
84
|
+
})
|
|
85
|
+
]
|
|
86
|
+
}),
|
|
87
|
+
total !== void 0 && total > shownLimit && /* @__PURE__ */ jsxs("p", {
|
|
88
|
+
className: "text-xs text-muted-foreground",
|
|
89
|
+
children: [
|
|
90
|
+
"The first ",
|
|
91
|
+
shownLimit,
|
|
92
|
+
" of ",
|
|
93
|
+
total,
|
|
94
|
+
"."
|
|
95
|
+
]
|
|
96
|
+
}),
|
|
97
|
+
filter === null || total === 0 ? /* @__PURE__ */ jsxs("p", {
|
|
98
|
+
className: "text-[13px] text-muted-foreground",
|
|
99
|
+
children: [
|
|
100
|
+
"No ",
|
|
101
|
+
plural,
|
|
102
|
+
"."
|
|
103
|
+
]
|
|
104
|
+
}) : /* @__PURE__ */ jsx(EmbeddedList, {
|
|
105
|
+
model,
|
|
106
|
+
view,
|
|
107
|
+
filter,
|
|
108
|
+
sort: defaultOrderBy(model, view),
|
|
109
|
+
limit: shownLimit,
|
|
110
|
+
display: cards?.name ?? tableDisplay,
|
|
111
|
+
row: true,
|
|
112
|
+
link: false,
|
|
113
|
+
empty: `No ${plural}.`
|
|
114
|
+
})
|
|
115
|
+
]
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Asks before a saved search takes the list's filter: what changes in its filters, how many more or fewer records it
|
|
120
|
+
* finds, and the records it would add and drop, each found by the list API (`addedFilter`, `removedFilter`).
|
|
121
|
+
*/
|
|
122
|
+
const UpdateSearchDialog = ({ model, view, title, before, after, saveNewLabel, updating, creating, onUpdate, onSaveNew, onCancel }) => {
|
|
123
|
+
const changes = filterChanges(useFilterChips(model, before), useFilterChips(model, after));
|
|
124
|
+
const added = useMemo(() => addedFilter(before, after), [before, after]);
|
|
125
|
+
const removed = useMemo(() => removedFilter(before, after), [before, after]);
|
|
126
|
+
const addedCount = useDiffCount(model, added);
|
|
127
|
+
const removedCount = useDiffCount(model, removed);
|
|
128
|
+
const singular = (view?.names?.singular ?? humanize(model.name)).toLowerCase();
|
|
129
|
+
const plural = (view?.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
130
|
+
const counted = addedCount !== void 0 && removedCount !== void 0;
|
|
131
|
+
return /* @__PURE__ */ jsx(ResponsiveDialog, {
|
|
132
|
+
open: true,
|
|
133
|
+
wide: true,
|
|
134
|
+
title,
|
|
135
|
+
onClose: onCancel,
|
|
136
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
137
|
+
/* @__PURE__ */ jsx(Button, {
|
|
138
|
+
onClick: onCancel,
|
|
139
|
+
children: "Cancel"
|
|
140
|
+
}),
|
|
141
|
+
/* @__PURE__ */ jsx(Button, {
|
|
142
|
+
loading: creating,
|
|
143
|
+
disabled: updating,
|
|
144
|
+
onClick: onSaveNew,
|
|
145
|
+
children: saveNewLabel
|
|
146
|
+
}),
|
|
147
|
+
/* @__PURE__ */ jsx(Button, {
|
|
148
|
+
variant: "primary",
|
|
149
|
+
loading: updating,
|
|
150
|
+
disabled: creating,
|
|
151
|
+
onClick: onUpdate,
|
|
152
|
+
children: "Update"
|
|
153
|
+
})
|
|
154
|
+
] }),
|
|
155
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
156
|
+
className: "flex flex-col gap-4",
|
|
157
|
+
children: [
|
|
158
|
+
changes.length > 0 && /* @__PURE__ */ jsx(FilterChanges, { changes }),
|
|
159
|
+
counted ? /* @__PURE__ */ jsxs("p", {
|
|
160
|
+
className: "text-[13px]",
|
|
161
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
162
|
+
className: "font-semibold",
|
|
163
|
+
children: netChange(addedCount, removedCount, singular, plural)
|
|
164
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
165
|
+
className: "text-muted-foreground",
|
|
166
|
+
children: [
|
|
167
|
+
": ",
|
|
168
|
+
count(addedCount, singular, plural),
|
|
169
|
+
" added, ",
|
|
170
|
+
removedCount,
|
|
171
|
+
" removed"
|
|
172
|
+
]
|
|
173
|
+
})]
|
|
174
|
+
}) : /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-48" }),
|
|
175
|
+
/* @__PURE__ */ jsx(RecordsSide, {
|
|
176
|
+
model,
|
|
177
|
+
view,
|
|
178
|
+
title: "Added",
|
|
179
|
+
filter: added,
|
|
180
|
+
total: addedCount,
|
|
181
|
+
plural
|
|
182
|
+
}),
|
|
183
|
+
/* @__PURE__ */ jsx(RecordsSide, {
|
|
184
|
+
model,
|
|
185
|
+
view,
|
|
186
|
+
title: "Removed",
|
|
187
|
+
filter: removed,
|
|
188
|
+
total: removedCount,
|
|
189
|
+
plural
|
|
190
|
+
})
|
|
191
|
+
]
|
|
192
|
+
})
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
//#endregion
|
|
196
|
+
export { UpdateSearchDialog };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useAppAddress } from "../use-app-address.js";
|
|
2
|
+
//#region src/app/searches/use-opened-search.ts
|
|
3
|
+
/**
|
|
4
|
+
* The saved search the list was opened from, by its key, as the address names it ("View all results" does), and `open`
|
|
5
|
+
* to name another, such as a search just saved.
|
|
6
|
+
*/
|
|
7
|
+
const useOpenedSearch = () => {
|
|
8
|
+
const { address, set } = useAppAddress();
|
|
9
|
+
return {
|
|
10
|
+
key: address?.kind === "list" ? address.savedSearch : void 0,
|
|
11
|
+
open: (key) => set({ savedSearch: key })
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { useOpenedSearch };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useClient } from "../../data/api-provider.js";
|
|
2
2
|
import { recordKey } from "../../live/model-helpers.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
3
4
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
4
5
|
import { useAdminMeta } from "../meta-gate.js";
|
|
5
6
|
import { useAppAddress } from "../use-app-address.js";
|
|
6
7
|
import { can } from "../permissions.js";
|
|
7
8
|
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
9
|
+
import { useOpenedSearch } from "./use-opened-search.js";
|
|
8
10
|
import { useSavedSearches } from "./use-saved-searches.js";
|
|
9
11
|
import { useMutation } from "@tanstack/react-query";
|
|
10
12
|
import { encodeKey } from "@protobase/schema/internal";
|
|
@@ -12,15 +14,18 @@ import { encodeKey } from "@protobase/schema/internal";
|
|
|
12
14
|
const message = (error) => error instanceof Error ? error.message : "Something went wrong";
|
|
13
15
|
/**
|
|
14
16
|
* A list page's "Save search" button for its `filter`: `save` for someone who can list, create and delete the saved
|
|
15
|
-
* searches, `sign-in` for a visitor their resource asks to sign in (`prompt`), nothing otherwise.
|
|
16
|
-
* filter
|
|
17
|
+
* searches, `sign-in` for a visitor their resource asks to sign in (`prompt`), nothing otherwise. `saved` while a search
|
|
18
|
+
* has the filter, and `remove` takes it off again; otherwise `saveNew` saves the filter as a search, with the name the
|
|
19
|
+
* user gave (`naming` says how to ask for it) or none. While the list was opened from a saved search (`opened`) whose
|
|
20
|
+
* filter differs, `update` gives that search the list's filter.
|
|
17
21
|
*/
|
|
18
|
-
const useSaveSearch = (search, filter
|
|
22
|
+
const useSaveSearch = (search, filter) => {
|
|
19
23
|
const meta = useAdminMeta();
|
|
20
24
|
const client = useClient();
|
|
21
25
|
const toast = useToast();
|
|
22
26
|
const refresh = useRefreshResource();
|
|
23
|
-
const {
|
|
27
|
+
const { href, open } = useAppAddress();
|
|
28
|
+
const opened = useOpenedSearch();
|
|
24
29
|
const { resource } = search;
|
|
25
30
|
const model = meta.resources[resource];
|
|
26
31
|
const of = meta.views[resource]?.searchesOf;
|
|
@@ -28,42 +33,84 @@ const useSaveSearch = (search, filter, name) => {
|
|
|
28
33
|
const usable = model !== void 0 && of !== void 0 && permissions !== void 0 && can(permissions, "create") && can(permissions, "delete");
|
|
29
34
|
const prompt = meta.signIn?.[resource]?.signIn;
|
|
30
35
|
const saved = useSavedSearches(resource, usable);
|
|
36
|
+
const keyOf = (row) => encodeKey(recordKey(model, row));
|
|
31
37
|
const current = of && saved.data?.find((row) => row[of.filter] === filter);
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
onSuccess: (
|
|
42
|
-
|
|
38
|
+
const edited = current || opened.key === void 0 || !permissions || !can(permissions, "update") ? void 0 : saved.data?.find((row) => keyOf(row) === opened.key);
|
|
39
|
+
const names = meta.views[resource]?.names;
|
|
40
|
+
const plural = names?.plural ?? "saved searches";
|
|
41
|
+
const nameField = of?.name === void 0 ? void 0 : model?.fields[of.name];
|
|
42
|
+
const create = useMutation({
|
|
43
|
+
mutationFn: (name) => client.create(resource, {
|
|
44
|
+
[of.filter]: filter,
|
|
45
|
+
...of.name && { [of.name]: name }
|
|
46
|
+
}),
|
|
47
|
+
onSuccess: (created) => {
|
|
48
|
+
opened.open(keyOf(created.record));
|
|
49
|
+
toast.show({
|
|
43
50
|
state: "success",
|
|
44
51
|
title: "Search saved",
|
|
45
52
|
action: {
|
|
46
53
|
label: `Open ${plural.toLowerCase()}`,
|
|
47
|
-
|
|
54
|
+
href: href({
|
|
48
55
|
kind: "list",
|
|
49
56
|
resource
|
|
50
|
-
})
|
|
57
|
+
}),
|
|
58
|
+
onNavigate: open
|
|
51
59
|
}
|
|
52
60
|
});
|
|
53
61
|
},
|
|
54
|
-
onError: (error
|
|
62
|
+
onError: (error) => toast.show({
|
|
63
|
+
state: "error",
|
|
64
|
+
title: "Could not save the search",
|
|
65
|
+
description: message(error)
|
|
66
|
+
}),
|
|
67
|
+
onSettled: () => refresh(resource)
|
|
68
|
+
});
|
|
69
|
+
const remove = useMutation({
|
|
70
|
+
mutationFn: (row) => client.remove(resource, keyOf(row)),
|
|
71
|
+
onError: (error) => toast.show({
|
|
55
72
|
state: "error",
|
|
56
|
-
title:
|
|
73
|
+
title: "Could not take the search off",
|
|
57
74
|
description: message(error)
|
|
58
75
|
}),
|
|
59
76
|
onSettled: () => refresh(resource)
|
|
60
77
|
});
|
|
78
|
+
const update = useMutation({
|
|
79
|
+
mutationFn: (row) => client.update(resource, keyOf(row), { [of.filter]: filter }, row.etag ?? "*"),
|
|
80
|
+
onSuccess: () => toast.show({
|
|
81
|
+
state: "success",
|
|
82
|
+
title: "Search updated"
|
|
83
|
+
}),
|
|
84
|
+
onError: (error) => toast.show({
|
|
85
|
+
state: "error",
|
|
86
|
+
title: "Could not update the search",
|
|
87
|
+
description: message(error)
|
|
88
|
+
}),
|
|
89
|
+
onSettled: () => refresh(resource)
|
|
90
|
+
});
|
|
91
|
+
const pending = create.isPending || remove.isPending || update.isPending;
|
|
61
92
|
return {
|
|
62
93
|
mode: usable ? "save" : prompt ? "sign-in" : void 0,
|
|
63
94
|
prompt,
|
|
64
95
|
saved: current !== void 0,
|
|
65
|
-
|
|
66
|
-
|
|
96
|
+
/** The search the list was opened from and differs from: its filter, and its name when it has one. */
|
|
97
|
+
opened: edited && {
|
|
98
|
+
filter: String(edited[of.filter] ?? ""),
|
|
99
|
+
name: of.name && typeof edited[of.name] === "string" && edited[of.name] !== "" ? String(edited[of.name]) : void 0
|
|
100
|
+
},
|
|
101
|
+
/** What one search is called, as the saved searches' view names it. */
|
|
102
|
+
noun: (names?.singular ?? humanize(resource)).toLowerCase(),
|
|
103
|
+
/** How to ask for a new search's name, from its field and view; none when searches have no name. */
|
|
104
|
+
naming: nameField && {
|
|
105
|
+
label: meta.views[resource]?.fields[nameField.name]?.label ?? humanize(nameField.name),
|
|
106
|
+
required: !nameField.nullable
|
|
107
|
+
},
|
|
108
|
+
ready: saved.data !== void 0 && !pending,
|
|
109
|
+
updating: update.isPending,
|
|
110
|
+
creating: create.isPending,
|
|
111
|
+
remove: () => current && remove.mutate(current),
|
|
112
|
+
saveNew: (name, options) => create.mutate(name, options),
|
|
113
|
+
update: (options) => edited && update.mutate(edited, options)
|
|
67
114
|
};
|
|
68
115
|
};
|
|
69
116
|
//#endregion
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
import { humanize } from "../../live/naming.js";
|
|
3
|
+
import { countQuery, useRefreshResource } from "../pages/use-layout-data.js";
|
|
4
|
+
import { isUnseen, unseenFilter } from "./saved-search.js";
|
|
5
|
+
import { chipsText } from "./filter-chip-list.js";
|
|
6
|
+
import { useFilterChips } from "./use-filter-chips.js";
|
|
7
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
8
|
+
//#region src/app/searches/use-search-row.ts
|
|
9
|
+
/**
|
|
10
|
+
* One saved search as a row of what it finds: its name, if it has one, its filters as chips, its title (the name, or
|
|
11
|
+
* the chips as words), and the `RecordRowBody` of its records: with `unseen` newest first, those new to the user marked
|
|
12
|
+
* "New", saying so when none are, and "View all results", which opens the list from the search and counts as having
|
|
13
|
+
* seen them.
|
|
14
|
+
*/
|
|
15
|
+
const useSearchRow = ({ model, of, target, targetView, row, recordKey }) => {
|
|
16
|
+
const client = useClient();
|
|
17
|
+
const refresh = useRefreshResource();
|
|
18
|
+
const filter = String(row[of.filter] ?? "");
|
|
19
|
+
const chips = useFilterChips(target, filter);
|
|
20
|
+
const stored = of.name === void 0 ? void 0 : row[of.name];
|
|
21
|
+
const name = typeof stored === "string" && stored !== "" ? stored : void 0;
|
|
22
|
+
const unseen = of.unseen;
|
|
23
|
+
const markSeen = useMutation({
|
|
24
|
+
mutationFn: (seenAt) => client.update(model.name, recordKey, { [unseen.seenAt]: seenAt }, row.etag ?? "*"),
|
|
25
|
+
onSettled: () => refresh(model.name)
|
|
26
|
+
});
|
|
27
|
+
const newCount = useQuery({
|
|
28
|
+
...countQuery(client, target, unseen ? unseenFilter(filter, unseen.field, row[unseen.seenAt]) : ""),
|
|
29
|
+
enabled: unseen !== void 0
|
|
30
|
+
});
|
|
31
|
+
const plural = (targetView?.names?.plural ?? humanize(target.name)).toLowerCase();
|
|
32
|
+
const body = {
|
|
33
|
+
model: target,
|
|
34
|
+
view: targetView,
|
|
35
|
+
filter,
|
|
36
|
+
sort: unseen ? `${unseen.field} desc` : void 0,
|
|
37
|
+
limit: of.limit,
|
|
38
|
+
chips: name !== void 0,
|
|
39
|
+
empty: `No ${plural} match.`,
|
|
40
|
+
note: unseen && newCount.data === 0 && `No new ${plural} since you last looked.`,
|
|
41
|
+
isNew: unseen && ((record) => isUnseen(record.record[unseen.field], row[unseen.seenAt])),
|
|
42
|
+
viewAll: {
|
|
43
|
+
label: "View all results",
|
|
44
|
+
savedSearch: recordKey,
|
|
45
|
+
...unseen && { onOpen: () => markSeen.mutate((/* @__PURE__ */ new Date()).toISOString()) }
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
chips,
|
|
51
|
+
title: name ?? chipsText(chips),
|
|
52
|
+
body
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { useSearchRow };
|