) =>
- (name: keyof T, helperText = ""): FormHelpers => {
+ (name: keyof T, HelperText: ReactNode = ""): FormHelpers => {
if (typeof name !== "string") {
throw new Error(`name must be type of string, instead received '${typeof name}'`)
}
@@ -28,7 +28,7 @@ export const getFormHelpers =
...form.getFieldProps(name),
id: name,
error: touched && Boolean(error),
- helperText: touched ? error || helperText : helperText,
+ helperText: touched ? error || HelperText : HelperText,
}
}
diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts
index d7ed65299cd67..95bc27433e667 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,26 @@ describe("util/schedule", () => {
expect(extractTimezone(input)).toBe(expected)
})
})
+
+ describe("dowToWeeklyFlag", () => {
+ it.each<[string, WeeklyFlag]>([
+ // All days
+ ["*", [true, true, true, true, true, true, true]],
+ ["0-6", [true, true, true, true, true, true, true]],
+ ["1-7", [true, true, true, true, true, true, true]],
+
+ // Single number modulo 7
+ ["3", [false, false, false, true, false, false, false]],
+ ["0", [true, false, false, false, false, false, false]],
+ ["7", [true, false, false, false, false, false, false]],
+ ["8", [false, true, false, false, false, false, false]],
+
+ // Comma-separated Numbers, Ranges and Mixes
+ ["1,3,5", [false, true, false, true, false, true, false]],
+ ["1-2,4-5", [false, true, true, false, true, true, false]],
+ ["1,3-4,6", [false, true, false, true, true, false, true]],
+ ])(`dowToWeeklyFlag(%p) returns %p`, (dow, weeklyFlag) => {
+ expect(dowToWeeklyFlag(dow)).toEqual(weeklyFlag)
+ })
+ })
})
diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts
index 55c26aadfea14..81dd10694f4bb 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 representing which days of the week are set or flagged
+ *
+ * @remarks
+ *
+ * A WeeklyFlag has an array size of 7 and should never have its size modified.
+ * The 0th index is Sunday
+ * The 6th index is Saturday
+ */
+export type WeeklyFlag = [boolean, boolean, boolean, boolean, boolean, boolean, boolean]
+
+/**
+ * dowToWeeklyFlag converts a dow cron string to a WeeklyFlag array.
+ *
+ * @example
+ *
+ * dowToWeeklyFlag("1") // [false, true, false, false, false, false, false]
+ * dowToWeeklyFlag("1-5") // [false, true, true, true, true, true, false]
+ * dowToWeeklyFlag("1,3-4,6") // [false, true, false, true, true, false, true]
+ */
+export const dowToWeeklyFlag = (dow: string): WeeklyFlag => {
+ if (dow === "*") {
+ return [true, true, true, true, true, true, true]
+ }
+
+ const results: WeeklyFlag = [false, false, false, false, false, false, false]
+
+ const commaSeparatedRangeOrNum = dow.split(",")
+
+ for (const rangeOrNum of commaSeparatedRangeOrNum) {
+ const flags = processRangeOrNum(rangeOrNum)
+
+ flags.forEach((value, idx) => {
+ if (value) {
+ results[idx] = true
+ }
+ })
+ }
+
+ return results
+}
+
+/**
+ * processRangeOrNum is a helper for dowToWeeklyFlag. It processes a range or
+ * number (modulo 7) into a Weeklyflag boolean array.
+ *
+ * @example
+ *
+ * processRangeOrNum("1") // [false, true, false, false, false, false, false]
+ * processRangeOrNum("1-5") // [false, true, true, true, true, true, false]
+ */
+const processRangeOrNum = (rangeOrNum: string): WeeklyFlag => {
+ const result: WeeklyFlag = [false, false, false, false, false, false, false]
+
+ const isRange = /^[0-9]-[0-9]$/.test(rangeOrNum)
+
+ if (isRange) {
+ const [first, last] = rangeOrNum.split("-")
+
+ for (let i = Number(first); i <= Number(last); i++) {
+ result[i % 7] = true
+ }
+ } else {
+ result[Number(rangeOrNum) % 7] = true
+ }
+
+ return result
+}
diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts
new file mode 100644
index 0000000000000..a1ddb58254366
--- /dev/null
+++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts
@@ -0,0 +1,151 @@
+/**
+ * @fileoverview workspaceSchedule is an xstate machine backing a form to CRUD
+ * an individual workspace's schedule.
+ */
+import { assign, createMachine } from "xstate"
+import * as API from "../../api/api"
+import { ApiError, FieldErrors, mapApiErrorToFieldErrors } from "../../api/errors"
+import * as TypesGen from "../../api/typesGenerated"
+import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
+
+export const Language = {
+ errorSubmissionFailed: "Failed to update schedule",
+ errorWorkspaceFetch: "Failed to fetch workspace",
+ successMessage: "Successfully updated workspace schedule.",
+}
+
+export interface WorkspaceScheduleContext {
+ formErrors?: FieldErrors
+ getWorkspaceError?: Error | unknown
+ /**
+ * Each workspace has their own schedule (start and ttl). For this reason, we
+ * re-fetch the workspace to ensure we're up-to-date. As a result, this
+ * machine is partially influenced by workspaceXService.
+ */
+ workspace?: TypesGen.Workspace
+}
+
+export type WorkspaceScheduleEvent =
+ | { type: "GET_WORKSPACE"; workspaceId: string }
+ | {
+ type: "SUBMIT_SCHEDULE"
+ autoStart: TypesGen.UpdateWorkspaceAutostartRequest
+ ttl: TypesGen.UpdateWorkspaceTTLRequest
+ }
+
+export const workspaceSchedule = createMachine(
+ {
+ tsTypes: {} as import("./workspaceScheduleXService.typegen").Typegen0,
+ schema: {
+ context: {} as WorkspaceScheduleContext,
+ events: {} as WorkspaceScheduleEvent,
+ services: {} as {
+ getWorkspace: {
+ data: TypesGen.Workspace
+ }
+ },
+ },
+ id: "workspaceScheduleState",
+ initial: "idle",
+ on: {
+ GET_WORKSPACE: "gettingWorkspace",
+ },
+ states: {
+ idle: {
+ tags: "loading",
+ },
+ gettingWorkspace: {
+ entry: ["clearGetWorkspaceError", "clearContext"],
+ invoke: {
+ src: "getWorkspace",
+ id: "getWorkspace",
+ onDone: {
+ target: "presentForm",
+ actions: ["assignWorkspace"],
+ },
+ onError: {
+ target: "error",
+ actions: ["assignGetWorkspaceError", "displayWorkspaceError"],
+ },
+ },
+ tags: "loading",
+ },
+ presentForm: {
+ on: {
+ SUBMIT_SCHEDULE: "submittingSchedule",
+ },
+ },
+ submittingSchedule: {
+ invoke: {
+ src: "submitSchedule",
+ id: "submitSchedule",
+ onDone: {
+ target: "submitSuccess",
+ actions: "displaySuccess",
+ },
+ onError: {
+ target: "presentForm",
+ actions: ["assignSubmissionError", "displaySubmissionError"],
+ },
+ },
+ tags: "loading",
+ },
+ submitSuccess: {
+ on: {
+ SUBMIT_SCHEDULE: "submittingSchedule",
+ },
+ },
+ error: {
+ on: {
+ GET_WORKSPACE: "gettingWorkspace",
+ },
+ },
+ },
+ },
+ {
+ actions: {
+ assignSubmissionError: assign({
+ formErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
+ }),
+ assignWorkspace: assign({
+ workspace: (_, event) => event.data,
+ }),
+ assignGetWorkspaceError: assign({
+ getWorkspaceError: (_, event) => event.data,
+ }),
+ clearContext: () => {
+ assign({ workspace: undefined })
+ },
+ clearGetWorkspaceError: (context) => {
+ assign({ ...context, getWorkspaceError: undefined })
+ },
+ displayWorkspaceError: () => {
+ displayError(Language.errorWorkspaceFetch)
+ },
+ displaySubmissionError: () => {
+ displayError(Language.errorSubmissionFailed)
+ },
+ displaySuccess: () => {
+ displaySuccess(Language.successMessage)
+ },
+ },
+
+ services: {
+ getWorkspace: async (_, event) => {
+ return await API.getWorkspace(event.workspaceId)
+ },
+ submitSchedule: async (context, event) => {
+ if (!context.workspace?.id) {
+ // This state is theoretically impossible, but helps TS
+ throw new Error("failed to load workspace")
+ }
+
+ // REMARK: These calls are purposefully synchronous because if one
+ // value contradicts the other, we don't want a race condition
+ // on re-submission.
+ await API.putWorkspaceAutostart(context.workspace.id, event.autoStart)
+ await API.putWorkspaceAutostop(context.workspace.id, event.ttl)
+ },
+ },
+ },
+)
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