mirror of
https://github.com/coder/coder.git
synced 2025-07-10 23:53:15 +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 }) => {
|
test("list templates", async ({ page, baseURL }) => {
|
||||||
await page.goto(`${baseURL}/templates`, { waitUntil: "networkidle" })
|
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 GroupsPage from "pages/GroupsPage/GroupsPage"
|
||||||
import LoginPage from "pages/LoginPage/LoginPage"
|
import LoginPage from "pages/LoginPage/LoginPage"
|
||||||
import { SetupPage } from "pages/SetupPage/SetupPage"
|
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 TemplatesPage from "pages/TemplatesPage/TemplatesPage"
|
||||||
import UsersPage from "pages/UsersPage/UsersPage"
|
import UsersPage from "pages/UsersPage/UsersPage"
|
||||||
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
|
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
|
||||||
@ -16,6 +16,7 @@ import { DashboardLayout } from "./components/Dashboard/DashboardLayout"
|
|||||||
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
|
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
|
||||||
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
|
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
|
||||||
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
|
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
|
||||||
|
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
|
||||||
|
|
||||||
// Lazy load pages
|
// Lazy load pages
|
||||||
// - Pages that are secondary, not in the main navigation or not usually accessed
|
// - 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(
|
const TemplatePermissionsPage = lazy(
|
||||||
() =>
|
() =>
|
||||||
import(
|
import(
|
||||||
"./pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage"
|
"./pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const TemplateSummaryPage = lazy(
|
const TemplateSummaryPage = lazy(
|
||||||
@ -120,7 +121,10 @@ const CreateTemplatePage = lazy(
|
|||||||
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
|
() => import("./pages/CreateTemplatePage/CreateTemplatePage"),
|
||||||
)
|
)
|
||||||
const TemplateVariablesPage = lazy(
|
const TemplateVariablesPage = lazy(
|
||||||
() => import("./pages/TemplateVariablesPage/TemplateVariablesPage"),
|
() =>
|
||||||
|
import(
|
||||||
|
"./pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const WorkspaceSettingsPage = lazy(
|
const WorkspaceSettingsPage = lazy(
|
||||||
() => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"),
|
() => import("./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"),
|
||||||
@ -129,7 +133,13 @@ const CreateTokenPage = lazy(
|
|||||||
() => import("./pages/CreateTokenPage/CreateTokenPage"),
|
() => import("./pages/CreateTokenPage/CreateTokenPage"),
|
||||||
)
|
)
|
||||||
const TemplateFilesPage = lazy(
|
const TemplateFilesPage = lazy(
|
||||||
() => import("./pages/TemplateFilesPage/TemplateFilesPage"),
|
() => import("./pages/TemplatePage/TemplateFilesPage/TemplateFilesPage"),
|
||||||
|
)
|
||||||
|
const TemplateSchedulePage = lazy(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
"./pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const AppRouter: FC = () => {
|
export const AppRouter: FC = () => {
|
||||||
@ -160,16 +170,25 @@ export const AppRouter: FC = () => {
|
|||||||
<Route path=":template">
|
<Route path=":template">
|
||||||
<Route element={<TemplateLayout />}>
|
<Route element={<TemplateLayout />}>
|
||||||
<Route index element={<TemplateSummaryPage />} />
|
<Route index element={<TemplateSummaryPage />} />
|
||||||
<Route
|
|
||||||
path="permissions"
|
|
||||||
element={<TemplatePermissionsPage />}
|
|
||||||
/>
|
|
||||||
<Route path="files" element={<TemplateFilesPage />} />
|
<Route path="files" element={<TemplateFilesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="workspace" element={<CreateWorkspacePage />} />
|
<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="versions">
|
||||||
<Route path=":version">
|
<Route path=":version">
|
||||||
<Route index element={<TemplateVersionPage />} />
|
<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) => {
|
export const LazyIconField: FC<IconFieldProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense fallback={<div role="progressbar" data-testid="loader" />}>
|
||||||
<IconField {...props} />
|
<IconField {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
@ -130,6 +130,7 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
color: theme.palette.text.secondary,
|
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>>
|
type TemplateLayoutContextValue = Awaited<ReturnType<typeof fetchTemplate>>
|
||||||
|
|
||||||
const TemplateLayoutContext = createContext<
|
const TemplateLayoutContext = createContext<
|
||||||
@ -71,28 +64,31 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const orgId = useOrganizationId()
|
const orgId = useOrganizationId()
|
||||||
const { template } = useParams() as { template: string }
|
const { template: templateName } = useParams() as { template: string }
|
||||||
const templateData = useTemplateData(orgId, template)
|
const { data, error, isLoading } = useQuery({
|
||||||
|
queryKey: ["template", templateName],
|
||||||
|
queryFn: () => fetchTemplate(orgId, templateName),
|
||||||
|
})
|
||||||
const dashboard = useDashboard()
|
const dashboard = useDashboard()
|
||||||
|
|
||||||
if (templateData.error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.error}>
|
<div className={styles.error}>
|
||||||
<AlertBanner severity="error" error={templateData.error} />
|
<AlertBanner severity="error" error={error} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (templateData.isLoading || !templateData.data) {
|
if (isLoading || !data) {
|
||||||
return <Loader />
|
return <Loader />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplatePageHeader
|
<TemplatePageHeader
|
||||||
template={templateData.data.template}
|
template={data.template}
|
||||||
activeVersion={templateData.data.activeVersion}
|
activeVersion={data.activeVersion}
|
||||||
permissions={templateData.data.permissions}
|
permissions={data.permissions}
|
||||||
canEditFiles={dashboard.experiments.includes("template_editor")}
|
canEditFiles={dashboard.experiments.includes("template_editor")}
|
||||||
onDeleteTemplate={() => {
|
onDeleteTemplate={() => {
|
||||||
navigate("/templates")
|
navigate("/templates")
|
||||||
@ -104,7 +100,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
|
|||||||
<Stack direction="row" spacing={0.25}>
|
<Stack direction="row" spacing={0.25}>
|
||||||
<NavLink
|
<NavLink
|
||||||
end
|
end
|
||||||
to={`/templates/${template}`}
|
to={`/templates/${templateName}`}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
combineClasses([
|
combineClasses([
|
||||||
styles.tabItem,
|
styles.tabItem,
|
||||||
@ -115,18 +111,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
|
|||||||
Summary
|
Summary
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/templates/${template}/permissions`}
|
to={`/templates/${templateName}/files`}
|
||||||
className={({ isActive }) =>
|
|
||||||
combineClasses([
|
|
||||||
styles.tabItem,
|
|
||||||
isActive ? styles.tabItemActive : undefined,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Permissions
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to={`/templates/${template}/files`}
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
combineClasses([
|
combineClasses([
|
||||||
styles.tabItem,
|
styles.tabItem,
|
||||||
@ -141,7 +126,7 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Margins>
|
<Margins>
|
||||||
<TemplateLayoutContext.Provider value={templateData.data}>
|
<TemplateLayoutContext.Provider value={data}>
|
||||||
<Suspense fallback={<Loader />}>{children}</Suspense>
|
<Suspense fallback={<Loader />}>{children}</Suspense>
|
||||||
</TemplateLayoutContext.Provider>
|
</TemplateLayoutContext.Provider>
|
||||||
</Margins>
|
</Margins>
|
||||||
|
@ -67,13 +67,6 @@ const TemplateMenu: FC<{
|
|||||||
>
|
>
|
||||||
{Language.settingsButton}
|
{Language.settingsButton}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
|
||||||
component={RouterLink}
|
|
||||||
to={`/templates/${templateName}/variables`}
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
{Language.variablesButton}
|
|
||||||
</MenuItem>
|
|
||||||
{canEditFiles && (
|
{canEditFiles && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"title": "Template settings",
|
"title": "General Settings",
|
||||||
"nameLabel": "Name",
|
"nameLabel": "Name",
|
||||||
"displayNameLabel": "Display name",
|
"displayNameLabel": "Display name",
|
||||||
"descriptionLabel": "Description",
|
"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 * as Yup from "yup"
|
||||||
import i18next from "i18next"
|
import i18next from "i18next"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Maybe } from "components/Conditionals/Maybe"
|
|
||||||
import { LazyIconField } from "components/IconField/LazyIconField"
|
import { LazyIconField } from "components/IconField/LazyIconField"
|
||||||
import {
|
import {
|
||||||
FormFields,
|
FormFields,
|
||||||
@ -23,28 +22,8 @@ import { Stack } from "components/Stack/Stack"
|
|||||||
import Checkbox from "@material-ui/core/Checkbox"
|
import Checkbox from "@material-ui/core/Checkbox"
|
||||||
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
|
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
|
||||||
import { makeStyles } from "@material-ui/core/styles"
|
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_DESCRIPTION_CHAR_LIMIT = 128
|
||||||
const MAX_TTL_DAYS = 7
|
|
||||||
const MS_HOUR_CONVERSION = 3600000
|
|
||||||
|
|
||||||
export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
||||||
Yup.object({
|
Yup.object({
|
||||||
@ -58,20 +37,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
|||||||
MAX_DESCRIPTION_CHAR_LIMIT,
|
MAX_DESCRIPTION_CHAR_LIMIT,
|
||||||
i18next.t("descriptionMaxError", { ns: "templateSettingsPage" }),
|
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(),
|
allow_user_cancel_workspace_jobs: Yup.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -81,7 +47,6 @@ export interface TemplateSettingsForm {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
isSubmitting: boolean
|
isSubmitting: boolean
|
||||||
error?: unknown
|
error?: unknown
|
||||||
canSetMaxTTL: boolean
|
|
||||||
// Helpful to show field errors on Storybook
|
// Helpful to show field errors on Storybook
|
||||||
initialTouched?: FormikTouched<UpdateTemplateMeta>
|
initialTouched?: FormikTouched<UpdateTemplateMeta>
|
||||||
}
|
}
|
||||||
@ -91,11 +56,9 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
error,
|
error,
|
||||||
canSetMaxTTL,
|
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
initialTouched,
|
initialTouched,
|
||||||
}) => {
|
}) => {
|
||||||
const { t: commonT } = useTranslation("common")
|
|
||||||
const validationSchema = getValidationSchema()
|
const validationSchema = getValidationSchema()
|
||||||
const form: FormikContextType<UpdateTemplateMeta> =
|
const form: FormikContextType<UpdateTemplateMeta> =
|
||||||
useFormik<UpdateTemplateMeta>({
|
useFormik<UpdateTemplateMeta>({
|
||||||
@ -103,28 +66,12 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||||||
name: template.name,
|
name: template.name,
|
||||||
display_name: template.display_name,
|
display_name: template.display_name,
|
||||||
description: template.description,
|
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,
|
icon: template.icon,
|
||||||
allow_user_cancel_workspace_jobs:
|
allow_user_cancel_workspace_jobs:
|
||||||
template.allow_user_cancel_workspace_jobs,
|
template.allow_user_cancel_workspace_jobs,
|
||||||
},
|
},
|
||||||
validationSchema,
|
validationSchema,
|
||||||
onSubmit: (formData) => {
|
onSubmit,
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
initialTouched,
|
initialTouched,
|
||||||
})
|
})
|
||||||
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
|
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
|
||||||
@ -188,55 +135,6 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||||||
</FormFields>
|
</FormFields>
|
||||||
</FormSection>
|
</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
|
<FormSection
|
||||||
title={t("operations.title")}
|
title={t("operations.title")}
|
||||||
description={t("operations.description")}
|
description={t("operations.description")}
|
||||||
@ -290,8 +188,4 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
fontSize: theme.spacing(1.5),
|
fontSize: theme.spacing(1.5),
|
||||||
color: theme.palette.text.secondary,
|
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 * as API from "api/api"
|
||||||
import { UpdateTemplateMeta } from "api/typesGenerated"
|
import { UpdateTemplateMeta } from "api/typesGenerated"
|
||||||
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
|
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
|
||||||
import { MockTemplate } from "../../testHelpers/entities"
|
import { MockTemplate } from "../../../testHelpers/entities"
|
||||||
import { renderWithAuth } from "../../testHelpers/renderHelpers"
|
import {
|
||||||
|
renderWithTemplateSettingsLayout,
|
||||||
|
waitForLoaderToBeRemoved,
|
||||||
|
} from "../../../testHelpers/renderHelpers"
|
||||||
import { getValidationSchema } from "./TemplateSettingsForm"
|
import { getValidationSchema } from "./TemplateSettingsForm"
|
||||||
import { TemplateSettingsPage } from "./TemplateSettingsPage"
|
import { TemplateSettingsPage } from "./TemplateSettingsPage"
|
||||||
import i18next from "i18next"
|
import i18next from "i18next"
|
||||||
@ -16,32 +19,24 @@ const validFormValues = {
|
|||||||
display_name: "A display name",
|
display_name: "A display name",
|
||||||
description: "A description",
|
description: "A description",
|
||||||
icon: "vscode.png",
|
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,
|
allow_user_cancel_workspace_jobs: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderTemplateSettingsPage = async () => {
|
const renderTemplateSettingsPage = async () => {
|
||||||
renderWithAuth(<TemplateSettingsPage />, {
|
renderWithTemplateSettingsLayout(<TemplateSettingsPage />, {
|
||||||
route: `/templates/${MockTemplate.name}/settings`,
|
route: `/templates/${MockTemplate.name}/settings`,
|
||||||
path: `/templates/:template/settings`,
|
path: `/templates/:template/settings`,
|
||||||
extraRoutes: [{ path: "templates/:template", element: <></> }],
|
|
||||||
})
|
})
|
||||||
// Wait the form to be rendered
|
await waitForLoaderToBeRemoved()
|
||||||
const label = t("nameLabel", { ns: "templateSettingsPage" })
|
|
||||||
await screen.findAllByLabelText(label)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fillAndSubmitForm = async ({
|
const fillAndSubmitForm = async ({
|
||||||
name,
|
name,
|
||||||
display_name,
|
display_name,
|
||||||
description,
|
description,
|
||||||
default_ttl_ms,
|
|
||||||
max_ttl_ms,
|
|
||||||
icon,
|
icon,
|
||||||
allow_user_cancel_workspace_jobs,
|
allow_user_cancel_workspace_jobs,
|
||||||
}: Required<UpdateTemplateMeta>) => {
|
}: Required<Omit<UpdateTemplateMeta, "default_ttl_ms" | "max_ttl_ms">>) => {
|
||||||
const label = t("nameLabel", { ns: "templateSettingsPage" })
|
const label = t("nameLabel", { ns: "templateSettingsPage" })
|
||||||
const nameField = await screen.findByLabelText(label)
|
const nameField = await screen.findByLabelText(label)
|
||||||
await userEvent.clear(nameField)
|
await userEvent.clear(nameField)
|
||||||
@ -63,19 +58,6 @@ const fillAndSubmitForm = async ({
|
|||||||
await userEvent.clear(iconField)
|
await userEvent.clear(iconField)
|
||||||
await userEvent.type(iconField, icon)
|
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")
|
const allowCancelJobsField = screen.getByRole("checkbox")
|
||||||
// checkbox is checked by default, so it must be clicked to get unchecked
|
// checkbox is checked by default, so it must be clicked to get unchecked
|
||||||
if (!allow_user_cancel_workspace_jobs) {
|
if (!allow_user_cancel_workspace_jobs) {
|
||||||
@ -89,81 +71,16 @@ const fillAndSubmitForm = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("TemplateSettingsPage", () => {
|
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 () => {
|
it("succeeds", async () => {
|
||||||
await renderTemplateSettingsPage()
|
await renderTemplateSettingsPage()
|
||||||
|
|
||||||
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
|
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
|
||||||
...MockTemplate,
|
...MockTemplate,
|
||||||
...validFormValues,
|
...validFormValues,
|
||||||
})
|
})
|
||||||
await fillAndSubmitForm(validFormValues)
|
await fillAndSubmitForm(validFormValues)
|
||||||
|
|
||||||
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
|
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", () => {
|
it("allows a description of 128 chars", () => {
|
||||||
const values: UpdateTemplateMeta = {
|
const values: UpdateTemplateMeta = {
|
||||||
...validFormValues,
|
...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 { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
import { displaySuccess } from "components/GlobalSnackbar/utils"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
import { Helmet } from "react-helmet-async"
|
import { Helmet } from "react-helmet-async"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useNavigate, useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import { pageTitle } from "util/page"
|
import { pageTitle } from "util/page"
|
||||||
import { templateSettingsMachine } from "xServices/templateSettings/templateSettingsXService"
|
import { useTemplateSettingsContext } from "../TemplateSettingsLayout"
|
||||||
import { TemplateSettingsPageView } from "./TemplateSettingsPageView"
|
import { TemplateSettingsPageView } from "./TemplateSettingsPageView"
|
||||||
|
|
||||||
export const TemplateSettingsPage: FC = () => {
|
export const TemplateSettingsPage: FC = () => {
|
||||||
const { template: templateName } = useParams() as { template: string }
|
const { template: templateName } = useParams() as { template: string }
|
||||||
const { t } = useTranslation("templateSettingsPage")
|
const { t } = useTranslation("templateSettingsPage")
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const organizationId = useOrganizationId()
|
const { template } = useTemplateSettingsContext()
|
||||||
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 { entitlements } = useDashboard()
|
const { entitlements } = useDashboard()
|
||||||
const canSetMaxTTL =
|
const canSetMaxTTL =
|
||||||
entitlements.features["advanced_template_scheduling"].enabled
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle(t("title"))}</title>
|
<title>{pageTitle([template.name, t("title")])}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<TemplateSettingsPageView
|
<TemplateSettingsPageView
|
||||||
canSetMaxTTL={canSetMaxTTL}
|
canSetMaxTTL={canSetMaxTTL}
|
||||||
isSubmitting={state.hasTag("submitting")}
|
isSubmitting={isSubmitting}
|
||||||
template={template}
|
template={template}
|
||||||
errors={{
|
submitError={submitError}
|
||||||
getTemplateError,
|
|
||||||
saveTemplateSettingsError,
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
navigate(`/templates/${templateName}`)
|
navigate(`/templates/${templateName}`)
|
||||||
}}
|
}}
|
||||||
onSubmit={(templateSettings) => {
|
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 { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
|
||||||
import { Paywall } from "components/Paywall/Paywall"
|
import { Paywall } from "components/Paywall/Paywall"
|
||||||
import { Stack } from "components/Stack/Stack"
|
import { Stack } from "components/Stack/Stack"
|
||||||
import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
|
|
||||||
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
|
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
|
||||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
import { Helmet } from "react-helmet-async"
|
import { Helmet } from "react-helmet-async"
|
||||||
import { pageTitle } from "util/page"
|
import { pageTitle } from "util/page"
|
||||||
import { templateACLMachine } from "xServices/template/templateACLXService"
|
import { templateACLMachine } from "xServices/template/templateACLXService"
|
||||||
|
import { useTemplateSettingsContext } from "../TemplateSettingsLayout"
|
||||||
import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"
|
import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"
|
||||||
|
|
||||||
export const TemplatePermissionsPage: FC<
|
export const TemplatePermissionsPage: FC<
|
||||||
React.PropsWithChildren<unknown>
|
React.PropsWithChildren<unknown>
|
||||||
> = () => {
|
> = () => {
|
||||||
const organizationId = useOrganizationId()
|
const organizationId = useOrganizationId()
|
||||||
const { template, permissions } = useTemplateLayoutContext()
|
const { template, permissions } = useTemplateSettingsContext()
|
||||||
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility()
|
const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility()
|
||||||
const [state, send] = useMachine(templateACLMachine, {
|
const [state, send] = useMachine(templateACLMachine, {
|
||||||
context: { templateId: template.id },
|
context: { templateId: template.id },
|
||||||
@ -28,7 +28,7 @@ export const TemplatePermissionsPage: FC<
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle(`${template?.name} · Permissions`)}</title>
|
<title>{pageTitle([template.name, "Permissions"])}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<ChooseOne>
|
<ChooseOne>
|
||||||
<Cond condition={!isTemplateRBACEnabled}>
|
<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 userEvent from "@testing-library/user-event"
|
||||||
import {
|
import {
|
||||||
MockTemplate,
|
MockTemplate,
|
||||||
@ -6,16 +6,14 @@ import {
|
|||||||
MockTemplateVersion,
|
MockTemplateVersion,
|
||||||
MockTemplateVersionVariable1,
|
MockTemplateVersionVariable1,
|
||||||
MockTemplateVersionVariable2,
|
MockTemplateVersionVariable2,
|
||||||
renderWithAuth,
|
|
||||||
MockTemplateVersionVariable5,
|
MockTemplateVersionVariable5,
|
||||||
|
renderWithTemplateSettingsLayout,
|
||||||
|
waitForLoaderToBeRemoved,
|
||||||
} from "testHelpers/renderHelpers"
|
} from "testHelpers/renderHelpers"
|
||||||
import * as API from "api/api"
|
import * as API from "api/api"
|
||||||
import i18next from "i18next"
|
import i18next from "i18next"
|
||||||
import TemplateVariablesPage from "./TemplateVariablesPage"
|
import TemplateVariablesPage from "./TemplateVariablesPage"
|
||||||
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
|
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
|
||||||
import * as router from "react-router"
|
|
||||||
|
|
||||||
const navigate = jest.fn()
|
|
||||||
|
|
||||||
const { t } = i18next
|
const { t } = i18next
|
||||||
|
|
||||||
@ -30,12 +28,13 @@ const validationRequiredField = t("validationRequiredVariable", {
|
|||||||
ns: "templateVariablesPage",
|
ns: "templateVariablesPage",
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderTemplateVariablesPage = () => {
|
const renderTemplateVariablesPage = async () => {
|
||||||
return renderWithAuth(<TemplateVariablesPage />, {
|
renderWithTemplateSettingsLayout(<TemplateVariablesPage />, {
|
||||||
route: `/templates/${MockTemplate.name}/variables`,
|
route: `/templates/${MockTemplate.name}/variables`,
|
||||||
path: `/templates/:template/variables`,
|
path: `/templates/:template/variables`,
|
||||||
extraRoutes: [{ path: `/templates/${MockTemplate.name}`, element: <></> }],
|
extraRoutes: [{ path: `/templates/${MockTemplate.name}`, element: <></> }],
|
||||||
})
|
})
|
||||||
|
await waitForLoaderToBeRemoved()
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TemplateVariablesPage", () => {
|
describe("TemplateVariablesPage", () => {
|
||||||
@ -51,7 +50,7 @@ describe("TemplateVariablesPage", () => {
|
|||||||
MockTemplateVersionVariable2,
|
MockTemplateVersionVariable2,
|
||||||
])
|
])
|
||||||
|
|
||||||
renderTemplateVariablesPage()
|
await renderTemplateVariablesPage()
|
||||||
|
|
||||||
const element = await screen.findByText(pageTitleText)
|
const element = await screen.findByText(pageTitleText)
|
||||||
expect(element).toBeDefined()
|
expect(element).toBeDefined()
|
||||||
@ -84,9 +83,8 @@ describe("TemplateVariablesPage", () => {
|
|||||||
jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
|
jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
|
||||||
message: "done",
|
message: "done",
|
||||||
})
|
})
|
||||||
jest.spyOn(router, "useNavigate").mockImplementation(() => navigate)
|
|
||||||
|
|
||||||
renderTemplateVariablesPage()
|
await renderTemplateVariablesPage()
|
||||||
|
|
||||||
const element = await screen.findByText(pageTitleText)
|
const element = await screen.findByText(pageTitleText)
|
||||||
expect(element).toBeDefined()
|
expect(element).toBeDefined()
|
||||||
@ -120,10 +118,8 @@ describe("TemplateVariablesPage", () => {
|
|||||||
)
|
)
|
||||||
await userEvent.click(submitButton)
|
await userEvent.click(submitButton)
|
||||||
|
|
||||||
// Wait for redirect
|
// Wait for the success message
|
||||||
await waitFor(() =>
|
await screen.findByText("Template updated successfully")
|
||||||
expect(navigate).toHaveBeenCalledWith(`/templates/${MockTemplate.name}`),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("user forgets to fill the required field", async () => {
|
it("user forgets to fill the required field", async () => {
|
||||||
@ -143,9 +139,8 @@ describe("TemplateVariablesPage", () => {
|
|||||||
jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
|
jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({
|
||||||
message: "done",
|
message: "done",
|
||||||
})
|
})
|
||||||
jest.spyOn(router, "useNavigate").mockImplementation(() => navigate)
|
|
||||||
|
|
||||||
renderTemplateVariablesPage()
|
await renderTemplateVariablesPage()
|
||||||
|
|
||||||
const element = await screen.findByText(pageTitleText)
|
const element = await screen.findByText(pageTitleText)
|
||||||
expect(element).toBeDefined()
|
expect(element).toBeDefined()
|
||||||
@ -170,20 +165,4 @@ describe("TemplateVariablesPage", () => {
|
|||||||
const validationError = await screen.findByText(validationRequiredField)
|
const validationError = await screen.findByText(validationRequiredField)
|
||||||
expect(validationError).toBeDefined()
|
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,
|
TemplateVersionVariable,
|
||||||
VariableValue,
|
VariableValue,
|
||||||
} from "api/typesGenerated"
|
} from "api/typesGenerated"
|
||||||
|
import { displaySuccess } from "components/GlobalSnackbar/utils"
|
||||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
import { Helmet } from "react-helmet-async"
|
import { Helmet } from "react-helmet-async"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useNavigate, useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import { templateVariablesMachine } from "xServices/template/templateVariablesXService"
|
import { templateVariablesMachine } from "xServices/template/templateVariablesXService"
|
||||||
import { pageTitle } from "../../util/page"
|
import { pageTitle } from "../../../util/page"
|
||||||
|
import { useTemplateSettingsContext } from "../TemplateSettingsLayout"
|
||||||
import { TemplateVariablesPageView } from "./TemplateVariablesPageView"
|
import { TemplateVariablesPageView } from "./TemplateVariablesPageView"
|
||||||
|
|
||||||
export const TemplateVariablesPage: FC = () => {
|
export const TemplateVariablesPage: FC = () => {
|
||||||
@ -19,15 +21,16 @@ export const TemplateVariablesPage: FC = () => {
|
|||||||
template: string
|
template: string
|
||||||
}
|
}
|
||||||
const organizationId = useOrganizationId()
|
const organizationId = useOrganizationId()
|
||||||
|
const { template } = useTemplateSettingsContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [state, send] = useMachine(templateVariablesMachine, {
|
const [state, send] = useMachine(templateVariablesMachine, {
|
||||||
context: {
|
context: {
|
||||||
organizationId,
|
organizationId,
|
||||||
templateName,
|
template,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
onUpdateTemplate: () => {
|
onUpdateTemplate: () => {
|
||||||
navigate(`/templates/${templateName}`)
|
displaySuccess("Template updated successfully")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -43,7 +46,7 @@ export const TemplateVariablesPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle(t("title"))}</title>
|
<title>{pageTitle([template.name, t("title")])}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<TemplateVariablesPageView
|
<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,
|
organization_id: MockOrganization.id,
|
||||||
owner_name: MockUser.username,
|
owner_name: MockUser.username,
|
||||||
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
|
autostart_schedule: MockWorkspaceAutostartEnabled.schedule,
|
||||||
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours as milliseconds
|
ttl_ms: 2 * 60 * 60 * 1000,
|
||||||
latest_build: MockWorkspaceBuild,
|
latest_build: MockWorkspaceBuild,
|
||||||
last_used_at: "",
|
last_used_at: "",
|
||||||
|
organization_id: MockOrganization.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MockStoppedWorkspace: TypesGen.Workspace = {
|
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 MockExperiments: TypesGen.Experiment[] = []
|
||||||
|
|
||||||
export const MockAuditLog: TypesGen.AuditLog = {
|
export const MockAuditLog: TypesGen.AuditLog = {
|
||||||
|
@ -8,6 +8,7 @@ import { AppProviders } from "app"
|
|||||||
import { DashboardLayout } from "components/Dashboard/DashboardLayout"
|
import { DashboardLayout } from "components/Dashboard/DashboardLayout"
|
||||||
import { createMemoryHistory } from "history"
|
import { createMemoryHistory } from "history"
|
||||||
import { i18n } from "i18n"
|
import { i18n } from "i18n"
|
||||||
|
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
|
||||||
import { FC, ReactElement } from "react"
|
import { FC, ReactElement } from "react"
|
||||||
import { I18nextProvider } from "react-i18next"
|
import { I18nextProvider } from "react-i18next"
|
||||||
import {
|
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> =>
|
export const waitForLoaderToBeRemoved = (): Promise<void> =>
|
||||||
waitForElementToBeRemoved(() => screen.getByTestId("loader"))
|
waitForElementToBeRemoved(() => screen.getByTestId("loader"))
|
||||||
|
|
||||||
|
@ -242,5 +242,15 @@ export const getOverrides = ({
|
|||||||
minWidth: 120,
|
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 => {
|
export const pageTitle = (prefix: string | string[]): string => {
|
||||||
return `${prefix} – Coder`
|
const title = Array.isArray(prefix) ? prefix.join(" · ") : prefix
|
||||||
|
return `${title} - Coder`
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@ export const entitlementsMachine = createMachine(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
getEntitlements: API.getEntitlements,
|
getEntitlements: () => API.getEntitlements(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createTemplateVersion,
|
createTemplateVersion,
|
||||||
getTemplateByName,
|
|
||||||
getTemplateVersion,
|
getTemplateVersion,
|
||||||
getTemplateVersionVariables,
|
getTemplateVersionVariables,
|
||||||
updateActiveTemplateVersion,
|
updateActiveTemplateVersion,
|
||||||
@ -17,9 +16,8 @@ import { Message } from "api/types"
|
|||||||
|
|
||||||
type TemplateVariablesContext = {
|
type TemplateVariablesContext = {
|
||||||
organizationId: string
|
organizationId: string
|
||||||
templateName: string
|
|
||||||
|
|
||||||
template?: Template
|
template: Template
|
||||||
activeTemplateVersion?: TemplateVersion
|
activeTemplateVersion?: TemplateVersion
|
||||||
templateVariables?: TemplateVersionVariable[]
|
templateVariables?: TemplateVersionVariable[]
|
||||||
|
|
||||||
@ -46,9 +44,6 @@ export const templateVariablesMachine = createMachine(
|
|||||||
context: {} as TemplateVariablesContext,
|
context: {} as TemplateVariablesContext,
|
||||||
events: {} as UpdateTemplateEvent,
|
events: {} as UpdateTemplateEvent,
|
||||||
services: {} as {
|
services: {} as {
|
||||||
getTemplate: {
|
|
||||||
data: Template
|
|
||||||
}
|
|
||||||
getActiveTemplateVersion: {
|
getActiveTemplateVersion: {
|
||||||
data: TemplateVersion
|
data: TemplateVersion
|
||||||
}
|
}
|
||||||
@ -66,24 +61,8 @@ export const templateVariablesMachine = createMachine(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
initial: "gettingTemplate",
|
initial: "gettingActiveTemplateVersion",
|
||||||
states: {
|
states: {
|
||||||
gettingTemplate: {
|
|
||||||
entry: "clearGetTemplateDataError",
|
|
||||||
invoke: {
|
|
||||||
src: "getTemplate",
|
|
||||||
onDone: [
|
|
||||||
{
|
|
||||||
actions: ["assignTemplate"],
|
|
||||||
target: "gettingActiveTemplateVersion",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onError: {
|
|
||||||
actions: ["assignGetTemplateDataError"],
|
|
||||||
target: "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
gettingActiveTemplateVersion: {
|
gettingActiveTemplateVersion: {
|
||||||
entry: "clearGetTemplateDataError",
|
entry: "clearGetTemplateDataError",
|
||||||
invoke: {
|
invoke: {
|
||||||
@ -183,19 +162,10 @@ export const templateVariablesMachine = createMachine(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
services: {
|
services: {
|
||||||
getTemplate: ({ organizationId, templateName }) => {
|
|
||||||
return getTemplateByName(organizationId, templateName)
|
|
||||||
},
|
|
||||||
getActiveTemplateVersion: ({ template }) => {
|
getActiveTemplateVersion: ({ template }) => {
|
||||||
if (!template) {
|
|
||||||
throw new Error("No template selected")
|
|
||||||
}
|
|
||||||
return getTemplateVersion(template.active_version_id)
|
return getTemplateVersion(template.active_version_id)
|
||||||
},
|
},
|
||||||
getTemplateVariables: ({ template }) => {
|
getTemplateVariables: ({ template }) => {
|
||||||
if (!template) {
|
|
||||||
throw new Error("No template selected")
|
|
||||||
}
|
|
||||||
return getTemplateVersionVariables(template.active_version_id)
|
return getTemplateVersionVariables(template.active_version_id)
|
||||||
},
|
},
|
||||||
createNewTemplateVersion: ({
|
createNewTemplateVersion: ({
|
||||||
@ -224,10 +194,6 @@ export const templateVariablesMachine = createMachine(
|
|||||||
return newTemplateVersion
|
return newTemplateVersion
|
||||||
},
|
},
|
||||||
updateTemplate: ({ template, newTemplateVersion }) => {
|
updateTemplate: ({ template, newTemplateVersion }) => {
|
||||||
if (!template) {
|
|
||||||
throw new Error("No template selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newTemplateVersion) {
|
if (!newTemplateVersion) {
|
||||||
throw new Error("New template version is undefined")
|
throw new Error("New template version is undefined")
|
||||||
}
|
}
|
||||||
@ -238,9 +204,6 @@ export const templateVariablesMachine = createMachine(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
assignTemplate: assign({
|
|
||||||
template: (_, event) => event.data,
|
|
||||||
}),
|
|
||||||
assignActiveTemplateVersion: assign({
|
assignActiveTemplateVersion: assign({
|
||||||
activeTemplateVersion: (_, event) => event.data,
|
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