@protobase/ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/LICENSE +202 -0
  2. package/dist/activity-chart/activity-chart.js +125 -0
  3. package/dist/activity-chart/activity-math.js +11 -0
  4. package/dist/activity-chart/activity-tooltip.js +24 -0
  5. package/dist/app/action-icon.js +10 -0
  6. package/dist/app/app.d.ts +24 -0
  7. package/dist/app/app.js +118 -0
  8. package/dist/app/batch-failure.js +21 -0
  9. package/dist/app/breadcrumb-for.js +27 -0
  10. package/dist/app/create/create-page.js +118 -0
  11. package/dist/app/create/create-record-form.js +68 -0
  12. package/dist/app/create/create-values.js +52 -0
  13. package/dist/app/delete/dialogs.js +109 -0
  14. package/dist/app/delete/record-diff.js +17 -0
  15. package/dist/app/delete/use-delete-flow.js +193 -0
  16. package/dist/app/delete/use-restore.js +9 -0
  17. package/dist/app/draft-storage.js +18 -0
  18. package/dist/app/error-banner.js +28 -0
  19. package/dist/app/field-editor.js +88 -0
  20. package/dist/app/icons.js +27 -0
  21. package/dist/app/index.d.ts +2 -0
  22. package/dist/app/index.html +12 -0
  23. package/dist/app/list-chart.js +36 -0
  24. package/dist/app/list-order.js +15 -0
  25. package/dist/app/list-page.js +213 -0
  26. package/dist/app/list-search.js +30 -0
  27. package/dist/app/main.d.ts +1 -0
  28. package/dist/app/main.js +14 -0
  29. package/dist/app/menu-icons.js +21 -0
  30. package/dist/app/meta-gate.js +46 -0
  31. package/dist/app/nav-from-meta.js +65 -0
  32. package/dist/app/nav-recent.js +33 -0
  33. package/dist/app/pages/action-href.js +7 -0
  34. package/dist/app/pages/blocks/action.js +38 -0
  35. package/dist/app/pages/blocks/block-props.js +5 -0
  36. package/dist/app/pages/blocks/card-frame.js +34 -0
  37. package/dist/app/pages/blocks/card-row.js +55 -0
  38. package/dist/app/pages/blocks/card.js +19 -0
  39. package/dist/app/pages/blocks/field.js +45 -0
  40. package/dist/app/pages/blocks/grid.js +34 -0
  41. package/dist/app/pages/blocks/link.js +27 -0
  42. package/dist/app/pages/blocks/modal-form.js +149 -0
  43. package/dist/app/pages/blocks/page-block.js +20 -0
  44. package/dist/app/pages/blocks/progress.js +54 -0
  45. package/dist/app/pages/blocks/record-card.js +56 -0
  46. package/dist/app/pages/blocks/show.js +43 -0
  47. package/dist/app/pages/blocks/stat.js +78 -0
  48. package/dist/app/pages/blocks/table.js +65 -0
  49. package/dist/app/pages/composed-page.js +19 -0
  50. package/dist/app/pages/confirm-action.js +20 -0
  51. package/dist/app/pages/field-value.js +58 -0
  52. package/dist/app/pages/project-ui.d.ts +25 -0
  53. package/dist/app/pages/project-ui.js +13 -0
  54. package/dist/app/pages/record-actions.js +40 -0
  55. package/dist/app/pages/record-scope.d.ts +16 -0
  56. package/dist/app/pages/record-scope.js +12 -0
  57. package/dist/app/pages/record-table.js +110 -0
  58. package/dist/app/pages/render-node.js +84 -0
  59. package/dist/app/pages/use-layout-data.js +73 -0
  60. package/dist/app/pages/use-path-value.js +65 -0
  61. package/dist/app/pages/use-relation-labels.js +30 -0
  62. package/dist/app/pages/use-run-action.js +88 -0
  63. package/dist/app/permissions.js +15 -0
  64. package/dist/app/record-lines.js +39 -0
  65. package/dist/app/record-page.js +318 -0
  66. package/dist/app/related-section.js +126 -0
  67. package/dist/app/relation-picker.js +69 -0
  68. package/dist/app/router.js +155 -0
  69. package/dist/app/search/search-model.js +70 -0
  70. package/dist/app/search/use-global-search.js +68 -0
  71. package/dist/app/sensitive-field.js +65 -0
  72. package/dist/app/use-lines-draft.js +45 -0
  73. package/dist/app/use-lines.js +19 -0
  74. package/dist/app/use-live-save.js +54 -0
  75. package/dist/app/use-nav-recent.js +41 -0
  76. package/dist/app/use-record-editor.js +154 -0
  77. package/dist/app/use-related-records.js +31 -0
  78. package/dist/app/user-menu-from-meta.js +36 -0
  79. package/dist/app-shell/app-shell.d.ts +4 -0
  80. package/dist/app-shell/app-shell.js +57 -0
  81. package/dist/app-shell/breadcrumb.js +37 -0
  82. package/dist/app-shell/drawer.js +30 -0
  83. package/dist/app-shell/global-search.d.ts +1 -0
  84. package/dist/app-shell/global-search.js +145 -0
  85. package/dist/app-shell/index.d.ts +9 -0
  86. package/dist/app-shell/logo.js +46 -0
  87. package/dist/app-shell/nav-open-storage.js +10 -0
  88. package/dist/app-shell/nav.d.ts +2 -0
  89. package/dist/app-shell/nav.js +85 -0
  90. package/dist/app-shell/page-header.d.ts +1 -0
  91. package/dist/app-shell/page-header.js +17 -0
  92. package/dist/app-shell/profile-menu.d.ts +2 -0
  93. package/dist/app-shell/profile-menu.js +115 -0
  94. package/dist/app-shell/sidebar-group.js +94 -0
  95. package/dist/app-shell/sidebar-item.js +46 -0
  96. package/dist/app-shell/sidebar-mode.d.ts +4 -0
  97. package/dist/app-shell/sidebar-mode.js +13 -0
  98. package/dist/app-shell/sidebar.d.ts +2 -0
  99. package/dist/app-shell/sidebar.js +64 -0
  100. package/dist/app-shell/status-dot.d.ts +1 -0
  101. package/dist/app-shell/status-dot.js +20 -0
  102. package/dist/app-shell/top-bar.js +82 -0
  103. package/dist/auth/auth-gate.js +70 -0
  104. package/dist/auth/auth-layout.js +29 -0
  105. package/dist/auth/auth-provider.js +64 -0
  106. package/dist/auth/first-run-page.js +51 -0
  107. package/dist/auth/forgot-password-form.js +75 -0
  108. package/dist/auth/forgot-password-page.js +35 -0
  109. package/dist/auth/reset-link.js +29 -0
  110. package/dist/auth/reset-password-form.js +88 -0
  111. package/dist/auth/reset-password-page.js +43 -0
  112. package/dist/auth/sign-in-form.js +74 -0
  113. package/dist/auth/sign-in-page.js +38 -0
  114. package/dist/auth/user-to-shell.js +18 -0
  115. package/dist/data/api-provider.d.ts +54 -0
  116. package/dist/data/api-provider.js +53 -0
  117. package/dist/data/meta-data.d.ts +14 -0
  118. package/dist/data/meta-data.js +9 -0
  119. package/dist/data/query-keys.js +95 -0
  120. package/dist/data/use-aggregates.d.ts +8 -0
  121. package/dist/data/use-aggregates.js +43 -0
  122. package/dist/data/use-list.d.ts +231 -0
  123. package/dist/data/use-list.js +31 -0
  124. package/dist/data/use-meta.d.ts +8 -0
  125. package/dist/data/use-meta.js +35 -0
  126. package/dist/data/use-record.d.ts +6 -0
  127. package/dist/data/use-record.js +16 -0
  128. package/dist/data/use-update-record.d.ts +15 -0
  129. package/dist/data/use-update-record.js +26 -0
  130. package/dist/data-grid/cells.js +85 -0
  131. package/dist/data-grid/column-spec.js +4 -0
  132. package/dist/data-grid/columns.js +16 -0
  133. package/dist/data-grid/data-grid.js +238 -0
  134. package/dist/data-grid/geometry.js +32 -0
  135. package/dist/data-grid/grid-footer.js +33 -0
  136. package/dist/data-grid/grid-header.js +69 -0
  137. package/dist/data-grid/grid-row.js +83 -0
  138. package/dist/data-grid/grid-template.js +12 -0
  139. package/dist/data-grid/header-menu.js +52 -0
  140. package/dist/data-grid/page-cache.js +27 -0
  141. package/dist/data-grid/pinned-storage.js +13 -0
  142. package/dist/data-grid/row-menu.js +88 -0
  143. package/dist/data-grid/use-grid-scroll.js +80 -0
  144. package/dist/data-grid/use-horizontal-scroll.js +38 -0
  145. package/dist/data-grid/use-paged-rows.js +50 -0
  146. package/dist/data-grid/use-pinned-columns.js +16 -0
  147. package/dist/filter-panel/active-filter-chips.js +34 -0
  148. package/dist/filter-panel/date-presets.js +20 -0
  149. package/dist/filter-panel/facet-group.js +48 -0
  150. package/dist/filter-panel/filter-config.js +18 -0
  151. package/dist/filter-panel/filter-menu.js +41 -0
  152. package/dist/filter-panel/filter-panel.js +112 -0
  153. package/dist/filter-panel/filter-section.js +11 -0
  154. package/dist/filter-panel/filter-state.js +63 -0
  155. package/dist/filter-panel/range-filter.js +54 -0
  156. package/dist/filter-panel/range-math.js +11 -0
  157. package/dist/filter-panel/range-slider.css +32 -0
  158. package/dist/filter-panel/toggle-row.js +13 -0
  159. package/dist/filter-panel/use-filter-state.js +15 -0
  160. package/dist/format/date.js +37 -0
  161. package/dist/format/money.js +15 -0
  162. package/dist/format/number.js +12 -0
  163. package/dist/index.d.ts +12 -0
  164. package/dist/index.js +10 -0
  165. package/dist/inline-child-table/columns.js +4 -0
  166. package/dist/inline-child-table/inline-child-table.js +120 -0
  167. package/dist/inline-child-table/line-menu.js +54 -0
  168. package/dist/inline-child-table/line-row.js +91 -0
  169. package/dist/inline-child-table/number-cell.js +25 -0
  170. package/dist/inline-child-table/reorder.js +10 -0
  171. package/dist/inline-child-table/totals-footer.js +42 -0
  172. package/dist/lib/cn.js +4 -0
  173. package/dist/lib/guarded.js +12 -0
  174. package/dist/lib/use-debounced.js +13 -0
  175. package/dist/lib/use-focus-trap.js +46 -0
  176. package/dist/lib/use-media-query.js +12 -0
  177. package/dist/live/columns-from-model.js +93 -0
  178. package/dist/live/display-value.js +24 -0
  179. package/dist/live/field-values.js +32 -0
  180. package/dist/live/filter-string.js +135 -0
  181. package/dist/live/grid-loader.js +63 -0
  182. package/dist/live/model-helpers.js +30 -0
  183. package/dist/live/naming.js +9 -0
  184. package/dist/live/relation-labels.js +53 -0
  185. package/dist/live/use-filter-config.js +88 -0
  186. package/dist/live/use-live-rows.js +45 -0
  187. package/dist/live/view-fields.js +35 -0
  188. package/dist/mocks/activity.js +24 -0
  189. package/dist/mocks/invoices.js +13 -0
  190. package/dist/mocks/products.js +167 -0
  191. package/dist/mocks/rng.js +20 -0
  192. package/dist/mocks/users.js +63 -0
  193. package/dist/primitives/avatar.js +22 -0
  194. package/dist/primitives/badge.d.ts +1 -0
  195. package/dist/primitives/badge.js +25 -0
  196. package/dist/primitives/button.js +23 -0
  197. package/dist/primitives/checkbox.js +21 -0
  198. package/dist/primitives/dialog.js +41 -0
  199. package/dist/primitives/input.js +16 -0
  200. package/dist/primitives/kbd.js +8 -0
  201. package/dist/primitives/segmented.js +14 -0
  202. package/dist/primitives/sheet.js +44 -0
  203. package/dist/primitives/skeleton.js +6 -0
  204. package/dist/primitives/spinner.js +10 -0
  205. package/dist/primitives/switch.js +14 -0
  206. package/dist/primitives/tooltip.js +13 -0
  207. package/dist/record-view/action-rail.js +28 -0
  208. package/dist/record-view/field.js +62 -0
  209. package/dist/record-view/record-header.js +50 -0
  210. package/dist/record-view/record-sidebar.js +58 -0
  211. package/dist/record-view/section.js +21 -0
  212. package/dist/styles.css +132 -0
  213. package/dist/toasts/toast-provider.js +69 -0
  214. package/dist/toasts/toast.js +45 -0
  215. package/package.json +45 -3
  216. package/README.md +0 -3
@@ -0,0 +1,155 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { Dialog } from "../primitives/dialog.js";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/app/router.tsx
6
+ const RouterContext = createContext(null);
7
+ const useRouter = () => {
8
+ const value = useContext(RouterContext);
9
+ if (!value) throw new Error("useRouter must be used inside Router");
10
+ return value;
11
+ };
12
+ const browserUrl = () => `${window.location.pathname}${window.location.search}`;
13
+ const pathOf = (url) => new URL(url, "http://local").pathname;
14
+ /** Our place in the browser history, so a back or forward press can be told apart and, if needed, undone. */
15
+ const historyIndex = () => window.history.state?.protobaseIndex ?? 0;
16
+ const Router = ({ basePath = "", initialUrl, children }) => {
17
+ const memory = initialUrl !== void 0;
18
+ const [url, setUrl] = useState(initialUrl ?? browserUrl());
19
+ const guards = useRef(/* @__PURE__ */ new Set());
20
+ const urlRef = useRef(url);
21
+ urlRef.current = url;
22
+ const position = useRef(memory ? 0 : historyIndex());
23
+ const undoing = useRef(false);
24
+ const proceeding = useRef(false);
25
+ const [pending, setPending] = useState();
26
+ useEffect(() => {
27
+ if (memory) return;
28
+ window.history.replaceState({ protobaseIndex: position.current }, "");
29
+ const onPop = () => {
30
+ const next = historyIndex();
31
+ if (undoing.current) {
32
+ undoing.current = false;
33
+ position.current = next;
34
+ return;
35
+ }
36
+ const delta = next - position.current;
37
+ const leaving = pathOf(browserUrl()) !== pathOf(urlRef.current);
38
+ if (!proceeding.current && guards.current.size > 0 && leaving) {
39
+ undoing.current = true;
40
+ window.history.go(-delta);
41
+ setPending({
42
+ kind: "history",
43
+ delta
44
+ });
45
+ return;
46
+ }
47
+ proceeding.current = false;
48
+ position.current = next;
49
+ setUrl(browserUrl());
50
+ };
51
+ window.addEventListener("popstate", onPop);
52
+ return () => window.removeEventListener("popstate", onPop);
53
+ }, [memory]);
54
+ const go = useCallback((full, replace) => {
55
+ if (!memory) {
56
+ position.current += replace ? 0 : 1;
57
+ window.history[replace ? "replaceState" : "pushState"]({ protobaseIndex: position.current }, "", full);
58
+ }
59
+ setUrl(full);
60
+ }, [memory]);
61
+ const navigate = useCallback((to, { replace = false } = {}) => {
62
+ const full = to.startsWith(basePath) && basePath ? to : `${basePath}${to}`;
63
+ if (pathOf(full) !== pathOf(urlRef.current) && guards.current.size > 0) return setPending({
64
+ kind: "link",
65
+ full,
66
+ replace
67
+ });
68
+ go(full, replace);
69
+ }, [basePath, go]);
70
+ const registerGuard = useCallback(() => {
71
+ const token = Symbol("guard");
72
+ guards.current.add(token);
73
+ return () => void guards.current.delete(token);
74
+ }, []);
75
+ const leave = () => {
76
+ if (!pending) return;
77
+ setPending(void 0);
78
+ if (pending.kind === "link") return go(pending.full, pending.replace);
79
+ proceeding.current = true;
80
+ window.history.go(pending.delta);
81
+ };
82
+ const value = useMemo(() => {
83
+ const parsed = new URL(url, "http://local");
84
+ const path = parsed.pathname.slice(basePath.length) || "/";
85
+ return {
86
+ path,
87
+ params: parsed.searchParams,
88
+ basePath,
89
+ navigate,
90
+ registerGuard,
91
+ setParams: (patch) => {
92
+ const next = new URLSearchParams(parsed.searchParams);
93
+ for (const [name, entry] of Object.entries(patch)) if (entry === void 0 || entry === "") next.delete(name);
94
+ else next.set(name, entry);
95
+ const query = next.toString();
96
+ navigate(`${path}${query ? `?${query}` : ""}`, { replace: true });
97
+ }
98
+ };
99
+ }, [
100
+ url,
101
+ basePath,
102
+ navigate,
103
+ registerGuard
104
+ ]);
105
+ return /* @__PURE__ */ jsxs(RouterContext.Provider, {
106
+ value,
107
+ children: [children, pending && /* @__PURE__ */ jsx(Dialog, {
108
+ title: "Leave without saving?",
109
+ description: "You have unsaved changes on this page. They stay in this browser, and come back when you open the record again.",
110
+ onClose: () => setPending(void 0),
111
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
112
+ onClick: () => setPending(void 0),
113
+ children: "Stay"
114
+ }), /* @__PURE__ */ jsx(Button, {
115
+ variant: "primary",
116
+ onClick: leave,
117
+ children: "Leave"
118
+ })] })
119
+ })]
120
+ });
121
+ };
122
+ const Link = ({ to, onClick, ...rest }) => {
123
+ const { navigate, basePath } = useRouter();
124
+ return /* @__PURE__ */ jsx("a", {
125
+ href: `${basePath}${to}`,
126
+ onClick: (event) => {
127
+ onClick?.(event);
128
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.button !== 0) return;
129
+ event.preventDefault();
130
+ navigate(to);
131
+ },
132
+ ...rest
133
+ });
134
+ };
135
+ /** `/orders` is a list, `/orders/<key>` a record; composite keys stay in their comma-joined form. */
136
+ const matchRoute = (path) => {
137
+ const [resource, key] = path.split("/").filter(Boolean);
138
+ return {
139
+ resource: resource && decodeURIComponent(resource),
140
+ key: key && decodeURIComponent(key)
141
+ };
142
+ };
143
+ /** While `active`, leaving the page asks first (in-app links and closing the tab). */
144
+ const useNavigationGuard = (active) => {
145
+ const { registerGuard } = useRouter();
146
+ useEffect(() => active ? registerGuard() : void 0, [active, registerGuard]);
147
+ useEffect(() => {
148
+ if (!active) return;
149
+ const warn = (event) => event.preventDefault();
150
+ window.addEventListener("beforeunload", warn);
151
+ return () => window.removeEventListener("beforeunload", warn);
152
+ }, [active]);
153
+ };
154
+ //#endregion
155
+ export { Link, Router, matchRoute, useNavigationGuard, useRouter };
@@ -0,0 +1,70 @@
1
+ import { recordKey, recordTitleField } from "../../live/model-helpers.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { createWhere, encodeKey, enumLabel, matchedSearchField, printFilter } from "@protobase/schema";
4
+ //#region src/app/search/search-model.ts
5
+ const where = createWhere();
6
+ const searchTargets = (meta) => Object.values(meta.resources).flatMap((model) => {
7
+ const view = meta.views[model.name];
8
+ if (!model.search?.length || !view || view.nav?.hidden || meta.permissions[model.name]?.read === false) return [];
9
+ return [{
10
+ model,
11
+ view,
12
+ label: view.names?.plural ?? humanize(model.name)
13
+ }];
14
+ });
15
+ /** "Search organizations and services", or "Search organizations, services and more" when it looks in more. */
16
+ const searchPlaceholder = (targets) => {
17
+ const names = targets.map((target) => target.label.toLowerCase());
18
+ if (names.length === 0) return "Search";
19
+ if (names.length === 1) return `Search ${names[0]}`;
20
+ if (names.length === 2) return `Search ${names[0]} and ${names[1]}`;
21
+ return `Search ${names[0]}, ${names[1]} and more`;
22
+ };
23
+ /** The list filter for the text: the server's partial-word `search(...)` over the resource's search fields. */
24
+ const searchFilter = (text) => printFilter(where.search(text.trim()));
25
+ /** The fields a result's bold line joins: the view's `searchResult.title`, else the record's title field. */
26
+ const titleFields = (target) => {
27
+ const fields = target.view?.searchResult?.title;
28
+ return fields?.length ? fields : [recordTitleField(target.model, target.view)];
29
+ };
30
+ /** The second line's field, unless the search matched another: the view's `searchResult.subtitle`, else the first search field not in the title. */
31
+ const subtitleField = (target) => {
32
+ const title = titleFields(target);
33
+ return target.view?.searchResult?.subtitle ?? target.model.search?.find((name) => !title.includes(name));
34
+ };
35
+ /** The fields a result needs: the key, the title, the subtitle and the search fields, which say what the text matched. */
36
+ const searchFields = (target) => {
37
+ const subtitle = subtitleField(target);
38
+ const lineFields = [
39
+ ...titleFields(target),
40
+ ...subtitle ? [subtitle] : [],
41
+ ...target.model.search ?? []
42
+ ];
43
+ return [.../* @__PURE__ */ new Set([...target.model.primaryKey, ...lineFields.filter((name) => Object.hasOwn(target.model.fields, name))])];
44
+ };
45
+ /** A field of the row as a result line shows it: an enum by its label. */
46
+ const shown = (target, row, name) => {
47
+ const value = row[name];
48
+ if (value === null || value === void 0) return "";
49
+ return target.model.fields[name]?.type === "enum" ? enumLabel(String(value), target.view?.fields[name]?.valueLabels) : String(value);
50
+ };
51
+ /**
52
+ * A row as a result for `text`: its title fields joined by spaces (the key when they are empty) over a subtitle, which is
53
+ * the search field explaining the match when neither the title nor the default subtitle does (a phone number searched
54
+ * for), else the default subtitle. `basePath` is where the app is served, so the link opens it in a new tab too.
55
+ */
56
+ const toHit = (target, row, text, basePath = "") => {
57
+ const key = encodeKey(recordKey(target.model, row));
58
+ const title = titleFields(target);
59
+ const fallback = subtitleField(target);
60
+ const field = matchedSearchField(target.model, row, text, [...title, ...fallback ? [fallback] : []]) ?? fallback;
61
+ const subtitle = field ? shown(target, row, field) : "";
62
+ return {
63
+ id: key,
64
+ title: title.map((name) => shown(target, row, name)).filter(Boolean).join(" ") || key,
65
+ ...subtitle && { subtitle },
66
+ href: `${basePath}/${target.model.name}/${encodeURIComponent(key)}`
67
+ };
68
+ };
69
+ //#endregion
70
+ export { searchFields, searchFilter, searchPlaceholder, searchTargets, subtitleField, titleFields, toHit };
@@ -0,0 +1,68 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { useRouter } from "../router.js";
3
+ import { useAdminMeta } from "../meta-gate.js";
4
+ import { useDebounced } from "../../lib/use-debounced.js";
5
+ import { searchFields, searchFilter, searchPlaceholder, searchTargets, toHit } from "./search-model.js";
6
+ import { useMemo, useState } from "react";
7
+ import { useQueries } from "@tanstack/react-query";
8
+ //#region src/app/search/use-global-search.ts
9
+ /**
10
+ * The top bar's search: the typed text, searched in every resource the user can read that has search fields, through
11
+ * the list API with the user's token, so row filters and hidden fields apply exactly as on the lists. The results stay
12
+ * while the text does, so opening the box again asks nothing; other texts' results are dropped, so changed text asks again.
13
+ */
14
+ const useGlobalSearch = () => {
15
+ const meta = useAdminMeta();
16
+ const client = useClient();
17
+ const { basePath } = useRouter();
18
+ const targets = useMemo(() => searchTargets(meta), [meta]);
19
+ const [text, setText] = useState("");
20
+ const typed = text.trim();
21
+ const query = useDebounced(typed, 200);
22
+ const wanted = typed.length >= 2;
23
+ const searching = wanted && query.length >= 2;
24
+ const results = useQueries({ queries: targets.map((target) => ({
25
+ queryKey: [
26
+ "global-search",
27
+ target.model.name,
28
+ query
29
+ ],
30
+ enabled: searching,
31
+ staleTime: Infinity,
32
+ gcTime: 0,
33
+ queryFn: () => client.list(target.model.name, {
34
+ filter: searchFilter(query),
35
+ pageSize: 5,
36
+ fields: searchFields(target)
37
+ })
38
+ })) });
39
+ const groups = targets.flatMap((target, index) => {
40
+ const result = results[index];
41
+ if (!searching || !result) return [];
42
+ if (result.error) return [{
43
+ id: target.model.name,
44
+ label: target.label,
45
+ hits: [],
46
+ failed: true
47
+ }];
48
+ const hits = (result.data?.items ?? []).map((row) => toHit(target, row, query, basePath));
49
+ return hits.length > 0 ? [{
50
+ id: target.model.name,
51
+ label: target.label,
52
+ hits
53
+ }] : [];
54
+ });
55
+ return {
56
+ /** False when no resource can be searched; the top bar then shows no search box. */
57
+ available: targets.length > 0,
58
+ placeholder: searchPlaceholder(targets),
59
+ text,
60
+ setText,
61
+ /** The text the results belong to, once it is long enough to search. */
62
+ query: searching ? query : "",
63
+ loading: wanted && (typed !== query || results.some((result) => result.isFetching)),
64
+ groups
65
+ };
66
+ };
67
+ //#endregion
68
+ export { useGlobalSearch };
@@ -0,0 +1,65 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Input } from "../primitives/input.js";
3
+ import { Button } from "../primitives/button.js";
4
+ import { humanize } from "../live/naming.js";
5
+ import { displayValue } from "../live/display-value.js";
6
+ import { toDraft } from "../live/field-values.js";
7
+ import { ErrorBanner } from "./error-banner.js";
8
+ import { useState } from "react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ import { Eye, EyeOff } from "lucide-react";
11
+ import { useMutation } from "@tanstack/react-query";
12
+ //#region src/app/sensitive-field.tsx
13
+ const mask = "••••••••••";
14
+ /** A value hidden like a password. Showing a stored one asks the server for it, once per page visit. */
15
+ const SensitiveField = ({ field, hints, value, reveal, locked, invalid, placeholder, onChange }) => {
16
+ const [shown, setShown] = useState(false);
17
+ const stored = useMutation({
18
+ mutationFn: async () => reveal(),
19
+ onSuccess: () => setShown(true)
20
+ });
21
+ const typed = toDraft(value) !== "";
22
+ const label = hints?.label ?? humanize(field.name);
23
+ const toggle = () => {
24
+ if (shown || typed || !reveal || stored.isSuccess) setShown(!shown);
25
+ else stored.mutate();
26
+ };
27
+ const eye = /* @__PURE__ */ jsx(Button, {
28
+ variant: "ghost",
29
+ size: "sm",
30
+ className: "-mr-1.5 px-1.5",
31
+ "aria-label": `${shown ? "Hide" : "Show"} ${label}`,
32
+ "aria-pressed": shown,
33
+ loading: stored.isPending,
34
+ onClick: toggle,
35
+ children: !stored.isPending && (shown ? /* @__PURE__ */ jsx(EyeOff, { className: "size-3.5" }) : /* @__PURE__ */ jsx(Eye, { className: "size-3.5" }))
36
+ });
37
+ const error = stored.error && /* @__PURE__ */ jsx("div", {
38
+ className: "mt-2",
39
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
40
+ title: `Could not show the ${label}`,
41
+ error: stored.error
42
+ })
43
+ });
44
+ if (locked) return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
45
+ className: "flex items-center gap-2 py-1 text-[13px]",
46
+ children: [/* @__PURE__ */ jsx("span", {
47
+ className: cn(shown ? hints?.format === "code" ? "font-mono text-xs" : "" : "tracking-widest text-muted-foreground"),
48
+ children: shown ? displayValue(field, stored.data, hints) : mask
49
+ }), reveal && eye]
50
+ }), error] });
51
+ const text = typed ? toDraft(value) : shown && stored.isSuccess ? toDraft(stored.data) : "";
52
+ return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Input, {
53
+ type: shown ? "text" : "password",
54
+ autoComplete: "off",
55
+ invalid,
56
+ "aria-label": label,
57
+ className: cn(hints?.format === "code" && "font-mono text-xs"),
58
+ placeholder: typed || !reveal ? placeholder : mask,
59
+ value: text,
60
+ trailing: eye,
61
+ onChange: (event) => onChange(event.target.value)
62
+ }), error] });
63
+ };
64
+ //#endregion
65
+ export { SensitiveField };
@@ -0,0 +1,45 @@
1
+ import { recordKey } from "../live/model-helpers.js";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { encodeKey } from "@protobase/schema";
4
+ //#region src/app/use-lines-draft.ts
5
+ /**
6
+ * The order the person has given the lines of a record, kept in the form until Save writes it, together with the
7
+ * record, in one batch. `shown` is the lines in that order; `op()` is the batch operation that makes it permanent.
8
+ */
9
+ const useLinesDraft = (model, stored) => {
10
+ const [order, setOrder] = useState();
11
+ const ids = stored.map((line) => String(line.id));
12
+ const shown = useMemo(() => {
13
+ if (!order) return stored;
14
+ const known = order.flatMap((id) => stored.filter((line) => String(line.id) === id));
15
+ const added = stored.filter((line) => !order.includes(String(line.id)));
16
+ return [...known, ...added];
17
+ }, [order, stored]);
18
+ const shownIds = shown.map((line) => String(line.id));
19
+ const dirty = order !== void 0 && shownIds.some((id, index) => id !== ids[index]);
20
+ const op = useCallback(() => {
21
+ if (!model || !dirty) return [];
22
+ return [{
23
+ op: "reorder",
24
+ resource: model.name,
25
+ field: "position",
26
+ keys: shown.map((line) => recordKey(model, line)),
27
+ etags: Object.fromEntries(shown.flatMap((line) => typeof line.etag === "string" ? [[encodeKey(recordKey(model, line)), line.etag]] : []))
28
+ }];
29
+ }, [
30
+ model,
31
+ dirty,
32
+ shown
33
+ ]);
34
+ return {
35
+ shown,
36
+ dirty,
37
+ /** The ids in the order on screen, for keeping a draft. */
38
+ order: dirty ? shownIds : void 0,
39
+ setOrder: (next) => setOrder(next),
40
+ reset: () => setOrder(void 0),
41
+ op
42
+ };
43
+ };
44
+ //#endregion
45
+ export { useLinesDraft };
@@ -0,0 +1,19 @@
1
+ import { useList } from "../data/use-list.js";
2
+ //#region src/app/use-lines.ts
3
+ const byPosition = (a, b) => Number(a.position) - Number(b.position);
4
+ /** The numbered lines of a record, in position order, as the server has them. Nothing is fetched without a lines resource. */
5
+ const useLines = (lines, parent, parentKey) => {
6
+ const link = lines && Object.values(lines.fields).find((field) => field.type === "relation" && field.relation?.resource === parent.name);
7
+ const list = useList(lines?.name ?? "", {
8
+ enabled: Boolean(lines && link),
9
+ filter: link ? `${link.name} = "${parentKey}"` : void 0,
10
+ pageSize: 200,
11
+ ...lines?.fields.position?.sortable && { orderBy: "position asc" }
12
+ });
13
+ return {
14
+ items: [...list.items].sort(byPosition),
15
+ loading: list.isPending && Boolean(lines)
16
+ };
17
+ };
18
+ //#endregion
19
+ export { useLines };
@@ -0,0 +1,54 @@
1
+ import { useToast } from "../toasts/toast-provider.js";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { isApiError } from "@protobase/client";
4
+ //#region src/app/use-live-save.ts
5
+ const savedForMs = 2e3;
6
+ /**
7
+ * Shows the outcome of a save the way the view asks: a toast (spinner, then check) or the state of
8
+ * the Save button. API errors are reported and not rethrown; anything else propagates.
9
+ */
10
+ const useLiveSave = (mode, subject) => {
11
+ const toast = useToast();
12
+ const [phase, setPhase] = useState("idle");
13
+ const [message, setMessage] = useState();
14
+ const timer = useRef(void 0);
15
+ useEffect(() => () => clearTimeout(timer.current), []);
16
+ const run = async (action) => {
17
+ const id = mode === "toast" ? toast.show({
18
+ state: "loading",
19
+ title: `Saving ${subject}...`
20
+ }) : void 0;
21
+ setMessage(void 0);
22
+ if (mode === "button") setPhase("saving");
23
+ try {
24
+ await action();
25
+ if (id !== void 0) toast.update(id, {
26
+ state: "success",
27
+ title: `${subject} saved`
28
+ });
29
+ if (mode === "button") {
30
+ setPhase("saved");
31
+ timer.current = setTimeout(() => setPhase("idle"), savedForMs);
32
+ }
33
+ } catch (error) {
34
+ if (!isApiError(error)) throw error;
35
+ const title = error.status === 412 ? "Changed by someone else" : "Could not save";
36
+ if (id !== void 0) toast.update(id, {
37
+ state: "error",
38
+ title,
39
+ description: error.message
40
+ });
41
+ if (mode === "button") {
42
+ setPhase("idle");
43
+ setMessage(`${title}: ${error.message}`);
44
+ }
45
+ }
46
+ };
47
+ return {
48
+ phase,
49
+ message,
50
+ run
51
+ };
52
+ };
53
+ //#endregion
54
+ export { useLiveSave };
@@ -0,0 +1,41 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { userMenuResources } from "./user-menu-from-meta.js";
4
+ import { recentRecords, recentRequest } from "./nav-recent.js";
5
+ import { useQueries } from "@tanstack/react-query";
6
+ //#region src/app/use-nav-recent.ts
7
+ /**
8
+ * The records of every sidebar group (`nav.recent`), one list request each, under the `list` query key so that any
9
+ * write to the resource refreshes them. The server applies the caller's access, as on the list page.
10
+ */
11
+ const useNavRecent = (meta, basePath, active) => {
12
+ const client = useClient();
13
+ const inMenu = userMenuResources(meta);
14
+ const groups = Object.values(meta.views).flatMap((view) => {
15
+ const model = meta.resources[view.resource];
16
+ const recent = view.nav?.recent;
17
+ return model && recent && !view.nav?.hidden && !inMenu.has(model.name) ? [{
18
+ model,
19
+ view,
20
+ recent,
21
+ request: recentRequest(model, view, recent)
22
+ }] : [];
23
+ });
24
+ const results = useQueries({ queries: groups.map(({ model, request }) => ({
25
+ queryKey: keys.navRecent(model.name, request.filter, request.orderBy, request.pageSize),
26
+ queryFn: () => client.list(model.name, request),
27
+ refetchOnWindowFocus: true
28
+ })) });
29
+ return Object.fromEntries(groups.map(({ model, view, recent }, index) => {
30
+ const result = results[index];
31
+ if (result.isPending) return [model.name, { state: "loading" }];
32
+ if (result.isError) return [model.name, { state: "error" }];
33
+ const activeKey = active.resource === model.name ? active.key : void 0;
34
+ return [model.name, {
35
+ state: "ready",
36
+ records: recentRecords(model, view, recent, result.data.items, basePath, activeKey)
37
+ }];
38
+ }));
39
+ };
40
+ //#endregion
41
+ export { useNavRecent };
@@ -0,0 +1,154 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { differs, toDraft, toPatchValue } from "../live/field-values.js";
4
+ import { useLiveSave } from "./use-live-save.js";
5
+ import { readDraft, writeDraft } from "./draft-storage.js";
6
+ import { isConflict, useUpdateRecord } from "../data/use-update-record.js";
7
+ import { nameFailingOperation } from "./batch-failure.js";
8
+ import { useEffect, useState } from "react";
9
+ import { useQueryClient } from "@tanstack/react-query";
10
+ import { isApiError } from "@protobase/client";
11
+ //#region src/app/use-record-editor.ts
12
+ const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
13
+ /**
14
+ * Draft state for one record. Saving sends only the changed fields with the ETag that was read;
15
+ * a 412 refetches the record and marks the fields both sides changed so the user can pick a side.
16
+ * When the lines were reordered too, Save sends the record and the new order as one batch: all or nothing.
17
+ */
18
+ const useRecordEditor = ({ model, stored, recordKey, mode, subject, draftKey, lines }) => {
19
+ const client = useClient();
20
+ const queryClient = useQueryClient();
21
+ const update = useUpdateRecord(model.name, recordKey);
22
+ const feedback = useLiveSave(mode, subject);
23
+ const [restored] = useState(() => draftKey ? readDraft(localStorage, draftKey, stored.etag) : void 0);
24
+ const [draft, setDraft] = useState(restored?.fields ?? {});
25
+ const [showRestored, setShowRestored] = useState(Boolean(restored));
26
+ const [linesConflict, setLinesConflict] = useState(false);
27
+ const [conflicts, setConflicts] = useState({});
28
+ const [errors, setErrors] = useState({});
29
+ const fieldsDirty = Object.keys(draft).length > 0;
30
+ const dirty = fieldsDirty || Boolean(lines?.dirty);
31
+ useEffect(() => {
32
+ if (restored?.order) lines?.setOrder(restored.order);
33
+ }, []);
34
+ useEffect(() => {
35
+ if (!draftKey) return;
36
+ const kept = Object.fromEntries(Object.entries(draft).filter(([name]) => !model.fields[name]?.sensitive));
37
+ const keep = Object.keys(kept).length > 0 || Boolean(lines?.dirty);
38
+ writeDraft(localStorage, draftKey, keep ? {
39
+ etag: stored.etag,
40
+ fields: kept,
41
+ ...lines?.order && { order: lines.order }
42
+ } : void 0);
43
+ }, [
44
+ draftKey,
45
+ model,
46
+ draft,
47
+ lines?.dirty,
48
+ lines?.order,
49
+ stored.etag
50
+ ]);
51
+ const value = (name) => name in draft ? draft[name] : model.fields[name].type === "boolean" ? stored.record[name] : toDraft(stored.record[name]);
52
+ const change = (name, next) => {
53
+ setErrors((current) => without(current, name));
54
+ setDraft((current) => differs(model.fields[name], next, stored.record[name]) ? {
55
+ ...current,
56
+ [name]: next
57
+ } : without(current, name));
58
+ };
59
+ const adoptLatest = async () => {
60
+ const latest = await client.get(model.name, recordKey);
61
+ queryClient.setQueryData(keys.record(model.name, recordKey), latest);
62
+ const theirs = {};
63
+ const keep = {};
64
+ for (const [name, mine] of Object.entries(draft)) {
65
+ const field = model.fields[name];
66
+ if (!differs(field, mine, latest.record[name])) continue;
67
+ keep[name] = mine;
68
+ if (toDraft(latest.record[name]) !== toDraft(stored.record[name])) theirs[name] = toDraft(latest.record[name]);
69
+ }
70
+ setDraft(keep);
71
+ setConflicts(theirs);
72
+ };
73
+ const patchOf = () => Object.fromEntries(Object.entries(draft).map(([name, entry]) => [name, toPatchValue(model.fields[name], entry)]));
74
+ const fieldErrors = (error) => isApiError(error) ? Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])) : {};
75
+ const saveRecordOnly = async () => {
76
+ try {
77
+ await update.mutateAsync({
78
+ patch: patchOf(),
79
+ etag: stored.etag
80
+ });
81
+ } catch (error) {
82
+ if (isConflict(error)) await adoptLatest();
83
+ else if (isApiError(error)) setErrors(fieldErrors(error));
84
+ throw error;
85
+ }
86
+ };
87
+ const saveBatch = async (linesOps) => {
88
+ const ops = [...fieldsDirty ? [{
89
+ op: "update",
90
+ resource: model.name,
91
+ key: recordKey,
92
+ data: patchOf(),
93
+ etag: stored.etag
94
+ }] : [], ...linesOps];
95
+ try {
96
+ await client.batchWrite(ops);
97
+ } catch (error) {
98
+ if (isConflict(error)) {
99
+ await adoptLatest();
100
+ await queryClient.invalidateQueries({ queryKey: ["list"] });
101
+ setLinesConflict(true);
102
+ } else if (isApiError(error)) setErrors(fieldErrors(error));
103
+ throw isApiError(error) ? nameFailingOperation(error, ops) : error;
104
+ }
105
+ await Promise.all([
106
+ queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) }),
107
+ queryClient.invalidateQueries({ queryKey: ["list"] }),
108
+ queryClient.invalidateQueries({ queryKey: ["page"] })
109
+ ]);
110
+ lines?.reset();
111
+ };
112
+ const save = () => feedback.run(async () => {
113
+ const linesOps = lines?.op() ?? [];
114
+ if (linesOps.length > 0) await saveBatch(linesOps);
115
+ else await saveRecordOnly();
116
+ setDraft({});
117
+ setConflicts({});
118
+ setErrors({});
119
+ setLinesConflict(false);
120
+ setShowRestored(false);
121
+ });
122
+ const discard = () => {
123
+ setDraft({});
124
+ setConflicts({});
125
+ setErrors({});
126
+ setLinesConflict(false);
127
+ setShowRestored(false);
128
+ lines?.reset();
129
+ };
130
+ return {
131
+ value,
132
+ change,
133
+ save,
134
+ dirty,
135
+ discard,
136
+ /** The changes on screen were brought back from this browser. */
137
+ restored: showRestored && dirty,
138
+ dismissRestored: () => setShowRestored(false),
139
+ /** Someone changed the lines while the new order was being made. */
140
+ linesConflict,
141
+ saving: feedback.phase === "saving",
142
+ phase: feedback.phase,
143
+ message: feedback.message,
144
+ conflicts,
145
+ errors,
146
+ keepMine: (name) => setConflicts((current) => without(current, name)),
147
+ useTheirs: (name) => {
148
+ setConflicts((current) => without(current, name));
149
+ setDraft((current) => without(current, name));
150
+ }
151
+ };
152
+ };
153
+ //#endregion
154
+ export { useRecordEditor };