Feat: delete template button (#3781)

* Add api call

* Extract DropDownButton

* Start adding DropdownButton to Template page

* Move stories to dropdown button

* Format

* Update xservice to delete

* Deletion flow

* Format

* Move ErrorSummary for consistency

* RBAC (unfinished) and style tweak

* Format

* Test rbac

* Format

* Move ErrorSummary under PageHeader in workspace and template

* Format

* Replace hook with onBlur

* Make style arg optional

* Format
This commit is contained in:
Presley Pizzo
2022-09-01 14:24:14 -04:00
committed by GitHub
parent 6826b976d7
commit 6d95145d3b
20 changed files with 618 additions and 408 deletions
+5
View File
@@ -153,6 +153,11 @@ export const updateTemplateMeta = async (
return response.data
}
export const deleteTemplate = async (templateId: string): Promise<TypesGen.Template> => {
const response = await axios.delete<TypesGen.Template>(`/api/v2/templates/${templateId}`)
return response.data
}
export const getWorkspace = async (
workspaceId: string,
params?: TypesGen.WorkspaceOptions,
@@ -21,10 +21,15 @@ interface ArrowProps {
export const OpenDropdown: FC<ArrowProps> = ({ margin = true }) => {
const styles = useStyles({ margin })
return <KeyboardArrowDown className={styles.arrowIcon} />
return <KeyboardArrowDown aria-label="open-dropdown" className={styles.arrowIcon} />
}
export const CloseDropdown: FC<ArrowProps> = ({ margin = true }) => {
const styles = useStyles({ margin })
return <KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
return (
<KeyboardArrowUp
aria-label="close-dropdown"
className={`${styles.arrowIcon} ${styles.arrowIconUp}`}
/>
)
}
@@ -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<DropdownButtonProps> = (args) => <DropdownButton {...args} />
export const WithDropdown = Template.bind({})
WithDropdown.args = {
primaryAction: <StartButton handleAction={action("start")} />,
secondaryActions: [
{ action: "update", button: <UpdateButton handleAction={action("update")} /> },
{ action: "delete", button: <DeleteButton handleAction={action("delete")} /> },
],
canCancel: false,
}
export const WithCancel = Template.bind({})
WithCancel.args = {
primaryAction: <DisabledButton workspaceState={WorkspaceStateEnum.deleting} />,
secondaryActions: [],
canCancel: true,
handleCancel: action("cancel"),
}
@@ -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<DropdownButtonProps> = ({
primaryAction,
secondaryActions,
canCancel,
handleCancel,
}) => {
const styles = useStyles()
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const id = isOpen ? "action-popover" : undefined
return (
<span className={styles.buttonContainer}>
{/* primary workspace CTA */}
<span data-testid="primary-cta" className={styles.primaryCta}>
{primaryAction}
</span>
{canCancel && handleCancel ? (
<CancelButton handleAction={handleCancel} />
) : (
<>
{/* popover toggle button */}
<Button
data-testid="workspace-actions-button"
aria-controls="workspace-actions-menu"
aria-haspopup="true"
className={styles.dropdownButton}
ref={anchorRef}
disabled={!secondaryActions.length}
onClick={() => {
setIsOpen(true)
}}
>
{isOpen ? <CloseDropdown /> : <OpenDropdown />}
</Button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => setIsOpen(false)}
onBlur={() => setIsOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
{/* secondary workspace CTAs */}
<DropdownContent secondaryActions={secondaryActions} />
</Popover>
</>
)}
</span>
)
}
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`,
},
}))
@@ -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<ButtonMapping>
secondaryActions: Array<{ action: string; button: ReactNode }>
}
/* secondary workspace CTAs */
export const DropdownContent: FC<React.PropsWithChildren<DropdownContentProps>> = ({
secondaryActions,
buttonMapping,
}) => {
const styles = useStyles()
return (
<span data-testid="secondary-ctas">
{secondaryActions.map((action) => (
{secondaryActions.map(({ action, button }) => (
<div key={action} className={styles.popoverActionButton}>
{buttonMapping[action]}
{button}
</div>
))}
</span>
+38 -41
View File
@@ -68,20 +68,19 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
const styles = useStyles()
const navigate = useNavigate()
const buildError = workspaceErrors[WorkspaceErrors.BUILD_ERROR] ? (
<ErrorSummary error={workspaceErrors[WorkspaceErrors.BUILD_ERROR]} dismissible />
) : (
<></>
)
const cancellationError = workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR] ? (
<ErrorSummary error={workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR]} dismissible />
) : (
<></>
)
return (
<Margins>
<Stack spacing={1}>
{workspaceErrors[WorkspaceErrors.BUILD_ERROR] ? (
<ErrorSummary error={workspaceErrors[WorkspaceErrors.BUILD_ERROR]} dismissible />
) : (
<></>
)}
{workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR] ? (
<ErrorSummary error={workspaceErrors[WorkspaceErrors.CANCELLATION_ERROR]} dismissible />
) : (
<></>
)}
</Stack>
<PageHeader
actions={
<Stack direction="row" spacing={1} className={styles.actions}>
@@ -109,39 +108,37 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
</PageHeader>
<Stack direction="row" spacing={3}>
<Stack direction="column" className={styles.firstColumnSpacer} spacing={3}>
<WorkspaceScheduleBanner
isLoading={bannerProps.isLoading}
onExtend={bannerProps.onExtend}
<Stack direction="column" className={styles.firstColumnSpacer} spacing={2.5}>
{buildError}
{cancellationError}
<WorkspaceScheduleBanner
isLoading={bannerProps.isLoading}
onExtend={bannerProps.onExtend}
workspace={workspace}
/>
<WorkspaceDeletedBanner workspace={workspace} handleClick={() => navigate(`/templates`)} />
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />
{!!resources && !!resources.length && (
<Resources
resources={resources}
getResourcesError={workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]}
workspace={workspace}
canUpdateWorkspace={canUpdateWorkspace}
buildInfo={buildInfo}
/>
)}
<WorkspaceDeletedBanner
workspace={workspace}
handleClick={() => navigate(`/templates`)}
/>
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />
{!!resources && !!resources.length && (
<Resources
resources={resources}
getResourcesError={workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]}
workspace={workspace}
canUpdateWorkspace={canUpdateWorkspace}
buildInfo={buildInfo}
/>
<WorkspaceSection title="Logs" contentsProps={{ className: styles.timelineContents }}>
{workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? (
<ErrorSummary error={workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR]} />
) : (
<BuildsTable builds={builds} className={styles.timelineTable} />
)}
<WorkspaceSection title="Logs" contentsProps={{ className: styles.timelineContents }}>
{workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR] ? (
<ErrorSummary error={workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR]} />
) : (
<BuildsTable builds={builds} className={styles.timelineTable} />
)}
</WorkspaceSection>
</Stack>
</WorkspaceSection>
</Stack>
</Margins>
)
@@ -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<DropdownContentProps> = (args) => <DropdownContent {...args} />
const buttonMappingMock: Partial<ButtonMapping> = {
[ButtonTypesEnum.delete]: <DeleteButton handleAction={() => jest.fn()} />,
[ButtonTypesEnum.start]: <StartButton handleAction={() => jest.fn()} />,
[ButtonTypesEnum.stop]: <StopButton handleAction={() => jest.fn()} />,
[ButtonTypesEnum.delete]: <DeleteButton handleAction={() => 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,
}
@@ -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<WorkspaceActionsProps> = {}) => {
@@ -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<WorkspaceActionsProps> = ({
handleUpdate,
handleCancel,
}) => {
const styles = useStyles()
const anchorRef = useRef<HTMLButtonElement>(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<WorkspaceActionsProps> = ({
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]: <UpdateButton handleAction={handleUpdate} />,
@@ -98,80 +79,14 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
}
return (
<span className={styles.buttonContainer}>
{/* primary workspace CTA */}
<span data-testid="primary-cta" className={styles.primaryCta}>
{buttonMapping[actions.primary]}
</span>
{actions.canCancel ? (
// cancel CTA
<>{buttonMapping[ButtonTypesEnum.cancel]}</>
) : (
<>
{/* popover toggle button */}
<Button
data-testid="workspace-actions-button"
aria-controls="workspace-actions-menu"
aria-haspopup="true"
className={styles.dropdownButton}
ref={anchorRef}
disabled={!actions.secondary.length}
onClick={() => {
setIsOpen(true)
}}
>
{isOpen ? <CloseDropdown /> : <OpenDropdown />}
</Button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => setIsOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
{/* secondary workspace CTAs */}
<DropdownContent secondaryActions={actions.secondary} buttonMapping={buttonMapping} />
</Popover>
</>
)}
</span>
<DropdownButton
primaryAction={buttonMapping[actions.primary]}
canCancel={actions.canCancel}
handleCancel={handleCancel}
secondaryActions={actions.secondary.map((action) => ({
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`,
},
}))
@@ -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 = {
+2
View File
@@ -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,
}
+8
View File
@@ -0,0 +1,8 @@
{
"deleteDialog": {
"title": "Delete template",
"message": "Are you sure you want to delete this template?",
"confirm": "Delete"
},
"deleteSuccess": "Template successfully deleted."
}
@@ -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(<TemplatePage />, {
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(<TemplatePage />, {
route: `/templates/${MockTemplate.id}`,
path: "/templates/:template",
})
const dropdownButton = screen.queryByLabelText("open-dropdown")
expect(dropdownButton).toBe(null)
})
})
+46 -7
View File
@@ -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<React.PropsWithChildren<unknown>> = () => {
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 <Loader />
}
if (templateState.matches("deleted")) {
return <Navigate to="/templates" />
}
return (
<>
<Helmet>
@@ -45,6 +65,25 @@ export const TemplatePage: FC<React.PropsWithChildren<unknown>> = () => {
activeTemplateVersion={activeTemplateVersion}
templateResources={templateResources}
templateVersions={templateVersions}
canDeleteTemplate={permissions.deleteTemplates}
handleDeleteTemplate={handleDeleteTemplate}
deleteTemplateError={deleteTemplateError}
/>
<ConfirmDialog
type="delete"
hideCancel={false}
open={templateState.matches("confirmingDelete")}
confirmLoading={templateState.matches("deleting")}
title={t("deleteDialog.title")}
confirmText={t("deleteDialog.confirm")}
onConfirm={() => {
templateSend("CONFIRM_DELETE")
}}
onClose={() => {
templateSend("CANCEL_DELETE")
}}
description={<>{t("deleteDialog.message")}</>}
/>
</>
)
+112 -72
View File
@@ -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<React.PropsWithChildren<TemplatePageViewProps>> = ({
@@ -43,97 +49,131 @@ export const TemplatePageView: FC<React.PropsWithChildren<TemplatePageViewProps>
activeTemplateVersion,
templateResources,
templateVersions,
handleDeleteTemplate,
deleteTemplateError,
canDeleteTemplate,
}) => {
const styles = useStyles()
const readme = frontMatter(activeTemplateVersion.readme)
const hasIcon = template.icon && template.icon !== ""
const deleteError = deleteTemplateError ? (
<ErrorSummary error={deleteTemplateError} dismissible />
) : (
<></>
)
const getStartedResources = (resources: WorkspaceResource[]) => {
return resources.filter((resource) => resource.workspace_transition === "start")
}
const createWorkspaceButton = (className?: string) => (
<Link underline="none" component={RouterLink} to={`/templates/${template.name}/workspace`}>
<Button className={className ?? ""} startIcon={<AddCircleOutline />}>
{Language.createButton}
</Button>
</Link>
)
return (
<Margins>
<PageHeader
actions={
<Stack direction="row" spacing={1}>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/settings`}
>
<Button variant="outlined" startIcon={<SettingsOutlined />}>
{Language.settingsButton}
</Button>
</Link>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/workspace`}
>
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
</Stack>
}
>
<Stack direction="row" spacing={3} className={styles.pageTitle}>
<div>
{hasIcon ? (
<div className={styles.iconWrapper}>
<img src={template.icon} alt="" />
</div>
) : (
<Avatar className={styles.avatar}>{firstLetter(template.name)}</Avatar>
)}
</div>
<div>
<PageHeaderTitle>{template.name}</PageHeaderTitle>
<PageHeaderSubtitle>
{template.description === "" ? Language.noDescription : template.description}
</PageHeaderSubtitle>
</div>
</Stack>
</PageHeader>
<>
<PageHeader
actions={
<Stack direction="row" spacing={1}>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/settings`}
>
<Button variant="outlined" startIcon={<SettingsOutlined />}>
{Language.settingsButton}
</Button>
</Link>
<Stack spacing={2.5}>
<TemplateStats template={template} activeVersion={activeTemplateVersion} />
<WorkspaceSection
title={Language.resourcesTitle}
contentsProps={{ className: styles.resourcesTableContents }}
{canDeleteTemplate ? (
<DropdownButton
primaryAction={createWorkspaceButton(styles.actionButton)}
secondaryActions={[
{
action: "delete",
button: (
<DeleteButton handleAction={() => handleDeleteTemplate(template.id)} />
),
},
]}
canCancel={false}
/>
) : (
createWorkspaceButton()
)}
</Stack>
}
>
<TemplateResourcesTable resources={getStartedResources(templateResources)} />
</WorkspaceSection>
<WorkspaceSection
title={Language.readmeTitle}
contentsProps={{ className: styles.readmeContents }}
>
<div className={styles.markdownWrapper}>
<ReactMarkdown
components={{
a: ({ href, target, children }) => (
<Link href={href} target={target}>
{children}
</Link>
),
}}
>
{readme.body}
</ReactMarkdown>
</div>
</WorkspaceSection>
<WorkspaceSection
title={Language.versionsTitle}
contentsProps={{ className: styles.versionsTableContents }}
>
<VersionsTable versions={templateVersions} />
</WorkspaceSection>
</Stack>
<Stack direction="row" spacing={3} className={styles.pageTitle}>
<div>
{hasIcon ? (
<div className={styles.iconWrapper}>
<img src={template.icon} alt="" />
</div>
) : (
<Avatar className={styles.avatar}>{firstLetter(template.name)}</Avatar>
)}
</div>
<div>
<PageHeaderTitle>{template.name}</PageHeaderTitle>
<PageHeaderSubtitle>
{template.description === "" ? Language.noDescription : template.description}
</PageHeaderSubtitle>
</div>
</Stack>
</PageHeader>
<Stack spacing={2.5}>
{deleteError}
<TemplateStats template={template} activeVersion={activeTemplateVersion} />
<WorkspaceSection
title={Language.resourcesTitle}
contentsProps={{ className: styles.resourcesTableContents }}
>
<TemplateResourcesTable resources={getStartedResources(templateResources)} />
</WorkspaceSection>
<WorkspaceSection
title={Language.readmeTitle}
contentsProps={{ className: styles.readmeContents }}
>
<div className={styles.markdownWrapper}>
<ReactMarkdown
components={{
a: ({ href, target, children }) => (
<Link href={href} target={target}>
{children}
</Link>
),
}}
>
{readme.body}
</ReactMarkdown>
</div>
</WorkspaceSection>
<WorkspaceSection
title={Language.versionsTitle}
contentsProps={{ className: styles.versionsTableContents }}
>
<VersionsTable versions={templateVersions} />
</WorkspaceSection>
</Stack>
</>
</Margins>
)
}
export const useStyles = makeStyles((theme) => {
return {
actionButton: {
border: "none",
borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`,
},
readmeContents: {
margin: 0,
},
@@ -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,
+3
View File
@@ -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) => {
+7
View File
@@ -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",
+196 -116
View File
@@ -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",
id: "(machine)",
initial: "gettingTemplate",
states: {
gettingTemplate: {
invoke: {
src: "getTemplate",
onDone: [
{
actions: "assignTemplate",
target: "initialInfo",
},
],
},
},
initialInfo: {
type: "parallel",
states: {
activeTemplateVersion: {
initial: "gettingActiveTemplateVersion",
states: {
gettingActiveTemplateVersion: {
invoke: {
src: "getActiveTemplateVersion",
onDone: [
{
actions: "assignActiveTemplateVersion",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
templateResources: {
initial: "gettingTemplateResources",
states: {
gettingTemplateResources: {
invoke: {
src: "getTemplateResources",
onDone: [
{
actions: "assignTemplateResources",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
templateVersions: {
initial: "gettingTemplateVersions",
states: {
gettingTemplateVersions: {
invoke: {
src: "getTemplateVersions",
onDone: [
{
actions: "assignTemplateVersions",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
},
onDone: {
actions: ["assignTemplate"],
target: "initialInfo",
target: "loaded",
},
},
},
initialInfo: {
type: "parallel",
onDone: "loaded",
states: {
activeTemplateVersion: {
initial: "gettingActiveTemplateVersion",
states: {
gettingActiveTemplateVersion: {
invoke: {
src: "getActiveTemplateVersion",
onDone: {
actions: ["assignActiveTemplateVersion"],
target: "success",
},
},
},
success: { type: "final" },
},
},
templateResources: {
initial: "gettingTemplateResources",
states: {
gettingTemplateResources: {
invoke: {
src: "getTemplateResources",
onDone: {
actions: ["assignTemplateResources"],
target: "success",
},
},
},
success: { type: "final" },
},
},
templateVersions: {
initial: "gettingTemplateVersions",
states: {
gettingTemplateVersions: {
invoke: {
src: "getTemplateVersions",
onDone: {
actions: ["assignTemplateVersions"],
target: "success",
},
},
},
success: { type: "final" },
loaded: {
on: {
DELETE: {
target: "confirmingDelete",
},
},
},
},
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 getTemplateVersionResources(ctx.template.active_version_id)
},
getTemplateVersions: (ctx) => {
if (!ctx.template) {
throw new Error("Template not loaded")
}
return getTemplateVersions(ctx.template.id)
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",
},
},
},
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,
}),
{
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 getTemplateVersionResources(ctx.template.active_version_id)
},
getTemplateVersions: (ctx) => {
if (!ctx.template) {
throw new Error("Template not loaded")
}
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" })),
},
},
},
)
)