@protobase/ui 0.19.12 → 0.19.14

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.
Files changed (28) hide show
  1. package/dist/app/create/create-values.js +8 -3
  2. package/dist/app/gallery/detail-fields.js +4 -3
  3. package/dist/app/list-page.js +14 -3
  4. package/dist/app/record-mode/record-mode.js +7 -1
  5. package/dist/app/record-page.js +3 -3
  6. package/dist/app/related-dots/card-place.js +15 -0
  7. package/dist/app/related-dots/dot-card.js +26 -0
  8. package/dist/app/related-dots/dots-summary.js +15 -0
  9. package/dist/app/related-dots/related-dots-columns.js +38 -0
  10. package/dist/app/related-dots/related-dots.js +170 -0
  11. package/dist/live/grid-loader.js +15 -4
  12. package/dist/live/related-column-rows.js +70 -0
  13. package/dist/live/use-live-rows.js +6 -3
  14. package/dist/package.js +1 -1
  15. package/dist/primitives/badge.js +4 -3
  16. package/dist/shell/assets/app-Bet1bgM_.js +69 -0
  17. package/dist/shell/assets/index-DEEIUcYb.css +2 -0
  18. package/dist/shell/assets/{index-Cw_3EJp-.js → index-DF5WCYEC.js} +1 -1
  19. package/dist/shell/assets/{maplibre-DfaBCAzg.js → maplibre-DYbWlNbK.js} +1 -1
  20. package/dist/shell/assets/{markdown-content-COc571bl.js → markdown-content-o9fxfOx0.js} +1 -1
  21. package/dist/shell/assets/{record-map-ChfoXEsT.js → record-map-CmVH3wtS.js} +1 -1
  22. package/dist/shell/assets/{records-map-CAw0zACV.js → records-map-XibwyiR4.js} +1 -1
  23. package/dist/shell/assets/{shared-protobase-ui-BSdpZAbP.js → shared-protobase-ui-DNhP23mn.js} +1 -1
  24. package/dist/shell/index.html +4 -4
  25. package/dist/shell/protobase.shell.json +1 -1
  26. package/package.json +4 -4
  27. package/dist/shell/assets/app-CGERExLJ.js +0 -69
  28. package/dist/shell/assets/index-Dbt3yBEP.css +0 -2
@@ -1,15 +1,20 @@
1
1
  import { isEditable, toPatchValue } from "../../live/field-values.js";
2
+ import { whenShown } from "../record-mode/record-mode.js";
2
3
  //#region src/app/create/create-values.ts
3
4
  const isChosen = (value) => typeof value === "object" && value !== null && "key" in value;
4
5
  /** Fields the user can set on a new record: not read-only, not the tenant, and not structured data. */
5
6
  const isCreatable = (model, field) => field.name !== model.tenant && !field.readOnly && (field.type === "relation" || isEditable(field));
6
- /** The view's sections limited to creatable fields, plus a section for any creatable field the layout leaves out. */
7
+ /**
8
+ * The view's sections limited to creatable fields, plus a section for any creatable field the layout leaves out. The
9
+ * create form is a form, so sections and fields meant only for reading the record stay out of their sections, and a
10
+ * creatable field among them lands in that last section.
11
+ */
7
12
  const createSections = (model, view) => {
8
13
  const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field)).map((field) => field.name);
9
14
  const placed = /* @__PURE__ */ new Set();
10
15
  const sections = (view?.layout ?? []).flatMap((item) => {
11
- if (item.kind !== "section") return [];
12
- const fields = item.fields.filter((name) => creatable.includes(name) && !placed.has(name));
16
+ if (item.kind !== "section" || !whenShown(item.when, true)) return [];
17
+ const fields = item.fields.filter((name) => creatable.includes(name) && !placed.has(name) && whenShown(view?.fields[name]?.when, true));
13
18
  fields.forEach((name) => placed.add(name));
14
19
  return fields.length > 0 ? [{
15
20
  title: item.title,
@@ -1,12 +1,13 @@
1
+ import { whenShown } from "../record-mode/record-mode.js";
1
2
  //#region src/app/gallery/detail-fields.ts
2
3
  /**
3
4
  * The fields of a photo's details: those its record page shows in its sections, or without a layout every field but its
4
- * key and tenant, less sensitive ones and those the gallery already shows: `omit`, such as the photo's file and the
5
+ * key and tenant, less sensitive ones, those meant only for its form and those the gallery already shows: `omit`, such as the photo's file and the
5
6
  * field pointing at the gallery's record.
6
7
  */
7
8
  const detailFields = (model, view, omit) => {
8
- const sections = (view?.layout ?? []).flatMap((item) => item.kind === "section" ? item.fields : []);
9
- return (sections.length > 0 ? [...new Set(sections)] : Object.keys(model.fields).filter((name) => name !== model.tenant && !model.primaryKey.includes(name))).filter((name) => model.fields[name] && !model.fields[name].sensitive && !omit.includes(name));
9
+ const sections = (view?.layout ?? []).flatMap((item) => item.kind === "section" && whenShown(item.when, false) ? item.fields : []);
10
+ return (sections.length > 0 ? [...new Set(sections)] : Object.keys(model.fields).filter((name) => name !== model.tenant && !model.primaryKey.includes(name))).filter((name) => model.fields[name] && !model.fields[name].sensitive && !omit.includes(name) && whenShown(view?.fields[name]?.when, false));
10
11
  };
11
12
  //#endregion
12
13
  export { detailFields };
@@ -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";
@@ -32,5 +32,11 @@ const recordShown = (mode, canUpdate, edit) => {
32
32
  const helpShown = (own, view, editing) => (own ?? view?.helpWhen ?? "always") === "always" || editing;
33
33
  /** A field's help line on the record page, when it shows. */
34
34
  const fieldHelp = (hints, view, editing) => hints?.help !== void 0 && helpShown(hints.helpWhen, view, editing) ? hints.help : void 0;
35
+ /** Whether a field or layout item shows: always, unless it says only while editing or only while reading and the page is not. */
36
+ const whenShown = (when, editing) => {
37
+ if (when === "editing") return editing;
38
+ if (when === "reading") return !editing;
39
+ return true;
40
+ };
35
41
  //#endregion
36
- export { fieldHelp, helpShown, recordShown };
42
+ export { fieldHelp, helpShown, recordShown, whenShown };
@@ -28,6 +28,7 @@ import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record
28
28
  import { RecordHeader } from "../record-view/record-header.js";
29
29
  import { SpecTable } from "../record-view/spec-table.js";
30
30
  import { KeyFacts } from "../record-view/key-facts.js";
31
+ import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
31
32
  import { FieldEditor } from "./field-editor.js";
32
33
  import { namedIcon } from "./named-icon.js";
33
34
  import { useDeleteFlow } from "./delete/use-delete-flow.js";
@@ -41,7 +42,6 @@ import { draftStorageKey } from "./draft-storage.js";
41
42
  import { useLines } from "./use-lines.js";
42
43
  import { useLinesDraft } from "./use-lines-draft.js";
43
44
  import { GallerySection } from "./gallery/gallery-section.js";
44
- import { fieldHelp, helpShown, recordShown } from "./record-mode/record-mode.js";
45
45
  import { useRecordEditor } from "./use-record-editor.js";
46
46
  import { useRelatedRecords } from "./use-related-records.js";
47
47
  import { RelatedSection } from "./related-section.js";
@@ -148,14 +148,14 @@ const LoadedRecord = ({ model, recordKey, stored }) => {
148
148
  });
149
149
  const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
150
150
  const { fields: fieldTypes } = useProjectUi();
151
- const layout = layoutFor(model, view, shown === "read");
151
+ const layout = layoutFor(model, view, shown === "read").filter((item) => whenShown(item.when, editing));
152
152
  const sidebarFields = layout.flatMap((item) => item.kind === "sidebar" ? item.fields : []);
153
153
  const hasStatus = model.fields.status?.type === "enum";
154
154
  const statusValue = stored.record.status;
155
155
  const status = hasStatus && !sidebarFields.includes("status") && statusValue !== null && statusValue !== void 0 && statusValue !== "" ? String(statusValue) : void 0;
156
156
  const updatedAt = auditField(model, "updated");
157
157
  const hint = (name) => view?.fields[name];
158
- const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden";
158
+ const visible = (name) => fieldAccess(stored.permissions, name) !== "hidden" && whenShown(hint(name)?.when, editing);
159
159
  const fieldView = (name) => {
160
160
  const field = model.fields[name];
161
161
  const access = fieldAccess(stored.permissions, name);
@@ -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.14";
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 };