@protobase/ui 0.55.1 → 0.55.3

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 (42) 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/nav-from-meta.js +19 -3
  7. package/dist/app/pages/field-value.js +3 -14
  8. package/dist/app/record-page.js +1 -1
  9. package/dist/app/related-cards/related-card.js +1 -1
  10. package/dist/app/relation-picker.js +12 -4
  11. package/dist/app/titles/record-titles.js +1 -1
  12. package/dist/app/titles/relation-title-cells.js +28 -0
  13. package/dist/app/titles/relation-title-text.js +7 -0
  14. package/dist/app/titles/relation-title.js +3 -11
  15. package/dist/app/titles/use-relation-title.js +25 -0
  16. package/dist/app/use-related-records.js +1 -1
  17. package/dist/app-shell/sidebar-create-link.js +26 -0
  18. package/dist/app-shell/sidebar-group.js +6 -14
  19. package/dist/app-shell/sidebar-rows.js +13 -0
  20. package/dist/app-shell/sidebar.js +11 -5
  21. package/dist/index.js +2 -2
  22. package/dist/live/grid-loader.js +5 -3
  23. package/dist/live/relation-labels.js +5 -2
  24. package/dist/live/title-rows.js +47 -0
  25. package/dist/live/use-filter-config.js +27 -6
  26. package/dist/live/use-reference-search.js +14 -7
  27. package/dist/package.js +1 -1
  28. package/dist/shell/assets/app-CAj-S5YI.js +9 -0
  29. package/dist/shell/assets/{index-BX1N2n2W.css → index-BRYcNA4H.css} +1 -1
  30. package/dist/shell/assets/{index-DprdVbQM.js → index-CEhwvXIf.js} +2 -2
  31. package/dist/shell/assets/{records-map-DhDIjikh.js → records-map-Cp4sUeC-.js} +2 -2
  32. package/dist/shell/assets/{shared-protobase-ui-uXIvgyTZ.js → shared-protobase-ui-Dd4pdw8C.js} +2 -2
  33. package/dist/shell/index.html +4 -4
  34. package/dist/shell/protobase.shell.json +1 -1
  35. package/dist/shell/protobase.sw.js +2 -2
  36. package/dist/shell-source-maps/assets/app-CAj-S5YI.js.map +1 -0
  37. package/dist/shell-source-maps/assets/{index-DprdVbQM.js.map → index-CEhwvXIf.js.map} +1 -1
  38. package/dist/shell-source-maps/assets/{records-map-DhDIjikh.js.map → records-map-Cp4sUeC-.js.map} +1 -1
  39. package/dist/shell-source-maps/assets/{shared-protobase-ui-uXIvgyTZ.js.map → shared-protobase-ui-Dd4pdw8C.js.map} +1 -1
  40. package/package.json +4 -4
  41. package/dist/shell/assets/app-DxuzNqzR.js +0 -9
  42. 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,7 +1,10 @@
1
+ import { allowEverything } from "../data/meta-data.js";
1
2
  import { humanize } from "../live/naming.js";
2
3
  import { text } from "./messages.js";
4
+ import { text as text$1 } from "./create/messages.js";
3
5
  import { iconFor } from "./icons.js";
4
6
  import { menuIcon } from "./menu-icons.js";
7
+ import { createOffered } from "./create/create-offered.js";
5
8
  import { userMenuResources } from "./user-menu-from-meta.js";
6
9
  import { LayoutDashboard } from "lucide-react";
7
10
  import { appPath } from "@protobase/schema";
@@ -21,10 +24,21 @@ const schemaGroup = (schema = "public") => {
21
24
  if (schema === "public") return "";
22
25
  return groupLabels[schema] ? text(groupLabels[schema]) : humanize(schema);
23
26
  };
27
+ /** The link under an entry whose `nav.create` asks for one, to whoever the list would offer New. */
28
+ const createLink = (meta, view, nav, singular, basePath) => {
29
+ if (!nav?.create || !createOffered(view, meta.pages, meta.permissions[view.resource] ?? allowEverything)) return {};
30
+ return { create: {
31
+ label: nav.create === true ? text$1("title", singular) : nav.create.label,
32
+ href: appPath({
33
+ kind: "create",
34
+ resource: view.resource
35
+ }, basePath)
36
+ } };
37
+ };
24
38
  /**
25
39
  * One sidebar group per `nav.group` (default: the database schema, none for `public`, and "Pages" for pages); `nav.hidden` and a place in
26
40
  * the user menu leave an entry out, `nav.order` sorts within a group, and `recent` (from `useNavRecent`) makes an entry
27
- * collapsible. Groups with pages come first, so a dashboard leads the sidebar. The resources of a source that cannot be
41
+ * collapsible, and `create` links to the create form under it. Groups with pages come first, so a dashboard leads the sidebar. The resources of a source that cannot be
28
42
  * read are listed under its label, each with the problem. A visitor also gets the resources whose views ask them to sign
29
43
  * in, where those views place them, under "Account" without a group. Each named list of a view is an entry of its
30
44
  * own, placed by its own `nav`.
@@ -73,7 +87,8 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
73
87
  kind: "list",
74
88
  resource: model.name
75
89
  }, basePath),
76
- ...recent[model.name] && { recent: recent[model.name] }
90
+ ...recent[model.name] && { recent: recent[model.name] },
91
+ ...createLink(meta, view, view.nav, view.names?.singular ?? humanize(model.name), basePath)
77
92
  }, view.nav?.order);
78
93
  for (const named of view.lists ?? []) {
79
94
  if (named.nav?.hidden) continue;
@@ -85,7 +100,8 @@ const navFromMeta = (meta, basePath = "", recent = {}) => {
85
100
  kind: "list",
86
101
  resource: model.name,
87
102
  list: named.name
88
- }, basePath)
103
+ }, basePath),
104
+ ...createLink(meta, view, named.nav, named.names.singular, basePath)
89
105
  }, named.nav?.order);
90
106
  }
91
107
  }
@@ -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. */
@@ -0,0 +1,26 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { subRow, subRowIndent } from "./sidebar-rows.js";
3
+ import { Plus } from "lucide-react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/app-shell/sidebar-create-link.tsx
6
+ /** The link under a sidebar entry to its resource's create form, such as "New message", lined up with its records. */
7
+ const SidebarCreateLink = ({ create, mode, onNavigate }) => /* @__PURE__ */ jsx("div", {
8
+ className: cn("border-l border-transparent pl-1.5", subRowIndent[mode]),
9
+ children: /* @__PURE__ */ jsxs("a", {
10
+ href: create.href,
11
+ onClick: onNavigate && ((event) => {
12
+ event.preventDefault();
13
+ onNavigate(create.href);
14
+ }),
15
+ className: cn("flex items-center rounded-md text-faint-foreground transition-colors hover:bg-muted hover:text-foreground", subRow[mode]),
16
+ children: [/* @__PURE__ */ jsx(Plus, {
17
+ "aria-hidden": true,
18
+ className: "size-3.5 shrink-0"
19
+ }), /* @__PURE__ */ jsx("span", {
20
+ className: "truncate",
21
+ children: create.label
22
+ })]
23
+ })
24
+ });
25
+ //#endregion
26
+ export { SidebarCreateLink };
@@ -1,5 +1,6 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { text } from "./messages.js";
3
+ import { subRow, subRowIndent } from "./sidebar-rows.js";
3
4
  import { navOpenStorageKey, readNavOpen, writeNavOpen } from "./nav-open-storage.js";
4
5
  import { SidebarItem } from "./sidebar-item.js";
5
6
  import { StatusDot } from "./status-dot.js";
@@ -7,15 +8,6 @@ import { useId, useState } from "react";
7
8
  import { ChevronRight } from "lucide-react";
8
9
  import { jsx, jsxs } from "react/jsx-runtime";
9
10
  //#region src/app-shell/sidebar-group.tsx
10
- const row = {
11
- "text-large": "h-8 gap-2.5 px-2.5",
12
- "text-small": "h-7 gap-2 px-2 text-body"
13
- };
14
- /** Lines the records up under the entry's icon. */
15
- const indent = {
16
- "text-large": "ml-[20px]",
17
- "text-small": "ml-[17px]"
18
- };
19
11
  /**
20
12
  * A sidebar entry with a few of its records under it. The label opens the list, the chevron shows or hides the
21
13
  * records (remembered in this browser), and "View all" at the end opens the list too.
@@ -58,14 +50,14 @@ const SidebarGroup = ({ item, recent, mode, active, onNavigate }) => {
58
50
  }), open && /* @__PURE__ */ jsxs("ul", {
59
51
  id: listId,
60
52
  "aria-label": text("recent", item.label.toLowerCase()),
61
- className: cn("flex flex-col gap-0.5 border-l pl-1.5", indent[mode]),
53
+ className: cn("flex flex-col gap-0.5 border-l pl-1.5", subRowIndent[mode]),
62
54
  children: [
63
55
  recent.state === "loading" && /* @__PURE__ */ jsx("li", {
64
- className: cn("flex items-center text-faint-foreground", row[mode]),
56
+ className: cn("flex items-center text-faint-foreground", subRow[mode]),
65
57
  children: text("loading")
66
58
  }),
67
59
  recent.state === "error" && /* @__PURE__ */ jsx("li", {
68
- className: cn("flex items-center text-faint-foreground", row[mode]),
60
+ className: cn("flex items-center text-faint-foreground", subRow[mode]),
69
61
  children: text("couldNotLoad")
70
62
  }),
71
63
  recent.state === "ready" && recent.records.map((record) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", {
@@ -73,7 +65,7 @@ const SidebarGroup = ({ item, recent, mode, active, onNavigate }) => {
73
65
  "aria-label": record.status ? `${record.label}, ${record.status.label}` : record.label,
74
66
  title: record.status ? `${record.label} (${record.status.label})` : record.label,
75
67
  "aria-current": record.active ? "page" : void 0,
76
- className: cn("flex min-w-0 items-center rounded-md font-medium transition-colors", row[mode], record.active ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"),
68
+ className: cn("flex min-w-0 items-center rounded-md font-medium transition-colors", subRow[mode], record.active ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"),
77
69
  children: [record.status && /* @__PURE__ */ jsx(StatusDot, {
78
70
  tone: record.status.tone,
79
71
  pulse: record.status.pulse
@@ -84,7 +76,7 @@ const SidebarGroup = ({ item, recent, mode, active, onNavigate }) => {
84
76
  }) }, record.id)),
85
77
  /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
86
78
  ...link(href),
87
- className: cn("flex items-center rounded-md text-faint-foreground transition-colors hover:bg-muted hover:text-foreground", row[mode]),
79
+ className: cn("flex items-center rounded-md text-faint-foreground transition-colors hover:bg-muted hover:text-foreground", subRow[mode]),
88
80
  children: text("viewAll")
89
81
  }) })
90
82
  ]
@@ -0,0 +1,13 @@
1
+ //#region src/app-shell/sidebar-rows.ts
2
+ /** The height and spacing of a row under a sidebar entry, such as a recent record or its create link. */
3
+ const subRow = {
4
+ "text-large": "h-8 gap-2.5 px-2.5",
5
+ "text-small": "h-7 gap-2 px-2 text-body"
6
+ };
7
+ /** Lines the rows under an entry up under the entry's icon. */
8
+ const subRowIndent = {
9
+ "text-large": "ml-[20px]",
10
+ "text-small": "ml-[17px]"
11
+ };
12
+ //#endregion
13
+ export { subRow, subRowIndent };
@@ -5,11 +5,13 @@ import { Logo } from "./logo.js";
5
5
  import { navGroups } from "./nav.js";
6
6
  import { ThemeSwitch } from "./theme-switch.js";
7
7
  import { ProfileMenu } from "./profile-menu.js";
8
+ import { SidebarCreateLink } from "./sidebar-create-link.js";
8
9
  import { isTextMode, sidebarWidth } from "./sidebar-mode.js";
9
10
  import { SidebarItem } from "./sidebar-item.js";
10
11
  import { SidebarGroup } from "./sidebar-group.js";
12
+ import { Fragment } from "react";
11
13
  import { LogIn } from "lucide-react";
12
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
13
15
  //#region src/app-shell/sidebar.tsx
14
16
  const Sidebar = ({ mode, activeItem, user, workspace, appName, appIcon, forceExpanded, profileMenuOpen, groups = navGroups, userMenu, account, onNavigate, onSignOut, signIn, organizations, roles, theme, sticky, logo: SidebarLogo = Logo, notice }) => {
15
17
  const expanding = mode === "icon-expand";
@@ -37,18 +39,22 @@ const Sidebar = ({ mode, activeItem, user, workspace, appName, appIcon, forceExp
37
39
  children: [inline ? group.label && /* @__PURE__ */ jsx("div", {
38
40
  className: cn("px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-faint-foreground", expanding && "h-4 whitespace-nowrap py-0 opacity-0 transition-opacity group-hover/side:opacity-100 group-focus-within/side:opacity-100", expanded && "opacity-100"),
39
41
  children: group.label
40
- }) : index > 0 && /* @__PURE__ */ jsx("div", { className: "mx-auto mb-1 h-px w-6 bg-border" }), group.items.map((item) => item.recent && isTextMode(mode) ? /* @__PURE__ */ jsx(SidebarGroup, {
42
+ }) : index > 0 && /* @__PURE__ */ jsx("div", { className: "mx-auto mb-1 h-px w-6 bg-border" }), group.items.map((item) => /* @__PURE__ */ jsxs(Fragment, { children: [item.recent && isTextMode(mode) ? /* @__PURE__ */ jsx(SidebarGroup, {
41
43
  item,
42
44
  recent: item.recent,
43
45
  mode,
44
46
  active: item.id === activeItem,
45
47
  onNavigate
46
- }, item.id) : /* @__PURE__ */ jsx(SidebarItem, {
48
+ }) : /* @__PURE__ */ jsx(SidebarItem, {
47
49
  item,
48
50
  mode,
49
51
  active: item.id === activeItem,
50
52
  onNavigate
51
- }, item.id))]
53
+ }), item.create && isTextMode(mode) && /* @__PURE__ */ jsx(SidebarCreateLink, {
54
+ create: item.create,
55
+ mode,
56
+ onNavigate
57
+ })] }, item.id))]
52
58
  }, group.label))
53
59
  }),
54
60
  notice,
@@ -65,7 +71,7 @@ const Sidebar = ({ mode, activeItem, user, workspace, appName, appIcon, forceExp
65
71
  organizations,
66
72
  roles,
67
73
  theme
68
- }) : /* @__PURE__ */ jsxs(Fragment, { children: [theme && inline && /* @__PURE__ */ jsx(ThemeSwitch, {
74
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [theme && inline && /* @__PURE__ */ jsx(ThemeSwitch, {
69
75
  theme,
70
76
  className: "flex min-h-11 w-full items-center gap-2.5 whitespace-nowrap p-1.5 text-muted-foreground md:min-h-0"
71
77
  }), signIn && /* @__PURE__ */ jsxs(RouteLink, {
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);