From dca77ba487b164ae25cc2f83171a3b27eb1c16b6 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 16 May 2023 07:01:22 -0700 Subject: [PATCH] feat: add workspaces banner for impending deletion (#7538) * feat: add workspaces banner for impending deletion * added storybook * remove storybook - cannot add because of hook used in badge component --- .../AlertBanner/AlertBannerCtas.tsx | 6 +- site/src/hooks/index.ts | 11 +++ site/src/hooks/useLocalStorage.ts | 25 +++++ .../WorkspacesPage/WorkspacesPage.test.tsx | 34 ++++++- .../pages/WorkspacesPage/WorkspacesPage.tsx | 10 ++ .../WorkspacesPage/WorkspacesPageView.tsx | 92 +++++++++++++++---- site/src/testHelpers/entities.ts | 11 +++ 7 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 site/src/hooks/index.ts create mode 100644 site/src/hooks/useLocalStorage.ts diff --git a/site/src/components/AlertBanner/AlertBannerCtas.tsx b/site/src/components/AlertBanner/AlertBannerCtas.tsx index 0758ec5d2e..7b79455022 100644 --- a/site/src/components/AlertBanner/AlertBannerCtas.tsx +++ b/site/src/components/AlertBanner/AlertBannerCtas.tsx @@ -37,7 +37,11 @@ export const AlertBannerCtas: FC = ({ {/* close CTA */} {dismissible && ( - )} diff --git a/site/src/hooks/index.ts b/site/src/hooks/index.ts new file mode 100644 index 0000000000..0187a3fe1d --- /dev/null +++ b/site/src/hooks/index.ts @@ -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" diff --git a/site/src/hooks/useLocalStorage.ts b/site/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000000..193e4b8f64 --- /dev/null +++ b/site/src/hooks/useLocalStorage.ts @@ -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 => { + 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) +} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 14c97c63f3..8a9ddeda68 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -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 @@ -29,7 +33,7 @@ describe("WorkspacesPage", () => { ) // When - render() + renderWithAuth() // Then const text = t("emptyCreateWorkspaceMessage", { ns: "workspacesPage" }) @@ -37,11 +41,31 @@ describe("WorkspacesPage", () => { }) it("renders a filled workspaces page", async () => { - render() + renderWithAuth() 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() + + 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 + }) }) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 34d351c419..f69baa7d8f 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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, @@ -34,6 +42,8 @@ const WorkspacesPage: FC = () => { onUpdateWorkspace={(workspace) => { updateWorkspace.mutate(workspace) }} + allowAdvancedScheduling={allowAdvancedScheduling} + allowWorkspaceActions={allowWorkspaceActions} /> ) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 6309a131ce..330457119e 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -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", @@ -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[] @@ -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< @@ -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 ( @@ -94,6 +135,19 @@ export const WorkspacesPageView: FC< } /> + + + saveLocal( + "dismissedWorkspaceList", + JSON.stringify(workspaceIdsWithImpendingDeletions), + ) + } + dismissible + text="You have workspaces that will be deleted soon." + /> +