feat: Create user page (#1197)

* Add button and route

* Hook up api

* Lint

* Add basic form

* Get users on page mount

* Make cancel work

* Creating -> idle bc users page refetches

* Import as TypesGen

* Handle api errors

* Lint

* Add handler

* Add FormFooter

* Add FullPageForm

* Lint

* Better form, error, stories

bug in formErrors story

* Make detail optional

* Use Language

* Remove detail prop

* Add back autoFocus

* Remove displayError, use displaySuccess

* Lint, export Language

* Tests - wip

* Fix cancel tests

* Switch back to mock

* Add navigate to xservice

Doesn't work in test

* Move error type predicate to xservice

* Lint

* Switch to using creation mode in XState

still problems in tests

* Lint

* Lint

* Lint

* Revert "Switch to using creation mode in XState"

This reverts commit cf8442fa4b.

* Give XService a navigate action

* Add missing validation messages

* Fix XState warning

* Fix tests

IRL is broken bc I need to send org id

* Pretend user has org id and make it work

* Format

* Lint

* Switch to org ids array

* Skip lines between tests

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

* Punctuate notification messages

Co-authored-by: G r e y <grey@coder.com>
This commit is contained in:
Presley Pizzo
2022-04-28 16:32:23 -04:00
committed by GitHub
parent 4efde58726
commit c16f105727
17 changed files with 412 additions and 23 deletions

View File

@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage" import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage"
import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage" import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage"
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage" import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
import { UsersPage } from "./pages/UsersPage/UsersPage" import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage" import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
@ -83,14 +84,24 @@ export const AppRouter: React.FC = () => (
/> />
</Route> </Route>
<Route <Route path="users">
path="users" <Route
element={ index
<AuthAndFrame> element={
<UsersPage /> <AuthAndFrame>
</AuthAndFrame> <UsersPage />
} </AuthAndFrame>
/> }
/>
<Route
path="create"
element={
<RequireAuth>
<CreateUserPage />
</RequireAuth>
}
/>
</Route>
<Route <Route
path="orgs" path="orgs"
element={ element={

View File

@ -11,7 +11,7 @@ interface FieldError {
detail: string detail: string
} }
type FieldErrors = Record<FieldError["field"], FieldError["detail"]> export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
export interface ApiErrorResponse { export interface ApiErrorResponse {
message: string message: string

View File

@ -85,6 +85,11 @@ export const getUsers = async (): Promise<Types.PagedUsers> => {
}) })
} }
export const createUser = async (user: Types.CreateUserRequest): Promise<TypesGen.User> => {
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
return response.data
}
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

View File

@ -10,11 +10,20 @@ export interface LoginResponse {
session_token: string session_token: string
} }
export interface CreateUserRequest {
username: string
email: string
password: string
organization_id: string
}
export interface UserResponse { export interface UserResponse {
readonly id: string readonly id: string
readonly username: string readonly username: string
readonly email: string readonly email: string
readonly created_at: string readonly created_at: string
readonly status: "active" | "suspended"
readonly organization_ids: string[]
} }
/** /**

View File

@ -0,0 +1,43 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import React from "react"
import { CreateUserForm, CreateUserFormProps } from "./CreateUserForm"
export default {
title: "components/CreateUserForm",
component: CreateUserForm,
}
const Template: Story<CreateUserFormProps> = (args: CreateUserFormProps) => <CreateUserForm {...args} />
export const Ready = Template.bind({})
Ready.args = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
}
export const UnknownError = Template.bind({})
UnknownError.args = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
error: "Something went wrong",
}
export const FormError = Template.bind({})
FormError.args = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: false,
formErrors: {
username: "Username taken",
},
}
export const Loading = Template.bind({})
Loading.args = {
onCancel: action("cancel"),
onSubmit: action("submit"),
isLoading: true,
}

View File

@ -0,0 +1,92 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import React from "react"
import * as Yup from "yup"
import { CreateUserRequest } from "../../api/types"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { FormFooter } from "../FormFooter/FormFooter"
import { FullPageForm } from "../FullPageForm/FullPageForm"
export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
usernameLabel: "Username",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordRequired: "Please enter a password.",
usernameRequired: "Please enter a username.",
createUser: "Create",
cancel: "Cancel",
}
export interface CreateUserFormProps {
onSubmit: (user: CreateUserRequest) => void
onCancel: () => void
formErrors?: FormikErrors<CreateUserRequest>
isLoading: boolean
error?: string
myOrgId: string
}
const validationSchema = Yup.object({
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
password: Yup.string().required(Language.passwordRequired),
username: Yup.string().required(Language.usernameRequired),
})
export const CreateUserForm: React.FC<CreateUserFormProps> = ({
onSubmit,
onCancel,
formErrors,
isLoading,
error,
myOrgId,
}) => {
const form: FormikContextType<CreateUserRequest> = useFormik<CreateUserRequest>({
initialValues: {
email: "",
password: "",
username: "",
organization_id: myOrgId,
},
validationSchema,
onSubmit,
})
const getFieldHelpers = getFormHelpers<CreateUserRequest>(form, formErrors)
return (
<FullPageForm title="Create user" onCancel={onCancel}>
<form onSubmit={form.handleSubmit}>
<TextField
{...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)}
autoComplete="username"
autoFocus
fullWidth
label={Language.usernameLabel}
variant="outlined"
/>
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
autoComplete="email"
fullWidth
label={Language.emailLabel}
variant="outlined"
/>
<TextField
{...getFieldHelpers("password")}
autoComplete="current-password"
fullWidth
id="password"
label={Language.passwordLabel}
type="password"
variant="outlined"
/>
{error && <FormHelperText error>{error}</FormHelperText>}
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</form>
</FullPageForm>
)
}

View File

@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles"
import React from "react" import React from "react"
import { LoadingButton } from "../LoadingButton/LoadingButton" import { LoadingButton } from "../LoadingButton/LoadingButton"
const Language = { export const Language = {
cancelLabel: "Cancel", cancelLabel: "Cancel",
defaultSubmitLabel: "Submit", defaultSubmitLabel: "Submit",
} }

View File

@ -34,8 +34,10 @@ describe("AccountPage", () => {
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) => jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
Promise.resolve({ Promise.resolve({
id: userId, id: userId,
...data,
created_at: new Date().toString(), created_at: new Date().toString(),
status: "active",
organization_ids: ["123"],
...data,
}), }),
) )
const { user } = renderPage() const { user } = renderPage()

View File

@ -0,0 +1,98 @@
import { screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { rest } from "msw"
import React from "react"
import * as API from "../../../api"
import { Language as FormLanguage } from "../../../components/CreateUserForm/CreateUserForm"
import { Language as FooterLanguage } from "../../../components/FormFooter/FormFooter"
import { history, render } from "../../../testHelpers"
import { server } from "../../../testHelpers/server"
import { Language as UserLanguage } from "../../../xServices/users/usersXService"
import { CreateUserPage, Language } from "./CreateUserPage"
const fillForm = async ({
username = "someuser",
email = "someone@coder.com",
password = "password",
}: {
username?: string
email?: string
password?: string
}) => {
const usernameField = screen.getByLabelText(FormLanguage.usernameLabel)
const emailField = screen.getByLabelText(FormLanguage.emailLabel)
const passwordField = screen.getByLabelText(FormLanguage.passwordLabel)
await userEvent.type(usernameField, username)
await userEvent.type(emailField, email)
await userEvent.type(passwordField, password)
const submitButton = await screen.findByText(FooterLanguage.defaultSubmitLabel)
submitButton.click()
}
describe("Create User Page", () => {
beforeEach(() => {
history.replace("/users/create")
})
it("shows validation error message", async () => {
render(<CreateUserPage />)
await fillForm({ email: "test" })
const errorMessage = await screen.findByText(FormLanguage.emailInvalid)
expect(errorMessage).toBeDefined()
})
it("shows generic error message", async () => {
jest.spyOn(API, "createUser").mockRejectedValueOnce({
data: "unknown error",
})
render(<CreateUserPage />)
await fillForm({})
const errorMessage = await screen.findByText(Language.unknownError)
expect(errorMessage).toBeDefined()
})
it("shows API error message", async () => {
const fieldErrorMessage = "username already in use"
server.use(
rest.post("/api/v2/users", async (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: "invalid field",
errors: [
{
detail: fieldErrorMessage,
field: "username",
},
],
}),
)
}),
)
render(<CreateUserPage />)
await fillForm({})
const errorMessage = await screen.findByText(fieldErrorMessage)
expect(errorMessage).toBeDefined()
})
it("shows success notification and redirects to users page", async () => {
render(<CreateUserPage />)
await fillForm({})
const successMessage = screen.findByText(UserLanguage.createUserSuccess)
expect(successMessage).toBeDefined()
})
it("redirects to users page on cancel", async () => {
render(<CreateUserPage />)
const cancelButton = await screen.findByText(FooterLanguage.cancelLabel)
cancelButton.click()
expect(history.location.pathname).toEqual("/users")
})
it("redirects to users page on close", async () => {
render(<CreateUserPage />)
const closeButton = await screen.findByText("ESC")
closeButton.click()
expect(history.location.pathname).toEqual("/users")
})
})

View File

@ -0,0 +1,33 @@
import { useActor, useSelector } from "@xstate/react"
import React, { useContext } from "react"
import { useNavigate } from "react-router"
import { CreateUserRequest } from "../../../api/types"
import { CreateUserForm } from "../../../components/CreateUserForm/CreateUserForm"
import { selectOrgId } from "../../../xServices/auth/authSelectors"
import { XServiceContext } from "../../../xServices/StateContext"
export const Language = {
unknownError: "Oops, an unknown error occurred.",
}
export const CreateUserPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const myOrgId = useSelector(xServices.authXService, selectOrgId)
const [usersState, usersSend] = useActor(xServices.usersXService)
const { createUserError, createUserFormErrors } = usersState.context
const navigate = useNavigate()
// There is no field for organization id in Community Edition, so handle its field error like a generic error
const genericError =
createUserError || createUserFormErrors?.organization_id || !myOrgId ? Language.unknownError : undefined
return (
<CreateUserForm
formErrors={createUserFormErrors}
onSubmit={(user: CreateUserRequest) => usersSend({ type: "CREATE", user })}
onCancel={() => navigate("/users")}
isLoading={usersState.hasTag("loading")}
error={genericError}
myOrgId={myOrgId ?? ""}
/>
)
}

View File

@ -1,17 +1,34 @@
import { useActor } from "@xstate/react" import { useActor } from "@xstate/react"
import React, { useContext } from "react" import React, { useContext, useEffect } from "react"
import { useNavigate } from "react-router"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { XServiceContext } from "../../xServices/StateContext" import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView" import { UsersPageView } from "./UsersPageView"
export const UsersPage: React.FC = () => { export const UsersPage: React.FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [usersState] = useActor(xServices.usersXService) const [usersState, usersSend] = useActor(xServices.usersXService)
const { users, pager, getUsersError } = usersState.context const { users, pager, getUsersError } = usersState.context
const navigate = useNavigate()
/**
* Fetch users on component mount
*/
useEffect(() => {
usersSend("GET_USERS")
}, [usersSend])
if (usersState.matches("error")) { if (usersState.matches("error")) {
return <ErrorSummary error={getUsersError} /> return <ErrorSummary error={getUsersError} />
} else { } else {
return <UsersPageView users={users} pager={pager} /> return (
<UsersPageView
users={users}
pager={pager}
openUserCreationDialog={() => {
navigate("/users/create")
}}
/>
)
} }
} }

View File

@ -7,18 +7,25 @@ import { UsersTable } from "../../components/UsersTable/UsersTable"
export const Language = { export const Language = {
pageTitle: "Users", pageTitle: "Users",
pageSubtitle: (pager: Pager | undefined): string => (pager ? `${pager.total} total` : ""), pageSubtitle: (pager: Pager | undefined): string => (pager ? `${pager.total} total` : ""),
newUserButton: "New User",
} }
export interface UsersPageViewProps { export interface UsersPageViewProps {
users: UserResponse[] users: UserResponse[]
pager?: Pager pager?: Pager
openUserCreationDialog: () => void
} }
export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, pager }) => { export const UsersPageView: React.FC<UsersPageViewProps> = ({ users, pager, openUserCreationDialog }) => {
const styles = useStyles() const styles = useStyles()
return ( return (
<div className={styles.flexColumn}> <div className={styles.flexColumn}>
<Header title={Language.pageTitle} subTitle={Language.pageSubtitle(pager)} /> <Header
title={Language.pageTitle}
subTitle={Language.pageSubtitle(pager)}
action={{ text: Language.newUserButton, onClick: openUserCreationDialog }}
/>
<UsersTable users={users} /> <UsersTable users={users} />
</div> </div>
) )

View File

@ -25,6 +25,8 @@ export const MockUser: UserResponse = {
username: "TestUser", username: "TestUser",
email: "test@coder.com", email: "test@coder.com",
created_at: "", created_at: "",
status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
} }
export const MockUser2: UserResponse = { export const MockUser2: UserResponse = {
@ -32,6 +34,8 @@ export const MockUser2: UserResponse = {
username: "TestUser2", username: "TestUser2",
email: "test2@coder.com", email: "test2@coder.com",
created_at: "", created_at: "",
status: "active",
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
} }
export const MockPager: Pager = { export const MockPager: Pager = {

View File

@ -24,6 +24,9 @@ export const handlers = [
rest.get("/api/v2/users", async (req, res, ctx) => { rest.get("/api/v2/users", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ page: [M.MockUser, M.MockUser2], pager: M.MockPager })) return res(ctx.status(200), ctx.json({ page: [M.MockUser, M.MockUser2], pager: M.MockPager }))
}), }),
rest.post("/api/v2/users", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockUser))
}),
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))
}), }),

View File

@ -1,5 +1,6 @@
import { useInterpret } from "@xstate/react" import { useInterpret } from "@xstate/react"
import React, { createContext } from "react" import React, { createContext } from "react"
import { useNavigate } from "react-router"
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"
@ -22,12 +23,17 @@ interface XServiceContextType {
export const XServiceContext = createContext({} as XServiceContextType) export const XServiceContext = createContext({} as XServiceContextType)
export const XServiceProvider: React.FC = ({ children }) => { export const XServiceProvider: React.FC = ({ children }) => {
const navigate = useNavigate()
const redirectToUsersPage = () => {
navigate("users")
}
return ( return (
<XServiceContext.Provider <XServiceContext.Provider
value={{ value={{
authXService: useInterpret(authMachine), authXService: useInterpret(authMachine),
buildInfoXService: useInterpret(buildInfoMachine), buildInfoXService: useInterpret(buildInfoMachine),
usersXService: useInterpret(usersMachine), usersXService: useInterpret(() => usersMachine.withConfig({ actions: { redirectToUsersPage } })),
}} }}
> >
{children} {children}

View File

@ -0,0 +1,6 @@
import { State } from "xstate"
import { AuthContext, AuthEvent } from "./authXService"
export const selectOrgId = (state: State<AuthContext, AuthEvent>): string | undefined => {
return state.context.me?.organization_ids[0]
}

View File

@ -1,14 +1,23 @@
import { assign, createMachine } from "xstate" import { assign, createMachine } from "xstate"
import * as API from "../../api" import * as API from "../../api"
import { ApiError, FieldErrors, isApiError, mapApiErrorToFieldErrors } from "../../api/errors"
import * as Types from "../../api/types" import * as Types from "../../api/types"
import * as TypesGen from "../../api/typesGenerated"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
createUserSuccess: "Successfully created user.",
}
export interface UsersContext { export interface UsersContext {
users: Types.UserResponse[] users: Types.UserResponse[]
pager?: Types.Pager pager?: Types.Pager
getUsersError?: Error | unknown getUsersError?: Error | unknown
createUserError?: Error | unknown
createUserFormErrors?: FieldErrors
} }
export type UsersEvent = { type: "GET_USERS" } export type UsersEvent = { type: "GET_USERS" } | { type: "CREATE"; user: Types.CreateUserRequest }
export const usersMachine = createMachine( export const usersMachine = createMachine(
{ {
@ -20,21 +29,30 @@ export const usersMachine = createMachine(
getUsers: { getUsers: {
data: Types.PagedUsers data: Types.PagedUsers
} }
createUser: {
data: TypesGen.User
}
}, },
}, },
id: "usersState", id: "usersState",
context: { context: {
users: [], users: [],
}, },
initial: "gettingUsers", initial: "idle",
states: { states: {
idle: {
on: {
GET_USERS: "gettingUsers",
CREATE: "creatingUser",
},
},
gettingUsers: { gettingUsers: {
invoke: { invoke: {
src: "getUsers", src: "getUsers",
id: "getUsers", id: "getUsers",
onDone: [ onDone: [
{ {
target: "#usersState.ready", target: "#usersState.idle",
actions: ["assignUsers", "clearGetUsersError"], actions: ["assignUsers", "clearGetUsersError"],
}, },
], ],
@ -47,10 +65,27 @@ export const usersMachine = createMachine(
}, },
tags: "loading", tags: "loading",
}, },
ready: { creatingUser: {
on: { invoke: {
GET_USERS: "gettingUsers", src: "createUser",
id: "createUser",
onDone: {
target: "idle",
actions: ["displayCreateUserSuccess", "redirectToUsersPage", "clearCreateUserError"],
},
onError: [
{
target: "idle",
cond: "isFormError",
actions: ["assignCreateUserFormErrors"],
},
{
target: "idle",
actions: ["assignCreateUserError"],
},
],
}, },
tags: "loading",
}, },
error: { error: {
on: { on: {
@ -62,6 +97,10 @@ export const usersMachine = createMachine(
{ {
services: { services: {
getUsers: API.getUsers, getUsers: API.getUsers,
createUser: (_, event) => API.createUser(event.user),
},
guards: {
isFormError: (_, event) => isApiError(event.data),
}, },
actions: { actions: {
assignUsers: assign({ assignUsers: assign({
@ -75,6 +114,20 @@ export const usersMachine = createMachine(
...context, ...context,
getUsersError: undefined, getUsersError: undefined,
})), })),
assignCreateUserError: assign({
createUserError: (_, event) => event.data,
}),
assignCreateUserFormErrors: assign({
// the guard ensures it is ApiError
createUserFormErrors: (_, event) => mapApiErrorToFieldErrors((event.data as ApiError).response.data),
}),
clearCreateUserError: assign((context: UsersContext) => ({
...context,
createUserError: undefined,
})),
displayCreateUserSuccess: () => {
displaySuccess(Language.createUserSuccess)
},
}, },
}, },
) )