diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index c348ed6a38c8a..8363fcdd7bed5 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -5,10 +5,6 @@ import { makeStyles } from "@material-ui/core/styles" import AddCircleOutline from "@material-ui/icons/AddCircleOutline" import SettingsOutlined from "@material-ui/icons/SettingsOutlined" import { useMachine, useSelector } from "@xstate/react" -import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" -import { DeleteButton } from "components/DropdownButton/ActionCtas" -import { DropdownButton } from "components/DropdownButton/DropdownButton" import { PageHeader, PageHeaderSubtitle, @@ -22,12 +18,7 @@ import { Suspense, useContext, } from "react" -import { - Link as RouterLink, - Navigate, - NavLink, - useParams, -} from "react-router-dom" +import { Link as RouterLink, NavLink, useParams } from "react-router-dom" import { combineClasses } from "util/combineClasses" import { firstLetter } from "util/firstLetter" import { selectPermissions } from "xServices/auth/authSelectors" @@ -36,8 +27,8 @@ import { TemplateContext, templateMachine, } from "xServices/template/templateXService" -import { Margins } from "../../components/Margins/Margins" -import { Stack } from "../../components/Stack/Stack" +import { Margins } from "components/Margins/Margins" +import { Stack } from "components/Stack/Stack" import { Permissions } from "xServices/auth/authXService" import { Loader } from "components/Loader/Loader" @@ -76,11 +67,40 @@ export const useTemplateLayoutContext = (): TemplateLayoutContextValue => { return context } +const TemplateSettingsButton: FC<{ templateName: string }> = ({ + templateName, +}) => ( + + }> + {Language.settingsButton} + + +) + +const CreateWorkspaceButton: FC<{ + templateName: string + className?: string +}> = ({ templateName, className }) => ( + + }> + {Language.createButton} + + +) + export const TemplateLayout: FC = ({ children }) => { const styles = useStyles() const organizationId = useOrganizationId() const templateName = useTemplateName() - const [templateState, templateSend] = useMachine(templateMachine, { + const [templateState, _] = useMachine(templateMachine, { context: { templateName, organizationId, @@ -103,30 +123,20 @@ export const TemplateLayout: FC = ({ children }) => { !templateDAUs || !templatePermissions - if (templateState.matches("deleted")) { - return - } - const hasIcon = template && template.icon && template.icon !== "" - const createWorkspaceButton = (className?: string) => ( - - } - disabled={isLoading} - > - {Language.createButton} - - - ) + const generatePageHeaderActions = (): JSX.Element[] => { + const pageActions: JSX.Element[] = [] + + if (!isLoading && templatePermissions.canUpdateTemplate) { + pageActions.push() + } - const handleDeleteTemplate = () => { - templateSend("DELETE") + if (!isLoading) { + pageActions.push() + } + + return pageActions } return ( @@ -134,36 +144,11 @@ export const TemplateLayout: FC = ({ children }) => { - - - }> - {Language.settingsButton} - - - - - ), - }, - ]} - canCancel={false} - /> - - - {createWorkspaceButton()} - - ) + <> + {generatePageHeaderActions().map((action, i) => ( + {action} + ))} + > } > @@ -234,31 +219,12 @@ export const TemplateLayout: FC = ({ children }) => { }>{children} - - {!isLoading && ( - { - templateSend("CONFIRM_DELETE") - }} - onCancel={() => { - templateSend("CANCEL_DELETE") - }} - entity="template" - name={template.name} - /> - )} > ) } export const useStyles = makeStyles((theme) => { return { - actionButton: { - border: "none", - borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, - }, pageTitle: { alignItems: "center", }, diff --git a/site/src/i18n/en/templatePage.json b/site/src/i18n/en/templatePage.json index 03daf50eb649f..fcbf8541dc492 100644 --- a/site/src/i18n/en/templatePage.json +++ b/site/src/i18n/en/templatePage.json @@ -1,4 +1,13 @@ { "deleteSuccess": "Template successfully deleted.", - "createdVersion": "created the version" + "createdVersion": "created the version", + "templateSettings": { + "title": "Template settings", + "dangerZone": { + "dangerZoneHeader": "Danger Zone", + "deleteTemplateHeader": "Delete this template", + "deleteTemplateCaption": "Once you delete a template, there is no going back. Please be certain.", + "deleteCta": "Delete Template" + } + } } diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx index fd2945506365a..bfad63f8f0b5a 100644 --- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from "@testing-library/react" +import { screen } from "@testing-library/react" import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import { rest } from "msw" import { ResizeObserver } from "resize-observer" @@ -42,13 +42,6 @@ describe("TemplateSummaryPage", () => { screen.getByText(MockWorkspaceResource.name) screen.queryAllByText(`${MockTemplateVersion.name}`).length }) - it("allows an admin to delete a template", async () => { - renderPage() - const dropdownButton = await screen.findByLabelText("open-dropdown") - fireEvent.click(dropdownButton) - const deleteButton = await screen.findByText("Delete") - expect(deleteButton).toBeDefined() - }) it("does not allow a member to delete a template", () => { // get member-level permissions server.use( diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx index dbc8e7b820b25..7155f1b2d4354 100644 --- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx @@ -12,7 +12,6 @@ export const TemplateSummaryPage: FC = () => { activeTemplateVersion, templateResources, templateVersions, - deleteTemplateError, templateDAUs, } = context @@ -31,7 +30,6 @@ export const TemplateSummaryPage: FC = () => { templateResources={templateResources} templateVersions={templateVersions} templateDAUs={templateDAUs} - deleteTemplateError={deleteTemplateError} /> > ) diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx index fe32208da56eb..11433139f86cb 100644 --- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx @@ -5,7 +5,6 @@ import { TemplateVersion, WorkspaceResource, } from "api/typesGenerated" -import { AlertBanner } from "components/AlertBanner/AlertBanner" import { MemoizedMarkdown } from "components/Markdown/Markdown" import { Stack } from "components/Stack/Stack" import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" @@ -21,7 +20,6 @@ export interface TemplateSummaryPageViewProps { templateResources: WorkspaceResource[] templateVersions?: TemplateVersion[] templateDAUs?: TemplateDAUsResponse - deleteTemplateError: Error | unknown } export const TemplateSummaryPageView: FC< @@ -32,15 +30,10 @@ export const TemplateSummaryPageView: FC< templateResources, templateVersions, templateDAUs, - deleteTemplateError, }) => { const styles = useStyles() const readme = frontMatter(activeTemplateVersion.readme) - const deleteError = deleteTemplateError ? ( - - ) : null - const getStartedResources = (resources: WorkspaceResource[]) => { return resources.filter( (resource) => resource.workspace_transition === "start", @@ -49,7 +42,6 @@ export const TemplateSummaryPageView: FC< return ( - {deleteError} = ({ there are no validation errors for that field, display helper text. We do not use the MUI helper-text prop because it overrides the validation error */} {form.values.default_ttl_ms && !form.errors.default_ttl_ms && ( - - {Language.ttlHelperText(form.values.default_ttl_ms)} - + {Language.ttlHelperText(form.values.default_ttl_ms)} )} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx index 95aed343c0d01..2d5806c6d8d50 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.test.tsx @@ -10,7 +10,7 @@ import { validationSchema, } from "./TemplateSettingsForm" import { TemplateSettingsPage } from "./TemplateSettingsPage" -import { Language as ViewLanguage } from "./TemplateSettingsPageView" +import i18next from "i18next" const renderTemplateSettingsPage = async () => { const renderResult = renderWithAuth(, { @@ -61,11 +61,25 @@ const fillAndSubmitForm = async ({ describe("TemplateSettingsPage", () => { it("renders", async () => { + const { t } = i18next + const pageTitle = t("templateSettings.title", { + ns: "templatePage", + }) await renderTemplateSettingsPage() - const element = await screen.findByText(ViewLanguage.title) + const element = await screen.findByText(pageTitle) expect(element).toBeDefined() }) + it("allows an admin to delete a template", async () => { + const { t } = i18next + await renderTemplateSettingsPage() + const deleteCta = t("templateSettings.dangerZone.deleteCta", { + ns: "templatePage", + }) + 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 464591223ecf1..e157e63880fa3 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPage.tsx @@ -28,6 +28,7 @@ export const TemplateSettingsPage: FC = () => { templateSettings: template, saveTemplateSettingsError, getTemplateError, + deleteTemplateError, } = state.context return ( @@ -41,6 +42,7 @@ export const TemplateSettingsPage: FC = () => { errors={{ getTemplateError, saveTemplateSettingsError, + deleteTemplateError, }} onCancel={() => { navigate(`/templates/${templateName}`) @@ -48,6 +50,14 @@ 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 f05fabbbec001..41044bcea8dce 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.tsx @@ -4,19 +4,29 @@ import { FullPageForm } from "components/FullPageForm/FullPageForm" import { Loader } from "components/Loader/Loader" import { ComponentProps, FC } from "react" import { TemplateSettingsForm } from "./TemplateSettingsForm" - -export const Language = { - title: "Template settings", -} +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" 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"] } @@ -25,28 +35,110 @@ export const TemplateSettingsPageView: FC = ({ template, onCancel, onSubmit, + onDelete, + onConfirmDelete, + onCancelDelete, + isConfirmingDelete, + isDeleting, + isDeleted, isSubmitting, errors = {}, initialTouched, }) => { + const classes = useStyles() const isLoading = !template && !errors.getTemplateError + const { t } = useTranslation("templatePage") + + if (isDeleted) { + return + } return ( - + {Boolean(errors.getTemplateError) && ( - + + + + )} + {Boolean(errors.deleteTemplateError) && ( + + + )} {isLoading && } {template && ( - + <> + + + + {t("templateSettings.dangerZone.dangerZoneHeader")} + + + + + + {t("templateSettings.dangerZone.deleteTemplateHeader")} + + + {t("templateSettings.dangerZone.deleteTemplateCaption")} + + + + {t("templateSettings.dangerZone.deleteCta")} + + + + + + > )} ) } + +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], + }, +})) diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts index 436e6daf08873..b7b836e4565ef 100644 --- a/site/src/xServices/template/templateXService.ts +++ b/site/src/xServices/template/templateXService.ts @@ -1,22 +1,19 @@ -import { displaySuccess } from "components/GlobalSnackbar/utils" -import { t } from "i18next" import { assign, createMachine } from "xstate" import { checkAuthorization, - deleteTemplate, getTemplateByName, getTemplateDAUs, getTemplateVersion, getTemplateVersionResources, getTemplateVersions, -} from "../../api/api" +} from "api/api" import { AuthorizationResponse, Template, TemplateDAUsResponse, TemplateVersion, WorkspaceResource, -} from "../../api/typesGenerated" +} from "api/typesGenerated" export interface TemplateContext { organizationId: string @@ -27,15 +24,9 @@ export interface TemplateContext { templateVersions?: TemplateVersion[] templateDAUs?: TemplateDAUsResponse permissions?: AuthorizationResponse - deleteTemplateError?: Error | unknown getTemplateError?: Error | unknown } -type TemplateEvent = - | { type: "DELETE" } - | { type: "CONFIRM_DELETE" } - | { type: "CANCEL_DELETE" } - const getPermissionsToCheck = (templateId: string) => ({ canUpdateTemplate: { object: { @@ -55,7 +46,6 @@ export const templateMachine = tsTypes: {} as import("./templateXService.typegen").Typegen0, schema: { context: {} as TemplateContext, - events: {} as TemplateEvent, services: {} as { getTemplate: { data: Template @@ -69,9 +59,6 @@ export const templateMachine = getTemplateVersions: { data: TemplateVersion[] } - deleteTemplate: { - data: Template - } getTemplateDAUs: { data: TemplateDAUsResponse } @@ -201,11 +188,6 @@ export const templateMachine = }, }, loaded: { - on: { - DELETE: { - target: "confirmingDelete", - }, - }, initial: "waiting", states: { refreshingTemplate: { @@ -222,38 +204,6 @@ export const templateMachine = }, }, }, - confirmingDelete: { - on: { - CONFIRM_DELETE: { - target: "deleting", - }, - CANCEL_DELETE: { - target: "loaded", - }, - }, - }, - deleting: { - entry: "clearDeleteTemplateError", - invoke: { - src: "deleteTemplate", - id: "deleteTemplate", - onDone: [ - { - target: "deleted", - actions: "displayDeleteSuccess", - }, - ], - onError: [ - { - actions: "assignDeleteTemplateError", - target: "loaded", - }, - ], - }, - }, - deleted: { - type: "final", - }, error: { type: "final", }, @@ -284,12 +234,6 @@ export const templateMachine = return getTemplateVersions(ctx.template.id) }, - deleteTemplate: (ctx) => { - if (!ctx.template) { - throw new Error("Template not loaded") - } - return deleteTemplate(ctx.template.id) - }, getTemplateDAUs: (ctx) => { if (!ctx.template) { throw new Error("Template not loaded") @@ -327,14 +271,6 @@ export const templateMachine = assignPermissions: assign({ permissions: (_, event) => event.data, }), - assignDeleteTemplateError: assign({ - deleteTemplateError: (_, event) => event.data, - }), - clearDeleteTemplateError: assign({ - deleteTemplateError: (_) => undefined, - }), - displayDeleteSuccess: () => - displaySuccess(t("deleteSuccess", { ns: "templatePage" })), }, }, ) diff --git a/site/src/xServices/templateSettings/templateSettingsXService.ts b/site/src/xServices/templateSettings/templateSettingsXService.ts index 7b49aeb3212a6..f8420a9625c71 100644 --- a/site/src/xServices/templateSettings/templateSettingsXService.ts +++ b/site/src/xServices/templateSettings/templateSettingsXService.ts @@ -1,7 +1,9 @@ -import { getTemplateByName, updateTemplateMeta } from "api/api" +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 */ @@ -17,6 +19,7 @@ export const templateSettingsMachine = templateSettings?: Template getTemplateError?: unknown saveTemplateSettingsError?: unknown + deleteTemplateError?: Error | unknown } services: { getTemplateSettings: { @@ -26,7 +29,11 @@ export const templateSettingsMachine = data: Template } } - events: { type: "SAVE"; templateSettings: UpdateTemplateMeta } + events: + | { type: "SAVE"; templateSettings: UpdateTemplateMeta } + | { type: "DELETE" } + | { type: "CONFIRM_DELETE" } + | { type: "CANCEL_DELETE" } }, initial: "loading", states: { @@ -50,8 +57,43 @@ export const templateSettingsMachine = 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", @@ -94,6 +136,12 @@ export const templateSettingsMachine = return updateTemplateMeta(templateSettings.id, newTemplateSettings) }, + deleteTemplate: (ctx) => { + if (!ctx.templateSettings) { + throw new Error("Template not loaded") + } + return deleteTemplate(ctx.templateSettings.id) + }, }, actions: { assignTemplateSettings: assign({ @@ -105,6 +153,14 @@ export const templateSettingsMachine = assignSaveTemplateSettingsError: assign({ saveTemplateSettingsError: (_, { data }) => data, }), + assignDeleteTemplateError: assign({ + deleteTemplateError: (_, event) => event.data, + }), + clearDeleteTemplateError: assign({ + deleteTemplateError: (_) => undefined, + }), + displayDeleteSuccess: () => + displaySuccess(t("deleteSuccess", { ns: "templatePage" })), }, }, )
+ {t("templateSettings.dangerZone.deleteTemplateHeader")} +