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,64 @@
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import hasBreadcrumbTrail from "@/src/utils/has-breadcrumb-trail";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The address the page is at, e.g. `/admin/settings/bots/3`.
|
||||
*/
|
||||
pathname?: string | null;
|
||||
/**
|
||||
* The page's own name, for the last crumb: what the address can't say,
|
||||
* since its last segment is an id (`Scout`, not `3`) or a slug that
|
||||
* differs from the name the page shows (`MCPs`, not `Mcps`).
|
||||
*/
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The trail above a page's content: `Home`, then every section the address
|
||||
* passes through, then the page itself, named by `title`.
|
||||
*
|
||||
* It is `twui`'s `Breadcrumbs`, which reads the trail off the path, so the
|
||||
* sections come from the address and only the page's own name is given
|
||||
* here; `hasBreadcrumbTrail` says which pages get one at all.
|
||||
*/
|
||||
export default function AdminBreadcrumbs({ pathname, title }: Props) {
|
||||
if (!hasBreadcrumbTrail({ pathname })) return null;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={twMerge(
|
||||
"w-full shrink-0 pt-5 pb-1 sticky top-0 z-10 bg-background-light",
|
||||
"dark:bg-background-dark",
|
||||
)}
|
||||
>
|
||||
<Breadcrumbs
|
||||
pageUrl={pathname || AdminHomePath}
|
||||
currentTitle={title}
|
||||
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"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import hasBreadcrumbTrail from "./has-breadcrumb-trail";
|
||||
|
||||
describe("hasBreadcrumbTrail", () => {
|
||||
test("a top-level page has one, so a lone page never goes without", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/routines" })).toBe(true);
|
||||
});
|
||||
|
||||
test("a nested page has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/general" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/settings/bots/3" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("the crew of bots is home, so it has none", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/bots/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("the address that only redirects to home has none either", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/admin/" })).toBe(false);
|
||||
});
|
||||
|
||||
test("a folder in the files browser keeps the trail of its page", () => {
|
||||
expect(
|
||||
hasBreadcrumbTrail({
|
||||
pathname: "/admin/files?volume=bot&owner=1&path=reports",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("nothing outside the admin pages has one", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/auth/login" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "/" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: "" })).toBe(false);
|
||||
expect(hasBreadcrumbTrail({})).toBe(false);
|
||||
expect(hasBreadcrumbTrail({ pathname: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("a path that merely starts like admin does not count", () => {
|
||||
expect(hasBreadcrumbTrail({ pathname: "/administrator" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
|
||||
type Params = {
|
||||
pathname?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an address has a trail above it: `Home`, then every section it
|
||||
* passes through, then the page. `twui`'s `Breadcrumbs` draws the trail
|
||||
* itself from the path, so this only answers which pages get one.
|
||||
*
|
||||
* The crew of bots is where the app lands, and its own address would
|
||||
* read `Home / Bots`, both the same page, so it has none. Neither does
|
||||
* anything outside the admin pages, since everything else redirects to
|
||||
* the bots.
|
||||
*
|
||||
* The trailing slash and the query string are ignored, so a folder opened
|
||||
* in the files browser keeps the trail of the page it is on.
|
||||
*/
|
||||
export default function hasBreadcrumbTrail({ pathname }: Params): boolean {
|
||||
if (!pathname) return false;
|
||||
|
||||
const path = pathname.split("?")[0]!.replace(/\/$/, "");
|
||||
|
||||
if (path != "/admin" && !path.startsWith("/admin/")) return false;
|
||||
|
||||
/**
|
||||
* `/admin` only redirects to the crew of bots, which is home.
|
||||
*/
|
||||
return path != AdminHomePath && path != "/admin";
|
||||
}
|
||||
Reference in New Issue
Block a user