diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index d10db7b1557c2..5d1be6af3365d 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -1,5 +1,6 @@ import { useSelector } from "@xstate/react" import { SetupPage } from "pages/SetupPage/SetupPage" +import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" import { FC, lazy, Suspense, useContext } from "react" import { Navigate, Route, Routes } from "react-router-dom" import { selectPermissions } from "xServices/auth/authSelectors" @@ -97,6 +98,14 @@ export const AppRouter: FC = () => { } /> + + + + } + /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5d40a668400f3..6d8d84cd00801 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -145,6 +145,14 @@ export const getTemplateVersions = async ( return response.data } +export const updateTemplateMeta = async ( + templateId: string, + data: TypesGen.UpdateTemplateMeta, +): Promise => { + const response = await axios.patch(`/api/v2/templates/${templateId}`, data) + return response.data +} + export const getWorkspace = async ( workspaceId: string, params?: TypesGen.WorkspaceOptions, diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx index 6eab044d0f688..ff7e1ba2b2864 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -2,6 +2,7 @@ import Button from "@material-ui/core/Button" import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" import AddCircleOutline from "@material-ui/icons/AddCircleOutline" +import SettingsOutlined from "@material-ui/icons/SettingsOutlined" import frontMatter from "front-matter" import { FC } from "react" import ReactMarkdown from "react-markdown" @@ -20,6 +21,7 @@ import { VersionsTable } from "../../components/VersionsTable/VersionsTable" import { WorkspaceSection } from "../../components/WorkspaceSection/WorkspaceSection" const Language = { + settingsButton: "Settings", createButton: "Create workspace", noDescription: "", readmeTitle: "README", @@ -51,13 +53,24 @@ export const TemplatePageView: FC = ({ - - + + + + + + + + } > {template.name} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx new file mode 100644 index 0000000000000..0598d0d87b946 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx @@ -0,0 +1,94 @@ +import TextField from "@material-ui/core/TextField" +import { Template, UpdateTemplateMeta } from "api/typesGenerated" +import { FormFooter } from "components/FormFooter/FormFooter" +import { Stack } from "components/Stack/Stack" +import { FormikContextType, FormikTouched, useFormik } from "formik" +import { FC } from "react" +import { getFormHelpersWithError, nameValidator, onChangeTrimmed } from "util/formUtils" +import * as Yup from "yup" + +export const Language = { + nameLabel: "Name", + descriptionLabel: "Description", + maxTtlLabel: "Max TTL", + // This is the same from the CLI on https://github.com/coder/coder/blob/546157b63ef9204658acf58cb653aa9936b70c49/cli/templateedit.go#L59 + maxTtlHelperText: "Edit the template maximum time before shutdown in milliseconds", + formAriaLabel: "Template settings form", +} + +export const validationSchema = Yup.object({ + name: nameValidator(Language.nameLabel), + description: Yup.string(), + max_ttl_ms: Yup.number(), +}) + +export interface TemplateSettingsForm { + template: Template + onSubmit: (data: UpdateTemplateMeta) => void + onCancel: () => void + isSubmitting: boolean + error?: unknown + // Helpful to show field errors on Storybook + initialTouched?: FormikTouched +} + +export const TemplateSettingsForm: FC = ({ + template, + onSubmit, + onCancel, + error, + isSubmitting, + initialTouched, +}) => { + const form: FormikContextType = useFormik({ + initialValues: { + name: template.name, + description: template.description, + max_ttl_ms: template.max_ttl_ms, + }, + validationSchema, + onSubmit: (data) => { + onSubmit(data) + }, + initialTouched, + }) + const getFieldHelpers = getFormHelpersWithError(form, error) + + return ( +
+ + + + + + + + + + + ) +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx new file mode 100644 index 0000000000000..f3e7bdf81fd49 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx @@ -0,0 +1,66 @@ +import { screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import * as API from "api/api" +import { UpdateTemplateMeta } from "api/typesGenerated" +import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" +import { MockTemplate } from "../../testHelpers/entities" +import { renderWithAuth } from "../../testHelpers/renderHelpers" +import { Language as FormLanguage } from "./TemplateSettingsForm" +import { TemplateSettingsPage } from "./TemplateSettingsPage" +import { Language as ViewLanguage } from "./TemplateSettingsPageView" + +const renderTemplateSettingsPage = async () => { + const renderResult = renderWithAuth(, { + route: `/templates/${MockTemplate.name}/settings`, + path: `/templates/:templateId/settings`, + }) + // Wait the form to be rendered + await screen.findAllByLabelText(FormLanguage.nameLabel) + return renderResult +} + +const fillAndSubmitForm = async ({ + name, + description, + max_ttl_ms, +}: Omit, "min_autostart_interval_ms">) => { + const nameField = await screen.findByLabelText(FormLanguage.nameLabel) + await userEvent.clear(nameField) + await userEvent.type(nameField, name) + + const descriptionField = await screen.findByLabelText(FormLanguage.descriptionLabel) + await userEvent.clear(descriptionField) + await userEvent.type(descriptionField, description) + + const maxTtlField = await screen.findByLabelText(FormLanguage.maxTtlLabel) + await userEvent.clear(maxTtlField) + await userEvent.type(maxTtlField, max_ttl_ms.toString()) + + const submitButton = await screen.findByText(FooterFormLanguage.defaultSubmitLabel) + await userEvent.click(submitButton) +} + +describe("TemplateSettingsPage", () => { + it("renders", async () => { + await renderTemplateSettingsPage() + const element = await screen.findByText(ViewLanguage.title) + expect(element).toBeDefined() + }) + + it("succeeds", async () => { + await renderTemplateSettingsPage() + + const newTemplateSettings = { + name: "edited-template-name", + description: "Edited description", + max_ttl_ms: 4000, + } + jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ + ...MockTemplate, + ...newTemplateSettings, + }) + await fillAndSubmitForm(newTemplateSettings) + + await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) + }) +}) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx new file mode 100644 index 0000000000000..883cebba092b4 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx @@ -0,0 +1,50 @@ +import { useMachine } from "@xstate/react" +import { useOrganizationId } from "hooks/useOrganizationId" +import { FC } from "react" +import { Helmet } from "react-helmet" +import { useNavigate, useParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService" +import { TemplateSettingsPageView } from "./TemplateSettingsPageView" + +const Language = { + title: "Template Settings", +} + +export const TemplateSettingsPage: FC = () => { + const { template: templateName } = useParams() as { template: string } + const navigate = useNavigate() + const organizationId = useOrganizationId() + const [state, send] = useMachine(templateSettingsMachine, { + context: { templateName, organizationId }, + actions: { + onSave: (_, { data }) => { + // Use the data.name because the template name can be changed + navigate(`/templates/${data.name}`) + }, + }, + }) + const { templateSettings: template, saveTemplateSettingsError, getTemplateError } = state.context + + return ( + <> + + {pageTitle(Language.title)} + + { + navigate(`/templates/${templateName}`) + }} + onSubmit={(templateSettings) => { + send({ type: "SAVE", templateSettings }) + }} + /> + + ) +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx new file mode 100644 index 0000000000000..671fca00a4bec --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx @@ -0,0 +1,55 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import * as Mocks from "../../testHelpers/renderHelpers" +import { makeMockApiError } from "../../testHelpers/renderHelpers" +import { TemplateSettingsPageView, TemplateSettingsPageViewProps } from "./TemplateSettingsPageView" + +export default { + title: "pages/TemplateSettingsPageView", + component: TemplateSettingsPageView, +} + +const Template: Story = (args) => ( + +) + +export const Example = Template.bind({}) +Example.args = { + template: Mocks.MockTemplate, + onSubmit: action("onSubmit"), + onCancel: action("cancel"), +} + +export const GetTemplateError = Template.bind({}) +GetTemplateError.args = { + template: undefined, + errors: { + getTemplateError: makeMockApiError({ + message: "Failed to fetch the template.", + detail: "You do not have permission to access this resource.", + }), + }, + onSubmit: action("onSubmit"), + onCancel: action("cancel"), +} + +export const SaveTemplateSettingsError = Template.bind({}) +SaveTemplateSettingsError.args = { + template: Mocks.MockTemplate, + errors: { + saveTemplateSettingsError: makeMockApiError({ + message: 'Template "test" already exists.', + validations: [ + { + field: "name", + detail: "This value is already in use and should be unique.", + }, + ], + }), + }, + initialTouched: { + name: true, + }, + onSubmit: action("onSubmit"), + onCancel: action("cancel"), +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx new file mode 100644 index 0000000000000..46c69a4eb533c --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx @@ -0,0 +1,50 @@ +import { Template, UpdateTemplateMeta } from "api/typesGenerated" +import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" +import { FullPageForm } from "components/FullPageForm/FullPageForm" +import { Loader } from "components/Loader/Loader" +import { ComponentProps, FC } from "react" +import { TemplateSettingsForm } from "./TemplateSettingsForm" + +export const Language = { + title: "Template settings", +} + +export interface TemplateSettingsPageViewProps { + template?: Template + onSubmit: (data: UpdateTemplateMeta) => void + onCancel: () => void + isSubmitting: boolean + errors?: { + getTemplateError?: unknown + saveTemplateSettingsError?: unknown + } + initialTouched?: ComponentProps["initialTouched"] +} + +export const TemplateSettingsPageView: FC = ({ + template, + onCancel, + onSubmit, + isSubmitting, + errors = {}, + initialTouched, +}) => { + const isLoading = !template && !errors.getTemplateError + + return ( + + {errors.getTemplateError && } + {isLoading && } + {template && ( + + )} + + ) +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 738c29459ff4a..5c484b7e89662 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -146,7 +146,7 @@ export const MockTemplate: TypesGen.Template = { created_at: "2022-05-17T17:39:01.382927298Z", updated_at: "2022-05-18T17:39:01.382927298Z", organization_id: MockOrganization.id, - name: "Test Template", + name: "test-template", provisioner: MockProvisioner.provisioners[0], active_version_id: MockTemplateVersion.id, workspace_owner_count: 1, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index da7b0ebeda7a7..ae4f23d3b08db 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -28,6 +28,9 @@ export const handlers = [ rest.get("/api/v2/templates/:templateId/versions", async (req, res, ctx) => { return res(ctx.status(200), ctx.json([M.MockTemplateVersion])) }), + rest.patch("/api/v2/templates/:templateId", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockTemplate)) + }), rest.get("/api/v2/templateversions/:templateVersionId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockTemplateVersion)) }), diff --git a/site/src/xServices/templateSettings/templateSettingsXService.ts b/site/src/xServices/templateSettings/templateSettingsXService.ts new file mode 100644 index 0000000000000..de80fbcedc842 --- /dev/null +++ b/site/src/xServices/templateSettings/templateSettingsXService.ts @@ -0,0 +1,104 @@ +import { getTemplateByName, updateTemplateMeta } from "api/api" +import { Template, UpdateTemplateMeta } from "api/typesGenerated" +import { createMachine } from "xstate" +import { assign } from "xstate/lib/actions" + +export const templateSettingsMachine = + /** @xstate-layout N4IgpgJg5mDOIC5QBcwFsAOAbAhqgymMsgJYB2UsAdFgPY4TlQDEEtZYV5AbrQNadUmXASKkK1OgyYIetAMZ4S7ANoAGALqJQGWrBKl22kAA9EANgCMVAKwB2AByWATJbWWAnABYv55w-MAGhAAT0RnAGZnKgibGw9nD3M1JJsHNQiAX0zgoWw8MEJiJmpIAyZmfABBADUAUWNdfUMyYzMESx9bSK8IhwdncwcbF2dgsIRejypzX2dXf09zCLUbbNz0fNFiiSpYHG4Ktg4uMl4BKjyRQrESvYOZOUUW9S0kECbyo3f25KoHOxeDwODx9EYeNTzcYWGxqKgeGw+SJ2cx+VZebI5EBkWgQODGK4FIriSg0eiMCiNPRfVo-RBeMahRAQqhqNnuWERFFeOyedYgQnbEmlRgkqnNZS00DtSwRcz-EY2PzeeYOLk2aEIWJ2WwOIEBNIeBG8-mCm47Un7Q6U96fFptenWRJDIZy+y9AFBJkIEbRbxeWUBRwIyx2U2ba7Eu5WyDimkOhAI2yxSx+foMpWWTV2RLJgIrPoA4bh4RE24SOP2ukdHXOgJq8zuvoozWWBys9nzSydNt2NQYzFAA */ + createMachine( + { + initial: "loading", + schema: {} as { + context: { + organizationId: string + templateName: string + templateSettings?: Template + getTemplateError?: unknown + saveTemplateSettingsError?: unknown + } + services: { + getTemplateSettings: { + data: Template + } + saveTemplateSettings: { + data: Template + } + } + events: { type: "SAVE"; templateSettings: UpdateTemplateMeta } + }, + tsTypes: {} as import("./templateSettingsXService.typegen").Typegen0, + states: { + loading: { + invoke: { + src: "getTemplateSettings", + onDone: [ + { + actions: "assignTemplateSettings", + target: "editing", + }, + ], + onError: { + target: "error", + actions: "assignGetTemplateError", + }, + }, + }, + editing: { + on: { + SAVE: { + target: "saving", + }, + }, + }, + saving: { + invoke: { + src: "saveTemplateSettings", + onDone: [ + { + target: "saved", + }, + ], + onError: [{ target: "editing", actions: ["assignSaveTemplateSettingsError"] }], + }, + tags: ["submitting"], + }, + saved: { + entry: "onSave", + type: "final", + tags: ["submitting"], + }, + error: { + type: "final", + }, + }, + id: "templateSettings", + }, + { + services: { + getTemplateSettings: async ({ organizationId, templateName }) => { + return getTemplateByName(organizationId, templateName) + }, + saveTemplateSettings: async ( + { templateSettings }, + { templateSettings: newTemplateSettings }, + ) => { + if (!templateSettings) { + throw new Error("templateSettings is not loaded yet.") + } + + return updateTemplateMeta(templateSettings.id, newTemplateSettings) + }, + }, + actions: { + assignTemplateSettings: assign({ + templateSettings: (_, { data }) => data, + }), + assignGetTemplateError: assign({ + getTemplateError: (_, { data }) => data, + }), + assignSaveTemplateSettingsError: assign({ + saveTemplateSettingsError: (_, { data }) => data, + }), + }, + }, + )