@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.
- package/dist/app/create/create-values.js +8 -3
- package/dist/app/gallery/detail-fields.js +4 -3
- package/dist/app/list-page.js +14 -3
- package/dist/app/record-mode/record-mode.js +7 -1
- package/dist/app/record-page.js +3 -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-Bet1bgM_.js +69 -0
- package/dist/shell/assets/index-DEEIUcYb.css +2 -0
- package/dist/shell/assets/{index-Cw_3EJp-.js → index-DF5WCYEC.js} +1 -1
- package/dist/shell/assets/{maplibre-DfaBCAzg.js → maplibre-DYbWlNbK.js} +1 -1
- package/dist/shell/assets/{markdown-content-COc571bl.js → markdown-content-o9fxfOx0.js} +1 -1
- package/dist/shell/assets/{record-map-ChfoXEsT.js → record-map-CmVH3wtS.js} +1 -1
- package/dist/shell/assets/{records-map-CAw0zACV.js → records-map-XibwyiR4.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-BSdpZAbP.js → shared-protobase-ui-DNhP23mn.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
|
@@ -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
|
-
/**
|
|
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 };
|
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";
|
|
@@ -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 };
|
package/dist/app/record-page.js
CHANGED
|
@@ -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 };
|
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 };
|