Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d931b2c

Browse files
authored
chore: refactor schedule banner (#4274)
* Start refactor * Fix color of auto stop switch * Format * Use helper functions for min/max check * Fix type * Put new component in own file * Fix decrease deadline bug * Simplify functions * Use ChooseOne * Remove commented code
1 parent 139bc6f commit d931b2c

File tree

7 files changed

+309
-152
lines changed

7 files changed

+309
-152
lines changed

site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export const WorkspaceScheduleForm: FC<React.PropsWithChildren<WorkspaceSchedule
309309
name="autoStopEnabled"
310310
checked={form.values.autoStopEnabled}
311311
onChange={handleToggleAutoStop}
312+
color="primary"
312313
/>
313314
}
314315
label={Language.stopSwitch}

site/src/pages/WorkspacePage/WorkspacePage.tsx

+23-126
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,21 @@
11
import { makeStyles } from "@material-ui/core/styles"
2-
import { useActor, useMachine, useSelector } from "@xstate/react"
3-
import { FeatureNames } from "api/types"
4-
import dayjs from "dayjs"
5-
import minMax from "dayjs/plugin/minMax"
6-
import { FC, useContext, useEffect } from "react"
7-
import { Helmet } from "react-helmet-async"
8-
import { useTranslation } from "react-i18next"
2+
import { useMachine } from "@xstate/react"
3+
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
4+
import { FC, useEffect } from "react"
95
import { useParams } from "react-router-dom"
10-
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
11-
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
126
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
137
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
14-
import { Workspace, WorkspaceErrors } from "../../components/Workspace/Workspace"
158
import { firstOrItem } from "../../util/array"
16-
import { pageTitle } from "../../util/page"
17-
import { canExtendDeadline, canReduceDeadline, maxDeadline, minDeadline } from "../../util/schedule"
18-
import { getFaviconByStatus } from "../../util/workspace"
19-
import { XServiceContext } from "../../xServices/StateContext"
209
import { workspaceMachine } from "../../xServices/workspace/workspaceXService"
21-
import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"
22-
23-
dayjs.extend(minMax)
10+
import { WorkspaceReadyPage } from "./WorkspaceReadyPage"
2411

2512
export const WorkspacePage: FC = () => {
2613
const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams()
2714
const username = firstOrItem(usernameQueryParam, null)
2815
const workspaceName = firstOrItem(workspaceQueryParam, null)
29-
const { t } = useTranslation("workspacePage")
30-
const xServices = useContext(XServiceContext)
31-
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
3216
const [workspaceState, workspaceSend] = useMachine(workspaceMachine)
33-
const {
34-
workspace,
35-
getWorkspaceError,
36-
template,
37-
getTemplateWarning,
38-
refreshWorkspaceWarning,
39-
builds,
40-
getBuildsError,
41-
permissions,
42-
checkPermissionsError,
43-
buildError,
44-
cancellationError,
45-
applicationsHost,
46-
} = workspaceState.context
47-
const canUpdateWorkspace = Boolean(permissions?.updateWorkspace)
48-
const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)
49-
const [buildInfoState] = useActor(xServices.buildInfoXService)
17+
const { workspace, getWorkspaceError, getTemplateWarning, checkPermissionsError } =
18+
workspaceState.context
5019
const styles = useStyles()
5120

5221
/**
@@ -57,95 +26,23 @@ export const WorkspacePage: FC = () => {
5726
username && workspaceName && workspaceSend({ type: "GET_WORKSPACE", username, workspaceName })
5827
}, [username, workspaceName, workspaceSend])
5928

60-
if (workspaceState.matches("error")) {
61-
return (
62-
<div className={styles.error}>
63-
{Boolean(getWorkspaceError) && <ErrorSummary error={getWorkspaceError} />}
64-
{Boolean(getTemplateWarning) && <ErrorSummary error={getTemplateWarning} />}
65-
{Boolean(checkPermissionsError) && <ErrorSummary error={checkPermissionsError} />}
66-
</div>
67-
)
68-
} else if (!workspace || !permissions) {
69-
return <FullScreenLoader />
70-
} else if (!template) {
71-
return <FullScreenLoader />
72-
} else {
73-
const deadline = dayjs(workspace.latest_build.deadline).utc()
74-
const favicon = getFaviconByStatus(workspace.latest_build)
75-
return (
76-
<>
77-
<Helmet>
78-
<title>{pageTitle(`${workspace.owner_name}/${workspace.name}`)}</title>
79-
<link rel="alternate icon" type="image/png" href={`/favicons/${favicon}.png`} />
80-
<link rel="icon" type="image/svg+xml" href={`/favicons/${favicon}.svg`} />
81-
</Helmet>
82-
83-
<Workspace
84-
bannerProps={{
85-
isLoading: bannerState.hasTag("loading"),
86-
onExtend: () => {
87-
bannerSend({
88-
type: "UPDATE_DEADLINE",
89-
workspaceId: workspace.id,
90-
newDeadline: dayjs.min(deadline.add(4, "hours"), maxDeadline(workspace, template)),
91-
})
92-
},
93-
}}
94-
scheduleProps={{
95-
onDeadlineMinus: () => {
96-
bannerSend({
97-
type: "UPDATE_DEADLINE",
98-
workspaceId: workspace.id,
99-
newDeadline: dayjs.max(deadline.add(-1, "hours"), minDeadline()),
100-
})
101-
},
102-
onDeadlinePlus: () => {
103-
bannerSend({
104-
type: "UPDATE_DEADLINE",
105-
workspaceId: workspace.id,
106-
newDeadline: dayjs.min(deadline.add(1, "hours"), maxDeadline(workspace, template)),
107-
})
108-
},
109-
deadlineMinusEnabled: () => {
110-
return canReduceDeadline(deadline)
111-
},
112-
deadlinePlusEnabled: () => {
113-
return canExtendDeadline(deadline, workspace, template)
114-
},
115-
}}
116-
isUpdating={workspaceState.hasTag("updating")}
117-
workspace={workspace}
118-
handleStart={() => workspaceSend("START")}
119-
handleStop={() => workspaceSend("STOP")}
120-
handleDelete={() => workspaceSend("ASK_DELETE")}
121-
handleUpdate={() => workspaceSend("UPDATE")}
122-
handleCancel={() => workspaceSend("CANCEL")}
123-
resources={workspace.latest_build.resources}
124-
builds={builds}
125-
canUpdateWorkspace={canUpdateWorkspace}
126-
hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]}
127-
workspaceErrors={{
128-
[WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning,
129-
[WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError,
130-
[WorkspaceErrors.BUILD_ERROR]: buildError,
131-
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
132-
}}
133-
buildInfo={buildInfoState.context.buildInfo}
134-
applicationsHost={applicationsHost}
135-
/>
136-
<DeleteDialog
137-
entity="workspace"
138-
name={workspace.name}
139-
info={t("deleteDialog.info", { timeAgo: dayjs(workspace.created_at).fromNow() })}
140-
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
141-
onCancel={() => workspaceSend("CANCEL_DELETE")}
142-
onConfirm={() => {
143-
workspaceSend("DELETE")
144-
}}
145-
/>
146-
</>
147-
)
148-
}
29+
return (
30+
<ChooseOne>
31+
<Cond condition={workspaceState.matches("error")}>
32+
<div className={styles.error}>
33+
{Boolean(getWorkspaceError) && <ErrorSummary error={getWorkspaceError} />}
34+
{Boolean(getTemplateWarning) && <ErrorSummary error={getTemplateWarning} />}
35+
{Boolean(checkPermissionsError) && <ErrorSummary error={checkPermissionsError} />}
36+
</div>
37+
</Cond>
38+
<Cond condition={Boolean(workspace) && workspaceState.matches("ready")}>
39+
<WorkspaceReadyPage workspaceState={workspaceState} workspaceSend={workspaceSend} />
40+
</Cond>
41+
<Cond>
42+
<FullScreenLoader />
43+
</Cond>
44+
</ChooseOne>
45+
)
14946
}
15047

15148
const useStyles = makeStyles((theme) => ({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useActor, useSelector } from "@xstate/react"
2+
import { FeatureNames } from "api/types"
3+
import dayjs from "dayjs"
4+
import { useContext } from "react"
5+
import { Helmet } from "react-helmet-async"
6+
import { useTranslation } from "react-i18next"
7+
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
8+
import { StateFrom } from "xstate"
9+
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
10+
import { Workspace, WorkspaceErrors } from "../../components/Workspace/Workspace"
11+
import { pageTitle } from "../../util/page"
12+
import { getFaviconByStatus } from "../../util/workspace"
13+
import { XServiceContext } from "../../xServices/StateContext"
14+
import { WorkspaceEvent, workspaceMachine } from "../../xServices/workspace/workspaceXService"
15+
16+
interface WorkspaceReadyPageProps {
17+
workspaceState: StateFrom<typeof workspaceMachine>
18+
workspaceSend: (event: WorkspaceEvent) => void
19+
}
20+
21+
export const WorkspaceReadyPage = ({
22+
workspaceState,
23+
workspaceSend,
24+
}: WorkspaceReadyPageProps): JSX.Element => {
25+
const [bannerState, bannerSend] = useActor(workspaceState.children["scheduleBannerMachine"])
26+
const xServices = useContext(XServiceContext)
27+
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
28+
const [buildInfoState] = useActor(xServices.buildInfoXService)
29+
const {
30+
workspace,
31+
refreshWorkspaceWarning,
32+
builds,
33+
getBuildsError,
34+
buildError,
35+
cancellationError,
36+
applicationsHost,
37+
permissions,
38+
} = workspaceState.context
39+
if (workspace === undefined) {
40+
throw Error("Workspace is undefined")
41+
}
42+
const canUpdateWorkspace = Boolean(permissions?.updateWorkspace)
43+
const { t } = useTranslation("workspacePage")
44+
const favicon = getFaviconByStatus(workspace.latest_build)
45+
46+
return (
47+
<>
48+
<Helmet>
49+
<title>{pageTitle(`${workspace.owner_name}/${workspace.name}`)}</title>
50+
<link rel="alternate icon" type="image/png" href={`/favicons/${favicon}.png`} />
51+
<link rel="icon" type="image/svg+xml" href={`/favicons/${favicon}.svg`} />
52+
</Helmet>
53+
54+
<Workspace
55+
bannerProps={{
56+
isLoading: bannerState.hasTag("loading"),
57+
onExtend: () => {
58+
bannerSend({
59+
type: "INCREASE_DEADLINE",
60+
hours: 4,
61+
})
62+
},
63+
}}
64+
scheduleProps={{
65+
onDeadlineMinus: () => {
66+
bannerSend({
67+
type: "DECREASE_DEADLINE",
68+
hours: 1,
69+
})
70+
},
71+
onDeadlinePlus: () => {
72+
bannerSend({
73+
type: "INCREASE_DEADLINE",
74+
hours: 1,
75+
})
76+
},
77+
deadlineMinusEnabled: () => !bannerState.matches("atMinDeadline"),
78+
deadlinePlusEnabled: () => !bannerState.matches("atMaxDeadline"),
79+
}}
80+
isUpdating={workspaceState.hasTag("updating")}
81+
workspace={workspace}
82+
handleStart={() => workspaceSend({ type: "START" })}
83+
handleStop={() => workspaceSend({ type: "STOP" })}
84+
handleDelete={() => workspaceSend({ type: "ASK_DELETE" })}
85+
handleUpdate={() => workspaceSend({ type: "UPDATE" })}
86+
handleCancel={() => workspaceSend({ type: "CANCEL" })}
87+
resources={workspace.latest_build.resources}
88+
builds={builds}
89+
canUpdateWorkspace={canUpdateWorkspace}
90+
hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]}
91+
workspaceErrors={{
92+
[WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning,
93+
[WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError,
94+
[WorkspaceErrors.BUILD_ERROR]: buildError,
95+
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
96+
}}
97+
buildInfo={buildInfoState.context.buildInfo}
98+
applicationsHost={applicationsHost}
99+
/>
100+
<DeleteDialog
101+
entity="workspace"
102+
name={workspace.name}
103+
info={t("deleteDialog.info", { timeAgo: dayjs(workspace.created_at).fromNow() })}
104+
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
105+
onCancel={() => workspaceSend({ type: "CANCEL_DELETE" })}
106+
onConfirm={() => {
107+
workspaceSend({ type: "DELETE" })
108+
}}
109+
/>
110+
</>
111+
)
112+
}

site/src/util/schedule.test.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
deadlineExtensionMax,
99
deadlineExtensionMin,
1010
extractTimezone,
11-
maxDeadline,
12-
minDeadline,
11+
getMaxDeadline,
12+
getMinDeadline,
1313
stripTimezone,
1414
} from "./schedule"
1515

@@ -55,7 +55,7 @@ describe("maxDeadline", () => {
5555
}
5656

5757
// Then: deadlineMinusDisabled should be falsy
58-
const delta = maxDeadline(workspace, template).diff(now)
58+
const delta = getMaxDeadline(workspace, template).diff(now)
5959
expect(delta).toBeLessThanOrEqual(deadlineExtensionMax.asMilliseconds())
6060
})
6161
})
@@ -68,15 +68,15 @@ describe("maxDeadline", () => {
6868
}
6969

7070
// Then: deadlineMinusDisabled should be falsy
71-
const delta = maxDeadline(workspace, template).diff(now)
71+
const delta = getMaxDeadline(workspace, template).diff(now)
7272
expect(delta).toBeLessThanOrEqual(deadlineExtensionMax.asMilliseconds())
7373
})
7474
})
7575
})
7676

7777
describe("minDeadline", () => {
7878
it("should never be less than 30 minutes", () => {
79-
const delta = minDeadline().diff(now)
79+
const delta = getMinDeadline().diff(now)
8080
expect(delta).toBeGreaterThanOrEqual(deadlineExtensionMin.asMilliseconds())
8181
})
8282
})

site/src/util/schedule.ts

+21-4
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,30 @@ export const autoStopDisplay = (workspace: Workspace): string => {
113113
export const deadlineExtensionMin = dayjs.duration(30, "minutes")
114114
export const deadlineExtensionMax = dayjs.duration(24, "hours")
115115

116-
export function maxDeadline(ws: Workspace, tpl: Template): dayjs.Dayjs {
116+
/**
117+
* Depends on the time the workspace was last updated, the template config,
118+
* and a global constant.
119+
* @param ws workspace
120+
* @param tpl template
121+
* @returns the latest datetime at which the workspace can be automatically shut down.
122+
*/
123+
export function getMaxDeadline(ws: Workspace | undefined, tpl: Template): dayjs.Dayjs {
117124
// note: we count runtime from updated_at as started_at counts from the start of
118125
// the workspace build process, which can take a while.
126+
if (ws === undefined) {
127+
throw Error("Cannot calculate max deadline because workspace is undefined")
128+
}
119129
const startedAt = dayjs(ws.latest_build.updated_at)
120130
const maxTemplateDeadline = startedAt.add(dayjs.duration(tpl.max_ttl_ms, "milliseconds"))
121131
const maxGlobalDeadline = startedAt.add(deadlineExtensionMax)
122132
return dayjs.min(maxTemplateDeadline, maxGlobalDeadline)
123133
}
124134

125-
export function minDeadline(): dayjs.Dayjs {
135+
/**
136+
* Depends on the current time and a global constant.
137+
* @returns the earliest datetime at which the workspace can be automatically shut down.
138+
*/
139+
export function getMinDeadline(): dayjs.Dayjs {
126140
return dayjs().add(deadlineExtensionMin)
127141
}
128142

@@ -131,9 +145,12 @@ export function canExtendDeadline(
131145
workspace: Workspace,
132146
template: Template,
133147
): boolean {
134-
return deadline < maxDeadline(workspace, template)
148+
return deadline < getMaxDeadline(workspace, template)
135149
}
136150

137151
export function canReduceDeadline(deadline: dayjs.Dayjs): boolean {
138-
return deadline > minDeadline()
152+
return deadline > getMinDeadline()
139153
}
154+
155+
export const getDeadline = (workspace: Workspace): dayjs.Dayjs =>
156+
dayjs(workspace.latest_build.deadline).utc()

0 commit comments

Comments
 (0)