@protobase/ui 0.19.11 → 0.19.13
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/list-page.js +14 -3
- package/dist/app/record-page.js +21 -30
- package/dist/app/related-dots/card-place.js +15 -0
- package/dist/app/related-dots/dot-card.js +26 -0
- package/dist/app/related-dots/dots-summary.js +15 -0
- package/dist/app/related-dots/related-dots-columns.js +38 -0
- package/dist/app/related-dots/related-dots.js +170 -0
- package/dist/live/grid-loader.js +15 -4
- package/dist/live/related-column-rows.js +70 -0
- package/dist/live/use-live-rows.js +6 -3
- package/dist/package.js +1 -1
- package/dist/primitives/badge.js +4 -3
- package/dist/record-view/record-header.js +50 -44
- package/dist/shell/assets/app-D65L8GHE.js +69 -0
- package/dist/shell/assets/index-DEEIUcYb.css +2 -0
- package/dist/shell/assets/{index-C15EeI8X.js → index-ztKNmFFb.js} +1 -1
- package/dist/shell/assets/{maplibre-CqK5gKEr.js → maplibre-CsiuejGw.js} +1 -1
- package/dist/shell/assets/{markdown-content-DS62HyvW.js → markdown-content-4BktGTHi.js} +1 -1
- package/dist/shell/assets/{record-map-Cp9MkzTk.js → record-map-De6U-Z0I.js} +1 -1
- package/dist/shell/assets/{records-map-BQm4s9Cw.js → records-map-DaIZwI2y.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Cqe5I8UQ.js → shared-protobase-ui-DdZuFJz6.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-DQRL4UH0.js +0 -69
- package/dist/shell/assets/index-DxV5Rp_F.css +0 -2
package/dist/app/list-page.js
CHANGED
|
@@ -43,6 +43,8 @@ import { useDisplayRows } from "./displays/use-display-rows.js";
|
|
|
43
43
|
import { useListScroll } from "./list-scroll.js";
|
|
44
44
|
import { ListChart } from "./list-chart.js";
|
|
45
45
|
import { ListSearch } from "./list-search.js";
|
|
46
|
+
import { relatedColumnLoads } from "./related-dots/dot-card.js";
|
|
47
|
+
import { withRelatedDotsColumns } from "./related-dots/related-dots-columns.js";
|
|
46
48
|
import { SavedList } from "./saved/saved-list.js";
|
|
47
49
|
import { SaveSearchButton } from "./searches/save-search-button.js";
|
|
48
50
|
import { SavedSearches } from "./searches/saved-searches.js";
|
|
@@ -109,10 +111,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
109
111
|
const view = meta.views[resource];
|
|
110
112
|
const fields = useMemo(() => widgetFields(view, model), [view, model]);
|
|
111
113
|
const { fields: fieldTypes } = useProjectUi();
|
|
112
|
-
const columns = useMemo(() => withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), [
|
|
114
|
+
const columns = useMemo(() => withRelatedDotsColumns(withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), model, view, meta.resources, meta.views), [
|
|
113
115
|
model,
|
|
114
116
|
view,
|
|
115
|
-
fieldTypes
|
|
117
|
+
fieldTypes,
|
|
118
|
+
meta.resources,
|
|
119
|
+
meta.views
|
|
116
120
|
]);
|
|
117
121
|
const filterText = address.filter;
|
|
118
122
|
const parsed = useMemo(() => textToState(fields, filterText), [fields, filterText]);
|
|
@@ -136,13 +140,20 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
136
140
|
view,
|
|
137
141
|
columns
|
|
138
142
|
]);
|
|
143
|
+
const related = useMemo(() => relatedColumnLoads(view, meta.resources, meta.views, loaded), [
|
|
144
|
+
view,
|
|
145
|
+
meta.resources,
|
|
146
|
+
meta.views,
|
|
147
|
+
loaded
|
|
148
|
+
]);
|
|
139
149
|
const rows = useLiveRows({
|
|
140
150
|
resources: meta.resources,
|
|
141
151
|
views: meta.views,
|
|
142
152
|
model,
|
|
143
153
|
columns: loaded,
|
|
144
154
|
filter: loadFilter,
|
|
145
|
-
orderBy
|
|
155
|
+
orderBy,
|
|
156
|
+
related
|
|
146
157
|
});
|
|
147
158
|
const display = shownDisplay(view, address.display);
|
|
148
159
|
const pageScroll = display?.kind !== "map";
|
package/dist/app/record-page.js
CHANGED
|
@@ -104,7 +104,6 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
104
104
|
const { basePath } = useRouter();
|
|
105
105
|
const { address, go, open } = useAppAddress();
|
|
106
106
|
const view = meta.views[model.name];
|
|
107
|
-
const singular = view?.names?.singular ?? humanize(model.name);
|
|
108
107
|
const title = recordTitle(model, view, stored.record, recordKey);
|
|
109
108
|
const { state: auth } = useAuth();
|
|
110
109
|
const linesModel = useMemo(() => findLinesResource(meta.resources, model), [meta.resources, model]);
|
|
@@ -153,7 +152,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
153
152
|
const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
|
|
154
153
|
const hasStatus = model.fields.status?.type === "enum";
|
|
155
154
|
const statusValue = stored.record.status;
|
|
156
|
-
const status = hasStatus && statusValue !== null && statusValue !== void 0 && statusValue !== "" ? String(statusValue) : void 0;
|
|
155
|
+
const status = hasStatus && !sidebarFields.includes("status") && statusValue !== null && statusValue !== void 0 && statusValue !== "" ? String(statusValue) : void 0;
|
|
157
156
|
const updatedAt = auditField(model, "updated");
|
|
158
157
|
const hint = (name) => view?.fields[name];
|
|
159
158
|
const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
|
|
@@ -264,29 +263,23 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
264
263
|
snapshot: stored.record
|
|
265
264
|
}) : void 0;
|
|
266
265
|
const formControls = shown === "form" && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
267
|
-
editor.dirty && /* @__PURE__ */ jsx("span", {
|
|
268
|
-
className: "text-xs text-muted-foreground",
|
|
269
|
-
children: "Unsaved changes"
|
|
270
|
-
}),
|
|
271
|
-
editor.dirty && !leavesForm && /* @__PURE__ */ jsx(Button, {
|
|
272
|
-
className: "max-md:min-h-11",
|
|
273
|
-
disabled: editor.saving,
|
|
274
|
-
onClick: editor.discard,
|
|
275
|
-
children: "Discard"
|
|
276
|
-
}),
|
|
277
|
-
leavesForm && /* @__PURE__ */ jsx(AppLink, {
|
|
278
|
-
to: readAddress,
|
|
279
|
-
className: cn(buttonClass(), "max-md:min-h-11"),
|
|
280
|
-
onClick: (event) => inApp(event) && editor.discard(),
|
|
281
|
-
children: "Cancel"
|
|
282
|
-
}),
|
|
283
266
|
/* @__PURE__ */ jsxs(Button, {
|
|
284
267
|
variant: "primary",
|
|
285
|
-
className: "max-md:min-h-11 max-md:flex-1",
|
|
268
|
+
className: "max-md:min-h-11 max-md:flex-1 md:w-full",
|
|
286
269
|
loading: editor.saving,
|
|
287
270
|
disabled: !editor.dirty && editor.phase !== "saved",
|
|
288
271
|
onClick: () => void save(),
|
|
289
272
|
children: [editor.phase === "saved" && /* @__PURE__ */ jsx(Check, { className: "size-4" }), editor.phase === "saved" ? "Saved" : "Save"]
|
|
273
|
+
}),
|
|
274
|
+
leavesForm && /* @__PURE__ */ jsx(AppLink, {
|
|
275
|
+
to: readAddress,
|
|
276
|
+
className: cn(buttonClass(), "max-md:min-h-11 md:w-full"),
|
|
277
|
+
onClick: (event) => inApp(event) && editor.discard(),
|
|
278
|
+
children: "Cancel"
|
|
279
|
+
}),
|
|
280
|
+
editor.dirty && /* @__PURE__ */ jsx("span", {
|
|
281
|
+
className: "text-xs text-muted-foreground max-md:order-first",
|
|
282
|
+
children: "Unsaved changes"
|
|
290
283
|
})
|
|
291
284
|
] });
|
|
292
285
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -300,17 +293,11 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
300
293
|
children: [
|
|
301
294
|
/* @__PURE__ */ jsx(RecordHeader, {
|
|
302
295
|
title,
|
|
296
|
+
titleHidden: true,
|
|
303
297
|
status: status ? {
|
|
304
298
|
label: enumLabel(status, hint("status")?.valueLabels),
|
|
305
299
|
tone: toneFor(status)
|
|
306
|
-
} :
|
|
307
|
-
label: singular,
|
|
308
|
-
tone: "neutral"
|
|
309
|
-
},
|
|
310
|
-
actions: /* @__PURE__ */ jsx("div", {
|
|
311
|
-
className: "hidden items-center gap-4 md:flex",
|
|
312
|
-
children: formControls
|
|
313
|
-
})
|
|
300
|
+
} : void 0
|
|
314
301
|
}),
|
|
315
302
|
editor.message && /* @__PURE__ */ jsx("div", {
|
|
316
303
|
className: "mt-4",
|
|
@@ -323,7 +310,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
323
310
|
className: "mt-4",
|
|
324
311
|
children: /* @__PURE__ */ jsx(ErrorBanner, {
|
|
325
312
|
title: "The lines were changed by someone else",
|
|
326
|
-
error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it
|
|
313
|
+
error: /* @__PURE__ */ new Error("The lines were reloaded. Your new order is kept: Save again to apply it.")
|
|
327
314
|
})
|
|
328
315
|
}),
|
|
329
316
|
shown !== "form" && offersEdit && editor.dirty && /* @__PURE__ */ jsxs("div", {
|
|
@@ -361,7 +348,7 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
361
348
|
/* @__PURE__ */ jsxs("div", {
|
|
362
349
|
className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
|
|
363
350
|
children: [/* @__PURE__ */ jsxs("main", {
|
|
364
|
-
className: "divide-y divide-border",
|
|
351
|
+
className: cn("divide-y divide-border", !status && "*:first:pt-0!"),
|
|
365
352
|
children: [layout.flatMap((item, index) => {
|
|
366
353
|
if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
|
|
367
354
|
item,
|
|
@@ -406,8 +393,12 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
|
|
|
406
393
|
locked: shown !== "form"
|
|
407
394
|
})]
|
|
408
395
|
}), /* @__PURE__ */ jsxs("aside", {
|
|
409
|
-
className: "space-y-4 md:pt-8",
|
|
396
|
+
className: cn("space-y-4", status && "md:pt-8"),
|
|
410
397
|
children: [
|
|
398
|
+
formControls && /* @__PURE__ */ jsx("div", {
|
|
399
|
+
className: "hidden flex-col items-center gap-2 md:flex",
|
|
400
|
+
children: formControls
|
|
401
|
+
}),
|
|
411
402
|
/* @__PURE__ */ jsx(RecordActions, {
|
|
412
403
|
model,
|
|
413
404
|
stored,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/app/related-dots/card-place.ts
|
|
2
|
+
const gap = 6;
|
|
3
|
+
/**
|
|
4
|
+
* Where a card of `card`'s size goes for the dot at `dot`, in viewport pixels: below it, or above it when the viewport
|
|
5
|
+
* has no room underneath, starting at its left edge and kept inside the viewport's sides.
|
|
6
|
+
*/
|
|
7
|
+
const placeCard = (dot, card, viewport) => {
|
|
8
|
+
const below = dot.bottom + gap;
|
|
9
|
+
return {
|
|
10
|
+
top: below + card.height > viewport.height - gap ? Math.max(gap, dot.top - gap - card.height) : below,
|
|
11
|
+
left: Math.max(gap, Math.min(dot.left, viewport.width - gap - card.width))
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { placeCard };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { displaysOf, shownFields } from "../displays/list-displays.js";
|
|
2
|
+
//#region src/app/related-dots/dot-card.ts
|
|
3
|
+
/**
|
|
4
|
+
* The card a dot shows of its record: the first cards display of the record's view, without the photos and favorite
|
|
5
|
+
* button a small card has no room for, else the record's title with its status as the badge.
|
|
6
|
+
*/
|
|
7
|
+
const dotCard = (view, status) => {
|
|
8
|
+
const cards = displaysOf(view).find((display) => display.kind === "cards");
|
|
9
|
+
if (!cards) return {
|
|
10
|
+
kind: "cards",
|
|
11
|
+
name: "dot",
|
|
12
|
+
badge: status
|
|
13
|
+
};
|
|
14
|
+
const { photos: _, favorite: __, ...card } = cards;
|
|
15
|
+
return card;
|
|
16
|
+
};
|
|
17
|
+
/** What the list loads for the related columns of its view among `columns`: each with the fields its dots' cards show. */
|
|
18
|
+
const relatedColumnLoads = (view, resources, views, columns) => (view?.list?.relatedColumns ?? []).filter((column) => columns.includes(column.name)).flatMap((column) => {
|
|
19
|
+
const target = resources[column.resource];
|
|
20
|
+
return target ? [{
|
|
21
|
+
column,
|
|
22
|
+
fields: shownFields(target, views[target.name], dotCard(views[target.name], column.status))
|
|
23
|
+
}] : [];
|
|
24
|
+
});
|
|
25
|
+
//#endregion
|
|
26
|
+
export { dotCard, relatedColumnLoads };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/app/related-dots/dots-summary.ts
|
|
2
|
+
/**
|
|
3
|
+
* What a related column's dots say, as text: how many records there are, then how many have each status, in the order
|
|
4
|
+
* of the status's values, such as "4 tasks: 1 To do, 3 Done". "No tasks" without any.
|
|
5
|
+
*/
|
|
6
|
+
const dotsSummary = (statuses, { values, label, singular, plural }) => {
|
|
7
|
+
if (statuses.length === 0) return `No ${plural}`;
|
|
8
|
+
const counts = /* @__PURE__ */ new Map();
|
|
9
|
+
for (const status of statuses) if (status !== null && status !== void 0) counts.set(String(status), (counts.get(String(status)) ?? 0) + 1);
|
|
10
|
+
const parts = [...values, ...[...counts.keys()].filter((value) => !values.includes(value))].filter((value) => counts.has(value)).map((value) => `${counts.get(value)} ${label(value)}`);
|
|
11
|
+
const total = `${statuses.length} ${statuses.length === 1 ? singular : plural}`;
|
|
12
|
+
return parts.length === 0 ? total : `${total}: ${parts.join(", ")}`;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { dotsSummary };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { humanize } from "../../live/naming.js";
|
|
2
|
+
import { headerWidth, listColumnNames } from "../../live/columns-from-model.js";
|
|
3
|
+
import { RelatedDots } from "./related-dots.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/app/related-dots/related-dots-columns.tsx
|
|
6
|
+
/**
|
|
7
|
+
* The list's columns with its view's related columns among them, in the order the view lists them: each draws its
|
|
8
|
+
* records, which the list loads with its rows under the column's name, as dots.
|
|
9
|
+
*/
|
|
10
|
+
const withRelatedDotsColumns = (columns, model, view, resources, views) => {
|
|
11
|
+
const related = view?.list?.relatedColumns ?? [];
|
|
12
|
+
if (related.length === 0) return columns;
|
|
13
|
+
const byId = new Map(columns.map((spec) => [spec.id, spec]));
|
|
14
|
+
return listColumnNames(model, view).flatMap((name) => {
|
|
15
|
+
const field = byId.get(name);
|
|
16
|
+
if (field) return [field];
|
|
17
|
+
const column = related.find((candidate) => candidate.name === name);
|
|
18
|
+
const target = column && resources[column.resource];
|
|
19
|
+
if (!column || !target) return [];
|
|
20
|
+
const header = column.label ?? humanize(column.name);
|
|
21
|
+
return [{
|
|
22
|
+
id: column.name,
|
|
23
|
+
header,
|
|
24
|
+
kind: "text",
|
|
25
|
+
width: 160,
|
|
26
|
+
minWidth: headerWidth(header, false),
|
|
27
|
+
sortable: false,
|
|
28
|
+
render: (row) => /* @__PURE__ */ jsx(RelatedDots, {
|
|
29
|
+
column,
|
|
30
|
+
records: row[column.name] ?? [],
|
|
31
|
+
target,
|
|
32
|
+
view: views[target.name]
|
|
33
|
+
})
|
|
34
|
+
}];
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { withRelatedDotsColumns };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { toneDots } from "../../primitives/badge.js";
|
|
3
|
+
import { recordTitleField } from "../../live/model-helpers.js";
|
|
4
|
+
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { displayValue } from "../../live/display-value.js";
|
|
6
|
+
import { displayRow } from "../displays/list-displays.js";
|
|
7
|
+
import { AppLink } from "../use-app-address.js";
|
|
8
|
+
import { toneFor } from "../../live/columns-from-model.js";
|
|
9
|
+
import { valueColor } from "../../live/value-color.js";
|
|
10
|
+
import { RecordCard } from "../displays/record-card.js";
|
|
11
|
+
import { dotCard } from "./dot-card.js";
|
|
12
|
+
import { placeCard } from "./card-place.js";
|
|
13
|
+
import { dotsSummary } from "./dots-summary.js";
|
|
14
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
+
import { createPortal } from "react-dom";
|
|
17
|
+
//#region src/app/related-dots/related-dots.tsx
|
|
18
|
+
const hideMs = 150;
|
|
19
|
+
const cardWidth = 288;
|
|
20
|
+
/**
|
|
21
|
+
* The card of the dot that is hovered, focused or tapped, portalled to the body: every grid row is its own stacking
|
|
22
|
+
* context and the grid body clips, so a card inside the row would sit under the rows below it.
|
|
23
|
+
*/
|
|
24
|
+
const DotCardPopover = ({ shown, row, target, view, column, onEnter, onLeave }) => {
|
|
25
|
+
const card = useRef(null);
|
|
26
|
+
const [place, setPlace] = useState();
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
if (!card.current) return;
|
|
29
|
+
setPlace(placeCard(shown.dot, {
|
|
30
|
+
width: cardWidth,
|
|
31
|
+
height: card.current.offsetHeight
|
|
32
|
+
}, {
|
|
33
|
+
width: window.innerWidth,
|
|
34
|
+
height: window.innerHeight
|
|
35
|
+
}));
|
|
36
|
+
}, [shown]);
|
|
37
|
+
return createPortal(/* @__PURE__ */ jsx("div", {
|
|
38
|
+
ref: card,
|
|
39
|
+
"data-dot-card": true,
|
|
40
|
+
onMouseEnter: onEnter,
|
|
41
|
+
onMouseLeave: onLeave,
|
|
42
|
+
style: {
|
|
43
|
+
width: cardWidth,
|
|
44
|
+
...place ?? {
|
|
45
|
+
visibility: "hidden",
|
|
46
|
+
top: 0,
|
|
47
|
+
left: 0
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
className: "fixed z-50 rounded-lg shadow-pop",
|
|
51
|
+
children: /* @__PURE__ */ jsx(RecordCard, {
|
|
52
|
+
model: target,
|
|
53
|
+
view,
|
|
54
|
+
display: dotCard(view, column.status),
|
|
55
|
+
row
|
|
56
|
+
})
|
|
57
|
+
}), document.body);
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* A related column's cell: a dot per record in the colour its status has in its view, each a link to the record. Hovering
|
|
61
|
+
* or focusing a dot shows the record's card; on a touch screen the first tap shows it, and the card opens the record.
|
|
62
|
+
* The dots say how many records have each status to a screen reader.
|
|
63
|
+
*/
|
|
64
|
+
const RelatedDots = ({ column, records, target, view }) => {
|
|
65
|
+
const rows = useMemo(() => records.map((record) => displayRow(target, record)), [records, target]);
|
|
66
|
+
const [shown, setShown] = useState();
|
|
67
|
+
const hideTimer = useRef(void 0);
|
|
68
|
+
const pointer = useRef("mouse");
|
|
69
|
+
const box = useRef(null);
|
|
70
|
+
const status = target.fields[column.status];
|
|
71
|
+
const hints = view?.fields[column.status];
|
|
72
|
+
const statusText = (row) => status ? displayValue(status, row.record[column.status], hints) : "";
|
|
73
|
+
const titleField = recordTitleField(target, view);
|
|
74
|
+
const title = (row) => row.labels[titleField] ?? String(row.record[titleField] ?? row.key);
|
|
75
|
+
const singular = (view?.names?.singular ?? humanize(target.name)).toLowerCase();
|
|
76
|
+
const plural = (view?.names?.plural ?? humanize(target.name)).toLowerCase();
|
|
77
|
+
const summary = dotsSummary(rows.map((row) => row.record[column.status]), {
|
|
78
|
+
values: status?.enumValues ?? [],
|
|
79
|
+
label: (value) => status ? displayValue(status, value, hints) : value,
|
|
80
|
+
singular,
|
|
81
|
+
plural
|
|
82
|
+
});
|
|
83
|
+
const cancelHide = () => window.clearTimeout(hideTimer.current);
|
|
84
|
+
const hideSoon = () => {
|
|
85
|
+
cancelHide();
|
|
86
|
+
hideTimer.current = window.setTimeout(() => setShown(void 0), hideMs);
|
|
87
|
+
};
|
|
88
|
+
const show = (key, dot) => {
|
|
89
|
+
cancelHide();
|
|
90
|
+
setShown({
|
|
91
|
+
key,
|
|
92
|
+
dot: dot.getBoundingClientRect()
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
useEffect(() => cancelHide, []);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!shown) return;
|
|
98
|
+
const hide = () => setShown(void 0);
|
|
99
|
+
const key = (event) => event.key === "Escape" && hide();
|
|
100
|
+
const outside = (event) => {
|
|
101
|
+
const target = event.target;
|
|
102
|
+
if (!box.current?.contains(target) && !target?.closest?.("[data-dot-card]")) hide();
|
|
103
|
+
};
|
|
104
|
+
document.addEventListener("keydown", key);
|
|
105
|
+
document.addEventListener("pointerdown", outside);
|
|
106
|
+
document.addEventListener("scroll", hide, true);
|
|
107
|
+
window.addEventListener("resize", hide);
|
|
108
|
+
return () => {
|
|
109
|
+
document.removeEventListener("keydown", key);
|
|
110
|
+
document.removeEventListener("pointerdown", outside);
|
|
111
|
+
document.removeEventListener("scroll", hide, true);
|
|
112
|
+
window.removeEventListener("resize", hide);
|
|
113
|
+
};
|
|
114
|
+
}, [shown]);
|
|
115
|
+
const tap = (key) => (event) => {
|
|
116
|
+
if (pointer.current !== "touch" || shown?.key === key) return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
show(key, event.currentTarget);
|
|
119
|
+
};
|
|
120
|
+
const blur = (event) => {
|
|
121
|
+
if (!event.relatedTarget?.closest?.("[data-dot-card]")) hideSoon();
|
|
122
|
+
};
|
|
123
|
+
const shownRow = shown && rows.find((row) => row.key === shown.key);
|
|
124
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
125
|
+
ref: box,
|
|
126
|
+
className: "flex min-w-0 items-center gap-0.5",
|
|
127
|
+
onClick: (event) => event.stopPropagation(),
|
|
128
|
+
onPointerDown: (event) => pointer.current = event.pointerType,
|
|
129
|
+
children: [
|
|
130
|
+
/* @__PURE__ */ jsx("span", {
|
|
131
|
+
className: "sr-only",
|
|
132
|
+
children: summary
|
|
133
|
+
}),
|
|
134
|
+
rows.map((row) => {
|
|
135
|
+
const value = row.record[column.status];
|
|
136
|
+
const color = valueColor(hints?.valueColors, value);
|
|
137
|
+
return /* @__PURE__ */ jsx(AppLink, {
|
|
138
|
+
to: {
|
|
139
|
+
kind: "record",
|
|
140
|
+
resource: target.name,
|
|
141
|
+
key: row.key
|
|
142
|
+
},
|
|
143
|
+
"aria-label": `${title(row)}, ${statusText(row)}`,
|
|
144
|
+
onPointerEnter: (event) => event.pointerType !== "touch" && show(row.key, event.currentTarget),
|
|
145
|
+
onPointerLeave: (event) => event.pointerType !== "touch" && hideSoon(),
|
|
146
|
+
onFocus: (event) => event.currentTarget.matches(":focus-visible") && show(row.key, event.currentTarget),
|
|
147
|
+
onBlur: blur,
|
|
148
|
+
onClick: tap(row.key),
|
|
149
|
+
className: "flex shrink-0 items-center justify-center rounded-full p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 pointer-coarse:p-1.5",
|
|
150
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
151
|
+
"aria-hidden": true,
|
|
152
|
+
style: color === void 0 ? void 0 : { backgroundColor: color },
|
|
153
|
+
className: cn("size-2.5 rounded-full", color === void 0 && toneDots[toneFor(String(value))], shown?.key === row.key && "ring-2 ring-foreground/30")
|
|
154
|
+
})
|
|
155
|
+
}, row.key);
|
|
156
|
+
}),
|
|
157
|
+
shown && shownRow && /* @__PURE__ */ jsx(DotCardPopover, {
|
|
158
|
+
shown,
|
|
159
|
+
row: shownRow,
|
|
160
|
+
target,
|
|
161
|
+
view,
|
|
162
|
+
column,
|
|
163
|
+
onEnter: cancelHide,
|
|
164
|
+
onLeave: hideSoon
|
|
165
|
+
})
|
|
166
|
+
]
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
//#endregion
|
|
170
|
+
export { RelatedDots };
|
package/dist/live/grid-loader.js
CHANGED
|
@@ -2,15 +2,17 @@ import { keys } from "../data/query-keys.js";
|
|
|
2
2
|
import { recordId } from "./model-helpers.js";
|
|
3
3
|
import { listPhotos, withPhotos } from "./row-photos.js";
|
|
4
4
|
import { listLabels, withLabels } from "./relation-labels.js";
|
|
5
|
+
import { loadRelatedColumn } from "./related-column-rows.js";
|
|
5
6
|
//#region src/live/grid-loader.ts
|
|
6
7
|
const exactBelow = 5e3;
|
|
7
8
|
/**
|
|
8
9
|
* Loads fixed-size pages by number. The page after a cached or loading one follows that page's `next_page_token`; a
|
|
9
10
|
* jump to a page whose predecessor is neither asks `:seek` for the page at that row position, so the pages of one jump
|
|
10
|
-
* are read on from a single seek. Every page is cached by TanStack Query.
|
|
11
|
+
* are read on from a single seek. Every page is cached by TanStack Query, with the records of its related columns.
|
|
11
12
|
*/
|
|
12
|
-
const createGridLoader = ({ queryClient, client, resources, views, model, columns, filter, orderBy }) => {
|
|
13
|
-
const
|
|
13
|
+
const createGridLoader = ({ queryClient, client, resources, views, model, columns, filter, orderBy, related = [] }) => {
|
|
14
|
+
const relatedNames = related.map((load) => load.column.name).join(",");
|
|
15
|
+
const pageKey = (number) => related.length === 0 ? keys.page(model.name, filter, orderBy, 40, number) : [...keys.page(model.name, filter, orderBy, 40, number), relatedNames];
|
|
14
16
|
const labels = listLabels(resources, views, model, columns);
|
|
15
17
|
const photos = listPhotos(views[model.name]);
|
|
16
18
|
/** The server's total is a planner estimate, off by a lot for small results; settle those exactly. */
|
|
@@ -60,9 +62,18 @@ const createGridLoader = ({ queryClient, client, resources, views, model, column
|
|
|
60
62
|
nextPageToken: ""
|
|
61
63
|
};
|
|
62
64
|
const ids = result.items.map((record) => recordId(model, record));
|
|
65
|
+
const labelled = withLabels(resources, model, columns, withPhotos(model, result.items, result.photos), result.labels);
|
|
66
|
+
const shown = await Promise.all(related.map((load) => loadRelatedColumn({
|
|
67
|
+
client,
|
|
68
|
+
resources,
|
|
69
|
+
views,
|
|
70
|
+
load,
|
|
71
|
+
owners: ids
|
|
72
|
+
})));
|
|
63
73
|
return {
|
|
64
|
-
rows:
|
|
74
|
+
rows: labelled.map((record, index) => ({
|
|
65
75
|
...record,
|
|
76
|
+
...Object.fromEntries(related.map((load, column) => [load.column.name, shown[column].get(ids[index]) ?? []])),
|
|
66
77
|
id: ids[index]
|
|
67
78
|
})),
|
|
68
79
|
total: page === 0 ? await firstPageTotal(result) : result.totalSizeEstimate,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { listLabels, withLabels } from "./relation-labels.js";
|
|
2
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
3
|
+
//#region src/live/related-column-rows.ts
|
|
4
|
+
const where = createWhere();
|
|
5
|
+
const and = (...parts) => {
|
|
6
|
+
const given = parts.filter((part) => Boolean(part));
|
|
7
|
+
return given.length === 1 ? given[0] : given.map((part) => `(${part})`).join(" AND ");
|
|
8
|
+
};
|
|
9
|
+
const text = (value) => value === null || value === void 0 ? void 0 : String(value);
|
|
10
|
+
/**
|
|
11
|
+
* The records of each owner, in the order `records` come in: a record belongs to every owner one of whose `links`
|
|
12
|
+
* (`[owner, value]`) holds its `field`'s value. A relation's value may come labelled as `{ id, name }`.
|
|
13
|
+
*/
|
|
14
|
+
const groupByOwner = (links, records, field) => {
|
|
15
|
+
const owners = /* @__PURE__ */ new Map();
|
|
16
|
+
for (const [owner, value] of links) owners.set(value, (owners.get(value) ?? /* @__PURE__ */ new Set()).add(owner));
|
|
17
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
18
|
+
for (const record of records) {
|
|
19
|
+
const raw = record[field];
|
|
20
|
+
const value = text(typeof raw === "object" && raw !== null && "id" in raw ? raw.id : raw);
|
|
21
|
+
for (const owner of value === void 0 ? [] : owners.get(value) ?? []) grouped.set(owner, [...grouped.get(owner) ?? [], record]);
|
|
22
|
+
}
|
|
23
|
+
return grouped;
|
|
24
|
+
};
|
|
25
|
+
const linksOf = async (client, resources, column, owners) => {
|
|
26
|
+
const through = column.through;
|
|
27
|
+
if (!through) return owners.map((owner) => [owner, owner]);
|
|
28
|
+
const via = resources[through.resource];
|
|
29
|
+
const key = through.key ?? via?.primaryKey[0];
|
|
30
|
+
if (!via || key === void 0) throw new Error(`The related column ${column.name} names a join resource the app does not have: ${through.resource}`);
|
|
31
|
+
return (await client.search(via.name, {
|
|
32
|
+
filter: and(printFilter(where.in(through.field, owners)), through.filter),
|
|
33
|
+
fields: [through.field, key],
|
|
34
|
+
pageSize: 500
|
|
35
|
+
})).items.flatMap((row) => {
|
|
36
|
+
const owner = text(row[through.field]);
|
|
37
|
+
const value = text(row[key]);
|
|
38
|
+
return owner === void 0 || value === void 0 ? [] : [[owner, value]];
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The records a related column shows for each of `owners`, the keys of one page of the list's rows: its join records in
|
|
43
|
+
* one request, then the records they pick in another, in the column's order, their relations labelled.
|
|
44
|
+
*/
|
|
45
|
+
const loadRelatedColumn = async ({ client, resources, views, load, owners }) => {
|
|
46
|
+
const { column, fields } = load;
|
|
47
|
+
const target = resources[column.resource];
|
|
48
|
+
if (!target) throw new Error(`The related column ${column.name} shows a resource the app does not have: ${column.resource}`);
|
|
49
|
+
if (owners.length === 0) return /* @__PURE__ */ new Map();
|
|
50
|
+
const links = await linksOf(client, resources, column, owners);
|
|
51
|
+
const values = [...new Set(links.map(([, value]) => value))];
|
|
52
|
+
if (values.length === 0) return /* @__PURE__ */ new Map();
|
|
53
|
+
const loaded = [.../* @__PURE__ */ new Set([
|
|
54
|
+
...target.primaryKey,
|
|
55
|
+
column.field,
|
|
56
|
+
column.status,
|
|
57
|
+
...fields
|
|
58
|
+
])];
|
|
59
|
+
const labels = listLabels(resources, views, target, loaded);
|
|
60
|
+
const page = await client.search(target.name, {
|
|
61
|
+
filter: and(printFilter(where.in(column.field, values)), column.filter),
|
|
62
|
+
...column.sort && { orderBy: column.sort },
|
|
63
|
+
fields: loaded,
|
|
64
|
+
pageSize: 500,
|
|
65
|
+
labels
|
|
66
|
+
});
|
|
67
|
+
return groupByOwner(links, withLabels(resources, target, loaded, page.items, page.labels), column.field);
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { groupByOwner, loadRelatedColumn };
|
|
@@ -8,10 +8,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
8
8
|
* A grid row source backed by the API; `total` is the server's estimate from the first page. `page` loads one page of
|
|
9
9
|
* the same rows by number, and `key` names them in the query cache, for list displays that page through them.
|
|
10
10
|
*/
|
|
11
|
-
const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
|
|
11
|
+
const useLiveRows = ({ resources, views, model, columns, filter, orderBy, related }) => {
|
|
12
12
|
const client = useClient();
|
|
13
13
|
const queryClient = useQueryClient();
|
|
14
14
|
const columnKey = columns.join(",");
|
|
15
|
+
const relatedKey = JSON.stringify(related ?? []);
|
|
15
16
|
const loader = useMemo(() => createGridLoader({
|
|
16
17
|
queryClient,
|
|
17
18
|
client,
|
|
@@ -20,7 +21,8 @@ const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
|
|
|
20
21
|
model,
|
|
21
22
|
columns,
|
|
22
23
|
filter,
|
|
23
|
-
orderBy
|
|
24
|
+
orderBy,
|
|
25
|
+
...related && { related }
|
|
24
26
|
}), [
|
|
25
27
|
queryClient,
|
|
26
28
|
client,
|
|
@@ -29,7 +31,8 @@ const useLiveRows = ({ resources, views, model, columns, filter, orderBy }) => {
|
|
|
29
31
|
model,
|
|
30
32
|
columnKey,
|
|
31
33
|
filter,
|
|
32
|
-
orderBy
|
|
34
|
+
orderBy,
|
|
35
|
+
relatedKey
|
|
33
36
|
]);
|
|
34
37
|
const first = useQuery({
|
|
35
38
|
queryKey: [...keys.first(model.name, filter, orderBy), columnKey],
|
package/dist/package.js
CHANGED
package/dist/primitives/badge.js
CHANGED
|
@@ -10,7 +10,8 @@ const tones = {
|
|
|
10
10
|
red: "bg-danger-soft text-danger-text",
|
|
11
11
|
violet: "bg-violet-soft text-violet-text"
|
|
12
12
|
};
|
|
13
|
-
|
|
13
|
+
/** The dot of a badge in each tone, for a status drawn as a dot alone. */
|
|
14
|
+
const toneDots = {
|
|
14
15
|
neutral: "bg-faint-foreground",
|
|
15
16
|
blue: "bg-primary",
|
|
16
17
|
green: "bg-success",
|
|
@@ -28,7 +29,7 @@ const Badge = ({ tone = "neutral", dot = true, color, shape, children, className
|
|
|
28
29
|
...shape === "arrow" && { clipPath: arrow }
|
|
29
30
|
},
|
|
30
31
|
className: cn(shape === "arrow" ? "inline-flex h-5 items-center gap-1.5 rounded-l-sm pr-4 pl-2 text-xs font-semibold whitespace-nowrap" : "inline-flex h-5 items-center gap-1.5 rounded-full px-2 text-xs font-medium whitespace-nowrap", color === void 0 && tones[tone], className),
|
|
31
|
-
children: [dot && color === void 0 && shape === void 0 && /* @__PURE__ */ jsx("span", { className: cn("size-1.5 rounded-full",
|
|
32
|
+
children: [dot && color === void 0 && shape === void 0 && /* @__PURE__ */ jsx("span", { className: cn("size-1.5 rounded-full", toneDots[tone]) }), children]
|
|
32
33
|
});
|
|
33
34
|
//#endregion
|
|
34
|
-
export { Badge };
|
|
35
|
+
export { Badge, toneDots };
|