@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.
Files changed (37) hide show
  1. package/dist/app/app.js +1 -1
  2. package/dist/app/displays/record-card.js +8 -0
  3. package/dist/app/embedded-list/embedded-columns.js +2 -1
  4. package/dist/app/embedded-list/use-embedded-rows.js +6 -2
  5. package/dist/app/list-page.js +6 -2
  6. package/dist/app/pages/field-value.js +3 -14
  7. package/dist/app/record-page.js +1 -1
  8. package/dist/app/related-cards/related-card.js +1 -1
  9. package/dist/app/relation-picker.js +12 -4
  10. package/dist/app/titles/record-titles.js +1 -1
  11. package/dist/app/titles/relation-title-cells.js +28 -0
  12. package/dist/app/titles/relation-title-text.js +7 -0
  13. package/dist/app/titles/relation-title.js +3 -11
  14. package/dist/app/titles/use-relation-title.js +25 -0
  15. package/dist/app/use-related-records.js +1 -1
  16. package/dist/index.js +2 -2
  17. package/dist/live/grid-loader.js +5 -3
  18. package/dist/live/relation-labels.js +5 -2
  19. package/dist/live/title-rows.js +47 -0
  20. package/dist/live/use-filter-config.js +27 -6
  21. package/dist/live/use-reference-search.js +14 -7
  22. package/dist/package.js +1 -1
  23. package/dist/shell/assets/app-CTbX0_B2.js +9 -0
  24. package/dist/shell/assets/{index-BX1N2n2W.css → index-BRYcNA4H.css} +1 -1
  25. package/dist/shell/assets/{index-DprdVbQM.js → index-CY05Rf2z.js} +2 -2
  26. package/dist/shell/assets/{records-map-DhDIjikh.js → records-map-DA9Zv2ST.js} +2 -2
  27. package/dist/shell/assets/{shared-protobase-ui-uXIvgyTZ.js → shared-protobase-ui-DjAPl6MN.js} +2 -2
  28. package/dist/shell/index.html +4 -4
  29. package/dist/shell/protobase.shell.json +1 -1
  30. package/dist/shell/protobase.sw.js +2 -2
  31. package/dist/shell-source-maps/assets/app-CTbX0_B2.js.map +1 -0
  32. package/dist/shell-source-maps/assets/{index-DprdVbQM.js.map → index-CY05Rf2z.js.map} +1 -1
  33. package/dist/shell-source-maps/assets/{records-map-DhDIjikh.js.map → records-map-DA9Zv2ST.js.map} +1 -1
  34. package/dist/shell-source-maps/assets/{shared-protobase-ui-uXIvgyTZ.js.map → shared-protobase-ui-DjAPl6MN.js.map} +1 -1
  35. package/package.json +4 -4
  36. package/dist/shell/assets/app-DxuzNqzR.js +0 -9
  37. 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 { keepPreviousData, useQuery } from "@tanstack/react-query";
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. A filter through a join
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])
@@ -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 { relationTitle, relationTitleQuery } from "../titles/relation-title.js";
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 { views } = useAdminMeta();
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 ?? relationTitle(label.data, titleOf, target, view, id) ?? id
26
+ children: known ?? title ?? id
38
27
  });
39
28
  };
40
29
  const RelationValue = ({ field, value, known }) => {
@@ -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 query = relationOptionsQuery(target, labelField, search.text, refused);
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: [keyField, labelField],
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[labelField], search.text)) : rows.items;
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: String(item[labelField])
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 { createWhere, printFilter } from "@protobase/schema";
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: keys.titleRow(target.name, id, from.fields.join(",")),
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";
@@ -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
- /** What a list call asks `labels` for: each relation among `columns` to a single-key resource, by the field that names its records. */
34
- const listLabels = (resources, views, model, columns) => Object.fromEntries(relationColumns(resources, model, columns).filter(({ target }) => target.primaryKey.length === 1).map(({ name, target }) => [name, labelFieldOf(target, views[target.name])]));
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((counts(group.id, group.id) ?? []).flatMap((facet) => facet.value === null ? [] : [String(facet.value)]), state.facets[group.id] ?? []);
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([...named, ...labelQueries[index]?.data ?? []]);
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: [keyField, labelField],
40
+ fields: nameFields(target),
34
41
  pageSize: 20
35
- })).items.map((item) => [String(item[keyField]), String(item[labelField])]);
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
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.55.1";
2
+ var version = "0.55.2";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };