From e29cd8dc42487b81caba70bcf565e63184e836e4 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 8 Apr 2026 18:02:08 +0700 Subject: [PATCH] #683 quick search on mobile (#749) Co-authored-by: lethemanh --- __test__/components/PeopleSearch.test.tsx | 3 +- src/components/Attendees/AttendeeChip.tsx | 81 +++++++ .../Attendees/AttendeeOptionsList.tsx | 57 +++++ src/components/Attendees/AttendeeSearch.tsx | 4 +- src/components/Attendees/PeopleSearch.tsx | 208 ++++++++++-------- src/components/Attendees/types.ts | 8 + src/components/Attendees/usePasteHandler.ts | 2 +- src/components/Attendees/useUserSearch.ts | 65 ++++-- src/components/Calendar/Calendar.tsx | 20 +- .../Calendar/CalendarAccessRights.tsx | 3 +- src/components/Calendar/CalendarSearch.tsx | 3 +- .../Calendar/MobileTempSearchInput.tsx | 60 +++++ .../Calendar/Sidebar/MobileSidebar.tsx | 9 +- src/components/Calendar/Sidebar/SideBar.tsx | 4 +- .../Calendar/Sidebar/SidebarCommonContent.tsx | 24 +- .../Calendar/TempCalendarsInput.tsx | 156 ++++--------- src/components/Calendar/TempSearchDialog.tsx | 129 +++++++++++ .../Calendar/handlers/eventHandlers.ts | 2 +- .../Calendar/hooks/useTempSearch.ts | 100 +++++++++ .../Calendar/utils/tempSearchUtil.ts | 58 +++++ src/components/Menubar/EventSearchBar.tsx | 3 +- src/components/Menubar/MobileMenuBar.tsx | 5 +- src/features/Calendars/CalendarSlice.ts | 10 +- .../Calendars/api/addCalendarResourceAsync.ts | 2 +- .../services/getTempCalendarsListAsync.ts | 2 +- src/features/Calendars/types/CalendarData.ts | 2 +- src/features/User/userAPI.ts | 2 +- src/locales/en.json | 4 +- src/locales/fr.json | 4 +- src/locales/ru.json | 4 +- src/locales/vi.json | 4 +- 31 files changed, 773 insertions(+), 265 deletions(-) create mode 100644 src/components/Attendees/AttendeeChip.tsx create mode 100644 src/components/Attendees/AttendeeOptionsList.tsx create mode 100644 src/components/Attendees/types.ts create mode 100644 src/components/Calendar/MobileTempSearchInput.tsx create mode 100644 src/components/Calendar/TempSearchDialog.tsx create mode 100644 src/components/Calendar/hooks/useTempSearch.ts create mode 100644 src/components/Calendar/utils/tempSearchUtil.ts diff --git a/__test__/components/PeopleSearch.test.tsx b/__test__/components/PeopleSearch.test.tsx index dd412ae..b8eb566 100644 --- a/__test__/components/PeopleSearch.test.tsx +++ b/__test__/components/PeopleSearch.test.tsx @@ -1,4 +1,5 @@ -import { PeopleSearch, User } from '@/components/Attendees/PeopleSearch' +import { PeopleSearch } from '@/components/Attendees/PeopleSearch' +import { User } from '@/components/Attendees/types' import { searchUsers } from '@/features/User/userAPI' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' diff --git a/src/components/Attendees/AttendeeChip.tsx b/src/components/Attendees/AttendeeChip.tsx new file mode 100644 index 0000000..ca66975 --- /dev/null +++ b/src/components/Attendees/AttendeeChip.tsx @@ -0,0 +1,81 @@ +import { Chip, useTheme, Icon, IconButton } from '@linagora/twake-mui' +import { User } from './types' +import { getAccessiblePair } from '@/utils/getAccessiblePair' +import { ReactElement } from 'react' +import CloseIcon from '@mui/icons-material/Close' +import CircleIcon from '@mui/icons-material/Circle' + +export interface AttendeeChipProps { + option: string | User + getTagProps: (args: { index: number }) => { + key: number + className: string + disabled: boolean + 'data-item-index': number + tabIndex: -1 + onDelete: (event: unknown) => void + } + getChipIcon?: (user: User) => ReactElement + index: number +} + +export const AttendeeChip: React.FC = ({ + option, + getTagProps, + getChipIcon, + index +}) => { + const theme = useTheme() + + const isString = typeof option === 'string' + const label = isString ? option : option.displayName || option.email + const chipColor = isString + ? theme.palette.grey[200] + : (option.color?.light ?? theme.palette.grey[200]) + const textColor = getAccessiblePair(chipColor, theme) + + const renderIcon = (): ReactElement | undefined => { + if (!isString && getChipIcon) { + return getChipIcon(option) + } + + if (chipColor) { + return ( + + + + ) + } + } + + const renderDeleteIcon = (): ReactElement => { + return ( + + + + ) + } + + return ( + + ) +} diff --git a/src/components/Attendees/AttendeeOptionsList.tsx b/src/components/Attendees/AttendeeOptionsList.tsx new file mode 100644 index 0000000..3db1614 --- /dev/null +++ b/src/components/Attendees/AttendeeOptionsList.tsx @@ -0,0 +1,57 @@ +import { + Avatar, + ListItem, + ListItemAvatar, + ListItemText +} from '@linagora/twake-mui' +import React, { HTMLAttributes } from 'react' +import { stringAvatar } from '@/components/Event/utils/eventUtils' +import { ResourceIcon } from './ResourceIcon' +import { User } from './types' + +export interface AttendeeOptionsListProps extends HTMLAttributes { + options: User[] + onOptionClick?: (user: User) => void + selectedUsers: User[] +} + +export const AttendeeOptionsList: React.FC = ({ + options, + onOptionClick, + selectedUsers, + ...props +}) => { + return ( + <> + {options.map(option => { + if (selectedUsers.find(u => u.email === option.email)) return null + const isResource = option.objectType === 'resource' + return ( + onOptionClick?.(option)} + disableGutters + sx={{ cursor: 'pointer', py: 1 }} + {...props} + > + + {isResource ? ( + + ) : ( + + )} + + + + ) + })} + + ) +} diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 9f7c18d..9a5f5ee 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -4,9 +4,9 @@ import { useEffect, useRef, useState } from 'react' import { FreeBusyIndicator } from './FreeBusyIndicator' import { ExtendedAutocompleteRenderInputParams, - PeopleSearch, - User + PeopleSearch } from './PeopleSearch' +import { User } from './types' import { FreeBusyMap, useAttendeesFreeBusy } from './useFreeBusy' const attendeeToUser = (a: userAttendee, openpaasId = ''): User => ({ diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx index 31ebc1d..3b9fe71 100644 --- a/src/components/Attendees/PeopleSearch.tsx +++ b/src/components/Attendees/PeopleSearch.tsx @@ -1,42 +1,32 @@ -import { getAccessiblePair } from '@/utils/getAccessiblePair' -import { stringAvatar } from '@/components/Event/utils/eventUtils' import { useUserSearch } from './useUserSearch' import { SnackbarAlert } from '@/components/Loading/SnackBarAlert' -import CloseIcon from '@mui/icons-material/Close' import { Autocomplete, - Avatar, - Chip, CircularProgress, - ListItem, - ListItemAvatar, - ListItemText, PaperProps, PopperProps, TextField, - useTheme, - type AutocompleteRenderInputParams + type AutocompleteRenderInputParams, + Box } from '@linagora/twake-mui' +import { AttendeeOptionsList } from './AttendeeOptionsList' import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined' import { HTMLAttributes, + ReactElement, useCallback, + useEffect, + useMemo, + useRef, type ReactNode, type SyntheticEvent } from 'react' import { useI18n } from 'twake-i18n' -import { ResourceIcon } from './ResourceIcon' import { isValidEmail } from '../../utils/isValidEmail' import { usePasteHandler } from './usePasteHandler' - -export interface User { - email: string - displayName: string - avatarUrl?: string - openpaasId?: string - color?: Record - objectType?: string -} +import { User } from './types' +import { AttendeeChip } from './AttendeeChip' +import { SearchState } from '../Calendar/utils/tempSearchUtil' export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams { error?: boolean @@ -46,19 +36,7 @@ export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRende onKeyDown?: (e: React.KeyboardEvent) => void } -export function PeopleSearch({ - selectedUsers, - onChange, - objectTypes, - disabled, - freeSolo, - onToggleEventPreview, - placeholder, - inputSlot, - customRenderInput, - customSlotProps, - getChipIcon -}: { +export interface PeopleSearchProps { selectedUsers: User[] onChange: (event: SyntheticEvent, users: User[]) => void objectTypes: string[] @@ -66,7 +44,7 @@ export function PeopleSearch({ freeSolo?: boolean onToggleEventPreview?: () => void placeholder?: string - inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode + inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => ReactNode customRenderInput?: ( params: AutocompleteRenderInputParams, query: string, @@ -77,12 +55,34 @@ export function PeopleSearch({ paper?: Partial listbox?: Partial> } - getChipIcon?: (user: User) => ReactNode -}) { + getChipIcon?: (user: User) => ReactElement + hideOptions?: boolean + onSearchStateChange?: (state: SearchState) => void + inputValue?: string +} + +export const PeopleSearch: React.FC = ({ + selectedUsers, + onChange, + objectTypes, + disabled, + freeSolo, + onToggleEventPreview, + placeholder, + inputSlot, + customRenderInput, + customSlotProps, + getChipIcon, + hideOptions, + onSearchStateChange, + inputValue +}) => { const { t } = useI18n() const searchPlaceholder = placeholder ?? t('peopleSearch.placeholder') const errorMessage = t('peopleSearch.searchError') + const lastQueryTimeRef = useRef(0) + const { query, setQuery, @@ -96,10 +96,28 @@ export function PeopleSearch({ snackbarOpen, setSnackbarOpen, snackbarMessage, - setSnackbarMessage + setSnackbarMessage, + queryTime } = useUserSearch({ objectTypes, errorMessage }) - const theme = useTheme() + const onSearchStateChangeRef = useRef(onSearchStateChange) + useEffect(() => { + onSearchStateChangeRef.current = onSearchStateChange + }, [onSearchStateChange]) + + useEffect(() => { + if (!hideOptions) return + if (queryTime !== lastQueryTimeRef.current) { + lastQueryTimeRef.current = queryTime + onSearchStateChangeRef.current?.({ query, options, loading }) + } + }, [queryTime, query, options, loading, hideOptions]) + + useEffect(() => { + if (inputValue !== undefined) { + setQuery(inputValue) + } + }, [inputValue, setQuery]) const handleBlurCommit = useCallback( (event: React.SyntheticEvent) => { @@ -162,7 +180,9 @@ export function PeopleSearch({ } } - const handleEnterKey = (e: React.KeyboardEvent) => { + const handleEnterKey = ( + e: React.KeyboardEvent + ): void => { if (e.key === 'Enter' && onToggleEventPreview) { e.preventDefault() onToggleEventPreview() @@ -210,6 +230,11 @@ export function PeopleSearch({ {...defaultTextFieldProps} InputProps={inputProps} size="medium" + sx={{ + '& .MuiInputBase-input': { + maxWidth: '90%' + } + }} /> ) @@ -225,6 +250,24 @@ export function PeopleSearch({ ] ) + const isOpenOptions = useMemo(() => { + if (hideOptions) return false + if (customRenderInput) { + return ( + isOpen && !!query && ((loading && hasSearched) || options.length > 0) + ) + } + return isOpen && !!query && (loading || hasSearched) + }, [ + customRenderInput, + hasSearched, + hideOptions, + isOpen, + loading, + options.length, + query + ]) + return ( <> 0) - : isOpen && !!query && (loading || hasSearched) - } + open={isOpenOptions} onOpen={() => setIsOpen(true)} onClose={() => setIsOpen(false)} disabled={disabled} @@ -256,8 +295,15 @@ export function PeopleSearch({ } }} sx={{ - '& .MuiAutocomplete-inputRoot': { - py: 0 + '& .MuiAutocomplete-inputRoot.MuiOutlinedInput-root': { + py: 0, + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + flexDirection: 'row' + }, + '& .MuiInputBase-input': { + maxWidth: '80%' } }} filterSelectedOptions @@ -302,58 +348,26 @@ export function PeopleSearch({ ? customRenderInput(params, query, setQuery) : defaultRenderInput(params) } - renderOption={(props, option) => { - if (selectedUsers.find(u => u.email === option.email)) return null - const { key, ...otherProps } = props - const isResource = option.objectType === 'resource' - return ( - - - {isResource ? ( - - ) : ( - - )} - - ( + + )} + renderValue={(value, getTagProps) => ( + + {value.map((option, index) => ( + - - ) - }} - renderValue={(value, getTagProps) => - value.map((option, index) => { - const isString = typeof option === 'string' - const label = isString ? option : option.displayName || option.email - const chipColor = isString - ? theme.palette.grey[200] - : (option.color?.light ?? theme.palette.grey[200]) - const textColor = getAccessiblePair(chipColor, theme) - - return ( - } - style={{ - backgroundColor: chipColor, - color: textColor - }} - label={label} - /> - ) - }) - } + ))} + + )} /> + objectType?: string +} diff --git a/src/components/Attendees/usePasteHandler.ts b/src/components/Attendees/usePasteHandler.ts index 49f57ae..6cad97e 100644 --- a/src/components/Attendees/usePasteHandler.ts +++ b/src/components/Attendees/usePasteHandler.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import type { SyntheticEvent } from 'react' import { isValidEmail } from '../../utils/isValidEmail' -import type { User } from './PeopleSearch' +import type { User } from './types' export function usePasteHandler({ freeSolo, diff --git a/src/components/Attendees/useUserSearch.ts b/src/components/Attendees/useUserSearch.ts index 2bf97d0..937f49e 100644 --- a/src/components/Attendees/useUserSearch.ts +++ b/src/components/Attendees/useUserSearch.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { searchUsers } from '@/features/User/userAPI' export interface UseUserSearchProps { @@ -9,7 +9,22 @@ export interface UseUserSearchProps { export function useUserSearch({ objectTypes, errorMessage -}: UseUserSearchProps) { +}: UseUserSearchProps): { + query: string + setQuery: (query: string) => void + loading: boolean + options: T[] + hasSearched: boolean + isOpen: boolean + setIsOpen: (isOpen: boolean) => void + inputError: string | null + setInputError: (error: string | null) => void + snackbarOpen: boolean + setSnackbarOpen: (open: boolean) => void + snackbarMessage: string + setSnackbarMessage: (message: string) => void + queryTime: number +} { const [query, setQuery] = useState('') const [loading, setLoading] = useState(false) const [options, setOptions] = useState([]) @@ -20,10 +35,18 @@ export function useUserSearch({ const [snackbarOpen, setSnackbarOpen] = useState(false) const [snackbarMessage, setSnackbarMessage] = useState('') + const [queryTime, setQueryTime] = useState(0) + + const objectTypesRef = useRef(objectTypes) + + useEffect(() => { + objectTypesRef.current = objectTypes + }, [objectTypes]) + useEffect(() => { let cancelled = false - const delayDebounceFn = setTimeout(async () => { + const handleQueryChange = async (): Promise => { if (!query.trim()) { if (!cancelled) { setOptions([]) @@ -33,35 +56,36 @@ export function useUserSearch({ return } - if (!cancelled) { - setLoading(true) - setHasSearched(false) - } + if (cancelled) return + + setLoading(true) + setHasSearched(false) try { - const res = await searchUsers(query, objectTypes) - if (!cancelled) { - setOptions(res as unknown as T[]) - setHasSearched(true) - } + const res = await searchUsers(query, objectTypesRef.current) + setOptions(res as unknown as T[]) + setHasSearched(true) } catch { - if (!cancelled) { - setHasSearched(false) - setSnackbarMessage(errorMessage) - setSnackbarOpen(true) - } + setHasSearched(false) + setSnackbarMessage(errorMessage) + setSnackbarOpen(true) } finally { if (!cancelled) { setLoading(false) + setQueryTime(Date.now()) } } + } + + const delayDebounceFn = setTimeout(() => { + void handleQueryChange() }, 300) - return () => { + return (): void => { cancelled = true clearTimeout(delayDebounceFn) } - }, [objectTypes, query, errorMessage]) + }, [objectTypesRef, query, errorMessage]) return { query, @@ -76,6 +100,7 @@ export function useUserSearch({ snackbarOpen, setSnackbarOpen, snackbarMessage, - setSnackbarMessage + setSnackbarMessage, + queryTime } } diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 7cd8e0e..98e8cc8 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -27,7 +27,7 @@ import moment from 'moment-timezone' import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react' import { useI18n } from 'twake-i18n' import { useCalendarDataLoader } from '../../features/Calendars/useCalendarLoader' -import { User } from '../Attendees/PeopleSearch' +import { User } from '../Attendees/types' import { EventErrorSnackbar } from '../Error/ErrorSnackbar' import { EventErrorHandler } from '../Error/EventErrorHandler' import { EditModeDialog } from '../Event/EditModeDialog' @@ -36,7 +36,6 @@ import './Calendar.styl' import './CustomCalendar.styl' import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers' import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers' -import Sidebar from './Sidebar/SideBar' import { TimezoneSelector } from './TimezoneSelector' import { eventToFullCalendarFormat, @@ -44,6 +43,9 @@ import { updateSlotLabelVisibility } from './utils/calendarUtils' import { CALENDAR_VIEWS } from './utils/constants' +import Sidebar from './Sidebar/SideBar' +import TempSearchDialog from './TempSearchDialog' +import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice' const localeMap: Record = { fr: frLocale, @@ -609,6 +611,20 @@ const CalendarApp: React.FC = ({ )} + + {isMobile && ( + { + dispatch(setIsMobileSearchOpen(false)) + onCloseSidebar() + }} + handleToggleEventPreview={() => + eventHandlers.handleDateSelect(null as unknown as DateSelectArg) + } + /> + )} ) } diff --git a/src/components/Calendar/CalendarAccessRights.tsx b/src/components/Calendar/CalendarAccessRights.tsx index 54e73c9..1c45564 100644 --- a/src/components/Calendar/CalendarAccessRights.tsx +++ b/src/components/Calendar/CalendarAccessRights.tsx @@ -19,7 +19,8 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff' import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined' import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from 'twake-i18n' -import { PeopleSearch, User } from '../Attendees/PeopleSearch' +import { PeopleSearch } from '../Attendees/PeopleSearch' +import { User } from '../Attendees/types' import { FieldWithLabel } from '../Event/components/FieldWithLabel' import { stringAvatar } from '../Event/utils/eventUtils' import { ResourceAdmin } from './ResourceAdmins' diff --git a/src/components/Calendar/CalendarSearch.tsx b/src/components/Calendar/CalendarSearch.tsx index a4dea5d..0521b89 100644 --- a/src/components/Calendar/CalendarSearch.tsx +++ b/src/components/Calendar/CalendarSearch.tsx @@ -16,7 +16,8 @@ import { import CloseIcon from '@mui/icons-material/Close' import { useState } from 'react' import { useI18n } from 'twake-i18n' -import { PeopleSearch, User } from '../Attendees/PeopleSearch' +import { PeopleSearch } from '../Attendees/PeopleSearch' +import { User } from '../Attendees/types' import { ResponsiveDialog } from '../Dialog' import { stringAvatar } from '../Event/utils/eventUtils' import { ColorPicker } from './CalendarColorPicker' diff --git a/src/components/Calendar/MobileTempSearchInput.tsx b/src/components/Calendar/MobileTempSearchInput.tsx new file mode 100644 index 0000000..1fbde28 --- /dev/null +++ b/src/components/Calendar/MobileTempSearchInput.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { TextField } from '@linagora/twake-mui' +import { PeopleSearch } from '../Attendees/PeopleSearch' +import { User } from '../Attendees/types' +import { useI18n } from 'twake-i18n' +import { SearchState } from './utils/tempSearchUtil' + +interface MobileTempSearchInputProps { + tempUsers: User[] + searchState: SearchState + handleToggleEventPreview: () => void + handleChange: (event: React.SyntheticEvent, users: User[]) => void + handleSearchChange: ({ query, options, loading }: SearchState) => void +} + +export const MobileTempSearchInput: React.FC = ({ + tempUsers, + searchState, + handleToggleEventPreview, + handleChange, + handleSearchChange +}) => { + const { t } = useI18n() + + return ( + ( + 0 + ? { + '& .MuiOutlinedInput-root': { + flexDirection: 'column', + alignItems: 'start', + '& .MuiInputBase-input': { + width: '100%' + } + } + } + : undefined) + }} + /> + )} + /> + ) +} diff --git a/src/components/Calendar/Sidebar/MobileSidebar.tsx b/src/components/Calendar/Sidebar/MobileSidebar.tsx index 3f41e84..fe89b86 100644 --- a/src/components/Calendar/Sidebar/MobileSidebar.tsx +++ b/src/components/Calendar/Sidebar/MobileSidebar.tsx @@ -7,8 +7,9 @@ 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 { useAppDispatch, useAppSelector } from '@/app/hooks' import { useUtilMenus } from '../hooks/useUtilMenus' +import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice' export const MobileSidebar: React.FC = ({ open, @@ -26,6 +27,7 @@ export const MobileSidebar: React.FC = ({ }) => { const { t } = useI18n() const user = useAppSelector(state => state.user.userData) + const dispatch = useAppDispatch() const { anchorEl, @@ -39,6 +41,10 @@ export const MobileSidebar: React.FC = ({ handleLogoutClick } = useUtilMenus() + const openSearch = (): void => { + dispatch(setIsMobileSearchOpen(true)) + } + return ( = ({ setTempUsers={setTempUsers} selectedCalendars={selectedCalendars} setSelectedCalendars={setSelectedCalendars} + openSearchOnMobile={openSearch} /> ) diff --git a/src/components/Calendar/Sidebar/SideBar.tsx b/src/components/Calendar/Sidebar/SideBar.tsx index 9e00cf4..3c1fdf2 100644 --- a/src/components/Calendar/Sidebar/SideBar.tsx +++ b/src/components/Calendar/Sidebar/SideBar.tsx @@ -1,7 +1,7 @@ import { useScreenSizeDetection } from '@/useScreenSizeDetection' import { CalendarApi } from '@fullcalendar/core' import { Dispatch, MutableRefObject, SetStateAction } from 'react' -import { User } from '../../Attendees/PeopleSearch' +import { User } from '../../Attendees/types' import { DesktopSidebar } from './DesktopSidebar' import { TabletSidebar } from './TabletSidebar' import { MobileSidebar } from './MobileSidebar' @@ -32,7 +32,7 @@ const Sidebar: React.FC = ( return } - return isTablet || isMobile ? ( + return isTablet ? ( ) : ( diff --git a/src/components/Calendar/Sidebar/SidebarCommonContent.tsx b/src/components/Calendar/Sidebar/SidebarCommonContent.tsx index 94d0f99..f9f0a74 100644 --- a/src/components/Calendar/Sidebar/SidebarCommonContent.tsx +++ b/src/components/Calendar/Sidebar/SidebarCommonContent.tsx @@ -3,20 +3,23 @@ import CalendarSelection from '../CalendarSelection' import { TempCalendarsInput } from '../TempCalendarsInput' import { CalendarSidebarProps } from './SideBar' -export function SidebarCommonContent({ +export const SidebarCommonContent: React.FC< + Pick< + CalendarSidebarProps, + | 'onCreateEvent' + | 'tempUsers' + | 'setTempUsers' + | 'selectedCalendars' + | 'setSelectedCalendars' + > & { openSearchOnMobile?: () => void } +> = ({ onCreateEvent, tempUsers, setTempUsers, selectedCalendars, - setSelectedCalendars -}: Pick< - CalendarSidebarProps, - | 'onCreateEvent' - | 'tempUsers' - | 'setTempUsers' - | 'selectedCalendars' - | 'setSelectedCalendars' ->) { + setSelectedCalendars, + openSearchOnMobile +}) => { return ( <> @@ -24,6 +27,7 @@ export function SidebarCommonContent({ tempUsers={tempUsers} setTempUsers={setTempUsers} handleToggleEventPreview={onCreateEvent} + onOpenSearchOnMobile={openSearchOnMobile} /> diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx index 6b5e0f2..96eaa41 100644 --- a/src/components/Calendar/TempCalendarsInput.tsx +++ b/src/components/Calendar/TempCalendarsInput.tsx @@ -1,78 +1,39 @@ -import { useAppDispatch, useAppSelector } from '@/app/hooks' -import { removeTempCal } from '@/features/Calendars/CalendarSlice' -import { Calendar } from '@/features/Calendars/CalendarTypes' -import { getTempCalendarsListAsync } from '@/features/Calendars/services' -import { setView } from '@/features/Settings/SettingsSlice' -import { defaultColors } from '@/utils/defaultColors' -import { TextField } from '@linagora/twake-mui' -import { useRef } from 'react' +import { + TextField, + useTheme, + useMediaQuery, + InputAdornment +} from '@linagora/twake-mui' +import SearchIcon from '@mui/icons-material/Search' +import React from 'react' import { useI18n } from 'twake-i18n' -import { PeopleSearch, User } from '../Attendees/PeopleSearch' +import { PeopleSearch } from '../Attendees/PeopleSearch' +import { User } from '../Attendees/types' +import { useTempSearch } from './hooks/useTempSearch' -const requestControllers = new Map() - -export function TempCalendarsInput({ - tempUsers, - setTempUsers, - handleToggleEventPreview -}: { +export const TempCalendarsInput: React.FC<{ tempUsers: User[] setTempUsers: (users: User[]) => void handleToggleEventPreview: () => void -}) { - const dispatch = useAppDispatch() - const tempcalendars = useAppSelector(state => state.calendars.templist) ?? {} + onOpenSearchOnMobile?: () => void +}> = ({ + tempUsers, + setTempUsers, + handleToggleEventPreview, + onOpenSearchOnMobile +}) => { + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('sm')) const { t } = useI18n() - const prevUsersRef = useRef([]) - const userColorsRef = useRef( - new Map() - ) + const { handleUserChange } = useTempSearch({ setTempUsers, tempUsers }) - const handleUserChange = (_: React.SyntheticEvent, users: User[]) => { - setTempUsers(users) - - const prevUsers = prevUsersRef.current - - const addedUsers = users.filter( - u => !prevUsers.some(p => p.email === u.email) - ) - const removedUsers = prevUsers.filter( - p => !users.some(u => u.email === p.email) - ) - - prevUsersRef.current = users - - if (addedUsers.length > 0) { - dispatch(setView('calendar')) - for (const user of addedUsers) { - const controller = new AbortController() - requestControllers.set(user.email, controller) - - if (!userColorsRef.current.has(user.email)) { - const usedLights = Array.from(userColorsRef.current.values()).map( - c => c.light - ) - const colorPair = generateDistinctColor(usedLights) - userColorsRef.current.set(user.email, colorPair) - } - - user.color = userColorsRef.current.get(user.email) ?? defaultColors[0] - dispatch(getTempCalendarsListAsync(user, { signal: controller.signal })) - } - } - - for (const user of removedUsers) { - const controller = requestControllers.get(user.email) - if (controller) { - controller.abort() - requestControllers.delete(user.email) - } - - const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email) - calIds?.forEach(id => dispatch(removeTempCal(id))) - userColorsRef.current.delete(user.email) - } + const handleInputFocus = ( + e: React.FocusEvent + ): void | undefined => { + if (!isMobile || !onOpenSearchOnMobile) return + onOpenSearchOnMobile() + e.currentTarget.blur() } return ( @@ -86,6 +47,20 @@ export function TempCalendarsInput({ + {params.InputProps.endAdornment} + {isMobile && ( + + + + )} + + ) + }} sx={ tempUsers.length > 0 ? { @@ -104,50 +79,3 @@ export function TempCalendarsInput({ /> ) } - -function buildEmailToCalendarMap(calRecord: Record) { - const map = new Map() - for (const [id, cal] of Object.entries(calRecord)) { - cal.owner?.emails?.forEach(email => { - const existing = map.get(email) - if (existing) { - existing.push(id) - } else { - map.set(email, [id]) - } - }) - } - return map -} - -function shiftLightness(hex: string, amount: number): string { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - - const clamp = (v: number) => Math.max(0, Math.min(255, v)) - const toHex = (v: number) => - clamp(v + amount) - .toString(16) - .padStart(2, '0') - - return `#${toHex(r)}${toHex(g)}${toHex(b)}` -} - -function generateDistinctColor(usedLights: string[]): { - light: string - dark: string -} { - for (const color of defaultColors) { - if (!usedLights.includes(color.light)) return color - } - - const cycle = usedLights.length % defaultColors.length - const round = Math.floor(usedLights.length / defaultColors.length) - const base = defaultColors[cycle] - - return { - light: shiftLightness(base.light, round * 12), - dark: shiftLightness(base.dark, -(round * 10)) - } -} diff --git a/src/components/Calendar/TempSearchDialog.tsx b/src/components/Calendar/TempSearchDialog.tsx new file mode 100644 index 0000000..552b2a9 --- /dev/null +++ b/src/components/Calendar/TempSearchDialog.tsx @@ -0,0 +1,129 @@ +import { useAppSelector } from '@/app/hooks' +import React, { useCallback, useEffect, useState } from 'react' +import { + IconButton, + Dialog, + DialogTitle, + DialogContent, + useTheme, + alpha, + DialogActions, + Button +} from '@linagora/twake-mui' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import { User } from '../Attendees/types' +import { useI18n } from 'twake-i18n' +import { useTempSearch } from './hooks/useTempSearch' +import { AttendeeOptionsList } from '../Attendees/AttendeeOptionsList' +import { MobileTempSearchInput } from './MobileTempSearchInput' +import { SearchState } from './utils/tempSearchUtil' + +interface TempSearchDialogProps { + tempUsers: User[] + setTempUsers: (users: User[]) => void + handleToggleEventPreview: () => void + onClose: () => void +} + +export default function TempSearchDialog({ + tempUsers, + setTempUsers, + handleToggleEventPreview, + onClose +}: TempSearchDialogProps): JSX.Element { + const { t } = useI18n() + const theme = useTheme() + const isMobileSearchOpen = useAppSelector( + state => state.calendars.isMobileSearchOpen + ) + const [searchState, setSearchState] = useState({ + query: '', + options: [] as User[], + loading: false + }) + + useEffect(() => { + const resetSearchState = (): void => { + setSearchState({ query: '', options: [], loading: false }) + } + if (isMobileSearchOpen) { + resetSearchState() + } + }, [isMobileSearchOpen]) + + const { handleUserChange } = useTempSearch({ setTempUsers, tempUsers }) + + const handleSearchChange = useCallback( + ({ query, options, loading }: SearchState): void => { + setSearchState(prev => ({ + query: query ?? prev.query, + options: options ?? prev.options, + loading: loading ?? prev.loading + })) + }, + [] + ) + + const handleChange = (event: React.SyntheticEvent, users: User[]): void => { + handleUserChange(event, users) + setSearchState(prev => ({ ...prev, query: '' })) + } + + return ( + + + + + + + + + + + handleChange({} as React.SyntheticEvent, [...tempUsers, user]) + } + selectedUsers={tempUsers} + /> + + + {tempUsers.length > 0 && ( + + + + )} + + ) +} diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index 1dec19f..d442b4d 100644 --- a/src/components/Calendar/handlers/eventHandlers.ts +++ b/src/components/Calendar/handlers/eventHandlers.ts @@ -1,5 +1,5 @@ import { AppDispatch } from '@/app/store' -import { User } from '@/components/Attendees/PeopleSearch' +import { User } from '@/components/Attendees/types' import { formatLocalDateTime } from '@/components/Event/utils/dateTimeFormatters' import { Calendar } from '@/features/Calendars/CalendarTypes' import { diff --git a/src/components/Calendar/hooks/useTempSearch.ts b/src/components/Calendar/hooks/useTempSearch.ts new file mode 100644 index 0000000..c314109 --- /dev/null +++ b/src/components/Calendar/hooks/useTempSearch.ts @@ -0,0 +1,100 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { removeTempCal } from '@/features/Calendars/CalendarSlice' +import { getTempCalendarsListAsync } from '@/features/Calendars/services' +import { setView } from '@/features/Settings/SettingsSlice' +import { defaultColors } from '@/utils/defaultColors' +import { useEffect, useRef } from 'react' +import { + buildEmailToCalendarMap, + generateDistinctColor +} from '../utils/tempSearchUtil' +import { User } from '../../Attendees/types' + +const requestControllers = new Map() + +export const useTempSearch = ({ + setTempUsers, + tempUsers +}: { + setTempUsers: (users: User[]) => void + tempUsers?: User[] +}): { + handleUserChange: (event: React.SyntheticEvent, users: User[]) => void +} => { + const dispatch = useAppDispatch() + const tempcalendars = useAppSelector(state => state.calendars.templist) ?? {} + + const prevUsersRef = useRef([]) + const userColorsRef = useRef( + new Map() + ) + + useEffect(() => { + prevUsersRef.current = tempUsers || [] + + for (const user of tempUsers || []) { + if (!userColorsRef.current.has(user.email)) { + const usedLights = Array.from(userColorsRef.current.values()).map( + c => c.light + ) + const colorPair = generateDistinctColor(usedLights) + userColorsRef.current.set(user.email, colorPair) + } + } + }, [tempUsers]) + + const addTempEvent = (user: User): void => { + const controller = new AbortController() + requestControllers.set(user.email, controller) + + if (!userColorsRef.current.has(user.email)) { + const usedLights = Array.from(userColorsRef.current.values()).map( + c => c.light + ) + const colorPair = generateDistinctColor(usedLights) + userColorsRef.current.set(user.email, colorPair) + } + + user.color = userColorsRef.current.get(user.email) ?? defaultColors[0] + void dispatch( + getTempCalendarsListAsync(user, { signal: controller.signal }) + ) + } + + const removeTempEvent = (user: User): void => { + const controller = requestControllers.get(user.email) + if (controller) { + controller.abort() + requestControllers.delete(user.email) + } + + const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email) + calIds?.forEach(id => dispatch(removeTempCal(id))) + userColorsRef.current.delete(user.email) + } + + const handleUserChange = (_: React.SyntheticEvent, users: User[]): void => { + setTempUsers(users) + + const prevUsers = prevUsersRef.current + + const addedUsers = + users.filter(u => !prevUsers.some(p => p.email === u.email)) ?? [] + const removedUsers = + prevUsers.filter(p => !users.some(u => u.email === p.email)) ?? [] + + prevUsersRef.current = users + + dispatch(setView('calendar')) + + for (const user of addedUsers) { + addTempEvent(user) + } + + for (const user of removedUsers) { + removeTempEvent(user) + } + } + + return { handleUserChange } +} diff --git a/src/components/Calendar/utils/tempSearchUtil.ts b/src/components/Calendar/utils/tempSearchUtil.ts new file mode 100644 index 0000000..faa523b --- /dev/null +++ b/src/components/Calendar/utils/tempSearchUtil.ts @@ -0,0 +1,58 @@ +import { User } from '@/components/Attendees/types' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { defaultColors } from '@/utils/defaultColors' + +export interface SearchState { + query?: string + options?: User[] + loading?: boolean +} + +export function buildEmailToCalendarMap( + calRecord: Record +): Map { + const map = new Map() + for (const [id, cal] of Object.entries(calRecord)) { + cal.owner?.emails?.forEach(email => { + const existing = map.get(email) + if (existing) { + existing.push(id) + } else { + map.set(email, [id]) + } + }) + } + return map +} + +function shiftLightness(hex: string, amount: number): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + + const clamp = (v: number): number => Math.max(0, Math.min(255, v)) + const toHex = (v: number): string => + clamp(v + amount) + .toString(16) + .padStart(2, '0') + + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +export function generateDistinctColor(usedLights: string[]): { + light: string + dark: string +} { + for (const color of defaultColors) { + if (!usedLights.includes(color.light)) return color + } + + const cycle = usedLights.length % defaultColors.length + const round = Math.floor(usedLights.length / defaultColors.length) + const base = defaultColors[cycle] + + return { + light: shiftLightness(base.light, round * 12), + dark: shiftLightness(base.dark, -(round * 10)) + } +} diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx index d0f48ff..c223859 100644 --- a/src/components/Menubar/EventSearchBar.tsx +++ b/src/components/Menubar/EventSearchBar.tsx @@ -29,7 +29,8 @@ import TuneIcon from '@mui/icons-material/Tune' import { useEffect, useRef, useState } from 'react' import { useI18n } from 'twake-i18n' import UserSearch from '../Attendees/AttendeeSearch' -import { PeopleSearch, User } from '../Attendees/PeopleSearch' +import { PeopleSearch } from '../Attendees/PeopleSearch' +import { User } from '../Attendees/types' import { CalendarItemList } from '../Calendar/CalendarItemList' const SEARCH_OBJECT_TYPES = ['user', 'contact'] diff --git a/src/components/Menubar/MobileMenuBar.tsx b/src/components/Menubar/MobileMenuBar.tsx index 3f5ecbc..59e83b0 100644 --- a/src/components/Menubar/MobileMenuBar.tsx +++ b/src/components/Menubar/MobileMenuBar.tsx @@ -40,7 +40,10 @@ export const MobileMenubar: React.FC = ({ <>
- +
diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index f300344..d230fae 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -30,12 +30,14 @@ const CalendarSlice = createSlice({ list: {} as Record, templist: {} as Record, pending: true, - error: null as string | null + error: null as string | null, + isMobileSearchOpen: false } as { list: Record templist: Record pending: boolean error: string | null + isMobileSearchOpen: boolean }, reducers: { createCalendar: ( @@ -109,6 +111,9 @@ const CalendarSlice = createSlice({ }> ) => { state.list[action.payload.id].color = action.payload.color + }, + setIsMobileSearchOpen: (state, action: PayloadAction) => { + state.isMobileSearchOpen = action.payload } }, extraReducers: builder => { @@ -554,6 +559,7 @@ export const { emptyEventsCal, clearFetchCache, clearError, - updateCalColor + updateCalColor, + setIsMobileSearchOpen } = CalendarSlice.actions export default CalendarSlice.reducer diff --git a/src/features/Calendars/api/addCalendarResourceAsync.ts b/src/features/Calendars/api/addCalendarResourceAsync.ts index 1c486c3..36d068f 100644 --- a/src/features/Calendars/api/addCalendarResourceAsync.ts +++ b/src/features/Calendars/api/addCalendarResourceAsync.ts @@ -4,7 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit' import { addSharedCalendar } from '../CalendarApi' import { CalendarInput } from '../types/CalendarData' import { RejectedError } from '../types/RejectedError' -import { User } from '@/components/Attendees/PeopleSearch' +import { User } from '@/components/Attendees/types' import { fetchOwnerOfResource } from '../services/helpers' export const addCalendarResourceAsync = createAsyncThunk< diff --git a/src/features/Calendars/services/getTempCalendarsListAsync.ts b/src/features/Calendars/services/getTempCalendarsListAsync.ts index f1733db..42c595b 100644 --- a/src/features/Calendars/services/getTempCalendarsListAsync.ts +++ b/src/features/Calendars/services/getTempCalendarsListAsync.ts @@ -1,4 +1,4 @@ -import { User } from '@/components/Attendees/PeopleSearch' +import { User } from '@/components/Attendees/types' import { getCalendarVisibility } from '@/components/Calendar/utils/calendarUtils' import { formatReduxError } from '@/utils/errorUtils' import { createAsyncThunk } from '@reduxjs/toolkit' diff --git a/src/features/Calendars/types/CalendarData.ts b/src/features/Calendars/types/CalendarData.ts index fd26444..5ad193f 100644 --- a/src/features/Calendars/types/CalendarData.ts +++ b/src/features/Calendars/types/CalendarData.ts @@ -1,4 +1,4 @@ -import { User } from '@/components/Attendees/PeopleSearch' +import { User } from '@/components/Attendees/types' import { CalDavLink } from '../api/types' // Access control entry diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index c8e192e..1a282e3 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -1,4 +1,4 @@ -import { User } from '@/components/Attendees/PeopleSearch' +import { User } from '@/components/Attendees/types' import { api } from '@/utils/apiUtils' import { BusinessHour } from '../Settings/SettingsSlice' import { OpenPaasUserData } from './type/OpenPaasUserData' diff --git a/src/locales/en.json b/src/locales/en.json index 9b92255..3b1b588 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -56,7 +56,9 @@ "select_timezone": "Select Timezone", "moreOptions": "More options", "search": "Search", - "resource": "Resource" + "resource": "Resource", + "back": "Back", + "show": "Show" }, "search": { "searchIn": "Search in", diff --git a/src/locales/fr.json b/src/locales/fr.json index c65d994..6f41182 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -56,7 +56,9 @@ "select_timezone": "Sélectionner le fuseau horaire", "moreOptions": "Plus d'options", "search": "Rechercher", - "resource": "Ressource" + "resource": "Ressource", + "back": "Retour", + "show": "Afficher" }, "search": { "searchIn": "Rechercher dans", diff --git a/src/locales/ru.json b/src/locales/ru.json index 477a533..88aa5d3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -56,7 +56,9 @@ "select_timezone": "Выберите часовой пояс", "moreOptions": "Больше параметров", "search": "Поиск", - "resource": "Ресурсы" + "resource": "Ресурсы", + "back": "Назад", + "show": "Показать" }, "search": { "searchIn": "Поиск в", diff --git a/src/locales/vi.json b/src/locales/vi.json index f76191c..a94a84e 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -56,7 +56,9 @@ "select_timezone": "Chọn múi giờ", "moreOptions": "Thêm tùy chọn", "search": "Tìm kiếm", - "resource": "Tài nguyên" + "resource": "Tài nguyên", + "back": "Quay lại", + "show": "Hiển thị" }, "search": { "searchIn": "Tìm kiếm trong",