From 58ecefa121312f085bb60a11dc77c9b306c058b6 Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 24 May 2022 05:51:40 +0000 Subject: [PATCH 01/26] feat: edit workspace schedule page --- .../WorkspaceStats/WorkspaceScheduleForm.tsx | 3 + .../WorkspaceSchedulePage.tsx | 88 +++++++++++ .../workspaceScheduleXService.ts | 146 ++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx create mode 100644 site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index 1603e28e7e8e1..2f088d9398dfa 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -32,6 +32,8 @@ export const Language = { export interface WorkspaceScheduleFormProps { onCancel: () => void + + // TODO(Grey): un-promisfy and adding isSubmitting prop onSubmit: (values: WorkspaceScheduleFormValues) => Promise } @@ -73,6 +75,7 @@ export const validationSchema = Yup.object({ friday: Yup.boolean(), saturday: Yup.boolean(), + // TODO(Grey): Add validation that the string is "" or "HH:mm" (24 hours) startTime: Yup.string(), ttl: Yup.number().min(0).integer(), }) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx new file mode 100644 index 0000000000000..1c100ff89a177 --- /dev/null +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -0,0 +1,88 @@ +import { useMachine } from "@xstate/react" +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 { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService" + +// TODO(Grey): Test before opening PR from draft +export const formValuesToAutoStartRequest = ( + values: WorkspaceScheduleFormValues, +): TypesGen.UpdateWorkspaceAutostartRequest => { + if (!values.startTime) { + return { + schedule: "", + } + } + + // TODO(Grey): Fill in + return { + schedule: "9 30 * * 1-5", + } +} + +export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): TypesGen.UpdateWorkspaceTTLRequest => { + if (!values.ttl) { + return { + ttl: 0, // TODO(Grey): Verify with Cian whether 0 or null is better to send + } + } + + // TODO(Grey): Fill in + return { + ttl: 0, + } +} + +// TODO(Grey): React testing library for this +export const WorkspaceSchedulePage: React.FC = () => { + const navigate = useNavigate() + const { workspace: workspaceQueryParam } = useParams() + const workspaceId = firstOrItem(workspaceQueryParam, null) + + // TODO(Grey): Consume the formSubmissionErrors in WorkspaceScheduleForm + const [scheduleState, scheduleSend] = useMachine(workspaceSchedule) + const { 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("error")) { + return scheduleSend({ type: "GET_WORKSPACE", workspaceId })} /> + } else if (!workspace) { + return + } else { + return ( + { + navigate(`/workspaces/${workspaceId}`) + }} + onSubmit={(values) => { + scheduleSend({ + type: "SUBMIT_SCHEDULE", + autoStart: formValuesToAutoStartRequest(values), + ttl: formValuesToTTLRequest(values), + }) + + // TODO(Grey): Remove this after onSubmit is un-promisified + // TODO(Grey): navigation logic + return Promise.resolve() + }} + /> + ) + } +} diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts new file mode 100644 index 0000000000000..3ba50b2d7a87d --- /dev/null +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts @@ -0,0 +1,146 @@ +/** + * @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: "idle", + actions: "displaySuccess", + }, + onError: { + target: "presentForm", + actions: ["assignSubmissionError", "displaySubmissionError"], + }, + }, + tags: "loading", + }, + 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) + }, + }, + }, +) From 3f73a6cef72cc152fafb668541dcfab123d85423 Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 24 May 2022 05:58:15 +0000 Subject: [PATCH 02/26] fixup! feat: edit workspace schedule page --- site/src/AppRouter.tsx | 9 +++ .../WorkspaceSchedule/WorkspaceSchedule.tsx | 76 ++++++++++--------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index b11a33d3e39cc..1ed08d2113700 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -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")) @@ -73,6 +74,14 @@ export const AppRouter: React.FC = () => ( } /> + + + + } + /> diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index 8e3b33af0f52d..45ae726752d36 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -15,42 +15,44 @@ import { Stack } from "../Stack/Stack" dayjs.extend(duration) dayjs.extend(relativeTime) -const autoStartLabel = (schedule: string): string => { - const prefix = "Start" - - if (schedule) { - return `${prefix} (${extractTimezone(schedule)})` - } else { - return prefix - } -} - -const autoStartDisplay = (schedule: string): string => { - if (schedule) { - return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) - } - return "Manual" -} +const Language = { + autoStartDisplay: (schedule: string): string => { + if (schedule) { + return cronstrue.toString(stripTimezone(schedule), { throwExceptionOnParseError: false }) + } + return "Manual" + }, + autoStartLabel: (schedule: string): string => { + const prefix = "Start" -const autoStopDisplay = (workspace: Workspace): string => { - const latest = workspace.latest_build + if (schedule) { + return `${prefix} (${extractTimezone(schedule)})` + } else { + return prefix + } + }, + autoStopDisplay: (workspace: Workspace): string => { + const latest = workspace.latest_build - if (!workspace.ttl || workspace.ttl < 1) { - return "Manual" - } + if (!workspace.ttl || workspace.ttl < 1) { + return "Manual" + } - if (latest.transition === "start") { - const now = dayjs() - const updatedAt = dayjs(latest.updated_at) - const deadline = updatedAt.add(workspace.ttl / 1_000_000, "ms") - if (now.isAfter(deadline)) { - return "Workspace is shutting down now" + if (latest.transition === "start") { + const now = dayjs() + const updatedAt = dayjs(latest.updated_at) + const deadline = updatedAt.add(workspace.ttl / 1_000_000, "ms") + if (now.isAfter(deadline)) { + return "Workspace is shutting down now" + } + return now.to(deadline) } - return now.to(deadline) - } - const duration = dayjs.duration(workspace.ttl / 1_000_000, "milliseconds") - return `${duration.humanize()} after start` + const duration = dayjs.duration(workspace.ttl / 1_000_000, "milliseconds") + return `${duration.humanize()} after start` + }, + editScheduleLink: "Edit schedule", + schedule: "Schedule", } export interface WorkspaceScheduleProps { @@ -65,18 +67,20 @@ export const WorkspaceSchedule: React.FC = ({ workspace - Schedule + {Language.schedule}
- {autoStartLabel(workspace.autostart_schedule)} - {autoStartDisplay(workspace.autostart_schedule)} + {Language.autoStartLabel(workspace.autostart_schedule)} + {Language.autoStartDisplay(workspace.autostart_schedule)}
Shutdown - {autoStopDisplay(workspace)} + {Language.autoStopDisplay(workspace)}
- Edit schedule + + {Language.editScheduleLink} +
From ecc6792c3e1a322e963829f8e6617cfa4c92740b Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 24 May 2022 19:11:05 +0000 Subject: [PATCH 03/26] remove promise --- .../WorkspaceStats/WorkspaceScheduleForm.stories.tsx | 5 +---- .../components/WorkspaceStats/WorkspaceScheduleForm.tsx | 9 ++++----- .../WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 4 +--- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx index 74fc7b921ce75..9b7f43e0850f2 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.stories.tsx @@ -13,8 +13,5 @@ const Template: Story = (args) => action("onCancel"), - onSubmit: () => { - action("onSubmit") - return Promise.resolve() - }, + onSubmit: () => action("onSubmit"), } diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index 2f088d9398dfa..0ced4dd3f5103 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -31,10 +31,9 @@ export const Language = { } export interface WorkspaceScheduleFormProps { + isLoading: boolean onCancel: () => void - - // TODO(Grey): un-promisfy and adding isSubmitting prop - onSubmit: (values: WorkspaceScheduleFormValues) => Promise + onSubmit: (values: WorkspaceScheduleFormValues) => void } export interface WorkspaceScheduleFormValues { @@ -80,7 +79,7 @@ export const validationSchema = Yup.object({ ttl: Yup.number().min(0).integer(), }) -export const WorkspaceScheduleForm: React.FC = ({ onCancel, onSubmit }) => { +export const WorkspaceScheduleForm: React.FC = ({ isLoading, onCancel, onSubmit }) => { const styles = useStyles() const form = useFormik({ @@ -210,7 +209,7 @@ export const WorkspaceScheduleForm: React.FC = ({ on variant="standard" /> - + diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 1c100ff89a177..d21f96162d5e1 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -68,6 +68,7 @@ export const WorkspaceSchedulePage: React.FC = () => { } else { return ( { navigate(`/workspaces/${workspaceId}`) }} @@ -77,10 +78,7 @@ export const WorkspaceSchedulePage: React.FC = () => { autoStart: formValuesToAutoStartRequest(values), ttl: formValuesToTTLRequest(values), }) - - // TODO(Grey): Remove this after onSubmit is un-promisified // TODO(Grey): navigation logic - return Promise.resolve() }} /> ) From d61a332c53bf60fc8c5c020622b813c2f8b76041 Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 24 May 2022 21:45:50 +0000 Subject: [PATCH 04/26] refactor to map + add loading/disabled --- .../WorkspaceStats/WorkspaceScheduleForm.tsx | 104 +++++------------- .../WorkspaceSchedulePage.tsx | 6 +- 2 files changed, 29 insertions(+), 81 deletions(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index 0ced4dd3f5103..d8d337de5ef78 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -100,12 +100,23 @@ export const WorkspaceScheduleForm: React.FC = ({ is }) const formHelpers = getFormHelpers(form) + 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 (
= ({ is - - } - label={Language.daySundayLabel} - /> - - } - label={Language.dayMondayLabel} - /> - - } - label={Language.dayTuesdayLabel} - /> - - } - label={Language.dayWednesdayLabel} - /> - - } - label={Language.dayThursdayLabel} - /> - - } - label={Language.dayFridayLabel} - /> - - } - label={Language.daySaturdayLabel} - /> + {checkboxes.map((checkbox) => ( + + } + key={checkbox.name} + label={checkbox.label} + /> + ))} + {form.errors.monday && {Language.errorNoDayOfWeek}} { const [scheduleState, scheduleSend] = useMachine(workspaceSchedule) const { getWorkspaceError, workspace } = scheduleState.context - /** - * Get workspace on mount and whenever workspaceId changes (scheduleSend - * should not change). - */ + // Get workspace on mount and whenever workspaceId changes. + // scheduleSend should not change. useEffect(() => { workspaceId && scheduleSend({ type: "GET_WORKSPACE", workspaceId }) }, [workspaceId, scheduleSend]) From f60f59b8360e337aef6e4c3b10338adb56924aaa Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 24 May 2022 22:23:57 +0000 Subject: [PATCH 05/26] time validation --- .../WorkspaceScheduleForm.test.ts | 40 ++++++++++++++++++- .../WorkspaceStats/WorkspaceScheduleForm.tsx | 17 +++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts index cdd133dfc7932..927a0a4dc9bbf 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts @@ -32,7 +32,7 @@ describe("validationSchema", () => { }) it("disallows ttl to be negative", () => { - const values = { + const values: WorkspaceScheduleFormValues = { ...valid, ttl: -1, } @@ -41,7 +41,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 +54,40 @@ describe("validationSchema", () => { const validate = () => validationSchema.validateSync(values) expect(validate).toThrowError(Language.errorNoDayOfWeek) }) + + 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 13:01", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + startTime: "13: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) + }) }) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index d8d337de5ef78..ef385f29d6fa9 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -16,6 +16,7 @@ import { Stack } from "../Stack/Stack" export const Language = { errorNoDayOfWeek: "Must set at least one day of week", + errorTime: "Invalid time", daysOfWeekLabel: "Days of Week", daySundayLabel: "Sunday", dayMondayLabel: "Monday", @@ -74,8 +75,20 @@ export const validationSchema = Yup.object({ friday: Yup.boolean(), saturday: Yup.boolean(), - // TODO(Grey): Add validation that the string is "" or "HH:mm" (24 hours) - 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 <= 12 && mm >= 0 && mm <= 59 + } + }), ttl: Yup.number().min(0).integer(), }) From 406d465a75c02645ab238e7ee19fbb7ec1090894 Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 24 May 2022 23:12:00 +0000 Subject: [PATCH 06/26] more tests --- .../WorkspaceScheduleForm.test.ts | 13 +- .../WorkspaceStats/WorkspaceScheduleForm.tsx | 2 +- .../WorkspaceSchedulePage.test.tsx | 146 ++++++++++++++++++ .../WorkspaceSchedulePage.tsx | 60 +++++-- 4 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts index 927a0a4dc9bbf..e7a8f113dc68c 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts @@ -55,6 +55,15 @@ describe("validationSchema", () => { 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, @@ -73,10 +82,10 @@ describe("validationSchema", () => { expect(validate).toThrowError(Language.errorTime) }) - it("disallows an invalid startTime 13:01", () => { + it("disallows an invalid startTime 24:01", () => { const values: WorkspaceScheduleFormValues = { ...valid, - startTime: "13:01", + startTime: "24:01", } const validate = () => validationSchema.validateSync(values) expect(validate).toThrowError(Language.errorTime) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index ef385f29d6fa9..66beebfe21b51 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -86,7 +86,7 @@ export const validationSchema = Yup.object({ const parts = value.split(":") const HH = Number(parts[0]) const mm = Number(parts[1]) - return HH >= 0 && HH <= 12 && mm >= 0 && mm <= 59 + return HH >= 0 && HH <= 23 && mm >= 0 && mm <= 59 } }), ttl: Yup.number().min(0).integer(), diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx new file mode 100644 index 0000000000000..224b9bc7111db --- /dev/null +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -0,0 +1,146 @@ +import * as TypesGen from "../../api/typesGenerated" +import { WorkspaceScheduleFormValues } from "../../components/WorkspaceStats/WorkspaceScheduleForm" +import { formValuesToAutoStartRequest, formValuesToTTLRequest } from "./WorkspaceSchedulePage" + +const validValues: WorkspaceScheduleFormValues = { + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + startTime: "09:30", + 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: "", + ttl: 0, + }, + { + schedule: "", + }, + ], + [ + // Single day + { + sunday: true, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + startTime: "16:20", + ttl: 120, + }, + { + schedule: "20 16 * * 0", + }, + ], + [ + // Standard 1-5 case + { + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + startTime: "09:30", + ttl: 120, + }, + { + schedule: "30 09 * * 1-5", + }, + ], + [ + // Everyday + { + sunday: true, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: true, + startTime: "09:00", + ttl: 60 * 8, + }, + { + schedule: "00 09 * * 1-7", + }, + ], + [ + // Mon, Wed, Fri Evenings + { + sunday: false, + monday: true, + tuesday: false, + wednesday: true, + thursday: false, + friday: true, + saturday: false, + startTime: "16:20", + 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: 120, + }, + { + ttl: 7_200_000_000_000, + }, + ], + [ + // 8 hours = 2.88e+13 case + { + ...validValues, + ttl: 60 * 8, + }, + { + ttl: 28_800_000_000_000, + }, + ], + ])(`formValuesToTTLRequest(%p) returns %p`, (values, request) => { + expect(formValuesToTTLRequest(values)).toEqual(request) + }) + }) +}) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 58e715937940d..e3a8a6c0cd481 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -11,7 +11,6 @@ import { import { firstOrItem } from "../../util/array" import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService" -// TODO(Grey): Test before opening PR from draft export const formValuesToAutoStartRequest = ( values: WorkspaceScheduleFormValues, ): TypesGen.UpdateWorkspaceAutostartRequest => { @@ -21,22 +20,61 @@ export const formValuesToAutoStartRequest = ( } } - // TODO(Grey): Fill in - return { - schedule: "9 30 * * 1-5", - } -} + const [HH, mm] = values.startTime.split(":") -export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): TypesGen.UpdateWorkspaceTTLRequest => { - if (!values.ttl) { + const makeCronString = (dow: string) => `${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 throw to comma-separation + if (isEveryDay) { return { - ttl: 0, // TODO(Grey): Verify with Cian whether 0 or null is better to send + schedule: makeCronString("1-7"), + } + } 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), } } +} - // TODO(Grey): Fill in +export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): TypesGen.UpdateWorkspaceTTLRequest => { return { - ttl: 0, + // minutes to nanoseconds + ttl: values.ttl ? values.ttl * 60 * 1000 * 1_000_000 : undefined, } } From a6dff9d5155e9ec9839c274a226509eda77e012f Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 24 May 2022 19:54:54 -0400 Subject: [PATCH 07/26] Update site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx --- site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index e3a8a6c0cd481..83d5c2921eabc 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -46,7 +46,7 @@ export const formValuesToAutoStartRequest = ( !values.saturday && !values.sunday - // Handle special cases, falling throw to comma-separation + // Handle special cases, falling through to comma-separation if (isEveryDay) { return { schedule: makeCronString("1-7"), From 7a14859d3e0964f2297597b1b8adc81faf5b2a34 Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 25 May 2022 19:23:57 +0000 Subject: [PATCH 08/26] fix routing --- site/src/AppRouter.tsx | 4 ++-- site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index c1413c22c56a6..f4a055d12862d 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -87,9 +87,9 @@ export const AppRouter: React.FC = () => ( + - + } /> diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index 45ae726752d36..b39ed857c8c9f 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -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,7 @@ export const WorkspaceSchedule: React.FC = ({ workspace {Language.autoStopDisplay(workspace)}
- + {Language.editScheduleLink}
From 1158645fa6877bd243ce4f8c501999987dbe4928 Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 25 May 2022 19:25:18 +0000 Subject: [PATCH 09/26] handle formErrors --- .../WorkspaceStats/WorkspaceScheduleForm.tsx | 11 +++++++++-- .../WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 6 ++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index 66beebfe21b51..99708b30c87d9 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -9,6 +9,7 @@ import TextField from "@material-ui/core/TextField" 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" @@ -32,6 +33,7 @@ export const Language = { } export interface WorkspaceScheduleFormProps { + fieldErrors?: FieldErrors isLoading: boolean onCancel: () => void onSubmit: (values: WorkspaceScheduleFormValues) => void @@ -92,7 +94,12 @@ export const validationSchema = Yup.object({ ttl: Yup.number().min(0).integer(), }) -export const WorkspaceScheduleForm: React.FC = ({ isLoading, onCancel, onSubmit }) => { +export const WorkspaceScheduleForm: React.FC = ({ + fieldErrors, + isLoading, + onCancel, + onSubmit, +}) => { const styles = useStyles() const form = useFormik({ @@ -111,7 +118,7 @@ export const WorkspaceScheduleForm: React.FC = ({ is onSubmit, validationSchema, }) - const formHelpers = getFormHelpers(form) + const formHelpers = getFormHelpers(form, fieldErrors) const checkboxes: Array<{ value: boolean; name: string; label: string }> = [ { value: form.values.sunday, name: "sunday", label: Language.daySundayLabel }, diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 83d5c2921eabc..f1f4da70417cd 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -78,15 +78,12 @@ export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): Typ } } -// TODO(Grey): React testing library for this export const WorkspaceSchedulePage: React.FC = () => { const navigate = useNavigate() const { workspace: workspaceQueryParam } = useParams() const workspaceId = firstOrItem(workspaceQueryParam, null) - - // TODO(Grey): Consume the formSubmissionErrors in WorkspaceScheduleForm const [scheduleState, scheduleSend] = useMachine(workspaceSchedule) - const { getWorkspaceError, workspace } = scheduleState.context + const { formErrors, getWorkspaceError, workspace } = scheduleState.context // Get workspace on mount and whenever workspaceId changes. // scheduleSend should not change. @@ -104,6 +101,7 @@ export const WorkspaceSchedulePage: React.FC = () => { } else { return ( { navigate(`/workspaces/${workspaceId}`) From 7a050dbae1d636b5f5a266e832fa656b614813f9 Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 25 May 2022 19:25:31 +0000 Subject: [PATCH 10/26] finalize machine --- .../WorkspaceSchedulePage.tsx | 15 +++++++++++---- .../workspaceScheduleXService.ts | 7 ++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index f1f4da70417cd..ab2cc1790a507 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -94,11 +94,11 @@ export const WorkspaceSchedulePage: React.FC = () => { if (!workspaceId) { navigate("/workspaces") return null + } else if (scheduleState.matches("idle") || scheduleState.matches("gettingWorkspace") || !workspace) { + return } else if (scheduleState.matches("error")) { return scheduleSend({ type: "GET_WORKSPACE", workspaceId })} /> - } else if (!workspace) { - return - } else { + } else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) { return ( { autoStart: formValuesToAutoStartRequest(values), ttl: formValuesToTTLRequest(values), }) - // TODO(Grey): navigation logic }} /> ) + } else if (scheduleState.matches("submitSuccess")) { + navigate(`/workspaces/${workspaceId}`) + return + } else { + // Theoretically impossible - log and bail + console.error("WorkspaceSchedulePage: unknown state :: ", scheduleState) + navigate("/") + return null } } diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts index 3ba50b2d7a87d..a1ddb58254366 100644 --- a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts @@ -80,7 +80,7 @@ export const workspaceSchedule = createMachine( src: "submitSchedule", id: "submitSchedule", onDone: { - target: "idle", + target: "submitSuccess", actions: "displaySuccess", }, onError: { @@ -90,6 +90,11 @@ export const workspaceSchedule = createMachine( }, tags: "loading", }, + submitSuccess: { + on: { + SUBMIT_SCHEDULE: "submittingSchedule", + }, + }, error: { on: { GET_WORKSPACE: "gettingWorkspace", From 11669249a0ba70c1ee91de73d07fc4997ecb4f46 Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 25 May 2022 20:25:38 +0000 Subject: [PATCH 11/26] add timezone --- .../WorkspaceScheduleForm.test.ts | 11 ++++ .../WorkspaceStats/WorkspaceScheduleForm.tsx | 51 +++++++++++++++++++ .../WorkspaceSchedulePage.test.tsx | 10 +++- .../WorkspaceSchedulePage.tsx | 5 +- .../{formUtils.test.ts => formUtils.test.tsx} | 0 site/src/util/{formUtils.ts => formUtils.tsx} | 12 ++--- 6 files changed, 80 insertions(+), 9 deletions(-) rename site/src/util/{formUtils.test.ts => formUtils.test.tsx} (100%) rename site/src/util/{formUtils.ts => formUtils.tsx} (73%) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts index e7a8f113dc68c..b1ffe7795b800 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.test.ts @@ -10,6 +10,7 @@ const valid: WorkspaceScheduleFormValues = { saturday: false, startTime: "09:30", + timezone: "Canada/Eastern", ttl: 120, } @@ -25,6 +26,7 @@ describe("validationSchema", () => { saturday: false, startTime: "", + timezone: "", ttl: 0, } const validate = () => validationSchema.validateSync(values) @@ -99,4 +101,13 @@ describe("validationSchema", () => { const validate = () => validationSchema.validateSync(values) expect(validate).toThrowError(Language.errorTime) }) + + it("disallows an invalid timezone Canada/North", () => { + const values: WorkspaceScheduleFormValues = { + ...valid, + timezone: "Canada/North", + } + const validate = () => validationSchema.validateSync(values) + expect(validate).toThrowError(Language.errorTimezone) + }) }) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index 99708b30c87d9..d97e478ceb450 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -4,8 +4,12 @@ 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" @@ -15,9 +19,16 @@ 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: "Invalid time", + errorTimezone: "Invalid timezone", daysOfWeekLabel: "Days of Week", daySundayLabel: "Sunday", dayMondayLabel: "Monday", @@ -28,6 +39,7 @@ export const Language = { daySaturdayLabel: "Saturday", startTimeLabel: "Start time", startTimeHelperText: "Your workspace will automatically start at this time.", + timezoneLabel: "Timezone", ttlLabel: "Runtime (minutes)", ttlHelperText: "Your workspace will automatically shutdown after the runtime.", } @@ -49,6 +61,7 @@ export interface WorkspaceScheduleFormValues { saturday: boolean startTime: string + timezone: string ttl: number } @@ -91,6 +104,25 @@ export const validationSchema = Yup.object({ 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(), }) @@ -113,6 +145,7 @@ export const WorkspaceScheduleForm: React.FC = ({ saturday: false, startTime: "09:30", + timezone: "", ttl: 120, }, onSubmit, @@ -145,6 +178,24 @@ export const WorkspaceScheduleForm: React.FC = ({ variant="standard" /> + + Timezone must be a valid{" "} + + tz database name + + , + )} + disabled={form.isSubmitting || isLoading || !form.values.startTime} + InputLabelProps={{ + shrink: true, + }} + label={Language.timezoneLabel} + variant="standard" + /> + {Language.daysOfWeekLabel} diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 224b9bc7111db..5d21b59a39f48 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -11,6 +11,7 @@ const validValues: WorkspaceScheduleFormValues = { friday: true, saturday: false, startTime: "09:30", + timezone: "Canada/Eastern", ttl: 120, } @@ -28,6 +29,7 @@ describe("WorkspaceSchedulePage", () => { friday: false, saturday: false, startTime: "", + timezone: "", ttl: 0, }, { @@ -45,10 +47,11 @@ describe("WorkspaceSchedulePage", () => { friday: false, saturday: false, startTime: "16:20", + timezone: "Canada/Eastern", ttl: 120, }, { - schedule: "20 16 * * 0", + schedule: "CRON_TZ=Canada/Eastern 20 16 * * 0", }, ], [ @@ -62,10 +65,11 @@ describe("WorkspaceSchedulePage", () => { friday: true, saturday: false, startTime: "09:30", + timezone: "America/Central", ttl: 120, }, { - schedule: "30 09 * * 1-5", + schedule: "CRON_TZ=America/Central 30 09 * * 1-5", }, ], [ @@ -79,6 +83,7 @@ describe("WorkspaceSchedulePage", () => { friday: true, saturday: true, startTime: "09:00", + timezone: "", ttl: 60 * 8, }, { @@ -96,6 +101,7 @@ describe("WorkspaceSchedulePage", () => { friday: true, saturday: false, startTime: "16:20", + timezone: "", ttl: 60 * 3, }, { diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index ab2cc1790a507..6bcedeb468bac 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -22,7 +22,10 @@ export const formValuesToAutoStartRequest = ( const [HH, mm] = values.startTime.split(":") - const makeCronString = (dow: string) => `${mm} ${HH} * * ${dow}` + // 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, diff --git a/site/src/util/formUtils.test.ts b/site/src/util/formUtils.test.tsx similarity index 100% rename from site/src/util/formUtils.test.ts rename to site/src/util/formUtils.test.tsx diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.tsx similarity index 73% rename from site/src/util/formUtils.ts rename to site/src/util/formUtils.tsx index eec487f70ca15..e4a2e5d71a73b 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.tsx @@ -1,5 +1,5 @@ import { FormikContextType, FormikErrors, getIn } from "formik" -import { ChangeEvent, ChangeEventHandler, FocusEventHandler } from "react" +import React, { ChangeEvent, ChangeEventHandler, FocusEventHandler } from "react" interface FormHelpers { name: string @@ -8,12 +8,12 @@ interface FormHelpers { id: string value?: string | number error: boolean - helperText?: string + helperText?: React.ReactNode } export const getFormHelpers = - (form: FormikContextType, formErrors?: FormikErrors) => - (name: keyof T, helperText = ""): FormHelpers => { + (form: FormikContextType, formErrors?: FormikErrors) => + (name: keyof T, HelperText: React.ReactNode = ""): FormHelpers => { if (typeof name !== "string") { throw new Error(`name must be type of string, instead received '${typeof name}'`) } @@ -28,12 +28,12 @@ export const getFormHelpers = ...form.getFieldProps(name), id: name, error: touched && Boolean(error), - helperText: touched ? error || helperText : helperText, + helperText: touched ? error || HelperText : HelperText, } } export const onChangeTrimmed = - (form: FormikContextType) => + (form: FormikContextType) => (event: ChangeEvent): void => { event.target.value = event.target.value.trim() form.handleChange(event) From 947b4a09d650637ff07d1c171a4c9811555ef796 Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 25 May 2022 20:29:02 +0000 Subject: [PATCH 12/26] switch to TTL (hours) --- site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx | 4 ++-- .../WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx | 4 ++-- .../src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index d97e478ceb450..f3d96b39cc5ad 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -40,8 +40,8 @@ export const Language = { startTimeLabel: "Start time", startTimeHelperText: "Your workspace will automatically start at this time.", timezoneLabel: "Timezone", - ttlLabel: "Runtime (minutes)", - ttlHelperText: "Your workspace will automatically shutdown after the runtime.", + ttlLabel: "TTL (hours)", + ttlHelperText: "Your workspace will automatically shutdown after the TTL.", } export interface WorkspaceScheduleFormProps { diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 5d21b59a39f48..10084c5116cd1 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -129,7 +129,7 @@ describe("WorkspaceSchedulePage", () => { // 2 Hours = 7.2e+12 case { ...validValues, - ttl: 120, + ttl: 2, }, { ttl: 7_200_000_000_000, @@ -139,7 +139,7 @@ describe("WorkspaceSchedulePage", () => { // 8 hours = 2.88e+13 case { ...validValues, - ttl: 60 * 8, + ttl: 8, }, { ttl: 28_800_000_000_000, diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 6bcedeb468bac..22db2aa785215 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -77,7 +77,7 @@ export const formValuesToAutoStartRequest = ( export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): TypesGen.UpdateWorkspaceTTLRequest => { return { // minutes to nanoseconds - ttl: values.ttl ? values.ttl * 60 * 1000 * 1_000_000 : undefined, + ttl: values.ttl ? values.ttl * 60 * 60 * 1000 * 1_000_000 : undefined, } } From 4764e5cba4315f84beaa288cb112a871c09a6cad Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 25 May 2022 20:32:26 +0000 Subject: [PATCH 13/26] adjust ttl --- site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index f3d96b39cc5ad..d677a88ed22a3 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -146,7 +146,7 @@ export const WorkspaceScheduleForm: React.FC = ({ startTime: "09:30", timezone: "", - ttl: 120, + ttl: 5, }, onSubmit, validationSchema, @@ -224,7 +224,7 @@ export const WorkspaceScheduleForm: React.FC = ({ Date: Thu, 26 May 2022 05:33:11 +0000 Subject: [PATCH 14/26] initialization --- .../WorkspaceSchedulePage.test.tsx | 72 +++++++++++++++++- .../WorkspaceSchedulePage.tsx | 46 ++++++++++++ site/src/util/schedule.test.ts | 23 +++++- site/src/util/schedule.ts | 75 ++++++++++++++++++- 4 files changed, 211 insertions(+), 5 deletions(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 10084c5116cd1..594cbc502fd02 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -1,6 +1,7 @@ import * as TypesGen from "../../api/typesGenerated" import { WorkspaceScheduleFormValues } from "../../components/WorkspaceStats/WorkspaceScheduleForm" -import { formValuesToAutoStartRequest, formValuesToTTLRequest } from "./WorkspaceSchedulePage" +import * as Mocks from "../../testHelpers/entities" +import { formValuesToAutoStartRequest, formValuesToTTLRequest, workspaceToInitialValues } from "./WorkspaceSchedulePage" const validValues: WorkspaceScheduleFormValues = { sunday: false, @@ -149,4 +150,73 @@ describe("WorkspaceSchedulePage", () => { 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, + }, + ], + + // Basic case: 9:30 1-5 UTC running for 2 hours + [ + { + ...Mocks.MockWorkspace, + autostart_schedule: "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: "", + ttl: 2, + }, + ], + + // Complex case: 4:20 1 3-4 6 Canada/Eastern for 8 hours + [ + { + ...Mocks.MockWorkspace, + autostart_schedule: "CRON_TZ=Canada/Eastern 20 16 * * 1,3-4,6", + ttl: 28_800_000_000_000, + }, + { + sunday: false, + monday: true, + tuesday: false, + wednesday: true, + thursday: true, + friday: false, + saturday: true, + startTime: "16:20", + timezone: "Canada/Eastern", + ttl: 8, + }, + ], + ])(`workspaceToInitialValues(%p) returns %p`, (workspace, formValues) => { + expect(workspaceToInitialValues(workspace)).toEqual(formValues) + }) + }) }) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 22db2aa785215..ce58f19ac41cc 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -9,6 +9,7 @@ import { WorkspaceScheduleFormValues, } from "../../components/WorkspaceStats/WorkspaceScheduleForm" import { firstOrItem } from "../../util/array" +import { dowToWeeklyFlag, extractTimezone, stripTimezone } from "../../util/schedule" import { workspaceSchedule } from "../../xServices/workspaceSchedule/workspaceScheduleXService" export const formValuesToAutoStartRequest = ( @@ -81,6 +82,51 @@ export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): Typ } } +export const workspaceToInitialValues = (workspace: TypesGen.Workspace): WorkspaceScheduleFormValues => { + const schedule = workspace.autostart_schedule + + if (!schedule) { + return { + sunday: false, + monday: false, + tuesday: false, + wednesday: false, + thursday: false, + friday: false, + saturday: false, + startTime: "", + timezone: "", + ttl: 0, + } + } + + const timezone = extractTimezone(schedule, "") + 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: workspace.ttl ? workspace.ttl / (1_000_000 * 1000 * 60 * 60) : 0, + } +} + export const WorkspaceSchedulePage: React.FC = () => { const navigate = useNavigate() const { workspace: workspaceQueryParam } = useParams() diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts index d7ed65299cd67..a633a99fadfe1 100644 --- a/site/src/util/schedule.test.ts +++ b/site/src/util/schedule.test.ts @@ -1,4 +1,4 @@ -import { extractTimezone, stripTimezone } from "./schedule" +import { dowToWeeklyFlag, extractTimezone, stripTimezone, WeeklyFlag } from "./schedule" describe("util/schedule", () => { describe("stripTimezone", () => { @@ -20,4 +20,25 @@ describe("util/schedule", () => { expect(extractTimezone(input)).toBe(expected) }) }) + + describe("dowToWeeklyFlag", () => { + it.each<[string, WeeklyFlag]>([ + // All days + ["*", [true, true, true, true, true, true, true]], + ["1-7", [true, true, true, true, true, true, true]], + + // Single number modulo 7 + ["3", [false, false, false, true, false, false, false]], + ["0", [true, false, false, false, false, false, false]], + ["7", [true, false, false, false, false, false, false]], + ["8", [false, true, false, false, false, false, false]], + + // Comma-separated Numbers, Ranges and Mixes + ["1,3,5", [false, true, false, true, false, true, false]], + ["1-2,4-5", [false, true, true, false, true, true, false]], + ["1,3-4,6", [false, true, false, true, true, false, true]], + ])(`dowToWeeklyFlag(%p) returns %p`, (dow, weeklyFlag) => { + expect(dowToWeeklyFlag(dow)).toEqual(weeklyFlag) + }) + }) }) diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index 55c26aadfea14..72da5fbee7442 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -19,14 +19,83 @@ export const stripTimezone = (raw: string): string => { /** * extractTimezone returns a leading timezone from a schedule string if one is - * specified; otherwise DEFAULT_TIMEZONE + * specified; otherwise the specified defaultTZ */ -export const extractTimezone = (raw: string): string => { +export const extractTimezone = (raw: string, defaultTZ = DEFAULT_TIMEZONE): string => { const matches = raw.match(/CRON_TZ=\S*\s/g) if (matches && matches.length) { return matches[0].replace(/CRON_TZ=/, "").trim() } else { - return DEFAULT_TIMEZONE + return defaultTZ } } + +/** + * WeeklyFlag is an array represnting 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 +} From 747b52fe1dd126ffce96386c8e3b4f8cf97125f4 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 05:33:26 +0000 Subject: [PATCH 15/26] fixup! initialization --- .../WorkspaceStats/WorkspaceScheduleForm.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index d677a88ed22a3..ef78110e736d9 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -46,6 +46,7 @@ export const Language = { export interface WorkspaceScheduleFormProps { fieldErrors?: FieldErrors + initialValues?: WorkspaceScheduleFormValues isLoading: boolean onCancel: () => void onSubmit: (values: WorkspaceScheduleFormValues) => void @@ -128,6 +129,19 @@ export const validationSchema = Yup.object({ export const WorkspaceScheduleForm: React.FC = ({ fieldErrors, + initialValues = { + sunday: false, + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + + startTime: "09:30", + timezone: "", + ttl: 5, + }, isLoading, onCancel, onSubmit, @@ -135,19 +149,7 @@ export const WorkspaceScheduleForm: React.FC = ({ const styles = useStyles() const form = useFormik({ - initialValues: { - sunday: false, - monday: true, - tuesday: true, - wednesday: true, - thursday: true, - friday: true, - saturday: false, - - startTime: "09:30", - timezone: "", - ttl: 5, - }, + initialValues, onSubmit, validationSchema, }) From 854f781332fc446ea38308d5fc1ece99d0fbc412 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 05:33:56 +0000 Subject: [PATCH 16/26] fixup! initialization --- site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index ce58f19ac41cc..ab1bf9786e439 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -151,6 +151,7 @@ export const WorkspaceSchedulePage: React.FC = () => { return ( { navigate(`/workspaces/${workspaceId}`) From 6afbb7426772dbe3ba62c6b57756bcacc7e598f0 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 05:47:10 +0000 Subject: [PATCH 17/26] improve error message --- site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx index ef78110e736d9..da4fec20269d0 100644 --- a/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceScheduleForm.tsx @@ -27,7 +27,7 @@ dayjs.extend(timezone) export const Language = { errorNoDayOfWeek: "Must set at least one day of week", - errorTime: "Invalid time", + errorTime: "Time must be in HH:mm format (24 hours)", errorTimezone: "Invalid timezone", daysOfWeekLabel: "Days of Week", daySundayLabel: "Sunday", From 5bacfb1dfdbdb90cc2ab18fec700518ccdfc4120 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 02:03:15 -0400 Subject: [PATCH 18/26] Apply suggestions from code review --- site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index ab1bf9786e439..66beb58397a68 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -53,7 +53,7 @@ export const formValuesToAutoStartRequest = ( // Handle special cases, falling through to comma-separation if (isEveryDay) { return { - schedule: makeCronString("1-7"), + schedule: makeCronString("*"), } } else if (isMonThroughFri) { return { From 5b3adc3574d272c495dedeb8552e911b62c6a1fc Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 02:03:46 -0400 Subject: [PATCH 19/26] Update site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx --- .../pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 594cbc502fd02..4849fbaef0d23 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -88,7 +88,7 @@ describe("WorkspaceSchedulePage", () => { ttl: 60 * 8, }, { - schedule: "00 09 * * 1-7", + schedule: "00 09 * * *", }, ], [ From 81e7e05f5e03d29c1a6cd9bb20158495dad07a27 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 06:22:24 +0000 Subject: [PATCH 20/26] fix ttl initialization --- .../WorkspaceSchedulePage.test.tsx | 21 +++++++++++++++++++ .../WorkspaceSchedulePage.tsx | 5 +++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 4849fbaef0d23..35b5e101a500d 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -174,6 +174,27 @@ describe("WorkspaceSchedulePage", () => { }, ], + // 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 [ { diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 66beb58397a68..9dd592c2c6ea9 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -84,6 +84,7 @@ export const formValuesToTTLRequest = (values: WorkspaceScheduleFormValues): Typ 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 { @@ -96,7 +97,7 @@ export const workspaceToInitialValues = (workspace: TypesGen.Workspace): Workspa saturday: false, startTime: "", timezone: "", - ttl: 0, + ttl, } } @@ -123,7 +124,7 @@ export const workspaceToInitialValues = (workspace: TypesGen.Workspace): Workspa saturday: weeklyFlags[6], startTime: `${HH.padStart(2, "0")}:${mm.padStart(2, "0")}`, timezone, - ttl: workspace.ttl ? workspace.ttl / (1_000_000 * 1000 * 60 * 60) : 0, + ttl, } } From 531df3e51a1a99a577adffcea1e6dbaaaf5d5e13 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 02:38:14 -0400 Subject: [PATCH 21/26] Update site/src/util/schedule.test.ts --- site/src/util/schedule.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts index a633a99fadfe1..95bc27433e667 100644 --- a/site/src/util/schedule.test.ts +++ b/site/src/util/schedule.test.ts @@ -25,6 +25,7 @@ describe("util/schedule", () => { 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 From 7ae590a04a19b592e555f5940e101fcaae136bf9 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 10:14:57 -0400 Subject: [PATCH 22/26] Fix typo Co-authored-by: Kira Pilot --- site/src/util/schedule.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index 72da5fbee7442..81dd10694f4bb 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -32,7 +32,7 @@ export const extractTimezone = (raw: string, defaultTZ = DEFAULT_TIMEZONE): stri } /** - * WeeklyFlag is an array represnting which days of the week are set or flagged + * WeeklyFlag is an array representing which days of the week are set or flagged * * @remarks * From 262d9e3b24c9b4ad7cba4589c8ce5d0d1136d08c Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 15:45:42 +0000 Subject: [PATCH 23/26] import ReactNode directly --- site/src/util/{formUtils.test.tsx => formUtils.test.ts} | 0 site/src/util/{formUtils.tsx => formUtils.ts} | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename site/src/util/{formUtils.test.tsx => formUtils.test.ts} (100%) rename site/src/util/{formUtils.tsx => formUtils.ts} (86%) diff --git a/site/src/util/formUtils.test.tsx b/site/src/util/formUtils.test.ts similarity index 100% rename from site/src/util/formUtils.test.tsx rename to site/src/util/formUtils.test.ts diff --git a/site/src/util/formUtils.tsx b/site/src/util/formUtils.ts similarity index 86% rename from site/src/util/formUtils.tsx rename to site/src/util/formUtils.ts index e4a2e5d71a73b..55d62e26351b8 100644 --- a/site/src/util/formUtils.tsx +++ b/site/src/util/formUtils.ts @@ -1,5 +1,5 @@ import { FormikContextType, FormikErrors, getIn } from "formik" -import React, { 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?: React.ReactNode + helperText?: ReactNode } export const getFormHelpers = (form: FormikContextType, formErrors?: FormikErrors) => - (name: keyof T, HelperText: React.ReactNode = ""): FormHelpers => { + (name: keyof T, HelperText: ReactNode = ""): FormHelpers => { if (typeof name !== "string") { throw new Error(`name must be type of string, instead received '${typeof name}'`) } From 5d22197bbde62ca0405a659c469898ac019bbc2e Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 15:48:45 +0000 Subject: [PATCH 24/26] guess timezone --- .../WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 9dd592c2c6ea9..a686daf4daf00 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -1,4 +1,7 @@ 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" @@ -12,6 +15,12 @@ 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 => { @@ -101,7 +110,7 @@ export const workspaceToInitialValues = (workspace: TypesGen.Workspace): Workspa } } - const timezone = extractTimezone(schedule, "") + const timezone = extractTimezone(schedule, dayjs.tz.guess()) const cronString = stripTimezone(schedule) // parts has the following format: "mm HH * * dow" From eda8ad80c5e20c0738c77841e645c9fa5511faf8 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 15:58:21 +0000 Subject: [PATCH 25/26] fix test --- .../WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 35b5e101a500d..798f6a7b62532 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -196,10 +196,13 @@ describe("WorkspaceSchedulePage", () => { ], // 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: "30 9 * * 1-5", + autostart_schedule: "CRON_TZ=UTC 30 9 * * 1-5", ttl: 7_200_000_000_000, }, { @@ -211,7 +214,7 @@ describe("WorkspaceSchedulePage", () => { friday: true, saturday: false, startTime: "09:30", - timezone: "", + timezone: "UTC", ttl: 2, }, ], From f59f056c890a44d63479bae9b595cb97ad4f69f9 Mon Sep 17 00:00:00 2001 From: G r e y Date: Thu, 26 May 2022 15:59:27 +0000 Subject: [PATCH 26/26] lint --- site/src/util/formUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index 55d62e26351b8..108ffdbedf261 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -12,7 +12,7 @@ interface FormHelpers { } export const getFormHelpers = - (form: FormikContextType, formErrors?: FormikErrors) => + (form: FormikContextType, formErrors?: FormikErrors) => (name: keyof T, HelperText: ReactNode = ""): FormHelpers => { if (typeof name !== "string") { throw new Error(`name must be type of string, instead received '${typeof name}'`) @@ -33,7 +33,7 @@ export const getFormHelpers = } export const onChangeTrimmed = - (form: FormikContextType) => + (form: FormikContextType) => (event: ChangeEvent): void => { event.target.value = event.target.value.trim() form.handleChange(event)