#12 topbar search (#371)

- added search in topbar
 - added skeleton search results page
 - added tests
 - set personal calendars as default search in and allow to select personal and shared as a search in option

---------

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-11-28 14:54:13 +01:00
committed by GitHub
parent 1a85afc57d
commit ae17ac2bd6
20 changed files with 1508 additions and 152 deletions
+147 -135
View File
@@ -45,6 +45,7 @@ import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import frLocale from "@fullcalendar/core/locales/fr";
import ruLocale from "@fullcalendar/core/locales/ru";
import viLocale from "@fullcalendar/core/locales/vi";
import SearchResultsPage from "../../features/Search/SearchResultsPage";
const localeMap: Record<string, any> = {
fr: frLocale,
@@ -76,7 +77,7 @@ export default function CalendarApp({
dispatch(push("/"));
}
}, [dispatch, tokens, userId]);
const view = useAppSelector((state) => state.settings.view);
const calendars = useAppSelector((state) => state.calendars.list);
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
@@ -116,6 +117,14 @@ export default function CalendarApp({
const [eventErrors, setEventErrors] = useState<string[]>([]);
const errorHandler = useRef(new EventErrorHandler());
// useEffect(() => {
// if (view === "search") {
// document.body.classList.add("dialog-expanded");
// } else {
// document.body.classList.remove("dialog-expanded");
// }
// }, [view]);
useEffect(() => {
const handler = errorHandler.current;
handler.setErrorCallback(setEventErrors);
@@ -628,149 +637,152 @@ export default function CalendarApp({
</Box>
<div className="calendar">
<ImportAlert />
<FullCalendar
ref={(ref) => {
if (ref) {
calendarRef.current = ref.getApi();
{view === "calendar" && (
<FullCalendar
ref={(ref) => {
if (ref) {
calendarRef.current = ref.getApi();
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin,
]}
initialView="timeGridWeek"
firstDay={1}
editable={true}
locale={localeMap[lang]}
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}
events={eventToFullCalendarFormat(
filteredEvents,
filteredTempEvents,
userId
)}
weekNumbers={
currentView === "timeGridWeek" || currentView === "timeGridDay"
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin,
]}
initialView="timeGridWeek"
firstDay={1}
editable={true}
locale={localeMap[lang]}
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}
events={eventToFullCalendarFormat(
filteredEvents,
filteredTempEvents,
userId
)}
weekNumbers={
currentView === "timeGridWeek" || currentView === "timeGridDay"
}
weekNumberFormat={{ week: "long" }}
weekNumberContent={(arg) => {
return (
<div className="weekSelector">
<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",
});
if (arg.view.type === "dayGridMonth") {
weekNumberFormat={{ week: "long" }}
weekNumberContent={(arg) => {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? "current-date" : ""
}`}
>
{arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText}
</span>
<div className="weekSelector">
<div>
{t("menubar.views.week")} {arg.num}
</div>
<TimezoneSelector
value={timezone}
referenceDate={calendarRef.current?.getDate() ?? new Date()}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</div>
);
}
}}
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) => {
setCurrentView(arg.view.type);
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start);
if (arg.view.type === "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
if (onViewChange) {
onViewChange(arg.view.type);
}
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date());
}, 100);
}}
dayHeaderContent={(arg) => {
const date = arg.date.getDate();
const weekDay = arg.date
.toLocaleDateString(t("locale"), { weekday: "short" })
.toUpperCase();
return (
<div className="fc-daygrid-day-top">
<small>{weekDay}</small>
{arg.view.type !== "dayGridMonth" && (
}}
dayCellContent={(arg) => {
const month = arg.date.toLocaleDateString(t("locale"), {
month: "short",
});
if (arg.view.type === "dayGridMonth") {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? "current-date" : ""
}`}
>
{date}
{arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText}
</span>
)}
</div>
);
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
viewDidMount={viewHandlers.handleViewDidMount}
viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventClick={eventHandlers.handleEventClick}
eventAllow={eventHandlers.handleEventAllow}
eventDrop={eventHandlers.handleEventDrop}
eventResize={eventHandlers.handleEventResize}
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) => {
setCurrentView(arg.view.type);
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start);
if (arg.view.type === "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
if (onViewChange) {
onViewChange(arg.view.type);
}
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date());
}, 100);
}}
dayHeaderContent={(arg) => {
const date = arg.date.getDate();
const weekDay = arg.date
.toLocaleDateString(t("locale"), { weekday: "short" })
.toUpperCase();
return (
<div className="fc-daygrid-day-top">
<small>{weekDay}</small>
{arg.view.type !== "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={eventHandlers.handleEventDrop}
eventResize={eventHandlers.handleEventResize}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
)}
{view === "search" && <SearchResultsPage />}
<EventPopover
anchorEl={anchorEl}
open={Boolean(anchorEl)}
+1 -2
View File
@@ -77,7 +77,7 @@ export default function CalendarLayout() {
return (
<div className="App">
<Menubar {...menubarProps} />
{view === "calendar" && (
{(view === "calendar" || view === "search") && (
<CalendarApp
calendarRef={calendarRef}
onDateChange={handleDateChange}
@@ -85,7 +85,6 @@ export default function CalendarLayout() {
/>
)}
{view === "settings" && <SettingsPage />}
{view === "search" && <SearchResultsPage />}
<ErrorSnackbar error={error} type="calendar" />
</div>
);
+3 -1
View File
@@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice";
import { useEffect, useState } from "react";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { setView } from "../../features/Settings/SettingsSlice";
export function MiniCalendar({
calendarRef,
@@ -38,10 +39,11 @@ export function MiniCalendar({
>
<DateCalendar
value={moment(visibleDate)}
onChange={(dateMoment, selectionState) => {
onChange={async (dateMoment, selectionState) => {
if (!dateMoment) return;
const date = dateMoment.toDate();
if (selectionState === "finish") {
await dispatch(setView("calendar"));
setSelectedMiniDate(date);
calendarRef.current?.gotoDate(date);
}
@@ -6,6 +6,7 @@ import {
removeTempCal,
} from "../../features/Calendars/CalendarSlice";
import { Calendars } from "../../features/Calendars/CalendarTypes";
import { setView } from "../../features/Settings/SettingsSlice";
import { User, PeopleSearch } from "../Attendees/PeopleSearch";
import { getAccessiblePair } from "./utils/calendarColorsUtils";
@@ -69,7 +70,7 @@ export function TempCalendarsInput({
light: lightColor,
dark: getAccessiblePair(lightColor, theme),
};
dispatch(setView("calendar"));
dispatch(
getTempCalendarsListAsync(user, { signal: controller.signal })
);
+357
View File
@@ -0,0 +1,357 @@
import {
Box,
Button,
Card,
CardActions,
CardContent,
Divider,
IconButton,
InputAdornment,
InputLabel,
ListSubheader,
MenuItem,
Popover,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useRef, useState } from "react";
import HighlightOffIcon from "@mui/icons-material/HighlightOff";
import SearchIcon from "@mui/icons-material/Search";
import TuneIcon from "@mui/icons-material/Tune";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { searchEventsAsync } from "../../features/Search/SearchSlice";
import { setView } from "../../features/Settings/SettingsSlice";
import { userAttendee } from "../../features/User/userDataTypes";
import UserSearch from "../Attendees/AttendeeSearch";
import { CalendarItemList } from "../Calendar/CalendarItemList";
export default function SearchBar() {
const { t } = useI18n();
const dispatch = useAppDispatch();
const calendars = Object.values(
useAppSelector((state) => state.calendars.list)
);
const userId = useAppSelector((state) => state.user.userData.openpaasId);
const personnalCalendars = calendars.filter(
(c) => c.id.split("/")[0] === userId
);
const sharedCalendars = calendars.filter(
(c) => c.id.split("/")[0] !== userId
);
const [search, setSearch] = useState("");
const [extended, setExtended] = useState(false);
const [filters, setFilters] = useState({
searchIn: "my-calendars",
keywords: "",
organizers: [] as userAttendee[],
attendees: [] as userAttendee[],
});
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const filterOpen = Boolean(anchorEl);
const searchWidth = {
xs: "10vw",
sm: "20vw",
md: "35vw",
xl: "35vw",
"@media (min-width: 2000px)": {
width: "55vw",
},
};
const searchBoxRef = useRef<HTMLElement | null>(null);
const handleFilterChange = (
field: string,
value: string | userAttendee[]
) => {
setFilters((prev) => ({ ...prev, [field]: value }));
};
const handleClearFilters = () => {
setFilters({
searchIn: "my-calendars",
keywords: "",
organizers: [] as userAttendee[],
attendees: [] as userAttendee[],
});
setAnchorEl(null);
setExtended(false);
};
const handleSearch = async () => {
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 if (filters.searchIn === "shared-calendars") {
searchInCalendars = sharedCalendars.map((c) => c.id);
} else {
searchInCalendars = [filters.searchIn];
}
const cleanedFilters = {
...filters,
organizers: filters.organizers.map((u) => u.cal_address),
attendees: filters.attendees.map((u) => u.cal_address),
searchIn: searchInCalendars,
};
dispatch(
searchEventsAsync({
search,
filters: cleanedFilters,
})
);
dispatch(setView("search"));
setAnchorEl(null);
};
return (
<>
<Box
ref={searchBoxRef}
sx={{
margin: "0 auto",
height: "44px",
position: "relative",
width: extended ? searchWidth : "auto",
transition: "width 0.25s ease-out",
}}
>
{!extended && (
<IconButton onClick={() => setExtended(true)}>
<SearchIcon />
</IconButton>
)}
{extended && (
<TextField
fullWidth
placeholder={t("common.search")}
value={search}
onBlur={() => {
if (!search.trim()) {
setExtended(false);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch();
}
}}
onChange={(e) => {
setSearch(e.target.value);
handleFilterChange("keywords", e.target.value);
}}
variant="outlined"
sx={{
borderRadius: "999px",
"& .MuiOutlinedInput-root": {
borderRadius: "999px",
},
"& .MuiInputBase-input": { padding: "12px 10px" },
animation: "scaleIn 0.25s ease-out",
"@keyframes scaleIn": {
from: { transform: "scaleX(0)", opacity: 0 },
to: { transform: "scaleX(1)", opacity: 1 },
},
transformOrigin: "right",
}}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: "#605D62" }} />
</InputAdornment>
),
endAdornment: (
<>
<InputAdornment position="end">
<IconButton
onClick={(e) => setAnchorEl(searchBoxRef.current)}
>
<TuneIcon />
</IconButton>
</InputAdornment>
{search && (
<InputAdornment position="end">
<IconButton onClick={() => setSearch("")}>
<HighlightOffIcon />
</IconButton>
</InputAdornment>
)}
</>
),
}}
/>
)}
</Box>
<Popover
open={filterOpen}
anchorEl={anchorEl}
onClose={handleClearFilters}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
paper: {
sx: { mt: 1.2, width: extended ? searchWidth : "auto" },
},
}}
>
<Card sx={{ p: 2, pb: 1 }}>
<CardContent>
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "140px 1fr",
gap: 2,
alignItems: "center",
}}
>
<InputLabel id="search-in" sx={{ m: 0 }}>
{t("search.searchIn")}
</InputLabel>
<Select
displayEmpty
value={filters.searchIn}
onChange={(e) =>
handleFilterChange("searchIn", e.target.value)
}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
color: "#8C9CAF",
},
},
}}
>
<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)}
<Divider />
<MenuItem
value="shared-calendars"
sx={{
color: "#243B55",
font: "Roboto",
fontSize: "12px",
weight: 400,
pointerEvents: "auto",
}}
>
{t("search.filter.sharedCalendars")}
</MenuItem>
{CalendarItemList(sharedCalendars)}
</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
placeholder={t("search.keywordsPlaceholder")}
value={filters.keywords}
onChange={(e) =>
handleFilterChange("keywords", e.target.value)
}
/>
</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)
}
/>
</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("participants", users)
}
/>
</Box>
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: "flex-end", p: 2, gap: 2 }}>
<Button variant="text" onClick={handleClearFilters}>
{t("common.cancel")}
</Button>
<Button variant="contained" onClick={handleSearch}>
{t("common.search")}
</Button>
</CardActions>
</Card>
</Popover>
</>
);
}
+8 -1
View File
@@ -46,6 +46,9 @@
align-items center
width calc(330px - 0.5rem)
.tc-home
cursor pointer
.logo
padding 0.5rem 1rem
font-size 1.5rem
@@ -95,5 +98,9 @@
body.fullscreen-view .navigation-controls,
body.fullscreen-view .current-date-time,
body.fullscreen-view .refresh-button,
body.fullscreen-view .select-display
body.fullscreen-view .select-display,
body.fullscreen-view .search-container
display none
.search-container
width 100%
+16 -4
View File
@@ -35,6 +35,7 @@ import {
ru as ruLocale,
vi as viLocale,
} from "date-fns/locale";
import SearchBar from "./EventSearchBar";
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
export type AppIconProps = {
@@ -86,9 +87,9 @@ export function Menubar({
setAnchorEl(null);
};
const handleNavigation = (action: "prev" | "next" | "today") => {
const handleNavigation = async (action: "prev" | "next" | "today") => {
if (!calendarRef.current) return;
await dispatch(setView("calendar"));
switch (action) {
case "prev":
calendarRef.current.prev();
@@ -108,8 +109,10 @@ export function Menubar({
}
};
const handleViewChange = (view: string) => {
const handleViewChange = async (view: string) => {
if (!calendarRef.current) return;
await dispatch(setView("calendar"));
calendarRef.current.changeView(view);
// Notify parent about view change
@@ -190,6 +193,9 @@ export function Menubar({
</div>
</div>
<div className="right-menu">
<div className="search-container">
<SearchBar />
</div>
<div className="menu-items">
<IconButton
className="refresh-button"
@@ -350,9 +356,15 @@ export function Menubar({
export function MainTitle() {
const { t } = useI18n();
const dispatch = useAppDispatch();
return (
<div className="menubar-item tc-home">
<img className="logo" src={logo} alt={t("menubar.logoAlt")} />
<img
className="logo"
src={logo}
alt={t("menubar.logoAlt")}
onClick={() => dispatch(setView("calendar"))}
/>
</div>
);
}