[#691] changed searchbar in menubar to mobile searchbar (#822)

* [#691] changed searchbar in menubar to mobile searchbar
* [#691] add filters to results page for mobile
* [#691] added filter for organizers and attendees
This commit is contained in:
Camille Moussu
2026-04-28 15:07:45 +02:00
committed by GitHub
parent 00e00594a2
commit 234b4c3ae8
18 changed files with 1593 additions and 595 deletions
+10 -1
View File
@@ -26,7 +26,16 @@ describe('SearchSlice', () => {
results: [],
hits: 0,
error: null,
loading: false
loading: false,
searchParams: {
filters: {
attendees: [],
keywords: '',
organizers: [],
searchIn: 'my-calendars'
},
search: ''
}
})
})
})
+43 -205
View File
@@ -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<User[]>([])
const [extended, setExtended] = useState(false)
const [filters, setFilters] = useState({
searchIn: 'my-calendars',
keywords: '',
organizers: [] as userAttendee[],
attendees: [] as userAttendee[]
})
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const filterOpen = Boolean(anchorEl)
const [filterError, setFilterError] = useState(false)
@@ -72,6 +70,7 @@ const SearchBar: React.FC<{
const inputRef = useRef<HTMLInputElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const [popperAnchor, setPopperAnchor] = useState<HTMLDivElement | null>(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<void> => {
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 (
<>
<Box
ref={containerRef}
ref={(el: HTMLDivElement | null) => {
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<{
<Card sx={{ p: 2, pb: 1 }}>
<CardContent>
<Stack spacing={2}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel id="search-in" sx={{ m: 0 }}>
{t('search.searchIn')}
</InputLabel>
<Select
displayEmpty
value={filters.searchIn}
onChange={e => handleFilterChange('searchIn', e.target.value)}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
color: '#8C9CAF'
}
}
}}
sx={{ height: '40px' }}
>
<MenuItem value="">
<Typography
sx={{
color: '#243B55',
font: 'Roboto',
fontSize: '16px',
weight: 400,
pointerEvents: 'auto'
}}
>
{t('search.filter.allCalendar')}
</Typography>
</MenuItem>
<Divider />
<MenuItem
value="my-calendars"
sx={{
color: '#243B55',
font: 'Roboto',
fontSize: '12px',
weight: 400,
pointerEvents: 'auto'
}}
>
{t('search.filter.myCalendar')}
</MenuItem>
{CalendarItemList(personnalCalendars)}
</Select>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel id="keywords" sx={{ m: 0 }}>
{t('search.keywords')}
</InputLabel>
<TextField
fullWidth
error={filterError}
helperText={filterError ? t('search.error.emptySearch') : ''}
placeholder={t('search.keywordsPlaceholder')}
value={filters.keywords}
onChange={e => {
handleFilterChange('keywords', e.target.value)
if (e.target.value.trim()) {
setFilterError(false)
}
}}
size="small"
/>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel id="from" sx={{ m: 0 }}>
{t('search.organizers')}
</InputLabel>
<UserSearch
attendees={filters.organizers}
setAttendees={(users: userAttendee[]) => {
handleFilterChange('organizers', users)
if (users.length > 0) {
setFilterError(false)
}
}}
/>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel id="participant" sx={{ m: 0 }}>
{t('search.participants')}
</InputLabel>
<UserSearch
attendees={filters.attendees}
setAttendees={(users: userAttendee[]) => {
handleFilterChange('attendees', users)
if (users.length > 0) {
setFilterError(false)
}
}}
/>
</Box>
<SearchInFilter mode="popover" />
<KeywordsFilter
mode="popover"
error={filterError}
onErrorClear={() => setFilterError(false)}
/>
<OrganizersFilter
mode="popover"
onErrorClear={() => setFilterError(false)}
/>
<AttendeesFilter
mode="popover"
onErrorClear={() => setFilterError(false)}
/>
</Stack>
</CardContent>
@@ -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 (
<>
<Box
sx={{
position: 'relative',
width: '100%',
transition: 'width 0.25s ease-out'
}}
>
<PeopleSearch
selectedUsers={selectedContacts}
onChange={(_event, users) => handleContactSelect(users)}
hideOptions
inputValue={inputQuery}
onSearchStateChange={handleSearchChange}
objectTypes={SEARCH_OBJECT_TYPES}
onToggleEventPreview={() => {}}
customRenderInput={(
params: AutocompleteRenderInputParams,
query: string,
setQuery
) => (
<SearchTextField
params={params}
query={query}
setQuery={setQuery}
selectedContacts={selectedContacts}
onQueryChange={setInputQuery}
onEnter={() => void handleSearch(query, filters)}
onClear={clearAll}
/>
)}
/>
</Box>
<MobileSearchDialog
open={dialogOpen}
onShow={handleShow}
showSearchButton={!!inputQuery || filters.organizers.length > 0}
options={searchState.options ?? []}
selectedUsers={selectedContacts}
onOptionClick={user =>
handleContactSelect([
...selectedContacts,
{ displayName: user.displayName, email: user.email }
])
}
/>
</>
)
}
export default MobileSearchBar
+43 -9
View File
@@ -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<CalendarApi | null>
currentDate: Date
onDateChange?: (date: Date) => void
@@ -34,7 +36,10 @@ export const MobileMenubar: React.FC<MobileMenubarProps> = ({
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<MobileMenubarProps> = ({
event.stopPropagation()
event.preventDefault()
dispatch(setView('calendar'))
dispatch(clearSearch())
}
if (openEventSearch) {
return (
<header className="menubar menubar--mobile">
<IconButton
onClick={e => {
setOpenEventSearch(false)
handleBackClick(e)
}}
aria-label={t('common.back')}
sx={{ mr: 1 }}
>
<ArrowBackIcon sx={{ color: theme.palette.text.secondary }} />
</IconButton>
<MobileSearchBar />
</header>
)
}
return (
@@ -122,7 +146,17 @@ export const MobileMenubar: React.FC<MobileMenubarProps> = ({
</div>
<div className="right-menu">
<div className="search-container">
<SearchBar />
<IconButton
sx={{ mr: 1 }}
onClick={() => {
setOpenDatePicker(false)
dispatch(setView('search'))
setOpenEventSearch(true)
}}
aria-label={t('common.search')}
>
<SearchIcon />
</IconButton>
</div>
</div>
</>
@@ -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 (
<Paper
elevation={4}
sx={{
position: 'fixed',
top: '70px',
left: 0,
right: 0,
bottom: 0,
p: 2,
zIndex: theme => theme.zIndex.appBar - 1,
borderRadius: 0,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column'
}}
>
<Box sx={{ flex: 1, mt: 1, overflowY: 'auto' }}>
<AttendeeOptionsList
options={options}
onOptionClick={onOptionClick}
selectedUsers={selectedUsers}
/>
</Box>
{showSearchButton && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 2 }}>
<Button
variant="contained"
onClick={() => {
onShow()
}}
>
{t('common.show')}
</Button>
</Box>
)}
</Paper>
)
}
@@ -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<HTMLInputElement>
}
export const SearchTextField: React.FC<SearchTextFieldProps> = ({
params,
query,
setQuery,
selectedContacts,
onQueryChange,
onEnter,
inputRef,
onClear
}) => {
const { t } = useI18n()
const theme = useTheme()
return (
<TextField
{...params}
fullWidth
autoFocus
inputRef={inputRef}
placeholder={t('common.search')}
onKeyDown={e => {
if (e.key === 'Enter') onEnter()
}}
onChange={e => {
setQuery(e.target.value)
onQueryChange(e.target.value)
}}
variant="outlined"
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start">
<SearchIcon sx={{ color: theme.palette.grey[700] }} />
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<InputAdornment position="end">
{(query || selectedContacts.length > 0) && (
<IconButton aria-label={t('common.clear')} onClick={onClear}>
<HighlightOffIcon />
</IconButton>
)}
</InputAdornment>
)
}}
/>
)
}
+255
View File
@@ -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<React.SetStateAction<string>>
searchState: SearchState
selectedContacts: User[]
filters: SearchFilters
handleSearch: (
searchQuery: string,
currentFilters: SearchFilters
) => Promise<void>
handleSearchChange: ({ query, options, loading }: SearchState) => void
handleContactSelect: (contacts: User[]) => void
clearAll: () => void
handleShow: () => void
}
interface StateSetters {
setInputQuery: React.Dispatch<React.SetStateAction<string>>
setSelectedContacts: React.Dispatch<React.SetStateAction<User[]>>
setSearchState: React.Dispatch<React.SetStateAction<SearchState>>
}
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<User[]>(
filters[filterKey].map(user => ({
email: user.cal_address,
displayName: user.cn
}))
)
const [searchState, setSearchState] = useState<SearchState>({
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<void> {
return useCallback(
async (
searchQuery: string,
currentFilters: SearchFilters
): Promise<void> => {
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<React.SetStateAction<SearchState>>
): ({ 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<void>
): (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
}
}
+45 -53
View File
@@ -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<HTMLDivElement>
paperRef?: React.RefObject<HTMLDivElement>
@@ -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 (
<>
<SelectorButton onClick={() => setOpen(true)}>
return (
<>
<SelectorButton onClick={() => setOpen(true)}>
{typeof displayText === 'string' ? (
<Typography variant="body1">{displayText}</Typography>
<Box
component={ArrowDropDownIcon}
sx={{
fontSize: 20,
transition: 'transform 0.2s',
transform: open ? 'rotate(180deg)' : 'rotate(0deg)'
}}
/>
</SelectorButton>
{children ? (
<StyledSwipeableDrawer
ref={bottomSheetRef}
anchor="bottom"
open={open}
onClose={onClose}
onOpen={(): void => {}}
disableAutoFocus
slotProps={{
paper: {
ref: paperRef,
sx: { maxHeight: '90dvh' }
}
}}
>
{children}
</StyledSwipeableDrawer>
) : (
bottomSheetChildren?.({ open, onClose })
displayText
)}
</>
)
}
)
<Box
component={ArrowDropDownIcon}
sx={{
fontSize: 20,
transition: 'transform 0.2s',
transform: open ? 'rotate(180deg)' : 'rotate(0deg)'
}}
/>
</SelectorButton>
{children ? (
<StyledSwipeableDrawer
anchor="bottom"
open={open}
onClose={onClose}
onOpen={(): void => {}}
disableAutoFocus
slotProps={{
paper: {
sx: fullscreen ? { height: '100dvh' } : { maxHeight: '90dvh' }
}
}}
>
{children}
</StyledSwipeableDrawer>
) : (
bottomSheetChildren?.({ open, onClose })
)}
</>
)
})
MobileSelector.displayName = 'MobileSelector'
+53
View File
@@ -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<Props> = ({ mode, onErrorClear }) => {
const { t } = useI18n()
const dispatch = useAppDispatch()
const filters = useAppSelector(
state => state.searchResult.searchParams.filters
)
const mobileSearch = useFilterSearch('attendees', () => {})
if (mode === 'mobile') {
return (
<MobileFilterPicker
displayText={t('search.participants')}
objectTypes={['user', 'contact']}
{...mobileSearch}
/>
)
}
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel sx={{ m: 0 }}>{t('search.participants')}</InputLabel>
<UserSearch
attendees={filters.attendees}
setAttendees={(users: userAttendee[]) => {
dispatch(setFilters({ attendees: users }))
if (users.length > 0) onErrorClear?.()
}}
/>
</Box>
)
}
@@ -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 = (
<Box className="loading">
<CircularProgress size={32} />
</Box>
)
} else if (error) {
layout = (
<Box className="error">
<Typography className="error-text">{error}</Typography>
</Box>
)
} else if (!hits) {
layout = (
<Box className="noResults">
<img className="logo" src={logo} alt={t('search.noResults')} />
<Typography sx={styles.M3TitleMedium}>
{t('search.noResults')}
</Typography>
<Typography sx={styles.M3BodyMedium}>
{t('search.noResultsSubtitle')}
</Typography>
</Box>
)
} else {
layout = (
<Box className="search-result-content-body">
<Stack sx={{ mt: 2 }}>
{results.map((r: SearchEventResult, idx: number) => (
<ResultItem
key={`row-${idx}-event-${r.data.uid}`}
eventData={r}
dispatch={dispatch}
/>
))}
</Stack>
</Box>
)
}
return (
<Box className="search-layout">
<Box className="search-result-content-header">
<Box className="back-button">
<IconButton
onClick={() => dispatch(setView('calendar'))}
aria-label={t('settings.back')}
>
<ArrowBackIcon />
</IconButton>
<Typography variant="h5">{t('search.resultsTitle')}</Typography>
</Box>
</Box>
{layout}
</Box>
)
}
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<void> => {
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 (
<>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
p: 3,
borderTop: '1px solid #F3F6F9',
cursor: 'pointer',
'&:hover': { backgroundColor: '#e7e7e7ff' },
alignItems: 'center',
textAlign: 'left',
maxWidth: '80vw'
}}
onClick={() => void handleOpenResult(eventData)}
>
<Typography sx={{ ...styles.M3BodyLarge, minWidth: '90px' }}>
{startDate.toLocaleDateString(t('locale'), {
day: '2-digit',
month: 'short',
timeZone
})}
{startDate.toDateString() !== endDate.toDateString() && (
<>
{' - '}
{endDate.toLocaleDateString(t('locale'), {
day: '2-digit',
month: 'short',
timeZone
})}
</>
)}
</Typography>
{!eventData.data.allDay && (
<Typography sx={{ ...styles.M3BodyMedium1, minWidth: '120px' }}>
{startDate.toLocaleTimeString(t('locale'), {
hour: '2-digit',
minute: '2-digit',
timeZone
})}
-
{endDate.toLocaleTimeString(t('locale'), {
hour: '2-digit',
minute: '2-digit',
timeZone
})}
</Typography>
)}
<SquareRoundedIcon
style={{
color: calendarColor ?? defaultColors[0].light,
width: 24,
height: 24,
flexShrink: 0
}}
/>
<Box display="flex" flexDirection="row" gap={1} sx={{ minWidth: 0 }}>
<Typography sx={styles.M3BodyLarge}>
{eventData.data.summary || t('event.untitled')}
</Typography>
{eventData.data.isRecurrentMaster && <RepeatIcon />}
</Box>
{(eventData.data.organizer?.cn || eventData.data.organizer?.email) && (
<Typography
sx={{
...styles.M3BodyMedium1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: '150px',
maxWidth: '150px'
}}
>
{eventData.data.organizer.cn || eventData.data.organizer.email}
</Typography>
)}
{eventData.data?.location && (
<Typography
sx={{
...styles.M3BodyMedium,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: '150px',
maxWidth: '250px'
}}
>
{eventData.data.location}
</Typography>
)}
{eventData.data?.description && (
<Typography
sx={{
...styles.M3BodyMedium3,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
minWidth: 0
}}
>
{eventData.data.description.replace(/\n/g, ' ')}
</Typography>
)}
{eventData.data['x-openpaas-videoconference'] && (
<Button
startIcon={<VideocamIcon />}
sx={{ flexShrink: 0, ml: 'auto' }}
onClick={e => {
e.stopPropagation()
window.open(
eventData.data['x-openpaas-videoconference'],
'_blank',
'noopener,noreferrer'
)
}}
>
{t('eventPreview.joinVideoShort')}
</Button>
)}
</Box>
{calendar && calendar.events[eventData.data.uid] && (
<EventPreviewModal
eventId={eventData.data.uid}
calId={calendar.id}
open={openPreview}
onClose={() => setOpenPreview(false)}
/>
)}
</>
)
}
+62
View File
@@ -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<Props> = ({
mode,
error,
onErrorClear
}) => {
const { t } = useI18n()
const dispatch = useAppDispatch()
const filters = useAppSelector(
state => state.searchResult.searchParams.filters
)
const field = (
<TextField
fullWidth
error={error}
helperText={error ? t('search.error.emptySearch') : ''}
placeholder={t('search.keywordsPlaceholder')}
value={filters.keywords}
onChange={e => {
dispatch(setFilters({ keywords: e.target.value }))
if (e.target.value.trim()) onErrorClear?.()
}}
size="small"
/>
)
if (mode === 'mobile') {
return (
<MobileSelector
ref={null}
displayText={t('search.keywords')}
bottomSheetChildren={() => <Box sx={{ p: 2 }}>{field}</Box>}
/>
)
}
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel sx={{ m: 0 }}>{t('search.keywords')}</InputLabel>
{field}
</Box>
)
}
+152
View File
@@ -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<React.SetStateAction<string>>
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<React.SetStateAction<string>>
handleSearchChange: UseFilterSearchResult['handleSearchChange']
handleContactSelect: UseFilterSearchResult['handleContactSelect']
clearAll: UseFilterSearchResult['clearAll']
objectTypes: string[]
}
const FilterDrawer: React.FC<FilterDrawerProps> = ({
open,
onClose,
searchState,
selectedContacts,
inputQuery,
setInputQuery,
handleSearchChange,
handleContactSelect,
clearAll,
objectTypes
}) => {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
inputRef.current?.focus()
}
}, [open])
return (
<SwipeableDrawer
anchor="bottom"
open={open}
onClose={onClose}
onOpen={(): void => {}}
slotProps={{
paper: {
sx: { height: '100dvh' }
}
}}
>
<Box sx={{ p: 2 }}>
<PeopleSearch
selectedUsers={selectedContacts}
onChange={(_event, users) => {
handleContactSelect(users)
onClose()
}}
hideOptions
inputValue={inputQuery}
onSearchStateChange={handleSearchChange}
objectTypes={objectTypes}
onToggleEventPreview={() => {}}
customRenderInput={(
params: AutocompleteRenderInputParams,
query: string,
setQuery
) => (
<SearchTextField
params={{ ...params }}
inputRef={inputRef}
query={query}
setQuery={setQuery}
selectedContacts={selectedContacts}
onQueryChange={setInputQuery}
onEnter={() => {}}
onClear={clearAll}
/>
)}
/>
</Box>
{searchState.options && searchState.options.length > 0 && (
<Box sx={{ flex: 1, m: 1 }}>
<AttendeeOptionsList
options={searchState.options}
selectedUsers={selectedContacts}
onOptionClick={user => {
handleContactSelect([
...selectedContacts,
{ displayName: user.displayName, email: user.email }
])
onClose()
}}
/>
</Box>
)}
</SwipeableDrawer>
)
}
export const MobileFilterPicker: React.FC<MobileFilterPickerProps> = ({
displayText,
searchState,
selectedContacts,
inputQuery,
setInputQuery,
handleSearchChange,
handleContactSelect,
clearAll,
objectTypes = ['user', 'resources']
}) => {
return (
<MobileSelector
displayText={selectedContacts[0]?.displayName ?? displayText}
fullscreen
bottomSheetChildren={({ open, onClose }) => (
<FilterDrawer
open={open}
onClose={onClose}
searchState={searchState}
selectedContacts={selectedContacts}
inputQuery={inputQuery}
setInputQuery={setInputQuery}
handleSearchChange={handleSearchChange}
handleContactSelect={handleContactSelect}
clearAll={clearAll}
objectTypes={objectTypes}
/>
)}
/>
)
}
@@ -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 (
<>
<FiltersButtons />
{displaySearch && <DesktopSearchResultsPage />}
</>
)
}
export default MobileSearchResultsPage
const FiltersButtons: React.FC = () => {
return (
<Box
sx={{
display: 'flex',
overflowX: 'auto',
gap: 2,
px: 2,
py: 1,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
backgroundColor: '#FFF',
minHeight: '48px'
}}
>
<SearchInFilter mode="mobile" />
<OrganizersFilter mode="mobile" />
<AttendeesFilter mode="mobile" />
</Box>
)
}
+62
View File
@@ -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<Props> = ({ mode, onErrorClear }) => {
const { t } = useI18n()
const dispatch = useAppDispatch()
const searchParams = useAppSelector(state => state.searchResult.searchParams)
const mobileSearch = useFilterSearch('organizers', () => {})
if (mode === 'mobile') {
return (
<MobileFilterPicker
displayText={t('search.organizers')}
objectTypes={['user', 'resources']}
{...{
...mobileSearch,
handleContactSelect: contact => {
mobileSearch.handleContactSelect(contact)
dispatch(
setFilters({
...searchParams.filters,
keywords: searchParams.search
})
)
}
}}
/>
)
}
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel sx={{ m: 0 }}>{t('search.organizers')}</InputLabel>
<UserSearch
attendees={searchParams.filters.organizers}
setAttendees={(users: userAttendee[]) => {
dispatch(setFilters({ organizers: users }))
if (users.length > 0) onErrorClear?.()
}}
/>
</Box>
)
}
+157
View File
@@ -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<Props> = ({ 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<MobileSelectorHandle>(null)
const mobileSearch = useFilterSearch('organizers', () => {})
const handleSelect = async (value: string): Promise<void> => {
dispatch(setFilters({ searchIn: value }))
await mobileSearch.handleSearch(searchParams.search, {
...searchParams.filters,
searchIn: value
})
selectorRef.current?.onClose()
}
if (mode === 'mobile') {
return (
<CalendarMobileSelector
selectorRef={selectorRef}
filters={searchParams.filters}
personalCalendars={personalCalendars}
t={t}
handleSelect={v => void handleSelect(v)}
/>
)
}
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: 'center'
}}
>
<InputLabel sx={{ m: 0 }}>{t('search.searchIn')}</InputLabel>
<Select
displayEmpty
value={searchParams.filters.searchIn}
onChange={e => dispatch(setFilters({ searchIn: e.target.value }))}
sx={{ height: '40px' }}
>
<MenuItem value="">
<Typography sx={{ color: '#243B55', fontSize: '16px' }}>
{t('search.filter.allCalendar')}
</Typography>
</MenuItem>
<Divider />
<MenuItem
value="my-calendars"
sx={{ color: '#243B55', fontSize: '12px' }}
>
{t('search.filter.myCalendar')}
</MenuItem>
{CalendarItemList(personalCalendars)}
</Select>
</Box>
)
}
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 ? <CalendarName calendar={selected} /> : t('search.searchIn')
}
const CalendarMobileSelector: React.FC<{
selectorRef: React.RefObject<MobileSelectorHandle>
filters: SearchFilters
personalCalendars: Calendar[]
t: (key: string) => string
handleSelect: (value: string) => void
}> = ({ selectorRef, filters, personalCalendars, t, handleSelect }) => {
return (
<MobileSelector
ref={selectorRef}
displayText={getDisplayLabel(filters, personalCalendars, t)}
>
<List>
<ListItemButton
selected={filters.searchIn === ''}
onClick={() => handleSelect('')}
>
<ListItemText primary={t('search.filter.allCalendar')} />
</ListItemButton>
<Divider />
<ListItemButton
selected={filters.searchIn === 'my-calendars'}
onClick={() => handleSelect('my-calendars')}
>
<ListItemText primary={t('search.filter.myCalendar')} />
</ListItemButton>
{personalCalendars.map(c => (
<ListItemButton
key={c.id}
selected={filters.searchIn === c.id}
onClick={() => handleSelect(c.id)}
>
<CalendarName calendar={c} />
</ListItemButton>
))}
</List>
</MobileSelector>
)
}
+9 -316
View File
@@ -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 = (
<Box className="loading">
<CircularProgress size={32} />
</Box>
)
} else if (error) {
layout = (
<Box className="error">
<Typography className="error-text">{error}</Typography>
</Box>
)
} else if (!hits) {
layout = (
<Box className="noResults">
<img className="logo" src={logo} alt={t('search.noResults')} />
<Typography sx={styles.M3TitleMedium}>
{t('search.noResults')}
</Typography>
<Typography sx={styles.M3BodyMedium}>
{t('search.noResultsSubtitle')}
</Typography>
</Box>
)
if (isMobile) {
return <MobileSearchResultsPage />
} else {
layout = (
<Box className="search-result-content-body">
<Stack sx={{ mt: 2 }}>
{results.map((r: SearchEventResult, idx: number) => (
<ResultItem
key={`row-${idx}-event-${r.data.uid}`}
eventData={r}
dispatch={dispatch}
/>
))}
</Stack>
</Box>
)
return <DesktopSearchResultsPage />
}
return (
<Box className="search-layout">
<Box className="search-result-content-header">
<Box className="back-button">
<IconButton
onClick={() => dispatch(setView('calendar'))}
aria-label={t('settings.back')}
>
<ArrowBackIcon />
</IconButton>
<Typography variant="h5">{t('search.resultsTitle')}</Typography>
</Box>
</Box>
{layout}
</Box>
)
}
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 (
<>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
p: 3,
borderTop: '1px solid #F3F6F9',
cursor: 'pointer',
'&:hover': { backgroundColor: '#e7e7e7ff' },
alignItems: 'center',
textAlign: 'left',
maxWidth: '80vw'
}}
onClick={() => handleOpenResult(eventData)}
>
<Typography sx={{ ...styles.M3BodyLarge, minWidth: '90px' }}>
{startDate.toLocaleDateString(t('locale'), {
day: '2-digit',
month: 'short',
timeZone
})}
{startDate.toDateString() !== endDate.toDateString() && (
<>
{' - '}
{endDate.toLocaleDateString(t('locale'), {
day: '2-digit',
month: 'short',
timeZone
})}
</>
)}
</Typography>
{!eventData.data.allDay && (
<Typography sx={{ ...styles.M3BodyMedium1, minWidth: '120px' }}>
{startDate.toLocaleTimeString(t('locale'), {
hour: '2-digit',
minute: '2-digit',
timeZone
})}
-
{endDate.toLocaleTimeString(t('locale'), {
hour: '2-digit',
minute: '2-digit',
timeZone
})}
</Typography>
)}
<SquareRoundedIcon
style={{
color: calendarColor ?? defaultColors[0].light,
width: 24,
height: 24,
flexShrink: 0
}}
/>
<Box display="flex" flexDirection="row" gap={1} sx={{ minWidth: 0 }}>
<Typography sx={styles.M3BodyLarge}>
{eventData.data.summary || t('event.untitled')}
</Typography>
{eventData.data.isRecurrentMaster && <RepeatIcon />}
</Box>
{(eventData.data.organizer?.cn || eventData.data.organizer?.email) && (
<Typography
sx={{
...styles.M3BodyMedium1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: '150px',
maxWidth: '150px'
}}
>
{eventData.data.organizer.cn || eventData.data.organizer.email}
</Typography>
)}
{eventData.data?.location && (
<Typography
sx={{
...styles.M3BodyMedium,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: '150px',
maxWidth: '250px'
}}
>
{eventData.data.location}
</Typography>
)}
{eventData.data?.description && (
<Typography
sx={{
...styles.M3BodyMedium3,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
minWidth: 0
}}
>
{eventData.data.description.replace(/\n/g, ' ')}
</Typography>
)}
{eventData.data['x-openpaas-videoconference'] && (
<Button
startIcon={<VideocamIcon />}
sx={{ flexShrink: 0, ml: 'auto' }}
onClick={e => {
e.stopPropagation()
window.open(
eventData.data['x-openpaas-videoconference'],
'_blank',
'noopener,noreferrer'
)
}}
>
{t('eventPreview.joinVideoShort')}
</Button>
)}
</Box>
{calendar && calendar.events[eventData.data.uid] && (
<EventPreviewModal
eventId={eventData.data.uid}
calId={calendar.id}
open={openPreview}
onClose={() => setOpenPreview(false)}
/>
)}
</>
)
}
export default SearchResultsPage
+62 -11
View File
@@ -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<string, unknown>[]
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<string, unknown>[] },
{ 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<SearchEventResult[]>) => {
state.results = action.payload
},
setHits: (state, action: PayloadAction<number>) => {
state.hits = action.payload
},
setSearchQuery: (state, action: PayloadAction<string>) => {
state.searchParams.search = action.payload
},
setFilters: (state, action: PayloadAction<Partial<SearchFilters>>) => {
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
+48
View File
@@ -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)
}
}
}