@protobase/ui 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/app/current-address.d.ts +5 -1
  2. package/dist/app/current-address.js +2 -1
  3. package/dist/app/displays/record-card.js +28 -16
  4. package/dist/app/embedded-list/embedded-display.js +7 -3
  5. package/dist/app/embedded-list/embedded-list.js +5 -4
  6. package/dist/app/list-page.js +4 -2
  7. package/dist/app/pages/blocks/record-row.js +51 -0
  8. package/dist/app/pages/render-node.js +2 -0
  9. package/dist/app/record-row/record-row-body.js +114 -0
  10. package/dist/app/record-row/record-row.js +93 -0
  11. package/dist/app/record-row/row-window.js +20 -0
  12. package/dist/app/record-row/saved-record-row.js +42 -0
  13. package/dist/app/record-row/use-row-scroll.js +51 -0
  14. package/dist/app/searches/filter-chip-list.js +28 -0
  15. package/dist/app/searches/save-search-button.js +72 -8
  16. package/dist/app/searches/saved-search-rows.js +73 -0
  17. package/dist/app/searches/saved-search-section.js +91 -73
  18. package/dist/app/searches/saved-search.js +7 -3
  19. package/dist/app/searches/saved-searches.js +3 -5
  20. package/dist/app/searches/search-controls.js +13 -2
  21. package/dist/app/searches/search-diff.js +97 -0
  22. package/dist/app/searches/search-name-dialog.js +62 -0
  23. package/dist/app/searches/update-search-dialog.js +196 -0
  24. package/dist/app/searches/use-opened-search.js +15 -0
  25. package/dist/app/searches/use-save-search.js +64 -18
  26. package/dist/app/searches/use-search-row.js +56 -0
  27. package/dist/package.js +1 -1
  28. package/dist/primitives/dialog.js +3 -2
  29. package/dist/primitives/responsive-dialog.d.ts +3 -1
  30. package/dist/primitives/responsive-dialog.js +2 -1
  31. package/dist/shell/assets/app-BSKdnId2.js +66 -0
  32. package/dist/shell/assets/index-D0AzKE47.css +2 -0
  33. package/dist/shell/assets/{index-LBhMBiK4.js → index-DNc-xM_v.js} +1 -1
  34. package/dist/shell/assets/{maplibre-Dy0ZTnFZ.js → maplibre-1qVldvCU.js} +1 -1
  35. package/dist/shell/assets/{record-map-BiWSbqqT.js → record-map-BPHuPMlK.js} +1 -1
  36. package/dist/shell/assets/{records-map-DabYc3ad.js → records-map-BORhj6IH.js} +1 -1
  37. package/dist/shell/assets/{shared-protobase-ui-BURelnPG.js → shared-protobase-ui-CHRI5o6a.js} +1 -1
  38. package/dist/shell/index.html +4 -4
  39. package/dist/shell/protobase.shell.json +1 -1
  40. package/package.json +4 -4
  41. package/dist/shell/assets/app-DJAZP1yB.js +0 -66
  42. 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 searches, pressed while it is saved,
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, name, className }) => {
13
- const state = useSaveSearch(search, filter, name);
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.toggle(),
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 { defaultOrderBy } from "../list-order.js";
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 { searchName, unseenFilter } from "./saved-search.js";
8
- import { EmbeddedList } from "../embedded-list/embedded-list.js";
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 { useFilterChips } from "./use-filter-chips.js";
11
- import { Trash2 } from "lucide-react";
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 chipClass = "inline-flex h-6 items-center rounded-full border border-primary-border bg-primary-soft px-2.5 text-xs font-medium text-primary-text";
17
- const defaultLimit = 12;
17
+ const message = (error) => error instanceof Error ? error.message : "Something went wrong";
18
18
  /**
19
- * One saved search: its name and filters, its controls and delete, and a row of the records it finds that are new to
20
- * the user (with `unseen`), newest first, with "View all results", which opens the list with its filter and counts as
21
- * having seen them.
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, title, onDelete }) => {
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 filter = String(row[of.filter] ?? "");
27
- const chips = useFilterChips(target, filter);
28
- const unseen = of.unseen;
29
- const markSeen = useMutation({
30
- mutationFn: (seenAt) => client.update(model.name, recordKey, { [unseen.seenAt]: seenAt }, row.etag ?? "*"),
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 cards = targetView?.list?.displays?.find((display) => display.kind === "cards");
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: [/* @__PURE__ */ jsxs("div", {
39
- className: "flex flex-wrap items-start justify-between gap-3",
40
- children: [/* @__PURE__ */ jsxs("div", {
41
- className: "flex min-w-0 flex-col gap-2",
42
- children: [/* @__PURE__ */ jsx("h2", {
43
- className: "text-[15px] font-semibold",
44
- children: /* @__PURE__ */ jsx(AppLink, {
45
- to: {
46
- kind: "record",
47
- resource: model.name,
48
- key: recordKey
49
- },
50
- className: "hover:underline",
51
- children: title
52
- })
53
- }), chips.length > 0 && searchName(chips) !== title && /* @__PURE__ */ jsx("ul", {
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
- }), /* @__PURE__ */ jsx(EmbeddedList, {
80
- model: target,
81
- view: targetView,
82
- filter: unseen ? unseenFilter(filter, unseen.field, row[unseen.seenAt]) : filter,
83
- sort: unseen ? `${unseen.field} desc` : defaultOrderBy(target, targetView),
84
- limit: of.limit ?? defaultLimit,
85
- display: cards?.name ?? tableDisplay,
86
- row: true,
87
- empty: unseen ? `No new ${plural} since you last looked.` : `No ${plural} match.`,
88
- viewAll: {
89
- filter,
90
- label: "View all results",
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, searchName, unseenFilter };
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, recordTitleField } from "../../live/model-helpers.js";
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 title = String(row[titleField] ?? key);
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
- title,
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
- /** A saved search's controls, each saved as soon as it changes; a refused change says why and shows the search as stored. */
39
- const SearchControls = ({ model, view, recordKey, row, fields }) => {
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 };