feat(site): Read users into basic UsersTable (#981)

* Start users

* Set up fake response

* Update handler

* Update types

* Set up page

* Start adding table

* Add header

* Add Header

* Remove roles

* Add UsersPageView

* Add test

* Lint

* Storybook error summary

* Strip Pager to just what's currently needed

* Clean up ErrorSummary while I'm here

* Storybook tweaks

* Extract language

* Lint

* Add missing $

Co-authored-by: G r e y <grey@coder.com>

* Lint

* Lint

* Fix syntax error

* Lint

Co-authored-by: G r e y <grey@coder.com>
This commit is contained in:
Presley Pizzo
2022-04-14 13:57:55 -04:00
committed by GitHub
parent f803e37505
commit 82275a81c7
17 changed files with 287 additions and 14 deletions

View File

@ -16,7 +16,7 @@ import { SettingsPage } from "./pages/settings"
import { TemplatesPage } from "./pages/templates"
import { TemplatePage } from "./pages/templates/[organization]/[template]"
import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create"
import { UsersPage } from "./pages/users"
import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspacePage } from "./pages/workspaces/[workspace]"
export const AppRouter: React.FC = () => (

View File

@ -1,5 +1,6 @@
import axios, { AxiosRequestHeaders } from "axios"
import { mutate } from "swr"
import { MockPager, MockUser, MockUser2 } from "../test_helpers"
import * as Types from "./types"
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
@ -69,6 +70,15 @@ export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
return response.data
}
export const getUsers = async (): Promise<Types.PagedUsers> => {
// const response = await axios.get<Types.UserResponse[]>("/api/v2/users")
// return response.data
return Promise.resolve({
page: [MockUser, MockUser2],
pager: MockPager,
})
}
export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
const response = await axios.get("/api/v2/buildinfo")
return response.data

View File

@ -79,6 +79,15 @@ export interface UserAgent {
readonly os: string
}
export interface Pager {
total: number
}
export interface PagedUsers {
page: UserResponse[]
pager: Pager
}
export interface WorkspaceAutostartRequest {
schedule: string
}

View File

@ -0,0 +1,17 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { ErrorSummary, ErrorSummaryProps } from "."
export default {
title: "components/ErrorSummary",
component: ErrorSummary,
} as ComponentMeta<typeof ErrorSummary>
const Template: Story<ErrorSummaryProps> = (args) => <ErrorSummary {...args} />
export const WithError = Template.bind({})
WithError.args = {
error: new Error("Something went wrong!"),
}
export const WithUndefined = Template.bind({})

View File

@ -1,15 +1,19 @@
import React from "react"
const Language = {
unknownErrorMessage: "Unknown error",
}
export interface ErrorSummaryProps {
error: Error | undefined
error: Error | unknown
}
export const ErrorSummary: React.FC<ErrorSummaryProps> = ({ error }) => {
// TODO: More interesting error page
if (typeof error === "undefined") {
return <div>{"Unknown error"}</div>
if (!(error instanceof Error)) {
return <div>{Language.unknownErrorMessage}</div>
} else {
return <div>{error.toString()}</div>
}
return <div>{error.toString()}</div>
}

View File

@ -0,0 +1,21 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockUser, MockUser2 } from "../../test_helpers"
import { UsersTable, UsersTableProps } from "./UsersTable"
export default {
title: "Components/UsersTable",
component: UsersTable,
} as ComponentMeta<typeof UsersTable>
const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
export const Example = Template.bind({})
Example.args = {
users: [MockUser, MockUser2],
}
export const Empty = Template.bind({})
Empty.args = {
users: [],
}

View File

@ -0,0 +1,32 @@
import React from "react"
import { UserResponse } from "../../api/types"
import { Column, Table } from "../../components/Table"
import { EmptyState } from "../EmptyState"
import { UserCell } from "../Table/Cells/UserCell"
const Language = {
pageTitle: "Users",
usersTitle: "All users",
emptyMessage: "No users found",
usernameLabel: "User",
}
const emptyState = <EmptyState message={Language.emptyMessage} />
const columns: Column<UserResponse>[] = [
{
key: "username",
name: Language.usernameLabel,
renderer: (field, data) => {
return <UserCell Avatar={{ username: data.username }} primaryText={data.username} caption={data.email} />
},
},
]
export interface UsersTableProps {
users: UserResponse[]
}
export const UsersTable: React.FC<UsersTableProps> = ({ users }) => {
return <Table columns={columns} data={users} title={Language.usersTitle} emptyState={emptyState} />
}

View File

@ -0,0 +1,18 @@
import { screen } from "@testing-library/react"
import React from "react"
import { MockPager, render } from "../../test_helpers"
import { UsersPage } from "./UsersPage"
import { Language } from "./UsersPageView"
describe("Users Page", () => {
it("has a header with the total number of users", async () => {
render(<UsersPage />)
const total = await screen.findByText(/\d+ total/)
expect(total.innerHTML).toEqual(Language.pageSubtitle(MockPager))
})
it("shows users", async () => {
render(<UsersPage />)
const users = await screen.findAllByText(/.*@coder.com/)
expect(users.length).toEqual(2)
})
})

View File

@ -0,0 +1,17 @@
import { useActor } from "@xstate/react"
import React, { useContext } from "react"
import { ErrorSummary } from "../../components/ErrorSummary"
import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView"
export const UsersPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [usersState] = useActor(xServices.usersXService)
const { users, pager, getUsersError } = usersState.context
if (usersState.matches("error")) {
return <ErrorSummary error={getUsersError} />
} else {
return <UsersPageView users={users} pager={pager} />
}
}

View File

@ -0,0 +1,21 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockPager, MockUser, MockUser2 } from "../../test_helpers"
import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
export default {
title: "pages/UsersPageView",
component: UsersPageView,
} as ComponentMeta<typeof UsersPageView>
const Template: Story<UsersPageViewProps> = (args) => <UsersPageView {...args} />
export const Ready = Template.bind({})
Ready.args = {
users: [MockUser, MockUser2],
pager: MockPager,
}
export const Empty = Template.bind({})
Empty.args = {
users: [],
}

View File

@ -0,0 +1,32 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { Pager, UserResponse } from "../../api/types"
import { Header } from "../../components/Header"
import { UsersTable } from "../../components/UsersTable/UsersTable"
export const Language = {
pageTitle: "Users",
pageSubtitle: (pager: Pager | undefined): string => (pager ? `${pager.total} total` : ""),
}
export interface UsersPageViewProps {
users: UserResponse[]
pager?: Pager
}
export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, pager }) => {
const styles = useStyles()
return (
<div className={styles.flexColumn}>
<Header title={Language.pageTitle} subTitle={Language.pageSubtitle(pager)} />
<UsersTable users={users} />
</div>
)
}
const useStyles = makeStyles(() => ({
flexColumn: {
display: "flex",
flexDirection: "column",
},
}))

View File

@ -41,7 +41,6 @@ describe("SignInPage", () => {
const password = screen.getByLabelText(Language.passwordLabel)
await userEvent.type(email, "test@coder.com")
await userEvent.type(password, "password")
// Click sign-in
const signInButton = await screen.findByText(Language.signIn)
act(() => signInButton.click())

View File

@ -1,5 +0,0 @@
import React from "react"
export const UsersPage: React.FC = () => {
return <div>Coming soon!</div>
}

View File

@ -1,6 +1,7 @@
import {
BuildInfoResponse,
Organization,
Pager,
Provisioner,
Template,
UserAgent,
@ -25,6 +26,17 @@ export const MockUser: UserResponse = {
created_at: "",
}
export const MockUser2: UserResponse = {
id: "test-user-2",
username: "TestUser2",
email: "test2@coder.com",
created_at: "",
}
export const MockPager: Pager = {
total: 2,
}
export const MockOrganization: Organization = {
id: "test-org",
name: "Test Organization",

View File

@ -21,6 +21,9 @@ export const handlers = [
}),
// users
rest.get("/api/v2/users", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ page: [M.MockUser, M.MockUser2], pager: M.MockPager }))
}),
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),

View File

@ -3,10 +3,12 @@ import React, { createContext } from "react"
import { ActorRefFrom } from "xstate"
import { authMachine } from "./auth/authXService"
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
import { usersMachine } from "./users/usersXService"
interface XServiceContextType {
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
authXService: ActorRefFrom<typeof authMachine>
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
usersXService: ActorRefFrom<typeof usersMachine>
}
/**
@ -23,8 +25,9 @@ export const XServiceProvider: React.FC = ({ children }) => {
return (
<XServiceContext.Provider
value={{
buildInfoXService: useInterpret(buildInfoMachine),
authXService: useInterpret(authMachine),
buildInfoXService: useInterpret(buildInfoMachine),
usersXService: useInterpret(usersMachine),
}}
>
{children}

View File

@ -0,0 +1,80 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api"
import * as Types from "../../api/types"
export interface UsersContext {
users: Types.UserResponse[]
pager?: Types.Pager
getUsersError?: Error | unknown
}
export type UsersEvent = { type: "GET_USERS" }
export const usersMachine = createMachine(
{
tsTypes: {} as import("./usersXService.typegen").Typegen0,
schema: {
context: {} as UsersContext,
events: {} as UsersEvent,
services: {} as {
getUsers: {
data: Types.PagedUsers
}
},
},
id: "usersState",
context: {
users: [],
},
initial: "gettingUsers",
states: {
gettingUsers: {
invoke: {
src: "getUsers",
id: "getUsers",
onDone: [
{
target: "#usersState.ready",
actions: ["assignUsers", "clearGetUsersError"],
},
],
onError: [
{
actions: "assignGetUsersError",
target: "#usersState.error",
},
],
},
tags: "loading",
},
ready: {
on: {
GET_USERS: "gettingUsers",
},
},
error: {
on: {
GET_USERS: "gettingUsers",
},
},
},
},
{
services: {
getUsers: API.getUsers,
},
actions: {
assignUsers: assign({
users: (_, event) => event.data.page,
pager: (_, event) => event.data.pager,
}),
assignGetUsersError: assign({
getUsersError: (_, event) => event.data,
}),
clearGetUsersError: assign((context: UsersContext) => ({
...context,
getUsersError: undefined,
})),
},
},
)