diff --git a/cypress/e2e/users_spec/user_creation.cy.ts b/cypress/e2e/users_spec/user_creation.cy.ts index 62a4bd53601..0e2707b921d 100644 --- a/cypress/e2e/users_spec/user_creation.cy.ts +++ b/cypress/e2e/users_spec/user_creation.cy.ts @@ -1,10 +1,10 @@ -import { UserCreation } from "../../pageObject/Users/UserCreation"; -import { FacilityCreation } from "../../pageObject/facility/FacilityCreation"; +import { UserCreation } from "pageObject/Users/UserCreation"; +import { FacilityCreation } from "pageObject/facility/FacilityCreation"; import { generateName, generatePhoneNumber, generateUsername, -} from "../../utils/commonUtils"; +} from "utils/commonUtils"; describe("User Creation", () => { const facilityCreation = new FacilityCreation(); @@ -30,7 +30,6 @@ describe("User Creation", () => { confirmPassword: defaultPassword, email: `${generateUsername(firstName)}@test.com`, phoneNumber: generatePhoneNumber(), - dateOfBirth: "1990-01-01", userType: "Doctor", state: "Kerala", district: "Ernakulam", diff --git a/cypress/pageObject/Users/UserCreation.ts b/cypress/pageObject/Users/UserCreation.ts index 181ad9d0649..c85b5998343 100644 --- a/cypress/pageObject/Users/UserCreation.ts +++ b/cypress/pageObject/Users/UserCreation.ts @@ -5,7 +5,6 @@ export interface UserData { password?: string; email?: string; phoneNumber?: string; - dateOfBirth?: string; userType?: string; state?: string; district?: string; @@ -78,17 +77,11 @@ export class UserCreation { label: "Alternate Phone Number", message: "Phone number must start with +91 followed by 10 digits", }, - { label: "Date of Birth", message: "Required" }, { label: "State", message: "Required" }, ]); return this; } - fillDateOfBirth(dateOfBirth: string) { - cy.typeIntoField('[data-cy="dob-input"]', dateOfBirth); - return this; - } - selectUserType(userType: string) { cy.clickAndSelectOption('[data-cy="user-type-select"]', userType); return this; @@ -125,7 +118,6 @@ export class UserCreation { } if (userData.email) this.fillEmail(userData.email); if (userData.phoneNumber) this.fillPhoneNumber(userData.phoneNumber); - if (userData.dateOfBirth) this.fillDateOfBirth(userData.dateOfBirth); if (userData.state) this.selectState(userData.state); if (userData.district) this.selectDistrict(userData.district); if (userData.localBody) this.selectLocalBody(userData.localBody); diff --git a/public/locale/en.json b/public/locale/en.json index 74cbc59a01e..e7ab17781fc 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -349,6 +349,7 @@ "add_skill": "Add Skill", "add_spoke": "Add Spoke Facility", "add_tags": "Add Tags", + "add_user": "Add User", "additional_information": "Additional Information", "additional_instructions": "Additional Instructions", "address": "Address", @@ -702,6 +703,7 @@ "create_tag": "Create Tag", "create_template": "Create Template", "create_user": "Create User", + "create_user_and_add_to_org": "Create a new user and add them to the organization.", "created": "Created", "created_by": "Created By", "created_date": "Created Date", @@ -831,6 +833,7 @@ "edit_profile": "Edit Profile", "edit_role": "Edit Role", "edit_schedule_template": "Edit Schedule Template", + "edit_user": "Edit User", "edit_user_profile": "Edit Profile", "edit_user_role": "Edit User Role", "edited_by": "Edited by", @@ -2140,6 +2143,7 @@ "update_shift_request": "Update Shift Request", "update_status": "Update Status", "update_status_details": "Update Status/Details", + "update_user": "Update User", "update_user_role_organization": "Update the role for this user in the organization", "update_volunteer": "Reassign Volunteer", "updated": "Updated", @@ -2177,6 +2181,7 @@ "user_removed_success": "User removed from organization successfully", "user_role_update_success": "User role updated successfully", "user_type": "User Type", + "user_updated_successfully": "User updated successfully", "username": "Username", "username_already_exists": "This username already exists", "username_available": "Username is available", diff --git a/src/common/constants.tsx b/src/common/constants.tsx index a88b50e35b0..bf0c68def2f 100644 --- a/src/common/constants.tsx +++ b/src/common/constants.tsx @@ -104,6 +104,10 @@ export const GENDER_TYPES = [ { id: "non_binary", text: "Non Binary", icon: "TRANS" }, ] as const; +export const GENDERS = GENDER_TYPES.map((gender) => gender.id) as [ + (typeof GENDER_TYPES)[number]["id"], +]; + export const CONSULTATION_SUGGESTION = [ { id: "HI", text: "Home Isolation", deprecated: true }, // # Deprecated. Preserving option for backward compatibility (use only for readonly operations) { id: "A", text: "Admission" }, diff --git a/src/components/Patient/PatientRegistration.tsx b/src/components/Patient/PatientRegistration.tsx index 69c3ff24e0b..543e87148f6 100644 --- a/src/components/Patient/PatientRegistration.tsx +++ b/src/components/Patient/PatientRegistration.tsx @@ -46,6 +46,7 @@ import { GENDER_TYPES, // OCCUPATION_TYPES, //RATION_CARD_CATEGORY, // SOCIOECONOMIC_STATUS_CHOICES , } from "@/common/constants"; +import { GENDERS } from "@/common/constants"; import countryList from "@/common/static/countries.json"; import { PLUGIN_Component } from "@/PluginEngine"; @@ -63,10 +64,6 @@ interface PatientRegistrationPageProps { patientId?: string; } -export const GENDERS = GENDER_TYPES.map((gender) => gender.id) as [ - (typeof GENDER_TYPES)[number]["id"], -]; - export const BLOOD_GROUPS = BLOOD_GROUP_CHOICES.map((bg) => bg.id) as [ (typeof BLOOD_GROUP_CHOICES)[number]["id"], ]; diff --git a/src/components/Users/CreateUserForm.tsx b/src/components/Users/UserForm.tsx similarity index 55% rename from src/components/Users/CreateUserForm.tsx rename to src/components/Users/UserForm.tsx index 0fb1d32adaa..b0750393764 100644 --- a/src/components/Users/CreateUserForm.tsx +++ b/src/components/Users/UserForm.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -31,41 +31,50 @@ import { import { validateRule } from "@/components/Users/UserFormValidations"; import { GENDER_TYPES } from "@/common/constants"; +import { GENDERS } from "@/common/constants"; +import mutate from "@/Utils/request/mutate"; import query from "@/Utils/request/query"; -import request from "@/Utils/request/request"; import GovtOrganizationSelector from "@/pages/Organization/components/GovtOrganizationSelector"; -import { UserBase } from "@/types/user/user"; -import UserApi from "@/types/user/userApi"; +import { CreateUserModel, UpdateUserModel, UserBase } from "@/types/user/user"; import userApi from "@/types/user/userApi"; interface Props { onSubmitSuccess?: (user: UserBase) => void; + existingUsername?: string; } -export default function CreateUserForm({ onSubmitSuccess }: Props) { +export default function UserForm({ onSubmitSuccess, existingUsername }: Props) { const { t } = useTranslation(); + const isEditMode = !!existingUsername; + const queryClient = useQueryClient(); const userFormSchema = z .object({ - user_type: z.enum(["doctor", "nurse", "staff", "volunteer"]), - username: z - .string() - .min(4, t("username_min_length_validation")) - .max(16, t("username_max_length_validation")) - .regex(/^[a-z0-9._-]*$/, t("username_characters_validation")) - .regex(/^[a-z0-9].*[a-z0-9]$/, t("username_start_end_validation")) - .refine( - (val) => !val.match(/(?:[._-]{2,})/), - t("username_consecutive_validation"), - ), - password: z - .string() - .min(8, t("password_length_validation")) - .regex(/[a-z]/, t("password_lowercase_validation")) - .regex(/[A-Z]/, t("password_uppercase_validation")) - .regex(/[0-9]/, t("password_number_validation")), - c_password: z.string(), + user_type: isEditMode + ? z.enum(["doctor", "nurse", "staff", "volunteer"]).optional() + : z.enum(["doctor", "nurse", "staff", "volunteer"]), + username: isEditMode + ? z.string().optional() + : z + .string() + .min(4, t("username_min_length_validation")) + .max(16, t("username_max_length_validation")) + .regex(/^[a-z0-9._-]*$/, t("username_characters_validation")) + .regex(/^[a-z0-9].*[a-z0-9]$/, t("username_start_end_validation")) + .refine( + (val) => !val.match(/(?:[._-]{2,})/), + t("username_consecutive_validation"), + ), + password: isEditMode + ? z.string().optional() + : z + .string() + .min(8, t("password_length_validation")) + .regex(/[a-z]/, t("password_lowercase_validation")) + .regex(/[A-Z]/, t("password_uppercase_validation")) + .regex(/[0-9]/, t("password_number_validation")), + c_password: isEditMode ? z.string().optional() : z.string(), first_name: z.string().min(1, t("field_required")), last_name: z.string().min(1, t("field_required")), email: z.string().email(t("invalid_email_address")), @@ -77,17 +86,28 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { .regex(/^\+91[0-9]{10}$/, t("phone_number_validation")) .optional(), phone_number_is_whatsapp: z.boolean().default(true), - date_of_birth: z.string().min(1, t("field_required")), - gender: z.enum(["male", "female", "other"]), - qualification: z.string().optional(), + gender: z.enum(GENDERS), + /* TODO: Userbase doesn't currently support these, neither does BE + but we will probably need these */ + /* qualification: z.string().optional(), doctor_experience_commenced_on: z.string().optional(), - doctor_medical_council_registration: z.string().optional(), - geo_organization: z.string().min(1, t("field_required")), + doctor_medical_council_registration: z.string().optional(), */ + geo_organization: isEditMode + ? z.string().optional() + : z.string().min(1, t("field_required")), }) - .refine((data) => data.password === data.c_password, { - message: t("password_mismatch"), - path: ["c_password"], - }); + .refine( + (data) => { + if (!isEditMode) { + return data.password === data.c_password; + } + return true; + }, + { + message: t("password_mismatch"), + path: ["c_password"], + }, + ); type UserFormValues = z.infer; @@ -98,11 +118,33 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { phone_number: "+91", alt_phone_number: "+91", phone_number_is_whatsapp: true, - gender: "male", }, }); - const userType = form.watch("user_type"); + const { data: userData, isLoading: isLoadingUser } = useQuery({ + queryKey: ["user", existingUsername], + queryFn: query(userApi.get, { + pathParams: { username: existingUsername! }, + }), + enabled: !!existingUsername, + }); + + useEffect(() => { + if (userData && isEditMode) { + const formData: Partial = { + user_type: userData.user_type, + first_name: userData.first_name, + last_name: userData.last_name, + email: userData.email, + phone_number: userData.phone_number || "", + gender: userData.gender, + phone_number_is_whatsapp: true, + }; + form.reset(formData); + } + }, [userData, form, isEditMode]); + + //const userType = form.watch("user_type"); const usernameInput = form.watch("username"); const phoneNumber = form.watch("phone_number"); const isWhatsApp = form.watch("phone_number_is_whatsapp"); @@ -111,10 +153,10 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { if (isWhatsApp) { form.setValue("alt_phone_number", phoneNumber); } - if (usernameInput && usernameInput.length > 0) { + if (usernameInput && usernameInput.length > 0 && !isEditMode) { form.trigger("username"); } - }, [phoneNumber, isWhatsApp, form, usernameInput]); + }, [phoneNumber, isWhatsApp, form, usernameInput, isEditMode]); const { isLoading: isUsernameChecking, isError: isUsernameTaken } = useQuery({ queryKey: ["checkUsername", usernameInput], @@ -122,7 +164,7 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { pathParams: { username: usernameInput }, silent: true, }), - enabled: !form.formState.errors.username, + enabled: !form.formState.errors.username && !isEditMode, }); const renderUsernameFeedback = (usernameInput: string) => { @@ -160,57 +202,98 @@ export default function CreateUserForm({ onSubmitSuccess }: Props) { } }; - const onSubmit = async (data: UserFormValues) => { - try { - const { - res, - data: user, - error, - } = await request(UserApi.create, { - body: { - ...data, - // Omit c_password as it's not needed in the API - c_password: undefined, - } as unknown as UserBase, + const { mutate: createUser, isPending: createPending } = useMutation({ + mutationKey: ["create_user"], + mutationFn: mutate(userApi.create), + onSuccess: (resp: UserBase) => { + toast.success(t("user_added_successfully")); + queryClient.invalidateQueries({ + queryKey: ["facilityUsers"], + }); + queryClient.invalidateQueries({ + queryKey: ["organizationUsers"], + }); + queryClient.invalidateQueries({ + queryKey: ["facilityOrganizationUsers"], + }); + onSubmitSuccess?.(resp); + }, + onError: (error) => { + toast.error(error?.message ?? t("user_add_error")); + }, + }); + + const { mutate: updateUser, isPending: updatePending } = useMutation({ + mutationKey: ["update_user"], + mutationFn: mutate(userApi.update, { + pathParams: { username: existingUsername! }, + }), + onSuccess: (resp: UserBase) => { + toast.success(t("user_updated_successfully")); + queryClient.invalidateQueries({ + queryKey: ["facilityUsers"], + }); + queryClient.invalidateQueries({ + queryKey: ["organizationUsers"], + }); + queryClient.invalidateQueries({ + queryKey: ["facilityOrganizationUsers"], + }); + queryClient.invalidateQueries({ + queryKey: ["getUserDetails", resp.username], }); + onSubmitSuccess?.(resp); + }, + onError: (error) => { + toast.error(error?.message ?? t("user_update_error")); + }, + }); - if (res?.ok) { - toast.success(t("user_added_successfully")); - onSubmitSuccess?.(user!); - } else { - toast.error((error?.message as string) ?? t("user_add_error")); - } - } catch (error) { - toast.error(t("user_add_error")); + const onSubmit = async (data: UserFormValues) => { + if (isEditMode) { + updateUser({ + ...data, + } as UpdateUserModel); + } else { + createUser({ + ...data, + password: data.password, + profile_picture_url: "", + } as CreateUserModel); } }; return (
- ( - - {t("user_type")} - - - - )} - /> + {!isEditMode && ( + ( + + {t("user_type")} + + + + )} + /> + )}
( - {t("first_name")} + {t("first_name")} ( - {t("last_name")} + {t("last_name")}
- ( - - {t("username")} - -
- -
-
- {renderUsernameFeedback(usernameInput)} -
- )} - /> -
- ( - - {t("password")} - - - - - - )} - /> + {!isEditMode && ( + <> + ( + + {t("username")} + +
+ +
+
+ {renderUsernameFeedback(usernameInput ?? "")} +
+ )} + /> - ( - - {t("confirm_password")} - - - - - - )} - /> -
+
+ ( + + {t("password")} + + + + + + )} + /> + + ( + + {t("confirm_password")} + + + + + + )} + /> +
+ + )} ( - {t("email")} + {t("email")} ( - {t("phone_number")} + {t("phone_number")}
- ( - - {t("date_of_birth")} - - - - - - )} - /> - ( - {t("gender")} + {t("gender")} - - - - - {roles?.results?.map((role) => ( - -
- {role.name} - {role.description && ( - - {role.description} - - )} -
-
- ))} -
- -
+
+ + +
+ +
+ -
- - - - - + + + + + {t("remove_user_organization")} + + + {t("remove_user_warn", { + firstName: userRole.user.first_name, + lastName: userRole.user.last_name, + })} + + + + {t("cancel")} + removeRole()} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("remove")} + + + + + + {editPermissions && ( + - - - - - {t("remove_user_organization")} - - - {t("remove_user_warn", { - firstName: userRole.user.first_name, - lastName: userRole.user.last_name, - })} - - - - {t("cancel")} - removeRole()} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {t("remove")} - - - - + )} +
- - - + + + ); } diff --git a/src/pages/Organization/components/EditUserSheet.tsx b/src/pages/Organization/components/EditUserSheet.tsx new file mode 100644 index 00000000000..29dacea2d3d --- /dev/null +++ b/src/pages/Organization/components/EditUserSheet.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from "react-i18next"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; + +import UserForm from "@/components/Users/UserForm"; + +import { UserBase } from "@/types/user/user"; + +interface EditUserSheetProps { + existingUsername: string; + open: boolean; + setOpen: (open: boolean) => void; + onUserUpdated?: (user: UserBase) => void; +} + +export default function EditUserSheet({ + existingUsername, + open, + setOpen, + onUserUpdated, +}: EditUserSheetProps) { + const { t } = useTranslation(); + return ( + + + + {t("edit_user")} + {t("update_user")} + +
+ { + setOpen(false); + onUserUpdated?.(user); + }} + existingUsername={existingUsername} + /> +
+
+
+ ); +} diff --git a/src/types/user/user.ts b/src/types/user/user.ts index 010e1614807..1f60dd2f705 100644 --- a/src/types/user/user.ts +++ b/src/types/user/user.ts @@ -14,3 +14,22 @@ export type UserBase = { phone_number: string; gender: (typeof GENDER_TYPES)[number]["id"]; }; + +export type CreateUserModel = { + user_type: UserType; + username: string; + password: string; + first_name: string; + last_name: string; + email: string; + phone_number: string; + phone_number_is_whatsapp: boolean; + alt_phone_number?: string; + gender: (typeof GENDER_TYPES)[number]["id"]; + qualification?: string; + doctor_experience_commenced_on?: string; + doctor_medical_council_registration?: string; + geo_organization: string; +}; + +export type UpdateUserModel = Omit; diff --git a/src/types/user/userApi.ts b/src/types/user/userApi.ts index 2609c0026e3..f1f776cece0 100644 --- a/src/types/user/userApi.ts +++ b/src/types/user/userApi.ts @@ -1,6 +1,6 @@ import { HttpMethod, Type } from "@/Utils/request/api"; import { PaginatedResponse } from "@/Utils/request/types"; -import { UserBase } from "@/types/user/user"; +import { CreateUserModel, UpdateUserModel, UserBase } from "@/types/user/user"; export default { list: { @@ -12,12 +12,11 @@ export default { path: "/api/v1/users/", method: HttpMethod.POST, TRes: Type(), - TBody: Type(), + TBody: Type(), }, get: { path: "/api/v1/users/{username}/", method: HttpMethod.GET, - TRes: Type(), }, checkUsername: { @@ -25,4 +24,10 @@ export default { method: HttpMethod.GET, TRes: Type, }, + update: { + path: "/api/v1/users/{username}/", + method: HttpMethod.PUT, + TRes: Type(), + TBody: Type>(), + }, };