@protobase/ui 0.55.1 → 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/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/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-DprdVbQM.js → index-CY05Rf2z.js} +2 -2
- package/dist/shell/assets/{records-map-DhDIjikh.js → records-map-DA9Zv2ST.js} +2 -2
- package/dist/shell/assets/{shared-protobase-ui-uXIvgyTZ.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-DprdVbQM.js.map → index-CY05Rf2z.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-DhDIjikh.js.map → records-map-DA9Zv2ST.js.map} +1 -1
- package/dist/shell-source-maps/assets/{shared-protobase-ui-uXIvgyTZ.js.map → shared-protobase-ui-DjAPl6MN.js.map} +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-DxuzNqzR.js +0 -9
- package/dist/shell-source-maps/assets/app-DxuzNqzR.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
|
};
|
|
@@ -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. */
|
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),
|
|
@@ -8,12 +8,21 @@ const where$1 = createWhere();
|
|
|
8
8
|
/**
|
|
9
9
|
* The search text typed into each facet's combobox and the records of its target resource that match it. A facet
|
|
10
10
|
* without a target, or whose target has no search fields or a composite key, gets nothing: its options are filtered
|
|
11
|
-
* by name instead.
|
|
11
|
+
* by name instead. A target whose view derives its titles names its records by `titleOf`, for the reader.
|
|
12
12
|
*/
|
|
13
|
-
const useReferenceSearch = (targets, views) => {
|
|
13
|
+
const useReferenceSearch = (targets, views, titleOf) => {
|
|
14
14
|
const client = useClient();
|
|
15
15
|
const [texts, setTexts] = useState({});
|
|
16
16
|
const searchable = (target) => Boolean(target?.search?.length) && target.primaryKey.length === 1;
|
|
17
|
+
const nameFields = (target) => {
|
|
18
|
+
const view = views[target.name];
|
|
19
|
+
return [target.primaryKey[0], ...view?.titleFrom ? view.titleFrom.fields : [labelFieldOf(target, view)]];
|
|
20
|
+
};
|
|
21
|
+
const named = (target, item) => {
|
|
22
|
+
const view = views[target.name];
|
|
23
|
+
const key = String(item[target.primaryKey[0]]);
|
|
24
|
+
return [key, view?.titleFrom ? titleOf(target, view, item, key) : String(item[labelFieldOf(target, view)])];
|
|
25
|
+
};
|
|
17
26
|
const results = useQueries({ queries: targets.map(({ id, target }) => {
|
|
18
27
|
const text = (texts[id] ?? "").trim();
|
|
19
28
|
return {
|
|
@@ -26,13 +35,11 @@ const useReferenceSearch = (targets, views) => {
|
|
|
26
35
|
enabled: searchable(target) && text !== "",
|
|
27
36
|
placeholderData: keepPreviousData,
|
|
28
37
|
queryFn: async () => {
|
|
29
|
-
const keyField = target.primaryKey[0];
|
|
30
|
-
const labelField = labelFieldOf(target, views[target.name]);
|
|
31
38
|
return (await client.list(target.name, {
|
|
32
39
|
filter: printFilter(where$1.search(text)),
|
|
33
|
-
fields:
|
|
40
|
+
fields: nameFields(target),
|
|
34
41
|
pageSize: 20
|
|
35
|
-
})).items
|
|
42
|
+
})).items;
|
|
36
43
|
}
|
|
37
44
|
};
|
|
38
45
|
}) });
|
|
@@ -45,7 +52,7 @@ const useReferenceSearch = (targets, views) => {
|
|
|
45
52
|
if ((texts[id] ?? "").trim() === "") return { onSearch };
|
|
46
53
|
return {
|
|
47
54
|
onSearch,
|
|
48
|
-
matches: results[index]?.data ?? []
|
|
55
|
+
matches: (results[index]?.data ?? []).map((item) => named(target, item))
|
|
49
56
|
};
|
|
50
57
|
});
|
|
51
58
|
};
|
package/dist/package.js
CHANGED