mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): allow starting task workspace from task page (#19790)
Closes https://github.com/coder/coder/issues/19622 When viewing a task with a stopped workspace, instead show a 'Start workspace' button instead of a 'View workspace'. The user can still view the workspace by clicking the workspace button at the top right of the page. https://github.com/user-attachments/assets/4424c251-5f20-4e82-9ee0-c87a0b30a193
This commit is contained in:
@@ -11,14 +11,20 @@ import {
|
||||
MockWorkspaceResource,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import { withProxyProvider, withWebSocket } from "testHelpers/storybook";
|
||||
import {
|
||||
withGlobalSnackbar,
|
||||
withProxyProvider,
|
||||
withWebSocket,
|
||||
} from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceApp,
|
||||
WorkspaceResource,
|
||||
} from "api/typesGenerated";
|
||||
import { expect, spyOn, within } from "storybook/test";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import TaskPage, { data, WorkspaceDoesNotHaveAITaskError } from "./TaskPage";
|
||||
|
||||
const meta: Meta<typeof TaskPage> = {
|
||||
@@ -351,3 +357,117 @@ export const ActivePreview: Story = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceStartFailure: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "startWorkspace").mockRejectedValue(
|
||||
new Error("Some unexpected error"),
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
pathParams: {
|
||||
username: MockStoppedWorkspace.owner_name,
|
||||
workspace: MockStoppedWorkspace.name,
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
path: "/tasks/:username/:workspace",
|
||||
},
|
||||
}),
|
||||
queries: [
|
||||
{
|
||||
key: [
|
||||
"tasks",
|
||||
MockStoppedWorkspace.owner_name,
|
||||
MockStoppedWorkspace.name,
|
||||
],
|
||||
data: {
|
||||
prompt: "Create competitors page",
|
||||
workspace: MockStoppedWorkspace,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: ["workspace", MockStoppedWorkspace.id, "parameters"],
|
||||
data: {
|
||||
templateVersionRichParameters: [],
|
||||
buildParameters: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const startButton = await canvas.findByText("Start workspace");
|
||||
expect(startButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(startButton);
|
||||
|
||||
await waitFor(async () => {
|
||||
const errorMessage = await canvas.findByText("Some unexpected error");
|
||||
expect(errorMessage).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceStartFailureWithDialog: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "startWorkspace").mockRejectedValue({
|
||||
...mockApiError({
|
||||
message: "Bad Request",
|
||||
detail: "Invalid build parameters provided",
|
||||
}),
|
||||
code: "ERR_BAD_REQUEST",
|
||||
});
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: {
|
||||
pathParams: {
|
||||
username: MockStoppedWorkspace.owner_name,
|
||||
workspace: MockStoppedWorkspace.name,
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
path: "/tasks/:username/:workspace",
|
||||
},
|
||||
}),
|
||||
queries: [
|
||||
{
|
||||
key: [
|
||||
"tasks",
|
||||
MockStoppedWorkspace.owner_name,
|
||||
MockStoppedWorkspace.name,
|
||||
],
|
||||
data: {
|
||||
prompt: "Create competitors page",
|
||||
workspace: MockStoppedWorkspace,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: ["workspace", MockStoppedWorkspace.id, "parameters"],
|
||||
data: {
|
||||
templateVersionRichParameters: [],
|
||||
buildParameters: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const startButton = await canvas.findByText("Start workspace");
|
||||
expect(startButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(startButton);
|
||||
|
||||
await waitFor(async () => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const dialogTitle = await body.findByText("Error building workspace");
|
||||
expect(dialogTitle).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import { getErrorDetail, getErrorMessage, isApiError } from "api/errors";
|
||||
import { template as templateQueryOptions } from "api/queries/templates";
|
||||
import { startWorkspace } from "api/queries/workspaces";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
@@ -8,6 +9,7 @@ import type {
|
||||
} from "api/typesGenerated";
|
||||
import isChromatic from "chromatic/isChromatic";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
@@ -16,10 +18,11 @@ import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
||||
import { useAgentLogs } from "modules/resources/useAgentLogs";
|
||||
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
|
||||
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQuery } from "react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Link as RouterLink, useParams } from "react-router";
|
||||
import type { FixedSizeList } from "react-window";
|
||||
@@ -119,27 +122,7 @@ const TaskPage = () => {
|
||||
</div>
|
||||
);
|
||||
} else if (task.workspace.latest_build.status !== "running") {
|
||||
content = (
|
||||
<Margins>
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Workspace is not running
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Apps and previous statuses are not available
|
||||
</span>
|
||||
<Button size="sm" className="mt-4" asChild>
|
||||
<RouterLink
|
||||
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
|
||||
>
|
||||
View workspace
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Margins>
|
||||
);
|
||||
content = <WorkspaceNotRunning task={task} />;
|
||||
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
|
||||
content = <TaskStartingAgent agent={agent} />;
|
||||
} else {
|
||||
@@ -174,6 +157,73 @@ const TaskPage = () => {
|
||||
|
||||
export default TaskPage;
|
||||
|
||||
type WorkspaceNotRunningProps = {
|
||||
task: Task;
|
||||
};
|
||||
|
||||
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({ task }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: parameters } = useQuery({
|
||||
queryKey: ["workspace", task.workspace.id, "parameters"],
|
||||
queryFn: () => API.getWorkspaceParameters(task.workspace),
|
||||
});
|
||||
|
||||
const mutateStartWorkspace = useMutation({
|
||||
...startWorkspace(task?.workspace, queryClient),
|
||||
onError: (error: unknown) => {
|
||||
if (!isApiError(error)) {
|
||||
displayError(getErrorMessage(error, "Failed to build workspace."));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const apiError = isApiError(mutateStartWorkspace.error)
|
||||
? mutateStartWorkspace.error
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Workspace is not running
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Apps and previous statuses are not available
|
||||
</span>
|
||||
<div className="flex flex-row mt-4 gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={mutateStartWorkspace.isPending}
|
||||
onClick={() => {
|
||||
mutateStartWorkspace.mutate({
|
||||
buildParameters: parameters?.buildParameters,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{mutateStartWorkspace.isPending
|
||||
? "Starting workspace..."
|
||||
: "Start workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorkspaceErrorDialog
|
||||
open={apiError !== undefined}
|
||||
error={apiError}
|
||||
onClose={mutateStartWorkspace.reset}
|
||||
showDetail={true}
|
||||
workspaceOwner={task.workspace.owner_name}
|
||||
workspaceName={task.workspace.name}
|
||||
templateVersionId={task.workspace.latest_build.template_version_id}
|
||||
isDeleting={false}
|
||||
/>
|
||||
</Margins>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskBuildingWorkspaceProps = { task: Task };
|
||||
|
||||
const TaskBuildingWorkspace: FC<TaskBuildingWorkspaceProps> = ({ task }) => {
|
||||
|
||||
Reference in New Issue
Block a user