Skip to content

feat: Create user page #1197

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 49 commits into from
Apr 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
db013f8
Add button and route
presleyp Apr 22, 2022
b73fb80
Hook up api
presleyp Apr 22, 2022
fc5ec16
Lint
presleyp Apr 22, 2022
635eab9
Add basic form
presleyp Apr 22, 2022
c2de4d4
Get users on page mount
presleyp Apr 25, 2022
028840a
Make cancel work
presleyp Apr 25, 2022
8c31769
Creating -> idle bc users page refetches
presleyp Apr 25, 2022
1021029
Merge branch 'main' into create-user/presleyp/734
presleyp Apr 25, 2022
073d694
Import as TypesGen
presleyp Apr 25, 2022
ad76fcb
Merge branch 'main' into create-user/presleyp/734
presleyp Apr 25, 2022
4aa97e6
Handle api errors
presleyp Apr 25, 2022
211ca6f
Lint
presleyp Apr 25, 2022
f9c29c8
Add handler
presleyp Apr 25, 2022
c501b6a
Add FormFooter
presleyp Apr 25, 2022
5d6d0c1
Add FullPageForm
presleyp Apr 25, 2022
861969e
Lint
presleyp Apr 25, 2022
ee64b70
Merge branch 'main' into create-user/presleyp/734
presleyp Apr 25, 2022
a939194
Merge branch 'fullpageform/presleyp' into create-user/presleyp/734
presleyp Apr 25, 2022
8b72976
Better form, error, stories
presleyp Apr 25, 2022
369af6b
Make detail optional
presleyp Apr 25, 2022
90283ed
Use Language
presleyp Apr 25, 2022
8f6d135
Merge branch 'fullpageform/presleyp' into create-user/presleyp/734
presleyp Apr 25, 2022
35f462a
Remove detail prop
presleyp Apr 25, 2022
23ba888
Add back autoFocus
presleyp Apr 26, 2022
c2f6cce
Remove displayError, use displaySuccess
presleyp Apr 26, 2022
f46799e
Lint, export Language
presleyp Apr 26, 2022
7d6a03f
Tests - wip
presleyp Apr 26, 2022
f5fe8e8
Fix cancel tests
presleyp Apr 27, 2022
470481c
Switch back to mock
presleyp Apr 27, 2022
6a490c2
Add navigate to xservice
presleyp Apr 27, 2022
7d98c13
Move error type predicate to xservice
presleyp Apr 27, 2022
30b8799
Lint
presleyp Apr 27, 2022
cf8442f
Switch to using creation mode in XState
presleyp Apr 27, 2022
8f23d91
Merge branch 'main' into create-user/presleyp/734
presleyp Apr 27, 2022
642ca22
Lint
presleyp Apr 27, 2022
a0717f8
Lint
presleyp Apr 27, 2022
9941c1c
Lint
presleyp Apr 27, 2022
8ca5922
Revert "Switch to using creation mode in XState"
presleyp Apr 27, 2022
01522cc
Give XService a navigate action
presleyp Apr 27, 2022
fa75015
Add missing validation messages
presleyp Apr 28, 2022
c3bb6ff
Fix XState warning
presleyp Apr 28, 2022
62dad5e
Fix tests
presleyp Apr 28, 2022
797bb34
Pretend user has org id and make it work
presleyp Apr 28, 2022
6f6165b
Format
presleyp Apr 28, 2022
4c56afd
Lint
presleyp Apr 28, 2022
6acba0e
Merge branch 'main' into create-user/presleyp/734
presleyp Apr 28, 2022
9154a33
Switch to org ids array
presleyp Apr 28, 2022
983d7ff
Skip lines between tests
presleyp Apr 28, 2022
10c765c
Punctuate notification messages
presleyp Apr 28, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just hard-coded the org id in here; in EE we'll need a drop-down field for it.

},
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this instead of getting authState using useActor means that this component will only re-render in response to changes in the org id, not all authXService changes. I haven't bothered with this approach in other places yet because so far it's been components that have a lot to do with the XService they're accessing and there probably won't be many irrelevant updates, but we should do this for cases where we only care about a little piece of some global state.

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 ?? ""}
/>
)
}
Loading