@protobase/ui 0.14.4 → 0.14.6

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 (55) hide show
  1. package/dist/activity-chart/activity-chart.js +1 -1
  2. package/dist/app/app.d.ts +2 -1
  3. package/dist/app/app.js +10 -3
  4. package/dist/app/displays/display-controls.js +1 -1
  5. package/dist/app/embedded-list/embedded-list.js +35 -27
  6. package/dist/app/index.html +10 -0
  7. package/dist/app/list-page.js +20 -3
  8. package/dist/app/menu-icons.js +2 -1
  9. package/dist/app/named-icon.js +3 -2
  10. package/dist/app/pages/blocks/field.js +2 -2
  11. package/dist/app/pages/blocks/progress.js +1 -1
  12. package/dist/app/pages/blocks/show.js +1 -1
  13. package/dist/app/pages/blocks/stat.js +2 -2
  14. package/dist/app/pages/blocks/table.js +1 -1
  15. package/dist/app/searches/save-search-button.js +44 -0
  16. package/dist/app/searches/saved-search-section.js +97 -0
  17. package/dist/app/searches/saved-search.js +94 -0
  18. package/dist/app/searches/saved-searches.js +107 -0
  19. package/dist/app/searches/search-controls.js +64 -0
  20. package/dist/app/searches/use-filter-chips.js +27 -0
  21. package/dist/app/searches/use-save-search.js +70 -0
  22. package/dist/app/searches/use-saved-searches.js +37 -0
  23. package/dist/app-shell/app-shell.js +4 -3
  24. package/dist/app-shell/drawer.js +1 -1
  25. package/dist/app-shell/logo.js +2 -2
  26. package/dist/app-shell/profile-menu.js +10 -2
  27. package/dist/app-shell/sidebar.js +3 -2
  28. package/dist/app-shell/theme-switch.js +41 -0
  29. package/dist/auth/auth-gate.js +0 -9
  30. package/dist/data-grid/data-grid.js +1 -1
  31. package/dist/live/use-filter-config.js +2 -6
  32. package/dist/live/view-fields.js +8 -1
  33. package/dist/package.js +1 -1
  34. package/dist/primitives/dialog.js +1 -1
  35. package/dist/primitives/responsive-dialog.d.ts +1 -2
  36. package/dist/primitives/responsive-dialog.js +11 -13
  37. package/dist/primitives/segmented.js +6 -2
  38. package/dist/primitives/sheet.js +39 -39
  39. package/dist/shell/assets/app-CYs0l-vz.js +66 -0
  40. package/dist/shell/assets/{index-DhgTXlZ3.js → index-BCqsBT_o.js} +1 -1
  41. package/dist/shell/assets/index-ibaHR5wQ.css +2 -0
  42. package/dist/shell/assets/{maplibre-gl-CKQN3M1I.js → maplibre-gl-CxevrnFA.js} +1 -1
  43. package/dist/shell/assets/{record-map-CzJ60-ux.js → record-map-Mn5aTqIF.js} +2 -2
  44. package/dist/shell/assets/{records-map-dch_o1ug.js → records-map-BdPiGjmg.js} +1 -1
  45. package/dist/shell/assets/{shared-protobase-ui-C-qDEeIK.js → shared-protobase-ui-CRhV6ipQ.js} +1 -1
  46. package/dist/shell/index.html +14 -4
  47. package/dist/shell/protobase.shell.json +1 -1
  48. package/dist/styles.css +5 -2
  49. package/dist/theme/theme-preference.js +23 -0
  50. package/dist/theme/theme-provider.js +43 -0
  51. package/dist/theme.css +4 -0
  52. package/dist/tokens.css +2 -0
  53. package/package.json +4 -5
  54. package/dist/shell/assets/app-CyW0VzOi.js +0 -66
  55. package/dist/shell/assets/index-DthLyPYI.css +0 -2
@@ -1,9 +1,9 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useMediaQuery } from "../lib/use-media-query.js";
3
+ import { Segmented } from "../primitives/segmented.js";
3
4
  import { formatInt } from "../format/number.js";
4
5
  import { formatDayShort } from "../format/date.js";
5
6
  import { activitySeries } from "../mocks/activity.js";
6
- import { Segmented } from "../primitives/segmented.js";
7
7
  import { halfOverHalfDelta, totalCount } from "./activity-math.js";
8
8
  import { ActivityTooltip } from "./activity-tooltip.js";
9
9
  import { useState } from "react";
package/dist/app/app.d.ts CHANGED
@@ -30,7 +30,8 @@ export type AppProps = {
30
30
  };
31
31
  /**
32
32
  * The admin: sign-in first, or browsing as a visitor when the project has an anonymous role, then a sidebar from
33
- * `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent.
33
+ * `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent, in the theme
34
+ * this browser chose, light, dark or the system's.
34
35
  */
35
36
  export declare const App: ({ auth, workspace, ...rest }: AppProps) => import("react").JSX.Element;
36
37
  //#endregion
package/dist/app/app.js CHANGED
@@ -10,6 +10,7 @@ import { isAdmin, userToShell } from "../auth/user-to-shell.js";
10
10
  import { useRecord } from "../data/use-record.js";
11
11
  import { recordTitle } from "../live/model-helpers.js";
12
12
  import { humanize } from "../live/naming.js";
13
+ import { ThemeProvider, useTheme } from "../theme/theme-provider.js";
13
14
  import { ToastProvider } from "../toasts/toast-provider.js";
14
15
  import { startErrorReporting } from "./error-reporting.js";
15
16
  import { useProjectUi } from "./pages/project-ui.js";
@@ -57,6 +58,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
57
58
  const place = placeOf(address);
58
59
  const shown = recordOf(address);
59
60
  const search = useGlobalSearch();
61
+ const theme = useTheme();
60
62
  const recent = useNavRecent(meta, basePath, {
61
63
  resource: place,
62
64
  key: shown?.key
@@ -187,6 +189,10 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
187
189
  others: organizations.organizations.filter((organization) => organization.id !== current?.organization?.id),
188
190
  onSwitch: (id) => void session.organizations.switchTo(id).then(() => reopenIn(basePath))
189
191
  },
192
+ theme: user && {
193
+ preference: theme.preference,
194
+ onChange: theme.choose
195
+ },
190
196
  roles: role.current?.active ? {
191
197
  active: role.current.active,
192
198
  roles: role.current.roles,
@@ -261,9 +267,10 @@ const Authenticated = ({ baseUrl, client, sources, basePath, initialUrl, workspa
261
267
  };
262
268
  /**
263
269
  * The admin: sign-in first, or browsing as a visitor when the project has an anonymous role, then a sidebar from
264
- * `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent.
270
+ * `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its parent, in the theme
271
+ * this browser chose, light, dark or the system's.
265
272
  */
266
- const App = ({ auth, workspace, ...rest }) => /* @__PURE__ */ jsx(AuthProvider, {
273
+ const App = ({ auth, workspace, ...rest }) => /* @__PURE__ */ jsx(ThemeProvider, { children: /* @__PURE__ */ jsx(AuthProvider, {
267
274
  session: auth,
268
275
  children: /* @__PURE__ */ jsx(AuthGate, {
269
276
  workspace,
@@ -272,6 +279,6 @@ const App = ({ auth, workspace, ...rest }) => /* @__PURE__ */ jsx(AuthProvider,
272
279
  ...rest
273
280
  })
274
281
  })
275
- });
282
+ }) });
276
283
  //#endregion
277
284
  export { App };
@@ -1,9 +1,9 @@
1
1
  import { cn } from "../../lib/cn.js";
2
+ import { Segmented } from "../../primitives/segmented.js";
2
3
  import { humanize } from "../../live/naming.js";
3
4
  import { displayChoices, sortFields } from "./list-displays.js";
4
5
  import { parseSort } from "../list-order.js";
5
6
  import { MenuSelect } from "../../primitives/menu-select.js";
6
- import { Segmented } from "../../primitives/segmented.js";
7
7
  import { Calendar, Columns3, LayoutGrid, List, Map, SquareKanban, Table2 } from "lucide-react";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/app/displays/display-controls.tsx
@@ -19,7 +19,7 @@ import { viewAllAddress } from "./embedded-display.js";
19
19
  import { useEmbeddedRows } from "./use-embedded-rows.js";
20
20
  import { useMemo, useState } from "react";
21
21
  import { ArrowRight } from "lucide-react";
22
- import { jsx, jsxs } from "react/jsx-runtime";
22
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
23
23
  //#region src/app/embedded-list/embedded-list.tsx
24
24
  /** The switch between the table and the view's displays, for an embedded list's heading; nothing with only a table. */
25
25
  const EmbeddedDisplaySwitch = ({ view, value, onChange }) => /* @__PURE__ */ jsx(DisplaySwitch, {
@@ -36,7 +36,7 @@ const Loading = () => /* @__PURE__ */ jsx("div", {
36
36
  * The first `limit` records of a list, drawn by the list page's own table (sized to its rows) or display, and a link
37
37
  * to the whole list with the same filter, order and display. On a phone a cards display is one row that scrolls sideways.
38
38
  */
39
- const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty }) => {
39
+ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empty, row = false, viewAll }) => {
40
40
  const { resources, views } = useAdminMeta();
41
41
  const { fields: fieldTypes } = useProjectUi();
42
42
  const { go } = useAppAddress();
@@ -73,15 +73,42 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
73
73
  rowAt: (index) => loaded[index]
74
74
  }, [loaded]);
75
75
  const shown = shownDisplay(view, display);
76
+ const plural = view?.names?.plural ?? humanize(model.name);
77
+ const allLink = /* @__PURE__ */ jsxs(AppLink, {
78
+ to: viewAllAddress(model, view, xs, viewAll ? {
79
+ filter: viewAll.filter,
80
+ orderBy: defaultOrderBy(model, view),
81
+ display
82
+ } : {
83
+ filter,
84
+ orderBy,
85
+ display
86
+ }),
87
+ onClick: viewAll?.onOpen,
88
+ className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
89
+ children: [viewAll ? viewAll.label : /* @__PURE__ */ jsxs(Fragment$1, { children: ["View all ", /* @__PURE__ */ jsx("span", {
90
+ className: "sr-only",
91
+ children: plural.toLowerCase()
92
+ })] }), /* @__PURE__ */ jsx(ArrowRight, {
93
+ className: "size-3.5",
94
+ "aria-hidden": true
95
+ })]
96
+ });
76
97
  if (rows.isPending) return /* @__PURE__ */ jsx(Loading, {});
77
98
  if (rows.error) return /* @__PURE__ */ jsx(ErrorBanner, {
78
99
  title: "Could not load",
79
100
  error: rows.error
80
101
  });
81
- if (!source || source.total === 0) return /* @__PURE__ */ jsx("p", {
82
- className: "text-[13px] text-muted-foreground",
83
- children: empty ?? "Nothing here yet."
84
- });
102
+ if (!source || source.total === 0) {
103
+ const nothing = /* @__PURE__ */ jsx("p", {
104
+ className: "text-[13px] text-muted-foreground",
105
+ children: empty ?? "Nothing here yet."
106
+ });
107
+ return viewAll ? /* @__PURE__ */ jsxs("div", {
108
+ className: "flex flex-col gap-3",
109
+ children: [nothing, allLink]
110
+ }) : nothing;
111
+ }
85
112
  const list = {
86
113
  resource: model.name,
87
114
  rows: rows.data.map((row) => displayRow(model, row)),
@@ -97,7 +124,6 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
97
124
  key: recordKey(model, row)
98
125
  });
99
126
  const singular = view?.names?.singular ?? humanize(model.name);
100
- const plural = view?.names?.plural ?? humanize(model.name);
101
127
  return /* @__PURE__ */ jsxs("div", {
102
128
  className: "flex flex-col gap-3",
103
129
  children: [!shown ? /* @__PURE__ */ jsx(DataGrid, {
@@ -119,7 +145,7 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
119
145
  view,
120
146
  display: shown,
121
147
  list,
122
- row: xs,
148
+ row: row || xs,
123
149
  footer: false
124
150
  }) : shown.kind === "map" ? /* @__PURE__ */ jsx(MapDisplay, {
125
151
  model,
@@ -132,25 +158,7 @@ const EmbeddedList = ({ model, view, filter, sort, columns, limit, display, empt
132
158
  }) : /* @__PURE__ */ jsx(CustomDisplay, {
133
159
  display: shown,
134
160
  list
135
- }), /* @__PURE__ */ jsxs(AppLink, {
136
- to: viewAllAddress(model, view, xs, {
137
- filter,
138
- orderBy,
139
- display
140
- }),
141
- className: "flex items-center gap-1 self-end text-[13px] font-medium text-primary-text hover:underline",
142
- children: [
143
- "View all ",
144
- /* @__PURE__ */ jsx("span", {
145
- className: "sr-only",
146
- children: plural.toLowerCase()
147
- }),
148
- /* @__PURE__ */ jsx(ArrowRight, {
149
- className: "size-3.5",
150
- "aria-hidden": true
151
- })
152
- ]
153
- })]
161
+ }), allLink]
154
162
  });
155
163
  };
156
164
  //#endregion
@@ -4,6 +4,16 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <title>Protobase</title>
7
+ <!-- The theme before the first paint, so a dark page never flashes light: as theme/theme-preference.ts resolves it, the choice stored in this browser, else the system's. -->
8
+ <script>
9
+ var stored = null
10
+ try {
11
+ stored = localStorage.getItem('protobase:theme')
12
+ } catch (error) {
13
+ if (!(error instanceof DOMException)) throw error
14
+ }
15
+ document.documentElement.dataset.theme = stored === 'light' || stored === 'dark' ? stored : matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
16
+ </script>
7
17
  </head>
8
18
  <body>
9
19
  <!-- The page reaches under the iPhone home indicator, which the bottom bar keeps clear of; the sides keep clear of the notch. -->
@@ -39,6 +39,9 @@ import { ListChart } from "./list-chart.js";
39
39
  import { ListSearch } from "./list-search.js";
40
40
  import { withFieldTypeCells } from "./field-types/field-type-cells.js";
41
41
  import { SavedList } from "./saved/saved-list.js";
42
+ import { facetNames, filterChips, searchName } from "./searches/saved-search.js";
43
+ import { SaveSearchButton } from "./searches/save-search-button.js";
44
+ import { SavedSearches } from "./searches/saved-searches.js";
42
45
  import { useMemo, useState } from "react";
43
46
  import { Plus, SlidersHorizontal } from "lucide-react";
44
47
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -48,8 +51,9 @@ import { createWhere } from "@protobase/schema";
48
51
  const where = createWhere();
49
52
  const searchText = (extras) => extras.find((clause) => clause.kind === "search");
50
53
  /**
51
- * A resource's list: its rows, or with `listOf` the records its relation points at (`SavedList`). A visitor who cannot
52
- * see the resource is told what signing in gives, when its view says (`SignInPrompt`).
54
+ * A resource's list: its rows, with `listOf` the records its relation points at (`SavedList`), or with `searchesOf` the
55
+ * user's saved searches and what each finds (`SavedSearches`). A visitor who cannot see the resource is told what
56
+ * signing in gives, when its view says (`SignInPrompt`).
53
57
  */
54
58
  const ListPage = ({ address }) => {
55
59
  const { resources, views, signIn } = useAdminMeta();
@@ -67,6 +71,10 @@ const ListPage = ({ address }) => {
67
71
  ]
68
72
  });
69
73
  const view = views[address.resource];
74
+ if (view?.searchesOf) return /* @__PURE__ */ jsx(SavedSearches, {
75
+ model,
76
+ view
77
+ });
70
78
  const listed = listedResource(resources, views, address.resource);
71
79
  if (view && listed.listOf) {
72
80
  const target = listed.model;
@@ -255,6 +263,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
255
263
  singular.toLowerCase()
256
264
  ]
257
265
  });
266
+ const saveSearch = view?.saveSearch && !scope && parsed.ok && filterText && /* @__PURE__ */ jsx(SaveSearchButton, {
267
+ search: view.saveSearch,
268
+ filter: filterText,
269
+ name: searchName(filterChips(model, view, filterText, facetNames(config)))
270
+ });
258
271
  const displaySwitch = (menu) => /* @__PURE__ */ jsx(DisplaySwitch, {
259
272
  view,
260
273
  value: display?.name ?? tableDisplay,
@@ -331,7 +344,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
331
344
  title: heading?.title ?? plural,
332
345
  subtitle: heading ? heading.subtitle : view?.help,
333
346
  aside: !desktop && displaySwitch(true),
334
- actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [displaySwitch(false), createButton] }) : createButton
347
+ actions: desktop ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
348
+ displaySwitch(false),
349
+ saveSearch,
350
+ createButton
351
+ ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [saveSearch, createButton] })
335
352
  }),
336
353
  !parsed.ok && /* @__PURE__ */ jsx(ErrorBanner, {
337
354
  title: "The filter in the address could not be read",
@@ -1,8 +1,9 @@
1
- import { Bell, BookOpen, Building2, CreditCard, ExternalLink, FileText, Heart, KeyRound, LayoutDashboard, LifeBuoy, Mail, Receipt, Settings, ShieldCheck, Users } from "lucide-react";
1
+ import { Bell, BookOpen, Bookmark, Building2, CreditCard, ExternalLink, FileText, Heart, KeyRound, LayoutDashboard, LifeBuoy, Mail, Receipt, Settings, ShieldCheck, Users } from "lucide-react";
2
2
  //#region src/app/menu-icons.ts
3
3
  const byName = {
4
4
  bell: Bell,
5
5
  "book-open": BookOpen,
6
+ bookmark: Bookmark,
6
7
  building: Building2,
7
8
  "credit-card": CreditCard,
8
9
  "external-link": ExternalLink,
@@ -1,4 +1,4 @@
1
- import { Bed, Bell, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
1
+ import { Bed, Bell, Bookmark, FileText, Heart, RulerDimensionLine, Send, Wallet } from "lucide-react";
2
2
  //#region src/app/named-icon.ts
3
3
  /** The icons an action's, a field's or a sign-in prompt's `icon` may name. */
4
4
  const byName = {
@@ -8,7 +8,8 @@ const byName = {
8
8
  ruler: RulerDimensionLine,
9
9
  bed: Bed,
10
10
  heart: Heart,
11
- bell: Bell
11
+ bell: Bell,
12
+ bookmark: Bookmark
12
13
  };
13
14
  /** The icon a name stands for, or nothing for a name the app does not know. */
14
15
  const namedIcon = (name) => name !== void 0 && Object.hasOwn(byName, name) ? byName[name] : void 0;
@@ -1,10 +1,10 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { humanize } from "../../../live/naming.js";
3
3
  import { useAdminMeta } from "../../meta-gate.js";
4
- import { useLayoutRecord } from "../record-scope.js";
5
- import { propsOf } from "./block-props.js";
6
4
  import { FieldValue } from "../field-value.js";
7
5
  import { usePathValue } from "../use-path-value.js";
6
+ import { useLayoutRecord } from "../record-scope.js";
7
+ import { propsOf } from "./block-props.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/app/pages/blocks/field.tsx
10
10
  /** The view's label for the field at the end of the path, else the path in words: `companyId.city` is "Company city". */
@@ -1,7 +1,7 @@
1
1
  import { Skeleton } from "../../../primitives/skeleton.js";
2
+ import { usePathValue } from "../use-path-value.js";
2
3
  import { useLayoutRecord } from "../record-scope.js";
3
4
  import { propsOf } from "./block-props.js";
4
- import { usePathValue } from "../use-path-value.js";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/app/pages/blocks/progress.tsx
7
7
  /** A number as given, or the number in a field of the record around it. */
@@ -1,9 +1,9 @@
1
1
  import { useClient } from "../../../data/api-provider.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
3
  import { countQuery } from "../use-layout-data.js";
4
+ import { usePathValues } from "../use-path-value.js";
4
5
  import { useLayoutRecord } from "../record-scope.js";
5
6
  import { propsOf } from "./block-props.js";
6
- import { usePathValues } from "../use-path-value.js";
7
7
  import { useMemo } from "react";
8
8
  import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
9
9
  import { useQueries } from "@tanstack/react-query";
@@ -2,11 +2,11 @@ import { Skeleton } from "../../../primitives/skeleton.js";
2
2
  import { useAdminMeta } from "../../meta-gate.js";
3
3
  import { AppLink } from "../../use-app-address.js";
4
4
  import { useCount } from "../use-layout-data.js";
5
+ import { FieldValue } from "../field-value.js";
6
+ import { usePathValue } from "../use-path-value.js";
5
7
  import { useLayoutRecord } from "../record-scope.js";
6
8
  import { propsOf } from "./block-props.js";
7
9
  import { CardFrame } from "./card-frame.js";
8
- import { FieldValue } from "../field-value.js";
9
- import { usePathValue } from "../use-path-value.js";
10
10
  import { jsx, jsxs } from "react/jsx-runtime";
11
11
  //#region src/app/pages/blocks/stat.tsx
12
12
  const big = "text-2xl font-semibold tracking-tight tabular-nums";
@@ -1,7 +1,7 @@
1
1
  import { useAdminMeta } from "../../meta-gate.js";
2
+ import { EmbeddedDisplaySwitch, EmbeddedList } from "../../embedded-list/embedded-list.js";
2
3
  import { propsOf } from "./block-props.js";
3
4
  import { CardFrame } from "./card-frame.js";
4
- import { EmbeddedDisplaySwitch, EmbeddedList } from "../../embedded-list/embedded-list.js";
5
5
  import { useEmbeddedDisplay } from "../../embedded-list/use-embedded-display.js";
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/app/pages/blocks/table.tsx
@@ -0,0 +1,44 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { SignInPrompt } from "../../auth/sign-in-prompt.js";
3
+ import { useSaveSearch } from "./use-save-search.js";
4
+ import { useState } from "react";
5
+ import { Bookmark, BookmarkCheck } from "lucide-react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/app/searches/save-search-button.tsx
8
+ /**
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
+ */
12
+ const SaveSearchButton = ({ search, filter, name, className }) => {
13
+ const state = useSaveSearch(search, filter, name);
14
+ const [prompting, setPrompting] = useState(false);
15
+ if (!state.mode) return null;
16
+ const label = search.label ?? "Save search";
17
+ if (state.mode === "sign-in") return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
18
+ className,
19
+ "aria-haspopup": "dialog",
20
+ onClick: () => setPrompting(true),
21
+ children: [/* @__PURE__ */ jsx(Bookmark, {
22
+ className: "size-3.5",
23
+ "aria-hidden": true
24
+ }), label]
25
+ }), /* @__PURE__ */ jsx(SignInPrompt, {
26
+ as: "dialog",
27
+ prompt: state.prompt,
28
+ open: prompting,
29
+ onClose: () => setPrompting(false)
30
+ })] });
31
+ const Icon = state.saved ? BookmarkCheck : Bookmark;
32
+ return /* @__PURE__ */ jsxs(Button, {
33
+ className,
34
+ "aria-pressed": state.saved,
35
+ "aria-disabled": !state.ready,
36
+ onClick: () => state.ready && state.toggle(),
37
+ children: [/* @__PURE__ */ jsx(Icon, {
38
+ className: state.saved ? "size-3.5 text-primary" : "size-3.5",
39
+ "aria-hidden": true
40
+ }), label]
41
+ });
42
+ };
43
+ //#endregion
44
+ export { SaveSearchButton };
@@ -0,0 +1,97 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { defaultOrderBy } from "../list-order.js";
5
+ import { AppLink } from "../use-app-address.js";
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";
9
+ import { SearchControls } from "./search-controls.js";
10
+ import { useFilterChips } from "./use-filter-chips.js";
11
+ import { Trash2 } from "lucide-react";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ import { useMutation } from "@tanstack/react-query";
14
+ import { tableDisplay } from "@protobase/schema/internal";
15
+ //#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;
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.
22
+ */
23
+ const SavedSearchSection = ({ model, view, of, target, targetView, row, recordKey, title, onDelete }) => {
24
+ const client = useClient();
25
+ 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 ?? "*"),
31
+ onSettled: () => refresh(model.name)
32
+ });
33
+ const cards = targetView?.list?.displays?.find((display) => display.kind === "cards");
34
+ const plural = (targetView?.names?.plural ?? humanize(target.name)).toLowerCase();
35
+ return /* @__PURE__ */ jsxs("section", {
36
+ "aria-label": title,
37
+ 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
76
+ })
77
+ })]
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
+ })]
94
+ });
95
+ };
96
+ //#endregion
97
+ export { SavedSearchSection };
@@ -0,0 +1,94 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { activeChips } from "../../filter-panel/filter-state.js";
3
+ import { textToState } from "../../live/filter-string.js";
4
+ import { rangeFormat, widgetFields } from "../../live/view-fields.js";
5
+ import { createWhere, enumLabel, parseFilter, printFilter } from "@protobase/schema";
6
+ //#region src/app/searches/saved-search.ts
7
+ const where = createWhere();
8
+ const span = {
9
+ start: 0,
10
+ end: 0
11
+ };
12
+ /** The relation fields a filter chooses values of through its facets, each with the values it chooses. */
13
+ const chosenRelations = (model, view, text) => {
14
+ const parsed = textToState(widgetFields(view, model), text);
15
+ if (!parsed.ok) return {};
16
+ return Object.fromEntries(Object.entries(parsed.state.facets).filter(([field, values]) => model.fields[field]?.type === "relation" && values.length > 0));
17
+ };
18
+ /**
19
+ * The panel's widgets named as the list page names them, without counts: what reading a saved filter back into chips
20
+ * needs. Enum values take the view's value labels, relation values the names in `names`.
21
+ */
22
+ const namedConfig = (model, view, names) => {
23
+ const label = (name) => view?.fields[name]?.label ?? humanize(name);
24
+ const skeleton = widgetFields(view, model);
25
+ return {
26
+ ...skeleton,
27
+ facets: skeleton.facets.map((group) => {
28
+ if (group.flags) return {
29
+ ...group,
30
+ options: group.options.map((option) => ({
31
+ ...option,
32
+ label: label(option.value)
33
+ }))
34
+ };
35
+ const values = model.fields[group.id]?.enumValues ?? [];
36
+ const labels = model.fields[group.id]?.type === "enum" ? Object.fromEntries(values.map((value) => [value, enumLabel(value, view?.fields[group.id]?.valueLabels)])) : names[group.id] ?? {};
37
+ return {
38
+ ...group,
39
+ label: label(group.id),
40
+ labels
41
+ };
42
+ }),
43
+ ranges: (skeleton.ranges ?? []).map((range) => ({
44
+ ...range,
45
+ label: label(range.id),
46
+ format: rangeFormat(view, range.id)
47
+ })),
48
+ toggles: skeleton.toggles?.map((toggle) => ({
49
+ ...toggle,
50
+ label: label(toggle.id)
51
+ }))
52
+ };
53
+ };
54
+ /**
55
+ * A saved filter as the chips the list page shows for it, then a chip per condition no widget holds: the search text
56
+ * in quotes, or the condition as written. One chip saying so when the filter cannot be read.
57
+ */
58
+ const filterChips = (model, view, text, names = {}) => {
59
+ const config = namedConfig(model, view, names);
60
+ const parsed = textToState(config, text);
61
+ if (!parsed.ok) return [{
62
+ key: "unreadable",
63
+ label: "A filter that cannot be read"
64
+ }];
65
+ const extras = parsed.extras.map((clause, index) => ({
66
+ key: `extra:${index}`,
67
+ label: clause.kind === "search" ? `"${clause.text}"` : printFilter(clause)
68
+ }));
69
+ return [...activeChips(config, parsed.state), ...extras];
70
+ };
71
+ /** The names the list page's panel knows for its facets' values, to name a search saved from it. */
72
+ const facetNames = (config) => Object.fromEntries(config.facets.filter((group) => !group.flags).map((group) => [group.id, {
73
+ ...group.labels,
74
+ ...Object.fromEntries(group.options.map((option) => [option.value, option.label]))
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
+ /**
79
+ * The search's filter narrowed to the records new since the user last opened its results: those whose `field` is after
80
+ * `seenAt`. Without a `seenAt` every record is new.
81
+ */
82
+ const unseenFilter = (text, field, seenAt) => {
83
+ if (typeof seenAt !== "string" || seenAt === "") return text;
84
+ const after = where.gt(field, {
85
+ kind: "timestamp",
86
+ value: new Date(seenAt).toISOString(),
87
+ span
88
+ });
89
+ const parsed = parseFilter(text);
90
+ if (!parsed.ok) throw new Error(`The saved filter cannot be read: ${parsed.errors.map((error) => error.message).join("; ")}`);
91
+ return printFilter(parsed.ast ? where.and(parsed.ast, after) : after);
92
+ };
93
+ //#endregion
94
+ export { chosenRelations, facetNames, filterChips, searchName, unseenFilter };