Add dedicated bot, workspace and user settings pages
This commit is contained in:
@@ -122,7 +122,7 @@ export default function AddFlow({ children }: Props) {
|
||||
const pathname = pageProps?.url?.pathname;
|
||||
|
||||
if (
|
||||
(linked && pathname == `/admin/workspaces/${workspace?.id}`) ||
|
||||
(linked && pathname == `/admin/settings/workspaces/${workspace?.id}`) ||
|
||||
pathname == SettingsComputersPath
|
||||
) {
|
||||
window.location.reload();
|
||||
|
||||
@@ -7,7 +7,11 @@ type Props = {
|
||||
submit_label: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Left out on a form embedded in a settings page: then there is
|
||||
* nothing to cancel into.
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -19,17 +23,19 @@ export default function AddFormActions({ submit_label, loading, error, onCancel
|
||||
<Stack className="gap-3! items-stretch">
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
<Row className="gap-2! flex-nowrap justify-end">
|
||||
<Button
|
||||
title="Cancel"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={loading}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{onCancel ? (
|
||||
<Button
|
||||
title="Cancel"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={loading}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button title={submit_label} type="submit" size="small" loading={loading} disabled={loading}>
|
||||
{submit_label}
|
||||
</Button>
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
|
||||
await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null);
|
||||
}
|
||||
|
||||
window.location.href = `/admin/workspaces/${workspace_id}`;
|
||||
window.location.href = `/admin/settings/workspaces/${workspace_id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,15 @@ type Props = {
|
||||
* Edits this bot; adds a worker when not given.
|
||||
*/
|
||||
bot?: BUN_SQLITE_COBBLE_BOTS;
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Left out when the form is embedded in a settings page.
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* Called with the saved bot instead of reloading. For the settings
|
||||
* page, which updates in place.
|
||||
*/
|
||||
onSaved?: (bot: BUN_SQLITE_COBBLE_BOTS) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,7 +38,7 @@ type Props = {
|
||||
* The coordinator keeps its name and has no colour: only its picture and
|
||||
* instructions change.
|
||||
*/
|
||||
export default function BotForm({ bot, onCancel }: Props) {
|
||||
export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const workspace = pageProps?.workspace;
|
||||
const crew = pageProps?.all_bots || pageProps?.bots || [];
|
||||
@@ -72,6 +80,8 @@ export default function BotForm({ bot, onCancel }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
let image_media_id: number | null | undefined;
|
||||
|
||||
if (image.changed) {
|
||||
const image_res = await setProfileImage({
|
||||
target: "bot",
|
||||
@@ -84,9 +94,24 @@ export default function BotForm({ bot, onCancel }: Props) {
|
||||
setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image.");
|
||||
return;
|
||||
}
|
||||
|
||||
image_media_id = image_res.singleRes?.image_media_id;
|
||||
}
|
||||
|
||||
if (bot?.id) {
|
||||
const saved = res.singleRes as BUN_SQLITE_COBBLE_BOTS | undefined;
|
||||
|
||||
if (onSaved && saved?.id) {
|
||||
setLoading(false);
|
||||
onSaved({
|
||||
...saved,
|
||||
...(image_media_id === undefined
|
||||
? {}
|
||||
: { image_media_id: image_media_id ?? undefined }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import UserAvatar from "@/src/components/general/user-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import {
|
||||
SettingsRecordBack,
|
||||
SettingsRecordSections,
|
||||
} from "@/src/dict/settings-record-sections-dict";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import parseSettingsRecordPath from "@/src/utils/parse-settings-record-path";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import RailBackLink from "./rail-back-link";
|
||||
import RailSettingsLink from "./rail-settings-link";
|
||||
import RailSettingsList from "./rail-settings-list";
|
||||
import RailSettingsRecordRow from "./rail-settings-record-row";
|
||||
|
||||
type Props = {
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings mode of the rail. On a list page it shows the settings sub
|
||||
* pages; on a bot, workspace or user record page it drills into that
|
||||
* record: a back link to its list, the record, then its sections.
|
||||
*/
|
||||
export default function RailSettingsAside({ pathname }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const record = parseSettingsRecordPath({ pathname });
|
||||
|
||||
if (!record) {
|
||||
return <RailSettingsList pathname={pathname} />;
|
||||
}
|
||||
|
||||
const back = SettingsRecordBack[record.kind];
|
||||
const sections = SettingsRecordSections[record.kind].filter((section) => {
|
||||
if (
|
||||
record.kind == "bots" &&
|
||||
section.url == "#delete" &&
|
||||
pageProps?.bot?.kind == "coordinator"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (section.url == "#models") {
|
||||
return record.kind == "bots"
|
||||
? Boolean(pageProps?.bot_harness_overview?.harnesses?.length)
|
||||
: record.kind == "workspaces"
|
||||
? Boolean(
|
||||
pageProps?.workspace_harness_overview?.harnesses?.length,
|
||||
)
|
||||
: true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
let avatar: ReactNode = null;
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
|
||||
if (record.kind == "bots") {
|
||||
const bot = pageProps?.bot;
|
||||
|
||||
if (!bot?.id) return <RailSettingsList pathname={pathname} />;
|
||||
|
||||
title = bot.name || `Bot ${bot.id}`;
|
||||
subtitle =
|
||||
bot.role ||
|
||||
(bot.kind == "coordinator"
|
||||
? "Coordinates your crew"
|
||||
: "Worker bot");
|
||||
avatar = (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
/>
|
||||
);
|
||||
} else if (record.kind == "workspaces") {
|
||||
const workspace = pageProps?.workspace;
|
||||
|
||||
if (!workspace?.id) return <RailSettingsList pathname={pathname} />;
|
||||
|
||||
title = workspace.name || `Workspace ${workspace.id}`;
|
||||
subtitle = workspace.description || "A workspace";
|
||||
avatar = (
|
||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
|
||||
);
|
||||
} else {
|
||||
const person = pageProps?.target_person;
|
||||
|
||||
if (!person?.id) return <RailSettingsList pathname={pathname} />;
|
||||
|
||||
const types = UserTypes.filter((type) =>
|
||||
parseUserTypes({ user_types: person.user_types }).includes(
|
||||
type.value,
|
||||
),
|
||||
)
|
||||
.map((type) => type.title)
|
||||
.join(", ");
|
||||
|
||||
title =
|
||||
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
|
||||
person.email ||
|
||||
`User ${person.id}`;
|
||||
subtitle = types || person.email || "User";
|
||||
avatar = <UserAvatar person={person} size="md" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RailBackLink title={back.title} href={back.href} />
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<RailSettingsRecordRow
|
||||
avatar={avatar}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
{sections.map((section) => (
|
||||
<RailSettingsLink key={section.url} {...section} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
avatar: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The record whose settings are open, at the top of its rail. Same 48px
|
||||
* row as a bot, selected.
|
||||
*/
|
||||
export default function RailSettingsRecordRow({
|
||||
avatar,
|
||||
title,
|
||||
subtitle,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row
|
||||
aria-current="page"
|
||||
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-3 bg-ink/5"
|
||||
>
|
||||
{avatar}
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{title}
|
||||
</Span>
|
||||
{subtitle ? (
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{subtitle}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { WorkspaceWithActivity } from "@/src/types";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceWithActivity;
|
||||
@@ -15,9 +18,14 @@ type Props = {
|
||||
* hue, so unread is said in words beside the description.
|
||||
*/
|
||||
export default function WorkspaceRow({ workspace, selected }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const href = canManageCrew({ user_types: pageProps?.user_types })
|
||||
? `/admin/settings/workspaces/${workspace.id}`
|
||||
: `/admin/workspaces/${workspace.id}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/workspaces/${workspace.id}`}
|
||||
href={href}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
title={workspace.name}
|
||||
className={twMerge(
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RailPages } from "@/src/dict/rail-pages-dict";
|
||||
import RailHeader from "../(partials)/rail-header";
|
||||
import RailPagesList from "../(partials)/rail-pages-list";
|
||||
import RailCrewList from "../(partials)/rail-crew-list";
|
||||
import RailSettingsList from "../(partials)/rail-settings-list";
|
||||
import RailSettingsAside from "../(partials)/rail-settings-aside";
|
||||
import RailUserRow from "../(partials)/rail-user-row";
|
||||
import RailWorkspaceList from "../(partials)/rail-workspace-list";
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function BotsRail() {
|
||||
<RailHeader show_add={!is_settings_page && !is_rail_page} />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
|
||||
{is_settings_page ? (
|
||||
<RailSettingsList pathname={pathname} />
|
||||
<RailSettingsAside pathname={pathname} />
|
||||
) : is_rail_page ? (
|
||||
<RailPagesList pathname={pathname} />
|
||||
) : in_workspace && live_workspace ? (
|
||||
|
||||
Reference in New Issue
Block a user