Skip to content

Commit

Permalink
feat: Create user page (#1197)
Browse files Browse the repository at this point in the history
* 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 cf8442f.

* 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>
  • Loading branch information
presleyp and greyscaled committed Apr 28, 2022
1 parent 4efde58 commit c16f105
Show file tree
Hide file tree
Showing 17 changed files with 412 additions and 23 deletions.
27 changes: 19 additions & 8 deletions site/src/AppRouter.tsx
Expand Up @@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage"
import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage"
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"

Expand Down Expand Up @@ -83,14 +84,24 @@ export const AppRouter: React.FC = () => (
/>
</Route>

<Route
path="users"
element={
<AuthAndFrame>
<UsersPage />
</AuthAndFrame>
}
/>
<Route path="users">
<Route
index
element={
<AuthAndFrame>
<UsersPage />
</AuthAndFrame>
}
/>
<Route
path="create"
element={
<RequireAuth>
<CreateUserPage />
</RequireAuth>
}
/>
</Route>
<Route
path="orgs"
element={
Expand Down
2 changes: 1 addition & 1 deletion site/src/api/errors.ts
Expand Up @@ -11,7 +11,7 @@ interface FieldError {
detail: string
}

type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>

export interface ApiErrorResponse {
message: string
Expand Down
5 changes: 5 additions & 0 deletions site/src/api/index.ts
Expand Up @@ -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> => {
const response = await axios.get("/api/v2/buildinfo")
return response.data
Expand Down
9 changes: 9 additions & 0 deletions site/src/api/types.ts
Expand Up @@ -10,11 +10,20 @@ export interface LoginResponse {
session_token: string
}

export interface CreateUserRequest {
username: string
email: string
password: string
organization_id: string
}

export interface UserResponse {
readonly id: string
readonly username: string
readonly email: string
readonly created_at: string
readonly status: "active" | "suspended"
readonly organization_ids: string[]
}

/**
Expand Down
43 changes: 43 additions & 0 deletions site/src/components/CreateUserForm/CreateUserForm.stories.tsx
@@ -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,
}
92 changes: 92 additions & 0 deletions site/src/components/CreateUserForm/CreateUserForm.tsx
@@ -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>
)
}
2 changes: 1 addition & 1 deletion site/src/components/FormFooter/FormFooter.tsx
Expand Up @@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { LoadingButton } from "../LoadingButton/LoadingButton"

const Language = {
export const Language = {
cancelLabel: "Cancel",
defaultSubmitLabel: "Submit",
}
Expand Down
Expand Up @@ -34,8 +34,10 @@ describe("AccountPage", () => {
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
Promise.resolve({
id: userId,
...data,
created_at: new Date().toString(),
status: "active",
organization_ids: ["123"],
...data,
}),
)
const { user } = renderPage()
Expand Down
98 changes: 98 additions & 0 deletions site/src/pages/UsersPage/CreateUserPage/CreateUserPage.test.tsx
@@ -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")
})
})
33 changes: 33 additions & 0 deletions site/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx
@@ -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 ?? ""}
/>
)
}

0 comments on commit c16f105

Please sign in to comment.