Skip to content

Commit 82275a8

Browse files
presleypgreyscaled
andauthored
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>
1 parent f803e37 commit 82275a8

17 files changed

+287
-14
lines changed

site/src/AppRouter.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { SettingsPage } from "./pages/settings"
1616
import { TemplatesPage } from "./pages/templates"
1717
import { TemplatePage } from "./pages/templates/[organization]/[template]"
1818
import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create"
19-
import { UsersPage } from "./pages/users"
19+
import { UsersPage } from "./pages/UsersPage/UsersPage"
2020
import { WorkspacePage } from "./pages/workspaces/[workspace]"
2121

2222
export const AppRouter: React.FC = () => (

site/src/api/index.ts

+10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import axios, { AxiosRequestHeaders } from "axios"
22
import { mutate } from "swr"
3+
import { MockPager, MockUser, MockUser2 } from "../test_helpers"
34
import * as Types from "./types"
45

56
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
@@ -69,6 +70,15 @@ export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
6970
return response.data
7071
}
7172

73+
export const getUsers = async (): Promise<Types.PagedUsers> => {
74+
// const response = await axios.get<Types.UserResponse[]>("/api/v2/users")
75+
// return response.data
76+
return Promise.resolve({
77+
page: [MockUser, MockUser2],
78+
pager: MockPager,
79+
})
80+
}
81+
7282
export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
7383
const response = await axios.get("/api/v2/buildinfo")
7484
return response.data

site/src/api/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ export interface UserAgent {
7979
readonly os: string
8080
}
8181

82+
export interface Pager {
83+
total: number
84+
}
85+
86+
export interface PagedUsers {
87+
page: UserResponse[]
88+
pager: Pager
89+
}
90+
8291
export interface WorkspaceAutostartRequest {
8392
schedule: string
8493
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ComponentMeta, Story } from "@storybook/react"
2+
import React from "react"
3+
import { ErrorSummary, ErrorSummaryProps } from "."
4+
5+
export default {
6+
title: "components/ErrorSummary",
7+
component: ErrorSummary,
8+
} as ComponentMeta<typeof ErrorSummary>
9+
10+
const Template: Story<ErrorSummaryProps> = (args) => <ErrorSummary {...args} />
11+
12+
export const WithError = Template.bind({})
13+
WithError.args = {
14+
error: new Error("Something went wrong!"),
15+
}
16+
17+
export const WithUndefined = Template.bind({})
+9-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import React from "react"
22

3+
const Language = {
4+
unknownErrorMessage: "Unknown error",
5+
}
6+
37
export interface ErrorSummaryProps {
4-
error: Error | undefined
8+
error: Error | unknown
59
}
610

711
export const ErrorSummary: React.FC<ErrorSummaryProps> = ({ error }) => {
812
// TODO: More interesting error page
913

10-
if (typeof error === "undefined") {
11-
return <div>{"Unknown error"}</div>
14+
if (!(error instanceof Error)) {
15+
return <div>{Language.unknownErrorMessage}</div>
16+
} else {
17+
return <div>{error.toString()}</div>
1218
}
13-
14-
return <div>{error.toString()}</div>
1519
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ComponentMeta, Story } from "@storybook/react"
2+
import React from "react"
3+
import { MockUser, MockUser2 } from "../../test_helpers"
4+
import { UsersTable, UsersTableProps } from "./UsersTable"
5+
6+
export default {
7+
title: "Components/UsersTable",
8+
component: UsersTable,
9+
} as ComponentMeta<typeof UsersTable>
10+
11+
const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
12+
13+
export const Example = Template.bind({})
14+
Example.args = {
15+
users: [MockUser, MockUser2],
16+
}
17+
18+
export const Empty = Template.bind({})
19+
Empty.args = {
20+
users: [],
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from "react"
2+
import { UserResponse } from "../../api/types"
3+
import { Column, Table } from "../../components/Table"
4+
import { EmptyState } from "../EmptyState"
5+
import { UserCell } from "../Table/Cells/UserCell"
6+
7+
const Language = {
8+
pageTitle: "Users",
9+
usersTitle: "All users",
10+
emptyMessage: "No users found",
11+
usernameLabel: "User",
12+
}
13+
14+
const emptyState = <EmptyState message={Language.emptyMessage} />
15+
16+
const columns: Column<UserResponse>[] = [
17+
{
18+
key: "username",
19+
name: Language.usernameLabel,
20+
renderer: (field, data) => {
21+
return <UserCell Avatar={{ username: data.username }} primaryText={data.username} caption={data.email} />
22+
},
23+
},
24+
]
25+
26+
export interface UsersTableProps {
27+
users: UserResponse[]
28+
}
29+
30+
export const UsersTable: React.FC<UsersTableProps> = ({ users }) => {
31+
return <Table columns={columns} data={users} title={Language.usersTitle} emptyState={emptyState} />
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { screen } from "@testing-library/react"
2+
import React from "react"
3+
import { MockPager, render } from "../../test_helpers"
4+
import { UsersPage } from "./UsersPage"
5+
import { Language } from "./UsersPageView"
6+
7+
describe("Users Page", () => {
8+
it("has a header with the total number of users", async () => {
9+
render(<UsersPage />)
10+
const total = await screen.findByText(/\d+ total/)
11+
expect(total.innerHTML).toEqual(Language.pageSubtitle(MockPager))
12+
})
13+
it("shows users", async () => {
14+
render(<UsersPage />)
15+
const users = await screen.findAllByText(/.*@coder.com/)
16+
expect(users.length).toEqual(2)
17+
})
18+
})
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useActor } from "@xstate/react"
2+
import React, { useContext } from "react"
3+
import { ErrorSummary } from "../../components/ErrorSummary"
4+
import { XServiceContext } from "../../xServices/StateContext"
5+
import { UsersPageView } from "./UsersPageView"
6+
7+
export const UsersPage: React.FC = () => {
8+
const xServices = useContext(XServiceContext)
9+
const [usersState] = useActor(xServices.usersXService)
10+
const { users, pager, getUsersError } = usersState.context
11+
12+
if (usersState.matches("error")) {
13+
return <ErrorSummary error={getUsersError} />
14+
} else {
15+
return <UsersPageView users={users} pager={pager} />
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ComponentMeta, Story } from "@storybook/react"
2+
import React from "react"
3+
import { MockPager, MockUser, MockUser2 } from "../../test_helpers"
4+
import { UsersPageView, UsersPageViewProps } from "./UsersPageView"
5+
6+
export default {
7+
title: "pages/UsersPageView",
8+
component: UsersPageView,
9+
} as ComponentMeta<typeof UsersPageView>
10+
11+
const Template: Story<UsersPageViewProps> = (args) => <UsersPageView {...args} />
12+
13+
export const Ready = Template.bind({})
14+
Ready.args = {
15+
users: [MockUser, MockUser2],
16+
pager: MockPager,
17+
}
18+
export const Empty = Template.bind({})
19+
Empty.args = {
20+
users: [],
21+
}
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import React from "react"
3+
import { Pager, UserResponse } from "../../api/types"
4+
import { Header } from "../../components/Header"
5+
import { UsersTable } from "../../components/UsersTable/UsersTable"
6+
7+
export const Language = {
8+
pageTitle: "Users",
9+
pageSubtitle: (pager: Pager | undefined): string => (pager ? `${pager.total} total` : ""),
10+
}
11+
12+
export interface UsersPageViewProps {
13+
users: UserResponse[]
14+
pager?: Pager
15+
}
16+
17+
export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, pager }) => {
18+
const styles = useStyles()
19+
return (
20+
<div className={styles.flexColumn}>
21+
<Header title={Language.pageTitle} subTitle={Language.pageSubtitle(pager)} />
22+
<UsersTable users={users} />
23+
</div>
24+
)
25+
}
26+
27+
const useStyles = makeStyles(() => ({
28+
flexColumn: {
29+
display: "flex",
30+
flexDirection: "column",
31+
},
32+
}))

site/src/pages/login.test.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ describe("SignInPage", () => {
4141
const password = screen.getByLabelText(Language.passwordLabel)
4242
await userEvent.type(email, "test@coder.com")
4343
await userEvent.type(password, "password")
44-
4544
// Click sign-in
4645
const signInButton = await screen.findByText(Language.signIn)
4746
act(() => signInButton.click())

site/src/pages/users.tsx

-5
This file was deleted.

site/src/test_helpers/entities.ts

+12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
BuildInfoResponse,
33
Organization,
4+
Pager,
45
Provisioner,
56
Template,
67
UserAgent,
@@ -25,6 +26,17 @@ export const MockUser: UserResponse = {
2526
created_at: "",
2627
}
2728

29+
export const MockUser2: UserResponse = {
30+
id: "test-user-2",
31+
username: "TestUser2",
32+
email: "test2@coder.com",
33+
created_at: "",
34+
}
35+
36+
export const MockPager: Pager = {
37+
total: 2,
38+
}
39+
2840
export const MockOrganization: Organization = {
2941
id: "test-org",
3042
name: "Test Organization",

site/src/test_helpers/handlers.ts

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const handlers = [
2121
}),
2222

2323
// users
24+
rest.get("/api/v2/users", async (req, res, ctx) => {
25+
return res(ctx.status(200), ctx.json({ page: [M.MockUser, M.MockUser2], pager: M.MockPager }))
26+
}),
2427
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
2528
return res(ctx.status(200), ctx.json(M.MockWorkspace))
2629
}),

site/src/xServices/StateContext.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import React, { createContext } from "react"
33
import { ActorRefFrom } from "xstate"
44
import { authMachine } from "./auth/authXService"
55
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
6+
import { usersMachine } from "./users/usersXService"
67

78
interface XServiceContextType {
8-
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
99
authXService: ActorRefFrom<typeof authMachine>
10+
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
11+
usersXService: ActorRefFrom<typeof usersMachine>
1012
}
1113

1214
/**
@@ -23,8 +25,9 @@ export const XServiceProvider: React.FC = ({ children }) => {
2325
return (
2426
<XServiceContext.Provider
2527
value={{
26-
buildInfoXService: useInterpret(buildInfoMachine),
2728
authXService: useInterpret(authMachine),
29+
buildInfoXService: useInterpret(buildInfoMachine),
30+
usersXService: useInterpret(usersMachine),
2831
}}
2932
>
3033
{children}
+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { assign, createMachine } from "xstate"
2+
import * as API from "../../api"
3+
import * as Types from "../../api/types"
4+
5+
export interface UsersContext {
6+
users: Types.UserResponse[]
7+
pager?: Types.Pager
8+
getUsersError?: Error | unknown
9+
}
10+
11+
export type UsersEvent = { type: "GET_USERS" }
12+
13+
export const usersMachine = createMachine(
14+
{
15+
tsTypes: {} as import("./usersXService.typegen").Typegen0,
16+
schema: {
17+
context: {} as UsersContext,
18+
events: {} as UsersEvent,
19+
services: {} as {
20+
getUsers: {
21+
data: Types.PagedUsers
22+
}
23+
},
24+
},
25+
id: "usersState",
26+
context: {
27+
users: [],
28+
},
29+
initial: "gettingUsers",
30+
states: {
31+
gettingUsers: {
32+
invoke: {
33+
src: "getUsers",
34+
id: "getUsers",
35+
onDone: [
36+
{
37+
target: "#usersState.ready",
38+
actions: ["assignUsers", "clearGetUsersError"],
39+
},
40+
],
41+
onError: [
42+
{
43+
actions: "assignGetUsersError",
44+
target: "#usersState.error",
45+
},
46+
],
47+
},
48+
tags: "loading",
49+
},
50+
ready: {
51+
on: {
52+
GET_USERS: "gettingUsers",
53+
},
54+
},
55+
error: {
56+
on: {
57+
GET_USERS: "gettingUsers",
58+
},
59+
},
60+
},
61+
},
62+
{
63+
services: {
64+
getUsers: API.getUsers,
65+
},
66+
actions: {
67+
assignUsers: assign({
68+
users: (_, event) => event.data.page,
69+
pager: (_, event) => event.data.pager,
70+
}),
71+
assignGetUsersError: assign({
72+
getUsersError: (_, event) => event.data,
73+
}),
74+
clearGetUsersError: assign((context: UsersContext) => ({
75+
...context,
76+
getUsersError: undefined,
77+
})),
78+
},
79+
},
80+
)

0 commit comments

Comments
 (0)