[#361] Improve search result page (#375)

* [#361] added back button

* [#361] greatly improved looks of results

* [#361] added popup on click

* [#361] removed classnames with \!important replaced with style in component

---------

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-12-02 15:26:00 +01:00
committed by GitHub
parent 8a68280023
commit 325d33caef
10 changed files with 328 additions and 90 deletions
@@ -199,7 +199,6 @@ describe("SearchResultsPage", () => {
});
expect(screen.getByText("Untitled Event")).toBeInTheDocument();
expect(screen.getByText("-")).toBeInTheDocument();
});
it("should format all-day events correctly", () => {
+1
View File
@@ -15,3 +15,4 @@
.App-link
color #61dafb
+16 -4
View File
@@ -8,7 +8,6 @@ import {
IconButton,
InputAdornment,
InputLabel,
ListSubheader,
MenuItem,
Popover,
Select,
@@ -137,9 +136,17 @@ export default function SearchBar() {
{extended && (
<TextField
fullWidth
autoFocus
placeholder={t("common.search")}
value={search}
onBlur={() => {
onBlur={(e) => {
const next = e.relatedTarget as HTMLElement | null;
if (
next instanceof Node &&
searchBoxRef.current?.contains(next)
) {
return;
}
if (!search.trim()) {
setExtended(false);
}
@@ -184,7 +191,12 @@ export default function SearchBar() {
</InputAdornment>
{search && (
<InputAdornment position="end">
<IconButton onClick={() => setSearch("")}>
<IconButton
onClick={() => {
setSearch("");
handleFilterChange("keywords", "");
}}
>
<HighlightOffIcon />
</IconButton>
</InputAdornment>
@@ -335,7 +347,7 @@ export default function SearchBar() {
<UserSearch
attendees={filters.attendees}
setAttendees={(users: userAttendee[]) =>
handleFilterChange("participants", users)
handleFilterChange("attendees", users)
}
/>
</Box>
+1 -1
View File
@@ -684,7 +684,7 @@ const CalendarSlice = createSlice({
action.payload.event;
state.list[action.payload.calendarUid].events[
action.payload.event.uid
].URL = `dav/calendars/${action.payload.calendarUid}/${
].URL = `/calendars/${action.payload.calendarUid}/${
action.payload.event.uid.split("/")[0]
}.isc`;
},
+270 -79
View File
@@ -1,99 +1,290 @@
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { useAppSelector } from "../../app/hooks";
import logo from "../../static/noResult-logo.svg";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
import { Box, IconButton, Stack, Typography } from "@mui/material";
import CircularProgress from "@mui/material/CircularProgress";
import { Box, Card, CardContent, Typography, Chip, Stack } from "@mui/material";
import { format } from "date-fns";
import {
enGB,
fr as frLocale,
ru as ruLocale,
vi as viLocale,
} from "date-fns/locale";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { AppDispatch } from "../../app/store";
import logo from "../../static/noResult-logo.svg";
import { getEventAsync } from "../Calendars/CalendarSlice";
import EventPreviewModal from "../Events/EventDisplayPreview";
import { CalendarEvent } from "../Events/EventsTypes";
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
import { setView } from "../Settings/SettingsSlice";
import "./searchResult.styl";
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 SearchResultsPage() {
const { t } = useI18n();
const dispatch = useAppDispatch();
const { error, loading, hits, results } = useAppSelector(
(state) => state.searchResult
);
let layout;
if (loading) {
return (
<div className="search-layout">
<CircularProgress size={24} />
</div>
layout = (
<Box className="loading">
<CircularProgress size={32} />
</Box>
);
}
if (error) {
return (
<div className="search-layout">
<div style={{ color: "red", marginTop: 8 }}>{error}</div>
</div>
} else if (error) {
layout = (
<Box className="error">
<Typography className="error-text">{error}</Typography>
</Box>
);
}
if (!hits) {
return (
<div className="search-layout">
<h1>{t("search.noResults")}</h1>
} else if (!hits) {
layout = (
<Box className="noResults">
<img className="logo" src={logo} alt={t("search.noResults")} />
</div>
<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: any, idx: number) => (
<ResultItem
key={`row-${idx}-event-${r.data.uid}`}
eventData={r}
dispatch={dispatch}
/>
))}
</Stack>
</Box>
);
}
return (
<div className="search-layout">
<h1>{t("search.resultsTitle")}</h1>
<Stack spacing={2} sx={{ mt: 2 }}>
{results?.map((r: any, idx: number) => (
<ResultItem key={r.data.uid || idx} eventData={r.data} />
))}
</Stack>
</div>
);
}
function ResultItem({ eventData }: { eventData: Record<string, any> }) {
const { lang } = useI18n();
const locale = dateLocales[lang as keyof typeof dateLocales] || enGB;
const startDate = new Date(eventData.start);
const formatDateTime = (date: Date) => {
if (eventData.allDay) {
return format(date, "PPP", { locale });
}
return format(date, "PPP p", { locale });
};
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: "200px 1fr 200px",
gap: 2,
p: 2,
borderBottom: "1px solid #e0e0e0",
cursor: "pointer",
"&:hover": {
backgroundColor: "#f5f5f5",
},
}}
>
<Typography variant="body2" color="text.secondary">
{formatDateTime(startDate)}
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{eventData.summary || "Untitled Event"}
</Typography>
<Typography variant="body2" color="text.secondary">
{eventData.organizer?.cn || eventData.organizer?.email || "-"}
</Typography>
<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: Record<string, any>;
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.calendars.timeZone);
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: Record<string, any>) => {
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: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "90px 120px 35px 1fr",
md: "90px 120px 35px 220px 150px 250px 1fr",
},
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}>
{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>
<Typography sx={styles.M3BodyMedium1}>
{!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,
})}
</>
)}
</Typography>
<SquareRoundedIcon
style={{
color: calendarColor ?? "#3788D8",
width: 24,
height: 24,
}}
/>
<Typography sx={styles.M3BodyLarge}>
{eventData.data.summary || t("event.untitled")}
</Typography>
<Typography
sx={{
...styles.M3BodyMedium1,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{eventData.data.organizer?.cn ||
eventData.data.organizer?.email ||
""}
</Typography>
{eventData.data?.location && (
<Typography
sx={{
...styles.M3BodyMedium,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{eventData.data?.location ?? ""}
</Typography>
)}
<Typography
sx={{
...styles.M3BodyMedium3,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{eventData.data?.description?.replace(/\n/g, " ") ?? ""}
</Typography>
</Box>
{calendar && calendar.events[eventData.data.uid] && (
<EventPreviewModal
eventId={eventData.data.uid}
calId={calendar.id}
open={openPreview}
onClose={() => setOpenPreview(false)}
/>
)}
</>
);
}
+31
View File
@@ -0,0 +1,31 @@
.search-layout
flex-grow 1
display flex
flex-direction column
height 100%
overflow-y auto
border-radius 16px
background #fff
.noResults,
.loading,
.error
height: 90%
align-content center
align-items center
bottom 50%
.back-button
display flex
align-content flex-start
align-items center
.search-result-content-header
display flex
align-items center
padding 16px 24px
gap 16px
.search-result-content-body
flex-grow 1
+3 -2
View File
@@ -62,8 +62,9 @@
"keywordsPlaceholder": "Enter keywords",
"organizers": "Organizers",
"participants": "Participants",
"noResults": "No results found",
"resultsTitle": "Search Results"
"noResults": "No events found",
"resultsTitle": "Search Results",
"noResultsSubtitle": "Try adjusting your filters or search terms."
},
"calendarPopover": {
"tabs": {
+2 -1
View File
@@ -63,7 +63,8 @@
"organizers": "Organisateurs",
"participants": "Participants",
"noResults": "Aucun résultat",
"resultsTitle": "Résultats de la recherche"
"resultsTitle": "Résultats de la recherche",
"noResultsSubtitle": "Essayez de changer vos filtres ou termes recherchés."
},
"calendarPopover": {
"tabs": {
+2 -1
View File
@@ -63,7 +63,8 @@
"organizers": "Организаторы",
"participants": "Участники",
"noResults": "Результаты не найдены",
"resultsTitle": "Результаты поиска"
"resultsTitle": "Результаты поиска",
"noResultsSubtitle": "Попробуйте изменить фильтры или условия поиска."
},
"calendarPopover": {
"tabs": {
+2 -1
View File
@@ -63,7 +63,8 @@
"organizers": "Người tổ chức",
"participants": "Người tham gia",
"noResults": "Không tìm thấy kết quả",
"resultsTitle": "Kết quả tìm kiếm"
"resultsTitle": "Kết quả tìm kiếm",
"noResultsSubtitle": "Hãy thử điều chỉnh bộ lọc hoặc thuật ngữ tìm kiếm của bạn."
},
"calendarPopover": {
"tabs": {