Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

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

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

export interface ApiErrorResponse {
message: string
Expand Down
5 changes: 5 additions & 0 deletionssite/src/api/index.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/api/types.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/components/CreateUserForm/CreateUserForm.stories.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/components/CreateUserForm/CreateUserForm.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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
ContributorAuthor

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.

greyscaled reacted with thumbs up emoji
},
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 deletionsite/src/components/FormFooter/FormFooter.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 = {
exportconst Language = {
cancelLabel: "Cancel",
defaultSubmitLabel: "Submit",
}
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/pages/UsersPage/CreateUserPage/CreateUserPage.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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
ContributorAuthor

Choose a reason for hiding this comment

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

Doing this instead of gettingauthState usinguseActor means that this component will only re-render in response to changes in the org id, not allauthXService 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.

greyscaled reacted with heart emoji
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

[8]ページ先頭

©2009-2025 Movatter.jp