mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
refactor(site): Group template permissions, settings and variables under a settings layout (#6737)
This commit is contained in:
@ -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")
|
||||
})
|
||||
|
@ -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 />} />
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -130,6 +130,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
email: {
|
||||
color: theme.palette.text.secondary,
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Template settings",
|
||||
"title": "General Settings",
|
||||
"nameLabel": "Name",
|
||||
"displayNameLabel": "Display name",
|
||||
"descriptionLabel": "Description",
|
||||
|
149
site/src/pages/TemplateSettingsPage/Sidebar.tsx
Normal file
149
site/src/pages/TemplateSettingsPage/Sidebar.tsx
Normal 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",
|
||||
},
|
||||
}))
|
@ -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%",
|
||||
},
|
||||
}))
|
@ -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,
|
@ -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,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
@ -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,
|
||||
},
|
||||
}
|
@ -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,
|
||||
},
|
||||
}))
|
@ -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}>
|
@ -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%",
|
||||
},
|
||||
}))
|
@ -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" }),
|
||||
)
|
||||
})
|
||||
})
|
@ -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
|
@ -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,
|
||||
}
|
@ -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,
|
||||
},
|
||||
}))
|
@ -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%",
|
||||
},
|
||||
}))
|
@ -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,
|
||||
},
|
||||
}
|
@ -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),
|
||||
},
|
||||
}))
|
@ -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()
|
||||
})
|
||||
})
|
@ -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
|
@ -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,
|
||||
},
|
||||
}))
|
@ -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,
|
||||
},
|
||||
}))
|
@ -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 = {
|
||||
|
@ -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"))
|
||||
|
||||
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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`
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ export const entitlementsMachine = createMachine(
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
getEntitlements: API.getEntitlements,
|
||||
getEntitlements: () => API.getEntitlements(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -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,
|
||||
}),
|
||||
|
@ -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" })),
|
||||
},
|
||||
},
|
||||
)
|
Reference in New Issue
Block a user