* [#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:
@@ -199,7 +199,6 @@ describe("SearchResultsPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("Untitled Event")).toBeInTheDocument();
|
expect(screen.getByText("Untitled Event")).toBeInTheDocument();
|
||||||
expect(screen.getByText("-")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format all-day events correctly", () => {
|
it("should format all-day events correctly", () => {
|
||||||
|
|||||||
@@ -15,3 +15,4 @@
|
|||||||
|
|
||||||
.App-link
|
.App-link
|
||||||
color #61dafb
|
color #61dafb
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
ListSubheader,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Popover,
|
Popover,
|
||||||
Select,
|
Select,
|
||||||
@@ -137,9 +136,17 @@ export default function SearchBar() {
|
|||||||
{extended && (
|
{extended && (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
autoFocus
|
||||||
placeholder={t("common.search")}
|
placeholder={t("common.search")}
|
||||||
value={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()) {
|
if (!search.trim()) {
|
||||||
setExtended(false);
|
setExtended(false);
|
||||||
}
|
}
|
||||||
@@ -184,7 +191,12 @@ export default function SearchBar() {
|
|||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
{search && (
|
{search && (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton onClick={() => setSearch("")}>
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
handleFilterChange("keywords", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<HighlightOffIcon />
|
<HighlightOffIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
@@ -335,7 +347,7 @@ export default function SearchBar() {
|
|||||||
<UserSearch
|
<UserSearch
|
||||||
attendees={filters.attendees}
|
attendees={filters.attendees}
|
||||||
setAttendees={(users: userAttendee[]) =>
|
setAttendees={(users: userAttendee[]) =>
|
||||||
handleFilterChange("participants", users)
|
handleFilterChange("attendees", users)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -684,7 +684,7 @@ const CalendarSlice = createSlice({
|
|||||||
action.payload.event;
|
action.payload.event;
|
||||||
state.list[action.payload.calendarUid].events[
|
state.list[action.payload.calendarUid].events[
|
||||||
action.payload.event.uid
|
action.payload.event.uid
|
||||||
].URL = `dav/calendars/${action.payload.calendarUid}/${
|
].URL = `/calendars/${action.payload.calendarUid}/${
|
||||||
action.payload.event.uid.split("/")[0]
|
action.payload.event.uid.split("/")[0]
|
||||||
}.isc`;
|
}.isc`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,99 +1,290 @@
|
|||||||
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import { useAppSelector } from "../../app/hooks";
|
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
|
||||||
import logo from "../../static/noResult-logo.svg";
|
import { Box, IconButton, Stack, Typography } from "@mui/material";
|
||||||
import CircularProgress from "@mui/material/CircularProgress";
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { Box, Card, CardContent, Typography, Chip, Stack } from "@mui/material";
|
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
|
||||||
import { format } from "date-fns";
|
import { useState } from "react";
|
||||||
import {
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
enGB,
|
import { AppDispatch } from "../../app/store";
|
||||||
fr as frLocale,
|
import logo from "../../static/noResult-logo.svg";
|
||||||
ru as ruLocale,
|
import { getEventAsync } from "../Calendars/CalendarSlice";
|
||||||
vi as viLocale,
|
import EventPreviewModal from "../Events/EventDisplayPreview";
|
||||||
} from "date-fns/locale";
|
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() {
|
export default function SearchResultsPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const { error, loading, hits, results } = useAppSelector(
|
const { error, loading, hits, results } = useAppSelector(
|
||||||
(state) => state.searchResult
|
(state) => state.searchResult
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let layout;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
layout = (
|
||||||
<div className="search-layout">
|
<Box className="loading">
|
||||||
<CircularProgress size={24} />
|
<CircularProgress size={32} />
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
} else if (error) {
|
||||||
|
layout = (
|
||||||
if (error) {
|
<Box className="error">
|
||||||
return (
|
<Typography className="error-text">{error}</Typography>
|
||||||
<div className="search-layout">
|
</Box>
|
||||||
<div style={{ color: "red", marginTop: 8 }}>{error}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
} else if (!hits) {
|
||||||
|
layout = (
|
||||||
if (!hits) {
|
<Box className="noResults">
|
||||||
return (
|
|
||||||
<div className="search-layout">
|
|
||||||
<h1>{t("search.noResults")}</h1>
|
|
||||||
<img className="logo" src={logo} alt={t("search.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 (
|
return (
|
||||||
<div className="search-layout">
|
<Box className={`search-layout`}>
|
||||||
<h1>{t("search.resultsTitle")}</h1>
|
<Box className="search-result-content-header">
|
||||||
<Stack spacing={2} sx={{ mt: 2 }}>
|
<Box className="back-button">
|
||||||
{results?.map((r: any, idx: number) => (
|
<IconButton
|
||||||
<ResultItem key={r.data.uid || idx} eventData={r.data} />
|
onClick={() => dispatch(setView("calendar"))}
|
||||||
))}
|
aria-label={t("settings.back")}
|
||||||
</Stack>
|
>
|
||||||
</div>
|
<ArrowBackIcon />
|
||||||
);
|
</IconButton>
|
||||||
}
|
<Typography variant="h5">{t("search.resultsTitle")}</Typography>
|
||||||
|
</Box>
|
||||||
function ResultItem({ eventData }: { eventData: Record<string, any> }) {
|
</Box>
|
||||||
const { lang } = useI18n();
|
{layout}
|
||||||
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>
|
</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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -62,8 +62,9 @@
|
|||||||
"keywordsPlaceholder": "Enter keywords",
|
"keywordsPlaceholder": "Enter keywords",
|
||||||
"organizers": "Organizers",
|
"organizers": "Organizers",
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"noResults": "No results found",
|
"noResults": "No events found",
|
||||||
"resultsTitle": "Search Results"
|
"resultsTitle": "Search Results",
|
||||||
|
"noResultsSubtitle": "Try adjusting your filters or search terms."
|
||||||
},
|
},
|
||||||
"calendarPopover": {
|
"calendarPopover": {
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
+2
-1
@@ -63,7 +63,8 @@
|
|||||||
"organizers": "Organisateurs",
|
"organizers": "Organisateurs",
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"noResults": "Aucun résultat",
|
"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": {
|
"calendarPopover": {
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
+2
-1
@@ -63,7 +63,8 @@
|
|||||||
"organizers": "Организаторы",
|
"organizers": "Организаторы",
|
||||||
"participants": "Участники",
|
"participants": "Участники",
|
||||||
"noResults": "Результаты не найдены",
|
"noResults": "Результаты не найдены",
|
||||||
"resultsTitle": "Результаты поиска"
|
"resultsTitle": "Результаты поиска",
|
||||||
|
"noResultsSubtitle": "Попробуйте изменить фильтры или условия поиска."
|
||||||
},
|
},
|
||||||
"calendarPopover": {
|
"calendarPopover": {
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
+2
-1
@@ -63,7 +63,8 @@
|
|||||||
"organizers": "Người tổ chức",
|
"organizers": "Người tổ chức",
|
||||||
"participants": "Người tham gia",
|
"participants": "Người tham gia",
|
||||||
"noResults": "Không tìm thấy kết quả",
|
"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": {
|
"calendarPopover": {
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
Reference in New Issue
Block a user