Skip to content

Commit a2be7c0

Browse files
authored
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
1 parent 3dbcddc commit a2be7c0

File tree

14 files changed

+261
-66
lines changed

14 files changed

+261
-66
lines changed

site/src/AppRouter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePa
1919
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
2020
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
2121
import { UsersPage } from "./pages/UsersPage/UsersPage"
22-
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
22+
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
2323

2424
const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage"))
2525

site/src/api/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const provisioners: Types.Provisioner[] = [
2020

2121
export namespace Workspace {
2222
export const create = async (request: Types.CreateWorkspaceRequest): Promise<Types.Workspace> => {
23-
const response = await fetch(`/api/v2/users/me/workspaces`, {
23+
const response = await fetch(`/api/v2/organizations/${request.organization_id}/workspaces`, {
2424
method: "POST",
2525
headers: {
2626
"Content-Type": "application/json",
@@ -80,12 +80,27 @@ export const getUsers = async (): Promise<TypesGen.User[]> => {
8080
return response.data
8181
}
8282

83+
export const getOrganization = async (organizationId: string): Promise<Types.Organization> => {
84+
const response = await axios.get<Types.Organization>(`/api/v2/organizations/${organizationId}`)
85+
return response.data
86+
}
87+
8388
export const getOrganizations = async (): Promise<Types.Organization[]> => {
8489
const response = await axios.get<Types.Organization[]>("/api/v2/users/me/organizations")
8590
return response.data
8691
}
8792

88-
export const getWorkspace = async (
93+
export const getTemplate = async (templateId: string): Promise<Types.Template> => {
94+
const response = await axios.get<Types.Template>(`/api/v2/templates/${templateId}`)
95+
return response.data
96+
}
97+
98+
export const getWorkspace = async (workspaceId: string): Promise<Types.Workspace> => {
99+
const response = await axios.get<Types.Workspace>(`/api/v2/workspaces/${workspaceId}`)
100+
return response.data
101+
}
102+
103+
export const getWorkspaceByOwnerAndName = async (
89104
organizationID: string,
90105
username = "me",
91106
workspaceName: string,

site/src/api/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface CreateTemplateRequest {
6161
export interface CreateWorkspaceRequest {
6262
name: string
6363
template_id: string
64+
organization_id: string
6465
}
6566

6667
export interface WorkspaceBuild {

site/src/forms/CreateWorkspaceForm.test.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen } from "@testing-library/react"
22
import React from "react"
3-
import { MockTemplate, MockWorkspace } from "../testHelpers"
3+
import { MockOrganization, MockTemplate, MockWorkspace } from "../testHelpers"
44
import { CreateWorkspaceForm } from "./CreateWorkspaceForm"
55

66
describe("CreateWorkspaceForm", () => {
@@ -10,7 +10,14 @@ describe("CreateWorkspaceForm", () => {
1010
const onCancel = () => Promise.resolve()
1111

1212
// When
13-
render(<CreateWorkspaceForm template={MockTemplate} onSubmit={onSubmit} onCancel={onCancel} />)
13+
render(
14+
<CreateWorkspaceForm
15+
template={MockTemplate}
16+
onSubmit={onSubmit}
17+
onCancel={onCancel}
18+
organization_id={MockOrganization.id}
19+
/>,
20+
)
1421

1522
// Then
1623
// Simple smoke test to verify form renders

site/src/forms/CreateWorkspaceForm.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ export interface CreateWorkspaceForm {
1515
template: Template
1616
onSubmit: (request: CreateWorkspaceRequest) => Promise<Workspace>
1717
onCancel: () => void
18+
organization_id: string
1819
}
1920

2021
const validationSchema = Yup.object({
2122
name: Yup.string().required("Name is required"),
2223
})
2324

24-
export const CreateWorkspaceForm: React.FC<CreateWorkspaceForm> = ({ template, onSubmit, onCancel }) => {
25+
export const CreateWorkspaceForm: React.FC<CreateWorkspaceForm> = ({
26+
template,
27+
onSubmit,
28+
onCancel,
29+
organization_id,
30+
}) => {
2531
const styles = useStyles()
2632

2733
const form: FormikContextType<{ name: string }> = useFormik<{ name: string }>({
@@ -34,6 +40,7 @@ export const CreateWorkspaceForm: React.FC<CreateWorkspaceForm> = ({ template, o
3440
return onSubmit({
3541
template_id: template.id,
3642
name: name,
43+
organization_id,
3744
})
3845
},
3946
})

site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { makeStyles } from "@material-ui/core/styles"
2-
import React, { useCallback } from "react"
2+
import { useSelector } from "@xstate/react"
3+
import React, { useCallback, useContext } from "react"
34
import { useNavigate, useParams } from "react-router-dom"
45
import useSWR from "swr"
56
import * as API from "../../../../api"
@@ -8,12 +9,17 @@ import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary"
89
import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader"
910
import { CreateWorkspaceForm } from "../../../../forms/CreateWorkspaceForm"
1011
import { unsafeSWRArgument } from "../../../../util"
12+
import { selectOrgId } from "../../../../xServices/auth/authSelectors"
13+
import { XServiceContext } from "../../../../xServices/StateContext"
1114

1215
export const CreateWorkspacePage: React.FC = () => {
1316
const { organization: organizationName, template: templateName } = useParams()
1417
const navigate = useNavigate()
1518
const styles = useStyles()
1619

20+
const xServices = useContext(XServiceContext)
21+
const myOrgId = useSelector(xServices.authXService, selectOrgId)
22+
1723
const { data: organizationInfo, error: organizationError } = useSWR<Types.Organization, Error>(
1824
() => `/api/v2/users/me/organizations/${organizationName}`,
1925
)
@@ -44,9 +50,13 @@ export const CreateWorkspacePage: React.FC = () => {
4450
return <FullScreenLoader />
4551
}
4652

53+
if (!myOrgId) {
54+
return <ErrorSummary error={Error("no organization id")} />
55+
}
56+
4757
return (
4858
<div className={styles.root}>
49-
<CreateWorkspaceForm onCancel={onCancel} onSubmit={onSubmit} template={template} />
59+
<CreateWorkspaceForm onCancel={onCancel} onSubmit={onSubmit} template={template} organization_id={myOrgId} />
5060
</div>
5161
)
5262
}

site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export const TemplatePage: React.FC = () => {
2626

2727
// This just grabs all workspaces... and then later filters them to match the
2828
// current template.
29-
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(() => `/api/v2/users/me/workspaces`)
29+
30+
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(
31+
() => `/api/v2/organizations/${unsafeSWRArgument(organizationInfo).id}/workspaces`,
32+
)
3033

3134
if (organizationError) {
3235
return <ErrorSummary error={organizationError} />
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { screen } from "@testing-library/react"
2+
import React from "react"
3+
import { MockTemplate, MockWorkspace, renderWithAuth } from "../../testHelpers"
4+
import { WorkspacePage } from "./WorkspacePage"
5+
6+
describe("Workspace Page", () => {
7+
it("shows a workspace", async () => {
8+
renderWithAuth(<WorkspacePage />, { route: `/workspaces/${MockWorkspace.id}`, path: "/workspaces/:workspace" })
9+
const workspaceName = await screen.findByText(MockWorkspace.name)
10+
const templateName = await screen.findByText(MockTemplate.name)
11+
expect(workspaceName).toBeDefined()
12+
expect(templateName).toBeDefined()
13+
})
14+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useActor } from "@xstate/react"
2+
import React, { useContext, useEffect } from "react"
3+
import { useParams } from "react-router-dom"
4+
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
5+
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
6+
import { Margins } from "../../components/Margins/Margins"
7+
import { Stack } from "../../components/Stack/Stack"
8+
import { Workspace } from "../../components/Workspace/Workspace"
9+
import { firstOrItem } from "../../util/array"
10+
import { XServiceContext } from "../../xServices/StateContext"
11+
12+
export const WorkspacePage: React.FC = () => {
13+
const { workspace: workspaceQueryParam } = useParams()
14+
const workspaceId = firstOrItem(workspaceQueryParam, null)
15+
16+
const xServices = useContext(XServiceContext)
17+
const [workspaceState, workspaceSend] = useActor(xServices.workspaceXService)
18+
const { workspace, template, organization, getWorkspaceError, getTemplateError, getOrganizationError } =
19+
workspaceState.context
20+
21+
/**
22+
* Get workspace, template, and organization on mount and whenever workspaceId changes.
23+
* workspaceSend should not change.
24+
*/
25+
useEffect(() => {
26+
workspaceId && workspaceSend({ type: "GET_WORKSPACE", workspaceId })
27+
}, [workspaceId, workspaceSend])
28+
29+
if (workspaceState.matches("error")) {
30+
return <ErrorSummary error={getWorkspaceError || getTemplateError || getOrganizationError} />
31+
} else if (!workspace || !template || !organization) {
32+
return <FullScreenLoader />
33+
} else {
34+
return (
35+
<Margins>
36+
<Stack spacing={4}>
37+
<Workspace organization={organization} template={template} workspace={workspace} />
38+
</Stack>
39+
</Margins>
40+
)
41+
}
42+
}

site/src/pages/WorkspacesPage/WorkspacesPage.tsx

Lines changed: 0 additions & 54 deletions
This file was deleted.

site/src/testHelpers/index.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,22 @@ export const render = (component: React.ReactElement): RenderResult => {
2626

2727
type RenderWithAuthResult = RenderResult & { user: typeof MockUser }
2828

29-
export function renderWithAuth(ui: JSX.Element, { route = "/" }: { route?: string } = {}): RenderWithAuthResult {
29+
/**
30+
*
31+
* @param ui The component to render and test
32+
* @param options Can contain `route`, the URL to use, such as /users/user1, and `path`,
33+
* such as /users/:userid. When there are no parameters, they are the same and you can just supply `route`.
34+
*/
35+
export function renderWithAuth(
36+
ui: JSX.Element,
37+
{ route = "/", path }: { route?: string; path?: string } = {},
38+
): RenderWithAuthResult {
3039
const renderResult = wrappedRender(
3140
<MemoryRouter initialEntries={[route]}>
3241
<XServiceProvider>
3342
<ThemeProvider theme={dark}>
3443
<Routes>
35-
<Route path={route} element={<RequireAuth>{ui}</RequireAuth>} />
44+
<Route path={path ?? route} element={<RequireAuth>{ui}</RequireAuth>} />
3645
</Routes>
3746
</ThemeProvider>
3847
</XServiceProvider>

site/src/xServices/StateContext.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { ActorRefFrom } from "xstate"
55
import { authMachine } from "./auth/authXService"
66
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
77
import { usersMachine } from "./users/usersXService"
8+
import { workspaceMachine } from "./workspace/workspaceXService"
89

910
interface XServiceContextType {
1011
authXService: ActorRefFrom<typeof authMachine>
1112
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
1213
usersXService: ActorRefFrom<typeof usersMachine>
14+
workspaceXService: ActorRefFrom<typeof workspaceMachine>
1315
}
1416

1517
/**
@@ -34,6 +36,7 @@ export const XServiceProvider: React.FC = ({ children }) => {
3436
authXService: useInterpret(authMachine),
3537
buildInfoXService: useInterpret(buildInfoMachine),
3638
usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })),
39+
workspaceXService: useInterpret(workspaceMachine),
3740
}}
3841
>
3942
{children}

site/src/xServices/terminal/terminalXService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export const terminalMachine =
154154
if (!context.organizations || !context.workspaceName) {
155155
throw new Error("organizations or workspace not set")
156156
}
157-
return API.getWorkspace(context.organizations[0].id, context.username, context.workspaceName)
157+
return API.getWorkspaceByOwnerAndName(context.organizations[0].id, context.username, context.workspaceName)
158158
},
159159
getWorkspaceAgent: async (context) => {
160160
if (!context.workspace || !context.workspaceName) {

0 commit comments

Comments
 (0)