- Notifications
You must be signed in to change notification settings - Fork928
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
db013f8
b73fb80
fc5ec16
635eab9
c2de4d4
028840a
8c31769
1021029
073d694
ad76fcb
4aa97e6
211ca6f
f9c29c8
c501b6a
5d6d0c1
861969e
ee64b70
a939194
8b72976
369af6b
90283ed
8f6d135
35f462a
23ba888
c2f6cce
f46799e
7d6a03f
f5fe8e8
470481c
6a490c2
7d98c13
30b8799
cf8442f
8f23d91
642ca22
a0717f8
9941c1c
8ca5922
01522cc
fa75015
c3bb6ff
62dad5e
797bb34
6f6165b
4c56afd
6acba0e
9154a33
983d7ff
10c765c
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -10,11 +10,20 @@ export interface LoginResponse { | ||
session_token: string | ||
} | ||
export interface CreateUserRequest { | ||
username: string | ||
email: string | ||
password: string | ||
organization_id: string | ||
} | ||
presleyp marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
export interface UserResponse { | ||
readonly id: string | ||
readonly username: string | ||
readonly email: string | ||
readonly created_at: string | ||
readonly status: "active" | "suspended" | ||
readonly organization_ids: string[] | ||
} | ||
/** | ||
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, | ||
} |
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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
) | ||
} |
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 () => { | ||
presleyp marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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 () => { | ||
presleyp marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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 () => { | ||
presleyp marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
render(<CreateUserPage />) | ||
await fillForm({}) | ||
const successMessage = screen.findByText(UserLanguage.createUserSuccess) | ||
expect(successMessage).toBeDefined() | ||
}) | ||
it("redirects to users page on cancel", async () => { | ||
presleyp marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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 () => { | ||
presleyp marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
render(<CreateUserPage />) | ||
const closeButton = await screen.findByText("ESC") | ||
closeButton.click() | ||
expect(history.location.pathname).toEqual("/users") | ||
}) | ||
}) |
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. Doing this instead of getting | ||
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 ?? ""} | ||
/> | ||
) | ||
} |
Uh oh!
There was an error while loading.Please reload this page.