@protobase/ui 0.54.5 → 0.55.0
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 +6 -5
- package/dist/app/create/create-values.js +5 -5
- package/dist/app/field-editor.js +1 -1
- package/dist/app/field-types/built-in-fields.js +6 -0
- package/dist/app/field-types/members/member-chip.js +20 -0
- package/dist/app/field-types/members/member-ids.js +13 -0
- package/dist/app/field-types/members/members-display.js +44 -0
- package/dist/app/field-types/members/members-editor.js +44 -0
- package/dist/app/field-types/members/members-field.js +16 -0
- package/dist/app/field-types/members/messages.js +18 -0
- package/dist/app/field-types/members/use-members-field.js +18 -0
- package/dist/app/field-types/shown-widget.js +11 -6
- 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/picker/picker-combobox.js +140 -0
- package/dist/app/record-page.js +3 -3
- package/dist/app/related-cards/related-card.js +32 -28
- package/dist/app/relation-picker.js +27 -124
- 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/data/query-keys.js +7 -0
- package/dist/filter-panel/facet-combobox.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/live/field-values.js +22 -1
- package/dist/organizations/organizations-page.js +1 -1
- package/dist/package.js +1 -1
- package/dist/plugins/plugin-ui.js +1 -0
- package/dist/shell/assets/app-tBBnYXLP.js +9 -0
- package/dist/shell/assets/{index-DvC8OI3c.js → index-DvCa5s54.js} +2 -2
- package/dist/shell/assets/{records-map-C-UIoBtb.js → records-map-BWSeQRmm.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-Br4XDldp.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-tBBnYXLP.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-DvC8OI3c.js.map → index-DvCa5s54.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-C-UIoBtb.js.map → records-map-BWSeQRmm.js.map} +1 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-Br4XDldp.js.map +1 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-7LGt1Apg.js +0 -9
- package/dist/shell/assets/shared-protobase-ui-Cc69lTef.js +0 -2
- package/dist/shell-source-maps/assets/app-7LGt1Apg.js.map +0 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-Cc69lTef.js.map +0 -1
|
@@ -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,151 +1,54 @@
|
|
|
1
|
-
import { cn } from "../lib/cn.js";
|
|
2
|
-
import { Input } from "../primitives/input.js";
|
|
3
1
|
import { humanize } from "../live/naming.js";
|
|
4
2
|
import { labelFieldOf } from "../live/model-helpers.js";
|
|
5
3
|
import { text } from "./messages.js";
|
|
6
4
|
import { useList } from "../data/use-list.js";
|
|
5
|
+
import { PickerCombobox, usePickerSearch } from "./picker/picker-combobox.js";
|
|
7
6
|
import { relationOptionsQuery, titleMatches } from "./relation-options.js";
|
|
8
|
-
import { useCallback, useEffect,
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
9
8
|
import { ApiError } from "@protobase/client";
|
|
10
|
-
import { jsx
|
|
9
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
10
|
//#region src/app/relation-picker.tsx
|
|
12
|
-
/** Loads the next page while `sentinel` is in view of the list; it re-arms after each page, in case it still is. */
|
|
13
|
-
const useLoadMoreInView = (list, sentinel, loaded, loadMore) => {
|
|
14
|
-
useEffect(() => {
|
|
15
|
-
if (!list || !sentinel) return;
|
|
16
|
-
const observer = new IntersectionObserver((entries) => entries.some((entry) => entry.isIntersecting) && loadMore(), { root: list });
|
|
17
|
-
observer.observe(sentinel);
|
|
18
|
-
return () => observer.disconnect();
|
|
19
|
-
}, [
|
|
20
|
-
list,
|
|
21
|
-
sentinel,
|
|
22
|
-
loaded,
|
|
23
|
-
loadMore
|
|
24
|
-
]);
|
|
25
|
-
};
|
|
26
11
|
/**
|
|
27
12
|
* Picks a record of another resource: type to narrow the records by their search fields or title, scroll for more, and
|
|
28
13
|
* choose with the mouse or with the arrow keys and Enter; Escape closes the list.
|
|
29
14
|
*/
|
|
30
15
|
const RelationPicker = ({ target, view, value, label, chosen = [value], several = false, invalid, onChange }) => {
|
|
31
|
-
const
|
|
32
|
-
const [open, setOpen] = useState(false);
|
|
33
|
-
const [active, setActive] = useState(-1);
|
|
34
|
-
const [list, setList] = useState(null);
|
|
35
|
-
const [sentinel, setSentinel] = useState(null);
|
|
36
|
-
const listId = useId();
|
|
16
|
+
const search = usePickerSearch();
|
|
37
17
|
const keyField = target.primaryKey[0];
|
|
38
18
|
const labelField = labelFieldOf(target, view);
|
|
39
19
|
const [refused, setRefused] = useState(false);
|
|
40
|
-
const query = relationOptionsQuery(target, labelField, text
|
|
20
|
+
const query = relationOptionsQuery(target, labelField, search.text, refused);
|
|
41
21
|
const rows = useList(target.name, {
|
|
42
22
|
filter: query.filter,
|
|
43
23
|
fields: [keyField, labelField],
|
|
44
|
-
enabled: open
|
|
24
|
+
enabled: search.open
|
|
45
25
|
});
|
|
46
26
|
const expensive = query.filter !== void 0 && rows.error instanceof ApiError && rows.error.slug === "expensive-query";
|
|
47
27
|
useEffect(() => {
|
|
48
28
|
if (expensive) setRefused(true);
|
|
49
29
|
}, [expensive]);
|
|
50
|
-
const items = query.local ? rows.items.filter((item) => titleMatches(item[labelField], text
|
|
51
|
-
const more = Boolean(rows.hasNextPage);
|
|
52
|
-
const loading = rows.isPending || rows.isFetchingNextPage;
|
|
30
|
+
const items = query.local ? rows.items.filter((item) => titleMatches(item[labelField], search.text)) : rows.items;
|
|
53
31
|
const { fetchNextPage } = rows;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const onKeyDown = (event) => {
|
|
75
|
-
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
76
|
-
event.preventDefault();
|
|
77
|
-
if (!open) return setOpen(true);
|
|
78
|
-
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
79
|
-
setActive((current) => Math.min(Math.max(current + step, 0), items.length - 1));
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (event.key === "Enter" && open && items[active]) {
|
|
83
|
-
event.preventDefault();
|
|
84
|
-
choose(items[active]);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (event.key === "Escape" && open) {
|
|
88
|
-
event.preventDefault();
|
|
89
|
-
event.stopPropagation();
|
|
90
|
-
close();
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
94
|
-
className: "relative",
|
|
95
|
-
children: [/* @__PURE__ */ jsx(Input, {
|
|
96
|
-
ref: input,
|
|
97
|
-
role: "combobox",
|
|
98
|
-
"aria-autocomplete": "list",
|
|
99
|
-
"aria-expanded": open,
|
|
100
|
-
"aria-controls": listId,
|
|
101
|
-
"aria-activedescendant": open && items[active] ? optionId(active) : void 0,
|
|
102
|
-
invalid,
|
|
103
|
-
placeholder: text("searchPlural", (view?.names?.plural ?? humanize(target.name)).toLowerCase()),
|
|
104
|
-
value: open ? text$1 : label ?? value,
|
|
105
|
-
onFocus: () => setOpen(true),
|
|
106
|
-
onBlur: close,
|
|
107
|
-
onKeyDown,
|
|
108
|
-
onChange: (event) => {
|
|
109
|
-
setText(event.target.value);
|
|
110
|
-
setActive(event.target.value.trim() ? 0 : -1);
|
|
111
|
-
setOpen(true);
|
|
112
|
-
}
|
|
113
|
-
}), open && /* @__PURE__ */ jsxs("ul", {
|
|
114
|
-
ref: setList,
|
|
115
|
-
id: listId,
|
|
116
|
-
role: "listbox",
|
|
117
|
-
"aria-multiselectable": several || void 0,
|
|
118
|
-
className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 shadow-pop",
|
|
119
|
-
onMouseDown: (event) => event.preventDefault(),
|
|
120
|
-
children: [
|
|
121
|
-
items.map((item, index) => /* @__PURE__ */ jsx("li", {
|
|
122
|
-
id: optionId(index),
|
|
123
|
-
role: "option",
|
|
124
|
-
"aria-selected": index === active,
|
|
125
|
-
className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted", chosen.includes(String(item[keyField])) && "font-medium"),
|
|
126
|
-
onMouseMove: () => index !== active && setActive(index),
|
|
127
|
-
onClick: () => choose(item),
|
|
128
|
-
children: String(item[labelField])
|
|
129
|
-
}, String(item[keyField]))),
|
|
130
|
-
more && /* @__PURE__ */ jsx("li", {
|
|
131
|
-
ref: setSentinel,
|
|
132
|
-
"aria-hidden": true,
|
|
133
|
-
className: "h-px"
|
|
134
|
-
}),
|
|
135
|
-
rows.error && !expensive && /* @__PURE__ */ jsx("li", {
|
|
136
|
-
className: "px-3 py-1.5 text-muted-foreground",
|
|
137
|
-
children: text("couldNotLoadOptions")
|
|
138
|
-
}),
|
|
139
|
-
!more && !loading && !rows.error && items.length === 0 && /* @__PURE__ */ jsx("li", {
|
|
140
|
-
className: "px-3 py-1.5 text-muted-foreground",
|
|
141
|
-
children: text("noMatches")
|
|
142
|
-
}),
|
|
143
|
-
loading && /* @__PURE__ */ jsx("li", {
|
|
144
|
-
className: "px-3 py-1.5 text-muted-foreground",
|
|
145
|
-
children: text("loading")
|
|
146
|
-
})
|
|
147
|
-
]
|
|
148
|
-
})]
|
|
32
|
+
const loadMore = useCallback(() => fetchNextPage({ cancelRefetch: false }), [fetchNextPage]);
|
|
33
|
+
return /* @__PURE__ */ jsx(PickerCombobox, {
|
|
34
|
+
search,
|
|
35
|
+
options: items.map((item) => ({
|
|
36
|
+
key: String(item[keyField]),
|
|
37
|
+
label: String(item[labelField])
|
|
38
|
+
})),
|
|
39
|
+
chosen,
|
|
40
|
+
several,
|
|
41
|
+
closedText: label ?? value,
|
|
42
|
+
placeholder: text("searchPlural", (view?.names?.plural ?? humanize(target.name)).toLowerCase()),
|
|
43
|
+
invalid,
|
|
44
|
+
paging: {
|
|
45
|
+
more: Boolean(rows.hasNextPage),
|
|
46
|
+
loaded: rows.items.length,
|
|
47
|
+
loadMore
|
|
48
|
+
},
|
|
49
|
+
loading: rows.isPending || rows.isFetchingNextPage,
|
|
50
|
+
failed: Boolean(rows.error) && !expensive,
|
|
51
|
+
onChoose: (option) => onChange(option.key, option.label)
|
|
149
52
|
});
|
|
150
53
|
};
|
|
151
54
|
//#endregion
|
|
@@ -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
|
package/dist/data/query-keys.js
CHANGED
|
@@ -114,6 +114,13 @@ const keys = {
|
|
|
114
114
|
resource,
|
|
115
115
|
id
|
|
116
116
|
],
|
|
117
|
+
/** The fields a record pointed at is read with for its view to derive its title. */
|
|
118
|
+
titleRow: (resource, id, fields) => [
|
|
119
|
+
"title-row",
|
|
120
|
+
resource,
|
|
121
|
+
id,
|
|
122
|
+
fields
|
|
123
|
+
],
|
|
117
124
|
labels: (resource, ids) => [
|
|
118
125
|
"labels",
|
|
119
126
|
resource,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { formatInt } from "../format/number.js";
|
|
3
|
-
import { text } from "./messages.js";
|
|
4
3
|
import { Token } from "../primitives/token.js";
|
|
4
|
+
import { text } from "./messages.js";
|
|
5
5
|
import { facetLabel } from "./filter-state.js";
|
|
6
6
|
import { comboboxOptions, moveActive } from "./combobox-options.js";
|
|
7
7
|
import { useEffect, useId, useRef, useState } from "react";
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { LogoMark, ShellLogoProps } from "./app-shell/logo.js";
|
|
|
2
2
|
import { FieldWidget, FieldWidgetEditorProps, FieldWidgetProps } from "./app/field-types/field-widget.js";
|
|
3
3
|
import { BoardDrop, BoardDropContext, BoardDropUndo, BoardDropWrite } from "./app/displays/board-drop.js";
|
|
4
4
|
import { Drop, DropRule } from "./app/hand-order/drop-rule.js";
|
|
5
|
+
import { TitleReader, TitleRule } from "./app/titles/title-rule.js";
|
|
5
6
|
import { ActionContext, ActionHandler, BeforeSignInProps, ProjectUi, definePlugin, defineUi } from "./app/pages/project-ui.js";
|
|
6
7
|
import { PluginInfo, usePlugin } from "./plugins/plugin-context.js";
|
|
7
8
|
import { App, AppProps } from "./app/app.js";
|
|
@@ -80,4 +81,4 @@ import { Section } from "./record-view/section.js";
|
|
|
80
81
|
import { Form, FormProps } from "./app/create/form.js";
|
|
81
82
|
import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
|
|
82
83
|
import { Spinner } from "./primitives/spinner.js";
|
|
83
|
-
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, Carousel, type CarouselImage, type CarouselProps, type ChatAuthor, ChatDivider, type ChatDividerProps, ChatMessage, type ChatMessageProps, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, Markdown, type MarkdownContentProps as MarkdownProps, type MembersResult, MentionBox, type MentionBoxProps, type MentionDraft, MentionedText, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PeopleSearch, type PickedUser, type PluginInfo, type ProjectUi, type PushState, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, allowDelayMs, buttonClass, chatDayLabel, definePlugin, defineUi, diffStats, emptyDraft, isConflict, isFramed, mentionedIds, mergeUi, parseDiff, pluginApi, protobaseVersion, sameChatDay, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMembers, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUnreadNotifications, useUpdateRecord };
|
|
84
|
+
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, Carousel, type CarouselImage, type CarouselProps, type ChatAuthor, ChatDivider, type ChatDividerProps, ChatMessage, type ChatMessageProps, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, Markdown, type MarkdownContentProps as MarkdownProps, type MembersResult, MentionBox, type MentionBoxProps, type MentionDraft, MentionedText, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PeopleSearch, type PickedUser, type PluginInfo, type ProjectUi, type PushState, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, type TitleReader, type TitleRule, allowDelayMs, buttonClass, chatDayLabel, definePlugin, defineUi, diffStats, emptyDraft, isConflict, isFramed, mentionedIds, mergeUi, parseDiff, pluginApi, protobaseVersion, sameChatDay, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMembers, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUnreadNotifications, useUpdateRecord };
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { Textarea } from "./primitives/textarea.js";
|
|
|
14
14
|
import { useList } from "./data/use-list.js";
|
|
15
15
|
import { OtpInput } from "./primitives/otp-input.js";
|
|
16
16
|
import { definePlugin, defineUi } from "./app/pages/project-ui.js";
|
|
17
|
+
import { useMembers } from "./people/use-members.js";
|
|
17
18
|
import { Markdown } from "./markdown/markdown.js";
|
|
18
19
|
import { ActionInputError } from "./app/pages/action-inputs/input-errors.js";
|
|
19
20
|
import { useLayoutRecord } from "./app/pages/record-scope.js";
|
|
@@ -58,7 +59,6 @@ import { useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./no
|
|
|
58
59
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
59
60
|
import { App } from "./app/app.js";
|
|
60
61
|
import { RecordLink } from "./app/displays/record-link.js";
|
|
61
|
-
import { useMembers } from "./people/use-members.js";
|
|
62
62
|
import { Carousel } from "./carousel/carousel.js";
|
|
63
63
|
import { Choices } from "./primitives/choices.js";
|
|
64
64
|
import { RecordPicker } from "./app/record-picker.js";
|
|
@@ -12,7 +12,19 @@ const textEdited = /* @__PURE__ */ new Set([
|
|
|
12
12
|
"currency",
|
|
13
13
|
"country"
|
|
14
14
|
]);
|
|
15
|
-
const
|
|
15
|
+
const uneditable = /* @__PURE__ */ new Set([
|
|
16
|
+
"uuid",
|
|
17
|
+
"timestamp",
|
|
18
|
+
"relation"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Whether a form edits the field: not read-only, nor of a type it shows only. JSON is edited only by the field type the
|
|
22
|
+
* view's `hints` choose for it (`.widget(name)`), as there is no built-in input for it.
|
|
23
|
+
*/
|
|
24
|
+
const isEditable = (field, hints) => {
|
|
25
|
+
if (field.readOnly || uneditable.has(field.type)) return false;
|
|
26
|
+
return field.type !== "json" || hints?.widget !== void 0;
|
|
27
|
+
};
|
|
16
28
|
/** A field a record's form cannot leave empty: written through the API, never null and no boolean, which always holds a value. */
|
|
17
29
|
const isRequiredOnEdit = (field) => !field.readOnly && !field.nullable && field.type !== "boolean";
|
|
18
30
|
const numeric = /* @__PURE__ */ new Set([
|
|
@@ -20,6 +32,14 @@ const numeric = /* @__PURE__ */ new Set([
|
|
|
20
32
|
"bigint",
|
|
21
33
|
"decimal"
|
|
22
34
|
]);
|
|
35
|
+
const jsonValue = (text) => {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof SyntaxError) return text;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
23
43
|
const numberText = (text, decimal) => parseNumberText(text, decimal) ?? text;
|
|
24
44
|
/**
|
|
25
45
|
* The JSON value to send for an edited field: integers become numbers, empty optional fields become null, and numbers
|
|
@@ -34,6 +54,7 @@ const toPatchValue = (field, draft, decimal = localeDecimal()) => {
|
|
|
34
54
|
if (field.type === "integer") return Number(text);
|
|
35
55
|
if (field.type === "relation") return typeof draft === "number" ? draft : text;
|
|
36
56
|
if (textEdited.has(field.type) || field.type === "enum") return text;
|
|
57
|
+
if (field.type === "json") return jsonValue(text);
|
|
37
58
|
throw new Error(`Not implemented: editing ${field.type} fields`);
|
|
38
59
|
};
|
|
39
60
|
/** The value a draft stands for, as the API gives one: null when empty, a boolean or number where the field holds one. */
|