@protobase/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) 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 +123 -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 +30 -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/assistant-dock/assistant-dock.d.ts +15 -0
  104. package/dist/assistant-dock/assistant-dock.js +81 -0
  105. package/dist/assistant-dock/chat-message.js +9 -0
  106. package/dist/assistant-dock/composer.js +39 -0
  107. package/dist/assistant-dock/dock-item.d.ts +4 -0
  108. package/dist/assistant-dock/dock-item.js +37 -0
  109. package/dist/assistant-dock/model.d.ts +84 -0
  110. package/dist/assistant-dock/model.js +26 -0
  111. package/dist/assistant-dock/plan-card.d.ts +13 -0
  112. package/dist/assistant-dock/plan-card.js +133 -0
  113. package/dist/assistant-dock/progress-card.js +18 -0
  114. package/dist/assistant-dock/query-card.js +52 -0
  115. package/dist/assistant-dock/queue-card.js +31 -0
  116. package/dist/assistant-dock/result-card.js +77 -0
  117. package/dist/assistant-panel/content.d.ts +7 -0
  118. package/dist/assistant-panel/step-list.js +30 -0
  119. package/dist/auth/auth-gate.js +70 -0
  120. package/dist/auth/auth-layout.js +29 -0
  121. package/dist/auth/auth-provider.js +71 -0
  122. package/dist/auth/first-run-page.js +51 -0
  123. package/dist/auth/forgot-password-form.js +75 -0
  124. package/dist/auth/forgot-password-page.js +35 -0
  125. package/dist/auth/reset-link.js +29 -0
  126. package/dist/auth/reset-password-form.js +88 -0
  127. package/dist/auth/reset-password-page.js +43 -0
  128. package/dist/auth/sign-in-form.js +90 -0
  129. package/dist/auth/sign-in-page.js +39 -0
  130. package/dist/auth/user-to-shell.js +18 -0
  131. package/dist/data/api-provider.d.ts +54 -0
  132. package/dist/data/api-provider.js +53 -0
  133. package/dist/data/meta-data.d.ts +14 -0
  134. package/dist/data/meta-data.js +9 -0
  135. package/dist/data/query-keys.js +95 -0
  136. package/dist/data/use-aggregates.d.ts +8 -0
  137. package/dist/data/use-aggregates.js +43 -0
  138. package/dist/data/use-list.d.ts +231 -0
  139. package/dist/data/use-list.js +31 -0
  140. package/dist/data/use-meta.d.ts +8 -0
  141. package/dist/data/use-meta.js +35 -0
  142. package/dist/data/use-record.d.ts +6 -0
  143. package/dist/data/use-record.js +16 -0
  144. package/dist/data/use-update-record.d.ts +15 -0
  145. package/dist/data/use-update-record.js +26 -0
  146. package/dist/data-grid/cells.js +85 -0
  147. package/dist/data-grid/column-spec.js +4 -0
  148. package/dist/data-grid/columns.js +16 -0
  149. package/dist/data-grid/data-grid.js +238 -0
  150. package/dist/data-grid/geometry.js +32 -0
  151. package/dist/data-grid/grid-footer.js +33 -0
  152. package/dist/data-grid/grid-header.js +69 -0
  153. package/dist/data-grid/grid-row.js +83 -0
  154. package/dist/data-grid/grid-template.js +12 -0
  155. package/dist/data-grid/header-menu.js +52 -0
  156. package/dist/data-grid/page-cache.js +27 -0
  157. package/dist/data-grid/pinned-storage.js +13 -0
  158. package/dist/data-grid/row-menu.js +88 -0
  159. package/dist/data-grid/use-grid-scroll.js +80 -0
  160. package/dist/data-grid/use-horizontal-scroll.js +38 -0
  161. package/dist/data-grid/use-paged-rows.js +50 -0
  162. package/dist/data-grid/use-pinned-columns.js +16 -0
  163. package/dist/filter-panel/active-filter-chips.js +34 -0
  164. package/dist/filter-panel/date-presets.js +20 -0
  165. package/dist/filter-panel/facet-group.js +48 -0
  166. package/dist/filter-panel/filter-config.js +18 -0
  167. package/dist/filter-panel/filter-menu.js +41 -0
  168. package/dist/filter-panel/filter-panel.js +112 -0
  169. package/dist/filter-panel/filter-section.js +11 -0
  170. package/dist/filter-panel/filter-state.js +63 -0
  171. package/dist/filter-panel/range-filter.js +54 -0
  172. package/dist/filter-panel/range-math.js +11 -0
  173. package/dist/filter-panel/range-slider.css +32 -0
  174. package/dist/filter-panel/toggle-row.js +13 -0
  175. package/dist/filter-panel/use-filter-state.js +15 -0
  176. package/dist/format/date.js +37 -0
  177. package/dist/format/money.js +15 -0
  178. package/dist/format/number.js +12 -0
  179. package/dist/index.d.ts +14 -0
  180. package/dist/index.js +11 -0
  181. package/dist/inline-child-table/columns.js +4 -0
  182. package/dist/inline-child-table/inline-child-table.js +120 -0
  183. package/dist/inline-child-table/line-menu.js +54 -0
  184. package/dist/inline-child-table/line-row.js +91 -0
  185. package/dist/inline-child-table/number-cell.js +25 -0
  186. package/dist/inline-child-table/reorder.js +10 -0
  187. package/dist/inline-child-table/totals-footer.js +42 -0
  188. package/dist/lib/cn.js +4 -0
  189. package/dist/lib/guarded.js +12 -0
  190. package/dist/lib/use-debounced.js +13 -0
  191. package/dist/lib/use-focus-trap.js +46 -0
  192. package/dist/lib/use-media-query.js +12 -0
  193. package/dist/live/columns-from-model.js +93 -0
  194. package/dist/live/display-value.js +24 -0
  195. package/dist/live/field-values.js +32 -0
  196. package/dist/live/filter-string.js +135 -0
  197. package/dist/live/grid-loader.js +63 -0
  198. package/dist/live/model-helpers.js +30 -0
  199. package/dist/live/naming.js +9 -0
  200. package/dist/live/relation-labels.js +53 -0
  201. package/dist/live/use-filter-config.js +88 -0
  202. package/dist/live/use-live-rows.js +45 -0
  203. package/dist/live/view-fields.js +35 -0
  204. package/dist/mocks/activity.js +24 -0
  205. package/dist/mocks/invoices.js +13 -0
  206. package/dist/mocks/products.js +167 -0
  207. package/dist/mocks/rng.js +20 -0
  208. package/dist/mocks/users.js +63 -0
  209. package/dist/primitives/avatar.js +22 -0
  210. package/dist/primitives/badge.d.ts +1 -0
  211. package/dist/primitives/badge.js +25 -0
  212. package/dist/primitives/button.js +23 -0
  213. package/dist/primitives/checkbox.js +21 -0
  214. package/dist/primitives/dialog.js +41 -0
  215. package/dist/primitives/input.js +16 -0
  216. package/dist/primitives/kbd.js +8 -0
  217. package/dist/primitives/segmented.js +14 -0
  218. package/dist/primitives/sheet.js +44 -0
  219. package/dist/primitives/skeleton.js +6 -0
  220. package/dist/primitives/spinner.js +10 -0
  221. package/dist/primitives/switch.js +14 -0
  222. package/dist/primitives/tooltip.js +13 -0
  223. package/dist/record-view/action-rail.js +28 -0
  224. package/dist/record-view/field.js +62 -0
  225. package/dist/record-view/record-header.js +50 -0
  226. package/dist/record-view/record-sidebar.js +58 -0
  227. package/dist/record-view/section.js +21 -0
  228. package/dist/styles.css +132 -0
  229. package/dist/toasts/toast-provider.js +69 -0
  230. package/dist/toasts/toast.js +45 -0
  231. package/package.json +45 -3
  232. package/README.md +0 -3
@@ -0,0 +1,118 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { recordKey } from "../../live/model-helpers.js";
3
+ import { humanize } from "../../live/naming.js";
4
+ import { useRouter } from "../router.js";
5
+ import { FieldEditor } from "../field-editor.js";
6
+ import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
7
+ import { can } from "../permissions.js";
8
+ import { useLiveSave } from "../use-live-save.js";
9
+ import { CreateRecordForm } from "./create-record-form.js";
10
+ import { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isRequired } from "./create-values.js";
11
+ import { useState } from "react";
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ import { useQueryClient } from "@tanstack/react-query";
14
+ import { isApiError } from "@protobase/client";
15
+ import { encodeKey } from "@protobase/schema";
16
+ //#region src/app/create/create-page.tsx
17
+ const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
18
+ /** `/:resource/new`: a form from the view's layout; on success it opens the new record. */
19
+ const CreatePage = ({ resource }) => {
20
+ const meta = useAdminMeta();
21
+ const model = meta.resources[resource];
22
+ const permissions = usePermissions(resource);
23
+ if (!model) return /* @__PURE__ */ jsxs(Notice, {
24
+ title: "Not found",
25
+ children: [
26
+ "There is no resource called ",
27
+ resource,
28
+ "."
29
+ ]
30
+ });
31
+ if (!can(permissions, "create")) return /* @__PURE__ */ jsxs(Notice, {
32
+ title: "Not allowed",
33
+ children: [
34
+ "You cannot create ",
35
+ meta.views[resource]?.names?.plural.toLowerCase() ?? resource,
36
+ "."
37
+ ]
38
+ });
39
+ return /* @__PURE__ */ jsx(LoadedCreate, { resource });
40
+ };
41
+ const LoadedCreate = ({ resource }) => {
42
+ const meta = useAdminMeta();
43
+ const model = meta.resources[resource];
44
+ const view = meta.views[resource];
45
+ const client = useClient();
46
+ const queryClient = useQueryClient();
47
+ const { navigate } = useRouter();
48
+ const noun = view?.names?.singular ?? humanize(resource);
49
+ const feedback = useLiveSave(view?.saveFeedback ?? "toast", `New ${noun.toLowerCase()}`);
50
+ const sections = createSections(model, view);
51
+ const [draft, setDraft] = useState(() => initialDraft(model, sections));
52
+ const [errors, setErrors] = useState({});
53
+ const change = (name, value) => {
54
+ setErrors((current) => without(current, name));
55
+ setDraft((current) => ({
56
+ ...current,
57
+ [name]: value
58
+ }));
59
+ };
60
+ const submit = () => feedback.run(async () => {
61
+ let created;
62
+ try {
63
+ created = await client.create(resource, createBody(model, meta.resources, draft));
64
+ } catch (error) {
65
+ if (isApiError(error)) setErrors(Object.fromEntries(error.errors.flatMap((entry) => entry.field ? [[entry.field, entry.message]] : [])));
66
+ throw error;
67
+ }
68
+ for (const prefix of [
69
+ "page",
70
+ "list",
71
+ "first-page",
72
+ "facets",
73
+ "series"
74
+ ]) queryClient.invalidateQueries({ queryKey: [prefix, resource] });
75
+ navigate(`/${resource}/${encodeKey(recordKey(model, created.record))}`, { replace: true });
76
+ });
77
+ const formSections = sections.map((section) => ({
78
+ title: section.title,
79
+ help: section.help,
80
+ fields: section.fields.map((name) => {
81
+ const field = model.fields[name];
82
+ const value = draft[name];
83
+ const target = field.relation ? meta.resources[field.relation.resource] : void 0;
84
+ return {
85
+ name,
86
+ label: view?.fields[name]?.label ?? humanize(name),
87
+ help: view?.fields[name]?.help,
88
+ required: isRequired(field),
89
+ error: errors[name],
90
+ editor: /* @__PURE__ */ jsx(FieldEditor, {
91
+ field,
92
+ hints: view?.fields[name],
93
+ value: isChosen(value) ? value.key : value ?? (field.type === "boolean" ? false : ""),
94
+ stored: void 0,
95
+ relationLabel: isChosen(value) ? value.label : void 0,
96
+ placeholder: hasDatabaseDefault(field) ? "Set by the database" : void 0,
97
+ relationPicker: target ? {
98
+ target,
99
+ view: meta.views[target.name]
100
+ } : void 0,
101
+ invalid: Boolean(errors[name]),
102
+ onChange: (next) => change(name, next)
103
+ })
104
+ };
105
+ })
106
+ }));
107
+ return /* @__PURE__ */ jsx(CreateRecordForm, {
108
+ noun,
109
+ collection: view?.names?.plural ?? humanize(resource),
110
+ sections: formSections,
111
+ saving: feedback.phase === "saving",
112
+ message: feedback.message,
113
+ onSubmit: () => void submit(),
114
+ onCancel: () => navigate(`/${resource}`)
115
+ });
116
+ };
117
+ //#endregion
118
+ export { CreatePage };
@@ -0,0 +1,68 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { ErrorBanner } from "../error-banner.js";
3
+ import { Field } from "../../record-view/field.js";
4
+ import { Section } from "../../record-view/section.js";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/app/create/create-record-form.tsx
7
+ /** The presentation of "New <thing>": the view's sections, each field with its editor, and Create or Cancel. */
8
+ const CreateRecordForm = ({ noun, collection, sections, saving, message, onSubmit, onCancel }) => /* @__PURE__ */ jsx("form", {
9
+ className: "min-h-0 flex-1 overflow-y-auto",
10
+ onSubmit: (event) => {
11
+ event.preventDefault();
12
+ onSubmit();
13
+ },
14
+ children: /* @__PURE__ */ jsxs("div", {
15
+ className: "mx-auto max-w-[760px] px-4 py-6 md:px-10 md:py-8",
16
+ children: [
17
+ /* @__PURE__ */ jsxs("header", {
18
+ className: "flex flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between sm:gap-6",
19
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
20
+ className: "text-xs text-muted-foreground",
21
+ children: collection
22
+ }), /* @__PURE__ */ jsxs("h1", {
23
+ className: "mt-1 text-2xl font-semibold tracking-tight",
24
+ children: ["New ", noun.toLowerCase()]
25
+ })] }), /* @__PURE__ */ jsxs("div", {
26
+ className: "flex items-center gap-2",
27
+ children: [/* @__PURE__ */ jsx(Button, {
28
+ className: "max-sm:min-h-11",
29
+ onClick: onCancel,
30
+ children: "Cancel"
31
+ }), /* @__PURE__ */ jsxs(Button, {
32
+ variant: "primary",
33
+ type: "submit",
34
+ className: "max-sm:min-h-11 max-sm:flex-1",
35
+ loading: saving,
36
+ children: ["Create ", noun.toLowerCase()]
37
+ })]
38
+ })]
39
+ }),
40
+ message && /* @__PURE__ */ jsx("div", {
41
+ className: "mt-4",
42
+ children: /* @__PURE__ */ jsx(ErrorBanner, {
43
+ title: "Not created",
44
+ error: new Error(message)
45
+ })
46
+ }),
47
+ /* @__PURE__ */ jsx("div", {
48
+ className: "divide-y divide-border",
49
+ children: sections.map((section) => /* @__PURE__ */ jsx(Section, {
50
+ title: section.title,
51
+ help: section.help,
52
+ children: /* @__PURE__ */ jsx("div", {
53
+ className: "grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2",
54
+ children: section.fields.map((field) => /* @__PURE__ */ jsx(Field, {
55
+ label: field.label,
56
+ required: field.required,
57
+ help: field.help,
58
+ error: field.error,
59
+ children: field.editor
60
+ }, field.name))
61
+ })
62
+ }, section.title))
63
+ })
64
+ ]
65
+ })
66
+ });
67
+ //#endregion
68
+ export { CreateRecordForm };
@@ -0,0 +1,52 @@
1
+ import { isEditable, toPatchValue } from "../../live/field-values.js";
2
+ //#region src/app/create/create-values.ts
3
+ const isChosen = (value) => typeof value === "object" && value !== null && "key" in value;
4
+ /** Fields the user can set on a new record: not read-only, not the tenant, and not structured data. */
5
+ const isCreatable = (model, field) => field.name !== model.tenant && !field.readOnly && (field.type === "relation" || isEditable(field));
6
+ /** The view's sections limited to creatable fields, plus a section for any creatable field the layout leaves out. */
7
+ const createSections = (model, view) => {
8
+ const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field)).map((field) => field.name);
9
+ const placed = /* @__PURE__ */ new Set();
10
+ const sections = (view?.layout ?? []).flatMap((item) => {
11
+ if (item.kind !== "section") return [];
12
+ const fields = item.fields.filter((name) => creatable.includes(name) && !placed.has(name));
13
+ fields.forEach((name) => placed.add(name));
14
+ return fields.length > 0 ? [{
15
+ title: item.title,
16
+ help: item.help,
17
+ fields
18
+ }] : [];
19
+ });
20
+ const rest = creatable.filter((name) => !placed.has(name));
21
+ if (rest.length === 0) return sections;
22
+ return [...sections, {
23
+ title: sections.length === 0 ? "Details" : "Other fields",
24
+ fields: rest
25
+ }];
26
+ };
27
+ const keyValue = (target, key) => {
28
+ return (target?.primaryKey.length === 1 ? target.fields[target.primaryKey[0]]?.type : void 0) === "integer" ? Number(key) : key;
29
+ };
30
+ /**
31
+ * The JSON body for `POST`: only what the user filled in, so the database applies defaults to the rest
32
+ * and the server reports what is still required.
33
+ */
34
+ const createBody = (model, resources, draft) => Object.fromEntries(Object.entries(draft).flatMap(([name, value]) => {
35
+ const field = model.fields[name];
36
+ if (!field) return [];
37
+ if (isChosen(value)) return [[name, keyValue(resources[field.relation?.resource ?? ""], value.key)]];
38
+ if (field.type === "boolean") return [[name, Boolean(value)]];
39
+ if (String(value).trim() === "") return [];
40
+ return [[name, toPatchValue(field, value)]];
41
+ }));
42
+ /** A field is required when it can be neither empty nor filled in by a default. */
43
+ const isRequired = (field) => !field.nullable && !field.default && field.type !== "boolean";
44
+ /** The form's starting values: literal defaults from `/meta`. Database defaults stay empty so the database fills them. */
45
+ const initialDraft = (model, sections) => Object.fromEntries(sections.flatMap((section) => section.fields.flatMap((name) => {
46
+ const fallback = model.fields[name]?.default;
47
+ if (!fallback || !("value" in fallback) || fallback.value === null || fallback.value === void 0) return [];
48
+ return [[name, typeof fallback.value === "boolean" ? fallback.value : String(fallback.value)]];
49
+ })));
50
+ const hasDatabaseDefault = (field) => Boolean(field.default && "db" in field.default);
51
+ //#endregion
52
+ export { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isCreatable, isRequired };
@@ -0,0 +1,109 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { Dialog } from "../../primitives/dialog.js";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/app/delete/dialogs.tsx
5
+ const ConfirmDeleteDialog = ({ title, noun, busy, onConfirm, onCancel }) => /* @__PURE__ */ jsx(Dialog, {
6
+ title: `Delete ${title}?`,
7
+ description: `This ${noun.toLowerCase()} will be removed. This cannot be undone.`,
8
+ onClose: onCancel,
9
+ actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
10
+ onClick: onCancel,
11
+ children: "Cancel"
12
+ }), /* @__PURE__ */ jsx(Button, {
13
+ variant: "primary",
14
+ className: "bg-danger hover:bg-danger/90",
15
+ loading: busy,
16
+ onClick: onConfirm,
17
+ children: "Delete"
18
+ })] })
19
+ });
20
+ const ConflictDialog = ({ title, changes, busy, onReview, onDeleteAnyway, onCancel }) => /* @__PURE__ */ jsx(Dialog, {
21
+ title: `${title} was changed since you opened it`,
22
+ description: "Someone saved this record in the meantime. Review what changed before you delete it.",
23
+ onClose: onCancel,
24
+ actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
25
+ onClick: onReview,
26
+ children: "Review changes"
27
+ }), /* @__PURE__ */ jsx(Button, {
28
+ variant: "primary",
29
+ className: "bg-danger hover:bg-danger/90",
30
+ loading: busy,
31
+ onClick: onDeleteAnyway,
32
+ children: "Delete anyway"
33
+ })] }),
34
+ children: changes.length === 0 ? /* @__PURE__ */ jsx("p", {
35
+ className: "text-[13px] text-muted-foreground",
36
+ children: "No visible fields differ; the record was saved again with the same values."
37
+ }) : /* @__PURE__ */ jsx("ul", {
38
+ className: "divide-y rounded-md border text-[13px]",
39
+ children: changes.map((change) => /* @__PURE__ */ jsxs("li", {
40
+ className: "grid grid-cols-[96px_1fr] gap-3 px-3 py-2",
41
+ children: [/* @__PURE__ */ jsx("span", {
42
+ className: "text-muted-foreground",
43
+ children: change.label
44
+ }), /* @__PURE__ */ jsxs("span", { children: [
45
+ /* @__PURE__ */ jsx("span", {
46
+ className: "text-muted-foreground line-through",
47
+ children: change.before
48
+ }),
49
+ " ",
50
+ /* @__PURE__ */ jsx("span", {
51
+ className: "font-medium",
52
+ children: change.after
53
+ })
54
+ ] })]
55
+ }, change.field))
56
+ })
57
+ });
58
+ const BulkDeleteDialog = (props) => {
59
+ if (props.stage === "confirm") return /* @__PURE__ */ jsx(Dialog, {
60
+ title: `Delete ${props.count} ${props.count === 1 ? props.plural.replace(/s$/, "") : props.plural.toLowerCase()}?`,
61
+ description: "This cannot be undone. Each record is deleted only if it has not changed since the list loaded; anything that changed is reported and kept.",
62
+ onClose: props.onCancel,
63
+ actions: /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Button, {
64
+ onClick: props.onCancel,
65
+ children: "Cancel"
66
+ }), /* @__PURE__ */ jsxs(Button, {
67
+ variant: "primary",
68
+ className: "bg-danger hover:bg-danger/90",
69
+ onClick: props.onConfirm,
70
+ children: ["Delete ", props.count]
71
+ })] })
72
+ });
73
+ if (props.stage === "running") return /* @__PURE__ */ jsx(Dialog, {
74
+ title: "Deleting",
75
+ description: `${props.done} of ${props.total} done`,
76
+ onClose: () => void 0,
77
+ actions: /* @__PURE__ */ jsx(Button, {
78
+ loading: true,
79
+ disabled: true,
80
+ children: "Working"
81
+ })
82
+ });
83
+ const deleted = props.total - props.failures.length;
84
+ return /* @__PURE__ */ jsx(Dialog, {
85
+ title: props.failures.length === 0 ? `Deleted ${deleted}` : `Deleted ${deleted} of ${props.total}`,
86
+ description: props.failures.length === 0 ? void 0 : `${props.failures.length} could not be deleted and were kept.`,
87
+ onClose: props.onClose,
88
+ actions: /* @__PURE__ */ jsx(Button, {
89
+ variant: "primary",
90
+ onClick: props.onClose,
91
+ children: "Done"
92
+ }),
93
+ children: props.failures.length > 0 && /* @__PURE__ */ jsx("ul", {
94
+ className: "max-h-48 divide-y overflow-y-auto rounded-md border text-[13px]",
95
+ children: props.failures.map((failure) => /* @__PURE__ */ jsxs("li", {
96
+ className: "px-3 py-2",
97
+ children: [/* @__PURE__ */ jsx("span", {
98
+ className: "font-medium",
99
+ children: failure.title
100
+ }), /* @__PURE__ */ jsx("span", {
101
+ className: "block text-xs text-danger-text",
102
+ children: failure.message
103
+ })]
104
+ }, failure.title))
105
+ })
106
+ });
107
+ };
108
+ //#endregion
109
+ export { BulkDeleteDialog, ConfirmDeleteDialog, ConflictDialog };
@@ -0,0 +1,17 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { displayValue } from "../../live/display-value.js";
3
+ //#region src/app/delete/record-diff.ts
4
+ const volatile = /* @__PURE__ */ new Set(["updatedAt"]);
5
+ /** The fields whose stored value differs between two reads of a record, for the "changed since you opened it" dialog. */
6
+ const diffRecords = (model, view, before, after) => Object.values(model.fields).flatMap((field) => {
7
+ if (volatile.has(field.name) || JSON.stringify(before[field.name] ?? null) === JSON.stringify(after[field.name] ?? null)) return [];
8
+ const hints = view?.fields[field.name];
9
+ return [{
10
+ field: field.name,
11
+ label: hints?.label ?? humanize(field.name),
12
+ before: displayValue(field, before[field.name], hints),
13
+ after: displayValue(field, after[field.name], hints)
14
+ }];
15
+ });
16
+ //#endregion
17
+ export { diffRecords };
@@ -0,0 +1,193 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ import { humanize } from "../../live/naming.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { BulkDeleteDialog, ConfirmDeleteDialog, ConflictDialog } from "./dialogs.js";
5
+ import { diffRecords } from "./record-diff.js";
6
+ import { useState } from "react";
7
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
+ import { useQueryClient } from "@tanstack/react-query";
9
+ import { PreconditionFailedError, isApiError } from "@protobase/client";
10
+ //#region src/app/delete/use-delete-flow.tsx
11
+ const concurrency = 4;
12
+ /**
13
+ * Delete with `If-Match`: hard deletes ask first, soft deletes go straight through (with Undo when `restore` exists),
14
+ * a 412 opens a dialog that says what changed, and bulk deletes confirm once and report each failure.
15
+ */
16
+ const useDeleteFlow = ({ model, view, onDeleted, onReview, restore }) => {
17
+ const client = useClient();
18
+ const queryClient = useQueryClient();
19
+ const toast = useToast();
20
+ const [flow, setFlow] = useState();
21
+ const [busy, setBusy] = useState(false);
22
+ const noun = view?.names?.singular ?? humanize(model.name);
23
+ const plural = view?.names?.plural ?? humanize(model.name);
24
+ const refresh = () => {
25
+ for (const prefix of [
26
+ "page",
27
+ "list",
28
+ "first-page"
29
+ ]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
30
+ for (const prefix of [
31
+ "facets",
32
+ "series",
33
+ "histogram"
34
+ ]) queryClient.invalidateQueries({ queryKey: [prefix, model.name] });
35
+ };
36
+ /** Deletes with the given ETag (`*` for any version). Returns false after opening the conflict dialog on a 412. */
37
+ const remove = async (target, etag) => {
38
+ const version = etag ?? (await client.get(model.name, target.key)).etag;
39
+ try {
40
+ await client.remove(model.name, target.key, { etag: version });
41
+ } catch (error) {
42
+ if (!(error instanceof PreconditionFailedError)) throw error;
43
+ return error;
44
+ }
45
+ };
46
+ const succeeded = (target) => {
47
+ refresh();
48
+ setFlow(void 0);
49
+ onDeleted?.(target);
50
+ toast.show({
51
+ state: "success",
52
+ title: `${target.title} deleted`,
53
+ ...model.softDelete && restore && { action: {
54
+ label: "Undo",
55
+ onClick: () => {
56
+ restore(target).then(() => {
57
+ refresh();
58
+ toast.show({
59
+ state: "success",
60
+ title: `${target.title} restored`
61
+ });
62
+ }, (error) => {
63
+ if (!isApiError(error)) throw error;
64
+ refresh();
65
+ toast.show({
66
+ state: error.status === 409 ? "success" : "error",
67
+ title: error.status === 409 ? `${target.title} is already back` : `Could not restore ${target.title}`,
68
+ description: error.status === 409 ? void 0 : error.message
69
+ });
70
+ });
71
+ }
72
+ } }
73
+ });
74
+ };
75
+ const attempt = async (target, etag) => {
76
+ setBusy(true);
77
+ try {
78
+ if (!await remove(target, etag)) return succeeded(target);
79
+ const fresh = (await client.get(model.name, target.key)).record;
80
+ setFlow({
81
+ kind: "conflict",
82
+ target,
83
+ fresh,
84
+ changes: diffRecords(model, view, target.snapshot, fresh)
85
+ });
86
+ } catch (error) {
87
+ if (!isApiError(error)) throw error;
88
+ setFlow(void 0);
89
+ toast.show({
90
+ state: "error",
91
+ title: `Could not delete ${target.title}`,
92
+ description: error.message
93
+ });
94
+ } finally {
95
+ setBusy(false);
96
+ }
97
+ };
98
+ const deleteQuietly = async (target) => {
99
+ try {
100
+ return await remove(target, target.etag) ? {
101
+ title: target.title,
102
+ message: "Changed since the list loaded, so it was kept."
103
+ } : void 0;
104
+ } catch (error) {
105
+ if (!isApiError(error)) throw error;
106
+ return {
107
+ title: target.title,
108
+ message: error.message
109
+ };
110
+ }
111
+ };
112
+ const runBulk = async (targets) => {
113
+ const failures = [];
114
+ let done = 0;
115
+ setFlow({
116
+ kind: "bulk-running",
117
+ done,
118
+ total: targets.length
119
+ });
120
+ const queue = [...targets];
121
+ const worker = async () => {
122
+ for (let target = queue.shift(); target; target = queue.shift()) {
123
+ const failure = await deleteQuietly(target);
124
+ if (failure) failures.push(failure);
125
+ done += 1;
126
+ setFlow({
127
+ kind: "bulk-running",
128
+ done,
129
+ total: targets.length
130
+ });
131
+ }
132
+ };
133
+ await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, worker));
134
+ refresh();
135
+ setFlow({
136
+ kind: "bulk-done",
137
+ total: targets.length,
138
+ failures
139
+ });
140
+ };
141
+ const close = () => setFlow(void 0);
142
+ return {
143
+ dialogs: flow && /* @__PURE__ */ jsxs(Fragment$1, { children: [
144
+ flow.kind === "confirm" && /* @__PURE__ */ jsx(ConfirmDeleteDialog, {
145
+ title: flow.target.title,
146
+ noun,
147
+ busy,
148
+ onCancel: close,
149
+ onConfirm: () => void attempt(flow.target, flow.target.etag)
150
+ }),
151
+ flow.kind === "conflict" && /* @__PURE__ */ jsx(ConflictDialog, {
152
+ title: flow.target.title,
153
+ changes: flow.changes,
154
+ busy,
155
+ onCancel: close,
156
+ onReview: () => {
157
+ close();
158
+ onReview?.(flow.target, flow.fresh);
159
+ },
160
+ onDeleteAnyway: () => void attempt(flow.target, "*")
161
+ }),
162
+ flow.kind === "bulk-confirm" && /* @__PURE__ */ jsx(BulkDeleteDialog, {
163
+ stage: "confirm",
164
+ count: flow.targets.length,
165
+ plural,
166
+ onCancel: close,
167
+ onConfirm: () => void runBulk(flow.targets)
168
+ }),
169
+ flow.kind === "bulk-running" && /* @__PURE__ */ jsx(BulkDeleteDialog, {
170
+ stage: "running",
171
+ done: flow.done,
172
+ total: flow.total
173
+ }),
174
+ flow.kind === "bulk-done" && /* @__PURE__ */ jsx(BulkDeleteDialog, {
175
+ stage: "done",
176
+ total: flow.total,
177
+ failures: flow.failures,
178
+ onClose: close
179
+ })
180
+ ] }),
181
+ /** Soft-delete resources go straight through; others ask first. */
182
+ start: (target) => model.softDelete ? attempt(target, target.etag) : setFlow({
183
+ kind: "confirm",
184
+ target
185
+ }),
186
+ startBulk: (targets) => setFlow({
187
+ kind: "bulk-confirm",
188
+ targets
189
+ })
190
+ };
191
+ };
192
+ //#endregion
193
+ export { useDeleteFlow };
@@ -0,0 +1,9 @@
1
+ import { useClient } from "../../data/api-provider.js";
2
+ //#region src/app/delete/use-restore.ts
3
+ /** Brings a soft-deleted record back (AIP-164), for the Undo toast. */
4
+ const useRestore = (model) => {
5
+ const client = useClient();
6
+ return model.softDelete ? async (target) => void await client.undelete(model.name, target.key) : void 0;
7
+ };
8
+ //#endregion
9
+ export { useRestore };
@@ -0,0 +1,18 @@
1
+ import { guarded } from "../lib/guarded.js";
2
+ //#region src/app/draft-storage.ts
3
+ const draftStorageKey = (userId, resource, recordKey) => `protobase:draft:${userId}:${resource}:${recordKey}`;
4
+ /** The saved draft, only if it was made against the version of the record that is on screen now. */
5
+ const readDraft = (storage, key, etag) => guarded(() => {
6
+ const raw = storage.getItem(key);
7
+ if (raw === null) return void 0;
8
+ const parsed = JSON.parse(raw);
9
+ if (!parsed || parsed.etag !== etag || typeof parsed.fields !== "object" || parsed.fields === null) return void 0;
10
+ return {
11
+ etag,
12
+ fields: parsed.fields,
13
+ ...Array.isArray(parsed.order) && { order: parsed.order.map(String) }
14
+ };
15
+ }, void 0);
16
+ const writeDraft = (storage, key, draft) => guarded(() => draft ? storage.setItem(key, JSON.stringify(draft)) : storage.removeItem(key), void 0);
17
+ //#endregion
18
+ export { draftStorageKey, readDraft, writeDraft };
@@ -0,0 +1,28 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { TriangleAlert } from "lucide-react";
3
+ import { isApiError } from "@protobase/client";
4
+ //#region src/app/error-banner.tsx
5
+ const describe = (error) => {
6
+ if (!isApiError(error)) return [{ message: error instanceof Error ? error.message : "Something went wrong" }];
7
+ const detailed = error.errors.map((entry) => ({
8
+ message: entry.message,
9
+ hint: entry.hint
10
+ }));
11
+ return detailed.length > 0 ? detailed : [{ message: error.message }];
12
+ };
13
+ const ErrorBanner = ({ error, title }) => /* @__PURE__ */ jsxs("div", {
14
+ role: "alert",
15
+ className: "flex gap-2.5 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger-text",
16
+ children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "mt-0.5 size-4 shrink-0" }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
17
+ className: "font-medium",
18
+ children: title
19
+ }), describe(error).map((detail) => /* @__PURE__ */ jsxs("p", {
20
+ className: "text-xs",
21
+ children: [detail.message, detail.hint && /* @__PURE__ */ jsxs("span", {
22
+ className: "text-muted-foreground",
23
+ children: [" ", detail.hint]
24
+ })]
25
+ }, detail.message))] })]
26
+ });
27
+ //#endregion
28
+ export { ErrorBanner };