[#692] split search result page to mobile and desktop (#823)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2026-04-29 09:55:17 +02:00
committed by GitHub
parent 29d82e5ade
commit d66bc59967
7 changed files with 811 additions and 466 deletions
+198 -196
View File
@@ -416,212 +416,214 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
<div className="calendar"> <div className="calendar">
<ImportAlert /> <ImportAlert />
{menubarProps?.isIframe && <Menubar {...menubarProps} />} {menubarProps?.isIframe && <Menubar {...menubarProps} />}
{(isTablet || isMobile) && (
<Fab
color="primary"
aria-label={t('event.createEvent')}
onClick={() => eventHandlers.handleDateSelect(null)}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 20,
borderRadius: '16px'
}}
>
<AddIcon />
</Fab>
)}
{view === 'calendar' && ( {view === 'calendar' && (
<div <>
ref={calendarWrapperRef} {' '}
className={isTablet || isMobile ? 'calendar-swipe-container' : ''} {(isTablet || isMobile) && (
style={{ <Fab
height: '100%', color="primary"
...(isTablet || isMobile ? { touchAction: 'pan-y' } : {}) aria-label={t('event.createEvent')}
}} onClick={() => eventHandlers.handleDateSelect(null)}
{...(isTablet || isMobile ? handlers : {})} sx={{
> position: 'fixed',
<FullCalendar bottom: 24,
key={hiddenDays.join(',')} right: 24,
ref={ref => { zIndex: 20,
if (ref) { borderRadius: '16px'
calendarRef.current = ref.getApi() }}
>
<AddIcon />
</Fab>
)}
<div
ref={calendarWrapperRef}
className={isTablet || isMobile ? 'calendar-swipe-container' : ''}
style={{
height: '100%',
...(isTablet || isMobile ? { touchAction: 'pan-y' } : {})
}}
{...(isTablet || isMobile ? handlers : {})}
>
<FullCalendar
key={hiddenDays.join(',')}
ref={ref => {
calendarRef.current = ref ? ref.getApi() : null
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin
]}
initialView={
currentView ||
(isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek)
} }
}} initialDate={selectedDate}
plugins={[ firstDay={1}
dayGridPlugin, editable={true}
timeGridPlugin, locale={localeMap[lang]}
interactionPlugin, hiddenDays={hiddenDays}
momentTimezonePlugin selectable={true}
]} timeZone={timezone}
initialView={ height="100%"
currentView || select={eventHandlers.handleDateSelect}
(isTablet nowIndicator
? CALENDAR_VIEWS.timeGridDay slotLabelClassNames={arg => [
: CALENDAR_VIEWS.timeGridWeek) updateSlotLabelVisibility(new Date(), arg, timezone)
} ]}
initialDate={selectedDate} nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
firstDay={1} headerToolbar={false}
editable={true} views={{
locale={localeMap[lang]} timeGridWeek: {
hiddenDays={hiddenDays} titleFormat: { month: 'long', year: 'numeric' }
selectable={true} }
timeZone={timezone} }}
height="100%" dayMaxEvents={true}
select={eventHandlers.handleDateSelect} moreLinkClick={handleMoreLinkClick}
nowIndicator events={eventToFullCalendarFormat(
slotLabelClassNames={arg => [ filteredEvents,
updateSlotLabelVisibility(new Date(), arg, timezone) filteredTempEvents,
]} userId,
nowIndicatorContent={viewHandlers.handleNowIndicatorContent} userData?.email,
headerToolbar={false} isPending,
views={{ calendars
timeGridWeek: { )}
titleFormat: { month: 'long', year: 'numeric' } eventOrder={(a: EventApi, b: EventApi) =>
a.extendedProps.priority - b.extendedProps.priority
} }
}} weekNumbers={
dayMaxEvents={true} currentView === CALENDAR_VIEWS.timeGridWeek ||
moreLinkClick={handleMoreLinkClick} currentView === CALENDAR_VIEWS.timeGridDay
events={eventToFullCalendarFormat( }
filteredEvents, weekNumberFormat={{ week: 'long' }}
filteredTempEvents, weekNumberContent={arg => {
userId,
userData?.email,
isPending,
calendars
)}
eventOrder={(a: EventApi, b: EventApi) =>
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === CALENDAR_VIEWS.timeGridWeek ||
currentView === CALENDAR_VIEWS.timeGridDay
}
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
return (
<div className="weekSelector">
{displayWeekNumbers && (
<>
<div>
{t('menubar.views.week')} {arg.num}
</div>
<TimezoneSelector
value={timezone}
referenceDate={
calendarRef.current?.getDate() ?? new Date()
}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</>
)}
</div>
)
}}
dayCellContent={arg => {
const month = arg.date.toLocaleDateString(t('locale'), {
month: 'short',
timeZone: timezone
})
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
return ( return (
<span <div className="weekSelector">
className={`fc-daygrid-day-number ${ {displayWeekNumbers && (
arg.isToday ? 'current-date' : '' <>
}`} <div>
> {t('menubar.views.week')} {arg.num}
{arg.dayNumberText === '1' ? month : ''}{' '} </div>
{arg.dayNumberText} <TimezoneSelector
</span> value={timezone}
referenceDate={
calendarRef.current?.getDate() ?? new Date()
}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</>
)}
</div>
) )
} }}
}} dayCellContent={arg => {
slotDuration="00:30:00" const month = arg.date.toLocaleDateString(t('locale'), {
slotLabelInterval="01:00:00" month: 'short',
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false
}}
datesSet={arg => {
onViewChange?.(arg.view.type)
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// Notify parent about view change
setCurrentView(arg.view.type)
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone timeZone: timezone
}) })
.toUpperCase() if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
return (
return (
<div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
>
<small>{weekDay}</small>
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
<span <span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`} className={`fc-daygrid-day-number ${
arg.isToday ? 'current-date' : ''
}`}
> >
{date} {arg.dayNumberText === '1' ? month : ''}{' '}
{arg.dayNumberText}
</span> </span>
)} )
</div> }
) }}
}} slotDuration="00:30:00"
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} slotLabelInterval="01:00:00"
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} scrollTime="12:00:00"
viewDidMount={viewHandlers.handleViewDidMount} unselectAuto={false}
viewWillUnmount={viewHandlers.handleViewWillUnmount} allDayText=""
eventClick={eventHandlers.handleEventClick} slotLabelFormat={{
eventAllow={eventHandlers.handleEventAllow} hour: '2-digit',
eventDrop={arg => { minute: '2-digit',
void eventHandlers.handleEventDrop(arg) hour12: false
}} }}
eventResize={arg => { datesSet={arg => {
void eventHandlers.handleEventResize(arg) onViewChange?.(arg.view.type)
}} const calendarCurrentDate =
eventContent={viewHandlers.handleEventContent} calendarRef.current?.getDate() || new Date(arg.start)
eventDidMount={viewHandlers.handleEventDidMount} setDisplayedDateAndRange(calendarCurrentDate)
/>
</div> if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate)
}
// Notify parent about view change
setCurrentView(arg.view.type)
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone
})
.toUpperCase()
return (
<div
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
>
<small>{weekDay}</small>
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
<span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
>
{date}
</span>
)}
</div>
)
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
viewDidMount={viewHandlers.handleViewDidMount}
viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventClick={eventHandlers.handleEventClick}
eventAllow={eventHandlers.handleEventAllow}
eventDrop={arg => {
void eventHandlers.handleEventDrop(arg)
}}
eventResize={arg => {
void eventHandlers.handleEventResize(arg)
}}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
</div>
</>
)} )}
{view === 'search' && <SearchResultsPage />} {view === 'search' && <SearchResultsPage />}
<EventPopover <EventPopover
+74 -247
View File
@@ -1,79 +1,23 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks' 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 EventPreviewModal from '@/features/Events/EventPreview'
import { CalendarEvent } from '../Events/EventsTypes' import { defaultColors } from '@/utils/defaultColors'
import { Box, IconButton, Typography } from '@linagora/twake-mui'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import SquareRoundedIcon from '@mui/icons-material/SquareRounded'
import { useI18n } from 'twake-i18n'
import { setView } from '../Settings/SettingsSlice' import { setView } from '../Settings/SettingsSlice'
import { ResultsList } from './ResultsList'
import './searchResult.styl' import './searchResult.styl'
import {
RenderDate,
RenderText,
RenderOrganizer,
RenderTime,
RenderTitle,
RenderVideoJoin
} from './searchResultsComponents'
import { SearchEventResult } from './types/SearchEventResult' import { SearchEventResult } from './types/SearchEventResult'
import { useEventPreview } from './useEventPreview'
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 { export default function DesktopSearchResultsPage(): JSX.Element {
const { t } = useI18n() const { t } = useI18n()
@@ -82,48 +26,6 @@ export default function DesktopSearchResultsPage(): JSX.Element {
state => state.searchResult 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 ( return (
<Box className="search-layout"> <Box className="search-layout">
<Box className="search-result-content-header"> <Box className="search-result-content-header">
@@ -137,23 +39,39 @@ export default function DesktopSearchResultsPage(): JSX.Element {
<Typography variant="h5">{t('search.resultsTitle')}</Typography> <Typography variant="h5">{t('search.resultsTitle')}</Typography>
</Box> </Box>
</Box> </Box>
{layout}
<ResultsList
loading={loading}
error={error}
hits={hits}
results={results}
renderItem={(result, idx) => (
<DesktopResultItem
key={`row-${idx}-event-${result.data.uid}`}
eventData={result}
/>
)}
noResultsTitleSx={{
fontWeight: 500,
textAlign: 'center',
color: 'text.primary'
}}
noResultsSubtitleSx={{ color: 'text.secondary' }}
stackSx={{ mt: 2 }}
/>
</Box> </Box>
) )
} }
function ResultItem({ function DesktopResultItem({
eventData, eventData
dispatch
}: { }: {
eventData: SearchEventResult eventData: SearchEventResult
dispatch: AppDispatch
}): JSX.Element { }): JSX.Element {
const { t } = useI18n() const { t } = useI18n()
const startDate = new Date(eventData.data.start) const startDate = new Date(eventData.data.start)
const endDate = eventData.data.end ? new Date(eventData.data.end) : startDate const endDate = eventData.data.end ? new Date(eventData.data.end) : startDate
const timeZone =
useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone
const calendar = useAppSelector( const calendar = useAppSelector(
state => state =>
state.calendars.list[ state.calendars.list[
@@ -162,32 +80,10 @@ function ResultItem({
) )
const calendarColor = calendar?.color?.light const calendarColor = calendar?.color?.light
const [openPreview, setOpenPreview] = useState(false) const { openPreview, setOpenPreview, handleOpen, timeZone } = useEventPreview(
eventData,
const handleOpenResult = async ( calendar
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 ( return (
<> <>
@@ -197,48 +93,29 @@ function ResultItem({
flexDirection: 'row', flexDirection: 'row',
gap: 2, gap: 2,
p: 3, p: 3,
borderTop: '1px solid #F3F6F9', borderTop: '1px solid',
borderColor: 'divider',
cursor: 'pointer', cursor: 'pointer',
'&:hover': { backgroundColor: '#e7e7e7ff' }, '&:hover': { backgroundColor: 'action.hover' },
alignItems: 'center', alignItems: 'center',
textAlign: 'left', textAlign: 'left',
maxWidth: '80vw' maxWidth: '80vw'
}} }}
onClick={() => void handleOpenResult(eventData)} onClick={() => void handleOpen()}
> >
<Typography sx={{ ...styles.M3BodyLarge, minWidth: '90px' }}> <RenderDate
{startDate.toLocaleDateString(t('locale'), { startDate={startDate}
day: '2-digit', endDate={endDate}
month: 'short', t={t}
timeZone timeZone={timeZone}
})} />
{startDate.toDateString() !== endDate.toDateString() && ( <RenderTime
<> startDate={startDate}
{' - '} endDate={endDate}
{endDate.toLocaleDateString(t('locale'), { allDay={!!eventData.data.allDay}
day: '2-digit', t={t}
month: 'short', timeZone={timeZone}
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 <SquareRoundedIcon
style={{ style={{
color: calendarColor ?? defaultColors[0].light, color: calendarColor ?? defaultColors[0].light,
@@ -247,72 +124,22 @@ function ResultItem({
flexShrink: 0 flexShrink: 0
}} }}
/> />
<Box display="flex" flexDirection="row" gap={1} sx={{ minWidth: 0 }}>
<Typography sx={styles.M3BodyLarge}> <RenderTitle
{eventData.data.summary || t('event.untitled')} summary={eventData.data.summary}
</Typography> isRecurrent={!!eventData.data.isRecurrentMaster}
{eventData.data.isRecurrentMaster && <RepeatIcon />} t={t}
</Box> />
{(eventData.data.organizer?.cn || eventData.data.organizer?.email) && ( <RenderOrganizer organizer={eventData.data.organizer} />
<Typography <RenderText text={eventData.data.location} />
sx={{ <RenderText text={eventData.data.description} />
...styles.M3BodyMedium1, <RenderVideoJoin
whiteSpace: 'nowrap', t={t}
overflow: 'hidden', url={eventData.data['x-openpaas-videoconference']}
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> </Box>
{calendar && calendar.events[eventData.data.uid] && (
{calendar?.events?.[eventData.data.uid] && (
<EventPreviewModal <EventPreviewModal
eventId={eventData.data.uid} eventId={eventData.data.uid}
calId={calendar.id} calId={calendar.id}
+109 -23
View File
@@ -1,10 +1,19 @@
import { useAppSelector } from '@/app/hooks' import { useAppSelector } from '@/app/hooks'
import EventPreviewModal from '@/features/Events/EventPreview'
import { Box } from '@linagora/twake-mui' import { Box } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
import { AttendeesFilter } from './AttendeesFilter' import { AttendeesFilter } from './AttendeesFilter'
import DesktopSearchResultsPage from './DesktopSearchResultsPage' import { normalizeCalendars } from './calendarColorUtils'
import { OrganizersFilter } from './OrganizersFilter' import { OrganizersFilter } from './OrganizersFilter'
import { ResultsList } from './ResultsList'
import { SearchInFilter } from './SearchInFilter' import { SearchInFilter } from './SearchInFilter'
import './searchResult.styl' import './searchResult.styl'
import {
RenderMobileDate,
RenderMobileEventCard
} from './searchResultsComponents'
import { SearchEventResult } from './types/SearchEventResult'
import { useEventPreview } from './useEventPreview'
const MobileSearchResultsPage: React.FC = () => { const MobileSearchResultsPage: React.FC = () => {
const searchResults = useAppSelector(state => state.searchResult) const searchResults = useAppSelector(state => state.searchResult)
@@ -15,37 +24,114 @@ const MobileSearchResultsPage: React.FC = () => {
searchResults.searchParams.filters.attendees.length > 0 searchResults.searchParams.filters.attendees.length > 0
const displaySearch = const displaySearch =
(!!searchResults.hits || !!searchResults.error || searchResults.loading) && !!searchResults.hits ||
!!searchResults.error ||
searchResults.loading ||
hasSearchParams hasSearchParams
return ( return (
<> <Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
}}
>
<FiltersButtons /> <FiltersButtons />
{displaySearch && <DesktopSearchResultsPage />} {displaySearch && (
</> <Box sx={{ m: 2, flex: 1, minHeight: 0, overflow: 'auto' }}>
<ResultsList
loading={searchResults.loading}
error={searchResults.error}
hits={searchResults.hits}
results={searchResults.results}
renderItem={(result, idx) => (
<MobileResultItem
key={`row-${idx}-event-${result.data.uid}`}
eventData={result}
/>
)}
noResultsTitleSx={{ variant: 'h5' }}
noResultsSubtitleSx={{ variant: 'subtitle1' }}
/>
</Box>
)}
</Box>
) )
} }
export default MobileSearchResultsPage export default MobileSearchResultsPage
const FiltersButtons: React.FC = () => { const FiltersButtons: React.FC = () => (
<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>
)
const MobileResultItem: React.FC<{ eventData: SearchEventResult }> = ({
eventData
}) => {
const { t } = useI18n()
const rawCalendars = useAppSelector(state => state.calendars.list)
const calendars = normalizeCalendars(rawCalendars)
const calId = `${eventData.data.userId}/${eventData.data.calendarId}`
const calendar = calendars[calId]
const { openPreview, setOpenPreview, handleOpen, timeZone } = useEventPreview(
eventData,
calendar
)
const startDate = new Date(eventData.data.start)
return ( return (
<Box <>
sx={{ <Box
display: 'flex', sx={{
overflowX: 'auto', display: 'grid',
gap: 2, gridTemplateColumns: '10%',
px: 2, gridAutoFlow: 'column',
py: 1, gap: 2,
scrollbarWidth: 'none', pt: 1,
'&::-webkit-scrollbar': { display: 'none' }, cursor: 'pointer',
backgroundColor: '#FFF', alignItems: 'center',
minHeight: '48px' textAlign: 'left'
}} }}
> onClick={() => void handleOpen()}
<SearchInFilter mode="mobile" /> >
<OrganizersFilter mode="mobile" /> <RenderMobileDate startDate={startDate} t={t} timeZone={timeZone} />
<AttendeesFilter mode="mobile" /> <RenderMobileEventCard
</Box> eventData={eventData}
calendar={calendar}
timeZone={timeZone}
/>
</Box>
{calendar?.events?.[eventData.data.uid] && (
<EventPreviewModal
eventId={eventData.data.uid}
calId={calendar.id}
open={openPreview}
onClose={() => setOpenPreview(false)}
/>
)}
</>
) )
} }
+73
View File
@@ -0,0 +1,73 @@
import { Box, CircularProgress, Stack, Typography } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
import logo from '../../static/noResult-logo.svg'
import { SearchEventResult } from './types/SearchEventResult'
import { SxProps } from '@mui/material'
interface ResultsListProps {
loading: boolean
error: string | null
hits: number | null
results: SearchEventResult[]
renderItem: (result: SearchEventResult, idx: number) => React.ReactNode
noResultsTitleSx?: SxProps
noResultsSubtitleSx?: SxProps
stackSx?: SxProps
}
export const ResultsList: React.FC<ResultsListProps> = ({
loading,
error,
hits,
results,
renderItem,
noResultsTitleSx,
noResultsSubtitleSx,
stackSx
}) => {
const { t } = useI18n()
if (loading) {
return (
<Box className="loading">
<CircularProgress size={32} />
</Box>
)
}
if (error) {
return (
<Box className="error">
<Typography className="error-text">{error}</Typography>
</Box>
)
}
if (!hits) {
return (
<Box className="noResults">
<img className="logo" src={logo} alt={t('search.noResults')} />
<Typography
sx={noResultsTitleSx}
variant={noResultsTitleSx ? undefined : 'h5'}
>
{t('search.noResults')}
</Typography>
<Typography
sx={noResultsSubtitleSx}
variant={noResultsSubtitleSx ? undefined : 'subtitle1'}
>
{t('search.noResultsSubtitle')}
</Typography>
</Box>
)
}
return (
<Box className="search-result-content-body">
<Stack sx={stackSx}>
{results.map((result, idx) => renderItem(result, idx))}
</Stack>
</Box>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Calendar } from '@/features/Calendars/CalendarTypes'
export type NormalizedColor = { light: string; dark: string }
export type NormalizedCalendar = Omit<Calendar, 'color'> & {
color: NormalizedColor
}
/**
* EventChip expects calendar.color to be { light: string; dark: string }.
* The Redux store may hold color as a plain string — normalize it here.
*/
export function normalizeCalendarColor(
color: Calendar['color']
): NormalizedColor {
if (
color &&
typeof color === 'object' &&
'light' in color &&
'dark' in color
) {
return color as NormalizedColor
}
const hex = typeof color === 'string' ? color : '#ffffff'
return { light: hex, dark: hex }
}
/**
* Returns a copy of the calendars map with every color normalized to { light, dark }.
*/
export function normalizeCalendars(
calendars: Record<string, Calendar>
): Record<string, NormalizedCalendar> {
return Object.fromEntries(
Object.entries(calendars).map(([key, cal]) => [
key,
{ ...cal, color: normalizeCalendarColor(cal.color) }
])
)
}
@@ -0,0 +1,272 @@
import {
getBestColor,
getTitleStyle
} from '@/components/Event/EventChip/EventChipUtils'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { defaultColors } from '@/utils/defaultColors'
import { Box, Button, Card, CardHeader, Typography } from '@linagora/twake-mui'
import RepeatIcon from '@mui/icons-material/Repeat'
import VideocamIcon from '@mui/icons-material/Videocam'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { SearchEventResult } from './types/SearchEventResult'
interface DateProps {
startDate: Date
endDate: Date
t: (key: string) => string
timeZone: string
}
interface TimeProps {
startDate: Date
endDate: Date
allDay: boolean
t: (key: string) => string
timeZone: string
}
interface TitleProps {
summary?: string
isRecurrent: boolean
t: (key: string) => string
}
interface OrganizerProps {
organizer?: {
cn?: string
email?: string
}
}
interface VideoJoinProps {
url?: string
t: (key: string) => string
}
interface MobileDateProps {
startDate: Date
t: (key: string) => string
timeZone: string
}
interface MobileEventCardProps {
eventData: SearchEventResult
calendar: Calendar | undefined
timeZone: string
}
export const RenderDate: React.FC<DateProps> = ({
startDate,
endDate,
t,
timeZone
}) => {
const dayKey = (d: Date): string =>
d.toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone
})
return (
<Typography variant="h4" sx={{ fontWeight: 400, minWidth: '90px' }}>
{startDate.toLocaleDateString(t('locale'), {
day: '2-digit',
month: 'short',
timeZone
})}
{dayKey(startDate) !== dayKey(endDate) && (
<>
{' - '}
{endDate.toLocaleDateString(t('locale'), {
day: '2-digit',
month: 'short',
timeZone
})}
</>
)}
</Typography>
)
}
export const RenderTime: React.FC<TimeProps> = ({
startDate,
endDate,
allDay,
t,
timeZone
}) => {
if (allDay) return null
return (
<Typography variant="body1" sx={{ 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>
)
}
export const RenderTitle: React.FC<TitleProps> = ({
summary,
isRecurrent,
t
}) => (
<Box display="flex" flexDirection="row" gap={1} sx={{ minWidth: 0 }}>
<Typography variant="h4" sx={{ fontWeight: 400 }}>
{summary || t('event.untitled')}
</Typography>
{isRecurrent && <RepeatIcon />}
</Box>
)
export const RenderOrganizer: React.FC<OrganizerProps> = ({ organizer }) => {
if (!organizer?.cn && !organizer?.email) return null
return (
<Typography
variant="body1"
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: '150px',
maxWidth: '150px'
}}
>
{organizer.cn || organizer.email}
</Typography>
)
}
export const RenderText: React.FC<{ text?: string }> = ({ text }) => {
if (!text) return null
return (
<Typography
variant="body2"
sx={{
color: 'text.secondary',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: '150px',
maxWidth: '250px'
}}
>
{text.replace(/\n/g, ' ')}
</Typography>
)
}
export const RenderVideoJoin: React.FC<VideoJoinProps> = ({ url, t }) => {
if (!url) return null
return (
<Button
startIcon={<VideocamIcon />}
sx={{ flexShrink: 0, ml: 'auto' }}
onClick={e => {
e.stopPropagation()
try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
return
window.open(parsed.toString(), '_blank', 'noopener,noreferrer')
} catch {
return
}
}}
>
{t('eventPreview.joinVideoShort')}
</Button>
)
}
export const RenderMobileDate: React.FC<MobileDateProps> = ({
startDate,
t,
timeZone
}) => (
<Box sx={{ width: '100%' }}>
<Typography variant="h4" sx={{ fontWeight: 400 }}>
{startDate.toLocaleDateString(t('locale'), { day: '2-digit', timeZone })}
</Typography>
<Typography variant="caption" color="text.secondary">
{startDate
.toLocaleDateString(t('locale'), { weekday: 'short', timeZone })
.toUpperCase()}
</Typography>
</Box>
)
export const RenderMobileEventCard: React.FC<MobileEventCardProps> = ({
eventData,
calendar,
timeZone
}) => {
const { t } = useI18n()
if (!calendar) return null
const startDate = new Date(eventData.data.start)
const bestColor = calendar.color
? getBestColor(calendar.color as { light: string; dark: string })
: defaultColors[0].dark
const titleStyle = getTitleStyle(bestColor, 'ACCEPTED', calendar, false)
return (
<Card
variant="outlined"
sx={{
height: 'stretch',
width: '100%',
borderRadius: '8px',
p: 1,
boxShadow: 'none',
backgroundColor: calendar?.color?.light,
color: calendar?.color?.dark,
border: '1px solid',
borderColor: 'background.paper',
display: 'flex'
}}
data-testid={`event-card-${eventData.data.uid}`}
>
<CardHeader
sx={{ p: '0px', '& .MuiCardHeader-content': { overflow: 'hidden' } }}
title={
<Typography variant="body2" noWrap style={titleStyle}>
{eventData.data.summary || t('event.untitled')}
</Typography>
}
subheader={
!eventData.data.allDay && (
<Typography
style={{
color: titleStyle.color,
opacity: '70%',
fontFamily: 'Inter',
fontWeight: '500',
fontSize: '10px',
lineHeight: '16px',
letterSpacing: '0%',
verticalAlign: 'middle'
}}
>
{startDate.toLocaleTimeString(t('locale'), {
hour: '2-digit',
minute: '2-digit',
timeZone: timeZone
})}
</Typography>
)
}
/>
</Card>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { browserDefaultTimeZone } from '@/utils/timezone'
import { useState } from 'react'
import { getEventAsync } from '../Calendars/services'
import { CalendarEvent } from '../Events/EventsTypes'
import { SearchEventResult } from './types/SearchEventResult'
export function useEventPreview(
eventData: SearchEventResult,
calendar: Calendar | undefined
): {
openPreview: boolean
setOpenPreview: (b: boolean) => void
handleOpen: () => Promise<void>
timeZone: string
} {
const dispatch = useAppDispatch()
const timeZone =
useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone
const [openPreview, setOpenPreview] = useState(false)
const handleOpen = async (): Promise<void> => {
if (!calendar) return
const event: CalendarEvent = {
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
}
await dispatch(getEventAsync(event))
setOpenPreview(true)
}
return { openPreview, setOpenPreview, handleOpen, timeZone }
}