Skip to content

Commit 0b15b1b

Browse files
authored
feat: add impending deletion indicators to the workspace page (#7588)
* created WorkspaceDeletion directory * remove commented code * attempting to fix workspace stories * fix lint * fix the rest of the stories * fix right stories * PR comments * fix lint
1 parent 8e31ed4 commit 0b15b1b

20 files changed

+422
-142
lines changed

site/src/components/Workspace/Workspace.stories.tsx

+29-13
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { action } from "@storybook/addon-actions"
22
import { Story } from "@storybook/react"
33
import { WatchAgentMetadataContext } from "components/Resources/AgentMetadata"
44
import { ProvisionerJobLog } from "api/typesGenerated"
5-
import * as Mocks from "../../testHelpers/entities"
5+
import * as Mocks from "testHelpers/entities"
66
import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace"
77
import { withReactContext } from "storybook-react-context"
88
import EventSource from "eventsourcemock"
99
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
10-
import { MockProxyLatencies } from "../../testHelpers/entities"
10+
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider"
1111

1212
export default {
1313
title: "components/Workspace",
@@ -24,21 +24,37 @@ export default {
2424
],
2525
}
2626

27+
const MockedAppearance = {
28+
config: Mocks.MockAppearance,
29+
preview: false,
30+
setPreview: () => null,
31+
save: () => null,
32+
}
33+
2734
const Template: Story<WorkspaceProps> = (args) => (
28-
<ProxyContext.Provider
35+
<DashboardProviderContext.Provider
2936
value={{
30-
proxyLatencies: MockProxyLatencies,
31-
proxy: getPreferredProxy([], undefined),
32-
proxies: [],
33-
isLoading: false,
34-
isFetched: true,
35-
setProxy: () => {
36-
return
37-
},
37+
buildInfo: Mocks.MockBuildInfo,
38+
entitlements: Mocks.MockEntitlementsWithScheduling,
39+
experiments: Mocks.MockExperiments,
40+
appearance: MockedAppearance,
3841
}}
3942
>
40-
<Workspace {...args} />
41-
</ProxyContext.Provider>
43+
<ProxyContext.Provider
44+
value={{
45+
proxyLatencies: Mocks.MockProxyLatencies,
46+
proxy: getPreferredProxy([], undefined),
47+
proxies: [],
48+
isLoading: false,
49+
isFetched: true,
50+
setProxy: () => {
51+
return
52+
},
53+
}}
54+
>
55+
<Workspace {...args} />
56+
</ProxyContext.Provider>
57+
</DashboardProviderContext.Provider>
4258
)
4359

4460
export const Running = Template.bind({})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Workspace } from "api/typesGenerated"
2+
import { displayImpendingDeletion } from "./utils"
3+
import { useDashboard } from "components/Dashboard/DashboardProvider"
4+
import { Pill } from "components/Pill/Pill"
5+
import ErrorIcon from "@mui/icons-material/ErrorOutline"
6+
7+
export const ImpendingDeletionBadge = ({
8+
workspace,
9+
}: {
10+
workspace: Workspace
11+
}): JSX.Element | null => {
12+
const { entitlements, experiments } = useDashboard()
13+
const allowAdvancedScheduling =
14+
entitlements.features["advanced_template_scheduling"].enabled
15+
// This check can be removed when https://github.com/coder/coder/milestone/19
16+
// is merged up
17+
const allowWorkspaceActions = experiments.includes("workspace_actions")
18+
// return null
19+
20+
if (
21+
!displayImpendingDeletion(
22+
workspace,
23+
allowAdvancedScheduling,
24+
allowWorkspaceActions,
25+
)
26+
) {
27+
return null
28+
}
29+
30+
return <Pill icon={<ErrorIcon />} text="Impending deletion" type="error" />
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Workspace } from "api/typesGenerated"
2+
import { displayImpendingDeletion } from "./utils"
3+
import { useDashboard } from "components/Dashboard/DashboardProvider"
4+
import { Maybe } from "components/Conditionals/Maybe"
5+
import { Alert } from "components/Alert/Alert"
6+
7+
export const ImpendingDeletionBanner = ({
8+
workspace,
9+
onDismiss,
10+
displayImpendingDeletionBanner,
11+
}: {
12+
workspace?: Workspace
13+
onDismiss: () => void
14+
displayImpendingDeletionBanner: boolean
15+
}): JSX.Element | null => {
16+
const { entitlements, experiments } = useDashboard()
17+
const allowAdvancedScheduling =
18+
entitlements.features["advanced_template_scheduling"].enabled
19+
// This check can be removed when https://github.com/coder/coder/milestone/19
20+
// is merged up
21+
const allowWorkspaceActions = experiments.includes("workspace_actions")
22+
23+
return (
24+
<Maybe
25+
condition={Boolean(
26+
workspace &&
27+
displayImpendingDeletion(
28+
workspace,
29+
allowAdvancedScheduling,
30+
allowWorkspaceActions,
31+
) &&
32+
displayImpendingDeletionBanner,
33+
)}
34+
>
35+
<Alert severity="info" onDismiss={onDismiss} dismissible>
36+
You have workspaces that will be deleted soon.
37+
</Alert>
38+
</Maybe>
39+
)
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Maybe } from "components/Conditionals/Maybe"
2+
import { StatsItem } from "components/Stats/Stats"
3+
import Link from "@mui/material/Link"
4+
import { Link as RouterLink } from "react-router-dom"
5+
import styled from "@emotion/styled"
6+
import { Workspace } from "api/typesGenerated"
7+
import { displayImpendingDeletion } from "./utils"
8+
import { useDashboard } from "components/Dashboard/DashboardProvider"
9+
10+
export const ImpendingDeletionStat = ({
11+
workspace,
12+
}: {
13+
workspace: Workspace
14+
}): JSX.Element => {
15+
const { entitlements, experiments } = useDashboard()
16+
const allowAdvancedScheduling =
17+
entitlements.features["advanced_template_scheduling"].enabled
18+
// This check can be removed when https://github.com/coder/coder/milestone/19
19+
// is merged up
20+
const allowWorkspaceActions = experiments.includes("workspace_actions")
21+
22+
return (
23+
<Maybe
24+
condition={displayImpendingDeletion(
25+
workspace,
26+
allowAdvancedScheduling,
27+
allowWorkspaceActions,
28+
)}
29+
>
30+
<StyledStatsItem
31+
label="Deletion on"
32+
className="containerClass"
33+
value={
34+
<Link
35+
component={RouterLink}
36+
to={`/templates/${workspace.template_name}/settings/schedule`}
37+
title="Schedule settings"
38+
>
39+
{/* We check for string existence in the conditional */}
40+
{new Date(workspace.deleting_at as string).toLocaleString()}
41+
</Link>
42+
}
43+
/>
44+
</Maybe>
45+
)
46+
}
47+
48+
const StyledStatsItem = styled(StatsItem)(() => ({
49+
"&.containerClass": {
50+
flexDirection: "column",
51+
gap: 0,
52+
padding: 0,
53+
54+
"& > span:first-of-type": {
55+
fontSize: 12,
56+
fontWeight: 500,
57+
},
58+
},
59+
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Workspace } from "api/typesGenerated"
2+
import { displayImpendingDeletion } from "./utils"
3+
import { useDashboard } from "components/Dashboard/DashboardProvider"
4+
import styled from "@emotion/styled"
5+
import { Theme as MaterialUITheme } from "@mui/material/styles"
6+
7+
export const ImpendingDeletionText = ({
8+
workspace,
9+
}: {
10+
workspace: Workspace
11+
}): JSX.Element | null => {
12+
const { entitlements, experiments } = useDashboard()
13+
const allowAdvancedScheduling =
14+
entitlements.features["advanced_template_scheduling"].enabled
15+
// This check can be removed when https://github.com/coder/coder/milestone/19
16+
// is merged up
17+
const allowWorkspaceActions = experiments.includes("workspace_actions")
18+
19+
if (
20+
!displayImpendingDeletion(
21+
workspace,
22+
allowAdvancedScheduling,
23+
allowWorkspaceActions,
24+
)
25+
) {
26+
return null
27+
}
28+
return <StyledSpan role="status">Impending deletion</StyledSpan>
29+
}
30+
31+
const StyledSpan = styled.span<{ theme?: MaterialUITheme }>`
32+
color: ${(props) => props.theme.palette.warning.light};
33+
font-weight: 600;
34+
`
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from "./ImpendingDeletionStat"
2+
export * from "./ImpendingDeletionBadge"
3+
export * from "./ImpendingDeletionText"
4+
export * from "./ImpendingDeletionBanner"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as TypesGen from "api/typesGenerated"
2+
import * as Mocks from "testHelpers/entities"
3+
import { displayImpendingDeletion } from "./utils"
4+
5+
describe("displayImpendingDeletion", () => {
6+
const today = new Date()
7+
it.each<[string, boolean, boolean, boolean]>([
8+
[
9+
new Date(new Date().setDate(today.getDate() + 15)).toISOString(),
10+
true,
11+
true,
12+
false,
13+
], // today + 15 days out
14+
[
15+
new Date(new Date().setDate(today.getDate() + 14)).toISOString(),
16+
true,
17+
true,
18+
true,
19+
], // today + 14
20+
[
21+
new Date(new Date().setDate(today.getDate() + 13)).toISOString(),
22+
true,
23+
true,
24+
true,
25+
], // today + 13
26+
[
27+
new Date(new Date().setDate(today.getDate() + 1)).toISOString(),
28+
true,
29+
true,
30+
true,
31+
], // today + 1
32+
[new Date().toISOString(), true, true, true], // today + 0
33+
[new Date().toISOString(), false, true, false], // Advanced Scheduling off
34+
[new Date().toISOString(), true, false, false], // Workspace Actions off
35+
])(
36+
`deleting_at=%p, allowAdvancedScheduling=%p, AllowWorkspaceActions=%p, shouldDisplay=%p`,
37+
(
38+
deleting_at,
39+
allowAdvancedScheduling,
40+
allowWorkspaceActions,
41+
shouldDisplay,
42+
) => {
43+
const workspace: TypesGen.Workspace = {
44+
...Mocks.MockWorkspace,
45+
deleting_at,
46+
}
47+
expect(
48+
displayImpendingDeletion(
49+
workspace,
50+
allowAdvancedScheduling,
51+
allowWorkspaceActions,
52+
),
53+
).toBe(shouldDisplay)
54+
},
55+
)
56+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Workspace } from "api/typesGenerated"
2+
3+
// This const dictates how far out we alert the user that a workspace
4+
// has an impending deletion (due to template.InactivityTTL being set)
5+
const IMPENDING_DELETION_DISPLAY_THRESHOLD = 14 // 14 days
6+
7+
/**
8+
* Returns a boolean indicating if an impending deletion indicator should be
9+
* displayed in the UI. Impending deletions are configured by setting the
10+
* Template.InactivityTTL
11+
* @param {TypesGen.Workspace} workspace
12+
* @returns {boolean}
13+
*/
14+
export const displayImpendingDeletion = (
15+
workspace: Workspace,
16+
allowAdvancedScheduling: boolean,
17+
allowWorkspaceActions: boolean,
18+
) => {
19+
const today = new Date()
20+
if (
21+
!workspace.deleting_at ||
22+
!allowAdvancedScheduling ||
23+
!allowWorkspaceActions
24+
) {
25+
return false
26+
}
27+
return (
28+
new Date(workspace.deleting_at) <=
29+
new Date(
30+
today.setDate(today.getDate() + IMPENDING_DELETION_DISPLAY_THRESHOLD),
31+
)
32+
)
33+
}

site/src/components/WorkspaceStats/WorkspaceStats.stories.tsx

+25-2
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
11
import { Story } from "@storybook/react"
2-
import { MockWorkspace } from "testHelpers/entities"
2+
import {
3+
MockWorkspace,
4+
MockAppearance,
5+
MockBuildInfo,
6+
MockEntitlementsWithScheduling,
7+
MockExperiments,
8+
} from "testHelpers/entities"
39
import {
410
WorkspaceStats,
511
WorkspaceStatsProps,
612
} from "../WorkspaceStats/WorkspaceStats"
13+
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider"
714

815
export default {
916
title: "components/WorkspaceStats",
1017
component: WorkspaceStats,
1118
}
1219

20+
const MockedAppearance = {
21+
config: MockAppearance,
22+
preview: false,
23+
setPreview: () => null,
24+
save: () => null,
25+
}
26+
1327
const Template: Story<WorkspaceStatsProps> = (args) => (
14-
<WorkspaceStats {...args} />
28+
<DashboardProviderContext.Provider
29+
value={{
30+
buildInfo: MockBuildInfo,
31+
entitlements: MockEntitlementsWithScheduling,
32+
experiments: MockExperiments,
33+
appearance: MockedAppearance,
34+
}}
35+
>
36+
<WorkspaceStats {...args} />
37+
</DashboardProviderContext.Provider>
1538
)
1639

1740
export const Example = Template.bind({})

site/src/components/WorkspaceStats/WorkspaceStats.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Popover from "@mui/material/Popover"
2020
import TextField from "@mui/material/TextField"
2121
import Button from "@mui/material/Button"
2222
import { WorkspaceStatusText } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"
23+
import { ImpendingDeletionStat } from "components/WorkspaceDeletion"
2324

2425
const Language = {
2526
workspaceDetails: "Workspace Details",
@@ -74,6 +75,7 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
7475
label="Status"
7576
value={<WorkspaceStatusText workspace={workspace} />}
7677
/>
78+
<ImpendingDeletionStat workspace={workspace} />
7779
<StatsItem
7880
className={styles.statsItem}
7981
label={Language.templateLabel}

0 commit comments

Comments
 (0)