diff --git a/.env b/.env index 4e9159b2bf0..4928b31e2ba 100644 --- a/.env +++ b/.env @@ -14,4 +14,8 @@ ESLINT_NO_DEV_ERRORS=true CARE_CDN_URL="https://egov-s3-facility-10bedicu.s3.amazonaws.com https://egov-s3-patient-data-10bedicu.s3.amazonaws.com http://localhost:4566" REACT_ALLOWED_LOCALES="en,hi,ta,ml,mr,kn" -REACT_ENABLED_APPS="https://care-scribe-fe.pages.dev|ohcnetwork/care_scribe_fe" +# Remote apps +# Localhost : ohcnetwork/care_scribe_fe@localhost:4173 +# Remote URL : ohcnetwork/care_scribe_fe@https://care-scribe-fe.pages.dev +# Repo/Github Pages : ohcnetwork/care_scribe_fe +REACT_ENABLED_APPS="" diff --git a/cypress/e2e/facility_spec/facility_creation.cy.ts b/cypress/e2e/facility_spec/facility_creation.cy.ts index c784d4c3991..15288719ba8 100644 --- a/cypress/e2e/facility_spec/facility_creation.cy.ts +++ b/cypress/e2e/facility_spec/facility_creation.cy.ts @@ -13,10 +13,12 @@ describe("Facility Management", () => { cy.loginByApi("nurse"); }); - it("Create a new facility using the admin role", () => { + it("Create a new facility using the admin role and verify validation errors", () => { facilityPage.navigateToOrganization("Kerala"); facilityPage.navigateToFacilitiesList(); facilityPage.clickAddFacility(); + facilityPage.submitFacilityCreationForm(); + facilityPage.verifyValidationErrors(); // Fill form facilityPage.fillBasicDetails( @@ -47,12 +49,4 @@ describe("Facility Management", () => { facilityPage.searchFacility(testFacility.name); facilityPage.verifyFacilityNameInCard(testFacility.name); }); - - it("Should show validation errors for required fields", () => { - facilityPage.navigateToOrganization("Kerala"); - facilityPage.navigateToFacilitiesList(); - facilityPage.clickAddFacility(); - facilityPage.submitFacilityCreationForm(); - facilityPage.verifyValidationErrors(); - }); }); diff --git a/cypress/e2e/login_spec/loginpage.cy.ts b/cypress/e2e/login_spec/loginpage.cy.ts index 4b1965f75f7..89e67763ade 100644 --- a/cypress/e2e/login_spec/loginpage.cy.ts +++ b/cypress/e2e/login_spec/loginpage.cy.ts @@ -7,16 +7,7 @@ describe("Login Page", () => { cy.visit("/login"); }); - it("should successfully login with admin credentials", () => { - cy.loginByApi("staff"); - cy.url().should("include", "/"); - }); - - it("should display login form elements", () => { - loginPage.verifyFormElements(); - }); - - it("should show validation errors for empty fields", () => { - loginPage.clickSubmit().verifyValidationErrors(); + it("should validate login form elements and display validation errors", () => { + loginPage.verifyFormElements().clickSubmit().verifyValidationErrors(); }); }); diff --git a/cypress/e2e/patient_spec/patient_creation.cy.ts b/cypress/e2e/patient_spec/patient_creation.cy.ts new file mode 100644 index 00000000000..941da15b51d --- /dev/null +++ b/cypress/e2e/patient_spec/patient_creation.cy.ts @@ -0,0 +1,74 @@ +import { patientCreation } from "pageObject/Patients/PatientCreation"; +import { patientDashboard } from "pageObject/Patients/PatientDashboard"; +import { patientVerify } from "pageObject/Patients/PatientVerify"; + +import { + generateAddress, + generatePatientName, + generatePhoneNumber, +} from "../../utils/commonUtils"; + +const ENCOUNTER_TYPE = "Observation"; +const ENCOUNTER_STATUS = "In Progress"; +const ENCOUNTER_PRIORITY = "ASAP"; + +describe("Patient Management", () => { + const TEST_PHONE = "9495031234"; + const PATIENT_DETAILS = { + name: "Nihal", + sex: "Male", + phone: TEST_PHONE, + }; + + const testPatientData = { + name: generatePatientName(), + phoneNumber: generatePhoneNumber(), + gender: "male", + bloodGroup: "B+", + dateOfBirth: "01-01-1990", + address: generateAddress(), + pincode: "682001", + state: "Kerala", + district: "Ernakulam", + localBody: "Aluva", + ward: "4", + }; + + beforeEach(() => { + cy.visit("/login"); + }); + + it("create a new patient and verify details", () => { + cy.loginByApi("doctor"); + patientCreation + .selectFacility("Arike") + .clickSearchPatients() + .clickCreateNewPatient() + .fillPatientDetails(testPatientData) + .submitPatientForm() + .assertPatientRegistrationSuccess(); + patientVerify + .verifyPatientName(testPatientData.name) + .verifyCreateEncounterButton() + .clickCreateEncounter() + .selectEncounterType(ENCOUNTER_TYPE) + .selectEncounterStatus(ENCOUNTER_STATUS) + .selectEncounterPriority(ENCOUNTER_PRIORITY) + .clickSubmitEncounter() + .assertEncounterCreationSuccess(); + patientDashboard.verifyEncounterPatientInfo([ + ENCOUNTER_TYPE, + ENCOUNTER_STATUS, + ENCOUNTER_PRIORITY, + ]); + }); + + it("search patient with phone number and verifies details", () => { + cy.loginByApi("staff"); + patientCreation + .selectFacility("Arike") + .clickSearchPatients() + .searchPatient(TEST_PHONE) + .verifySearchResults(PATIENT_DETAILS); + }); +}); diff --git a/cypress/e2e/patient_spec/patient_search.cy.ts b/cypress/e2e/patient_spec/patient_search.cy.ts deleted file mode 100644 index 66a09eed153..00000000000 --- a/cypress/e2e/patient_spec/patient_search.cy.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { patientSearch } from "../../pageObject/Patients/PatientSearch"; - -describe("Patient Search", () => { - const TEST_PHONE = "9495031234"; - const PATIENT_DETAILS = { - name: "Nihal", - sex: "Male", - phone: TEST_PHONE, - }; - - beforeEach(() => { - cy.visit("/login"); - cy.loginByApi("nurse"); - }); - - it("search patient with phone number and verifies details", () => { - patientSearch - .selectFacility("Arike") - .clickSearchPatients() - .searchPatient(TEST_PHONE) - .verifySearchResults(PATIENT_DETAILS); - }); -}); diff --git a/cypress/fixtures/users.json b/cypress/fixtures/users.json index 7fe744d7edc..2c859370d91 100644 --- a/cypress/fixtures/users.json +++ b/cypress/fixtures/users.json @@ -7,8 +7,12 @@ "username": "nihal-nurse", "password": "Test@123" }, + "doctor": { + "username": "arjun-doctor", + "password": "Test@123" + }, "staff": { "username": "nihal-staff", "password": "Test@123" } -} \ No newline at end of file +} diff --git a/cypress/pageObject/Patients/PatientCreation.ts b/cypress/pageObject/Patients/PatientCreation.ts new file mode 100644 index 00000000000..05adc98e328 --- /dev/null +++ b/cypress/pageObject/Patients/PatientCreation.ts @@ -0,0 +1,156 @@ +interface PatientFormData { + name: string; + phoneNumber: string; + dateOfBirth: string; + gender: string; + bloodGroup: string; + address: string; + pincode: string; + state: string; + district: string; + localBody: string; + ward: string; +} + +export class PatientCreation { + // Selectors + private selectors = { + patientsButton: '[data-cy="patients-button"]', + searchInput: "#patient-search", + patientCard: "#patient-search-results", + patientName: '[data-cy="patient-name"]', + patientDetails: "#patient-search-results", + createNewPatientButton: '[data-cy="create-new-patient-button"]', + }; + + // Actions + clickCreateNewPatient() { + cy.get(this.selectors.createNewPatientButton).click(); + cy.url().should("include", "/patient/create"); + return this; + } + + searchPatient(searchQuery: string) { + cy.get(this.selectors.searchInput).type(searchQuery); + + // Wait for results to load + cy.get(this.selectors.patientCard).should("be.visible"); + return this; + } + + verifySearchResults(patientDetails: { + name: string; + sex: string; + phone: string; + }) { + // Convert object values to an array of strings + const detailsArray = Object.values(patientDetails); + cy.verifyContentPresence(this.selectors.patientDetails, detailsArray); + } + + selectFacility(facilityName: string) { + cy.verifyAndClickElement("[data-cy='facility-list']", facilityName); + return this; + } + + clickSearchPatients() { + cy.get('[data-sidebar="content"]').contains("Search Patients").click(); + return this; + } + + enterName(name: string) { + cy.typeIntoField('[data-cy="patient-name-input"]', name); + return this; + } + + enterPhoneNumber(phoneNumber: string) { + cy.typeIntoField('[data-cy="patient-phone-input"]', phoneNumber, { + skipVerification: true, + }); + return this; + } + + enterDateOfBirth(dateString: string) { + // Split the date string (expected format: "DD-MM-YYYY") + const [day, month, year] = dateString.split("-"); + + cy.get('[data-cy="dob-day-input"]').type(day); + cy.get('[data-cy="dob-month-input"]').type(month); + cy.get('[data-cy="dob-year-input"]').type(year); + + return this; + } + + selectGender(gender: string) { + const lowercaseGender = gender.toLowerCase(); + cy.get(`[data-cy="gender-radio-${lowercaseGender}"]`).click(); + return this; + } + + selectBloodGroup(bloodGroup: string) { + cy.clickAndSelectOption('[data-cy="blood-group-select"]', bloodGroup); + return this; + } + + enterAddress(address: string) { + cy.typeIntoField('[data-cy="current-address-input"]', address); + return this; + } + + enterPincode(pincode: string) { + cy.typeIntoField('[data-cy="pincode-input"]', pincode); + return this; + } + + fillPatientDetails(patient: PatientFormData) { + return this.enterName(patient.name) + .enterPhoneNumber(patient.phoneNumber) + .clickSamePhoneNumberCheckbox() + .selectGender(patient.gender) + .selectBloodGroup(patient.bloodGroup) + .enterDateOfBirth(patient.dateOfBirth) + .enterAddress(patient.address) + .enterPincode(patient.pincode) + .selectState(patient.state) + .selectDistrict(patient.district) + .selectLocalBody(patient.localBody) + .selectWard(patient.ward); + } + + selectState(state: string) { + cy.typeAndSelectOption('[data-cy="select-state"]', state); + return this; + } + + selectDistrict(district: string) { + cy.typeAndSelectOption('[data-cy="select-district"]', district); + return this; + } + + selectLocalBody(localBody: string) { + cy.typeAndSelectOption('[data-cy="select-local_body"]', localBody); + return this; + } + + selectWard(ward: string) { + cy.typeAndSelectOption('[data-cy="select-ward"]', ward); + return this; + } + + submitPatientForm() { + cy.clickSubmitButton("Save and Continue"); + return this; + } + + clickSamePhoneNumberCheckbox() { + cy.get('[data-cy="same-phone-number-checkbox"]').click(); + return this; + } + + assertPatientRegistrationSuccess() { + cy.verifyNotification("Patient Registered Successfully"); + return this; + } +} + +export const patientCreation = new PatientCreation(); diff --git a/cypress/pageObject/Patients/PatientDashboard.ts b/cypress/pageObject/Patients/PatientDashboard.ts new file mode 100644 index 00000000000..9d608b17ea6 --- /dev/null +++ b/cypress/pageObject/Patients/PatientDashboard.ts @@ -0,0 +1,8 @@ +class PatientDashboard { + verifyEncounterPatientInfo(contents: string[]) { + cy.verifyContentPresence("#patient-infobadges", contents); + return this; + } +} + +export const patientDashboard = new PatientDashboard(); diff --git a/cypress/pageObject/Patients/PatientSearch.ts b/cypress/pageObject/Patients/PatientSearch.ts deleted file mode 100644 index 24e369e9b6e..00000000000 --- a/cypress/pageObject/Patients/PatientSearch.ts +++ /dev/null @@ -1,41 +0,0 @@ -export class PatientSearch { - // Selectors - private selectors = { - patientsButton: '[data-cy="patients-button"]', - searchInput: "#patient-search", - patientCard: "#patient-search-results", - patientName: '[data-cy="patient-name"]', - patientDetails: "#patient-search-results", - }; - - // Actions - searchPatient(searchQuery: string) { - cy.get(this.selectors.searchInput).type(searchQuery); - - // Wait for results to load - cy.get(this.selectors.patientCard).should("be.visible"); - return this; - } - - verifySearchResults(patientDetails: { - name: string; - sex: string; - phone: string; - }) { - // Convert object values to an array of strings - const detailsArray = Object.values(patientDetails); - cy.verifyContentPresence(this.selectors.patientDetails, detailsArray); - } - - selectFacility(facilityName: string) { - cy.verifyAndClickElement("[data-cy='facility-list']", facilityName); - return this; - } - - clickSearchPatients() { - cy.get('[data-sidebar="content"]').contains("Search Patients").click(); - return this; - } -} - -export const patientSearch = new PatientSearch(); diff --git a/cypress/pageObject/Patients/PatientVerify.ts b/cypress/pageObject/Patients/PatientVerify.ts new file mode 100644 index 00000000000..de98d9d8d61 --- /dev/null +++ b/cypress/pageObject/Patients/PatientVerify.ts @@ -0,0 +1,63 @@ +class PatientVerify { + verifyPatientName(expectedName: string) { + cy.get('[data-cy="verify-patient-name"]').should("contain", expectedName); + return this; + } + + verifyCreateEncounterButton() { + cy.get('[data-cy="create-encounter-button"]').should( + "contain", + "Create Encounter", + ); + return this; + } + + clickCreateEncounter() { + cy.verifyAndClickElement( + '[data-cy="create-encounter-button"]', + "Create Encounter", + ); + return this; + } + + // Map display text to data-cy values for better maintainability + private encounterTypeMap = { + Observation: "obsenc", + Inpatient: "imp", + Ambulatory: "amb", + Emergency: "emer", + Virtual: "vr", + "Home Health": "hh", + }; + + selectEncounterType(displayText: string) { + const dataCyValue = this.encounterTypeMap[displayText]; + cy.verifyAndClickElement( + `[data-cy="encounter-type-${dataCyValue}"]`, + displayText, + ); + return this; + } + + selectEncounterStatus(status: string) { + cy.clickAndSelectOption('[data-cy="encounter-status"]', status); + return this; + } + + selectEncounterPriority(priority: string) { + cy.clickAndSelectOption('[data-cy="encounter-priority"]', priority); + return this; + } + + clickSubmitEncounter() { + cy.clickSubmitButton("Create Encounter"); + return this; + } + + assertEncounterCreationSuccess() { + cy.verifyNotification("Encounter created successfully"); + return this; + } +} + +export const patientVerify = new PatientVerify(); diff --git a/cypress/pageObject/auth/LoginPage.ts b/cypress/pageObject/auth/LoginPage.ts index 8509b2b448a..5cad5c8986c 100644 --- a/cypress/pageObject/auth/LoginPage.ts +++ b/cypress/pageObject/auth/LoginPage.ts @@ -11,7 +11,6 @@ export class LoginPage { private readonly usernameInput = "[data-cy=username]"; private readonly passwordInput = "[data-cy=password]"; private readonly submitButton = "[data-cy=submit]"; - private readonly errorMessage = ".text-red-500"; // Add new methods while keeping existing loginByRole typeUsername(username: string) { @@ -25,7 +24,7 @@ export class LoginPage { } clickSubmit() { - cy.get(this.submitButton).click(); + cy.clickSubmitButton("Login"); return this; } @@ -37,7 +36,10 @@ export class LoginPage { } verifyValidationErrors() { - cy.get(this.errorMessage).should("be.visible"); + cy.verifyErrorMessages([ + "This field is required", + "This field is required", + ]); return this; } diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c8c0dbbd3d8..5af8df3a8ec 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -85,7 +85,11 @@ Cypress.Commands.add("verifyNotification", (text: string) => { return cy .get("li[data-sonner-toast] div[data-title]") .should("exist") - .contains(text); + .contains(text) + .should("be.visible") + .then(() => { + cy.closeNotification(); + }); }); Cypress.Commands.add("clearAllFilters", () => { @@ -104,13 +108,19 @@ Cypress.Commands.add("clickCancelButton", (buttonText = "Cancel") => { Cypress.Commands.add( "typeAndSelectOption", - (element: string, reference: string) => { - cy.get(element) - .click() - .type(reference) - .then(() => { - cy.get("[role='option']").contains(reference).click(); - }); + (selector: string, value: string) => { + // Click to open the dropdown + cy.get(selector).click(); + + // Type in the command input + cy.get("[cmdk-input]").should("be.visible").clear().type(value); + + // Select the filtered option from command menu + cy.get("[cmdk-list]") + .find("[cmdk-item]") + .contains(value) + .should("be.visible") + .click(); }, ); @@ -192,10 +202,15 @@ Cypress.Commands.add("preventPrint", () => { }); Cypress.Commands.add("closeNotification", () => { - cy.get(".pnotify") - .should("exist") - .each(($div) => { - cy.wrap($div).click(); + return cy + .get("li[data-sonner-toast] div[data-title]") + .first() + .parents("li[data-sonner-toast]") + .then(($toast) => { + cy.wrap($toast) + .find('button[aria-label="Close toast"]', { timeout: 5000 }) + .should("be.visible") + .click(); }); }); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index cfa6266439c..22839037dae 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -39,7 +39,7 @@ declare global { reference: string, ): Chainable; preventPrint(): Chainable; - closeNotification(): Chainable; + closeNotification(): Chainable>; verifyContentPresence( selector: string, texts: string[], diff --git a/cypress/utils/commonUtils.ts b/cypress/utils/commonUtils.ts index c6cddc66840..2b1eb76b5cb 100644 --- a/cypress/utils/commonUtils.ts +++ b/cypress/utils/commonUtils.ts @@ -1,21 +1,71 @@ -export function generatePhoneNumber(): string { - // Create a new Uint8Array to store our random bytes +// Utility Functions +function getRandomIndex(max: number): number { const randomBytes = new Uint8Array(1); - // Get a cryptographically secure random value crypto.getRandomValues(randomBytes); + return randomBytes[0] % max; +} + +// Data Generators +export function generatePatientName(): string { + const firstNames = [ + "John", + "Jane", + "Alex", + "Sarah", + "Michael", + "Emma", + "David", + "Maria", + ]; + const lastNames = [ + "Smith", + "Johnson", + "Williams", + "Brown", + "Jones", + "Garcia", + "Miller", + ]; + + const randomFirst = firstNames[getRandomIndex(firstNames.length)]; + const randomLast = lastNames[getRandomIndex(lastNames.length)]; + + return `${randomFirst} ${randomLast}`; +} - // First digit should be 6, 7, 8, or 9 for Indian mobile numbers +export function generatePhoneNumber(): string { const validFirstDigits = [6, 7, 8, 9]; - const firstDigit = validFirstDigits[randomBytes[0] % validFirstDigits.length]; + const firstDigit = validFirstDigits[getRandomIndex(validFirstDigits.length)]; - // Generate remaining 9 digits using crypto const remainingDigits = new Uint8Array(9); crypto.getRandomValues(remainingDigits); - - // Convert to string and ensure each digit is 0-9 const remainingDigitsStr = Array.from(remainingDigits) .map((byte) => byte % 10) .join(""); return `${firstDigit}${remainingDigitsStr}`; } + +export function generateAddress(): string { + const houseNumbers = ["123", "45A", "67B", "89", "234"]; + const streets = [ + "Main Street", + "Park Avenue", + "Oak Road", + "Church Street", + "Hill Road", + ]; + const areas = [ + "Downtown", + "Westside", + "North Colony", + "South Extension", + "East End", + ]; + + const randomHouse = houseNumbers[getRandomIndex(houseNumbers.length)]; + const randomStreet = streets[getRandomIndex(streets.length)]; + const randomArea = areas[getRandomIndex(areas.length)]; + + return `${randomHouse}, ${randomStreet}, ${randomArea}`; +} diff --git a/public/locale/en.json b/public/locale/en.json index 1d65f731ec2..a10c08c369a 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -512,7 +512,6 @@ "change_avatar": "Change Avatar", "change_avatar_note": "JPG, GIF or PNG. 1MB max.", "change_file": "Change File", - "change_password": "Change Password", "change_phone_number": "Change Phone Number", "change_status": "Change Status", "chat_on_whatsapp": "Chat on Whatsapp", @@ -588,7 +587,6 @@ "confirm_delete": "Confirm Delete", "confirm_discontinue": "Confirm Discontinue", "confirm_password": "Confirm Password", - "confirm_password_required": "Confirm password is required", "confirm_transfer_complete": "Confirm Transfer Complete!", "confirm_unavailability": "Confirm Unavailability", "confirmed": "Confirmed", @@ -1014,7 +1012,6 @@ "filter_by_category": "Filter by category", "filters": "Filters", "first_name": "First Name", - "first_name_required": "First Name is required", "footer_body": "Open Healthcare Network is an open-source public utility designed by a multi-disciplinary team of innovators and volunteers. Open Healthcare Network CARE is a Digital Public Good recognised by the United Nations.", "forget_password": "Forgot password?", "forget_password_instruction": "Enter your username, and if it exists, we will send you a link to reset your password.", @@ -1106,6 +1103,7 @@ "invalid_asset_id_msg": "Oops! The asset ID you entered does not appear to be valid.", "invalid_date_format": "Invalid date format, expected {{format}}", "invalid_email": "Please enter a valid email address", + "invalid_email_address": "Invalid email address", "invalid_ip_address": "Invalid IP Address", "invalid_link_msg": "It appears that the password reset link you have used is either invalid or expired. Please request a new password reset link.", "invalid_otp": "Invalid OTP, Please check the OPT and try Again", @@ -1121,7 +1119,6 @@ "invalid_url_http_https": "URL should start with http:// or https://", "invalid_url_javascript": "URL should not include javascript, please enter a valid URL.", "invalid_username": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", - "invalid_username_format": "Please enter a 4-16 characters long username with lowercase letters, digits and . _ - only and it should not start or end with . _ -", "inventory_management": "Inventory Management", "investigation_report": "Investigation Report", "investigation_report_for_{{name}}": "Investigation Report for {{name}}", @@ -1168,7 +1165,6 @@ "last_modified": "Last Modified", "last_modified_by": "Last Modified By", "last_name": "Last Name", - "last_name_required": "Last Name is required", "last_online": "Last Online", "last_serviced_on": "Last Serviced On", "last_updated_by": "Last updated by", @@ -1289,7 +1285,8 @@ "never_logged_in": "Never Logged In", "new_password": "New Password", "new_password_confirmation": "Confirm New Password", - "new_password_same_as_old": "New password is same as old password, please enter a different new password.", + "new_password_different_from_old": "Your new password is different from the old password.", + "new_password_same_as_old": "Your new password must not match the old password.", "new_password_validation": "New password is not valid.", "new_session": "New Session", "next_fortnight_short": "Next 2wk", @@ -1387,6 +1384,7 @@ "occupancy": "Occupancy", "occupation": "Occupation", "occupied": "Occupied", + "old_password": "Current Password", "on": "on", "on_emergency_basis": " on emergency basis", "ongoing_medications": "Ongoing Medications", @@ -1417,17 +1415,22 @@ "pain_chart_description": "Mark region and intensity of pain", "passport_number": "Passport Number", "password": "Password", - "password_length_validation": "Password must be at least 8 characters long", - "password_lowercase_validation": "Password must contain at least one lowercase letter (a-z)", - "password_mismatch": "New password and confirm password must be the same.", - "password_number_validation": "Password must contain at least one number (0-9)", + "password_length_met": "It’s at least 8 characters long", + "password_length_validation": "Use at least 8 characters", + "password_lowercase_met": "It includes at least one lowercase letter", + "password_lowercase_validation": "Include at least one lowercase letter", + "password_mismatch": "Passwords do not match", + "password_number_met": "It includes at least one number.", + "password_number_validation": "Include at least one number.", "password_required": "Password is required", "password_reset_failure": "Password Reset Failed", "password_reset_success": "Password Reset successfully", "password_sent": "Password Reset Email Sent", "password_update_error": "Error while updating password. Try again later.", - "password_uppercase_validation": "Password must contain at least one uppercase letter (A-Z)", - "password_validation": "Password must contain at least: 8 characters, 1 uppercase letter (A-Z), 1 lowercase letter (a-z), and 1 number (0-9)", + "password_updated": "Password updated successfully", + "password_uppercase_met": "It includes at least one uppercase letter.", + "password_uppercase_validation": "Include at least one uppercase letter.", + "passwords_match": "Passwords match.", "patient": "Patient", "patient-notes": "Notes", "patient__general-info": "General Info", @@ -1497,6 +1500,7 @@ "phone_number_at_current_facility": "Phone Number of Contact person at current Facility", "phone_number_min_error": "Phone number must be at least 10 characters long", "phone_number_not_found": "Phone number not found", + "phone_number_validation": "Phone number must start with +91 followed by 10 digits", "phone_number_verified": "Phone Number Verified", "pincode": "Pincode", "pincode_autofill": "State and District auto-filled from Pincode", @@ -1506,6 +1510,7 @@ "please_check_your_messages": "Please check your messages", "please_confirm_password": "Please confirm your new password.", "please_enter_a_reason_for_the_shift": "Please enter a reason for the shift.", + "please_enter_confirm_password": "Please confirm your new password", "please_enter_current_password": "Please enter your current password.", "please_enter_new_password": "Please enter your new password.", "please_enter_username": "Please enter the username", @@ -1980,6 +1985,7 @@ "update_facility": "Update Facility", "update_facility_middleware_success": "Facility middleware updated successfully", "update_log": "Update Log", + "update_password": "Update Password", "update_patient_details": "Update Patient Details", "update_preset": "Update Preset", "update_preset_position_to_current": "Update preset's position to camera's current position", @@ -1994,7 +2000,7 @@ "updated": "Updated", "updated_on": "Updated On", "updates": "Updates", - "updating": "Updating", + "updating": "Updating...", "upload": "Upload", "upload_an_image": "Upload an image", "upload_file": "Upload File", @@ -2025,8 +2031,14 @@ "username": "Username", "username_already_exists": "This username already exists", "username_available": "Username is available", + "username_characters_validation": "Only lowercase letters, numbers, and . _ - are allowed", + "username_consecutive_validation": "Cannot contain consecutive special characters", + "username_max_length_validation": "Use at most 16 characters", + "username_min_length_validation": "Use at least 4 characters", "username_not_available": "Username is not available", + "username_start_end_validation": "Must start and end with a letter or number", "username_userdetails_not_found": "Unable to fetch details as username or user details not found", + "username_valid": "Username is valid", "users": "Users", "vacant": "Vacant", "vaccinated": "Vaccinated", diff --git a/scripts/setup-care-apps.ts b/scripts/setup-care-apps.ts index c1e8896644d..f03c07f6cad 100644 --- a/scripts/setup-care-apps.ts +++ b/scripts/setup-care-apps.ts @@ -16,28 +16,70 @@ interface Plugin { camelCaseName: string; } +interface ParsedRemoteConfig { + url: string; + org: string; + repo: string; +} + +/** + * Parses a remote app configuration string into its components + * Supports two formats: + * 1. GitHub Pages: "organization/repository" + * Example: "coronasafe/care_fe" + * + * 2. Custom URL: "organization/repository@url" + * Example: "coronasafe/care_fe@localhost:5173" + * Example: "coronasafe/care_fe@care.coronasafe.network" + * Note: Protocol (http/https) is automatically added in the vite config: + * - localhost URLs use http:// + * - all other URLs use https:// + * + * @param appConfig - Configuration string for a remote app + * @returns Parsed configuration object + */ +function parseRemoteConfig(appConfig: string): ParsedRemoteConfig { + // Handle custom URLs (both localhost and custom hosted) + if (appConfig.includes("@")) { + const [package_] = appConfig.split("@"); + const [org, repo] = package_.split("/"); + return { + url: "", // URL not needed for plugin setup + org, + repo, + }; + } + + // Handle GitHub Pages URLs + const [org, repo] = appConfig.split("/"); + return { + url: "", // URL not needed for plugin setup + org, + repo, + }; +} + // Function to read enabled apps from env function readAppsConfig(): Plugin[] { - const appsConfig = process.env.REACT_ENABLED_APPS - ? process.env.REACT_ENABLED_APPS.split(",").map((app) => { - const package_ = app.includes("|") - ? app.split("|")[1].split("@")[0] - : app.split("@")[0]; - console.log(package_); - const [, repo] = package_.split("/"); - return { - repo, - // Convert repo name to camelCase for import - camelCaseName: repo - .replace(/[-_]/g, "") - .replace(/\b\w/g, (char, index) => - index === 0 ? char.toLowerCase() : char.toUpperCase(), - ), - }; - }) - : []; - console.log("Found plugins: ", appsConfig); - return appsConfig; + if (!process.env.REACT_ENABLED_APPS) { + return []; + } + + const plugins = process.env.REACT_ENABLED_APPS.split(",").map((app) => { + const { repo } = parseRemoteConfig(app); + return { + repo, + // Convert repo name to camelCase for import + camelCaseName: repo + .replace(/[-_]/g, "") + .replace(/\b\w/g, (char, index) => + index === 0 ? char.toLowerCase() : char.toUpperCase(), + ), + }; + }); + + console.log("Found plugins:", plugins); + return plugins; } const plugins = readAppsConfig(); diff --git a/src/CAREUI/misc/PaginatedList.tsx b/src/CAREUI/misc/PaginatedList.tsx index 9cefca73aac..01b9b0538b5 100644 --- a/src/CAREUI/misc/PaginatedList.tsx +++ b/src/CAREUI/misc/PaginatedList.tsx @@ -6,7 +6,7 @@ import { Button, ButtonProps } from "@/components/ui/button"; import Pagination from "@/components/Common/Pagination"; -import { PaginatedResponse, QueryRoute } from "@/Utils/request/types"; +import { ApiRoute, PaginatedResponse } from "@/Utils/request/types"; import useTanStackQueryInstead, { QueryOptions, } from "@/Utils/request/useQuery"; @@ -35,7 +35,7 @@ function useContextualized() { } interface Props extends QueryOptions> { - route: QueryRoute>; + route: ApiRoute>; perPage?: number; initialPage?: number; onPageChange?: (page: number) => void; diff --git a/src/Utils/request/api.tsx b/src/Utils/request/api.tsx index 773aba670a1..940e5c383dc 100644 --- a/src/Utils/request/api.tsx +++ b/src/Utils/request/api.tsx @@ -173,7 +173,7 @@ const routes = { updatePassword: { path: "/api/v1/password_change/", method: "PUT", - TRes: Type>(), + TRes: Type<{ message: string }>(), TBody: Type(), }, // User Endpoints diff --git a/src/Utils/request/errorHandler.ts b/src/Utils/request/errorHandler.ts index 73363218800..e83973cfeeb 100644 --- a/src/Utils/request/errorHandler.ts +++ b/src/Utils/request/errorHandler.ts @@ -3,7 +3,7 @@ import { navigate } from "raviger"; import { toast } from "sonner"; import * as Notifications from "@/Utils/Notifications"; -import { HTTPError } from "@/Utils/request/types"; +import { HTTPError, StructuredError } from "@/Utils/request/types"; export function handleHttpError(error: Error) { if (error.name === "AbortError") { @@ -37,6 +37,12 @@ export function handleHttpError(error: Error) { handlePydanticErrors(errs); return; } + + if (isStructuredError(cause)) { + handleStructuredErrors(cause); + return; + } + Notifications.BadRequest({ errs }); return; } @@ -70,11 +76,28 @@ function isNotFound(error: HTTPError) { type PydanticError = { type: string; loc?: string[]; - msg: string; + msg: string | Record; input?: unknown; url?: string; }; +function isStructuredError(err: HTTPError["cause"]): err is StructuredError { + return typeof err === "object" && !Array.isArray(err); +} + +function handleStructuredErrors(cause: StructuredError) { + for (const value of Object.values(cause)) { + if (Array.isArray(value)) { + value.forEach((err) => toast.error(err)); + return; + } + if (typeof value === "string") { + toast.error(value); + return; + } + } +} + function isPydanticError(errors: unknown): errors is PydanticError[] { return ( Array.isArray(errors) && @@ -86,14 +109,15 @@ function isPydanticError(errors: unknown): errors is PydanticError[] { function handlePydanticErrors(errors: PydanticError[]) { errors.map(({ type, loc, msg }) => { + const message = typeof msg === "string" ? msg : Object.values(msg)[0]; if (!loc) { - toast.error(msg); + toast.error(message); return; } type = type .replace("_", " ") .replace(/\b\w/g, (char) => char.toUpperCase()); - toast.error(msg, { + toast.error(message, { description: `${type}: '${loc.join(".")}'`, duration: 8000, }); diff --git a/src/Utils/request/handleResponse.ts b/src/Utils/request/handleResponse.ts index df3a3a11ad7..1a97a5b8b24 100644 --- a/src/Utils/request/handleResponse.ts +++ b/src/Utils/request/handleResponse.ts @@ -5,6 +5,9 @@ import { toast } from "sonner"; import * as Notifications from "@/Utils/Notifications"; import { RequestResult } from "@/Utils/request/types"; +/** + * @deprecated in favor of useQuery/useMutation/callApi + */ export default function handleResponse( { res, error }: RequestResult, silent?: boolean, diff --git a/src/Utils/request/mutate.ts b/src/Utils/request/mutate.ts index 2372920c162..e500b18d909 100644 --- a/src/Utils/request/mutate.ts +++ b/src/Utils/request/mutate.ts @@ -1,5 +1,5 @@ import { callApi } from "@/Utils/request/query"; -import { APICallOptions, Route } from "@/Utils/request/types"; +import { ApiCallOptions, ApiRoute } from "@/Utils/request/types"; /** * Creates a TanStack Query compatible mutation function. @@ -16,11 +16,11 @@ import { APICallOptions, Route } from "@/Utils/request/types"; * }); * ``` */ -export default function mutate( - route: Route, - options?: APICallOptions, +export default function mutate>( + route: Route, + options?: ApiCallOptions, ) { - return (variables: TBody) => { + return (variables: Route["TBody"]) => { return callApi(route, { ...options, body: variables }); }; } diff --git a/src/Utils/request/query.ts b/src/Utils/request/query.ts index 3ad2f1d25c4..34fc3ce89ac 100644 --- a/src/Utils/request/query.ts +++ b/src/Utils/request/query.ts @@ -1,14 +1,14 @@ import careConfig from "@careConfig"; import { getResponseBody } from "@/Utils/request/request"; -import { APICallOptions, HTTPError, Route } from "@/Utils/request/types"; +import { ApiCallOptions, ApiRoute, HTTPError } from "@/Utils/request/types"; import { makeHeaders, makeUrl } from "@/Utils/request/utils"; import { sleep } from "@/Utils/utils"; -export async function callApi( - { path, method, noAuth }: Route, - options?: APICallOptions, -): Promise { +export async function callApi>( + { path, method, noAuth }: Route, + options?: ApiCallOptions, +): Promise { const url = `${careConfig.apiUrl}${makeUrl(path, options?.queryParams, options?.pathParams)}`; const fetchOptions: RequestInit = { @@ -29,7 +29,7 @@ export async function callApi( throw new Error("Network Error"); } - const data = await getResponseBody(res); + const data = await getResponseBody(res); if (!res.ok) { throw new HTTPError({ @@ -60,9 +60,9 @@ export async function callApi( * }); * ``` */ -export default function query( - route: Route, - options?: APICallOptions, +export default function query>( + route: Route, + options?: ApiCallOptions, ) { return ({ signal }: { signal: AbortSignal }) => { return callApi(route, { ...options, signal }); @@ -98,9 +98,9 @@ export default function query( * - When aborted, both the `sleep` promise and the fetch request are cancelled automatically * - TanStack Query handles the abortion and cleanup of previous in-flight requests */ -const debouncedQuery = ( - route: Route, - options?: APICallOptions & { debounceInterval?: number }, +const debouncedQuery = >( + route: Route, + options?: ApiCallOptions & { debounceInterval?: number }, ) => { return async ({ signal }: { signal: AbortSignal }) => { await sleep(options?.debounceInterval ?? 500); diff --git a/src/Utils/request/request.ts b/src/Utils/request/request.ts index 975cef9ad55..7bbfebb2614 100644 --- a/src/Utils/request/request.ts +++ b/src/Utils/request/request.ts @@ -1,7 +1,7 @@ import careConfig from "@careConfig"; import handleResponse from "@/Utils/request/handleResponse"; -import { RequestOptions, RequestResult, Route } from "@/Utils/request/types"; +import { ApiRoute, RequestOptions, RequestResult } from "@/Utils/request/types"; import { makeHeaders, makeUrl } from "@/Utils/request/utils"; type Options = RequestOptions & { @@ -10,9 +10,12 @@ type Options = RequestOptions & { /** * @deprecated use useQuery/useMutation/callApi instead + * + * This no longer ensures that the path params are provided correctly during runtime. + * Usages so far works as path params were passed correctly, but this should not be used anymore. */ export default async function request( - { path, method, noAuth }: Route, + { path, method, noAuth }: ApiRoute, { query, body, diff --git a/src/Utils/request/types.ts b/src/Utils/request/types.ts index bdb1038ed38..113fe2a5575 100644 --- a/src/Utils/request/types.ts +++ b/src/Utils/request/types.ts @@ -8,50 +8,55 @@ type QueryParamValue = export type QueryParams = Record; -interface RouteBase { +export interface ApiRoute { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + TBody?: TBody; path: string; TRes: TData; noAuth?: boolean; } -export interface QueryRoute extends RouteBase { - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - TBody?: TBody; -} - -export interface MutationRoute extends RouteBase { - method: "POST" | "PUT" | "PATCH" | "DELETE"; - TBody?: TBody; -} - -export type Route = - | QueryRoute - | MutationRoute; - +/** + * @deprecated in favor of useQuery/useMutation/callApi + */ export interface RequestResult { res: Response | undefined; data: TData | undefined; error: undefined | Record; } +/** + * @deprecated in favor of ApiCallOptions used by useQuery/useMutation/callApi + */ export interface RequestOptions { query?: QueryParams; body?: TBody; - pathParams?: Record; + pathParams?: Record; onResponse?: (res: RequestResult) => void; silent?: boolean; } -export interface APICallOptions { - pathParams?: Record; +type ExtractRouteParams = + T extends `${infer _Start}{${infer Param}}${infer Rest}` + ? Param | ExtractRouteParams + : never; + +type PathParams = { + [K in ExtractRouteParams]: string; +}; + +export interface ApiCallOptions> { + pathParams?: PathParams; queryParams?: QueryParams; - body?: TBody; + body?: Route["TBody"]; silent?: boolean; signal?: AbortSignal; headers?: HeadersInit; } -type HTTPErrorCause = Record | undefined; +export type StructuredError = Record; + +type HTTPErrorCause = StructuredError | Record | undefined; export class HTTPError extends Error { status: number; diff --git a/src/Utils/request/useQuery.ts b/src/Utils/request/useQuery.ts index b6977e74419..422e5f96868 100644 --- a/src/Utils/request/useQuery.ts +++ b/src/Utils/request/useQuery.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo, useRef } from "react"; import request from "@/Utils/request/request"; -import { QueryRoute, RequestOptions } from "@/Utils/request/types"; +import { ApiRoute, RequestOptions } from "@/Utils/request/types"; import { mergeRequestOptions } from "./utils"; @@ -15,7 +15,7 @@ export interface QueryOptions extends RequestOptions { * @deprecated use `useQuery` from `@tanstack/react-query` instead. */ export default function useTanStackQueryInstead( - route: QueryRoute, + route: ApiRoute, options?: QueryOptions, ) { const overridesRef = useRef>(); diff --git a/src/Utils/request/utils.ts b/src/Utils/request/utils.ts index 24c492633d3..ea5948b0479 100644 --- a/src/Utils/request/utils.ts +++ b/src/Utils/request/utils.ts @@ -1,5 +1,4 @@ import { Dispatch, SetStateAction } from "react"; -import { toast } from "sonner"; import { LocalStorageKeys } from "@/common/constants"; @@ -17,8 +16,6 @@ export function makeUrl( ); } - ensurePathNotMissingReplacements(path); - if (query) { path += `?${makeQueryParams(query)}`; } @@ -43,22 +40,6 @@ const makeQueryParams = (query: QueryParams) => { return qParams.toString(); }; -/** - * TODO: consider replacing this with inferring the types from the route and using a generic - * to ensure that the path params are not missing. - */ -const ensurePathNotMissingReplacements = (path: string) => { - const missingParams = path.match(/\{.*\}/g); - - if (missingParams) { - const msg = `Missing path params: ${missingParams.join( - ", ", - )}. Path: ${path}`; - toast.error(msg); - throw new Error(msg); - } -}; - export function makeHeaders(noAuth: boolean, additionalHeaders?: HeadersInit) { const headers = new Headers(additionalHeaders); diff --git a/src/components/Auth/Login.tsx b/src/components/Auth/Login.tsx index 1bd8747e66d..9fcd37a55ac 100644 --- a/src/components/Auth/Login.tsx +++ b/src/components/Auth/Login.tsx @@ -19,6 +19,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/input-password"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -515,10 +516,9 @@ const Login = (props: LoginProps) => {
- { const { t } = useTranslation(); const handleChange = (e: any) => { - const { value, name } = e; + const { value, name } = e.target; const fieldValue = Object.assign({}, form); const errorField = Object.assign({}, errors); if (errorField[name]) { @@ -107,89 +107,105 @@ const ResetPassword = (props: ResetPasswordProps) => { }, []); return ( -
-
-
-
{ - handleSubmit(e); - }} - > -
- {t("reset_password")} -
-
- setPasswordInputInFocus(true)} - onBlur={() => setPasswordInputInFocus(false)} - /> - {passwordInputInFocus && ( -
- {validateRule( - form.password?.length >= 8, - t("password_length_validation"), - !form.password, - )} - {validateRule( - form.password !== form.password.toUpperCase(), - t("password_lowercase_validation"), - !form.password, - )} - {validateRule( - form.password !== form.password.toLowerCase(), - t("password_uppercase_validation"), - !form.password, - )} - {validateRule( - /\d/.test(form.password), - t("password_number_validation"), - !form.password, - )} -
- )} - setConfirmPasswordInputInFocus(true)} - onBlur={() => setConfirmPasswordInputInFocus(false)} - /> - {confirmPasswordInputInFocus && - form.confirm.length > 0 && - form.password.length > 0 && - validateRule( - form.confirm === form.password, - t("password_mismatch"), - !form.password && form.password.length > 0, +
+ { + handleSubmit(e); + }} + > +
+ {t("reset_password")} +
+ +
+
+ setPasswordInputInFocus(true)} + onBlur={() => setPasswordInputInFocus(false)} + /> + {errors.password && ( +
+ {errors.password} +
+ )} + {passwordInputInFocus && ( +
+ {validateRule( + form.password?.length >= 8, + t("password_length_validation"), + !form.password, + t("password_length_met"), + )} + {validateRule( + form.password !== form.password.toUpperCase(), + t("password_lowercase_validation"), + !form.password, + t("password_lowercase_met"), + )} + {validateRule( + form.password !== form.password.toLowerCase(), + t("password_uppercase_validation"), + !form.password, + t("password_uppercase_met"), + )} + {validateRule( + /\d/.test(form.password), + t("password_number_validation"), + !form.password, + t("password_number_met"), )} -
-
- - -
- +
+ )} +
+ +
+ setConfirmPasswordInputInFocus(true)} + onBlur={() => setConfirmPasswordInputInFocus(false)} + /> + {errors.confirm && ( +
+ {errors.confirm} +
+ )} + {confirmPasswordInputInFocus && + form.confirm.length > 0 && + form.password.length > 0 && + validateRule( + form.confirm === form.password, + t("password_mismatch"), + !form.password && form.password.length > 0, + t("password_match"), + )} +
+
+ +
+ +
-
+
); }; diff --git a/src/components/Common/Export.tsx b/src/components/Common/Export.tsx index 85a6a257da8..90f31eba96d 100644 --- a/src/components/Common/Export.tsx +++ b/src/components/Common/Export.tsx @@ -14,7 +14,7 @@ import DropdownMenu, { import useExport from "@/hooks/useExport"; import request from "@/Utils/request/request"; -import { Route } from "@/Utils/request/types"; +import { ApiRoute } from "@/Utils/request/types"; interface ExportItem { options?: DropdownItemProps; @@ -23,7 +23,7 @@ interface ExportItem { label: string; parse?: (data: string) => string; action?: Parameters["exportFile"]>[0]; - route?: Route; + route?: ApiRoute; } interface ExportMenuProps { @@ -38,7 +38,7 @@ interface ExportButtonProps { tooltipClassName?: string; type?: "csv" | "json"; action?: Parameters["exportFile"]>[0]; - route?: Route; + route?: ApiRoute; parse?: (data: string) => string; filenamePrefix: string; className?: string; diff --git a/src/components/Encounter/CreateEncounterForm.tsx b/src/components/Encounter/CreateEncounterForm.tsx index 39a5e8363e2..0bbb85cd8a4 100644 --- a/src/components/Encounter/CreateEncounterForm.tsx +++ b/src/components/Encounter/CreateEncounterForm.tsx @@ -216,6 +216,7 @@ export default function CreateEncounterForm({ - )} - {!hideSubmitButton && ( - - )} -
- )} - - - - ); -}; - -export default Form; diff --git a/src/components/Form/FormContext.ts b/src/components/Form/FormContext.ts deleted file mode 100644 index 2be5c12234c..00000000000 --- a/src/components/Form/FormContext.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext } from "react"; - -import { FieldError, FieldValidator } from "@/components/Form/FieldValidators"; -import { FormDetails } from "@/components/Form/Utils"; - -export type FormContextValue = ( - name: keyof T, - validate?: FieldValidator, - excludeFromDraft?: boolean, -) => { - id: keyof T; - name: keyof T; - onChange: any; - value: any; - error: FieldError | undefined; -}; - -export const createFormContext = () => - createContext>(undefined as any); diff --git a/src/components/Kanban/Board.tsx b/src/components/Kanban/Board.tsx index 0023c494609..e6024ec0632 100644 --- a/src/components/Kanban/Board.tsx +++ b/src/components/Kanban/Board.tsx @@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next"; import CareIcon from "@/CAREUI/icons/CareIcon"; import { callApi } from "@/Utils/request/query"; -import { QueryRoute } from "@/Utils/request/types"; +import { ApiRoute } from "@/Utils/request/types"; import { QueryOptions } from "@/Utils/request/useQuery"; interface KanbanBoardProps { @@ -24,7 +24,7 @@ interface KanbanBoardProps { id: string, ...args: unknown[] ) => { - route: QueryRoute; + route: ApiRoute; options?: QueryOptions; }; }[]; diff --git a/src/components/Patient/PatientDetailsTab/Appointments.tsx b/src/components/Patient/PatientDetailsTab/Appointments.tsx index cf1da394da3..8b6228c4125 100644 --- a/src/components/Patient/PatientDetailsTab/Appointments.tsx +++ b/src/components/Patient/PatientDetailsTab/Appointments.tsx @@ -38,13 +38,20 @@ export const Appointments = (props: PatientProps) => { const getStatusBadge = (status: string) => { const statusColors: Record = { - booked: "bg-yellow-100 text-yellow-800", - checked_in: "bg-green-100 text-green-800", - cancelled: "bg-red-100 text-red-800", + booked: + "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 hover:text-yellow-900", + checked_in: + "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900", + cancelled: "bg-red-100 text-red-800 hover:bg-red-200 hover:text-red-900", }; return ( - + {status.replace("_", " ").toUpperCase()} ); @@ -52,12 +59,15 @@ export const Appointments = (props: PatientProps) => { return (
-
-

+
+

{t("appointments")}

+
+ )} + {isEditing && ( +
+ +
+ ( + + {t("old_password")} + + { + field.onChange(e.target.value); + }} + /> + + + )} -
-

-
- - {field("new_password_2").value?.length > 0 && ( -
- {validateRule( - field("new_password_1").value === - field("new_password_2").value, - t("password_mismatch"), - !field("new_password_2").value, +
+ ( + + {t("new_password")} + + { + field.onChange(e.target.value); + }} + onFocus={() => setIsPasswordFieldFocused(true)} + onBlur={() => setIsPasswordFieldFocused(false)} + /> + + {isPasswordFieldFocused ? ( +
+ {validateRule( + field.value.length >= 8, + t("password_length_validation"), + !field.value, + t("password_length_met"), + )} + {validateRule( + /[a-z]/.test(field.value), + t("password_lowercase_validation"), + !field.value, + t("password_lowercase_met"), + )} + {validateRule( + /[A-Z]/.test(field.value), + t("password_uppercase_validation"), + !field.value, + t("password_uppercase_met"), + )} + {validateRule( + /\d/.test(field.value), + t("password_number_validation"), + !field.value, + t("password_number_met"), + )} + {validateRule( + field.value !== form.watch("old_password"), + t("new_password_same_as_old"), + !field.value, + t("new_password_different_from_old"), + )} +
+ ) : ( + + )} +
)} -
- )} + /> + + ( + + {t("new_password_confirmation")} + + { + field.onChange(e.target.value); + }} + /> + + + + )} + /> +
-
- )} - - ); - }; - const editButton = () => ( -
- -
- ); - - return ( -
- {editButton()} - {isEditing && renderPasswordForm()} +
+ + +
+ + + )}
); } diff --git a/src/components/ui/autocomplete.tsx b/src/components/ui/autocomplete.tsx index b79d27d0688..e5f1e4b302a 100644 --- a/src/components/ui/autocomplete.tsx +++ b/src/components/ui/autocomplete.tsx @@ -31,6 +31,7 @@ interface AutocompleteProps { placeholder?: string; noOptionsMessage?: string; disabled?: boolean; + "data-cy"?: string; } export default function Autocomplete({ @@ -41,6 +42,7 @@ export default function Autocomplete({ placeholder = "Select...", noOptionsMessage = "No options found", disabled, + "data-cy": dataCy, }: AutocompleteProps) { const [open, setOpen] = React.useState(false); @@ -58,6 +60,7 @@ export default function Autocomplete({ aria-expanded={open} className="w-full justify-between" disabled={disabled} + data-cy={dataCy} > {value diff --git a/src/components/ui/input-password.tsx b/src/components/ui/input-password.tsx new file mode 100644 index 00000000000..18d37c561f7 --- /dev/null +++ b/src/components/ui/input-password.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +import CareIcon from "@/CAREUI/icons/CareIcon"; + +import { Input } from "@/components/ui/input"; + +const PasswordInput = React.forwardRef< + HTMLInputElement, + React.ComponentProps<"input"> +>(({ className, ...props }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + return ( +
+ + +
+ ); +}); + +PasswordInput.displayName = "PasswordInput"; + +export { PasswordInput }; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index a8f245154c7..c23ce217662 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -17,6 +17,7 @@ const Input = React.forwardRef>( if (type === "date" || type === "time") { e.target.showPicker(); } + props.onFocus?.(e); }} /> ); diff --git a/src/index.tsx b/src/index.tsx index cfef8691acb..31f3787f9e5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,6 +6,16 @@ import App from "@/App"; import "@/i18n"; import "@/style/index.css"; +// Extend Window interface to include CARE_API_URL +declare global { + interface Window { + CARE_API_URL: string; + } +} + +// Set API URL from environment variable +window.CARE_API_URL = import.meta.env.REACT_CARE_API_URL; + if ("serviceWorker" in navigator) { registerSW({ immediate: false }); } diff --git a/src/pages/Appointments/AppointmentsPage.tsx b/src/pages/Appointments/AppointmentsPage.tsx index d15c39b83dd..269561aa2a0 100644 --- a/src/pages/Appointments/AppointmentsPage.tsx +++ b/src/pages/Appointments/AppointmentsPage.tsx @@ -293,8 +293,10 @@ export default function AppointmentsPage(props: { facilityId?: string }) { queryFn: query(scheduleApis.slots.getSlotsForDay, { pathParams: { facility_id: facilityId }, body: { - user: practitioner?.id, - day: qParams.date_from, + // voluntarily coalesce to empty string since we know query would be + // enabled only if practitioner and date_from are present + user: practitioner?.id ?? "", + day: qParams.date_from ?? "", }, }), enabled: slotsFilterEnabled, diff --git a/src/pages/Appointments/BookAppointment.tsx b/src/pages/Appointments/BookAppointment.tsx index 4a15b24465b..39ddd8eea45 100644 --- a/src/pages/Appointments/BookAppointment.tsx +++ b/src/pages/Appointments/BookAppointment.tsx @@ -78,7 +78,9 @@ export default function BookAppointment(props: Props) { queryFn: query(scheduleApis.slots.getSlotsForDay, { pathParams: { facility_id: props.facilityId }, body: { - user: resourceId, + // voluntarily coalesce to empty string since we know query would be + // enabled only if resourceId and selectedDate are present + user: resourceId ?? "", day: dateQueryString(selectedDate), }, }), diff --git a/src/pages/Appointments/utils.ts b/src/pages/Appointments/utils.ts index 7bb2562b550..4b131e50382 100644 --- a/src/pages/Appointments/utils.ts +++ b/src/pages/Appointments/utils.ts @@ -79,7 +79,9 @@ export const useAvailabilityHeatmap = ({ let queryFn = query(scheduleApis.slots.availabilityStats, { pathParams: { facility_id: facilityId }, body: { - user: userId, + // voluntarily coalesce to empty string since we know query would be + // enabled only if userId is present + user: userId ?? "", from_date: fromDate, to_date: toDate, }, diff --git a/src/pages/Encounters/tabs/EncounterFilesTab.tsx b/src/pages/Encounters/tabs/EncounterFilesTab.tsx index 053637b75d8..364a40325ab 100644 --- a/src/pages/Encounters/tabs/EncounterFilesTab.tsx +++ b/src/pages/Encounters/tabs/EncounterFilesTab.tsx @@ -356,7 +356,7 @@ export const EncounterFilesTab = (props: EncounterTabProps) => { const RenderTable = () => { return ( - +
diff --git a/src/pages/FacilityOrganization/FacilityOrganizationView.tsx b/src/pages/FacilityOrganization/FacilityOrganizationView.tsx index 9481f1c215c..5def2fc8afa 100644 --- a/src/pages/FacilityOrganization/FacilityOrganizationView.tsx +++ b/src/pages/FacilityOrganization/FacilityOrganizationView.tsx @@ -41,7 +41,7 @@ export default function FacilityOrganizationView({ id, facilityId }: Props) { searchQuery, ], queryFn: query.debounced(routes.facilityOrganization.list, { - pathParams: { facilityId, organizationId: id }, + pathParams: { facilityId }, queryParams: { parent: id, offset: (page - 1) * limit, diff --git a/src/pages/Organization/components/OrganizationSelector.tsx b/src/pages/Organization/components/OrganizationSelector.tsx index 672c9f887de..04ae00ffe9a 100644 --- a/src/pages/Organization/components/OrganizationSelector.tsx +++ b/src/pages/Organization/components/OrganizationSelector.tsx @@ -108,13 +108,16 @@ export default function OrganizationSelector(props: OrganizationSelectorProps) { <> {/* Selected Levels */} {selectedLevels.map((level, index) => ( -
+
-
+
{level.name}
@@ -124,6 +127,7 @@ export default function OrganizationSelector(props: OrganizationSelectorProps) { size="icon" onClick={() => handleEdit(index)} type="button" + data-cy={`edit-${level.metadata?.govt_org_type?.toLowerCase()}`} > @@ -153,6 +157,11 @@ export default function OrganizationSelector(props: OrganizationSelectorProps) { handleLevelChange(value, selectedLevels.length) } onSearch={setSearchQuery} + data-cy={`select-${ + lastLevel?.metadata?.govt_org_children_type?.toLowerCase() || + lastLevel?.metadata?.govt_org_type?.toLowerCase() || + "state" + }`} />
)} diff --git a/src/pages/Patients/VerifyPatient.tsx b/src/pages/Patients/VerifyPatient.tsx index 9e1594032a2..965e9ac5f8d 100644 --- a/src/pages/Patients/VerifyPatient.tsx +++ b/src/pages/Patients/VerifyPatient.tsx @@ -87,7 +87,7 @@ export default function VerifyPatient(props: { facilityId: string }) {

{patientData.name} @@ -152,6 +152,7 @@ export default function VerifyPatient(props: { facilityId: string }) { trigger={