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) + } + }, }, }, - }, -) + )