mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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"),
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
187
site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx
Normal file
187
site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx
Normal 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
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
Reference in New Issue
Block a user