@protobase/ui 0.55.0 → 0.55.2
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/app.js +1 -1
- package/dist/app/displays/record-card.js +8 -0
- package/dist/app/displays/virtual-cards.js +1 -1
- package/dist/app/embedded-list/embedded-columns.js +2 -1
- package/dist/app/embedded-list/use-embedded-rows.js +6 -2
- package/dist/app/list-page.js +6 -2
- package/dist/app/pages/field-value.js +3 -14
- package/dist/app/record-page.js +1 -1
- package/dist/app/related-cards/related-card.js +1 -1
- package/dist/app/relation-picker.js +12 -4
- package/dist/app/titles/record-titles.js +1 -1
- package/dist/app/titles/relation-title-cells.js +28 -0
- package/dist/app/titles/relation-title-text.js +7 -0
- package/dist/app/titles/relation-title.js +3 -11
- package/dist/app/titles/use-relation-title.js +25 -0
- package/dist/app/use-related-records.js +1 -1
- package/dist/data-grid/use-virtual-rows.js +5 -2
- package/dist/index.js +2 -2
- package/dist/live/grid-loader.js +5 -3
- package/dist/live/relation-labels.js +5 -2
- package/dist/live/title-rows.js +47 -0
- package/dist/live/use-filter-config.js +27 -6
- package/dist/live/use-reference-search.js +14 -7
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-CTbX0_B2.js +9 -0
- package/dist/shell/assets/{index-BX1N2n2W.css → index-BRYcNA4H.css} +1 -1
- package/dist/shell/assets/{index-DvCa5s54.js → index-CY05Rf2z.js} +2 -2
- package/dist/shell/assets/{records-map-BWSeQRmm.js → records-map-DA9Zv2ST.js} +2 -2
- package/dist/shell/assets/{shared-protobase-ui-Br4XDldp.js → shared-protobase-ui-DjAPl6MN.js} +2 -2
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-CTbX0_B2.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-DvCa5s54.js.map → index-CY05Rf2z.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-BWSeQRmm.js.map → records-map-DA9Zv2ST.js.map} +1 -1
- package/dist/shell-source-maps/assets/{shared-protobase-ui-Br4XDldp.js.map → shared-protobase-ui-DjAPl6MN.js.map} +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-tBBnYXLP.js +0 -9
- package/dist/shell-source-maps/assets/app-tBBnYXLP.js.map +0 -1
package/dist/app/app.js
CHANGED
|
@@ -11,11 +11,11 @@ import { text } from "./messages.js";
|
|
|
11
11
|
import { Router, useRouter } from "./router.js";
|
|
12
12
|
import { useAppAddress } from "./use-app-address.js";
|
|
13
13
|
import { ProjectUiContext, useProjectUi } from "./pages/project-ui.js";
|
|
14
|
+
import { RecordTitles, useRecordTitle } from "./titles/record-titles.js";
|
|
14
15
|
import { isAdmin, userToShell } from "../auth/user-to-shell.js";
|
|
15
16
|
import { startErrorReporting } from "./error-reporting.js";
|
|
16
17
|
import { slotted } from "../locale/slotted.js";
|
|
17
18
|
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
18
|
-
import { RecordTitles, useRecordTitle } from "./titles/record-titles.js";
|
|
19
19
|
import { useSidebarNoticeShown } from "../app-shell/sidebar-notice.js";
|
|
20
20
|
import { AppShell } from "../app-shell/app-shell.js";
|
|
21
21
|
import { useSignInAddress } from "../auth/use-sign-in-address.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
1
2
|
import { Badge } from "../../primitives/badge.js";
|
|
2
3
|
import { humanize } from "../../live/naming.js";
|
|
3
4
|
import { markdownExcerpt } from "../../markdown/markdown-excerpt.js";
|
|
@@ -15,6 +16,7 @@ import { Markdown } from "../../markdown/markdown.js";
|
|
|
15
16
|
import { ValueBadge } from "../value-badge.js";
|
|
16
17
|
import { useSeedRecord } from "../record-preview.js";
|
|
17
18
|
import { namedIcon } from "../named-icon.js";
|
|
19
|
+
import { RelationTitleText } from "../titles/relation-title-text.js";
|
|
18
20
|
import { FavoriteButton } from "../saved/favorite-button.js";
|
|
19
21
|
import { CardDots, CardDotsRoom } from "../related-dots/card-dots.js";
|
|
20
22
|
import { cardDotsLines } from "../related-dots/card-dots-lines.js";
|
|
@@ -42,10 +44,16 @@ const MetaValue = ({ icon, children }) => {
|
|
|
42
44
|
const RecordCard = ({ model, view, display, row, swipe = true, mark }) => {
|
|
43
45
|
const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
|
|
44
46
|
const label = (name) => view?.fields[name]?.label ?? humanize(name);
|
|
47
|
+
const { resources, views } = useAdminMeta();
|
|
45
48
|
const text$1 = (name) => {
|
|
46
49
|
const field = model.fields[name];
|
|
47
50
|
if (!field) return "";
|
|
48
51
|
const value = row.record[name];
|
|
52
|
+
const target = field.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
|
|
53
|
+
if (target && views[target.name]?.titleFrom && value != null) return /* @__PURE__ */ jsx(RelationTitleText, {
|
|
54
|
+
target,
|
|
55
|
+
id: String(value)
|
|
56
|
+
});
|
|
49
57
|
if (row.labels[name] === void 0 && isMarkdown(field, view?.fields[name]) && typeof value === "string" && value !== "") return markdownExcerpt(value);
|
|
50
58
|
return row.labels[name] ?? displayValue(field, value, view?.fields[name]);
|
|
51
59
|
};
|
|
@@ -46,7 +46,7 @@ const VirtualCards = ({ source, label, initialIndex, onScrollIndex, minHeight, c
|
|
|
46
46
|
if (changed) scroll.scrollTo(scroll.top);
|
|
47
47
|
anchor.current = {
|
|
48
48
|
layout,
|
|
49
|
-
top: scroll.top
|
|
49
|
+
top: scroll.started() ? scroll.top : 0
|
|
50
50
|
};
|
|
51
51
|
});
|
|
52
52
|
const range = recordRange(scroll.first, scroll.last, columns, total);
|
|
@@ -5,6 +5,7 @@ import { scopeOf } from "../pages/use-layout-data.js";
|
|
|
5
5
|
import { FieldValue } from "../pages/field-value.js";
|
|
6
6
|
import { usePathValue } from "../pages/use-path-value.js";
|
|
7
7
|
import { withFieldTypeCells } from "../field-types/field-type-cells.js";
|
|
8
|
+
import { withRelationTitleCells } from "../titles/relation-title-cells.js";
|
|
8
9
|
import { jsx } from "react/jsx-runtime";
|
|
9
10
|
import { resolveFieldPath } from "@protobase/layout";
|
|
10
11
|
//#region src/app/embedded-list/embedded-columns.tsx
|
|
@@ -45,7 +46,7 @@ const pathColumn = (model, column, { resources, views }) => {
|
|
|
45
46
|
};
|
|
46
47
|
/** The list page's columns for `names`: fields of `model` as its table draws them, and paths through relations. */
|
|
47
48
|
const embeddedColumns = (model, view, names, meta) => {
|
|
48
|
-
const fields = new Map(withFieldTypeCells(columnsFromModel(model, view, [], names.filter((name) => !isPath(name))), model, view, meta.fieldTypes).map((spec) => [spec.id, spec]));
|
|
49
|
+
const fields = new Map(withRelationTitleCells(withFieldTypeCells(columnsFromModel(model, view, [], names.filter((name) => !isPath(name))), model, view, meta.fieldTypes), model, meta.resources, meta.views).map((spec) => [spec.id, spec]));
|
|
49
50
|
return names.flatMap((name) => isPath(name) ? [pathColumn(model, name, meta)] : fields.get(name) ?? []);
|
|
50
51
|
};
|
|
51
52
|
//#endregion
|
|
@@ -4,16 +4,19 @@ import { useAdminMeta } from "../meta-gate.js";
|
|
|
4
4
|
import { recordId } from "../../live/model-helpers.js";
|
|
5
5
|
import { listPhotos, withPhotos } from "../../live/row-photos.js";
|
|
6
6
|
import { listLabels, withLabels } from "../../live/relation-labels.js";
|
|
7
|
-
import {
|
|
7
|
+
import { seedTitleRows } from "../../live/title-rows.js";
|
|
8
|
+
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
8
9
|
//#region src/app/embedded-list/use-embedded-rows.ts
|
|
9
10
|
/**
|
|
10
11
|
* The first `limit` records in `orderBy` order, as the list page's table and displays get them: relations among
|
|
11
12
|
* `fields` as `{ id, name }`, named in the same call, with the photos of its cards, and how many records match in all,
|
|
12
|
-
* counted in the same call: `estimated` when more than the server counts exactly match
|
|
13
|
+
* counted in the same call: `estimated` when more than the server counts exactly match; the fields the derived titles
|
|
14
|
+
* of the records its relations point at read, in one more call per resource. A filter through a join
|
|
13
15
|
* resource may hold hundreds of keys, so it goes in a JSON body.
|
|
14
16
|
*/
|
|
15
17
|
const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
|
|
16
18
|
const client = useClient();
|
|
19
|
+
const queryClient = useQueryClient();
|
|
17
20
|
const { resources, views } = useAdminMeta();
|
|
18
21
|
return useQuery({
|
|
19
22
|
queryKey: [...keys.list(model.name, filter, orderBy, limit, "embedded"), fields.join(",")],
|
|
@@ -29,6 +32,7 @@ const useEmbeddedRows = ({ model, filter, orderBy, limit, fields }) => {
|
|
|
29
32
|
labels,
|
|
30
33
|
photos
|
|
31
34
|
});
|
|
35
|
+
await seedTitleRows(queryClient, client, resources, views, model, fields, page.items);
|
|
32
36
|
const rows = withLabels(resources, model, fields, withPhotos(model, page.items, page.photos), page.labels).map((row, index) => ({
|
|
33
37
|
...row,
|
|
34
38
|
id: recordId(model, page.items[index])
|
package/dist/app/list-page.js
CHANGED
|
@@ -14,6 +14,7 @@ import { text } from "./messages.js";
|
|
|
14
14
|
import { AppLink, useAppAddress } from "./use-app-address.js";
|
|
15
15
|
import { ErrorBanner } from "./error-banner.js";
|
|
16
16
|
import { useProjectUi } from "./pages/project-ui.js";
|
|
17
|
+
import { useRecordTitle } from "./titles/record-titles.js";
|
|
17
18
|
import { can, rowAllows } from "./permissions.js";
|
|
18
19
|
import { usePageScroll } from "../app-shell/page-scroll.js";
|
|
19
20
|
import { PageHeader } from "../app-shell/page-header.js";
|
|
@@ -40,6 +41,7 @@ import { CustomDisplay } from "./displays/custom-display.js";
|
|
|
40
41
|
import { MapDisplay } from "./displays/map-display.js";
|
|
41
42
|
import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
|
|
42
43
|
import { withFieldTypeCells } from "./field-types/field-type-cells.js";
|
|
44
|
+
import { withRelationTitleCells } from "./titles/relation-title-cells.js";
|
|
43
45
|
import { useFilterConfig } from "../live/use-filter-config.js";
|
|
44
46
|
import { useDeleteFlow } from "./delete/use-delete-flow.js";
|
|
45
47
|
import { useRestore } from "./delete/use-restore.js";
|
|
@@ -127,7 +129,7 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
127
129
|
const view = viewOfList(meta.views[resource], address.list);
|
|
128
130
|
const fields = useMemo(() => widgetFields(view, model), [view, model]);
|
|
129
131
|
const { fields: fieldTypes } = useProjectUi();
|
|
130
|
-
const columns = useMemo(() => withRelatedDotsColumns(withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), model, view, meta.resources, meta.views), [
|
|
132
|
+
const columns = useMemo(() => withRelatedDotsColumns(withRelationTitleCells(withFieldTypeCells(columnsFromModel(model, view), model, view, fieldTypes), model, meta.resources, meta.views), model, view, meta.resources, meta.views), [
|
|
131
133
|
model,
|
|
132
134
|
view,
|
|
133
135
|
fieldTypes,
|
|
@@ -143,13 +145,15 @@ const LoadedList = ({ model, address, scope, heading }) => {
|
|
|
143
145
|
const xs = useIsXs();
|
|
144
146
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
145
147
|
const scoped = scope ? [...extras, scope] : extras;
|
|
148
|
+
const titleOf = useRecordTitle();
|
|
146
149
|
const config = useFilterConfig({
|
|
147
150
|
model,
|
|
148
151
|
resources: meta.resources,
|
|
149
152
|
views: meta.views,
|
|
150
153
|
view,
|
|
151
154
|
state,
|
|
152
|
-
extras: scoped
|
|
155
|
+
extras: scoped,
|
|
156
|
+
titleOf
|
|
153
157
|
});
|
|
154
158
|
const loadFilter = scope ? stateToText(fields, state, scoped) : parsed.ok ? filterText : "";
|
|
155
159
|
const loaded = useMemo(() => loadedFields(model, view, columns.map((column) => column.id)), [
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { useClient } from "../../data/api-provider.js";
|
|
2
1
|
import { cn } from "../../lib/cn.js";
|
|
3
2
|
import { useAdminMeta } from "../meta-gate.js";
|
|
4
3
|
import { fileShown, shownImage } from "../../live/file-values.js";
|
|
@@ -11,22 +10,12 @@ import { useShownWidget } from "../field-types/shown-widget.js";
|
|
|
11
10
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
12
11
|
import { Markdown } from "../../markdown/markdown.js";
|
|
13
12
|
import { ContactLink } from "../../live/contact-link.js";
|
|
14
|
-
import {
|
|
15
|
-
import { useRecordTitle } from "../titles/record-titles.js";
|
|
13
|
+
import { useRelationTitle } from "../titles/use-relation-title.js";
|
|
16
14
|
import { ValueBadge } from "../value-badge.js";
|
|
17
15
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
18
|
-
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
19
16
|
//#region src/app/pages/field-value.tsx
|
|
20
17
|
const RelationLink = ({ target, id, known }) => {
|
|
21
|
-
const
|
|
22
|
-
const client = useClient();
|
|
23
|
-
const queryClient = useQueryClient();
|
|
24
|
-
const titleOf = useRecordTitle();
|
|
25
|
-
const view = views[target.name];
|
|
26
|
-
const label = useQuery({
|
|
27
|
-
...relationTitleQuery(queryClient, client, target, view, id),
|
|
28
|
-
enabled: known === void 0
|
|
29
|
-
});
|
|
18
|
+
const title = useRelationTitle(target, id, known === void 0);
|
|
30
19
|
return /* @__PURE__ */ jsx(AppLink, {
|
|
31
20
|
to: {
|
|
32
21
|
kind: "record",
|
|
@@ -34,7 +23,7 @@ const RelationLink = ({ target, id, known }) => {
|
|
|
34
23
|
key: id
|
|
35
24
|
},
|
|
36
25
|
className: "font-medium text-primary-text hover:underline",
|
|
37
|
-
children: known ??
|
|
26
|
+
children: known ?? title ?? id
|
|
38
27
|
});
|
|
39
28
|
};
|
|
40
29
|
const RelationValue = ({ field, value, known }) => {
|
package/dist/app/record-page.js
CHANGED
|
@@ -30,12 +30,12 @@ import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/reco
|
|
|
30
30
|
import { isChosen, keyValue } from "./create/create-values.js";
|
|
31
31
|
import { ErrorBanner } from "./error-banner.js";
|
|
32
32
|
import { useProjectUi } from "./pages/project-ui.js";
|
|
33
|
+
import { useRecordTitle } from "./titles/record-titles.js";
|
|
33
34
|
import { shownWidget } from "./field-types/shown-widget.js";
|
|
34
35
|
import { WidgetPart } from "./field-types/widget-part.js";
|
|
35
36
|
import { ContactLink } from "../live/contact-link.js";
|
|
36
37
|
import { FieldEditor } from "./field-editor.js";
|
|
37
38
|
import { can, fieldAccess } from "./permissions.js";
|
|
38
|
-
import { useRecordTitle } from "./titles/record-titles.js";
|
|
39
39
|
import { ValueBadge } from "./value-badge.js";
|
|
40
40
|
import { FieldValue } from "./pages/field-value.js";
|
|
41
41
|
import { fieldMessage } from "./field-errors.js";
|
|
@@ -2,9 +2,9 @@ import { useAdminMeta } from "../meta-gate.js";
|
|
|
2
2
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
3
|
import { recordKey } from "../../live/model-helpers.js";
|
|
4
4
|
import { AppLink } from "../use-app-address.js";
|
|
5
|
+
import { useRecordTitle } from "../titles/record-titles.js";
|
|
5
6
|
import { RecordScope } from "../pages/record-scope.js";
|
|
6
7
|
import { ActionButton } from "../pages/blocks/action.js";
|
|
7
|
-
import { useRecordTitle } from "../titles/record-titles.js";
|
|
8
8
|
import { FieldValue } from "../pages/field-value.js";
|
|
9
9
|
import { usePathValue } from "../pages/use-path-value.js";
|
|
10
10
|
import { fieldLabel } from "../pages/blocks/field.js";
|
|
@@ -4,6 +4,7 @@ import { text } from "./messages.js";
|
|
|
4
4
|
import { useList } from "../data/use-list.js";
|
|
5
5
|
import { PickerCombobox, usePickerSearch } from "./picker/picker-combobox.js";
|
|
6
6
|
import { relationOptionsQuery, titleMatches } from "./relation-options.js";
|
|
7
|
+
import { useRecordTitle } from "./titles/record-titles.js";
|
|
7
8
|
import { useCallback, useEffect, useState } from "react";
|
|
8
9
|
import { ApiError } from "@protobase/client";
|
|
9
10
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -17,24 +18,31 @@ const RelationPicker = ({ target, view, value, label, chosen = [value], several
|
|
|
17
18
|
const keyField = target.primaryKey[0];
|
|
18
19
|
const labelField = labelFieldOf(target, view);
|
|
19
20
|
const [refused, setRefused] = useState(false);
|
|
20
|
-
const
|
|
21
|
+
const titleOf = useRecordTitle();
|
|
22
|
+
const query = relationOptionsQuery(target, labelField, search.text, refused || view?.titleFrom !== void 0 && !target.search?.length);
|
|
23
|
+
const fields = [.../* @__PURE__ */ new Set([
|
|
24
|
+
keyField,
|
|
25
|
+
labelField,
|
|
26
|
+
...view?.titleFrom?.fields ?? []
|
|
27
|
+
])];
|
|
28
|
+
const nameOf = (item) => view?.titleFrom ? titleOf(target, view, item, String(item[keyField])) : String(item[labelField]);
|
|
21
29
|
const rows = useList(target.name, {
|
|
22
30
|
filter: query.filter,
|
|
23
|
-
fields
|
|
31
|
+
fields,
|
|
24
32
|
enabled: search.open
|
|
25
33
|
});
|
|
26
34
|
const expensive = query.filter !== void 0 && rows.error instanceof ApiError && rows.error.slug === "expensive-query";
|
|
27
35
|
useEffect(() => {
|
|
28
36
|
if (expensive) setRefused(true);
|
|
29
37
|
}, [expensive]);
|
|
30
|
-
const items = query.local ? rows.items.filter((item) => titleMatches(item
|
|
38
|
+
const items = query.local ? rows.items.filter((item) => titleMatches(nameOf(item), search.text)) : rows.items;
|
|
31
39
|
const { fetchNextPage } = rows;
|
|
32
40
|
const loadMore = useCallback(() => fetchNextPage({ cancelRefetch: false }), [fetchNextPage]);
|
|
33
41
|
return /* @__PURE__ */ jsx(PickerCombobox, {
|
|
34
42
|
search,
|
|
35
43
|
options: items.map((item) => ({
|
|
36
44
|
key: String(item[keyField]),
|
|
37
|
-
label:
|
|
45
|
+
label: nameOf(item)
|
|
38
46
|
})),
|
|
39
47
|
chosen,
|
|
40
48
|
several,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useOptionalAuth } from "../../auth/auth-provider.js";
|
|
2
2
|
import { useAdminMeta } from "../meta-gate.js";
|
|
3
|
-
import { useProjectUi } from "../pages/project-ui.js";
|
|
4
3
|
import { useMembers } from "../../people/use-members.js";
|
|
4
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
5
5
|
import { recordTitleOf } from "./record-title-of.js";
|
|
6
6
|
import { createContext, useContext, useMemo } from "react";
|
|
7
7
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useRelationTitle } from "./use-relation-title.js";
|
|
2
|
+
import { TruncatedText } from "../../data-grid/truncated-text.js";
|
|
3
|
+
import { renderCell } from "../../data-grid/cells.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/app/titles/relation-title-cells.tsx
|
|
6
|
+
const relationKey = (value) => String(typeof value === "object" && value !== null && "id" in value ? value.id : value);
|
|
7
|
+
const RelationTitleCell = ({ target, id }) => /* @__PURE__ */ jsx(TruncatedText, {
|
|
8
|
+
text: useRelationTitle(target, id) ?? id,
|
|
9
|
+
className: "font-medium text-primary-text hover:underline"
|
|
10
|
+
});
|
|
11
|
+
/**
|
|
12
|
+
* The list's columns, where a relation points at a resource whose view derives its titles, drawn with the record's
|
|
13
|
+
* title for the reader, its key while the title loads. A column a field type draws stays as it is.
|
|
14
|
+
*/
|
|
15
|
+
const withRelationTitleCells = (columns, model, resources, views) => columns.map((spec) => {
|
|
16
|
+
const field = model.fields[spec.id];
|
|
17
|
+
const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
|
|
18
|
+
if (spec.render || !target || !views[target.name]?.titleFrom) return spec;
|
|
19
|
+
return {
|
|
20
|
+
...spec,
|
|
21
|
+
render: (row) => row[spec.id] == null ? renderCell(spec, null) : /* @__PURE__ */ jsx(RelationTitleCell, {
|
|
22
|
+
target,
|
|
23
|
+
id: relationKey(row[spec.id])
|
|
24
|
+
})
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
//#endregion
|
|
28
|
+
export { withRelationTitleCells };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useRelationTitle } from "./use-relation-title.js";
|
|
2
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/app/titles/relation-title-text.tsx
|
|
4
|
+
/** The name of the record `id` of `target` a relation points at, for the reader; its key while the name loads. */
|
|
5
|
+
const RelationTitleText = ({ target, id }) => /* @__PURE__ */ jsx(Fragment, { children: useRelationTitle(target, id) ?? id });
|
|
6
|
+
//#endregion
|
|
7
|
+
export { RelationTitleText };
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { keys } from "../../data/query-keys.js";
|
|
2
2
|
import { fetchLabels } from "../../live/relation-labels.js";
|
|
3
|
-
import {
|
|
3
|
+
import { readTitleRows, titleRowKey } from "../../live/title-rows.js";
|
|
4
4
|
//#region src/app/titles/relation-title.ts
|
|
5
|
-
const where$1 = createWhere();
|
|
6
5
|
/**
|
|
7
6
|
* The query naming the record `id` of a single-key resource that a relation points at: its label field, cached as other
|
|
8
7
|
* labels are, or the fields its view's derived title reads, which `relationTitle` titles for the reader.
|
|
@@ -13,16 +12,9 @@ const relationTitleQuery = (queryClient, client, target, view, id) => {
|
|
|
13
12
|
queryKey: keys.relatedLabel(target.name, id),
|
|
14
13
|
queryFn: async () => ({ label: (await fetchLabels(queryClient, client, target, [id], view)).get(id) ?? id })
|
|
15
14
|
};
|
|
16
|
-
const keyField = target.primaryKey[0];
|
|
17
15
|
return {
|
|
18
|
-
queryKey:
|
|
19
|
-
queryFn: async () => {
|
|
20
|
-
return { row: (await client.list(target.name, {
|
|
21
|
-
filter: printFilter(where$1.in(keyField, [id])),
|
|
22
|
-
fields: [keyField, ...from.fields],
|
|
23
|
-
pageSize: 1
|
|
24
|
-
})).items[0] ?? null };
|
|
25
|
-
}
|
|
16
|
+
queryKey: titleRowKey(target, id, from.fields),
|
|
17
|
+
queryFn: async () => ({ row: (await readTitleRows(client, target, from.fields, [id])).get(id) ?? null })
|
|
26
18
|
};
|
|
27
19
|
};
|
|
28
20
|
/** The name `relationTitleQuery` read, titled for the reader; the key for a record not found, undefined while it loads. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useClient } from "../../data/api-provider.js";
|
|
2
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
3
|
+
import { useRecordTitle } from "./record-titles.js";
|
|
4
|
+
import { relationTitle, relationTitleQuery } from "./relation-title.js";
|
|
5
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
//#region src/app/titles/use-relation-title.ts
|
|
7
|
+
/**
|
|
8
|
+
* The name of the record `id` of `target` a relation points at, for the reader: its label, or its view's derived title.
|
|
9
|
+
* Undefined while it loads or when not `enabled`; a list seeds the names of its page's records, which it reads first.
|
|
10
|
+
*/
|
|
11
|
+
const useRelationTitle = (target, id, enabled = true) => {
|
|
12
|
+
const { views } = useAdminMeta();
|
|
13
|
+
const client = useClient();
|
|
14
|
+
const queryClient = useQueryClient();
|
|
15
|
+
const titleOf = useRecordTitle();
|
|
16
|
+
const view = views[target.name];
|
|
17
|
+
const read = useQuery({
|
|
18
|
+
...relationTitleQuery(queryClient, client, target, view, id),
|
|
19
|
+
staleTime: 3e5,
|
|
20
|
+
enabled
|
|
21
|
+
});
|
|
22
|
+
return relationTitle(read.data, titleOf, target, view, id);
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { useRelationTitle };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useClient } from "../data/api-provider.js";
|
|
2
|
-
import { relationTitle, relationTitleQuery } from "./titles/relation-title.js";
|
|
3
2
|
import { useRecordTitle } from "./titles/record-titles.js";
|
|
3
|
+
import { relationTitle, relationTitleQuery } from "./titles/relation-title.js";
|
|
4
4
|
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
//#region src/app/use-related-records.ts
|
|
6
6
|
/** Per relation the record holds, the address and name of the record it points at, derived for the reader where its view does. The tenant is left out. */
|
|
@@ -18,7 +18,8 @@ const viewRows = (view, total, rowHeight) => {
|
|
|
18
18
|
* scrolling ancestor, or the page. Only the rows in view, and `overscan` more on each side, are drawn: `ref` goes on the
|
|
19
19
|
* list's element, `geometry.contentHeight` tall, and each drawn row sits at `offset` from its top. Nothing listens for
|
|
20
20
|
* wheel or touch: the list follows the scroll position, and draws again only when other rows come into view.
|
|
21
|
-
* `first` and `last` are the indexes of the drawn rows, overscan included
|
|
21
|
+
* `first` and `last` are the indexes of the drawn rows, overscan included; `started` says whether the list has started
|
|
22
|
+
* at `initialIndex`, read after the list's layout effects.
|
|
22
23
|
*/
|
|
23
24
|
const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, ready = true, onScroll }) => {
|
|
24
25
|
const ref = useRef(null);
|
|
@@ -117,6 +118,7 @@ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, rea
|
|
|
117
118
|
scroller.current.scrollTop += scrollFromTop(geometry, top) - current.position;
|
|
118
119
|
measure();
|
|
119
120
|
}, [read, measure]);
|
|
121
|
+
const started = useCallback(() => !start.current.pending, []);
|
|
120
122
|
const drawn = viewRows(view, total, rowHeight);
|
|
121
123
|
const top = settle ? settle(drawn.top) : drawn.top;
|
|
122
124
|
const { first, last } = settle ? rowsInView(total, top, rowHeight, view.height, overscan) : drawn;
|
|
@@ -132,7 +134,8 @@ const useVirtualRows = ({ total, rowHeight, initialIndex, inset = 0, settle, rea
|
|
|
132
134
|
rows,
|
|
133
135
|
first,
|
|
134
136
|
last,
|
|
135
|
-
scrollTo
|
|
137
|
+
scrollTo,
|
|
138
|
+
started
|
|
136
139
|
};
|
|
137
140
|
};
|
|
138
141
|
//#endregion
|
package/dist/index.js
CHANGED
|
@@ -12,9 +12,9 @@ import { AppLink, useAppAddress } from "./app/use-app-address.js";
|
|
|
12
12
|
import { Segmented } from "./primitives/segmented.js";
|
|
13
13
|
import { Textarea } from "./primitives/textarea.js";
|
|
14
14
|
import { useList } from "./data/use-list.js";
|
|
15
|
-
import { OtpInput } from "./primitives/otp-input.js";
|
|
16
|
-
import { definePlugin, defineUi } from "./app/pages/project-ui.js";
|
|
17
15
|
import { useMembers } from "./people/use-members.js";
|
|
16
|
+
import { definePlugin, defineUi } from "./app/pages/project-ui.js";
|
|
17
|
+
import { OtpInput } from "./primitives/otp-input.js";
|
|
18
18
|
import { Markdown } from "./markdown/markdown.js";
|
|
19
19
|
import { ActionInputError } from "./app/pages/action-inputs/input-errors.js";
|
|
20
20
|
import { useLayoutRecord } from "./app/pages/record-scope.js";
|
package/dist/live/grid-loader.js
CHANGED
|
@@ -2,13 +2,15 @@ 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 { seedTitleRows } from "./title-rows.js";
|
|
5
6
|
import { loadRelatedColumn } from "./related-column-rows.js";
|
|
6
7
|
//#region src/live/grid-loader.ts
|
|
7
8
|
const exactBelow = 5e3;
|
|
8
9
|
/**
|
|
9
10
|
* Loads fixed-size pages by number. The page after a cached or loading one follows that page's `next_page_token`; a
|
|
10
11
|
* jump to a page whose predecessor is neither asks `:seek` for the page at that row position, so the pages of one jump
|
|
11
|
-
* are read on from a single seek. Every page is cached by TanStack Query, with the records of its related columns
|
|
12
|
+
* are read on from a single seek. Every page is cached by TanStack Query, with the records of its related columns; the
|
|
13
|
+
* fields the derived titles of the records its relations point at read are cached per record (see `seedTitleRows`).
|
|
12
14
|
*/
|
|
13
15
|
const createGridLoader = ({ queryClient, client, resources, views, model, columns, filter, orderBy, related = [] }) => {
|
|
14
16
|
const relatedNames = related.map((load) => load.column.name).join(",");
|
|
@@ -63,13 +65,13 @@ const createGridLoader = ({ queryClient, client, resources, views, model, column
|
|
|
63
65
|
};
|
|
64
66
|
const ids = result.items.map((record) => recordId(model, record));
|
|
65
67
|
const labelled = withLabels(resources, model, columns, withPhotos(model, result.items, result.photos), result.labels);
|
|
66
|
-
const shown = await Promise.all(related.map((load) => loadRelatedColumn({
|
|
68
|
+
const [shown] = await Promise.all([Promise.all(related.map((load) => loadRelatedColumn({
|
|
67
69
|
client,
|
|
68
70
|
resources,
|
|
69
71
|
views,
|
|
70
72
|
load,
|
|
71
73
|
owners: ids
|
|
72
|
-
})));
|
|
74
|
+
}))), seedTitleRows(queryClient, client, resources, views, model, columns, result.items)]);
|
|
73
75
|
return {
|
|
74
76
|
rows: labelled.map((record, index) => ({
|
|
75
77
|
...record,
|
|
@@ -30,8 +30,11 @@ const relationColumns = (resources, model, columns) => columns.flatMap((name) =>
|
|
|
30
30
|
target
|
|
31
31
|
}] : [];
|
|
32
32
|
});
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* What a list call asks `labels` for: each relation among `columns` to a single-key resource, by the field that names its
|
|
35
|
+
* records. A resource whose view derives its titles per reader is named by the browser instead, so none is asked for it.
|
|
36
|
+
*/
|
|
37
|
+
const listLabels = (resources, views, model, columns) => Object.fromEntries(relationColumns(resources, model, columns).filter(({ target }) => target.primaryKey.length === 1 && !views[target.name]?.titleFrom).map(({ name, target }) => [name, labelFieldOf(target, views[target.name])]));
|
|
35
38
|
/** Replaces each relation value among `columns` with `{ id, name }`, named by the list call's `labels`, else by its key. */
|
|
36
39
|
const withLabels = (resources, model, columns, rows, labels = {}) => {
|
|
37
40
|
const relations = relationColumns(resources, model, columns);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { keys } from "../data/query-keys.js";
|
|
2
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
3
|
+
//#region src/live/title-rows.ts
|
|
4
|
+
const where$1 = createWhere();
|
|
5
|
+
/** The fields a derived title reads, when a single-key `target`'s view derives its titles from fields the reader may read. */
|
|
6
|
+
const derivedTitleFields = (target, view) => {
|
|
7
|
+
const from = view?.titleFrom;
|
|
8
|
+
if (!from || from.unreadable || target.primaryKey.length !== 1) return;
|
|
9
|
+
return from.fields;
|
|
10
|
+
};
|
|
11
|
+
/** Where the fields a record's derived title reads are cached, `{ row }` with `row` null for a record not found. */
|
|
12
|
+
const titleRowKey = (target, id, fields) => keys.titleRow(target.name, id, fields.join(","));
|
|
13
|
+
/** The key and `fields` of the records `ids` of `target`, in one list call, by key. */
|
|
14
|
+
const readTitleRows = async (client, target, fields, ids) => {
|
|
15
|
+
const keyField = target.primaryKey[0];
|
|
16
|
+
const unique = [...new Set(ids)];
|
|
17
|
+
if (unique.length === 0) return /* @__PURE__ */ new Map();
|
|
18
|
+
const page = await client.list(target.name, {
|
|
19
|
+
filter: printFilter(where$1.in(keyField, unique)),
|
|
20
|
+
fields: [keyField, ...fields],
|
|
21
|
+
pageSize: unique.length
|
|
22
|
+
});
|
|
23
|
+
return new Map(page.items.map((item) => [String(item[keyField]), item]));
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Reads, for one page of `rows`, the fields the derived titles of the records their relations among `columns` point at
|
|
27
|
+
* read: one list call per target resource, each record cached under its `titleRowKey`, so the cells titling them for
|
|
28
|
+
* the reader ask nothing more.
|
|
29
|
+
*/
|
|
30
|
+
const seedTitleRows = async (queryClient, client, resources, views, model, columns, rows) => {
|
|
31
|
+
const ids = /* @__PURE__ */ new Map();
|
|
32
|
+
for (const name of columns) {
|
|
33
|
+
const field = model.fields[name];
|
|
34
|
+
const target = field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
|
|
35
|
+
if (!target || !derivedTitleFields(target, views[target.name])) continue;
|
|
36
|
+
const values = rows.flatMap((row) => row[name] == null ? [] : [String(row[name])]);
|
|
37
|
+
ids.set(target.name, [...ids.get(target.name) ?? [], ...values]);
|
|
38
|
+
}
|
|
39
|
+
await Promise.all([...ids].map(async ([name, wanted]) => {
|
|
40
|
+
const target = resources[name];
|
|
41
|
+
const fields = derivedTitleFields(target, views[name]);
|
|
42
|
+
const found = await readTitleRows(client, target, fields, wanted);
|
|
43
|
+
for (const id of new Set(wanted)) queryClient.setQueryData(titleRowKey(target, id, fields), { row: found.get(id) ?? null });
|
|
44
|
+
}));
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
export { derivedTitleFields, readTitleRows, seedTitleRows, titleRowKey };
|
|
@@ -3,6 +3,7 @@ import { useClient } from "../data/api-provider.js";
|
|
|
3
3
|
import { humanize } from "./naming.js";
|
|
4
4
|
import { labelFieldOf } from "./model-helpers.js";
|
|
5
5
|
import { fetchLabels } from "./relation-labels.js";
|
|
6
|
+
import { derivedTitleFields, readTitleRows } from "./title-rows.js";
|
|
6
7
|
import { setRange } from "../filter-panel/filter-state.js";
|
|
7
8
|
import { stateToText } from "./filter-string.js";
|
|
8
9
|
import { rangeFormat, rangeHistogram, widgetFields } from "./view-fields.js";
|
|
@@ -16,7 +17,7 @@ const histogramBuckets = 24;
|
|
|
16
17
|
* The panel's config for a view: facet counts come from `:facets` and each range's bounds from `:histogram`, each
|
|
17
18
|
* computed with every other active filter applied but not its own, so choosing one value does not hide the rest.
|
|
18
19
|
*/
|
|
19
|
-
const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
20
|
+
const useFilterConfig = ({ model, resources, views, view, state, extras, titleOf }) => {
|
|
20
21
|
const client = useClient();
|
|
21
22
|
const queryClient = useQueryClient();
|
|
22
23
|
const skeleton = widgetFields(view, model);
|
|
@@ -25,6 +26,7 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
25
26
|
const field = model.fields[name];
|
|
26
27
|
return field?.type === "relation" && field.relation ? resources[field.relation.resource] : void 0;
|
|
27
28
|
};
|
|
29
|
+
const derives = (target) => Boolean(target && views[target.name]?.titleFrom);
|
|
28
30
|
const counted = skeleton.facets.flatMap((group) => (group.flags ? group.options.map((option) => option.value) : [group.id]).map((field) => ({
|
|
29
31
|
group: group.id,
|
|
30
32
|
field
|
|
@@ -38,7 +40,7 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
38
40
|
}
|
|
39
41
|
}, extras);
|
|
40
42
|
const target = targetOf(field);
|
|
41
|
-
const label = target?.primaryKey.length === 1 ? labelFieldOf(target, views[target.name]) : void 0;
|
|
43
|
+
const label = target?.primaryKey.length === 1 && !derives(target) ? labelFieldOf(target, views[target.name]) : void 0;
|
|
42
44
|
return {
|
|
43
45
|
queryKey: keys.facets(model.name, field, filter),
|
|
44
46
|
queryFn: () => client.facets(model.name, field, {
|
|
@@ -49,19 +51,34 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
49
51
|
};
|
|
50
52
|
}) });
|
|
51
53
|
const counts = (group, field) => facetQueries[counted.findIndex((entry) => entry.group === group && entry.field === field)]?.data;
|
|
54
|
+
const countedKeys = (group) => (counts(group, group) ?? []).flatMap((facet) => facet.value === null ? [] : [String(facet.value)]);
|
|
52
55
|
const labelQueries = useQueries({ queries: skeleton.facets.map((group) => {
|
|
53
56
|
const target = targetOf(group.id);
|
|
54
|
-
const ids = labelKeys((
|
|
57
|
+
const ids = labelKeys(countedKeys(group.id), state.facets[group.id] ?? []);
|
|
55
58
|
return {
|
|
56
59
|
queryKey: keys.labels(`facet:${model.name}.${group.id}`, ids.join(",")),
|
|
57
|
-
enabled: Boolean(target) && ids.length > 0,
|
|
60
|
+
enabled: Boolean(target) && !derives(target) && ids.length > 0,
|
|
58
61
|
queryFn: async () => [...await fetchLabels(queryClient, client, target, ids, views[target.name])]
|
|
59
62
|
};
|
|
60
63
|
}) });
|
|
64
|
+
const titleQueries = useQueries({ queries: skeleton.facets.map((group) => {
|
|
65
|
+
const target = targetOf(group.id);
|
|
66
|
+
const fields = target && derives(target) ? derivedTitleFields(target, views[target.name]) : void 0;
|
|
67
|
+
const ids = [.../* @__PURE__ */ new Set([...countedKeys(group.id), ...state.facets[group.id] ?? []])];
|
|
68
|
+
return {
|
|
69
|
+
queryKey: keys.labels(`facet-title:${model.name}.${group.id}`, ids.join(",")),
|
|
70
|
+
enabled: fields !== void 0 && ids.length > 0,
|
|
71
|
+
queryFn: async () => [...await readTitleRows(client, target, fields, ids)]
|
|
72
|
+
};
|
|
73
|
+
}) });
|
|
74
|
+
const titled = (group, index) => {
|
|
75
|
+
const target = targetOf(group);
|
|
76
|
+
return (titleQueries[index]?.data ?? []).map(([id, row]) => [id, titleOf(target, views[target.name], row, id)]);
|
|
77
|
+
};
|
|
61
78
|
const searches = useReferenceSearch(skeleton.facets.map((group) => ({
|
|
62
79
|
id: group.id,
|
|
63
80
|
target: group.combobox ? targetOf(group.id) : void 0
|
|
64
|
-
})), views);
|
|
81
|
+
})), views, titleOf);
|
|
65
82
|
const ranges = skeleton.ranges ?? [];
|
|
66
83
|
const histogramQueries = useQueries({ queries: ranges.map((range) => {
|
|
67
84
|
const filter = stateToText(skeleton, setRange(state, range.id, void 0), extras);
|
|
@@ -88,7 +105,11 @@ const useFilterConfig = ({ model, resources, views, view, state, extras }) => {
|
|
|
88
105
|
}
|
|
89
106
|
const field = model.fields[group.id];
|
|
90
107
|
const named = (counts(group.id, group.id) ?? []).flatMap((facet) => facet.label === void 0 ? [] : [[String(facet.value), facet.label]]);
|
|
91
|
-
const names = new Map([
|
|
108
|
+
const names = new Map([
|
|
109
|
+
...named,
|
|
110
|
+
...labelQueries[index]?.data ?? [],
|
|
111
|
+
...titled(group.id, index)
|
|
112
|
+
]);
|
|
92
113
|
const values = (counts(group.id, group.id) ?? []).flatMap((facet) => facet.value === null ? [] : [{
|
|
93
114
|
value: String(facet.value),
|
|
94
115
|
label: field?.type === "enum" ? enumLabel(String(facet.value), view?.fields[group.id]?.valueLabels) : names.get(String(facet.value)) ?? String(facet.value),
|