diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx index 4b305b9..97689cc 100644 --- a/__test__/features/Events/EventApi.test.tsx +++ b/__test__/features/Events/EventApi.test.tsx @@ -3,6 +3,7 @@ import { moveEvent, deleteEvent, importEventFromFile, + searchEvent, } from "../../../src/features/Events/EventApi"; import { CalendarEvent } from "../../../src/features/Events/EventsTypes"; import { calendarEventToJCal } from "../../../src/features/Events/eventUtils"; @@ -178,4 +179,95 @@ describe("eventApi", () => { }) ); }); + + describe("searchEvent", () => { + const mockFilters = { + searchIn: ["user1/calendar1", "user2/calendar2"], + keywords: "meeting", + organizers: ["org@example.com"], + attendees: ["part@example.com"], + }; + + it("should call API with correct parameters", async () => { + const mockResponse = { + _total_hits: 5, + _embedded: { events: [] }, + }; + + (api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + + await searchEvent("test", mockFilters); + + expect(api.post).toHaveBeenCalledWith( + "calendar/api/events/search?limit=30&offset=0", + { + body: JSON.stringify({ + query: "meeting", + calendars: [ + { calendarId: "calendar1", userId: "user1" }, + { calendarId: "calendar2", userId: "user2" }, + ], + organizers: ["org@example.com"], + attendees: ["part@example.com"], + }), + } + ); + }); + + it("should use query param when keywords is empty", async () => { + const mockResponse = { _total_hits: 0, _embedded: { events: [] } }; + + (api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + + await searchEvent("fallback query", { + ...mockFilters, + keywords: "", + }); + + expect(api.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"query":"fallback query"'), + }) + ); + }); + + it("should omit organizers when empty", async () => { + const mockResponse = { _total_hits: 0, _embedded: { events: [] } }; + + (api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + + await searchEvent("test", { + ...mockFilters, + organizers: [], + }); + + const callArgs = (api.post as jest.Mock).mock.calls[0][1]; + const body = JSON.parse(callArgs.body); + expect(body.organizers).toBeUndefined(); + }); + + it("should omit participants when empty", async () => { + const mockResponse = { _total_hits: 0, _embedded: { events: [] } }; + + (api.post as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResponse), + }); + + await searchEvent("test", { + ...mockFilters, + attendees: [], + }); + + const callArgs = (api.post as jest.Mock).mock.calls[0][1]; + const body = JSON.parse(callArgs.body); + expect(body.participants).toBeUndefined(); + }); + }); }); diff --git a/__test__/features/Search/EventSearchBar.test.tsx b/__test__/features/Search/EventSearchBar.test.tsx new file mode 100644 index 0000000..1c06c64 --- /dev/null +++ b/__test__/features/Search/EventSearchBar.test.tsx @@ -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(, preloadedState); + + const searchButton = screen.getByRole("button"); + expect(searchButton).toBeInTheDocument(); + }); + + it("should expand search bar when icon is clicked", () => { + renderWithProviders(, 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(, 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(, 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(, 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(, 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(, 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(, 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", + }); + }); + }); +}); diff --git a/__test__/features/Search/SearchResultsPage.test.tsx b/__test__/features/Search/SearchResultsPage.test.tsx new file mode 100644 index 0000000..d8ce076 --- /dev/null +++ b/__test__/features/Search/SearchResultsPage.test.tsx @@ -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(, { + ...preloadedState, + searchResult: { loading: true, error: null, hits: 0, results: [] }, + }); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("should show error message when error occurs", () => { + renderWithProviders(, { + ...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(, { + ...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(, { + ...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(, { + ...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(, { + ...preloadedState, + searchResult: { results: mockResults, hits: 1 }, + }); + + expect(screen.getByText("All Day Event")).toBeInTheDocument(); + }); +}); diff --git a/__test__/features/Search/SearchSlice.test.tsx b/__test__/features/Search/SearchSlice.test.tsx new file mode 100644 index 0000000..8a08f7e --- /dev/null +++ b/__test__/features/Search/SearchSlice.test.tsx @@ -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; + }); + }); +}); diff --git a/src/app/store.ts b/src/app/store.ts index 4eb99d8..a63b304 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,6 +1,7 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit"; import userReducer from "../features/User/userSlice"; import settingsReducer from "../features/Settings/SettingsSlice"; +import searchResultReducer from "../features/Search/SearchSlice"; import eventsCalendar from "../features/Calendars/CalendarSlice"; import { createReduxHistoryContext } from "redux-first-history"; import { createBrowserHistory } from "history"; @@ -13,6 +14,7 @@ const rootReducer = combineReducers({ user: userReducer, calendars: eventsCalendar, settings: settingsReducer, + searchResult: searchResultReducer, }); export const setupStore = (preloadedState?: Partial) => { diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index ad4bf9a..feda25a 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -45,6 +45,7 @@ import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import frLocale from "@fullcalendar/core/locales/fr"; import ruLocale from "@fullcalendar/core/locales/ru"; import viLocale from "@fullcalendar/core/locales/vi"; +import SearchResultsPage from "../../features/Search/SearchResultsPage"; const localeMap: Record = { fr: frLocale, @@ -76,7 +77,7 @@ export default function CalendarApp({ dispatch(push("/")); } }, [dispatch, tokens, userId]); - + const view = useAppSelector((state) => state.settings.view); const calendars = useAppSelector((state) => state.calendars.list); const tempcalendars = useAppSelector((state) => state.calendars.templist); const [selectedCalendars, setSelectedCalendars] = useState([]); @@ -116,6 +117,14 @@ export default function CalendarApp({ const [eventErrors, setEventErrors] = useState([]); const errorHandler = useRef(new EventErrorHandler()); + // useEffect(() => { + // if (view === "search") { + // document.body.classList.add("dialog-expanded"); + // } else { + // document.body.classList.remove("dialog-expanded"); + // } + // }, [view]); + useEffect(() => { const handler = errorHandler.current; handler.setErrorCallback(setEventErrors); @@ -628,149 +637,152 @@ export default function CalendarApp({
- { - if (ref) { - calendarRef.current = ref.getApi(); + {view === "calendar" && ( + { + if (ref) { + calendarRef.current = ref.getApi(); + } + }} + plugins={[ + dayGridPlugin, + timeGridPlugin, + interactionPlugin, + momentTimezonePlugin, + ]} + initialView="timeGridWeek" + firstDay={1} + editable={true} + locale={localeMap[lang]} + selectable={true} + timeZone={timezone} + height={"100%"} + select={eventHandlers.handleDateSelect} + nowIndicator + slotLabelClassNames={(arg) => [ + updateSlotLabelVisibility(new Date(), arg, timezone), + ]} + nowIndicatorContent={viewHandlers.handleNowIndicatorContent} + headerToolbar={false} + views={{ + timeGridWeek: { titleFormat: { month: "long", year: "numeric" } }, + }} + dayMaxEvents={true} + events={eventToFullCalendarFormat( + filteredEvents, + filteredTempEvents, + userId + )} + weekNumbers={ + currentView === "timeGridWeek" || currentView === "timeGridDay" } - }} - plugins={[ - dayGridPlugin, - timeGridPlugin, - interactionPlugin, - momentTimezonePlugin, - ]} - initialView="timeGridWeek" - firstDay={1} - editable={true} - locale={localeMap[lang]} - selectable={true} - timeZone={timezone} - height={"100%"} - select={eventHandlers.handleDateSelect} - nowIndicator - slotLabelClassNames={(arg) => [ - updateSlotLabelVisibility(new Date(), arg, timezone), - ]} - nowIndicatorContent={viewHandlers.handleNowIndicatorContent} - headerToolbar={false} - views={{ - timeGridWeek: { titleFormat: { month: "long", year: "numeric" } }, - }} - dayMaxEvents={true} - events={eventToFullCalendarFormat( - filteredEvents, - filteredTempEvents, - userId - )} - weekNumbers={ - currentView === "timeGridWeek" || currentView === "timeGridDay" - } - weekNumberFormat={{ week: "long" }} - weekNumberContent={(arg) => { - return ( -
-
- {t("menubar.views.week")} {arg.num} -
- - dispatch(setTimeZone(newTimezone)) - } - /> -
- ); - }} - dayCellContent={(arg) => { - const month = arg.date.toLocaleDateString(t("locale"), { - month: "short", - }); - if (arg.view.type === "dayGridMonth") { + weekNumberFormat={{ week: "long" }} + weekNumberContent={(arg) => { return ( - - {arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText} - +
+
+ {t("menubar.views.week")} {arg.num} +
+ + dispatch(setTimeZone(newTimezone)) + } + /> +
); - } - }} - slotDuration={"00:30:00"} - slotLabelInterval={"01:00:00"} - scrollTime="12:00:00" - unselectAuto={false} - allDayText="" - slotLabelFormat={{ - hour: "2-digit", - minute: "2-digit", - hour12: false, - }} - datesSet={(arg) => { - setCurrentView(arg.view.type); - const calendarCurrentDate = - calendarRef.current?.getDate() || new Date(arg.start); - - if (arg.view.type === "dayGridMonth") { - const start = new Date(arg.start).getTime(); - const end = new Date(arg.end).getTime(); - const middle = start + (end - start) / 2; - setSelectedDate(new Date(middle)); - setSelectedMiniDate(calendarCurrentDate); - } else { - setSelectedDate(calendarCurrentDate); - setSelectedMiniDate(calendarCurrentDate); - } - - // Always use the calendar's current date for consistency - if (onDateChange) { - onDateChange(calendarCurrentDate); - } - - // Notify parent about view change - if (onViewChange) { - onViewChange(arg.view.type); - } - - // Update slot label visibility when view changes - setTimeout(() => { - updateSlotLabelVisibility(new Date()); - }, 100); - }} - dayHeaderContent={(arg) => { - const date = arg.date.getDate(); - const weekDay = arg.date - .toLocaleDateString(t("locale"), { weekday: "short" }) - .toUpperCase(); - return ( -
- {weekDay} - {arg.view.type !== "dayGridMonth" && ( + }} + dayCellContent={(arg) => { + const month = arg.date.toLocaleDateString(t("locale"), { + month: "short", + }); + if (arg.view.type === "dayGridMonth") { + return ( - {date} + {arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText} - )} -
- ); - }} - dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} - dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} - viewDidMount={viewHandlers.handleViewDidMount} - viewWillUnmount={viewHandlers.handleViewWillUnmount} - eventClick={eventHandlers.handleEventClick} - eventAllow={eventHandlers.handleEventAllow} - eventDrop={eventHandlers.handleEventDrop} - eventResize={eventHandlers.handleEventResize} - eventContent={viewHandlers.handleEventContent} - eventDidMount={viewHandlers.handleEventDidMount} - /> + ); + } + }} + slotDuration={"00:30:00"} + slotLabelInterval={"01:00:00"} + scrollTime="12:00:00" + unselectAuto={false} + allDayText="" + slotLabelFormat={{ + hour: "2-digit", + minute: "2-digit", + hour12: false, + }} + datesSet={(arg) => { + setCurrentView(arg.view.type); + const calendarCurrentDate = + calendarRef.current?.getDate() || new Date(arg.start); + + if (arg.view.type === "dayGridMonth") { + const start = new Date(arg.start).getTime(); + const end = new Date(arg.end).getTime(); + const middle = start + (end - start) / 2; + setSelectedDate(new Date(middle)); + setSelectedMiniDate(calendarCurrentDate); + } else { + setSelectedDate(calendarCurrentDate); + setSelectedMiniDate(calendarCurrentDate); + } + + // Always use the calendar's current date for consistency + if (onDateChange) { + onDateChange(calendarCurrentDate); + } + + // Notify parent about view change + if (onViewChange) { + onViewChange(arg.view.type); + } + + // Update slot label visibility when view changes + setTimeout(() => { + updateSlotLabelVisibility(new Date()); + }, 100); + }} + dayHeaderContent={(arg) => { + const date = arg.date.getDate(); + const weekDay = arg.date + .toLocaleDateString(t("locale"), { weekday: "short" }) + .toUpperCase(); + return ( +
+ {weekDay} + {arg.view.type !== "dayGridMonth" && ( + + {date} + + )} +
+ ); + }} + dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} + dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} + viewDidMount={viewHandlers.handleViewDidMount} + viewWillUnmount={viewHandlers.handleViewWillUnmount} + eventClick={eventHandlers.handleEventClick} + eventAllow={eventHandlers.handleEventAllow} + eventDrop={eventHandlers.handleEventDrop} + eventResize={eventHandlers.handleEventResize} + eventContent={viewHandlers.handleEventContent} + eventDidMount={viewHandlers.handleEventDidMount} + /> + )} + {view === "search" && } - {view === "calendar" && ( + {(view === "calendar" || view === "search") && ( )} {view === "settings" && } - {view === "search" && }
); diff --git a/src/components/Calendar/MiniCalendar.tsx b/src/components/Calendar/MiniCalendar.tsx index 666c7e4..bd19a34 100644 --- a/src/components/Calendar/MiniCalendar.tsx +++ b/src/components/Calendar/MiniCalendar.tsx @@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice"; import { useEffect, useState } from "react"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; +import { setView } from "../../features/Settings/SettingsSlice"; export function MiniCalendar({ calendarRef, @@ -38,10 +39,11 @@ export function MiniCalendar({ > { + onChange={async (dateMoment, selectionState) => { if (!dateMoment) return; const date = dateMoment.toDate(); if (selectionState === "finish") { + await dispatch(setView("calendar")); setSelectedMiniDate(date); calendarRef.current?.gotoDate(date); } diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx index bcb38a2..affbb80 100644 --- a/src/components/Calendar/TempCalendarsInput.tsx +++ b/src/components/Calendar/TempCalendarsInput.tsx @@ -6,6 +6,7 @@ import { removeTempCal, } from "../../features/Calendars/CalendarSlice"; import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { setView } from "../../features/Settings/SettingsSlice"; import { User, PeopleSearch } from "../Attendees/PeopleSearch"; import { getAccessiblePair } from "./utils/calendarColorsUtils"; @@ -69,7 +70,7 @@ export function TempCalendarsInput({ light: lightColor, dark: getAccessiblePair(lightColor, theme), }; - + dispatch(setView("calendar")); dispatch( getTempCalendarsListAsync(user, { signal: controller.signal }) ); diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx new file mode 100644 index 0000000..f978f15 --- /dev/null +++ b/src/components/Menubar/EventSearchBar.tsx @@ -0,0 +1,357 @@ +import { + Box, + Button, + Card, + CardActions, + CardContent, + Divider, + IconButton, + InputAdornment, + InputLabel, + ListSubheader, + MenuItem, + Popover, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { useRef, useState } from "react"; +import HighlightOffIcon from "@mui/icons-material/HighlightOff"; +import SearchIcon from "@mui/icons-material/Search"; +import TuneIcon from "@mui/icons-material/Tune"; +import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { searchEventsAsync } from "../../features/Search/SearchSlice"; +import { setView } from "../../features/Settings/SettingsSlice"; +import { userAttendee } from "../../features/User/userDataTypes"; +import UserSearch from "../Attendees/AttendeeSearch"; +import { CalendarItemList } from "../Calendar/CalendarItemList"; + +export default function SearchBar() { + const { t } = useI18n(); + const dispatch = useAppDispatch(); + const calendars = Object.values( + useAppSelector((state) => state.calendars.list) + ); + const userId = useAppSelector((state) => state.user.userData.openpaasId); + const personnalCalendars = calendars.filter( + (c) => c.id.split("/")[0] === userId + ); + const sharedCalendars = calendars.filter( + (c) => c.id.split("/")[0] !== userId + ); + + const [search, setSearch] = useState(""); + const [extended, setExtended] = useState(false); + + const [filters, setFilters] = useState({ + searchIn: "my-calendars", + keywords: "", + organizers: [] as userAttendee[], + attendees: [] as userAttendee[], + }); + + const [anchorEl, setAnchorEl] = useState(null); + const filterOpen = Boolean(anchorEl); + + const searchWidth = { + xs: "10vw", + sm: "20vw", + md: "35vw", + xl: "35vw", + "@media (min-width: 2000px)": { + width: "55vw", + }, + }; + const searchBoxRef = useRef(null); + + const handleFilterChange = ( + field: string, + value: string | userAttendee[] + ) => { + setFilters((prev) => ({ ...prev, [field]: value })); + }; + + const handleClearFilters = () => { + setFilters({ + searchIn: "my-calendars", + keywords: "", + organizers: [] as userAttendee[], + attendees: [] as userAttendee[], + }); + setAnchorEl(null); + setExtended(false); + }; + + const handleSearch = async () => { + let searchInCalendars: string[]; + + if (filters.searchIn === "" || !filters.searchIn) { + searchInCalendars = calendars.map((c) => c.id); + } else if (filters.searchIn === "my-calendars") { + searchInCalendars = personnalCalendars.map((c) => c.id); + } else if (filters.searchIn === "shared-calendars") { + searchInCalendars = sharedCalendars.map((c) => c.id); + } else { + searchInCalendars = [filters.searchIn]; + } + + const cleanedFilters = { + ...filters, + organizers: filters.organizers.map((u) => u.cal_address), + attendees: filters.attendees.map((u) => u.cal_address), + searchIn: searchInCalendars, + }; + + dispatch( + searchEventsAsync({ + search, + filters: cleanedFilters, + }) + ); + + dispatch(setView("search")); + setAnchorEl(null); + }; + + return ( + <> + + {!extended && ( + setExtended(true)}> + + + )} + + {extended && ( + { + if (!search.trim()) { + setExtended(false); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSearch(); + } + }} + onChange={(e) => { + setSearch(e.target.value); + handleFilterChange("keywords", e.target.value); + }} + variant="outlined" + sx={{ + borderRadius: "999px", + "& .MuiOutlinedInput-root": { + borderRadius: "999px", + }, + "& .MuiInputBase-input": { padding: "12px 10px" }, + animation: "scaleIn 0.25s ease-out", + "@keyframes scaleIn": { + from: { transform: "scaleX(0)", opacity: 0 }, + to: { transform: "scaleX(1)", opacity: 1 }, + }, + transformOrigin: "right", + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + <> + + setAnchorEl(searchBoxRef.current)} + > + + + + {search && ( + + setSearch("")}> + + + + )} + + ), + }} + /> + )} + + + + + + + + + {t("search.searchIn")} + + + + + + + {t("search.keywords")} + + + handleFilterChange("keywords", e.target.value) + } + /> + + + + + {t("search.organizers")} + + + handleFilterChange("organizers", users) + } + /> + + + + + {t("search.participants")} + + + handleFilterChange("participants", users) + } + /> + + + + + + + + + + + + ); +} diff --git a/src/components/Menubar/Menubar.styl b/src/components/Menubar/Menubar.styl index 733e7bc..38f7345 100644 --- a/src/components/Menubar/Menubar.styl +++ b/src/components/Menubar/Menubar.styl @@ -46,6 +46,9 @@ align-items center width calc(330px - 0.5rem) +.tc-home + cursor pointer + .logo padding 0.5rem 1rem font-size 1.5rem @@ -95,5 +98,9 @@ body.fullscreen-view .navigation-controls, body.fullscreen-view .current-date-time, body.fullscreen-view .refresh-button, -body.fullscreen-view .select-display +body.fullscreen-view .select-display, +body.fullscreen-view .search-container display none + +.search-container + width 100% diff --git a/src/components/Menubar/Menubar.tsx b/src/components/Menubar/Menubar.tsx index 6d3d151..90d5853 100644 --- a/src/components/Menubar/Menubar.tsx +++ b/src/components/Menubar/Menubar.tsx @@ -35,6 +35,7 @@ import { ru as ruLocale, vi as viLocale, } from "date-fns/locale"; +import SearchBar from "./EventSearchBar"; const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale }; export type AppIconProps = { @@ -86,9 +87,9 @@ export function Menubar({ setAnchorEl(null); }; - const handleNavigation = (action: "prev" | "next" | "today") => { + const handleNavigation = async (action: "prev" | "next" | "today") => { if (!calendarRef.current) return; - + await dispatch(setView("calendar")); switch (action) { case "prev": calendarRef.current.prev(); @@ -108,8 +109,10 @@ export function Menubar({ } }; - const handleViewChange = (view: string) => { + const handleViewChange = async (view: string) => { if (!calendarRef.current) return; + await dispatch(setView("calendar")); + calendarRef.current.changeView(view); // Notify parent about view change @@ -190,6 +193,9 @@ export function Menubar({
+
+ +
- {t("menubar.logoAlt")} + {t("menubar.logoAlt")} dispatch(setView("calendar"))} + />
); } diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 8263d89..25602ff 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -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; +} diff --git a/src/features/Search/SearchResultsPage.tsx b/src/features/Search/SearchResultsPage.tsx index 8a161d3..7e1e4d3 100644 --- a/src/features/Search/SearchResultsPage.tsx +++ b/src/features/Search/SearchResultsPage.tsx @@ -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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!hits) { + return ( +
+

{t("search.noResults")}

+ {t("search.noResults")} +
+ ); + } + return ( -
-

Search Results

-
+
+

{t("search.resultsTitle")}

+ + {results?.map((r: any, idx: number) => ( + + ))} + +
+ ); +} + +function ResultItem({ eventData }: { eventData: Record }) { + 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 ( + + + {formatDateTime(startDate)} + + + + {eventData.summary || "Untitled Event"} + + + + {eventData.organizer?.cn || eventData.organizer?.email || "-"} + + ); } diff --git a/src/features/Search/SearchSlice.ts b/src/features/Search/SearchSlice.ts new file mode 100644 index 0000000..f3449a3 --- /dev/null +++ b/src/features/Search/SearchSlice.ts @@ -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[]; + error: string | null; + loading: boolean; +} + +const initialState: SearchResultsState = { + results: [], + hits: 0, + error: null, + loading: false, +}; + +export const searchEventsAsync = createAsyncThunk< + { hits: number; events: Record[] }, + { 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) => { + 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; diff --git a/src/locales/en.json b/src/locales/en.json index 9f6a964..c562416 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -48,7 +48,22 @@ "all": "All", "you": "You", "select_timezone": "Select Timezone", - "moreOptions": "More options" + "moreOptions": "More options", + "search": "Search" + }, + "search": { + "searchIn": "Search in", + "filter": { + "allCalendar": "All calendars", + "myCalendar": "My calendars", + "sharedCalendars": "Shared calendars" + }, + "keywords": "Keywords*", + "keywordsPlaceholder": "Enter keywords", + "organizers": "Organizers", + "participants": "Participants", + "noResults": "No results found", + "resultsTitle": "Search Results" }, "calendarPopover": { "tabs": { diff --git a/src/locales/fr.json b/src/locales/fr.json index 1401859..1a12751 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -48,7 +48,22 @@ "all": "Tous", "you": "Vous", "select_timezone": "Sélectionner le fuseau horaire", - "moreOptions": "Plus d'options" + "moreOptions": "Plus d'options", + "search": "Rechercher" + }, + "search": { + "searchIn": "Rechercher dans", + "filter": { + "allCalendar": "Tous les calendriers", + "myCalendar": "Mes calendriers", + "sharedCalendars": "Calendriers partagés" + }, + "keywords": "Mots-clés*", + "keywordsPlaceholder": "Saisissez des mots-clés", + "organizers": "Organisateurs", + "participants": "Participants", + "noResults": "Aucun résultat", + "resultsTitle": "Résultats de la recherche" }, "calendarPopover": { "tabs": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 5b5725d..8b15eaa 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -48,7 +48,22 @@ "all": "Все", "you": "Вы", "select_timezone": "Выберите часовой пояс", - "moreOptions": "Больше параметров" + "moreOptions": "Больше параметров", + "search": "Поиск" + }, + "search": { + "searchIn": "Поиск в", + "filter": { + "allCalendar": "Все календари", + "myCalendar": "Мои календари", + "sharedCalendars": "Общие календари" + }, + "keywords": "Ключевые слова*", + "keywordsPlaceholder": "Введите ключевые слова", + "organizers": "Организаторы", + "participants": "Участники", + "noResults": "Результаты не найдены", + "resultsTitle": "Результаты поиска" }, "calendarPopover": { "tabs": { diff --git a/src/locales/vi.json b/src/locales/vi.json index c21784a..bcfd1eb 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -48,7 +48,22 @@ "all": "Tất cả", "you": "Bạn", "select_timezone": "Chọn múi giờ", - "moreOptions": "Thêm tùy chọn" + "moreOptions": "Thêm tùy chọn", + "search": "Tìm kiếm" + }, + "search": { + "searchIn": "Tìm kiếm trong", + "filter": { + "allCalendar": "Tất cả lịch", + "myCalendar": "Lịch của tôi", + "sharedCalendars": "Lịch được chia sẻ" + }, + "keywords": "Từ khóa*", + "keywordsPlaceholder": "Nhập từ khóa", + "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" }, "calendarPopover": { "tabs": { diff --git a/src/static/noResult-logo.svg b/src/static/noResult-logo.svg new file mode 100644 index 0000000..d8e928b --- /dev/null +++ b/src/static/noResult-logo.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file