From d66bc59967d9f1b365a6d8ebc452b3d5191db04a Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:55:17 +0200 Subject: [PATCH] [#692] split search result page to mobile and desktop (#823) Co-authored-by: Camille Moussu --- src/components/Calendar/Calendar.tsx | 394 +++++++++--------- .../Search/DesktopSearchResultsPage.tsx | 321 ++++---------- .../Search/MobileSearchResultsPage.tsx | 132 +++++- src/features/Search/ResultsList.tsx | 73 ++++ src/features/Search/calendarColorUtils.ts | 39 ++ .../Search/searchResultsComponents.tsx | 272 ++++++++++++ src/features/Search/useEventPreview.ts | 46 ++ 7 files changed, 811 insertions(+), 466 deletions(-) create mode 100644 src/features/Search/ResultsList.tsx create mode 100644 src/features/Search/calendarColorUtils.ts create mode 100644 src/features/Search/searchResultsComponents.tsx create mode 100644 src/features/Search/useEventPreview.ts diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 474c361..a64d24c 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -416,212 +416,214 @@ const CalendarApp: React.FC = ({
{menubarProps?.isIframe && } - {(isTablet || isMobile) && ( - eventHandlers.handleDateSelect(null)} - sx={{ - position: 'fixed', - bottom: 24, - right: 24, - zIndex: 20, - borderRadius: '16px' - }} - > - - - )} + {view === 'calendar' && ( -
- { - if (ref) { - calendarRef.current = ref.getApi() + <> + {' '} + {(isTablet || isMobile) && ( + eventHandlers.handleDateSelect(null)} + sx={{ + position: 'fixed', + bottom: 24, + right: 24, + zIndex: 20, + borderRadius: '16px' + }} + > + + + )} +
+ { + calendarRef.current = ref ? ref.getApi() : null + }} + plugins={[ + dayGridPlugin, + timeGridPlugin, + interactionPlugin, + momentTimezonePlugin + ]} + initialView={ + currentView || + (isTablet + ? CALENDAR_VIEWS.timeGridDay + : CALENDAR_VIEWS.timeGridWeek) } - }} - plugins={[ - dayGridPlugin, - timeGridPlugin, - interactionPlugin, - momentTimezonePlugin - ]} - initialView={ - currentView || - (isTablet - ? CALENDAR_VIEWS.timeGridDay - : CALENDAR_VIEWS.timeGridWeek) - } - initialDate={selectedDate} - firstDay={1} - editable={true} - locale={localeMap[lang]} - hiddenDays={hiddenDays} - selectable={true} - timeZone={timezone} - height="100%" - select={eventHandlers.handleDateSelect} - nowIndicator - slotLabelClassNames={arg => [ - updateSlotLabelVisibility(new Date(), arg, timezone) - ]} - nowIndicatorContent={viewHandlers.handleNowIndicatorContent} - headerToolbar={false} - views={{ - timeGridWeek: { - titleFormat: { month: 'long', year: 'numeric' } + initialDate={selectedDate} + firstDay={1} + editable={true} + locale={localeMap[lang]} + hiddenDays={hiddenDays} + selectable={true} + timeZone={timezone} + height="100%" + select={eventHandlers.handleDateSelect} + nowIndicator + slotLabelClassNames={arg => [ + updateSlotLabelVisibility(new Date(), arg, timezone) + ]} + nowIndicatorContent={viewHandlers.handleNowIndicatorContent} + headerToolbar={false} + views={{ + timeGridWeek: { + titleFormat: { month: 'long', year: 'numeric' } + } + }} + dayMaxEvents={true} + moreLinkClick={handleMoreLinkClick} + events={eventToFullCalendarFormat( + filteredEvents, + filteredTempEvents, + userId, + userData?.email, + isPending, + calendars + )} + eventOrder={(a: EventApi, b: EventApi) => + a.extendedProps.priority - b.extendedProps.priority } - }} - dayMaxEvents={true} - moreLinkClick={handleMoreLinkClick} - events={eventToFullCalendarFormat( - filteredEvents, - filteredTempEvents, - 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 ( -
- {displayWeekNumbers && ( - <> -
- {t('menubar.views.week')} {arg.num} -
- - dispatch(setTimeZone(newTimezone)) - } - /> - - )} -
- ) - }} - dayCellContent={arg => { - const month = arg.date.toLocaleDateString(t('locale'), { - month: 'short', - timeZone: timezone - }) - if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { + weekNumbers={ + currentView === CALENDAR_VIEWS.timeGridWeek || + currentView === CALENDAR_VIEWS.timeGridDay + } + weekNumberFormat={{ week: 'long' }} + weekNumberContent={arg => { return ( - - {arg.dayNumberText === '1' ? month : ''}{' '} - {arg.dayNumberText} - +
+ {displayWeekNumbers && ( + <> +
+ {t('menubar.views.week')} {arg.num} +
+ + dispatch(setTimeZone(newTimezone)) + } + /> + + )} +
) - } - }} - slotDuration="00:30:00" - slotLabelInterval="01:00:00" - 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', + }} + dayCellContent={arg => { + const month = arg.date.toLocaleDateString(t('locale'), { + month: 'short', timeZone: timezone }) - .toUpperCase() - - return ( -
- {weekDay} - {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( + if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { + return ( - {date} + {arg.dayNumberText === '1' ? month : ''}{' '} + {arg.dayNumberText} - )} -
- ) - }} - 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} - /> -
+ ) + } + }} + slotDuration="00:30:00" + slotLabelInterval="01:00:00" + 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 + }) + .toUpperCase() + + return ( +
+ {weekDay} + {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( + + {date} + + )} +
+ ) + }} + 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} + /> +
+ )} {view === 'search' && } state.searchResult ) - let layout - - if (loading) { - layout = ( - - - - ) - } else if (error) { - layout = ( - - {error} - - ) - } else if (!hits) { - layout = ( - - {t('search.noResults')} - - {t('search.noResults')} - - - {t('search.noResultsSubtitle')} - - - ) - } else { - layout = ( - - - {results.map((r: SearchEventResult, idx: number) => ( - - ))} - - - ) - } - return ( @@ -137,23 +39,39 @@ export default function DesktopSearchResultsPage(): JSX.Element { {t('search.resultsTitle')} - {layout} + + ( + + )} + noResultsTitleSx={{ + fontWeight: 500, + textAlign: 'center', + color: 'text.primary' + }} + noResultsSubtitleSx={{ color: 'text.secondary' }} + stackSx={{ mt: 2 }} + /> ) } -function ResultItem({ - eventData, - dispatch +function DesktopResultItem({ + eventData }: { 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[ @@ -162,32 +80,10 @@ function ResultItem({ ) const calendarColor = calendar?.color?.light - const [openPreview, setOpenPreview] = useState(false) - - const handleOpenResult = async ( - eventData: SearchEventResult - ): Promise => { - if (calendar) { - const event = { - URL: eventData._links.self.href, - calId: calendar.id, - uid: eventData.data.uid, - start: eventData.data.start, - end: eventData.data.end, - allday: eventData.data.allDay, - attendee: eventData.data.attendees, - class: eventData.data.class, - description: eventData.data.description, - stamp: eventData.data.dtstamp, - location: eventData.data.location, - organizer: eventData.data.organizer, - title: eventData.data.summary, - timezone: timeZone - } as CalendarEvent - await dispatch(getEventAsync(event)) - setOpenPreview(true) - } - } + const { openPreview, setOpenPreview, handleOpen, timeZone } = useEventPreview( + eventData, + calendar + ) return ( <> @@ -197,48 +93,29 @@ function ResultItem({ flexDirection: 'row', gap: 2, p: 3, - borderTop: '1px solid #F3F6F9', + borderTop: '1px solid', + borderColor: 'divider', cursor: 'pointer', - '&:hover': { backgroundColor: '#e7e7e7ff' }, + '&:hover': { backgroundColor: 'action.hover' }, alignItems: 'center', textAlign: 'left', maxWidth: '80vw' }} - onClick={() => void handleOpenResult(eventData)} + onClick={() => void handleOpen()} > - - {startDate.toLocaleDateString(t('locale'), { - day: '2-digit', - month: 'short', - timeZone - })} - {startDate.toDateString() !== endDate.toDateString() && ( - <> - {' - '} - {endDate.toLocaleDateString(t('locale'), { - day: '2-digit', - month: 'short', - timeZone - })} - - )} - - {!eventData.data.allDay && ( - - {startDate.toLocaleTimeString(t('locale'), { - hour: '2-digit', - minute: '2-digit', - timeZone - })} - - - {endDate.toLocaleTimeString(t('locale'), { - hour: '2-digit', - minute: '2-digit', - timeZone - })} - - )} - + + - - - {eventData.data.summary || t('event.untitled')} - - {eventData.data.isRecurrentMaster && } - - {(eventData.data.organizer?.cn || eventData.data.organizer?.email) && ( - - {eventData.data.organizer.cn || eventData.data.organizer.email} - - )} - {eventData.data?.location && ( - - {eventData.data.location} - - )} - {eventData.data?.description && ( - - {eventData.data.description.replace(/\n/g, ' ')} - - )} - {eventData.data['x-openpaas-videoconference'] && ( - - )} + + + + + + - {calendar && calendar.events[eventData.data.uid] && ( + + {calendar?.events?.[eventData.data.uid] && ( { const searchResults = useAppSelector(state => state.searchResult) @@ -15,37 +24,114 @@ const MobileSearchResultsPage: React.FC = () => { searchResults.searchParams.filters.attendees.length > 0 const displaySearch = - (!!searchResults.hits || !!searchResults.error || searchResults.loading) && + !!searchResults.hits || + !!searchResults.error || + searchResults.loading || hasSearchParams return ( - <> + - {displaySearch && } - + {displaySearch && ( + + ( + + )} + noResultsTitleSx={{ variant: 'h5' }} + noResultsSubtitleSx={{ variant: 'subtitle1' }} + /> + + )} + ) } export default MobileSearchResultsPage -const FiltersButtons: React.FC = () => { +const FiltersButtons: React.FC = () => ( + + + + + +) + +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 ( - - - - - + <> + void handleOpen()} + > + + + + + {calendar?.events?.[eventData.data.uid] && ( + setOpenPreview(false)} + /> + )} + ) } diff --git a/src/features/Search/ResultsList.tsx b/src/features/Search/ResultsList.tsx new file mode 100644 index 0000000..9ed7e9c --- /dev/null +++ b/src/features/Search/ResultsList.tsx @@ -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 = ({ + loading, + error, + hits, + results, + renderItem, + noResultsTitleSx, + noResultsSubtitleSx, + stackSx +}) => { + const { t } = useI18n() + + if (loading) { + return ( + + + + ) + } + + if (error) { + return ( + + {error} + + ) + } + + if (!hits) { + return ( + + {t('search.noResults')} + + {t('search.noResults')} + + + {t('search.noResultsSubtitle')} + + + ) + } + + return ( + + + {results.map((result, idx) => renderItem(result, idx))} + + + ) +} diff --git a/src/features/Search/calendarColorUtils.ts b/src/features/Search/calendarColorUtils.ts new file mode 100644 index 0000000..f748a3b --- /dev/null +++ b/src/features/Search/calendarColorUtils.ts @@ -0,0 +1,39 @@ +import { Calendar } from '@/features/Calendars/CalendarTypes' + +export type NormalizedColor = { light: string; dark: string } +export type NormalizedCalendar = Omit & { + 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 +): Record { + return Object.fromEntries( + Object.entries(calendars).map(([key, cal]) => [ + key, + { ...cal, color: normalizeCalendarColor(cal.color) } + ]) + ) +} diff --git a/src/features/Search/searchResultsComponents.tsx b/src/features/Search/searchResultsComponents.tsx new file mode 100644 index 0000000..c311d39 --- /dev/null +++ b/src/features/Search/searchResultsComponents.tsx @@ -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 = ({ + startDate, + endDate, + t, + timeZone +}) => { + const dayKey = (d: Date): string => + d.toLocaleDateString('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone + }) + return ( + + {startDate.toLocaleDateString(t('locale'), { + day: '2-digit', + month: 'short', + timeZone + })} + {dayKey(startDate) !== dayKey(endDate) && ( + <> + {' - '} + {endDate.toLocaleDateString(t('locale'), { + day: '2-digit', + month: 'short', + timeZone + })} + + )} + + ) +} + +export const RenderTime: React.FC = ({ + startDate, + endDate, + allDay, + t, + timeZone +}) => { + if (allDay) return null + return ( + + {startDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', + timeZone + })} + {' - '} + {endDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', + timeZone + })} + + ) +} + +export const RenderTitle: React.FC = ({ + summary, + isRecurrent, + t +}) => ( + + + {summary || t('event.untitled')} + + {isRecurrent && } + +) + +export const RenderOrganizer: React.FC = ({ organizer }) => { + if (!organizer?.cn && !organizer?.email) return null + return ( + + {organizer.cn || organizer.email} + + ) +} + +export const RenderText: React.FC<{ text?: string }> = ({ text }) => { + if (!text) return null + return ( + + {text.replace(/\n/g, ' ')} + + ) +} + +export const RenderVideoJoin: React.FC = ({ url, t }) => { + if (!url) return null + return ( + + ) +} + +export const RenderMobileDate: React.FC = ({ + startDate, + t, + timeZone +}) => ( + + + {startDate.toLocaleDateString(t('locale'), { day: '2-digit', timeZone })} + + + {startDate + .toLocaleDateString(t('locale'), { weekday: 'short', timeZone }) + .toUpperCase()} + + +) + +export const RenderMobileEventCard: React.FC = ({ + 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 ( + + + {eventData.data.summary || t('event.untitled')} + + } + subheader={ + !eventData.data.allDay && ( + + {startDate.toLocaleTimeString(t('locale'), { + hour: '2-digit', + minute: '2-digit', + timeZone: timeZone + })} + + ) + } + /> + + ) +} diff --git a/src/features/Search/useEventPreview.ts b/src/features/Search/useEventPreview.ts new file mode 100644 index 0000000..52743f2 --- /dev/null +++ b/src/features/Search/useEventPreview.ts @@ -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 + timeZone: string +} { + const dispatch = useAppDispatch() + const timeZone = + useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone + const [openPreview, setOpenPreview] = useState(false) + + const handleOpen = async (): Promise => { + 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 } +}