feat(site): add per-template Coder Agents controls (#27514)

Relates to CODAGT-713

Depends on #27285

This replaces the deployment-wide template allowlist UI with per-template Coder Agents controls. Each template's General Settings page exposes `agents_allowed` to organisation and template administrators through the existing template-update permission, whilst AI Settings > Templates gives deployment administrators a bulk management page. The bulk page requires both deployment-config and template-update permissions, reuses the same URL-backed server-side filter as `/templates`, tracks pending updates independently for each template, and surfaces failures through template-specific toasts. The copy refers to new workspace creation because disabling a template does not revoke an existing Coder Agent workspace.

This completes the frontend migration away from the experimental `/template-allowlist` API. #27515 removes the now-unused legacy storage, routes, SDK methods, generated types, and JSON utility. Later PRs add CLI flags and update the platform controls documentation. The stack is intended to merge as a unit.
This commit is contained in:
Ethan
2026-08-06 14:23:36 +10:00
committed by GitHub
parent 0ac23e3ee1
commit 01c00ce1bf
22 changed files with 869 additions and 662 deletions
+2 -2
View File
@@ -58,7 +58,7 @@
"@novnc/novnc": "^1.5.0",
"@pierre/diffs": "1.2.7",
"@pierre/trees": "1.0.0-beta.4",
"@tanstack/react-query-devtools": "5.77.0",
"@tanstack/react-query-devtools": "5.82.0",
"@xterm/addon-canvas": "0.7.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode11": "0.9.0",
@@ -96,7 +96,7 @@
"react-dom": "19.2.8",
"react-infinite-scroll-component": "7.1.0",
"react-markdown": "9.1.0",
"react-query": "npm:@tanstack/react-query@5.77.0",
"react-query": "npm:@tanstack/react-query@5.82.0",
"react-resizable-panels": "3.0.6",
"react-router": "7.18.0",
"react-syntax-highlighter": "15.6.6",
+20 -20
View File
@@ -96,8 +96,8 @@ importers:
specifier: 1.0.0-beta.4
version: 1.0.0-beta.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@tanstack/react-query-devtools':
specifier: 5.77.0
version: 5.77.0(@tanstack/react-query@5.77.0(react@19.2.8))(react@19.2.8)
specifier: 5.82.0
version: 5.82.0(@tanstack/react-query@5.82.0(react@19.2.8))(react@19.2.8)
'@xterm/addon-canvas':
specifier: 0.7.0
version: 0.7.0(@xterm/xterm@5.5.0)
@@ -210,8 +210,8 @@ importers:
specifier: 9.1.0
version: 9.1.0(@types/react@19.2.17)(react@19.2.8)
react-query:
specifier: npm:@tanstack/react-query@5.77.0
version: '@tanstack/react-query@5.77.0(react@19.2.8)'
specifier: npm:@tanstack/react-query@5.82.0
version: '@tanstack/react-query@5.82.0(react@19.2.8)'
react-resizable-panels:
specifier: 3.0.6
version: 3.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -2575,20 +2575,20 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/query-core@5.77.0':
resolution: {integrity: sha512-PFeWjgMQjOsnxBwnW/TJoO0pCja2dzuMQoZ3Diho7dPz7FnTUwTrjNmdf08evrhSE5nvPIKeqV6R0fvQfmhGeg==, tarball: https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.77.0.tgz}
'@tanstack/query-core@5.82.0':
resolution: {integrity: sha512-JrjoVuaajBQtnoWSg8iaPHaT4mW73lK2t+exxHNOSMqy0+13eKLqJgTKXKImLejQIfdAHQ6Un0njEhOvUtOd5w==, tarball: https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.82.0.tgz}
'@tanstack/query-devtools@5.76.0':
resolution: {integrity: sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==, tarball: https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.76.0.tgz}
'@tanstack/query-devtools@5.81.2':
resolution: {integrity: sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==, tarball: https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.81.2.tgz}
'@tanstack/react-query-devtools@5.77.0':
resolution: {integrity: sha512-Dwvs+ksXiK1tW4YnTtHwYPO5+d8IUk1l8QQJ4aGEIqKz6uTLu/67NIo7EnUF0G/Edv+UOn9P1V3tYWuVfvhbmg==, tarball: https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.77.0.tgz}
'@tanstack/react-query-devtools@5.82.0':
resolution: {integrity: sha512-MC05Zq3zr/59jhgF7dL6JSGPg1krbasDSizmRxjNcvxgh/sUTwRFD9CGN10YYX7LB6jq0ZpFtCjSVGdLiFrKAA==, tarball: https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.82.0.tgz}
peerDependencies:
'@tanstack/react-query': ^5.77.0
'@tanstack/react-query': ^5.82.0
react: ^18 || ^19
'@tanstack/react-query@5.77.0':
resolution: {integrity: sha512-jX52ot8WxWzWnAknpRSEWj6PTR/7nkULOfoiaVPk6nKu0otwt30UMBC9PTg/m1x0uhz1g71/imwjViTm/oYHxA==, tarball: https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.77.0.tgz}
'@tanstack/react-query@5.82.0':
resolution: {integrity: sha512-mnk8/ofKEthFeMdhV1dV8YXRf+9HqvXAcciXkoo755d/ocfWq7N/Y9jGOzS3h7ZW9dDGwSIhs3/HANWUBsyqYg==, tarball: https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.82.0.tgz}
peerDependencies:
react: ^18 || ^19
@@ -8681,19 +8681,19 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(tsx@4.22.4)(yaml@2.8.3)
'@tanstack/query-core@5.77.0': {}
'@tanstack/query-core@5.82.0': {}
'@tanstack/query-devtools@5.76.0': {}
'@tanstack/query-devtools@5.81.2': {}
'@tanstack/react-query-devtools@5.77.0(@tanstack/react-query@5.77.0(react@19.2.8))(react@19.2.8)':
'@tanstack/react-query-devtools@5.82.0(@tanstack/react-query@5.82.0(react@19.2.8))(react@19.2.8)':
dependencies:
'@tanstack/query-devtools': 5.76.0
'@tanstack/react-query': 5.77.0(react@19.2.8)
'@tanstack/query-devtools': 5.81.2
'@tanstack/react-query': 5.82.0(react@19.2.8)
react: 19.2.8
'@tanstack/react-query@5.77.0(react@19.2.8)':
'@tanstack/react-query@5.82.0(react@19.2.8)':
dependencies:
'@tanstack/query-core': 5.77.0
'@tanstack/query-core': 5.82.0
react: 19.2.8
'@testing-library/dom@10.4.0':
-19
View File
@@ -1799,25 +1799,6 @@ export const updateChatAutoArchiveDays = (queryClient: QueryClient) => ({
},
});
const chatTemplateAllowlistKey = [
...chatConfigKey,
"template-allowlist",
] as const;
export const chatTemplateAllowlist = () => ({
queryKey: chatTemplateAllowlistKey,
queryFn: () => API.experimental.getChatTemplateAllowlist(),
});
export const updateChatTemplateAllowlist = (queryClient: QueryClient) => ({
mutationFn: API.experimental.updateChatTemplateAllowlist,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatTemplateAllowlistKey,
});
},
});
const chatUserCustomPromptKey = [...chatConfigKey, "prompt", "me"] as const;
export const chatUserCustomPrompt = () => ({
+41 -2
View File
@@ -1,4 +1,9 @@
import type { MutationOptions, QueryClient, QueryOptions } from "react-query";
import {
type MutationOptions,
mutationOptions,
type QueryClient,
type QueryOptions,
} from "react-query";
import {
API,
type GetTemplatesOptions,
@@ -12,12 +17,14 @@ import type {
Template,
TemplateRole,
TemplateVersion,
UpdateTemplateMeta,
UsersRequest,
} from "#/api/typesGenerated";
import { delay } from "#/utils/delay";
import { getTemplateVersionFiles } from "#/utils/templateVersion";
const templateKey = (templateId: string) => ["template", templateId];
const templateListsKey = ["templates", "list"] as const;
export const template = (templateId: string) => {
return {
@@ -41,7 +48,7 @@ export const templateByName = (organization: string, name: string) => {
export const getTemplatesQueryKey = (
options?: GetTemplatesOptions | GetTemplatesQuery,
) => ["templates", options];
) => [...templateListsKey, options];
export const templates = (
options?: GetTemplatesOptions | GetTemplatesQuery,
@@ -52,6 +59,38 @@ export const templates = (
};
};
export const invalidateTemplateListQueries = (queryClient: QueryClient) =>
queryClient.invalidateQueries({
queryKey: templateListsKey,
refetchType: "all",
});
type UpdateTemplateMetaVariables = {
template: Template;
data: UpdateTemplateMeta;
};
export const updateTemplateMeta = (queryClient: QueryClient) =>
mutationOptions({
mutationFn: ({ template, data }: UpdateTemplateMetaVariables) =>
API.updateTemplateMeta(template.id, data),
onSuccess: async (result, { template }) => {
const updatedTemplate = result ?? template;
await Promise.all([
invalidateTemplateListQueries(queryClient),
queryClient.invalidateQueries({
queryKey: templateKey(template.id),
}),
queryClient.invalidateQueries({
queryKey: templateByNameKey(
updatedTemplate.organization_name,
updatedTemplate.name,
),
}),
]);
},
});
export const templateACL = (templateId: string) => {
return {
queryKey: ["templateAcl", templateId],
@@ -75,6 +75,26 @@ export const NoDeploymentConfig: Story = {
editDeploymentConfig: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument();
expect(canvas.queryByText("Templates")).not.toBeInTheDocument();
},
};
export const NoUpdateTemplates: Story = {
args: {
permissions: {
...MockPermissions,
updateTemplates: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Coder Agents")).toBeVisible();
expect(canvas.queryByText("Templates")).not.toBeInTheDocument();
expect(canvas.getByText("Models")).toBeVisible();
},
};
export const NoPermissions: Story = {
@@ -62,7 +62,9 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
<SubNavItem href="/ai/settings/mcp-servers">
MCP servers
</SubNavItem>
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
{permissions.updateTemplates && (
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
)}
<SubNavItem href="/ai/settings/instructions">
Instructions
</SubNavItem>
@@ -0,0 +1,305 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { API } from "#/api/api";
import { getTemplatesQueryKey } from "#/api/queries/templates";
import type { Template } from "#/api/typesGenerated";
import { createDeferred, type Deferred } from "#/testHelpers/deferred";
import {
MockTemplate,
MockUserOwner,
mockApiError,
} from "#/testHelpers/entities";
import {
withAuthProvider,
withDashboardProvider,
withToaster,
} from "#/testHelpers/storybook";
import TemplatesPage from "./TemplatesPage";
const mockSecondTemplate: Template = {
...MockTemplate,
id: "second-template",
name: "second-template",
display_name: "Second Template",
};
type ToggleDeferreds = {
first: Deferred<Template | null>;
second: Deferred<Template | null>;
retry: Deferred<Template | null>;
};
let toggleDeferreds: ToggleDeferreds | undefined;
let refetchedTemplates: Template[] = [];
const meta = {
title: "pages/AISettingsPage/TemplatesPage/TemplatesPage",
component: TemplatesPage,
decorators: [withToaster, withAuthProvider, withDashboardProvider],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
permissions: {
editDeploymentConfig: true,
updateTemplates: true,
},
queries: [
{
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate],
},
],
},
} satisfies Meta<typeof TemplatesPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ServerSideFilter: Story = {
parameters: {
queries: [
{
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate, mockSecondTemplate],
},
],
},
beforeEach: () => {
spyOn(API, "getTemplates").mockImplementation((options) => {
const query = options && "q" in options ? options.q : "";
return Promise.resolve(
query === "Second"
? [mockSecondTemplate]
: [MockTemplate, mockSecondTemplate],
);
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
expect(await canvas.findByText("Test Template")).toBeVisible();
expect(canvas.getByText("Second Template")).toBeVisible();
await user.type(canvas.getByRole("textbox", { name: "Filter" }), "Second");
await waitFor(() =>
expect(API.getTemplates).toHaveBeenCalledWith({ q: "Second" }),
);
expect(await canvas.findByText("Second Template")).toBeVisible();
expect(canvas.queryByText("Test Template")).not.toBeInTheDocument();
},
};
export const ConcurrentToggles: Story = {
parameters: {
queries: [
{
key: getTemplatesQueryKey({ q: "" }),
data: [MockTemplate, mockSecondTemplate],
},
],
},
beforeEach: () => {
toggleDeferreds = {
first: createDeferred<Template | null>(),
second: createDeferred<Template | null>(),
retry: createDeferred<Template | null>(),
};
refetchedTemplates = [MockTemplate, mockSecondTemplate];
let mutationCall = 0;
spyOn(API, "updateTemplateMeta").mockImplementation(() => {
mutationCall += 1;
if (!toggleDeferreds) {
throw new Error("Toggle deferreds were not initialized.");
}
switch (mutationCall) {
case 1:
return toggleDeferreds.first.promise;
case 2:
return toggleDeferreds.second.promise;
case 3:
return toggleDeferreds.retry.promise;
default:
throw new Error(`Unexpected mutation call ${mutationCall}.`);
}
});
spyOn(API, "getTemplates").mockImplementation((options) => {
const query = options && "q" in options ? options.q : "";
return Promise.resolve(
query === "Second"
? refetchedTemplates.filter(
(template) => template.id === mockSecondTemplate.id,
)
: refetchedTemplates,
);
});
},
play: async ({ canvasElement }) => {
const deferreds = toggleDeferreds;
if (!deferreds) {
throw new Error("Toggle deferreds were not initialized.");
}
const canvas = within(canvasElement);
const body = within(document.body);
const user = userEvent.setup();
const firstSwitch = await canvas.findByRole("switch", {
name: "Allow Coder Agents to create workspaces using Test Template in My Organization",
});
const secondSwitch = canvas.getByRole("switch", {
name: "Allow Coder Agents to create workspaces using Second Template in My Organization",
});
await user.click(firstSwitch);
await user.click(secondSwitch);
await waitFor(() => expect(firstSwitch).toBeDisabled());
await waitFor(() => expect(secondSwitch).toBeDisabled());
expect(API.updateTemplateMeta).toHaveBeenNthCalledWith(1, MockTemplate.id, {
agents_allowed: false,
});
expect(API.updateTemplateMeta).toHaveBeenNthCalledWith(
2,
mockSecondTemplate.id,
{ agents_allowed: false },
);
refetchedTemplates = [
MockTemplate,
{ ...mockSecondTemplate, agents_allowed: false },
];
deferreds.first.reject(
mockApiError({ message: "Template access is locked." }),
);
deferreds.second.resolve({ ...mockSecondTemplate, agents_allowed: false });
const errorToast = await body.findByText(
"Test Template in My Organization: Template access is locked.",
);
await waitFor(() => expect(errorToast).toBeVisible());
await waitFor(() => expect(firstSwitch).toBeEnabled());
await waitFor(() => expect(secondSwitch).toBeEnabled());
expect(firstSwitch).toBeChecked();
expect(secondSwitch).not.toBeChecked();
const filter = canvas.getByRole("textbox", { name: "Filter" });
await user.type(filter, "Second");
await waitFor(() =>
expect(API.getTemplates).toHaveBeenCalledWith({ q: "Second" }),
);
expect(await canvas.findByText("Second Template")).toBeVisible();
expect(
canvas.queryByRole("switch", {
name: "Allow Coder Agents to create workspaces using Test Template in My Organization",
}),
).not.toBeInTheDocument();
const filteredErrorToast = await body.findByText(
"Test Template in My Organization: Template access is locked.",
);
await waitFor(() => expect(filteredErrorToast).toBeVisible());
await user.clear(filter);
const retrySwitch = await canvas.findByRole("switch", {
name: "Allow Coder Agents to create workspaces using Test Template in My Organization",
});
await user.click(retrySwitch);
await waitFor(() => expect(retrySwitch).toBeDisabled());
expect(API.updateTemplateMeta).toHaveBeenNthCalledWith(3, MockTemplate.id, {
agents_allowed: false,
});
refetchedTemplates = [
{ ...MockTemplate, agents_allowed: false },
{ ...mockSecondTemplate, agents_allowed: false },
];
deferreds.retry.resolve({ ...MockTemplate, agents_allowed: false });
await waitFor(() => expect(retrySwitch).toBeEnabled());
await waitFor(() => expect(retrySwitch).not.toBeChecked());
},
};
export const DisplaysFallbackMutationError: Story = {
beforeEach: () => {
spyOn(API, "updateTemplateMeta").mockRejectedValue({});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const templateSwitch = await canvas.findByRole("switch", {
name: "Allow Coder Agents to create workspaces using Test Template in My Organization",
});
await userEvent.click(templateSwitch);
const errorToast = await body.findByText(
"Test Template in My Organization: Failed to update whether Coder Agents can create workspaces.",
);
await waitFor(() => expect(errorToast).toBeVisible());
expect(API.updateTemplateMeta).toHaveBeenCalledWith(MockTemplate.id, {
agents_allowed: false,
});
await waitFor(() => expect(templateSwitch).toBeEnabled());
expect(templateSwitch).toBeChecked();
},
};
export const NoDeploymentConfigPermission: Story = {
parameters: {
permissions: {
editDeploymentConfig: false,
updateTemplates: true,
viewAllUsers: true,
},
queries: [],
},
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([]);
spyOn(API, "getUsers").mockResolvedValue({ users: [], count: 0 });
},
play: async () => {
const body = within(document.body);
expect(
await body.findByText("You don't have permission to view this page"),
).toBeInTheDocument();
expect(body.queryByText("Test Template")).not.toBeInTheDocument();
expect(API.getTemplates).not.toHaveBeenCalled();
expect(API.getUsers).not.toHaveBeenCalled();
},
};
export const FetchesWhenAllowed: Story = {
parameters: {
permissions: {
editDeploymentConfig: true,
updateTemplates: true,
viewAllUsers: true,
},
queries: [],
},
beforeEach: () => {
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
spyOn(API, "getUsers").mockResolvedValue({
users: [MockUserOwner],
count: 1,
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Test Template")).toBeVisible();
await waitFor(() => expect(API.getTemplates).toHaveBeenCalled());
await waitFor(() => expect(API.getUsers).toHaveBeenCalled());
},
};
export const NoUpdateTemplatesPermission: Story = {
parameters: {
permissions: {
editDeploymentConfig: true,
updateTemplates: false,
},
},
play: async () => {
const body = within(document.body);
expect(
await body.findByText("You don't have permission to view this page"),
).toBeInTheDocument();
expect(body.queryByText("Test Template")).not.toBeInTheDocument();
},
};
@@ -1,44 +1,75 @@
import type { FC } from "react";
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatTemplateAllowlist,
updateChatTemplateAllowlist,
} from "#/api/queries/chats";
import { templates } from "#/api/queries/templates";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { templates, updateTemplateMeta } from "#/api/queries/templates";
import type * as TypesGen from "#/api/typesGenerated";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { useTemplatesFilter } from "#/pages/TemplatesPage/TemplatesFilter";
import { pageTitle } from "#/utils/page";
import { TemplatesPageView } from "./TemplatesPageView";
const TemplatesPage: FC = () => {
const { permissions } = useAuthenticated();
const queryClient = useQueryClient();
const canManageTemplates =
permissions.editDeploymentConfig && permissions.updateTemplates;
const [searchParams, setSearchParams] = useSearchParams();
const filterState = useTemplatesFilter({
searchParams,
onSearchParamsChange: setSearchParams,
enabled: canManageTemplates,
});
const templatesQuery = useQuery({
...templates({ q: filterState.filter.query }),
enabled: canManageTemplates,
});
const updateTemplateMutation = useMutation(updateTemplateMeta(queryClient));
const [pendingTemplateIDs, setPendingTemplateIDs] = useState<
ReadonlySet<string>
>(new Set());
const templatesQuery = useQuery(templates());
const allowlistQuery = useQuery(chatTemplateAllowlist());
const saveAllowlistMutation = useMutation(
updateChatTemplateAllowlist(queryClient),
);
const isLoading = templatesQuery.isLoading || allowlistQuery.isLoading;
const toggleAgentsAllowed = async (
template: TypesGen.Template,
agentsAllowed: boolean,
) => {
setPendingTemplateIDs((current) => new Set(current).add(template.id));
try {
await updateTemplateMutation.mutateAsync({
template,
data: { agents_allowed: agentsAllowed },
});
} catch (error) {
toast.error(
`${template.display_name || template.name} in ${template.organization_display_name || template.organization_name}: ${getErrorMessage(error, "Failed to update whether Coder Agents can create workspaces.")}`,
{
description: getErrorDetail(error),
duration: Number.POSITIVE_INFINITY,
},
);
} finally {
setPendingTemplateIDs((current) => {
const next = new Set(current);
next.delete(template.id);
return next;
});
}
};
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<RequirePermission isFeatureVisible={canManageTemplates}>
<title>{pageTitle("Templates", "AI Settings")}</title>
<TemplatesPageView
templatesData={templatesQuery.data}
allowlistData={allowlistQuery.data}
isLoading={isLoading}
templatesError={templatesQuery.error}
allowlistError={allowlistQuery.error}
onRetry={() => {
void templatesQuery.refetch();
void allowlistQuery.refetch();
}}
onSaveAllowlist={saveAllowlistMutation.mutate}
isSaving={saveAllowlistMutation.isPending}
saveError={saveAllowlistMutation.error}
filterState={filterState}
templates={templatesQuery.data}
isLoading={templatesQuery.isLoading}
error={templatesQuery.error}
onRetry={() => void templatesQuery.refetch()}
onToggleAgentsAllowed={toggleAgentsAllowed}
pendingTemplateIDs={pendingTemplateIDs}
/>
</RequirePermission>
);
@@ -1,282 +1,269 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { MockTemplate } from "#/testHelpers/entities";
import { getDefaultFilterProps } from "#/components/Filter/storyHelpers";
import type { TemplateFilterState } from "#/pages/TemplatesPage/TemplatesFilter";
import { MockTemplate, mockApiError } from "#/testHelpers/entities";
import { withDashboardProvider } from "#/testHelpers/storybook";
import { TemplatesPageView } from "./TemplatesPageView";
const templateIDs = ["t-01", "t-02", "t-03", "t-04", "t-05", "t-06"];
const templates: TypesGen.Template[] = [
const templates = [
{
id: templateIDs[0],
id: "t-01",
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,
agents_allowed: true,
},
{
id: templateIDs[1],
id: "t-02",
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,
agents_allowed: false,
},
{
id: templateIDs[2],
id: "t-03",
name: "ai-webinar",
display_name: "AI webinar",
description: "Workspace for webinar demos.",
updated_at: "2026-06-04T12:00:00.000Z",
active_user_count: 3,
agents_allowed: true,
},
{
id: templateIDs[3],
id: "t-04",
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,
agents_allowed: false,
},
{
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 }));
].map(
(template): TypesGen.Template => ({
...MockTemplate,
...template,
}),
);
const filterState = getDefaultFilterProps<TemplateFilterState>({
menus: {},
values: {},
});
const meta = {
title: "pages/AISettingsPage/TemplatesPage/TemplatesPageView",
component: TemplatesPageView,
// TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude.
parameters: { pixel: { exclude: true } },
decorators: [withDashboardProvider],
args: {
templatesData: templates,
allowlistData: { template_ids: [templateIDs[0], templateIDs[1]] },
filterState,
templates,
isLoading: false,
templatesError: undefined,
allowlistError: undefined,
isSaving: false,
saveError: undefined,
error: undefined,
pendingTemplateIDs: new Set<string>(),
onRetry: fn(),
onSaveAllowlist: fn(),
onToggleAgentsAllowed: 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 }) => {
export const MixedToggles: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("No restrictions set.")).toBeVisible();
expect(await canvas.findByText("Docker containers")).toBeVisible();
expect(
canvas.getByText(
"All templates are available. Add a template to create an allowlist.",
),
canvas.getAllByText(MockTemplate.organization_display_name)[0],
).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();
expect(canvas.getByText("125 developers")).toBeVisible();
const table = canvas.getByRole("table", {
name: "Templates Coder Agents can use to create workspaces",
});
expect(
within(table).getByRole("columnheader", {
name: "Coder Agents workspace creation",
}),
).toBeInTheDocument();
const rows = within(table).getAllByRole("row");
expect(within(rows[1]).getByText("Docker containers")).toBeVisible();
expect(within(rows[2]).getByText("Product ops engineering")).toBeVisible();
expect(
canvas.getByRole("switch", {
name: "Allow Coder Agents to create workspaces using Docker containers in My Organization",
}),
).toBeChecked();
expect(
canvas.getByRole("switch", {
name: "Allow Coder Agents to create workspaces using Product ops engineering in My Organization",
}),
).not.toBeChecked();
},
};
export const TemplateAllowlist: Story = {
play: async ({ canvasElement, step, args }) => {
export const ToggleTemplate: Story = {
play: async ({ canvasElement, 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]],
});
});
});
await userEvent.click(
canvas.getByRole("switch", {
name: "Allow Coder Agents to create workspaces using Docker containers in My Organization",
}),
);
expect(args.onToggleAgentsAllowed).toHaveBeenCalledWith(
templates[0],
false,
);
},
};
export const Loading: Story = {
args: {
isLoading: true,
templatesData: undefined,
allowlistData: undefined,
templates: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByRole("status")).toBeVisible();
},
};
export const TemplatesLoadError: Story = {
export const LoadError: Story = {
args: {
templatesError: new Error("Templates request failed"),
error: new Error("Templates request failed"),
templates: undefined,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("Failed to load templates.")).toBeVisible();
expect(canvas.queryByRole("table")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Retry" }));
expect(args.onRetry).toHaveBeenCalled();
},
};
export const RefetchError: Story = {
args: {
error: 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."),
canvas.getByRole("table", {
name: "Templates Coder Agents can use to create workspaces",
}),
).toBeVisible();
expect(canvas.getByText("Docker containers")).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Retry" }));
expect(args.onRetry).toHaveBeenCalled();
},
};
export const AllowlistLoadError: Story = {
export const ValidationError: Story = {
args: {
allowlistError: new Error("Allowlist request failed"),
error: mockApiError({
message: "Invalid template search query.",
validations: [
{
field: "search",
detail: "The template filter is invalid.",
},
],
}),
templates: undefined,
},
play: async ({ canvasElement, args }) => {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText(
"Failed to load template allowlist configuration.",
await canvas.findByText("The template filter is invalid."),
).toBeVisible();
expect(canvas.queryByRole("table")).not.toBeInTheDocument();
expect(canvas.queryByRole("status")).not.toBeInTheDocument();
},
};
export const Empty: Story = {
args: {
templates: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText("No templates found.")).toBeVisible();
expect(
canvas.getByText(
"Create a template before configuring whether Coder Agents can create workspaces.",
),
).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Retry" }));
expect(args.onRetry).toHaveBeenCalled();
},
};
export const PhantomTemplateIDs: Story = {
export const FilteredEmpty: 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,
templates: [],
filterState: {
...filterState,
filter: {
...filterState.filter,
query: "missing",
used: 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."),
await canvas.findByText("No results matched your search."),
).toBeVisible();
},
};
export const MixedOrganizations: Story = {
args: {
templates: [
{
...templates[0],
organization_id: "engineering-id",
organization_name: "engineering",
organization_display_name: "Engineering",
},
{
...templates[0],
id: "product-template-id",
organization_id: "product-id",
organization_name: "product",
organization_display_name: "Product",
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const engineeringSwitch = await canvas.findByRole("switch", {
name: "Allow Coder Agents to create workspaces using Docker containers in Engineering",
});
const productSwitch = canvas.getByRole("switch", {
name: "Allow Coder Agents to create workspaces using Docker containers in Product",
});
expect(engineeringSwitch).toBeChecked();
expect(productSwitch).toBeChecked();
},
};
export const UpdatingOneTemplate: Story = {
args: {
pendingTemplateIDs: new Set([templates[1].id]),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByRole("switch", {
name: "Allow Coder Agents to create workspaces using Product ops engineering in My Organization",
}),
).toBeDisabled();
expect(
canvas.getByRole("switch", {
name: "Allow Coder Agents to create workspaces using Docker containers in My Organization",
}),
).toBeEnabled();
},
};
@@ -1,39 +1,21 @@
import type { FC } from "react";
import {
ChevronDownIcon,
EllipsisVerticalIcon,
PlusIcon,
TrashIcon,
} from "lucide-react";
import { type FC, useMemo, useState } from "react";
import { DetailedError, getErrorDetail, getErrorMessage } from "#/api/errors";
DetailedError,
getErrorDetail,
hasError,
isApiValidationError,
} from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
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 { Switch } from "#/components/Switch/Switch";
import {
Table,
TableBody,
@@ -44,144 +26,60 @@ import {
} from "#/components/Table/Table";
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
import { TableLoader } from "#/components/TableLoader/TableLoader";
import {
type TemplateFilterState,
TemplatesFilter,
} from "#/pages/TemplatesPage/TemplatesFilter";
import { createDayString } from "#/utils/createDayString";
import { formatTemplateActiveDevelopers } from "#/utils/templates";
import { formatTemplateActiveDevelopersLabel } from "#/utils/templates";
interface TemplatesPageViewProps {
templatesData: TypesGen.Template[] | undefined;
allowlistData: TypesGen.ChatTemplateAllowlist | undefined;
filterState: TemplateFilterState;
templates: TypesGen.Template[] | undefined;
isLoading: boolean;
templatesError: unknown;
allowlistError: unknown;
error: unknown;
onRetry: () => void;
onSaveAllowlist: (req: TypesGen.ChatTemplateAllowlist) => void;
isSaving: boolean;
saveError: unknown;
onToggleAgentsAllowed: (
template: TypesGen.Template,
agentsAllowed: boolean,
) => void;
pendingTemplateIDs: ReadonlySet<string>;
}
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;
isPending: boolean;
onToggleAgentsAllowed: (
template: TypesGen.Template,
agentsAllowed: boolean,
) => void;
}
const TemplateRow: FC<TemplateRowProps> = ({
template,
isSaving,
onRemoveTemplate,
isPending,
onToggleAgentsAllowed,
}) => {
const label = template.display_name || template.name;
const organization =
template.organization_display_name || template.organization_name;
return (
<TableRow>
<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>
<AvatarData
truncate
title={<span title={label}>{label}</span>}
subtitle={<span title={organization}>{organization}</span>}
avatar={
<Avatar
size="lg"
variant="icon"
src={template.icon}
fallback={label}
/>
}
/>
</TableCell>
<TableCell
data-pixel="ignore"
@@ -190,220 +88,108 @@ const TemplateRow: FC<TemplateRowProps> = ({
{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"}`}
{formatTemplateActiveDevelopersLabel(template.active_user_count)}
</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 className="whitespace-nowrap pr-4 text-right">
<Switch
checked={template.agents_allowed}
onCheckedChange={(agentsAllowed) =>
onToggleAgentsAllowed(template, agentsAllowed)
}
disabled={isPending}
aria-label={`Allow Coder Agents to create workspaces using ${label} in ${organization}`}
/>
</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 size="lg">
{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,
filterState,
templates,
isLoading,
templatesError,
allowlistError,
error,
onRetry,
onSaveAllowlist,
isSaving,
saveError,
onToggleAgentsAllowed,
pendingTemplateIDs,
}) => {
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;
const hasValidationError = isApiValidationError(error);
const hasLoadError = hasError(error) && !hasValidationError;
return (
<div>
<SettingsHeader
actions={
!isLoading &&
!hasError &&
allowlistedTemplates.length > 0 && (
<AddTemplatePicker
availableTemplates={availableTemplates}
isSaving={isSaving}
onAddTemplate={handleAddTemplate}
/>
)
}
>
<SettingsHeader>
<SettingsHeaderTitle>Templates</SettingsHeaderTitle>
<SettingsHeaderDescription>
Restrict which templates agents can use to create workspaces.
Choose which templates Coder 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),
)
}
/>
)}
<TemplatesFilter
filter={filterState.filter}
error={error}
userMenu={filterState.menus.user}
/>
{hasLoadError && (
<div className="mb-4 flex flex-col gap-4">
<ErrorAlert
error={
new DetailedError(
"Failed to load templates.",
getErrorDetail(error),
)
}
/>
<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>
)}
</>
)}
{!hasValidationError && (!hasLoadError || templates !== undefined) && (
<Table
aria-label="Templates Coder Agents can use to create workspaces"
className="table-fixed"
>
<TableHeader>
<TableRow>
<TableHead className="w-1/2">Template</TableHead>
<TableHead className="w-44">Last updated</TableHead>
<TableHead className="w-44">Used by</TableHead>
<TableHead className="w-36 text-right">
<span className="sr-only">Coder Agents workspace creation</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody size="lg">
{isLoading || !templates ? (
<TableLoader />
) : templates.length === 0 ? (
<TableEmpty
message={
filterState.filter.used
? "No results matched your search."
: "No templates found."
}
description={
filterState.filter.used
? undefined
: "Create a template before configuring whether Coder Agents can create workspaces."
}
isCompact
className="min-h-52"
/>
) : (
templates.map((template) => (
<TemplateRow
key={template.id}
template={template}
isPending={pendingTemplateIDs.has(template.id)}
onToggleAgentsAllowed={onToggleAgentsAllowed}
/>
))
)}
</TableBody>
</Table>
)}
</div>
);
@@ -73,7 +73,7 @@ export const EmptyTemplates: Story = {
parameters: {
queries: [
{
key: ["templates", { q: "has-ai-task:true" }],
key: getTemplatesQueryKey({ q: "has-ai-task:true" }),
data: [],
},
{
@@ -52,6 +52,7 @@ export const validationSchema = Yup.object({
MAX_DESCRIPTION_MESSAGE,
),
allow_user_cancel_workspace_jobs: Yup.boolean(),
agents_allowed: Yup.boolean(),
icon: iconValidator,
require_active_version: Yup.boolean(),
disable_module_cache: Yup.boolean(),
@@ -92,6 +93,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
icon: template.icon,
allow_user_cancel_workspace_jobs:
template.allow_user_cancel_workspace_jobs,
agents_allowed: template.agents_allowed,
update_workspace_last_used_at: false,
update_workspace_dormant_at: false,
require_active_version: template.require_active_version,
@@ -201,6 +203,23 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
description="Regulate actions allowed on workspaces created from this template."
>
<FormFields className="gap-12">
<div className="flex items-start">
<Checkbox
id="agents_allowed"
name="agents_allowed"
disabled={isSubmitting}
checked={form.values.agents_allowed}
onCheckedChange={(checked) => {
form.setFieldValue("agents_allowed", checked === true);
}}
/>
<Label htmlFor="agents_allowed">
<StackLabel>
Allow Coder Agents to create workspaces using this template
</StackLabel>
</Label>
</div>
<div className="flex items-start">
<Checkbox
id="allow_user_cancel_workspace_jobs"
@@ -4,6 +4,7 @@ import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { API } from "#/api/api";
import { getAuthorizationKey } from "#/api/queries/authCheck";
import { templateByNameKey } from "#/api/queries/templates";
import type { Template } from "#/api/typesGenerated";
import { MockTemplate, mockApiError } from "#/testHelpers/entities";
import { withDashboardProvider, withToaster } from "#/testHelpers/storybook";
import { TemplateSettingsLayout } from "../TemplateSettingsLayout";
@@ -59,12 +60,20 @@ export const UpdateSucceeds: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
const updatedTemplate: Template = {
...MockTemplate,
name: "new-name",
agents_allowed: false,
};
const updateTemplateMetaSpy = spyOn(
API,
"updateTemplateMeta",
).mockResolvedValue({ ...MockTemplate, name: "new-name" });
).mockResolvedValue(updatedTemplate);
await fillAndSubmitForm(canvas, user);
await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1));
expect(updateTemplateMetaSpy.mock.calls[0][1]).toEqual(
expect.objectContaining({ agents_allowed: false }),
);
},
};
@@ -159,6 +168,12 @@ async function fillAndSubmitForm(
await user.clear(iconField);
await user.type(iconField, "vscode.png");
const agentsAllowedField = canvas.getByRole("checkbox", {
name: /allow coder agents to create workspaces using this template/i,
});
expect(agentsAllowedField).toBeChecked();
await user.click(agentsAllowedField);
const allowCancelJobsField = canvas.getByRole("checkbox", {
name: /allow users to cancel in-progress workspace jobs/i,
});
@@ -4,7 +4,10 @@ import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { API } from "#/api/api";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { templateByNameKey } from "#/api/queries/templates";
import {
invalidateTemplateListQueries,
templateByNameKey,
} from "#/api/queries/templates";
import type { UpdateTemplateMeta } from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { linkToTemplate, useLinks } from "#/modules/navigation";
@@ -40,12 +43,13 @@ const TemplateSettingsPage: FC = () => {
if (!data) {
data = template;
} else {
// Only invalid the query if data is returned, indicating at least one field was updated.
//
// we use data.name because an admin may have updated templateName to something new
await queryClient.invalidateQueries({
queryKey: templateByNameKey(template.organization_name, data.name),
});
// Use data.name because an admin may have renamed the template.
await Promise.all([
invalidateTemplateListQueries(queryClient),
queryClient.invalidateQueries({
queryKey: templateByNameKey(template.organization_name, data.name),
}),
]);
}
toast.success(`Template "${data.name}" updated successfully.`);
navigate(getLink(linkToTemplate(data.organization_name, data.name)));
@@ -21,6 +21,15 @@ type Story = StoryObj<typeof TemplateSettingsPageView>;
export const Example: Story = {};
export const AgentsNotAllowed: Story = {
args: {
template: {
...MockTemplate,
agents_allowed: false,
},
},
};
export const SaveTemplateSettingsError: Story = {
args: {
submitError: mockApiError({
@@ -137,6 +137,12 @@ describe("TemplateSchedulePage", () => {
await waitForWithCutoff(() =>
expect(API.updateTemplateMeta).toBeCalledTimes(1),
);
const updateCall = vi.mocked(API.updateTemplateMeta).mock.calls.at(0);
if (!updateCall) {
throw new Error("expected template metadata update");
}
expect(updateCall[1]).not.toHaveProperty("agents_allowed");
}, 15_000);
test("default is converted to and from hours", async () => {
@@ -54,12 +54,7 @@ const TemplateSchedulePage: FC = () => {
onCancel={() => {
navigate(getLink(linkToTemplate(organizationName, templateName)));
}}
onSubmit={(templateScheduleSettings) => {
updateTemplate({
...template,
...templateScheduleSettings,
});
}}
onSubmit={updateTemplate}
/>
</>
);
@@ -6,6 +6,7 @@ import {
Filter,
MenuSkeleton,
type UseFilterResult,
useFilter,
} from "#/components/Filter/Filter";
import { useFilterMenu } from "#/components/Filter/menu";
import {
@@ -16,9 +17,51 @@ import {
DEFAULT_USER_FILTER_WIDTH,
type UserFilterMenu,
UserMenu,
useUserFilterMenu,
} from "#/components/Filter/UserFilter";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
export type TemplateFilterState = {
filter: UseFilterResult;
menus: {
user?: ReturnType<typeof useUserFilterMenu>;
};
};
type UseTemplatesFilterOptions = {
searchParams: URLSearchParams;
onSearchParamsChange: (params: URLSearchParams) => void;
enabled?: boolean;
};
export const useTemplatesFilter = ({
searchParams,
onSearchParamsChange,
enabled = true,
}: UseTemplatesFilterOptions): TemplateFilterState => {
const filter = useFilter({
searchParams,
onSearchParamsChange,
});
const { permissions } = useAuthenticated();
const canFilterByUser = permissions.viewAllUsers;
const userMenu = useUserFilterMenu({
value: filter.values.author,
onChange: (option) =>
filter.update({ ...filter.values, author: option?.value }),
enabled: enabled && canFilterByUser,
});
return {
filter,
menus: {
user: canFilterByUser ? userMenu : undefined,
},
};
};
interface TemplatesFilterProps {
filter: UseFilterResult;
error?: unknown;
+1 -40
View File
@@ -4,11 +4,10 @@ import { useSearchParams } from "react-router";
import { deploymentConfig } from "#/api/queries/deployment";
import { workspacePermissionsByOrganization } from "#/api/queries/organizations";
import { templateExamples, templates } from "#/api/queries/templates";
import { type UseFilterResult, useFilter } from "#/components/Filter/Filter";
import { useUserFilterMenu } from "#/components/Filter/UserFilter";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { pageTitle } from "#/utils/page";
import { useTemplatesFilter } from "./TemplatesFilter";
import { TemplatesPageView } from "./TemplatesPageView";
const TemplatesPage: FC = () => {
@@ -66,41 +65,3 @@ const TemplatesPage: FC = () => {
};
export default TemplatesPage;
export type TemplateFilterState = {
filter: UseFilterResult;
menus: {
user?: ReturnType<typeof useUserFilterMenu>;
};
};
type UseTemplatesFilterOptions = {
searchParams: URLSearchParams;
onSearchParamsChange: (params: URLSearchParams) => void;
};
const useTemplatesFilter = ({
searchParams,
onSearchParamsChange,
}: UseTemplatesFilterOptions): TemplateFilterState => {
const filter = useFilter({
searchParams,
onSearchParamsChange,
});
const { permissions } = useAuthenticated();
const canFilterByUser = permissions.viewAllUsers;
const userMenu = useUserFilterMenu({
value: filter.values.author,
onChange: (option) =>
filter.update({ ...filter.values, author: option?.value }),
enabled: canFilterByUser,
});
return {
filter,
menus: {
user: canFilterByUser ? userMenu : undefined,
},
};
};
@@ -12,7 +12,7 @@ import {
} from "#/testHelpers/entities";
import { pixelWithTablet } from "#/testHelpers/pixel";
import { withDashboardProvider } from "#/testHelpers/storybook";
import type { TemplateFilterState } from "./TemplatesPage";
import type { TemplateFilterState } from "./TemplatesFilter";
import { TemplatesPageView } from "./TemplatesPageView";
const defaultFilterProps = getDefaultFilterProps<TemplateFilterState>({
@@ -44,12 +44,11 @@ import { cn } from "#/utils/cn";
import { createDayString } from "#/utils/createDayString";
import { docs } from "#/utils/docs";
import {
formatTemplateActiveDevelopers,
formatTemplateActiveDevelopersLabel,
formatTemplateBuildTime,
} from "#/utils/templates";
import { EmptyTemplates } from "./EmptyTemplates";
import { TemplatesFilter } from "./TemplatesFilter";
import type { TemplateFilterState } from "./TemplatesPage";
import { type TemplateFilterState, TemplatesFilter } from "./TemplatesFilter";
const TemplateHelpPopover: FC = () => {
return (
@@ -132,7 +131,9 @@ const TemplateRow: FC<TemplateRowProps> = ({
);
const navigate = useNavigate();
const developerCount = `${formatTemplateActiveDevelopers(template.active_user_count)} developer${template.active_user_count !== 1 ? "s" : ""}`;
const developerCount = formatTemplateActiveDevelopersLabel(
template.active_user_count,
);
const clickableRow = useClickableTableRow({
onClick: () => navigate(templatePageLink),
+3
View File
@@ -13,6 +13,9 @@ export const formatTemplateActiveDevelopers = (num?: number): string => {
return num.toString();
};
export const formatTemplateActiveDevelopersLabel = (num?: number): string =>
`${formatTemplateActiveDevelopers(num)} developer${num === 1 ? "" : "s"}`;
export const formatTemplateBuildTime = (
buildTimeMs?: number | null,
): string => {