feat: edit workspace schedule page (#1701)

Resolves: #1455 
Resolves: #1456

Summary:

Adds a page (accessible from Workspace Schedule section on a workspace) to edit a schedule.

Impact:

General parity with CLI for autostart/autostop: that is you can update your schedule from the UI
This commit is contained in:
G r e y
2022-05-26 12:11:30 -04:00
committed by GitHub
parent 9a70c345c7
commit 7c59ec4a2b
11 changed files with 877 additions and 112 deletions

View File

@ -17,6 +17,7 @@ import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspaceBuildPage } from "./pages/WorkspaceBuildPage/WorkspaceBuildPage"
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
import { WorkspaceSchedulePage } from "./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"
import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsPage"
const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage"))
@ -83,6 +84,14 @@ export const AppRouter: React.FC = () => (
</AuthAndFrame>
}
/>
<Route
path="schedule"
element={
<RequireAuth>
<WorkspaceSchedulePage />
</RequireAuth>
}
/>
</Route>
</Route>

View File

@ -7,6 +7,7 @@ import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import React from "react"
import { Link as RouterLink } from "react-router-dom"
import { Workspace } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { extractTimezone, stripTimezone } from "../../util/schedule"
@ -78,7 +79,9 @@ export const WorkspaceSchedule: React.FC<WorkspaceScheduleProps> = ({ workspace
<span className={styles.scheduleValue}>{Language.autoStopDisplay(workspace)}</span>
</div>
<div>
<Link className={styles.scheduleAction}>{Language.editScheduleLink}</Link>
<Link className={styles.scheduleAction} component={RouterLink} to={`/workspaces/${workspace.id}/schedule`}>
{Language.editScheduleLink}
</Link>
</div>
</Stack>
</div>

View File

@ -13,8 +13,5 @@ const Template: Story<WorkspaceScheduleFormProps> = (args) => <WorkspaceSchedule
export const Example = Template.bind({})
Example.args = {
onCancel: () => action("onCancel"),
onSubmit: () => {
action("onSubmit")
return Promise.resolve()
},
onSubmit: () => action("onSubmit"),
}

View File

@ -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)
})
})

View File

@ -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<void>
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<WorkspaceScheduleFormProps> = ({ onCancel, onSubmit }) => {
export const WorkspaceScheduleForm: React.FC<WorkspaceScheduleFormProps> = ({
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<WorkspaceScheduleFormValues>({
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<WorkspaceScheduleFormValues>(form)
const formHelpers = getFormHelpers<WorkspaceScheduleFormValues>(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 (
<FullPageForm onCancel={onCancel} title="Workspace Schedule">
@ -104,6 +171,7 @@ export const WorkspaceScheduleForm: React.FC<WorkspaceScheduleFormProps> = ({ on
<Stack className={styles.stack}>
<TextField
{...formHelpers("startTime", Language.startTimeHelperText)}
disabled={form.isSubmitting || isLoading}
InputLabelProps={{
shrink: true,
}}
@ -112,102 +180,59 @@ export const WorkspaceScheduleForm: React.FC<WorkspaceScheduleFormProps> = ({ on
variant="standard"
/>
<TextField
{...formHelpers(
"timezone",
<>
Timezone must be a valid{" "}
<Link href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List" target="_blank">
tz database name
</Link>
</>,
)}
disabled={form.isSubmitting || isLoading || !form.values.startTime}
InputLabelProps={{
shrink: true,
}}
label={Language.timezoneLabel}
variant="standard"
/>
<FormControl component="fieldset" error={Boolean(form.errors.monday)}>
<FormLabel className={styles.daysOfWeekLabel} component="legend">
{Language.daysOfWeekLabel}
</FormLabel>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={form.values.sunday}
disabled={!form.values.startTime}
onChange={form.handleChange}
name="sunday"
/>
}
label={Language.daySundayLabel}
/>
<FormControlLabel
control={
<Checkbox
checked={form.values.monday}
disabled={!form.values.startTime}
onChange={form.handleChange}
name="monday"
/>
}
label={Language.dayMondayLabel}
/>
<FormControlLabel
control={
<Checkbox
checked={form.values.tuesday}
disabled={!form.values.startTime}
onChange={form.handleChange}
name="tuesday"
/>
}
label={Language.dayTuesdayLabel}
/>
<FormControlLabel
control={
<Checkbox
checked={form.values.wednesday}
disabled={!form.values.startTime}
onChange={form.handleChange}
name="wednesday"
/>
}
label={Language.dayWednesdayLabel}
/>
<FormControlLabel
control={
<Checkbox
checked={form.values.thursday}
disabled={!form.values.startTime}
onChange={form.handleChange}
name="thursday"
/>
}
label={Language.dayThursdayLabel}
/>
<FormControlLabel
control={
<Checkbox
checked={form.values.friday}
disabled={!form.values.startTime}
onChange={form.handleChange}
name="friday"
/>
}
label={Language.dayFridayLabel}
/>
<FormControlLabel
control={
<Checkbox
checked={form.values.saturday}
disabled={!form.values.startTime}
onChange={form.handleChange}
name="saturday"
/>
}
label={Language.daySaturdayLabel}
/>
{checkboxes.map((checkbox) => (
<FormControlLabel
control={
<Checkbox
checked={checkbox.value}
disabled={!form.values.startTime || form.isSubmitting || isLoading}
onChange={form.handleChange}
name={checkbox.name}
/>
}
key={checkbox.name}
label={checkbox.label}
/>
))}
</FormGroup>
{form.errors.monday && <FormHelperText>{Language.errorNoDayOfWeek}</FormHelperText>}
</FormControl>
<TextField
{...formHelpers("ttl", Language.ttlHelperText)}
inputProps={{ min: 0, step: 30 }}
disabled={form.isSubmitting || isLoading}
inputProps={{ min: 0, step: 1 }}
label={Language.ttlLabel}
type="number"
variant="standard"
/>
<FormFooter onCancel={onCancel} isLoading={form.isSubmitting} />
<FormFooter onCancel={onCancel} isLoading={form.isSubmitting || isLoading} />
</Stack>
</form>
</FullPageForm>

View File

@ -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)
})
})
})

View File

@ -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 <FullScreenLoader />
} else if (scheduleState.matches("error")) {
return <ErrorSummary error={getWorkspaceError} retry={() => scheduleSend({ type: "GET_WORKSPACE", workspaceId })} />
} else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
return (
<WorkspaceScheduleForm
fieldErrors={formErrors}
initialValues={workspaceToInitialValues(workspace)}
isLoading={scheduleState.tags.has("loading")}
onCancel={() => {
navigate(`/workspaces/${workspaceId}`)
}}
onSubmit={(values) => {
scheduleSend({
type: "SUBMIT_SCHEDULE",
autoStart: formValuesToAutoStartRequest(values),
ttl: formValuesToTTLRequest(values),
})
}}
/>
)
} else if (scheduleState.matches("submitSuccess")) {
navigate(`/workspaces/${workspaceId}`)
return <FullScreenLoader />
} else {
// Theoretically impossible - log and bail
console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState)
navigate("/")
return null
}
}

View File

@ -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 =
<T>(form: FormikContextType<T>, formErrors?: FormikErrors<T>) =>
(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,
}
}

View File

@ -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)
})
})
})

View File

@ -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
}

View File

@ -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)
},
},
},
)