@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.
- package/LICENSE +202 -0
- package/dist/activity-chart/activity-chart.js +125 -0
- package/dist/activity-chart/activity-math.js +11 -0
- package/dist/activity-chart/activity-tooltip.js +24 -0
- package/dist/app/action-icon.js +10 -0
- package/dist/app/app.d.ts +24 -0
- package/dist/app/app.js +123 -0
- package/dist/app/batch-failure.js +21 -0
- package/dist/app/breadcrumb-for.js +27 -0
- package/dist/app/create/create-page.js +118 -0
- package/dist/app/create/create-record-form.js +68 -0
- package/dist/app/create/create-values.js +52 -0
- package/dist/app/delete/dialogs.js +109 -0
- package/dist/app/delete/record-diff.js +17 -0
- package/dist/app/delete/use-delete-flow.js +193 -0
- package/dist/app/delete/use-restore.js +9 -0
- package/dist/app/draft-storage.js +18 -0
- package/dist/app/error-banner.js +28 -0
- package/dist/app/field-editor.js +88 -0
- package/dist/app/icons.js +27 -0
- package/dist/app/index.d.ts +2 -0
- package/dist/app/index.html +12 -0
- package/dist/app/list-chart.js +36 -0
- package/dist/app/list-order.js +15 -0
- package/dist/app/list-page.js +213 -0
- package/dist/app/list-search.js +30 -0
- package/dist/app/main.d.ts +1 -0
- package/dist/app/main.js +14 -0
- package/dist/app/menu-icons.js +21 -0
- package/dist/app/meta-gate.js +46 -0
- package/dist/app/nav-from-meta.js +65 -0
- package/dist/app/nav-recent.js +33 -0
- package/dist/app/pages/action-href.js +7 -0
- package/dist/app/pages/blocks/action.js +38 -0
- package/dist/app/pages/blocks/block-props.js +5 -0
- package/dist/app/pages/blocks/card-frame.js +34 -0
- package/dist/app/pages/blocks/card-row.js +55 -0
- package/dist/app/pages/blocks/card.js +19 -0
- package/dist/app/pages/blocks/field.js +45 -0
- package/dist/app/pages/blocks/grid.js +34 -0
- package/dist/app/pages/blocks/link.js +27 -0
- package/dist/app/pages/blocks/modal-form.js +149 -0
- package/dist/app/pages/blocks/page-block.js +20 -0
- package/dist/app/pages/blocks/progress.js +54 -0
- package/dist/app/pages/blocks/record-card.js +56 -0
- package/dist/app/pages/blocks/show.js +43 -0
- package/dist/app/pages/blocks/stat.js +78 -0
- package/dist/app/pages/blocks/table.js +65 -0
- package/dist/app/pages/composed-page.js +19 -0
- package/dist/app/pages/confirm-action.js +20 -0
- package/dist/app/pages/field-value.js +58 -0
- package/dist/app/pages/project-ui.d.ts +30 -0
- package/dist/app/pages/project-ui.js +13 -0
- package/dist/app/pages/record-actions.js +40 -0
- package/dist/app/pages/record-scope.d.ts +16 -0
- package/dist/app/pages/record-scope.js +12 -0
- package/dist/app/pages/record-table.js +110 -0
- package/dist/app/pages/render-node.js +84 -0
- package/dist/app/pages/use-layout-data.js +73 -0
- package/dist/app/pages/use-path-value.js +65 -0
- package/dist/app/pages/use-relation-labels.js +30 -0
- package/dist/app/pages/use-run-action.js +88 -0
- package/dist/app/permissions.js +15 -0
- package/dist/app/record-lines.js +39 -0
- package/dist/app/record-page.js +318 -0
- package/dist/app/related-section.js +126 -0
- package/dist/app/relation-picker.js +69 -0
- package/dist/app/router.js +155 -0
- package/dist/app/search/search-model.js +70 -0
- package/dist/app/search/use-global-search.js +68 -0
- package/dist/app/sensitive-field.js +65 -0
- package/dist/app/use-lines-draft.js +45 -0
- package/dist/app/use-lines.js +19 -0
- package/dist/app/use-live-save.js +54 -0
- package/dist/app/use-nav-recent.js +41 -0
- package/dist/app/use-record-editor.js +154 -0
- package/dist/app/use-related-records.js +31 -0
- package/dist/app/user-menu-from-meta.js +36 -0
- package/dist/app-shell/app-shell.d.ts +4 -0
- package/dist/app-shell/app-shell.js +57 -0
- package/dist/app-shell/breadcrumb.js +37 -0
- package/dist/app-shell/drawer.js +30 -0
- package/dist/app-shell/global-search.d.ts +1 -0
- package/dist/app-shell/global-search.js +145 -0
- package/dist/app-shell/index.d.ts +9 -0
- package/dist/app-shell/logo.js +46 -0
- package/dist/app-shell/nav-open-storage.js +10 -0
- package/dist/app-shell/nav.d.ts +2 -0
- package/dist/app-shell/nav.js +85 -0
- package/dist/app-shell/page-header.d.ts +1 -0
- package/dist/app-shell/page-header.js +17 -0
- package/dist/app-shell/profile-menu.d.ts +2 -0
- package/dist/app-shell/profile-menu.js +115 -0
- package/dist/app-shell/sidebar-group.js +94 -0
- package/dist/app-shell/sidebar-item.js +46 -0
- package/dist/app-shell/sidebar-mode.d.ts +4 -0
- package/dist/app-shell/sidebar-mode.js +13 -0
- package/dist/app-shell/sidebar.d.ts +2 -0
- package/dist/app-shell/sidebar.js +64 -0
- package/dist/app-shell/status-dot.d.ts +1 -0
- package/dist/app-shell/status-dot.js +20 -0
- package/dist/app-shell/top-bar.js +82 -0
- package/dist/assistant-dock/assistant-dock.d.ts +15 -0
- package/dist/assistant-dock/assistant-dock.js +81 -0
- package/dist/assistant-dock/chat-message.js +9 -0
- package/dist/assistant-dock/composer.js +39 -0
- package/dist/assistant-dock/dock-item.d.ts +4 -0
- package/dist/assistant-dock/dock-item.js +37 -0
- package/dist/assistant-dock/model.d.ts +84 -0
- package/dist/assistant-dock/model.js +26 -0
- package/dist/assistant-dock/plan-card.d.ts +13 -0
- package/dist/assistant-dock/plan-card.js +133 -0
- package/dist/assistant-dock/progress-card.js +18 -0
- package/dist/assistant-dock/query-card.js +52 -0
- package/dist/assistant-dock/queue-card.js +31 -0
- package/dist/assistant-dock/result-card.js +77 -0
- package/dist/assistant-panel/content.d.ts +7 -0
- package/dist/assistant-panel/step-list.js +30 -0
- package/dist/auth/auth-gate.js +70 -0
- package/dist/auth/auth-layout.js +29 -0
- package/dist/auth/auth-provider.js +71 -0
- package/dist/auth/first-run-page.js +51 -0
- package/dist/auth/forgot-password-form.js +75 -0
- package/dist/auth/forgot-password-page.js +35 -0
- package/dist/auth/reset-link.js +29 -0
- package/dist/auth/reset-password-form.js +88 -0
- package/dist/auth/reset-password-page.js +43 -0
- package/dist/auth/sign-in-form.js +90 -0
- package/dist/auth/sign-in-page.js +39 -0
- package/dist/auth/user-to-shell.js +18 -0
- package/dist/data/api-provider.d.ts +54 -0
- package/dist/data/api-provider.js +53 -0
- package/dist/data/meta-data.d.ts +14 -0
- package/dist/data/meta-data.js +9 -0
- package/dist/data/query-keys.js +95 -0
- package/dist/data/use-aggregates.d.ts +8 -0
- package/dist/data/use-aggregates.js +43 -0
- package/dist/data/use-list.d.ts +231 -0
- package/dist/data/use-list.js +31 -0
- package/dist/data/use-meta.d.ts +8 -0
- package/dist/data/use-meta.js +35 -0
- package/dist/data/use-record.d.ts +6 -0
- package/dist/data/use-record.js +16 -0
- package/dist/data/use-update-record.d.ts +15 -0
- package/dist/data/use-update-record.js +26 -0
- package/dist/data-grid/cells.js +85 -0
- package/dist/data-grid/column-spec.js +4 -0
- package/dist/data-grid/columns.js +16 -0
- package/dist/data-grid/data-grid.js +238 -0
- package/dist/data-grid/geometry.js +32 -0
- package/dist/data-grid/grid-footer.js +33 -0
- package/dist/data-grid/grid-header.js +69 -0
- package/dist/data-grid/grid-row.js +83 -0
- package/dist/data-grid/grid-template.js +12 -0
- package/dist/data-grid/header-menu.js +52 -0
- package/dist/data-grid/page-cache.js +27 -0
- package/dist/data-grid/pinned-storage.js +13 -0
- package/dist/data-grid/row-menu.js +88 -0
- package/dist/data-grid/use-grid-scroll.js +80 -0
- package/dist/data-grid/use-horizontal-scroll.js +38 -0
- package/dist/data-grid/use-paged-rows.js +50 -0
- package/dist/data-grid/use-pinned-columns.js +16 -0
- package/dist/filter-panel/active-filter-chips.js +34 -0
- package/dist/filter-panel/date-presets.js +20 -0
- package/dist/filter-panel/facet-group.js +48 -0
- package/dist/filter-panel/filter-config.js +18 -0
- package/dist/filter-panel/filter-menu.js +41 -0
- package/dist/filter-panel/filter-panel.js +112 -0
- package/dist/filter-panel/filter-section.js +11 -0
- package/dist/filter-panel/filter-state.js +63 -0
- package/dist/filter-panel/range-filter.js +54 -0
- package/dist/filter-panel/range-math.js +11 -0
- package/dist/filter-panel/range-slider.css +32 -0
- package/dist/filter-panel/toggle-row.js +13 -0
- package/dist/filter-panel/use-filter-state.js +15 -0
- package/dist/format/date.js +37 -0
- package/dist/format/money.js +15 -0
- package/dist/format/number.js +12 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +11 -0
- package/dist/inline-child-table/columns.js +4 -0
- package/dist/inline-child-table/inline-child-table.js +120 -0
- package/dist/inline-child-table/line-menu.js +54 -0
- package/dist/inline-child-table/line-row.js +91 -0
- package/dist/inline-child-table/number-cell.js +25 -0
- package/dist/inline-child-table/reorder.js +10 -0
- package/dist/inline-child-table/totals-footer.js +42 -0
- package/dist/lib/cn.js +4 -0
- package/dist/lib/guarded.js +12 -0
- package/dist/lib/use-debounced.js +13 -0
- package/dist/lib/use-focus-trap.js +46 -0
- package/dist/lib/use-media-query.js +12 -0
- package/dist/live/columns-from-model.js +93 -0
- package/dist/live/display-value.js +24 -0
- package/dist/live/field-values.js +32 -0
- package/dist/live/filter-string.js +135 -0
- package/dist/live/grid-loader.js +63 -0
- package/dist/live/model-helpers.js +30 -0
- package/dist/live/naming.js +9 -0
- package/dist/live/relation-labels.js +53 -0
- package/dist/live/use-filter-config.js +88 -0
- package/dist/live/use-live-rows.js +45 -0
- package/dist/live/view-fields.js +35 -0
- package/dist/mocks/activity.js +24 -0
- package/dist/mocks/invoices.js +13 -0
- package/dist/mocks/products.js +167 -0
- package/dist/mocks/rng.js +20 -0
- package/dist/mocks/users.js +63 -0
- package/dist/primitives/avatar.js +22 -0
- package/dist/primitives/badge.d.ts +1 -0
- package/dist/primitives/badge.js +25 -0
- package/dist/primitives/button.js +23 -0
- package/dist/primitives/checkbox.js +21 -0
- package/dist/primitives/dialog.js +41 -0
- package/dist/primitives/input.js +16 -0
- package/dist/primitives/kbd.js +8 -0
- package/dist/primitives/segmented.js +14 -0
- package/dist/primitives/sheet.js +44 -0
- package/dist/primitives/skeleton.js +6 -0
- package/dist/primitives/spinner.js +10 -0
- package/dist/primitives/switch.js +14 -0
- package/dist/primitives/tooltip.js +13 -0
- package/dist/record-view/action-rail.js +28 -0
- package/dist/record-view/field.js +62 -0
- package/dist/record-view/record-header.js +50 -0
- package/dist/record-view/record-sidebar.js +58 -0
- package/dist/record-view/section.js +21 -0
- package/dist/styles.css +132 -0
- package/dist/toasts/toast-provider.js +69 -0
- package/dist/toasts/toast.js +45 -0
- package/package.json +45 -3
- 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 };
|