@protobase/ui 0.16.0 → 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/app/current-address.d.ts +5 -1
- package/dist/app/current-address.js +2 -1
- package/dist/app/displays/record-card.js +28 -16
- package/dist/app/embedded-list/embedded-display.js +7 -3
- package/dist/app/embedded-list/embedded-list.js +5 -4
- package/dist/app/list-page.js +4 -2
- package/dist/app/pages/blocks/record-row.js +51 -0
- package/dist/app/pages/render-node.js +2 -0
- 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/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 +64 -18
- package/dist/app/searches/use-search-row.js +56 -0
- package/dist/package.js +1 -1
- package/dist/primitives/dialog.js +3 -2
- package/dist/primitives/responsive-dialog.d.ts +3 -1
- package/dist/primitives/responsive-dialog.js +2 -1
- package/dist/shell/assets/app-sDEk9LFu.js +66 -0
- package/dist/shell/assets/index-D0AzKE47.css +2 -0
- package/dist/shell/assets/{index-LBhMBiK4.js → index-L7nXyA_7.js} +1 -1
- package/dist/shell/assets/{maplibre-Dy0ZTnFZ.js → maplibre-AOh9A0NP.js} +1 -1
- package/dist/shell/assets/{record-map-BiWSbqqT.js → record-map-DbwHSPf3.js} +1 -1
- package/dist/shell/assets/{records-map-DabYc3ad.js → records-map-2U_CQ4RG.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BURelnPG.js → shared-protobase-ui-BMcStVNP.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-DJAZP1yB.js +0 -66
- package/dist/shell/assets/index-C5pYkUfv.css +0 -2
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { Button } from "../../primitives/button.js";
|
|
2
2
|
import { SignInPrompt } from "../../auth/sign-in-prompt.js";
|
|
3
|
+
import { SearchNameDialog } from "./search-name-dialog.js";
|
|
4
|
+
import { UpdateSearchDialog } from "./update-search-dialog.js";
|
|
3
5
|
import { useSaveSearch } from "./use-save-search.js";
|
|
4
6
|
import { useState } from "react";
|
|
5
|
-
import { Bookmark, BookmarkCheck } from "lucide-react";
|
|
7
|
+
import { Bookmark, BookmarkCheck, BookmarkPlus, RefreshCw } from "lucide-react";
|
|
6
8
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
7
9
|
//#region src/app/searches/save-search-button.tsx
|
|
8
10
|
/**
|
|
9
|
-
* "Save search" on a filtered list: saves its filter as one of the signed-in user's
|
|
10
|
-
* or, for a visitor, opens the sign-in prompt of the saved searches' resource.
|
|
11
|
+
* "Save search" on a filtered list: asks for an optional name and saves its filter as one of the signed-in user's
|
|
12
|
+
* searches, pressed while it is saved, or, for a visitor, opens the sign-in prompt of the saved searches' resource. On a
|
|
13
|
+
* list opened from a saved search whose filter it no longer has, "Update <search>" asks before giving the search the
|
|
14
|
+
* list's filter, beside "Save as new search".
|
|
11
15
|
*/
|
|
12
|
-
const SaveSearchButton = ({ search, filter,
|
|
13
|
-
const state = useSaveSearch(search, filter
|
|
16
|
+
const SaveSearchButton = ({ model, view, search, filter, chips, className }) => {
|
|
17
|
+
const state = useSaveSearch(search, filter);
|
|
14
18
|
const [prompting, setPrompting] = useState(false);
|
|
19
|
+
const [confirming, setConfirming] = useState(false);
|
|
20
|
+
const [naming, setNaming] = useState();
|
|
15
21
|
if (!state.mode) return null;
|
|
16
22
|
const label = search.label ?? "Save search";
|
|
17
23
|
if (state.mode === "sign-in") return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
|
|
@@ -28,17 +34,75 @@ const SaveSearchButton = ({ search, filter, name, className }) => {
|
|
|
28
34
|
open: prompting,
|
|
29
35
|
onClose: () => setPrompting(false)
|
|
30
36
|
})] });
|
|
37
|
+
const saveNew = (title) => {
|
|
38
|
+
setConfirming(false);
|
|
39
|
+
if (state.naming) setNaming(title);
|
|
40
|
+
else state.saveNew(null);
|
|
41
|
+
};
|
|
42
|
+
const nameDialog = naming !== void 0 && state.naming && /* @__PURE__ */ jsx(SearchNameDialog, {
|
|
43
|
+
title: naming,
|
|
44
|
+
chips,
|
|
45
|
+
label: state.naming.label,
|
|
46
|
+
required: state.naming.required,
|
|
47
|
+
busy: state.creating,
|
|
48
|
+
onSave: (name) => state.saveNew(name, { onSuccess: () => setNaming(void 0) }),
|
|
49
|
+
onClose: () => setNaming(void 0)
|
|
50
|
+
});
|
|
51
|
+
const opened = state.opened;
|
|
52
|
+
if (opened) {
|
|
53
|
+
const saveNewLabel = `Save as new ${state.noun}`;
|
|
54
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
55
|
+
/* @__PURE__ */ jsxs(Button, {
|
|
56
|
+
className,
|
|
57
|
+
"aria-haspopup": "dialog",
|
|
58
|
+
"aria-disabled": !state.ready,
|
|
59
|
+
onClick: () => state.ready && setConfirming(true),
|
|
60
|
+
children: [/* @__PURE__ */ jsx(RefreshCw, {
|
|
61
|
+
className: "size-3.5",
|
|
62
|
+
"aria-hidden": true
|
|
63
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
64
|
+
className: "max-w-60 truncate",
|
|
65
|
+
children: opened.name ? `Update ${opened.name}` : `Update ${state.noun}`
|
|
66
|
+
})]
|
|
67
|
+
}),
|
|
68
|
+
/* @__PURE__ */ jsxs(Button, {
|
|
69
|
+
className,
|
|
70
|
+
"aria-haspopup": state.naming ? "dialog" : void 0,
|
|
71
|
+
"aria-disabled": !state.ready,
|
|
72
|
+
onClick: () => state.ready && saveNew(saveNewLabel),
|
|
73
|
+
children: [/* @__PURE__ */ jsx(BookmarkPlus, {
|
|
74
|
+
className: "size-3.5",
|
|
75
|
+
"aria-hidden": true
|
|
76
|
+
}), saveNewLabel]
|
|
77
|
+
}),
|
|
78
|
+
confirming && /* @__PURE__ */ jsx(UpdateSearchDialog, {
|
|
79
|
+
model,
|
|
80
|
+
view,
|
|
81
|
+
title: opened.name ? `Update ${opened.name}?` : `Update this ${state.noun}?`,
|
|
82
|
+
before: opened.filter,
|
|
83
|
+
after: filter,
|
|
84
|
+
saveNewLabel,
|
|
85
|
+
updating: state.updating,
|
|
86
|
+
creating: state.creating,
|
|
87
|
+
onUpdate: () => state.update({ onSuccess: () => setConfirming(false) }),
|
|
88
|
+
onSaveNew: () => saveNew(saveNewLabel),
|
|
89
|
+
onCancel: () => setConfirming(false)
|
|
90
|
+
}),
|
|
91
|
+
nameDialog
|
|
92
|
+
] });
|
|
93
|
+
}
|
|
31
94
|
const Icon = state.saved ? BookmarkCheck : Bookmark;
|
|
32
|
-
return /* @__PURE__ */ jsxs(Button, {
|
|
95
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
|
|
33
96
|
className,
|
|
34
97
|
"aria-pressed": state.saved,
|
|
98
|
+
"aria-haspopup": !state.saved && state.naming ? "dialog" : void 0,
|
|
35
99
|
"aria-disabled": !state.ready,
|
|
36
|
-
onClick: () => state.ready && state.
|
|
100
|
+
onClick: () => state.ready && (state.saved ? state.remove() : saveNew(label)),
|
|
37
101
|
children: [/* @__PURE__ */ jsx(Icon, {
|
|
38
102
|
className: state.saved ? "size-3.5 text-primary" : "size-3.5",
|
|
39
103
|
"aria-hidden": true
|
|
40
104
|
}), label]
|
|
41
|
-
});
|
|
105
|
+
}), nameDialog] });
|
|
42
106
|
};
|
|
43
107
|
//#endregion
|
|
44
108
|
export { SaveSearchButton };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
5
|
+
import { AppLink } from "../use-app-address.js";
|
|
6
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
7
|
+
import { InlineFilterChips } from "./filter-chip-list.js";
|
|
8
|
+
import { useSavedSearches } from "./use-saved-searches.js";
|
|
9
|
+
import { RecordRowBody } from "../record-row/record-row-body.js";
|
|
10
|
+
import { useSearchRow } from "./use-search-row.js";
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
import { encodeKey } from "@protobase/schema/internal";
|
|
13
|
+
//#region src/app/searches/saved-search-rows.tsx
|
|
14
|
+
const SearchRowItem = ({ model, view, target, targetView, row }) => {
|
|
15
|
+
const key = encodeKey(recordKey(model, row));
|
|
16
|
+
const { name, chips, title, body } = useSearchRow({
|
|
17
|
+
model,
|
|
18
|
+
of: view.searchesOf,
|
|
19
|
+
target,
|
|
20
|
+
targetView,
|
|
21
|
+
row,
|
|
22
|
+
recordKey: key
|
|
23
|
+
});
|
|
24
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
25
|
+
"aria-label": title,
|
|
26
|
+
className: "flex flex-col gap-3 py-4 first:pt-0 last:pb-0",
|
|
27
|
+
children: [/* @__PURE__ */ jsx("h3", {
|
|
28
|
+
className: "text-[14px] font-semibold",
|
|
29
|
+
children: /* @__PURE__ */ jsx(AppLink, {
|
|
30
|
+
to: {
|
|
31
|
+
kind: "record",
|
|
32
|
+
resource: model.name,
|
|
33
|
+
key
|
|
34
|
+
},
|
|
35
|
+
className: "hover:underline",
|
|
36
|
+
children: name ?? /* @__PURE__ */ jsx(InlineFilterChips, { chips })
|
|
37
|
+
})
|
|
38
|
+
}), /* @__PURE__ */ jsx(RecordRowBody, { ...body })]
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The user's saved searches of a view with `searchesOf`, for a page: each with its name or filters, linking to it, over
|
|
43
|
+
* the row of what it finds (`useSearchRow`), in the order of their list. `empty` when there are none.
|
|
44
|
+
*/
|
|
45
|
+
const SavedSearchRows = ({ model, view, empty }) => {
|
|
46
|
+
const { resources, views } = useAdminMeta();
|
|
47
|
+
const of = view.searchesOf;
|
|
48
|
+
const target = resources[of.resource];
|
|
49
|
+
const saved = useSavedSearches(model.name, true);
|
|
50
|
+
const plural = (view.names?.plural ?? humanize(model.name)).toLowerCase();
|
|
51
|
+
if (!target) return null;
|
|
52
|
+
if (saved.error) return /* @__PURE__ */ jsx(ErrorBanner, {
|
|
53
|
+
title: `Could not load your ${plural}`,
|
|
54
|
+
error: saved.error
|
|
55
|
+
});
|
|
56
|
+
if (!saved.data) return /* @__PURE__ */ jsx(Skeleton, { className: "h-40" });
|
|
57
|
+
if (saved.data.length === 0) return /* @__PURE__ */ jsx("p", {
|
|
58
|
+
className: "text-[13px] text-muted-foreground",
|
|
59
|
+
children: empty ?? `No ${plural} yet.`
|
|
60
|
+
});
|
|
61
|
+
return /* @__PURE__ */ jsx("div", {
|
|
62
|
+
className: "flex flex-col divide-y",
|
|
63
|
+
children: saved.data.map((row) => /* @__PURE__ */ jsx(SearchRowItem, {
|
|
64
|
+
model,
|
|
65
|
+
view,
|
|
66
|
+
target,
|
|
67
|
+
targetView: views[of.resource],
|
|
68
|
+
row
|
|
69
|
+
}, encodeKey(recordKey(model, row))))
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
export { SavedSearchRows };
|
|
@@ -1,96 +1,114 @@
|
|
|
1
1
|
import { Button } from "../../primitives/button.js";
|
|
2
2
|
import { useClient } from "../../data/api-provider.js";
|
|
3
3
|
import { humanize } from "../../live/naming.js";
|
|
4
|
-
import {
|
|
4
|
+
import { useToast } from "../../toasts/toast-provider.js";
|
|
5
5
|
import { AppLink } from "../use-app-address.js";
|
|
6
6
|
import { useRefreshResource } from "../pages/use-layout-data.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { InlineFilterChips } from "./filter-chip-list.js";
|
|
8
|
+
import { SearchNameDialog } from "./search-name-dialog.js";
|
|
9
|
+
import { RecordRowBody } from "../record-row/record-row-body.js";
|
|
9
10
|
import { SearchControls } from "./search-controls.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import { useSearchRow } from "./use-search-row.js";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { Pencil, Trash2 } from "lucide-react";
|
|
12
14
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
15
|
import { useMutation } from "@tanstack/react-query";
|
|
14
|
-
import { tableDisplay } from "@protobase/schema/internal";
|
|
15
16
|
//#region src/app/searches/saved-search-section.tsx
|
|
16
|
-
const
|
|
17
|
-
const defaultLimit = 12;
|
|
17
|
+
const message = (error) => error instanceof Error ? error.message : "Something went wrong";
|
|
18
18
|
/**
|
|
19
|
-
* One saved search: its name
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* One saved search on its list page: its name when it has one, and what it filters on as chips, which are its title
|
|
20
|
+
* when it has none; its controls, rename and delete; and the records it finds in a row of their cards that scrolls
|
|
21
|
+
* sideways (`useSearchRow`).
|
|
22
22
|
*/
|
|
23
|
-
const SavedSearchSection = ({ model, view, of, target, targetView, row, recordKey,
|
|
23
|
+
const SavedSearchSection = ({ model, view, of, target, targetView, row, recordKey, canRename, onDelete }) => {
|
|
24
24
|
const client = useClient();
|
|
25
|
+
const toast = useToast();
|
|
25
26
|
const refresh = useRefreshResource();
|
|
26
|
-
const
|
|
27
|
-
const chips =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
const [renaming, setRenaming] = useState(false);
|
|
28
|
+
const { name, chips, title, body } = useSearchRow({
|
|
29
|
+
model,
|
|
30
|
+
of,
|
|
31
|
+
target,
|
|
32
|
+
targetView,
|
|
33
|
+
row,
|
|
34
|
+
recordKey
|
|
35
|
+
});
|
|
36
|
+
const rename = useMutation({
|
|
37
|
+
mutationFn: (next) => client.update(model.name, recordKey, { [of.name]: next }, row.etag ?? "*"),
|
|
38
|
+
onSuccess: () => setRenaming(false),
|
|
39
|
+
onError: (error) => toast.show({
|
|
40
|
+
state: "error",
|
|
41
|
+
title: "Could not rename the search",
|
|
42
|
+
description: message(error)
|
|
43
|
+
}),
|
|
31
44
|
onSettled: () => refresh(model.name)
|
|
32
45
|
});
|
|
33
|
-
const
|
|
34
|
-
const plural = (targetView?.names?.plural ?? humanize(target.name)).toLowerCase();
|
|
46
|
+
const nameField = of.name === void 0 ? void 0 : model.fields[of.name];
|
|
35
47
|
return /* @__PURE__ */ jsxs("section", {
|
|
36
48
|
"aria-label": title,
|
|
37
49
|
className: "flex flex-col gap-3 rounded-lg border bg-surface p-4",
|
|
38
|
-
children: [
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
className: "flex flex-wrap gap-1.5",
|
|
55
|
-
"aria-label": "Filters",
|
|
56
|
-
children: chips.map((chip) => /* @__PURE__ */ jsx("li", {
|
|
57
|
-
className: chipClass,
|
|
58
|
-
children: chip.label
|
|
59
|
-
}, chip.key))
|
|
60
|
-
})]
|
|
61
|
-
}), /* @__PURE__ */ jsxs("div", {
|
|
62
|
-
className: "flex flex-wrap items-center gap-2",
|
|
63
|
-
children: [of.controls && /* @__PURE__ */ jsx(SearchControls, {
|
|
64
|
-
model,
|
|
65
|
-
view,
|
|
66
|
-
recordKey,
|
|
67
|
-
row,
|
|
68
|
-
fields: of.controls
|
|
69
|
-
}), onDelete && /* @__PURE__ */ jsx(Button, {
|
|
70
|
-
variant: "ghost",
|
|
71
|
-
"aria-label": `Delete ${title}`,
|
|
72
|
-
onClick: onDelete,
|
|
73
|
-
children: /* @__PURE__ */ jsx(Trash2, {
|
|
74
|
-
className: "size-3.5",
|
|
75
|
-
"aria-hidden": true
|
|
50
|
+
children: [
|
|
51
|
+
/* @__PURE__ */ jsxs("div", {
|
|
52
|
+
className: "flex flex-wrap items-start justify-between gap-3",
|
|
53
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
54
|
+
className: "flex min-w-0 flex-col gap-2",
|
|
55
|
+
children: /* @__PURE__ */ jsx("h2", {
|
|
56
|
+
className: "text-[15px] font-semibold",
|
|
57
|
+
children: /* @__PURE__ */ jsx(AppLink, {
|
|
58
|
+
to: {
|
|
59
|
+
kind: "record",
|
|
60
|
+
resource: model.name,
|
|
61
|
+
key: recordKey
|
|
62
|
+
},
|
|
63
|
+
className: "hover:underline",
|
|
64
|
+
children: name ?? /* @__PURE__ */ jsx(InlineFilterChips, { chips })
|
|
65
|
+
})
|
|
76
66
|
})
|
|
67
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
68
|
+
className: "flex flex-wrap items-center gap-2",
|
|
69
|
+
children: [
|
|
70
|
+
of.controls && /* @__PURE__ */ jsx(SearchControls, {
|
|
71
|
+
model,
|
|
72
|
+
view,
|
|
73
|
+
recordKey,
|
|
74
|
+
title,
|
|
75
|
+
row,
|
|
76
|
+
fields: of.controls
|
|
77
|
+
}),
|
|
78
|
+
canRename && nameField && /* @__PURE__ */ jsx(Button, {
|
|
79
|
+
variant: "ghost",
|
|
80
|
+
"aria-label": `Rename ${title}`,
|
|
81
|
+
"aria-haspopup": "dialog",
|
|
82
|
+
onClick: () => setRenaming(true),
|
|
83
|
+
children: /* @__PURE__ */ jsx(Pencil, {
|
|
84
|
+
className: "size-3.5",
|
|
85
|
+
"aria-hidden": true
|
|
86
|
+
})
|
|
87
|
+
}),
|
|
88
|
+
onDelete && /* @__PURE__ */ jsx(Button, {
|
|
89
|
+
variant: "ghost",
|
|
90
|
+
"aria-label": `Delete ${title}`,
|
|
91
|
+
onClick: () => onDelete(title),
|
|
92
|
+
children: /* @__PURE__ */ jsx(Trash2, {
|
|
93
|
+
className: "size-3.5",
|
|
94
|
+
"aria-hidden": true
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
]
|
|
77
98
|
})]
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
...unseen && { onOpen: () => markSeen.mutate((/* @__PURE__ */ new Date()).toISOString()) }
|
|
92
|
-
}
|
|
93
|
-
})]
|
|
99
|
+
}),
|
|
100
|
+
renaming && nameField && /* @__PURE__ */ jsx(SearchNameDialog, {
|
|
101
|
+
title: `Rename ${title}`,
|
|
102
|
+
chips,
|
|
103
|
+
label: view.fields[nameField.name]?.label ?? humanize(nameField.name),
|
|
104
|
+
required: !nameField.nullable,
|
|
105
|
+
initial: name,
|
|
106
|
+
busy: rename.isPending,
|
|
107
|
+
onSave: (next) => rename.mutate(next),
|
|
108
|
+
onClose: () => setRenaming(false)
|
|
109
|
+
}),
|
|
110
|
+
/* @__PURE__ */ jsx(RecordRowBody, { ...body })
|
|
111
|
+
]
|
|
94
112
|
});
|
|
95
113
|
};
|
|
96
114
|
//#endregion
|
|
@@ -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 };
|