@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.
- 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/nav-from-meta.js +19 -3
- 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/app-shell/sidebar-create-link.js +26 -0
- package/dist/app-shell/sidebar-group.js +6 -14
- package/dist/app-shell/sidebar-rows.js +13 -0
- package/dist/app-shell/sidebar.js +11 -5
- 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-CAj-S5YI.js +9 -0
- package/dist/shell/assets/{index-BX1N2n2W.css → index-BRYcNA4H.css} +1 -1
- package/dist/shell/assets/{index-DprdVbQM.js → index-CEhwvXIf.js} +2 -2
- package/dist/shell/assets/{records-map-DhDIjikh.js → records-map-Cp4sUeC-.js} +2 -2
- package/dist/shell/assets/{shared-protobase-ui-uXIvgyTZ.js → shared-protobase-ui-Dd4pdw8C.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-CAj-S5YI.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-DprdVbQM.js.map → index-CEhwvXIf.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-DhDIjikh.js.map → records-map-Cp4sUeC-.js.map} +1 -1
- package/dist/shell-source-maps/assets/{shared-protobase-ui-uXIvgyTZ.js.map → shared-protobase-ui-Dd4pdw8C.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,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 {
|
|
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. */
|
|
@@ -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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
-
}
|
|
48
|
+
}) : /* @__PURE__ */ jsx(SidebarItem, {
|
|
47
49
|
item,
|
|
48
50
|
mode,
|
|
49
51
|
active: item.id === activeItem,
|
|
50
52
|
onNavigate
|
|
51
|
-
}, item.
|
|
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";
|
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);
|