@protobase/ui 0.14.4 → 0.14.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activity-chart/activity-chart.js +1 -1
- package/dist/app/app.d.ts +2 -1
- package/dist/app/app.js +10 -3
- package/dist/app/displays/display-controls.js +1 -1
- package/dist/app/embedded-list/embedded-list.js +35 -27
- package/dist/app/index.html +10 -0
- package/dist/app/list-page.js +20 -3
- package/dist/app/menu-icons.js +2 -1
- package/dist/app/named-icon.js +3 -2
- package/dist/app/pages/blocks/field.js +2 -2
- package/dist/app/pages/blocks/progress.js +1 -1
- package/dist/app/pages/blocks/show.js +1 -1
- package/dist/app/pages/blocks/stat.js +2 -2
- package/dist/app/pages/blocks/table.js +1 -1
- package/dist/app/searches/save-search-button.js +44 -0
- package/dist/app/searches/saved-search-section.js +97 -0
- package/dist/app/searches/saved-search.js +94 -0
- package/dist/app/searches/saved-searches.js +107 -0
- package/dist/app/searches/search-controls.js +64 -0
- package/dist/app/searches/use-filter-chips.js +27 -0
- package/dist/app/searches/use-save-search.js +70 -0
- package/dist/app/searches/use-saved-searches.js +37 -0
- package/dist/app-shell/app-shell.js +3 -2
- package/dist/app-shell/drawer.js +1 -1
- package/dist/app-shell/logo.js +2 -2
- package/dist/app-shell/profile-menu.js +10 -2
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/theme-switch.js +41 -0
- package/dist/auth/auth-gate.js +0 -9
- package/dist/data-grid/data-grid.js +1 -1
- package/dist/live/use-filter-config.js +2 -6
- package/dist/live/view-fields.js +8 -1
- package/dist/package.js +1 -1
- package/dist/primitives/dialog.js +1 -1
- package/dist/primitives/responsive-dialog.d.ts +1 -2
- package/dist/primitives/responsive-dialog.js +11 -13
- package/dist/primitives/segmented.js +6 -2
- package/dist/primitives/sheet.js +39 -39
- package/dist/shell/assets/app-DxpTYgsV.js +66 -0
- package/dist/shell/assets/index-B0V1UXFn.css +2 -0
- package/dist/shell/assets/{index-DhgTXlZ3.js → index-C_ilu7LX.js} +1 -1
- package/dist/shell/assets/{maplibre-gl-CKQN3M1I.js → maplibre-gl-DPsuz0sk.js} +1 -1
- package/dist/shell/assets/{record-map-CzJ60-ux.js → record-map-grRO6rSC.js} +2 -2
- package/dist/shell/assets/{records-map-dch_o1ug.js → records-map-DjUTonTE.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-C-qDEeIK.js → shared-protobase-ui-BkgB1XZX.js} +1 -1
- package/dist/shell/index.html +14 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/styles.css +5 -2
- package/dist/theme/theme-preference.js +23 -0
- package/dist/theme/theme-provider.js +43 -0
- package/dist/theme.css +4 -0
- package/dist/tokens.css +2 -0
- package/package.json +4 -5
- package/dist/shell/assets/app-CyW0VzOi.js +0 -66
- 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)
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
}),
|
|
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
|
package/dist/app/index.html
CHANGED
|
@@ -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. -->
|
package/dist/app/list-page.js
CHANGED
|
@@ -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,
|
|
52
|
-
*
|
|
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: [
|
|
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",
|
package/dist/app/menu-icons.js
CHANGED
|
@@ -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,
|
package/dist/app/named-icon.js
CHANGED
|
@@ -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 };
|