diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts
index 37b8b66dca66b..3be9daf125ed6 100644
--- a/site/src/i18n/en/index.ts
+++ b/site/src/i18n/en/index.ts
@@ -12,6 +12,7 @@ import templateSettingsPage from "./templateSettingsPage.json"
import templateVersionPage from "./templateVersionPage.json"
import loginPage from "./loginPage.json"
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
+import workspaceSchedulePage from "./workspaceSchedulePage.json"
import serviceBannerSettings from "./serviceBannerSettings.json"
export const en = {
@@ -29,5 +30,6 @@ export const en = {
templateVersionPage,
loginPage,
workspaceChangeVersionPage,
+ workspaceSchedulePage,
serviceBannerSettings,
}
diff --git a/site/src/i18n/en/workspaceSchedulePage.json b/site/src/i18n/en/workspaceSchedulePage.json
new file mode 100644
index 0000000000000..abfa221efa362
--- /dev/null
+++ b/site/src/i18n/en/workspaceSchedulePage.json
@@ -0,0 +1,7 @@
+{
+ "forbiddenError": "You don't have permissions to update the schedule for this workspace.",
+ "dialogTitle": "Restart workspace?",
+ "dialogDescription": "Would you like to restart your workspace now to apply your new auto-stop setting, or let it apply after your next workspace start?",
+ "restart": "Restart workspace now",
+ "applyLater": "Apply update later"
+}
diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx
index f44a89dbc466c..a228d399849e0 100644
--- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx
+++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx
@@ -1,3 +1,10 @@
+import {
+ MockUser,
+ MockWorkspace,
+ renderWithAuth,
+} from "testHelpers/renderHelpers"
+import userEvent from "@testing-library/user-event"
+import { screen } from "@testing-library/react"
import {
formValuesToAutoStartRequest,
formValuesToTTLRequest,
@@ -8,7 +15,14 @@ import {
} from "pages/WorkspaceSchedulePage/schedule"
import { AutoStop, ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl"
import * as TypesGen from "../../api/typesGenerated"
-import { WorkspaceScheduleFormValues } from "../../components/WorkspaceScheduleForm/WorkspaceScheduleForm"
+import {
+ WorkspaceScheduleFormValues,
+ Language as FormLanguage,
+} from "components/WorkspaceScheduleForm/WorkspaceScheduleForm"
+import { WorkspaceSchedulePage } from "./WorkspaceSchedulePage"
+import i18next from "i18next"
+
+const { t } = i18next
const validValues: WorkspaceScheduleFormValues = {
autoStartEnabled: true,
@@ -241,4 +255,44 @@ describe("WorkspaceSchedulePage", () => {
expect(ttlMsToAutoStop(ttlMs)).toEqual(autoStop)
})
})
+
+ describe("autoStop change dialog", () => {
+ it("shows if autoStop is changed", async () => {
+ renderWithAuth(, {
+ route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`,
+ path: "/@:username/:workspace/schedule",
+ })
+ const user = userEvent.setup()
+ const autoStopToggle = await screen.findByLabelText(
+ FormLanguage.stopSwitch,
+ )
+ await user.click(autoStopToggle)
+ const submitButton = await screen.findByRole("button", {
+ name: /submit/i,
+ })
+ await user.click(submitButton)
+ const title = t("dialogTitle", { ns: "workspaceSchedulePage" })
+ const dialog = await screen.findByText(title)
+ expect(dialog).toBeInTheDocument()
+ })
+
+ it("doesn't show if autoStop is not changed", async () => {
+ renderWithAuth(, {
+ route: `/@${MockUser.username}/${MockWorkspace.name}/schedule`,
+ path: "/@:username/:workspace/schedule",
+ })
+ const user = userEvent.setup()
+ const autoStartToggle = await screen.findByLabelText(
+ FormLanguage.startSwitch,
+ )
+ await user.click(autoStartToggle)
+ const submitButton = await screen.findByRole("button", {
+ name: /submit/i,
+ })
+ await user.click(submitButton)
+ const title = t("dialogTitle", { ns: "workspaceSchedulePage" })
+ const dialog = screen.queryByText(title)
+ expect(dialog).not.toBeInTheDocument()
+ })
+ })
})
diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx
index ad48166dea9c7..d17cf95fc0870 100644
--- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx
+++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx
@@ -1,8 +1,12 @@
+import { makeStyles } from "@material-ui/core/styles"
import { useMachine } from "@xstate/react"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
+import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
+import { Margins } from "components/Margins/Margins"
import { scheduleToAutoStart } from "pages/WorkspaceSchedulePage/schedule"
import { ttlMsToAutoStop } from "pages/WorkspaceSchedulePage/ttl"
-import React, { useEffect, useState } from "react"
+import React, { useEffect } from "react"
+import { useTranslation } from "react-i18next"
import { Navigate, useNavigate, useParams } from "react-router-dom"
import { scheduleChanged } from "util/schedule"
import * as TypesGen from "../../api/typesGenerated"
@@ -15,14 +19,20 @@ import {
formValuesToTTLRequest,
} from "./formToRequest"
-const Language = {
- forbiddenError:
- "You don't have permissions to update the schedule for this workspace.",
- getWorkspaceError: "Failed to fetch workspace.",
- checkPermissionsError: "Failed to fetch permissions.",
-}
+const getAutoStart = (workspace?: TypesGen.Workspace) =>
+ scheduleToAutoStart(workspace?.autostart_schedule)
+const getAutoStop = (workspace?: TypesGen.Workspace) =>
+ ttlMsToAutoStop(workspace?.ttl_ms)
+
+const useStyles = makeStyles((theme) => ({
+ topMargin: {
+ marginTop: `${theme.spacing(3)}px`,
+ },
+}))
export const WorkspaceSchedulePage: React.FC = () => {
+ const { t } = useTranslation("workspaceSchedulePage")
+ const styles = useStyles()
const { username: usernameQueryParam, workspace: workspaceQueryParam } =
useParams()
const navigate = useNavigate()
@@ -33,6 +43,7 @@ export const WorkspaceSchedulePage: React.FC = () => {
checkPermissionsError,
submitScheduleError,
getWorkspaceError,
+ getTemplateError,
permissions,
workspace,
} = scheduleState.context
@@ -45,52 +56,39 @@ export const WorkspaceSchedulePage: React.FC = () => {
scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
}, [username, workspaceName, scheduleSend])
- const getAutoStart = (workspace?: TypesGen.Workspace) =>
- scheduleToAutoStart(workspace?.autostart_schedule)
- const getAutoStop = (workspace?: TypesGen.Workspace) =>
- ttlMsToAutoStop(workspace?.ttl_ms)
-
- const [autoStart, setAutoStart] = useState(getAutoStart(workspace))
- const [autoStop, setAutoStop] = useState(getAutoStop(workspace))
-
- useEffect(() => {
- setAutoStart(getAutoStart(workspace))
- setAutoStop(getAutoStop(workspace))
- }, [workspace])
-
if (!username || !workspaceName) {
return
}
- if (
- scheduleState.matches("idle") ||
- scheduleState.matches("gettingWorkspace") ||
- scheduleState.matches("gettingPermissions") ||
- !workspace
- ) {
+ if (scheduleState.hasTag("loading")) {
return
}
if (scheduleState.matches("error")) {
return (
-
- scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
- }
- />
+
+
+
+ scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
+ }
+ />
+
+
)
}
if (!permissions?.updateWorkspace) {
return (
-
+
+
+
)
}
@@ -101,7 +99,10 @@ export const WorkspaceSchedulePage: React.FC = () => {
return (
{
navigate(`/@${username}/${workspaceName}`)
@@ -111,15 +112,34 @@ export const WorkspaceSchedulePage: React.FC = () => {
type: "SUBMIT_SCHEDULE",
autoStart: formValuesToAutoStartRequest(values),
ttl: formValuesToTTLRequest(values),
- autoStartChanged: scheduleChanged(autoStart, values),
- autoStopChanged: scheduleChanged(autoStop, values),
+ autoStartChanged: scheduleChanged(getAutoStart(workspace), values),
+ autoStopChanged: scheduleChanged(getAutoStop(workspace), values),
})
}}
/>
)
}
- if (scheduleState.matches("submitSuccess")) {
+ if (scheduleState.matches("showingRestartDialog")) {
+ return (
+ {
+ scheduleSend("RESTART_WORKSPACE")
+ }}
+ onClose={() => {
+ scheduleSend("APPLY_LATER")
+ }}
+ />
+ )
+ }
+
+ if (scheduleState.matches("done")) {
return
}
diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts
index 06a577ec48488..9c41216ef0d6b 100644
--- a/site/src/testHelpers/handlers.ts
+++ b/site/src/testHelpers/handlers.ts
@@ -131,6 +131,7 @@ export const handlers = [
const permissions = [
...Object.keys(permissionsToCheck),
"canUpdateTemplate",
+ "updateWorkspace",
]
const response = permissions.reduce((obj, permission) => {
return {
diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts
index 33382097dd535..65506a0f70e44 100644
--- a/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts
+++ b/site/src/xServices/workspaceSchedule/workspaceScheduleXService.ts
@@ -5,7 +5,6 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
-import { displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
successMessage: "Successfully updated workspace schedule.",
@@ -21,9 +20,13 @@ export interface WorkspaceScheduleContext {
* machine is partially influenced by workspaceXService.
*/
workspace?: TypesGen.Workspace
+ template?: TypesGen.Template
+ getTemplateError?: Error | unknown
permissions?: Permissions
checkPermissionsError?: Error | unknown
submitScheduleError?: Error | unknown
+ autoStopChanged?: boolean
+ shouldRestartWorkspace?: boolean
}
export const checks = {
@@ -50,160 +53,229 @@ export type WorkspaceScheduleEvent =
ttl: TypesGen.UpdateWorkspaceTTLRequest
autoStopChanged: boolean
}
+ | { type: "RESTART_WORKSPACE" }
+ | { type: "APPLY_LATER" }
-export const workspaceSchedule = createMachine(
- {
- id: "workspaceScheduleState",
- predictableActionArguments: true,
- tsTypes: {} as import("./workspaceScheduleXService.typegen").Typegen0,
- schema: {
- context: {} as WorkspaceScheduleContext,
- events: {} as WorkspaceScheduleEvent,
- services: {} as {
- getWorkspace: {
- data: TypesGen.Workspace
- }
+export const workspaceSchedule =
+ /** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGMwBlAgC0gFcAbEgFzzrAGIBxAUQBUB9AdQHkASgGliABQCCAYXYBtAAwBdRKBypYASzobUAOxUgAHogCMAJgB0AVgAcAThsAWeXZMA2O2YDsdq1YA0IACeiGbyFl428o4AzFYmMT4xbl5mAL5pgWhYuIQk5FS0xAxMFjB02rpQvBjY+ETMEHpgFhq6AG6omC3lNTn1YArKSCBqmtp6BsYI7tbybmZ+9mYO8u6OgSEIMY6OFo5eC34mdqkLNhlZtblEpBQQNPSMPWAVbdXXA8xg6OgYFjhqIwAGYYAC2ZVefTqeSGBjGWh0+hG02SNmsXhMVhWdkcbkcNm8Xk2oRcFgW7hi8h8djcVkuIGyMNuBQeRRKLzeVTEPzBGlgmj0sEazVaHS6LQKBEwPPQfIFSNgcJGCImyNA0wAtOEYrjsa4vD5bMkEiSEG44uTFosCa41lY3AymTd8vdHsVnpCuVBZfLBbphT8-ugAUC6KC5RYpTLefz-UqlPD1IjJijEMkrBE3G4bO5cfN5GYzGadnsDgt5rmnFZHL4nZ88ndCk9Sjh0HAwLo6AAxcHMYgAVQAQgBZACSPGIUgAEuwACIDgAyckTKuTaqmiEzqTMdOzJixJi82OLwUQNic5MxrjxusS5nr-UbrPdHIssEoACM+d6m2yWE0ugtG0nTdO+X4-n+jzKqo65IpuMx7CYF7ZjYMSJLihpeBsZ4zO4Jj7HahomPINaEo4j7Mq6zYeqUH7flolRQFBtAikBYqgS09GQS+tCyCYwyweM8Fpggjg1hYu5UrqZiOCsjjmGaB5uARtYkfq7jUjYjqZIyDYsm67KetxjHvCxLBBv8gIguC4EMXQ5kwaMcGphq6YpBEB4JFYuqxN4Jhmq4bj7CkiReNEpykSYlEuuZtFcWQqDIO8ghwAw6B0HOGh4NQqBQMwgjsMQnASIIPACCI4jSCugnOcJrlGOeMT7LYyExMhClybESmJMFrU2OFDo+OYOlXE+Bk0W+sCJclVSpbA6WZdluX5RIYhiIuACa3CLhInDsIITmqiJbnbG4Oq1gpaxOHmMRKWY2kWPYObnehOwxDFAxxW+lnoGwXB8EIoiSDIR0ueqjXbFYdgWKcVZmCN5x+EpBJPa4ngmLE8jtV4GS6boqAQHABjOl9vEtmASb1RDWqo75GmGr4aFuGampYpYTgI-IUS+Nzhy47ppPPoZFOtBAtBUymNOmOEUQ1nidJWMe1IPazdIWDsXMHseXgxFEAtjVR32euUTHQi6ksbqJXgWPI8y1rJix23ESso3sdvRLrKxmDEjvpIL+nUf+8VekxvpxoqlsnZD6K5nYLhHnJNjYmsZoEjbuu+ASJqdbrn3C5Nnpth2Xa9nKUcNdMuxPdjaFOFS2ErGakTNQjDu3qkJF2PnE3B1NEGmVU5kV9LMzhNDSvzBa1IqZ4OFbLSMMWj7OZoXrhIfQH41B6+xkzSlaV4BlWU5XlI8ISReyYbJ5h2A4cnI7hI2ZtmcSRNpOZxP7huxeTIe-efUSthMyyU8PHO+R4LRKQks4HMmMDhfzWNFLeRs-5vkApTNc1MEK6hAe4c6pE6QDTCCzJ+tZY4nD1qkdCqQBYZCAA */
+ createMachine(
+ {
+ id: "workspaceScheduleState",
+ predictableActionArguments: true,
+ tsTypes: {} as import("./workspaceScheduleXService.typegen").Typegen0,
+ schema: {
+ context: {} as WorkspaceScheduleContext,
+ events: {} as WorkspaceScheduleEvent,
+ services: {} as {
+ getWorkspace: {
+ data: TypesGen.Workspace
+ }
+ getTemplate: {
+ data: TypesGen.Template
+ }
+ },
},
- },
- initial: "idle",
- on: {
- GET_WORKSPACE: "gettingWorkspace",
- },
- states: {
- idle: {
- tags: "loading",
+ initial: "idle",
+ on: {
+ GET_WORKSPACE: "gettingWorkspace",
},
- gettingWorkspace: {
- entry: ["clearGetWorkspaceError", "clearContext"],
- invoke: {
- src: "getWorkspace",
- id: "getWorkspace",
- onDone: {
- target: "gettingPermissions",
- actions: ["assignWorkspace"],
+ states: {
+ idle: {
+ tags: "loading",
+ },
+ gettingWorkspace: {
+ entry: ["clearGetWorkspaceError", "clearContext"],
+ invoke: {
+ src: "getWorkspace",
+ id: "getWorkspace",
+ onDone: {
+ target: "gettingPermissions",
+ actions: ["assignWorkspace"],
+ },
+ onError: {
+ target: "error",
+ actions: ["assignGetWorkspaceError"],
+ },
},
- onError: {
- target: "error",
- actions: ["assignGetWorkspaceError"],
+ tags: "loading",
+ },
+ gettingPermissions: {
+ entry: "clearGetPermissionsError",
+ invoke: {
+ src: "checkPermissions",
+ id: "checkPermissions",
+ onDone: [
+ {
+ actions: ["assignPermissions"],
+ target: "gettingTemplate",
+ },
+ ],
+ onError: [
+ {
+ actions: "assignGetPermissionsError",
+ target: "error",
+ },
+ ],
},
+ tags: "loading",
},
- tags: "loading",
- },
- gettingPermissions: {
- entry: "clearGetPermissionsError",
- invoke: {
- src: "checkPermissions",
- id: "checkPermissions",
- onDone: [
- {
- actions: ["assignPermissions"],
+ gettingTemplate: {
+ entry: "clearGetTemplateError",
+ invoke: {
+ src: "getTemplate",
+ id: "getTemplate",
+ onDone: {
target: "presentForm",
+ actions: ["assignTemplate"],
},
- ],
- onError: [
- {
- actions: "assignGetPermissionsError",
+ onError: {
target: "error",
+ actions: ["assignGetTemplateError"],
},
- ],
+ },
+ tags: "loading",
},
- },
- presentForm: {
- on: {
- SUBMIT_SCHEDULE: "submittingSchedule",
+ presentForm: {
+ on: {
+ SUBMIT_SCHEDULE: {
+ target: "submittingSchedule",
+ actions: "assignAutoStopChanged",
+ },
+ },
},
- },
- submittingSchedule: {
- invoke: {
- src: "submitSchedule",
- id: "submitSchedule",
- onDone: {
- target: "submitSuccess",
- actions: "displaySuccess",
+ submittingSchedule: {
+ invoke: {
+ src: "submitSchedule",
+ id: "submitSchedule",
+ onDone: [
+ {
+ cond: "autoStopChanged",
+ target: "showingRestartDialog",
+ },
+ { target: "done" },
+ ],
+ onError: {
+ target: "presentForm",
+ actions: ["assignSubmissionError"],
+ },
},
- onError: {
- target: "presentForm",
- actions: ["assignSubmissionError"],
+ tags: "loading",
+ },
+ showingRestartDialog: {
+ on: {
+ RESTART_WORKSPACE: {
+ target: "done",
+ actions: "restartWorkspace",
+ },
+ APPLY_LATER: "done",
},
},
- tags: "loading",
- },
- submitSuccess: {
- on: {
- SUBMIT_SCHEDULE: "submittingSchedule",
+ error: {
+ on: {
+ GET_WORKSPACE: "gettingWorkspace",
+ },
},
- },
- error: {
- on: {
- GET_WORKSPACE: "gettingWorkspace",
+ done: {
+ type: "final",
},
},
},
- },
- {
- actions: {
- assignSubmissionError: assign({
- submitScheduleError: (_, event) => event.data,
- }),
- assignWorkspace: assign({
- workspace: (_, event) => event.data,
- }),
- assignGetWorkspaceError: assign({
- getWorkspaceError: (_, event) => event.data,
- }),
- assignPermissions: assign({
- // Setting event.data as Permissions to be more stricted. So we know
- // what permissions we asked for.
- permissions: (_, event) => event.data as Permissions,
- }),
- assignGetPermissionsError: assign({
- checkPermissionsError: (_, event) => event.data,
- }),
- clearGetPermissionsError: assign({
- checkPermissionsError: (_) => undefined,
- }),
- clearContext: () => {
- assign({ workspace: undefined, permissions: undefined })
+ {
+ guards: {
+ autoStopChanged: (context) => Boolean(context.autoStopChanged),
},
- clearGetWorkspaceError: (context) => {
- assign({ ...context, getWorkspaceError: undefined })
- },
- displaySuccess: () => {
- displaySuccess(Language.successMessage)
+ actions: {
+ assignSubmissionError: assign({
+ submitScheduleError: (_, event) => event.data,
+ }),
+ assignWorkspace: assign({
+ workspace: (_, event) => event.data,
+ }),
+ assignGetWorkspaceError: assign({
+ getWorkspaceError: (_, event) => event.data,
+ }),
+ assignPermissions: assign({
+ // Setting event.data as Permissions to be more stricted. So we know
+ // what permissions we asked for.
+ permissions: (_, event) => event.data as Permissions,
+ }),
+ assignGetPermissionsError: assign({
+ checkPermissionsError: (_, event) => event.data,
+ }),
+ assignTemplate: assign({
+ template: (_, event) => event.data,
+ }),
+ assignGetTemplateError: assign({
+ getTemplateError: (_, event) => event.data,
+ }),
+ clearGetTemplateError: assign({
+ getTemplateError: (_) => undefined,
+ }),
+ assignAutoStopChanged: assign({
+ autoStopChanged: (_, event) => event.autoStopChanged,
+ }),
+ clearGetPermissionsError: assign({
+ checkPermissionsError: (_) => undefined,
+ }),
+ clearContext: () => {
+ assign({ workspace: undefined, permissions: undefined })
+ },
+ clearGetWorkspaceError: (context) => {
+ assign({ ...context, getWorkspaceError: undefined })
+ },
+ // action instead of service because we fire and forget so that the
+ // user can return to the workspace page to see the restart
+ restartWorkspace: (context) => {
+ if (context.workspace && context.template) {
+ return API.startWorkspace(
+ context.workspace.id,
+ context.template.active_version_id,
+ )
+ }
+ },
},
- },
- services: {
- getWorkspace: async (_, event) => {
- return await API.getWorkspaceByOwnerAndName(
- event.username,
- event.workspaceName,
- )
- },
- checkPermissions: async (context) => {
- if (context.workspace) {
- return await API.checkAuthorization({
- checks: permissionsToCheck(context.workspace),
- })
- } else {
- throw Error(
- "Cannot check permissions without both workspace and user id",
+ services: {
+ getWorkspace: async (_, event) => {
+ return await API.getWorkspaceByOwnerAndName(
+ event.username,
+ event.workspaceName,
)
- }
- },
- submitSchedule: async (context, event) => {
- if (!context.workspace?.id) {
- // This state is theoretically impossible, but helps TS
- throw new Error("Failed to load workspace.")
- }
+ },
+ getTemplate: async (context) => {
+ if (context.workspace) {
+ return await API.getTemplate(context.workspace.template_id)
+ } else {
+ throw Error("Can't fetch template without workspace.")
+ }
+ },
+ checkPermissions: async (context) => {
+ if (context.workspace) {
+ return await API.checkAuthorization({
+ checks: permissionsToCheck(context.workspace),
+ })
+ } else {
+ throw Error(
+ "Cannot check permissions without both workspace and user id",
+ )
+ }
+ },
+ submitSchedule: async (context, event) => {
+ if (!context.workspace?.id) {
+ // This state is theoretically impossible, but helps TS
+ throw new Error("Failed to load workspace.")
+ }
- if (event.autoStartChanged) {
- await API.putWorkspaceAutostart(context.workspace.id, event.autoStart)
- }
- if (event.autoStopChanged) {
- await API.putWorkspaceAutostop(context.workspace.id, event.ttl)
- }
+ if (event.autoStartChanged) {
+ await API.putWorkspaceAutostart(
+ context.workspace.id,
+ event.autoStart,
+ )
+ }
+ if (event.autoStopChanged) {
+ await API.putWorkspaceAutostop(context.workspace.id, event.ttl)
+ }
+ },
},
},
- },
-)
+ )