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