mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+2
-2
@@ -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",
|
||||
|
||||
Generated
+20
-20
@@ -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':
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
{
|
||||
|
||||
+19
@@ -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"
|
||||
|
||||
+16
-1
@@ -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,
|
||||
});
|
||||
|
||||
+11
-7
@@ -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)));
|
||||
|
||||
+9
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user