Skip to content

Commit c16f105

Browse files
presleypgreyscaled
andauthored
feat: Create user page (coder#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 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>
1 parent 4efde58 commit c16f105

File tree

17 files changed

+412
-23
lines changed

17 files changed

+412
-23
lines changed

site/src/AppRouter.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
1717
import { CreateWorkspacePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/CreateWorkspacePage"
1818
import { TemplatePage } from "./pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage"
1919
import { TemplatesPage } from "./pages/TemplatesPages/TemplatesPage"
20+
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
2021
import { UsersPage } from "./pages/UsersPage/UsersPage"
2122
import { WorkspacePage } from "./pages/WorkspacesPage/WorkspacesPage"
2223

@@ -83,14 +84,24 @@ export const AppRouter: React.FC = () => (
8384
/>
8485
</Route>
8586

86-
<Route
87-
path="users"
88-
element={
89-
<AuthAndFrame>
90-
<UsersPage />
91-
</AuthAndFrame>
92-
}
93-
/>
87+
<Route path="users">
88+
<Route
89+
index
90+
element={
91+
<AuthAndFrame>
92+
<UsersPage />
93+
</AuthAndFrame>
94+
}
95+
/>
96+
<Route
97+
path="create"
98+
element={
99+
<RequireAuth>
100+
<CreateUserPage />
101+
</RequireAuth>
102+
}
103+
/>
104+
</Route>
94105
<Route
95106
path="orgs"
96107
element={

site/src/api/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface FieldError {
1111
detail: string
1212
}
1313

14-
type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
14+
export type FieldErrors = Record<FieldError["field"], FieldError["detail"]>
1515

1616
export interface ApiErrorResponse {
1717
message: string

site/src/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export const getUsers = async (): Promise<Types.PagedUsers> => {
8585
})
8686
}
8787

88+
export const createUser = async (user: Types.CreateUserRequest): Promise<TypesGen.User> => {
89+
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
90+
return response.data
91+
}
92+
8893
export const getBuildInfo = async (): Promise<Types.BuildInfoResponse> => {
8994
const response = await axios.get("/api/v2/buildinfo")
9095
return response.data

site/src/api/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,20 @@ export interface LoginResponse {
1010
session_token: string
1111
}
1212

13+
export interface CreateUserRequest {
14+
username: string
15+
email: string
16+
password: string
17+
organization_id: string
18+
}
19+
1320
export interface UserResponse {
1421
readonly id: string
1522
readonly username: string
1623
readonly email: string
1724
readonly created_at: string
25+
readonly status: "active" | "suspended"
26+
readonly organization_ids: string[]
1827
}
1928

2029
/**
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { action } from "@storybook/addon-actions"
2+
import { Story } from "@storybook/react"
3+
import React from "react"
4+
import { CreateUserForm, CreateUserFormProps } from "./CreateUserForm"
5+
6+
export default {
7+
title: "components/CreateUserForm",
8+
component: CreateUserForm,
9+
}
10+
11+
const Template: Story<CreateUserFormProps> = (args: CreateUserFormProps) => <CreateUserForm {...args} />
12+
13+
export const Ready = Template.bind({})
14+
Ready.args = {
15+
onCancel: action("cancel"),
16+
onSubmit: action("submit"),
17+
isLoading: false,
18+
}
19+
20+
export const UnknownError = Template.bind({})
21+
UnknownError.args = {
22+
onCancel: action("cancel"),
23+
onSubmit: action("submit"),
24+
isLoading: false,
25+
error: "Something went wrong",
26+
}
27+
28+
export const FormError = Template.bind({})
29+
FormError.args = {
30+
onCancel: action("cancel"),
31+
onSubmit: action("submit"),
32+
isLoading: false,
33+
formErrors: {
34+
username: "Username taken",
35+
},
36+
}
37+
38+
export const Loading = Template.bind({})
39+
Loading.args = {
40+
onCancel: action("cancel"),
41+
onSubmit: action("submit"),
42+
isLoading: true,
43+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import FormHelperText from "@material-ui/core/FormHelperText"
2+
import TextField from "@material-ui/core/TextField"
3+
import { FormikContextType, FormikErrors, useFormik } from "formik"
4+
import React from "react"
5+
import * as Yup from "yup"
6+
import { CreateUserRequest } from "../../api/types"
7+
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
8+
import { FormFooter } from "../FormFooter/FormFooter"
9+
import { FullPageForm } from "../FullPageForm/FullPageForm"
10+
11+
export const Language = {
12+
emailLabel: "Email",
13+
passwordLabel: "Password",
14+
usernameLabel: "Username",
15+
emailInvalid: "Please enter a valid email address.",
16+
emailRequired: "Please enter an email address.",
17+
passwordRequired: "Please enter a password.",
18+
usernameRequired: "Please enter a username.",
19+
createUser: "Create",
20+
cancel: "Cancel",
21+
}
22+
23+
export interface CreateUserFormProps {
24+
onSubmit: (user: CreateUserRequest) => void
25+
onCancel: () => void
26+
formErrors?: FormikErrors<CreateUserRequest>
27+
isLoading: boolean
28+
error?: string
29+
myOrgId: string
30+
}
31+
32+
const validationSchema = Yup.object({
33+
email: Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
34+
password: Yup.string().required(Language.passwordRequired),
35+
username: Yup.string().required(Language.usernameRequired),
36+
})
37+
38+
export const CreateUserForm: React.FC<CreateUserFormProps> = ({
39+
onSubmit,
40+
onCancel,
41+
formErrors,
42+
isLoading,
43+
error,
44+
myOrgId,
45+
}) => {
46+
const form: FormikContextType<CreateUserRequest> = useFormik<CreateUserRequest>({
47+
initialValues: {
48+
email: "",
49+
password: "",
50+
username: "",
51+
organization_id: myOrgId,
52+
},
53+
validationSchema,
54+
onSubmit,
55+
})
56+
const getFieldHelpers = getFormHelpers<CreateUserRequest>(form, formErrors)
57+
58+
return (
59+
<FullPageForm title="Create user" onCancel={onCancel}>
60+
<form onSubmit={form.handleSubmit}>
61+
<TextField
62+
{...getFieldHelpers("username")}
63+
onChange={onChangeTrimmed(form)}
64+
autoComplete="username"
65+
autoFocus
66+
fullWidth
67+
label={Language.usernameLabel}
68+
variant="outlined"
69+
/>
70+
<TextField
71+
{...getFieldHelpers("email")}
72+
onChange={onChangeTrimmed(form)}
73+
autoComplete="email"
74+
fullWidth
75+
label={Language.emailLabel}
76+
variant="outlined"
77+
/>
78+
<TextField
79+
{...getFieldHelpers("password")}
80+
autoComplete="current-password"
81+
fullWidth
82+
id="password"
83+
label={Language.passwordLabel}
84+
type="password"
85+
variant="outlined"
86+
/>
87+
{error && <FormHelperText error>{error}</FormHelperText>}
88+
<FormFooter onCancel={onCancel} isLoading={isLoading} />
89+
</form>
90+
</FullPageForm>
91+
)
92+
}

site/src/components/FormFooter/FormFooter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { makeStyles } from "@material-ui/core/styles"
33
import React from "react"
44
import { LoadingButton } from "../LoadingButton/LoadingButton"
55

6-
const Language = {
6+
export const Language = {
77
cancelLabel: "Cancel",
88
defaultSubmitLabel: "Submit",
99
}

site/src/pages/PreferencesPages/AccountPage/AccountPage.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ describe("AccountPage", () => {
3434
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
3535
Promise.resolve({
3636
id: userId,
37-
...data,
3837
created_at: new Date().toString(),
38+
status: "active",
39+
organization_ids: ["123"],
40+
...data,
3941
}),
4042
)
4143
const { user } = renderPage()
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { screen } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import { rest } from "msw"
4+
import React from "react"
5+
import * as API from "../../../api"
6+
import { Language as FormLanguage } from "../../../components/CreateUserForm/CreateUserForm"
7+
import { Language as FooterLanguage } from "../../../components/FormFooter/FormFooter"
8+
import { history, render } from "../../../testHelpers"
9+
import { server } from "../../../testHelpers/server"
10+
import { Language as UserLanguage } from "../../../xServices/users/usersXService"
11+
import { CreateUserPage, Language } from "./CreateUserPage"
12+
13+
const fillForm = async ({
14+
username = "someuser",
15+
email = "someone@coder.com",
16+
password = "password",
17+
}: {
18+
username?: string
19+
email?: string
20+
password?: string
21+
}) => {
22+
const usernameField = screen.getByLabelText(FormLanguage.usernameLabel)
23+
const emailField = screen.getByLabelText(FormLanguage.emailLabel)
24+
const passwordField = screen.getByLabelText(FormLanguage.passwordLabel)
25+
await userEvent.type(usernameField, username)
26+
await userEvent.type(emailField, email)
27+
await userEvent.type(passwordField, password)
28+
const submitButton = await screen.findByText(FooterLanguage.defaultSubmitLabel)
29+
submitButton.click()
30+
}
31+
32+
describe("Create User Page", () => {
33+
beforeEach(() => {
34+
history.replace("/users/create")
35+
})
36+
37+
it("shows validation error message", async () => {
38+
render(<CreateUserPage />)
39+
await fillForm({ email: "test" })
40+
const errorMessage = await screen.findByText(FormLanguage.emailInvalid)
41+
expect(errorMessage).toBeDefined()
42+
})
43+
44+
it("shows generic error message", async () => {
45+
jest.spyOn(API, "createUser").mockRejectedValueOnce({
46+
data: "unknown error",
47+
})
48+
render(<CreateUserPage />)
49+
await fillForm({})
50+
const errorMessage = await screen.findByText(Language.unknownError)
51+
expect(errorMessage).toBeDefined()
52+
})
53+
54+
it("shows API error message", async () => {
55+
const fieldErrorMessage = "username already in use"
56+
server.use(
57+
rest.post("/api/v2/users", async (req, res, ctx) => {
58+
return res(
59+
ctx.status(400),
60+
ctx.json({
61+
message: "invalid field",
62+
errors: [
63+
{
64+
detail: fieldErrorMessage,
65+
field: "username",
66+
},
67+
],
68+
}),
69+
)
70+
}),
71+
)
72+
render(<CreateUserPage />)
73+
await fillForm({})
74+
const errorMessage = await screen.findByText(fieldErrorMessage)
75+
expect(errorMessage).toBeDefined()
76+
})
77+
78+
it("shows success notification and redirects to users page", async () => {
79+
render(<CreateUserPage />)
80+
await fillForm({})
81+
const successMessage = screen.findByText(UserLanguage.createUserSuccess)
82+
expect(successMessage).toBeDefined()
83+
})
84+
85+
it("redirects to users page on cancel", async () => {
86+
render(<CreateUserPage />)
87+
const cancelButton = await screen.findByText(FooterLanguage.cancelLabel)
88+
cancelButton.click()
89+
expect(history.location.pathname).toEqual("/users")
90+
})
91+
92+
it("redirects to users page on close", async () => {
93+
render(<CreateUserPage />)
94+
const closeButton = await screen.findByText("ESC")
95+
closeButton.click()
96+
expect(history.location.pathname).toEqual("/users")
97+
})
98+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useActor, useSelector } from "@xstate/react"
2+
import React, { useContext } from "react"
3+
import { useNavigate } from "react-router"
4+
import { CreateUserRequest } from "../../../api/types"
5+
import { CreateUserForm } from "../../../components/CreateUserForm/CreateUserForm"
6+
import { selectOrgId } from "../../../xServices/auth/authSelectors"
7+
import { XServiceContext } from "../../../xServices/StateContext"
8+
9+
export const Language = {
10+
unknownError: "Oops, an unknown error occurred.",
11+
}
12+
13+
export const CreateUserPage: React.FC = () => {
14+
const xServices = useContext(XServiceContext)
15+
const myOrgId = useSelector(xServices.authXService, selectOrgId)
16+
const [usersState, usersSend] = useActor(xServices.usersXService)
17+
const { createUserError, createUserFormErrors } = usersState.context
18+
const navigate = useNavigate()
19+
// There is no field for organization id in Community Edition, so handle its field error like a generic error
20+
const genericError =
21+
createUserError || createUserFormErrors?.organization_id || !myOrgId ? Language.unknownError : undefined
22+
23+
return (
24+
<CreateUserForm
25+
formErrors={createUserFormErrors}
26+
onSubmit={(user: CreateUserRequest) => usersSend({ type: "CREATE", user })}
27+
onCancel={() => navigate("/users")}
28+
isLoading={usersState.hasTag("loading")}
29+
error={genericError}
30+
myOrgId={myOrgId ?? ""}
31+
/>
32+
)
33+
}

0 commit comments

Comments
 (0)