@protobase/ui 0.21.8 → 0.21.9
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/dist/app/app.js +1 -0
- package/dist/app/charts/chart-view.js +3 -1
- package/dist/app/displays/calendar-controls.js +2 -1
- package/dist/app/displays/calendar-display.js +2 -1
- package/dist/app/displays/calendar-event.js +2 -1
- package/dist/app/displays/calendar-range.js +18 -14
- package/dist/app/export/cells.js +6 -4
- package/dist/app/field-editor.js +13 -1
- package/dist/app/import/convert.js +2 -12
- package/dist/app/import/locale-formats.js +2 -1
- package/dist/app/incoming-card.js +35 -0
- package/dist/app/incoming-relations.js +36 -0
- package/dist/app/list-chart.js +3 -1
- package/dist/app/pages/field-value.js +9 -1
- package/dist/app/pages/record-actions.js +34 -16
- package/dist/app/record-page.js +26 -18
- package/dist/app/runtime/runtime-status.js +5 -4
- package/dist/app/search/navigation-hits.js +45 -0
- package/dist/app/search/use-global-search.js +13 -4
- package/dist/app/use-incoming-counts.js +38 -0
- package/dist/app/use-related-records.js +3 -10
- package/dist/app-shell/app-shell.js +9 -2
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/logo.d.ts +5 -1
- package/dist/app-shell/logo.js +10 -3
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/top-bar-slot.js +11 -0
- package/dist/app-shell/top-bar.d.ts +3 -0
- package/dist/app-shell/top-bar.js +27 -19
- package/dist/data-grid/cells.js +8 -1
- package/dist/data-grid/grid-header.js +17 -16
- package/dist/data-grid/header-menu.js +16 -6
- package/dist/data-grid/row-menu.js +2 -39
- package/dist/format/date.js +29 -9
- package/dist/format/number-text.js +16 -0
- package/dist/format/zone.js +62 -0
- package/dist/live/columns-from-model.js +2 -0
- package/dist/live/contact-link.js +25 -0
- package/dist/live/contact-links.js +42 -0
- package/dist/live/display-value.js +14 -4
- package/dist/live/field-values.js +20 -9
- package/dist/live/filter-string.js +30 -30
- package/dist/package.js +1 -1
- package/dist/primitives/popover-menu.js +56 -0
- package/dist/record-view/action-menu.js +59 -0
- package/dist/record-view/record-header.js +2 -6
- package/dist/record-view/record-sidebar.js +2 -30
- package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
- package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
- package/dist/shell/assets/{index-xN6_oZU2.js → index-CRNWPM01.js} +1 -1
- package/dist/shell/assets/index-CzqIlZPP.css +2 -0
- package/dist/shell/assets/{maplibre-Ba9RapjY.js → maplibre-DZlOrjmh.js} +1 -1
- package/dist/shell/assets/{markdown-content-BUxrbovf.js → markdown-content-DT_18T89.js} +1 -1
- package/dist/shell/assets/{record-map-14zotZ2s.js → record-map-Bmns3gJf.js} +1 -1
- package/dist/shell/assets/{records-map-wi-0pXqF.js → records-map-Ci3qFCDs.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BVwhKZk9.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-C01lazBK.js +0 -69
- package/dist/shell/assets/export-worker-BnOpS_4Q.js +0 -2
- package/dist/shell/assets/index-CBGigFxb.css +0 -2
package/dist/app/app.js
CHANGED
|
@@ -193,6 +193,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
193
193
|
onSignOut: user && (() => void signOut()),
|
|
194
194
|
signIn: user ? void 0 : signIn.link(),
|
|
195
195
|
workspace: current?.organization?.name ?? workspace,
|
|
196
|
+
appName: meta.appName,
|
|
196
197
|
logo: ProjectLogo,
|
|
197
198
|
navGroups: groups,
|
|
198
199
|
bottomBar: bottomBarFromMeta(meta, basePath, place),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { viewerZone } from "../../format/zone.js";
|
|
2
3
|
import { labelFieldOf } from "../../live/model-helpers.js";
|
|
3
4
|
import { useAdminMeta } from "../meta-gate.js";
|
|
4
5
|
import { useAggregate } from "../../data/use-aggregates.js";
|
|
@@ -30,7 +31,8 @@ const ChartView = ({ model, view, groupBy, type = "bar", sum, average, filter =
|
|
|
30
31
|
const label = target && target.primaryKey.length === 1 ? labelFieldOf(target, views[target.name]) : void 0;
|
|
31
32
|
const params = dated ? {
|
|
32
33
|
granularity,
|
|
33
|
-
range
|
|
34
|
+
range,
|
|
35
|
+
timeZone: viewerZone()
|
|
34
36
|
} : {
|
|
35
37
|
limit,
|
|
36
38
|
...label && { label }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { segmentedClass } from "../../primitives/segmented.js";
|
|
2
|
+
import { dayIn, viewerZone } from "../../format/zone.js";
|
|
2
3
|
import { formatDayRange, formatMonth } from "../../format/date.js";
|
|
3
4
|
import { buttonClass } from "../../primitives/button.js";
|
|
4
5
|
import { AppLink } from "../use-app-address.js";
|
|
@@ -31,7 +32,7 @@ const CalendarControls = ({ view, now, linkTo, onView }) => {
|
|
|
31
32
|
onClick: () => onView?.(to),
|
|
32
33
|
children
|
|
33
34
|
});
|
|
34
|
-
const holdsToday = view.scale === "month" &&
|
|
35
|
+
const holdsToday = view.scale === "month" && dayIn(now, viewerZone()).startsWith(view.day.slice(0, 7));
|
|
35
36
|
const scaled = (to) => to === "week" && holdsToday ? todayView("week", now) : calendarView({
|
|
36
37
|
scale: to,
|
|
37
38
|
day: view.day
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
2
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
|
+
import { dayIn, viewerZone } from "../../format/zone.js";
|
|
3
4
|
import { formatDate, formatWeekday } from "../../format/date.js";
|
|
4
5
|
import { ErrorBanner } from "../error-banner.js";
|
|
5
6
|
import { usePermissions } from "../meta-gate.js";
|
|
@@ -51,7 +52,7 @@ const CalendarDisplay = ({ model, view, display, fields, filter, at, linkTo, now
|
|
|
51
52
|
},
|
|
52
53
|
coordinateGetter: dayCoordinates
|
|
53
54
|
}));
|
|
54
|
-
const today =
|
|
55
|
+
const today = dayIn(now, viewerZone());
|
|
55
56
|
const week = shown.scale === "week";
|
|
56
57
|
const dayText = (id) => formatDate(Date.parse(`${String(id).slice(dayId("").length)}T00:00:00Z`));
|
|
57
58
|
const title = (picked) => eventTitle(model, view, display, picked.row);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { dayIn, viewerZone } from "../../format/zone.js";
|
|
2
3
|
import { formatTime } from "../../format/date.js";
|
|
3
4
|
import { recordTitleField } from "../../live/model-helpers.js";
|
|
4
5
|
import { displayValue } from "../../live/display-value.js";
|
|
@@ -19,7 +20,7 @@ const startTime = (model, display, row, day) => {
|
|
|
19
20
|
const value = row.record[display.start];
|
|
20
21
|
if (model.fields[display.start]?.type !== "timestamp" || typeof value !== "string") return void 0;
|
|
21
22
|
const ms = Date.parse(value);
|
|
22
|
-
return
|
|
23
|
+
return dayIn(ms, viewerZone()) === day ? formatTime(ms) : void 0;
|
|
23
24
|
};
|
|
24
25
|
/** An event as a one-line link to its record, its time first for a timestamp, styled as a small record card. */
|
|
25
26
|
const EventChip = ({ model, view, display, row, day }) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dayIn, instantAt, startOfDayIn, viewerZone, zoneOffset } from "../../format/zone.js";
|
|
1
2
|
import { printFilter, where } from "@protobase/schema";
|
|
2
3
|
//#region src/app/displays/calendar-range.ts
|
|
3
4
|
const dayMs = 864e5;
|
|
@@ -15,14 +16,14 @@ const normalised = ({ scale, day }) => ({
|
|
|
15
16
|
day: scale === "month" ? monthOf(day) : monday(day)
|
|
16
17
|
});
|
|
17
18
|
/** The month or week a calendar shows: the address's, starting on the month's 1st or the week's Monday, else the month of `now`. */
|
|
18
|
-
const calendarView = (at, now) => normalised(at ?? {
|
|
19
|
+
const calendarView = (at, now, zone = viewerZone()) => normalised(at ?? {
|
|
19
20
|
scale: "month",
|
|
20
|
-
day:
|
|
21
|
+
day: dayIn(now, zone)
|
|
21
22
|
});
|
|
22
23
|
/** The month or week of `scale` that holds `now`. */
|
|
23
|
-
const todayView = (scale, now) => normalised({
|
|
24
|
+
const todayView = (scale, now, zone = viewerZone()) => normalised({
|
|
24
25
|
scale,
|
|
25
|
-
day:
|
|
26
|
+
day: dayIn(now, zone)
|
|
26
27
|
});
|
|
27
28
|
/** The month or week before (-1) or after (1) `view`. */
|
|
28
29
|
const stepView = (view, step) => view.scale === "month" ? {
|
|
@@ -53,11 +54,12 @@ const isTimestamp = (display, model) => model.fields[display.start]?.type === "t
|
|
|
53
54
|
* it and ending in or after its first day. A timestamp's `end` is the instant it is over, so one ending at midnight is
|
|
54
55
|
* not on the day it starts. Each branch reads one indexed field.
|
|
55
56
|
*/
|
|
56
|
-
const rangeFilter = (display, model, range) => {
|
|
57
|
+
const rangeFilter = (display, model, range, zone = viewerZone()) => {
|
|
57
58
|
const timestamp = isTimestamp(display, model);
|
|
59
|
+
const instant = (day) => new Date(startOfDayIn(day, zone)).toISOString().replace(".000Z", "Z");
|
|
58
60
|
const bound = (day) => timestamp ? {
|
|
59
61
|
kind: "timestamp",
|
|
60
|
-
value:
|
|
62
|
+
value: instant(day),
|
|
61
63
|
span: {
|
|
62
64
|
start: 0,
|
|
63
65
|
end: 0
|
|
@@ -86,33 +88,35 @@ const instantOf = (value) => {
|
|
|
86
88
|
* The days of `days` a record is on: from its start's day through its end's, both included, where a timestamp `end`
|
|
87
89
|
* at midnight is over before that day starts; its start day alone without an end or with one before its start.
|
|
88
90
|
*/
|
|
89
|
-
const eventDays = (record, display, model, days) => {
|
|
91
|
+
const eventDays = (record, display, model, days, zone = viewerZone()) => {
|
|
90
92
|
const start = instantOf(record[display.start]);
|
|
91
93
|
if (start === void 0) return [];
|
|
92
|
-
const
|
|
94
|
+
const timestamp = isTimestamp(display, model);
|
|
95
|
+
const day = (ms) => timestamp ? dayIn(ms, zone) : dayOf(ms);
|
|
96
|
+
const first = day(start);
|
|
93
97
|
const end = display.end === void 0 ? void 0 : instantOf(record[display.end]);
|
|
94
|
-
const ending = end === void 0 ? first :
|
|
98
|
+
const ending = end === void 0 ? first : day(timestamp ? end - 1 : end);
|
|
95
99
|
const last = ending < first ? first : ending;
|
|
96
100
|
return days.filter((day) => day >= first && day <= last);
|
|
97
101
|
};
|
|
98
102
|
/** The rows on each day of `days`, each day's in start order. */
|
|
99
|
-
const byDay = (rows, display, model, days) => {
|
|
103
|
+
const byDay = (rows, display, model, days, zone = viewerZone()) => {
|
|
100
104
|
const placed = new Map(days.map((day) => [day, []]));
|
|
101
105
|
const ordered = rows.toSorted((a, b) => (instantOf(a.record[display.start]) ?? 0) - (instantOf(b.record[display.start]) ?? 0));
|
|
102
|
-
for (const row of ordered) for (const day of eventDays(row.record, display, model, days)) placed.get(day).push(row);
|
|
106
|
+
for (const row of ordered) for (const day of eventDays(row.record, display, model, days, zone)) placed.get(day).push(row);
|
|
103
107
|
return placed;
|
|
104
108
|
};
|
|
105
109
|
/**
|
|
106
110
|
* The update that moves a record shown on `from` to `to`: its `start`, and its `end` when it has one, moved by the same
|
|
107
|
-
* number of days, a timestamp keeping its time of day
|
|
111
|
+
* number of days, a timestamp keeping its time of day on the clock of `zone`.
|
|
108
112
|
*/
|
|
109
|
-
const movedPatch = (record, display, model, from, to) => {
|
|
113
|
+
const movedPatch = (record, display, model, from, to, zone = viewerZone()) => {
|
|
110
114
|
const days = (startOf(to) - startOf(from)) / dayMs;
|
|
111
115
|
const timestamp = isTimestamp(display, model);
|
|
112
116
|
const moved = (value) => {
|
|
113
117
|
const ms = instantOf(value);
|
|
114
118
|
if (ms === void 0) return void 0;
|
|
115
|
-
return timestamp ? new Date(ms + days * dayMs).toISOString() : addDays(dayOf(ms), days);
|
|
119
|
+
return timestamp ? new Date(instantAt(ms + zoneOffset(ms, zone) + days * dayMs, zone)).toISOString() : addDays(dayOf(ms), days);
|
|
116
120
|
};
|
|
117
121
|
const start = moved(record[display.start]);
|
|
118
122
|
const end = display.end === void 0 ? void 0 : moved(record[display.end]);
|
package/dist/app/export/cells.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { viewerZone, zoneOffset } from "../../format/zone.js";
|
|
1
2
|
import { fileShown, isStoredFile } from "../../live/file-values.js";
|
|
2
3
|
import { displayValue } from "../../live/display-value.js";
|
|
3
4
|
//#region src/app/export/cells.ts
|
|
@@ -57,17 +58,18 @@ const numberCell = (column, value, mode) => {
|
|
|
57
58
|
value: number
|
|
58
59
|
};
|
|
59
60
|
};
|
|
60
|
-
const dateCell = (type, value, mode) => {
|
|
61
|
+
const dateCell = (type, value, mode, zone) => {
|
|
61
62
|
const ms = Date.parse(String(value));
|
|
62
63
|
if (Number.isNaN(ms)) return text(String(value));
|
|
64
|
+
const clock = type === "timestamp" && mode === "shown" ? ms + zoneOffset(ms, zone) : ms;
|
|
63
65
|
return {
|
|
64
66
|
kind: "number",
|
|
65
|
-
value: excelSerial(
|
|
67
|
+
value: excelSerial(clock),
|
|
66
68
|
format: dateFormats[mode][type]
|
|
67
69
|
};
|
|
68
70
|
};
|
|
69
71
|
/** A value as one typed Excel cell: numbers, dates and booleans as such, labels or stored values as text. */
|
|
70
|
-
const xlsxCell = (column, value, labels, mode) => {
|
|
72
|
+
const xlsxCell = (column, value, labels, mode, zone = viewerZone()) => {
|
|
71
73
|
if (isEmpty(value)) return empty;
|
|
72
74
|
const { field } = column;
|
|
73
75
|
switch (field.type) {
|
|
@@ -79,7 +81,7 @@ const xlsxCell = (column, value, labels, mode) => {
|
|
|
79
81
|
case "bigint":
|
|
80
82
|
case "decimal": return numberCell(column, value, mode);
|
|
81
83
|
case "date":
|
|
82
|
-
case "timestamp": return dateCell(field.type, value, mode);
|
|
84
|
+
case "timestamp": return dateCell(field.type, value, mode, zone);
|
|
83
85
|
case "relation":
|
|
84
86
|
if (mode === "shown") return text(relationLabel(column, value, labels));
|
|
85
87
|
return typeof value === "number" ? {
|
package/dist/app/field-editor.js
CHANGED
|
@@ -7,7 +7,8 @@ import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
|
|
|
7
7
|
import { Switch } from "../primitives/switch.js";
|
|
8
8
|
import { Textarea } from "../primitives/textarea.js";
|
|
9
9
|
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
10
|
-
import {
|
|
10
|
+
import { contactFormat, contactInputProps } from "../live/contact-links.js";
|
|
11
|
+
import { displayValue, valueTitle } from "../live/display-value.js";
|
|
11
12
|
import { AppLink } from "./use-app-address.js";
|
|
12
13
|
import { RelationPicker } from "./relation-picker.js";
|
|
13
14
|
import { SensitiveField } from "./sensitive-field.js";
|
|
@@ -17,6 +18,7 @@ import { WidgetPart } from "./field-types/widget-part.js";
|
|
|
17
18
|
import { Markdown } from "../markdown/markdown.js";
|
|
18
19
|
import { MarkdownEditor } from "../markdown/markdown-editor.js";
|
|
19
20
|
import { numeric, numericInputProps } from "./numeric-input.js";
|
|
21
|
+
import { ContactLink } from "../live/contact-link.js";
|
|
20
22
|
import { ArrowUpRight } from "lucide-react";
|
|
21
23
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
22
24
|
import { enumLabel } from "@protobase/schema";
|
|
@@ -95,7 +97,16 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
95
97
|
source: stored,
|
|
96
98
|
className: "py-1.5"
|
|
97
99
|
});
|
|
100
|
+
const contact = field.type === "text" ? contactFormat(hints) : void 0;
|
|
101
|
+
if ((!isEditable(field) || locked) && contact && typeof stored === "string" && stored !== "") return /* @__PURE__ */ jsx("div", {
|
|
102
|
+
className: "min-w-0 break-words py-1.5",
|
|
103
|
+
children: /* @__PURE__ */ jsx(ContactLink, {
|
|
104
|
+
format: contact,
|
|
105
|
+
value: stored
|
|
106
|
+
})
|
|
107
|
+
});
|
|
98
108
|
if (!isEditable(field) || locked) return /* @__PURE__ */ jsx("div", {
|
|
109
|
+
title: valueTitle(field, stored),
|
|
99
110
|
className: cn("py-1.5 tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground", multiline && "whitespace-pre-line break-words"),
|
|
100
111
|
children: displayValue(field, stored, hints)
|
|
101
112
|
});
|
|
@@ -133,6 +144,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
|
|
|
133
144
|
placeholder,
|
|
134
145
|
className: cn(numeric.has(field.type) && "text-right tabular-nums"),
|
|
135
146
|
...numericInputProps(field.type),
|
|
147
|
+
...contactInputProps(contact),
|
|
136
148
|
value: String(value),
|
|
137
149
|
onChange: (event) => onChange(event.target.value)
|
|
138
150
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseNumberText } from "../../format/number-text.js";
|
|
1
2
|
import { cellText, isEmptyCell, isSheetDate } from "./sheet-cell.js";
|
|
2
3
|
import { enumLabel } from "@protobase/schema";
|
|
3
4
|
//#region src/app/import/convert.ts
|
|
@@ -10,17 +11,6 @@ const problem = (text) => ({
|
|
|
10
11
|
problem: text
|
|
11
12
|
});
|
|
12
13
|
const quoted = (cell) => `“${cellText(cell)}”`;
|
|
13
|
-
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
-
/**
|
|
15
|
-
* A number written as text, as a plain decimal (`-1234.5`): digits grouped by thousands with the other separator or
|
|
16
|
-
* spaces, and the `decimal` separator before the fraction. Undefined when the text is no such number.
|
|
17
|
-
*/
|
|
18
|
-
const parseNumberText = (text, decimal) => {
|
|
19
|
-
const group = `[${escapeRegExp(decimal === "." ? "," : ".")}\\s\\u00a0\\u202f']`;
|
|
20
|
-
const match = new RegExp(`^([-+]?)(\\d{1,3}(?:${group}\\d{3})+|\\d+)(?:${escapeRegExp(decimal)}(\\d+))?$`).exec(text.trim());
|
|
21
|
-
if (match) return `${match[1] === "-" ? "-" : ""}${match[2].replace(/\D/g, "")}${match[3] ? `.${match[3]}` : ""}`;
|
|
22
|
-
if (decimal === "." && /^[-+]?\d+(\.\d+)?e[-+]?\d+$/i.test(text.trim())) return String(Number(text.trim()));
|
|
23
|
-
};
|
|
24
14
|
const toNumberText = (cell, options) => {
|
|
25
15
|
if (typeof cell === "number") return String(cell);
|
|
26
16
|
if (typeof cell === "string") return parseNumberText(cell, options.decimal);
|
|
@@ -148,4 +138,4 @@ const convertCell = (field, hints, cell, options) => {
|
|
|
148
138
|
}
|
|
149
139
|
};
|
|
150
140
|
//#endregion
|
|
151
|
-
export { convertCell
|
|
141
|
+
export { convertCell };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { localeDecimal } from "../../format/number-text.js";
|
|
1
2
|
//#region src/app/import/locale-formats.ts
|
|
2
3
|
/**
|
|
3
4
|
* How the browser's locale writes numbers and dates, as the import's starting choice: its decimal separator, and whether
|
|
4
5
|
* its dates put the day or the month first. The user sees both and can change them.
|
|
5
6
|
*/
|
|
6
7
|
const localeFormats = (locale) => {
|
|
7
|
-
const decimal =
|
|
8
|
+
const decimal = localeDecimal(locale);
|
|
8
9
|
const parts = new Intl.DateTimeFormat(locale).formatToParts(new Date(2026, 3, 3)).map((part) => part.type);
|
|
9
10
|
return {
|
|
10
11
|
decimal,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Skeleton } from "../primitives/skeleton.js";
|
|
2
|
+
import { formatInt } from "../format/number.js";
|
|
3
|
+
import { SidebarCard } from "../record-view/record-sidebar.js";
|
|
4
|
+
import { useAdminMeta } from "./meta-gate.js";
|
|
5
|
+
import { AppLink } from "./use-app-address.js";
|
|
6
|
+
import { incomingAddress, incomingRelations } from "./incoming-relations.js";
|
|
7
|
+
import { useIncomingCounts } from "./use-incoming-counts.js";
|
|
8
|
+
import { ChevronRight } from "lucide-react";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
//#region src/app/incoming-card.tsx
|
|
11
|
+
/** The rail's card of the records pointing at this one: per resource and field, how many, linking to them in their list. */
|
|
12
|
+
const IncomingCard = ({ model, recordKey, layout, lines }) => {
|
|
13
|
+
const { resources, views } = useAdminMeta();
|
|
14
|
+
const counts = useIncomingCounts(incomingRelations(model, resources, views, layout, lines), resources, recordKey);
|
|
15
|
+
if (counts.length === 0) return null;
|
|
16
|
+
return /* @__PURE__ */ jsx(SidebarCard, {
|
|
17
|
+
title: "Related records",
|
|
18
|
+
children: /* @__PURE__ */ jsx("ul", {
|
|
19
|
+
className: "-mx-2",
|
|
20
|
+
children: counts.map(({ relation, count }) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
|
|
21
|
+
to: incomingAddress(relation, recordKey),
|
|
22
|
+
className: "group flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-muted",
|
|
23
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
24
|
+
className: "min-w-0 truncate font-medium text-primary-text",
|
|
25
|
+
children: relation.label
|
|
26
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
27
|
+
className: "flex shrink-0 items-center gap-1 tabular-nums text-muted-foreground",
|
|
28
|
+
children: [count === void 0 ? /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-6" }) : `${formatInt(count.count)}${count.exact ? "" : "+"}`, /* @__PURE__ */ jsx(ChevronRight, { className: "size-4 text-faint-foreground group-hover:text-foreground" })]
|
|
29
|
+
})]
|
|
30
|
+
}) }, `${relation.resource}.${relation.field}`))
|
|
31
|
+
})
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { IncomingCard };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { humanize } from "../live/naming.js";
|
|
2
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
3
|
+
//#region src/app/incoming-relations.ts
|
|
4
|
+
const where$1 = createWhere();
|
|
5
|
+
/**
|
|
6
|
+
* The relations that point at records of `model`, as the rail lists them by default: each filterable relation of
|
|
7
|
+
* another resource the user can see (a hidden resource or field is not in the meta), to a record of one key column.
|
|
8
|
+
* Left out are a resource's tenant, which every record holds, the lines drawn on the page, and what an `l.related`
|
|
9
|
+
* section of the page already lists. Two fields of one resource are told apart by their labels.
|
|
10
|
+
*/
|
|
11
|
+
const incomingRelations = (model, resources, views, layout, lines) => {
|
|
12
|
+
if (model.primaryKey.length !== 1) return [];
|
|
13
|
+
const listed = new Set(layout.flatMap((item) => item.kind === "related" && !item.through ? [`${item.resource}.${item.field}`] : []));
|
|
14
|
+
const found = Object.values(resources).flatMap((source) => Object.values(source.fields).filter((field) => field.type === "relation" && field.relation?.resource === model.name && field.relation.columns.length === 1 && field.filterable).filter((field) => field.name !== source.tenant && source.name !== lines?.name && !listed.has(`${source.name}.${field.name}`)).map((field) => ({
|
|
15
|
+
source,
|
|
16
|
+
field: field.name
|
|
17
|
+
})));
|
|
18
|
+
return found.map(({ source, field }) => {
|
|
19
|
+
const view = views[source.name];
|
|
20
|
+
const plural = view?.names?.plural ?? humanize(source.name);
|
|
21
|
+
const several = found.filter((other) => other.source.name === source.name).length > 1;
|
|
22
|
+
return {
|
|
23
|
+
resource: source.name,
|
|
24
|
+
field,
|
|
25
|
+
label: several ? `${plural} (${view?.fields[field]?.label ?? humanize(field)})` : plural
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
/** The list of the records that point at `key` through `relation`, filtered as its list's address reads. */
|
|
30
|
+
const incomingAddress = (relation, key) => ({
|
|
31
|
+
kind: "list",
|
|
32
|
+
resource: relation.resource,
|
|
33
|
+
filter: printFilter(where$1.eq(relation.field, key))
|
|
34
|
+
});
|
|
35
|
+
//#endregion
|
|
36
|
+
export { incomingAddress, incomingRelations };
|
package/dist/app/list-chart.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { viewerZone } from "../format/zone.js";
|
|
1
2
|
import { useSeries } from "../data/use-aggregates.js";
|
|
2
3
|
import { ActivityChart } from "../activity-chart/activity-chart.js";
|
|
3
4
|
import { useState } from "react";
|
|
@@ -18,7 +19,8 @@ const ListChart = ({ resource, chart, title, noun, filter }) => {
|
|
|
18
19
|
field: chart.field,
|
|
19
20
|
range,
|
|
20
21
|
granularity,
|
|
21
|
-
filter
|
|
22
|
+
filter,
|
|
23
|
+
timeZone: viewerZone()
|
|
22
24
|
}).data?.points.map((point) => ({
|
|
23
25
|
at: Date.parse(`${point.bucket}Z`),
|
|
24
26
|
count: point.count
|
|
@@ -3,13 +3,15 @@ import { keys } from "../../data/query-keys.js";
|
|
|
3
3
|
import { useClient } from "../../data/api-provider.js";
|
|
4
4
|
import { fileShown, shownImage } from "../../live/file-values.js";
|
|
5
5
|
import { isMarkdown } from "../../markdown/markdown-hint.js";
|
|
6
|
-
import {
|
|
6
|
+
import { contactFormat } from "../../live/contact-links.js";
|
|
7
|
+
import { displayValue, valueTitle } from "../../live/display-value.js";
|
|
7
8
|
import { useAdminMeta } from "../meta-gate.js";
|
|
8
9
|
import { AppLink } from "../use-app-address.js";
|
|
9
10
|
import { FileImage } from "../../live/file-image.js";
|
|
10
11
|
import { useShownWidget } from "../field-types/shown-widget.js";
|
|
11
12
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
12
13
|
import { Markdown } from "../../markdown/markdown.js";
|
|
14
|
+
import { ContactLink } from "../../live/contact-link.js";
|
|
13
15
|
import { fetchLabels } from "../../live/relation-labels.js";
|
|
14
16
|
import { ValueBadge } from "../value-badge.js";
|
|
15
17
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -74,12 +76,18 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
|
|
|
74
76
|
}
|
|
75
77
|
if (isMarkdown(field, hints)) return /* @__PURE__ */ jsx(Markdown, { source: String(value) });
|
|
76
78
|
const text = displayValue(field, value, hints);
|
|
79
|
+
const contact = field.type === "text" ? contactFormat(hints) : void 0;
|
|
80
|
+
if (contact) return /* @__PURE__ */ jsx(ContactLink, {
|
|
81
|
+
format: contact,
|
|
82
|
+
value: text
|
|
83
|
+
});
|
|
77
84
|
if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(ValueBadge, {
|
|
78
85
|
value,
|
|
79
86
|
hints,
|
|
80
87
|
children: text
|
|
81
88
|
});
|
|
82
89
|
return /* @__PURE__ */ jsx("span", {
|
|
90
|
+
title: valueTitle(field, value),
|
|
83
91
|
className: cn(hints.format === "code" && "font-mono text-xs", (field.type === "decimal" || field.type === "integer") && "tabular-nums", hints.multiline && "whitespace-pre-line break-words"),
|
|
84
92
|
children: text
|
|
85
93
|
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { useTopBarSlot } from "../../app-shell/top-bar-slot.js";
|
|
2
|
+
import "../../app-shell/top-bar.js";
|
|
1
3
|
import { ActionRail } from "../../record-view/action-rail.js";
|
|
4
|
+
import { ActionMenu } from "../../record-view/action-menu.js";
|
|
2
5
|
import { AppLink, useAppAddress } from "../use-app-address.js";
|
|
3
6
|
import { ActionPrompt, asksFirst } from "./action-prompt.js";
|
|
4
7
|
import { useActionRunner } from "./use-run-action.js";
|
|
@@ -6,14 +9,17 @@ import { actionIcon } from "../action-icon.js";
|
|
|
6
9
|
import { useState } from "react";
|
|
7
10
|
import { Pencil, Trash2 } from "lucide-react";
|
|
8
11
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
9
13
|
//#region src/app/pages/record-actions.tsx
|
|
10
14
|
/**
|
|
11
15
|
* The record page's actions in one rail: Edit, a link to the form at `edit`, when the page offers it; the view's named
|
|
12
16
|
* actions, run like the Action block runs them, a delete among them returning to the list; and the record's own Delete,
|
|
13
|
-
* `onDelete`, unless a named action deletes already.
|
|
17
|
+
* `onDelete`, unless a named action deletes already. On a mobile screen they sit in the top bar's menu instead, at hand
|
|
18
|
+
* however long the record is, when the shell has a top bar.
|
|
14
19
|
*/
|
|
15
20
|
const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
|
|
16
21
|
const { go } = useAppAddress();
|
|
22
|
+
const slot = useTopBarSlot();
|
|
17
23
|
const scope = {
|
|
18
24
|
model,
|
|
19
25
|
record: stored.record,
|
|
@@ -39,8 +45,9 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
|
|
|
39
45
|
...edit ? [{
|
|
40
46
|
name: "Edit",
|
|
41
47
|
icon: Pencil,
|
|
42
|
-
render: ({ className, children }) => /* @__PURE__ */ jsx(AppLink, {
|
|
48
|
+
render: ({ className, children, role }) => /* @__PURE__ */ jsx(AppLink, {
|
|
43
49
|
to: edit,
|
|
50
|
+
role,
|
|
44
51
|
className,
|
|
45
52
|
children
|
|
46
53
|
})
|
|
@@ -54,20 +61,31 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
|
|
|
54
61
|
}] : []
|
|
55
62
|
];
|
|
56
63
|
if (actions.length === 0) return null;
|
|
57
|
-
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
65
|
+
/* @__PURE__ */ jsx("div", {
|
|
66
|
+
className: slot ? "max-md:hidden" : void 0,
|
|
67
|
+
children: /* @__PURE__ */ jsx(ActionRail, { actions })
|
|
68
|
+
}),
|
|
69
|
+
slot && createPortal(/* @__PURE__ */ jsx(ActionMenu, {
|
|
70
|
+
actions,
|
|
71
|
+
label: "Record actions",
|
|
72
|
+
className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden"
|
|
73
|
+
}), slot),
|
|
74
|
+
asking && /* @__PURE__ */ jsx(ActionPrompt, {
|
|
75
|
+
action: asking,
|
|
76
|
+
label: asking.label,
|
|
77
|
+
danger: asking.run?.kind === "delete",
|
|
78
|
+
onCancel: () => setAsking(void 0),
|
|
79
|
+
onConfirm: () => {
|
|
80
|
+
setAsking(void 0);
|
|
81
|
+
runner.run(asking);
|
|
82
|
+
},
|
|
83
|
+
onSubmit: async (values) => {
|
|
84
|
+
await runner.submit(asking, values);
|
|
85
|
+
setAsking(void 0);
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
] });
|
|
71
89
|
};
|
|
72
90
|
//#endregion
|
|
73
91
|
export { RecordActions };
|
package/dist/app/record-page.js
CHANGED
|
@@ -6,12 +6,12 @@ import { Button, buttonClass } from "../primitives/button.js";
|
|
|
6
6
|
import { usePauseFocusRefresh } from "../data/focus-refresh.js";
|
|
7
7
|
import { keys } from "../data/query-keys.js";
|
|
8
8
|
import { useClient } from "../data/api-provider.js";
|
|
9
|
-
import { useNavigationGuard
|
|
9
|
+
import { useNavigationGuard } from "./router.js";
|
|
10
10
|
import { useOpenedRecord } from "../data/use-record.js";
|
|
11
11
|
import { humanize } from "../live/naming.js";
|
|
12
12
|
import { Field } from "../record-view/field.js";
|
|
13
13
|
import { Section } from "../record-view/section.js";
|
|
14
|
-
import { MetadataCard
|
|
14
|
+
import { MetadataCard } from "../record-view/record-sidebar.js";
|
|
15
15
|
import { RecordHeader } from "../record-view/record-header.js";
|
|
16
16
|
import { SpecTable } from "../record-view/spec-table.js";
|
|
17
17
|
import { KeyFacts } from "../record-view/key-facts.js";
|
|
@@ -21,8 +21,9 @@ import { isChosen, keyValue } from "./create/create-values.js";
|
|
|
21
21
|
import { ErrorBanner } from "./error-banner.js";
|
|
22
22
|
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
23
23
|
import { recordTitle } from "../live/model-helpers.js";
|
|
24
|
+
import { contactFormat } from "../live/contact-links.js";
|
|
24
25
|
import { toneFor } from "../live/columns-from-model.js";
|
|
25
|
-
import { displayValue } from "../live/display-value.js";
|
|
26
|
+
import { displayValue, valueTitle } from "../live/display-value.js";
|
|
26
27
|
import { filledFields } from "./filled-fields.js";
|
|
27
28
|
import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
28
29
|
import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
@@ -30,6 +31,7 @@ import { useProjectUi } from "./pages/project-ui.js";
|
|
|
30
31
|
import { shownWidget } from "./field-types/shown-widget.js";
|
|
31
32
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
32
33
|
import { Markdown } from "../markdown/markdown.js";
|
|
34
|
+
import { ContactLink } from "../live/contact-link.js";
|
|
33
35
|
import { FieldEditor } from "./field-editor.js";
|
|
34
36
|
import { fieldMessage } from "./field-errors.js";
|
|
35
37
|
import { useFocusFirstInvalid } from "./use-focus-first-invalid.js";
|
|
@@ -52,6 +54,7 @@ import { HistorySection } from "./history/history-section.js";
|
|
|
52
54
|
import { CommentsSection } from "./comments/comments-section.js";
|
|
53
55
|
import { useRecordEditor } from "./use-record-editor.js";
|
|
54
56
|
import { useRelatedRecords } from "./use-related-records.js";
|
|
57
|
+
import { IncomingCard } from "./incoming-card.js";
|
|
55
58
|
import { RelatedSection } from "./related-section.js";
|
|
56
59
|
import { Fragment, useMemo } from "react";
|
|
57
60
|
import { Check } from "lucide-react";
|
|
@@ -121,8 +124,7 @@ const RecordPage = ({ resource, recordKey }) => {
|
|
|
121
124
|
};
|
|
122
125
|
const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
123
126
|
const meta = useAdminMeta();
|
|
124
|
-
const {
|
|
125
|
-
const { address, go, open } = useAppAddress();
|
|
127
|
+
const { address, go } = useAppAddress();
|
|
126
128
|
const view = meta.views[model.name];
|
|
127
129
|
const title = recordTitle(model, view, stored.record, recordKey);
|
|
128
130
|
const { state: auth } = useAuth();
|
|
@@ -168,7 +170,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
168
170
|
}, { replace: true }),
|
|
169
171
|
onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
|
|
170
172
|
});
|
|
171
|
-
const related = useRelatedRecords(model,
|
|
173
|
+
const related = useRelatedRecords(model, meta.resources, meta.views, stored.record);
|
|
172
174
|
const pickTarget = (name) => {
|
|
173
175
|
const field = model.fields[name];
|
|
174
176
|
const target = field?.relation ? meta.resources[field.relation.resource] : void 0;
|
|
@@ -180,7 +182,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
180
182
|
...stored.record,
|
|
181
183
|
...Object.fromEntries(picked.map((field) => [field.name, fromDraft(field, editor.value(field.name))]))
|
|
182
184
|
};
|
|
183
|
-
const chosen = useRelatedRecords(model,
|
|
185
|
+
const chosen = useRelatedRecords(model, meta.resources, meta.views, drafted);
|
|
184
186
|
const pick = (name, next) => {
|
|
185
187
|
const target = pickTarget(name);
|
|
186
188
|
if (!target || !isChosen(next)) return editor.change(name, next);
|
|
@@ -279,11 +281,16 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
279
281
|
const text = displayValue(field, stored.record[name], hint(name));
|
|
280
282
|
const value = stored.record[name];
|
|
281
283
|
const markdown = isMarkdown(field, hint(name)) && typeof value === "string" && value !== "";
|
|
284
|
+
const contact = field.type === "text" && typeof value === "string" && value !== "" ? contactFormat(hint(name)) : void 0;
|
|
282
285
|
const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
|
|
283
286
|
value,
|
|
284
287
|
hints: hint(name),
|
|
285
288
|
children: text
|
|
286
|
-
}) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : /* @__PURE__ */ jsx(
|
|
289
|
+
}) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : contact ? /* @__PURE__ */ jsx(ContactLink, {
|
|
290
|
+
format: contact,
|
|
291
|
+
value: text
|
|
292
|
+
}) : /* @__PURE__ */ jsx("strong", {
|
|
293
|
+
title: valueTitle(field, value),
|
|
287
294
|
className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
|
|
288
295
|
children: text
|
|
289
296
|
});
|
|
@@ -349,7 +356,6 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
349
356
|
children: [
|
|
350
357
|
/* @__PURE__ */ jsx(RecordHeader, {
|
|
351
358
|
title,
|
|
352
|
-
titleHidden: true,
|
|
353
359
|
status: status ? {
|
|
354
360
|
label: enumLabel(status, hint("status")?.valueLabels),
|
|
355
361
|
tone: toneFor(status)
|
|
@@ -404,7 +410,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
404
410
|
/* @__PURE__ */ jsxs("div", {
|
|
405
411
|
className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
|
|
406
412
|
children: [/* @__PURE__ */ jsxs("main", {
|
|
407
|
-
className:
|
|
413
|
+
className: "divide-y divide-border",
|
|
408
414
|
children: [layout.flatMap((item, index) => {
|
|
409
415
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
410
416
|
item,
|
|
@@ -463,7 +469,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
463
469
|
locked: shown !== "form"
|
|
464
470
|
})]
|
|
465
471
|
}), /* @__PURE__ */ jsxs("aside", {
|
|
466
|
-
className:
|
|
472
|
+
className: "space-y-4 md:pt-8",
|
|
467
473
|
children: [
|
|
468
474
|
formControls && /* @__PURE__ */ jsx("div", {
|
|
469
475
|
className: "hidden flex-col items-center gap-2 md:flex",
|
|
@@ -495,16 +501,18 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
495
501
|
if (!name || !field || !visible(name)) return [];
|
|
496
502
|
return [{
|
|
497
503
|
label: hint(name)?.label ?? humanize(name),
|
|
498
|
-
value: known(name) ?
|
|
504
|
+
value: known(name) ? /* @__PURE__ */ jsx("span", {
|
|
505
|
+
title: valueTitle(field, stored.record[name]),
|
|
506
|
+
children: displayValue(field, stored.record[name], hint(name))
|
|
507
|
+
}) : /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
|
|
499
508
|
}];
|
|
500
509
|
})
|
|
501
510
|
}),
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
})
|
|
511
|
+
!preview && /* @__PURE__ */ jsx(IncomingCard, {
|
|
512
|
+
model,
|
|
513
|
+
recordKey,
|
|
514
|
+
layout,
|
|
515
|
+
lines: linesModel
|
|
508
516
|
})
|
|
509
517
|
]
|
|
510
518
|
})]
|