From 07efb8ce51317a92aa69a97ce92b20e2c0131aa4 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 22 Apr 2026 16:15:34 +0700 Subject: [PATCH] #698 personal settings --- .../Calendar/Sidebar/MobileSidebar.tsx | 95 ++++---- .../Calendar/Sidebar/UtilButtons.tsx | 60 +++++ src/components/Event/WeekDaySelector.tsx | 99 +++++--- src/components/Menubar/MobileMenuBar.tsx | 116 ++++++---- src/components/MobileSelector/index.tsx | 107 +++++++++ src/features/Settings/DesktopSettingsPage.tsx | 108 +++++++++ src/features/Settings/GeneralSettings.tsx | 173 ++------------ .../DesktopLanguageSelector.tsx | 28 +++ .../MobileLanguageSelector.tsx | 66 ++++++ .../Settings/LanguageSelector/index.tsx | 39 ++++ .../Settings/LanguageSelector/index.types.ts | 26 +++ src/features/Settings/MobileSettingsPage.tsx | 60 +++++ .../Settings/NotificationSettings.tsx | 16 +- .../Settings/SettingErrorSnackbars.tsx | 79 +++++++ src/features/Settings/SettingsHeader.tsx | 49 ++++ src/features/Settings/SettingsPage.styl | 7 + src/features/Settings/SettingsPage.tsx | 217 +++++------------- .../MobileTimezoneSelector.tsx | 26 +++ .../Settings/TimezoneSelector/index.tsx | 130 +++++++++++ .../Settings/hooks/useLanguageChange.ts | 43 ++++ src/locales/en.json | 9 + src/locales/fr.json | 9 + src/locales/ru.json | 9 + src/locales/vi.json | 9 + 24 files changed, 1127 insertions(+), 453 deletions(-) create mode 100644 src/components/Calendar/Sidebar/UtilButtons.tsx create mode 100644 src/components/MobileSelector/index.tsx create mode 100644 src/features/Settings/DesktopSettingsPage.tsx create mode 100644 src/features/Settings/LanguageSelector/DesktopLanguageSelector.tsx create mode 100644 src/features/Settings/LanguageSelector/MobileLanguageSelector.tsx create mode 100644 src/features/Settings/LanguageSelector/index.tsx create mode 100644 src/features/Settings/LanguageSelector/index.types.ts create mode 100644 src/features/Settings/MobileSettingsPage.tsx create mode 100644 src/features/Settings/SettingErrorSnackbars.tsx create mode 100644 src/features/Settings/SettingsHeader.tsx create mode 100644 src/features/Settings/TimezoneSelector/MobileTimezoneSelector.tsx create mode 100644 src/features/Settings/TimezoneSelector/index.tsx create mode 100644 src/features/Settings/hooks/useLanguageChange.ts diff --git a/src/components/Calendar/Sidebar/MobileSidebar.tsx b/src/components/Calendar/Sidebar/MobileSidebar.tsx index fe89b86..befc74b 100644 --- a/src/components/Calendar/Sidebar/MobileSidebar.tsx +++ b/src/components/Calendar/Sidebar/MobileSidebar.tsx @@ -1,15 +1,21 @@ -import { Drawer, IconButton, Box } from '@linagora/twake-mui' +import { + Drawer, + Box, + useTheme, + ListItemIcon, + ListItemText, + ListItemButton +} from '@linagora/twake-mui' import { CalendarSidebarProps } from './SideBar' import { SidebarCommonContent } from './SidebarCommonContent' import { ViewSwitcher } from './ViewSwitcher' import { MainTitle } from '@/components/Menubar/MainTitle' -import HelpOutlineIcon from '@mui/icons-material/HelpOutline' import { useI18n } from 'twake-i18n' -import { AppListMenu } from '@/components/Menubar/AppListMenu' -import { UserMenu } from '@/components/Menubar/UserMenu' -import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { useAppDispatch } from '@/app/hooks' import { useUtilMenus } from '../hooks/useUtilMenus' import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice' +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' +import { UtilButtons } from './UtilButtons' export const MobileSidebar: React.FC = ({ open, @@ -26,25 +32,20 @@ export const MobileSidebar: React.FC = ({ onDateChange }) => { const { t } = useI18n() - const user = useAppSelector(state => state.user.userData) + const theme = useTheme() const dispatch = useAppDispatch() - const { - anchorEl, - supportLink, - userMenuAnchorEl, - handleAppMenuOpen, - handleAppMenuClose, - handleUserMenuOpen, - handleUserMenuClose, - handleSettingsClick, - handleLogoutClick - } = useUtilMenus() + const { handleSettingsClick } = useUtilMenus() const openSearch = (): void => { dispatch(setIsMobileSearchOpen(true)) } + const openSettings = (): void => { + handleSettingsClick() + onClose() + } + return ( = ({ onDateChange={onDateChange} /> - - {supportLink && ( - - - - )} - - - - void handleLogoutClick()} - onUserMenuOpen={handleUserMenuOpen} - isIframe={isIframe} - user={user} - size="s" - /> - + )} @@ -127,6 +96,32 @@ export const MobileSidebar: React.FC = ({ setSelectedCalendars={setSelectedCalendars} openSearchOnMobile={openSearch} /> + + + + + + + + + ) } diff --git a/src/components/Calendar/Sidebar/UtilButtons.tsx b/src/components/Calendar/Sidebar/UtilButtons.tsx new file mode 100644 index 0000000..3235de7 --- /dev/null +++ b/src/components/Calendar/Sidebar/UtilButtons.tsx @@ -0,0 +1,60 @@ +import { IconButton, Box } from '@linagora/twake-mui' +import HelpOutlineIcon from '@mui/icons-material/HelpOutline' +import { useI18n } from 'twake-i18n' +import { AppListMenu } from '@/components/Menubar/AppListMenu' +import { UserMenu } from '@/components/Menubar/UserMenu' +import { useAppSelector } from '@/app/hooks' +import { useUtilMenus } from '../hooks/useUtilMenus' + +export const UtilButtons: React.FC<{ isIframe?: boolean }> = ({ isIframe }) => { + const { t } = useI18n() + const user = useAppSelector(state => state.user.userData) + + const { + anchorEl, + supportLink, + userMenuAnchorEl, + handleAppMenuOpen, + handleAppMenuClose, + handleUserMenuOpen, + handleUserMenuClose, + handleSettingsClick, + handleLogoutClick + } = useUtilMenus() + + return ( + + {supportLink && ( + + + + )} + + + + void handleLogoutClick()} + onUserMenuOpen={handleUserMenuOpen} + isIframe={isIframe} + user={user} + size="s" + /> + + ) +} diff --git a/src/components/Event/WeekDaySelector.tsx b/src/components/Event/WeekDaySelector.tsx index 31bc1f7..896538e 100644 --- a/src/components/Event/WeekDaySelector.tsx +++ b/src/components/Event/WeekDaySelector.tsx @@ -1,4 +1,5 @@ -import { Box } from '@linagora/twake-mui' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { Box, SxProps, Theme } from '@linagora/twake-mui' import { useI18n } from 'twake-i18n' interface WeekDaySelectorProps { @@ -17,27 +18,72 @@ export const FC_DAYS = [ { fc: 0, ics: 'SU' } ] -export function WeekDaySelector({ +const WeekDayStyle = ( + isSelected: boolean, + isMobile: boolean, + disabled?: boolean +): SxProps => { + const desktopStyle = { + width: 40, + height: 40, + borderRadius: '4px', + border: '1px solid', + borderColor: isSelected ? 'primary.main' : '#AEAEC0', + color: isSelected ? '#fff' : '#8C9CAF', + fontSize: 16, + fontWeight: 400, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + bgcolor: isSelected ? 'primary.main' : 'transparent', + cursor: disabled ? 'default' : 'pointer', + '@media (hover: hover)': { + '&:hover': !disabled + ? { + borderColor: 'primary.main', + bgcolor: 'primary.main', + color: '#fff' + } + : undefined + } + } as SxProps + + if (isMobile) { + return { + ...desktopStyle, + width: 200, + justifyContent: 'start', + padding: 1 + } + } + + return desktopStyle +} + +export const WeekDaySelector: React.FC = ({ selectedDays, onChange, disabled -}: WeekDaySelectorProps) { +}) => { const { t } = useI18n() - const getDayLabel = (ics: string) => { + const { isTooSmall: isMobile } = useScreenSizeDetection() + + const getDayLabel = (ics: string, fullLabel: boolean): string => { + const weekdayKey = fullLabel ? 'fullDays' : 'days' const dayMap: Record = { - MO: t('event.repeat.days.monday'), - TU: t('event.repeat.days.tuesday'), - WE: t('event.repeat.days.wednesday'), - TH: t('event.repeat.days.thursday'), - FR: t('event.repeat.days.friday'), - SA: t('event.repeat.days.saturday'), - SU: t('event.repeat.days.sunday') + MO: t(`event.repeat.${weekdayKey}.monday`), + TU: t(`event.repeat.${weekdayKey}.tuesday`), + WE: t(`event.repeat.${weekdayKey}.wednesday`), + TH: t(`event.repeat.${weekdayKey}.thursday`), + FR: t(`event.repeat.${weekdayKey}.friday`), + SA: t(`event.repeat.${weekdayKey}.saturday`), + SU: t(`event.repeat.${weekdayKey}.sunday`) } return dayMap[ics] || ics } - const handleToggle = (fcDay: number) => { + const handleToggle = (fcDay: number): void => { if (disabled) return const updated = selectedDays.includes(fcDay) ? selectedDays.filter(d => d !== fcDay) @@ -46,10 +92,10 @@ export function WeekDaySelector({ } return ( - + {FC_DAYS.map(({ fc, ics }) => { const isSelected = selectedDays.includes(fc) - const fullLabel = getDayLabel(ics) + const fullLabel = getDayLabel(ics, isMobile) return ( handleToggle(fc)} disabled={disabled} - sx={{ - width: 40, - height: 40, - borderRadius: '4px', - border: '1px solid', - borderColor: isSelected ? 'primary.main' : '#AEAEC0', - color: isSelected ? '#fff' : '#8C9CAF', - fontSize: 16, - fontWeight: 400, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - bgcolor: isSelected ? 'primary.main' : 'transparent', - cursor: disabled ? 'default' : 'pointer', - '&:hover': !disabled - ? { - borderColor: 'primary.main', - bgcolor: 'primary.main', - color: '#fff' - } - : undefined - }} + sx={WeekDayStyle(isSelected, isMobile, disabled)} > - {fullLabel.charAt(0)} + {isMobile ? fullLabel : fullLabel.charAt(0)} ) })} diff --git a/src/components/Menubar/MobileMenuBar.tsx b/src/components/Menubar/MobileMenuBar.tsx index b6d62b2..dfbfaef 100644 --- a/src/components/Menubar/MobileMenuBar.tsx +++ b/src/components/Menubar/MobileMenuBar.tsx @@ -10,6 +10,9 @@ import './Menubar.styl' import { DatePickerMobile } from './components/DatePickerMobile' import { Typography } from '@mui/material' import { SmallNavigationControls } from './components/SmallNavigationControls' +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { setView } from '@/features/Settings/SettingsSlice' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' export type MobileMenubarProps = { calendarRef: React.RefObject @@ -27,6 +30,9 @@ export const MobileMenubar: React.FC = ({ onOpenSidebar }) => { const { t } = useI18n() + const dispatch = useAppDispatch() + + const view = useAppSelector(state => state.settings.view) const [openDatePicker, setOpenDatePicker] = useState(false) @@ -46,53 +52,81 @@ export const MobileMenubar: React.FC = ({ }) } + const handleBackClick = (event: React.MouseEvent): void => { + event.stopPropagation() + event.preventDefault() + dispatch(setView('calendar')) + } + return ( <>
-
- - - -
- -
-
- - - {dateLabel} - + {view === 'settings' ? ( + <> + + + + + + {t('menubar.settings')} + + + ) : ( + <> +
- {openDatePicker ? : } + - -
-
-
-
- -
-
+
+ +
+
+ + + {dateLabel} + + + {openDatePicker ? ( + + ) : ( + + )} + + +
+
+
+
+ +
+
+ + )}
{openDatePicker ? ( diff --git a/src/components/MobileSelector/index.tsx b/src/components/MobileSelector/index.tsx new file mode 100644 index 0000000..a829e8f --- /dev/null +++ b/src/components/MobileSelector/index.tsx @@ -0,0 +1,107 @@ +import { + Box, + Typography, + styled, + ButtonBase, + SwipeableDrawer +} from '@linagora/twake-mui' +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' +import React, { forwardRef, useImperativeHandle, useState } from 'react' + +export interface MobileSelectorHandle { + open: boolean + onClose: () => void +} + +const StyledSwipeableDrawer = styled(SwipeableDrawer)(({ theme }) => ({ + zIndex: theme.zIndex.modal + 100 +})) + +const SelectorButton = styled(ButtonBase)(({ theme }) => ({ + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1.5, 2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.secondary, + textAlign: 'left', + cursor: 'pointer', + transition: 'border-color 0.2s', + '&:hover': { + borderColor: theme.palette.text.primary + } +})) + +interface MobileSelectorProps { + displayText: string + children?: React.ReactNode + bottomSheetRef?: React.RefObject + paperRef?: React.RefObject + bottomSheetChildren?: (props: { + open: boolean + onClose: () => void + }) => React.ReactNode +} + +export const MobileSelector = forwardRef< + MobileSelectorHandle, + MobileSelectorProps +>( + ( + { displayText, children, bottomSheetRef, paperRef, bottomSheetChildren }, + ref + ) => { + const [open, setOpen] = useState(false) + + const onClose = (): void => { + setOpen(false) + } + + useImperativeHandle(ref, () => ({ + open, + onClose + })) + + return ( + <> + setOpen(true)}> + {displayText} + + + + {children ? ( + {}} + disableAutoFocus + slotProps={{ + paper: { + ref: paperRef, + sx: { maxHeight: '90dvh' } + } + }} + > + {children} + + ) : ( + bottomSheetChildren?.({ open, onClose }) + )} + + ) + } +) + +MobileSelector.displayName = 'MobileSelector' diff --git a/src/features/Settings/DesktopSettingsPage.tsx b/src/features/Settings/DesktopSettingsPage.tsx new file mode 100644 index 0000000..c349aff --- /dev/null +++ b/src/features/Settings/DesktopSettingsPage.tsx @@ -0,0 +1,108 @@ +import { + Box, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Typography +} from '@linagora/twake-mui' +import SettingsIcon from '@mui/icons-material/Settings' +import { useState } from 'react' +import { useI18n } from 'twake-i18n' +import { GeneralSettings } from './GeneralSettings' +import { NotificationsSettings } from './NotificationSettings' +import './SettingsPage.styl' +import { SettingsHeader } from './SettingsHeader' +import type { SidebarNavItem, SettingsSubTab } from './SettingsPage' + +export const DesktopSettingsPage: React.FC<{ + activeSettingsSubTab: SettingsSubTab + setLanguageErrorOpen: (open: boolean) => void + setTimeZoneErrorOpen: (open: boolean) => void + setAlarmEmailsErrorOpen: (open: boolean) => void + setHideDeclinedEventsErrorOpen: (open: boolean) => void + setDisplayWeekNumbersErrorOpen: (open: boolean) => void + setWorkingDaysErrorOpen: (open: boolean) => void + setActiveSettingsSubTab: (subTab: SettingsSubTab) => void + handleSettingsSubTabChange: ( + _event: React.SyntheticEvent, + newValue: SettingsSubTab + ) => void +}> = ({ + activeSettingsSubTab, + setLanguageErrorOpen, + setTimeZoneErrorOpen, + setAlarmEmailsErrorOpen, + setHideDeclinedEventsErrorOpen, + setDisplayWeekNumbersErrorOpen, + setWorkingDaysErrorOpen, + handleSettingsSubTabChange, + setActiveSettingsSubTab +}) => { + const { t } = useI18n() + + const [activeNavItem, setActiveNavItem] = useState('settings') + + const handleNavItemClick = (item: SidebarNavItem): void => { + setActiveNavItem(item) + if (item === 'settings') { + setActiveSettingsSubTab('settings') + } + } + + return ( + <> + + + handleNavItemClick('settings')} + > + + + + + + + + + + + {activeNavItem === 'settings' && ( + <> + {activeSettingsSubTab === 'settings' && ( + setLanguageErrorOpen(true)} + onTimeZoneError={() => setTimeZoneErrorOpen(true)} + onHideDeclinedEventsError={() => + setHideDeclinedEventsErrorOpen(true) + } + onDisplayWeekNumbersError={() => + setDisplayWeekNumbersErrorOpen(true) + } + onWorkingDaysError={() => setWorkingDaysErrorOpen(true)} + /> + )} + {activeSettingsSubTab === 'notifications' && ( + setAlarmEmailsErrorOpen(true)} + /> + )} + + )} + + {activeNavItem === 'sync' && ( + + + {t('settings.sync.empty') || 'Sync settings coming soon'} + + + )} + + + ) +} diff --git a/src/features/Settings/GeneralSettings.tsx b/src/features/Settings/GeneralSettings.tsx index 0a5a19a..c316c4e 100644 --- a/src/features/Settings/GeneralSettings.tsx +++ b/src/features/Settings/GeneralSettings.tsx @@ -1,35 +1,25 @@ import { useAppDispatch, useAppSelector } from '@/app/hooks' -import { useTimeZoneList } from '@/components/Timezone/hooks/useTimeZoneList' import { WeekDaySelector } from '@/components/Event/WeekDaySelector' -import { TimezoneAutocomplete } from '@/components/Timezone/TimezoneAutocomplete' -import { browserDefaultTimeZone, getTimezoneOffset } from '@/utils/timezone' import { Box, FormControl, FormControlLabel, - MenuItem, - Select, Switch, Typography } from '@linagora/twake-mui' import { useRef, useCallback, useEffect } from 'react' import { useI18n } from 'twake-i18n' -import { - setLanguage as setUserLanguage, - setTimezone as setUserTimeZone, - updateUserConfigurationsAsync -} from '../User/userSlice' -import { AVAILABLE_LANGUAGES } from './constants' +import { updateUserConfigurationsAsync } from '../User/userSlice' import { BusinessHour, setBusinessHours, setDisplayWeekNumbers, setHideDeclinedEvents, - setIsBrowserDefaultTimeZone, - setLanguage as setSettingsLanguage, - setTimeZone as setSettingsTimeZone, setWorkingDays } from './SettingsSlice' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { LanguageSelector } from './LanguageSelector' +import { TimezoneSelector } from './TimezoneSelector' interface GeneralSettingsProps { onLanguageError: () => void @@ -48,22 +38,7 @@ export const GeneralSettings: React.FC = ({ }) => { const dispatch = useAppDispatch() const { t } = useI18n() - - const previousConfig = useAppSelector(state => state.user.coreConfig) - const userLanguage = useAppSelector(state => state.user?.coreConfig.language) - const settingsLanguage = useAppSelector(state => state.settings?.language) - const currentLanguage = userLanguage || settingsLanguage || 'en' - - const timezoneList = useTimeZoneList() - const userTimeZone = useAppSelector( - state => state.user?.coreConfig?.datetime?.timeZone - ) - const settingTimeZone = useAppSelector(state => state.settings?.timeZone) - const currentTimeZone = - userTimeZone ?? settingTimeZone ?? browserDefaultTimeZone - const isBrowserDefault = useAppSelector( - state => state.settings.isBrowserDefaultTimeZone - ) + const { isTooSmall: isMobile } = useScreenSizeDetection() const hideDeclinedEvents = useAppSelector( state => state.settings?.hideDeclinedEvents @@ -78,67 +53,6 @@ export const GeneralSettings: React.FC = ({ null ) - const handleLanguageChange = ( - event: - | React.ChangeEvent< - Omit & { - value: string - } - > - | (Event & { - target: { - value: string - name: string - } - }) - ): void => { - const newLanguage = event.target.value - const previousLanguage = currentLanguage - dispatch(setUserLanguage(newLanguage)) - dispatch(setSettingsLanguage(newLanguage)) - dispatch(updateUserConfigurationsAsync({ language: newLanguage })) - .unwrap() - .catch(() => { - dispatch(setUserLanguage(previousLanguage)) - dispatch(setSettingsLanguage(previousLanguage)) - onLanguageError() - }) - } - - const handleTimeZoneChange = (newTimeZone: string): void => { - const previousTimeZone = currentTimeZone - dispatch(setUserTimeZone(newTimeZone)) - dispatch(setSettingsTimeZone(newTimeZone)) - dispatch( - updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) - ) - .unwrap() - .catch(() => { - dispatch(setUserTimeZone(previousTimeZone)) - dispatch(setSettingsTimeZone(previousTimeZone)) - onTimeZoneError() - }) - } - - const handleTimeZoneDefaultChange = (isDefault: boolean): void => { - const previousTimeZone = currentTimeZone - dispatch(setIsBrowserDefaultTimeZone(isDefault)) - if (isDefault) { - dispatch(setUserTimeZone(null)) - dispatch(setSettingsTimeZone(browserDefaultTimeZone)) - dispatch( - updateUserConfigurationsAsync({ timezone: null, previousConfig }) - ) - .unwrap() - .catch(() => { - dispatch(setUserTimeZone(previousTimeZone)) - dispatch(setSettingsTimeZone(previousTimeZone)) - dispatch(setIsBrowserDefaultTimeZone(!isDefault)) - onTimeZoneError() - }) - } - } - const handleHideDeclinedEvents = (value: boolean): void => { dispatch(setHideDeclinedEvents(value)) dispatch(updateUserConfigurationsAsync({ hideDeclinedEvents: value })) @@ -207,74 +121,15 @@ export const GeneralSettings: React.FC = ({ }) } + const inputMinWidth = isMobile ? '100%' : 500 + return ( {/* Language */} - - - {t('settings.language') || 'Language'} - - - {t('settings.languageDescription') || - 'This will be the language used in your Twake Calendar'} - - - - - + {/* Timezone */} - - - {t('settings.timeZone')} - - - - - handleTimeZoneDefaultChange(!isBrowserDefault) - } - aria-label={t('settings.timeZoneBrowserDefault')} - /> - } - label={t('settings.timeZoneBrowserDefault')} - labelPlacement="start" - sx={{ - minWidth: 400, - justifyContent: 'space-between', - marginLeft: 0, - mb: 2 - }} - /> - {!isBrowserDefault && ( - - )} - - - + {/* Working */} @@ -285,7 +140,7 @@ export const GeneralSettings: React.FC = ({ selectedDays={businessHours?.daysOfWeek ?? []} onChange={days => handleBusinessHour({ days })} /> - + = ({ label={t('settings.showOnlyWorkingDays')} labelPlacement="start" sx={{ - minWidth: 400, + minWidth: isMobile ? '100%' : 400, justifyContent: 'space-between', marginLeft: 0 }} @@ -309,7 +164,7 @@ export const GeneralSettings: React.FC = ({ {t('settings.calAndEvent')} - + = ({ label={t('settings.showDeclinedEvent')} labelPlacement="start" sx={{ - minWidth: 400, + minWidth: isMobile ? '100%' : 400, justifyContent: 'space-between', marginLeft: 0 }} @@ -337,7 +192,7 @@ export const GeneralSettings: React.FC = ({ label={t('settings.displayWeekNumbers')} labelPlacement="start" sx={{ - minWidth: 400, + minWidth: isMobile ? '100%' : 400, justifyContent: 'space-between', marginLeft: 0 }} diff --git a/src/features/Settings/LanguageSelector/DesktopLanguageSelector.tsx b/src/features/Settings/LanguageSelector/DesktopLanguageSelector.tsx new file mode 100644 index 0000000..6592c8c --- /dev/null +++ b/src/features/Settings/LanguageSelector/DesktopLanguageSelector.tsx @@ -0,0 +1,28 @@ +import { FormControl, MenuItem, Select } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { AVAILABLE_LANGUAGES } from '../constants' +import { LanguageSelectorInputProps } from './index.types' + +export const DesktopSelectLanguage: React.FC = ({ + currentLanguage, + onChange +}) => { + const { t } = useI18n() + + return ( + + + + ) +} diff --git a/src/features/Settings/LanguageSelector/MobileLanguageSelector.tsx b/src/features/Settings/LanguageSelector/MobileLanguageSelector.tsx new file mode 100644 index 0000000..9ece4b6 --- /dev/null +++ b/src/features/Settings/LanguageSelector/MobileLanguageSelector.tsx @@ -0,0 +1,66 @@ +import React, { useRef } from 'react' +import { AVAILABLE_LANGUAGES } from '../constants' +import { EventChange, LanguageSelectorInputProps } from './index.types' +import { + MobileSelector, + MobileSelectorHandle +} from '@/components/MobileSelector' +import { + Box, + List, + ListItem, + ListItemButton, + ListItemText, + Typography, + useTheme +} from '@linagora/twake-mui' +import { Check as CheckIcon } from '@mui/icons-material' + +export const MobileLanguageSelector: React.FC = ({ + currentLanguage, + onChange +}) => { + const theme = useTheme() + const selectorRef = useRef(null) + + const currentLabel = + AVAILABLE_LANGUAGES.find(({ code }) => code === currentLanguage)?.label ?? + currentLanguage + + const handleChange = (event: EventChange): void => { + onChange(event) + selectorRef.current?.onClose() + } + + return ( + + + {AVAILABLE_LANGUAGES.map(({ code, label }) => ( + + + handleChange({ target: { value: code } } as EventChange) + } + sx={{ py: 1 }} + > + + {label} + + } + /> + {currentLanguage === code && ( + + )} + + + ))} + + + ) +} diff --git a/src/features/Settings/LanguageSelector/index.tsx b/src/features/Settings/LanguageSelector/index.tsx new file mode 100644 index 0000000..0c02f8d --- /dev/null +++ b/src/features/Settings/LanguageSelector/index.tsx @@ -0,0 +1,39 @@ +import { Box, Typography } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { DesktopSelectLanguage } from './DesktopLanguageSelector' +import { MobileLanguageSelector } from './MobileLanguageSelector' +import { useLanguageChange } from '../hooks/useLanguageChange' +import type { LanguageSelectorProps } from './index.types' + +export const LanguageSelector: React.FC = ({ + onLanguageError +}) => { + const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + const { currentLanguage, handleLanguageChange } = useLanguageChange({ + onLanguageError + }) + + return ( + + + {t('settings.language')} + + + {t('settings.languageDescription')} + + {isMobile ? ( + + ) : ( + + )} + + ) +} diff --git a/src/features/Settings/LanguageSelector/index.types.ts b/src/features/Settings/LanguageSelector/index.types.ts new file mode 100644 index 0000000..9671f22 --- /dev/null +++ b/src/features/Settings/LanguageSelector/index.types.ts @@ -0,0 +1,26 @@ +export type EventChange = + | React.ChangeEvent< + Omit & { + value: string + } + > + | (Event & { + target: { + value: string + name: string + } + }) + +export interface LanguageSelectorProps { + onLanguageError: () => void +} + +export interface LanguageSelectorInputProps { + currentLanguage: string + onChange: (event: EventChange) => void +} + +export interface LanguageListBottomSheetProps extends LanguageSelectorInputProps { + open: boolean + onClose: () => void +} diff --git a/src/features/Settings/MobileSettingsPage.tsx b/src/features/Settings/MobileSettingsPage.tsx new file mode 100644 index 0000000..a7c0a8b --- /dev/null +++ b/src/features/Settings/MobileSettingsPage.tsx @@ -0,0 +1,60 @@ +import { Box } from '@linagora/twake-mui' +import { GeneralSettings } from './GeneralSettings' +import { NotificationsSettings } from './NotificationSettings' +import './SettingsPage.styl' +import { SettingsHeader } from './SettingsHeader' +import { SettingsSubTab } from './SettingsPage' + +export const MobileSettingsPage: React.FC<{ + activeSettingsSubTab: SettingsSubTab + setLanguageErrorOpen: (open: boolean) => void + setTimeZoneErrorOpen: (open: boolean) => void + setAlarmEmailsErrorOpen: (open: boolean) => void + setHideDeclinedEventsErrorOpen: (open: boolean) => void + setDisplayWeekNumbersErrorOpen: (open: boolean) => void + setWorkingDaysErrorOpen: (open: boolean) => void + setActiveSettingsSubTab: (subTab: SettingsSubTab) => void + handleSettingsSubTabChange: ( + _event: React.SyntheticEvent, + newValue: SettingsSubTab + ) => void +}> = ({ + activeSettingsSubTab, + setLanguageErrorOpen, + setTimeZoneErrorOpen, + setAlarmEmailsErrorOpen, + setHideDeclinedEventsErrorOpen, + setDisplayWeekNumbersErrorOpen, + setWorkingDaysErrorOpen, + handleSettingsSubTabChange +}) => { + return ( + + + + {activeSettingsSubTab === 'settings' && ( + setLanguageErrorOpen(true)} + onTimeZoneError={() => setTimeZoneErrorOpen(true)} + onHideDeclinedEventsError={() => + setHideDeclinedEventsErrorOpen(true) + } + onDisplayWeekNumbersError={() => + setDisplayWeekNumbersErrorOpen(true) + } + onWorkingDaysError={() => setWorkingDaysErrorOpen(true)} + /> + )} + {activeSettingsSubTab === 'notifications' && ( + setAlarmEmailsErrorOpen(true)} + /> + )} + + + ) +} diff --git a/src/features/Settings/NotificationSettings.tsx b/src/features/Settings/NotificationSettings.tsx index 05a8d8f..b7f387d 100644 --- a/src/features/Settings/NotificationSettings.tsx +++ b/src/features/Settings/NotificationSettings.tsx @@ -6,23 +6,27 @@ import { setAlarmEmails, updateUserConfigurationsAsync } from '../User/userSlice' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' interface NotificationsSettingsProps { onAlarmEmailsError: () => void } -export function NotificationsSettings({ +export const NotificationsSettings: React.FC = ({ onAlarmEmailsError -}: NotificationsSettingsProps) { +}) => { const dispatch = useAppDispatch() const { t } = useI18n() + + const { isTooSmall: isMobile } = useScreenSizeDetection() + const alarmEmailsEnabled = useAppSelector( state => state.user?.alarmEmailsEnabled ?? true ) const handleAlarmEmailsToggle = ( event: React.ChangeEvent - ) => { + ): void => { const newValue = event.target.checked const previousValue = alarmEmailsEnabled dispatch(setAlarmEmails(newValue)) @@ -48,7 +52,11 @@ export function NotificationsSettings({ } label={t('settings.notifications.email')} labelPlacement="start" - sx={{ minWidth: 400, justifyContent: 'space-between', marginLeft: 0 }} + sx={{ + minWidth: isMobile ? '100%' : 400, + justifyContent: 'space-between', + marginLeft: 0 + }} /> ) diff --git a/src/features/Settings/SettingErrorSnackbars.tsx b/src/features/Settings/SettingErrorSnackbars.tsx new file mode 100644 index 0000000..f93d9ee --- /dev/null +++ b/src/features/Settings/SettingErrorSnackbars.tsx @@ -0,0 +1,79 @@ +import { Snackbar } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import './SettingsPage.styl' + +export const SettingErrorSnackbars: React.FC<{ + languageErrorOpen: boolean + timeZoneErrorOpen: boolean + alarmEmailsErrorOpen: boolean + hideDeclinedEventsErrorOpen: boolean + displayWeekNumbersErrorOpen: boolean + workingDaysErrorOpen: boolean + handleLanguageErrorClose: () => void + handleTimeZoneErrorClose: () => void + handleAlarmEmailsErrorClose: () => void + handleHideDeclinedEventsErrorClose: () => void + handleDisplayWeekNumbersErrorClose: () => void + setWorkingDaysErrorOpen: (open: boolean) => void +}> = ({ + languageErrorOpen, + timeZoneErrorOpen, + alarmEmailsErrorOpen, + hideDeclinedEventsErrorOpen, + displayWeekNumbersErrorOpen, + workingDaysErrorOpen, + handleLanguageErrorClose, + handleTimeZoneErrorClose, + handleAlarmEmailsErrorClose, + handleHideDeclinedEventsErrorClose, + handleDisplayWeekNumbersErrorClose, + setWorkingDaysErrorOpen +}) => { + const { t } = useI18n() + + return ( + <> + + + + + + setWorkingDaysErrorOpen(false)} + message={t('settings.workingDaysUpdateError')} + /> + + ) +} diff --git a/src/features/Settings/SettingsHeader.tsx b/src/features/Settings/SettingsHeader.tsx new file mode 100644 index 0000000..9b71b79 --- /dev/null +++ b/src/features/Settings/SettingsHeader.tsx @@ -0,0 +1,49 @@ +import { useAppDispatch } from '@/app/hooks' +import { Box, IconButton, Tab, Tabs } from '@linagora/twake-mui' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import { useI18n } from 'twake-i18n' +import './SettingsPage.styl' +import { setView } from './SettingsSlice' +import { SettingsSubTab, SidebarNavItem } from './SettingsPage' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' + +export const SettingsHeader: React.FC<{ + activeNavItem?: SidebarNavItem + activeSettingsSubTab: SettingsSubTab + handleSettingsSubTabChange: ( + _event: React.SyntheticEvent, + newValue: SettingsSubTab + ) => void +}> = ({ activeNavItem, activeSettingsSubTab, handleSettingsSubTabChange }) => { + const dispatch = useAppDispatch() + const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + + const handleBackClick = (): void => { + dispatch(setView('calendar')) + } + + return ( + + {!isMobile && ( + + + + )} + {activeNavItem === 'settings' && ( + + + + + )} + + ) +} diff --git a/src/features/Settings/SettingsPage.styl b/src/features/Settings/SettingsPage.styl index e2f15cd..be44667 100644 --- a/src/features/Settings/SettingsPage.styl +++ b/src/features/Settings/SettingsPage.styl @@ -4,6 +4,9 @@ height calc(100vh - 70px) padding 0 10px 10px 0; + &--mobile + padding 0 + .main-layout.settings-layout.isInIframe height 100vh @@ -106,3 +109,7 @@ .MuiTabs-indicator background-color #F67E35 + &--mobile + .MuiTab-root + font-size 17px + diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index 74e311d..6a21451 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -1,33 +1,16 @@ -import { useAppDispatch } from '@/app/hooks' -import { - Box, - IconButton, - List, - ListItem, - ListItemIcon, - ListItemText, - Snackbar, - Tab, - Tabs, - Typography -} from '@linagora/twake-mui' -import ArrowBackIcon from '@mui/icons-material/ArrowBack' -import SettingsIcon from '@mui/icons-material/Settings' import { useState } from 'react' -import { useI18n } from 'twake-i18n' -import { GeneralSettings } from './GeneralSettings' -import { NotificationsSettings } from './NotificationSettings' import './SettingsPage.styl' -import { setView } from './SettingsSlice' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { SettingErrorSnackbars } from './SettingErrorSnackbars' +import { DesktopSettingsPage } from './DesktopSettingsPage' +import { MobileSettingsPage } from './MobileSettingsPage' -type SidebarNavItem = 'settings' | 'sync' -type SettingsSubTab = 'settings' | 'notifications' +export type SidebarNavItem = 'settings' | 'sync' +export type SettingsSubTab = 'settings' | 'notifications' -export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) { - const dispatch = useAppDispatch() - const { t } = useI18n() +const SettingsPage: React.FC<{ isInIframe?: boolean }> = ({ isInIframe }) => { + const { isTooSmall: isMobile } = useScreenSizeDetection() - const [activeNavItem, setActiveNavItem] = useState('settings') const [activeSettingsSubTab, setActiveSettingsSubTab] = useState('settings') @@ -40,163 +23,73 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) { useState(false) const [workingDaysErrorOpen, setWorkingDaysErrorOpen] = useState(false) - const handleBackClick = () => { - dispatch(setView('calendar')) - } - - const handleNavItemClick = (item: SidebarNavItem) => { - setActiveNavItem(item) - if (item === 'settings') { - setActiveSettingsSubTab('settings') - } - } - const handleSettingsSubTabChange = ( _event: React.SyntheticEvent, newValue: SettingsSubTab - ) => { + ): void => { setActiveSettingsSubTab(newValue) } - const handleLanguageErrorClose = () => { + const handleLanguageErrorClose = (): void => { setLanguageErrorOpen(false) } - const handleTimeZoneErrorClose = () => { + const handleTimeZoneErrorClose = (): void => { setTimeZoneErrorOpen(false) } - const handleHideDeclinedEventsErrorClose = () => { + const handleHideDeclinedEventsErrorClose = (): void => { setHideDeclinedEventsErrorOpen(false) } - const handleAlarmEmailsErrorClose = () => { + const handleAlarmEmailsErrorClose = (): void => { setAlarmEmailsErrorOpen(false) } - const handleDisplayWeekNumbersErrorClose = () => { + const handleDisplayWeekNumbersErrorClose = (): void => { setDisplayWeekNumbersErrorOpen(false) } return (
- - - handleNavItemClick('settings')} - sx={{ cursor: 'pointer' }} - > - - - - - - {/* handleNavItemClick("sync")} - sx={{ cursor: "pointer" }} - > - - - - - */} - - - - - - - - {activeNavItem === 'settings' && ( - - - - - )} - - - {activeNavItem === 'settings' && ( - <> - {activeSettingsSubTab === 'settings' && ( - setLanguageErrorOpen(true)} - onTimeZoneError={() => setTimeZoneErrorOpen(true)} - onHideDeclinedEventsError={() => - setHideDeclinedEventsErrorOpen(true) - } - onDisplayWeekNumbersError={() => - setDisplayWeekNumbersErrorOpen(true) - } - onWorkingDaysError={() => setWorkingDaysErrorOpen(true)} - /> - )} - {activeSettingsSubTab === 'notifications' && ( - setAlarmEmailsErrorOpen(true)} - /> - )} - - )} - - {activeNavItem === 'sync' && ( - - - {t('settings.sync.empty') || 'Sync settings coming soon'} - - - )} - - - - - - - setWorkingDaysErrorOpen(false)} - message={t('settings.workingDaysUpdateError')} + {isMobile ? ( + + ) : ( + + )} +
) } + +export default SettingsPage diff --git a/src/features/Settings/TimezoneSelector/MobileTimezoneSelector.tsx b/src/features/Settings/TimezoneSelector/MobileTimezoneSelector.tsx new file mode 100644 index 0000000..9b04c20 --- /dev/null +++ b/src/features/Settings/TimezoneSelector/MobileTimezoneSelector.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { MobileSelector } from '@/components/MobileSelector' +import { SmallTimezoneSelector } from '@/components/Timezone/SmallTimeZoneSelector' + +export const MobileTimezoneSelector: React.FC<{ + currentTimezone: string + onChange: (tz: string) => void +}> = ({ currentTimezone, onChange }) => { + return ( + ( + { + onChange(tz) + onClose() + }} + referenceDate={new Date()} + /> + )} + /> + ) +} diff --git a/src/features/Settings/TimezoneSelector/index.tsx b/src/features/Settings/TimezoneSelector/index.tsx new file mode 100644 index 0000000..1833b5a --- /dev/null +++ b/src/features/Settings/TimezoneSelector/index.tsx @@ -0,0 +1,130 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { useTimeZoneList } from '@/components/Timezone/hooks/useTimeZoneList' +import { TimezoneAutocomplete } from '@/components/Timezone/TimezoneAutocomplete' +import { browserDefaultTimeZone, getTimezoneOffset } from '@/utils/timezone' +import { + Box, + FormControl, + FormControlLabel, + Switch, + Typography +} from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { + setTimezone as setUserTimeZone, + updateUserConfigurationsAsync +} from '../../User/userSlice' +import { + setIsBrowserDefaultTimeZone, + setTimeZone as setSettingsTimeZone +} from '../SettingsSlice' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { MobileTimezoneSelector } from './MobileTimezoneSelector' + +interface TimezoneSelectorProps { + onTimeZoneError: () => void +} + +export const TimezoneSelector: React.FC = ({ + onTimeZoneError +}) => { + const dispatch = useAppDispatch() + const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + + const previousConfig = useAppSelector(state => state.user.coreConfig) + + const timezoneList = useTimeZoneList() + const userTimeZone = useAppSelector( + state => state.user?.coreConfig?.datetime?.timeZone + ) + const settingTimeZone = useAppSelector(state => state.settings?.timeZone) + const currentTimeZone = + userTimeZone ?? settingTimeZone ?? browserDefaultTimeZone + const isBrowserDefault = useAppSelector( + state => state.settings.isBrowserDefaultTimeZone + ) + + const handleTimeZoneChange = (newTimeZone: string): void => { + const previousTimeZone = currentTimeZone + dispatch(setUserTimeZone(newTimeZone)) + dispatch(setSettingsTimeZone(newTimeZone)) + dispatch( + updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) + ) + .unwrap() + .catch(() => { + dispatch(setUserTimeZone(previousTimeZone)) + dispatch(setSettingsTimeZone(previousTimeZone)) + onTimeZoneError() + }) + } + + const handleTimeZoneDefaultChange = (isDefault: boolean): void => { + const previousTimeZone = currentTimeZone + dispatch(setIsBrowserDefaultTimeZone(isDefault)) + if (isDefault) { + dispatch(setUserTimeZone(null)) + dispatch(setSettingsTimeZone(browserDefaultTimeZone)) + dispatch( + updateUserConfigurationsAsync({ timezone: null, previousConfig }) + ) + .unwrap() + .catch(() => { + dispatch(setUserTimeZone(previousTimeZone)) + dispatch(setSettingsTimeZone(previousTimeZone)) + dispatch(setIsBrowserDefaultTimeZone(!isDefault)) + onTimeZoneError() + }) + } + } + + const inputMinWidth = isMobile ? '100%' : 500 + + return ( + + + {t('settings.timeZone')} + + + + handleTimeZoneDefaultChange(!isBrowserDefault)} + aria-label={t('settings.timeZoneBrowserDefault')} + /> + } + label={t('settings.timeZoneBrowserDefault')} + labelPlacement="start" + sx={{ + minWidth: isMobile ? '100%' : 400, + justifyContent: 'space-between', + marginLeft: 0, + mb: 2 + }} + /> + {!isMobile && !isBrowserDefault && ( + + )} + + + {isMobile && !isBrowserDefault && ( + + )} + + ) +} diff --git a/src/features/Settings/hooks/useLanguageChange.ts b/src/features/Settings/hooks/useLanguageChange.ts new file mode 100644 index 0000000..441ede4 --- /dev/null +++ b/src/features/Settings/hooks/useLanguageChange.ts @@ -0,0 +1,43 @@ +import { useRef } from 'react' +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { + setLanguage as setUserLanguage, + updateUserConfigurationsAsync +} from '../../User/userSlice' +import { setLanguage as setSettingsLanguage } from '../SettingsSlice' +import { EventChange } from '../LanguageSelector/index.types' + +export const useLanguageChange = ({ + onLanguageError +}: { + onLanguageError: () => void +}): { + handleLanguageChange: (event: EventChange) => void + currentLanguage: string +} => { + const dispatch = useAppDispatch() + const userLanguage = useAppSelector(state => state.user?.coreConfig.language) + const settingsLanguage = useAppSelector(state => state.settings?.language) + const currentLanguage = userLanguage || settingsLanguage || 'en' + const languageRequestSeq = useRef(0) + + const handleLanguageChange = (event: EventChange): void => { + const newLanguage = event.target.value + const previousLanguage = currentLanguage + const requestSeq = ++languageRequestSeq.current + dispatch(setUserLanguage(newLanguage)) + dispatch(setSettingsLanguage(newLanguage)) + dispatch(updateUserConfigurationsAsync({ language: newLanguage })) + .unwrap() + .catch(() => { + if (requestSeq !== languageRequestSeq.current) { + return + } + dispatch(setUserLanguage(previousLanguage)) + dispatch(setSettingsLanguage(previousLanguage)) + onLanguageError() + }) + } + + return { currentLanguage, handleLanguageChange } +} diff --git a/src/locales/en.json b/src/locales/en.json index e340321..5d65d52 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -129,6 +129,15 @@ "saturday": "SA", "sunday": "SU" }, + "fullDays": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, "end": { "label": "How many times to repeat", "never": "Always", diff --git a/src/locales/fr.json b/src/locales/fr.json index 7f82933..3eaeffe 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -130,6 +130,15 @@ "saturday": "SA", "sunday": "DI" }, + "fullDays": { + "monday": "Lundi", + "tuesday": "Mardi", + "wednesday": "Mercredi", + "thursday": "Jeudi", + "friday": "Vendredi", + "saturday": "Samedi", + "sunday": "Dimanche" + }, "end": { "label": "Fin :", "never": "Jamais", diff --git a/src/locales/ru.json b/src/locales/ru.json index fa19b12..6426461 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -130,6 +130,15 @@ "saturday": "СБ", "sunday": "ВС" }, + "fullDays": { + "monday": "Понедельник", + "tuesday": "Вторник", + "wednesday": "Среда", + "thursday": "Четверг", + "friday": "Пятница", + "saturday": "Суббота", + "sunday": "Воскресенье" + }, "end": { "label": "Сколько раз повторить", "never": "Всегда", diff --git a/src/locales/vi.json b/src/locales/vi.json index 9c27aef..8389121 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -128,6 +128,15 @@ "saturday": "Bảy", "sunday": "CN" }, + "fullDays": { + "monday": "Thứ hai", + "tuesday": "Thứ ba", + "wednesday": "Thứ tư", + "thursday": "Thứ năm", + "friday": "Thứ sáu", + "saturday": "Thứ bảy", + "sunday": "Chủ nhật" + }, "end": { "label": "Lặp lại bao nhiêu lần", "never": "Luôn luôn",