#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
+39
View File
@@ -10,6 +10,7 @@ import {
import ICAL from "ical.js";
import moment from "moment-timezone";
import { detectDateTimeFormat } from "../../components/Event/utils/dateTimeHelpers";
import { User } from "../../components/Attendees/PeopleSearch";
function resolveTimezoneId(tzid?: string): string | undefined {
if (!tzid) return undefined;
@@ -361,3 +362,41 @@ export async function importEventFromFile(id: string, calLink: string) {
});
return response;
}
export async function searchEvent(
query: string,
filters: {
searchIn: string[];
keywords: string;
organizers: string[];
attendees: string[];
}
) {
const { keywords, searchIn, organizers, attendees } = filters;
const reqParam: {
query: string;
calendars: { calendarId: string; userId: string }[];
organizers?: string[];
attendees?: string[];
} = {
query: !!keywords ? keywords : query,
calendars: searchIn.map((calId) => {
const [userId, calendarId] = calId.split("/");
return { calendarId, userId };
}),
};
if (organizers.length) {
reqParam.organizers = organizers;
}
if (attendees.length) {
reqParam.attendees = attendees;
}
const response = await api
.post("calendar/api/events/search?limit=30&offset=0", {
body: JSON.stringify(reqParam),
})
.json();
return response;
}
+94 -4
View File
@@ -1,9 +1,99 @@
import React from "react";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { useAppSelector } from "../../app/hooks";
import logo from "../../static/noResult-logo.svg";
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";
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
export default function SearchResultsPage() {
const { t } = useI18n();
const { error, loading, hits, results } = useAppSelector(
(state) => state.searchResult
);
if (loading) {
return (
<div className="search-layout">
<CircularProgress size={24} />
</div>
);
}
if (error) {
return (
<div className="search-layout">
<div style={{ color: "red", marginTop: 8 }}>{error}</div>
</div>
);
}
if (!hits) {
return (
<div className="search-layout">
<h1>{t("search.noResults")}</h1>
<img className="logo" src={logo} alt={t("search.noResults")} />
</div>
);
}
return (
<main className="main-layout search-layout">
<h1>Search Results</h1>
</main>
<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>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
import { formatReduxError } from "../../utils/errorUtils";
import { searchEvent } from "../Events/EventApi";
export interface SearchResultsState {
hits: number;
results: Record<string, any>[];
error: string | null;
loading: boolean;
}
const initialState: SearchResultsState = {
results: [],
hits: 0,
error: null,
loading: false,
};
export const searchEventsAsync = createAsyncThunk<
{ hits: number; events: Record<string, any>[] },
{ search: string; filters: any },
{ rejectValue: { message: string; status?: number } }
>("events/searchEvents", async ({ search, filters }, { rejectWithValue }) => {
try {
const response = (await searchEvent(search, filters)) as Record<
string,
any
>;
return {
hits: Number(response._total_hits),
events: response._embedded?.events ?? [],
};
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
}
});
export const searchResultsSlice = createSlice({
name: "settings",
initialState,
reducers: {
setResults: (state, action: PayloadAction<[]>) => {
state.results = action.payload;
},
setHits: (state, action: PayloadAction<number>) => {
state.hits = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(searchEventsAsync.pending, (state) => {
state.loading = true;
state.error = null; // reset error on new search
})
.addCase(searchEventsAsync.fulfilled, (state, action) => {
state.loading = false;
state.hits = action.payload.hits;
state.results = action.payload.events;
})
.addCase(searchEventsAsync.rejected, (state, action) => {
state.loading = false;
state.error = action.payload?.message || "Unknown error occurred";
});
},
});
export const { setResults, setHits } = searchResultsSlice.actions;
export default searchResultsSlice.reducer;