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

Commitc16f105

Browse files
feat: Create user page (#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, storiesbug 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 xserviceDoesn't work in test* Move error type predicate to xservice* Lint* Switch to using creation mode in XStatestill problems in tests* Lint* Lint* Lint* Revert "Switch to using creation mode in XState"This reverts commitcf8442f.* Give XService a navigate action* Add missing validation messages* Fix XState warning* Fix testsIRL 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 testsCo-authored-by: G r e y <grey@coder.com>* Punctuate notification messagesCo-authored-by: G r e y <grey@coder.com>
1 parent4efde58 commitc16f105

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+
<Routepath="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-
typeFieldErrors=Record<FieldError["field"],FieldError["detail"]>
14+
exporttypeFieldErrors=Record<FieldError["field"],FieldError["detail"]>
1515

1616
exportinterfaceApiErrorResponse{
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+
exportconstcreateUser=async(user:Types.CreateUserRequest):Promise<TypesGen.User>=>{
89+
constresponse=awaitaxios.post<TypesGen.User>("/api/v2/users",user)
90+
returnresponse.data
91+
}
92+
8893
exportconstgetBuildInfo=async():Promise<Types.BuildInfoResponse>=>{
8994
constresponse=awaitaxios.get("/api/v2/buildinfo")
9095
returnresponse.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+
exportinterfaceCreateUserRequest{
14+
username:string
15+
email:string
16+
password:string
17+
organization_id:string
18+
}
19+
1320
exportinterfaceUserResponse{
1421
readonlyid:string
1522
readonlyusername:string
1623
readonlyemail:string
1724
readonlycreated_at:string
25+
readonlystatus:"active"|"suspended"
26+
readonlyorganization_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+
importReactfrom"react"
4+
import{CreateUserForm,CreateUserFormProps}from"./CreateUserForm"
5+
6+
exportdefault{
7+
title:"components/CreateUserForm",
8+
component:CreateUserForm,
9+
}
10+
11+
constTemplate:Story<CreateUserFormProps>=(args:CreateUserFormProps)=><CreateUserForm{...args}/>
12+
13+
exportconstReady=Template.bind({})
14+
Ready.args={
15+
onCancel:action("cancel"),
16+
onSubmit:action("submit"),
17+
isLoading:false,
18+
}
19+
20+
exportconstUnknownError=Template.bind({})
21+
UnknownError.args={
22+
onCancel:action("cancel"),
23+
onSubmit:action("submit"),
24+
isLoading:false,
25+
error:"Something went wrong",
26+
}
27+
28+
exportconstFormError=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+
exportconstLoading=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+
importFormHelperTextfrom"@material-ui/core/FormHelperText"
2+
importTextFieldfrom"@material-ui/core/TextField"
3+
import{FormikContextType,FormikErrors,useFormik}from"formik"
4+
importReactfrom"react"
5+
import*asYupfrom"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+
exportconstLanguage={
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+
exportinterfaceCreateUserFormProps{
24+
onSubmit:(user:CreateUserRequest)=>void
25+
onCancel:()=>void
26+
formErrors?:FormikErrors<CreateUserRequest>
27+
isLoading:boolean
28+
error?:string
29+
myOrgId:string
30+
}
31+
32+
constvalidationSchema=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+
exportconstCreateUserForm:React.FC<CreateUserFormProps>=({
39+
onSubmit,
40+
onCancel,
41+
formErrors,
42+
isLoading,
43+
error,
44+
myOrgId,
45+
})=>{
46+
constform:FormikContextType<CreateUserRequest>=useFormik<CreateUserRequest>({
47+
initialValues:{
48+
email:"",
49+
password:"",
50+
username:"",
51+
organization_id:myOrgId,
52+
},
53+
validationSchema,
54+
onSubmit,
55+
})
56+
constgetFieldHelpers=getFormHelpers<CreateUserRequest>(form,formErrors)
57+
58+
return(
59+
<FullPageFormtitle="Create user"onCancel={onCancel}>
60+
<formonSubmit={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&&<FormHelperTexterror>{error}</FormHelperText>}
88+
<FormFooteronCancel={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
importReactfrom"react"
44
import{LoadingButton}from"../LoadingButton/LoadingButton"
55

6-
constLanguage={
6+
exportconstLanguage={
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:newDate().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+
importuserEventfrom"@testing-library/user-event"
3+
import{rest}from"msw"
4+
importReactfrom"react"
5+
import*asAPIfrom"../../../api"
6+
import{LanguageasFormLanguage}from"../../../components/CreateUserForm/CreateUserForm"
7+
import{LanguageasFooterLanguage}from"../../../components/FormFooter/FormFooter"
8+
import{history,render}from"../../../testHelpers"
9+
import{server}from"../../../testHelpers/server"
10+
import{LanguageasUserLanguage}from"../../../xServices/users/usersXService"
11+
import{CreateUserPage,Language}from"./CreateUserPage"
12+
13+
constfillForm=async({
14+
username="someuser",
15+
email="someone@coder.com",
16+
password="password",
17+
}:{
18+
username?:string
19+
email?:string
20+
password?:string
21+
})=>{
22+
constusernameField=screen.getByLabelText(FormLanguage.usernameLabel)
23+
constemailField=screen.getByLabelText(FormLanguage.emailLabel)
24+
constpasswordField=screen.getByLabelText(FormLanguage.passwordLabel)
25+
awaituserEvent.type(usernameField,username)
26+
awaituserEvent.type(emailField,email)
27+
awaituserEvent.type(passwordField,password)
28+
constsubmitButton=awaitscreen.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+
awaitfillForm({email:"test"})
40+
consterrorMessage=awaitscreen.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+
awaitfillForm({})
50+
consterrorMessage=awaitscreen.findByText(Language.unknownError)
51+
expect(errorMessage).toBeDefined()
52+
})
53+
54+
it("shows API error message",async()=>{
55+
constfieldErrorMessage="username already in use"
56+
server.use(
57+
rest.post("/api/v2/users",async(req,res,ctx)=>{
58+
returnres(
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+
awaitfillForm({})
74+
consterrorMessage=awaitscreen.findByText(fieldErrorMessage)
75+
expect(errorMessage).toBeDefined()
76+
})
77+
78+
it("shows success notification and redirects to users page",async()=>{
79+
render(<CreateUserPage/>)
80+
awaitfillForm({})
81+
constsuccessMessage=screen.findByText(UserLanguage.createUserSuccess)
82+
expect(successMessage).toBeDefined()
83+
})
84+
85+
it("redirects to users page on cancel",async()=>{
86+
render(<CreateUserPage/>)
87+
constcancelButton=awaitscreen.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+
constcloseButton=awaitscreen.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+
importReact,{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+
exportconstLanguage={
10+
unknownError:"Oops, an unknown error occurred.",
11+
}
12+
13+
exportconstCreateUserPage:React.FC=()=>{
14+
constxServices=useContext(XServiceContext)
15+
constmyOrgId=useSelector(xServices.authXService,selectOrgId)
16+
const[usersState,usersSend]=useActor(xServices.usersXService)
17+
const{ createUserError, createUserFormErrors}=usersState.context
18+
constnavigate=useNavigate()
19+
// There is no field for organization id in Community Edition, so handle its field error like a generic error
20+
constgenericError=
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)

[8]ページ先頭

©2009-2025 Movatter.jp