Skip to content

feat: Add template settings page #3557

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -97,6 +98,14 @@ export const AppRouter: FC = () => {
</RequireAuth>
}
/>
<Route
path="settings"
element={
<RequireAuth>
<TemplateSettingsPage />
</RequireAuth>
}
/>
</Route>
</Route>

Expand Down
8 changes: 8 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ export const getTemplateVersions = async (
return response.data
}

export const updateTemplateMeta = async (
templateId: string,
data: TypesGen.UpdateTemplateMeta,
): Promise<TypesGen.Template> => {
const response = await axios.patch<TypesGen.Template>(`/api/v2/templates/${templateId}`, data)
return response.data
}

export const getWorkspace = async (
workspaceId: string,
params?: TypesGen.WorkspaceOptions,
Expand Down
27 changes: 20 additions & 7 deletions site/src/pages/TemplatePage/TemplatePageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -51,13 +53,24 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({
<Margins>
<PageHeader
actions={
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/workspace`}
>
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
<Stack direction="row" spacing={1}>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/settings`}
>
<Button variant="outlined" startIcon={<SettingsOutlined />}>
{Language.settingsButton}
</Button>
</Link>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/workspace`}
>
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
</Stack>
}
>
<PageHeaderTitle>{template.name}</PageHeaderTitle>
Expand Down
94 changes: 94 additions & 0 deletions site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -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<UpdateTemplateMeta>
}

export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
template,
onSubmit,
onCancel,
error,
isSubmitting,
initialTouched,
}) => {
const form: FormikContextType<UpdateTemplateMeta> = useFormik<UpdateTemplateMeta>({
initialValues: {
name: template.name,
description: template.description,
max_ttl_ms: template.max_ttl_ms,
},
validationSchema,
onSubmit: (data) => {
onSubmit(data)
},
initialTouched,
})
const getFieldHelpers = getFormHelpersWithError<UpdateTemplateMeta>(form, error)

return (
<form onSubmit={form.handleSubmit} aria-label={Language.formAriaLabel}>
<Stack>
<TextField
{...getFieldHelpers("name")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={Language.nameLabel}
variant="outlined"
/>

<TextField
{...getFieldHelpers("description")}
multiline
disabled={isSubmitting}
fullWidth
label={Language.descriptionLabel}
variant="outlined"
rows={2}
/>

<TextField
{...getFieldHelpers("max_ttl_ms")}
helperText={Language.maxTtlHelperText}
disabled={isSubmitting}
fullWidth
inputProps={{ min: 0, step: 1 }}
label={Language.maxTtlLabel}
variant="outlined"
/>
</Stack>

<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
</form>
)
}
66 changes: 66 additions & 0 deletions site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TemplateSettingsPage />, {
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<Required<UpdateTemplateMeta>, "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))
})
})
50 changes: 50 additions & 0 deletions site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Helmet>
<title>{pageTitle(Language.title)}</title>
</Helmet>
<TemplateSettingsPageView
isSubmitting={state.hasTag("submitting")}
template={template}
errors={{
getTemplateError,
saveTemplateSettingsError,
}}
onCancel={() => {
navigate(`/templates/${templateName}`)
}}
onSubmit={(templateSettings) => {
send({ type: "SAVE", templateSettings })
}}
/>
</>
)
}
Original file line number Diff line number Diff line change
@@ -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<TemplateSettingsPageViewProps> = (args) => (
<TemplateSettingsPageView {...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"),
}
Loading