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) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy