@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,32 @@
1
+ //#region src/live/field-values.ts
2
+ /** What the editor's text input holds for a stored value. */
3
+ const toDraft = (value) => value === null || value === void 0 ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
4
+ const textEdited = /* @__PURE__ */ new Set([
5
+ "text",
6
+ "integer",
7
+ "bigint",
8
+ "decimal",
9
+ "date",
10
+ "currency",
11
+ "country"
12
+ ]);
13
+ const isEditable = (field) => !field.readOnly && field.type !== "json" && field.type !== "uuid" && field.type !== "file" && field.type !== "timestamp" && field.type !== "relation";
14
+ /** The JSON value to send for an edited field: integers become numbers, empty optional fields become null. */
15
+ const toPatchValue = (field, draft) => {
16
+ if (field.type === "boolean") return Boolean(draft);
17
+ const text = String(draft).trim();
18
+ if (text === "") return field.nullable ? null : "";
19
+ if (field.type === "integer") return Number(text);
20
+ if (textEdited.has(field.type) || field.type === "enum") return text;
21
+ throw new Error(`Not implemented: editing ${field.type} fields`);
22
+ };
23
+ /** Whether the draft differs from what the server holds, comparing numbers by value. */
24
+ const differs = (field, draft, stored) => {
25
+ if (field.type === "boolean") return Boolean(draft) !== Boolean(stored);
26
+ const a = String(draft ?? "").trim();
27
+ const b = toDraft(stored);
28
+ if (field.type === "decimal" || field.type === "integer") return a === "" || b === "" ? a !== b : Number(a) !== Number(b);
29
+ return a !== b;
30
+ };
31
+ //#endregion
32
+ export { differs, isEditable, toDraft, toPatchValue };
@@ -0,0 +1,135 @@
1
+ import { createWhere, parseFilter, printFilter } from "@protobase/schema";
2
+ //#region src/live/filter-string.ts
3
+ const w = createWhere();
4
+ const span = {
5
+ start: 0,
6
+ end: 0
7
+ };
8
+ const relative = {
9
+ "7d": {
10
+ amount: 7,
11
+ unit: "d"
12
+ },
13
+ "30d": {
14
+ amount: 30,
15
+ unit: "d"
16
+ },
17
+ "90d": {
18
+ amount: 90,
19
+ unit: "d"
20
+ }
21
+ };
22
+ const iso = (ms) => new Date(ms).toISOString().replace(".000Z", "Z");
23
+ /** First instant of the period a calendar preset starts at, in UTC. */
24
+ const periodStart = (preset, now) => {
25
+ const [year, month, day] = [
26
+ now.getUTCFullYear(),
27
+ now.getUTCMonth(),
28
+ now.getUTCDate()
29
+ ];
30
+ if (preset === "year") return iso(Date.UTC(year, 0, 1));
31
+ if (preset === "quarter") return iso(Date.UTC(year, Math.floor(month / 3) * 3, 1));
32
+ if (preset === "month") return iso(Date.UTC(year, month, 1));
33
+ if (preset === "yesterday") return iso(Date.UTC(year, month, day - 1));
34
+ return iso(Date.UTC(year, month, day));
35
+ };
36
+ const calendarPresets = [
37
+ "year",
38
+ "quarter",
39
+ "month",
40
+ "yesterday",
41
+ "today"
42
+ ];
43
+ /** Date fields take a plain date, timestamp fields an instant. */
44
+ const instant = (value, kind) => kind === "date" ? {
45
+ kind: "string",
46
+ value: value.slice(0, 10),
47
+ span
48
+ } : {
49
+ kind: "timestamp",
50
+ value,
51
+ span
52
+ };
53
+ const dateClauses = (field, preset, now, kind) => {
54
+ if (preset in relative) return [w.gte(field, w.ago(preset))];
55
+ const from = w.gte(field, instant(periodStart(preset, now), kind));
56
+ return preset === "yesterday" ? [from, w.lt(field, instant(periodStart("today", now), kind))] : [from];
57
+ };
58
+ /** The panel's state as filter clauses, in a fixed order so the text is stable. */
59
+ const stateToClauses = (config, state, now = /* @__PURE__ */ new Date()) => {
60
+ const facets = config.facets.flatMap((group) => {
61
+ const values = state.facets[group.id] ?? [];
62
+ if (values.length === 0) return [];
63
+ return [values.length === 1 ? w.eq(group.id, values[0]) : w.in(group.id, values)];
64
+ });
65
+ const range = config.range && state.range ? [w.gte(config.range.id, state.range[0]), w.lte(config.range.id, state.range[1])] : [];
66
+ const date = config.dateField && state.date ? dateClauses(config.dateField, state.date, now, config.dateKind) : [];
67
+ const toggles = (config.toggles ?? []).filter((toggle) => state.toggles.includes(toggle.id)).map((toggle) => w.eq(toggle.id, true));
68
+ return [
69
+ ...facets,
70
+ ...range,
71
+ ...date,
72
+ ...toggles
73
+ ];
74
+ };
75
+ const clausesToText = (clauses) => clauses.length === 0 ? "" : printFilter(w.and(...clauses));
76
+ const stateToText = (config, state, extras = [], now = /* @__PURE__ */ new Date()) => clausesToText([...stateToClauses(config, state, now), ...extras]);
77
+ const flatten = (expr) => expr.kind === "and" ? expr.args.flatMap(flatten) : [expr];
78
+ const fieldName = (expr) => expr.field.path.join(".");
79
+ const literalText = (value) => value.kind === "number" ? String(value.raw) : value.kind === "string" ? String(value.value) : void 0;
80
+ const presetOf = (clause, now) => {
81
+ const v = clause.value;
82
+ if (v.kind === "now" && v.offset?.sign === "-") {
83
+ const { amount, unit } = v.offset.duration;
84
+ return Object.entries(relative).find(([, d]) => d.amount === amount && d.unit === unit)?.[0];
85
+ }
86
+ if (v.kind === "timestamp") return calendarPresets.find((preset) => periodStart(preset, now) === v.value);
87
+ if (v.kind === "string") return calendarPresets.find((preset) => periodStart(preset, now).slice(0, 10) === v.value);
88
+ };
89
+ /**
90
+ * The inverse of `stateToText`: clauses that match a widget fill the panel state, everything else
91
+ * (search text, hand-written conditions) is returned as `extras` and survives the round trip.
92
+ */
93
+ const textToState = (config, text, now = /* @__PURE__ */ new Date()) => {
94
+ const parsed = parseFilter(text);
95
+ if (!parsed.ok) return {
96
+ ok: false,
97
+ errors: parsed.errors
98
+ };
99
+ const state = {
100
+ facets: {},
101
+ toggles: []
102
+ };
103
+ const extras = [];
104
+ const facetIds = new Set(config.facets.map((group) => group.id));
105
+ const toggleIds = new Set((config.toggles ?? []).map((toggle) => toggle.id));
106
+ const bounds = { clauses: [] };
107
+ let dateClause;
108
+ let todayEnd;
109
+ for (const clause of parsed.ast ? flatten(parsed.ast) : []) if (clause.kind === "compare" && facetIds.has(fieldName(clause)) && clause.op === "=" && literalText(clause.value) !== void 0) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], literalText(clause.value)];
110
+ else if (clause.kind === "in" && facetIds.has(fieldName(clause)) && clause.values.every((value) => literalText(value) !== void 0)) state.facets[fieldName(clause)] = [...state.facets[fieldName(clause)] ?? [], ...clause.values.map((value) => literalText(value))];
111
+ else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === ">=" && presetOf(clause, now) && !state.date) {
112
+ state.date = presetOf(clause, now);
113
+ dateClause = clause;
114
+ } else if (clause.kind === "compare" && config.dateField === fieldName(clause) && clause.op === "<" && (clause.value.kind === "timestamp" || clause.value.kind === "string") && periodStart("today", now).startsWith(clause.value.value.slice(0, 10))) todayEnd = clause;
115
+ else if (clause.kind === "compare" && toggleIds.has(fieldName(clause)) && clause.op === "=" && clause.value.kind === "boolean" && clause.value.value) state.toggles.push(fieldName(clause));
116
+ else if (clause.kind === "compare" && config.range?.id === fieldName(clause) && (clause.op === ">=" || clause.op === "<=") && clause.value.kind === "number") {
117
+ if (clause.op === ">=") bounds.low = clause.value.value;
118
+ else bounds.high = clause.value.value;
119
+ bounds.clauses.push(clause);
120
+ } else extras.push(clause);
121
+ if (state.date === "yesterday" && !todayEnd) {
122
+ extras.push(dateClause);
123
+ state.date = void 0;
124
+ }
125
+ if (todayEnd && state.date !== "yesterday") extras.push(todayEnd);
126
+ if (bounds.low !== void 0 && bounds.high !== void 0) state.range = [bounds.low, bounds.high];
127
+ else extras.push(...bounds.clauses);
128
+ return {
129
+ ok: true,
130
+ state,
131
+ extras
132
+ };
133
+ };
134
+ //#endregion
135
+ export { clausesToText, periodStart, stateToClauses, stateToText, textToState };
@@ -0,0 +1,63 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { recordId } from "./model-helpers.js";
3
+ import { withLabels } from "./relation-labels.js";
4
+ //#region src/live/grid-loader.ts
5
+ const exactBelow = 5e3;
6
+ /**
7
+ * Loads fixed-size pages by number. Sequential scrolling follows `next_page_token`; a jump asks `:seek` for the
8
+ * page at that row position. Every page is cached by TanStack Query.
9
+ */
10
+ const createGridLoader = ({ queryClient, client, resources, views, model, columns, filter, orderBy }) => {
11
+ const tokens = /* @__PURE__ */ new Map();
12
+ /** The server's total is a planner estimate, off by a lot for small results; settle those exactly. */
13
+ const firstPageTotal = async (result) => {
14
+ if (result.nextPageToken === "") return result.items.length;
15
+ if (result.totalSizeEstimate > exactBelow) return result.totalSizeEstimate;
16
+ return (await client.list(model.name, {
17
+ filter,
18
+ orderBy,
19
+ pageSize: 1,
20
+ count: "exact"
21
+ })).totalSize ?? result.totalSizeEstimate;
22
+ };
23
+ const fetchPage = async (page) => {
24
+ const token = tokens.get(page);
25
+ const params = {
26
+ filter,
27
+ orderBy,
28
+ pageSize: 40
29
+ };
30
+ const result = page === 0 || token !== void 0 ? await client.list(model.name, {
31
+ ...params,
32
+ pageToken: token ?? ""
33
+ }) : await client.seek(model.name, {
34
+ ...params,
35
+ position: page * 40
36
+ });
37
+ if (result.items.length === 0 && page > 0) return {
38
+ rows: [],
39
+ total: 0
40
+ };
41
+ tokens.set(page + 1, result.nextPageToken);
42
+ const ids = result.items.map((record) => recordId(model, record));
43
+ return {
44
+ rows: (await withLabels(queryClient, client, resources, views, model, columns, result.items)).map((record, index) => ({
45
+ ...record,
46
+ id: ids[index]
47
+ })),
48
+ total: page === 0 ? await firstPageTotal(result) : result.totalSizeEstimate
49
+ };
50
+ };
51
+ const page = (number) => queryClient.fetchQuery({
52
+ queryKey: keys.page(model.name, filter, orderBy, 40, number),
53
+ staleTime: 6e4,
54
+ queryFn: () => fetchPage(number)
55
+ });
56
+ const loadRows = async (start) => (await page(Math.floor(start / 40))).rows;
57
+ return {
58
+ page,
59
+ loadRows
60
+ };
61
+ };
62
+ //#endregion
63
+ export { createGridLoader };
@@ -0,0 +1,30 @@
1
+ import { enumLabel } from "@protobase/schema";
2
+ //#region src/live/model-helpers.ts
3
+ const labelCandidates = [
4
+ "name",
5
+ "title",
6
+ "number",
7
+ "sku",
8
+ "email",
9
+ "code",
10
+ "description"
11
+ ];
12
+ /** The field that names a record when another record points at it. */
13
+ const labelFieldOf = (model, view) => view?.title ?? labelCandidates.find((name) => model.fields[name]?.type === "text") ?? Object.values(model.fields).find((field) => field.type === "text")?.name ?? model.primaryKey[0];
14
+ /** What names a record: the view's `title`, else the first text column of its list, else `labelFieldOf`. */
15
+ const recordTitleField = (model, view) => view?.title ?? view?.list?.columns.find((name) => model.fields[name]?.type === "text") ?? labelFieldOf(model);
16
+ /** A record's name as shown: its title field's value, an enum's by its label, else `fallback` when the value is missing. */
17
+ const recordTitle = (model, view, record, fallback) => {
18
+ const name = recordTitleField(model, view);
19
+ const value = record[name];
20
+ if (value === null || value === void 0) return fallback;
21
+ return model.fields[name]?.type === "enum" ? enumLabel(String(value), view?.fields[name]?.valueLabels) : String(value);
22
+ };
23
+ const recordId = (model, record) => model.primaryKey.map((name) => String(record[name])).join(",");
24
+ /** The key as the URL carries it, for `get` and `update`. */
25
+ const recordKey = (model, record) => {
26
+ const parts = model.primaryKey.map((name) => record[name]);
27
+ return parts.length === 1 ? parts[0] : parts;
28
+ };
29
+ //#endregion
30
+ export { labelFieldOf, recordId, recordKey, recordTitle, recordTitleField };
@@ -0,0 +1,9 @@
1
+ //#region src/live/naming.ts
2
+ const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1);
3
+ /** `companyId` becomes "Company", `discountPercent` becomes "Discount percent". */
4
+ const humanize = (name) => {
5
+ const words = name.replace(/(Id|Code)$/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
6
+ return capitalise(words || name);
7
+ };
8
+ //#endregion
9
+ export { capitalise, humanize };
@@ -0,0 +1,53 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { labelFieldOf } from "./model-helpers.js";
3
+ import { createWhere, printFilter } from "@protobase/schema";
4
+ //#region src/live/relation-labels.ts
5
+ const where = createWhere();
6
+ /** Names for the given keys of a single-key resource, fetched in one list call and cached. */
7
+ const fetchLabels = async (queryClient, client, target, ids, view) => {
8
+ const keyField = target.primaryKey[0];
9
+ const unique = [...new Set(ids)].sort();
10
+ if (unique.length === 0 || target.primaryKey.length !== 1) return /* @__PURE__ */ new Map();
11
+ const labelField = labelFieldOf(target, view);
12
+ const entries = await queryClient.fetchQuery({
13
+ queryKey: keys.labels(target.name, unique.join(",")),
14
+ staleTime: 3e5,
15
+ queryFn: async () => {
16
+ return (await client.list(target.name, {
17
+ filter: printFilter(where.in(keyField, unique)),
18
+ fields: [keyField, labelField],
19
+ pageSize: unique.length
20
+ })).items.map((item) => [String(item[keyField]), String(item[labelField])]);
21
+ }
22
+ });
23
+ return new Map(entries);
24
+ };
25
+ /** Replaces each relation value with `{ id, name }` so grid cells can show the name. */
26
+ const withLabels = async (queryClient, client, resources, views, model, columns, rows) => {
27
+ const relations = columns.flatMap((name) => {
28
+ const field = model.fields[name];
29
+ const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
30
+ return target ? [{
31
+ name,
32
+ target
33
+ }] : [];
34
+ });
35
+ const resolved = await Promise.all(relations.map(async ({ name, target }) => {
36
+ const ids = rows.flatMap((row) => row[name] == null ? [] : [String(row[name])]);
37
+ return [name, await fetchLabels(queryClient, client, target, ids, views[target.name])];
38
+ }));
39
+ const labels = new Map(resolved);
40
+ return rows.map((row) => {
41
+ const next = { ...row };
42
+ for (const [name, names] of labels) {
43
+ if (row[name] == null) continue;
44
+ next[name] = {
45
+ id: row[name],
46
+ name: names.get(String(row[name])) ?? String(row[name])
47
+ };
48
+ }
49
+ return next;
50
+ });
51
+ };
52
+ //#endregion
53
+ export { fetchLabels, withLabels };
@@ -0,0 +1,88 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { humanize } from "./naming.js";
4
+ import { formatMoney } from "../format/money.js";
5
+ import { stateToText } from "./filter-string.js";
6
+ import { useHistogram } from "../data/use-aggregates.js";
7
+ import { fetchLabels } from "./relation-labels.js";
8
+ import { widgetFields } from "./view-fields.js";
9
+ import { useQueries, useQueryClient } from "@tanstack/react-query";
10
+ import { enumLabel } from "@protobase/schema";
11
+ //#region src/live/use-filter-config.ts
12
+ /**
13
+ * The panel's config for a view: facet counts come from `:facets`, each computed with every
14
+ * other active filter applied but not its own, so choosing one value does not hide the rest.
15
+ */
16
+ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
17
+ const client = useClient();
18
+ const queryClient = useQueryClient();
19
+ const skeleton = widgetFields(view, model);
20
+ const label = (name) => view?.fields[name]?.label ?? humanize(name);
21
+ const facetQueries = useQueries({ queries: skeleton.facets.map((group) => {
22
+ const filter = stateToText(skeleton, {
23
+ ...state,
24
+ facets: {
25
+ ...state.facets,
26
+ [group.id]: []
27
+ }
28
+ }, extras);
29
+ return {
30
+ queryKey: keys.facets(model.name, group.id, filter),
31
+ queryFn: () => client.facets(model.name, group.id, {
32
+ filter,
33
+ limit: 50
34
+ })
35
+ };
36
+ }) });
37
+ const labelQueries = useQueries({ queries: skeleton.facets.map((group, index) => {
38
+ const field = model.fields[group.id];
39
+ const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
40
+ const ids = (facetQueries[index]?.data ?? []).map((facet) => String(facet.value));
41
+ return {
42
+ queryKey: keys.labels(`facet:${model.name}.${group.id}`, ids.join(",")),
43
+ enabled: Boolean(target) && ids.length > 0,
44
+ queryFn: async () => [...await fetchLabels(queryClient, client, target, ids, views[target.name])]
45
+ };
46
+ }) });
47
+ const rangeFilter = stateToText({
48
+ ...skeleton,
49
+ range: void 0
50
+ }, state, extras);
51
+ const histogram = useHistogram(model.name, skeleton.range?.id ?? "", rangeFilter, 24, Boolean(skeleton.range));
52
+ const prefix = skeleton.range ? view?.fields[skeleton.range.id]?.prefix : void 0;
53
+ return {
54
+ facets: skeleton.facets.map((group, index) => {
55
+ const field = model.fields[group.id];
56
+ const names = new Map(labelQueries[index]?.data ?? []);
57
+ return {
58
+ ...group,
59
+ label: label(group.id),
60
+ options: (facetQueries[index]?.data ?? []).flatMap((facet) => facet.value === null ? [] : [{
61
+ value: String(facet.value),
62
+ label: field?.type === "enum" ? enumLabel(String(facet.value), view?.fields[group.id]?.valueLabels) : names.get(String(facet.value)) ?? String(facet.value),
63
+ count: facet.count
64
+ }])
65
+ };
66
+ }),
67
+ ...skeleton.range && histogram.data && histogram.data.min !== null && histogram.data.max !== null && { range: {
68
+ ...skeleton.range,
69
+ label: label(skeleton.range.id),
70
+ min: Math.floor(histogram.data.min),
71
+ max: Math.ceil(histogram.data.max),
72
+ step: Math.max(1, Math.round((histogram.data.max - histogram.data.min) / 100)),
73
+ histogram: histogram.data.buckets.map((bucket) => bucket.count),
74
+ format: (value) => prefix === "€" ? formatMoney(value * 100).replace(".00", "") : String(value)
75
+ } },
76
+ ...skeleton.dateField && {
77
+ datePresets: skeleton.datePresets ?? true,
78
+ dateField: skeleton.dateField,
79
+ dateKind: skeleton.dateKind
80
+ },
81
+ toggles: skeleton.toggles?.map((toggle) => ({
82
+ ...toggle,
83
+ label: label(toggle.id)
84
+ }))
85
+ };
86
+ };
87
+ //#endregion
88
+ export { useFilterConfig };
@@ -0,0 +1,45 @@
1
+ import { keys } from "../data/query-keys.js";
2
+ import { useClient } from "../data/api-provider.js";
3
+ import { createGridLoader } from "./grid-loader.js";
4
+ import { useMemo } from "react";
5
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
6
+ //#region src/live/use-live-rows.ts
7
+ /** A grid row source backed by the API; `total` is the server's estimate from the first page. */
8
+ const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
9
+ const client = useClient();
10
+ const queryClient = useQueryClient();
11
+ const columnKey = columns.join(",");
12
+ const loader = useMemo(() => createGridLoader({
13
+ queryClient,
14
+ client,
15
+ resources,
16
+ views,
17
+ model,
18
+ columns,
19
+ filter,
20
+ orderBy
21
+ }), [
22
+ queryClient,
23
+ client,
24
+ resources,
25
+ views,
26
+ model,
27
+ columnKey,
28
+ filter,
29
+ orderBy
30
+ ]);
31
+ const first = useQuery({
32
+ queryKey: [...keys.first(model.name, filter, orderBy), columnKey],
33
+ queryFn: () => loader.page(0)
34
+ });
35
+ return {
36
+ source: useMemo(() => first.data ? {
37
+ total: first.data.total,
38
+ loadRows: loader.loadRows
39
+ } : void 0, [first.data, loader]),
40
+ error: first.error,
41
+ isLoading: first.isLoading
42
+ };
43
+ };
44
+ //#endregion
45
+ export { useLiveRows };
@@ -0,0 +1,35 @@
1
+ //#region src/live/view-fields.ts
2
+ /** The widgets of a view in the shape `filter-string` talks to; options and counts are filled in later. */
3
+ const widgetFields = (view, model) => {
4
+ const widgets = view?.filters ?? [];
5
+ const range = widgets.find((widget) => widget.kind === "range");
6
+ const date = widgets.find((widget) => widget.kind === "dateRange");
7
+ return {
8
+ facets: widgets.flatMap((widget) => widget.kind === "facets" ? [{
9
+ id: widget.field,
10
+ label: widget.field,
11
+ options: [],
12
+ searchable: widget.search
13
+ }] : []),
14
+ ...range && { range: {
15
+ id: range.field,
16
+ label: range.field,
17
+ min: 0,
18
+ max: 0,
19
+ step: 1,
20
+ histogram: [],
21
+ format: String
22
+ } },
23
+ ...date && {
24
+ dateField: date.field,
25
+ dateKind: model?.fields[date.field]?.type === "date" ? "date" : "timestamp",
26
+ ...date.presets.length > 0 && { datePresets: date.presets }
27
+ },
28
+ toggles: widgets.flatMap((widget) => widget.kind === "toggle" ? [{
29
+ id: widget.field,
30
+ label: widget.field
31
+ }] : [])
32
+ };
33
+ };
34
+ //#endregion
35
+ export { widgetFields };
@@ -0,0 +1,24 @@
1
+ import { mockNow, rngAt } from "./rng.js";
2
+ //#region src/mocks/activity.ts
3
+ const dayMs = 864e5;
4
+ const dailyCounts = (days) => Array.from({ length: days }, (_, offset) => {
5
+ const at = mockNow - (days - 1 - offset) * dayMs;
6
+ const weekday = new Date(at).getUTCDay();
7
+ return {
8
+ at,
9
+ count: Math.round((weekday === 0 || weekday === 6 ? 120 : 410) * (.8 + rngAt(71, offset)() * .45) * (1 + offset / days / 3))
10
+ };
11
+ });
12
+ const activitySeries = (granularity) => {
13
+ if (granularity === "day") return dailyCounts(30);
14
+ const daily = dailyCounts(84);
15
+ return Array.from({ length: 12 }, (_, week) => {
16
+ const chunk = daily.slice(week * 7, week * 7 + 7);
17
+ return {
18
+ at: chunk[0].at,
19
+ count: chunk.reduce((sum, day) => sum + day.count, 0)
20
+ };
21
+ });
22
+ };
23
+ //#endregion
24
+ export { activitySeries };
@@ -0,0 +1,13 @@
1
+ //#region src/mocks/invoices.ts
2
+ const lineTotalCents = (line) => line.quantity * line.unitPriceCents;
3
+ const invoiceTotals = (lines, vatRate = .21) => {
4
+ const subtotalCents = lines.reduce((sum, line) => sum + lineTotalCents(line), 0);
5
+ const vatCents = Math.round(subtotalCents * vatRate);
6
+ return {
7
+ subtotalCents,
8
+ vatCents,
9
+ totalCents: subtotalCents + vatCents
10
+ };
11
+ };
12
+ //#endregion
13
+ export { invoiceTotals, lineTotalCents };