mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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
This commit is contained in:
@ -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>
|
||||
)}
|
||||
|
11
site/src/hooks/index.ts
Normal file
11
site/src/hooks/index.ts
Normal file
@ -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
site/src/hooks/useLocalStorage.ts
Normal file
25
site/src/hooks/useLocalStorage.ts
Normal file
@ -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)
|
||||
}
|
@ -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(<WorkspacesPage />)
|
||||
renderWithAuth(<WorkspacesPage />)
|
||||
|
||||
// Then
|
||||
const text = t("emptyCreateWorkspaceMessage", { ns: "workspacesPage" })
|
||||
@ -37,11 +41,31 @@ describe("WorkspacesPage", () => {
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
@ -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 (
|
||||
<Margins>
|
||||
@ -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}
|
||||
|
@ -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",
|
||||
@ -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",
|
||||
|
Reference in New Issue
Block a user