@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
package/dist/app/app.js
CHANGED
|
@@ -7,14 +7,15 @@ 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";
|
|
14
13
|
import { ProjectUiContext, useProjectUi } from "./pages/project-ui.js";
|
|
14
|
+
import { isAdmin, userToShell } from "../auth/user-to-shell.js";
|
|
15
15
|
import { startErrorReporting } from "./error-reporting.js";
|
|
16
16
|
import { slotted } from "../locale/slotted.js";
|
|
17
17
|
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
18
|
+
import { RecordTitles, useRecordTitle } from "./titles/record-titles.js";
|
|
18
19
|
import { useSidebarNoticeShown } from "../app-shell/sidebar-notice.js";
|
|
19
20
|
import { AppShell } from "../app-shell/app-shell.js";
|
|
20
21
|
import { useSignInAddress } from "../auth/use-sign-in-address.js";
|
|
@@ -25,7 +26,6 @@ import { ConnectRoute } from "../connect/connect-route.js";
|
|
|
25
26
|
import { SignInPolicyPage } from "../account/sign-in-policy-page.js";
|
|
26
27
|
import { PluginSourceProvider } from "../plugins/plugin-source.js";
|
|
27
28
|
import { AuthGate } from "../auth/auth-gate.js";
|
|
28
|
-
import { isAdmin, userToShell } from "../auth/user-to-shell.js";
|
|
29
29
|
import { AuthLocale } from "../auth/auth-locale.js";
|
|
30
30
|
import { useLiveUpdates } from "../data/use-live-updates.js";
|
|
31
31
|
import { useOpenedRecord } from "../data/use-record.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
|
};
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import { isEditable, toPatchValue } from "../../live/field-values.js";
|
|
1
|
+
import { isEditable, toDraft, toPatchValue } from "../../live/field-values.js";
|
|
2
2
|
import { whenShown } from "../record-mode/record-mode.js";
|
|
3
3
|
import { text } from "./messages.js";
|
|
4
4
|
import { layoutItems } from "@protobase/schema/internal";
|
|
5
5
|
import { isRecordCondition } from "@protobase/schema";
|
|
6
6
|
//#region src/app/create/create-values.ts
|
|
7
7
|
const isChosen = (value) => typeof value === "object" && value !== null && "key" in value;
|
|
8
|
-
/** Fields the user can set on a new record: not read-only, not the tenant, and
|
|
9
|
-
const isCreatable = (model, field) => field.name !== model.tenant && !field.readOnly && (field.type === "relation" || isEditable(field));
|
|
8
|
+
/** Fields the user can set on a new record: not read-only, not the tenant, and no JSON but through a field type (`isEditable`). */
|
|
9
|
+
const isCreatable = (model, field, hints) => field.name !== model.tenant && !field.readOnly && (field.type === "relation" || isEditable(field, hints));
|
|
10
10
|
/**
|
|
11
11
|
* The view's sections limited to creatable fields, plus a section for any creatable field the layout leaves out. The
|
|
12
12
|
* create form is a form, so sections and fields meant only for reading the record stay out of their sections, and a
|
|
13
13
|
* creatable field among them lands in that last section. A write-only field is on the form only where a section names it.
|
|
14
14
|
*/
|
|
15
15
|
const createSections = (model, view) => {
|
|
16
|
-
const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field)).map((field) => field.name);
|
|
16
|
+
const creatable = Object.values(model.fields).filter((field) => isCreatable(model, field, view?.fields[field.name])).map((field) => field.name);
|
|
17
17
|
const placed = /* @__PURE__ */ new Set();
|
|
18
18
|
const sections = layoutItems(view?.layout ?? []).flatMap((item) => {
|
|
19
19
|
if (item.kind !== "section" || isRecordCondition(item.when) || !whenShown(item.when, true)) return [];
|
|
@@ -83,7 +83,7 @@ const initialDraft = (model, sections, given = {}) => Object.fromEntries(section
|
|
|
83
83
|
const fallback = field.default;
|
|
84
84
|
if (numberDefault(field) !== void 0) return [];
|
|
85
85
|
if (!fallback || !("value" in fallback) || fallback.value === null || fallback.value === void 0) return [];
|
|
86
|
-
return [[name, typeof fallback.value === "boolean" ? fallback.value :
|
|
86
|
+
return [[name, typeof fallback.value === "boolean" ? fallback.value : toDraft(fallback.value)]];
|
|
87
87
|
})));
|
|
88
88
|
const hasDatabaseDefault = (field) => Boolean(field.default && "db" in field.default);
|
|
89
89
|
//#endregion
|
package/dist/app/field-editor.js
CHANGED
|
@@ -156,7 +156,7 @@ const ChosenEditor = (props) => {
|
|
|
156
156
|
const shown = useShownWidget(field, hints);
|
|
157
157
|
const builtIn = /* @__PURE__ */ jsx(BuiltInEditor, { ...props });
|
|
158
158
|
if (!shown) return builtIn;
|
|
159
|
-
if (locked || !isEditable(field)) return /* @__PURE__ */ jsx(WidgetPart, {
|
|
159
|
+
if (locked || !isEditable(field, hints)) return /* @__PURE__ */ jsx(WidgetPart, {
|
|
160
160
|
shown,
|
|
161
161
|
part: "display",
|
|
162
162
|
field,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { membersField } from "./members/members-field.js";
|
|
2
|
+
//#region src/app/field-types/built-in-fields.ts
|
|
3
|
+
/** The field types Protobase brings, which a view chooses by name like a plugin's; a plugin's of the same name wins. */
|
|
4
|
+
const builtInFields = { members: membersField };
|
|
5
|
+
//#endregion
|
|
6
|
+
export { builtInFields };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Avatar } from "../../../primitives/avatar.js";
|
|
2
|
+
import { hueOf, initialsOf } from "../../../auth/user-to-shell.js";
|
|
3
|
+
import { text } from "./messages.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/app/field-types/members/member-chip.tsx
|
|
6
|
+
/** A member by avatar and name, as chat messages and comments draw their authors; a name of `undefined` is someone unknown. */
|
|
7
|
+
const MemberChip = ({ id, name }) => /* @__PURE__ */ jsxs("span", {
|
|
8
|
+
className: "inline-flex min-w-0 items-center gap-1.5",
|
|
9
|
+
children: [/* @__PURE__ */ jsx(Avatar, {
|
|
10
|
+
initials: name && initialsOf(name) || "?",
|
|
11
|
+
hue: hueOf(id),
|
|
12
|
+
size: "xs",
|
|
13
|
+
className: "ring-0"
|
|
14
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
15
|
+
className: name === void 0 ? "truncate text-muted-foreground" : "truncate",
|
|
16
|
+
children: name ?? text("unknownMember")
|
|
17
|
+
})]
|
|
18
|
+
});
|
|
19
|
+
//#endregion
|
|
20
|
+
export { MemberChip };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { titleMatches } from "../../relation-options.js";
|
|
2
|
+
//#region src/app/field-types/members/member-ids.ts
|
|
3
|
+
/** The user ids a `members` field holds: the strings of its array, nothing for a value that is none. */
|
|
4
|
+
const memberIdsOf = (value) => Array.isArray(value) ? value.filter((id) => typeof id === "string") : [];
|
|
5
|
+
/** The ids with `id` added, or taken off when they hold it already. */
|
|
6
|
+
const toggledId = (ids, id) => ids.includes(id) ? ids.filter((held) => held !== id) : [...ids, id];
|
|
7
|
+
/** The members whose name holds every word of `search`, as the picker's options keyed by user id. */
|
|
8
|
+
const memberOptions = (people, search) => people.filter((person) => titleMatches(person.name, search)).map((person) => ({
|
|
9
|
+
key: person.id,
|
|
10
|
+
label: person.name
|
|
11
|
+
}));
|
|
12
|
+
//#endregion
|
|
13
|
+
export { memberIdsOf, memberOptions, toggledId };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { cn } from "../../../lib/cn.js";
|
|
2
|
+
import { Skeleton } from "../../../primitives/skeleton.js";
|
|
3
|
+
import { text } from "./messages.js";
|
|
4
|
+
import { MemberChip } from "./member-chip.js";
|
|
5
|
+
import { memberIdsOf } from "./member-ids.js";
|
|
6
|
+
import { useMembersField } from "./use-members-field.js";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
//#region src/app/field-types/members/members-display.tsx
|
|
9
|
+
const Members = ({ value, line }) => {
|
|
10
|
+
const members = useMembersField();
|
|
11
|
+
const ids = memberIdsOf(value);
|
|
12
|
+
if (ids.length === 0) return /* @__PURE__ */ jsx("span", {
|
|
13
|
+
className: "text-muted-foreground",
|
|
14
|
+
children: "—"
|
|
15
|
+
});
|
|
16
|
+
if (members.needsOrganizations) return /* @__PURE__ */ jsx("span", {
|
|
17
|
+
className: "text-muted-foreground",
|
|
18
|
+
children: text("needsOrganizations")
|
|
19
|
+
});
|
|
20
|
+
if (members.failed) return /* @__PURE__ */ jsx("span", {
|
|
21
|
+
className: "text-muted-foreground",
|
|
22
|
+
children: text("couldNotLoadMembers")
|
|
23
|
+
});
|
|
24
|
+
if (members.isLoading) return /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" });
|
|
25
|
+
return /* @__PURE__ */ jsx("span", {
|
|
26
|
+
className: cn("flex min-w-0 items-center gap-x-3 gap-y-1", line ? "overflow-hidden" : "flex-wrap"),
|
|
27
|
+
children: ids.map((id) => /* @__PURE__ */ jsx(MemberChip, {
|
|
28
|
+
id,
|
|
29
|
+
name: members.member(id)?.name
|
|
30
|
+
}, id))
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
/** The members a field holds by avatar and name, on record pages, cards and page blocks; one no longer a member as unknown. */
|
|
34
|
+
const MembersDisplay = (props) => /* @__PURE__ */ jsx(Members, {
|
|
35
|
+
...props,
|
|
36
|
+
line: false
|
|
37
|
+
});
|
|
38
|
+
/** The members a field holds on one line, in a list's table. */
|
|
39
|
+
const MembersCell = (props) => /* @__PURE__ */ jsx(Members, {
|
|
40
|
+
...props,
|
|
41
|
+
line: true
|
|
42
|
+
});
|
|
43
|
+
//#endregion
|
|
44
|
+
export { MembersCell, MembersDisplay };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PickerCombobox, usePickerSearch } from "../../picker/picker-combobox.js";
|
|
2
|
+
import { text } from "./messages.js";
|
|
3
|
+
import { memberIdsOf, memberOptions, toggledId } from "./member-ids.js";
|
|
4
|
+
import { useMembersField } from "./use-members-field.js";
|
|
5
|
+
import { Token } from "../../../primitives/token.js";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/field-types/members/members-editor.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Picks the organization's members into the field's array of user ids, as `RecordPicker multiple` picks records: type
|
|
10
|
+
* to narrow them by name, each choice a token that takes it off again, and choosing a chosen member takes them off.
|
|
11
|
+
*/
|
|
12
|
+
const MembersEditor = ({ value, invalid, onChange }) => {
|
|
13
|
+
const search = usePickerSearch();
|
|
14
|
+
const members = useMembersField();
|
|
15
|
+
const ids = memberIdsOf(value);
|
|
16
|
+
if (members.needsOrganizations) return /* @__PURE__ */ jsx("p", {
|
|
17
|
+
className: "py-1.5 text-muted-foreground",
|
|
18
|
+
children: text("needsOrganizations")
|
|
19
|
+
});
|
|
20
|
+
const labelOf = (id) => members.member(id)?.name ?? text("unknownMember");
|
|
21
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
22
|
+
className: "flex flex-col gap-2",
|
|
23
|
+
children: [/* @__PURE__ */ jsx(PickerCombobox, {
|
|
24
|
+
search,
|
|
25
|
+
options: memberOptions(members.data ?? [], search.text),
|
|
26
|
+
chosen: ids,
|
|
27
|
+
several: true,
|
|
28
|
+
closedText: "",
|
|
29
|
+
placeholder: text("searchMembers"),
|
|
30
|
+
invalid,
|
|
31
|
+
loading: members.isLoading,
|
|
32
|
+
failed: members.failed,
|
|
33
|
+
onChoose: (option) => onChange(toggledId(ids, option.key))
|
|
34
|
+
}), ids.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
35
|
+
className: "flex flex-wrap gap-1",
|
|
36
|
+
children: ids.map((id) => /* @__PURE__ */ jsx(Token, {
|
|
37
|
+
label: labelOf(id),
|
|
38
|
+
onRemove: () => onChange(ids.filter((held) => held !== id))
|
|
39
|
+
}, id))
|
|
40
|
+
})]
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { MembersEditor };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { MembersCell, MembersDisplay } from "./members-display.js";
|
|
2
|
+
import { MembersEditor } from "./members-editor.js";
|
|
3
|
+
import { builtInFieldTypes } from "@protobase/schema/internal";
|
|
4
|
+
//#region src/app/field-types/members/members-field.ts
|
|
5
|
+
/**
|
|
6
|
+
* The built-in `members` field type, for a JSON array of the user ids of members of the organization the session works
|
|
7
|
+
* in: `r.member_ids.widget('members')`. See the field types reference.
|
|
8
|
+
*/
|
|
9
|
+
const membersField = {
|
|
10
|
+
of: [...builtInFieldTypes.members],
|
|
11
|
+
display: MembersDisplay,
|
|
12
|
+
editor: MembersEditor,
|
|
13
|
+
cell: MembersCell
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { membersField };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineMessages } from "../../../locale/messages.js";
|
|
2
|
+
//#region src/app/field-types/members/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: {
|
|
5
|
+
needsOrganizations: "Choosing members needs organizations: turn them on in the auth config.",
|
|
6
|
+
couldNotLoadMembers: "Could not load the members.",
|
|
7
|
+
unknownMember: "Unknown member",
|
|
8
|
+
searchMembers: "Search members"
|
|
9
|
+
},
|
|
10
|
+
nl: {
|
|
11
|
+
needsOrganizations: "Leden kiezen vraagt om organisaties: zet ze aan in de auth-configuratie.",
|
|
12
|
+
couldNotLoadMembers: "De leden konden niet worden geladen.",
|
|
13
|
+
unknownMember: "Onbekend lid",
|
|
14
|
+
searchMembers: "Leden zoeken"
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
//#endregion
|
|
18
|
+
export { text };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useOptionalAuth } from "../../../auth/auth-provider.js";
|
|
2
|
+
import { useMembers } from "../../../people/use-members.js";
|
|
3
|
+
//#region src/app/field-types/members/use-members-field.ts
|
|
4
|
+
/**
|
|
5
|
+
* The organization's members for a `members` field, and what keeps it from showing them: `needsOrganizations` in an
|
|
6
|
+
* app without them, where `useMembers` would fail, and `failed` when they could not be read.
|
|
7
|
+
*/
|
|
8
|
+
const useMembersField = () => {
|
|
9
|
+
const auth = useOptionalAuth();
|
|
10
|
+
const members = useMembers();
|
|
11
|
+
return {
|
|
12
|
+
...members,
|
|
13
|
+
needsOrganizations: !auth?.organizations,
|
|
14
|
+
failed: members.isError
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { useMembersField };
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { useProjectUi } from "../pages/project-ui.js";
|
|
2
|
+
import { builtInFields } from "./built-in-fields.js";
|
|
2
3
|
//#region src/app/field-types/shown-widget.ts
|
|
4
|
+
const named = (fields, name) => {
|
|
5
|
+
if (fields && Object.hasOwn(fields, name)) return fields[name];
|
|
6
|
+
if (Object.hasOwn(builtInFields, name)) return builtInFields[name];
|
|
7
|
+
};
|
|
3
8
|
/**
|
|
4
|
-
* The field type the view's `hints` choose for `field`, when one of `fields`
|
|
5
|
-
* storage type; otherwise undefined, and the field shows as it would without one. A
|
|
6
|
-
* its value, so it keeps its own.
|
|
9
|
+
* The field type the view's `hints` choose for `field`, when one of `fields` or a built-in one (`builtInFields`) has
|
|
10
|
+
* that name and shows the field's storage type; otherwise undefined, and the field shows as it would without one. A
|
|
11
|
+
* sensitive field never comes with its value, so it keeps its own.
|
|
7
12
|
*/
|
|
8
13
|
const shownWidget = (fields, field, hints) => {
|
|
9
14
|
const chosen = hints?.widget;
|
|
10
|
-
if (!chosen || field.sensitive
|
|
11
|
-
const widget = fields
|
|
12
|
-
return widget
|
|
15
|
+
if (!chosen || field.sensitive) return void 0;
|
|
16
|
+
const widget = named(fields, chosen.name);
|
|
17
|
+
return widget?.of.includes(field.type) ? {
|
|
13
18
|
name: chosen.name,
|
|
14
19
|
widget,
|
|
15
20
|
props: chosen.props ?? {}
|
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
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { Input } from "../../primitives/input.js";
|
|
3
|
+
import { text } from "../messages.js";
|
|
4
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/app/picker/picker-combobox.tsx
|
|
7
|
+
const usePickerSearch = () => {
|
|
8
|
+
const [text, setText] = useState("");
|
|
9
|
+
const [open, setOpen] = useState(false);
|
|
10
|
+
return {
|
|
11
|
+
text,
|
|
12
|
+
setText,
|
|
13
|
+
open,
|
|
14
|
+
setOpen
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/** Loads the next page while `sentinel` is in view of the list; it re-arms after each page, in case it still is. */
|
|
18
|
+
const useLoadMoreInView = (list, sentinel, loaded, loadMore) => {
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!list || !sentinel || !loadMore) return;
|
|
21
|
+
const observer = new IntersectionObserver((entries) => entries.some((entry) => entry.isIntersecting) && loadMore(), { root: list });
|
|
22
|
+
observer.observe(sentinel);
|
|
23
|
+
return () => observer.disconnect();
|
|
24
|
+
}, [
|
|
25
|
+
list,
|
|
26
|
+
sentinel,
|
|
27
|
+
loaded,
|
|
28
|
+
loadMore
|
|
29
|
+
]);
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A box to type in that lists the options its owner reads for the text: choose with the mouse or with the arrow keys
|
|
33
|
+
* and Enter; Escape closes the list. The pickers of records and of members draw their lists with it.
|
|
34
|
+
*/
|
|
35
|
+
const PickerCombobox = ({ search, options, chosen, several, closedText, placeholder, invalid, paging, loading, failed, onChoose }) => {
|
|
36
|
+
const { text: text$1, setText, open, setOpen } = search;
|
|
37
|
+
const [active, setActive] = useState(-1);
|
|
38
|
+
const [list, setList] = useState(null);
|
|
39
|
+
const [sentinel, setSentinel] = useState(null);
|
|
40
|
+
const listId = useId();
|
|
41
|
+
const more = paging?.more ?? false;
|
|
42
|
+
useLoadMoreInView(list, sentinel, paging?.loaded ?? 0, paging?.loadMore);
|
|
43
|
+
const optionId = (index) => `${listId}-${index}`;
|
|
44
|
+
const input = useRef(null);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!open && document.activeElement === input.current) input.current?.select();
|
|
47
|
+
}, [open]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (open && active >= 0) document.getElementById(optionId(active))?.scrollIntoView({ block: "nearest" });
|
|
50
|
+
});
|
|
51
|
+
const close = () => {
|
|
52
|
+
setText("");
|
|
53
|
+
setActive(-1);
|
|
54
|
+
setOpen(false);
|
|
55
|
+
};
|
|
56
|
+
const choose = (option) => {
|
|
57
|
+
onChoose(option);
|
|
58
|
+
if (!several) return close();
|
|
59
|
+
setText("");
|
|
60
|
+
setActive(-1);
|
|
61
|
+
};
|
|
62
|
+
const onKeyDown = (event) => {
|
|
63
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
if (!open) return setOpen(true);
|
|
66
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
67
|
+
setActive((current) => Math.min(Math.max(current + step, 0), options.length - 1));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (event.key === "Enter" && open && options[active]) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
choose(options[active]);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (event.key === "Escape" && open) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
event.stopPropagation();
|
|
78
|
+
close();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
82
|
+
className: "relative",
|
|
83
|
+
children: [/* @__PURE__ */ jsx(Input, {
|
|
84
|
+
ref: input,
|
|
85
|
+
role: "combobox",
|
|
86
|
+
"aria-autocomplete": "list",
|
|
87
|
+
"aria-expanded": open,
|
|
88
|
+
"aria-controls": listId,
|
|
89
|
+
"aria-activedescendant": open && options[active] ? optionId(active) : void 0,
|
|
90
|
+
invalid,
|
|
91
|
+
placeholder,
|
|
92
|
+
value: open ? text$1 : closedText,
|
|
93
|
+
onFocus: () => setOpen(true),
|
|
94
|
+
onBlur: close,
|
|
95
|
+
onKeyDown,
|
|
96
|
+
onChange: (event) => {
|
|
97
|
+
setText(event.target.value);
|
|
98
|
+
setActive(event.target.value.trim() ? 0 : -1);
|
|
99
|
+
setOpen(true);
|
|
100
|
+
}
|
|
101
|
+
}), open && /* @__PURE__ */ jsxs("ul", {
|
|
102
|
+
ref: setList,
|
|
103
|
+
id: listId,
|
|
104
|
+
role: "listbox",
|
|
105
|
+
"aria-multiselectable": several || void 0,
|
|
106
|
+
className: "absolute z-30 mt-1 max-h-60 w-full overflow-y-auto rounded-md border bg-background py-1 shadow-pop",
|
|
107
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
108
|
+
children: [
|
|
109
|
+
options.map((option, index) => /* @__PURE__ */ jsx("li", {
|
|
110
|
+
id: optionId(index),
|
|
111
|
+
role: "option",
|
|
112
|
+
"aria-selected": index === active,
|
|
113
|
+
className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted", chosen.includes(option.key) && "font-medium"),
|
|
114
|
+
onMouseMove: () => index !== active && setActive(index),
|
|
115
|
+
onClick: () => choose(option),
|
|
116
|
+
children: option.label
|
|
117
|
+
}, option.key)),
|
|
118
|
+
more && /* @__PURE__ */ jsx("li", {
|
|
119
|
+
ref: setSentinel,
|
|
120
|
+
"aria-hidden": true,
|
|
121
|
+
className: "h-px"
|
|
122
|
+
}),
|
|
123
|
+
failed && /* @__PURE__ */ jsx("li", {
|
|
124
|
+
className: "px-3 py-1.5 text-muted-foreground",
|
|
125
|
+
children: text("couldNotLoadOptions")
|
|
126
|
+
}),
|
|
127
|
+
!more && !loading && !failed && options.length === 0 && /* @__PURE__ */ jsx("li", {
|
|
128
|
+
className: "px-3 py-1.5 text-muted-foreground",
|
|
129
|
+
children: text("noMatches")
|
|
130
|
+
}),
|
|
131
|
+
loading && /* @__PURE__ */ jsx("li", {
|
|
132
|
+
className: "px-3 py-1.5 text-muted-foreground",
|
|
133
|
+
children: text("loading")
|
|
134
|
+
})
|
|
135
|
+
]
|
|
136
|
+
})]
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
//#endregion
|
|
140
|
+
export { PickerCombobox, usePickerSearch };
|
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();
|