diff --git a/__test__/features/Search/SearchSlice.test.tsx b/__test__/features/Search/SearchSlice.test.tsx index eaf9554..31370b8 100644 --- a/__test__/features/Search/SearchSlice.test.tsx +++ b/__test__/features/Search/SearchSlice.test.tsx @@ -26,7 +26,16 @@ describe('SearchSlice', () => { results: [], hits: 0, error: null, - loading: false + loading: false, + searchParams: { + filters: { + attendees: [], + keywords: '', + organizers: [], + searchIn: 'my-calendars' + }, + search: '' + } }) }) }) diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx index 8ddd0bf..a763d1c 100644 --- a/src/components/Menubar/EventSearchBar.tsx +++ b/src/components/Menubar/EventSearchBar.tsx @@ -1,6 +1,15 @@ import { useAppDispatch, useAppSelector } from '@/app/hooks' import { selectCalendars } from '@/app/selectors/selectCalendars' -import { searchEventsAsync } from '@/features/Search/SearchSlice' +import { AttendeesFilter } from '@/features/Search/AttendeesFilter' +import { KeywordsFilter } from '@/features/Search/KeywordsFilter' +import { OrganizersFilter } from '@/features/Search/OrganizersFilter' +import { SearchInFilter } from '@/features/Search/SearchInFilter' +import { + clearFilters, + searchEventsAsync, + setFilters +} from '@/features/Search/SearchSlice' +import { buildQuery } from '@/features/Search/searchUtils' import { setView } from '@/features/Settings/SettingsSlice' import { userAttendee } from '@/features/User/models/attendee' import { createAttendee } from '@/features/User/models/attendee.mapper' @@ -11,16 +20,11 @@ import { Card, CardActions, CardContent, - Divider, IconButton, InputAdornment, - InputLabel, - MenuItem, Popover, - Select, Stack, TextField, - Typography, type AutocompleteRenderInputParams } from '@linagora/twake-mui' import HighlightOffIcon from '@mui/icons-material/HighlightOff' @@ -28,10 +32,8 @@ import SearchIcon from '@mui/icons-material/Search' 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 } from '../Attendees/PeopleSearch' import { User } from '../Attendees/types' -import { CalendarItemList } from '../Calendar/CalendarItemList' const SEARCH_OBJECT_TYPES = ['user', 'contact'] @@ -45,18 +47,14 @@ const SearchBar: React.FC<{ const personnalCalendars = userId ? calendars.filter(c => extractEventBaseUuid(c.id) === userId) : [] + const filters = useAppSelector( + state => state.searchResult.searchParams.filters + ) const [search, setSearch] = useState('') const [selectedContacts, setSelectedContacts] = useState([]) const [extended, setExtended] = useState(false) - const [filters, setFilters] = useState({ - searchIn: 'my-calendars', - keywords: '', - organizers: [] as userAttendee[], - attendees: [] as userAttendee[] - }) - const [anchorEl, setAnchorEl] = useState(null) const filterOpen = Boolean(anchorEl) const [filterError, setFilterError] = useState(false) @@ -72,6 +70,7 @@ const SearchBar: React.FC<{ const inputRef = useRef(null) const containerRef = useRef(null) + const [popperAnchor, setPopperAnchor] = useState(null) const shouldCollapseRef = useRef(false) type FilterField = 'searchIn' | 'keywords' | 'organizers' | 'attendees' @@ -79,7 +78,7 @@ const SearchBar: React.FC<{ field: FilterField, value: string | userAttendee[] ): void => { - setFilters(prev => ({ ...prev, [field]: value })) + dispatch(setFilters({ ...filters, [field]: value })) if (field === 'organizers') { setSelectedContacts( (value as userAttendee[]).map((a: userAttendee) => ({ @@ -90,68 +89,8 @@ const SearchBar: React.FC<{ } } - function buildQuery( - searchQuery: string, - filters: { - searchIn: string - keywords: string - organizers: userAttendee[] - attendees: userAttendee[] - } - ): - | { - search: string - filters: { - searchIn: string[] - keywords: string - organizers: string[] - attendees: string[] - } - } - | undefined { - const trimmedSearch = searchQuery.trim() - const trimmedKeywords = filters.keywords.trim() - - // Block search if all search criteria are empty - const hasSearchCriteria = - trimmedSearch || - trimmedKeywords || - filters.organizers.length > 0 || - filters.attendees.length > 0 - - if (!hasSearchCriteria) { - return - } - - let searchInCalendars: string[] - - if (filters.searchIn === '' || !filters.searchIn) { - searchInCalendars = calendars.map(c => c.id) - } else if (filters.searchIn === 'my-calendars') { - searchInCalendars = personnalCalendars.map(c => c.id) - } else { - searchInCalendars = [filters.searchIn] - } - - const cleanedFilters = { - keywords: trimmedKeywords, - organizers: filters.organizers.map(u => u.cal_address), - attendees: filters.attendees.map(u => u.cal_address), - searchIn: searchInCalendars - } - return { - search: trimmedSearch, - filters: cleanedFilters - } - } - const handleClearFilters = (): void => { - setFilters({ - searchIn: 'my-calendars', - keywords: '', - organizers: [] as userAttendee[], - attendees: [] as userAttendee[] - }) + dispatch(clearFilters()) setAnchorEl(null) setFilterError(false) } @@ -181,7 +120,12 @@ const SearchBar: React.FC<{ attendees: userAttendee[] } ): Promise => { - const cleanedQuery = buildQuery(searchQuery, filters) + const cleanedQuery = buildQuery( + searchQuery, + filters, + calendars.map(calendar => calendar.id), + personnalCalendars.map(calendar => calendar.id) + ) if (cleanedQuery) { await dispatch(searchEventsAsync(cleanedQuery)) dispatch(setView('search')) @@ -228,7 +172,12 @@ const SearchBar: React.FC<{ return ( <> { + containerRef.current = el + if (el && !popperAnchor) { + setPopperAnchor(el) + } + }} sx={{ position: 'relative', width: extended ? '100%' : 'auto', @@ -252,7 +201,7 @@ const SearchBar: React.FC<{ onToggleEventPreview={() => {}} customSlotProps={{ popper: { - anchorEl: containerRef.current, + anchorEl: popperAnchor, placement: 'bottom-start', sx: { minWidth: searchWidth, @@ -399,131 +348,20 @@ const SearchBar: React.FC<{ - - - {t('search.searchIn')} - - - - - - - {t('search.keywords')} - - { - handleFilterChange('keywords', e.target.value) - if (e.target.value.trim()) { - setFilterError(false) - } - }} - size="small" - /> - - - - - {t('search.organizers')} - - { - handleFilterChange('organizers', users) - if (users.length > 0) { - setFilterError(false) - } - }} - /> - - - - - {t('search.participants')} - - { - handleFilterChange('attendees', users) - if (users.length > 0) { - setFilterError(false) - } - }} - /> - + + setFilterError(false)} + /> + setFilterError(false)} + /> + setFilterError(false)} + /> diff --git a/src/components/Menubar/MobileEventSearchBar.tsx b/src/components/Menubar/MobileEventSearchBar.tsx new file mode 100644 index 0000000..2125efb --- /dev/null +++ b/src/components/Menubar/MobileEventSearchBar.tsx @@ -0,0 +1,77 @@ +import { Box, type AutocompleteRenderInputParams } from '@linagora/twake-mui' +import { useState } from 'react' +import { PeopleSearch } from '../Attendees/PeopleSearch' +import { MobileSearchDialog } from './MobileSearchDialog' +import { SearchTextField } from './MobileSearchFieldText' +import { useFilterSearch } from './useMobileSearch' + +const SEARCH_OBJECT_TYPES = ['user', 'contact'] + +const MobileSearchBar: React.FC = () => { + const [dialogOpen, setDialogOpen] = useState(false) + + const { + inputQuery, + setInputQuery, + searchState, + selectedContacts, + filters, + handleSearch, + handleSearchChange, + handleContactSelect, + clearAll, + handleShow + } = useFilterSearch('organizers', setDialogOpen) + + return ( + <> + + handleContactSelect(users)} + hideOptions + inputValue={inputQuery} + onSearchStateChange={handleSearchChange} + objectTypes={SEARCH_OBJECT_TYPES} + onToggleEventPreview={() => {}} + customRenderInput={( + params: AutocompleteRenderInputParams, + query: string, + setQuery + ) => ( + void handleSearch(query, filters)} + onClear={clearAll} + /> + )} + /> + + 0} + options={searchState.options ?? []} + selectedUsers={selectedContacts} + onOptionClick={user => + handleContactSelect([ + ...selectedContacts, + { displayName: user.displayName, email: user.email } + ]) + } + /> + + ) +} + +export default MobileSearchBar diff --git a/src/components/Menubar/MobileMenuBar.tsx b/src/components/Menubar/MobileMenuBar.tsx index dfbfaef..4c14321 100644 --- a/src/components/Menubar/MobileMenuBar.tsx +++ b/src/components/Menubar/MobileMenuBar.tsx @@ -1,20 +1,22 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { clearSearch } from '@/features/Search/SearchSlice' +import { setView } from '@/features/Settings/SettingsSlice' import { CalendarApi } from '@fullcalendar/core' -import { IconButton, Stack } from '@linagora/twake-mui' +import { IconButton, Stack, useTheme } from '@linagora/twake-mui' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp' import MenuIcon from '@mui/icons-material/Menu' +import SearchIcon from '@mui/icons-material/Search' +import { Typography } from '@mui/material' import React, { useState } from 'react' import { useI18n } from 'twake-i18n' -import SearchBar from './EventSearchBar' -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' +import './Menubar.styl' +import MobileSearchBar from './MobileEventSearchBar' -export type MobileMenubarProps = { +export interface MobileMenubarProps { calendarRef: React.RefObject currentDate: Date onDateChange?: (date: Date) => void @@ -34,7 +36,10 @@ export const MobileMenubar: React.FC = ({ const view = useAppSelector(state => state.settings.view) + const theme = useTheme() + const [openDatePicker, setOpenDatePicker] = useState(false) + const [openEventSearch, setOpenEventSearch] = useState(false) // Use i18n for month names instead of date-fns const monthIndex = currentDate.getMonth() @@ -56,6 +61,25 @@ export const MobileMenubar: React.FC = ({ event.stopPropagation() event.preventDefault() dispatch(setView('calendar')) + dispatch(clearSearch()) + } + + if (openEventSearch) { + return ( +
+ { + setOpenEventSearch(false) + handleBackClick(e) + }} + aria-label={t('common.back')} + sx={{ mr: 1 }} + > + + + +
+ ) } return ( @@ -122,7 +146,17 @@ export const MobileMenubar: React.FC = ({
- + { + setOpenDatePicker(false) + dispatch(setView('search')) + setOpenEventSearch(true) + }} + aria-label={t('common.search')} + > + +
diff --git a/src/components/Menubar/MobileSearchDialog.tsx b/src/components/Menubar/MobileSearchDialog.tsx new file mode 100644 index 0000000..5530ee1 --- /dev/null +++ b/src/components/Menubar/MobileSearchDialog.tsx @@ -0,0 +1,65 @@ +import { Box, Button, Paper } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { AttendeeOptionsList } from '../Attendees/AttendeeOptionsList' +import { User } from '../Attendees/types' + +interface MobileSearchDialogProps { + open: boolean + showSearchButton: boolean + onShow: () => void + options: User[] + selectedUsers: User[] + onOptionClick: (user: User) => void +} + +export function MobileSearchDialog({ + open, + onShow, + showSearchButton, + options, + selectedUsers, + onOptionClick +}: MobileSearchDialogProps): JSX.Element { + const { t } = useI18n() + + if (!open) return <> + + return ( + theme.zIndex.appBar - 1, + borderRadius: 0, + overflowY: 'auto', + display: 'flex', + flexDirection: 'column' + }} + > + + + + {showSearchButton && ( + + + + )} + + ) +} diff --git a/src/components/Menubar/MobileSearchFieldText.tsx b/src/components/Menubar/MobileSearchFieldText.tsx new file mode 100644 index 0000000..0c52b4f --- /dev/null +++ b/src/components/Menubar/MobileSearchFieldText.tsx @@ -0,0 +1,74 @@ +import { + IconButton, + InputAdornment, + TextField, + useTheme, + type AutocompleteRenderInputParams +} from '@linagora/twake-mui' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' +import SearchIcon from '@mui/icons-material/Search' +import { Ref } from 'react' +import { useI18n } from 'twake-i18n' +import { User } from '../Attendees/types' + +interface SearchTextFieldProps { + params: AutocompleteRenderInputParams + query: string + setQuery: (v: string) => void + selectedContacts: User[] + onQueryChange: (v: string) => void + onEnter: () => void + onClear: () => void + inputRef?: Ref +} + +export const SearchTextField: React.FC = ({ + params, + query, + setQuery, + selectedContacts, + onQueryChange, + onEnter, + inputRef, + onClear +}) => { + const { t } = useI18n() + const theme = useTheme() + return ( + { + if (e.key === 'Enter') onEnter() + }} + onChange={e => { + setQuery(e.target.value) + onQueryChange(e.target.value) + }} + variant="outlined" + InputProps={{ + ...params.InputProps, + startAdornment: ( + <> + + + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + + {(query || selectedContacts.length > 0) && ( + + + + )} + + ) + }} + /> + ) +} diff --git a/src/components/Menubar/useMobileSearch.ts b/src/components/Menubar/useMobileSearch.ts new file mode 100644 index 0000000..4fbc101 --- /dev/null +++ b/src/components/Menubar/useMobileSearch.ts @@ -0,0 +1,255 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { selectCalendars } from '@/app/selectors/selectCalendars' +import { AppDispatch } from '@/app/store' +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { + clearFilters, + searchEventsAsync, + SearchFilters, + setFilters, + setSearchQuery +} from '@/features/Search/SearchSlice' +import { buildQuery } from '@/features/Search/searchUtils' +import { setView } from '@/features/Settings/SettingsSlice' +import { createAttendee } from '@/features/User/models/attendee.mapper' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { useCallback, useMemo, useState } from 'react' +import { User } from '../Attendees/types' +import { SearchState } from '../Calendar/utils/tempSearchUtil' + +type FilterKey = 'organizers' | 'attendees' + +export interface UseFilterSearchResult { + inputQuery: string + setInputQuery: React.Dispatch> + searchState: SearchState + selectedContacts: User[] + filters: SearchFilters + handleSearch: ( + searchQuery: string, + currentFilters: SearchFilters + ) => Promise + handleSearchChange: ({ query, options, loading }: SearchState) => void + handleContactSelect: (contacts: User[]) => void + clearAll: () => void + handleShow: () => void +} + +interface StateSetters { + setInputQuery: React.Dispatch> + setSelectedContacts: React.Dispatch> + setSearchState: React.Dispatch> +} + +interface FilterSearchState extends StateSetters { + inputQuery: string + selectedContacts: User[] + searchState: SearchState +} + +function useFilterSearchState( + filters: SearchFilters, + filterKey: FilterKey +): FilterSearchState { + const [inputQuery, setInputQuery] = useState('') + + const [selectedContacts, setSelectedContacts] = useState( + filters[filterKey].map(user => ({ + email: user.cal_address, + displayName: user.cn + })) + ) + const [searchState, setSearchState] = useState({ + query: '', + options: [] as User[], + loading: false + }) + return { + inputQuery, + setInputQuery, + selectedContacts, + setSelectedContacts, + searchState, + setSearchState + } +} + +interface CalendarsResult { + calendars: Calendar[] + personalCalendars: Calendar[] +} + +function useCalendars(): CalendarsResult { + const calendars = useAppSelector(selectCalendars) + const userId = useAppSelector(state => state.user.userData?.openpaasId) + const personalCalendars = useMemo( + (): Calendar[] => + userId + ? calendars.filter(c => extractEventBaseUuid(c.id) === userId) + : [], + [calendars, userId] + ) + return { calendars, personalCalendars } +} + +function useSearchAction( + dispatch: AppDispatch, + calendarIds: string[], + personalCalendarIds: string[], + setDialogOpen: (b: boolean) => void +): (searchQuery: string, currentFilters: SearchFilters) => Promise { + return useCallback( + async ( + searchQuery: string, + currentFilters: SearchFilters + ): Promise => { + const query = buildQuery( + searchQuery, + currentFilters, + calendarIds, + personalCalendarIds + ) + if (!query) return + dispatch(setSearchQuery(query.search)) + await dispatch(searchEventsAsync(query)) + dispatch(setView('search')) + setDialogOpen(false) + }, + [dispatch, calendarIds, personalCalendarIds, setDialogOpen] + ) +} + +function useSearchChangeHandler( + setDialogOpen: (b: boolean) => void, + setSearchState: React.Dispatch> +): ({ query, options, loading }: SearchState) => void { + return useCallback( + ({ query, options, loading }: SearchState): void => { + if (!query) { + setDialogOpen(false) + setSearchState({ query: '', options: [], loading: false }) + return + } + setDialogOpen(true) + setSearchState(prev => ({ + query, + options: options ?? prev.options, + loading: loading ?? prev.loading + })) + }, + [setDialogOpen, setSearchState] + ) +} + +function useContactSelectHandler( + filterData: { filterKey: FilterKey; filters: SearchFilters }, + dispatch: AppDispatch, + setters: StateSetters, + handleSearch: (q: string, f: SearchFilters) => Promise +): (contacts: User[]) => void { + const { setInputQuery, setSelectedContacts, setSearchState } = setters + const { filters, filterKey } = filterData + return useCallback( + (contacts: User[]): void => { + const mapped = contacts.map(c => + createAttendee({ cal_address: c.email, cn: c.displayName }) + ) + const nextFilters = { ...filters, [filterKey]: mapped } + setSelectedContacts(contacts) + dispatch(setFilters(nextFilters)) + setInputQuery('') + setSearchState({ query: '', options: [], loading: false }) + if (contacts.length > 0) void handleSearch('', nextFilters) + }, + [ + filters, + filterKey, + handleSearch, + dispatch, + setInputQuery, + setSelectedContacts, + setSearchState + ] + ) +} + +function useClearAll( + dispatch: AppDispatch, + setDialogOpen: (b: boolean) => void, + setters: StateSetters +): () => void { + const { setInputQuery, setSelectedContacts, setSearchState } = setters + return useCallback((): void => { + setInputQuery('') + setSelectedContacts([]) + setSearchState({ query: '', options: [], loading: false }) + dispatch(clearFilters()) + setDialogOpen(false) + }, [ + dispatch, + setDialogOpen, + setInputQuery, + setSelectedContacts, + setSearchState + ]) +} + +export function useFilterSearch( + filterKey: FilterKey, + setDialogOpen: (b: boolean) => void +): UseFilterSearchResult { + const dispatch = useAppDispatch() + const filters = useAppSelector( + state => state.searchResult.searchParams.filters + ) + const { calendars, personalCalendars } = useCalendars() + const { + inputQuery, + setInputQuery, + selectedContacts, + setSelectedContacts, + searchState, + setSearchState + } = useFilterSearchState(filters, filterKey) + + const setters: StateSetters = { + setInputQuery, + setSelectedContacts, + setSearchState + } + + const handleSearch = useSearchAction( + dispatch, + calendars.map(c => c.id), + personalCalendars.map(c => c.id), + setDialogOpen + ) + const handleSearchChange = useSearchChangeHandler( + setDialogOpen, + setSearchState + ) + const handleContactSelect = useContactSelectHandler( + { filterKey, filters }, + dispatch, + setters, + handleSearch + ) + const clearAll = useClearAll(dispatch, setDialogOpen, setters) + const handleShow = useCallback( + () => void handleSearch(inputQuery, filters), + [inputQuery, filters, handleSearch] + ) + + return { + inputQuery, + setInputQuery, + searchState, + selectedContacts, + filters, + handleSearch, + handleSearchChange, + handleContactSelect, + clearAll, + handleShow + } +} diff --git a/src/components/MobileSelector/index.tsx b/src/components/MobileSelector/index.tsx index a829e8f..4313155 100644 --- a/src/components/MobileSelector/index.tsx +++ b/src/components/MobileSelector/index.tsx @@ -1,9 +1,9 @@ import { Box, - Typography, - styled, ButtonBase, - SwipeableDrawer + styled, + SwipeableDrawer, + Typography } from '@linagora/twake-mui' import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' import React, { forwardRef, useImperativeHandle, useState } from 'react' @@ -19,6 +19,7 @@ const StyledSwipeableDrawer = styled(SwipeableDrawer)(({ theme }) => ({ const SelectorButton = styled(ButtonBase)(({ theme }) => ({ width: '100%', + whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -36,7 +37,7 @@ const SelectorButton = styled(ButtonBase)(({ theme }) => ({ })) interface MobileSelectorProps { - displayText: string + displayText: React.ReactNode children?: React.ReactNode bottomSheetRef?: React.RefObject paperRef?: React.RefObject @@ -44,64 +45,55 @@ interface MobileSelectorProps { open: boolean onClose: () => void }) => React.ReactNode + fullscreen?: boolean } export const MobileSelector = forwardRef< MobileSelectorHandle, MobileSelectorProps ->( - ( - { displayText, children, bottomSheetRef, paperRef, bottomSheetChildren }, - ref - ) => { - const [open, setOpen] = useState(false) +>(({ displayText, children, bottomSheetChildren, fullscreen }, ref) => { + const [open, setOpen] = useState(false) + const onClose = (): void => setOpen(false) - const onClose = (): void => { - setOpen(false) - } + useImperativeHandle(ref, () => ({ open, onClose })) - useImperativeHandle(ref, () => ({ - open, - onClose - })) - - return ( - <> - setOpen(true)}> + return ( + <> + setOpen(true)}> + {typeof displayText === 'string' ? ( {displayText} - - - - {children ? ( - {}} - disableAutoFocus - slotProps={{ - paper: { - ref: paperRef, - sx: { maxHeight: '90dvh' } - } - }} - > - {children} - ) : ( - bottomSheetChildren?.({ open, onClose }) + displayText )} - - ) - } -) + + + {children ? ( + {}} + disableAutoFocus + slotProps={{ + paper: { + sx: fullscreen ? { height: '100dvh' } : { maxHeight: '90dvh' } + } + }} + > + {children} + + ) : ( + bottomSheetChildren?.({ open, onClose }) + )} + + ) +}) MobileSelector.displayName = 'MobileSelector' diff --git a/src/features/Search/AttendeesFilter.tsx b/src/features/Search/AttendeesFilter.tsx new file mode 100644 index 0000000..819062d --- /dev/null +++ b/src/features/Search/AttendeesFilter.tsx @@ -0,0 +1,53 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import UserSearch from '@/components/Attendees/AttendeeSearch' +import { useFilterSearch } from '@/components/Menubar/useMobileSearch' +import { setFilters } from '@/features/Search/SearchSlice' +import { userAttendee } from '@/features/User/models/attendee' +import { Box, InputLabel } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { MobileFilterPicker } from './MobileFilterPicker' + +interface Props { + mode: 'popover' | 'mobile' + onErrorClear?: () => void +} + +export const AttendeesFilter: React.FC = ({ mode, onErrorClear }) => { + const { t } = useI18n() + const dispatch = useAppDispatch() + const filters = useAppSelector( + state => state.searchResult.searchParams.filters + ) + + const mobileSearch = useFilterSearch('attendees', () => {}) + + if (mode === 'mobile') { + return ( + + ) + } + + return ( + + {t('search.participants')} + { + dispatch(setFilters({ attendees: users })) + if (users.length > 0) onErrorClear?.() + }} + /> + + ) +} diff --git a/src/features/Search/DesktopSearchResultsPage.tsx b/src/features/Search/DesktopSearchResultsPage.tsx new file mode 100644 index 0000000..3434b76 --- /dev/null +++ b/src/features/Search/DesktopSearchResultsPage.tsx @@ -0,0 +1,325 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { AppDispatch } from '@/app/store' +import { defaultColors } from '@/utils/defaultColors' +import { browserDefaultTimeZone } from '@/utils/timezone' +import { + Box, + Button, + CircularProgress, + IconButton, + Stack, + Typography +} from '@linagora/twake-mui' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import RepeatIcon from '@mui/icons-material/Repeat' +import SquareRoundedIcon from '@mui/icons-material/SquareRounded' +import VideocamIcon from '@mui/icons-material/Videocam' +import { useState } from 'react' +import { useI18n } from 'twake-i18n' +import logo from '../../static/noResult-logo.svg' +import { getEventAsync } from '../Calendars/services' +import EventPreviewModal from '@/features/Events/EventPreview' +import { CalendarEvent } from '../Events/EventsTypes' +import { setView } from '../Settings/SettingsSlice' +import './searchResult.styl' +import { SearchEventResult } from './types/SearchEventResult' + +const styles = { + M3BodyLarge: { + fontFamily: 'Roboto', + fontWeight: 400, + fontStyle: 'normal', + fontSize: '22px', + lineHeight: '28px', + letterSpacing: '0%', + color: '#243B55' + }, + M3BodyMedium1: { + fontFamily: 'Inter', + fontWeight: 400, + fontStyle: 'normal', + fontSize: '16px', + lineHeight: '24px', + letterSpacing: '-0.15px', + color: '#243B55' + }, + M3BodyMedium: { + fontFamily: 'Roboto', + fontWeight: 400, + fontStyle: 'normal', + fontSize: '14px', + lineHeight: '20px', + letterSpacing: '0.25px', + verticalAlign: 'middle', + color: '#8C9CAF' + }, + M3BodyMedium3: { + fontFamily: 'Inter', + fontWeight: 400, + fontSize: '14px', + lineHeight: '20px', + letterSpacing: '0.25px', + verticalAlign: 'middle', + color: '#8C9CAF' + }, + M3TitleMedium: { + fontFamily: 'Roboto', + fontWeight: 500, + fontStyle: 'medium', + fontSize: '16px', + lineHeight: '24px', + letterSpacing: '0.15px', + textAlign: 'center', + verticalAlign: 'middle', + color: '#243B55' + } +} + +export default function DesktopSearchResultsPage(): JSX.Element { + const { t } = useI18n() + const dispatch = useAppDispatch() + const { error, loading, hits, results } = useAppSelector( + state => state.searchResult + ) + + let layout + + if (loading) { + layout = ( + + + + ) + } else if (error) { + layout = ( + + {error} + + ) + } else if (!hits) { + layout = ( + + {t('search.noResults')} + + {t('search.noResults')} + + + {t('search.noResultsSubtitle')} + + + ) + } else { + layout = ( + + + {results.map((r: SearchEventResult, idx: number) => ( + + ))} + + + ) + } + + return ( + + + + dispatch(setView('calendar'))} + aria-label={t('settings.back')} + > + + + {t('search.resultsTitle')} + + + {layout} + + ) +} + +function ResultItem({ + eventData, + dispatch +}: { + eventData: SearchEventResult + dispatch: AppDispatch +}): JSX.Element { + const { t } = useI18n() + const startDate = new Date(eventData.data.start) + const endDate = eventData.data.end ? new Date(eventData.data.end) : startDate + const timeZone = + useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone + const calendar = useAppSelector( + state => + state.calendars.list[ + `${eventData.data.userId}/${eventData.data.calendarId}` + ] + ) + const calendarColor = calendar?.color?.light + + const [openPreview, setOpenPreview] = useState(false) + + const handleOpenResult = async ( + eventData: SearchEventResult + ): Promise => { + if (calendar) { + const event = { + URL: eventData._links.self.href, + calId: calendar.id, + uid: eventData.data.uid, + start: eventData.data.start, + end: eventData.data.end, + allday: eventData.data.allDay, + attendee: eventData.data.attendees, + class: eventData.data.class, + description: eventData.data.description, + stamp: eventData.data.dtstamp, + location: eventData.data.location, + organizer: eventData.data.organizer, + title: eventData.data.summary, + timezone: timeZone + } as CalendarEvent + await dispatch(getEventAsync(event)) + setOpenPreview(true) + } + } + + return ( + <> + void handleOpenResult(eventData)} + > + + {startDate.toLocaleDateString(t('locale'), { + day: '2-digit', + month: 'short', + timeZone + })} + {startDate.toDateString() !== endDate.toDateString() && ( + <> + {' - '} + {endDate.toLocaleDateString(t('locale'), { + day: '2-digit', + month: 'short', + timeZone + })} + + )} + + {!eventData.data.allDay && ( + + {startDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', + timeZone + })} + - + {endDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', + timeZone + })} + + )} + + + + + {eventData.data.summary || t('event.untitled')} + + {eventData.data.isRecurrentMaster && } + + {(eventData.data.organizer?.cn || eventData.data.organizer?.email) && ( + + {eventData.data.organizer.cn || eventData.data.organizer.email} + + )} + {eventData.data?.location && ( + + {eventData.data.location} + + )} + {eventData.data?.description && ( + + {eventData.data.description.replace(/\n/g, ' ')} + + )} + {eventData.data['x-openpaas-videoconference'] && ( + + )} + + {calendar && calendar.events[eventData.data.uid] && ( + setOpenPreview(false)} + /> + )} + + ) +} diff --git a/src/features/Search/KeywordsFilter.tsx b/src/features/Search/KeywordsFilter.tsx new file mode 100644 index 0000000..1dc394c --- /dev/null +++ b/src/features/Search/KeywordsFilter.tsx @@ -0,0 +1,62 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { MobileSelector } from '@/components/MobileSelector' +import { setFilters } from '@/features/Search/SearchSlice' +import { Box, InputLabel, TextField } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' + +interface Props { + mode: 'popover' | 'mobile' + error?: boolean + onErrorClear?: () => void +} + +export const KeywordsFilter: React.FC = ({ + mode, + error, + onErrorClear +}) => { + const { t } = useI18n() + const dispatch = useAppDispatch() + const filters = useAppSelector( + state => state.searchResult.searchParams.filters + ) + + const field = ( + { + dispatch(setFilters({ keywords: e.target.value })) + if (e.target.value.trim()) onErrorClear?.() + }} + size="small" + /> + ) + + if (mode === 'mobile') { + return ( + {field}} + /> + ) + } + + return ( + + {t('search.keywords')} + {field} + + ) +} diff --git a/src/features/Search/MobileFilterPicker.tsx b/src/features/Search/MobileFilterPicker.tsx new file mode 100644 index 0000000..3eeff01 --- /dev/null +++ b/src/features/Search/MobileFilterPicker.tsx @@ -0,0 +1,152 @@ +import { AttendeeOptionsList } from '@/components/Attendees/AttendeeOptionsList' +import { PeopleSearch } from '@/components/Attendees/PeopleSearch' +import { User } from '@/components/Attendees/types' +import { SearchState } from '@/components/Calendar/utils/tempSearchUtil' +import { SearchTextField } from '@/components/Menubar/MobileSearchFieldText' +import { UseFilterSearchResult } from '@/components/Menubar/useMobileSearch' +import { MobileSelector } from '@/components/MobileSelector' +import { + AutocompleteRenderInputParams, + Box, + SwipeableDrawer +} from '@linagora/twake-mui' +import { useEffect, useRef } from 'react' + +interface MobileFilterPickerProps { + displayText: string + searchState: SearchState + selectedContacts: User[] + inputQuery: string + setInputQuery: React.Dispatch> + handleSearchChange: UseFilterSearchResult['handleSearchChange'] + handleContactSelect: UseFilterSearchResult['handleContactSelect'] + clearAll: UseFilterSearchResult['clearAll'] + objectTypes?: string[] +} + +interface FilterDrawerProps { + open: boolean + onClose: () => void + searchState: SearchState + selectedContacts: User[] + inputQuery: string + setInputQuery: React.Dispatch> + handleSearchChange: UseFilterSearchResult['handleSearchChange'] + handleContactSelect: UseFilterSearchResult['handleContactSelect'] + clearAll: UseFilterSearchResult['clearAll'] + objectTypes: string[] +} + +const FilterDrawer: React.FC = ({ + open, + onClose, + searchState, + selectedContacts, + inputQuery, + setInputQuery, + handleSearchChange, + handleContactSelect, + clearAll, + objectTypes +}) => { + const inputRef = useRef(null) + + useEffect(() => { + if (open) { + inputRef.current?.focus() + } + }, [open]) + + return ( + {}} + slotProps={{ + paper: { + sx: { height: '100dvh' } + } + }} + > + + { + handleContactSelect(users) + onClose() + }} + hideOptions + inputValue={inputQuery} + onSearchStateChange={handleSearchChange} + objectTypes={objectTypes} + onToggleEventPreview={() => {}} + customRenderInput={( + params: AutocompleteRenderInputParams, + query: string, + setQuery + ) => ( + {}} + onClear={clearAll} + /> + )} + /> + + {searchState.options && searchState.options.length > 0 && ( + + { + handleContactSelect([ + ...selectedContacts, + { displayName: user.displayName, email: user.email } + ]) + onClose() + }} + /> + + )} + + ) +} + +export const MobileFilterPicker: React.FC = ({ + displayText, + searchState, + selectedContacts, + inputQuery, + setInputQuery, + handleSearchChange, + handleContactSelect, + clearAll, + objectTypes = ['user', 'resources'] +}) => { + return ( + ( + + )} + /> + ) +} diff --git a/src/features/Search/MobileSearchResultsPage.tsx b/src/features/Search/MobileSearchResultsPage.tsx new file mode 100644 index 0000000..44c0cd5 --- /dev/null +++ b/src/features/Search/MobileSearchResultsPage.tsx @@ -0,0 +1,51 @@ +import { useAppSelector } from '@/app/hooks' +import { Box } from '@linagora/twake-mui' +import { AttendeesFilter } from './AttendeesFilter' +import DesktopSearchResultsPage from './DesktopSearchResultsPage' +import { OrganizersFilter } from './OrganizersFilter' +import { SearchInFilter } from './SearchInFilter' +import './searchResult.styl' + +const MobileSearchResultsPage: React.FC = () => { + const searchResults = useAppSelector(state => state.searchResult) + const hasSearchParams = + searchResults.searchParams.search !== '' || + searchResults.searchParams.filters.keywords !== '' || + searchResults.searchParams.filters.organizers.length > 0 || + searchResults.searchParams.filters.attendees.length > 0 + + const displaySearch = + (!!searchResults.hits || !!searchResults.error || searchResults.loading) && + hasSearchParams + + return ( + <> + + {displaySearch && } + + ) +} + +export default MobileSearchResultsPage + +const FiltersButtons: React.FC = () => { + return ( + + + + + + ) +} diff --git a/src/features/Search/OrganizersFilter.tsx b/src/features/Search/OrganizersFilter.tsx new file mode 100644 index 0000000..a671106 --- /dev/null +++ b/src/features/Search/OrganizersFilter.tsx @@ -0,0 +1,62 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import UserSearch from '@/components/Attendees/AttendeeSearch' +import { useFilterSearch } from '@/components/Menubar/useMobileSearch' +import { Box, InputLabel } from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { userAttendee } from '../User/models/attendee' +import { MobileFilterPicker } from './MobileFilterPicker' +import { setFilters } from './SearchSlice' + +interface Props { + mode: 'popover' | 'mobile' + onErrorClear?: () => void +} + +export const OrganizersFilter: React.FC = ({ mode, onErrorClear }) => { + const { t } = useI18n() + const dispatch = useAppDispatch() + const searchParams = useAppSelector(state => state.searchResult.searchParams) + + const mobileSearch = useFilterSearch('organizers', () => {}) + + if (mode === 'mobile') { + return ( + { + mobileSearch.handleContactSelect(contact) + dispatch( + setFilters({ + ...searchParams.filters, + keywords: searchParams.search + }) + ) + } + }} + /> + ) + } + + return ( + + {t('search.organizers')} + { + dispatch(setFilters({ organizers: users })) + if (users.length > 0) onErrorClear?.() + }} + /> + + ) +} diff --git a/src/features/Search/SearchInFilter.tsx b/src/features/Search/SearchInFilter.tsx new file mode 100644 index 0000000..a7b5116 --- /dev/null +++ b/src/features/Search/SearchInFilter.tsx @@ -0,0 +1,157 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks' +import { selectCalendars } from '@/app/selectors/selectCalendars' +import { CalendarItemList } from '@/components/Calendar/CalendarItemList' +import { CalendarName } from '@/components/Calendar/CalendarName' +import { useFilterSearch } from '@/components/Menubar/useMobileSearch' +import { + MobileSelector, + MobileSelectorHandle +} from '@/components/MobileSelector' +import { SearchFilters, setFilters } from '@/features/Search/SearchSlice' +import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid' +import { + Box, + Divider, + InputLabel, + List, + ListItemButton, + ListItemText, + MenuItem, + Select, + Typography +} from '@linagora/twake-mui' +import { useRef } from 'react' +import { useI18n } from 'twake-i18n' +import { Calendar } from '../Calendars/CalendarTypes' + +interface Props { + mode: 'popover' | 'mobile' +} + +export const SearchInFilter: React.FC = ({ mode }) => { + const { t } = useI18n() + const dispatch = useAppDispatch() + const searchParams = useAppSelector(state => state.searchResult.searchParams) + const calendars = useAppSelector(selectCalendars) + const userId = useAppSelector(state => state.user.userData?.openpaasId) + const personalCalendars = userId + ? calendars.filter(c => extractEventBaseUuid(c.id) === userId) + : [] + + const selectorRef = useRef(null) + const mobileSearch = useFilterSearch('organizers', () => {}) + + const handleSelect = async (value: string): Promise => { + dispatch(setFilters({ searchIn: value })) + await mobileSearch.handleSearch(searchParams.search, { + ...searchParams.filters, + searchIn: value + }) + selectorRef.current?.onClose() + } + + if (mode === 'mobile') { + return ( + void handleSelect(v)} + /> + ) + } + + return ( + + {t('search.searchIn')} + + + ) +} + +const getDisplayLabel = ( + filters: SearchFilters, + personalCalendars: Calendar[], + t: (key: string) => string +): string | JSX.Element => { + if (!filters.searchIn) { + return t('search.filter.allCalendar') + } + + if (filters.searchIn === 'my-calendars') { + return t('search.filter.myCalendar') + } + + const selected = personalCalendars.find(c => c.id === filters.searchIn) + return selected ? : t('search.searchIn') +} + +const CalendarMobileSelector: React.FC<{ + selectorRef: React.RefObject + filters: SearchFilters + personalCalendars: Calendar[] + t: (key: string) => string + handleSelect: (value: string) => void +}> = ({ selectorRef, filters, personalCalendars, t, handleSelect }) => { + return ( + + + handleSelect('')} + > + + + + + + handleSelect('my-calendars')} + > + + + + {personalCalendars.map(c => ( + handleSelect(c.id)} + > + + + ))} + + + ) +} diff --git a/src/features/Search/SearchResultsPage.tsx b/src/features/Search/SearchResultsPage.tsx index 4e67c73..995b23e 100644 --- a/src/features/Search/SearchResultsPage.tsx +++ b/src/features/Search/SearchResultsPage.tsx @@ -1,323 +1,16 @@ -import { useAppDispatch, useAppSelector } from '@/app/hooks' -import { AppDispatch } from '@/app/store' -import { defaultColors } from '@/utils/defaultColors' -import { browserDefaultTimeZone } from '@/utils/timezone' -import { - Box, - Button, - CircularProgress, - IconButton, - Stack, - Typography -} from '@linagora/twake-mui' -import ArrowBackIcon from '@mui/icons-material/ArrowBack' -import RepeatIcon from '@mui/icons-material/Repeat' -import SquareRoundedIcon from '@mui/icons-material/SquareRounded' -import VideocamIcon from '@mui/icons-material/Videocam' -import { useState } from 'react' -import { useI18n } from 'twake-i18n' -import logo from '../../static/noResult-logo.svg' -import { getEventAsync } from '../Calendars/services' -import EventPreviewModal from '@/features/Events/EventPreview' -import { CalendarEvent } from '../Events/EventsTypes' -import { setView } from '../Settings/SettingsSlice' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import DesktopSearchResultsPage from './DesktopSearchResultsPage' +import MobileSearchResultsPage from './MobileSearchResultsPage' import './searchResult.styl' -import { SearchEventResult } from './types/SearchEventResult' -const styles = { - M3BodyLarge: { - fontFamily: 'Roboto', - fontWeight: 400, - fontStyle: 'normal', - fontSize: '22px', - lineHeight: '28px', - letterSpacing: '0%', - color: '#243B55' - }, - M3BodyMedium1: { - fontFamily: 'Inter', - fontWeight: 400, - fontStyle: 'normal', - fontSize: '16px', - lineHeight: '24px', - letterSpacing: '-0.15px', - color: '#243B55' - }, - M3BodyMedium: { - fontFamily: 'Roboto', - fontWeight: 400, - fontStyle: 'normal', - fontSize: '14px', - lineHeight: '20px', - letterSpacing: '0.25px', - verticalAlign: 'middle', - color: '#8C9CAF' - }, - M3BodyMedium3: { - fontFamily: 'Inter', - fontWeight: 400, - fontSize: '14px', - lineHeight: '20px', - letterSpacing: '0.25px', - verticalAlign: 'middle', - color: '#8C9CAF' - }, - M3TitleMedium: { - fontFamily: 'Roboto', - fontWeight: 500, - fontStyle: 'medium', - fontSize: '16px', - lineHeight: '24px', - letterSpacing: '0.15px', - textAlign: 'center', - verticalAlign: 'middle', - color: '#243B55' - } -} +const SearchResultsPage: React.FC = () => { + const { isTooSmall: isMobile } = useScreenSizeDetection() -export default function SearchResultsPage() { - const { t } = useI18n() - const dispatch = useAppDispatch() - const { error, loading, hits, results } = useAppSelector( - state => state.searchResult - ) - - let layout - - if (loading) { - layout = ( - - - - ) - } else if (error) { - layout = ( - - {error} - - ) - } else if (!hits) { - layout = ( - - {t('search.noResults')} - - {t('search.noResults')} - - - {t('search.noResultsSubtitle')} - - - ) + if (isMobile) { + return } else { - layout = ( - - - {results.map((r: SearchEventResult, idx: number) => ( - - ))} - - - ) + return } - - return ( - - - - dispatch(setView('calendar'))} - aria-label={t('settings.back')} - > - - - {t('search.resultsTitle')} - - - {layout} - - ) } -function ResultItem({ - eventData, - dispatch -}: { - eventData: SearchEventResult - dispatch: AppDispatch -}) { - const { t } = useI18n() - const startDate = new Date(eventData.data.start) - const endDate = eventData.data.end ? new Date(eventData.data.end) : startDate - const timeZone = - useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone - const calendar = useAppSelector( - state => - state.calendars.list[ - `${eventData.data.userId}/${eventData.data.calendarId}` - ] - ) - const calendarColor = calendar?.color?.light - - const [openPreview, setOpenPreview] = useState(false) - - const handleOpenResult = async (eventData: SearchEventResult) => { - if (calendar) { - const event = { - URL: eventData._links.self.href, - calId: calendar.id, - uid: eventData.data.uid, - start: eventData.data.start, - end: eventData.data.end, - allday: eventData.data.allDay, - attendee: eventData.data.attendees, - class: eventData.data.class, - description: eventData.data.description, - stamp: eventData.data.dtstamp, - location: eventData.data.location, - organizer: eventData.data.organizer, - title: eventData.data.summary, - timezone: timeZone - } as CalendarEvent - await dispatch(getEventAsync(event)) - setOpenPreview(true) - } - } - - return ( - <> - handleOpenResult(eventData)} - > - - {startDate.toLocaleDateString(t('locale'), { - day: '2-digit', - month: 'short', - timeZone - })} - {startDate.toDateString() !== endDate.toDateString() && ( - <> - {' - '} - {endDate.toLocaleDateString(t('locale'), { - day: '2-digit', - month: 'short', - timeZone - })} - - )} - - {!eventData.data.allDay && ( - - {startDate.toLocaleTimeString(t('locale'), { - hour: '2-digit', - minute: '2-digit', - timeZone - })} - - - {endDate.toLocaleTimeString(t('locale'), { - hour: '2-digit', - minute: '2-digit', - timeZone - })} - - )} - - - - - {eventData.data.summary || t('event.untitled')} - - {eventData.data.isRecurrentMaster && } - - {(eventData.data.organizer?.cn || eventData.data.organizer?.email) && ( - - {eventData.data.organizer.cn || eventData.data.organizer.email} - - )} - {eventData.data?.location && ( - - {eventData.data.location} - - )} - {eventData.data?.description && ( - - {eventData.data.description.replace(/\n/g, ' ')} - - )} - {eventData.data['x-openpaas-videoconference'] && ( - - )} - - {calendar && calendar.events[eventData.data.uid] && ( - setOpenPreview(false)} - /> - )} - - ) -} +export default SearchResultsPage diff --git a/src/features/Search/SearchSlice.ts b/src/features/Search/SearchSlice.ts index 757c548..d174645 100644 --- a/src/features/Search/SearchSlice.ts +++ b/src/features/Search/SearchSlice.ts @@ -1,23 +1,41 @@ import { formatReduxError } from '@/utils/errorUtils' import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit' import { searchEvent } from '../Events/EventApi' +import { userAttendee } from '../User/models/attendee' +import { SearchEventResult } from './types/SearchEventResult' + +export interface SearchFilters { + searchIn: string + keywords: string + organizers: userAttendee[] + attendees: userAttendee[] +} + +export interface SearchParams { + search: string + filters: SearchFilters +} export interface SearchResultsState { + searchParams: SearchParams hits: number - results: Record[] + results: SearchEventResult[] error: string | null loading: boolean } -const initialState: SearchResultsState = { - results: [], - hits: 0, - error: null, - loading: false +export const defaultSearchParams: SearchParams = { + search: '', + filters: { + searchIn: 'my-calendars', + keywords: '', + organizers: [], + attendees: [] + } } export const searchEventsAsync = createAsyncThunk< - { hits: number; events: Record[] }, + { hits: number; events: SearchEventResult[] }, { search: string filters: { @@ -34,7 +52,7 @@ export const searchEventsAsync = createAsyncThunk< return { hits: Number(response._total_hits), - events: response._embedded?.events ?? [] + events: (response._embedded?.events ?? []) as SearchEventResult[] } } catch (err) { const error = err as { response?: { status?: number } } @@ -45,15 +63,41 @@ export const searchEventsAsync = createAsyncThunk< } }) +const initialState: SearchResultsState = { + searchParams: defaultSearchParams, + hits: 0, + results: [], + error: null, + loading: false +} + export const searchResultsSlice = createSlice({ - name: 'settings', + name: 'searchResult', initialState, reducers: { - setResults: (state, action: PayloadAction<[]>) => { + setResults: (state, action: PayloadAction) => { state.results = action.payload }, setHits: (state, action: PayloadAction) => { state.hits = action.payload + }, + setSearchQuery: (state, action: PayloadAction) => { + state.searchParams.search = action.payload + }, + setFilters: (state, action: PayloadAction>) => { + state.searchParams.filters = { + ...state.searchParams.filters, + ...action.payload + } + }, + clearFilters: state => { + state.searchParams.filters = defaultSearchParams.filters + }, + clearSearch: state => { + state.searchParams = defaultSearchParams + state.results = [] + state.hits = 0 + state.error = null } }, extraReducers: builder => { @@ -74,5 +118,12 @@ export const searchResultsSlice = createSlice({ } }) -export const { setResults, setHits } = searchResultsSlice.actions +export const { + setResults, + setHits, + setFilters, + setSearchQuery, + clearFilters, + clearSearch +} = searchResultsSlice.actions export default searchResultsSlice.reducer diff --git a/src/features/Search/searchUtils.ts b/src/features/Search/searchUtils.ts new file mode 100644 index 0000000..e7f2a13 --- /dev/null +++ b/src/features/Search/searchUtils.ts @@ -0,0 +1,48 @@ +import { SearchFilters } from './SearchSlice' + +export function getSearchInCalendars( + searchIn: string, + allIds: string[], + personalIds: string[] +): string[] { + if (!searchIn) return allIds + if (searchIn === 'my-calendars') return personalIds + return [searchIn] +} + +export function buildQuery( + searchQuery: string, + filters: SearchFilters, + allIds: string[], + personalIds: string[] +): + | { + search: string + filters: { + searchIn: string[] + keywords: string + organizers: string[] + attendees: string[] + } + } + | undefined { + const trimmedSearch = searchQuery.trim() + const trimmedKeywords = filters.keywords.trim() + const hasSearchCriteria = + trimmedSearch || + trimmedKeywords || + filters.organizers.length > 0 || + filters.attendees.length > 0 + + if (!hasSearchCriteria) return undefined + + return { + search: trimmedSearch, + filters: { + keywords: trimmedKeywords, + organizers: filters.organizers.map(u => u.cal_address), + attendees: filters.attendees.map(u => u.cal_address), + searchIn: getSearchInCalendars(filters.searchIn, allIds, personalIds) + } + } +}