@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,88 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { useToast } from "../../toasts/toast-provider.js";
3
+ import { useRouter } from "../router.js";
4
+ import { useAdminMeta, usePermissions } from "../meta-gate.js";
5
+ import { can } from "../permissions.js";
6
+ import { fillHref, isAppPath } from "./action-href.js";
7
+ import { useProjectUi } from "./project-ui.js";
8
+ import { useRefreshResource } from "./use-layout-data.js";
9
+ import { useState } from "react";
10
+ //#region src/app/pages/use-run-action.ts
11
+ const message = (error) => error instanceof Error ? error.message : "Something went wrong";
12
+ /**
13
+ * The named actions of `resource`'s view, ready to run on `scope` (the record they act on): built-in behaviour first
14
+ * (`update`, `delete`, `link`), else the handler the project registered under the action's name. `blocked` says why one
15
+ * cannot run; `afterRun` hears about every action that succeeded.
16
+ */
17
+ const useActionRunner = (resource, scope, afterRun) => {
18
+ const { views } = useAdminMeta();
19
+ const { actions: handlers } = useProjectUi();
20
+ const permissions = usePermissions(resource);
21
+ const client = useClient();
22
+ const toast = useToast();
23
+ const refresh = useRefreshResource();
24
+ const { navigate } = useRouter();
25
+ const [running, setRunning] = useState();
26
+ const actions = views[resource]?.actions ?? [];
27
+ const blocked = (action) => {
28
+ const kind = action.run?.kind;
29
+ if ((kind === "update" || kind === "delete") && !scope) return "Needs a record";
30
+ if (kind === "update" && (!can(permissions, "update") || scope?.permissions?.update === false)) return "You cannot change this record";
31
+ if (kind === "delete" && (!can(permissions, "delete") || scope?.permissions?.delete === false)) return "You cannot delete this record";
32
+ if (!action.run && !handlers?.[action.name]) return `The app has no handler for "${action.name}"`;
33
+ };
34
+ const perform = async (action) => {
35
+ const { run } = action;
36
+ if (run?.kind === "link") {
37
+ const href = fillHref(run.href, scope?.record);
38
+ if (isAppPath(href)) navigate(href);
39
+ else window.location.assign(href);
40
+ return;
41
+ }
42
+ if (run?.kind === "update") await client.update(resource, scope.key, run.values, scope.etag ?? "*");
43
+ else if (run?.kind === "delete") await client.remove(resource, scope.key, { etag: scope.etag });
44
+ else await handlers[action.name]({
45
+ resource,
46
+ record: scope?.record,
47
+ recordKey: scope?.key,
48
+ etag: scope?.etag,
49
+ client,
50
+ navigate
51
+ });
52
+ await refresh(resource);
53
+ toast.show({
54
+ state: "success",
55
+ title: `${action.label}: done`
56
+ });
57
+ afterRun?.(action);
58
+ };
59
+ const run = async (action) => {
60
+ if (blocked(action)) return;
61
+ setRunning(action.name);
62
+ await perform(action).catch((error) => toast.show({
63
+ state: "error",
64
+ title: `${action.label} failed`,
65
+ description: message(error)
66
+ }));
67
+ setRunning(void 0);
68
+ };
69
+ return {
70
+ actions,
71
+ blocked,
72
+ running,
73
+ run
74
+ };
75
+ };
76
+ /** One named action of `resource`'s view, for a button. */
77
+ const useRunAction = (resource, name, scope) => {
78
+ const runner = useActionRunner(resource, scope);
79
+ const action = runner.actions.find((entry) => entry.name === name);
80
+ return {
81
+ action,
82
+ blocked: action ? runner.blocked(action) : `No action "${name}"`,
83
+ running: runner.running === name,
84
+ run: () => action ? runner.run(action) : Promise.resolve()
85
+ };
86
+ };
87
+ //#endregion
88
+ export { useActionRunner, useRunAction };
@@ -0,0 +1,15 @@
1
+ //#region src/app/permissions.ts
2
+ /**
3
+ * Whether to offer an operation: yes when allowed outright, and also when only some records qualify
4
+ * (`conditional`), because then the server decides per record and a 403 is reported when it says no.
5
+ */
6
+ const can = (permissions, operation) => permissions[operation] || permissions.conditional.includes(operation);
7
+ /** List rows carry `permissions: { update, delete }`; a row without them (the server was not asked) is allowed, and every write is checked again. */
8
+ const rowAllows = (row, operation) => row.permissions?.[operation] ?? true;
9
+ /** One field on one record: `edit`, `read`, or `hidden` (absent from a get response's `permissions.fields`). Without that map the model decides. */
10
+ const fieldAccess = (permissions, name) => {
11
+ if (!permissions?.fields) return "edit";
12
+ return permissions.fields[name] ?? "hidden";
13
+ };
14
+ //#endregion
15
+ export { can, fieldAccess, rowAllows };
@@ -0,0 +1,39 @@
1
+ import { usePermissions } from "./meta-gate.js";
2
+ import { can } from "./permissions.js";
3
+ import { InlineChildTable } from "../inline-child-table/inline-child-table.js";
4
+ import { Section } from "../record-view/section.js";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/app/record-lines.tsx
7
+ /** A resource that holds numbered lines of this one: it points at the parent and has `position` and `quantity`. */
8
+ const findLinesResource = (resources, parent) => Object.values(resources).find((candidate) => candidate.name !== parent.name && candidate.fields.position && candidate.fields.quantity && Object.values(candidate.fields).some((field) => field.type === "relation" && field.relation?.resource === parent.name));
9
+ const toLine = (record) => ({
10
+ id: String(record.id),
11
+ productId: String(record.productId ?? ""),
12
+ description: String(record.description ?? ""),
13
+ quantity: Number(record.quantity),
14
+ unitPriceCents: Math.round(Number(record.unitPrice) * 100)
15
+ });
16
+ /** The lines of a record. Reordering only changes `draft`; the record's Save writes it, in one batch. */
17
+ const RecordLines = ({ lines, draft, vatRate, saving }) => {
18
+ const canReorder = can(usePermissions(lines.name), "update");
19
+ return /* @__PURE__ */ jsx(Section, {
20
+ title: "Lines",
21
+ help: "What the document bills for. Drag the handle, or use the menu, to change the order; Save applies it.",
22
+ children: /* @__PURE__ */ jsx("div", {
23
+ className: "-mx-4 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch] md:mx-0 md:px-0",
24
+ children: /* @__PURE__ */ jsx("div", {
25
+ className: "min-w-[560px]",
26
+ children: /* @__PURE__ */ jsx(InlineChildTable, {
27
+ lines: draft.shown.map(toLine),
28
+ onChange: () => void 0,
29
+ onReorder: (next) => draft.setOrder(next.map((line) => line.id)),
30
+ vatRate,
31
+ readOnly: true,
32
+ reorderable: canReorder && !saving
33
+ })
34
+ })
35
+ })
36
+ });
37
+ };
38
+ //#endregion
39
+ export { RecordLines, findLinesResource };
@@ -0,0 +1,318 @@
1
+ import { Badge } from "../primitives/badge.js";
2
+ import { keys } from "../data/query-keys.js";
3
+ import { useClient } from "../data/api-provider.js";
4
+ import { useAuth } from "../auth/auth-provider.js";
5
+ import { Button } from "../primitives/button.js";
6
+ import { useRecord } from "../data/use-record.js";
7
+ import { recordTitle } from "../live/model-helpers.js";
8
+ import { humanize } from "../live/naming.js";
9
+ import { useNavigationGuard, useRouter } from "./router.js";
10
+ import { formatRelativeTime } from "../format/date.js";
11
+ import { displayValue } from "../live/display-value.js";
12
+ import { ErrorBanner } from "./error-banner.js";
13
+ import { FieldEditor } from "./field-editor.js";
14
+ import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
15
+ import { can, fieldAccess } from "./permissions.js";
16
+ import { Field } from "../record-view/field.js";
17
+ import { Section } from "../record-view/section.js";
18
+ import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
19
+ import { RecordHeader } from "../record-view/record-header.js";
20
+ import { toneFor } from "../live/columns-from-model.js";
21
+ import { useDeleteFlow } from "./delete/use-delete-flow.js";
22
+ import { useRestore } from "./delete/use-restore.js";
23
+ import { RecordActions } from "./pages/record-actions.js";
24
+ import { RecordLines, findLinesResource } from "./record-lines.js";
25
+ import { draftStorageKey } from "./draft-storage.js";
26
+ import { useLines } from "./use-lines.js";
27
+ import { useLinesDraft } from "./use-lines-draft.js";
28
+ import { useRecordEditor } from "./use-record-editor.js";
29
+ import { useRelatedRecords } from "./use-related-records.js";
30
+ import { RelatedSection } from "./related-section.js";
31
+ import { useMemo } from "react";
32
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
33
+ import { Check } from "lucide-react";
34
+ import { useQueryClient } from "@tanstack/react-query";
35
+ import { isApiError } from "@protobase/client";
36
+ import { enumLabel } from "@protobase/schema";
37
+ //#region src/app/record-page.tsx
38
+ const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
39
+ /**
40
+ * The view's layout, or one section with the editable fields when the view has none. A user who may only read the
41
+ * resource has every field read-only, so they get every field, locked, rather than an empty section.
42
+ */
43
+ const layoutFor = (model, view) => {
44
+ if (view && view.layout.length > 0) return view.layout;
45
+ const shown = Object.values(model.fields).filter((field) => !hidden(model, field.name));
46
+ const editable = shown.filter((field) => !field.readOnly);
47
+ return [{
48
+ kind: "section",
49
+ title: "Details",
50
+ fields: (editable.length > 0 ? editable : shown).map((field) => field.name)
51
+ }];
52
+ };
53
+ const RecordPage = ({ resource, recordKey }) => {
54
+ const { resources } = useAdminMeta();
55
+ const model = resources[resource];
56
+ const record = useRecord(resource, recordKey);
57
+ if (!model) return /* @__PURE__ */ jsxs(Notice, {
58
+ title: "Not found",
59
+ children: [
60
+ "There is no resource called ",
61
+ resource,
62
+ "."
63
+ ]
64
+ });
65
+ if (record.isPending) return /* @__PURE__ */ jsx(Notice, {
66
+ title: "Loading",
67
+ children: "Fetching the record."
68
+ });
69
+ if (record.error) return /* @__PURE__ */ jsx("div", {
70
+ className: "p-8",
71
+ children: isApiError(record.error) && record.error.status === 404 ? /* @__PURE__ */ jsx(Notice, {
72
+ title: "Not found",
73
+ children: "This record does not exist, or you cannot see it."
74
+ }) : /* @__PURE__ */ jsx(ErrorBanner, {
75
+ title: "Could not load the record",
76
+ error: record.error
77
+ })
78
+ });
79
+ return /* @__PURE__ */ jsx(LoadedRecord, {
80
+ model,
81
+ recordKey,
82
+ stored: record.data
83
+ });
84
+ };
85
+ const LoadedRecord = ({ model, recordKey, stored }) => {
86
+ const meta = useAdminMeta();
87
+ const { basePath, navigate } = useRouter();
88
+ const view = meta.views[model.name];
89
+ const singular = view?.names?.singular ?? humanize(model.name);
90
+ const title = recordTitle(model, view, stored.record, recordKey);
91
+ const { state: auth } = useAuth();
92
+ const linesModel = useMemo(() => findLinesResource(meta.resources, model), [meta.resources, model]);
93
+ const linesData = useLines(linesModel, model, recordKey);
94
+ const linesDraft = useLinesDraft(linesModel, linesData.items);
95
+ const editor = useRecordEditor({
96
+ model,
97
+ stored,
98
+ recordKey,
99
+ mode: view?.saveFeedback ?? "toast",
100
+ subject: title,
101
+ draftKey: auth.kind === "signed-in" ? draftStorageKey(auth.user.id, model.name, recordKey) : void 0,
102
+ lines: linesDraft
103
+ });
104
+ useNavigationGuard(editor.dirty);
105
+ const queryClient = useQueryClient();
106
+ const client = useClient();
107
+ const permissions = usePermissions(model.name);
108
+ const restore = useRestore(model);
109
+ const canUpdate = can(permissions, "update") && stored.permissions?.update !== false;
110
+ const remove = useDeleteFlow({
111
+ model,
112
+ view,
113
+ restore,
114
+ onDeleted: () => navigate(`/${model.name}`, { replace: true }),
115
+ onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
116
+ });
117
+ const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
118
+ const layout = layoutFor(model, view);
119
+ const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
120
+ const status = model.fields.status?.type === "enum" ? String(stored.record.status) : void 0;
121
+ const updated = stored.record.updatedAt ? Math.max(0, Math.round((Date.now() - Date.parse(String(stored.record.updatedAt))) / 1e3)) : void 0;
122
+ const hint = (name) => view?.fields[name];
123
+ const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
124
+ const fieldView = (name) => {
125
+ const field = model.fields[name];
126
+ const access = fieldAccess(stored.permissions, name);
127
+ if (!field || access === "hidden") return null;
128
+ const conflict = editor.conflicts[name];
129
+ return /* @__PURE__ */ jsx(Field, {
130
+ label: hint(name)?.label ?? humanize(name),
131
+ help: hint(name)?.help,
132
+ error: editor.errors[name],
133
+ conflict: conflict === void 0 ? void 0 : {
134
+ user: {
135
+ name: "Someone else",
136
+ initials: "?",
137
+ hue: 40
138
+ },
139
+ ago: "just now",
140
+ theirValue: conflict || "—",
141
+ onKeepMine: () => editor.keepMine(name),
142
+ onUseTheirs: () => editor.useTheirs(name)
143
+ },
144
+ children: /* @__PURE__ */ jsx(FieldEditor, {
145
+ field,
146
+ hints: hint(name),
147
+ value: editor.value(name),
148
+ stored: stored.record[name],
149
+ relationLabel: related.labels[name],
150
+ relationHref: related.hrefs[name],
151
+ locked: !canUpdate || access === "read",
152
+ invalid: Boolean(editor.errors[name]),
153
+ reveal: field.sensitive ? () => client.reveal(model.name, recordKey, name) : void 0,
154
+ onChange: (next) => editor.change(name, next)
155
+ })
156
+ }, field.sensitive ? `${name}:${stored.etag}` : name);
157
+ };
158
+ const saveControls = /* @__PURE__ */ jsxs(Fragment$1, { children: [
159
+ editor.dirty && /* @__PURE__ */ jsx("span", {
160
+ className: "text-xs text-muted-foreground",
161
+ children: "Unsaved changes"
162
+ }),
163
+ editor.dirty && canUpdate && /* @__PURE__ */ jsx(Button, {
164
+ className: "max-md:min-h-11",
165
+ disabled: editor.saving,
166
+ onClick: editor.discard,
167
+ children: "Discard"
168
+ }),
169
+ can(permissions, "delete") && stored.permissions?.delete !== false && /* @__PURE__ */ jsx(Button, {
170
+ variant: "danger",
171
+ className: "max-md:min-h-11",
172
+ onClick: () => void remove.start({
173
+ key: recordKey,
174
+ title,
175
+ etag: stored.etag,
176
+ snapshot: stored.record
177
+ }),
178
+ children: "Delete"
179
+ }),
180
+ canUpdate && /* @__PURE__ */ jsxs(Button, {
181
+ variant: "primary",
182
+ className: "max-md:min-h-11 max-md:flex-1",
183
+ loading: editor.saving,
184
+ disabled: !editor.dirty && editor.phase !== "saved",
185
+ onClick: () => void editor.save(),
186
+ children: [editor.phase === "saved" && /* @__PURE__ */ jsx(Check, { className: "size-4" }), editor.phase === "saved" ? "Saved" : "Save"]
187
+ })
188
+ ] });
189
+ return /* @__PURE__ */ jsxs("div", {
190
+ className: "min-h-0 flex-1 overflow-y-auto",
191
+ children: [
192
+ remove.dialogs,
193
+ /* @__PURE__ */ jsxs("div", {
194
+ className: "mx-auto max-w-[1100px] px-4 pb-28 pt-6 md:px-10 md:py-8",
195
+ children: [
196
+ /* @__PURE__ */ jsx(RecordHeader, {
197
+ collection: view?.names?.plural ?? humanize(model.name),
198
+ title,
199
+ status: {
200
+ label: status ? enumLabel(status, hint("status")?.valueLabels) : singular,
201
+ tone: status ? toneFor(status) : "neutral"
202
+ },
203
+ lastEditedBy: { ago: updated === void 0 ? "recently" : formatRelativeTime(updated) },
204
+ actions: /* @__PURE__ */ jsx("div", {
205
+ className: "hidden items-center gap-4 md:flex",
206
+ children: saveControls
207
+ })
208
+ }),
209
+ editor.message && /* @__PURE__ */ jsx("div", {
210
+ className: "mt-4",
211
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
212
+ title: "Not saved",
213
+ error: new Error(editor.message)
214
+ })
215
+ }),
216
+ editor.linesConflict && /* @__PURE__ */ jsx("div", {
217
+ className: "mt-4",
218
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
219
+ title: "The lines were changed by someone else",
220
+ error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it, or Discard to drop it.")
221
+ })
222
+ }),
223
+ editor.restored && /* @__PURE__ */ jsxs("div", {
224
+ className: "mt-4 flex items-center justify-between gap-3 rounded-lg border bg-surface px-3 py-2 text-[13px]",
225
+ role: "status",
226
+ children: [/* @__PURE__ */ jsx("span", { children: "Your unsaved changes were restored." }), /* @__PURE__ */ jsxs("span", {
227
+ className: "flex gap-2",
228
+ children: [/* @__PURE__ */ jsx(Button, {
229
+ size: "sm",
230
+ onClick: editor.dismissRestored,
231
+ children: "Keep"
232
+ }), /* @__PURE__ */ jsx(Button, {
233
+ size: "sm",
234
+ onClick: editor.discard,
235
+ children: "Discard"
236
+ })]
237
+ })]
238
+ }),
239
+ /* @__PURE__ */ jsxs("div", {
240
+ className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
241
+ children: [/* @__PURE__ */ jsxs("main", {
242
+ className: "divide-y divide-border",
243
+ children: [layout.flatMap((item) => {
244
+ if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
245
+ item,
246
+ recordKey
247
+ }, `related:${item.title}`)];
248
+ if (item.kind !== "section") return [];
249
+ const fields = item.fields.filter((name) => model.fields[name] && visible(name));
250
+ if (fields.length === 0) return [];
251
+ return [/* @__PURE__ */ jsx(Section, {
252
+ title: item.title,
253
+ help: item.help,
254
+ children: /* @__PURE__ */ jsx("div", {
255
+ className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
256
+ children: fields.map(fieldView)
257
+ })
258
+ }, item.title)];
259
+ }), linesModel && /* @__PURE__ */ jsx(RecordLines, {
260
+ lines: linesModel,
261
+ draft: linesDraft,
262
+ vatRate: Number(stored.record.vatRate ?? 21) / 100,
263
+ saving: editor.saving
264
+ })]
265
+ }), /* @__PURE__ */ jsxs("aside", {
266
+ className: "space-y-4 md:pt-8",
267
+ children: [
268
+ /* @__PURE__ */ jsx(RecordActions, {
269
+ model,
270
+ stored,
271
+ recordKey
272
+ }),
273
+ /* @__PURE__ */ jsx(MetadataCard, {
274
+ title: "Summary",
275
+ rows: sidebarFields.flatMap((name) => {
276
+ const field = model.fields[name];
277
+ if (!field || !visible(name)) return [];
278
+ const text = displayValue(field, stored.record[name], hint(name));
279
+ return [{
280
+ label: hint(name)?.label ?? humanize(name),
281
+ value: field.type === "enum" ? /* @__PURE__ */ jsx(Badge, {
282
+ tone: toneFor(String(stored.record[name])),
283
+ children: text
284
+ }) : /* @__PURE__ */ jsx("strong", { children: text })
285
+ }];
286
+ })
287
+ }),
288
+ /* @__PURE__ */ jsx(MetadataCard, {
289
+ title: "Details",
290
+ rows: ["createdAt", "updatedAt"].flatMap((name) => {
291
+ const field = model.fields[name];
292
+ return field && visible(name) ? [{
293
+ label: hint(name)?.label ?? humanize(name),
294
+ value: displayValue(field, stored.record[name], hint(name))
295
+ }] : [];
296
+ })
297
+ }),
298
+ related.records.length > 0 && /* @__PURE__ */ jsx(SidebarCard, {
299
+ title: "Related records",
300
+ children: /* @__PURE__ */ jsx(RelatedRecords, {
301
+ records: related.records,
302
+ onNavigate: (href) => navigate(href)
303
+ })
304
+ })
305
+ ]
306
+ })]
307
+ })
308
+ ]
309
+ }),
310
+ /* @__PURE__ */ jsx("div", {
311
+ className: "fixed inset-x-0 bottom-0 z-30 flex items-center gap-2 border-t bg-background p-3 md:hidden",
312
+ children: saveControls
313
+ })
314
+ ]
315
+ });
316
+ };
317
+ //#endregion
318
+ export { RecordPage };
@@ -0,0 +1,126 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { ErrorBanner } from "./error-banner.js";
4
+ import { useAdminMeta } from "./meta-gate.js";
5
+ import { Section } from "../record-view/section.js";
6
+ import { Skeleton } from "../primitives/skeleton.js";
7
+ import { RecordTable } from "./pages/record-table.js";
8
+ import { defaultColumns } from "./pages/blocks/table.js";
9
+ import { useState } from "react";
10
+ import { jsx } from "react/jsx-runtime";
11
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
12
+ import { createWhere, printFilter } from "@protobase/schema";
13
+ //#region src/app/related-section.tsx
14
+ const where = createWhere();
15
+ /** The most join records a section reads to find its records; the list API's largest page. */
16
+ const maxThrough = 500;
17
+ const and = (...parts) => parts.filter((part) => Boolean(part)).map((part) => `(${part})`).join(" AND ");
18
+ /**
19
+ * The filter on the section's resource: its `field` is this record's key, or, through a join resource, one of the keys
20
+ * the join records that point at this record hold. `null` when no join record does, so nothing can match.
21
+ */
22
+ const useMatch = (item, recordKey) => {
23
+ const client = useClient();
24
+ const { resources } = useAdminMeta();
25
+ const through = item.through;
26
+ const via = through ? resources[through.resource] : void 0;
27
+ const key = through?.key ?? via?.primaryKey[0];
28
+ const viaFilter = through ? and(printFilter(where.eq(through.field, recordKey)), through.filter) : "";
29
+ const collected = useQuery({
30
+ queryKey: keys.list(via?.name ?? "", viaFilter, "", maxThrough, `related:${key}`),
31
+ enabled: Boolean(via && key),
32
+ queryFn: async () => {
33
+ const page = await client.search(via.name, {
34
+ filter: viaFilter,
35
+ fields: [key],
36
+ pageSize: maxThrough
37
+ });
38
+ return [...new Set(page.items.flatMap((row) => row[key] === null || row[key] === void 0 ? [] : [String(row[key])]))];
39
+ }
40
+ });
41
+ if (!through) return {
42
+ filter: and(printFilter(where.eq(item.field, recordKey)), item.filter),
43
+ isPending: false,
44
+ error: null
45
+ };
46
+ if (!collected.data) return {
47
+ filter: void 0,
48
+ isPending: collected.isPending,
49
+ error: collected.error
50
+ };
51
+ return {
52
+ filter: collected.data.length === 0 ? null : and(printFilter(where.in(item.field, collected.data)), item.filter),
53
+ isPending: false,
54
+ error: null
55
+ };
56
+ };
57
+ const RelatedRows = ({ item, model, filter }) => {
58
+ const client = useClient();
59
+ const { views } = useAdminMeta();
60
+ const [tokens, setTokens] = useState([""]);
61
+ const pageSize = item.pageSize ?? 10;
62
+ const sort = item.sort ?? "";
63
+ const token = tokens.at(-1) ?? "";
64
+ const page = useQuery({
65
+ queryKey: keys.list(model.name, filter, sort, pageSize, token),
66
+ placeholderData: keepPreviousData,
67
+ queryFn: () => client.search(model.name, {
68
+ filter,
69
+ orderBy: sort || void 0,
70
+ pageSize,
71
+ pageToken: token || void 0
72
+ })
73
+ });
74
+ if (page.isPending) return /* @__PURE__ */ jsx(Loading, {});
75
+ if (page.error) return /* @__PURE__ */ jsx(ErrorBanner, {
76
+ title: "Could not load",
77
+ error: page.error
78
+ });
79
+ const rows = page.data.items;
80
+ if (rows.length === 0 && tokens.length === 1) return /* @__PURE__ */ jsx(Empty, { text: item.empty });
81
+ const columns = (item.columns ?? defaultColumns(model, views[model.name]).filter((name) => name !== item.field)).filter((name) => name.includes(".") || Object.hasOwn(model.fields, name));
82
+ const next = page.data.nextPageToken;
83
+ return /* @__PURE__ */ jsx(RecordTable, {
84
+ model,
85
+ columns,
86
+ rows,
87
+ label: item.title,
88
+ pager: {
89
+ page: tokens.length,
90
+ hasPrevious: tokens.length > 1,
91
+ hasNext: Boolean(next),
92
+ busy: page.isFetching,
93
+ previous: () => setTokens((stack) => stack.slice(0, -1)),
94
+ next: () => next && setTokens((stack) => [...stack, next])
95
+ }
96
+ });
97
+ };
98
+ const Loading = () => /* @__PURE__ */ jsx("div", {
99
+ className: "flex flex-col gap-2",
100
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
101
+ });
102
+ const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
103
+ className: "text-[13px] text-muted-foreground",
104
+ children: text ?? "Nothing here yet."
105
+ });
106
+ /** A record page section listing the records of another resource that point at this record, directly or through a join resource. */
107
+ const RelatedSection = ({ item, recordKey }) => {
108
+ const { resources } = useAdminMeta();
109
+ const model = resources[item.resource];
110
+ const match = useMatch(item, recordKey);
111
+ if (!model) return null;
112
+ return /* @__PURE__ */ jsx(Section, {
113
+ title: item.title,
114
+ help: item.help,
115
+ children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
116
+ title: "Could not load",
117
+ error: match.error
118
+ }) : match.filter === null || match.filter === void 0 ? /* @__PURE__ */ jsx(Empty, { text: item.empty }) : /* @__PURE__ */ jsx(RelatedRows, {
119
+ item,
120
+ model,
121
+ filter: match.filter
122
+ }, match.filter)
123
+ });
124
+ };
125
+ //#endregion
126
+ export { RelatedSection };
@@ -0,0 +1,69 @@
1
+ import { Input } from "../primitives/input.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { labelFieldOf } from "../live/model-helpers.js";
4
+ import { useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { useQuery } from "@tanstack/react-query";
7
+ import { createWhere, printFilter } from "@protobase/schema";
8
+ //#region src/app/relation-picker.tsx
9
+ const where = createWhere();
10
+ /** Picks a record of another resource: type to search (when the target has search fields), choose from the first matches. */
11
+ const RelationPicker = ({ target, view, value, label, invalid, onChange }) => {
12
+ const client = useClient();
13
+ const [text, setText] = useState("");
14
+ const [open, setOpen] = useState(false);
15
+ const keyField = target.primaryKey[0];
16
+ const labelField = labelFieldOf(target, view);
17
+ const options = useQuery({
18
+ queryKey: [
19
+ "relation-options",
20
+ target.name,
21
+ text
22
+ ],
23
+ enabled: open,
24
+ queryFn: () => client.list(target.name, {
25
+ filter: text && target.search?.length ? printFilter(where.search(text)) : void 0,
26
+ fields: [keyField, labelField],
27
+ pageSize: 8
28
+ })
29
+ });
30
+ return /* @__PURE__ */ jsxs("div", {
31
+ className: "relative",
32
+ children: [/* @__PURE__ */ jsx(Input, {
33
+ invalid,
34
+ placeholder: `Search ${target.name}`,
35
+ value: open ? text : label ?? value,
36
+ onFocus: () => setOpen(true),
37
+ onBlur: () => setTimeout(() => setOpen(false), 120),
38
+ onChange: (event) => setText(event.target.value)
39
+ }), open && /* @__PURE__ */ jsxs("ul", {
40
+ role: "listbox",
41
+ className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 text-[13px] shadow-pop",
42
+ children: [
43
+ options.data?.items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
44
+ type: "button",
45
+ role: "option",
46
+ "aria-selected": String(item[keyField]) === value,
47
+ className: "block w-full truncate px-3 py-1.5 text-left hover:bg-muted",
48
+ onMouseDown: (event) => event.preventDefault(),
49
+ onClick: () => {
50
+ onChange(String(item[keyField]), String(item[labelField]));
51
+ setText("");
52
+ setOpen(false);
53
+ },
54
+ children: String(item[labelField])
55
+ }) }, String(item[keyField]))),
56
+ options.data && options.data.items.length === 0 && /* @__PURE__ */ jsx("li", {
57
+ className: "px-3 py-1.5 text-muted-foreground",
58
+ children: "No matches"
59
+ }),
60
+ options.isPending && /* @__PURE__ */ jsx("li", {
61
+ className: "px-3 py-1.5 text-muted-foreground",
62
+ children: "Loading"
63
+ })
64
+ ]
65
+ })]
66
+ });
67
+ };
68
+ //#endregion
69
+ export { RelationPicker };