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

Commit88e30be

Browse files
feat: add the preferences/account page (#999)
1 parentc853eb3 commit88e30be

File tree

15 files changed

+480
-25
lines changed

15 files changed

+480
-25
lines changed

‎site/jest.setup.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ beforeAll(() =>
99

1010
// Reset any request handlers that we may add during the tests,
1111
// so they don't affect other tests.
12-
afterEach(()=>server.resetHandlers())
12+
afterEach(()=>{
13+
server.resetHandlers()
14+
jest.clearAllMocks()
15+
})
1316

1417
// Clean up after the tests are finished.
1518
afterAll(()=>server.close())

‎site/src/api/errors.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import{isApiError,mapApiErrorToFieldErrors}from"./errors"
2+
3+
describe("isApiError",()=>{
4+
it("returns true when the object is an API Error",()=>{
5+
expect(
6+
isApiError({
7+
isAxiosError:true,
8+
response:{
9+
data:{
10+
message:"Invalid entry",
11+
errors:[{detail:"Username is already in use",field:"username"}],
12+
},
13+
},
14+
}),
15+
).toBe(true)
16+
})
17+
18+
it("returns false when the object is Error",()=>{
19+
expect(isApiError(newError())).toBe(false)
20+
})
21+
22+
it("returns false when the object is undefined",()=>{
23+
expect(isApiError(undefined)).toBe(false)
24+
})
25+
})
26+
27+
describe("mapApiErrorToFieldErrors",()=>{
28+
it("returns correct field errors",()=>{
29+
expect(
30+
mapApiErrorToFieldErrors({
31+
message:"Invalid entry",
32+
errors:[{detail:"Username is already in use",field:"username"}],
33+
}),
34+
).toEqual({
35+
username:"Username is already in use",
36+
})
37+
})
38+
})

‎site/src/api/errors.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
importaxios,{AxiosError,AxiosResponse}from"axios"
2+
3+
exportconstLanguage={
4+
errorsByCode:{
5+
defaultErrorCode:"Invalid value",
6+
},
7+
}
8+
9+
interfaceFieldError{
10+
field:string
11+
detail:string
12+
}
13+
14+
typeFieldErrors=Record<FieldError["field"],FieldError["detail"]>
15+
16+
exportinterfaceApiErrorResponse{
17+
message:string
18+
errors?:FieldError[]
19+
}
20+
21+
exporttypeApiError=AxiosError<ApiErrorResponse>&{response:AxiosResponse<ApiErrorResponse>}
22+
23+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
24+
exportconstisApiError=(err:any):err isApiError=>{
25+
if(axios.isAxiosError(err)){
26+
constresponse=err.response?.data
27+
28+
return(
29+
typeofresponse.message==="string"&&(typeofresponse.errors==="undefined"||Array.isArray(response.errors))
30+
)
31+
}
32+
33+
returnfalse
34+
}
35+
36+
exportconstmapApiErrorToFieldErrors=(apiErrorResponse:ApiErrorResponse):FieldErrors=>{
37+
constresult:FieldErrors={}
38+
39+
if(apiErrorResponse.errors){
40+
for(consterrorofapiErrorResponse.errors){
41+
result[error.field]=error.detail||Language.errorsByCode.defaultErrorCode
42+
}
43+
}
44+
45+
returnresult
46+
}

‎site/src/api/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
importaxios,{AxiosRequestHeaders}from"axios"
22
import{mutate}from"swr"
3-
import{MockPager,MockUser,MockUser2}from"../test_helpers"
3+
import{MockPager,MockUser,MockUser2}from"../test_helpers/entities"
44
import*asTypesfrom"./types"
55

66
constCONTENT_TYPE_JSON:AxiosRequestHeaders={
@@ -103,3 +103,8 @@ export const putWorkspaceAutostop = async (
103103
headers:{ ...CONTENT_TYPE_JSON},
104104
})
105105
}
106+
107+
exportconstupdateProfile=async(userId:string,data:Types.UpdateProfileRequest):Promise<Types.UserResponse>=>{
108+
constresponse=awaitaxios.put(`/api/v2/users/${userId}/profile`,data)
109+
returnresponse.data
110+
}

‎site/src/api/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface UserResponse {
1515
readonlyusername:string
1616
readonlyemail:string
1717
readonlycreated_at:string
18+
readonlyname:string
1819
}
1920

2021
/**
@@ -95,3 +96,9 @@ export interface WorkspaceAutostartRequest {
9596
exportinterfaceWorkspaceAutostopRequest{
9697
schedule:string
9798
}
99+
100+
exportinterfaceUpdateProfileRequest{
101+
readonlyusername:string
102+
readonlyemail:string
103+
readonlyname:string
104+
}

‎site/src/components/Form/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ interface FormHelpers {
1717
helperText?:string
1818
}
1919

20-
exportconstgetFormHelpers=<T>(form:FormikContextType<T>,name:string):FormHelpers=>{
20+
exportconstgetFormHelpers=<T>(form:FormikContextType<T>,name:string,error?:string):FormHelpers=>{
2121
// getIn is a util function from Formik that gets at any depth of nesting, and is necessary for the types to work
2222
consttouched=getIn(form.touched,name)
23-
consterrors=getIn(form.errors,name)
23+
consterrors=error??getIn(form.errors,name)
2424
return{
2525
...form.getFieldProps(name),
2626
id:name,

‎site/src/components/Page/RequireAuth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const RequireAuth: React.FC<RequireAuthProps> = ({ children }) => {
1515
constlocation=useLocation()
1616
constredirectTo=embedRedirect(location.pathname)
1717

18-
if(authState.matches("signedOut")||!authState.context.me){
18+
if(authState.matches("signedOut")){
1919
return<Navigateto={redirectTo}/>
2020
}elseif(authState.hasTag("loading")){
2121
return<FullScreenLoader/>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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{getFormHelpers,onChangeTrimmed}from"../Form"
7+
import{Stack}from"../Stack/Stack"
8+
import{LoadingButton}from"./../Button"
9+
10+
interfaceAccountFormValues{
11+
name:string
12+
email:string
13+
username:string
14+
}
15+
16+
exportconstLanguage={
17+
nameLabel:"Name",
18+
usernameLabel:"Username",
19+
emailLabel:"Email",
20+
emailInvalid:"Please enter a valid email address.",
21+
emailRequired:"Please enter an email address.",
22+
updatePreferences:"Update preferences",
23+
}
24+
25+
constvalidationSchema=Yup.object({
26+
email:Yup.string().trim().email(Language.emailInvalid).required(Language.emailRequired),
27+
name:Yup.string().optional(),
28+
username:Yup.string().trim(),
29+
})
30+
31+
exporttypeAccountFormErrors=FormikErrors<AccountFormValues>
32+
exportinterfaceAccountFormProps{
33+
isLoading:boolean
34+
initialValues:AccountFormValues
35+
onSubmit:(values:AccountFormValues)=>void
36+
formErrors?:AccountFormErrors
37+
error?:string
38+
}
39+
40+
exportconstAccountForm:React.FC<AccountFormProps>=({
41+
isLoading,
42+
onSubmit,
43+
initialValues,
44+
formErrors={},
45+
error,
46+
})=>{
47+
constform:FormikContextType<AccountFormValues>=useFormik<AccountFormValues>({
48+
initialValues,
49+
validationSchema,
50+
onSubmit,
51+
})
52+
53+
return(
54+
<>
55+
<formonSubmit={form.handleSubmit}>
56+
<Stack>
57+
<TextField
58+
{...getFormHelpers<AccountFormValues>(form,"name")}
59+
autoFocus
60+
autoComplete="name"
61+
fullWidth
62+
label={Language.nameLabel}
63+
variant="outlined"
64+
/>
65+
<TextField
66+
{...getFormHelpers<AccountFormValues>(form,"email",formErrors.email)}
67+
onChange={onChangeTrimmed(form)}
68+
autoComplete="email"
69+
fullWidth
70+
label={Language.emailLabel}
71+
variant="outlined"
72+
/>
73+
<TextField
74+
{...getFormHelpers<AccountFormValues>(form,"username",formErrors.username)}
75+
onChange={onChangeTrimmed(form)}
76+
autoComplete="username"
77+
fullWidth
78+
label={Language.usernameLabel}
79+
variant="outlined"
80+
/>
81+
82+
{error&&<FormHelperTexterror>{error}</FormHelperText>}
83+
84+
<div>
85+
<LoadingButtoncolor="primary"loading={isLoading}type="submit"variant="contained">
86+
{isLoading ?"" :Language.updatePreferences}
87+
</LoadingButton>
88+
</div>
89+
</Stack>
90+
</form>
91+
</>
92+
)
93+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
importTextFieldfrom"@material-ui/core/TextField"
2+
import{Story}from"@storybook/react"
3+
importReactfrom"react"
4+
import{Stack,StackProps}from"./Stack"
5+
6+
exportdefault{
7+
title:"Components/Stack",
8+
component:Stack,
9+
}
10+
11+
constTemplate:Story<StackProps>=(args:StackProps)=>(
12+
<Stack{...args}>
13+
<TextFieldautoFocusautoComplete="name"fullWidthlabel="Name"variant="outlined"/>
14+
<TextFieldautoComplete="email"fullWidthlabel="Email"variant="outlined"/>
15+
<TextFieldautoComplete="username"fullWidthlabel="Username"variant="outlined"/>
16+
</Stack>
17+
)
18+
19+
exportconstExample=Template.bind({})
20+
Example.args={
21+
spacing:2,
22+
}

‎site/src/components/Stack/Stack.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import{makeStyles}from"@material-ui/core/styles"
2+
importReactfrom"react"
3+
4+
exportinterfaceStackProps{
5+
spacing?:number
6+
}
7+
8+
constuseStyles=makeStyles((theme)=>({
9+
stack:{
10+
display:"flex",
11+
flexDirection:"column",
12+
gap:({ spacing}:{spacing:number})=>theme.spacing(spacing),
13+
},
14+
}))
15+
16+
exportconstStack:React.FC<StackProps>=({ children, spacing=2})=>{
17+
conststyles=useStyles({ spacing})
18+
return<divclassName={styles.stack}>{children}</div>
19+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import{fireEvent,screen,waitFor}from"@testing-library/react"
2+
importReactfrom"react"
3+
import*asAPIfrom"../../api"
4+
import*asAccountFormfrom"../../components/Preferences/AccountForm"
5+
import{GlobalSnackbar}from"../../components/Snackbar/GlobalSnackbar"
6+
import{renderWithAuth}from"../../test_helpers"
7+
import*asAuthXServicefrom"../../xServices/auth/authXService"
8+
import{Language,PreferencesAccountPage}from"./account"
9+
10+
constrenderPage=()=>{
11+
returnrenderWithAuth(
12+
<>
13+
<PreferencesAccountPage/>
14+
<GlobalSnackbar/>
15+
</>,
16+
)
17+
}
18+
19+
constnewData={
20+
name:"User",
21+
email:"user@coder.com",
22+
username:"user",
23+
}
24+
25+
constfillAndSubmitForm=async()=>{
26+
awaitwaitFor(()=>screen.findByLabelText("Name"))
27+
fireEvent.change(screen.getByLabelText("Name"),{target:{value:newData.name}})
28+
fireEvent.change(screen.getByLabelText("Email"),{target:{value:newData.email}})
29+
fireEvent.change(screen.getByLabelText("Username"),{target:{value:newData.username}})
30+
fireEvent.click(screen.getByText(AccountForm.Language.updatePreferences))
31+
}
32+
33+
describe("PreferencesAccountPage",()=>{
34+
describe("when it is a success",()=>{
35+
it("shows the success message",async()=>{
36+
jest.spyOn(API,"updateProfile").mockImplementationOnce((userId,data)=>
37+
Promise.resolve({
38+
id:userId,
39+
...data,
40+
created_at:newDate().toString(),
41+
}),
42+
)
43+
const{ user}=renderPage()
44+
awaitfillAndSubmitForm()
45+
46+
constsuccessMessage=awaitscreen.findByText(AuthXService.Language.successProfileUpdate)
47+
expect(successMessage).toBeDefined()
48+
expect(API.updateProfile).toBeCalledTimes(1)
49+
expect(API.updateProfile).toBeCalledWith(user.id,newData)
50+
})
51+
})
52+
53+
describe("when the email is already taken",()=>{
54+
it("shows an error",async()=>{
55+
jest.spyOn(API,"updateProfile").mockRejectedValueOnce({
56+
isAxiosError:true,
57+
response:{
58+
data:{message:"Invalid profile",errors:[{detail:"Email is already in use",field:"email"}]},
59+
},
60+
})
61+
62+
const{ user}=renderPage()
63+
awaitfillAndSubmitForm()
64+
65+
consterrorMessage=awaitscreen.findByText("Email is already in use")
66+
expect(errorMessage).toBeDefined()
67+
expect(API.updateProfile).toBeCalledTimes(1)
68+
expect(API.updateProfile).toBeCalledWith(user.id,newData)
69+
})
70+
})
71+
72+
describe("when the username is already taken",()=>{
73+
it("shows an error",async()=>{
74+
jest.spyOn(API,"updateProfile").mockRejectedValueOnce({
75+
isAxiosError:true,
76+
response:{
77+
data:{message:"Invalid profile",errors:[{detail:"Username is already in use",field:"username"}]},
78+
},
79+
})
80+
81+
const{ user}=renderPage()
82+
awaitfillAndSubmitForm()
83+
84+
consterrorMessage=awaitscreen.findByText("Username is already in use")
85+
expect(errorMessage).toBeDefined()
86+
expect(API.updateProfile).toBeCalledTimes(1)
87+
expect(API.updateProfile).toBeCalledWith(user.id,newData)
88+
})
89+
})
90+
91+
describe("when it is an unknown error",()=>{
92+
it("shows a generic error message",async()=>{
93+
jest.spyOn(API,"updateProfile").mockRejectedValueOnce({
94+
data:"unknown error",
95+
})
96+
97+
const{ user}=renderPage()
98+
awaitfillAndSubmitForm()
99+
100+
consterrorMessage=awaitscreen.findByText(Language.unknownError)
101+
expect(errorMessage).toBeDefined()
102+
expect(API.updateProfile).toBeCalledTimes(1)
103+
expect(API.updateProfile).toBeCalledWith(user.id,newData)
104+
})
105+
})
106+
})

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp