fix: Update routes for project page, workspace creation page, and workspace page (#415)

Some API routes were updated in https://github.com/coder/coder/pull/401, which impacted the UX - the flow is currently broken when trying to navigate to a project:

![2022-03-08 15 30 59](https://user-images.githubusercontent.com/88213859/157343533-3d08edf1-70d5-433b-b4a0-fe68875b1928.gif)

This fixes all the routes so that the complete project -> create workspace -> workspace page flow works:

![2022-03-08 16 18 57](https://user-images.githubusercontent.com/88213859/157348186-b9bde553-c602-484e-89bc-208a1d97f703.gif)

Because this had to touch a bunch of UI routes, I also opportunistically fixed #380 as part of this change.

Fixes #380
This commit is contained in:
Bryan
2022-03-09 13:22:43 -08:00
committed by GitHub
parent ac387a1410
commit 9f190418bb
9 changed files with 152 additions and 97 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ export interface Workspace {
export namespace Workspace {
export const create = async (request: CreateWorkspaceRequest): Promise<Workspace> => {
const response = await fetch(`/api/v2/workspaces/me`, {
const response = await fetch(`/api/v2/users/me/workspaces`, {
method: "POST",
headers: {
"Content-Type": "application/json",
+2 -2
View File
@@ -1,12 +1,12 @@
import { render, screen } from "@testing-library/react"
import React from "react"
import { Workspace } from "./Workspace"
import { MockWorkspace } from "../../test_helpers"
import { MockOrganization, MockProject, MockWorkspace } from "../../test_helpers"
describe("Workspace", () => {
it("renders", async () => {
// When
render(<Workspace workspace={MockWorkspace} />)
render(<Workspace organization={MockOrganization} project={MockProject} workspace={MockWorkspace} />)
// Then
const element = await screen.findByText(MockWorkspace.name)
+8 -4
View File
@@ -10,19 +10,21 @@ import * as API from "../../api"
import { WorkspaceSection } from "./WorkspaceSection"
export interface WorkspaceProps {
organization: API.Organization
workspace: API.Workspace
project: API.Project
}
/**
* Workspace is the top-level component for viewing an individual workspace
*/
export const Workspace: React.FC<WorkspaceProps> = ({ workspace }) => {
export const Workspace: React.FC<WorkspaceProps> = ({ organization, project, workspace }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.vertical}>
<WorkspaceHeader workspace={workspace} />
<WorkspaceHeader organization={organization} project={project} workspace={workspace} />
<div className={styles.horizontal}>
<div className={styles.sidebarContainer}>
<WorkspaceSection title="Applications">
@@ -54,9 +56,11 @@ export const Workspace: React.FC<WorkspaceProps> = ({ workspace }) => {
/**
* Component for the header at the top of the workspace page
*/
export const WorkspaceHeader: React.FC<WorkspaceProps> = ({ workspace }) => {
export const WorkspaceHeader: React.FC<WorkspaceProps> = ({ organization, project, workspace }) => {
const styles = useStyles()
const projectLink = `/projects/${organization.name}/${project.name}`
return (
<Paper elevation={0} className={styles.section}>
<div className={styles.horizontal}>
@@ -64,7 +68,7 @@ export const WorkspaceHeader: React.FC<WorkspaceProps> = ({ workspace }) => {
<div className={styles.vertical}>
<Typography variant="h4">{workspace.name}</Typography>
<Typography variant="body2" color="textSecondary">
<Link href="javascript:;">{workspace.project_id}</Link>
<Link href={projectLink}>{project.name}</Link>
</Typography>
</div>
</div>
@@ -8,26 +8,36 @@ import { useUser } from "../../../../contexts/UserContext"
import { ErrorSummary } from "../../../../components/ErrorSummary"
import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader"
import { CreateWorkspaceForm } from "../../../../forms/CreateWorkspaceForm"
import { unsafeSWRArgument } from "../../../../util"
const CreateWorkspacePage: React.FC = () => {
const { push, query } = useRouter()
const styles = useStyles()
const { me } = useUser(/* redirectOnError */ true)
const { organization, project: projectName } = query
const { data: project, error: projectError } = useSWR<API.Project, Error>(
`/api/v2/projects/${organization}/${projectName}`,
const { organization: organizationName, project: projectName } = query
const { data: organizationInfo, error: organizationError } = useSWR<API.Organization, Error>(
() => `/api/v2/users/me/organizations/${organizationName}`,
)
const { data: project, error: projectError } = useSWR<API.Project, Error>(() => {
return `/api/v2/organizations/${unsafeSWRArgument(organizationInfo).id}/projects/${projectName}`
})
const onCancel = useCallback(async () => {
await push(`/projects/${organization}/${projectName}`)
}, [push, organization, projectName])
await push(`/projects/${organizationName}/${projectName}`)
}, [push, organizationName, projectName])
const onSubmit = async (req: API.CreateWorkspaceRequest) => {
const workspace = await API.Workspace.create(req)
await push(`/workspaces/me/${workspace.name}`)
await push(`/workspaces/${workspace.id}`)
return workspace
}
if (organizationError) {
return <ErrorSummary error={organizationError} />
}
if (projectError) {
return <ErrorSummary error={projectError} />
}
@@ -5,7 +5,7 @@ import Link from "next/link"
import { useRouter } from "next/router"
import useSWR from "swr"
import { Project, Workspace } from "../../../../api"
import { Organization, Project, Workspace } from "../../../../api"
import { Header } from "../../../../components/Header"
import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader"
import { Navbar } from "../../../../components/Navbar"
@@ -15,21 +15,32 @@ import { useUser } from "../../../../contexts/UserContext"
import { ErrorSummary } from "../../../../components/ErrorSummary"
import { firstOrItem } from "../../../../util/array"
import { EmptyState } from "../../../../components/EmptyState"
import { unsafeSWRArgument } from "../../../../util"
const ProjectPage: React.FC = () => {
const styles = useStyles()
const { me, signOut } = useUser(true)
const router = useRouter()
const { project, organization } = router.query
const { project: projectName, organization: organizationName } = router.query
const { data: organizationInfo, error: organizationError } = useSWR<Organization, Error>(
() => `/api/v2/users/me/organizations/${organizationName}`,
)
const { data: projectInfo, error: projectError } = useSWR<Project, Error>(
() => `/api/v2/projects/${organization}/${project}`,
)
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(
() => `/api/v2/projects/${organization}/${project}/workspaces`,
() => `/api/v2/organizations/${unsafeSWRArgument(organizationInfo).id}/projects/${projectName}`,
)
// TODO: The workspaces endpoint was recently changed, so that we can't get
// workspaces per-project. This just grabs all workspaces... and then
// later filters them to match the current project.
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(() => `/api/v2/users/me/workspaces`)
if (organizationError) {
return <ErrorSummary error={organizationError} />
}
if (projectError) {
return <ErrorSummary error={projectError} />
}
@@ -43,7 +54,7 @@ const ProjectPage: React.FC = () => {
}
const createWorkspace = () => {
void router.push(`/projects/${organization}/${project}/create`)
void router.push(`/projects/${organizationName}/${projectName}/create`)
}
const emptyState = (
@@ -61,16 +72,20 @@ const ProjectPage: React.FC = () => {
{
key: "name",
name: "Name",
renderer: (nameField: string) => {
return <Link href={`/workspaces/me/${nameField}`}>{nameField}</Link>
renderer: (nameField: string, workspace: Workspace) => {
return <Link href={`/workspaces/${workspace.id}`}>{nameField}</Link>
},
},
]
const perProjectWorkspaces = workspaces.filter((workspace) => {
return workspace.project_id === projectInfo.id
})
const tableProps = {
title: "Workspaces",
columns,
data: workspaces,
data: perProjectWorkspaces,
emptyState: emptyState,
}
@@ -78,9 +93,9 @@ const ProjectPage: React.FC = () => {
<div className={styles.root}>
<Navbar user={me} onSignOut={signOut} />
<Header
title={firstOrItem(project, "")}
description={firstOrItem(organization, "")}
subTitle={`${workspaces.length} workspaces`}
title={firstOrItem(projectName, "")}
description={firstOrItem(organizationName, "")}
subTitle={`${perProjectWorkspaces.length} workspaces`}
action={{
text: "Create Workspace",
onClick: createWorkspace,
@@ -1,71 +0,0 @@
import React from "react"
import useSWR from "swr"
import { makeStyles } from "@material-ui/core/styles"
import { useRouter } from "next/router"
import { Navbar } from "../../../components/Navbar"
import { Footer } from "../../../components/Page"
import { useUser } from "../../../contexts/UserContext"
import { firstOrItem } from "../../../util/array"
import { ErrorSummary } from "../../../components/ErrorSummary"
import { FullScreenLoader } from "../../../components/Loader/FullScreenLoader"
import { Workspace } from "../../../components/Workspace"
import * as API from "../../../api"
const WorkspacesPage: React.FC = () => {
const styles = useStyles()
const router = useRouter()
const { me, signOut } = useUser(true)
const { user: userQueryParam, workspace: workspaceQueryParam } = router.query
const { data: workspace, error: workspaceError } = useSWR<API.Workspace, Error>(() => {
const userParam = firstOrItem(userQueryParam, null)
const workspaceParam = firstOrItem(workspaceQueryParam, null)
// TODO(Bryan): Getting non-personal users isn't supported yet in the backend.
// So if the user is the same as 'me', use 'me' as the parameter
const normalizedUserParam = me && userParam === me.id ? "me" : userParam
// The SWR API expects us to 'throw' if the query isn't ready yet:
if (normalizedUserParam === null || workspaceParam === null) {
throw "Data not yet available to make API call"
}
return `/api/v2/workspaces/${normalizedUserParam}/${workspaceParam}`
})
if (workspaceError) {
return <ErrorSummary error={workspaceError} />
}
if (!me || !workspace) {
return <FullScreenLoader />
}
return (
<div className={styles.root}>
<Navbar user={me} onSignOut={signOut} />
<div className={styles.inner}>
<Workspace workspace={workspace} />
</div>
<Footer />
</div>
)
}
const useStyles = makeStyles(() => ({
root: {
display: "flex",
flexDirection: "column",
},
inner: {
maxWidth: "1380px",
margin: "1em auto",
width: "100%",
},
}))
export default WorkspacesPage
+78
View File
@@ -0,0 +1,78 @@
import React from "react"
import useSWR from "swr"
import { makeStyles } from "@material-ui/core/styles"
import { useRouter } from "next/router"
import { Navbar } from "../../components/Navbar"
import { Footer } from "../../components/Page"
import { useUser } from "../../contexts/UserContext"
import { firstOrItem } from "../../util/array"
import { ErrorSummary } from "../../components/ErrorSummary"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { Workspace } from "../../components/Workspace"
import { unsafeSWRArgument } from "../../util"
import * as API from "../../api"
const WorkspacesPage: React.FC = () => {
const styles = useStyles()
const router = useRouter()
const { me, signOut } = useUser(true)
const { workspace: workspaceQueryParam } = router.query
const { data: workspace, error: workspaceError } = useSWR<API.Workspace, Error>(() => {
const workspaceParam = firstOrItem(workspaceQueryParam, null)
return `/api/v2/workspaces/${workspaceParam}`
})
// Fetch parent project
const { data: project, error: projectError } = useSWR<API.Project, Error>(() => {
return `/api/v2/projects/${unsafeSWRArgument(workspace).project_id}`
})
const { data: organization, error: organizationError } = useSWR<API.Project, Error>(() => {
return `/api/v2/organizations/${unsafeSWRArgument(project).organization_id}`
})
if (workspaceError) {
return <ErrorSummary error={workspaceError} />
}
if (projectError) {
return <ErrorSummary error={projectError} />
}
if (organizationError) {
return <ErrorSummary error={organizationError} />
}
if (!me || !workspace || !project || !organization) {
return <FullScreenLoader />
}
return (
<div className={styles.root}>
<Navbar user={me} onSignOut={signOut} />
<div className={styles.inner}>
<Workspace organization={organization} project={project} workspace={workspace} />
</div>
<Footer />
</div>
)
}
const useStyles = makeStyles(() => ({
root: {
display: "flex",
flexDirection: "column",
},
inner: {
maxWidth: "1380px",
margin: "1em auto",
width: "100%",
},
}))
export default WorkspacesPage
+2
View File
@@ -0,0 +1,2 @@
export * from "./array"
export * from "./swr"
+17
View File
@@ -0,0 +1,17 @@
/**
* unsafeSWRArgument
*
* Helper function for working with SWR / useSWR in the TypeScript world.
* TypeScript is helpful in enforcing type-safety, but SWR is designed to
* with the expectation that, if the argument is not available, an exception
* will be thrown.
*
* This just helps in abiding by those rules, explicitly, and lets us suppress
* the lint warning in a single place.
*/
export const unsafeSWRArgument = <T>(arg: T | null | undefined): T => {
if (typeof arg === "undefined" || arg === null) {
throw "SWR: Expected exception because the argument is not available"
}
return arg
}