Template delete button/kira pilot (#4992)

* removed button

* ripped out delete dialog

* fixed tests

* added error message back

* redirecting after success
This commit is contained in:
Kira Pilot
2022-11-10 10:41:36 -05:00
committed by GitHub
parent 0eed533b17
commit 1c9677d37a
11 changed files with 253 additions and 190 deletions

View File

@ -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,
}) => (
<Link
underline="none"
component={RouterLink}
to={`/templates/${templateName}/settings`}
>
<Button variant="outlined" startIcon={<SettingsOutlined />}>
{Language.settingsButton}
</Button>
</Link>
)
const CreateWorkspaceButton: FC<{
templateName: string
className?: string
}> = ({ templateName, className }) => (
<Link
underline="none"
component={RouterLink}
to={`/templates/${templateName}/workspace`}
>
<Button className={className ?? ""} startIcon={<AddCircleOutline />}>
{Language.createButton}
</Button>
</Link>
)
export const TemplateLayout: FC<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ children }) => {
!templateDAUs ||
!templatePermissions
if (templateState.matches("deleted")) {
return <Navigate to="/templates" />
}
const hasIcon = template && template.icon && template.icon !== ""
const createWorkspaceButton = (className?: string) => (
<Link
underline="none"
component={RouterLink}
to={`/templates/${templateName}/workspace`}
>
<Button
className={className ?? ""}
startIcon={<AddCircleOutline />}
disabled={isLoading}
>
{Language.createButton}
</Button>
</Link>
)
const generatePageHeaderActions = (): JSX.Element[] => {
const pageActions: JSX.Element[] = []
const handleDeleteTemplate = () => {
templateSend("DELETE")
if (!isLoading && templatePermissions.canUpdateTemplate) {
pageActions.push(<TemplateSettingsButton templateName={templateName} />)
}
if (!isLoading) {
pageActions.push(<CreateWorkspaceButton templateName={templateName} />)
}
return pageActions
}
return (
@ -134,36 +144,11 @@ export const TemplateLayout: FC<PropsWithChildren> = ({ children }) => {
<Margins>
<PageHeader
actions={
isLoading ? undefined : (
<ChooseOne>
<Cond condition={templatePermissions.canUpdateTemplate}>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/settings`}
>
<Button variant="outlined" startIcon={<SettingsOutlined />}>
{Language.settingsButton}
</Button>
</Link>
<DropdownButton
primaryAction={createWorkspaceButton(styles.actionButton)}
secondaryActions={[
{
action: "delete",
button: (
<DeleteButton handleAction={handleDeleteTemplate} />
),
},
]}
canCancel={false}
/>
</Cond>
<Cond>{createWorkspaceButton()}</Cond>
</ChooseOne>
)
<>
{generatePageHeaderActions().map((action, i) => (
<div key={i}>{action}</div>
))}
</>
}
>
<Stack direction="row" spacing={3} className={styles.pageTitle}>
@ -234,31 +219,12 @@ export const TemplateLayout: FC<PropsWithChildren> = ({ children }) => {
<Suspense fallback={<Loader />}>{children}</Suspense>
</TemplateLayoutContext.Provider>
</Margins>
{!isLoading && (
<DeleteDialog
isOpen={templateState.matches("confirmingDelete")}
confirmLoading={templateState.matches("deleting")}
onConfirm={() => {
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",
},

View File

@ -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"
}
}
}

View File

@ -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(

View File

@ -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}
/>
</>
)

View File

@ -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 ? (
<AlertBanner severity="error" error={deleteTemplateError} dismissible />
) : null
const getStartedResources = (resources: WorkspaceResource[]) => {
return resources.filter(
(resource) => resource.workspace_transition === "start",
@ -49,7 +42,6 @@ export const TemplateSummaryPageView: FC<
return (
<Stack spacing={4}>
{deleteError}
<TemplateStats
template={template}
activeVersion={activeTemplateVersion}

View File

@ -5,7 +5,6 @@ import InputAdornment from "@material-ui/core/InputAdornment"
import Popover from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Typography from "@material-ui/core/Typography"
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { OpenDropdown } from "components/DropdownArrows/DropdownArrows"
import { FormFooter } from "components/FormFooter/FormFooter"
@ -188,9 +187,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
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 && (
<Typography variant="subtitle2">
{Language.ttlHelperText(form.values.default_ttl_ms)}
</Typography>
<span>{Language.ttlHelperText(form.values.default_ttl_ms)}</span>
)}
</Stack>

View File

@ -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(<TemplateSettingsPage />, {
@ -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()

View File

@ -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")}
/>
</>
)

View File

@ -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<typeof TemplateSettingsForm>["initialTouched"]
}
@ -25,28 +35,110 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
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 <Navigate to="/templates" />
}
return (
<FullPageForm title={Language.title} onCancel={onCancel}>
<FullPageForm title={t("templateSettings.title")} onCancel={onCancel}>
{Boolean(errors.getTemplateError) && (
<AlertBanner severity="error" error={errors.getTemplateError} />
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.getTemplateError} />
</Stack>
)}
{Boolean(errors.deleteTemplateError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.deleteTemplateError} />
</Stack>
)}
{isLoading && <Loader />}
{template && (
<TemplateSettingsForm
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}
onSubmit={onSubmit}
onCancel={onCancel}
error={errors.saveTemplateSettingsError}
/>
<>
<TemplateSettingsForm
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}
onSubmit={onSubmit}
onCancel={onCancel}
error={errors.saveTemplateSettingsError}
/>
<Stack className={classes.dangerContainer}>
<div className={classes.dangerHeader}>
{t("templateSettings.dangerZone.dangerZoneHeader")}
</div>
<Stack className={classes.dangerBorder}>
<Stack spacing={0}>
<p className={classes.deleteTemplateHeader}>
{t("templateSettings.dangerZone.deleteTemplateHeader")}
</p>
<span>
{t("templateSettings.dangerZone.deleteTemplateCaption")}
</span>
</Stack>
<Button
className={classes.deleteButton}
onClick={onDelete}
aria-label={t("templateSettings.dangerZone.deleteCta")}
>
{t("templateSettings.dangerZone.deleteCta")}
</Button>
</Stack>
</Stack>
<DeleteDialog
isOpen={isConfirmingDelete}
confirmLoading={isDeleting}
onConfirm={onConfirmDelete}
onCancel={onCancelDelete}
entity="template"
name={template.name}
/>
</>
)}
</FullPageForm>
)
}
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],
},
}))

View File

@ -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" })),
},
},
)

View File

@ -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" })),
},
},
)