feat: offer to restart workspace when ttl is changed (#5391)

* Update xstate machine

* Fix autoStopChanged

* Add dialog

* Restart workspace

* Clearing location doesn't work and doesn't seem necessary

* Fix test

* Fix second test

* Format

* Lint

* Use i18n

* Switch to fire and forget restart

* Improve error handling

* Format

* Format

* Update site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx

Co-authored-by: Kira Pilot <kira@coder.com>

* Fix name of guard

* Make done state final

* Format

Co-authored-by: Kira Pilot <kira@coder.com>
This commit is contained in:
Presley Pizzo
2022-12-21 10:11:54 -05:00
committed by GitHub
co-authored by Kira Pilot
parent 2bbeff53f9
commit 8d9528545a
6 changed files with 338 additions and 182 deletions
+2
View File
@@ -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,
}
@@ -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"
}
@@ -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(<WorkspaceSchedulePage />, {
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(<WorkspaceSchedulePage />, {
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()
})
})
})
@@ -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 <Navigate to="/workspaces" />
}
if (
scheduleState.matches("idle") ||
scheduleState.matches("gettingWorkspace") ||
scheduleState.matches("gettingPermissions") ||
!workspace
) {
if (scheduleState.hasTag("loading")) {
return <FullScreenLoader />
}
if (scheduleState.matches("error")) {
return (
<AlertBanner
severity="error"
error={getWorkspaceError || checkPermissionsError}
text={
getWorkspaceError
? Language.getWorkspaceError
: Language.checkPermissionsError
}
retry={() =>
scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
}
/>
<Margins>
<div className={styles.topMargin}>
<AlertBanner
severity="error"
error={
getWorkspaceError || checkPermissionsError || getTemplateError
}
retry={() =>
scheduleSend({ type: "GET_WORKSPACE", username, workspaceName })
}
/>
</div>
</Margins>
)
}
if (!permissions?.updateWorkspace) {
return (
<AlertBanner severity="error" error={Error(Language.forbiddenError)} />
<Margins>
<div className={styles.topMargin}>
<AlertBanner severity="error" error={Error(t("forbiddenError"))} />
</div>
</Margins>
)
}
@@ -101,7 +99,10 @@ export const WorkspaceSchedulePage: React.FC = () => {
return (
<WorkspaceScheduleForm
submitScheduleError={submitScheduleError}
initialValues={{ ...autoStart, ...autoStop }}
initialValues={{
...getAutoStart(workspace),
...getAutoStop(workspace),
}}
isLoading={scheduleState.tags.has("loading")}
onCancel={() => {
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 (
<ConfirmDialog
open
title={t("dialogTitle")}
description={t("dialogDescription")}
confirmText={t("restart")}
cancelText={t("applyLater")}
hideCancel={false}
onConfirm={() => {
scheduleSend("RESTART_WORKSPACE")
}}
onClose={() => {
scheduleSend("APPLY_LATER")
}}
/>
)
}
if (scheduleState.matches("done")) {
return <Navigate to={`/@${username}/${workspaceName}`} />
}
+1
View File
@@ -131,6 +131,7 @@ export const handlers = [
const permissions = [
...Object.keys(permissionsToCheck),
"canUpdateTemplate",
"updateWorkspace",
]
const response = permissions.reduce((obj, permission) => {
return {
@@ -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
}
},
},
initial: "idle",
on: {
GET_WORKSPACE: "gettingWorkspace",
},
states: {
idle: {
tags: "loading",
},
gettingWorkspace: {
entry: ["clearGetWorkspaceError", "clearContext"],
invoke: {
src: "getWorkspace",
id: "getWorkspace",
onDone: {
target: "gettingPermissions",
actions: ["assignWorkspace"],
},
onError: {
target: "error",
actions: ["assignGetWorkspaceError"],
},
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
}
},
tags: "loading",
},
gettingPermissions: {
entry: "clearGetPermissionsError",
invoke: {
src: "checkPermissions",
id: "checkPermissions",
onDone: [
{
actions: ["assignPermissions"],
target: "presentForm",
initial: "idle",
on: {
GET_WORKSPACE: "gettingWorkspace",
},
states: {
idle: {
tags: "loading",
},
gettingWorkspace: {
entry: ["clearGetWorkspaceError", "clearContext"],
invoke: {
src: "getWorkspace",
id: "getWorkspace",
onDone: {
target: "gettingPermissions",
actions: ["assignWorkspace"],
},
],
onError: [
{
actions: "assignGetPermissionsError",
onError: {
target: "error",
actions: ["assignGetWorkspaceError"],
},
],
},
},
presentForm: {
on: {
SUBMIT_SCHEDULE: "submittingSchedule",
},
},
submittingSchedule: {
invoke: {
src: "submitSchedule",
id: "submitSchedule",
onDone: {
target: "submitSuccess",
actions: "displaySuccess",
},
onError: {
target: "presentForm",
actions: ["assignSubmissionError"],
tags: "loading",
},
gettingPermissions: {
entry: "clearGetPermissionsError",
invoke: {
src: "checkPermissions",
id: "checkPermissions",
onDone: [
{
actions: ["assignPermissions"],
target: "gettingTemplate",
},
],
onError: [
{
actions: "assignGetPermissionsError",
target: "error",
},
],
},
tags: "loading",
},
gettingTemplate: {
entry: "clearGetTemplateError",
invoke: {
src: "getTemplate",
id: "getTemplate",
onDone: {
target: "presentForm",
actions: ["assignTemplate"],
},
onError: {
target: "error",
actions: ["assignGetTemplateError"],
},
},
tags: "loading",
},
presentForm: {
on: {
SUBMIT_SCHEDULE: {
target: "submittingSchedule",
actions: "assignAutoStopChanged",
},
},
},
tags: "loading",
},
submitSuccess: {
on: {
SUBMIT_SCHEDULE: "submittingSchedule",
submittingSchedule: {
invoke: {
src: "submitSchedule",
id: "submitSchedule",
onDone: [
{
cond: "autoStopChanged",
target: "showingRestartDialog",
},
{ target: "done" },
],
onError: {
target: "presentForm",
actions: ["assignSubmissionError"],
},
},
tags: "loading",
},
},
error: {
on: {
GET_WORKSPACE: "gettingWorkspace",
showingRestartDialog: {
on: {
RESTART_WORKSPACE: {
target: "done",
actions: "restartWorkspace",
},
APPLY_LATER: "done",
},
},
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 })
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,
)
}
},
},
displaySuccess: () => {
displaySuccess(Language.successMessage)
},
},
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)
}
},
},
},
},
)
)