mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): move template allowlist to ai settings (#26615)
This commit is contained in:
@@ -35,6 +35,11 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/ai/settings/models">Models</SidebarNavItem>
|
||||
)}
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/ai/settings/templates">
|
||||
Templates
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/agents/settings/agents">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
|
||||
+10
-6
@@ -7,9 +7,10 @@ import {
|
||||
import { templates } from "#/api/queries/templates";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { AgentSettingsTemplatesPageView } from "./AgentSettingsTemplatesPageView";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { TemplatesPageView } from "./TemplatesPageView";
|
||||
|
||||
const AgentSettingsTemplatesPage: FC = () => {
|
||||
const TemplatesPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -23,21 +24,24 @@ const AgentSettingsTemplatesPage: FC = () => {
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
|
||||
<AgentSettingsTemplatesPageView
|
||||
<title>{pageTitle("Templates", "AI Settings")}</title>
|
||||
|
||||
<TemplatesPageView
|
||||
templatesData={templatesQuery.data}
|
||||
allowlistData={allowlistQuery.data}
|
||||
isLoading={isLoading}
|
||||
hasError={Boolean(templatesQuery.error || allowlistQuery.error)}
|
||||
templatesError={templatesQuery.error}
|
||||
allowlistError={allowlistQuery.error}
|
||||
onRetry={() => {
|
||||
void templatesQuery.refetch();
|
||||
void allowlistQuery.refetch();
|
||||
}}
|
||||
onSaveAllowlist={saveAllowlistMutation.mutate}
|
||||
isSaving={saveAllowlistMutation.isPending}
|
||||
isSaveError={saveAllowlistMutation.isError}
|
||||
saveError={saveAllowlistMutation.error}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentSettingsTemplatesPage;
|
||||
export default TemplatesPage;
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { MockTemplate } from "#/testHelpers/entities";
|
||||
import { TemplatesPageView } from "./TemplatesPageView";
|
||||
|
||||
const templateIDs = ["t-01", "t-02", "t-03", "t-04", "t-05", "t-06"];
|
||||
|
||||
const templates: TypesGen.Template[] = [
|
||||
{
|
||||
id: templateIDs[0],
|
||||
name: "docker-containers",
|
||||
display_name: "Docker containers",
|
||||
description: "Develop inside Docker containers.",
|
||||
icon: "/icon/docker.png",
|
||||
updated_at: "2026-06-23T12:00:00.000Z",
|
||||
active_user_count: 125,
|
||||
},
|
||||
{
|
||||
id: templateIDs[1],
|
||||
name: "product-ops-engineering",
|
||||
display_name: "Product ops engineering",
|
||||
description: "Workspace for product operations engineering.",
|
||||
updated_at: "2026-06-20T12:00:00.000Z",
|
||||
active_user_count: 12,
|
||||
},
|
||||
{
|
||||
id: templateIDs[2],
|
||||
name: "ai-webinar",
|
||||
display_name: "AI webinar",
|
||||
description: "Workspace for webinar demos.",
|
||||
updated_at: "2026-06-04T12:00:00.000Z",
|
||||
active_user_count: 3,
|
||||
},
|
||||
{
|
||||
id: templateIDs[3],
|
||||
name: "fast-workspace",
|
||||
display_name: "A fast workspace",
|
||||
description: "A minimal workspace that starts quickly.",
|
||||
updated_at: "2026-05-23T12:00:00.000Z",
|
||||
active_user_count: 1,
|
||||
},
|
||||
{
|
||||
id: templateIDs[4],
|
||||
name: "aws-ec2",
|
||||
display_name: "AWS EC2",
|
||||
description: "Provision AWS EC2 instances as workspaces.",
|
||||
updated_at: "2026-01-23T12:00:00.000Z",
|
||||
active_user_count: 0,
|
||||
},
|
||||
{
|
||||
id: templateIDs[5],
|
||||
name: "gke-sandbox",
|
||||
display_name: "gke-sandbox",
|
||||
description: "Sandbox workspace on GKE.",
|
||||
updated_at: "2025-06-23T12:00:00.000Z",
|
||||
active_user_count: 0,
|
||||
},
|
||||
].map((template) => ({ ...MockTemplate, ...template }));
|
||||
|
||||
const meta = {
|
||||
title: "pages/AISettingsPage/TemplatesPage/TemplatesPageView",
|
||||
component: TemplatesPageView,
|
||||
args: {
|
||||
templatesData: templates,
|
||||
allowlistData: { template_ids: [templateIDs[0], templateIDs[1]] },
|
||||
isLoading: false,
|
||||
templatesError: undefined,
|
||||
allowlistError: undefined,
|
||||
isSaving: false,
|
||||
saveError: undefined,
|
||||
onRetry: fn(),
|
||||
onSaveAllowlist: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof TemplatesPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TemplatesPageView>;
|
||||
|
||||
export const NoRestrictions: Story = {
|
||||
args: {
|
||||
allowlistData: { template_ids: [] },
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText("No restrictions set.")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText(
|
||||
"All templates are available. Add a template to create an allowlist.",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const body = within(document.body);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /add template/i }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await body.findByRole("option", { name: /AI webinar/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAllowlist).toHaveBeenCalledWith({
|
||||
template_ids: [templateIDs[2]],
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
body.queryByRole("option", { name: /AI webinar/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TemplateAllowlist: Story = {
|
||||
play: async ({ canvasElement, step, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("renders allowlisted templates", async () => {
|
||||
expect(await canvas.findByText("Docker containers")).toBeVisible();
|
||||
expect(canvas.getByText("Product ops engineering")).toBeVisible();
|
||||
expect(canvas.getByText("125 developers")).toBeVisible();
|
||||
expect(canvas.getByText("12 developers")).toBeVisible();
|
||||
});
|
||||
|
||||
await step("searches and adds an available template", async () => {
|
||||
const body = within(document.body);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /add template/i }),
|
||||
);
|
||||
const searchInput = await body.findByLabelText("Search templates");
|
||||
await userEvent.click(searchInput);
|
||||
await userEvent.keyboard("webinar");
|
||||
expect(searchInput).toHaveValue("webinar");
|
||||
|
||||
expect(
|
||||
await body.findByRole("option", { name: /AI webinar/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
body.queryByRole("option", { name: /AWS EC2/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(body.getByRole("option", { name: /AI webinar/i }));
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAllowlist).toHaveBeenLastCalledWith({
|
||||
template_ids: [templateIDs[0], templateIDs[1], templateIDs[2]],
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
body.queryByRole("option", { name: /AI webinar/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
await step("removes an allowlisted template", async () => {
|
||||
const body = within(document.body);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Actions for Docker containers" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await body.findByRole("menuitem", { name: /remove/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAllowlist).toHaveBeenCalledWith({
|
||||
template_ids: [templateIDs[1]],
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
templatesData: undefined,
|
||||
allowlistData: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const TemplatesLoadError: Story = {
|
||||
args: {
|
||||
templatesError: new Error("Templates request failed"),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText("Failed to load templates.")).toBeVisible();
|
||||
expect(
|
||||
canvas.getByText("Please check the developer console for more details."),
|
||||
).toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Retry" }));
|
||||
expect(args.onRetry).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const AllowlistLoadError: Story = {
|
||||
args: {
|
||||
allowlistError: new Error("Allowlist request failed"),
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
"Failed to load template allowlist configuration.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Retry" }));
|
||||
expect(args.onRetry).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const PhantomTemplateIDs: Story = {
|
||||
args: {
|
||||
allowlistData: { template_ids: ["deleted-template", templateIDs[0]] },
|
||||
},
|
||||
play: async ({ canvasElement, step, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("drops phantom IDs when adding a template", async () => {
|
||||
const body = within(document.body);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: /add template/i }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await body.findByRole("option", { name: /AI webinar/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAllowlist).toHaveBeenLastCalledWith({
|
||||
template_ids: [templateIDs[0], templateIDs[2]],
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
body.queryByRole("option", { name: /AI webinar/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
await step("drops phantom IDs when removing a template", async () => {
|
||||
const body = within(document.body);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Actions for Docker containers" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await body.findByRole("menuitem", { name: /remove/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAllowlist).toHaveBeenLastCalledWith({
|
||||
template_ids: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const Saving: Story = {
|
||||
args: {
|
||||
isSaving: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(
|
||||
await canvas.findByRole("button", { name: /add template/i }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
canvas.getByRole("button", { name: "Actions for Docker containers" }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const SaveError: Story = {
|
||||
args: {
|
||||
saveError: "Template allowlist is locked.",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText("Docker containers")).toBeVisible();
|
||||
expect(
|
||||
await canvas.findByText("Template allowlist is locked."),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { DetailedError, getErrorDetail, getErrorMessage } from "#/api/errors";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "#/components/Command/Command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "#/components/Popover/Popover";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#/components/Table/Table";
|
||||
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "#/components/TableLoader/TableLoader";
|
||||
import { createDayString } from "#/utils/createDayString";
|
||||
import { formatTemplateActiveDevelopers } from "#/utils/templates";
|
||||
|
||||
interface TemplatesPageViewProps {
|
||||
templatesData: TypesGen.Template[] | undefined;
|
||||
allowlistData: TypesGen.ChatTemplateAllowlist | undefined;
|
||||
isLoading: boolean;
|
||||
templatesError: unknown;
|
||||
allowlistError: unknown;
|
||||
onRetry: () => void;
|
||||
onSaveAllowlist: (req: TypesGen.ChatTemplateAllowlist) => void;
|
||||
isSaving: boolean;
|
||||
saveError: unknown;
|
||||
}
|
||||
|
||||
interface AddTemplatePickerProps {
|
||||
availableTemplates: TypesGen.Template[];
|
||||
isSaving: boolean;
|
||||
onAddTemplate: (templateID: string) => void;
|
||||
}
|
||||
|
||||
const AddTemplatePicker: FC<AddTemplatePickerProps> = ({
|
||||
availableTemplates,
|
||||
isSaving,
|
||||
onAddTemplate,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const filteredTemplates = availableTemplates.filter((template) =>
|
||||
`${template.display_name || template.name} ${template.name}`
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" disabled={isSaving}>
|
||||
<PlusIcon />
|
||||
<span>Add template</span>
|
||||
<ChevronDownIcon className="ml-1 size-icon-xs" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-80 overflow-hidden border-border-default p-0"
|
||||
>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="[&_[cmdk-input-wrapper]]:border-0 [&_[cmdk-input-wrapper]]:border-border-default [&_[cmdk-input-wrapper]]:border-b [&_[cmdk-input-wrapper]]:border-solid [&_[cmdk-input-wrapper]]:px-4 [&_[cmdk-input-wrapper]]:py-3"
|
||||
>
|
||||
<CommandInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search..."
|
||||
aria-label="Search templates"
|
||||
className="h-auto py-0"
|
||||
/>
|
||||
<CommandList className="max-h-80 border-t-0">
|
||||
<CommandEmpty>No templates found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredTemplates.map((template) => (
|
||||
<CommandItem
|
||||
key={template.id}
|
||||
value={template.id}
|
||||
className="gap-3"
|
||||
onSelect={() => {
|
||||
onAddTemplate(template.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size="lg"
|
||||
variant="icon"
|
||||
src={template.icon}
|
||||
fallback={template.display_name || template.name}
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{template.display_name || template.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
interface TemplateRowProps {
|
||||
template: TypesGen.Template;
|
||||
isSaving: boolean;
|
||||
onRemoveTemplate: (templateID: string) => void;
|
||||
}
|
||||
|
||||
const TemplateRow: FC<TemplateRowProps> = ({
|
||||
template,
|
||||
isSaving,
|
||||
onRemoveTemplate,
|
||||
}) => {
|
||||
const label = template.display_name || template.name;
|
||||
|
||||
return (
|
||||
<TableRow className="h-[72px]">
|
||||
<TableCell className="w-full max-w-0 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
variant="icon"
|
||||
src={template.icon}
|
||||
fallback={label}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span
|
||||
className="truncate text-sm font-medium leading-5 text-content-primary"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{template.description && (
|
||||
<span
|
||||
className="truncate text-sm font-medium leading-5 text-content-secondary"
|
||||
title={template.description}
|
||||
>
|
||||
{template.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-chromatic="ignore"
|
||||
className="whitespace-nowrap text-sm font-medium leading-6 text-content-secondary"
|
||||
>
|
||||
{createDayString(template.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm font-medium leading-6 text-content-secondary">
|
||||
{`${formatTemplateActiveDevelopers(template.active_user_count)} developer${template.active_user_count === 1 ? "" : "s"}`}
|
||||
</TableCell>
|
||||
<TableCell className="w-12 whitespace-nowrap pr-4 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
aria-label={`Actions for ${label}`}
|
||||
>
|
||||
<EllipsisVerticalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onSelect={() => onRemoveTemplate(template.id)}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
interface TemplatesTableProps {
|
||||
isLoading: boolean;
|
||||
allowlistedTemplates: TypesGen.Template[];
|
||||
availableTemplates: TypesGen.Template[];
|
||||
isSaving: boolean;
|
||||
onAddTemplate: (templateID: string) => void;
|
||||
onRemoveTemplate: (templateID: string) => void;
|
||||
}
|
||||
|
||||
const TemplatesTable: FC<TemplatesTableProps> = ({
|
||||
isLoading,
|
||||
allowlistedTemplates,
|
||||
availableTemplates,
|
||||
isSaving,
|
||||
onAddTemplate,
|
||||
onRemoveTemplate,
|
||||
}) => {
|
||||
return (
|
||||
<Table aria-label="Allowed templates" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-1/2">Name</TableHead>
|
||||
<TableHead className="w-44">Last updated</TableHead>
|
||||
<TableHead className="w-44">Used by</TableHead>
|
||||
<TableHead className="w-12">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableLoader />
|
||||
) : allowlistedTemplates.length === 0 ? (
|
||||
<TableEmpty
|
||||
message="No restrictions set."
|
||||
description="All templates are available. Add a template to create an allowlist."
|
||||
cta={
|
||||
<AddTemplatePicker
|
||||
availableTemplates={availableTemplates}
|
||||
isSaving={isSaving}
|
||||
onAddTemplate={onAddTemplate}
|
||||
/>
|
||||
}
|
||||
isCompact
|
||||
className="min-h-52"
|
||||
/>
|
||||
) : (
|
||||
allowlistedTemplates.map((template) => (
|
||||
<TemplateRow
|
||||
key={template.id}
|
||||
template={template}
|
||||
isSaving={isSaving}
|
||||
onRemoveTemplate={onRemoveTemplate}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
|
||||
templatesData,
|
||||
allowlistData,
|
||||
isLoading,
|
||||
templatesError,
|
||||
allowlistError,
|
||||
onRetry,
|
||||
onSaveAllowlist,
|
||||
isSaving,
|
||||
saveError,
|
||||
}) => {
|
||||
const templateIDs = allowlistData?.template_ids ?? [];
|
||||
const { allowlistedTemplates, availableTemplates, resolvedTemplateIDs } =
|
||||
useMemo(() => {
|
||||
const allTemplates = templatesData ?? [];
|
||||
const templatesByID = new Map(
|
||||
allTemplates.map((template) => [template.id, template]),
|
||||
);
|
||||
const selectedIDs = new Set(templateIDs);
|
||||
const allowlisted = templateIDs
|
||||
.map((templateID) => templatesByID.get(templateID))
|
||||
.filter((template) => template !== undefined);
|
||||
const resolvedIDs = allowlisted.map((template) => template.id);
|
||||
const available = allTemplates
|
||||
.filter((template) => !selectedIDs.has(template.id))
|
||||
.toSorted((left, right) =>
|
||||
(left.display_name || left.name).localeCompare(
|
||||
right.display_name || right.name,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
allowlistedTemplates: allowlisted,
|
||||
availableTemplates: available,
|
||||
resolvedTemplateIDs: resolvedIDs,
|
||||
};
|
||||
}, [templatesData, templateIDs]);
|
||||
|
||||
const saveTemplateIDs = (nextTemplateIDs: string[]) => {
|
||||
onSaveAllowlist({ template_ids: nextTemplateIDs });
|
||||
};
|
||||
|
||||
const handleAddTemplate = (templateID: string) => {
|
||||
if (resolvedTemplateIDs.includes(templateID)) {
|
||||
return;
|
||||
}
|
||||
saveTemplateIDs([...resolvedTemplateIDs, templateID]);
|
||||
};
|
||||
|
||||
const handleRemoveTemplate = (templateID: string) => {
|
||||
saveTemplateIDs(resolvedTemplateIDs.filter((id) => id !== templateID));
|
||||
};
|
||||
|
||||
const hasTemplatesError = Boolean(templatesError);
|
||||
const hasAllowlistError = Boolean(allowlistError);
|
||||
const hasError = hasTemplatesError || hasAllowlistError;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
actions={
|
||||
!isLoading &&
|
||||
!hasError &&
|
||||
allowlistedTemplates.length > 0 && (
|
||||
<AddTemplatePicker
|
||||
availableTemplates={availableTemplates}
|
||||
isSaving={isSaving}
|
||||
onAddTemplate={handleAddTemplate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SettingsHeaderTitle>Templates</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Restrict which templates agents can use to create workspaces.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
{hasError ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{hasTemplatesError && (
|
||||
<ErrorAlert
|
||||
error={
|
||||
new DetailedError(
|
||||
"Failed to load templates.",
|
||||
getErrorDetail(templatesError),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasAllowlistError && (
|
||||
<ErrorAlert
|
||||
error={
|
||||
new DetailedError(
|
||||
"Failed to load template allowlist configuration.",
|
||||
getErrorDetail(allowlistError),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TemplatesTable
|
||||
isLoading={isLoading}
|
||||
allowlistedTemplates={allowlistedTemplates}
|
||||
availableTemplates={availableTemplates}
|
||||
isSaving={isSaving}
|
||||
onAddTemplate={handleAddTemplate}
|
||||
onRemoveTemplate={handleRemoveTemplate}
|
||||
/>
|
||||
{saveError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="m-0 pt-3 text-xs text-content-destructive"
|
||||
>
|
||||
{getErrorMessage(saveError, "Failed to save template allowlist.")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { MockTemplate } from "#/testHelpers/entities";
|
||||
import { AgentSettingsTemplatesPageView } from "./AgentSettingsTemplatesPageView";
|
||||
|
||||
const manyTemplates = [
|
||||
{ id: "t-01", name: "docker-dev", display_name: "Docker Development" },
|
||||
{
|
||||
id: "t-02",
|
||||
name: "kubernetes-prod",
|
||||
display_name: "Kubernetes Production",
|
||||
},
|
||||
{ id: "t-03", name: "aws-windows", display_name: "AWS Windows Desktop" },
|
||||
{ id: "t-04", name: "gcp-linux", display_name: "GCP Linux Workspace" },
|
||||
{
|
||||
id: "t-05",
|
||||
name: "azure-dotnet",
|
||||
display_name: "Azure .NET Environment",
|
||||
},
|
||||
{ id: "t-06", name: "ml-jupyter", display_name: "ML Jupyter Notebook" },
|
||||
{
|
||||
id: "t-07",
|
||||
name: "data-eng-spark",
|
||||
display_name: "Data Engineering (Spark)",
|
||||
},
|
||||
{
|
||||
id: "t-08",
|
||||
name: "frontend-vite",
|
||||
display_name: "Frontend (Vite + React)",
|
||||
},
|
||||
].map((t) => ({ ...MockTemplate, ...t }));
|
||||
|
||||
const meta = {
|
||||
title: "pages/AgentsPage/AgentSettingsTemplatesPageView",
|
||||
component: AgentSettingsTemplatesPageView,
|
||||
args: {
|
||||
templatesData: manyTemplates,
|
||||
allowlistData: { template_ids: [] },
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
isSaving: false,
|
||||
isSaveError: false,
|
||||
onRetry: fn(),
|
||||
onSaveAllowlist: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof AgentSettingsTemplatesPageView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentSettingsTemplatesPageView>;
|
||||
|
||||
export const TemplateAllowlist: Story = {
|
||||
play: async ({ canvasElement, step, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await step("starts empty", async () => {
|
||||
await canvas.findByText(/no templates selected/i);
|
||||
const saveBtn = await canvas.findByRole("button", {
|
||||
name: "Save",
|
||||
});
|
||||
expect(saveBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
await step("search filters by display name", async () => {
|
||||
const input = canvas.getByPlaceholderText("Select templates...");
|
||||
await userEvent.click(input);
|
||||
|
||||
// Type a partial display name.
|
||||
await userEvent.type(input, "Docker");
|
||||
|
||||
// The matching template should be visible.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("option", { name: "Docker Development" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// A non-matching template should not be visible.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.queryByRole("option", { name: "Kubernetes Production" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear the search and verify the full list returns.
|
||||
await userEvent.clear(input);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByRole("option", { name: "Kubernetes Production" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Close dropdown by pressing Escape so the next step starts clean.
|
||||
await userEvent.keyboard("{Escape}");
|
||||
});
|
||||
|
||||
await step("select one template and save", async () => {
|
||||
const input = canvas.getByPlaceholderText("Select templates...");
|
||||
await userEvent.click(input);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("option", {
|
||||
name: "Docker Development",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("1 template selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveBtn = canvas.getByRole("button", { name: "Save" });
|
||||
expect(saveBtn).toBeEnabled();
|
||||
await userEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAllowlist).toHaveBeenCalledWith(
|
||||
{ template_ids: ["t-01"] },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await step("add the remaining seven and save", async () => {
|
||||
const input = canvas.getByLabelText("Select allowed templates");
|
||||
await userEvent.click(input);
|
||||
|
||||
for (const name of [
|
||||
"Kubernetes Production",
|
||||
"AWS Windows Desktop",
|
||||
"GCP Linux Workspace",
|
||||
"Azure .NET Environment",
|
||||
"ML Jupyter Notebook",
|
||||
"Data Engineering (Spark)",
|
||||
"Frontend (Vite + React)",
|
||||
]) {
|
||||
await userEvent.click(await canvas.findByRole("option", { name }));
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("8 templates selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveBtn = canvas.getByRole("button", { name: "Save" });
|
||||
await userEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSaveAllowlist).toHaveBeenLastCalledWith(
|
||||
{
|
||||
template_ids: expect.arrayContaining([
|
||||
"t-01",
|
||||
"t-02",
|
||||
"t-03",
|
||||
"t-04",
|
||||
"t-05",
|
||||
"t-06",
|
||||
"t-07",
|
||||
"t-08",
|
||||
]),
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
import { type FC, type FormEvent, useState } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
MultiSelectCombobox,
|
||||
type Option,
|
||||
} from "#/components/MultiSelectCombobox/MultiSelectCombobox";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { SectionHeader } from "./components/SectionHeader";
|
||||
|
||||
interface MutationCallbacks {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
interface AgentSettingsTemplatesPageViewProps {
|
||||
// Raw query data
|
||||
templatesData: TypesGen.Template[] | undefined;
|
||||
allowlistData: TypesGen.ChatTemplateAllowlist | undefined;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
onRetry: () => void;
|
||||
|
||||
// Mutation
|
||||
onSaveAllowlist: (
|
||||
req: TypesGen.ChatTemplateAllowlist,
|
||||
options?: MutationCallbacks,
|
||||
) => void;
|
||||
isSaving: boolean;
|
||||
isSaveError: boolean;
|
||||
}
|
||||
|
||||
export const AgentSettingsTemplatesPageView: FC<
|
||||
AgentSettingsTemplatesPageViewProps
|
||||
> = ({
|
||||
templatesData,
|
||||
allowlistData,
|
||||
isLoading,
|
||||
hasError,
|
||||
onRetry,
|
||||
onSaveAllowlist,
|
||||
isSaving,
|
||||
isSaveError,
|
||||
}) => {
|
||||
// ── Local form state ──
|
||||
const [localSelection, setLocalSelection] = useState<Option[] | null>(null);
|
||||
|
||||
// ── Derived state ──
|
||||
const allOptions: Option[] = (templatesData ?? []).map((t) => ({
|
||||
value: t.id,
|
||||
label: t.display_name || t.name,
|
||||
icon: t.icon,
|
||||
}));
|
||||
|
||||
const optionsByID = new Map(allOptions.map((o) => [o.value, o]));
|
||||
|
||||
const serverSelection: Option[] = (allowlistData?.template_ids ?? [])
|
||||
.map((id) => optionsByID.get(id))
|
||||
.filter((o) => o !== undefined);
|
||||
|
||||
const currentSelection = localSelection ?? serverSelection;
|
||||
|
||||
const serverSet = new Set(serverSelection.map((o) => o.value));
|
||||
const isDirty =
|
||||
localSelection !== null &&
|
||||
(localSelection.length !== serverSet.size ||
|
||||
localSelection.some((o) => !serverSet.has(o.value)));
|
||||
|
||||
const serverSelectionKey = serverSelection.map((o) => o.value).join(",");
|
||||
|
||||
// ── Event handlers ──
|
||||
const handleSave = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!isDirty) return;
|
||||
onSaveAllowlist(
|
||||
{ template_ids: currentSelection.map((o) => o.value) },
|
||||
{ onSuccess: () => setLocalSelection(null) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<SectionHeader
|
||||
label="Templates"
|
||||
description="Restrict which templates agents can use to create workspaces. When no templates are selected, all templates are available."
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading templates"
|
||||
className="flex min-h-[120px] items-center justify-center"
|
||||
>
|
||||
<Spinner size="lg" loading className="text-content-secondary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && hasError && (
|
||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
Failed to load template data.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasError && (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => void handleSave(event)}
|
||||
>
|
||||
<MultiSelectCombobox
|
||||
key={serverSelectionKey}
|
||||
inputProps={{ "aria-label": "Select allowed templates" }}
|
||||
options={allOptions}
|
||||
defaultOptions={currentSelection}
|
||||
value={currentSelection}
|
||||
onChange={setLocalSelection}
|
||||
placeholder="Select templates..."
|
||||
emptyIndicator={
|
||||
<p className="text-center text-sm text-content-secondary">
|
||||
No templates found.
|
||||
</p>
|
||||
}
|
||||
disabled={isSaving}
|
||||
hidePlaceholderWhenSelected
|
||||
data-testid="template-allowlist-select"
|
||||
/>
|
||||
<p
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
className="m-0 text-xs text-content-secondary"
|
||||
>
|
||||
{currentSelection.length > 0
|
||||
? `${currentSelection.length} template${currentSelection.length !== 1 ? "s" : ""} selected`
|
||||
: "No templates selected \u2014 all templates are available"}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" type="submit" disabled={isSaving || !isDirty}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isSaveError && (
|
||||
<p role="alert" className="m-0 text-xs text-content-destructive">
|
||||
Failed to save template allowlist.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -187,9 +187,9 @@ export const SettingsPanel: FC<SettingsPanelProps> = ({
|
||||
<SettingsNavItem
|
||||
icon={LayoutTemplateIcon}
|
||||
label="Templates"
|
||||
active={settingsSection === "templates"}
|
||||
to="/agents/settings/templates"
|
||||
state={location.state}
|
||||
active={false}
|
||||
to="/ai/settings/templates"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
/>
|
||||
<SettingsNavItem
|
||||
icon={CoinsIcon}
|
||||
|
||||
+8
-4
@@ -398,9 +398,6 @@ const AgentSettingsMCPServersPage = lazy(
|
||||
const AgentSettingsSpendPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsSpendPage"),
|
||||
);
|
||||
const AgentSettingsTemplatesPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentSettingsTemplatesPage"),
|
||||
);
|
||||
const AgentAnalyticsPage = lazy(
|
||||
() => import("./pages/AgentsPage/AgentAnalyticsPage"),
|
||||
);
|
||||
@@ -451,6 +448,9 @@ const AISettingsGatewayKeysPage = lazy(
|
||||
const AISettingsModelsPage = lazy(
|
||||
() => import("./pages/AISettingsPage/ModelsPage/ModelsPage"),
|
||||
);
|
||||
const AISettingsTemplatesPage = lazy(
|
||||
() => import("./pages/AISettingsPage/TemplatesPage/TemplatesPage"),
|
||||
);
|
||||
const AISettingsAddModelPage = lazy(
|
||||
() => import("./pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPage"),
|
||||
);
|
||||
@@ -755,6 +755,7 @@ export const router = createBrowserRouter(
|
||||
/>
|
||||
<Route index element={<AISettingsIndexPage />} />
|
||||
<Route path="models" element={<AISettingsModelsPage />} />
|
||||
<Route path="templates" element={<AISettingsTemplatesPage />} />
|
||||
<Route path="models/add" element={<AISettingsAddModelPage />} />
|
||||
<Route
|
||||
path="models/:modelId"
|
||||
@@ -860,7 +861,10 @@ export const router = createBrowserRouter(
|
||||
<Route path="spend" element={<AgentSettingsSpendPage />} />
|
||||
<Route path="limits" element={<Navigate to="spend" replace />} />
|
||||
<Route path="usage" element={<NavigateWithSearch to="spend" />} />
|
||||
<Route path="templates" element={<AgentSettingsTemplatesPage />} />
|
||||
<Route
|
||||
path="templates"
|
||||
element={<Navigate to="/ai/settings/templates" replace />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="analytics" element={<AgentAnalyticsPage />} />
|
||||
<Route
|
||||
|
||||
Reference in New Issue
Block a user