Skip to content

Commit 9215ad6

Browse files
fix: DIA-1916: [FE] User is NOT able to upload profile image on new account page (#7106)
Co-authored-by: yyassi-heartex <104568407+yyassi-heartex@users.noreply.github.com>
1 parent a005f6b commit 9215ad6

File tree

5 files changed

+177
-79
lines changed

5 files changed

+177
-79
lines changed

web/libs/core/src/lib/api-proxy/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ export class APIProxy<T extends {}> {
5353
this.resolveMethods(options.endpoints);
5454
}
5555

56-
invoke(method: keyof typeof this.methods, params?: Record<string, any>, options?: ApiParams) {
56+
invoke<T>(
57+
method: keyof typeof this.methods,
58+
params?: Record<string, any>,
59+
options?: ApiParams,
60+
): Promise<WrappedResponse<T>> {
5761
if (!this.isValidMethod(method as string)) {
5862
throw new Error(`Method ${method.toString()} not found`);
5963
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { APIUser } from "@humansignal/core/types/user";
2+
import { API } from "apps/labelstudio/src/providers/ApiProvider";
3+
import { useAtomValue } from "jotai";
4+
import { atomWithMutation, atomWithQuery, queryClientAtom } from "jotai-tanstack-query";
5+
import { useCallback } from "react";
6+
7+
const currentUserAtom = atomWithQuery(() => ({
8+
queryKey: ["current-user"],
9+
async queryFn() {
10+
return await API.invoke<APIUser>("me");
11+
},
12+
}));
13+
14+
const currentUserUpdateAtom = atomWithMutation((get) => ({
15+
mutationKey: ["update-current-user"],
16+
async mutationFn({ pk, user }: { pk: number; user: Partial<APIUser> }) {
17+
return await API.invoke<APIUser>("updateUser", { pk }, { body: user });
18+
},
19+
20+
onSettled() {
21+
const queryClient = get(queryClientAtom);
22+
queryClient.invalidateQueries({ queryKey: ["current-user"] });
23+
},
24+
}));
25+
26+
export function useCurrentUserAtom() {
27+
const user = useAtomValue(currentUserAtom);
28+
const updateUser = useAtomValue(currentUserUpdateAtom);
29+
const queryClient = useAtomValue(queryClientAtom);
30+
const refetch = useCallback(() => queryClient.invalidateQueries({ queryKey: ["current-user"] }), []);
31+
const update = useCallback(
32+
(userUpdate: Partial<APIUser>) => {
33+
if (!user.data) {
34+
console.error("User is not loaded. Try fetching first.");
35+
return;
36+
}
37+
updateUser.mutate({ pk: user.data.id, user: userUpdate });
38+
},
39+
[user.data?.id, updateUser.mutate],
40+
);
41+
42+
const updateAsync = useCallback(
43+
(userUpdate: Partial<APIUser>) => {
44+
if (!user.data) {
45+
console.error("User is not loaded. Try fetching first.");
46+
return;
47+
}
48+
return updateUser.mutateAsync({ pk: user.data.id, user: userUpdate });
49+
},
50+
[user.data?.id, updateUser.mutate],
51+
);
52+
53+
const commonResponse = {
54+
isInProgress: user.isFetching || updateUser.isPending,
55+
isUpdating: updateUser.isPending,
56+
loaded: user.isSuccess,
57+
fetch: refetch,
58+
update,
59+
updateAsync,
60+
} as const;
61+
62+
return user.isSuccess
63+
? ({
64+
user: user.data,
65+
error: null,
66+
...commonResponse,
67+
} as const)
68+
: ({
69+
user: null,
70+
error: user.error,
71+
...commonResponse,
72+
} as const);
73+
}

web/libs/core/src/pages/AccountSettings/sections/PersonalInfo.tsx

Lines changed: 81 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,103 @@
1-
import { useCallback, useEffect, useRef, useState } from "react";
1+
import { type FormEventHandler, useCallback, useEffect, useRef, useState } from "react";
22
import clsx from "clsx";
3-
import { InputFile, useToast } from "@humansignal/ui";
3+
import { InputFile, ToastType, useToast } from "@humansignal/ui";
4+
import { API } from "apps/labelstudio/src/providers/ApiProvider";
5+
import styles from "../AccountSettings.module.scss";
6+
import { useCurrentUserAtom } from "@humansignal/core/lib/hooks/useCurrentUser";
7+
import { atomWithMutation } from "jotai-tanstack-query";
8+
import { useAtomValue } from "jotai";
9+
10+
/**
11+
* FIXME: This is legacy imports. We're not supposed to use such statements
12+
* each one of these eventually has to be migrated to core or ui
13+
*/
414
import { Input } from "/apps/labelstudio/src/components/Form/Elements";
515
import { Userpic } from "/apps/labelstudio/src/components/Userpic/Userpic";
6-
import { useCurrentUser } from "/apps/labelstudio/src/providers/CurrentUser";
716
import { Button } from "/apps/labelstudio/src/components/Button/Button";
8-
import { useAPI } from "apps/labelstudio/src/providers/ApiProvider";
9-
import styles from "../AccountSettings.module.scss";
1017

11-
export const PersonalInfo = () => {
12-
const api = useAPI();
13-
const toast = useToast();
14-
const { user, fetch, isInProgress: userInProgress } = useCurrentUser();
15-
const [fname, setFName] = useState("");
16-
const [lname, setLName] = useState("");
17-
const [email, setEmail] = useState("");
18-
const [phone, setPhone] = useState("");
19-
const [isInProgress, setIsInProgress] = useState(false);
20-
const userInfoForm = useRef();
21-
const userAvatarForm = useRef();
22-
const avatarRef = useRef();
23-
const fileChangeHandler = () => userAvatarForm.current.requestSubmit();
24-
const avatarFormSubmitHandler = useCallback(
25-
async (e, isDelete = false) => {
26-
e.preventDefault();
27-
const response = await api.callApi(isDelete ? "deleteUserAvatar" : "updateUserAvatar", {
28-
params: {
29-
pk: user?.id,
30-
},
31-
body: {
32-
avatar: avatarRef.current.files[0],
33-
},
18+
const updateUserAvatarAtom = atomWithMutation(() => ({
19+
mutationKey: ["update-user"],
20+
async mutationFn({
21+
userId,
22+
body,
23+
isDelete,
24+
}: { userId: number; body: FormData; isDelete?: never } | { userId: number; isDelete: true; body?: never }) {
25+
const method = isDelete ? "deleteUserAvatar" : "updateUserAvatar";
26+
const response = await API.invoke(
27+
method,
28+
{
29+
pk: userId,
30+
},
31+
{
32+
body,
3433
headers: {
3534
"Content-Type": "multipart/form-data",
3635
},
3736
errorFilter: () => true,
37+
},
38+
);
39+
return response;
40+
},
41+
}));
42+
43+
export const PersonalInfo = () => {
44+
const toast = useToast();
45+
const { user, fetch: refetchUser, isInProgress: userInProgress, updateAsync: updateUser } = useCurrentUserAtom();
46+
const updateUserAvatar = useAtomValue(updateUserAvatarAtom);
47+
const [isInProgress, setIsInProgress] = useState(false);
48+
const avatarRef = useRef<HTMLInputElement>();
49+
const fileChangeHandler: FormEventHandler<HTMLInputElement> = useCallback(
50+
async (e) => {
51+
if (!user) return;
52+
53+
const input = e.currentTarget as HTMLInputElement;
54+
const body = new FormData();
55+
body.append("avatar", input.files?.[0] ?? "");
56+
const response = await updateUserAvatar.mutateAsync({
57+
body,
58+
userId: user.id,
3859
});
39-
if (!isDelete && response?.status) {
40-
toast.show({ message: response?.response?.detail ?? "Error updating avatar", type: "error" });
60+
61+
if (!response.$meta.ok) {
62+
toast.show({ message: response?.response?.detail ?? "Error updating avatar", type: ToastType.error });
4163
} else {
42-
fetch();
64+
refetchUser();
4365
}
44-
userAvatarForm.current.reset();
66+
input.value = "";
4567
},
46-
[user?.id, fetch],
68+
[user?.id],
4769
);
48-
const userFormSubmitHandler = useCallback(
70+
71+
const deleteUserAvatar = async () => {
72+
if (!user) return;
73+
await updateUserAvatar.mutateAsync({ userId: user.id, isDelete: true });
74+
refetchUser();
75+
};
76+
77+
const userFormSubmitHandler: FormEventHandler = useCallback(
4978
async (e) => {
5079
e.preventDefault();
51-
const response = await api.callApi("updateUser", {
52-
params: {
53-
pk: user?.id,
54-
},
55-
body: {
56-
first_name: fname,
57-
last_name: lname,
58-
phone,
59-
},
60-
errorFilter: () => true,
61-
});
62-
if (response?.status) {
63-
toast.show({ message: response?.response?.detail ?? "Error updating user", type: "error" });
64-
} else {
65-
fetch();
80+
if (!user) return;
81+
const body = new FormData(e.currentTarget as HTMLFormElement);
82+
const json = Object.fromEntries(body.entries());
83+
const response = await updateUser(json);
84+
85+
refetchUser();
86+
if (!response?.$meta.ok) {
87+
toast.show({ message: response?.response?.detail ?? "Error updating user", type: ToastType.error });
6688
}
6789
},
68-
[fname, lname, phone, user?.id],
90+
[user?.id],
6991
);
7092

71-
useEffect(() => {
72-
if (userInProgress) return;
73-
setFName(user?.first_name);
74-
setLName(user?.last_name);
75-
setEmail(user?.email);
76-
setPhone(user?.phone);
77-
setIsInProgress(userInProgress);
78-
}, [user, userInProgress]);
79-
8093
useEffect(() => setIsInProgress(userInProgress), [userInProgress]);
8194

8295
return (
8396
<div className={styles.section} id="personal-info">
8497
<div className={styles.sectionContent}>
8598
<div className={styles.flexRow}>
8699
<Userpic user={user} isInProgress={userInProgress} size={92} style={{ flex: "none" }} />
87-
<form ref={userAvatarForm} className={styles.flex1} onSubmit={(e) => avatarFormSubmitHandler(e)}>
100+
<form className={styles.flex1}>
88101
<InputFile
89102
name="avatar"
90103
onChange={fileChangeHandler}
@@ -93,34 +106,26 @@ export const PersonalInfo = () => {
93106
/>
94107
</form>
95108
{user?.avatar && (
96-
<form onSubmit={(e) => avatarFormSubmitHandler(e, true)}>
97-
<button type="submit" look="danger">
98-
Delete
99-
</button>
100-
</form>
109+
<Button type="submit" look="danger" onClick={deleteUserAvatar}>
110+
Delete
111+
</Button>
101112
)}
102113
</div>
103-
<form ref={userInfoForm} className={styles.sectionContent} onSubmit={userFormSubmitHandler}>
114+
<form onSubmit={userFormSubmitHandler} className={styles.sectionContent}>
104115
<div className={styles.flexRow}>
105116
<div className={styles.flex1}>
106-
<Input label="First Name" value={fname} onChange={(e) => setFName(e.target.value)} />
117+
<Input label="First Name" value={user?.first_name} name="first_name" />
107118
</div>
108119
<div className={styles.flex1}>
109-
<Input label="Last Name" value={lname} onChange={(e) => setLName(e.target.value)} />
120+
<Input label="Last Name" value={user?.last_name} name="last_name" />
110121
</div>
111122
</div>
112123
<div className={styles.flexRow}>
113124
<div className={styles.flex1}>
114-
<Input
115-
label="E-mail"
116-
type="email"
117-
readOnly={true}
118-
value={email}
119-
onChange={(e) => setEmail(e.target.value)}
120-
/>
125+
<Input label="E-mail" type="email" readOnly={true} value={user?.email} />
121126
</div>
122127
<div className={styles.flex1}>
123-
<Input label="Phone" type="phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
128+
<Input label="Phone" type="phone" value={user?.phone} name="phone" />
124129
</div>
125130
</div>
126131
<div className={clsx(styles.flexRow, styles.flexEnd)}>

web/libs/core/src/types/user.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type APIUser = {
2+
id: number;
3+
first_name: string;
4+
last_name: string;
5+
username: string;
6+
email: string;
7+
last_activity: string;
8+
avatar: string | null;
9+
initials: string;
10+
phone: string;
11+
active_organization: number;
12+
allow_newsletters: boolean;
13+
date_joined: string;
14+
};

web/libs/ui/src/lib/InputFile/InputFile.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { IconUpload } from "../../assets/icons";
22
import clsx from "clsx";
3-
type InputFileProps = {
3+
type InputFileProps = HTMLAttributes<HTMLInputElement> & {
44
name?: string;
55
className?: string;
66
text?: React.ReactNode | string;
77
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
8+
accept?: string;
89
props?: Record<string, any>;
910
};
11+
1012
import styles from "./InputFile.module.scss";
1113
import type React from "react";
12-
import { forwardRef, useCallback, useRef } from "react";
14+
import { forwardRef, type HTMLAttributes, useCallback, useRef } from "react";
1315
export const InputFile = forwardRef(({ name, className, text, onChange, ...props }: InputFileProps, ref: any) => {
1416
if (!ref) {
1517
ref = useRef();

0 commit comments

Comments
 (0)