diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 20a5156e9d3f9..b8f84ce598a03 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -153,6 +153,11 @@ export const updateTemplateMeta = async ( return response.data } +export const deleteTemplate = async (templateId: string): Promise => { + const response = await axios.delete(`/api/v2/templates/${templateId}`) + return response.data +} + export const getWorkspace = async ( workspaceId: string, params?: TypesGen.WorkspaceOptions, diff --git a/site/src/components/DropdownArrows/DropdownArrows.tsx b/site/src/components/DropdownArrows/DropdownArrows.tsx index fa438157e87d0..f8cea75582f8d 100644 --- a/site/src/components/DropdownArrows/DropdownArrows.tsx +++ b/site/src/components/DropdownArrows/DropdownArrows.tsx @@ -21,10 +21,15 @@ interface ArrowProps { export const OpenDropdown: FC = ({ margin = true }) => { const styles = useStyles({ margin }) - return + return } export const CloseDropdown: FC = ({ margin = true }) => { const styles = useStyles({ margin }) - return + return ( + + ) } diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/DropdownButton/ActionCtas.tsx similarity index 100% rename from site/src/components/WorkspaceActions/ActionCtas.tsx rename to site/src/components/DropdownButton/ActionCtas.tsx diff --git a/site/src/components/DropdownButton/DropdownButton.stories.tsx b/site/src/components/DropdownButton/DropdownButton.stories.tsx new file mode 100644 index 0000000000000..6b9f70d88589f --- /dev/null +++ b/site/src/components/DropdownButton/DropdownButton.stories.tsx @@ -0,0 +1,30 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import { WorkspaceStateEnum } from "util/workspace" +import { DeleteButton, DisabledButton, StartButton, UpdateButton } from "./ActionCtas" +import { DropdownButton, DropdownButtonProps } from "./DropdownButton" + +export default { + title: "Components/DropdownButton", + component: DropdownButton, +} + +const Template: Story = (args) => + +export const WithDropdown = Template.bind({}) +WithDropdown.args = { + primaryAction: , + secondaryActions: [ + { action: "update", button: }, + { action: "delete", button: }, + ], + canCancel: false, +} + +export const WithCancel = Template.bind({}) +WithCancel.args = { + primaryAction: , + secondaryActions: [], + canCancel: true, + handleCancel: action("cancel"), +} diff --git a/site/src/components/DropdownButton/DropdownButton.tsx b/site/src/components/DropdownButton/DropdownButton.tsx new file mode 100644 index 0000000000000..67e129971b512 --- /dev/null +++ b/site/src/components/DropdownButton/DropdownButton.tsx @@ -0,0 +1,104 @@ +import Button from "@material-ui/core/Button" +import Popover from "@material-ui/core/Popover" +import { makeStyles } from "@material-ui/core/styles" +import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows" +import { DropdownContent } from "components/DropdownButton/DropdownContent/DropdownContent" +import { FC, ReactNode, useRef, useState } from "react" +import { CancelButton } from "./ActionCtas" + +export interface DropdownButtonProps { + primaryAction: ReactNode + secondaryActions: Array<{ action: string; button: ReactNode }> + canCancel: boolean + handleCancel?: () => void +} + +export const DropdownButton: FC = ({ + primaryAction, + secondaryActions, + canCancel, + handleCancel, +}) => { + const styles = useStyles() + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "action-popover" : undefined + + return ( + + {/* primary workspace CTA */} + + {primaryAction} + + {canCancel && handleCancel ? ( + + ) : ( + <> + {/* popover toggle button */} + + setIsOpen(false)} + onBlur={() => setIsOpen(false)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + {/* secondary workspace CTAs */} + + + + )} + + ) +} + +const useStyles = makeStyles((theme) => ({ + buttonContainer: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: `${theme.shape.borderRadius}px`, + display: "inline-flex", + }, + dropdownButton: { + border: "none", + borderLeft: `1px solid ${theme.palette.divider}`, + borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, + minWidth: "unset", + width: "63px", // matching cancel button so button grouping doesn't grow in size + "& .MuiButton-label": { + marginRight: "8px", + }, + }, + primaryCta: { + [theme.breakpoints.down("sm")]: { + width: "100%", + + "& > *": { + width: "100%", + }, + }, + }, + popoverPaper: { + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing(1)}px`, + }, +})) diff --git a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx b/site/src/components/DropdownButton/DropdownContent/DropdownContent.tsx similarity index 70% rename from site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx rename to site/src/components/DropdownButton/DropdownContent/DropdownContent.tsx index 3ce44a3e2e00a..9810c6abbad6f 100644 --- a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx +++ b/site/src/components/DropdownButton/DropdownContent/DropdownContent.tsx @@ -1,24 +1,21 @@ import { makeStyles } from "@material-ui/core/styles" -import { FC } from "react" -import { ButtonMapping, ButtonTypesEnum } from "../constants" +import { FC, ReactNode } from "react" export interface DropdownContentProps { - secondaryActions: ButtonTypesEnum[] - buttonMapping: Partial + secondaryActions: Array<{ action: string; button: ReactNode }> } /* secondary workspace CTAs */ export const DropdownContent: FC> = ({ secondaryActions, - buttonMapping, }) => { const styles = useStyles() return ( - {secondaryActions.map((action) => ( + {secondaryActions.map(({ action, button }) => (
- {buttonMapping[action]} + {button}
))}
diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 62099bdb9b984..647d031efad54 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -68,20 +68,19 @@ export const Workspace: FC> = ({ const styles = useStyles() const navigate = useNavigate() + const buildError = workspaceErrors[WorkspaceErrors.BUILD_ERROR] ? ( + + ) : ( + <> + ) + const cancellationError = workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR] ? ( + + ) : ( + <> + ) + return ( - - {workspaceErrors[WorkspaceErrors.BUILD_ERROR] ? ( - - ) : ( - <> - )} - {workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR] ? ( - - ) : ( - <> - )} - @@ -109,39 +108,37 @@ export const Workspace: FC> = ({ {workspace.owner_name} - - - + + {buildError} + {cancellationError} + + - navigate(`/templates`)} /> + + + + {!!resources && !!resources.length && ( + navigate(`/templates`)} + canUpdateWorkspace={canUpdateWorkspace} + buildInfo={buildInfo} /> + )} - - - {!!resources && !!resources.length && ( - + + {workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? ( + + ) : ( + )} - - - {workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? ( - - ) : ( - - )} - - + ) diff --git a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx deleted file mode 100644 index 257f31a01fb31..0000000000000 --- a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Story } from "@storybook/react" -import { WorkspaceStateEnum } from "util/workspace" -import { DeleteButton, StartButton, StopButton } from "../ActionCtas" -import { ButtonMapping, ButtonTypesEnum, WorkspaceStateActions } from "../constants" -import { DropdownContent, DropdownContentProps } from "./DropdownContent" - -// These are the stories for the secondary actions (housed in the dropdown) -// in WorkspaceActions.tsx - -export default { - title: "WorkspaceActionsDropdown", - component: DropdownContent, -} - -const Template: Story = (args) => - -const buttonMappingMock: Partial = { - [ButtonTypesEnum.delete]: jest.fn()} />, - [ButtonTypesEnum.start]: jest.fn()} />, - [ButtonTypesEnum.stop]: jest.fn()} />, - [ButtonTypesEnum.delete]: jest.fn()} />, -} - -const defaultArgs = { - buttonMapping: buttonMappingMock, -} - -export const Started = Template.bind({}) -Started.args = { - ...defaultArgs, - secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.started].secondary, -} - -export const Stopped = Template.bind({}) -Stopped.args = { - ...defaultArgs, - secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.stopped].secondary, -} - -export const Canceled = Template.bind({}) -Canceled.args = { - ...defaultArgs, - secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.canceled].secondary, -} - -export const Errored = Template.bind({}) -Errored.args = { - ...defaultArgs, - secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.error].secondary, -} diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx index d744cc2971a5b..bb8df9087bb56 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen } from "@testing-library/react" import { WorkspaceStateEnum } from "util/workspace" import * as Mocks from "../../testHelpers/entities" import { render } from "../../testHelpers/renderHelpers" -import { Language } from "./ActionCtas" +import { Language } from "../DropdownButton/ActionCtas" import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions" const renderComponent = async (props: Partial = {}) => { diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 3564de055fd4c..c3e406d0b535a 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,10 +1,7 @@ -import Button from "@material-ui/core/Button" -import Popover from "@material-ui/core/Popover" -import { makeStyles } from "@material-ui/core/styles" -import { FC, ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { DropdownButton } from "components/DropdownButton/DropdownButton" +import { FC, ReactNode, useMemo } from "react" import { getWorkspaceStatus, WorkspaceStateEnum, WorkspaceStatus } from "util/workspace" import { Workspace } from "../../api/typesGenerated" -import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows" import { ActionLoadingButton, CancelButton, @@ -14,9 +11,8 @@ import { StartButton, StopButton, UpdateButton, -} from "./ActionCtas" +} from "../DropdownButton/ActionCtas" import { ButtonMapping, ButtonTypesEnum, WorkspaceStateActions } from "./constants" -import { DropdownContent } from "./DropdownContent/DropdownContent" /** * Jobs submitted while another job is in progress will be discarded, @@ -43,11 +39,6 @@ export const WorkspaceActions: FC = ({ handleUpdate, handleCancel, }) => { - const styles = useStyles() - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const id = isOpen ? "action-popover" : undefined - const workspaceStatus: keyof typeof WorkspaceStateEnum = getWorkspaceStatus( workspace.latest_build, ) @@ -70,16 +61,6 @@ export const WorkspaceActions: FC = ({ return updatedActions }, [canBeUpdated, workspaceState]) - /** - * Ensures we close the popover before calling any action handler - */ - useEffect(() => { - setIsOpen(false) - return () => { - setIsOpen(false) - } - }, [workspaceStatus]) - // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { [ButtonTypesEnum.update]: , @@ -98,80 +79,14 @@ export const WorkspaceActions: FC = ({ } return ( - - {/* primary workspace CTA */} - - {buttonMapping[actions.primary]} - - {actions.canCancel ? ( - // cancel CTA - <>{buttonMapping[ButtonTypesEnum.cancel]} - ) : ( - <> - {/* popover toggle button */} - - setIsOpen(false)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - {/* secondary workspace CTAs */} - - - - )} - + ({ + action, + button: buttonMapping[action], + }))} + /> ) } - -const useStyles = makeStyles((theme) => ({ - buttonContainer: { - border: `1px solid ${theme.palette.divider}`, - borderRadius: `${theme.shape.borderRadius}px`, - display: "inline-flex", - }, - dropdownButton: { - border: "none", - borderLeft: `1px solid ${theme.palette.divider}`, - borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, - minWidth: "unset", - width: "63px", // matching cancel button so button grouping doesn't grow in size - "& .MuiButton-label": { - marginRight: "8px", - }, - }, - primaryCta: { - [theme.breakpoints.down("sm")]: { - width: "100%", - - "& > *": { - width: "100%", - }, - }, - }, - popoverPaper: { - padding: `${theme.spacing(1)}px ${theme.spacing(2)}px ${theme.spacing(1)}px`, - }, -})) diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index 383ca3630c8e3..335ca827208e1 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -3,20 +3,20 @@ import { WorkspaceStateEnum } from "util/workspace" // the button types we have export enum ButtonTypesEnum { - start, - starting, - stop, - stopping, - delete, - deleting, - update, - cancel, - error, + start = "start", + starting = "starting", + stop = "stop", + stopping = "stopping", + delete = "delete", + deleting = "deleting", + update = "update", + cancel = "cancel", + error = "error", // disabled buttons - canceling, - disabled, - queued, - loading, + canceling = "canceling", + disabled = "disabled", + queued = "queued", + loading = "loading", } export type ButtonMapping = { diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index ffaa949384e91..7d453dd427903 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -1,7 +1,9 @@ import common from "./common.json" +import templatePage from "./templatePage.json" import workspacePage from "./workspacePage.json" export const en = { common, workspacePage, + templatePage, } diff --git a/site/src/i18n/en/templatePage.json b/site/src/i18n/en/templatePage.json new file mode 100644 index 0000000000000..277e0fb87b4cf --- /dev/null +++ b/site/src/i18n/en/templatePage.json @@ -0,0 +1,8 @@ +{ + "deleteDialog": { + "title": "Delete template", + "message": "Are you sure you want to delete this template?", + "confirm": "Delete" + }, + "deleteSuccess": "Template successfully deleted." +} diff --git a/site/src/pages/TemplatePage/TemplatePage.test.tsx b/site/src/pages/TemplatePage/TemplatePage.test.tsx index 6497964053beb..87099a927843a 100644 --- a/site/src/pages/TemplatePage/TemplatePage.test.tsx +++ b/site/src/pages/TemplatePage/TemplatePage.test.tsx @@ -1,8 +1,12 @@ -import { screen } from "@testing-library/react" +import { fireEvent, screen } from "@testing-library/react" +import { rest } from "msw" +import { server } from "testHelpers/server" import * as CreateDayString from "util/createDayString" import { + MockMemberPermissions, MockTemplate, MockTemplateVersion, + MockUser, MockWorkspaceResource, renderWithAuth, } from "../../testHelpers/renderHelpers" @@ -23,4 +27,28 @@ describe("TemplatePage", () => { screen.getByText(MockWorkspaceResource.name) screen.queryAllByText(`${MockTemplateVersion.name}`).length }) + it("allows an admin to delete a template", async () => { + renderWithAuth(, { + route: `/templates/${MockTemplate.id}`, + path: "/templates/:template", + }) + 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( + rest.post(`/api/v2/users/${MockUser.id}/authorization`, async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockMemberPermissions)) + }), + ) + renderWithAuth(, { + route: `/templates/${MockTemplate.id}`, + path: "/templates/:template", + }) + const dropdownButton = screen.queryByLabelText("open-dropdown") + expect(dropdownButton).toBe(null) + }) }) diff --git a/site/src/pages/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatePage/TemplatePage.tsx index 1b72fa97e9e99..0efa91bbf3da8 100644 --- a/site/src/pages/TemplatePage/TemplatePage.tsx +++ b/site/src/pages/TemplatePage/TemplatePage.tsx @@ -1,7 +1,11 @@ -import { useMachine } from "@xstate/react" -import { FC } from "react" +import { useMachine, useSelector } from "@xstate/react" +import { ConfirmDialog } from "components/ConfirmDialog/ConfirmDialog" +import { FC, useContext } from "react" import { Helmet } from "react-helmet-async" -import { useParams } from "react-router-dom" +import { useTranslation } from "react-i18next" +import { Navigate, useParams } from "react-router-dom" +import { selectPermissions } from "xServices/auth/authSelectors" +import { XServiceContext } from "xServices/StateContext" import { Loader } from "../../components/Loader/Loader" import { useOrganizationId } from "../../hooks/useOrganizationId" import { pageTitle } from "../../util/page" @@ -20,21 +24,37 @@ const useTemplateName = () => { export const TemplatePage: FC> = () => { const organizationId = useOrganizationId() + const { t } = useTranslation("templatePage") const templateName = useTemplateName() - const [templateState] = useMachine(templateMachine, { + const [templateState, templateSend] = useMachine(templateMachine, { context: { templateName, organizationId, }, }) - const { template, activeTemplateVersion, templateResources, templateVersions } = - templateState.context - const isLoading = !template || !activeTemplateVersion || !templateResources + const { + template, + activeTemplateVersion, + templateResources, + templateVersions, + deleteTemplateError, + } = templateState.context + const xServices = useContext(XServiceContext) + const permissions = useSelector(xServices.authXService, selectPermissions) + const isLoading = !template || !activeTemplateVersion || !templateResources || !permissions + + const handleDeleteTemplate = () => { + templateSend("DELETE") + } if (isLoading) { return } + if (templateState.matches("deleted")) { + return + } + return ( <> @@ -45,6 +65,25 @@ export const TemplatePage: FC> = () => { activeTemplateVersion={activeTemplateVersion} templateResources={templateResources} templateVersions={templateVersions} + canDeleteTemplate={permissions.deleteTemplates} + handleDeleteTemplate={handleDeleteTemplate} + deleteTemplateError={deleteTemplateError} + /> + + { + templateSend("CONFIRM_DELETE") + }} + onClose={() => { + templateSend("CANCEL_DELETE") + }} + description={<>{t("deleteDialog.message")}} /> ) diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx index a2e25ef88fd4f..08cb829c27bb4 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -4,6 +4,9 @@ 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 { DeleteButton } from "components/DropdownButton/ActionCtas" +import { DropdownButton } from "components/DropdownButton/DropdownButton" +import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import frontMatter from "front-matter" import { FC } from "react" import ReactMarkdown from "react-markdown" @@ -36,6 +39,9 @@ export interface TemplatePageViewProps { activeTemplateVersion: TemplateVersion templateResources: WorkspaceResource[] templateVersions?: TemplateVersion[] + handleDeleteTemplate: (templateId: string) => void + deleteTemplateError: Error | unknown + canDeleteTemplate: boolean } export const TemplatePageView: FC> = ({ @@ -43,97 +49,131 @@ export const TemplatePageView: FC activeTemplateVersion, templateResources, templateVersions, + handleDeleteTemplate, + deleteTemplateError, + canDeleteTemplate, }) => { const styles = useStyles() const readme = frontMatter(activeTemplateVersion.readme) const hasIcon = template.icon && template.icon !== "" + const deleteError = deleteTemplateError ? ( + + ) : ( + <> + ) + const getStartedResources = (resources: WorkspaceResource[]) => { return resources.filter((resource) => resource.workspace_transition === "start") } + const createWorkspaceButton = (className?: string) => ( + + + + ) + return ( - - - - - - - - - } - > - -
- {hasIcon ? ( -
- -
- ) : ( - {firstLetter(template.name)} - )} -
-
- {template.name} - - {template.description === "" ? Language.noDescription : template.description} - -
-
-
+ <> + + + + - - - handleDeleteTemplate(template.id)} /> + ), + }, + ]} + canCancel={false} + /> + ) : ( + createWorkspaceButton() + )} + + } > - - - -
- ( - - {children} - - ), - }} - > - {readme.body} - -
-
- - - - + +
+ {hasIcon ? ( +
+ +
+ ) : ( + {firstLetter(template.name)} + )} +
+
+ {template.name} + + {template.description === "" ? Language.noDescription : template.description} + +
+
+
+ + + {deleteError} + + + + + +
+ ( + + {children} + + ), + }} + > + {readme.body} + +
+
+ + + +
+
) } export const useStyles = makeStyles((theme) => { return { + actionButton: { + border: "none", + borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, + }, readmeContents: { margin: 0, }, diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index b341d3255d126..12c67b7d455c4 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -4,7 +4,7 @@ import i18next from "i18next" import { rest } from "msw" import * as api from "../../api/api" import { Workspace } from "../../api/typesGenerated" -import { Language } from "../../components/WorkspaceActions/ActionCtas" +import { Language } from "../../components/DropdownButton/ActionCtas" import { MockBuilds, MockCanceledWorkspace, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index a12b46179a5be..8a9fd84365a8a 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -40,6 +40,9 @@ export const handlers = [ rest.get("/api/v2/templateversions/:templateVersionId/resources", async (req, res, ctx) => { return res(ctx.status(200), ctx.json([M.MockWorkspaceResource, M.MockWorkspaceResource2])) }), + rest.delete("/api/v2/templates/:templateId", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockTemplate)) + }), // users rest.get("/api/v2/users", async (req, res, ctx) => { diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 36e10e2347dc2..d3c0a27ab2bd2 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -14,6 +14,7 @@ export const checks = { updateUsers: "updateUsers", createUser: "createUser", createTemplates: "createTemplates", + deleteTemplates: "deleteTemplates", viewAuditLog: "viewAuditLog", } as const @@ -42,6 +43,12 @@ export const permissionsToCheck = { }, action: "update", }, + [checks.deleteTemplates]: { + object: { + resource_type: "template", + }, + action: "delete", + }, [checks.viewAuditLog]: { object: { resource_type: "audit_log", diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts index 1db743f0f8afa..666b4e31c28f5 100644 --- a/site/src/xServices/template/templateXService.ts +++ b/site/src/xServices/template/templateXService.ts @@ -1,5 +1,8 @@ +import { displaySuccess } from "components/GlobalSnackbar/utils" +import { t } from "i18next" import { assign, createMachine } from "xstate" import { + deleteTemplate, getTemplateByName, getTemplateVersion, getTemplateVersionResources, @@ -14,131 +17,208 @@ interface TemplateContext { activeTemplateVersion?: TemplateVersion templateResources?: WorkspaceResource[] templateVersions?: TemplateVersion[] + deleteTemplateError?: Error | unknown } -export const templateMachine = createMachine( - { - schema: { - context: {} as TemplateContext, - services: {} as { - getTemplate: { - data: Template - } - getActiveTemplateVersion: { - data: TemplateVersion - } - getTemplateResources: { - data: WorkspaceResource[] - } - getTemplateVersions: { - data: TemplateVersion[] - } +type TemplateEvent = { type: "DELETE" } | { type: "CONFIRM_DELETE" } | { type: "CANCEL_DELETE" } + +export const templateMachine = + /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOhgBdyCoAVMVABwBt1ywBiCAe0JIIDcuAazAk0WPIVIUq+WvWaswCAV0ytcPANoAGALqJQDLrFxUehkAA9EAJgDsOkgGZbO2wE5bANg8BGABZnHR0AgBoQAE9EPx0-Eg9EpIAOe28-D28ggF9siPEcAmI+fDNcdCYASXwAMy4SLCp+MDpGFjYANTAAJ1MeMjBKagBBTCaWhXawLt7NfE4eUVURMQxCqRKyiuq6hrHcZtbFTp6+-AGhuVHxo6mZs5V8QXVzfF0DJBBjU1fLGwQAgF4t4AKzeWzOTIhEEhZIRaIIZI6EEkEIhZyg2xuEF+XL5NaSYoELZVWr1NhtJQAJTgXAArt1MHALrJ5JS2DTYPTGXAFrxlqICoTSMSqNsySQKccwJzuUzYCzqLdqbSGfLHs8NNp9JZvmULJ9-kDkiRks4QR50SDkh5nH5nPCYjpXKi0SDnObbeaAniQEKiiLSmLSbspXdTnMFTIlZMlPdI3ylk9hIKCQHNsGduTYydZjwo4NWcrc2dYBq1Fq3jrPnrfobEAFQqbQt5kiCcf4go6ECD7Ca0XFMskAmksr7-RtReUQ1xEyRYOQlKsJOmp+K6rqTPr8H9ELaTclkg5wQEzRidB5u-Y0q6QgFbAEsuCMuO0xsmFx0BBIOwACIAUQAGX-Gh-03H45ksBFrycGE7zcW1bD8e0In+Pw3G8EggT8M0-Fbc8PGSV8Vw2TAeBqXBulQahfzAJhBg4ABhAB5AA5AAxSoqQAWQAfQA4DQPA7ddwQZCMVRUF7Bw3svXsEFuz8MFMNtC8bRtHQzWHYj1mKMjako6i5Fo+i2HYRjhlYxigP4oCQLAmstzrUA0OcaSSHsZwbSUjwAl83zwiiGJGw8LCwTtU8vGQnI8j9N9im-UzqDnAUSEShjizAYTnOsRB7BHEglLwoqskCWxFJ8ewPJ0bx8tsC1IQhZwdOFNK6MGZKem6LhuhIY46iotrTImdkssciCDRcvcbVRJrwr8fLXHsRTYRIOCau8WqYRxIjfXwLhv3gT4J2KaM5Ey7LIPrAFyqChBLVvEJPGk2w21PFrVyDacsz2G4c2mCN+jOqBrgOEbpXjSavicq6prEhar1tR7IXSaSfVik7AxJH7GjBzLIfOWA6UweUjqMGGof+TzbEKsEMiRdwYUhbtkhw5HnHvXx0g8D7Jy+9d6lxw5-oJy7Kb3B07vsJDkekjwcSyWxeaJfmZ0lf7ZTVZlgcyzWeTJ6GJp3a7kOWu7YjcR6wQCEFAQfbxlaxzMJTDFUuS1hUiZJuADdrWHcoQVtMJxdtWfvaL0hWm2rfcRXHxBR2M2+l2NdVfWxeNuHbW7Xz+xCWJkm8ZE3LbRO1zV12S0jRVzpFwH8F9inM4D03u2Ux6sWvQj2wTjH4qd5PQzrvMG-nYnSYz0TQRNG3gnUyE+zNNv-EevDrUya9mr7kiVexlPRoJxujdE7O7r8gIO+dXtUkyMvVazSfrt8bt7xpgcFttC1nXsROPy-SBH5w1iO6MKwRghAkbH2BSUtHBrTRPeC8rhxKJ30hRKiNF2psEAS3ZCNMkR4R8MOWqfloEIntCvDmnoRzyUIvLRO6VWTYP+F4TCNt0g22REhV6pCYgEJIPVDENsPBpA9GkehmCAHjREtdVmmFPCKy2u6RwcJzaQicMOb0wQ3ALVxNvXSRAmExBUWQvOA5baqXlrbXIuQgA */ + createMachine( + { + tsTypes: {} as import("./templateXService.typegen").Typegen0, + schema: { + context: {} as TemplateContext, + events: {} as TemplateEvent, + services: {} as { + getTemplate: { + data: Template + } + getActiveTemplateVersion: { + data: TemplateVersion + } + getTemplateResources: { + data: WorkspaceResource[] + } + getTemplateVersions: { + data: TemplateVersion[] + } + deleteTemplate: { + data: Template + } + }, }, - }, - tsTypes: {} as import("./templateXService.typegen").Typegen0, - initial: "gettingTemplate", - states: { - gettingTemplate: { - invoke: { - src: "getTemplate", - onDone: { - actions: ["assignTemplate"], - target: "initialInfo", + id: "(machine)", + initial: "gettingTemplate", + states: { + gettingTemplate: { + invoke: { + src: "getTemplate", + onDone: [ + { + actions: "assignTemplate", + target: "initialInfo", + }, + ], }, }, - }, - initialInfo: { - type: "parallel", - onDone: "loaded", - states: { - activeTemplateVersion: { - initial: "gettingActiveTemplateVersion", - states: { - gettingActiveTemplateVersion: { - invoke: { - src: "getActiveTemplateVersion", - onDone: { - actions: ["assignActiveTemplateVersion"], - target: "success", + initialInfo: { + type: "parallel", + states: { + activeTemplateVersion: { + initial: "gettingActiveTemplateVersion", + states: { + gettingActiveTemplateVersion: { + invoke: { + src: "getActiveTemplateVersion", + onDone: [ + { + actions: "assignActiveTemplateVersion", + target: "success", + }, + ], }, }, + success: { + type: "final", + }, }, - success: { type: "final" }, }, - }, - templateResources: { - initial: "gettingTemplateResources", - states: { - gettingTemplateResources: { - invoke: { - src: "getTemplateResources", - onDone: { - actions: ["assignTemplateResources"], - target: "success", + templateResources: { + initial: "gettingTemplateResources", + states: { + gettingTemplateResources: { + invoke: { + src: "getTemplateResources", + onDone: [ + { + actions: "assignTemplateResources", + target: "success", + }, + ], }, }, + success: { + type: "final", + }, }, - success: { type: "final" }, }, - }, - templateVersions: { - initial: "gettingTemplateVersions", - states: { - gettingTemplateVersions: { - invoke: { - src: "getTemplateVersions", - onDone: { - actions: ["assignTemplateVersions"], - target: "success", + templateVersions: { + initial: "gettingTemplateVersions", + states: { + gettingTemplateVersions: { + invoke: { + src: "getTemplateVersions", + onDone: [ + { + actions: "assignTemplateVersions", + target: "success", + }, + ], }, }, + success: { + type: "final", + }, }, - success: { type: "final" }, + }, + }, + onDone: { + target: "loaded", + }, + }, + loaded: { + on: { + DELETE: { + target: "confirmingDelete", }, }, }, + 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", + }, }, - loaded: {}, }, - }, - { - services: { - getTemplate: (ctx) => getTemplateByName(ctx.organizationId, ctx.templateName), - getActiveTemplateVersion: (ctx) => { - if (!ctx.template) { - throw new Error("Template not loaded") - } + { + services: { + getTemplate: (ctx) => getTemplateByName(ctx.organizationId, ctx.templateName), + getActiveTemplateVersion: (ctx) => { + if (!ctx.template) { + throw new Error("Template not loaded") + } - return getTemplateVersion(ctx.template.active_version_id) - }, - getTemplateResources: (ctx) => { - if (!ctx.template) { - throw new Error("Template not loaded") - } + return getTemplateVersion(ctx.template.active_version_id) + }, + getTemplateResources: (ctx) => { + if (!ctx.template) { + throw new Error("Template not loaded") + } - return getTemplateVersionResources(ctx.template.active_version_id) - }, - getTemplateVersions: (ctx) => { - if (!ctx.template) { - throw new Error("Template not loaded") - } + return getTemplateVersionResources(ctx.template.active_version_id) + }, + getTemplateVersions: (ctx) => { + if (!ctx.template) { + throw new Error("Template not loaded") + } - return getTemplateVersions(ctx.template.id) + return getTemplateVersions(ctx.template.id) + }, + deleteTemplate: (ctx) => { + if (!ctx.template) { + throw new Error("Template not loaded") + } + return deleteTemplate(ctx.template.id) + }, + }, + actions: { + assignTemplate: assign({ + template: (_, event) => event.data, + }), + assignActiveTemplateVersion: assign({ + activeTemplateVersion: (_, event) => event.data, + }), + assignTemplateResources: assign({ + templateResources: (_, event) => event.data, + }), + assignTemplateVersions: assign({ + templateVersions: (_, event) => event.data, + }), + assignDeleteTemplateError: assign({ + deleteTemplateError: (_, event) => event.data, + }), + clearDeleteTemplateError: assign({ + deleteTemplateError: (_) => undefined, + }), + displayDeleteSuccess: () => displaySuccess(t("deleteSuccess", { ns: "templatePage" })), }, }, - actions: { - assignTemplate: assign({ - template: (_, event) => event.data, - }), - assignActiveTemplateVersion: assign({ - activeTemplateVersion: (_, event) => event.data, - }), - assignTemplateResources: assign({ - templateResources: (_, event) => event.data, - }), - assignTemplateVersions: assign({ - templateVersions: (_, event) => event.data, - }), - }, - }, -) + )