@protobase/ui 0.54.4 → 0.54.6
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 +5 -4
- package/dist/app/nav-recent.js +7 -5
- package/dist/app/pages/field-value.js +19 -11
- package/dist/app/pages/merge-ui.d.ts +1 -1
- package/dist/app/pages/merge-ui.js +3 -1
- package/dist/app/pages/project-ui.d.ts +4 -1
- package/dist/app/record-page.js +3 -3
- package/dist/app/related-cards/related-card.js +32 -28
- package/dist/app/search/search-model.js +15 -6
- package/dist/app/search/use-global-search.js +3 -1
- package/dist/app/titles/record-title-of.js +17 -0
- package/dist/app/titles/record-titles.js +63 -0
- package/dist/app/titles/relation-title.js +35 -0
- package/dist/app/titles/title-rule.d.ts +14 -0
- package/dist/app/use-nav-recent.js +3 -1
- package/dist/app/use-related-records.js +9 -9
- package/dist/chat/chat-divider.d.ts +4 -1
- package/dist/chat/chat-divider.js +5 -1
- package/dist/chat/chat-thread.js +2 -16
- package/dist/chat/read-place.js +22 -0
- package/dist/data/query-keys.js +7 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/package.js +1 -1
- package/dist/plugins/plugin-ui.js +1 -0
- package/dist/shell/assets/app-Y_3vlU3f.js +9 -0
- package/dist/shell/assets/{index-FZEg_Him.js → index-IMD8ceia.js} +2 -2
- package/dist/shell/assets/{records-map-UOav4Lr-.js → records-map-TXpdTT7s.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-BHIhZYVr.js +2 -0
- package/dist/shell/index.html +3 -3
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-Y_3vlU3f.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-FZEg_Him.js.map → index-IMD8ceia.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-UOav4Lr-.js.map → records-map-TXpdTT7s.js.map} +1 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-BHIhZYVr.js.map +1 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-BL9BABNY.js +0 -9
- package/dist/shell/assets/shared-protobase-ui-GVFI0C-Q.js +0 -2
- package/dist/shell-source-maps/assets/app-BL9BABNY.js.map +0 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-GVFI0C-Q.js.map +0 -1
package/dist/app/app.js
CHANGED
|
@@ -7,7 +7,6 @@ import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
|
|
|
7
7
|
import { pageTabs, shownPageTab } from "./pages/page-tabs.js";
|
|
8
8
|
import { ToastProvider } from "../toasts/toast-provider.js";
|
|
9
9
|
import { humanize } from "../live/naming.js";
|
|
10
|
-
import { recordTitle } from "../live/model-helpers.js";
|
|
11
10
|
import { text } from "./messages.js";
|
|
12
11
|
import { Router, useRouter } from "./router.js";
|
|
13
12
|
import { useAppAddress } from "./use-app-address.js";
|
|
@@ -15,6 +14,7 @@ import { ProjectUiContext, useProjectUi } from "./pages/project-ui.js";
|
|
|
15
14
|
import { startErrorReporting } from "./error-reporting.js";
|
|
16
15
|
import { slotted } from "../locale/slotted.js";
|
|
17
16
|
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
17
|
+
import { RecordTitles, useRecordTitle } from "./titles/record-titles.js";
|
|
18
18
|
import { useSidebarNoticeShown } from "../app-shell/sidebar-notice.js";
|
|
19
19
|
import { AppShell } from "../app-shell/app-shell.js";
|
|
20
20
|
import { useSignInAddress } from "../auth/use-sign-in-address.js";
|
|
@@ -106,7 +106,8 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
106
106
|
const group = groups.find((entry) => entry.items.some((item) => item.id === place));
|
|
107
107
|
const model = resource ? meta.resources[resource] : void 0;
|
|
108
108
|
const record = useOpenedRecord(shown?.resource ?? "", shown?.key ?? "", Boolean(model && shown));
|
|
109
|
-
const
|
|
109
|
+
const titleOf = useRecordTitle();
|
|
110
|
+
const title = model && shown && record.data ? titleOf(model, view, record.data.record, shown.key) : void 0;
|
|
110
111
|
const titleLoading = Boolean(shown) && (record.isLoading || drawnFrom?.state === "loading");
|
|
111
112
|
const { shell } = useProjectUi();
|
|
112
113
|
const Actions = shell?.actions;
|
|
@@ -292,12 +293,12 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
|
|
|
292
293
|
onUnauthenticated: markSignedOut,
|
|
293
294
|
children: /* @__PURE__ */ jsx(MetaGate, { children: /* @__PURE__ */ jsxs(ToastProvider, { children: [!sidebarBanner && /* @__PURE__ */ jsx(NewVersionToast, {}), path === connectRoute ? /* @__PURE__ */ jsx(ConnectRoute, {}) : /* @__PURE__ */ jsx(PluginGate, {
|
|
294
295
|
ui,
|
|
295
|
-
children: /* @__PURE__ */ jsx(Routes, {
|
|
296
|
+
children: /* @__PURE__ */ jsx(RecordTitles, { children: /* @__PURE__ */ jsx(Routes, {
|
|
296
297
|
workspace,
|
|
297
298
|
sidebarMode,
|
|
298
299
|
assistant,
|
|
299
300
|
runtime
|
|
300
|
-
})
|
|
301
|
+
}) })
|
|
301
302
|
})] }) })
|
|
302
303
|
});
|
|
303
304
|
};
|
package/dist/app/nav-recent.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { recordKey,
|
|
1
|
+
import { recordKey, recordTitleField } from "../live/model-helpers.js";
|
|
2
2
|
import { defaultOrderBy } from "./list-order.js";
|
|
3
|
+
import { titleFieldsOf } from "./titles/record-title-of.js";
|
|
3
4
|
import { defaultRecentLimit, encodeKey } from "@protobase/schema/internal";
|
|
4
5
|
import { appPath, enumLabel } from "@protobase/schema";
|
|
5
6
|
//#region src/app/nav-recent.ts
|
|
6
|
-
/** The list request behind a sidebar group: its filter, order (the list's sort when unset), size, and only the fields it shows. */
|
|
7
|
+
/** The list request behind a sidebar group: its filter, order (the list's sort when unset), size, and only the fields it shows, those its derived title reads included. */
|
|
7
8
|
const recentRequest = (model, view, recent) => ({
|
|
8
9
|
filter: recent.filter ?? "",
|
|
9
10
|
orderBy: recent.orderBy ?? defaultOrderBy(model, view),
|
|
@@ -11,6 +12,7 @@ const recentRequest = (model, view, recent) => ({
|
|
|
11
12
|
fields: [.../* @__PURE__ */ new Set([
|
|
12
13
|
...model.primaryKey,
|
|
13
14
|
recordTitleField(model, view),
|
|
15
|
+
...titleFieldsOf(model, view),
|
|
14
16
|
...recent.status === void 0 ? [] : [recent.status]
|
|
15
17
|
])]
|
|
16
18
|
});
|
|
@@ -23,14 +25,14 @@ const recordStatus = (model, view, recent, row) => {
|
|
|
23
25
|
pulse: recent.pulse?.includes(value) ?? false
|
|
24
26
|
};
|
|
25
27
|
};
|
|
26
|
-
/** List rows as sidebar records: titled like the record page
|
|
27
|
-
const recentRecords = (model, view, recent, rows, basePath, activeKey) => {
|
|
28
|
+
/** List rows as sidebar records: titled like the record page by `titleOf`, dotted by their status when the group has one, active while open. */
|
|
29
|
+
const recentRecords = (model, view, recent, rows, basePath, activeKey, titleOf) => {
|
|
28
30
|
return rows.map((row) => {
|
|
29
31
|
const key = encodeKey(recordKey(model, row));
|
|
30
32
|
const status = recordStatus(model, view, recent, row);
|
|
31
33
|
return {
|
|
32
34
|
id: key,
|
|
33
|
-
label:
|
|
35
|
+
label: titleOf(model, view, row, key),
|
|
34
36
|
href: appPath({
|
|
35
37
|
kind: "record",
|
|
36
38
|
resource: model.name,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { keys } from "../../data/query-keys.js";
|
|
2
1
|
import { useClient } from "../../data/api-provider.js";
|
|
3
2
|
import { cn } from "../../lib/cn.js";
|
|
4
3
|
import { useAdminMeta } from "../meta-gate.js";
|
|
@@ -12,23 +11,22 @@ import { useShownWidget } from "../field-types/shown-widget.js";
|
|
|
12
11
|
import { WidgetPart } from "../field-types/widget-part.js";
|
|
13
12
|
import { Markdown } from "../../markdown/markdown.js";
|
|
14
13
|
import { ContactLink } from "../../live/contact-link.js";
|
|
15
|
-
import {
|
|
14
|
+
import { relationTitle, relationTitleQuery } from "../titles/relation-title.js";
|
|
15
|
+
import { useRecordTitle } from "../titles/record-titles.js";
|
|
16
16
|
import { ValueBadge } from "../value-badge.js";
|
|
17
17
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
18
18
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
19
19
|
//#region src/app/pages/field-value.tsx
|
|
20
|
-
const
|
|
21
|
-
const {
|
|
20
|
+
const RelationLink = ({ target, id, known }) => {
|
|
21
|
+
const { views } = useAdminMeta();
|
|
22
22
|
const client = useClient();
|
|
23
23
|
const queryClient = useQueryClient();
|
|
24
|
-
const
|
|
25
|
-
const
|
|
24
|
+
const titleOf = useRecordTitle();
|
|
25
|
+
const view = views[target.name];
|
|
26
26
|
const label = useQuery({
|
|
27
|
-
|
|
28
|
-
enabled:
|
|
29
|
-
queryFn: async () => (await fetchLabels(queryClient, client, target, [id], views[target.name])).get(id) ?? id
|
|
27
|
+
...relationTitleQuery(queryClient, client, target, view, id),
|
|
28
|
+
enabled: known === void 0
|
|
30
29
|
});
|
|
31
|
-
if (!target) return /* @__PURE__ */ jsx("span", { children: id });
|
|
32
30
|
return /* @__PURE__ */ jsx(AppLink, {
|
|
33
31
|
to: {
|
|
34
32
|
kind: "record",
|
|
@@ -36,7 +34,17 @@ const RelationValue = ({ field, value, known }) => {
|
|
|
36
34
|
key: id
|
|
37
35
|
},
|
|
38
36
|
className: "font-medium text-primary-text hover:underline",
|
|
39
|
-
children: known ?? label.data ?? id
|
|
37
|
+
children: known ?? relationTitle(label.data, titleOf, target, view, id) ?? id
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const RelationValue = ({ field, value, known }) => {
|
|
41
|
+
const { resources } = useAdminMeta();
|
|
42
|
+
const target = field.relation ? resources[field.relation.resource] : void 0;
|
|
43
|
+
if (!target) return /* @__PURE__ */ jsx("span", { children: String(value) });
|
|
44
|
+
return /* @__PURE__ */ jsx(RelationLink, {
|
|
45
|
+
target,
|
|
46
|
+
id: String(value),
|
|
47
|
+
known
|
|
40
48
|
});
|
|
41
49
|
};
|
|
42
50
|
const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
|
|
@@ -2,7 +2,7 @@ import { ProjectUi } from "./project-ui.js";
|
|
|
2
2
|
//#region src/app/pages/merge-ui.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
5
|
-
* handlers, field types, drop rules
|
|
5
|
+
* handlers, field types, drop rules, board drops and title rules are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
6
6
|
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo and the bell, the last config's to set one.
|
|
7
7
|
*/
|
|
8
8
|
export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
|
|
@@ -35,7 +35,7 @@ const around = (wrappers) => {
|
|
|
35
35
|
};
|
|
36
36
|
/**
|
|
37
37
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
38
|
-
* handlers, field types, drop rules
|
|
38
|
+
* handlers, field types, drop rules, board drops and title rules are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
39
39
|
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo and the bell, the last config's to set one.
|
|
40
40
|
*/
|
|
41
41
|
const mergeUi = (...uis) => {
|
|
@@ -45,6 +45,7 @@ const mergeUi = (...uis) => {
|
|
|
45
45
|
const fields = union("The field type", named, (ui) => ui.fields);
|
|
46
46
|
const dropRules = union("The drop rule", named, (ui) => ui.dropRules);
|
|
47
47
|
const boardDrops = union("The board drop", named, (ui) => ui.boardDrops);
|
|
48
|
+
const titles = union("The title rule", named, (ui) => ui.titles);
|
|
48
49
|
const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
|
|
49
50
|
const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
|
|
50
51
|
const logo = named.findLast(({ ui }) => ui.shell?.logo)?.ui.shell?.logo;
|
|
@@ -58,6 +59,7 @@ const mergeUi = (...uis) => {
|
|
|
58
59
|
...Object.keys(fields).length > 0 && { fields },
|
|
59
60
|
...Object.keys(dropRules).length > 0 && { dropRules },
|
|
60
61
|
...Object.keys(boardDrops).length > 0 && { boardDrops },
|
|
62
|
+
...Object.keys(titles).length > 0 && { titles },
|
|
61
63
|
...(actionSlot || rightPanel || logo || bell) && { shell: {
|
|
62
64
|
...actionSlot && { actions: actionSlot },
|
|
63
65
|
...rightPanel && { rightPanel },
|
|
@@ -2,6 +2,7 @@ import { ShellLogoProps } from "../../app-shell/logo.js";
|
|
|
2
2
|
import { FieldWidget } from "../field-types/field-widget.js";
|
|
3
3
|
import { BoardDrop } from "../displays/board-drop.js";
|
|
4
4
|
import { DropRule } from "../hand-order/drop-rule.js";
|
|
5
|
+
import { TitleRule } from "../titles/title-rule.js";
|
|
5
6
|
import { ComponentType, ReactNode } from "react";
|
|
6
7
|
import { Client } from "@protobase/client";
|
|
7
8
|
import { AppAddress } from "@protobase/schema";
|
|
@@ -46,7 +47,7 @@ export type BeforeSignInProps = {
|
|
|
46
47
|
* The project's own React code for composed pages: custom components by the name their layout declares
|
|
47
48
|
* (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour,
|
|
48
49
|
* field types by the name a view's `.widget(name)` chooses, drop rules by the name a list's `drag.rule` gives, board
|
|
49
|
-
* drops by the name a list's `drag.board` gives, and the shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
|
|
50
|
+
* drops by the name a list's `drag.board` gives, title rules by the name a view's `.titleFrom` gives, and the shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`), or whole `protobase.ui.tsx` modules
|
|
50
51
|
* (`import * as tasksUi from '...'`), their exported components included; `name` names it in their errors.
|
|
51
52
|
*/
|
|
52
53
|
export type ProjectUi = {
|
|
@@ -60,6 +61,8 @@ export type ProjectUi = {
|
|
|
60
61
|
dropRules?: Record<string, DropRule>;
|
|
61
62
|
/** Which columns of a board take a card and what a drop there writes, by the name a view's `drag.board` gives (see `BoardDrop`). */
|
|
62
63
|
boardDrops?: Record<string, BoardDrop>;
|
|
64
|
+
/** A record's title for the person reading it, by the name a view's `.titleFrom` gives (see `TitleRule`). */
|
|
65
|
+
titles?: Record<string, TitleRule>;
|
|
63
66
|
/**
|
|
64
67
|
* Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, `logo`
|
|
65
68
|
* at the top of the sidebar in place of Protobase's (see `ShellLogoProps`), and `bell` in the top bar in place of the
|
package/dist/app/record-page.js
CHANGED
|
@@ -14,7 +14,6 @@ import { RecordHeader } from "../record-view/record-header.js";
|
|
|
14
14
|
import { humanize } from "../live/naming.js";
|
|
15
15
|
import { markdownExcerpt } from "../markdown/markdown-excerpt.js";
|
|
16
16
|
import { isMarkdown } from "../markdown/markdown-hint.js";
|
|
17
|
-
import { recordTitle } from "../live/model-helpers.js";
|
|
18
17
|
import { contactFormat } from "../live/contact-links.js";
|
|
19
18
|
import { toneFor } from "../live/columns-from-model.js";
|
|
20
19
|
import { displayValue, valueTitle } from "../live/display-value.js";
|
|
@@ -36,6 +35,7 @@ import { WidgetPart } from "./field-types/widget-part.js";
|
|
|
36
35
|
import { ContactLink } from "../live/contact-link.js";
|
|
37
36
|
import { FieldEditor } from "./field-editor.js";
|
|
38
37
|
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";
|
|
@@ -152,7 +152,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
152
152
|
const meta = useAdminMeta();
|
|
153
153
|
const { address, go } = useAppAddress();
|
|
154
154
|
const view = meta.views[model.name];
|
|
155
|
-
const title =
|
|
155
|
+
const title = useRecordTitle()(model, view, stored.record, recordKey);
|
|
156
156
|
const { state: auth } = useAuth();
|
|
157
157
|
const linesModel = useMemo(() => findLinesResource(meta.resources, model), [meta.resources, model]);
|
|
158
158
|
const linesData = useLines(linesModel, model, recordKey);
|
|
@@ -216,7 +216,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
216
216
|
const pick = (name, next) => {
|
|
217
217
|
const target = pickTarget(name);
|
|
218
218
|
if (!target || !isChosen(next)) return editor.change(name, next);
|
|
219
|
-
queryClient.setQueryData(keys.relatedLabel(target.name, next.key), next.label);
|
|
219
|
+
queryClient.setQueryData(keys.relatedLabel(target.name, next.key), { label: next.label });
|
|
220
220
|
editor.change(name, keyValue(target, next.key));
|
|
221
221
|
};
|
|
222
222
|
const { fields: fieldTypes } = useProjectUi();
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useAdminMeta } from "../meta-gate.js";
|
|
2
2
|
import { Skeleton } from "../../primitives/skeleton.js";
|
|
3
|
-
import { recordKey
|
|
3
|
+
import { recordKey } from "../../live/model-helpers.js";
|
|
4
4
|
import { AppLink } from "../use-app-address.js";
|
|
5
5
|
import { RecordScope } from "../pages/record-scope.js";
|
|
6
6
|
import { ActionButton } from "../pages/blocks/action.js";
|
|
7
|
+
import { useRecordTitle } from "../titles/record-titles.js";
|
|
7
8
|
import { FieldValue } from "../pages/field-value.js";
|
|
8
9
|
import { usePathValue } from "../pages/use-path-value.js";
|
|
9
10
|
import { fieldLabel } from "../pages/blocks/field.js";
|
|
@@ -36,33 +37,36 @@ const CardField = ({ scope, path }) => {
|
|
|
36
37
|
* A related record as a card: its title linking to its page, its `fields` under their labels and the buttons of its
|
|
37
38
|
* view's `actions`, run on it as everywhere else.
|
|
38
39
|
*/
|
|
39
|
-
const RelatedCard = ({ scope, view, fields, actions }) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
path
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
children:
|
|
40
|
+
const RelatedCard = ({ scope, view, fields, actions }) => {
|
|
41
|
+
const titleOf = useRecordTitle();
|
|
42
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
43
|
+
className: "flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-surface p-4",
|
|
44
|
+
children: [
|
|
45
|
+
/* @__PURE__ */ jsx(AppLink, {
|
|
46
|
+
to: {
|
|
47
|
+
kind: "record",
|
|
48
|
+
resource: scope.model.name,
|
|
49
|
+
key: recordKey(scope.model, scope.record)
|
|
50
|
+
},
|
|
51
|
+
className: "self-start font-medium text-primary-text hover:underline",
|
|
52
|
+
children: titleOf(scope.model, view, scope.record, scope.key)
|
|
53
|
+
}),
|
|
54
|
+
fields.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
55
|
+
className: "flex flex-col gap-3",
|
|
56
|
+
children: fields.map((path) => /* @__PURE__ */ jsx(CardField, {
|
|
57
|
+
scope,
|
|
58
|
+
path
|
|
59
|
+
}, path))
|
|
60
|
+
}),
|
|
61
|
+
actions.length > 0 && /* @__PURE__ */ jsx(RecordScope, {
|
|
62
|
+
value: scope,
|
|
63
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
64
|
+
className: "flex flex-wrap items-center gap-2",
|
|
65
|
+
children: actions.map((name) => /* @__PURE__ */ jsx(ActionButton, { name }, name))
|
|
66
|
+
})
|
|
63
67
|
})
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
68
|
+
]
|
|
69
|
+
});
|
|
70
|
+
};
|
|
67
71
|
//#endregion
|
|
68
72
|
export { RelatedCard };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { humanize } from "../../live/naming.js";
|
|
2
2
|
import { recordKey, recordTitleField } from "../../live/model-helpers.js";
|
|
3
|
+
import { recordTitleOf, titleFieldsOf } from "../titles/record-title-of.js";
|
|
3
4
|
import { text } from "./messages.js";
|
|
4
5
|
import { encodeKey, matchedSearchField } from "@protobase/schema/internal";
|
|
5
6
|
import { appPath, createWhere, enumLabel, printFilter } from "@protobase/schema";
|
|
@@ -24,21 +25,25 @@ const searchPlaceholder = (targets) => {
|
|
|
24
25
|
};
|
|
25
26
|
/** The list filter for the text: the server's partial-word `search(...)` over the resource's search fields. */
|
|
26
27
|
const searchFilter = (text) => printFilter(where$1.search(text.trim()));
|
|
27
|
-
|
|
28
|
+
const derivesTitle = (target) => !target.view?.searchResult?.title?.length && target.view?.titleFrom !== void 0;
|
|
29
|
+
/** The fields a result's bold line joins: the view's `searchResult.title`, else the record's title field, none for a derived title. */
|
|
28
30
|
const titleFields = (target) => {
|
|
29
31
|
const fields = target.view?.searchResult?.title;
|
|
30
|
-
|
|
32
|
+
if (fields?.length) return fields;
|
|
33
|
+
return derivesTitle(target) ? [] : [recordTitleField(target.model, target.view)];
|
|
31
34
|
};
|
|
32
35
|
/** The second line's field, unless the search matched another: the view's `searchResult.subtitle`, else the first search field not in the title. */
|
|
33
36
|
const subtitleField = (target) => {
|
|
34
37
|
const title = titleFields(target);
|
|
35
38
|
return target.view?.searchResult?.subtitle ?? target.model.search?.find((name) => !title.includes(name));
|
|
36
39
|
};
|
|
37
|
-
/** The fields a result needs: the key, the title, the subtitle and the search fields, which say what the text matched. */
|
|
40
|
+
/** The fields a result needs: the key, the title or those its derived title reads, the subtitle and the search fields, which say what the text matched. */
|
|
38
41
|
const searchFields = (target) => {
|
|
39
42
|
const subtitle = subtitleField(target);
|
|
43
|
+
const derived = derivesTitle(target) ? titleFieldsOf(target.model, target.view) : [];
|
|
40
44
|
const lineFields = [
|
|
41
45
|
...titleFields(target),
|
|
46
|
+
...derived,
|
|
42
47
|
...subtitle ? [subtitle] : [],
|
|
43
48
|
...target.model.search ?? []
|
|
44
49
|
];
|
|
@@ -53,9 +58,13 @@ const shown = (target, row, name) => {
|
|
|
53
58
|
/**
|
|
54
59
|
* A row as a result for `text`: its title fields joined by spaces (the key when they are empty) over a subtitle, which is
|
|
55
60
|
* the search field explaining the match when neither the title nor the default subtitle does (a phone number searched
|
|
56
|
-
* for), else the default subtitle. `basePath` is where the app is served, so the link opens it in a new tab too
|
|
61
|
+
* for), else the default subtitle. `basePath` is where the app is served, so the link opens it in a new tab too; `titleOf`
|
|
62
|
+
* titles a view's derived title for the reader.
|
|
57
63
|
*/
|
|
58
|
-
const toHit = (target, row, text, basePath = ""
|
|
64
|
+
const toHit = (target, row, text, basePath = "", titleOf = recordTitleOf({}, {
|
|
65
|
+
user: void 0,
|
|
66
|
+
member: () => void 0
|
|
67
|
+
})) => {
|
|
59
68
|
const key = encodeKey(recordKey(target.model, row));
|
|
60
69
|
const title = titleFields(target);
|
|
61
70
|
const fallback = subtitleField(target);
|
|
@@ -63,7 +72,7 @@ const toHit = (target, row, text, basePath = "") => {
|
|
|
63
72
|
const subtitle = field ? shown(target, row, field) : "";
|
|
64
73
|
return {
|
|
65
74
|
id: key,
|
|
66
|
-
title: title.map((name) => shown(target, row, name)).filter(Boolean).join(" ") || key,
|
|
75
|
+
title: derivesTitle(target) ? titleOf(target.model, target.view, row, key) : title.map((name) => shown(target, row, name)).filter(Boolean).join(" ") || key,
|
|
67
76
|
...subtitle && { subtitle },
|
|
68
77
|
href: appPath({
|
|
69
78
|
kind: "record",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useClient } from "../../data/api-provider.js";
|
|
2
2
|
import { useAdminMeta } from "../meta-gate.js";
|
|
3
3
|
import { useRouter } from "../router.js";
|
|
4
|
+
import { useRecordTitle } from "../titles/record-titles.js";
|
|
4
5
|
import { useDebounced } from "../../lib/use-debounced.js";
|
|
5
6
|
import { navigationHits } from "./navigation-hits.js";
|
|
6
7
|
import { searchFields, searchFilter, searchPlaceholder, searchTargets, toHit } from "./search-model.js";
|
|
@@ -17,6 +18,7 @@ const useGlobalSearch = () => {
|
|
|
17
18
|
const meta = useAdminMeta();
|
|
18
19
|
const client = useClient();
|
|
19
20
|
const { basePath } = useRouter();
|
|
21
|
+
const titleOf = useRecordTitle();
|
|
20
22
|
const targets = useMemo(() => searchTargets(meta), [meta]);
|
|
21
23
|
const [text, setText] = useState("");
|
|
22
24
|
const typed = text.trim();
|
|
@@ -53,7 +55,7 @@ const useGlobalSearch = () => {
|
|
|
53
55
|
hits: [],
|
|
54
56
|
failed: true
|
|
55
57
|
}];
|
|
56
|
-
const hits = (result.data?.items ?? []).map((row) => toHit(target, row, query, basePath));
|
|
58
|
+
const hits = (result.data?.items ?? []).map((row) => toHit(target, row, query, basePath, titleOf));
|
|
57
59
|
return hits.length > 0 ? [{
|
|
58
60
|
id: target.model.name,
|
|
59
61
|
label: target.label,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { recordTitle } from "../../live/model-helpers.js";
|
|
2
|
+
//#region src/app/titles/record-title-of.ts
|
|
3
|
+
/** The fields a record must be read with for its view to title it. */
|
|
4
|
+
const titleFieldsOf = (model, view) => view?.titleFrom?.fields ?? [];
|
|
5
|
+
/**
|
|
6
|
+
* Titles records for this reader: a view's `titleFrom` by its rule, else its stored title as `recordTitle` does. The key
|
|
7
|
+
* shows when the rule returns nothing, is not registered, or a field it reads is unreadable or was not read with the record.
|
|
8
|
+
*/
|
|
9
|
+
const recordTitleOf = (rules, reader) => (model, view, record, fallback) => {
|
|
10
|
+
const from = view?.titleFrom;
|
|
11
|
+
if (!from) return recordTitle(model, view, record, fallback);
|
|
12
|
+
const rule = rules[from.rule];
|
|
13
|
+
if (!rule || from.unreadable || from.fields.some((name) => record[name] === void 0)) return fallback;
|
|
14
|
+
return rule(record, reader) || fallback;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { recordTitleOf, titleFieldsOf };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useOptionalAuth } from "../../auth/auth-provider.js";
|
|
2
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
3
|
+
import { useProjectUi } from "../pages/project-ui.js";
|
|
4
|
+
import { useMembers } from "../../people/use-members.js";
|
|
5
|
+
import { recordTitleOf } from "./record-title-of.js";
|
|
6
|
+
import { createContext, useContext, useMemo } from "react";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/titles/record-titles.tsx
|
|
9
|
+
const noMember = () => void 0;
|
|
10
|
+
const RecordTitleContext = createContext(recordTitleOf({}, {
|
|
11
|
+
user: void 0,
|
|
12
|
+
member: noMember
|
|
13
|
+
}));
|
|
14
|
+
/** How the framework titles a record for the person reading it: a view's derived title, else its stored one. */
|
|
15
|
+
const useRecordTitle = () => useContext(RecordTitleContext);
|
|
16
|
+
const WithoutMembers = ({ rules, user, children }) => {
|
|
17
|
+
const titleOf = useMemo(() => recordTitleOf(rules, {
|
|
18
|
+
user,
|
|
19
|
+
member: noMember
|
|
20
|
+
}), [rules, user]);
|
|
21
|
+
return /* @__PURE__ */ jsx(RecordTitleContext.Provider, {
|
|
22
|
+
value: titleOf,
|
|
23
|
+
children
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const WithMembers = ({ rules, user, children }) => {
|
|
27
|
+
const { member } = useMembers();
|
|
28
|
+
const titleOf = useMemo(() => recordTitleOf(rules, {
|
|
29
|
+
user,
|
|
30
|
+
member
|
|
31
|
+
}), [
|
|
32
|
+
rules,
|
|
33
|
+
user,
|
|
34
|
+
member
|
|
35
|
+
]);
|
|
36
|
+
return /* @__PURE__ */ jsx(RecordTitleContext.Provider, {
|
|
37
|
+
value: titleOf,
|
|
38
|
+
children
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Titles records with the project UI's `titles` rules for the signed-in reader, who gets the organization's members
|
|
43
|
+
* when the app has organizations and a view derives its titles; `useRecordTitle()` gives it to whatever titles a record.
|
|
44
|
+
*/
|
|
45
|
+
const RecordTitles = ({ children }) => {
|
|
46
|
+
const { views } = useAdminMeta();
|
|
47
|
+
const { titles } = useProjectUi();
|
|
48
|
+
const auth = useOptionalAuth();
|
|
49
|
+
const user = auth?.state.kind === "signed-in" ? auth.state.user : void 0;
|
|
50
|
+
const rules = titles ?? {};
|
|
51
|
+
if (Object.values(views).some((view) => view.titleFrom !== void 0) && user && auth?.organizations) return /* @__PURE__ */ jsx(WithMembers, {
|
|
52
|
+
rules,
|
|
53
|
+
user,
|
|
54
|
+
children
|
|
55
|
+
});
|
|
56
|
+
return /* @__PURE__ */ jsx(WithoutMembers, {
|
|
57
|
+
rules,
|
|
58
|
+
user,
|
|
59
|
+
children
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { RecordTitles, useRecordTitle };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { keys } from "../../data/query-keys.js";
|
|
2
|
+
import { fetchLabels } from "../../live/relation-labels.js";
|
|
3
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
4
|
+
//#region src/app/titles/relation-title.ts
|
|
5
|
+
const where$1 = createWhere();
|
|
6
|
+
/**
|
|
7
|
+
* The query naming the record `id` of a single-key resource that a relation points at: its label field, cached as other
|
|
8
|
+
* labels are, or the fields its view's derived title reads, which `relationTitle` titles for the reader.
|
|
9
|
+
*/
|
|
10
|
+
const relationTitleQuery = (queryClient, client, target, view, id) => {
|
|
11
|
+
const from = view?.titleFrom;
|
|
12
|
+
if (!from) return {
|
|
13
|
+
queryKey: keys.relatedLabel(target.name, id),
|
|
14
|
+
queryFn: async () => ({ label: (await fetchLabels(queryClient, client, target, [id], view)).get(id) ?? id })
|
|
15
|
+
};
|
|
16
|
+
const keyField = target.primaryKey[0];
|
|
17
|
+
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
|
+
}
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** The name `relationTitleQuery` read, titled for the reader; the key for a record not found, undefined while it loads. */
|
|
29
|
+
const relationTitle = (data, titleOf, target, view, id) => {
|
|
30
|
+
if (!data) return;
|
|
31
|
+
if ("label" in data) return data.label;
|
|
32
|
+
return data.row ? titleOf(target, view, data.row, id) : id;
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { relationTitle, relationTitleQuery };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { AuthUser, OrganizationPerson } from "@protobase/client";
|
|
2
|
+
//#region src/app/titles/title-rule.d.ts
|
|
3
|
+
/** Who reads a record whose title is derived: the signed-in user, if any, and the organization's members by user id. */
|
|
4
|
+
export type TitleReader = {
|
|
5
|
+
user: AuthUser | undefined;
|
|
6
|
+
/** The member with this user id, as `useMembers()` gives them; undefined while the members load or without organizations. */
|
|
7
|
+
member: (id: string) => OrganizationPerson | undefined;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* A rule a view's `.titleFrom(name, ...)` names: the record's title for this reader, from the fields the view picked.
|
|
11
|
+
* Returning undefined or an empty string titles the record by its key, as while the members load.
|
|
12
|
+
*/
|
|
13
|
+
export type TitleRule = (record: Record<string, unknown>, reader: TitleReader) => string | undefined;
|
|
14
|
+
//#endregion
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { keys } from "../data/query-keys.js";
|
|
2
2
|
import { useClient } from "../data/api-provider.js";
|
|
3
|
+
import { useRecordTitle } from "./titles/record-titles.js";
|
|
3
4
|
import { userMenuResources } from "./user-menu-from-meta.js";
|
|
4
5
|
import { recentRecords, recentRequest, recentRowDiffers } from "./nav-recent.js";
|
|
5
6
|
import { useEffect, useMemo } from "react";
|
|
@@ -34,6 +35,7 @@ const useFollowRecords = (groups) => {
|
|
|
34
35
|
*/
|
|
35
36
|
const useNavRecent = (meta, basePath, active) => {
|
|
36
37
|
const client = useClient();
|
|
38
|
+
const titleOf = useRecordTitle();
|
|
37
39
|
const groups = useMemo(() => {
|
|
38
40
|
const inMenu = userMenuResources(meta);
|
|
39
41
|
return Object.values(meta.views).flatMap((view) => {
|
|
@@ -59,7 +61,7 @@ const useNavRecent = (meta, basePath, active) => {
|
|
|
59
61
|
const activeKey = active.resource === model.name ? active.key : void 0;
|
|
60
62
|
return [model.name, {
|
|
61
63
|
state: "ready",
|
|
62
|
-
records: recentRecords(model, view, recent, result.data.items, basePath, activeKey)
|
|
64
|
+
records: recentRecords(model, view, recent, result.data.items, basePath, activeKey, titleOf)
|
|
63
65
|
}];
|
|
64
66
|
}));
|
|
65
67
|
};
|
|
@@ -1,20 +1,17 @@
|
|
|
1
|
-
import { keys } from "../data/query-keys.js";
|
|
2
1
|
import { useClient } from "../data/api-provider.js";
|
|
3
|
-
import {
|
|
2
|
+
import { relationTitle, relationTitleQuery } from "./titles/relation-title.js";
|
|
3
|
+
import { useRecordTitle } from "./titles/record-titles.js";
|
|
4
4
|
import { useQueries, useQueryClient } from "@tanstack/react-query";
|
|
5
5
|
//#region src/app/use-related-records.ts
|
|
6
|
-
/** Per relation the record holds, the address and name of the record it points at. The tenant is left out. */
|
|
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. */
|
|
7
7
|
const useRelatedRecords = (model, resources, views, record) => {
|
|
8
8
|
const client = useClient();
|
|
9
9
|
const queryClient = useQueryClient();
|
|
10
|
+
const titleOf = useRecordTitle();
|
|
10
11
|
const relations = Object.values(model.fields).filter((field) => field.type === "relation" && field.relation && field.name !== model.tenant && record[field.name] != null && resources[field.relation.resource]?.primaryKey.length === 1);
|
|
11
12
|
const labels = useQueries({ queries: relations.map((field) => {
|
|
12
13
|
const target = resources[field.relation.resource];
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
queryKey: keys.relatedLabel(target.name, id),
|
|
16
|
-
queryFn: async () => (await fetchLabels(queryClient, client, target, [id], views[target.name])).get(id) ?? id
|
|
17
|
-
};
|
|
14
|
+
return relationTitleQuery(queryClient, client, target, views[target.name], String(record[field.name]));
|
|
18
15
|
}) });
|
|
19
16
|
const addresses = relations.map((field) => ({
|
|
20
17
|
kind: "record",
|
|
@@ -23,7 +20,10 @@ const useRelatedRecords = (model, resources, views, record) => {
|
|
|
23
20
|
}));
|
|
24
21
|
return {
|
|
25
22
|
addresses: Object.fromEntries(relations.map((field, index) => [field.name, addresses[index]])),
|
|
26
|
-
labels: Object.fromEntries(relations.map((field, index) =>
|
|
23
|
+
labels: Object.fromEntries(relations.map((field, index) => {
|
|
24
|
+
const target = resources[field.relation.resource];
|
|
25
|
+
return [field.name, relationTitle(labels[index]?.data, titleOf, target, views[target.name], String(record[field.name]))];
|
|
26
|
+
}))
|
|
27
27
|
};
|
|
28
28
|
};
|
|
29
29
|
//#endregion
|
|
@@ -10,6 +10,9 @@ export type ChatDividerProps =
|
|
|
10
10
|
tone: 'new';
|
|
11
11
|
label?: string;
|
|
12
12
|
};
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* A line across a chat with a label in its middle, between the messages of two days or above the new ones. Marked
|
|
15
|
+
* `data-chat-divider`, so a thread keeps the reader's place on the message below it, not on the divider.
|
|
16
|
+
*/
|
|
14
17
|
export declare const ChatDivider: ({ tone, label }: ChatDividerProps) => import("react").JSX.Element;
|
|
15
18
|
//#endregion
|
|
@@ -2,7 +2,10 @@ import { cn } from "../lib/cn.js";
|
|
|
2
2
|
import { text } from "./messages.js";
|
|
3
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
4
|
//#region src/chat/chat-divider.tsx
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* A line across a chat with a label in its middle, between the messages of two days or above the new ones. Marked
|
|
7
|
+
* `data-chat-divider`, so a thread keeps the reader's place on the message below it, not on the divider.
|
|
8
|
+
*/
|
|
6
9
|
const ChatDivider = ({ tone = "day", label }) => {
|
|
7
10
|
const shown = label ?? text("newMessages");
|
|
8
11
|
const line = cn("h-px flex-1", tone === "new" ? "bg-danger" : "bg-border");
|
|
@@ -10,6 +13,7 @@ const ChatDivider = ({ tone = "day", label }) => {
|
|
|
10
13
|
role: "separator",
|
|
11
14
|
"aria-label": shown,
|
|
12
15
|
"data-tone": tone,
|
|
16
|
+
"data-chat-divider": "",
|
|
13
17
|
className: "flex items-center gap-3 text-xs font-medium",
|
|
14
18
|
children: [
|
|
15
19
|
/* @__PURE__ */ jsx("span", { className: line }),
|