Skip to content

Commit 7599ad4

Browse files
feat: Add template settings page (#3557)
1 parent aabb727 commit 7599ad4

File tree

11 files changed

+460
-8
lines changed

11 files changed

+460
-8
lines changed

site/src/AppRouter.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useSelector } from "@xstate/react"
22
import { SetupPage } from "pages/SetupPage/SetupPage"
3+
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage"
34
import { FC, lazy, Suspense, useContext } from "react"
45
import { Navigate, Route, Routes } from "react-router-dom"
56
import { selectPermissions } from "xServices/auth/authSelectors"
@@ -97,6 +98,14 @@ export const AppRouter: FC = () => {
9798
</RequireAuth>
9899
}
99100
/>
101+
<Route
102+
path="settings"
103+
element={
104+
<RequireAuth>
105+
<TemplateSettingsPage />
106+
</RequireAuth>
107+
}
108+
/>
100109
</Route>
101110
</Route>
102111

site/src/api/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ export const getTemplateVersions = async (
145145
return response.data
146146
}
147147

148+
export const updateTemplateMeta = async (
149+
templateId: string,
150+
data: TypesGen.UpdateTemplateMeta,
151+
): Promise<TypesGen.Template> => {
152+
const response = await axios.patch<TypesGen.Template>(`/api/v2/templates/${templateId}`, data)
153+
return response.data
154+
}
155+
148156
export const getWorkspace = async (
149157
workspaceId: string,
150158
params?: TypesGen.WorkspaceOptions,

site/src/pages/TemplatePage/TemplatePageView.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Button from "@material-ui/core/Button"
22
import Link from "@material-ui/core/Link"
33
import { makeStyles } from "@material-ui/core/styles"
44
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
5+
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
56
import frontMatter from "front-matter"
67
import { FC } from "react"
78
import ReactMarkdown from "react-markdown"
@@ -20,6 +21,7 @@ import { VersionsTable } from "../../components/VersionsTable/VersionsTable"
2021
import { WorkspaceSection } from "../../components/WorkspaceSection/WorkspaceSection"
2122

2223
const Language = {
24+
settingsButton: "Settings",
2325
createButton: "Create workspace",
2426
noDescription: "",
2527
readmeTitle: "README",
@@ -51,13 +53,24 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({
5153
<Margins>
5254
<PageHeader
5355
actions={
54-
<Link
55-
underline="none"
56-
component={RouterLink}
57-
to={`/templates/${template.name}/workspace`}
58-
>
59-
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
60-
</Link>
56+
<Stack direction="row" spacing={1}>
57+
<Link
58+
underline="none"
59+
component={RouterLink}
60+
to={`/templates/${template.name}/settings`}
61+
>
62+
<Button variant="outlined" startIcon={<SettingsOutlined />}>
63+
{Language.settingsButton}
64+
</Button>
65+
</Link>
66+
<Link
67+
underline="none"
68+
component={RouterLink}
69+
to={`/templates/${template.name}/workspace`}
70+
>
71+
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
72+
</Link>
73+
</Stack>
6174
}
6275
>
6376
<PageHeaderTitle>{template.name}</PageHeaderTitle>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import TextField from "@material-ui/core/TextField"
2+
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
3+
import { FormFooter } from "components/FormFooter/FormFooter"
4+
import { Stack } from "components/Stack/Stack"
5+
import { FormikContextType, FormikTouched, useFormik } from "formik"
6+
import { FC } from "react"
7+
import { getFormHelpersWithError, nameValidator, onChangeTrimmed } from "util/formUtils"
8+
import * as Yup from "yup"
9+
10+
export const Language = {
11+
nameLabel: "Name",
12+
descriptionLabel: "Description",
13+
maxTtlLabel: "Max TTL",
14+
// This is the same from the CLI on https://github.com/coder/coder/blob/546157b63ef9204658acf58cb653aa9936b70c49/cli/templateedit.go#L59
15+
maxTtlHelperText: "Edit the template maximum time before shutdown in milliseconds",
16+
formAriaLabel: "Template settings form",
17+
}
18+
19+
export const validationSchema = Yup.object({
20+
name: nameValidator(Language.nameLabel),
21+
description: Yup.string(),
22+
max_ttl_ms: Yup.number(),
23+
})
24+
25+
export interface TemplateSettingsForm {
26+
template: Template
27+
onSubmit: (data: UpdateTemplateMeta) => void
28+
onCancel: () => void
29+
isSubmitting: boolean
30+
error?: unknown
31+
// Helpful to show field errors on Storybook
32+
initialTouched?: FormikTouched<UpdateTemplateMeta>
33+
}
34+
35+
export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
36+
template,
37+
onSubmit,
38+
onCancel,
39+
error,
40+
isSubmitting,
41+
initialTouched,
42+
}) => {
43+
const form: FormikContextType<UpdateTemplateMeta> = useFormik<UpdateTemplateMeta>({
44+
initialValues: {
45+
name: template.name,
46+
description: template.description,
47+
max_ttl_ms: template.max_ttl_ms,
48+
},
49+
validationSchema,
50+
onSubmit: (data) => {
51+
onSubmit(data)
52+
},
53+
initialTouched,
54+
})
55+
const getFieldHelpers = getFormHelpersWithError<UpdateTemplateMeta>(form, error)
56+
57+
return (
58+
<form onSubmit={form.handleSubmit} aria-label={Language.formAriaLabel}>
59+
<Stack>
60+
<TextField
61+
{...getFieldHelpers("name")}
62+
disabled={isSubmitting}
63+
onChange={onChangeTrimmed(form)}
64+
autoFocus
65+
fullWidth
66+
label={Language.nameLabel}
67+
variant="outlined"
68+
/>
69+
70+
<TextField
71+
{...getFieldHelpers("description")}
72+
multiline
73+
disabled={isSubmitting}
74+
fullWidth
75+
label={Language.descriptionLabel}
76+
variant="outlined"
77+
rows={2}
78+
/>
79+
80+
<TextField
81+
{...getFieldHelpers("max_ttl_ms")}
82+
helperText={Language.maxTtlHelperText}
83+
disabled={isSubmitting}
84+
fullWidth
85+
inputProps={{ min: 0, step: 1 }}
86+
label={Language.maxTtlLabel}
87+
variant="outlined"
88+
/>
89+
</Stack>
90+
91+
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
92+
</form>
93+
)
94+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { screen, waitFor } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import * as API from "api/api"
4+
import { UpdateTemplateMeta } from "api/typesGenerated"
5+
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
6+
import { MockTemplate } from "../../testHelpers/entities"
7+
import { renderWithAuth } from "../../testHelpers/renderHelpers"
8+
import { Language as FormLanguage } from "./TemplateSettingsForm"
9+
import { TemplateSettingsPage } from "./TemplateSettingsPage"
10+
import { Language as ViewLanguage } from "./TemplateSettingsPageView"
11+
12+
const renderTemplateSettingsPage = async () => {
13+
const renderResult = renderWithAuth(<TemplateSettingsPage />, {
14+
route: `/templates/${MockTemplate.name}/settings`,
15+
path: `/templates/:templateId/settings`,
16+
})
17+
// Wait the form to be rendered
18+
await screen.findAllByLabelText(FormLanguage.nameLabel)
19+
return renderResult
20+
}
21+
22+
const fillAndSubmitForm = async ({
23+
name,
24+
description,
25+
max_ttl_ms,
26+
}: Omit<Required<UpdateTemplateMeta>, "min_autostart_interval_ms">) => {
27+
const nameField = await screen.findByLabelText(FormLanguage.nameLabel)
28+
await userEvent.clear(nameField)
29+
await userEvent.type(nameField, name)
30+
31+
const descriptionField = await screen.findByLabelText(FormLanguage.descriptionLabel)
32+
await userEvent.clear(descriptionField)
33+
await userEvent.type(descriptionField, description)
34+
35+
const maxTtlField = await screen.findByLabelText(FormLanguage.maxTtlLabel)
36+
await userEvent.clear(maxTtlField)
37+
await userEvent.type(maxTtlField, max_ttl_ms.toString())
38+
39+
const submitButton = await screen.findByText(FooterFormLanguage.defaultSubmitLabel)
40+
await userEvent.click(submitButton)
41+
}
42+
43+
describe("TemplateSettingsPage", () => {
44+
it("renders", async () => {
45+
await renderTemplateSettingsPage()
46+
const element = await screen.findByText(ViewLanguage.title)
47+
expect(element).toBeDefined()
48+
})
49+
50+
it("succeeds", async () => {
51+
await renderTemplateSettingsPage()
52+
53+
const newTemplateSettings = {
54+
name: "edited-template-name",
55+
description: "Edited description",
56+
max_ttl_ms: 4000,
57+
}
58+
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
59+
...MockTemplate,
60+
...newTemplateSettings,
61+
})
62+
await fillAndSubmitForm(newTemplateSettings)
63+
64+
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
65+
})
66+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useMachine } from "@xstate/react"
2+
import { useOrganizationId } from "hooks/useOrganizationId"
3+
import { FC } from "react"
4+
import { Helmet } from "react-helmet"
5+
import { useNavigate, useParams } from "react-router-dom"
6+
import { pageTitle } from "util/page"
7+
import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService"
8+
import { TemplateSettingsPageView } from "./TemplateSettingsPageView"
9+
10+
const Language = {
11+
title: "Template Settings",
12+
}
13+
14+
export const TemplateSettingsPage: FC = () => {
15+
const { template: templateName } = useParams() as { template: string }
16+
const navigate = useNavigate()
17+
const organizationId = useOrganizationId()
18+
const [state, send] = useMachine(templateSettingsMachine, {
19+
context: { templateName, organizationId },
20+
actions: {
21+
onSave: (_, { data }) => {
22+
// Use the data.name because the template name can be changed
23+
navigate(`/templates/${data.name}`)
24+
},
25+
},
26+
})
27+
const { templateSettings: template, saveTemplateSettingsError, getTemplateError } = state.context
28+
29+
return (
30+
<>
31+
<Helmet>
32+
<title>{pageTitle(Language.title)}</title>
33+
</Helmet>
34+
<TemplateSettingsPageView
35+
isSubmitting={state.hasTag("submitting")}
36+
template={template}
37+
errors={{
38+
getTemplateError,
39+
saveTemplateSettingsError,
40+
}}
41+
onCancel={() => {
42+
navigate(`/templates/${templateName}`)
43+
}}
44+
onSubmit={(templateSettings) => {
45+
send({ type: "SAVE", templateSettings })
46+
}}
47+
/>
48+
</>
49+
)
50+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { action } from "@storybook/addon-actions"
2+
import { Story } from "@storybook/react"
3+
import * as Mocks from "../../testHelpers/renderHelpers"
4+
import { makeMockApiError } from "../../testHelpers/renderHelpers"
5+
import { TemplateSettingsPageView, TemplateSettingsPageViewProps } from "./TemplateSettingsPageView"
6+
7+
export default {
8+
title: "pages/TemplateSettingsPageView",
9+
component: TemplateSettingsPageView,
10+
}
11+
12+
const Template: Story<TemplateSettingsPageViewProps> = (args) => (
13+
<TemplateSettingsPageView {...args} />
14+
)
15+
16+
export const Example = Template.bind({})
17+
Example.args = {
18+
template: Mocks.MockTemplate,
19+
onSubmit: action("onSubmit"),
20+
onCancel: action("cancel"),
21+
}
22+
23+
export const GetTemplateError = Template.bind({})
24+
GetTemplateError.args = {
25+
template: undefined,
26+
errors: {
27+
getTemplateError: makeMockApiError({
28+
message: "Failed to fetch the template.",
29+
detail: "You do not have permission to access this resource.",
30+
}),
31+
},
32+
onSubmit: action("onSubmit"),
33+
onCancel: action("cancel"),
34+
}
35+
36+
export const SaveTemplateSettingsError = Template.bind({})
37+
SaveTemplateSettingsError.args = {
38+
template: Mocks.MockTemplate,
39+
errors: {
40+
saveTemplateSettingsError: makeMockApiError({
41+
message: 'Template "test" already exists.',
42+
validations: [
43+
{
44+
field: "name",
45+
detail: "This value is already in use and should be unique.",
46+
},
47+
],
48+
}),
49+
},
50+
initialTouched: {
51+
name: true,
52+
},
53+
onSubmit: action("onSubmit"),
54+
onCancel: action("cancel"),
55+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy