@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.
Files changed (113) hide show
  1. package/dist/account/two-factor-setup.js +6 -5
  2. package/dist/app/app.js +1 -1
  3. package/dist/app/create/create-page.js +1 -0
  4. package/dist/app/create/create-record-form.js +1 -0
  5. package/dist/app/current-address.d.ts +5 -1
  6. package/dist/app/current-address.js +2 -1
  7. package/dist/app/displays/map-display.js +50 -34
  8. package/dist/app/displays/map-groups.js +12 -3
  9. package/dist/app/displays/measured-heights.js +23 -0
  10. package/dist/app/displays/record-card.js +29 -17
  11. package/dist/app/displays/virtual-cards.js +12 -14
  12. package/dist/app/embedded-list/embedded-display.js +7 -3
  13. package/dist/app/embedded-list/embedded-list.js +5 -4
  14. package/dist/app/field-editor.js +9 -1
  15. package/dist/app/list-page.js +5 -3
  16. package/dist/app/map/approximate-position.js +76 -0
  17. package/dist/app/map/area-features.js +80 -0
  18. package/dist/app/map/maplibre.js +11 -0
  19. package/dist/app/map/position-areas.js +78 -0
  20. package/dist/app/map/record-map.js +2 -7
  21. package/dist/app/map/record-position.js +5 -4
  22. package/dist/app/map/records-map.js +24 -7
  23. package/dist/app/map/theme-color.js +23 -0
  24. package/dist/app/nav-recent.js +1 -1
  25. package/dist/app/pages/blocks/card-frame.js +2 -2
  26. package/dist/app/pages/blocks/grid.js +10 -2
  27. package/dist/app/pages/blocks/link.js +1 -1
  28. package/dist/app/pages/blocks/record-row.js +51 -0
  29. package/dist/app/pages/blocks/stat.js +9 -5
  30. package/dist/app/pages/field-value.js +1 -1
  31. package/dist/app/pages/render-node.js +2 -0
  32. package/dist/app/record-page.js +5 -1
  33. package/dist/app/record-row/record-row-body.js +114 -0
  34. package/dist/app/record-row/record-row.js +93 -0
  35. package/dist/app/record-row/row-window.js +20 -0
  36. package/dist/app/record-row/saved-record-row.js +42 -0
  37. package/dist/app/record-row/use-row-scroll.js +51 -0
  38. package/dist/app/router.js +7 -8
  39. package/dist/app/saved/saved-records.js +1 -1
  40. package/dist/app/search/search-model.js +1 -1
  41. package/dist/app/searches/filter-chip-list.js +28 -0
  42. package/dist/app/searches/save-search-button.js +72 -8
  43. package/dist/app/searches/saved-search-rows.js +73 -0
  44. package/dist/app/searches/saved-search-section.js +91 -73
  45. package/dist/app/searches/saved-search.js +7 -3
  46. package/dist/app/searches/saved-searches.js +3 -5
  47. package/dist/app/searches/search-controls.js +13 -2
  48. package/dist/app/searches/search-diff.js +97 -0
  49. package/dist/app/searches/search-name-dialog.js +62 -0
  50. package/dist/app/searches/update-search-dialog.js +196 -0
  51. package/dist/app/searches/use-opened-search.js +15 -0
  52. package/dist/app/searches/use-save-search.js +68 -21
  53. package/dist/app/searches/use-search-row.js +56 -0
  54. package/dist/app/use-record-editor.js +5 -3
  55. package/dist/app-shell/app-shell.d.ts +1 -0
  56. package/dist/app-shell/app-shell.js +4 -3
  57. package/dist/app-shell/page-scroll.js +17 -1
  58. package/dist/app-shell/sidebar.d.ts +1 -0
  59. package/dist/app-shell/sidebar.js +4 -4
  60. package/dist/app-shell/top-bar.js +4 -4
  61. package/dist/auth/auth-gate.js +18 -9
  62. package/dist/auth/auth-provider.js +1 -0
  63. package/dist/auth/code-form.js +77 -0
  64. package/dist/auth/email-code-form.js +18 -77
  65. package/dist/auth/forgot-password-form.js +10 -10
  66. package/dist/auth/forgot-password-page.js +2 -2
  67. package/dist/auth/resend-code-button.js +29 -0
  68. package/dist/auth/sign-in-form.js +7 -8
  69. package/dist/auth/sign-in-page.js +3 -3
  70. package/dist/auth/sign-in-prompt.js +13 -9
  71. package/dist/auth/sign-up-code-form.js +22 -0
  72. package/dist/auth/sign-up-details-form.js +81 -111
  73. package/dist/auth/sign-up-page.js +17 -8
  74. package/dist/auth/two-factor-form.js +3 -3
  75. package/dist/auth/use-sign-in-address.js +21 -8
  76. package/dist/index.d.ts +2 -1
  77. package/dist/index.js +2 -1
  78. package/dist/lib/flip-guard.js +43 -0
  79. package/dist/lib/page-scroll-lock.js +20 -0
  80. package/dist/lib/use-focus-trap.js +4 -4
  81. package/dist/organizations/invitation-page.js +7 -6
  82. package/dist/package.js +1 -1
  83. package/dist/primitives/dialog.js +3 -2
  84. package/dist/primitives/otp-input.d.ts +21 -0
  85. package/dist/primitives/otp-input.js +117 -0
  86. package/dist/primitives/otp-value.js +23 -0
  87. package/dist/primitives/responsive-dialog.d.ts +3 -1
  88. package/dist/primitives/responsive-dialog.js +2 -1
  89. package/dist/primitives/route-link.d.ts +1 -0
  90. package/dist/primitives/route-link.js +20 -0
  91. package/dist/primitives/textarea.js +36 -0
  92. package/dist/record-view/field.js +56 -53
  93. package/dist/shell/assets/app-sDEk9LFu.js +66 -0
  94. package/dist/shell/assets/index-D0AzKE47.css +2 -0
  95. package/dist/shell/assets/{index-DxmdRnr8.js → index-L7nXyA_7.js} +1 -1
  96. package/dist/shell/assets/maplibre-AOh9A0NP.js +814 -0
  97. package/dist/shell/assets/record-map-DbwHSPf3.js +1 -0
  98. package/dist/shell/assets/records-map-2U_CQ4RG.js +1 -0
  99. package/dist/shell/assets/shared-protobase-ui-BMcStVNP.js +1 -0
  100. package/dist/shell/index.html +4 -4
  101. package/dist/shell/protobase.shell.json +1 -1
  102. package/dist/styles.css +15 -0
  103. package/dist/toasts/toast.js +22 -6
  104. package/package.json +4 -4
  105. package/dist/auth/code-input.js +0 -17
  106. package/dist/auth/sign-in-address.js +0 -47
  107. package/dist/shell/assets/app-BdEV1_xI.js +0 -66
  108. package/dist/shell/assets/index-ibaHR5wQ.css +0 -2
  109. package/dist/shell/assets/maplibre-gl-DFcSGTFJ.js +0 -1
  110. package/dist/shell/assets/record-map-CL9bBLic.js +0 -814
  111. package/dist/shell/assets/records-map-DgtD0cK5.js +0 -1
  112. package/dist/shell/assets/shared-protobase-ui-ibIDwOpE.js +0 -1
  113. /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, 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 };
@@ -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. A press saves the
16
- * filter as a search named `name`, or takes the saved one off again.
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, name) => {
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 { go } = useAppAddress();
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 plural = meta.views[resource]?.names?.plural ?? "saved searches";
33
- const toggle = useMutation({
34
- mutationFn: async (existing) => {
35
- if (existing) await client.remove(resource, encodeKey(recordKey(model, existing)));
36
- else await client.create(resource, {
37
- [of.filter]: filter,
38
- ...of.name && { [of.name]: name }
39
- });
40
- },
41
- onSuccess: (_, existing) => {
42
- if (!existing) toast.show({
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
- onClick: () => go({
54
+ href: href({
48
55
  kind: "list",
49
56
  resource
50
- })
57
+ }),
58
+ onNavigate: open
51
59
  }
52
60
  });
53
61
  },
54
- onError: (error, existing) => toast.show({
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: existing ? "Could not take the search off" : "Could not save the search",
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
- ready: saved.data !== void 0 && !toggle.isPending,
66
- toggle: () => toggle.mutate(current)
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 };