Skip to content

feat: add workspaces banner for impending deletion #7538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion site/src/components/AlertBanner/AlertBannerCtas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export const AlertBannerCtas: FC<AlertBannerCtasProps> = ({

{/* close CTA */}
{dismissible && (
<Button size="small" onClick={() => setOpen(false)}>
<Button
size="small"
onClick={() => setOpen(false)}
data-testid="dismiss-banner-btn"
>
{t("ctas.dismissCta")}
</Button>
)}
Expand Down
11 changes: 11 additions & 0 deletions site/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export * from "./useClickable"
export * from "./useClickableTableRow"
export * from "./useClipboard"
export * from "./useFeatureVisibility"
export * from "./useFilter"
export * from "./useLocalStorage"
export * from "./useMe"
export * from "./useOrganizationId"
export * from "./usePagination"
export * from "./usePermissions"
export * from "./useTab"
25 changes: 25 additions & 0 deletions site/src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
interface UseLocalStorage {
saveLocal: (arg0: string, arg1: string) => void
getLocal: (arg0: string) => string | undefined
clearLocal: (arg0: string) => void
}

export const useLocalStorage = (): UseLocalStorage => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

return {
saveLocal,
getLocal,
clearLocal,
}
}

const saveLocal = (itemKey: string, itemValue: string): void => {
window.localStorage.setItem(itemKey, itemValue)
}

const getLocal = (itemKey: string): string | undefined => {
return localStorage.getItem(itemKey) ?? undefined
}

const clearLocal = (itemKey: string): void => {
localStorage.removeItem(itemKey)
}
34 changes: 29 additions & 5 deletions site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import * as CreateDayString from "utils/createDayString"
import {
MockWorkspace,
MockWorkspacesResponse,
} from "../../testHelpers/entities"
import { history, render } from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server"
MockEntitlementsWithScheduling,
MockWorkspacesResponseWithDeletions,
} 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

Expand All @@ -29,19 +33,39 @@ describe("WorkspacesPage", () => {
)

// When
render(<WorkspacesPage />)
renderWithAuth(<WorkspacesPage />)

// Then
const text = t("emptyCreateWorkspaceMessage", { ns: "workspacesPage" })
await screen.findByText(text)
})

it("renders a filled workspaces page", async () => {
render(<WorkspacesPage />)
renderWithAuth(<WorkspacesPage />)
await screen.findByText(`${MockWorkspace.name}1`)
const templateDisplayNames = await screen.findAllByText(
`${MockWorkspace.template_display_name}`,
)
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.",
)
const user = userEvent.setup()
await user.click(screen.getByTestId("dismiss-banner-btn"))

expect(banner).toBeEmptyDOMElement
})
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a rendering test - the test you want to check if something is rendered or not - do you think we should move this to the storybook? I feel this would be better to add it to jest.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is checking flow - that the banner is visible until the user clicks 'Dismiss' and then it is hidden. However, adding a storybook is a great idea so I'll do that.

})
10 changes: 10 additions & 0 deletions site/src/pages/WorkspacesPage/WorkspacesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ import { workspaceFilterQuery } from "utils/filters"
import { pageTitle } from "utils/page"
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
import { WorkspacesPageView } from "./WorkspacesPageView"
import { useDashboard } from "components/Dashboard/DashboardProvider"

const WorkspacesPage: FC = () => {
const filter = useFilter(workspaceFilterQuery.me)
const pagination = usePagination()
const { entitlements, experiments } = useDashboard()
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions")

const { data, error, queryKey } = useWorkspacesData({
...pagination,
...filter,
Expand All @@ -34,6 +42,8 @@ const WorkspacesPage: FC = () => {
onUpdateWorkspace={(workspace) => {
updateWorkspace.mutate(workspace)
}}
allowAdvancedScheduling={allowAdvancedScheduling}
allowWorkspaceActions={allowWorkspaceActions}
/>
</>
)
Expand Down
92 changes: 73 additions & 19 deletions site/src/pages/WorkspacesPage/WorkspacesPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import { Maybe } from "components/Conditionals/Maybe"
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"
import { FC } from "react"
import { Link as RouterLink } from "react-router-dom"
import { Margins } from "../../components/Margins/Margins"
import { Margins } from "components/Margins/Margins"
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "../../components/PageHeader/PageHeader"
import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter"
import { Stack } from "../../components/Stack/Stack"
import { WorkspaceHelpTooltip } from "../../components/Tooltips"
import { WorkspacesTable } from "../../components/WorkspacesTable/WorkspacesTable"
import { workspaceFilterQuery } from "../../utils/filters"
} from "components/PageHeader/PageHeader"
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
import { Stack } from "components/Stack/Stack"
import { WorkspaceHelpTooltip } from "components/Tooltips"
import { WorkspacesTable } from "components/WorkspacesTable/WorkspacesTable"
import { workspaceFilterQuery } from "utils/filters"
import { useLocalStorage } from "hooks"
import difference from "lodash/difference"

export const Language = {
pageTitle: "Workspaces",
Expand All @@ -26,6 +28,19 @@ export const Language = {
template: "Template",
}

const presetFilters = [
{ query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton },
{ query: workspaceFilterQuery.all, name: Language.allWorkspacesButton },
{
query: workspaceFilterQuery.running,
name: Language.runningWorkspacesButton,
},
{
query: workspaceFilterQuery.failed,
name: "Failed workspaces",
},
]

export interface WorkspacesPageViewProps {
error: unknown
workspaces?: Workspace[]
Expand All @@ -36,6 +51,8 @@ export interface WorkspacesPageViewProps {
onPageChange: (page: number) => void
onFilter: (query: string) => void
onUpdateWorkspace: (workspace: Workspace) => void
allowAdvancedScheduling: boolean
allowWorkspaceActions: boolean
}

export const WorkspacesPageView: FC<
Expand All @@ -50,19 +67,43 @@ export const WorkspacesPageView: FC<
onFilter,
onPageChange,
onUpdateWorkspace,
allowAdvancedScheduling,
allowWorkspaceActions,
}) => {
const presetFilters = [
{ query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton },
{ query: workspaceFilterQuery.all, name: Language.allWorkspacesButton },
{
query: workspaceFilterQuery.running,
name: Language.runningWorkspacesButton,
},
{
query: workspaceFilterQuery.failed,
name: "Failed workspaces",
},
]
const { saveLocal, getLocal } = useLocalStorage()

const workspaceIdsWithImpendingDeletions = workspaces
?.filter((workspace) => workspace.deleting_at)
.map((workspace) => workspace.id)

/**
* Returns a boolean indicating if there are workspaces that have been
* recently marked for deletion but are not in local storage.
* If there are, we want to alert the user so they can potentially take action
* before deletion takes place.
* @returns {boolean}
*/
const isNewWorkspacesImpendingDeletion = (): boolean => {
const dismissedList = getLocal("dismissedWorkspaceList")
if (!dismissedList) {
return true
}

const diff = difference(
workspaceIdsWithImpendingDeletions,
JSON.parse(dismissedList),
)

return diff && diff.length > 0
}

const displayImpendingDeletionBanner =
(allowAdvancedScheduling &&
allowWorkspaceActions &&
workspaceIdsWithImpendingDeletions &&
workspaceIdsWithImpendingDeletions.length > 0 &&
isNewWorkspacesImpendingDeletion()) ??
false

return (
<Margins>
Expand Down Expand Up @@ -94,6 +135,19 @@ export const WorkspacesPageView: FC<
}
/>
</Maybe>
<Maybe condition={displayImpendingDeletionBanner}>
<AlertBanner
severity="info"
onDismiss={() =>
saveLocal(
"dismissedWorkspaceList",
JSON.stringify(workspaceIdsWithImpendingDeletions),
)
}
dismissible
text="You have workspaces that will be deleted soon."
/>
</Maybe>

<SearchBarWithFilter
filter={filter}
Expand Down
11 changes: 11 additions & 0 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,12 @@ export const MockDeletingWorkspace: TypesGen.Workspace = {
status: "deleting",
},
}

export const MockWorkspaceWithDeletion = {
...MockWorkspace,
deleting_at: new Date().toISOString(),
}

export const MockDeletedWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-deleted-workspace",
Expand Down Expand Up @@ -857,6 +863,11 @@ export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = {
count: 26,
}

export const MockWorkspacesResponseWithDeletions = {
workspaces: [...MockWorkspacesResponse.workspaces, MockWorkspaceWithDeletion],
count: MockWorkspacesResponse.count + 1,
}

export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter =
{
name: "first_parameter",
Expand Down