#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
@@ -0,0 +1,235 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import SearchBar from "../../../src/components/Menubar/EventSearchBar";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import * as searchThunk from "../../../src/features/Search/SearchSlice";
import userEvent from "@testing-library/user-event";
describe("EventSearchBar", () => {
const today = new Date();
const start = new Date(today);
start.setHours(10, 0, 0, 0);
const end = new Date(today);
end.setHours(11, 0, 0, 0);
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
},
tokens: { accessToken: "token" },
},
calendars: {
list: {
"user1/cal1": {
name: "Calendar personal",
id: "user1/cal1",
color: { light: "#FF0000", dark: "#000" },
ownerEmails: ["alice@example.com"],
events: {
event1: {
id: "event1",
calId: "user1/cal1",
uid: "event1",
title: "Test Event",
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
"user2/cal1": {
name: "Calendar delegated",
delegated: true,
id: "user2/cal1",
color: { light: "#FF0000", dark: "#000" },
ownerEmails: ["alice@example.com"],
events: {
event1: {
id: "event1",
calId: "user2/cal1",
uid: "event1",
title: "Test Event",
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
"user3/cal1": {
name: "Calendar shared",
id: "user3/cal1",
color: { light: "#FF0000", dark: "#000" },
ownerEmails: ["alice@example.com"],
events: {
event1: {
id: "event1",
calId: "user3/cal1",
uid: "event1",
title: "Test Event",
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
},
pending: false,
},
};
it("should render search icon button initially", () => {
renderWithProviders(<SearchBar />, preloadedState);
const searchButton = screen.getByRole("button");
expect(searchButton).toBeInTheDocument();
});
it("should expand search bar when icon is clicked", () => {
renderWithProviders(<SearchBar />, preloadedState);
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchInput = screen.getByPlaceholderText("common.search");
expect(searchInput).toBeInTheDocument();
});
it("should unexpand search bar when field is unfocused and empty", () => {
renderWithProviders(<SearchBar />, preloadedState);
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.blur(searchInput);
expect(searchInput).not.toBeInTheDocument();
});
it("should not unexpand search bar when field is unfocused but not empty", () => {
renderWithProviders(<SearchBar />, preloadedState);
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchInput = screen.getByPlaceholderText("common.search");
userEvent.type(searchInput, "test");
fireEvent.blur(searchInput);
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveValue("test");
});
it("should update search value on input", () => {
renderWithProviders(<SearchBar />, preloadedState);
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.change(searchInput, { target: { value: "meeting" } });
expect(searchInput).toHaveValue("meeting");
});
it("should open filter popover when tune icon is clicked", async () => {
renderWithProviders(<SearchBar />, preloadedState);
// Expand search bar
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
// Click tune icon
const tuneButtons = screen.getAllByRole("button");
const tuneButton = tuneButtons.find((btn) =>
btn.querySelector('[data-testid="TuneIcon"]')
);
if (tuneButton) fireEvent.click(tuneButton);
await waitFor(() => {
expect(screen.getByText("search.searchIn")).toBeInTheDocument();
});
});
it("should clear search value when clear button is clicked", () => {
renderWithProviders(<SearchBar />, preloadedState);
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.change(searchInput, { target: { value: "meeting" } });
const clearButton = screen.getByTestId("HighlightOffIcon");
fireEvent.click(clearButton);
expect(searchInput).toHaveValue("");
});
it("should trigger search on Enter key", async () => {
const searchSpy = jest.spyOn(searchThunk, "searchEventsAsync");
renderWithProviders(<SearchBar />, preloadedState);
const searchButton = screen.getByRole("button");
fireEvent.click(searchButton);
const searchInput = screen.getByPlaceholderText("common.search");
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.keyDown(searchInput, { key: "Enter" });
await waitFor(() => {
expect(searchSpy).toHaveBeenCalledWith({
filters: {
keywords: "test",
organizers: [],
attendees: [],
searchIn: ["user1/cal1"],
},
search: "test",
});
});
});
});
@@ -0,0 +1,225 @@
import { screen } from "@testing-library/react";
import SearchResultsPage from "../../../src/features/Search/SearchResultsPage";
import { renderWithProviders } from "../../utils/Renderwithproviders";
describe("SearchResultsPage", () => {
const today = new Date();
const start = new Date(today);
start.setHours(10, 0, 0, 0);
const end = new Date(today);
end.setHours(11, 0, 0, 0);
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "mockSid",
openpaasId: "user1",
},
tokens: { accessToken: "token" },
},
calendars: {
list: {
"user1/cal1": {
name: "Calendar personal",
id: "user1/cal1",
color: { light: "#FF0000", dark: "#000" },
ownerEmails: ["alice@example.com"],
events: {
event1: {
id: "event1",
calId: "user1/cal1",
uid: "event1",
title: "Test Event",
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
"user2/cal1": {
name: "Calendar delegated",
delegated: true,
id: "user2/cal1",
color: { light: "#FF0000", dark: "#000" },
ownerEmails: ["alice@example.com"],
events: {
event1: {
id: "event1",
calId: "user2/cal1",
uid: "event1",
title: "Test Event",
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
"user3/cal1": {
name: "Calendar shared",
id: "user3/cal1",
color: { light: "#FF0000", dark: "#000" },
ownerEmails: ["alice@example.com"],
events: {
event1: {
id: "event1",
calId: "user3/cal1",
uid: "event1",
title: "Test Event",
start: start.toISOString(),
end: end.toISOString(),
partstat: "ACCEPTED",
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
},
attendee: [
{
cn: "Alice",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
cal_address: "alice@example.com",
},
],
},
},
},
},
pending: false,
},
};
it("should show loading spinner when loading", () => {
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { loading: true, error: null, hits: 0, results: [] },
});
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("should show error message when error occurs", () => {
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: {
loading: false,
error: "Network error",
hits: 0,
results: [],
},
});
expect(screen.getByText("Network error")).toBeInTheDocument();
});
it("should show no results message when no hits", () => {
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { loading: false, error: null, hits: null, results: [] },
});
expect(screen.getByText("search.noResults")).toBeInTheDocument();
});
it("should render search results", () => {
const mockResults = [
{
data: {
uid: "1",
summary: "Team Meeting",
start: "2025-06-26T15:00:00Z",
organizer: { cn: "John Doe", email: "john@example.com" },
allDay: false,
},
},
{
data: {
uid: "2",
summary: "Project Review",
start: "2025-06-27T10:00:00Z",
organizer: { cn: "Jane Smith", email: "jane@example.com" },
allDay: false,
},
},
];
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { results: mockResults, hits: 2 },
});
expect(screen.getByText("search.resultsTitle")).toBeInTheDocument();
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Project Review")).toBeInTheDocument();
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("Jane Smith")).toBeInTheDocument();
});
it("should handle events without organizer", () => {
const mockResults = [
{
data: {
uid: "1",
summary: "Untitled Event",
start: "2025-06-26T15:00:00Z",
allDay: false,
},
},
];
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { results: mockResults, hits: 1 },
});
expect(screen.getByText("Untitled Event")).toBeInTheDocument();
expect(screen.getByText("-")).toBeInTheDocument();
});
it("should format all-day events correctly", () => {
const mockResults = [
{
data: {
uid: "1",
summary: "All Day Event",
start: "2025-06-26T00:00:00Z",
organizer: { cn: "Organizer" },
allDay: true,
},
},
];
renderWithProviders(<SearchResultsPage />, {
...preloadedState,
searchResult: { results: mockResults, hits: 1 },
});
expect(screen.getByText("All Day Event")).toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
// SearchSlice.test.ts
import { configureStore } from "@reduxjs/toolkit";
import searchResultReducer, {
searchEventsAsync,
setResults,
setHits,
} from "../../../src/features/Search/SearchSlice";
import * as EventApi from "../../../src/features/Events/EventApi";
jest.mock("../../../src/features/Events/EventApi");
describe("SearchSlice", () => {
let store: any;
beforeEach(() => {
store = configureStore({
reducer: {
searchResult: searchResultReducer,
},
});
});
describe("initial state", () => {
it("should have correct initial state", () => {
const state = store.getState().searchResult;
expect(state).toEqual({
results: [],
hits: 0,
error: null,
loading: false,
});
});
});
describe("reducers", () => {
it("should handle setResults", () => {
const mockResults = [
{ uid: "1", summary: "Event 1" },
{ uid: "2", summary: "Event 2" },
];
store.dispatch(setResults(mockResults as any));
expect(store.getState().searchResult.results).toEqual(mockResults);
});
it("should handle setHits", () => {
store.dispatch(setHits(42));
expect(store.getState().searchResult.hits).toBe(42);
});
});
describe("searchEventsAsync", () => {
const mockFilters = {
searchIn: ["user1/calendar1"],
keywords: "meeting",
organizers: ["user@example.com"],
attendees: ["attendee@example.com"],
};
it("should handle successful search", async () => {
const mockResponse = {
_total_hits: 5,
_embedded: {
events: [
{ data: { uid: "1", summary: "Test Event" } },
{ data: { uid: "2", summary: "Another Event" } },
],
},
};
(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse);
await store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters })
);
const state = store.getState().searchResult;
expect(state.loading).toBe(false);
expect(state.hits).toBe(5);
expect(state.results).toEqual(mockResponse._embedded.events);
expect(state.error).toBeNull();
});
it("should handle search with no results", async () => {
const mockResponse = {
_total_hits: 0,
_embedded: { events: [] },
};
(EventApi.searchEvent as jest.Mock).mockResolvedValue(mockResponse);
await store.dispatch(
searchEventsAsync({ search: "nonexistent", filters: mockFilters })
);
const state = store.getState().searchResult;
expect(state.hits).toBe(0);
expect(state.results).toEqual([]);
});
it("should handle search error", async () => {
const mockError = new Error("Network error");
(EventApi.searchEvent as jest.Mock).mockRejectedValue(mockError);
await store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters })
);
const state = store.getState().searchResult;
expect(state.loading).toBe(false);
expect(state.error).toBeTruthy();
expect(state.results).toEqual([]);
});
it("should set loading state during search", async () => {
(EventApi.searchEvent as jest.Mock).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
);
const promise = store.dispatch(
searchEventsAsync({ search: "test", filters: mockFilters })
);
expect(store.getState().searchResult.loading).toBe(true);
await promise;
});
});
});