feat: Add switches for auto-start and auto-stop (#3358)

* Add elements

* Add Loading story

* Make form show empty values when manual

* Make form depend on switches

* Fix style

* Format

* Update unit tests

* Tweaks

* Update storybook

* Move util files

* Pull out more util functions

* Pull out strings

* Add border to section

* Make min ttl 1

* Format

* Fix import

* Fix validation for falsey values

* Format and fix tests

* Put switches in form, persist form state

* Fix bug

* Remove helper text when disabled

* Fix storybook

* Revert "Remove helper text when disabled"

This reverts commit a6271ca6c4.

* Format

* Use nicer function to set values

* Format
This commit is contained in:
Presley Pizzo
2022-08-10 22:03:15 +00:00
committed by GitHub
parent cee0d1f848
commit 5d40b1f0f4
9 changed files with 424 additions and 327 deletions
+3 -2
View File
@@ -30,7 +30,7 @@ export const Section: SectionFC = ({
}) => {
const styles = useStyles({ layout })
return (
<div className={combineClasses([styles.root, className])}>
<section className={combineClasses([styles.root, className])}>
<div className={styles.inner}>
{(title || description) && (
<div className={styles.header}>
@@ -49,7 +49,7 @@ export const Section: SectionFC = ({
{alert && <div className={styles.alert}>{alert}</div>}
{children}
</div>
</div>
</section>
)
}
@@ -63,6 +63,7 @@ const useStyles = makeStyles((theme) => ({
marginBottom: theme.spacing(1),
padding: theme.spacing(6),
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
[theme.breakpoints.down("sm")]: {
padding: theme.spacing(4, 3, 4, 3),
@@ -3,12 +3,10 @@ import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { defaultSchedule, emptySchedule } from "pages/WorkspaceSchedulePage/schedule"
import { defaultTTL, emptyTTL } from "pages/WorkspaceSchedulePage/ttl"
import { makeMockApiError } from "testHelpers/entities"
import {
defaultWorkspaceSchedule,
WorkspaceScheduleForm,
WorkspaceScheduleFormProps,
} from "./WorkspaceScheduleForm"
import { WorkspaceScheduleForm, WorkspaceScheduleFormProps } from "./WorkspaceScheduleForm"
dayjs.extend(advancedFormat)
dayjs.extend(utc)
@@ -29,51 +27,60 @@ export default {
const Template: Story<WorkspaceScheduleFormProps> = (args) => <WorkspaceScheduleForm {...args} />
export const WorkspaceWillNotShutDown = Template.bind({})
WorkspaceWillNotShutDown.args = {
const defaultInitialValues = {
autoStartEnabled: true,
...defaultSchedule(),
autoStopEnabled: true,
ttl: defaultTTL,
}
export const AllDisabled = Template.bind({})
AllDisabled.args = {
initialValues: {
...defaultWorkspaceSchedule(5),
ttl: 0,
autoStartEnabled: false,
...emptySchedule,
autoStopEnabled: false,
ttl: emptyTTL,
},
}
export const WorkspaceWillShutdownInAnHour = Template.bind({})
WorkspaceWillShutdownInAnHour.args = {
export const AutoStart = Template.bind({})
AutoStart.args = {
initialValues: {
...defaultWorkspaceSchedule(5),
ttl: 1,
autoStartEnabled: true,
...defaultSchedule(),
autoStopEnabled: false,
ttl: emptyTTL,
},
}
export const WorkspaceWillShutdownInTwoHours = Template.bind({})
WorkspaceWillShutdownInTwoHours.args = {
initialValues: {
...defaultWorkspaceSchedule(2),
ttl: 2,
},
initialValues: { ...defaultInitialValues, ttl: 2 },
}
export const WorkspaceWillShutdownInADay = Template.bind({})
WorkspaceWillShutdownInADay.args = {
initialValues: {
...defaultWorkspaceSchedule(2),
ttl: 24,
},
initialValues: { ...defaultInitialValues, ttl: 24 },
}
export const WorkspaceWillShutdownInTwoDays = Template.bind({})
WorkspaceWillShutdownInTwoDays.args = {
initialValues: {
...defaultWorkspaceSchedule(2),
ttl: 48,
},
initialValues: { ...defaultInitialValues, ttl: 48 },
}
export const WithError = Template.bind({})
WithError.args = {
initialValues: { ...defaultInitialValues, ttl: 100 },
initialTouched: { ttl: true },
submitScheduleError: makeMockApiError({
message: "Something went wrong.",
validations: [{ field: "ttl_ms", detail: "Invalid time until shutdown." }],
}),
}
export const Loading = Template.bind({})
Loading.args = {
initialValues: defaultInitialValues,
isLoading: true,
}
@@ -7,6 +7,7 @@ import {
import { zones } from "./zones"
const valid: WorkspaceScheduleFormValues = {
autoStartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -14,15 +15,17 @@ const valid: WorkspaceScheduleFormValues = {
thursday: true,
friday: true,
saturday: false,
startTime: "09:30",
timezone: "Canada/Eastern",
autoStopEnabled: true,
ttl: 120,
}
describe("validationSchema", () => {
it("allows everything to be falsy", () => {
it("allows everything to be falsy when switches are off", () => {
const values: WorkspaceScheduleFormValues = {
autoStartEnabled: false,
sunday: false,
monday: false,
tuesday: false,
@@ -30,9 +33,10 @@ describe("validationSchema", () => {
thursday: false,
friday: false,
saturday: false,
startTime: "",
timezone: "",
autoStopEnabled: false,
ttl: 0,
}
const validate = () => validationSchema.validateSync(values)
@@ -48,7 +52,7 @@ describe("validationSchema", () => {
expect(validate).toThrow()
})
it("disallows all days-of-week to be false when startTime is set", () => {
it("disallows all days-of-week to be false when auto-start is enabled", () => {
const values: WorkspaceScheduleFormValues = {
...valid,
sunday: false,
@@ -63,7 +67,7 @@ describe("validationSchema", () => {
expect(validate).toThrowError(Language.errorNoDayOfWeek)
})
it("disallows empty startTime when at least one day is set", () => {
it("disallows empty startTime when auto-start is enabled", () => {
const values: WorkspaceScheduleFormValues = {
...valid,
sunday: false,
@@ -6,8 +6,10 @@ import FormHelperText from "@material-ui/core/FormHelperText"
import FormLabel from "@material-ui/core/FormLabel"
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 { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { Section } from "components/Section/Section"
import dayjs from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
@@ -15,7 +17,9 @@ import relativeTime from "dayjs/plugin/relativeTime"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { FormikTouched, useFormik } from "formik"
import { FC } from "react"
import { defaultSchedule } from "pages/WorkspaceSchedulePage/schedule"
import { defaultTTL } from "pages/WorkspaceSchedulePage/ttl"
import { ChangeEvent, FC } from "react"
import * as Yup from "yup"
import { getFormHelpersWithError } from "../../util/formUtils"
import { FormFooter } from "../FormFooter/FormFooter"
@@ -32,10 +36,11 @@ dayjs.extend(relativeTime)
dayjs.extend(timezone)
export const Language = {
errorNoDayOfWeek: "Must set at least one day of week if start time is set",
errorNoTime: "Start time is required when days of the week are selected",
errorNoDayOfWeek: "Must set at least one day of week if auto-start is enabled",
errorNoTime: "Start time is required when auto-start is enabled",
errorTime: "Time must be in HH:mm format (24 hours)",
errorTimezone: "Invalid timezone",
errorNoStop: "Time until shutdown must be greater than zero when auto-stop is enabled",
daysOfWeekLabel: "Days of Week",
daySundayLabel: "Sunday",
dayMondayLabel: "Monday",
@@ -51,11 +56,16 @@ export const Language = {
ttlCausesShutdownHelperText: "Your workspace will shut down",
ttlCausesShutdownAfterStart: "after start",
ttlCausesNoShutdownHelperText: "Your workspace will not automatically shut down.",
formTitle: "Workspace schedule",
startSection: "Start",
startSwitch: "Auto-start",
stopSection: "Stop",
stopSwitch: "Auto-stop",
}
export interface WorkspaceScheduleFormProps {
submitScheduleError?: Error | unknown
initialValues?: WorkspaceScheduleFormValues
initialValues: WorkspaceScheduleFormValues
isLoading: boolean
onCancel: () => void
onSubmit: (values: WorkspaceScheduleFormValues) => void
@@ -64,6 +74,7 @@ export interface WorkspaceScheduleFormProps {
}
export interface WorkspaceScheduleFormValues {
autoStartEnabled: boolean
sunday: boolean
monday: boolean
tuesday: boolean
@@ -71,18 +82,20 @@ export interface WorkspaceScheduleFormValues {
thursday: boolean
friday: boolean
saturday: boolean
startTime: string
timezone: string
autoStopEnabled: boolean
ttl: number
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const validationSchema = Yup.object({
sunday: Yup.boolean(),
monday: Yup.boolean().test("at-least-one-day", Language.errorNoDayOfWeek, function (value) {
const parent = this.parent as WorkspaceScheduleFormValues
if (!parent.startTime) {
if (!parent.autoStartEnabled) {
return true
} else {
return ![
@@ -104,20 +117,9 @@ export const validationSchema = Yup.object({
startTime: Yup.string()
.ensure()
.test("required-if-day-selected", Language.errorNoTime, function (value) {
.test("required-if-auto-start", Language.errorNoTime, function (value) {
const parent = this.parent as WorkspaceScheduleFormValues
const isDaySelected = [
parent.sunday,
parent.monday,
parent.tuesday,
parent.wednesday,
parent.thursday,
parent.friday,
parent.saturday,
].some((day) => day)
if (isDaySelected) {
if (parent.autoStartEnabled) {
return value !== ""
} else {
return true
@@ -157,31 +159,20 @@ export const validationSchema = Yup.object({
ttl: Yup.number()
.integer()
.min(0)
.max(24 * 7 /* 7 days */),
})
export const defaultWorkspaceScheduleTTL = 8
export const defaultWorkspaceSchedule = (
ttl = defaultWorkspaceScheduleTTL,
timezone = dayjs.tz.guess(),
): WorkspaceScheduleFormValues => ({
sunday: false,
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
startTime: "09:30",
timezone,
ttl,
.max(24 * 7 /* 7 days */)
.test("positive-if-auto-stop", Language.errorNoStop, function (value) {
const parent = this.parent as WorkspaceScheduleFormValues
if (parent.autoStopEnabled) {
return !!value
} else {
return true
}
}),
})
export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
submitScheduleError,
initialValues = defaultWorkspaceSchedule(),
initialValues,
isLoading,
onCancel,
onSubmit,
@@ -210,72 +201,115 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
{ value: form.values.saturday, name: "saturday", label: Language.daySaturdayLabel },
]
const handleToggleAutoStart = async (e: ChangeEvent) => {
form.handleChange(e)
// if enabling from empty values, fill with defaults
if (!form.values.autoStartEnabled && !form.values.startTime) {
await form.setValues({ ...form.values, autoStartEnabled: true, ...defaultSchedule() })
}
}
const handleToggleAutoStop = async (e: ChangeEvent) => {
form.handleChange(e)
// if enabling from empty values, fill with defaults
if (!form.values.autoStopEnabled && !form.values.ttl) {
await form.setFieldValue("ttl", defaultTTL)
}
}
return (
<FullPageForm onCancel={onCancel} title="Workspace schedule">
<FullPageForm onCancel={onCancel} title={Language.formTitle}>
<form onSubmit={form.handleSubmit} className={styles.form}>
<Stack>
{submitScheduleError && <ErrorSummary error={submitScheduleError} />}
<TextField
{...formHelpers("startTime", Language.startTimeHelperText)}
disabled={isLoading}
InputLabelProps={{
shrink: true,
}}
label={Language.startTimeLabel}
type="time"
/>
<TextField
{...formHelpers("timezone")}
disabled={isLoading}
InputLabelProps={{
shrink: true,
}}
label={Language.timezoneLabel}
select
>
{zones.map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>
))}
</TextField>
<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}
onChange={form.handleChange}
name={checkbox.name}
color="primary"
size="small"
disableRipple
/>
}
key={checkbox.name}
label={checkbox.label}
<Section title={Language.startSection}>
<FormControlLabel
control={
<Switch
name="autoStartEnabled"
checked={form.values.autoStartEnabled}
onChange={handleToggleAutoStart}
/>
}
label={Language.startSwitch}
/>
<TextField
{...formHelpers("startTime", Language.startTimeHelperText)}
disabled={isLoading || !form.values.autoStartEnabled}
InputLabelProps={{
shrink: true,
}}
label={Language.startTimeLabel}
type="time"
fullWidth
/>
<TextField
{...formHelpers("timezone")}
disabled={isLoading || !form.values.autoStartEnabled}
InputLabelProps={{
shrink: true,
}}
label={Language.timezoneLabel}
select
fullWidth
>
{zones.map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>
))}
</FormGroup>
</TextField>
{form.errors.monday && <FormHelperText>{Language.errorNoDayOfWeek}</FormHelperText>}
</FormControl>
<FormControl component="fieldset" error={Boolean(form.errors.monday)}>
<FormLabel className={styles.daysOfWeekLabel} component="legend">
{Language.daysOfWeekLabel}
</FormLabel>
<TextField
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl), "ttl_ms")}
disabled={isLoading}
inputProps={{ min: 0, step: 1 }}
label={Language.ttlLabel}
type="number"
/>
<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}
/>
}
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>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</Stack>
@@ -1,13 +1,14 @@
import * as TypesGen from "../../api/typesGenerated"
import { WorkspaceScheduleFormValues } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import * as Mocks from "../../testHelpers/entities"
import {
formValuesToAutoStartRequest,
formValuesToTTLRequest,
workspaceToInitialValues,
} from "./WorkspaceSchedulePage"
} from "pages/WorkspaceSchedulePage/formToRequest"
import { AutoStart, scheduleToAutoStart } from "pages/WorkspaceSchedulePage/schedule"
import { AutoStop, ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl"
import * as TypesGen from "../../api/typesGenerated"
import { WorkspaceScheduleFormValues } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
const validValues: WorkspaceScheduleFormValues = {
autoStartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -17,6 +18,7 @@ const validValues: WorkspaceScheduleFormValues = {
saturday: false,
startTime: "09:30",
timezone: "Canada/Eastern",
autoStopEnabled: true,
ttl: 120,
}
@@ -26,6 +28,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Empty case
{
autoStartEnabled: false,
sunday: false,
monday: false,
tuesday: false,
@@ -35,6 +38,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "",
timezone: "",
autoStopEnabled: false,
ttl: 0,
},
{
@@ -44,6 +48,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Single day
{
autoStartEnabled: true,
sunday: true,
monday: false,
tuesday: false,
@@ -53,6 +58,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "16:20",
timezone: "Canada/Eastern",
autoStopEnabled: true,
ttl: 120,
},
{
@@ -62,6 +68,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Standard 1-5 case
{
autoStartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -71,6 +78,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "09:30",
timezone: "America/Central",
autoStopEnabled: true,
ttl: 120,
},
{
@@ -80,6 +88,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Everyday
{
autoStartEnabled: true,
sunday: true,
monday: true,
tuesday: true,
@@ -89,6 +98,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: true,
startTime: "09:00",
timezone: "",
autoStopEnabled: true,
ttl: 60 * 8,
},
{
@@ -98,6 +108,7 @@ describe("WorkspaceSchedulePage", () => {
[
// Mon, Wed, Fri Evenings
{
autoStartEnabled: true,
sunday: false,
monday: true,
tuesday: false,
@@ -107,6 +118,7 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "16:20",
timezone: "",
autoStopEnabled: true,
ttl: 60 * 3,
},
{
@@ -155,61 +167,30 @@ describe("WorkspaceSchedulePage", () => {
})
})
describe("workspaceToInitialValues", () => {
it.each<[TypesGen.Workspace, WorkspaceScheduleFormValues]>([
describe("scheduleToAutoStart", () => {
it.each<[string | undefined, AutoStart]>([
// Empty case
[
undefined,
{
...Mocks.MockWorkspace,
autostart_schedule: undefined,
ttl_ms: undefined,
},
{
autoStartEnabled: false,
sunday: false,
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
startTime: "09:30",
startTime: "",
timezone: "",
ttl: 8,
},
],
// ttl-only case (2 hours)
// Basic case: 9:30 1-5 UTC
[
"CRON_TZ=UTC 30 9 * * 1-5",
{
...Mocks.MockWorkspace,
autostart_schedule: "",
ttl_ms: 7_200_000,
},
{
sunday: false,
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
startTime: "09:30",
timezone: "",
ttl: 2,
},
],
// Basic case: 9:30 1-5 UTC running for 2 hours
//
// NOTE: We have to set CRON_TZ here because otherwise this test will
// flake based off of where it runs!
[
{
...Mocks.MockWorkspace,
autostart_schedule: "CRON_TZ=UTC 30 9 * * 1-5",
ttl_ms: 7_200_000,
},
{
autoStartEnabled: true,
sunday: false,
monday: true,
tuesday: true,
@@ -219,18 +200,14 @@ describe("WorkspaceSchedulePage", () => {
saturday: false,
startTime: "09:30",
timezone: "UTC",
ttl: 2,
},
],
// Complex case: 4:20 1 3-4 6 Canada/Eastern for 8 hours
// Complex case: 4:20 1 3-4 6 Canada/Eastern
[
"CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6",
{
...Mocks.MockWorkspace,
autostart_schedule: "CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6",
ttl_ms: 28_800_000,
},
{
autoStartEnabled: true,
sunday: false,
monday: true,
tuesday: false,
@@ -240,11 +217,23 @@ describe("WorkspaceSchedulePage", () => {
saturday: true,
startTime: "16:20",
timezone: "Canada/Eastern",
ttl: 8,
},
],
])(`workspaceToInitialValues(%p) returns %p`, (workspace, formValues) => {
expect(workspaceToInitialValues(workspace)).toEqual(formValues)
])(`scheduleToAutoStart(%p) returns %p`, (schedule, autoStart) => {
expect(scheduleToAutoStart(schedule)).toEqual(autoStart)
})
})
describe("ttlMsToAutoStop", () => {
it.each<[number | undefined, AutoStop]>([
// empty case
[undefined, { autoStopEnabled: false, ttl: 0 }],
// zero
[0, { autoStopEnabled: false, ttl: 0 }],
// basic case
[28_800_000, { autoStopEnabled: true, ttl: 8 }],
])(`ttlMsToAutoStop(%p) returns %p`, (ttlMs, autoStop) => {
expect(ttlMsToAutoStop(ttlMs)).toEqual(autoStop)
})
})
})
@@ -1,30 +1,17 @@
import { useMachine, useSelector } from "@xstate/react"
import * as cronParser from "cron-parser"
import dayjs from "dayjs"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import React, { useContext, useEffect } from "react"
import { useNavigate, useParams } from "react-router-dom"
import { scheduleToAutoStart } from "pages/WorkspaceSchedulePage/schedule"
import { ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl"
import React, { useContext, useEffect, useState } from "react"
import { Navigate, useNavigate, useParams } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import {
defaultWorkspaceSchedule,
defaultWorkspaceScheduleTTL,
WorkspaceScheduleForm,
WorkspaceScheduleFormValues,
} from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import { WorkspaceScheduleForm } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import { firstOrItem } from "../../util/array"
import { extractTimezone, stripTimezone } from "../../util/schedule"
import { selectUser } from "../../xServices/auth/authSelectors"
import { XServiceContext } from "../../xServices/StateContext"
import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService"
// REMARK: timezone plugin depends on UTC
//
// SEE: https://day.js.org/docs/en/timezone/timezone
dayjs.extend(utc)
dayjs.extend(timezone)
import { formValuesToAutoStartRequest, formValuesToTTLRequest } from "./formToRequest"
const Language = {
forbiddenError: "You don't have permissions to update the schedule for this workspace.",
@@ -32,118 +19,6 @@ const Language = {
checkPermissionsError: "Failed to fetch permissions.",
}
export const formValuesToAutoStartRequest = (
values: WorkspaceScheduleFormValues,
): TypesGen.UpdateWorkspaceAutostartRequest => {
if (!values.startTime) {
return {
schedule: "",
}
}
const [HH, mm] = values.startTime.split(":")
// Note: Space after CRON_TZ if timezone is defined
const preparedTZ = values.timezone ? `CRON_TZ=${values.timezone} ` : ""
const makeCronString = (dow: string) => `${preparedTZ}${mm} ${HH} * * ${dow}`
const days = [
values.sunday,
values.monday,
values.tuesday,
values.wednesday,
values.thursday,
values.friday,
values.saturday,
]
const isEveryDay = days.every((day) => day)
const isMonThroughFri =
!values.sunday &&
values.monday &&
values.tuesday &&
values.wednesday &&
values.thursday &&
values.friday &&
!values.saturday &&
!values.sunday
// Handle special cases, falling through to comma-separation
if (isEveryDay) {
return {
schedule: makeCronString("*"),
}
} else if (isMonThroughFri) {
return {
schedule: makeCronString("1-5"),
}
} else {
const dow = days.reduce((previous, current, idx) => {
if (!current) {
return previous
} else {
const prefix = previous ? "," : ""
return previous + prefix + idx
}
}, "")
return {
schedule: makeCronString(dow),
}
}
}
export const formValuesToTTLRequest = (
values: WorkspaceScheduleFormValues,
): TypesGen.UpdateWorkspaceTTLRequest => {
return {
// minutes to nanoseconds
ttl_ms: values.ttl ? values.ttl * 60 * 60 * 1000 : undefined,
}
}
export const workspaceToInitialValues = (
workspace: TypesGen.Workspace,
defaultTimeZone = "",
): WorkspaceScheduleFormValues => {
const schedule = workspace.autostart_schedule
const ttlHours = workspace.ttl_ms
? Math.round(workspace.ttl_ms / (1000 * 60 * 60))
: defaultWorkspaceScheduleTTL
if (!schedule) {
return defaultWorkspaceSchedule(ttlHours, defaultTimeZone)
}
const timezone = extractTimezone(schedule, defaultTimeZone)
const expression = cronParser.parseExpression(stripTimezone(schedule))
const HH = expression.fields.hour.join("").padStart(2, "0")
const mm = expression.fields.minute.join("").padStart(2, "0")
const weeklyFlags = [false, false, false, false, false, false, false]
for (const day of expression.fields.dayOfWeek) {
weeklyFlags[day % 7] = true
}
return {
sunday: weeklyFlags[0],
monday: weeklyFlags[1],
tuesday: weeklyFlags[2],
wednesday: weeklyFlags[3],
thursday: weeklyFlags[4],
friday: weeklyFlags[5],
saturday: weeklyFlags[6],
startTime: `${HH}:${mm}`,
timezone,
ttl: ttlHours,
}
}
export const WorkspaceSchedulePage: React.FC = () => {
const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams()
const navigate = useNavigate()
@@ -167,9 +42,20 @@ export const WorkspaceSchedulePage: React.FC = () => {
username && workspaceName && scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
}, [username, workspaceName, scheduleSend])
const getAutoStart = (workspace?: TypesGen.Workspace) =>
scheduleToAutoStart(workspace?.autostart_schedule)
const getAutoStop = (workspace?: TypesGen.Workspace) => ttlMsToAutoStop(workspace?.ttl_ms)
const [autoStart, setAutoStart] = useState(getAutoStart(workspace))
const [autoStop, setAutoStop] = useState(getAutoStop(workspace))
useEffect(() => {
setAutoStart(getAutoStart(workspace))
setAutoStop(getAutoStop(workspace))
}, [workspace])
if (!username || !workspaceName) {
navigate("/workspaces")
return null
return <Navigate to="/workspaces" />
}
if (
@@ -201,7 +87,7 @@ export const WorkspaceSchedulePage: React.FC = () => {
return (
<WorkspaceScheduleForm
submitScheduleError={submitScheduleError}
initialValues={workspaceToInitialValues(workspace, dayjs.tz.guess())}
initialValues={{ ...autoStart, ...autoStop }}
isLoading={scheduleState.tags.has("loading")}
onCancel={() => {
navigate(`/@${username}/${workspaceName}`)
@@ -218,12 +104,10 @@ export const WorkspaceSchedulePage: React.FC = () => {
}
if (scheduleState.matches("submitSuccess")) {
navigate(`/@${username}/${workspaceName}`)
return <FullScreenLoader />
return <Navigate to={`/@${username}/${workspaceName}`} />
}
// Theoretically impossible - log and bail
console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState)
navigate("/")
return null
return <Navigate to="/" />
}
@@ -0,0 +1,74 @@
import * as TypesGen from "api/typesGenerated"
import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm"
export const formValuesToAutoStartRequest = (
values: WorkspaceScheduleFormValues,
): TypesGen.UpdateWorkspaceAutostartRequest => {
if (!values.autoStartEnabled || !values.startTime) {
return {
schedule: "",
}
}
const [HH, mm] = values.startTime.split(":")
// Note: Space after CRON_TZ if timezone is defined
const preparedTZ = values.timezone ? `CRON_TZ=${values.timezone} ` : ""
const makeCronString = (dow: string) => `${preparedTZ}${mm} ${HH} * * ${dow}`
const days = [
values.sunday,
values.monday,
values.tuesday,
values.wednesday,
values.thursday,
values.friday,
values.saturday,
]
const isEveryDay = days.every((day) => day)
const isMonThroughFri =
!values.sunday &&
values.monday &&
values.tuesday &&
values.wednesday &&
values.thursday &&
values.friday &&
!values.saturday &&
!values.sunday
// Handle special cases, falling through to comma-separation
if (isEveryDay) {
return {
schedule: makeCronString("*"),
}
} else if (isMonThroughFri) {
return {
schedule: makeCronString("1-5"),
}
} else {
const dow = days.reduce((previous, current, idx) => {
if (!current) {
return previous
} else {
const prefix = previous ? "," : ""
return previous + prefix + idx
}
}, "")
return {
schedule: makeCronString(dow),
}
}
}
export const formValuesToTTLRequest = (
values: WorkspaceScheduleFormValues,
): TypesGen.UpdateWorkspaceTTLRequest => {
return {
// minutes to nanoseconds
ttl_ms: values.autoStopEnabled && values.ttl ? values.ttl * 60 * 60 * 1000 : undefined,
}
}
@@ -0,0 +1,91 @@
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"
// REMARK: timezone plugin depends on UTC
//
// SEE: https://day.js.org/docs/en/timezone/timezone
dayjs.extend(utc)
dayjs.extend(timezone)
export interface AutoStartSchedule {
sunday: boolean
monday: boolean
tuesday: boolean
wednesday: boolean
thursday: boolean
friday: boolean
saturday: boolean
startTime: string
timezone: string
}
export type AutoStart = {
autoStartEnabled: boolean
} & AutoStartSchedule
export const emptySchedule = {
sunday: false,
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
startTime: "",
timezone: "",
}
export const defaultSchedule = (): AutoStartSchedule => ({
sunday: false,
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
startTime: "09:30",
timezone: dayjs.tz.guess(),
})
const transformSchedule = (schedule: string) => {
const timezone = extractTimezone(schedule, dayjs.tz.guess())
const expression = cronParser.parseExpression(stripTimezone(schedule))
const HH = expression.fields.hour.join("").padStart(2, "0")
const mm = expression.fields.minute.join("").padStart(2, "0")
const weeklyFlags = [false, false, false, false, false, false, false]
for (const day of expression.fields.dayOfWeek) {
weeklyFlags[day % 7] = true
}
return {
sunday: weeklyFlags[0],
monday: weeklyFlags[1],
tuesday: weeklyFlags[2],
wednesday: weeklyFlags[3],
thursday: weeklyFlags[4],
friday: weeklyFlags[5],
saturday: weeklyFlags[6],
startTime: `${HH}:${mm}`,
timezone,
}
}
export const scheduleToAutoStart = (schedule?: string): AutoStart => {
if (schedule) {
return {
autoStartEnabled: true,
...transformSchedule(schedule),
}
} else {
return { autoStartEnabled: false, ...emptySchedule }
}
}
@@ -0,0 +1,13 @@
export interface AutoStop {
autoStopEnabled: boolean
ttl: number
}
export const emptyTTL = 0
export const defaultTTL = 8
const msToHours = (ms: number) => Math.round(ms / (1000 * 60 * 60))
export const ttlMsToAutoStop = (ttl_ms?: number): AutoStop =>
ttl_ms ? { autoStopEnabled: true, ttl: msToHours(ttl_ms) } : { autoStopEnabled: false, ttl: 0 }