feat: Add template settings page (#3557)

This commit is contained in:
Bruno Quaresma
2022-08-18 16:58:01 -03:00
committed by GitHub
parent aabb72783c
commit 7599ad4bf6
11 changed files with 460 additions and 8 deletions

View File

@ -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 = () => {
</RequireAuth>
}
/>
<Route
path="settings"
element={
<RequireAuth>
<TemplateSettingsPage />
</RequireAuth>
}
/>
</Route>
</Route>

View File

@ -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,

View File

@ -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,6 +53,16 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({
<Margins>
<PageHeader
actions={
<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}
@ -58,6 +70,7 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({
>
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
</Stack>
}
>
<PageHeaderTitle>{template.name}</PageHeaderTitle>

View File

@ -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>
)
}

View File

@ -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))
})
})

View File

@ -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 })
}}
/>
</>
)
}

View File

@ -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"),
}

View File

@ -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<typeof TemplateSettingsForm>["initialTouched"]
}
export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
template,
onCancel,
onSubmit,
isSubmitting,
errors = {},
initialTouched,
}) => {
const isLoading = !template && !errors.getTemplateError
return (
<FullPageForm title={Language.title} onCancel={onCancel}>
{errors.getTemplateError && <ErrorSummary error={errors.getTemplateError} />}
{isLoading && <Loader />}
{template && (
<TemplateSettingsForm
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}
onSubmit={onSubmit}
onCancel={onCancel}
error={errors.saveTemplateSettingsError}
/>
)}
</FullPageForm>
)
}

View File

@ -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,

View File

@ -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))
}),

View File

@ -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,
}),
},
},
)