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:
Presley Pizzo
2022-05-06 10:02:17 -04:00
committed by GitHub
parent 3dbcddc310
commit a2be7c0294
14 changed files with 261 additions and 66 deletions
+1 -1
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -61,6 +61,7 @@ export interface CreateTemplateRequest {
export interface CreateWorkspaceRequest {
name: string
template_id: string
organization_id: string
}
export interface WorkspaceBuild {
+9 -2
View File
@@ -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
+8 -1
View File
@@ -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,
})
},
})
@@ -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>
)
}
+11 -2
View File
@@ -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>
+3
View File
@@ -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")
}
},
},
},
)