diff --git a/client-side/package.json b/client-side/package.json index b258e36ae..93f0681d6 100644 --- a/client-side/package.json +++ b/client-side/package.json @@ -13,13 +13,13 @@ "@mui/x-charts": "^7.10.0", "@mui/x-data-grid": "^7.10.0", "@mui/x-date-pickers": "^7.11.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^2.2.7", - "ajv": "^8.17.1", - "ajv-keywords": "^5.1.0", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@testing-library/user-event": "^13.5.0", "@types/redux": "^3.6.31", + "ajv": "^8.17.1", + "ajv-keywords": "^5.1.0", "axios": "^1.7.2", "dayjs": "^1.11.12", "dotenv": "^16.4.5", @@ -39,8 +39,8 @@ "react-google-recaptcha": "^3.1.0", "react-i18next": "^15.0.1", "react-redux": "^9.1.2", - "react-router": "^6.24.1", "react-refresh": "^0.14.2", + "react-router": "^6.24.1", "react-router-dom": "^6.26.0", "redux": "^5.0.1", "styled-components": "^6.1.11", @@ -49,10 +49,13 @@ }, "devDependencies": { "@babel/core": "^7.25.2", + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/preset-env": "^7.25.3", "@babel/preset-react": "^7.24.7", + "@chromatic-com/storybook": "^1.6.0", "@eslint/compat": "^1.1.1", + "@eslint/js": "^9.6.0", "@storybook/addon-actions": "^8.1.11", "@storybook/addon-essentials": "^8.1.11", "@storybook/addon-interactions": "^8.1.11", @@ -73,20 +76,17 @@ "eslint-plugin-react": "^7.35.0", "eslint-plugin-storybook": "^0.8.0", "globals": "^15.9.0", + "install": "^0.13.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "notistack": "^3.0.1", + "npm": "^10.8.2", "prop-types": "^15.8.1", + "react-scripts": "^5.0.1", "redux-mock-store": "^1.5.4", "sass": "^1.77.6", - "webpack": "^5.92.1", - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@chromatic-com/storybook": "^1.6.0", - "@eslint/js": "^9.6.0", - "install": "^0.13.0", - "npm": "^10.8.2", - "react-scripts": "^5.0.1", - "storybook": "^8.1.11" + "storybook": "^8.1.11", + "webpack": "^5.92.1" }, "scripts": { "start": "react-scripts start", diff --git a/client-side/src/components/settings/Notifications.jsx b/client-side/src/components/settings/Notifications.jsx index 3eebf13c3..170c78be2 100644 --- a/client-side/src/components/settings/Notifications.jsx +++ b/client-side/src/components/settings/Notifications.jsx @@ -16,11 +16,11 @@ const Notifications = ({ onUpdate, data }) => { const { t: translate } = useTranslation(); const { user } = useSelector(selectAuth); - const notificationTime = (data?.sendNotificationTime || user.preference?.sendNotificationTime || 10); - const initialSoundVoice = (data?.soundVoice || user.preference?.soundVoice || "alertSound.mp3"); - const showIncomeMessages = (data?.displayIncomeMessages || user.preference?.displayIncomeMessages || false); - const showBrowsingTimeLimit = (data?.displayBrowsingTimeLimit || user.preference?.displayBrowsingTimeLimit || false); - const initialEmailFrequency = (data?.emailFrequency || user.preference?.emailFrequency || EMAIL_FREQUENCY_ENUM.NEVER); + const notificationTime = (data?.sendNotificationTime || user?.preference?.sendNotificationTime || 10); + const initialSoundVoice = (data?.soundVoice || user?.preference?.soundVoice || "alertSound.mp3"); + const showIncomeMessages = (data?.displayIncomeMessages || user?.preference?.displayIncomeMessages || false); + const showBrowsingTimeLimit = (data?.displayBrowsingTimeLimit || user?.preference?.displayBrowsingTimeLimit || false); + const initialEmailFrequency = (data?.emailFrequency || user?.preference?.emailFrequency || EMAIL_FREQUENCY_ENUM.NEVER); const [emailFrequency, setEmailFrequency] = useState(initialEmailFrequency); const [ringtoneFile, setRingtoneFile] = useState({ name: initialSoundVoice }); @@ -30,15 +30,13 @@ const Notifications = ({ onUpdate, data }) => { const [displayBrowsingTimeLimit, setDisplayBrowsingTimeLimit] = useState(showBrowsingTimeLimit); const [prevValues] = useState({ - emailFrequency: user.preference.emailFrequency, - ringtoneFile: { name: user.preference.soundVoice }, - sendNotificationTime: user.preference.sendNotificationTime, - displayIncomeMessages: user.preference.displayIncomeMessages, - displayBrowsingTimeLimit: user.preference.displayBrowsingTimeLimit + emailFrequency: user?.preference.emailFrequency, + ringtoneFile: { name: user?.preference.soundVoice }, + sendNotificationTime: user?.preference.sendNotificationTime, + displayIncomeMessages: user?.preference.displayIncomeMessages, + displayBrowsingTimeLimit: user?.preference.displayBrowsingTimeLimit }); - - const emailFrequencyOptions = Object.keys(EMAIL_FREQUENCY_ENUM).map(key => ({ text: translate(key.toLowerCase()), value: EMAIL_FREQUENCY_ENUM[key] @@ -47,7 +45,7 @@ const Notifications = ({ onUpdate, data }) => { useEffect(() => { if (prevValues.emailFrequency !== emailFrequency) { onUpdate({ emailFrequency }); - }else { + } else if (data && 'emailFrequency' in data) { onUpdate({ emailFrequency: undefined }); } }, [emailFrequency]); @@ -55,7 +53,7 @@ const Notifications = ({ onUpdate, data }) => { useEffect(() => { if (prevValues.sendNotificationTime != sendNotificationTime) { onUpdate({ sendNotificationTime }); - }else { + } else if (data && 'sendNotificationTime' in data) { onUpdate({ sendNotificationTime: undefined }); } }, [sendNotificationTime]); @@ -64,7 +62,7 @@ const Notifications = ({ onUpdate, data }) => { if (prevValues.displayIncomeMessages !== displayIncomeMessages) { onUpdate({ displayIncomeMessages }); } - else { + else if (data && 'displayIncomeMessages' in data) { onUpdate({ displayIncomeMessages: undefined }); } }, [displayIncomeMessages]); @@ -72,7 +70,7 @@ const Notifications = ({ onUpdate, data }) => { useEffect(() => { if (prevValues.displayBrowsingTimeLimit !== displayBrowsingTimeLimit) { onUpdate({ displayBrowsingTimeLimit }); - }else { + } else if (data && 'displayBrowsingTimeLimit' in data) { onUpdate({ displayBrowsingTimeLimit: undefined }); } }, [displayBrowsingTimeLimit]); @@ -87,7 +85,7 @@ const Notifications = ({ onUpdate, data }) => { audioElement.load(); } return () => URL.revokeObjectURL(newSoundVoice); - }else { + } else if (data && 'soundVoice' in data) { onUpdate({ soundVoice: undefined }); } }, [ringtoneFile]); @@ -136,6 +134,7 @@ const Notifications = ({ onUpdate, data }) => { value={emailFrequency} size='large' widthOfSelect='11rem' + data-testid="select-email-frequency" />
@@ -160,9 +159,11 @@ const Notifications = ({ onUpdate, data }) => { accept='audio/mp3' className='generic-input-file' /> - + {soundVoice && + ( + )}
); diff --git a/client-side/src/components/settings/Preferences.jsx b/client-side/src/components/settings/Preferences.jsx index 3570d48bc..aac40355b 100644 --- a/client-side/src/components/settings/Preferences.jsx +++ b/client-side/src/components/settings/Preferences.jsx @@ -11,10 +11,12 @@ import CONSTANTS from './constantSetting.js'; import './Preferences.scss'; const createTimeZones = () => { - return moment.tz.names().map(timezone => ({ - value: timezone, - text: timezone, - })); + return moment.tz.names() + .filter(timezone => timezone.startsWith('Etc')) + .map(timezone => ({ + value: timezone.replace('Etc/', ''), + text: timezone.replace('Etc/', ''), + })); }; const Preferences = ({ onUpdate, data }) => { @@ -27,11 +29,11 @@ const Preferences = ({ onUpdate, data }) => { text: LANGUAGE[key]['text'], iconSrc: LANGUAGE[key]['icon'] })); - const initialLanguage = (data?.language || user.preference?.language || languageOptions[0].value); + const initialLanguage = (data?.language || user?.preference?.language || languageOptions[1].value); const timeZoneOptions = createTimeZones(); const defaultTimeZone = timeZoneOptions.find(option => option.value === "UTC")?.value || timeZoneOptions[0].value; - const initialTimeZone = (data?.timeZone || user.preference?.timeZone || defaultTimeZone); - const initialDateFormat = (data?.dateFormat || user.preference?.dateFormat || DATE_FORMATS[0].value); + const initialTimeZone = (data?.timeZone || user?.preference?.timeZone || defaultTimeZone); + const initialDateFormat = (data?.dateFormat || user?.preference?.dateFormat || DATE_FORMATS[0].value); const [language, setLanguage] = useState(initialLanguage); const [timeZone, setTimeZone] = useState(initialTimeZone); const [dateFormat, setDateFormat] = useState(initialDateFormat); @@ -41,18 +43,17 @@ const Preferences = ({ onUpdate, data }) => { value: value })); - const [prevValues, setPrevValues] = useState({ - language, - timeZone, - dateFormat + const [prevValues] = useState({ + language:user?.preference.language, + timeZone:user?.preference.timeZone, + dateFormat:user?.preference.dateFormat }); useEffect(() => { if (prevValues.language !== language) { onUpdate({ language }); - setPrevValues(prev => ({ ...prev, language })); } - else { + else if(data && 'language' in data){ onUpdate({ language: undefined }); } }, [language]); @@ -60,8 +61,7 @@ const Preferences = ({ onUpdate, data }) => { useEffect(() => { if (prevValues.timeZone !== timeZone) { onUpdate({ timeZone }); - setPrevValues(prev => ({ ...prev, timeZone })); - }else { + }else if(data && 'timeZone' in data){ onUpdate({ timeZone: undefined }); } }, [timeZone]); @@ -69,9 +69,9 @@ const Preferences = ({ onUpdate, data }) => { useEffect(() => { if (prevValues.dateFormat !== dateFormat) { onUpdate({ dateFormat }); - setPrevValues(prev => ({ ...prev, dateFormat })); - }else { + }else if(data && 'dateFormat' in data){ onUpdate({ dateFormat: undefined }); + } }, [dateFormat]); @@ -95,6 +95,7 @@ const Preferences = ({ onUpdate, data }) => { widthOfSelect='11rem' value={language} onChange={handleLanguageChange} + data-testid="select-language" />
@@ -106,6 +107,7 @@ const Preferences = ({ onUpdate, data }) => { value={timeZone} size='large' widthOfSelect='11rem' + data-testid="select-time-zone" />
@@ -117,6 +119,7 @@ const Preferences = ({ onUpdate, data }) => { title={translate(LABELS.SELECT_DATE_FORMAT)} size='large' widthOfSelect='11rem' + data-testid="select-date-format" />
diff --git a/client-side/src/components/settings/Settings.jsx b/client-side/src/components/settings/Settings.jsx index 9677cc71a..d4842cec1 100644 --- a/client-side/src/components/settings/Settings.jsx +++ b/client-side/src/components/settings/Settings.jsx @@ -6,11 +6,9 @@ import { useSelector, useDispatch } from 'react-redux'; import VerticalTabs from '../../stories/verticalTabs/verticalTabss'; import GenericButton from '../../stories/Button/GenericButton.jsx'; import ToastMessage from '../../stories/Toast/ToastMessage.jsx'; -import { updatePreference as updatePreferenceService } from '../../services/preferenceService.js'; -import { updatePreference } from '../../redux/preference/preference.slice.js'; +import { updatePreference } from '../../services/preferenceService.js'; import { updateCurrentUser } from '../../redux/auth/auth.slice.js'; import { selectAuth } from '../../redux/auth/auth.selector.js'; -import { selectPreference } from '../../redux/preference/preference.selector.js'; import Preferences from './Preferences.jsx'; import AccountTab from './AccountTab.jsx'; @@ -24,13 +22,12 @@ const Settings = () => { const { t: translate } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); const { user } = useSelector(selectAuth); - const updatedPreferences = useSelector(selectPreference); const dispatch = useDispatch(); const [notificationsData, setNotificationsData] = useState({}); const [preferencesData, setPreferencesData] = useState({}); - const preferenceId = user.preference._id; + const preferenceId = user?.preference._id; const handleUpdatePreferences = (updatedPreferences) => { setPreferencesData(prev => { @@ -63,10 +60,9 @@ const Settings = () => { if (value !== undefined) formData.append(key, value); }); try { - const response = await updatePreferenceService(preferenceId, formData); + const response = await updatePreference(preferenceId, formData); if (response) { enqueueSnackbar(); - dispatch(updatePreference(response)); dispatch(updateCurrentUser({ ...user, preference: response diff --git a/client-side/src/components/settings/UserLocalization.jsx b/client-side/src/components/settings/UserLocalization.jsx new file mode 100644 index 000000000..184bf05a1 --- /dev/null +++ b/client-side/src/components/settings/UserLocalization.jsx @@ -0,0 +1,82 @@ +import React, { useState, useEffect, useRef } from 'react'; +import axios from 'axios'; + +const Localization = ({ preferenceId }) => { + const [timeZone, setTimeZone] = useState('UTC'); + const [language, setLanguage] = useState('en'); + + const baseUrl = process.env.REACT_APP_BASE_URL; + const permissionRef = useRef(false); + + useEffect(() => { + + const askForPermission = async () => { + console.log('askForPermission executed'); + const permission = window.confirm('Allow us to get your location and preferred language for a better experience?'); + permissionRef.current = true; + + if (permission) { + // Get time zone + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + setTimeZone(userTimeZone); + + // Get language + const userLanguage = navigator.languages && navigator.languages.length ? navigator.languages[0] : navigator.language || 'en'; + setLanguage(userLanguage); + + // Update preferences with user's data + updateUserPreferences(userTimeZone, userLanguage); + }, + (error) => { + console.error('Error getting location:', error); + // Since geolocation failed, update with defaults + updateUserPreferences(timeZone, language); + } + ); + } else { + console.error('Geolocation is not supported by this browser.'); + // Update with defaults if geolocation not supported + updateUserPreferences(timeZone, language); + } + } else { + // Permission denied, update with defaults + updateUserPreferences(timeZone, language); + } + }; + + // Only ask for permission if it hasn't been granted before + if (permissionRef.current == false) { + askForPermission(); + } + }, []); + + const updateUserPreferences = async (updatedTimeZone, updatedLanguage) => { + try { + const formData = new FormData(); + formData.append('timeZone', updatedTimeZone); + formData.append('language', updatedLanguage); + + const response = await axios.put(`${baseUrl}/preferences/${preferenceId}`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + console.log(response.data); + } catch (error) { + console.error('Error updating preferences:', error); + } + }; + + return ( +
+

Localization Settings

+

Time Zone: {timeZone}

+

Preferred Language: {language}

+
+ ); +}; + +export default Localization; diff --git a/client-side/src/components/signUp.jsx b/client-side/src/components/signUp.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/client-side/src/i18n.js b/client-side/src/i18n.js index 29ccbc407..8c3c41882 100644 --- a/client-side/src/i18n.js +++ b/client-side/src/i18n.js @@ -8,7 +8,8 @@ i18n .use(languageDetector) .use(Backend) .init({ - debug: true, + debug: true, + lng: 'en', fallbackLng: 'en', interpolation: { escapeValue: false, diff --git a/client-side/src/stories/Select/Select.jsx b/client-side/src/stories/Select/Select.jsx index dfd0eee54..a0caafc66 100644 --- a/client-side/src/stories/Select/Select.jsx +++ b/client-side/src/stories/Select/Select.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Box,InputLabel,MenuItem,FormControl}from '@mui/material'; +import { Box, InputLabel, MenuItem, FormControl } from '@mui/material'; import SelectMui from '@mui/material/Select'; import PropTypes from 'prop-types'; @@ -9,25 +9,29 @@ import './select.scss'; const Select = ({ className, options = OPTION_SELSCT, - onChange = () => {}, + onChange = () => { }, title, size = 'large', - widthOfSelect, - value + widthOfSelect, + value, + 'data-testid': dataTestId }) => { return (
{title} - onChange(event.target.value)} value={value} + inputProps={{ 'data-testid': dataTestId }} > {options.map((option, index) => ( - - {option.iconSrc && } + + {option.iconSrc && } {option.text} ))} @@ -48,7 +52,8 @@ Select.propTypes = { size: PropTypes.oneOf(['small', 'large']), className: PropTypes.string.isRequired, widthOfSelect: PropTypes.string, - value: PropTypes.any.isRequired + value: PropTypes.any.isRequired, + 'data-testid': PropTypes.string }; export default Select; diff --git a/client-side/test/components/settings/Notifications.test.js b/client-side/test/components/settings/Notifications.test.js new file mode 100644 index 000000000..b5e8cb550 --- /dev/null +++ b/client-side/test/components/settings/Notifications.test.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import Notifications from '../../../src/components/settings/Notifications.jsx'; +import CONSTANTS from '../../../src/components/settings/constantSetting.js'; +import { useSelector } from 'react-redux'; + +// Mocking the useSelector hook from redux +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +beforeAll(() => { + // Mocking URL.createObjectURL + global.URL.createObjectURL = jest.fn(() => 'mockObjectUrl'); + // Mocking URL.revokeObjectURL + global.URL.revokeObjectURL = jest.fn(); +}); + +afterAll(() => { + // Clean up the mocks + delete global.URL.createObjectURL; + delete global.URL.revokeObjectURL; +}); + +describe('Notifications Component', () => { + const { LABELS } = CONSTANTS; + const mockOnUpdate = jest.fn(); + const mockData = { + emailFrequency: 'yearly', + sendNotificationTime: 15, + soundVoice: 'song.mp3', + displayIncomeMessages: false, + displayBrowsingTimeLimit: true, + }; + + beforeEach(() => { + useSelector.mockImplementation(() => ({ + auth: { + user: { + preference: { + emailFrequency: 'never', + sendNotificationTime: 10, + soundVoice: 'alertSound.mp3', + displayIncomeMessages: true, + displayBrowsingTimeLimit: false, + }, + }, + }, + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders Notifications component correctly', () => { + render(); + + expect(screen.getByLabelText(LABELS.DISPLAY_INCOME_MESSAGES)).toBeInTheDocument(); + expect(screen.getByLabelText(LABELS.DISPLAY_BROWSING_TIME_LIMIT)).toBeInTheDocument(); + expect(screen.getByLabelText(LABELS.CHANGE_NOTIFICATION_TIME)).toBeInTheDocument(); + expect(screen.getByLabelText(LABELS.CHANGE_RINGTONE)).toBeInTheDocument(); + }); + + test('calls onUpdate when email frequency changes', () => { + render(); + fireEvent.change(screen.getByTestId('select-email-frequency'), { target: { value: 'daily' } }); + console.log(mockOnUpdate.mock.calls); + expect(mockOnUpdate).toHaveBeenCalledWith({ emailFrequency: 'daily' }); + }); + + test('calls onUpdate when notification time changes', () => { + render(); + fireEvent.change(screen.getByLabelText(LABELS.CHANGE_NOTIFICATION_TIME), { target: { value: 20 } }); + expect(mockOnUpdate).toHaveBeenCalledWith({ sendNotificationTime: 20 }); + }); + + test('calls onUpdate when soundVoice changes', () => { + render(); + const file = new File(['(⌐□_□)'], 'alertSoundNew.mp3', { type: 'audio/mp3' }); + + const input = screen.getByLabelText(LABELS.CHANGE_RINGTONE); + fireEvent.change(input, { target: { files: [file] } }); + + expect(mockOnUpdate).toHaveBeenCalledWith({ soundVoice: file }); + }); + + test('calls onUpdate when display income messages changes', () => { + render(); + const checkbox = screen.getByLabelText(LABELS.DISPLAY_INCOME_MESSAGES); + fireEvent.click(checkbox); + + expect(mockOnUpdate).toHaveBeenCalledWith({ displayIncomeMessages: false }); + }); + + test('calls onUpdate when browsing time limit changes', () => { + render(); + const checkbox = screen.getByLabelText(LABELS.DISPLAY_BROWSING_TIME_LIMIT); + fireEvent.click(checkbox); + + expect(mockOnUpdate).toHaveBeenCalledWith({ displayBrowsingTimeLimit: true }); + }); + + +}); diff --git a/client-side/test/components/settings/Preferences.test.js b/client-side/test/components/settings/Preferences.test.js new file mode 100644 index 000000000..12cbe8df1 --- /dev/null +++ b/client-side/test/components/settings/Preferences.test.js @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import Preferences from '../../../src/components/settings/Preferences.jsx'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; + +// Mocking the useSelector hook from redux +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +// Mocking useTranslation hook from react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => key, + i18n: { + changeLanguage: jest.fn(), + }, + }), +})); + +describe('Preferences Component', () => { + const mockOnUpdate = jest.fn(); + const mockData = { + language: 'en', + timeZone: 'UTC', + dateFormat: 'MM/DD/YYYY', + }; + + beforeEach(() => { + useSelector.mockImplementation((selector) => + selector({ + auth: { + user: { + preference: { + language: 'en', + timeZone: 'UTC', + dateFormat: 'MM/DD/YYYY', + }, + }, + }, + }) + ); + }); + + test('renders Preferences component correctly', () => { + render(); + expect(screen.getByTestId('select-language')).toBeInTheDocument(); + expect(screen.getByTestId('select-time-zone')).toBeInTheDocument(); + expect(screen.getByTestId('select-date-format')).toBeInTheDocument(); + }); + + test('calls onUpdate when language changes', () => { + render(); + fireEvent.change(screen.getByTestId('select-language'), { target: { value: 'es' } }); + expect(mockOnUpdate).toHaveBeenCalledWith({ language: 'es' }); + }); + + test('calls onUpdate when timeZone changes', () => { + render(); + fireEvent.change(screen.getByTestId('select-time-zone'), { target: { value: 'GMT+1' } }); + expect(mockOnUpdate).toHaveBeenCalledWith({ timeZone: 'GMT+1' }); + }); + + test('calls onUpdate when dateFormat changes', () => { + render(); + fireEvent.change(screen.getByTestId('select-date-format'), { target: { value: 'DD-MM-YYYY' } }); + expect(mockOnUpdate).toHaveBeenCalledWith({ dateFormat: 'DD-MM-YYYY' }); + }); + +});