mirror of
https://github.com/coder/coder.git
synced 2025-07-21 01:28:49 +00:00
feat: Add template settings page (#3557)
This commit is contained in:
@ -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>
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
94
site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx
Normal file
94
site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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
site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx
Normal file
50
site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx
Normal 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 })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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"),
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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,
|
||||
|
@ -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))
|
||||
}),
|
||||
|
104
site/src/xServices/templateSettings/templateSettingsXService.ts
Normal file
104
site/src/xServices/templateSettings/templateSettingsXService.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
Reference in New Issue
Block a user