diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.stories.tsx new file mode 100644 index 0000000000..c33d318984 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.stories.tsx @@ -0,0 +1,178 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +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 { MockTemplate, mockApiError } from "#/testHelpers/entities"; +import { withDashboardProvider, withToaster } from "#/testHelpers/storybook"; +import { TemplateSettingsLayout } from "../TemplateSettingsLayout"; +import TemplateSettingsPage from "./TemplateSettingsPage"; + +const meta = { + title: "pages/TemplateSettingsPage/TemplateSettingsPage", + component: TemplateSettingsLayout, + decorators: [withToaster, withDashboardProvider], + parameters: { + layout: "fullscreen", + reactRouter: reactRouterParameters({ + location: { + path: "/templates/:template/settings", + pathParams: { template: MockTemplate.name }, + }, + routing: [ + { + path: "/templates/:template/settings", + useStoryElement: true, + children: [{ index: true, element: }], + }, + { path: "/templates/:template", element:
Template
}, + ], + }), + queries: [ + { + key: templateByNameKey("default", MockTemplate.name), + data: MockTemplate, + }, + { + key: getAuthorizationKey({ + checks: { + canUpdateTemplate: { + object: { + resource_type: "template", + resource_id: MockTemplate.id, + }, + action: "update", + }, + }, + }), + data: { canUpdateTemplate: true }, + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const UpdateSucceeds: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + const updateTemplateMetaSpy = spyOn( + API, + "updateTemplateMeta", + ).mockResolvedValue({ ...MockTemplate, name: "new-name" }); + await fillAndSubmitForm(canvas, user); + await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1)); + }, +}; + +export const DisplaysErrorWhenNameIsTaken: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + const updateTemplateMetaSpy = spyOn( + API, + "updateTemplateMeta", + ).mockRejectedValue( + mockApiError({ + message: `Template with name "test-template" already exists`, + validations: [ + { + field: "name", + detail: "This value is already in use and should be unique.", + }, + ], + }), + ); + await fillAndSubmitForm(canvas, user); + await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1)); + const form = await canvas.findByRole("form", { + name: /template settings/i, + }); + await within(form).findByText( + "This value is already in use and should be unique.", + ); + }, +}; + +export const DeprecatesTemplateWithAccessControl: Story = { + parameters: { + features: ["access_control"], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + const updateTemplateMetaSpy = spyOn( + API, + "updateTemplateMeta", + ).mockResolvedValue(MockTemplate); + const deprecationMessage = "This template is deprecated"; + await deprecateTemplate(canvas, user, deprecationMessage); + await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1)); + const [templateId, data] = updateTemplateMetaSpy.mock.calls[0]; + expect(templateId).toEqual(MockTemplate.id); + expect(data).toEqual( + expect.objectContaining({ deprecation_message: deprecationMessage }), + ); + }, +}; + +export const DoesNotDeprecateWithoutAccessControl: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + const updateTemplateMetaSpy = spyOn( + API, + "updateTemplateMeta", + ).mockResolvedValue(MockTemplate); + await deprecateTemplate( + canvas, + user, + "This template should not be able to deprecate", + ); + await waitFor(() => expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1)); + const [templateId, data] = updateTemplateMetaSpy.mock.calls[0]; + expect(templateId).toEqual(MockTemplate.id); + expect(data).toEqual(expect.objectContaining({ deprecation_message: "" })); + }, +}; + +async function fillAndSubmitForm( + canvas: ReturnType, + user: ReturnType, +) { + const nameField = await canvas.findByLabelText("Name"); + await user.clear(nameField); + await user.type(nameField, "Name"); + + const displayNameField = await canvas.findByLabelText("Display name"); + await user.clear(displayNameField); + await user.type(displayNameField, "A display name"); + + const descriptionField = await canvas.findByLabelText("Description"); + await user.clear(descriptionField); + await user.type(descriptionField, "A description"); + + const iconField = await canvas.findByLabelText("Icon"); + await user.clear(iconField); + await user.type(iconField, "vscode.png"); + + const allowCancelJobsField = canvas.getByRole("checkbox", { + name: /allow users to cancel in-progress workspace jobs/i, + }); + await user.click(allowCancelJobsField); + + await user.click(await canvas.findByRole("button", { name: /save/i })); +} + +async function deprecateTemplate( + canvas: ReturnType, + user: ReturnType, + message: string, +) { + const deprecationField = await canvas.findByLabelText("Deprecation Message"); + await user.type(deprecationField, message); + await user.click(await canvas.findByRole("button", { name: /save/i })); +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 153205c7cf..ce90944727 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -1,20 +1,5 @@ -import { screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { API, withDefaultFeatures } from "#/api/api"; import type { UpdateTemplateMeta } from "#/api/typesGenerated"; -import { - MockEntitlements, - MockTemplate, - mockApiError, -} from "#/testHelpers/entities"; -import { - renderWithTemplateSettingsLayout, - waitForLoaderToBeRemoved, -} from "#/testHelpers/renderHelpers"; -import { server } from "#/testHelpers/server"; import { validationSchema } from "./TemplateSettingsForm"; -import TemplateSettingsPage from "./TemplateSettingsPage"; type FormValues = Required< Omit< @@ -62,92 +47,7 @@ const validFormValues: FormValues = { disable_module_cache: false, }; -const renderTemplateSettingsPage = async () => { - renderWithTemplateSettingsLayout(, { - route: `/templates/${MockTemplate.name}/settings`, - path: "/templates/:template/settings", - extraRoutes: [ - { path: "/templates/:template", element:
Template
}, - ], - }); - await waitForLoaderToBeRemoved(); -}; - -const fillAndSubmitForm = async ({ - name, - display_name, - description, - icon, - allow_user_cancel_workspace_jobs, -}: FormValues) => { - const nameField = await screen.findByLabelText("Name"); - await userEvent.clear(nameField); - await userEvent.type(nameField, name); - - const displayNameField = await screen.findByLabelText("Display name"); - await userEvent.clear(displayNameField); - await userEvent.type(displayNameField, display_name); - - const descriptionField = await screen.findByLabelText("Description"); - await userEvent.clear(descriptionField); - await userEvent.type(descriptionField, description); - - const iconField = await screen.findByLabelText("Icon"); - await userEvent.clear(iconField); - await userEvent.type(iconField, icon); - - const allowCancelJobsField = screen.getByRole("checkbox", { - name: /allow users to cancel in-progress workspace jobs/i, - }); - // checkbox is checked by default, so it must be clicked to get unchecked - if (!allow_user_cancel_workspace_jobs) { - await userEvent.click(allowCancelJobsField); - } - - const submitButton = await screen.findByText(/save/i); - await userEvent.click(submitButton); -}; - -describe("TemplateSettingsPage", { timeout: 20_000 }, () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("succeeds", async () => { - await renderTemplateSettingsPage(); - vi.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ - ...MockTemplate, - ...validFormValues, - }); - await fillAndSubmitForm(validFormValues); - await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)); - }); - - it("displays an error if the name is taken", async () => { - await renderTemplateSettingsPage(); - vi.spyOn(API, "updateTemplateMeta").mockRejectedValueOnce( - mockApiError({ - message: `Template with name "test-template" already exists`, - validations: [ - { - field: "name", - detail: "This value is already in use and should be unique.", - }, - ], - }), - ); - await fillAndSubmitForm(validFormValues); - await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)); - const form = await screen.findByRole("form", { - name: /template settings/i, - }); - expect( - await within(form).findByText( - "This value is already in use and should be unique.", - ), - ).toBeInTheDocument(); - }); - +describe("TemplateSettingsPage", () => { it("allows a description of 128 chars", () => { const values: UpdateTemplateMeta = { ...validFormValues, @@ -167,66 +67,4 @@ describe("TemplateSettingsPage", { timeout: 20_000 }, () => { const validate = () => validationSchema.validateSync(values); expect(validate).toThrowError(); }); - - describe("Deprecate template", () => { - it("deprecates a template when has access control", async () => { - server.use( - http.get("/api/v2/entitlements", () => { - return HttpResponse.json({ - ...MockEntitlements, - features: withDefaultFeatures({ - access_control: { enabled: true, entitlement: "entitled" }, - }), - }); - }), - ); - const updateTemplateMetaSpy = vi.spyOn(API, "updateTemplateMeta"); - const deprecationMessage = "This template is deprecated"; - - await renderTemplateSettingsPage(); - await deprecateTemplate(deprecationMessage); - await waitFor(() => - expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1), - ); - - const [templateId, data] = updateTemplateMetaSpy.mock.calls[0]; - expect(templateId).toEqual(MockTemplate.id); - expect(data).toEqual( - expect.objectContaining({ deprecation_message: deprecationMessage }), - ); - }); - - it("does not deprecate a template when does not have access control", async () => { - server.use( - http.get("/api/v2/entitlements", () => { - return HttpResponse.json({ - ...MockEntitlements, - features: withDefaultFeatures({ - access_control: { enabled: false, entitlement: "not_entitled" }, - }), - }); - }), - ); - const updateTemplateMetaSpy = vi.spyOn(API, "updateTemplateMeta"); - - await renderTemplateSettingsPage(); - await deprecateTemplate("This template should not be able to deprecate"); - await waitFor(() => - expect(updateTemplateMetaSpy).toHaveBeenCalledTimes(1), - ); - - const [templateId, data] = updateTemplateMetaSpy.mock.calls[0]; - expect(templateId).toEqual(MockTemplate.id); - expect(data).toEqual( - expect.objectContaining({ deprecation_message: "" }), - ); - }); - }); }); - -async function deprecateTemplate(message: string) { - const deprecationField = screen.getByLabelText("Deprecation Message"); - await userEvent.type(deprecationField, message); - const submitButton = await screen.findByRole("button", { name: /save/i }); - await userEvent.click(submitButton); -}