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 = ({
- }>{Language.createButton}
-
+
+
+ }>
+ {Language.settingsButton}
+
+
+
+ }>{Language.createButton}
+
+
}
>
{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,
+ }),
+ },
+ },
+ )