- 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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user