@protobase/ui 0.19.12 → 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/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/shell/assets/app-D65L8GHE.js +69 -0
- package/dist/shell/assets/index-DEEIUcYb.css +2 -0
- package/dist/shell/assets/{index-Cw_3EJp-.js → index-ztKNmFFb.js} +1 -1
- package/dist/shell/assets/{maplibre-DfaBCAzg.js → maplibre-CsiuejGw.js} +1 -1
- package/dist/shell/assets/{markdown-content-COc571bl.js → markdown-content-4BktGTHi.js} +1 -1
- package/dist/shell/assets/{record-map-ChfoXEsT.js → record-map-De6U-Z0I.js} +1 -1
- package/dist/shell/assets/{records-map-CAw0zACV.js → records-map-DaIZwI2y.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BSdpZAbP.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-CGERExLJ.js +0 -69
- package/dist/shell/assets/index-Dbt3yBEP.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";
|
|
@@ -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 };
|