diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index bacd99e967542..c05090d02622f 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -92,16 +92,6 @@ afterAll(() => { }) describe("WorkspacePage", () => { - it("requests a stop job when the user presses Stop", async () => { - const stopWorkspaceMock = jest - .spyOn(api, "stopWorkspace") - .mockResolvedValueOnce(MockWorkspaceBuild) - testButton( - t("actionButton.stop", { ns: "workspacePage" }), - stopWorkspaceMock, - ) - }) - it("requests a delete job when the user presses Delete and confirms", async () => { const user = userEvent.setup() const deleteWorkspaceMock = jest @@ -140,11 +130,23 @@ describe("WorkspacePage", () => { const startWorkspaceMock = jest .spyOn(api, "startWorkspace") .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - testButton( + await testButton( t("actionButton.start", { ns: "workspacePage" }), startWorkspaceMock, ) }) + + it("requests a stop job when the user presses Stop", async () => { + const stopWorkspaceMock = jest + .spyOn(api, "stopWorkspace") + .mockResolvedValueOnce(MockWorkspaceBuild) + + await testButton( + t("actionButton.stop", { ns: "workspacePage" }), + stopWorkspaceMock, + ) + }) + it("requests cancellation when the user presses Cancel", async () => { server.use( rest.get( diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 83111dc2a0a9f..c01691ba6ef17 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -40,6 +40,27 @@ const moreBuildsAvailable = ( return event.data.latest_build.updated_at !== latestBuildInTimeline.updated_at } +const updateWorkspaceStatus = ( + status: TypesGen.WorkspaceStatus, + workspace?: TypesGen.Workspace, +) => { + if (!workspace) { + throw new Error("Workspace not defined") + } + + return { + ...workspace, + latest_build: { + ...workspace.latest_build, + status, + }, + } +} + +const isUpdated = (newDateStr: string, oldDateStr: string): boolean => { + return new Date(oldDateStr).getTime() - new Date(newDateStr).getTime() > 0 +} + const Language = { getTemplateWarning: "Error updating workspace: latest template could not be fetched.", @@ -252,6 +273,7 @@ export const workspaceMachine = createMachine( on: { REFRESH_WORKSPACE: { actions: ["refreshWorkspace"], + cond: "hasUpdates", }, EVENT_SOURCE_ERROR: { target: "error", @@ -325,7 +347,7 @@ export const workspaceMachine = createMachine( }, }, requestingStart: { - entry: "clearBuildError", + entry: ["clearBuildError", "updateStatusToStarting"], invoke: { src: "startWorkspace", id: "startWorkspace", @@ -344,7 +366,7 @@ export const workspaceMachine = createMachine( }, }, requestingStop: { - entry: "clearBuildError", + entry: ["clearBuildError", "updateStatusToStopping"], invoke: { src: "stopWorkspace", id: "stopWorkspace", @@ -363,7 +385,7 @@ export const workspaceMachine = createMachine( }, }, requestingDelete: { - entry: "clearBuildError", + entry: ["clearBuildError", "updateStatusToDeleting"], invoke: { src: "deleteWorkspace", id: "deleteWorkspace", @@ -382,7 +404,11 @@ export const workspaceMachine = createMachine( }, }, requestingCancel: { - entry: ["clearCancellationMessage", "clearCancellationError"], + entry: [ + "clearCancellationMessage", + "clearCancellationError", + "updateStatusToCanceling", + ], invoke: { src: "cancelWorkspace", id: "cancelWorkspace", @@ -430,9 +456,7 @@ export const workspaceMachine = createMachine( on: { REFRESH_TIMELINE: { target: "#workspaceState.ready.timeline.gettingBuilds", - cond: { - type: "moreBuildsAvailable", - }, + cond: "moreBuildsAvailable", }, }, }, @@ -599,9 +623,46 @@ export const workspaceMachine = createMachine( }), { to: "scheduleBannerMachine" }, ), + // Optimistically updates. So when the user clicks on stop, we can show + // the "stopping" state right away without having to wait 0.5s ~ 2s to + // display the visual feedback to the user. + updateStatusToStarting: assign({ + workspace: ({ workspace }) => + updateWorkspaceStatus("starting", workspace), + }), + updateStatusToStopping: assign({ + workspace: ({ workspace }) => + updateWorkspaceStatus("stopping", workspace), + }), + updateStatusToDeleting: assign({ + workspace: ({ workspace }) => + updateWorkspaceStatus("deleting", workspace), + }), + updateStatusToCanceling: assign({ + workspace: ({ workspace }) => + updateWorkspaceStatus("canceling", workspace), + }), }, guards: { moreBuildsAvailable, + // We only want to update the workspace when there are changes to it to + // avoid re-renderings and allow optimistically updates to improve the UI. + // When updating the workspace every second, the optimistic updates that + // were applied before get lost since it will be rewrite. + hasUpdates: ({ workspace }, event: { data: TypesGen.Workspace }) => { + if (!workspace) { + throw new Error("Workspace not defined") + } + const isWorkspaceUpdated = isUpdated( + event.data.updated_at, + workspace.updated_at, + ) + const isBuildUpdated = isUpdated( + event.data.latest_build.updated_at, + workspace.latest_build.updated_at, + ) + return isWorkspaceUpdated || isBuildUpdated + }, }, services: { getWorkspace: async (_, event) => {