mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
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:
@ -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 = () => (
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
17
site/src/components/ErrorSummary/ErrorSummary.stories.tsx
Normal file
17
site/src/components/ErrorSummary/ErrorSummary.stories.tsx
Normal 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({})
|
@ -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>
|
||||
}
|
||||
|
21
site/src/components/UsersTable/UsersTable.stories.tsx
Normal file
21
site/src/components/UsersTable/UsersTable.stories.tsx
Normal 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: [],
|
||||
}
|
32
site/src/components/UsersTable/UsersTable.tsx
Normal file
32
site/src/components/UsersTable/UsersTable.tsx
Normal 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} />
|
||||
}
|
18
site/src/pages/UsersPage/UsersPage.test.tsx
Normal file
18
site/src/pages/UsersPage/UsersPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
17
site/src/pages/UsersPage/UsersPage.tsx
Normal file
17
site/src/pages/UsersPage/UsersPage.tsx
Normal 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} />
|
||||
}
|
||||
}
|
21
site/src/pages/UsersPage/UsersPageView.stories.tsx
Normal file
21
site/src/pages/UsersPage/UsersPageView.stories.tsx
Normal 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: [],
|
||||
}
|
32
site/src/pages/UsersPage/UsersPageView.tsx
Normal file
32
site/src/pages/UsersPage/UsersPageView.tsx
Normal 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",
|
||||
},
|
||||
}))
|
@ -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())
|
||||
|
@ -1,5 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export const UsersPage: React.FC = () => {
|
||||
return <div>Coming soon!</div>
|
||||
}
|
@ -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",
|
||||
|
@ -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))
|
||||
}),
|
||||
|
@ -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}
|
||||
|
80
site/src/xServices/users/usersXService.ts
Normal file
80
site/src/xServices/users/usersXService.ts
Normal 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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
)
|
Reference in New Issue
Block a user