feat: add impending deletion filter to workspaces page (#7860)

* add workspace deletion dialog

* add deleting_by query param

* added test

* filtering on workspaces to be deleted

* cleaned up form

* added story

* added banner filter

* PR feedback

* fix lint and stories

* PR feedback

* added enterprise test

* added unit tests in search_test.go

* remove unused fn

* unstaged changes
This commit is contained in:
Kira Pilot
2023-06-12 11:55:51 -07:00
committed by GitHub
parent 4068f70d2b
commit f13632cea8
19 changed files with 501 additions and 181 deletions
@@ -2,7 +2,9 @@ import { Workspace } from "api/typesGenerated"
import { displayImpendingDeletion } from "./utils"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { Alert } from "components/Alert/Alert"
import { formatDistanceToNow, differenceInDays } from "date-fns"
import { formatDistanceToNow, differenceInDays, add, format } from "date-fns"
import Link from "@mui/material/Link"
import { Link as RouterLink } from "react-router-dom"
export enum Count {
Singular,
@@ -46,17 +48,34 @@ export const ImpendingDeletionBanner = ({
new Date(),
)
const plusFourteen = add(new Date(), { days: 14 })
return (
<Alert
severity={daysUntilDelete <= 7 ? "warning" : "info"}
onDismiss={onDismiss}
dismissible
>
{count === Count.Singular
? `This workspace has been unused for ${formatDistanceToNow(
Date.parse(workspace.last_used_at),
)} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.`
: "You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal."}
{count === Count.Singular ? (
`This workspace has been unused for ${formatDistanceToNow(
Date.parse(workspace.last_used_at),
)} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.`
) : (
<>
<span>There are</span>{" "}
<Link
component={RouterLink}
to={`/workspaces?filter=deleting_by:${format(
plusFourteen,
"y-MM-dd",
)}`}
>
workspaces
</Link>{" "}
that will be deleted soon due to inactivity. To keep these workspaces,
connect to them via SSH or the web terminal.
</>
)}
</Alert>
)
}
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react"
import { InactivityDialog } from "./InactivityDialog"
const meta: Meta<typeof InactivityDialog> = {
title: "InactivityDialog",
component: InactivityDialog,
}
export default meta
type Story = StoryObj<typeof InactivityDialog>
export const OpenDialog: Story = {
args: {
submitValues: () => null,
isInactivityDialogOpen: true,
setIsInactivityDialogOpen: () => null,
workspacesToBeDeletedToday: 2,
},
}
@@ -0,0 +1,30 @@
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
export const InactivityDialog = ({
submitValues,
isInactivityDialogOpen,
setIsInactivityDialogOpen,
workspacesToBeDeletedToday,
}: {
submitValues: () => void
isInactivityDialogOpen: boolean
setIsInactivityDialogOpen: (arg0: boolean) => void
workspacesToBeDeletedToday: number
}) => {
return (
<ConfirmDialog
type="delete"
open={isInactivityDialogOpen}
onConfirm={() => {
submitValues()
setIsInactivityDialogOpen(false)
}}
onClose={() => setIsInactivityDialogOpen(false)}
title="Delete inactive workspaces"
confirmText="Delete Workspaces"
description={`There are ${
workspacesToBeDeletedToday ? workspacesToBeDeletedToday : ""
} workspaces that already match this filter and will be deleted upon form submission. Are you sure you want to proceed?`}
/>
)
}
@@ -0,0 +1,19 @@
import { Maybe } from "components/Conditionals/Maybe"
import { useTranslation } from "react-i18next"
export const TTLHelperText = ({
ttl,
translationName,
}: {
ttl?: number
translationName: string
}) => {
const { t } = useTranslation("templateSettingsPage")
const count = typeof ttl !== "number" ? 0 : ttl
return (
// no helper text if ttl is negative - error will show once field is considered touched
<Maybe condition={count >= 0}>
<span>{t(translationName, { count })}</span>
</Maybe>
)
}
@@ -1,12 +1,9 @@
import TextField from "@mui/material/TextField"
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { FormikTouched, useFormik } from "formik"
import { FC, ChangeEvent } from "react"
import { FC, ChangeEvent, useState } from "react"
import { getFormHelpers } from "utils/formUtils"
import * as Yup from "yup"
import i18next from "i18next"
import { useTranslation } from "react-i18next"
import { Maybe } from "components/Conditionals/Maybe"
import {
FormSection,
HorizontalForm,
@@ -19,93 +16,16 @@ import Link from "@mui/material/Link"
import Checkbox from "@mui/material/Checkbox"
import FormControlLabel from "@mui/material/FormControlLabel"
import Switch from "@mui/material/Switch"
import { InactivityDialog } from "./InactivityDialog"
import { useWorkspacesToBeDeleted } from "./useWorkspacesToBeDeleted"
import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers"
import { TTLHelperText } from "./TTLHelperText"
const TTLHelperText = ({
ttl,
translationName,
}: {
ttl?: number
translationName: string
}) => {
const { t } = useTranslation("templateSettingsPage")
const count = typeof ttl !== "number" ? 0 : ttl
return (
// no helper text if ttl is negative - error will show once field is considered touched
<Maybe condition={count >= 0}>
<span>{t(translationName, { count })}</span>
</Maybe>
)
}
const MAX_TTL_DAYS = 7
const MS_HOUR_CONVERSION = 3600000
const MS_DAY_CONVERSION = 86400000
const FAILURE_CLEANUP_DEFAULT = 7
const INACTIVITY_CLEANUP_DEFAULT = 180
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
failure_cleanup_enabled: boolean
inactivity_cleanup_enabled: boolean
}
export const getValidationSchema = (): Yup.AnyObjectSchema =>
Yup.object({
default_ttl_ms: Yup.number()
.integer()
.min(
0,
i18next
.t("defaultTTLMinError", { ns: "templateSettingsPage" })
.toString(),
)
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next
.t("defaultTTLMaxError", { ns: "templateSettingsPage" })
.toString(),
),
max_ttl_ms: Yup.number()
.integer()
.min(
0,
i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(),
)
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(),
),
failure_ttl_ms: Yup.number()
.min(0, "Failure cleanup days must not be less than 0.")
.test(
"positive-if-enabled",
"Failure cleanup days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.failure_cleanup_enabled) {
return Boolean(value)
} else {
return true
}
},
),
inactivity_ttl_ms: Yup.number()
.min(0, "Inactivity cleanup days must not be less than 0.")
.test(
"positive-if-enabled",
"Inactivity cleanup days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.inactivity_cleanup_enabled) {
return Boolean(value)
} else {
return true
}
},
),
allow_user_autostart: Yup.boolean(),
allow_user_autostop: Yup.boolean(),
})
export interface TemplateScheduleForm {
template: Template
onSubmit: (data: UpdateTemplateMeta) => void
@@ -154,25 +74,16 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms),
},
validationSchema,
onSubmit: (formData) => {
// on submit, convert from hours => ms
onSubmit({
default_ttl_ms: formData.default_ttl_ms
? formData.default_ttl_ms * MS_HOUR_CONVERSION
: undefined,
max_ttl_ms: formData.max_ttl_ms
? formData.max_ttl_ms * MS_HOUR_CONVERSION
: undefined,
failure_ttl_ms: formData.failure_ttl_ms
? formData.failure_ttl_ms * MS_DAY_CONVERSION
: undefined,
inactivity_ttl_ms: formData.inactivity_ttl_ms
? formData.inactivity_ttl_ms * MS_DAY_CONVERSION
: undefined,
allow_user_autostart: formData.allow_user_autostart,
allow_user_autostop: formData.allow_user_autostop,
})
onSubmit: () => {
if (
form.values.inactivity_cleanup_enabled &&
workspacesToBeDeletedToday &&
workspacesToBeDeletedToday.length > 0
) {
setIsInactivityDialogOpen(true)
} else {
submitValues()
}
},
initialTouched,
})
@@ -183,6 +94,32 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
const { t } = useTranslation("templateSettingsPage")
const styles = useStyles()
const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values)
const [isInactivityDialogOpen, setIsInactivityDialogOpen] =
useState<boolean>(false)
const submitValues = () => {
// on submit, convert from hours => ms
onSubmit({
default_ttl_ms: form.values.default_ttl_ms
? form.values.default_ttl_ms * MS_HOUR_CONVERSION
: undefined,
max_ttl_ms: form.values.max_ttl_ms
? form.values.max_ttl_ms * MS_HOUR_CONVERSION
: undefined,
failure_ttl_ms: form.values.failure_ttl_ms
? form.values.failure_ttl_ms * MS_DAY_CONVERSION
: undefined,
inactivity_ttl_ms: form.values.inactivity_ttl_ms
? form.values.inactivity_ttl_ms * MS_DAY_CONVERSION
: undefined,
allow_user_autostart: form.values.allow_user_autostart,
allow_user_autostop: form.values.allow_user_autostop,
})
}
const handleToggleFailureCleanup = async (e: ChangeEvent) => {
form.handleChange(e)
if (!form.values.failure_cleanup_enabled) {
@@ -395,6 +332,12 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
</FormSection>
</>
)}
<InactivityDialog
submitValues={submitValues}
isInactivityDialogOpen={isInactivityDialogOpen}
setIsInactivityDialogOpen={setIsInactivityDialogOpen}
workspacesToBeDeletedToday={workspacesToBeDeletedToday?.length ?? 0}
/>
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
@@ -0,0 +1,68 @@
import { UpdateTemplateMeta } from "api/typesGenerated"
import * as Yup from "yup"
import i18next from "i18next"
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
failure_cleanup_enabled: boolean
inactivity_cleanup_enabled: boolean
}
const MAX_TTL_DAYS = 7
export const getValidationSchema = (): Yup.AnyObjectSchema =>
Yup.object({
default_ttl_ms: Yup.number()
.integer()
.min(
0,
i18next
.t("defaultTTLMinError", { ns: "templateSettingsPage" })
.toString(),
)
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next
.t("defaultTTLMaxError", { ns: "templateSettingsPage" })
.toString(),
),
max_ttl_ms: Yup.number()
.integer()
.min(
0,
i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(),
)
.max(
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(),
),
failure_ttl_ms: Yup.number()
.min(0, "Failure cleanup days must not be less than 0.")
.test(
"positive-if-enabled",
"Failure cleanup days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.failure_cleanup_enabled) {
return Boolean(value)
} else {
return true
}
},
),
inactivity_ttl_ms: Yup.number()
.min(0, "Inactivity cleanup days must not be less than 0.")
.test(
"positive-if-enabled",
"Inactivity cleanup days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.inactivity_cleanup_enabled) {
return Boolean(value)
} else {
return true
}
},
),
allow_user_autostart: Yup.boolean(),
allow_user_autostop: Yup.boolean(),
})
@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query"
import { getWorkspaces } from "api/api"
import { compareAsc, add, endOfToday } from "date-fns"
import { WorkspaceStatus, Workspace } from "api/typesGenerated"
import { TemplateScheduleFormValues } from "./formHelpers"
const inactiveStatuses: WorkspaceStatus[] = [
"stopped",
"canceled",
"failed",
"deleted",
]
export const useWorkspacesToBeDeleted = (
formValues: TemplateScheduleFormValues,
) => {
const { data: workspacesData } = useQuery({
queryKey: ["workspaces"],
queryFn: () => getWorkspaces({}),
enabled: formValues.inactivity_cleanup_enabled,
})
return workspacesData?.workspaces?.filter((workspace: Workspace) => {
const isInactive = inactiveStatuses.includes(workspace.latest_build.status)
const proposedDeletion = add(new Date(workspace.last_used_at), {
days: formValues.inactivity_ttl_ms,
})
if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) {
return workspace
}
})
}
@@ -11,7 +11,7 @@ import {
renderWithTemplateSettingsLayout,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import { getValidationSchema } from "./TemplateScheduleForm"
import { getValidationSchema } from "./TemplateScheduleForm/formHelpers"
import TemplateSchedulePage from "./TemplateSchedulePage"
import i18next from "i18next"
@@ -1,31 +1,46 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { Meta, StoryObj } from "@storybook/react"
import { MockTemplate } from "testHelpers/entities"
import {
TemplateSchedulePageView,
TemplateSchedulePageViewProps,
} from "./TemplateSchedulePageView"
import { TemplateSchedulePageView } from "./TemplateSchedulePageView"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
export default {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
refetchOnWindowFocus: false,
networkMode: "offlineFirst",
},
},
})
const meta: Meta<typeof TemplateSchedulePageView> = {
title: "pages/TemplateSchedulePageView",
component: TemplateSchedulePageView,
args: {
allowAdvancedScheduling: true,
allowWorkspaceActions: true,
template: MockTemplate,
onSubmit: action("onSubmit"),
onCancel: action("cancel"),
},
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
),
],
}
export default meta
type Story = StoryObj<typeof TemplateSchedulePageView>
const defaultArgs = {
allowAdvancedScheduling: true,
allowWorkspaceActions: true,
template: MockTemplate,
onSubmit: action("onSubmit"),
onCancel: action("cancel"),
}
const Template: Story<TemplateSchedulePageViewProps> = (args) => (
<TemplateSchedulePageView {...args} />
)
export const Example: Story = {
args: { ...defaultArgs },
}
export const Example = Template.bind({})
Example.args = {}
export const CantSetMaxTTL = Template.bind({})
CantSetMaxTTL.args = {
allowAdvancedScheduling: false,
export const CantSetMaxTTL: Story = {
args: { ...defaultArgs, allowAdvancedScheduling: false },
}
@@ -1,6 +1,6 @@
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { ComponentProps, FC } from "react"
import { TemplateScheduleForm } from "./TemplateScheduleForm"
import { TemplateScheduleForm } from "./TemplateScheduleForm/TemplateScheduleForm"
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
import { makeStyles } from "@mui/styles"
@@ -1,18 +1,11 @@
import { screen } from "@testing-library/react"
import { rest } from "msw"
import * as CreateDayString from "utils/createDayString"
import {
MockWorkspace,
MockWorkspacesResponse,
MockEntitlementsWithScheduling,
MockWorkspacesResponseWithDeletions,
} from "testHelpers/entities"
import { MockWorkspace, MockWorkspacesResponse } from "testHelpers/entities"
import { history, renderWithAuth } from "testHelpers/renderHelpers"
import { server } from "testHelpers/server"
import WorkspacesPage from "./WorkspacesPage"
import { i18n } from "i18n"
import * as API from "api/api"
import userEvent from "@testing-library/user-event"
const { t } = i18n
@@ -48,24 +41,4 @@ describe("WorkspacesPage", () => {
)
expect(templateDisplayNames).toHaveLength(MockWorkspacesResponse.count)
})
it("displays banner for impending deletions", async () => {
jest
.spyOn(API, "getEntitlements")
.mockResolvedValue(MockEntitlementsWithScheduling)
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue(MockWorkspacesResponseWithDeletions)
renderWithAuth(<WorkspacesPage />)
const banner = await screen.findByText(
"You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal.",
)
const user = userEvent.setup()
await user.click(screen.getByTestId("dismiss-banner-btn"))
expect(banner).toBeEmptyDOMElement
})
})