refactor(site): Refactor workspace schedule page (#7069)

This commit is contained in:
Bruno Quaresma
2023-04-11 16:27:52 +00:00
committed by GitHub
parent 4dd5d79412
commit b92e7d4fab
20 changed files with 582 additions and 362 deletions
+12 -3
View File
@@ -17,6 +17,7 @@ import { RequireAuth } from "./components/RequireAuth/RequireAuth"
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"
// Lazy load pages
// - Pages that are secondary, not in the main navigation or not usually accessed
@@ -45,7 +46,10 @@ const WorkspaceBuildPage = lazy(
)
const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"))
const WorkspaceSchedulePage = lazy(
() => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"),
() =>
import(
"./pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage"
),
)
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
const TemplatePermissionsPage = lazy(
@@ -260,12 +264,17 @@ export const AppRouter: FC = () => {
<Route path="/@:username">
<Route path=":workspace">
<Route index element={<WorkspacePage />} />
<Route path="schedule" element={<WorkspaceSchedulePage />} />
<Route
path="builds/:buildNumber"
element={<WorkspaceBuildPage />}
/>
<Route path="settings" element={<WorkspaceSettingsPage />} />
<Route path="settings" element={<WorkspaceSettingsLayout />}>
<Route index element={<WorkspaceSettingsPage />} />
<Route
path="schedule"
element={<WorkspaceSchedulePage />}
/>
</Route>
</Route>
</Route>
</Route>
@@ -74,7 +74,7 @@ export const WorkspaceSchedule: FC<
<Link
className={styles.scheduleAction}
component={RouterLink}
to={`/@${workspace.owner_name}/${workspace.name}/schedule`}
to={`/@${workspace.owner_name}/${workspace.name}/settings/schedule`}
>
{Language.editScheduleLink}
</Link>
@@ -6,8 +6,8 @@ import utc from "dayjs/plugin/utc"
import {
defaultSchedule,
emptySchedule,
} from "pages/WorkspaceSchedulePage/schedule"
import { emptyTTL } from "pages/WorkspaceSchedulePage/ttl"
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
import { emptyTTL } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import { makeMockApiError } from "testHelpers/entities"
import {
WorkspaceScheduleForm,
@@ -8,8 +8,13 @@ import MenuItem from "@material-ui/core/MenuItem"
import makeStyles from "@material-ui/core/styles/makeStyles"
import Switch from "@material-ui/core/Switch"
import TextField from "@material-ui/core/TextField"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Section } from "components/Section/Section"
import {
HorizontalForm,
FormFooter,
FormSection,
FormFields,
} from "components/Form/Form"
import { Stack } from "components/Stack/Stack"
import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
@@ -20,13 +25,10 @@ import { FormikTouched, useFormik } from "formik"
import {
defaultSchedule,
emptySchedule,
} from "pages/WorkspaceSchedulePage/schedule"
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
import { ChangeEvent, FC } from "react"
import * as Yup from "yup"
import { getFormHelpers } from "../../util/formUtils"
import { FormFooter } from "../FormFooter/FormFooter"
import { FullPageForm } from "../FullPageForm/FullPageForm"
import { Stack } from "../Stack/Stack"
import { zones } from "./zones"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
@@ -48,16 +50,14 @@ export const Language = {
errorTtlMax:
"Please enter a limit that is less than or equal to 168 hours (7 days).",
daysOfWeekLabel: "Days of Week",
daySundayLabel: "Sunday",
dayMondayLabel: "Monday",
dayTuesdayLabel: "Tuesday",
dayWednesdayLabel: "Wednesday",
dayThursdayLabel: "Thursday",
dayFridayLabel: "Friday",
daySaturdayLabel: "Saturday",
daySundayLabel: "Sun",
dayMondayLabel: "Mon",
dayTuesdayLabel: "Tue",
dayWednesdayLabel: "Wed",
dayThursdayLabel: "Thu",
dayFridayLabel: "Fri",
daySaturdayLabel: "Sat",
startTimeLabel: "Start time",
startTimeHelperText: "Your workspace will automatically start at this time.",
noStartTimeHelperText: "Your workspace will not automatically start.",
timezoneLabel: "Timezone",
ttlLabel: "Time until shutdown (hours)",
ttlCausesShutdownHelperText: "Your workspace will shut down",
@@ -67,13 +67,13 @@ export const Language = {
"Your workspace will not automatically shut down.",
formTitle: "Workspace schedule",
startSection: "Start",
startSwitch: "Autostart",
startSwitch: "Enable Autostart",
stopSection: "Stop",
stopSwitch: "Autostop",
stopSwitch: "Enable Autostop",
}
export interface WorkspaceScheduleFormProps {
submitScheduleError?: Error | unknown
submitScheduleError?: unknown
initialValues: WorkspaceScheduleFormValues
isLoading: boolean
onCancel: () => void
@@ -280,31 +280,26 @@ export const WorkspaceScheduleForm: FC<
}
return (
<FullPageForm title={Language.formTitle}>
<form onSubmit={form.handleSubmit} className={styles.form}>
<Stack>
{Boolean(submitScheduleError) && (
<AlertBanner severity="error" error={submitScheduleError} />
)}
<Section title={Language.startSection}>
<FormControlLabel
control={
<Switch
name="autostartEnabled"
checked={form.values.autostartEnabled}
onChange={handleToggleAutostart}
color="primary"
/>
}
label={Language.startSwitch}
/>
<HorizontalForm onSubmit={form.handleSubmit}>
<FormSection
title="Autostart"
description="Select the time and days of week on which you want the workspace starting automatically."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="autostartEnabled"
checked={form.values.autostartEnabled}
onChange={handleToggleAutostart}
color="primary"
/>
}
label={Language.startSwitch}
/>
<Stack direction="row">
<TextField
{...formHelpers(
"startTime",
form.values.autostartEnabled
? Language.startTimeHelperText
: Language.noStartTimeHelperText,
)}
{...formHelpers("startTime")}
disabled={isLoading || !form.values.autostartEnabled}
InputLabelProps={{
shrink: true,
@@ -313,7 +308,6 @@ export const WorkspaceScheduleForm: FC<
type="time"
fullWidth
/>
<TextField
{...formHelpers("timezone")}
disabled={isLoading || !form.values.autostartEnabled}
@@ -330,67 +324,68 @@ export const WorkspaceScheduleForm: FC<
</MenuItem>
))}
</TextField>
</Stack>
<FormControl
component="fieldset"
error={Boolean(form.errors.monday)}
>
<FormLabel className={styles.daysOfWeekLabel} component="legend">
{Language.daysOfWeekLabel}
</FormLabel>
<FormControl component="fieldset" error={Boolean(form.errors.monday)}>
<FormLabel className={styles.daysOfWeekLabel} component="legend">
{Language.daysOfWeekLabel}
</FormLabel>
<FormGroup>
{checkboxes.map((checkbox) => (
<FormControlLabel
control={
<Checkbox
checked={checkbox.value}
disabled={isLoading || !form.values.autostartEnabled}
onChange={form.handleChange}
name={checkbox.name}
color="primary"
size="small"
disableRipple
/>
}
key={checkbox.name}
label={checkbox.label}
/>
))}
</FormGroup>
{form.errors.monday && (
<FormHelperText>{Language.errorNoDayOfWeek}</FormHelperText>
)}
</FormControl>
</Section>
<Section title={Language.stopSection}>
<FormControlLabel
control={
<Switch
name="autostopEnabled"
checked={form.values.autostopEnabled}
onChange={handleToggleAutostop}
color="primary"
<FormGroup className={styles.daysOfWeekOptions}>
{checkboxes.map((checkbox) => (
<FormControlLabel
control={
<Checkbox
checked={checkbox.value}
disabled={isLoading || !form.values.autostartEnabled}
onChange={form.handleChange}
name={checkbox.name}
color="primary"
size="small"
disableRipple
/>
}
key={checkbox.name}
label={checkbox.label}
/>
}
label={Language.stopSwitch}
/>
<TextField
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")}
disabled={isLoading || !form.values.autostopEnabled}
inputProps={{ min: 0, step: 1 }}
label={Language.ttlLabel}
type="number"
fullWidth
/>
</Section>
))}
</FormGroup>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</Stack>
</form>
</FullPageForm>
{form.errors.monday && (
<FormHelperText>{Language.errorNoDayOfWeek}</FormHelperText>
)}
</FormControl>
</FormFields>
</FormSection>
<FormSection
title="Autostop"
description="Set how many hours should elapse after a workspace is started before it automatically shuts down. If workspace connection activity is detected, the autostop timer will be bumped up one hour."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="autostopEnabled"
checked={form.values.autostopEnabled}
onChange={handleToggleAutostop}
color="primary"
/>
}
label={Language.stopSwitch}
/>
<TextField
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")}
disabled={isLoading || !form.values.autostopEnabled}
inputProps={{ min: 0, step: 1 }}
label={Language.ttlLabel}
type="number"
fullWidth
/>
</FormFields>
</FormSection>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</HorizontalForm>
)
}
@@ -405,13 +400,14 @@ export const ttlShutdownAt = (formTTL: number): string => {
}
}
const useStyles = makeStyles({
form: {
"& input": {
colorScheme: "dark",
},
},
const useStyles = makeStyles((theme) => ({
daysOfWeekLabel: {
fontSize: 12,
},
})
daysOfWeekOptions: {
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
paddingTop: theme.spacing(0.5),
},
}))
@@ -1,154 +0,0 @@
import { makeStyles } from "@material-ui/core/styles"
import { useMachine } from "@xstate/react"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { Loader } from "components/Loader/Loader"
import { Margins } from "components/Margins/Margins"
import dayjs from "dayjs"
import { scheduleToAutostart } from "pages/WorkspaceSchedulePage/schedule"
import { ttlMsToAutostop } from "pages/WorkspaceSchedulePage/ttl"
import { useEffect, FC } from "react"
import { useTranslation } from "react-i18next"
import { Navigate, useNavigate, useParams } from "react-router-dom"
import { scheduleChanged } from "util/schedule"
import * as TypesGen from "../../api/typesGenerated"
import { WorkspaceScheduleForm } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import { firstOrItem } from "../../util/array"
import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService"
import {
formValuesToAutostartRequest,
formValuesToTTLRequest,
} from "./formToRequest"
const getAutostart = (workspace?: TypesGen.Workspace) =>
scheduleToAutostart(workspace?.autostart_schedule)
const getAutostop = (workspace?: TypesGen.Workspace) =>
ttlMsToAutostop(workspace?.ttl_ms)
const useStyles = makeStyles((theme) => ({
topMargin: {
marginTop: `${theme.spacing(3)}px`,
},
}))
export const WorkspaceSchedulePage: FC = () => {
const { t } = useTranslation("workspaceSchedulePage")
const styles = useStyles()
const { username: usernameQueryParam, workspace: workspaceQueryParam } =
useParams()
const navigate = useNavigate()
const username = firstOrItem(usernameQueryParam, null)
const workspaceName = firstOrItem(workspaceQueryParam, null)
const [scheduleState, scheduleSend] = useMachine(workspaceSchedule)
const {
checkPermissionsError,
submitScheduleError,
getWorkspaceError,
getTemplateError,
permissions,
workspace,
template,
} = scheduleState.context
// Get workspace on mount and whenever the args for getting a workspace change.
// scheduleSend should not change.
useEffect(() => {
username &&
workspaceName &&
scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
}, [username, workspaceName, scheduleSend])
if (!username || !workspaceName) {
return <Navigate to="/workspaces" />
}
if (scheduleState.hasTag("loading") || !template) {
return <Loader />
}
if (scheduleState.matches("error")) {
return (
<Margins>
<div className={styles.topMargin}>
<AlertBanner
severity="error"
error={
getWorkspaceError || checkPermissionsError || getTemplateError
}
retry={() =>
scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
}
/>
</div>
</Margins>
)
}
if (!permissions?.updateWorkspace) {
return (
<Margins>
<div className={styles.topMargin}>
<AlertBanner severity="error" error={Error(t("forbiddenError"))} />
</div>
</Margins>
)
}
if (
scheduleState.matches("presentForm") ||
scheduleState.matches("submittingSchedule")
) {
return (
<WorkspaceScheduleForm
submitScheduleError={submitScheduleError}
initialValues={{
...getAutostart(workspace),
...getAutostop(workspace),
}}
isLoading={scheduleState.tags.has("loading")}
defaultTTL={dayjs.duration(template.default_ttl_ms, "ms").asHours()}
onCancel={() => {
navigate(`/@${username}/${workspaceName}`)
}}
onSubmit={(values) => {
scheduleSend({
type: "SUBMIT_SCHEDULE",
autostart: formValuesToAutostartRequest(values),
ttl: formValuesToTTLRequest(values),
autostartChanged: scheduleChanged(getAutostart(workspace), values),
autostopChanged: scheduleChanged(getAutostop(workspace), values),
})
}}
/>
)
}
if (scheduleState.matches("showingRestartDialog")) {
return (
<ConfirmDialog
open
title={t("dialogTitle")}
description={t("dialogDescription")}
confirmText={t("restart")}
cancelText={t("applyLater")}
hideCancel={false}
onConfirm={() => {
scheduleSend("RESTART_WORKSPACE")
}}
onClose={() => {
scheduleSend("APPLY_LATER")
}}
/>
)
}
if (scheduleState.matches("done")) {
return <Navigate to={`/@${username}/${workspaceName}`} />
}
// Theoretically impossible - log and bail
console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState)
return <Navigate to="/" />
}
export default WorkspaceSchedulePage
@@ -0,0 +1,138 @@
import { makeStyles } from "@material-ui/core/styles"
import ScheduleIcon from "@material-ui/icons/TimerOutlined"
import { Workspace } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack"
import { FC, ElementType, PropsWithChildren, ReactNode } from "react"
import { Link, NavLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import GeneralIcon from "@material-ui/icons/SettingsOutlined"
import { Avatar } from "components/Avatar/Avatar"
const SidebarNavItem: FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
> = ({ children, href, icon }) => {
const styles = useStyles()
return (
<NavLink
end
to={href}
className={({ isActive }) =>
combineClasses([
styles.sidebarNavItem,
isActive ? styles.sidebarNavItemActive : undefined,
])
}
>
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
</NavLink>
)
}
const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
icon: Icon,
}) => {
const styles = useStyles()
return <Icon className={styles.sidebarNavItemIcon} />
}
export const Sidebar: React.FC<{ username: string; workspace: Workspace }> = ({
username,
workspace,
}) => {
const styles = useStyles()
return (
<nav className={styles.sidebar}>
<Stack
direction="row"
alignItems="center"
className={styles.workspaceInfo}
>
<Avatar src={workspace.template_icon} variant="square" fitImage />
<Stack spacing={0} className={styles.workspaceData}>
<Link className={styles.name} to={`/@${username}/${workspace.name}`}>
{workspace.name}
</Link>
<span className={styles.secondary}>
{workspace.template_display_name ?? workspace.template_name}
</span>
</Stack>
</Stack>
<SidebarNavItem href="" icon={<SidebarNavItemIcon icon={GeneralIcon} />}>
General
</SidebarNavItem>
<SidebarNavItem
href="schedule"
icon={<SidebarNavItemIcon icon={ScheduleIcon} />}
>
Schedule
</SidebarNavItem>
</nav>
)
}
const useStyles = makeStyles((theme) => ({
sidebar: {
width: 245,
flexShrink: 0,
},
sidebarNavItem: {
color: "inherit",
display: "block",
fontSize: 14,
textDecoration: "none",
padding: theme.spacing(1.5, 1.5, 1.5, 2),
borderRadius: theme.shape.borderRadius / 2,
transition: "background-color 0.15s ease-in-out",
marginBottom: 1,
position: "relative",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
sidebarNavItemActive: {
backgroundColor: theme.palette.action.hover,
"&:before": {
content: '""',
display: "block",
width: 3,
height: "100%",
position: "absolute",
left: 0,
top: 0,
backgroundColor: theme.palette.secondary.dark,
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
},
sidebarNavItemIcon: {
width: theme.spacing(2),
height: theme.spacing(2),
},
workspaceInfo: {
marginBottom: theme.spacing(2),
},
workspaceData: {
overflow: "hidden",
},
name: {
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: theme.palette.text.primary,
textDecoration: "none",
},
secondary: {
color: theme.palette.text.secondary,
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
},
}))
@@ -1,16 +1,19 @@
import { renderWithAuth } from "testHelpers/renderHelpers"
import { renderWithWorkspaceSettingsLayout } from "testHelpers/renderHelpers"
import userEvent from "@testing-library/user-event"
import { screen } from "@testing-library/react"
import {
formValuesToAutostartRequest,
formValuesToTTLRequest,
} from "pages/WorkspaceSchedulePage/formToRequest"
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/formToRequest"
import {
Autostart,
scheduleToAutostart,
} from "pages/WorkspaceSchedulePage/schedule"
import { Autostop, ttlMsToAutostop } from "pages/WorkspaceSchedulePage/ttl"
import * as TypesGen from "../../api/typesGenerated"
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
import {
Autostop,
ttlMsToAutostop,
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import * as TypesGen from "../../../api/typesGenerated"
import {
WorkspaceScheduleFormValues,
Language as FormLanguage,
@@ -257,7 +260,7 @@ describe("WorkspaceSchedulePage", () => {
describe("autostop change dialog", () => {
it("shows if autostop is changed", async () => {
renderWithAuth(<WorkspaceSchedulePage />, {
renderWithWorkspaceSettingsLayout(<WorkspaceSchedulePage />, {
route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`,
path: "/@:username/:workspace/schedule",
})
@@ -276,7 +279,7 @@ describe("WorkspaceSchedulePage", () => {
})
it("doesn't show if autostop is not changed", async () => {
renderWithAuth(<WorkspaceSchedulePage />, {
renderWithWorkspaceSettingsLayout(<WorkspaceSchedulePage />, {
route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`,
path: "/@:username/:workspace/schedule",
})
@@ -309,7 +312,7 @@ describe("WorkspaceSchedulePage", () => {
},
),
)
renderWithAuth(<WorkspaceSchedulePage />, {
renderWithWorkspaceSettingsLayout(<WorkspaceSchedulePage />, {
route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`,
path: "/@:username/:workspace/schedule",
})
@@ -0,0 +1,136 @@
import { makeStyles } from "@material-ui/core/styles"
import { useMachine } from "@xstate/react"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { Loader } from "components/Loader/Loader"
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
import dayjs from "dayjs"
import { scheduleToAutostart } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
import { ttlMsToAutostop } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import { useWorkspaceSettingsContext } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { Navigate, useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { scheduleChanged } from "util/schedule"
import * as TypesGen from "../../../api/typesGenerated"
import { WorkspaceScheduleForm } from "../../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import { firstOrItem } from "../../../util/array"
import { workspaceSchedule } from "../../../xServices/workspaceSchedule/workspaceScheduleXService"
import {
formValuesToAutostartRequest,
formValuesToTTLRequest,
} from "./formToRequest"
const getAutostart = (workspace: TypesGen.Workspace) =>
scheduleToAutostart(workspace.autostart_schedule)
const getAutostop = (workspace: TypesGen.Workspace) =>
ttlMsToAutostop(workspace.ttl_ms)
const useStyles = makeStyles((theme) => ({
topMargin: {
marginTop: `${theme.spacing(3)}px`,
},
pageHeader: {
paddingTop: 0,
},
}))
export const WorkspaceSchedulePage: FC = () => {
const { t } = useTranslation("workspaceSchedulePage")
const styles = useStyles()
const { username: usernameQueryParam, workspace: workspaceQueryParam } =
useParams()
const navigate = useNavigate()
const username = firstOrItem(usernameQueryParam, null)
const workspaceName = firstOrItem(workspaceQueryParam, null)
const { workspace } = useWorkspaceSettingsContext()
const [scheduleState, scheduleSend] = useMachine(workspaceSchedule, {
context: { workspace },
})
const {
checkPermissionsError,
submitScheduleError,
getTemplateError,
permissions,
template,
} = scheduleState.context
if (!username || !workspaceName) {
return <Navigate to="/workspaces" />
}
if (scheduleState.matches("done")) {
return <Navigate to={`/@${username}/${workspaceName}`} />
}
return (
<>
<Helmet>
<title>{pageTitle([workspaceName, "Schedule"])}</title>
</Helmet>
<PageHeader className={styles.pageHeader}>
<PageHeaderTitle>Workspace Schedule</PageHeaderTitle>
</PageHeader>
{(scheduleState.hasTag("loading") || !template) && <Loader />}
{scheduleState.matches("error") && (
<AlertBanner
severity="error"
error={checkPermissionsError || getTemplateError}
/>
)}
{permissions && !permissions.updateWorkspace && (
<AlertBanner severity="error" error={Error(t("forbiddenError"))} />
)}
{template &&
workspace &&
(scheduleState.matches("presentForm") ||
scheduleState.matches("submittingSchedule")) && (
<WorkspaceScheduleForm
submitScheduleError={submitScheduleError}
initialValues={{
...getAutostart(workspace),
...getAutostop(workspace),
}}
isLoading={scheduleState.tags.has("loading")}
defaultTTL={dayjs.duration(template.default_ttl_ms, "ms").asHours()}
onCancel={() => {
navigate(`/@${username}/${workspaceName}`)
}}
onSubmit={(values) => {
scheduleSend({
type: "SUBMIT_SCHEDULE",
autostart: formValuesToAutostartRequest(values),
ttl: formValuesToTTLRequest(values),
autostartChanged: scheduleChanged(
getAutostart(workspace),
values,
),
autostopChanged: scheduleChanged(
getAutostop(workspace),
values,
),
})
}}
/>
)}
<ConfirmDialog
open={scheduleState.matches("showingRestartDialog")}
title={t("dialogTitle")}
description={t("dialogDescription")}
confirmText={t("restart")}
cancelText={t("applyLater")}
hideCancel={false}
onConfirm={() => {
scheduleSend("RESTART_WORKSPACE")
}}
onClose={() => {
scheduleSend("APPLY_LATER")
}}
/>
</>
)
}
export default WorkspaceSchedulePage
@@ -2,7 +2,7 @@ import * as cronParser from "cron-parser"
import dayjs from "dayjs"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { extractTimezone, stripTimezone } from "../../util/schedule"
import { extractTimezone, stripTimezone } from "../../../util/schedule"
// REMARK: timezone plugin depends on UTC
//
@@ -0,0 +1,86 @@
import { makeStyles } from "@material-ui/core/styles"
import { Sidebar } from "./Sidebar"
import { Stack } from "components/Stack/Stack"
import { createContext, FC, Suspense, useContext } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "../../util/page"
import { Loader } from "components/Loader/Loader"
import { Outlet, useParams } from "react-router-dom"
import { Margins } from "components/Margins/Margins"
import { getWorkspaceByOwnerAndName } from "api/api"
import { useQuery } from "@tanstack/react-query"
const fetchWorkspaceSettings = async (owner: string, name: string) => {
const workspace = await getWorkspaceByOwnerAndName(owner, name)
return {
workspace,
}
}
const useWorkspace = (owner: string, name: string) => {
return useQuery({
queryKey: ["workspace", name, "settings"],
queryFn: () => fetchWorkspaceSettings(owner, name),
})
}
const WorkspaceSettingsContext = createContext<
Awaited<ReturnType<typeof fetchWorkspaceSettings>> | undefined
>(undefined)
export const useWorkspaceSettingsContext = () => {
const context = useContext(WorkspaceSettingsContext)
if (!context) {
throw new Error(
"useWorkspaceSettingsContext must be used within a WorkspaceSettingsContext.Provider",
)
}
return context
}
export const WorkspaceSettingsLayout: FC = () => {
const styles = useStyles()
const { workspace: workspaceName, username } = useParams() as {
workspace: string
username: string
}
const { data: settings } = useWorkspace(username, workspaceName)
return (
<>
<Helmet>
<title>{pageTitle([workspaceName, "Settings"])}</title>
</Helmet>
{settings ? (
<WorkspaceSettingsContext.Provider value={settings}>
<Margins>
<Stack className={styles.wrapper} direction="row" spacing={10}>
<Sidebar workspace={settings.workspace} username={username} />
<Suspense fallback={<Loader />}>
<main className={styles.content}>
<Outlet />
</main>
</Suspense>
</Stack>
</Margins>
</WorkspaceSettingsContext.Provider>
) : (
<Loader />
)}
</>
)
}
const useStyles = makeStyles((theme) => ({
wrapper: {
padding: theme.spacing(6, 0),
},
content: {
width: "100%",
},
}))
@@ -1,6 +1,6 @@
import userEvent from "@testing-library/user-event"
import {
renderWithAuth,
renderWithWorkspaceSettingsLayout,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import WorkspaceSettingsPage from "./WorkspaceSettingsPage"
@@ -41,7 +41,7 @@ test("Submit the workspace settings page successfully", async () => {
.mockResolvedValue(MockWorkspaceBuild)
// Setup event and rendering
const user = userEvent.setup()
renderWithAuth(<WorkspaceSettingsPage />, {
renderWithWorkspaceSettingsLayout(<WorkspaceSettingsPage />, {
route: "/@test-user/test-workspace/settings",
path: "/@:username/:workspace/settings",
// Need this because after submit the user is redirected
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { useUpdateWorkspaceSettings, useWorkspaceSettings } from "./data"
import { useWorkspaceSettingsContext } from "./WorkspaceSettingsLayout"
import { WorkspaceSettingsPageView } from "./WorkspaceSettingsPageView"
const WorkspaceSettingsPage = () => {
@@ -13,13 +14,10 @@ const WorkspaceSettingsPage = () => {
username: string
workspace: string
}
const {
data: settings,
error,
isLoading,
} = useWorkspaceSettings(username, workspaceName)
const { workspace } = useWorkspaceSettingsContext()
const { data: settings, error, isLoading } = useWorkspaceSettings(workspace)
const navigate = useNavigate()
const updateSettings = useUpdateWorkspaceSettings(settings?.workspace.id, {
const updateSettings = useUpdateWorkspaceSettings(workspace.id, {
onSuccess: ({ name }) => {
navigate(`/@${username}/${name}`)
},
@@ -30,7 +28,7 @@ const WorkspaceSettingsPage = () => {
return (
<>
<Helmet>
<title>{pageTitle(t("title"))}</title>
<title>{pageTitle([workspaceName, "Settings"])}</title>
</Helmet>
<WorkspaceSettingsPageView
@@ -39,7 +37,7 @@ const WorkspaceSettingsPage = () => {
isLoading={isLoading}
isSubmitting={updateSettings.isLoading}
settings={settings}
onCancel={() => navigate(-1)}
onCancel={() => navigate(`/@${username}/${workspaceName}`)}
onSubmit={updateSettings.mutate}
/>
</>
@@ -1,6 +1,7 @@
import { makeStyles } from "@material-ui/core/styles"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
import { Loader } from "components/Loader/Loader"
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { WorkspaceSettings, WorkspaceSettingsFormValue } from "./data"
@@ -26,22 +27,31 @@ export const WorkspaceSettingsPageView: FC<WorkspaceSettingsPageViewProps> = ({
loadingError,
}) => {
const { t } = useTranslation("workspaceSettingsPage")
const styles = useStyles()
return (
<FullPageHorizontalForm title={t("title")} onCancel={onCancel}>
<>
{loadingError && <AlertBanner error={loadingError} severity="error" />}
{isLoading && <Loader />}
{settings && (
<WorkspaceSettingsForm
error={formError}
isSubmitting={isSubmitting}
settings={settings}
onCancel={onCancel}
onSubmit={onSubmit}
/>
)}
</>
</FullPageHorizontalForm>
<>
<PageHeader className={styles.pageHeader}>
<PageHeaderTitle>{t("title")}</PageHeaderTitle>
</PageHeader>
{loadingError && <AlertBanner error={loadingError} severity="error" />}
{isLoading && <Loader />}
{settings && (
<WorkspaceSettingsForm
error={formError}
isSubmitting={isSubmitting}
settings={settings}
onCancel={onCancel}
onSubmit={onSubmit}
/>
)}
</>
)
}
const useStyles = makeStyles(() => ({
pageHeader: {
paddingTop: 0,
},
}))
+5 -7
View File
@@ -1,15 +1,13 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import {
getWorkspaceByOwnerAndName,
getWorkspaceBuildParameters,
getTemplateVersionRichParameters,
patchWorkspace,
postWorkspaceBuild,
} from "api/api"
import { WorkspaceBuildParameter } from "api/typesGenerated"
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"
const getWorkspaceSettings = async (owner: string, name: string) => {
const workspace = await getWorkspaceByOwnerAndName(owner, name)
const getWorkspaceSettings = async (workspace: Workspace) => {
const latestBuild = workspace.latest_build
const [templateVersionRichParameters, buildParameters] = await Promise.all([
getTemplateVersionRichParameters(latestBuild.template_version_id),
@@ -22,10 +20,10 @@ const getWorkspaceSettings = async (owner: string, name: string) => {
}
}
export const useWorkspaceSettings = (owner: string, workspace: string) => {
export const useWorkspaceSettings = (workspace: Workspace) => {
return useQuery({
queryKey: ["workspaceSettings", owner, workspace],
queryFn: () => getWorkspaceSettings(owner, workspace),
queryKey: ["workspaceSettings", workspace.id],
queryFn: () => getWorkspaceSettings(workspace),
})
}
+45
View File
@@ -9,6 +9,7 @@ import { DashboardLayout } from "components/Dashboard/DashboardLayout"
import { createMemoryHistory } from "history"
import { i18n } from "i18n"
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"
import { FC, ReactElement } from "react"
import { I18nextProvider } from "react-i18next"
import {
@@ -131,6 +132,50 @@ export function renderWithTemplateSettingsLayout(
}
}
export function renderWithWorkspaceSettingsLayout(
element: JSX.Element,
{
path = "/",
route = "/",
extraRoutes = [],
nonAuthenticatedRoutes = [],
}: RenderWithAuthOptions = {},
) {
const routes: RouteObject[] = [
{
element: <RequireAuth />,
children: [
{
element: <DashboardLayout />,
children: [
{
element: <WorkspaceSettingsLayout />,
children: [{ path, element }, ...extraRoutes],
},
],
},
],
},
...nonAuthenticatedRoutes,
]
const router = createMemoryRouter(routes, { initialEntries: [route] })
const renderResult = wrappedRender(
<I18nextProvider i18n={i18n}>
<AppProviders>
<RouterProvider router={router} />
</AppProviders>
</I18nextProvider>,
)
return {
user: MockUser,
router,
...renderResult,
}
}
export const waitForLoaderToBeRemoved = (): Promise<void> =>
// Sometimes, we have pages that are doing a lot of requests to get done, so the
// default timeout of 1_000 is not enough. We should revisit this when we unify
+2 -2
View File
@@ -1,7 +1,7 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import { emptySchedule } from "pages/WorkspaceSchedulePage/schedule"
import { emptyTTL } from "pages/WorkspaceSchedulePage/ttl"
import { emptySchedule } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
import { emptyTTL } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import { Workspace } from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities"
import {
+2 -2
View File
@@ -10,8 +10,8 @@ import utc from "dayjs/plugin/utc"
import { Workspace } from "../api/typesGenerated"
import { isWorkspaceOn } from "./workspace"
import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import { Autostop } from "pages/WorkspaceSchedulePage/ttl"
import { Autostart } from "pages/WorkspaceSchedulePage/schedule"
import { Autostop } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import { Autostart } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
@@ -15,7 +15,7 @@ export interface WorkspaceScheduleContext {
* re-fetch the workspace to ensure we're up-to-date. As a result, this
* machine is partially influenced by workspaceXService.
*/
workspace?: TypesGen.Workspace
workspace: TypesGen.Workspace
template?: TypesGen.Template
getTemplateError?: Error | unknown
permissions?: Permissions
@@ -41,7 +41,6 @@ const permissionsToCheck = (workspace: TypesGen.Workspace) => ({
})
export type WorkspaceScheduleEvent =
| { type: "GET_WORKSPACE"; username: string; workspaceName: string }
| {
type: "SUBMIT_SCHEDULE"
autostart: TypesGen.UpdateWorkspaceAutostartRequest
@@ -63,38 +62,13 @@ export const workspaceSchedule =
context: {} as WorkspaceScheduleContext,
events: {} as WorkspaceScheduleEvent,
services: {} as {
getWorkspace: {
data: TypesGen.Workspace
}
getTemplate: {
data: TypesGen.Template
}
},
},
initial: "idle",
on: {
GET_WORKSPACE: "gettingWorkspace",
},
initial: "gettingPermissions",
states: {
idle: {
tags: "loading",
},
gettingWorkspace: {
entry: ["clearGetWorkspaceError", "clearContext"],
invoke: {
src: "getWorkspace",
id: "getWorkspace",
onDone: {
target: "gettingPermissions",
actions: ["assignWorkspace"],
},
onError: {
target: "error",
actions: ["assignGetWorkspaceError"],
},
},
tags: "loading",
},
gettingPermissions: {
entry: "clearGetPermissionsError",
invoke: {
@@ -167,9 +141,7 @@ export const workspaceSchedule =
},
},
error: {
on: {
GET_WORKSPACE: "gettingWorkspace",
},
type: "final",
},
done: {
type: "final",
@@ -184,12 +156,6 @@ export const workspaceSchedule =
assignSubmissionError: assign({
submitScheduleError: (_, event) => event.data,
}),
assignWorkspace: assign({
workspace: (_, event) => event.data,
}),
assignGetWorkspaceError: assign({
getWorkspaceError: (_, event) => event.data,
}),
assignPermissions: assign({
// Setting event.data as Permissions to be more stricted. So we know
// what permissions we asked for.
@@ -213,12 +179,7 @@ export const workspaceSchedule =
clearGetPermissionsError: assign({
checkPermissionsError: (_) => undefined,
}),
clearContext: () => {
assign({ workspace: undefined, permissions: undefined })
},
clearGetWorkspaceError: (context) => {
assign({ ...context, getWorkspaceError: undefined })
},
// action instead of service because we fire and forget so that the
// user can return to the workspace page to see the restart
restartWorkspace: (context) => {
@@ -232,12 +193,6 @@ export const workspaceSchedule =
},
services: {
getWorkspace: async (_, event) => {
return await API.getWorkspaceByOwnerAndName(
event.username,
event.workspaceName,
)
},
getTemplate: async (context) => {
if (context.workspace) {
return await API.getTemplate(context.workspace.template_id)