refactor(site): Group template permissions, settings and variables under a settings layout (#6737)

This commit is contained in:
Bruno Quaresma
2023-03-23 16:43:12 -03:00
committed by GitHub
parent cb7375450b
commit 88e24db643
39 changed files with 1044 additions and 771 deletions

View File

@ -5,5 +5,5 @@ test.use({ storageState: getStatePath("authState") })
test("list templates", async ({ page, baseURL }) => {
await page.goto(`${baseURL}/templates`, { waitUntil: "networkidle" })
await expect(page).toHaveTitle("Templates Coder")
await expect(page).toHaveTitle("Templates - Coder")
})

View File

@ -6,7 +6,7 @@ import AuditPage from "pages/AuditPage/AuditPage"
import GroupsPage from "pages/GroupsPage/GroupsPage"
import LoginPage from "pages/LoginPage/LoginPage"
import { SetupPage } from "pages/SetupPage/SetupPage"
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage"
import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage"
import TemplatesPage from "pages/TemplatesPage/TemplatesPage"
import UsersPage from "pages/UsersPage/UsersPage"
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
@ -16,6 +16,7 @@ import { DashboardLayout } from "./components/Dashboard/DashboardLayout"
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
// Lazy load pages
// - Pages that are secondary, not in the main navigation or not usually accessed
@ -50,7 +51,7 @@ const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
const TemplatePermissionsPage = lazy(
() =>
import(
"./pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage"
"./pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage"
),
)
const TemplateSummaryPage = lazy(
@ -120,7 +121,10 @@ const CreateTemplatePage = lazy(
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
)
const TemplateVariablesPage = lazy(
() => import("./pages/TemplateVariablesPage/TemplateVariablesPage"),
() =>
import(
"./pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage"
),
)
const WorkspaceSettingsPage = lazy(
() => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"),
@ -129,7 +133,13 @@ const CreateTokenPage = lazy(
() => import("./pages/CreateTokenPage/CreateTokenPage"),
)
const TemplateFilesPage = lazy(
() => import("./pages/TemplateFilesPage/TemplateFilesPage"),
() => import("./pages/TemplatePage/TemplateFilesPage/TemplateFilesPage"),
)
const TemplateSchedulePage = lazy(
() =>
import(
"./pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage"
),
)
export const AppRouter: FC = () => {
@ -160,16 +170,25 @@ export const AppRouter: FC = () => {
<Route path=":template">
<Route element={<TemplateLayout />}>
<Route index element={<TemplateSummaryPage />} />
<Route
path="permissions"
element={<TemplatePermissionsPage />}
/>
<Route path="files" element={<TemplateFilesPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsPage />} />
<Route path="variables" element={<TemplateVariablesPage />} />
<Route path="settings" element={<TemplateSettingsLayout />}>
<Route index element={<TemplateSettingsPage />} />
<Route
path="permissions"
element={<TemplatePermissionsPage />}
/>
<Route
path="variables"
element={<TemplateVariablesPage />}
/>
<Route path="schedule" element={<TemplateSchedulePage />} />
</Route>
<Route path="versions">
<Route path=":version">
<Route index element={<TemplateVersionPage />} />

View File

@ -1,19 +0,0 @@
import Button from "@material-ui/core/Button"
interface GoBackButtonProps {
onClick: () => void
}
export const Language = {
ariaLabel: "Go back",
}
export const GoBackButton: React.FC<
React.PropsWithChildren<GoBackButtonProps>
> = ({ onClick }) => {
return (
<Button onClick={onClick} size="small" aria-label={Language.ariaLabel}>
Go back
</Button>
)
}

View File

@ -5,7 +5,7 @@ const IconField = lazy(() => import("./IconField"))
export const LazyIconField: FC<IconFieldProps> = (props) => {
return (
<Suspense>
<Suspense fallback={<div role="progressbar" data-testid="loader" />}>
<IconField {...props} />
</Suspense>
)

View File

@ -130,6 +130,7 @@ const useStyles = makeStyles((theme) => ({
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
email: {
color: theme.palette.text.secondary,

View File

@ -42,13 +42,6 @@ const fetchTemplate = async (orgId: string, templateName: string) => {
}
}
const useTemplateData = (orgId: string, templateName: string) => {
return useQuery({
queryKey: ["template", templateName],
queryFn: () => fetchTemplate(orgId, templateName),
})
}
type TemplateLayoutContextValue = Awaited<ReturnType<typeof fetchTemplate>>
const TemplateLayoutContext = createContext<
@ -71,28 +64,31 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
const navigate = useNavigate()
const styles = useStyles()
const orgId = useOrganizationId()
const { template } = useParams() as { template: string }
const templateData = useTemplateData(orgId, template)
const { template: templateName } = useParams() as { template: string }
const { data, error, isLoading } = useQuery({
queryKey: ["template", templateName],
queryFn: () => fetchTemplate(orgId, templateName),
})
const dashboard = useDashboard()
if (templateData.error) {
if (error) {
return (
<div className={styles.error}>
<AlertBanner severity="error" error={templateData.error} />
<AlertBanner severity="error" error={error} />
</div>
)
}
if (templateData.isLoading || !templateData.data) {
if (isLoading || !data) {
return <Loader />
}
return (
<>
<TemplatePageHeader
template={templateData.data.template}
activeVersion={templateData.data.activeVersion}
permissions={templateData.data.permissions}
template={data.template}
activeVersion={data.activeVersion}
permissions={data.permissions}
canEditFiles={dashboard.experiments.includes("template_editor")}
onDeleteTemplate={() => {
navigate("/templates")
@ -104,7 +100,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
<Stack direction="row" spacing={0.25}>
<NavLink
end
to={`/templates/${template}`}
to={`/templates/${templateName}`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
@ -115,18 +111,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
Summary
</NavLink>
<NavLink
to={`/templates/${template}/permissions`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Permissions
</NavLink>
<NavLink
to={`/templates/${template}/files`}
to={`/templates/${templateName}/files`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
@ -141,7 +126,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
</div>
<Margins>
<TemplateLayoutContext.Provider value={templateData.data}>
<TemplateLayoutContext.Provider value={data}>
<Suspense fallback={<Loader />}>{children}</Suspense>
</TemplateLayoutContext.Provider>
</Margins>

View File

@ -67,13 +67,6 @@ const TemplateMenu: FC<{
>
{Language.settingsButton}
</MenuItem>
<MenuItem
component={RouterLink}
to={`/templates/${templateName}/variables`}
onClick={handleClose}
>
{Language.variablesButton}
</MenuItem>
{canEditFiles && (
<MenuItem
component={RouterLink}

View File

@ -1,5 +1,5 @@
{
"title": "Template settings",
"title": "General Settings",
"nameLabel": "Name",
"displayNameLabel": "Display name",
"descriptionLabel": "Description",

View File

@ -0,0 +1,149 @@
import { makeStyles } from "@material-ui/core/styles"
import ScheduleIcon from "@material-ui/icons/TimerOutlined"
import VariablesIcon from "@material-ui/icons/CodeOutlined"
import { Template } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack"
import { FC, ElementType, PropsWithChildren, ReactNode } from "react"
import { Link, NavLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import GeneralIcon from "@material-ui/icons/SettingsOutlined"
import SecurityIcon from "@material-ui/icons/LockOutlined"
import { Avatar } from "components/Avatar/Avatar"
const SidebarNavItem: FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
> = ({ children, href, icon }) => {
const styles = useStyles()
return (
<NavLink
end
to={href}
className={({ isActive }) =>
combineClasses([
styles.sidebarNavItem,
isActive ? styles.sidebarNavItemActive : undefined,
])
}
>
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
</NavLink>
)
}
const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
icon: Icon,
}) => {
const styles = useStyles()
return <Icon className={styles.sidebarNavItemIcon} />
}
export const Sidebar: React.FC<{ template: Template }> = ({ template }) => {
const styles = useStyles()
return (
<nav className={styles.sidebar}>
<Stack
direction="row"
alignItems="center"
className={styles.templateInfo}
>
<Avatar src={template.icon} variant="square" fitImage />
<Stack spacing={0} className={styles.templateData}>
<Link className={styles.name} to={`/templates/${template.name}`}>
{template.display_name !== ""
? template.display_name
: template.name}
</Link>
<span className={styles.secondary}>{template.name}</span>
</Stack>
</Stack>
<SidebarNavItem href="" icon={<SidebarNavItemIcon icon={GeneralIcon} />}>
General
</SidebarNavItem>
<SidebarNavItem
href="permissions"
icon={<SidebarNavItemIcon icon={SecurityIcon} />}
>
Permissions
</SidebarNavItem>
<SidebarNavItem
href="variables"
icon={<SidebarNavItemIcon icon={VariablesIcon} />}
>
Variables
</SidebarNavItem>
<SidebarNavItem
href="schedule"
icon={<SidebarNavItemIcon icon={ScheduleIcon} />}
>
Schedule
</SidebarNavItem>
</nav>
)
}
const useStyles = makeStyles((theme) => ({
sidebar: {
width: 245,
flexShrink: 0,
},
sidebarNavItem: {
color: "inherit",
display: "block",
fontSize: 14,
textDecoration: "none",
padding: theme.spacing(1.5, 1.5, 1.5, 2),
borderRadius: theme.shape.borderRadius / 2,
transition: "background-color 0.15s ease-in-out",
marginBottom: 1,
position: "relative",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
sidebarNavItemActive: {
backgroundColor: theme.palette.action.hover,
"&:before": {
content: '""',
display: "block",
width: 3,
height: "100%",
position: "absolute",
left: 0,
top: 0,
backgroundColor: theme.palette.secondary.dark,
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
},
sidebarNavItemIcon: {
width: theme.spacing(2),
height: theme.spacing(2),
},
templateInfo: {
marginBottom: theme.spacing(2),
},
templateData: {
overflow: "hidden",
},
name: {
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: theme.palette.text.primary,
textDecoration: "none",
},
secondary: {
color: theme.palette.text.secondary,
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
},
}))

View File

@ -11,7 +11,6 @@ import {
import * as Yup from "yup"
import i18next from "i18next"
import { useTranslation } from "react-i18next"
import { Maybe } from "components/Conditionals/Maybe"
import { LazyIconField } from "components/IconField/LazyIconField"
import {
FormFields,
@ -23,28 +22,8 @@ import { Stack } from "components/Stack/Stack"
import Checkbox from "@material-ui/core/Checkbox"
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
import { makeStyles } from "@material-ui/core/styles"
import Link from "@material-ui/core/Link"
const TTLHelperText = ({
ttl,
translationName,
}: {
ttl?: number
translationName: string
}) => {
const { t } = useTranslation("templateSettingsPage")
const count = typeof ttl !== "number" ? 0 : ttl
return (
// no helper text if ttl is negative - error will show once field is considered touched
<Maybe condition={count >= 0}>
<span>{t(translationName, { count })}</span>
</Maybe>
)
}
const MAX_DESCRIPTION_CHAR_LIMIT = 128
const MAX_TTL_DAYS = 7
const MS_HOUR_CONVERSION = 3600000
export const getValidationSchema = (): Yup.AnyObjectSchema =>
Yup.object({
@ -58,20 +37,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
MAX_DESCRIPTION_CHAR_LIMIT,
i18next.t("descriptionMaxError", { ns: "templateSettingsPage" }),
),
default_ttl_ms: Yup.number()
.integer()
.min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" }))
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
),
max_ttl_ms: Yup.number()
.integer()
.min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }))
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }),
),
allow_user_cancel_workspace_jobs: Yup.boolean(),
})
@ -81,7 +47,6 @@ export interface TemplateSettingsForm {
onCancel: () => void
isSubmitting: boolean
error?: unknown
canSetMaxTTL: boolean
// Helpful to show field errors on Storybook
initialTouched?: FormikTouched<UpdateTemplateMeta>
}
@ -91,11 +56,9 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
onSubmit,
onCancel,
error,
canSetMaxTTL,
isSubmitting,
initialTouched,
}) => {
const { t: commonT } = useTranslation("common")
const validationSchema = getValidationSchema()
const form: FormikContextType<UpdateTemplateMeta> =
useFormik<UpdateTemplateMeta>({
@ -103,28 +66,12 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
name: template.name,
display_name: template.display_name,
description: template.description,
// on display, convert from ms => hours
default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION,
// the API ignores this value, but to avoid tripping up validation set
// it to zero if the user can't set the field.
max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0,
icon: template.icon,
allow_user_cancel_workspace_jobs:
template.allow_user_cancel_workspace_jobs,
},
validationSchema,
onSubmit: (formData) => {
// on submit, convert from hours => ms
onSubmit({
...formData,
default_ttl_ms: formData.default_ttl_ms
? formData.default_ttl_ms * MS_HOUR_CONVERSION
: undefined,
max_ttl_ms: formData.max_ttl_ms
? formData.max_ttl_ms * MS_HOUR_CONVERSION
: undefined,
})
},
onSubmit,
initialTouched,
})
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
@ -188,55 +135,6 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
</FormFields>
</FormSection>
<FormSection
title={t("schedule.title")}
description={t("schedule.description")}
>
<Stack direction="row" className={styles.ttlFields}>
<TextField
{...getFieldHelpers(
"default_ttl_ms",
<TTLHelperText
translationName="defaultTTLHelperText"
ttl={form.values.default_ttl_ms}
/>,
)}
disabled={isSubmitting}
fullWidth
inputProps={{ min: 0, step: 1 }}
label={t("defaultTtlLabel")}
variant="outlined"
type="number"
/>
<TextField
{...getFieldHelpers(
"max_ttl_ms",
canSetMaxTTL ? (
<TTLHelperText
translationName="maxTTLHelperText"
ttl={form.values.max_ttl_ms}
/>
) : (
<>
{commonT("licenseFieldTextHelper")}{" "}
<Link href="https://coder.com/docs/v2/latest/enterprise">
{commonT("learnMore")}
</Link>
.
</>
),
)}
disabled={isSubmitting || !canSetMaxTTL}
fullWidth
inputProps={{ min: 0, step: 1 }}
label={t("maxTtlLabel")}
variant="outlined"
type="number"
/>
</Stack>
</FormSection>
<FormSection
title={t("operations.title")}
description={t("operations.description")}
@ -290,8 +188,4 @@ const useStyles = makeStyles((theme) => ({
fontSize: theme.spacing(1.5),
color: theme.palette.text.secondary,
},
ttlFields: {
width: "100%",
},
}))

View File

@ -3,8 +3,11 @@ 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 { MockTemplate } from "../../../testHelpers/entities"
import {
renderWithTemplateSettingsLayout,
waitForLoaderToBeRemoved,
} from "../../../testHelpers/renderHelpers"
import { getValidationSchema } from "./TemplateSettingsForm"
import { TemplateSettingsPage } from "./TemplateSettingsPage"
import i18next from "i18next"
@ -16,32 +19,24 @@ const validFormValues = {
display_name: "A display name",
description: "A description",
icon: "vscode.png",
// these are the form values which are actually hours
default_ttl_ms: 1,
max_ttl_ms: 2,
allow_user_cancel_workspace_jobs: false,
}
const renderTemplateSettingsPage = async () => {
renderWithAuth(<TemplateSettingsPage />, {
renderWithTemplateSettingsLayout(<TemplateSettingsPage />, {
route: `/templates/${MockTemplate.name}/settings`,
path: `/templates/:template/settings`,
extraRoutes: [{ path: "templates/:template", element: <></> }],
})
// Wait the form to be rendered
const label = t("nameLabel", { ns: "templateSettingsPage" })
await screen.findAllByLabelText(label)
await waitForLoaderToBeRemoved()
}
const fillAndSubmitForm = async ({
name,
display_name,
description,
default_ttl_ms,
max_ttl_ms,
icon,
allow_user_cancel_workspace_jobs,
}: Required<UpdateTemplateMeta>) => {
}: Required<Omit<UpdateTemplateMeta, "default_ttl_ms" | "max_ttl_ms">>) => {
const label = t("nameLabel", { ns: "templateSettingsPage" })
const nameField = await screen.findByLabelText(label)
await userEvent.clear(nameField)
@ -63,19 +58,6 @@ const fillAndSubmitForm = async ({
await userEvent.clear(iconField)
await userEvent.type(iconField, icon)
const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
const defaultTtlField = await screen.findByLabelText(defaultTtlLabel)
await userEvent.clear(defaultTtlField)
await userEvent.type(defaultTtlField, default_ttl_ms.toString())
const entitlements = await API.getEntitlements()
if (entitlements.features["advanced_template_scheduling"].enabled) {
const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" })
const maxTtlField = await screen.findByLabelText(maxTtlLabel)
await userEvent.clear(maxTtlField)
await userEvent.type(maxTtlField, max_ttl_ms.toString())
}
const allowCancelJobsField = screen.getByRole("checkbox")
// checkbox is checked by default, so it must be clicked to get unchecked
if (!allow_user_cancel_workspace_jobs) {
@ -89,81 +71,16 @@ const fillAndSubmitForm = async ({
}
describe("TemplateSettingsPage", () => {
it("renders", async () => {
const { t } = i18next
const pageTitle = t("title", {
ns: "templateSettingsPage",
})
await renderTemplateSettingsPage()
const element = await screen.findByText(pageTitle)
expect(element).toBeDefined()
})
it("succeeds", async () => {
await renderTemplateSettingsPage()
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
...MockTemplate,
...validFormValues,
})
await fillAndSubmitForm(validFormValues)
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
})
test("ttl is converted to and from hours", async () => {
await renderTemplateSettingsPage()
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
...MockTemplate,
...validFormValues,
})
await fillAndSubmitForm(validFormValues)
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
await waitFor(() =>
expect(API.updateTemplateMeta).toBeCalledWith(
"test-template",
expect.objectContaining({
...validFormValues,
// convert from the display value (hours) to ms
default_ttl_ms: validFormValues.default_ttl_ms * 3600000,
// this value is undefined if not entitled
max_ttl_ms: undefined,
}),
),
)
})
it("allows a ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 24 * 7,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("allows ttl of 0", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 0,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("disallows a ttl of 7 days + 1 hour", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 24 * 7 + 1,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(
t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
)
})
it("allows a description of 128 chars", () => {
const values: UpdateTemplateMeta = {
...validFormValues,

View File

@ -1,57 +1,55 @@
import { useMachine } from "@xstate/react"
import { useMutation } from "@tanstack/react-query"
import { updateTemplateMeta } from "api/api"
import { UpdateTemplateMeta } from "api/typesGenerated"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useOrganizationId } from "hooks/useOrganizationId"
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService"
import { useTemplateSettingsContext } from "../TemplateSettingsLayout"
import { TemplateSettingsPageView } from "./TemplateSettingsPageView"
export const TemplateSettingsPage: FC = () => {
const { template: templateName } = useParams() as { template: string }
const { t } = useTranslation("templateSettingsPage")
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. Since the
// API can return 304 if the template name is not changed, we use the
// templateName from the URL as default.
navigate(`/templates/${data.name ?? templateName}`)
},
},
})
const {
templateSettings: template,
saveTemplateSettingsError,
getTemplateError,
} = state.context
const { template } = useTemplateSettingsContext()
const { entitlements } = useDashboard()
const canSetMaxTTL =
entitlements.features["advanced_template_scheduling"].enabled
const {
mutate: updateTemplate,
isLoading: isSubmitting,
error: submitError,
} = useMutation(
(data: UpdateTemplateMeta) => updateTemplateMeta(template.id, data),
{
onSuccess: async () => {
displaySuccess("Template updated successfully")
},
},
)
return (
<>
<Helmet>
<title>{pageTitle(t("title"))}</title>
<title>{pageTitle([template.name, t("title")])}</title>
</Helmet>
<TemplateSettingsPageView
canSetMaxTTL={canSetMaxTTL}
isSubmitting={state.hasTag("submitting")}
isSubmitting={isSubmitting}
template={template}
errors={{
getTemplateError,
saveTemplateSettingsError,
}}
submitError={submitError}
onCancel={() => {
navigate(`/templates/${templateName}`)
}}
onSubmit={(templateSettings) => {
send({ type: "SAVE", templateSettings })
updateTemplate({
...template,
...templateSettings,
})
}}
/>
</>

View File

@ -0,0 +1,41 @@
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,
args: {
template: Mocks.MockTemplate,
onSubmit: action("onSubmit"),
onCancel: action("cancel"),
},
}
const Template: Story<TemplateSettingsPageViewProps> = (args) => (
<TemplateSettingsPageView {...args} />
)
export const Example = Template.bind({})
Example.args = {}
export const SaveTemplateSettingsError = Template.bind({})
SaveTemplateSettingsError.args = {
submitError: makeMockApiError({
message: 'Template "test" already exists.',
validations: [
{
field: "name",
detail: "This value is already in use and should be unique.",
},
],
}),
initialTouched: {
allow_user_cancel_workspace_jobs: true,
},
}

View File

@ -0,0 +1,50 @@
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { ComponentProps, FC } from "react"
import { TemplateSettingsForm } from "./TemplateSettingsForm"
import { useTranslation } from "react-i18next"
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
import { makeStyles } from "@material-ui/core/styles"
export interface TemplateSettingsPageViewProps {
template: Template
onSubmit: (data: UpdateTemplateMeta) => void
onCancel: () => void
isSubmitting: boolean
submitError?: unknown
initialTouched?: ComponentProps<typeof TemplateSettingsForm>["initialTouched"]
}
export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
template,
onCancel,
onSubmit,
isSubmitting,
submitError,
initialTouched,
}) => {
const { t } = useTranslation("templateSettingsPage")
const styles = useStyles()
return (
<>
<PageHeader className={styles.pageHeader}>
<PageHeaderTitle>{t("title")}</PageHeaderTitle>
</PageHeader>
<TemplateSettingsForm
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}
onSubmit={onSubmit}
onCancel={onCancel}
error={submitError}
/>
</>
)
}
const useStyles = makeStyles(() => ({
pageHeader: {
paddingTop: 0,
},
}))

View File

@ -5,20 +5,20 @@ import { useMachine } from "@xstate/react"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Paywall } from "components/Paywall/Paywall"
import { Stack } from "components/Stack/Stack"
import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { templateACLMachine } from "xServices/template/templateACLXService"
import { useTemplateSettingsContext } from "../TemplateSettingsLayout"
import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"
export const TemplatePermissionsPage: FC<
React.PropsWithChildren<unknown>
> = () => {
const organizationId = useOrganizationId()
const { template, permissions } = useTemplateLayoutContext()
const { template, permissions } = useTemplateSettingsContext()
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility()
const [state, send] = useMachine(templateACLMachine, {
context: { templateId: template.id },
@ -28,7 +28,7 @@ export const TemplatePermissionsPage: FC<
return (
<>
<Helmet>
<title>{pageTitle(`${template?.name} · Permissions`)}</title>
<title>{pageTitle([template.name, "Permissions"])}</title>
</Helmet>
<ChooseOne>
<Cond condition={!isTemplateRBACEnabled}>

View File

@ -0,0 +1,165 @@
import TextField from "@material-ui/core/TextField"
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { FC } from "react"
import { getFormHelpers } from "util/formUtils"
import * as Yup from "yup"
import i18next from "i18next"
import { useTranslation } from "react-i18next"
import { Maybe } from "components/Conditionals/Maybe"
import { FormSection, HorizontalForm, FormFooter } from "components/Form/Form"
import { Stack } from "components/Stack/Stack"
import { makeStyles } from "@material-ui/core/styles"
import Link from "@material-ui/core/Link"
const TTLHelperText = ({
ttl,
translationName,
}: {
ttl?: number
translationName: string
}) => {
const { t } = useTranslation("templateSettingsPage")
const count = typeof ttl !== "number" ? 0 : ttl
return (
// no helper text if ttl is negative - error will show once field is considered touched
<Maybe condition={count >= 0}>
<span>{t(translationName, { count })}</span>
</Maybe>
)
}
const MAX_TTL_DAYS = 7
const MS_HOUR_CONVERSION = 3600000
export const getValidationSchema = (): Yup.AnyObjectSchema =>
Yup.object({
default_ttl_ms: Yup.number()
.integer()
.min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" }))
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
),
max_ttl_ms: Yup.number()
.integer()
.min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }))
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }),
),
})
export interface TemplateScheduleForm {
template: Template
onSubmit: (data: UpdateTemplateMeta) => void
onCancel: () => void
isSubmitting: boolean
error?: unknown
canSetMaxTTL: boolean
// Helpful to show field errors on Storybook
initialTouched?: FormikTouched<UpdateTemplateMeta>
}
export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
template,
onSubmit,
onCancel,
error,
canSetMaxTTL,
isSubmitting,
initialTouched,
}) => {
const { t: commonT } = useTranslation("common")
const validationSchema = getValidationSchema()
const form: FormikContextType<UpdateTemplateMeta> =
useFormik<UpdateTemplateMeta>({
initialValues: {
// on display, convert from ms => hours
default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION,
// the API ignores this value, but to avoid tripping up validation set
// it to zero if the user can't set the field.
max_ttl_ms: canSetMaxTTL ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0,
},
validationSchema,
onSubmit: (formData) => {
// on submit, convert from hours => ms
onSubmit({
default_ttl_ms: formData.default_ttl_ms
? formData.default_ttl_ms * MS_HOUR_CONVERSION
: undefined,
max_ttl_ms: formData.max_ttl_ms
? formData.max_ttl_ms * MS_HOUR_CONVERSION
: undefined,
})
},
initialTouched,
})
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
const { t } = useTranslation("templateSettingsPage")
const styles = useStyles()
return (
<HorizontalForm
onSubmit={form.handleSubmit}
aria-label={t("formAriaLabel")}
>
<FormSection
title={t("schedule.title")}
description={t("schedule.description")}
>
<Stack direction="row" className={styles.ttlFields}>
<TextField
{...getFieldHelpers(
"default_ttl_ms",
<TTLHelperText
translationName="defaultTTLHelperText"
ttl={form.values.default_ttl_ms}
/>,
)}
disabled={isSubmitting}
fullWidth
inputProps={{ min: 0, step: 1 }}
label={t("defaultTtlLabel")}
variant="outlined"
type="number"
/>
<TextField
{...getFieldHelpers(
"max_ttl_ms",
canSetMaxTTL ? (
<TTLHelperText
translationName="maxTTLHelperText"
ttl={form.values.max_ttl_ms}
/>
) : (
<>
{commonT("licenseFieldTextHelper")}{" "}
<Link href="https://coder.com/docs/v2/latest/enterprise">
{commonT("learnMore")}
</Link>
.
</>
),
)}
disabled={isSubmitting || !canSetMaxTTL}
fullWidth
inputProps={{ min: 0, step: 1 }}
label={t("maxTtlLabel")}
variant="outlined"
type="number"
/>
</Stack>
</FormSection>
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
</HorizontalForm>
)
}
const useStyles = makeStyles(() => ({
ttlFields: {
width: "100%",
},
}))

View File

@ -0,0 +1,123 @@
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 {
MockEntitlementsWithScheduling,
MockTemplate,
} from "../../../testHelpers/entities"
import {
renderWithTemplateSettingsLayout,
waitForLoaderToBeRemoved,
} from "../../../testHelpers/renderHelpers"
import { getValidationSchema } from "./TemplateScheduleForm"
import TemplateSchedulePage from "./TemplateSchedulePage"
import i18next from "i18next"
const { t } = i18next
const validFormValues = {
default_ttl_ms: 1,
max_ttl_ms: 2,
}
const renderTemplateSchedulePage = async () => {
renderWithTemplateSettingsLayout(<TemplateSchedulePage />, {
route: `/templates/${MockTemplate.name}/settings/schedule`,
path: `/templates/:template/settings/schedule`,
})
await waitForLoaderToBeRemoved()
}
const fillAndSubmitForm = async ({
default_ttl_ms,
max_ttl_ms,
}: {
default_ttl_ms: number
max_ttl_ms: number
}) => {
const user = userEvent.setup()
const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
const defaultTtlField = await screen.findByLabelText(defaultTtlLabel)
await user.clear(defaultTtlField)
await user.type(defaultTtlField, default_ttl_ms.toString())
const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" })
const maxTtlField = await screen.findByLabelText(maxTtlLabel)
await user.clear(maxTtlField)
await user.type(maxTtlField, max_ttl_ms.toString())
const submitButton = await screen.findByText(
FooterFormLanguage.defaultSubmitLabel,
)
await user.click(submitButton)
}
describe("TemplateSchedulePage", () => {
beforeEach(() => {
jest
.spyOn(API, "getEntitlements")
.mockResolvedValue(MockEntitlementsWithScheduling)
})
it("succeeds", async () => {
await renderTemplateSchedulePage()
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
...MockTemplate,
...validFormValues,
})
await fillAndSubmitForm(validFormValues)
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
})
test("ttl is converted to and from hours", async () => {
await renderTemplateSchedulePage()
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
...MockTemplate,
...validFormValues,
})
await fillAndSubmitForm(validFormValues)
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
await waitFor(() =>
expect(API.updateTemplateMeta).toBeCalledWith(
"test-template",
expect.objectContaining({
default_ttl_ms: validFormValues.default_ttl_ms * 3600000,
max_ttl_ms: validFormValues.max_ttl_ms * 3600000,
}),
),
)
})
it("allows a ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 24 * 7,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("allows ttl of 0", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 0,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("disallows a ttl of 7 days + 1 hour", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 24 * 7 + 1,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(
t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
)
})
})

View File

@ -0,0 +1,57 @@
import { useMutation } from "@tanstack/react-query"
import { updateTemplateMeta } from "api/api"
import { UpdateTemplateMeta } from "api/typesGenerated"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { useTemplateSettingsContext } from "../TemplateSettingsLayout"
import { TemplateSchedulePageView } from "./TemplateSchedulePageView"
const TemplateSchedulePage: FC = () => {
const { template: templateName } = useParams() as { template: string }
const navigate = useNavigate()
const { template } = useTemplateSettingsContext()
const { entitlements } = useDashboard()
const canSetMaxTTL =
entitlements.features["advanced_template_scheduling"].enabled
const {
mutate: updateTemplate,
isLoading: isSubmitting,
error: submitError,
} = useMutation(
(data: UpdateTemplateMeta) => updateTemplateMeta(template.id, data),
{
onSuccess: () => {
displaySuccess("Template updated successfully")
},
},
)
return (
<>
<Helmet>
<title>{pageTitle([template.name, "Schedule"])}</title>
</Helmet>
<TemplateSchedulePageView
canSetMaxTTL={canSetMaxTTL}
isSubmitting={isSubmitting}
template={template}
submitError={submitError}
onCancel={() => {
navigate(`/templates/${templateName}`)
}}
onSubmit={(templateScheduleSettings) => {
updateTemplate({
...template,
...templateScheduleSettings,
})
}}
/>
</>
)
}
export default TemplateSchedulePage

View File

@ -0,0 +1,30 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import * as Mocks from "../../../testHelpers/renderHelpers"
import {
TemplateSchedulePageView,
TemplateSchedulePageViewProps,
} from "./TemplateSchedulePageView"
export default {
title: "pages/TemplateSchedulePageView",
component: TemplateSchedulePageView,
args: {
canSetMaxTTL: true,
template: Mocks.MockTemplate,
onSubmit: action("onSubmit"),
onCancel: action("cancel"),
},
}
const Template: Story<TemplateSchedulePageViewProps> = (args) => (
<TemplateSchedulePageView {...args} />
)
export const Example = Template.bind({})
Example.args = {}
export const CantSetMaxTTL = Template.bind({})
CantSetMaxTTL.args = {
canSetMaxTTL: false,
}

View File

@ -0,0 +1,51 @@
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { ComponentProps, FC } from "react"
import { TemplateScheduleForm } from "./TemplateScheduleForm"
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
import { makeStyles } from "@material-ui/core/styles"
export interface TemplateSchedulePageViewProps {
template: Template
onSubmit: (data: UpdateTemplateMeta) => void
onCancel: () => void
isSubmitting: boolean
submitError?: unknown
initialTouched?: ComponentProps<typeof TemplateScheduleForm>["initialTouched"]
canSetMaxTTL: boolean
}
export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
template,
onCancel,
onSubmit,
isSubmitting,
canSetMaxTTL,
submitError,
initialTouched,
}) => {
const styles = useStyles()
return (
<>
<PageHeader className={styles.pageHeader}>
<PageHeaderTitle>Template schedule</PageHeaderTitle>
</PageHeader>
<TemplateScheduleForm
canSetMaxTTL={canSetMaxTTL}
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}
onSubmit={onSubmit}
onCancel={onCancel}
error={submitError}
/>
</>
)
}
const useStyles = makeStyles(() => ({
pageHeader: {
paddingTop: 0,
},
}))

View File

@ -0,0 +1,99 @@
import { makeStyles } from "@material-ui/core/styles"
import { Sidebar } from "./Sidebar"
import { Stack } from "components/Stack/Stack"
import { createContext, FC, Suspense, useContext } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "../../util/page"
import { Loader } from "components/Loader/Loader"
import { Outlet, useParams } from "react-router-dom"
import { Margins } from "components/Margins/Margins"
import { checkAuthorization, getTemplateByName } from "api/api"
import { useQuery } from "@tanstack/react-query"
import { useOrganizationId } from "hooks/useOrganizationId"
const templatePermissions = (templateId: string) => ({
canUpdateTemplate: {
object: {
resource_type: "template",
resource_id: templateId,
},
action: "update",
},
})
const fetchTemplateSettings = async (orgId: string, name: string) => {
const template = await getTemplateByName(orgId, name)
const permissions = await checkAuthorization({
checks: templatePermissions(template.id),
})
return {
template,
permissions,
}
}
const useTemplate = (orgId: string, name: string) => {
return useQuery({
queryKey: ["template", name, "settings"],
queryFn: () => fetchTemplateSettings(orgId, name),
})
}
const TemplateSettingsContext = createContext<
Awaited<ReturnType<typeof fetchTemplateSettings>> | undefined
>(undefined)
export const useTemplateSettingsContext = () => {
const context = useContext(TemplateSettingsContext)
if (!context) {
throw new Error(
"useTemplateSettingsContext must be used within a TemplateSettingsContext.Provider",
)
}
return context
}
export const TemplateSettingsLayout: FC = () => {
const styles = useStyles()
const orgId = useOrganizationId()
const { template: templateName } = useParams() as { template: string }
const { data: settings } = useTemplate(orgId, templateName)
return (
<>
<Helmet>
<title>{pageTitle([templateName, "Settings"])}</title>
</Helmet>
{settings ? (
<TemplateSettingsContext.Provider value={settings}>
<Margins>
<Stack className={styles.wrapper} direction="row" spacing={10}>
<Sidebar template={settings.template} />
<Suspense fallback={<Loader />}>
<main className={styles.content}>
<Outlet />
</main>
</Suspense>
</Stack>
</Margins>
</TemplateSettingsContext.Provider>
) : (
<Loader />
)}
</>
)
}
const useStyles = makeStyles((theme) => ({
wrapper: {
padding: theme.spacing(6, 0),
},
content: {
width: "100%",
},
}))

View File

@ -1,60 +0,0 @@
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,
args: {
canSetMaxTTL: true,
template: Mocks.MockTemplate,
onSubmit: action("onSubmit"),
onCancel: action("cancel"),
},
}
const Template: Story<TemplateSettingsPageViewProps> = (args) => (
<TemplateSettingsPageView {...args} />
)
export const Example = Template.bind({})
Example.args = {}
export const CantSetMaxTTL = Template.bind({})
CantSetMaxTTL.args = {
canSetMaxTTL: false,
}
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.",
}),
},
}
export const SaveTemplateSettingsError = Template.bind({})
SaveTemplateSettingsError.args = {
errors: {
saveTemplateSettingsError: makeMockApiError({
message: 'Template "test" already exists.',
validations: [
{
field: "name",
detail: "This value is already in use and should be unique.",
},
],
}),
},
initialTouched: {
allow_user_cancel_workspace_jobs: true,
},
}

View File

@ -1,66 +0,0 @@
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Loader } from "components/Loader/Loader"
import { ComponentProps, FC } from "react"
import { TemplateSettingsForm } from "./TemplateSettingsForm"
import { Stack } from "components/Stack/Stack"
import { makeStyles } from "@material-ui/core/styles"
import { useTranslation } from "react-i18next"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
export interface TemplateSettingsPageViewProps {
template?: Template
onSubmit: (data: UpdateTemplateMeta) => void
onCancel: () => void
isSubmitting: boolean
errors?: {
getTemplateError?: unknown
saveTemplateSettingsError?: unknown
}
initialTouched?: ComponentProps<typeof TemplateSettingsForm>["initialTouched"]
canSetMaxTTL: boolean
}
export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
template,
onCancel,
onSubmit,
isSubmitting,
canSetMaxTTL,
errors = {},
initialTouched,
}) => {
const classes = useStyles()
const isLoading = !template && !errors.getTemplateError
const { t } = useTranslation("templateSettingsPage")
return (
<FullPageHorizontalForm title={t("title")} onCancel={onCancel}>
{Boolean(errors.getTemplateError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.getTemplateError} />
</Stack>
)}
{isLoading && <Loader />}
{template && (
<>
<TemplateSettingsForm
canSetMaxTTL={canSetMaxTTL}
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}
onSubmit={onSubmit}
onCancel={onCancel}
error={errors.saveTemplateSettingsError}
/>
</>
)}
</FullPageHorizontalForm>
)
}
const useStyles = makeStyles((theme) => ({
errorContainer: {
marginBottom: theme.spacing(2),
},
}))

View File

@ -1,4 +1,4 @@
import { screen, waitFor } from "@testing-library/react"
import { screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import {
MockTemplate,
@ -6,16 +6,14 @@ import {
MockTemplateVersion,
MockTemplateVersionVariable1,
MockTemplateVersionVariable2,
renderWithAuth,
MockTemplateVersionVariable5,
renderWithTemplateSettingsLayout,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import * as API from "api/api"
import i18next from "i18next"
import TemplateVariablesPage from "./TemplateVariablesPage"
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
import * as router from "react-router"
const navigate = jest.fn()
const { t } = i18next
@ -30,12 +28,13 @@ const validationRequiredField = t("validationRequiredVariable", {
ns: "templateVariablesPage",
})
const renderTemplateVariablesPage = () => {
return renderWithAuth(<TemplateVariablesPage />, {
const renderTemplateVariablesPage = async () => {
renderWithTemplateSettingsLayout(<TemplateVariablesPage />, {
route: `/templates/${MockTemplate.name}/variables`,
path: `/templates/:template/variables`,
extraRoutes: [{ path: `/templates/${MockTemplate.name}`, element: <></> }],
})
await waitForLoaderToBeRemoved()
}
describe("TemplateVariablesPage", () => {
@ -51,7 +50,7 @@ describe("TemplateVariablesPage", () => {
MockTemplateVersionVariable2,
])
renderTemplateVariablesPage()
await renderTemplateVariablesPage()
const element = await screen.findByText(pageTitleText)
expect(element).toBeDefined()
@ -84,9 +83,8 @@ describe("TemplateVariablesPage", () => {
jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
message: "done",
})
jest.spyOn(router, "useNavigate").mockImplementation(() => navigate)
renderTemplateVariablesPage()
await renderTemplateVariablesPage()
const element = await screen.findByText(pageTitleText)
expect(element).toBeDefined()
@ -120,10 +118,8 @@ describe("TemplateVariablesPage", () => {
)
await userEvent.click(submitButton)
// Wait for redirect
await waitFor(() =>
expect(navigate).toHaveBeenCalledWith(`/templates/${MockTemplate.name}`),
)
// Wait for the success message
await screen.findByText("Template updated successfully")
})
it("user forgets to fill the required field", async () => {
@ -143,9 +139,8 @@ describe("TemplateVariablesPage", () => {
jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
message: "done",
})
jest.spyOn(router, "useNavigate").mockImplementation(() => navigate)
renderTemplateVariablesPage()
await renderTemplateVariablesPage()
const element = await screen.findByText(pageTitleText)
expect(element).toBeDefined()
@ -170,20 +165,4 @@ describe("TemplateVariablesPage", () => {
const validationError = await screen.findByText(validationRequiredField)
expect(validationError).toBeDefined()
})
it("no managed variables", async () => {
jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate)
jest
.spyOn(API, "getTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion)
jest.spyOn(API, "getTemplateVersionVariables").mockResolvedValueOnce([])
renderTemplateVariablesPage()
const element = await screen.findByText(pageTitleText)
expect(element).toBeDefined()
const goBackButton = await screen.findByText("Go back")
expect(goBackButton).toBeDefined()
})
})

View File

@ -4,13 +4,15 @@ import {
TemplateVersionVariable,
VariableValue,
} from "api/typesGenerated"
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom"
import { templateVariablesMachine } from "xServices/template/templateVariablesXService"
import { pageTitle } from "../../util/page"
import { pageTitle } from "../../../util/page"
import { useTemplateSettingsContext } from "../TemplateSettingsLayout"
import { TemplateVariablesPageView } from "./TemplateVariablesPageView"
export const TemplateVariablesPage: FC = () => {
@ -19,15 +21,16 @@ export const TemplateVariablesPage: FC = () => {
template: string
}
const organizationId = useOrganizationId()
const { template } = useTemplateSettingsContext()
const navigate = useNavigate()
const [state, send] = useMachine(templateVariablesMachine, {
context: {
organizationId,
templateName,
template,
},
actions: {
onUpdateTemplate: () => {
navigate(`/templates/${templateName}`)
displaySuccess("Template updated successfully")
},
},
})
@ -43,7 +46,7 @@ export const TemplateVariablesPage: FC = () => {
return (
<>
<Helmet>
<title>{pageTitle(t("title"))}</title>
<title>{pageTitle([template.name, t("title")])}</title>
</Helmet>
<TemplateVariablesPageView

View File

@ -0,0 +1,99 @@
import {
CreateTemplateVersionRequest,
TemplateVersion,
TemplateVersionVariable,
} from "api/typesGenerated"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Loader } from "components/Loader/Loader"
import { ComponentProps, FC } from "react"
import { TemplateVariablesForm } from "./TemplateVariablesForm"
import { Stack } from "components/Stack/Stack"
import { makeStyles } from "@material-ui/core/styles"
import { useTranslation } from "react-i18next"
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
export interface TemplateVariablesPageViewProps {
templateVersion?: TemplateVersion
templateVariables?: TemplateVersionVariable[]
onSubmit: (data: CreateTemplateVersionRequest) => void
onCancel: () => void
isSubmitting: boolean
errors?: {
getTemplateDataError?: unknown
updateTemplateError?: unknown
jobError?: TemplateVersion["job"]["error"]
}
initialTouched?: ComponentProps<
typeof TemplateVariablesForm
>["initialTouched"]
}
export const TemplateVariablesPageView: FC<TemplateVariablesPageViewProps> = ({
templateVersion,
templateVariables,
onCancel,
onSubmit,
isSubmitting,
errors = {},
initialTouched,
}) => {
const classes = useStyles()
const isLoading =
!templateVersion &&
!templateVariables &&
!errors.getTemplateDataError &&
!errors.updateTemplateError
const { t } = useTranslation("templateVariablesPage")
return (
<>
<PageHeader className={classes.pageHeader}>
<PageHeaderTitle>{t("title")}</PageHeaderTitle>
</PageHeader>
{Boolean(errors.getTemplateDataError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.getTemplateDataError} />
</Stack>
)}
{Boolean(errors.updateTemplateError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.updateTemplateError} />
</Stack>
)}
{Boolean(errors.jobError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" text={errors.jobError} />
</Stack>
)}
{isLoading && <Loader />}
{templateVersion && templateVariables && templateVariables.length > 0 && (
<TemplateVariablesForm
initialTouched={initialTouched}
isSubmitting={isSubmitting}
templateVersion={templateVersion}
templateVariables={templateVariables}
onSubmit={onSubmit}
onCancel={onCancel}
error={errors.updateTemplateError}
/>
)}
{templateVariables && templateVariables.length === 0 && (
<AlertBanner severity="info" text={t("unusedVariablesNotice")} />
)}
</>
)
}
const useStyles = makeStyles((theme) => ({
errorContainer: {
marginBottom: theme.spacing(8),
},
goBackSection: {
display: "flex",
width: "100%",
marginTop: 32,
},
pageHeader: {
paddingTop: 0,
},
}))

View File

@ -1,103 +0,0 @@
import {
CreateTemplateVersionRequest,
TemplateVersion,
TemplateVersionVariable,
} from "api/typesGenerated"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Loader } from "components/Loader/Loader"
import { ComponentProps, FC } from "react"
import { TemplateVariablesForm } from "./TemplateVariablesForm"
import { Stack } from "components/Stack/Stack"
import { makeStyles } from "@material-ui/core/styles"
import { useTranslation } from "react-i18next"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
import { GoBackButton } from "components/GoBackButton/GoBackButton"
export interface TemplateVariablesPageViewProps {
templateVersion?: TemplateVersion
templateVariables?: TemplateVersionVariable[]
onSubmit: (data: CreateTemplateVersionRequest) => void
onCancel: () => void
isSubmitting: boolean
errors?: {
getTemplateDataError?: unknown
updateTemplateError?: unknown
jobError?: TemplateVersion["job"]["error"]
}
initialTouched?: ComponentProps<
typeof TemplateVariablesForm
>["initialTouched"]
}
export const TemplateVariablesPageView: FC<TemplateVariablesPageViewProps> = ({
templateVersion,
templateVariables,
onCancel,
onSubmit,
isSubmitting,
errors = {},
initialTouched,
}) => {
const classes = useStyles()
const isLoading =
!templateVersion &&
!templateVariables &&
!errors.getTemplateDataError &&
!errors.updateTemplateError
const { t } = useTranslation("templateVariablesPage")
return (
<>
<FullPageHorizontalForm title={t("title")} onCancel={onCancel}>
{Boolean(errors.getTemplateDataError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.getTemplateDataError} />
</Stack>
)}
{Boolean(errors.updateTemplateError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.updateTemplateError} />
</Stack>
)}
{Boolean(errors.jobError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" text={errors.jobError} />
</Stack>
)}
{isLoading && <Loader />}
{templateVersion &&
templateVariables &&
templateVariables.length > 0 && (
<TemplateVariablesForm
initialTouched={initialTouched}
isSubmitting={isSubmitting}
templateVersion={templateVersion}
templateVariables={templateVariables}
onSubmit={onSubmit}
onCancel={onCancel}
error={errors.updateTemplateError}
/>
)}
{templateVariables && templateVariables.length === 0 && (
<div>
<AlertBanner severity="info" text={t("unusedVariablesNotice")} />
<div className={classes.goBackSection}>
<GoBackButton onClick={onCancel} />
</div>
</div>
)}
</FullPageHorizontalForm>
</>
)
}
const useStyles = makeStyles((theme) => ({
errorContainer: {
marginBottom: theme.spacing(8),
},
goBackSection: {
display: "flex",
width: "100%",
marginTop: 32,
},
}))

View File

@ -667,9 +667,10 @@ export const MockWorkspace: TypesGen.Workspace = {
organization_id: MockOrganization.id,
owner_name: MockUser.username,
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds
ttl_ms: 2 * 60 * 60 * 1000,
latest_build: MockWorkspaceBuild,
last_used_at: "",
organization_id: MockOrganization.id,
}
export const MockStoppedWorkspace: TypesGen.Workspace = {
@ -1280,6 +1281,20 @@ export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = {
}),
}
export const MockEntitlementsWithScheduling: TypesGen.Entitlements = {
errors: [],
warnings: [],
has_license: true,
require_telemetry: false,
trial: false,
features: withDefaultFeatures({
advanced_template_scheduling: {
enabled: true,
entitlement: "entitled",
},
}),
}
export const MockExperiments: TypesGen.Experiment[] = []
export const MockAuditLog: TypesGen.AuditLog = {

View File

@ -8,6 +8,7 @@ import { AppProviders } from "app"
import { DashboardLayout } from "components/Dashboard/DashboardLayout"
import { createMemoryHistory } from "history"
import { i18n } from "i18n"
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
import { FC, ReactElement } from "react"
import { I18nextProvider } from "react-i18next"
import {
@ -86,6 +87,50 @@ export function renderWithAuth(
}
}
export function renderWithTemplateSettingsLayout(
element: JSX.Element,
{
path = "/",
route = "/",
extraRoutes = [],
nonAuthenticatedRoutes = [],
}: RenderWithAuthOptions = {},
) {
const routes: RouteObject[] = [
{
element: <RequireAuth />,
children: [
{
element: <DashboardLayout />,
children: [
{
element: <TemplateSettingsLayout />,
children: [{ path, element }, ...extraRoutes],
},
],
},
],
},
...nonAuthenticatedRoutes,
]
const router = createMemoryRouter(routes, { initialEntries: [route] })
const renderResult = wrappedRender(
<I18nextProvider i18n={i18n}>
<AppProviders>
<RouterProvider router={router} />
</AppProviders>
</I18nextProvider>,
)
return {
user: MockUser,
router,
...renderResult,
}
}
export const waitForLoaderToBeRemoved = (): Promise<void> =>
waitForElementToBeRemoved(() => screen.getByTestId("loader"))

View File

@ -242,5 +242,15 @@ export const getOverrides = ({
minWidth: 120,
},
},
MuiSnackbar: {
anchorOriginBottomRight: {
bottom: `${24 + 36}px !important`, // 36 is the bottom bar height
},
},
MuiSnackbarContent: {
root: {
borderRadius: "4px !important",
},
},
}
}

View File

@ -1,3 +1,4 @@
export const pageTitle = (prefix: string): string => {
return `${prefix} Coder`
export const pageTitle = (prefix: string | string[]): string => {
const title = Array.isArray(prefix) ? prefix.join(" · ") : prefix
return `${title} - Coder`
}

View File

@ -58,7 +58,7 @@ export const entitlementsMachine = createMachine(
}),
},
services: {
getEntitlements: API.getEntitlements,
getEntitlements: () => API.getEntitlements(),
},
},
)

View File

@ -1,6 +1,5 @@
import {
createTemplateVersion,
getTemplateByName,
getTemplateVersion,
getTemplateVersionVariables,
updateActiveTemplateVersion,
@ -17,9 +16,8 @@ import { Message } from "api/types"
type TemplateVariablesContext = {
organizationId: string
templateName: string
template?: Template
template: Template
activeTemplateVersion?: TemplateVersion
templateVariables?: TemplateVersionVariable[]
@ -46,9 +44,6 @@ export const templateVariablesMachine = createMachine(
context: {} as TemplateVariablesContext,
events: {} as UpdateTemplateEvent,
services: {} as {
getTemplate: {
data: Template
}
getActiveTemplateVersion: {
data: TemplateVersion
}
@ -66,24 +61,8 @@ export const templateVariablesMachine = createMachine(
}
},
},
initial: "gettingTemplate",
initial: "gettingActiveTemplateVersion",
states: {
gettingTemplate: {
entry: "clearGetTemplateDataError",
invoke: {
src: "getTemplate",
onDone: [
{
actions: ["assignTemplate"],
target: "gettingActiveTemplateVersion",
},
],
onError: {
actions: ["assignGetTemplateDataError"],
target: "error",
},
},
},
gettingActiveTemplateVersion: {
entry: "clearGetTemplateDataError",
invoke: {
@ -183,19 +162,10 @@ export const templateVariablesMachine = createMachine(
},
{
services: {
getTemplate: ({ organizationId, templateName }) => {
return getTemplateByName(organizationId, templateName)
},
getActiveTemplateVersion: ({ template }) => {
if (!template) {
throw new Error("No template selected")
}
return getTemplateVersion(template.active_version_id)
},
getTemplateVariables: ({ template }) => {
if (!template) {
throw new Error("No template selected")
}
return getTemplateVersionVariables(template.active_version_id)
},
createNewTemplateVersion: ({
@ -224,10 +194,6 @@ export const templateVariablesMachine = createMachine(
return newTemplateVersion
},
updateTemplate: ({ template, newTemplateVersion }) => {
if (!template) {
throw new Error("No template selected")
}
if (!newTemplateVersion) {
throw new Error("New template version is undefined")
}
@ -238,9 +204,6 @@ export const templateVariablesMachine = createMachine(
},
},
actions: {
assignTemplate: assign({
template: (_, event) => event.data,
}),
assignActiveTemplateVersion: assign({
activeTemplateVersion: (_, event) => event.data,
}),

View File

@ -1,166 +0,0 @@
import { getTemplateByName, updateTemplateMeta, deleteTemplate } from "api/api"
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { createMachine } from "xstate"
import { assign } from "xstate/lib/actions"
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { t } from "i18next"
export const templateSettingsMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QBcwFsAOAbAhqgymMsgJYB2UsAdFgPY4TlQDEEtZYV5AbrQNadUmXASKkK1OgyYIetAMZ4S7ANoAGALqJQGWrBKl22kAA9EANgCMVAKwB2AByWATJbWWAnABYv55w-MAGhAAT0RnAGZnKgibGw9nD3M1JJsHNQiAX0zgoWw8MEJiJmpIAyZmfABBADUAUWNdfUMyYzMESx9bSK8IhwdncwcbF2dgsIRejypzX2dXf09zCLUbbNz0fNFiiSpYHG4Ktg4uMl4BKjyRQrESvYOZOUUW9S0kECbyo3f25KoHOxeDwODx9EYeNTzcYWGxqKgeGw+SJ2cx+VZebI5EBkWgQODGK4FIriSg0eiMCiNPRfVo-RBeMahRAQqhqNnuWERFFeOyedYgQnbEmlRgkqnNZS00DtSwRcz-EY2PzeeYOLk2aEIWJ2WwOIEBNIeBG8-mCm47Un7Q6U96fFptenWRJDIZy+y9AFBJkIEbRbxeWUBRwIyx2U2ba7Eu5WyDimkOhAI2yxSx+foMpWWTV2RLJgIrPoA4bh4RE24SOP2ukdHXOgJq8zuvoozWWBys9nzSydNt2NQYzFAA */
createMachine(
{
id: "templateSettings",
predictableActionArguments: true,
tsTypes: {} as import("./templateSettingsXService.typegen").Typegen0,
schema: {} as {
context: {
organizationId: string
templateName: string
templateSettings?: Template
getTemplateError?: unknown
saveTemplateSettingsError?: unknown
deleteTemplateError?: Error | unknown
}
services: {
getTemplateSettings: {
data: Template
}
saveTemplateSettings: {
data: Template
}
}
events:
| { type: "SAVE"; templateSettings: UpdateTemplateMeta }
| { type: "DELETE" }
| { type: "CONFIRM_DELETE" }
| { type: "CANCEL_DELETE" }
},
initial: "loading",
states: {
loading: {
invoke: {
src: "getTemplateSettings",
onDone: [
{
actions: "assignTemplateSettings",
target: "editing",
},
],
onError: {
target: "error",
actions: "assignGetTemplateError",
},
},
},
editing: {
on: {
SAVE: {
target: "saving",
},
DELETE: {
target: "confirmingDelete",
},
},
},
confirmingDelete: {
on: {
CONFIRM_DELETE: {
target: "deleting",
},
CANCEL_DELETE: {
target: "editing",
},
},
},
deleting: {
entry: "clearDeleteTemplateError",
invoke: {
src: "deleteTemplate",
id: "deleteTemplate",
onDone: [
{
target: "deleted",
actions: "displayDeleteSuccess",
},
],
onError: [
{
actions: "assignDeleteTemplateError",
target: "editing",
},
],
},
},
deleted: {
type: "final",
},
saving: {
invoke: {
src: "saveTemplateSettings",
onDone: [
{
target: "saved",
},
],
onError: [
{
target: "editing",
actions: ["assignSaveTemplateSettingsError"],
},
],
},
tags: ["submitting"],
},
saved: {
entry: "onSave",
type: "final",
tags: ["submitting"],
},
error: {
type: "final",
},
},
},
{
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)
},
deleteTemplate: (ctx) => {
if (!ctx.templateSettings) {
throw new Error("Template not loaded")
}
return deleteTemplate(ctx.templateSettings.id)
},
},
actions: {
assignTemplateSettings: assign({
templateSettings: (_, { data }) => data,
}),
assignGetTemplateError: assign({
getTemplateError: (_, { data }) => data,
}),
assignSaveTemplateSettingsError: assign({
saveTemplateSettingsError: (_, { data }) => data,
}),
assignDeleteTemplateError: assign({
deleteTemplateError: (_, event) => event.data,
}),
clearDeleteTemplateError: assign({
deleteTemplateError: (_) => undefined,
}),
displayDeleteSuccess: () =>
displaySuccess(t("deleteSuccess", { ns: "templatePage" })),
},
},
)