mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site): Refactor template settings (#6239)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import {
|
||||
FormFooterProps as BaseFormFooterProps,
|
||||
FormFooter as BaseFormFooter,
|
||||
} from "components/FormFooter/FormFooter"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { FC, HTMLProps, PropsWithChildren } from "react"
|
||||
|
||||
export const HorizontalForm: FC<
|
||||
PropsWithChildren & HTMLProps<HTMLFormElement>
|
||||
> = ({ children, ...formProps }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<form {...formProps}>
|
||||
<Stack direction="column" spacing={10} className={styles.formSections}>
|
||||
{children}
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormSection: FC<
|
||||
PropsWithChildren & { title: string; description: string | JSX.Element }
|
||||
> = ({ children, title, description }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.formSectionInfo}>
|
||||
<h2 className={styles.formSectionInfoTitle}>{title}</h2>
|
||||
<div className={styles.formSectionInfoDescription}>{description}</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormFields: FC<PropsWithChildren> = ({ children }) => {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<Stack direction="column" className={styles.formSectionFields}>
|
||||
{children}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormFooter: FC<BaseFormFooterProps> = (props) => {
|
||||
const formFooterStyles = useFormFooterStyles()
|
||||
return (
|
||||
<BaseFormFooter
|
||||
{...props}
|
||||
styles={{ ...formFooterStyles, ...props.styles }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
formSections: {
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
gap: theme.spacing(8),
|
||||
},
|
||||
},
|
||||
|
||||
formSection: {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing(15),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
|
||||
formSectionInfo: {
|
||||
width: 312,
|
||||
flexShrink: 0,
|
||||
position: "sticky",
|
||||
top: theme.spacing(3),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
width: "100%",
|
||||
position: "initial",
|
||||
},
|
||||
},
|
||||
|
||||
formSectionInfoTitle: {
|
||||
fontSize: 20,
|
||||
color: theme.palette.text.primary,
|
||||
fontWeight: 400,
|
||||
margin: 0,
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
|
||||
formSectionInfoDescription: {
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "160%",
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
formSectionFields: {
|
||||
width: "100%",
|
||||
},
|
||||
}))
|
||||
|
||||
const useFormFooterStyles = makeStyles((theme) => ({
|
||||
button: {
|
||||
minWidth: theme.spacing(23),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
flexDirection: "row-reverse",
|
||||
gap: theme.spacing(2),
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -1,22 +1,8 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
|
||||
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
|
||||
import { useMachine } from "@xstate/react"
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderSubtitle,
|
||||
PageHeaderTitle,
|
||||
} from "components/PageHeader/PageHeader"
|
||||
import { useOrganizationId } from "hooks/useOrganizationId"
|
||||
import { createContext, FC, Suspense, useContext } from "react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
NavLink,
|
||||
Outlet,
|
||||
useParams,
|
||||
} from "react-router-dom"
|
||||
import { NavLink, Outlet, useNavigate, useParams } from "react-router-dom"
|
||||
import { combineClasses } from "util/combineClasses"
|
||||
import {
|
||||
TemplateContext,
|
||||
@@ -27,14 +13,7 @@ import { Stack } from "components/Stack/Stack"
|
||||
import { Permissions } from "xServices/auth/authXService"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
import { Avatar } from "components/Avatar/Avatar"
|
||||
|
||||
const Language = {
|
||||
settingsButton: "Settings",
|
||||
editButton: "Edit",
|
||||
createButton: "Create workspace",
|
||||
noDescription: "",
|
||||
}
|
||||
import { TemplatePageHeader } from "./TemplatePageHeader"
|
||||
|
||||
const useTemplateName = () => {
|
||||
const { template } = useParams()
|
||||
@@ -65,38 +44,10 @@ export const useTemplateLayoutContext = (): TemplateLayoutContextValue => {
|
||||
return context
|
||||
}
|
||||
|
||||
const TemplateSettingsButton: FC<{ templateName: string }> = ({
|
||||
templateName,
|
||||
}) => (
|
||||
<Link
|
||||
underline="none"
|
||||
component={RouterLink}
|
||||
to={`/templates/${templateName}/settings`}
|
||||
>
|
||||
<Button variant="outlined" startIcon={<SettingsOutlined />}>
|
||||
{Language.settingsButton}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
const CreateWorkspaceButton: FC<{
|
||||
templateName: string
|
||||
className?: string
|
||||
}> = ({ templateName, className }) => (
|
||||
<Link
|
||||
underline="none"
|
||||
component={RouterLink}
|
||||
to={`/templates/${templateName}/workspace`}
|
||||
>
|
||||
<Button className={className ?? ""} startIcon={<AddCircleOutline />}>
|
||||
{Language.createButton}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
|
||||
children = <Outlet />,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const styles = useStyles()
|
||||
const organizationId = useOrganizationId()
|
||||
const templateName = useTemplateName()
|
||||
@@ -108,58 +59,20 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
|
||||
})
|
||||
const { template, permissions: templatePermissions } = templateState.context
|
||||
const permissions = usePermissions()
|
||||
const hasIcon = template && template.icon && template.icon !== ""
|
||||
|
||||
if (!template) {
|
||||
if (!template || !templatePermissions) {
|
||||
return <Loader />
|
||||
}
|
||||
|
||||
const generatePageHeaderActions = (): JSX.Element[] => {
|
||||
const pageActions: JSX.Element[] = []
|
||||
|
||||
if (templatePermissions?.canUpdateTemplate) {
|
||||
pageActions.push(<TemplateSettingsButton templateName={template.name} />)
|
||||
}
|
||||
|
||||
pageActions.push(<CreateWorkspaceButton templateName={template.name} />)
|
||||
|
||||
return pageActions
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
{generatePageHeaderActions().map((action, i) => (
|
||||
<div key={i}>{action}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack direction="row" spacing={3} className={styles.pageTitle}>
|
||||
{hasIcon ? (
|
||||
<Avatar size="xl" src={template.icon} variant="square" fitImage />
|
||||
) : (
|
||||
<Avatar size="xl">{template.name}</Avatar>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<PageHeaderTitle>
|
||||
{template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderSubtitle condensed>
|
||||
{template.description === ""
|
||||
? Language.noDescription
|
||||
: template.description}
|
||||
</PageHeaderSubtitle>
|
||||
</div>
|
||||
</Stack>
|
||||
</PageHeader>
|
||||
</Margins>
|
||||
<TemplatePageHeader
|
||||
template={template}
|
||||
permissions={templatePermissions}
|
||||
onDeleteTemplate={() => {
|
||||
navigate("/templates")
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.tabs}>
|
||||
<Margins>
|
||||
@@ -204,17 +117,6 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
|
||||
|
||||
export const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
pageTitle: {
|
||||
alignItems: "center",
|
||||
},
|
||||
iconWrapper: {
|
||||
width: theme.spacing(6),
|
||||
height: theme.spacing(6),
|
||||
"& img": {
|
||||
width: "100%",
|
||||
},
|
||||
},
|
||||
|
||||
tabs: {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
marginBottom: theme.spacing(5),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import { MockTemplate } from "testHelpers/entities"
|
||||
import {
|
||||
TemplatePageHeader,
|
||||
TemplatePageHeaderProps,
|
||||
} from "./TemplatePageHeader"
|
||||
|
||||
export default {
|
||||
title: "Components/TemplatePageHeader",
|
||||
component: TemplatePageHeader,
|
||||
argTypes: {
|
||||
template: {
|
||||
defaultValue: MockTemplate,
|
||||
},
|
||||
permissions: {
|
||||
defaultValue: {
|
||||
canUpdateTemplate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ComponentMeta<typeof TemplatePageHeader>
|
||||
|
||||
const Template: Story<TemplatePageHeaderProps> = (args) => (
|
||||
<TemplatePageHeader {...args} />
|
||||
)
|
||||
|
||||
export const CanUpdate = Template.bind({})
|
||||
CanUpdate.args = {}
|
||||
|
||||
export const CanNotUpdate = Template.bind({})
|
||||
CanNotUpdate.args = {
|
||||
permissions: {
|
||||
canUpdateTemplate: false,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import DeleteOutlined from "@material-ui/icons/DeleteOutlined"
|
||||
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
|
||||
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
|
||||
import { AuthorizationResponse, Template } from "api/typesGenerated"
|
||||
import { Avatar } from "components/Avatar/Avatar"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderTitle,
|
||||
PageHeaderSubtitle,
|
||||
} from "components/PageHeader/PageHeader"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { FC } from "react"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import { useDeleteTemplate } from "./deleteTemplate"
|
||||
import { Margins } from "components/Margins/Margins"
|
||||
|
||||
const Language = {
|
||||
editButton: "Edit",
|
||||
settingsButton: "Settings",
|
||||
createButton: "Create workspace",
|
||||
deleteButton: "Delete",
|
||||
}
|
||||
|
||||
const TemplateSettingsButton: FC<{ templateName: string }> = ({
|
||||
templateName,
|
||||
}) => (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/templates/${templateName}/settings`}
|
||||
startIcon={<SettingsOutlined />}
|
||||
>
|
||||
{Language.settingsButton}
|
||||
</Button>
|
||||
)
|
||||
|
||||
const CreateWorkspaceButton: FC<{
|
||||
templateName: string
|
||||
className?: string
|
||||
}> = ({ templateName }) => (
|
||||
<Button
|
||||
startIcon={<AddCircleOutline />}
|
||||
component={RouterLink}
|
||||
to={`/templates/${templateName}/workspace`}
|
||||
>
|
||||
{Language.createButton}
|
||||
</Button>
|
||||
)
|
||||
|
||||
const DeleteTemplateButton: FC<{ onClick: () => void }> = ({ onClick }) => (
|
||||
<Button startIcon={<DeleteOutlined />} onClick={onClick}>
|
||||
{Language.deleteButton}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export type TemplatePageHeaderProps = {
|
||||
template: Template
|
||||
permissions: AuthorizationResponse
|
||||
onDeleteTemplate: () => void
|
||||
}
|
||||
|
||||
export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
|
||||
template,
|
||||
permissions,
|
||||
onDeleteTemplate,
|
||||
}) => {
|
||||
const hasIcon = template.icon && template.icon !== ""
|
||||
const deleteTemplate = useDeleteTemplate(template, onDeleteTemplate)
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<Maybe condition={permissions.canUpdateTemplate}>
|
||||
<DeleteTemplateButton
|
||||
onClick={deleteTemplate.openDeleteConfirmation}
|
||||
/>
|
||||
<TemplateSettingsButton templateName={template.name} />
|
||||
</Maybe>
|
||||
<CreateWorkspaceButton templateName={template.name} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack direction="row" spacing={3} alignItems="center">
|
||||
{hasIcon ? (
|
||||
<Avatar size="xl" src={template.icon} variant="square" fitImage />
|
||||
) : (
|
||||
<Avatar size="xl">{template.name}</Avatar>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<PageHeaderTitle>
|
||||
{template.display_name.length > 0
|
||||
? template.display_name
|
||||
: template.name}
|
||||
</PageHeaderTitle>
|
||||
{template.description !== "" && (
|
||||
<PageHeaderSubtitle condensed>
|
||||
{template.description}
|
||||
</PageHeaderSubtitle>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</PageHeader>
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={deleteTemplate.isDeleteDialogOpen}
|
||||
confirmLoading={deleteTemplate.state.status === "deleting"}
|
||||
onConfirm={deleteTemplate.confirmDelete}
|
||||
onCancel={deleteTemplate.cancelDeleteConfirmation}
|
||||
entity="template"
|
||||
name={template.name}
|
||||
/>
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react"
|
||||
import { MockTemplate } from "testHelpers/entities"
|
||||
import { useDeleteTemplate } from "./deleteTemplate"
|
||||
import * as API from "api/api"
|
||||
|
||||
test("delete dialog starts closed", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDeleteTemplate(MockTemplate, jest.fn()),
|
||||
)
|
||||
expect(result.current.isDeleteDialogOpen).toBeFalsy()
|
||||
})
|
||||
|
||||
test("confirm template deletion", async () => {
|
||||
const onDeleteTemplate = jest.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useDeleteTemplate(MockTemplate, onDeleteTemplate),
|
||||
)
|
||||
|
||||
//Open delete confirmation
|
||||
act(() => {
|
||||
result.current.openDeleteConfirmation()
|
||||
})
|
||||
expect(result.current.isDeleteDialogOpen).toBeTruthy()
|
||||
|
||||
// Confirm delete
|
||||
jest.spyOn(API, "deleteTemplate")
|
||||
await act(async () => result.current.confirmDelete())
|
||||
await waitFor(() => expect(API.deleteTemplate).toBeCalledTimes(1))
|
||||
expect(onDeleteTemplate).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
test("cancel template deletion", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDeleteTemplate(MockTemplate, jest.fn()),
|
||||
)
|
||||
|
||||
//Open delete confirmation
|
||||
act(() => {
|
||||
result.current.openDeleteConfirmation()
|
||||
})
|
||||
expect(result.current.isDeleteDialogOpen).toBeTruthy()
|
||||
|
||||
// Cancel deletion
|
||||
act(() => {
|
||||
result.current.cancelDeleteConfirmation()
|
||||
})
|
||||
expect(result.current.isDeleteDialogOpen).toBeFalsy()
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { deleteTemplate } from "api/api"
|
||||
import { Template } from "api/typesGenerated"
|
||||
import { useState } from "react"
|
||||
|
||||
type DeleteTemplateState =
|
||||
| { status: "idle" }
|
||||
| { status: "confirming" }
|
||||
| { status: "deleting" }
|
||||
|
||||
export const useDeleteTemplate = (template: Template, onDelete: () => void) => {
|
||||
const [state, setState] = useState<DeleteTemplateState>({ status: "idle" })
|
||||
const isDeleteDialogOpen =
|
||||
state.status === "confirming" || state.status === "deleting"
|
||||
|
||||
const openDeleteConfirmation = () => {
|
||||
setState({ status: "confirming" })
|
||||
}
|
||||
|
||||
const cancelDeleteConfirmation = () => {
|
||||
setState({ status: "idle" })
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setState({ status: "deleting" })
|
||||
await deleteTemplate(template.id)
|
||||
onDelete()
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
isDeleteDialogOpen,
|
||||
openDeleteConfirmation,
|
||||
cancelDeleteConfirmation,
|
||||
confirmDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"generalInfo": {
|
||||
"title": "General info",
|
||||
"description": "The name is used to identify the template in URLs and the API. It must be unique."
|
||||
},
|
||||
"displayInfo": {
|
||||
"title": "Display info",
|
||||
"description": "Give your template a friendly name, description, and icon."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule",
|
||||
"description": "Define when workspaces created from this template automatically stop."
|
||||
},
|
||||
"operations": {
|
||||
"title": "Operations",
|
||||
"description": "Regulate actions allowed on workspaces created from this template."
|
||||
},
|
||||
"parameters": {
|
||||
"title": "Template variables",
|
||||
"description": "These variables are provided by your template's Terraform configuration."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"displayName": "Display name",
|
||||
"description": "Description",
|
||||
"icon": "Icon",
|
||||
"autoStop": "Auto-stop default",
|
||||
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs"
|
||||
},
|
||||
"helperText": {
|
||||
"autoStop": "Time in hours",
|
||||
"allowUsersToCancel": "If checked, users may be able to corrupt their workspace."
|
||||
},
|
||||
"upload": {
|
||||
"removeTitle": "Remove file",
|
||||
"title": "Upload template"
|
||||
},
|
||||
"tooltip": {
|
||||
"allowUsersToCancel": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases."
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,21 @@
|
||||
"ttlHelperText_other": "Workspaces created from this template will default to stopping after {{count}} hours.",
|
||||
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
|
||||
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.",
|
||||
"dangerZone": {
|
||||
"dangerZoneHeader": "Danger Zone",
|
||||
"deleteTemplateHeader": "Delete this template",
|
||||
"deleteTemplateCaption": "Do you want to permanently delete this template?",
|
||||
"deleteCta": "Delete Template"
|
||||
"allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.",
|
||||
"generalInfo": {
|
||||
"title": "General info",
|
||||
"description": "The name is used to identify the template in URLs and the API. It must be unique within your organization."
|
||||
},
|
||||
"displayInfo": {
|
||||
"title": "Display info",
|
||||
"description": "Give your template a friendly name, description, and icon."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule",
|
||||
"description": "Define when workspaces created from this template automatically stop."
|
||||
},
|
||||
"operations": {
|
||||
"title": "Operations",
|
||||
"description": "Regulate actions allowed on workspaces created from this template."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import Box from "@material-ui/core/Box"
|
||||
import Checkbox from "@material-ui/core/Checkbox"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import TextField from "@material-ui/core/TextField"
|
||||
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
||||
import { FormFooter } from "components/FormFooter/FormFooter"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { FormikContextType, FormikTouched, useFormik } from "formik"
|
||||
import { FC } from "react"
|
||||
import {
|
||||
@@ -18,6 +13,16 @@ import i18next from "i18next"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { LazyIconField } from "components/IconField/LazyIconField"
|
||||
import {
|
||||
FormFields,
|
||||
FormSection,
|
||||
HorizontalForm,
|
||||
FormFooter,
|
||||
} from "components/HorizontalForm/HorizontalForm"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import Checkbox from "@material-ui/core/Checkbox"
|
||||
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
|
||||
const TTLHelperText = ({ ttl }: { ttl?: number }) => {
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
@@ -101,48 +106,69 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
||||
})
|
||||
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit} aria-label={t("formAriaLabel")}>
|
||||
<Stack>
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
label={t("nameLabel")}
|
||||
variant="outlined"
|
||||
/>
|
||||
<HorizontalForm
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label={t("formAriaLabel")}
|
||||
>
|
||||
<FormSection
|
||||
title={t("generalInfo.title")}
|
||||
description={t("generalInfo.description")}
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("name")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
autoFocus
|
||||
fullWidth
|
||||
label={t("nameLabel")}
|
||||
variant="outlined"
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
label={t("displayNameLabel")}
|
||||
variant="outlined"
|
||||
/>
|
||||
<FormSection
|
||||
title={t("displayInfo.title")}
|
||||
description={t("displayInfo.description")}
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("display_name")}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
label={t("displayNameLabel")}
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
multiline
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
label={t("descriptionLabel")}
|
||||
variant="outlined"
|
||||
rows={2}
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers("description")}
|
||||
multiline
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
label={t("descriptionLabel")}
|
||||
variant="outlined"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<LazyIconField
|
||||
{...getFieldHelpers("icon")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("form.fields.icon")}
|
||||
variant="outlined"
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
<LazyIconField
|
||||
{...getFieldHelpers("icon")}
|
||||
disabled={isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
fullWidth
|
||||
label={t("iconLabel")}
|
||||
variant="outlined"
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title={t("schedule.title")}
|
||||
description={t("schedule.description")}
|
||||
>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"default_ttl_ms",
|
||||
@@ -155,30 +181,59 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
||||
variant="outlined"
|
||||
type="number"
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<Box display="flex">
|
||||
<div>
|
||||
{/*"getFieldHelpers" can't be used as it requires "helperText" property to be present.*/}
|
||||
<FormSection
|
||||
title={t("operations.title")}
|
||||
description={t("operations.description")}
|
||||
>
|
||||
<label htmlFor="allow_user_cancel_workspace_jobs">
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
id="allow_user_cancel_workspace_jobs"
|
||||
name="allow_user_cancel_workspace_jobs"
|
||||
disabled={isSubmitting}
|
||||
checked={form.values.allow_user_cancel_workspace_jobs}
|
||||
onChange={form.handleChange}
|
||||
/>
|
||||
</div>
|
||||
<Box>
|
||||
<Typography variant="h6" style={{ fontSize: 14 }}>
|
||||
{t("allowUserCancelWorkspaceJobsLabel")}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{t("allowUserCancelWorkspaceJobsNotice")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={0.5}
|
||||
className={styles.optionText}
|
||||
>
|
||||
{t("allowUserCancelWorkspaceJobsLabel")}
|
||||
|
||||
<HelpTooltip>
|
||||
<HelpTooltipText>
|
||||
{t("allowUserCancelWorkspaceJobsNotice")}
|
||||
</HelpTooltipText>
|
||||
</HelpTooltip>
|
||||
</Stack>
|
||||
<span className={styles.optionHelperText}>
|
||||
{t("allowUsersCancelHelperText")}
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</label>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
|
||||
</form>
|
||||
</HorizontalForm>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
optionText: {
|
||||
fontSize: theme.spacing(2),
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
optionHelperText: {
|
||||
fontSize: theme.spacing(1.5),
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -88,16 +88,6 @@ describe("TemplateSettingsPage", () => {
|
||||
expect(element).toBeDefined()
|
||||
})
|
||||
|
||||
it("allows an admin to delete a template", async () => {
|
||||
const { t } = i18next
|
||||
await renderTemplateSettingsPage()
|
||||
const deleteCta = t("dangerZone.deleteCta", {
|
||||
ns: "templateSettingsPage",
|
||||
})
|
||||
const deleteButton = await screen.findByText(deleteCta)
|
||||
expect(deleteButton).toBeDefined()
|
||||
})
|
||||
|
||||
it("succeeds", async () => {
|
||||
await renderTemplateSettingsPage()
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export const TemplateSettingsPage: FC = () => {
|
||||
templateSettings: template,
|
||||
saveTemplateSettingsError,
|
||||
getTemplateError,
|
||||
deleteTemplateError,
|
||||
} = state.context
|
||||
|
||||
return (
|
||||
@@ -40,7 +39,6 @@ export const TemplateSettingsPage: FC = () => {
|
||||
errors={{
|
||||
getTemplateError,
|
||||
saveTemplateSettingsError,
|
||||
deleteTemplateError,
|
||||
}}
|
||||
onCancel={() => {
|
||||
navigate(`/templates/${templateName}`)
|
||||
@@ -48,14 +46,6 @@ export const TemplateSettingsPage: FC = () => {
|
||||
onSubmit={(templateSettings) => {
|
||||
send({ type: "SAVE", templateSettings })
|
||||
}}
|
||||
onDelete={() => {
|
||||
send("DELETE")
|
||||
}}
|
||||
onConfirmDelete={() => send("CONFIRM_DELETE")}
|
||||
onCancelDelete={() => send("CANCEL_DELETE")}
|
||||
isConfirmingDelete={state.matches("confirmingDelete")}
|
||||
isDeleting={state.matches("deleting")}
|
||||
isDeleted={state.matches("deleted")}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { FullPageForm } from "components/FullPageForm/FullPageForm"
|
||||
import { Loader } from "components/Loader/Loader"
|
||||
import { ComponentProps, FC } from "react"
|
||||
import { TemplateSettingsForm } from "./TemplateSettingsForm"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import { colors } from "theme/colors"
|
||||
import Button from "@material-ui/core/Button"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Navigate } from "react-router-dom"
|
||||
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
|
||||
|
||||
export interface TemplateSettingsPageViewProps {
|
||||
template?: Template
|
||||
onSubmit: (data: UpdateTemplateMeta) => void
|
||||
onCancel: () => void
|
||||
onDelete: () => void
|
||||
onConfirmDelete: () => void
|
||||
onCancelDelete: () => void
|
||||
isConfirmingDelete: boolean
|
||||
isDeleting: boolean
|
||||
isDeleted: boolean
|
||||
isSubmitting: boolean
|
||||
errors?: {
|
||||
getTemplateError?: unknown
|
||||
saveTemplateSettingsError?: unknown
|
||||
deleteTemplateError?: unknown
|
||||
}
|
||||
initialTouched?: ComponentProps<typeof TemplateSettingsForm>["initialTouched"]
|
||||
}
|
||||
@@ -35,12 +24,6 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
||||
template,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
isConfirmingDelete,
|
||||
isDeleting,
|
||||
isDeleted,
|
||||
isSubmitting,
|
||||
errors = {},
|
||||
initialTouched,
|
||||
@@ -49,22 +32,13 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
||||
const isLoading = !template && !errors.getTemplateError
|
||||
const { t } = useTranslation("templateSettingsPage")
|
||||
|
||||
if (isDeleted) {
|
||||
return <Navigate to="/templates" />
|
||||
}
|
||||
|
||||
return (
|
||||
<FullPageForm title={t("title")}>
|
||||
<FullPageHorizontalForm title={t("title")} onCancel={onCancel}>
|
||||
{Boolean(errors.getTemplateError) && (
|
||||
<Stack className={classes.errorContainer}>
|
||||
<AlertBanner severity="error" error={errors.getTemplateError} />
|
||||
</Stack>
|
||||
)}
|
||||
{Boolean(errors.deleteTemplateError) && (
|
||||
<Stack className={classes.errorContainer}>
|
||||
<AlertBanner severity="error" error={errors.deleteTemplateError} />
|
||||
</Stack>
|
||||
)}
|
||||
{isLoading && <Loader />}
|
||||
{template && (
|
||||
<>
|
||||
@@ -76,39 +50,9 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
||||
onCancel={onCancel}
|
||||
error={errors.saveTemplateSettingsError}
|
||||
/>
|
||||
<Stack className={classes.dangerContainer}>
|
||||
<div className={classes.dangerHeader}>
|
||||
{t("dangerZone.dangerZoneHeader")}
|
||||
</div>
|
||||
|
||||
<Stack className={classes.dangerBorder}>
|
||||
<Stack spacing={0}>
|
||||
<p className={classes.deleteTemplateHeader}>
|
||||
{t("dangerZone.deleteTemplateHeader")}
|
||||
</p>
|
||||
<span>{t("dangerZone.deleteTemplateCaption")}</span>
|
||||
</Stack>
|
||||
<Button
|
||||
className={classes.deleteButton}
|
||||
onClick={onDelete}
|
||||
aria-label={t("dangerZone.deleteCta")}
|
||||
>
|
||||
{t("dangerZone.deleteCta")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={isConfirmingDelete}
|
||||
confirmLoading={isDeleting}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={onCancelDelete}
|
||||
entity="template"
|
||||
name={template.name}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FullPageForm>
|
||||
</FullPageHorizontalForm>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,27 +60,4 @@ const useStyles = makeStyles((theme) => ({
|
||||
errorContainer: {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
dangerContainer: {
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
dangerHeader: {
|
||||
fontSize: theme.typography.h5.fontSize,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
dangerBorder: {
|
||||
border: `1px solid ${colors.red[13]}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(2),
|
||||
|
||||
"& p": {
|
||||
marginTop: "0px",
|
||||
},
|
||||
},
|
||||
deleteTemplateHeader: {
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
deleteButton: {
|
||||
color: colors.red[8],
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user