* [#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:
@@ -26,7 +26,16 @@ describe('SearchSlice', () => {
|
|||||||
results: [],
|
results: [],
|
||||||
hits: 0,
|
hits: 0,
|
||||||
error: null,
|
error: null,
|
||||||
loading: false
|
loading: false,
|
||||||
|
searchParams: {
|
||||||
|
filters: {
|
||||||
|
attendees: [],
|
||||||
|
keywords: '',
|
||||||
|
organizers: [],
|
||||||
|
searchIn: 'my-calendars'
|
||||||
|
},
|
||||||
|
search: ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
||||||
import { selectCalendars } from '@/app/selectors/selectCalendars'
|
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 { setView } from '@/features/Settings/SettingsSlice'
|
||||||
import { userAttendee } from '@/features/User/models/attendee'
|
import { userAttendee } from '@/features/User/models/attendee'
|
||||||
import { createAttendee } from '@/features/User/models/attendee.mapper'
|
import { createAttendee } from '@/features/User/models/attendee.mapper'
|
||||||
@@ -11,16 +20,11 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CardActions,
|
CardActions,
|
||||||
CardContent,
|
CardContent,
|
||||||
Divider,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Popover,
|
Popover,
|
||||||
Select,
|
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
|
||||||
type AutocompleteRenderInputParams
|
type AutocompleteRenderInputParams
|
||||||
} from '@linagora/twake-mui'
|
} from '@linagora/twake-mui'
|
||||||
import HighlightOffIcon from '@mui/icons-material/HighlightOff'
|
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 TuneIcon from '@mui/icons-material/Tune'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useI18n } from 'twake-i18n'
|
import { useI18n } from 'twake-i18n'
|
||||||
import UserSearch from '../Attendees/AttendeeSearch'
|
|
||||||
import { PeopleSearch } from '../Attendees/PeopleSearch'
|
import { PeopleSearch } from '../Attendees/PeopleSearch'
|
||||||
import { User } from '../Attendees/types'
|
import { User } from '../Attendees/types'
|
||||||
import { CalendarItemList } from '../Calendar/CalendarItemList'
|
|
||||||
|
|
||||||
const SEARCH_OBJECT_TYPES = ['user', 'contact']
|
const SEARCH_OBJECT_TYPES = ['user', 'contact']
|
||||||
|
|
||||||
@@ -45,18 +47,14 @@ const SearchBar: React.FC<{
|
|||||||
const personnalCalendars = userId
|
const personnalCalendars = userId
|
||||||
? calendars.filter(c => extractEventBaseUuid(c.id) === userId)
|
? calendars.filter(c => extractEventBaseUuid(c.id) === userId)
|
||||||
: []
|
: []
|
||||||
|
const filters = useAppSelector(
|
||||||
|
state => state.searchResult.searchParams.filters
|
||||||
|
)
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedContacts, setSelectedContacts] = useState<User[]>([])
|
const [selectedContacts, setSelectedContacts] = useState<User[]>([])
|
||||||
const [extended, setExtended] = useState(false)
|
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 [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||||
const filterOpen = Boolean(anchorEl)
|
const filterOpen = Boolean(anchorEl)
|
||||||
const [filterError, setFilterError] = useState(false)
|
const [filterError, setFilterError] = useState(false)
|
||||||
@@ -72,6 +70,7 @@ const SearchBar: React.FC<{
|
|||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [popperAnchor, setPopperAnchor] = useState<HTMLDivElement | null>(null)
|
||||||
const shouldCollapseRef = useRef(false)
|
const shouldCollapseRef = useRef(false)
|
||||||
|
|
||||||
type FilterField = 'searchIn' | 'keywords' | 'organizers' | 'attendees'
|
type FilterField = 'searchIn' | 'keywords' | 'organizers' | 'attendees'
|
||||||
@@ -79,7 +78,7 @@ const SearchBar: React.FC<{
|
|||||||
field: FilterField,
|
field: FilterField,
|
||||||
value: string | userAttendee[]
|
value: string | userAttendee[]
|
||||||
): void => {
|
): void => {
|
||||||
setFilters(prev => ({ ...prev, [field]: value }))
|
dispatch(setFilters({ ...filters, [field]: value }))
|
||||||
if (field === 'organizers') {
|
if (field === 'organizers') {
|
||||||
setSelectedContacts(
|
setSelectedContacts(
|
||||||
(value as userAttendee[]).map((a: userAttendee) => ({
|
(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 => {
|
const handleClearFilters = (): void => {
|
||||||
setFilters({
|
dispatch(clearFilters())
|
||||||
searchIn: 'my-calendars',
|
|
||||||
keywords: '',
|
|
||||||
organizers: [] as userAttendee[],
|
|
||||||
attendees: [] as userAttendee[]
|
|
||||||
})
|
|
||||||
setAnchorEl(null)
|
setAnchorEl(null)
|
||||||
setFilterError(false)
|
setFilterError(false)
|
||||||
}
|
}
|
||||||
@@ -181,7 +120,12 @@ const SearchBar: React.FC<{
|
|||||||
attendees: userAttendee[]
|
attendees: userAttendee[]
|
||||||
}
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const cleanedQuery = buildQuery(searchQuery, filters)
|
const cleanedQuery = buildQuery(
|
||||||
|
searchQuery,
|
||||||
|
filters,
|
||||||
|
calendars.map(calendar => calendar.id),
|
||||||
|
personnalCalendars.map(calendar => calendar.id)
|
||||||
|
)
|
||||||
if (cleanedQuery) {
|
if (cleanedQuery) {
|
||||||
await dispatch(searchEventsAsync(cleanedQuery))
|
await dispatch(searchEventsAsync(cleanedQuery))
|
||||||
dispatch(setView('search'))
|
dispatch(setView('search'))
|
||||||
@@ -228,7 +172,12 @@ const SearchBar: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
ref={containerRef}
|
ref={(el: HTMLDivElement | null) => {
|
||||||
|
containerRef.current = el
|
||||||
|
if (el && !popperAnchor) {
|
||||||
|
setPopperAnchor(el)
|
||||||
|
}
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: extended ? '100%' : 'auto',
|
width: extended ? '100%' : 'auto',
|
||||||
@@ -252,7 +201,7 @@ const SearchBar: React.FC<{
|
|||||||
onToggleEventPreview={() => {}}
|
onToggleEventPreview={() => {}}
|
||||||
customSlotProps={{
|
customSlotProps={{
|
||||||
popper: {
|
popper: {
|
||||||
anchorEl: containerRef.current,
|
anchorEl: popperAnchor,
|
||||||
placement: 'bottom-start',
|
placement: 'bottom-start',
|
||||||
sx: {
|
sx: {
|
||||||
minWidth: searchWidth,
|
minWidth: searchWidth,
|
||||||
@@ -399,131 +348,20 @@ const SearchBar: React.FC<{
|
|||||||
<Card sx={{ p: 2, pb: 1 }}>
|
<Card sx={{ p: 2, pb: 1 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Box
|
<SearchInFilter mode="popover" />
|
||||||
sx={{
|
<KeywordsFilter
|
||||||
display: 'grid',
|
mode="popover"
|
||||||
gridTemplateColumns: '140px 1fr',
|
error={filterError}
|
||||||
gap: 2,
|
onErrorClear={() => setFilterError(false)}
|
||||||
alignItems: 'center'
|
/>
|
||||||
}}
|
<OrganizersFilter
|
||||||
>
|
mode="popover"
|
||||||
<InputLabel id="search-in" sx={{ m: 0 }}>
|
onErrorClear={() => setFilterError(false)}
|
||||||
{t('search.searchIn')}
|
/>
|
||||||
</InputLabel>
|
<AttendeesFilter
|
||||||
<Select
|
mode="popover"
|
||||||
displayEmpty
|
onErrorClear={() => setFilterError(false)}
|
||||||
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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</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
|
||||||
@@ -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 { 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 ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'
|
||||||
import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp'
|
import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp'
|
||||||
import MenuIcon from '@mui/icons-material/Menu'
|
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 React, { useState } from 'react'
|
||||||
import { useI18n } from 'twake-i18n'
|
import { useI18n } from 'twake-i18n'
|
||||||
import SearchBar from './EventSearchBar'
|
|
||||||
import './Menubar.styl'
|
|
||||||
import { DatePickerMobile } from './components/DatePickerMobile'
|
import { DatePickerMobile } from './components/DatePickerMobile'
|
||||||
import { Typography } from '@mui/material'
|
|
||||||
import { SmallNavigationControls } from './components/SmallNavigationControls'
|
import { SmallNavigationControls } from './components/SmallNavigationControls'
|
||||||
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
import './Menubar.styl'
|
||||||
import { setView } from '@/features/Settings/SettingsSlice'
|
import MobileSearchBar from './MobileEventSearchBar'
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
|
||||||
|
|
||||||
export type MobileMenubarProps = {
|
export interface MobileMenubarProps {
|
||||||
calendarRef: React.RefObject<CalendarApi | null>
|
calendarRef: React.RefObject<CalendarApi | null>
|
||||||
currentDate: Date
|
currentDate: Date
|
||||||
onDateChange?: (date: Date) => void
|
onDateChange?: (date: Date) => void
|
||||||
@@ -34,7 +36,10 @@ export const MobileMenubar: React.FC<MobileMenubarProps> = ({
|
|||||||
|
|
||||||
const view = useAppSelector(state => state.settings.view)
|
const view = useAppSelector(state => state.settings.view)
|
||||||
|
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
const [openDatePicker, setOpenDatePicker] = useState(false)
|
const [openDatePicker, setOpenDatePicker] = useState(false)
|
||||||
|
const [openEventSearch, setOpenEventSearch] = useState(false)
|
||||||
|
|
||||||
// Use i18n for month names instead of date-fns
|
// Use i18n for month names instead of date-fns
|
||||||
const monthIndex = currentDate.getMonth()
|
const monthIndex = currentDate.getMonth()
|
||||||
@@ -56,6 +61,25 @@ export const MobileMenubar: React.FC<MobileMenubarProps> = ({
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
dispatch(setView('calendar'))
|
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 (
|
return (
|
||||||
@@ -122,7 +146,17 @@ export const MobileMenubar: React.FC<MobileMenubarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="right-menu">
|
<div className="right-menu">
|
||||||
<div className="search-container">
|
<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>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
|
||||||
styled,
|
|
||||||
ButtonBase,
|
ButtonBase,
|
||||||
SwipeableDrawer
|
styled,
|
||||||
|
SwipeableDrawer,
|
||||||
|
Typography
|
||||||
} from '@linagora/twake-mui'
|
} from '@linagora/twake-mui'
|
||||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'
|
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'
|
||||||
import React, { forwardRef, useImperativeHandle, useState } from 'react'
|
import React, { forwardRef, useImperativeHandle, useState } from 'react'
|
||||||
@@ -19,6 +19,7 @@ const StyledSwipeableDrawer = styled(SwipeableDrawer)(({ theme }) => ({
|
|||||||
|
|
||||||
const SelectorButton = styled(ButtonBase)(({ theme }) => ({
|
const SelectorButton = styled(ButtonBase)(({ theme }) => ({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -36,7 +37,7 @@ const SelectorButton = styled(ButtonBase)(({ theme }) => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
interface MobileSelectorProps {
|
interface MobileSelectorProps {
|
||||||
displayText: string
|
displayText: React.ReactNode
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
bottomSheetRef?: React.RefObject<HTMLDivElement>
|
bottomSheetRef?: React.RefObject<HTMLDivElement>
|
||||||
paperRef?: React.RefObject<HTMLDivElement>
|
paperRef?: React.RefObject<HTMLDivElement>
|
||||||
@@ -44,64 +45,55 @@ interface MobileSelectorProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) => React.ReactNode
|
}) => React.ReactNode
|
||||||
|
fullscreen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileSelector = forwardRef<
|
export const MobileSelector = forwardRef<
|
||||||
MobileSelectorHandle,
|
MobileSelectorHandle,
|
||||||
MobileSelectorProps
|
MobileSelectorProps
|
||||||
>(
|
>(({ displayText, children, bottomSheetChildren, fullscreen }, ref) => {
|
||||||
(
|
const [open, setOpen] = useState(false)
|
||||||
{ displayText, children, bottomSheetRef, paperRef, bottomSheetChildren },
|
const onClose = (): void => setOpen(false)
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
const onClose = (): void => {
|
useImperativeHandle(ref, () => ({ open, onClose }))
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
return (
|
||||||
open,
|
<>
|
||||||
onClose
|
<SelectorButton onClick={() => setOpen(true)}>
|
||||||
}))
|
{typeof displayText === 'string' ? (
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SelectorButton onClick={() => setOpen(true)}>
|
|
||||||
<Typography variant="body1">{displayText}</Typography>
|
<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'
|
MobileSelector.displayName = 'MobileSelector'
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,323 +1,16 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
|
||||||
import { AppDispatch } from '@/app/store'
|
import DesktopSearchResultsPage from './DesktopSearchResultsPage'
|
||||||
import { defaultColors } from '@/utils/defaultColors'
|
import MobileSearchResultsPage from './MobileSearchResultsPage'
|
||||||
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 './searchResult.styl'
|
||||||
import { SearchEventResult } from './types/SearchEventResult'
|
|
||||||
|
|
||||||
const styles = {
|
const SearchResultsPage: React.FC = () => {
|
||||||
M3BodyLarge: {
|
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||||
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 SearchResultsPage() {
|
if (isMobile) {
|
||||||
const { t } = useI18n()
|
return <MobileSearchResultsPage />
|
||||||
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 {
|
} else {
|
||||||
layout = (
|
return <DesktopSearchResultsPage />
|
||||||
<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({
|
export default SearchResultsPage
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
import { formatReduxError } from '@/utils/errorUtils'
|
import { formatReduxError } from '@/utils/errorUtils'
|
||||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
import { searchEvent } from '../Events/EventApi'
|
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 {
|
export interface SearchResultsState {
|
||||||
|
searchParams: SearchParams
|
||||||
hits: number
|
hits: number
|
||||||
results: Record<string, unknown>[]
|
results: SearchEventResult[]
|
||||||
error: string | null
|
error: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: SearchResultsState = {
|
export const defaultSearchParams: SearchParams = {
|
||||||
results: [],
|
search: '',
|
||||||
hits: 0,
|
filters: {
|
||||||
error: null,
|
searchIn: 'my-calendars',
|
||||||
loading: false
|
keywords: '',
|
||||||
|
organizers: [],
|
||||||
|
attendees: []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchEventsAsync = createAsyncThunk<
|
export const searchEventsAsync = createAsyncThunk<
|
||||||
{ hits: number; events: Record<string, unknown>[] },
|
{ hits: number; events: SearchEventResult[] },
|
||||||
{
|
{
|
||||||
search: string
|
search: string
|
||||||
filters: {
|
filters: {
|
||||||
@@ -34,7 +52,7 @@ export const searchEventsAsync = createAsyncThunk<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hits: Number(response._total_hits),
|
hits: Number(response._total_hits),
|
||||||
events: response._embedded?.events ?? []
|
events: (response._embedded?.events ?? []) as SearchEventResult[]
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as { response?: { status?: number } }
|
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({
|
export const searchResultsSlice = createSlice({
|
||||||
name: 'settings',
|
name: 'searchResult',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setResults: (state, action: PayloadAction<[]>) => {
|
setResults: (state, action: PayloadAction<SearchEventResult[]>) => {
|
||||||
state.results = action.payload
|
state.results = action.payload
|
||||||
},
|
},
|
||||||
setHits: (state, action: PayloadAction<number>) => {
|
setHits: (state, action: PayloadAction<number>) => {
|
||||||
state.hits = action.payload
|
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 => {
|
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
|
export default searchResultsSlice.reducer
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user