Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7599ad4

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

11 files changed

+460
-8
lines changed

site/src/AppRouter.tsx

+9
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

+8
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

+20-7
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>
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+
}
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+
})
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+
}
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)