mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: create and read workspace page (#1294)
* Change name of existing workspace call * Add new api call (has handler already) * WorkspacesPage -> WorkspacePage * starting to replace swr * Add other api calls * Fix api call * Replace swr with xstate * Format * Test - wip * Fix route in template page * Fix endpoint in create workspace * Fix tests * Lint
This commit is contained in:
@@ -19,7 +19,7 @@ import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePa
|
||||
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
|
||||
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
|
||||
import { UsersPage } from "./pages/UsersPage/UsersPage"
|
||||
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
|
||||
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
|
||||
|
||||
const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage"))
|
||||
|
||||
|
||||
+17
-2
@@ -20,7 +20,7 @@ export const provisioners: Types.Provisioner[] = [
|
||||
|
||||
export namespace Workspace {
|
||||
export const create = async (request: Types.CreateWorkspaceRequest): Promise<Types.Workspace> => {
|
||||
const response = await fetch(`/api/v2/users/me/workspaces`, {
|
||||
const response = await fetch(`/api/v2/organizations/${request.organization_id}/workspaces`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -80,12 +80,27 @@ export const getUsers = async (): Promise<TypesGen.User[]> => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getOrganization = async (organizationId: string): Promise<Types.Organization> => {
|
||||
const response = await axios.get<Types.Organization>(`/api/v2/organizations/${organizationId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getOrganizations = async (): Promise<Types.Organization[]> => {
|
||||
const response = await axios.get<Types.Organization[]>("/api/v2/users/me/organizations")
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspace = async (
|
||||
export const getTemplate = async (templateId: string): Promise<Types.Template> => {
|
||||
const response = await axios.get<Types.Template>(`/api/v2/templates/${templateId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspace = async (workspaceId: string): Promise<Types.Workspace> => {
|
||||
const response = await axios.get<Types.Workspace>(`/api/v2/workspaces/${workspaceId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspaceByOwnerAndName = async (
|
||||
organizationID: string,
|
||||
username = "me",
|
||||
workspaceName: string,
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface CreateTemplateRequest {
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string
|
||||
template_id: string
|
||||
organization_id: string
|
||||
}
|
||||
|
||||
export interface WorkspaceBuild {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import React from "react"
|
||||
import { MockTemplate, MockWorkspace } from "../testHelpers"
|
||||
import { MockOrganization, MockTemplate, MockWorkspace } from "../testHelpers"
|
||||
import { CreateWorkspaceForm } from "./CreateWorkspaceForm"
|
||||
|
||||
describe("CreateWorkspaceForm", () => {
|
||||
@@ -10,7 +10,14 @@ describe("CreateWorkspaceForm", () => {
|
||||
const onCancel = () => Promise.resolve()
|
||||
|
||||
// When
|
||||
render(<CreateWorkspaceForm template={MockTemplate} onSubmit={onSubmit} onCancel={onCancel} />)
|
||||
render(
|
||||
<CreateWorkspaceForm
|
||||
template={MockTemplate}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
organization_id={MockOrganization.id}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Then
|
||||
// Simple smoke test to verify form renders
|
||||
|
||||
@@ -15,13 +15,19 @@ export interface CreateWorkspaceForm {
|
||||
template: Template
|
||||
onSubmit: (request: CreateWorkspaceRequest) => Promise<Workspace>
|
||||
onCancel: () => void
|
||||
organization_id: string
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: Yup.string().required("Name is required"),
|
||||
})
|
||||
|
||||
export const CreateWorkspaceForm: React.FC<CreateWorkspaceForm> = ({ template, onSubmit, onCancel }) => {
|
||||
export const CreateWorkspaceForm: React.FC<CreateWorkspaceForm> = ({
|
||||
template,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
organization_id,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
||||
const form: FormikContextType<{ name: string }> = useFormik<{ name: string }>({
|
||||
@@ -34,6 +40,7 @@ export const CreateWorkspaceForm: React.FC<CreateWorkspaceForm> = ({ template, o
|
||||
return onSubmit({
|
||||
template_id: template.id,
|
||||
name: name,
|
||||
organization_id,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
+12
-2
@@ -1,5 +1,6 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import React, { useCallback } from "react"
|
||||
import { useSelector } from "@xstate/react"
|
||||
import React, { useCallback, useContext } from "react"
|
||||
import { useNavigate, useParams } from "react-router-dom"
|
||||
import useSWR from "swr"
|
||||
import * as API from "../../../../api"
|
||||
@@ -8,12 +9,17 @@ import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary"
|
||||
import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader"
|
||||
import { CreateWorkspaceForm } from "../../../../forms/CreateWorkspaceForm"
|
||||
import { unsafeSWRArgument } from "../../../../util"
|
||||
import { selectOrgId } from "../../../../xServices/auth/authSelectors"
|
||||
import { XServiceContext } from "../../../../xServices/StateContext"
|
||||
|
||||
export const CreateWorkspacePage: React.FC = () => {
|
||||
const { organization: organizationName, template: templateName } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const styles = useStyles()
|
||||
|
||||
const xServices = useContext(XServiceContext)
|
||||
const myOrgId = useSelector(xServices.authXService, selectOrgId)
|
||||
|
||||
const { data: organizationInfo, error: organizationError } = useSWR<Types.Organization, Error>(
|
||||
() => `/api/v2/users/me/organizations/${organizationName}`,
|
||||
)
|
||||
@@ -44,9 +50,13 @@ export const CreateWorkspacePage: React.FC = () => {
|
||||
return <FullScreenLoader />
|
||||
}
|
||||
|
||||
if (!myOrgId) {
|
||||
return <ErrorSummary error={Error("no organization id")} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<CreateWorkspaceForm onCancel={onCancel} onSubmit={onSubmit} template={template} />
|
||||
<CreateWorkspaceForm onCancel={onCancel} onSubmit={onSubmit} template={template} organization_id={myOrgId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ export const TemplatePage: React.FC = () => {
|
||||
|
||||
// This just grabs all workspaces... and then later filters them to match the
|
||||
// current template.
|
||||
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(() => `/api/v2/users/me/workspaces`)
|
||||
|
||||
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(
|
||||
() => `/api/v2/organizations/${unsafeSWRArgument(organizationInfo).id}/workspaces`,
|
||||
)
|
||||
|
||||
if (organizationError) {
|
||||
return <ErrorSummary error={organizationError} />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { screen } from "@testing-library/react"
|
||||
import React from "react"
|
||||
import { MockTemplate, MockWorkspace, renderWithAuth } from "../../testHelpers"
|
||||
import { WorkspacePage } from "./WorkspacePage"
|
||||
|
||||
describe("Workspace Page", () => {
|
||||
it("shows a workspace", async () => {
|
||||
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
|
||||
const workspaceName = await screen.findByText(MockWorkspace.name)
|
||||
const templateName = await screen.findByText(MockTemplate.name)
|
||||
expect(workspaceName).toBeDefined()
|
||||
expect(templateName).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useActor } from "@xstate/react"
|
||||
import React, { useContext, useEffect } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import { Stack } from "../../components/Stack/Stack"
|
||||
import { Workspace } from "../../components/Workspace/Workspace"
|
||||
import { firstOrItem } from "../../util/array"
|
||||
import { XServiceContext } from "../../xServices/StateContext"
|
||||
|
||||
export const WorkspacePage: React.FC = () => {
|
||||
const { workspace: workspaceQueryParam } = useParams()
|
||||
const workspaceId = firstOrItem(workspaceQueryParam, null)
|
||||
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService)
|
||||
const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } =
|
||||
workspaceState.context
|
||||
|
||||
/**
|
||||
* Get workspace, template, and organization on mount and whenever workspaceId changes.
|
||||
* workspaceSend should not change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
workspaceId && workspaceSend({ type: "GET_WORKSPACE", workspaceId })
|
||||
}, [workspaceId, workspaceSend])
|
||||
|
||||
if (workspaceState.matches("error")) {
|
||||
return <ErrorSummary error={getWorkspaceError || getTemplateError || getOrganizationError} />
|
||||
} else if (!workspace || !template || !organization) {
|
||||
return <FullScreenLoader />
|
||||
} else {
|
||||
return (
|
||||
<Margins>
|
||||
<Stack spacing={4}>
|
||||
<Workspace organization={organization} template={template} workspace={workspace} />
|
||||
</Stack>
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import useSWR from "swr"
|
||||
import * as Types from "../../api/types"
|
||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import { Stack } from "../../components/Stack/Stack"
|
||||
import { Workspace } from "../../components/Workspace/Workspace"
|
||||
import { unsafeSWRArgument } from "../../util"
|
||||
import { firstOrItem } from "../../util/array"
|
||||
|
||||
export const WorkspacePage: React.FC = () => {
|
||||
const { workspace: workspaceQueryParam } = useParams()
|
||||
|
||||
const { data: workspace, error: workspaceError } = useSWR<Types.Workspace, Error>(() => {
|
||||
const workspaceParam = firstOrItem(workspaceQueryParam, null)
|
||||
|
||||
return `/api/v2/workspaces/${workspaceParam}`
|
||||
})
|
||||
|
||||
// Fetch parent template
|
||||
const { data: template, error: templateError } = useSWR<Types.Template, Error>(() => {
|
||||
return `/api/v2/templates/${unsafeSWRArgument(workspace).template_id}`
|
||||
})
|
||||
|
||||
const { data: organization, error: organizationError } = useSWR<Types.Template, Error>(() => {
|
||||
return `/api/v2/organizations/${unsafeSWRArgument(template).organization_id}`
|
||||
})
|
||||
|
||||
if (workspaceError) {
|
||||
return <ErrorSummary error={workspaceError} />
|
||||
}
|
||||
|
||||
if (templateError) {
|
||||
return <ErrorSummary error={templateError} />
|
||||
}
|
||||
|
||||
if (organizationError) {
|
||||
return <ErrorSummary error={organizationError} />
|
||||
}
|
||||
|
||||
if (!workspace || !template || !organization) {
|
||||
return <FullScreenLoader />
|
||||
}
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
<Stack spacing={4}>
|
||||
<Workspace organization={organization} template={template} workspace={workspace} />
|
||||
</Stack>
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
@@ -26,13 +26,22 @@ export const render = (component: React.ReactElement): RenderResult => {
|
||||
|
||||
type RenderWithAuthResult = RenderResult & { user: typeof MockUser }
|
||||
|
||||
export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult {
|
||||
/**
|
||||
*
|
||||
* @param ui The component to render and test
|
||||
* @param options Can contain `route`, the URL to use, such as /users/user1, and `path`,
|
||||
* such as /users/:userid. When there are no parameters, they are the same and you can just supply `route`.
|
||||
*/
|
||||
export function renderWithAuth(
|
||||
ui: JSX.Element,
|
||||
{ route = "/", path }: { route?: string; path?: string } = {},
|
||||
): RenderWithAuthResult {
|
||||
const renderResult = wrappedRender(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<XServiceProvider>
|
||||
<ThemeProvider theme={dark}>
|
||||
<Routes>
|
||||
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
|
||||
<Route path={path ?? route} element={<RequireAuth>{ui}</RequireAuth>} />
|
||||
</Routes>
|
||||
</ThemeProvider>
|
||||
</XServiceProvider>
|
||||
|
||||
@@ -5,11 +5,13 @@ import { ActorRefFrom } from "xstate"
|
||||
import { authMachine } from "./auth/authXService"
|
||||
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
||||
import { usersMachine } from "./users/usersXService"
|
||||
import { workspaceMachine } from "./workspace/workspaceXService"
|
||||
|
||||
interface XServiceContextType {
|
||||
authXService: ActorRefFrom<typeof authMachine>
|
||||
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
||||
usersXService: ActorRefFrom<typeof usersMachine>
|
||||
workspaceXService: ActorRefFrom<typeof workspaceMachine>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,7 @@ export const XServiceProvider: React.FC = ({ children }) => {
|
||||
authXService: useInterpret(authMachine),
|
||||
buildInfoXService: useInterpret(buildInfoMachine),
|
||||
usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })),
|
||||
workspaceXService: useInterpret(workspaceMachine),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -154,7 +154,7 @@ export const terminalMachine =
|
||||
if (!context.organizations || !context.workspaceName) {
|
||||
throw new Error("organizations or workspace not set")
|
||||
}
|
||||
return API.getWorkspace(context.organizations[0].id, context.username, context.workspaceName)
|
||||
return API.getWorkspaceByOwnerAndName(context.organizations[0].id, context.username, context.workspaceName)
|
||||
},
|
||||
getWorkspaceAgent: async (context) => {
|
||||
if (!context.workspace || !context.workspaceName) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { assign, createMachine } from "xstate"
|
||||
import * as API from "../../api"
|
||||
import * as Types from "../../api/types"
|
||||
|
||||
interface WorkspaceContext {
|
||||
workspace?: Types.Workspace
|
||||
template?: Types.Template
|
||||
organization?: Types.Organization
|
||||
getWorkspaceError?: Error | unknown
|
||||
getTemplateError?: Error | unknown
|
||||
getOrganizationError?: Error | unknown
|
||||
}
|
||||
|
||||
type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string }
|
||||
|
||||
export const workspaceMachine = createMachine(
|
||||
{
|
||||
tsTypes: {} as import("./workspaceXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as WorkspaceContext,
|
||||
events: {} as WorkspaceEvent,
|
||||
services: {} as {
|
||||
getWorkspace: {
|
||||
data: Types.Workspace
|
||||
}
|
||||
getTemplate: {
|
||||
data: Types.Template
|
||||
}
|
||||
getOrganization: {
|
||||
data: Types.Organization
|
||||
}
|
||||
},
|
||||
},
|
||||
id: "workspaceState",
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
GET_WORKSPACE: "gettingWorkspace",
|
||||
},
|
||||
},
|
||||
gettingWorkspace: {
|
||||
invoke: {
|
||||
src: "getWorkspace",
|
||||
id: "getWorkspace",
|
||||
onDone: {
|
||||
target: "gettingTemplate",
|
||||
actions: ["assignWorkspace", "clearGetWorkspaceError"],
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: "assignGetWorkspaceError",
|
||||
},
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
gettingTemplate: {
|
||||
invoke: {
|
||||
src: "getTemplate",
|
||||
id: "getTemplate",
|
||||
onDone: {
|
||||
target: "gettingOrganization",
|
||||
actions: ["assignTemplate", "clearGetTemplateError"],
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: "assignGetTemplateError",
|
||||
},
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
gettingOrganization: {
|
||||
invoke: {
|
||||
src: "getOrganization",
|
||||
id: "getOrganization",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignOrganization", "clearGetOrganizationError"],
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: "assignGetOrganizationError",
|
||||
},
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
GET_WORKSPACE: "gettingWorkspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
assignWorkspace: assign({
|
||||
workspace: (_, event) => event.data,
|
||||
}),
|
||||
assignGetWorkspaceError: assign({
|
||||
getWorkspaceError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetWorkspaceError: (context) => assign({ ...context, getWorkspaceError: undefined }),
|
||||
assignTemplate: assign({
|
||||
template: (_, event) => event.data,
|
||||
}),
|
||||
assignGetTemplateError: assign({
|
||||
getTemplateError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetTemplateError: (context) => assign({ ...context, getTemplateError: undefined }),
|
||||
assignOrganization: assign({
|
||||
organization: (_, event) => event.data,
|
||||
}),
|
||||
assignGetOrganizationError: assign({
|
||||
getOrganizationError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetOrganizationError: (context) => assign({ ...context, getOrganizationError: undefined }),
|
||||
},
|
||||
services: {
|
||||
getWorkspace: async (_, event) => {
|
||||
return await API.getWorkspace(event.workspaceId)
|
||||
},
|
||||
getTemplate: async (context) => {
|
||||
if (context.workspace) {
|
||||
return await API.getTemplate(context.workspace.template_id)
|
||||
} else {
|
||||
throw Error("Cannot get template without workspace")
|
||||
}
|
||||
},
|
||||
getOrganization: async (context) => {
|
||||
if (context.template) {
|
||||
return await API.getOrganization(context.template.organization_id)
|
||||
} else {
|
||||
throw Error("Cannot get organization without template")
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user