= ({ workspace
{Language.autoStopDisplay(workspace)}
- {Language.editScheduleLink}
+
+ {Language.editScheduleLink}
+
diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx
index 74fc7b921ce75..9b7f43e0850f2 100644
--- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx
+++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx
@@ -13,8 +13,5 @@ const Template: Story = (args) => action("onCancel"),
- onSubmit: () => {
- action("onSubmit")
- return Promise.resolve()
- },
+ onSubmit: () => action("onSubmit"),
}
diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts
index cdd133dfc7932..b1ffe7795b800 100644
--- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts
+++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts
@@ -10,6 +10,7 @@ const valid: WorkspaceScheduleFormValues = {
saturday: false,
startTime: "09:30",
+ timezone: "Canada/Eastern",
ttl: 120,
}
@@ -25,6 +26,7 @@ describe("validationSchema", () => {
saturday: false,
startTime: "",
+ timezone: "",
ttl: 0,
}
const validate = () => validationSchema.validateSync(values)
@@ -32,7 +34,7 @@ describe("validationSchema", () => {
})
it("disallows ttl to be negative", () => {
- const values = {
+ const values: WorkspaceScheduleFormValues = {
...valid,
ttl: -1,
}
@@ -41,7 +43,7 @@ describe("validationSchema", () => {
})
it("disallows all days-of-week to be false when startTime is set", () => {
- const values = {
+ const values: WorkspaceScheduleFormValues = {
...valid,
sunday: false,
monday: false,
@@ -54,4 +56,58 @@ describe("validationSchema", () => {
const validate = () => validationSchema.validateSync(values)
expect(validate).toThrowError(Language.errorNoDayOfWeek)
})
+
+ it("allows startTime 16:20", () => {
+ const values: WorkspaceScheduleFormValues = {
+ ...valid,
+ startTime: "16:20",
+ }
+ const validate = () => validationSchema.validateSync(values)
+ expect(validate).not.toThrow()
+ })
+
+ it("disallows startTime to be H:mm", () => {
+ const values: WorkspaceScheduleFormValues = {
+ ...valid,
+ startTime: "9:30",
+ }
+ const validate = () => validationSchema.validateSync(values)
+ expect(validate).toThrowError(Language.errorTime)
+ })
+
+ it("disallows startTime to be HH:m", () => {
+ const values: WorkspaceScheduleFormValues = {
+ ...valid,
+ startTime: "09:5",
+ }
+ const validate = () => validationSchema.validateSync(values)
+ expect(validate).toThrowError(Language.errorTime)
+ })
+
+ it("disallows an invalid startTime 24:01", () => {
+ const values: WorkspaceScheduleFormValues = {
+ ...valid,
+ startTime: "24:01",
+ }
+ const validate = () => validationSchema.validateSync(values)
+ expect(validate).toThrowError(Language.errorTime)
+ })
+
+ it("disallows an invalid startTime 09:60", () => {
+ const values: WorkspaceScheduleFormValues = {
+ ...valid,
+ startTime: "09:60",
+ }
+ const validate = () => validationSchema.validateSync(values)
+ expect(validate).toThrowError(Language.errorTime)
+ })
+
+ it("disallows an invalid timezone Canada/North", () => {
+ const values: WorkspaceScheduleFormValues = {
+ ...valid,
+ timezone: "Canada/North",
+ }
+ const validate = () => validationSchema.validateSync(values)
+ expect(validate).toThrowError(Language.errorTimezone)
+ })
})
diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx
index 1603e28e7e8e1..da4fec20269d0 100644
--- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx
+++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx
@@ -4,18 +4,31 @@ import FormControlLabel from "@material-ui/core/FormControlLabel"
import FormGroup from "@material-ui/core/FormGroup"
import FormHelperText from "@material-ui/core/FormHelperText"
import FormLabel from "@material-ui/core/FormLabel"
+import Link from "@material-ui/core/Link"
import makeStyles from "@material-ui/core/styles/makeStyles"
import TextField from "@material-ui/core/TextField"
+import dayjs from "dayjs"
+import timezone from "dayjs/plugin/timezone"
+import utc from "dayjs/plugin/utc"
import { useFormik } from "formik"
import React from "react"
import * as Yup from "yup"
+import { FieldErrors } from "../../api/errors"
import { getFormHelpers } from "../../util/formUtils"
import { FormFooter } from "../FormFooter/FormFooter"
import { FullPageForm } from "../FullPageForm/FullPageForm"
import { Stack } from "../Stack/Stack"
+// REMARK: timezone plugin depends on UTC
+//
+// SEE: https://day.js.org/docs/en/timezone/timezone
+dayjs.extend(utc)
+dayjs.extend(timezone)
+
export const Language = {
errorNoDayOfWeek: "Must set at least one day of week",
+ errorTime: "Time must be in HH:mm format (24 hours)",
+ errorTimezone: "Invalid timezone",
daysOfWeekLabel: "Days of Week",
daySundayLabel: "Sunday",
dayMondayLabel: "Monday",
@@ -26,13 +39,17 @@ export const Language = {
daySaturdayLabel: "Saturday",
startTimeLabel: "Start time",
startTimeHelperText: "Your workspace will automatically start at this time.",
- ttlLabel: "Runtime (minutes)",
- ttlHelperText: "Your workspace will automatically shutdown after the runtime.",
+ timezoneLabel: "Timezone",
+ ttlLabel: "TTL (hours)",
+ ttlHelperText: "Your workspace will automatically shutdown after the TTL.",
}
export interface WorkspaceScheduleFormProps {
+ fieldErrors?: FieldErrors
+ initialValues?: WorkspaceScheduleFormValues
+ isLoading: boolean
onCancel: () => void
- onSubmit: (values: WorkspaceScheduleFormValues) => Promise
+ onSubmit: (values: WorkspaceScheduleFormValues) => void
}
export interface WorkspaceScheduleFormValues {
@@ -45,6 +62,7 @@ export interface WorkspaceScheduleFormValues {
saturday: boolean
startTime: string
+ timezone: string
ttl: number
}
@@ -73,30 +91,79 @@ export const validationSchema = Yup.object({
friday: Yup.boolean(),
saturday: Yup.boolean(),
- startTime: Yup.string(),
+ startTime: Yup.string()
+ .ensure()
+ .test("is-time-string", Language.errorTime, (value) => {
+ if (value === "") {
+ return true
+ } else if (!/^[0-9][0-9]:[0-9][0-9]$/.test(value)) {
+ return false
+ } else {
+ const parts = value.split(":")
+ const HH = Number(parts[0])
+ const mm = Number(parts[1])
+ return HH >= 0 && HH <= 23 && mm >= 0 && mm <= 59
+ }
+ }),
+ timezone: Yup.string()
+ .ensure()
+ .test("is-timezone", Language.errorTimezone, function (value) {
+ const parent = this.parent as WorkspaceScheduleFormValues
+
+ if (!parent.startTime) {
+ return true
+ } else {
+ // Unfortunately, there's not a good API on dayjs at this time for
+ // evaluating a timezone. Attempt to parse today in the supplied timezone
+ // and return as valid if the function doesn't throw.
+ try {
+ dayjs.tz(dayjs(), value)
+ return true
+ } catch (e) {
+ return false
+ }
+ }
+ }),
ttl: Yup.number().min(0).integer(),
})
-export const WorkspaceScheduleForm: React.FC = ({ onCancel, onSubmit }) => {
+export const WorkspaceScheduleForm: React.FC = ({
+ fieldErrors,
+ initialValues = {
+ sunday: false,
+ monday: true,
+ tuesday: true,
+ wednesday: true,
+ thursday: true,
+ friday: true,
+ saturday: false,
+
+ startTime: "09:30",
+ timezone: "",
+ ttl: 5,
+ },
+ isLoading,
+ onCancel,
+ onSubmit,
+}) => {
const styles = useStyles()
const form = useFormik({
- initialValues: {
- sunday: false,
- monday: true,
- tuesday: true,
- wednesday: true,
- thursday: true,
- friday: true,
- saturday: false,
-
- startTime: "09:30",
- ttl: 120,
- },
+ initialValues,
onSubmit,
validationSchema,
})
- const formHelpers = getFormHelpers(form)
+ const formHelpers = getFormHelpers(form, fieldErrors)
+
+ const checkboxes: Array<{ value: boolean; name: string; label: string }> = [
+ { value: form.values.sunday, name: "sunday", label: Language.daySundayLabel },
+ { value: form.values.monday, name: "monday", label: Language.dayMondayLabel },
+ { value: form.values.tuesday, name: "tuesday", label: Language.dayTuesdayLabel },
+ { value: form.values.wednesday, name: "wednesday", label: Language.dayWednesdayLabel },
+ { value: form.values.thursday, name: "thursday", label: Language.dayThursdayLabel },
+ { value: form.values.friday, name: "friday", label: Language.dayFridayLabel },
+ { value: form.values.saturday, name: "saturday", label: Language.daySaturdayLabel },
+ ]
return (
@@ -104,6 +171,7 @@ export const WorkspaceScheduleForm: React.FC = ({ on
= ({ on
variant="standard"
/>
+
+ Timezone must be a valid{" "}
+
+ tz database name
+
+ >,
+ )}
+ disabled={form.isSubmitting || isLoading || !form.values.startTime}
+ InputLabelProps={{
+ shrink: true,
+ }}
+ label={Language.timezoneLabel}
+ variant="standard"
+ />
+
{Language.daysOfWeekLabel}
-
- }
- label={Language.daySundayLabel}
- />
-
- }
- label={Language.dayMondayLabel}
- />
-
- }
- label={Language.dayTuesdayLabel}
- />
-
- }
- label={Language.dayWednesdayLabel}
- />
-
- }
- label={Language.dayThursdayLabel}
- />
-
- }
- label={Language.dayFridayLabel}
- />
-
- }
- label={Language.daySaturdayLabel}
- />
+ {checkboxes.map((checkbox) => (
+
+ }
+ key={checkbox.name}
+ label={checkbox.label}
+ />
+ ))}
+
{form.errors.monday && {Language.errorNoDayOfWeek}}
-
+
diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx
new file mode 100644
index 0000000000000..798f6a7b62532
--- /dev/null
+++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx
@@ -0,0 +1,246 @@
+import * as TypesGen from "../../api/typesGenerated"
+import { WorkspaceScheduleFormValues } from "../../components/WorkspaceStats/WorkspaceScheduleForm"
+import * as Mocks from "../../testHelpers/entities"
+import { formValuesToAutoStartRequest, formValuesToTTLRequest, workspaceToInitialValues } from "./WorkspaceSchedulePage"
+
+const validValues: WorkspaceScheduleFormValues = {
+ sunday: false,
+ monday: true,
+ tuesday: true,
+ wednesday: true,
+ thursday: true,
+ friday: true,
+ saturday: false,
+ startTime: "09:30",
+ timezone: "Canada/Eastern",
+ ttl: 120,
+}
+
+describe("WorkspaceSchedulePage", () => {
+ describe("formValuesToAutoStartRequest", () => {
+ it.each<[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceAutostartRequest]>([
+ [
+ // Empty case
+ {
+ sunday: false,
+ monday: false,
+ tuesday: false,
+ wednesday: false,
+ thursday: false,
+ friday: false,
+ saturday: false,
+ startTime: "",
+ timezone: "",
+ ttl: 0,
+ },
+ {
+ schedule: "",
+ },
+ ],
+ [
+ // Single day
+ {
+ sunday: true,
+ monday: false,
+ tuesday: false,
+ wednesday: false,
+ thursday: false,
+ friday: false,
+ saturday: false,
+ startTime: "16:20",
+ timezone: "Canada/Eastern",
+ ttl: 120,
+ },
+ {
+ schedule: "CRON_TZ=Canada/Eastern 20 16 * * 0",
+ },
+ ],
+ [
+ // Standard 1-5 case
+ {
+ sunday: false,
+ monday: true,
+ tuesday: true,
+ wednesday: true,
+ thursday: true,
+ friday: true,
+ saturday: false,
+ startTime: "09:30",
+ timezone: "America/Central",
+ ttl: 120,
+ },
+ {
+ schedule: "CRON_TZ=America/Central 30 09 * * 1-5",
+ },
+ ],
+ [
+ // Everyday
+ {
+ sunday: true,
+ monday: true,
+ tuesday: true,
+ wednesday: true,
+ thursday: true,
+ friday: true,
+ saturday: true,
+ startTime: "09:00",
+ timezone: "",
+ ttl: 60 * 8,
+ },
+ {
+ schedule: "00 09 * * *",
+ },
+ ],
+ [
+ // Mon, Wed, Fri Evenings
+ {
+ sunday: false,
+ monday: true,
+ tuesday: false,
+ wednesday: true,
+ thursday: false,
+ friday: true,
+ saturday: false,
+ startTime: "16:20",
+ timezone: "",
+ ttl: 60 * 3,
+ },
+ {
+ schedule: "20 16 * * 1,3,5",
+ },
+ ],
+ ])(`formValuesToAutoStartRequest(%p) return %p`, (values, request) => {
+ expect(formValuesToAutoStartRequest(values)).toEqual(request)
+ })
+ })
+
+ describe("formValuesToTTLRequest", () => {
+ it.each<[WorkspaceScheduleFormValues, TypesGen.UpdateWorkspaceTTLRequest]>([
+ [
+ // 0 case
+ {
+ ...validValues,
+ ttl: 0,
+ },
+ {
+ ttl: undefined,
+ },
+ ],
+ [
+ // 2 Hours = 7.2e+12 case
+ {
+ ...validValues,
+ ttl: 2,
+ },
+ {
+ ttl: 7_200_000_000_000,
+ },
+ ],
+ [
+ // 8 hours = 2.88e+13 case
+ {
+ ...validValues,
+ ttl: 8,
+ },
+ {
+ ttl: 28_800_000_000_000,
+ },
+ ],
+ ])(`formValuesToTTLRequest(%p) returns %p`, (values, request) => {
+ expect(formValuesToTTLRequest(values)).toEqual(request)
+ })
+ })
+
+ describe("workspaceToInitialValues", () => {
+ it.each<[TypesGen.Workspace, WorkspaceScheduleFormValues]>([
+ // Empty case
+ [
+ {
+ ...Mocks.MockWorkspace,
+ autostart_schedule: "",
+ ttl: undefined,
+ },
+ {
+ sunday: false,
+ monday: false,
+ tuesday: false,
+ wednesday: false,
+ thursday: false,
+ friday: false,
+ saturday: false,
+ startTime: "",
+ timezone: "",
+ ttl: 0,
+ },
+ ],
+
+ // ttl-only case (2 hours)
+ [
+ {
+ ...Mocks.MockWorkspace,
+ autostart_schedule: "",
+ ttl: 7_200_000_000_000,
+ },
+ {
+ sunday: false,
+ monday: false,
+ tuesday: false,
+ wednesday: false,
+ thursday: false,
+ friday: false,
+ saturday: false,
+ startTime: "",
+ 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: 7_200_000_000_000,
+ },
+ {
+ sunday: false,
+ monday: true,
+ tuesday: true,
+ wednesday: true,
+ thursday: true,
+ friday: true,
+ saturday: false,
+ startTime: "09:30",
+ timezone: "UTC",
+ ttl: 2,
+ },
+ ],
+
+ // Complex case: 4:20 1 3-4 6 Canada/Eastern for 8 hours
+ [
+ {
+ ...Mocks.MockWorkspace,
+ autostart_schedule: "CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6",
+ ttl: 28_800_000_000_000,
+ },
+ {
+ sunday: false,
+ monday: true,
+ tuesday: false,
+ wednesday: true,
+ thursday: true,
+ friday: false,
+ saturday: true,
+ startTime: "16:20",
+ timezone: "Canada/Eastern",
+ ttl: 8,
+ },
+ ],
+ ])(`workspaceToInitialValues(%p) returns %p`, (workspace, formValues) => {
+ expect(workspaceToInitialValues(workspace)).toEqual(formValues)
+ })
+ })
+})
diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx
new file mode 100644
index 0000000000000..a686daf4daf00
--- /dev/null
+++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx
@@ -0,0 +1,187 @@
+import { useMachine } from "@xstate/react"
+import dayjs from "dayjs"
+import timezone from "dayjs/plugin/timezone"
+import utc from "dayjs/plugin/utc"
+import React, { useEffect } from "react"
+import { 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 {
+ WorkspaceScheduleForm,
+ WorkspaceScheduleFormValues,
+} from "../../components/WorkspaceStats/WorkspaceScheduleForm"
+import { firstOrItem } from "../../util/array"
+import { dowToWeeklyFlag, extractTimezone, stripTimezone } from "../../util/schedule"
+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)
+
+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: values.ttl ? values.ttl * 60 * 60 * 1000 * 1_000_000 : undefined,
+ }
+}
+
+export const workspaceToInitialValues = (workspace: TypesGen.Workspace): WorkspaceScheduleFormValues => {
+ const schedule = workspace.autostart_schedule
+ const ttl = workspace.ttl ? workspace.ttl / (1_000_000 * 1000 * 60 * 60) : 0
+
+ if (!schedule) {
+ return {
+ sunday: false,
+ monday: false,
+ tuesday: false,
+ wednesday: false,
+ thursday: false,
+ friday: false,
+ saturday: false,
+ startTime: "",
+ timezone: "",
+ ttl,
+ }
+ }
+
+ const timezone = extractTimezone(schedule, dayjs.tz.guess())
+ const cronString = stripTimezone(schedule)
+
+ // parts has the following format: "mm HH * * dow"
+ const parts = cronString.split(" ")
+
+ // -> we skip month and day-of-month
+ const mm = parts[0]
+ const HH = parts[1]
+ const dow = parts[4]
+
+ const weeklyFlags = dowToWeeklyFlag(dow)
+
+ return {
+ sunday: weeklyFlags[0],
+ monday: weeklyFlags[1],
+ tuesday: weeklyFlags[2],
+ wednesday: weeklyFlags[3],
+ thursday: weeklyFlags[4],
+ friday: weeklyFlags[5],
+ saturday: weeklyFlags[6],
+ startTime: `${HH.padStart(2, "0")}:${mm.padStart(2, "0")}`,
+ timezone,
+ ttl,
+ }
+}
+
+export const WorkspaceSchedulePage: React.FC = () => {
+ const navigate = useNavigate()
+ const { workspace: workspaceQueryParam } = useParams()
+ const workspaceId = firstOrItem(workspaceQueryParam, null)
+ const [scheduleState, scheduleSend] = useMachine(workspaceSchedule)
+ const { formErrors, getWorkspaceError, workspace } = scheduleState.context
+
+ // Get workspace on mount and whenever workspaceId changes.
+ // scheduleSend should not change.
+ useEffect(() => {
+ workspaceId && scheduleSend({ type: "GET_WORKSPACE", workspaceId })
+ }, [workspaceId, scheduleSend])
+
+ if (!workspaceId) {
+ navigate("/workspaces")
+ return null
+ } else if (scheduleState.matches("idle") || scheduleState.matches("gettingWorkspace") || !workspace) {
+ return
+ } else if (scheduleState.matches("error")) {
+ return scheduleSend({ type: "GET_WORKSPACE", workspaceId })} />
+ } else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
+ return (
+ {
+ navigate(`/workspaces/${workspaceId}`)
+ }}
+ onSubmit={(values) => {
+ scheduleSend({
+ type: "SUBMIT_SCHEDULE",
+ autoStart: formValuesToAutoStartRequest(values),
+ ttl: formValuesToTTLRequest(values),
+ })
+ }}
+ />
+ )
+ } else if (scheduleState.matches("submitSuccess")) {
+ navigate(`/workspaces/${workspaceId}`)
+ return
+ } else {
+ // Theoretically impossible - log and bail
+ console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState)
+ navigate("/")
+ return null
+ }
+}
diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts
index eec487f70ca15..108ffdbedf261 100644
--- a/site/src/util/formUtils.ts
+++ b/site/src/util/formUtils.ts
@@ -1,5 +1,5 @@
import { FormikContextType, FormikErrors, getIn } from "formik"
-import { ChangeEvent, ChangeEventHandler, FocusEventHandler } from "react"
+import { ChangeEvent, ChangeEventHandler, FocusEventHandler, ReactNode } from "react"
interface FormHelpers {
name: string
@@ -8,12 +8,12 @@ interface FormHelpers {
id: string
value?: string | number
error: boolean
- helperText?: string
+ helperText?: ReactNode
}
export const getFormHelpers =
(form: FormikContextType, formErrors?: FormikErrors) =>
- (name: keyof T, helperText = ""): FormHelpers => {
+ (name: keyof T, HelperText: ReactNode = ""): FormHelpers => {
if (typeof name !== "string") {
throw new Error(`name must be type of string, instead received '${typeof name}'`)
}
@@ -28,7 +28,7 @@ export const getFormHelpers =
...form.getFieldProps(name),
id: name,
error: touched && Boolean(error),
- helperText: touched ? error || helperText : helperText,
+ helperText: touched ? error || HelperText : HelperText,
}
}
diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts
index d7ed65299cd67..95bc27433e667 100644
--- a/site/src/util/schedule.test.ts
+++ b/site/src/util/schedule.test.ts
@@ -1,4 +1,4 @@
-import { extractTimezone, stripTimezone } from "./schedule"
+import { dowToWeeklyFlag, extractTimezone, stripTimezone, WeeklyFlag } from "./schedule"
describe("util/schedule", () => {
describe("stripTimezone", () => {
@@ -20,4 +20,26 @@ describe("util/schedule", () => {
expect(extractTimezone(input)).toBe(expected)
})
})
+
+ describe("dowToWeeklyFlag", () => {
+ it.each<[string, WeeklyFlag]>([
+ // All days
+ ["*", [true, true, true, true, true, true, true]],
+ ["0-6", [true, true, true, true, true, true, true]],
+ ["1-7", [true, true, true, true, true, true, true]],
+
+ // Single number modulo 7
+ ["3", [false, false, false, true, false, false, false]],
+ ["0", [true, false, false, false, false, false, false]],
+ ["7", [true, false, false, false, false, false, false]],
+ ["8", [false, true, false, false, false, false, false]],
+
+ // Comma-separated Numbers, Ranges and Mixes
+ ["1,3,5", [false, true, false, true, false, true, false]],
+ ["1-2,4-5", [false, true, true, false, true, true, false]],
+ ["1,3-4,6", [false, true, false, true, true, false, true]],
+ ])(`dowToWeeklyFlag(%p) returns %p`, (dow, weeklyFlag) => {
+ expect(dowToWeeklyFlag(dow)).toEqual(weeklyFlag)
+ })
+ })
})
diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts
index 55c26aadfea14..81dd10694f4bb 100644
--- a/site/src/util/schedule.ts
+++ b/site/src/util/schedule.ts
@@ -19,14 +19,83 @@ export const stripTimezone = (raw: string): string => {
/**
* extractTimezone returns a leading timezone from a schedule string if one is
- * specified; otherwise DEFAULT_TIMEZONE
+ * specified; otherwise the specified defaultTZ
*/
-export const extractTimezone = (raw: string): string => {
+export const extractTimezone = (raw: string, defaultTZ = DEFAULT_TIMEZONE): string => {
const matches = raw.match(/CRON_TZ=\S*\s/g)
if (matches && matches.length) {
return matches[0].replace(/CRON_TZ=/, "").trim()
} else {
- return DEFAULT_TIMEZONE
+ return defaultTZ
}
}
+
+/**
+ * WeeklyFlag is an array representing which days of the week are set or flagged
+ *
+ * @remarks
+ *
+ * A WeeklyFlag has an array size of 7 and should never have its size modified.
+ * The 0th index is Sunday
+ * The 6th index is Saturday
+ */
+export type WeeklyFlag = [boolean, boolean, boolean, boolean, boolean, boolean, boolean]
+
+/**
+ * dowToWeeklyFlag converts a dow cron string to a WeeklyFlag array.
+ *
+ * @example
+ *
+ * dowToWeeklyFlag("1") // [false, true, false, false, false, false, false]
+ * dowToWeeklyFlag("1-5") // [false, true, true, true, true, true, false]
+ * dowToWeeklyFlag("1,3-4,6") // [false, true, false, true, true, false, true]
+ */
+export const dowToWeeklyFlag = (dow: string): WeeklyFlag => {
+ if (dow === "*") {
+ return [true, true, true, true, true, true, true]
+ }
+
+ const results: WeeklyFlag = [false, false, false, false, false, false, false]
+
+ const commaSeparatedRangeOrNum = dow.split(",")
+
+ for (const rangeOrNum of commaSeparatedRangeOrNum) {
+ const flags = processRangeOrNum(rangeOrNum)
+
+ flags.forEach((value, idx) => {
+ if (value) {
+ results[idx] = true
+ }
+ })
+ }
+
+ return results
+}
+
+/**
+ * processRangeOrNum is a helper for dowToWeeklyFlag. It processes a range or
+ * number (modulo 7) into a Weeklyflag boolean array.
+ *
+ * @example
+ *
+ * processRangeOrNum("1") // [false, true, false, false, false, false, false]
+ * processRangeOrNum("1-5") // [false, true, true, true, true, true, false]
+ */
+const processRangeOrNum = (rangeOrNum: string): WeeklyFlag => {
+ const result: WeeklyFlag = [false, false, false, false, false, false, false]
+
+ const isRange = /^[0-9]-[0-9]$/.test(rangeOrNum)
+
+ if (isRange) {
+ const [first, last] = rangeOrNum.split("-")
+
+ for (let i = Number(first); i <= Number(last); i++) {
+ result[i % 7] = true
+ }
+ } else {
+ result[Number(rangeOrNum) % 7] = true
+ }
+
+ return result
+}
diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts
new file mode 100644
index 0000000000000..a1ddb58254366
--- /dev/null
+++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts
@@ -0,0 +1,151 @@
+/**
+ * @fileoverview workspaceSchedule is an xstate machine backing a form to CRUD
+ * an individual workspace's schedule.
+ */
+import { assign, createMachine } from "xstate"
+import * as API from "../../api/api"
+import { ApiError, FieldErrors, mapApiErrorToFieldErrors } from "../../api/errors"
+import * as TypesGen from "../../api/typesGenerated"
+import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
+
+export const Language = {
+ errorSubmissionFailed: "Failed to update schedule",
+ errorWorkspaceFetch: "Failed to fetch workspace",
+ successMessage: "Successfully updated workspace schedule.",
+}
+
+export interface WorkspaceScheduleContext {
+ formErrors?: FieldErrors
+ getWorkspaceError?: Error | unknown
+ /**
+ * Each workspace has their own schedule (start and ttl). For this reason, we
+ * re-fetch the workspace to ensure we're up-to-date. As a result, this
+ * machine is partially influenced by workspaceXService.
+ */
+ workspace?: TypesGen.Workspace
+}
+
+export type WorkspaceScheduleEvent =
+ | { type: "GET_WORKSPACE"; workspaceId: string }
+ | {
+ type: "SUBMIT_SCHEDULE"
+ autoStart: TypesGen.UpdateWorkspaceAutostartRequest
+ ttl: TypesGen.UpdateWorkspaceTTLRequest
+ }
+
+export const workspaceSchedule = createMachine(
+ {
+ tsTypes: {} as import("./workspaceScheduleXService.typegen").Typegen0,
+ schema: {
+ context: {} as WorkspaceScheduleContext,
+ events: {} as WorkspaceScheduleEvent,
+ services: {} as {
+ getWorkspace: {
+ data: TypesGen.Workspace
+ }
+ },
+ },
+ id: "workspaceScheduleState",
+ initial: "idle",
+ on: {
+ GET_WORKSPACE: "gettingWorkspace",
+ },
+ states: {
+ idle: {
+ tags: "loading",
+ },
+ gettingWorkspace: {
+ entry: ["clearGetWorkspaceError", "clearContext"],
+ invoke: {
+ src: "getWorkspace",
+ id: "getWorkspace",
+ onDone: {
+ target: "presentForm",
+ actions: ["assignWorkspace"],
+ },
+ onError: {
+ target: "error",
+ actions: ["assignGetWorkspaceError", "displayWorkspaceError"],
+ },
+ },
+ tags: "loading",
+ },
+ presentForm: {
+ on: {
+ SUBMIT_SCHEDULE: "submittingSchedule",
+ },
+ },
+ submittingSchedule: {
+ invoke: {
+ src: "submitSchedule",
+ id: "submitSchedule",
+ onDone: {
+ target: "submitSuccess",
+ actions: "displaySuccess",
+ },
+ onError: {
+ target: "presentForm",
+ actions: ["assignSubmissionError", "displaySubmissionError"],
+ },
+ },
+ tags: "loading",
+ },
+ submitSuccess: {
+ on: {
+ SUBMIT_SCHEDULE: "submittingSchedule",
+ },
+ },
+ error: {
+ on: {
+ GET_WORKSPACE: "gettingWorkspace",
+ },
+ },
+ },
+ },
+ {
+ actions: {
+ assignSubmissionError: assign({
+ formErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
+ }),
+ assignWorkspace: assign({
+ workspace: (_, event) => event.data,
+ }),
+ assignGetWorkspaceError: assign({
+ getWorkspaceError: (_, event) => event.data,
+ }),
+ clearContext: () => {
+ assign({ workspace: undefined })
+ },
+ clearGetWorkspaceError: (context) => {
+ assign({ ...context, getWorkspaceError: undefined })
+ },
+ displayWorkspaceError: () => {
+ displayError(Language.errorWorkspaceFetch)
+ },
+ displaySubmissionError: () => {
+ displayError(Language.errorSubmissionFailed)
+ },
+ displaySuccess: () => {
+ displaySuccess(Language.successMessage)
+ },
+ },
+
+ services: {
+ getWorkspace: async (_, event) => {
+ return await API.getWorkspace(event.workspaceId)
+ },
+ submitSchedule: async (context, event) => {
+ if (!context.workspace?.id) {
+ // This state is theoretically impossible, but helps TS
+ throw new Error("failed to load workspace")
+ }
+
+ // REMARK: These calls are purposefully synchronous because if one
+ // value contradicts the other, we don't want a race condition
+ // on re-submission.
+ await API.putWorkspaceAutostart(context.workspace.id, event.autoStart)
+ await API.putWorkspaceAutostop(context.workspace.id, event.ttl)
+ },
+ },
+ },
+)