Give every admin page a breadcrumb trail
The trail is twui's Breadcrumbs reading Home, the sections and the page off the address, rendered as the first thing in SettingsShell's content column, so every page that is not a conversation gets one without a page touching it. The last crumb is named by the page's own title rather than the address: a record reads Scout instead of its id, /admin/logs reads Activity, and /admin/settings/mcps reads MCPs. That replaces the breadcrumb_title prop the four settings record pages had to pass, so settings-breadcrumbs.tsx goes. hasBreadcrumbTrail drops the trail on the crew of bots, home, and on anything outside /admin; the trailing slash and query string are ignored, so a folder in the files browser keeps the trail of the page it is on.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import type { AppContextObject, PagePropsType } from "@/src/types";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsShell from "./settings-shell";
|
||||
|
||||
type RenderProps = {
|
||||
pathname: string;
|
||||
/**
|
||||
* The page's own title, the name the header shows.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
function render({ pathname, title }: RenderProps) {
|
||||
const pageProps: PagePropsType = {
|
||||
url: { pathname } as PagePropsType["url"],
|
||||
};
|
||||
const context = { pageProps } as AppContextObject;
|
||||
|
||||
return renderToString(
|
||||
<AppContext.Provider value={context}>
|
||||
<SettingsShell
|
||||
title={title}
|
||||
description="Work your bots do on a schedule."
|
||||
>
|
||||
<span>PAGE_CONTENT</span>
|
||||
</SettingsShell>
|
||||
</AppContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsShell breadcrumbs", () => {
|
||||
test("a top-level page shows Home and itself", () => {
|
||||
const html = render({ pathname: "/admin/routines", title: "Routines" });
|
||||
|
||||
expect(html).toContain('aria-label="Breadcrumb"');
|
||||
expect(html).toContain(">Home<");
|
||||
expect(html).toContain(">Routines<");
|
||||
expect(html).toContain('href="/admin/routines"');
|
||||
});
|
||||
|
||||
test("a settings page walks through Settings up to the page", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/general",
|
||||
title: "General",
|
||||
});
|
||||
|
||||
expect(html).toContain('href="/admin"');
|
||||
expect(html).toContain('href="/admin/settings"');
|
||||
expect(html).toContain('href="/admin/settings/general"');
|
||||
});
|
||||
|
||||
test("a record page reads its name, not the id in its address", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/bots/3",
|
||||
title: "Scout",
|
||||
});
|
||||
|
||||
expect(html).toContain(">Scout<");
|
||||
expect(html).not.toContain(">3<");
|
||||
});
|
||||
|
||||
test("a page whose slug differs from its name shows the name", () => {
|
||||
const html = render({
|
||||
pathname: "/admin/settings/change-password",
|
||||
title: "Change password",
|
||||
});
|
||||
|
||||
expect(html).toContain(">Change password<");
|
||||
expect(html).not.toContain(">Change Password<");
|
||||
});
|
||||
|
||||
test("Activity keeps its name though its address says logs", () => {
|
||||
const html = render({ pathname: "/admin/logs", title: "Activity" });
|
||||
|
||||
expect(html).toContain(">Activity<");
|
||||
expect(html).not.toContain(">Logs<");
|
||||
});
|
||||
|
||||
test("the trail sits above the page's content, right after the header", () => {
|
||||
const html = render({ pathname: "/admin/routines", title: "Routines" });
|
||||
|
||||
expect(html.indexOf('aria-label="Breadcrumb"')).toBeLessThan(
|
||||
html.indexOf("PAGE_CONTENT"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the crew of bots, which is home, has no trail above it", () => {
|
||||
const html = render({ pathname: "/admin/bots", title: "Bots" });
|
||||
|
||||
expect(html).not.toContain('aria-label="Breadcrumb"');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useContext } from "react";
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The record's name, shown where the URL has its id.
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The hierarchy above a nested settings page: Settings / Bots / Scout.
|
||||
* Built from the path, with the record's name replacing its id.
|
||||
*/
|
||||
export default function SettingsBreadcrumbs({ title }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const pathname = pageProps?.url?.pathname;
|
||||
|
||||
if (!pathname) return null;
|
||||
|
||||
return (
|
||||
<Stack className="w-full shrink-0 pt-5 pb-1">
|
||||
<Breadcrumbs
|
||||
pageUrl={pathname}
|
||||
currentTitle={title}
|
||||
skipHome
|
||||
excludeRegexMatch={/^admin$/}
|
||||
linkProps={{
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||
}}
|
||||
currentLinkProps={{
|
||||
title,
|
||||
className:
|
||||
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||
}}
|
||||
divider={
|
||||
<ChevronRight
|
||||
size={12}
|
||||
aria-hidden
|
||||
className="shrink-0 text-ink-secondary"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import SettingsBreadcrumbs from "./settings-breadcrumbs";
|
||||
import AdminBreadcrumbs from "@/src/components/general/admin-breadcrumbs";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsPaneHeader from "./settings-pane-header";
|
||||
|
||||
type Props = {
|
||||
@@ -12,10 +13,6 @@ type Props = {
|
||||
* A wider column, for tables (activity).
|
||||
*/
|
||||
wide?: boolean;
|
||||
/**
|
||||
* A nested record page's name, for its breadcrumb trail.
|
||||
*/
|
||||
breadcrumb_title?: string;
|
||||
/**
|
||||
* Controls at the end of the pane header, e.g. a settings link.
|
||||
*/
|
||||
@@ -23,17 +20,19 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings pane beside the rail: a 64px header like the conversation
|
||||
* header, then one readable column that scrolls on its own.
|
||||
* The pane beside the rail: a 64px header like the conversation header,
|
||||
* then one readable column that scrolls on its own, opening with the
|
||||
* breadcrumb trail above the page.
|
||||
*/
|
||||
export default function SettingsShell({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
wide,
|
||||
breadcrumb_title,
|
||||
action,
|
||||
}: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<SettingsPaneHeader
|
||||
@@ -48,9 +47,15 @@ export default function SettingsShell({
|
||||
wide && "max-w-[1200px]",
|
||||
)}
|
||||
>
|
||||
{breadcrumb_title ? (
|
||||
<SettingsBreadcrumbs title={breadcrumb_title} />
|
||||
) : null}
|
||||
{/**
|
||||
* The page's own `title` names the last crumb: the
|
||||
* address would say the id of a record, or a slug that
|
||||
* differs from the name the page shows.
|
||||
*/}
|
||||
<AdminBreadcrumbs
|
||||
pathname={pageProps?.url?.pathname}
|
||||
title={title}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function BotSettingsSection() {
|
||||
? "Coordinates your crew and hands work to it"
|
||||
: "A worker bot.")
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<BotOverviewGroup bot={bot} />
|
||||
<BotDetailsGroup
|
||||
|
||||
-1
@@ -45,7 +45,6 @@ export default function ComputerSettingsSection() {
|
||||
computer.description ||
|
||||
"Its container, the bots that reach it, and its workspaces."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<ComputerOverviewGroup computer={computer} />
|
||||
{is_super_admin ? (
|
||||
|
||||
@@ -4,7 +4,10 @@ import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsMcpsPage() {
|
||||
return (
|
||||
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
|
||||
<SettingsShell
|
||||
title="MCPs"
|
||||
description="MCP servers whose tools your bots can use."
|
||||
>
|
||||
<McpsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function UserSettingsSection() {
|
||||
description={
|
||||
types_title || person.email || "A user on this install."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<UserOverviewGroup
|
||||
person={person}
|
||||
|
||||
@@ -50,7 +50,6 @@ export default function WorkspaceOverview() {
|
||||
workspace.description ||
|
||||
"Context, bots, computers and models for this workspace."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<WorkspaceImageGroup workspace={workspace} />
|
||||
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
||||
|
||||
Reference in New Issue
Block a user