diff --git a/site/src/components/HorizontalForm/HorizontalForm.tsx b/site/src/components/HorizontalForm/HorizontalForm.tsx new file mode 100644 index 0000000000000..42dd9e00fa369 --- /dev/null +++ b/site/src/components/HorizontalForm/HorizontalForm.tsx @@ -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 +> = ({ children, ...formProps }) => { + const styles = useStyles() + + return ( +
+ + {children} + +
+ ) +} + +export const FormSection: FC< + PropsWithChildren & { title: string; description: string | JSX.Element } +> = ({ children, title, description }) => { + const styles = useStyles() + + return ( +
+
+

{title}

+
{description}
+
+ + {children} +
+ ) +} + +export const FormFields: FC = ({ children }) => { + const styles = useStyles() + return ( + + {children} + + ) +} + +export const FormFooter: FC = (props) => { + const formFooterStyles = useFormFooterStyles() + return ( + + ) +} + +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), + }, + }, +})) diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index fa02caff789ac..4716698baa392 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -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, -}) => ( - - - -) - -const CreateWorkspaceButton: FC<{ - templateName: string - className?: string -}> = ({ templateName, className }) => ( - - - -) - export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ children = , }) => { + 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 } - const generatePageHeaderActions = (): JSX.Element[] => { - const pageActions: JSX.Element[] = [] - - if (templatePermissions?.canUpdateTemplate) { - pageActions.push() - } - - pageActions.push() - - return pageActions - } - return ( <> - - - {generatePageHeaderActions().map((action, i) => ( -
{action}
- ))} - - } - > - - {hasIcon ? ( - - ) : ( - {template.name} - )} - -
- - {template.display_name.length > 0 - ? template.display_name - : template.name} - - - {template.description === "" - ? Language.noDescription - : template.description} - -
-
-
-
+ { + navigate("/templates") + }} + />
@@ -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), diff --git a/site/src/components/TemplateLayout/TemplatePageHeader.stories.tsx b/site/src/components/TemplateLayout/TemplatePageHeader.stories.tsx new file mode 100644 index 0000000000000..07d5334961ea6 --- /dev/null +++ b/site/src/components/TemplateLayout/TemplatePageHeader.stories.tsx @@ -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 + +const Template: Story = (args) => ( + +) + +export const CanUpdate = Template.bind({}) +CanUpdate.args = {} + +export const CanNotUpdate = Template.bind({}) +CanNotUpdate.args = { + permissions: { + canUpdateTemplate: false, + }, +} diff --git a/site/src/components/TemplateLayout/TemplatePageHeader.tsx b/site/src/components/TemplateLayout/TemplatePageHeader.tsx new file mode 100644 index 0000000000000..3ddafafb211a7 --- /dev/null +++ b/site/src/components/TemplateLayout/TemplatePageHeader.tsx @@ -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, +}) => ( + +) + +const CreateWorkspaceButton: FC<{ + templateName: string + className?: string +}> = ({ templateName }) => ( + +) + +const DeleteTemplateButton: FC<{ onClick: () => void }> = ({ onClick }) => ( + +) + +export type TemplatePageHeaderProps = { + template: Template + permissions: AuthorizationResponse + onDeleteTemplate: () => void +} + +export const TemplatePageHeader: FC = ({ + template, + permissions, + onDeleteTemplate, +}) => { + const hasIcon = template.icon && template.icon !== "" + const deleteTemplate = useDeleteTemplate(template, onDeleteTemplate) + + return ( + + + + + + + + + } + > + + {hasIcon ? ( + + ) : ( + {template.name} + )} + +
+ + {template.display_name.length > 0 + ? template.display_name + : template.name} + + {template.description !== "" && ( + + {template.description} + + )} +
+
+
+ + +
+ ) +} diff --git a/site/src/components/TemplateLayout/deleteTemplate.test.ts b/site/src/components/TemplateLayout/deleteTemplate.test.ts new file mode 100644 index 0000000000000..c573db4951818 --- /dev/null +++ b/site/src/components/TemplateLayout/deleteTemplate.test.ts @@ -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() +}) diff --git a/site/src/components/TemplateLayout/deleteTemplate.ts b/site/src/components/TemplateLayout/deleteTemplate.ts new file mode 100644 index 0000000000000..b3ef46bedb737 --- /dev/null +++ b/site/src/components/TemplateLayout/deleteTemplate.ts @@ -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({ 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, + } +} diff --git a/site/src/i18n/en/templateForm.json b/site/src/i18n/en/templateForm.json new file mode 100644 index 0000000000000..2ee747df36409 --- /dev/null +++ b/site/src/i18n/en/templateForm.json @@ -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." + } +} diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index 5d3c61b3b0356..f738567b50756 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -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." } } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx index d77aefedc56b1..84bdbe314b0fd 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx @@ -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 = ({ }) const getFieldHelpers = getFormHelpers(form, error) const { t } = useTranslation("templateSettingsPage") + const styles = useStyles() return ( -
- - + + + + + + - + + + - + - form.setFieldValue("icon", value)} - /> + form.setFieldValue("icon", value)} + /> + + + = ({ variant="outlined" type="number" /> + - -
- {/*"getFieldHelpers" can't be used as it requires "helperText" property to be present.*/} + +
- - - {t("allowUserCancelWorkspaceJobsLabel")} - - - {t("allowUserCancelWorkspaceJobsNotice")} - - -
-
+ + + + {t("allowUserCancelWorkspaceJobsLabel")} + + + + {t("allowUserCancelWorkspaceJobsNotice")} + + + + + {t("allowUsersCancelHelperText")} + + + + + - + ) } + +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, + }, +})) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx index bab264530a146..94d227e67d4b9 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx @@ -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() diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx index 21816f6a8780b..b00a56ac569d4 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx @@ -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")} /> ) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx index eb4baffa5c7af..6c514e1470b9a 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx @@ -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["initialTouched"] } @@ -35,12 +24,6 @@ export const TemplateSettingsPageView: FC = ({ template, onCancel, onSubmit, - onDelete, - onConfirmDelete, - onCancelDelete, - isConfirmingDelete, - isDeleting, - isDeleted, isSubmitting, errors = {}, initialTouched, @@ -49,22 +32,13 @@ export const TemplateSettingsPageView: FC = ({ const isLoading = !template && !errors.getTemplateError const { t } = useTranslation("templateSettingsPage") - if (isDeleted) { - return - } - return ( - + {Boolean(errors.getTemplateError) && ( )} - {Boolean(errors.deleteTemplateError) && ( - - - - )} {isLoading && } {template && ( <> @@ -76,39 +50,9 @@ export const TemplateSettingsPageView: FC = ({ onCancel={onCancel} error={errors.saveTemplateSettingsError} /> - -
- {t("dangerZone.dangerZoneHeader")} -
- - - -

- {t("dangerZone.deleteTemplateHeader")} -

- {t("dangerZone.deleteTemplateCaption")} -
- -
-
- - )} -
+ ) } @@ -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], - }, }))