@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.
@@ -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 };
@@ -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 pageKey = (number) => keys.page(model.name, filter, orderBy, 40, number);
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: withLabels(resources, model, columns, withPhotos(model, result.items, result.photos), result.labels).map((record, index) => ({
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
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.19.12";
2
+ var version = "0.19.13";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };
@@ -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
- const dots = {
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", dots[tone]) }), children]
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 };