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 { TemplatesPage } from "./pages/templates"
|
||||||
import { TemplatePage } from "./pages/templates/[organization]/[template]"
|
import { TemplatePage } from "./pages/templates/[organization]/[template]"
|
||||||
import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create"
|
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]"
|
import { WorkspacePage } from "./pages/workspaces/[workspace]"
|
||||||
|
|
||||||
export const AppRouter: React.FC = () => (
|
export const AppRouter: React.FC = () => (
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import axios, { AxiosRequestHeaders } from "axios"
|
import axios, { AxiosRequestHeaders } from "axios"
|
||||||
import { mutate } from "swr"
|
import { mutate } from "swr"
|
||||||
|
import { MockPager, MockUser, MockUser2 } from "../test_helpers"
|
||||||
import * as Types from "./types"
|
import * as Types from "./types"
|
||||||
|
|
||||||
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
|
const CONTENT_TYPE_JSON: AxiosRequestHeaders = {
|
||||||
@ -69,6 +70,15 @@ export const getApiKey = async (): Promise<Types.APIKeyResponse> => {
|
|||||||
return response.data
|
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> => {
|
export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
|
||||||
const response = await axios.get("/api/v2/buildinfo")
|
const response = await axios.get("/api/v2/buildinfo")
|
||||||
return response.data
|
return response.data
|
||||||
|
@ -79,6 +79,15 @@ export interface UserAgent {
|
|||||||
readonly os: string
|
readonly os: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Pager {
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedUsers {
|
||||||
|
page: UserResponse[]
|
||||||
|
pager: Pager
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceAutostartRequest {
|
export interface WorkspaceAutostartRequest {
|
||||||
schedule: string
|
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"
|
import React from "react"
|
||||||
|
|
||||||
|
const Language = {
|
||||||
|
unknownErrorMessage: "Unknown error",
|
||||||
|
}
|
||||||
|
|
||||||
export interface ErrorSummaryProps {
|
export interface ErrorSummaryProps {
|
||||||
error: Error | undefined
|
error: Error | unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorSummary: React.FC<ErrorSummaryProps> = ({ error }) => {
|
export const ErrorSummary: React.FC<ErrorSummaryProps> = ({ error }) => {
|
||||||
// TODO: More interesting error page
|
// TODO: More interesting error page
|
||||||
|
|
||||||
if (typeof error === "undefined") {
|
if (!(error instanceof Error)) {
|
||||||
return <div>{"Unknown error"}</div>
|
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)
|
const password = screen.getByLabelText(Language.passwordLabel)
|
||||||
await userEvent.type(email, "test@coder.com")
|
await userEvent.type(email, "test@coder.com")
|
||||||
await userEvent.type(password, "password")
|
await userEvent.type(password, "password")
|
||||||
|
|
||||||
// Click sign-in
|
// Click sign-in
|
||||||
const signInButton = await screen.findByText(Language.signIn)
|
const signInButton = await screen.findByText(Language.signIn)
|
||||||
act(() => signInButton.click())
|
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 {
|
import {
|
||||||
BuildInfoResponse,
|
BuildInfoResponse,
|
||||||
Organization,
|
Organization,
|
||||||
|
Pager,
|
||||||
Provisioner,
|
Provisioner,
|
||||||
Template,
|
Template,
|
||||||
UserAgent,
|
UserAgent,
|
||||||
@ -25,6 +26,17 @@ export const MockUser: UserResponse = {
|
|||||||
created_at: "",
|
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 = {
|
export const MockOrganization: Organization = {
|
||||||
id: "test-org",
|
id: "test-org",
|
||||||
name: "Test Organization",
|
name: "Test Organization",
|
||||||
|
@ -21,6 +21,9 @@ export const handlers = [
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// users
|
// 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) => {
|
rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||||
}),
|
}),
|
||||||
|
@ -3,10 +3,12 @@ import React, { createContext } from "react"
|
|||||||
import { ActorRefFrom } from "xstate"
|
import { ActorRefFrom } from "xstate"
|
||||||
import { authMachine } from "./auth/authXService"
|
import { authMachine } from "./auth/authXService"
|
||||||
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
|
||||||
|
import { usersMachine } from "./users/usersXService"
|
||||||
|
|
||||||
interface XServiceContextType {
|
interface XServiceContextType {
|
||||||
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
|
||||||
authXService: ActorRefFrom<typeof authMachine>
|
authXService: ActorRefFrom<typeof authMachine>
|
||||||
|
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
|
||||||
|
usersXService: ActorRefFrom<typeof usersMachine>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,8 +25,9 @@ export const XServiceProvider: React.FC = ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<XServiceContext.Provider
|
<XServiceContext.Provider
|
||||||
value={{
|
value={{
|
||||||
buildInfoXService: useInterpret(buildInfoMachine),
|
|
||||||
authXService: useInterpret(authMachine),
|
authXService: useInterpret(authMachine),
|
||||||
|
buildInfoXService: useInterpret(buildInfoMachine),
|
||||||
|
usersXService: useInterpret(usersMachine),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{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