- 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<RootState>) => {
|
||||
|
||||
@@ -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<string, any> = {
|
||||
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<string[]>([]);
|
||||
@@ -116,6 +117,14 @@ export default function CalendarApp({
|
||||
const [eventErrors, setEventErrors] = useState<string[]>([]);
|
||||
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({
|
||||
</Box>
|
||||
<div className="calendar">
|
||||
<ImportAlert />
|
||||
<FullCalendar
|
||||
ref={(ref) => {
|
||||
if (ref) {
|
||||
calendarRef.current = ref.getApi();
|
||||
{view === "calendar" && (
|
||||
<FullCalendar
|
||||
ref={(ref) => {
|
||||
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 (
|
||||
<div className="weekSelector">
|
||||
<div>
|
||||
{t("menubar.views.week")} {arg.num}
|
||||
</div>
|
||||
<TimezoneSelector
|
||||
value={timezone}
|
||||
referenceDate={calendarRef.current?.getDate() ?? new Date()}
|
||||
onChange={(newTimezone: string) =>
|
||||
dispatch(setTimeZone(newTimezone))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
dayCellContent={(arg) => {
|
||||
const month = arg.date.toLocaleDateString(t("locale"), {
|
||||
month: "short",
|
||||
});
|
||||
if (arg.view.type === "dayGridMonth") {
|
||||
weekNumberFormat={{ week: "long" }}
|
||||
weekNumberContent={(arg) => {
|
||||
return (
|
||||
<span
|
||||
className={`fc-daygrid-day-number ${
|
||||
arg.isToday ? "current-date" : ""
|
||||
}`}
|
||||
>
|
||||
{arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText}
|
||||
</span>
|
||||
<div className="weekSelector">
|
||||
<div>
|
||||
{t("menubar.views.week")} {arg.num}
|
||||
</div>
|
||||
<TimezoneSelector
|
||||
value={timezone}
|
||||
referenceDate={calendarRef.current?.getDate() ?? new Date()}
|
||||
onChange={(newTimezone: string) =>
|
||||
dispatch(setTimeZone(newTimezone))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}}
|
||||
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 (
|
||||
<div className="fc-daygrid-day-top">
|
||||
<small>{weekDay}</small>
|
||||
{arg.view.type !== "dayGridMonth" && (
|
||||
}}
|
||||
dayCellContent={(arg) => {
|
||||
const month = arg.date.toLocaleDateString(t("locale"), {
|
||||
month: "short",
|
||||
});
|
||||
if (arg.view.type === "dayGridMonth") {
|
||||
return (
|
||||
<span
|
||||
className={`fc-daygrid-day-number ${
|
||||
arg.isToday ? "current-date" : ""
|
||||
}`}
|
||||
>
|
||||
{date}
|
||||
{arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
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 (
|
||||
<div className="fc-daygrid-day-top">
|
||||
<small>{weekDay}</small>
|
||||
{arg.view.type !== "dayGridMonth" && (
|
||||
<span
|
||||
className={`fc-daygrid-day-number ${
|
||||
arg.isToday ? "current-date" : ""
|
||||
}`}
|
||||
>
|
||||
{date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
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" && <SearchResultsPage />}
|
||||
<EventPopover
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function CalendarLayout() {
|
||||
return (
|
||||
<div className="App">
|
||||
<Menubar {...menubarProps} />
|
||||
{view === "calendar" && (
|
||||
{(view === "calendar" || view === "search") && (
|
||||
<CalendarApp
|
||||
calendarRef={calendarRef}
|
||||
onDateChange={handleDateChange}
|
||||
@@ -85,7 +85,6 @@ export default function CalendarLayout() {
|
||||
/>
|
||||
)}
|
||||
{view === "settings" && <SettingsPage />}
|
||||
{view === "search" && <SearchResultsPage />}
|
||||
<ErrorSnackbar error={error} type="calendar" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<DateCalendar
|
||||
value={moment(visibleDate)}
|
||||
onChange={(dateMoment, selectionState) => {
|
||||
onChange={async (dateMoment, selectionState) => {
|
||||
if (!dateMoment) return;
|
||||
const date = dateMoment.toDate();
|
||||
if (selectionState === "finish") {
|
||||
await dispatch(setView("calendar"));
|
||||
setSelectedMiniDate(date);
|
||||
calendarRef.current?.gotoDate(date);
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
);
|
||||
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const filterOpen = Boolean(anchorEl);
|
||||
|
||||
const searchWidth = {
|
||||
xs: "10vw",
|
||||
sm: "20vw",
|
||||
md: "35vw",
|
||||
xl: "35vw",
|
||||
"@media (min-width: 2000px)": {
|
||||
width: "55vw",
|
||||
},
|
||||
};
|
||||
const searchBoxRef = useRef<HTMLElement | null>(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 (
|
||||
<>
|
||||
<Box
|
||||
ref={searchBoxRef}
|
||||
sx={{
|
||||
margin: "0 auto",
|
||||
height: "44px",
|
||||
position: "relative",
|
||||
width: extended ? searchWidth : "auto",
|
||||
|
||||
transition: "width 0.25s ease-out",
|
||||
}}
|
||||
>
|
||||
{!extended && (
|
||||
<IconButton onClick={() => setExtended(true)}>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{extended && (
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder={t("common.search")}
|
||||
value={search}
|
||||
onBlur={() => {
|
||||
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: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ color: "#605D62" }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={(e) => setAnchorEl(searchBoxRef.current)}
|
||||
>
|
||||
<TuneIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
{search && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setSearch("")}>
|
||||
<HighlightOffIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Popover
|
||||
open={filterOpen}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClearFilters}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { mt: 1.2, width: extended ? searchWidth : "auto" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Card sx={{ p: 2, pb: 1 }}>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "140px 1fr",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<InputLabel id="search-in" sx={{ m: 0 }}>
|
||||
{t("search.searchIn")}
|
||||
</InputLabel>
|
||||
<Select
|
||||
displayEmpty
|
||||
value={filters.searchIn}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("searchIn", e.target.value)
|
||||
}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 300,
|
||||
color: "#8C9CAF",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<Typography
|
||||
sx={{
|
||||
color: "#243B55",
|
||||
font: "Roboto",
|
||||
fontSize: "16px",
|
||||
weight: 400,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{t("search.filter.allCalendar")}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
value="my-calendars"
|
||||
sx={{
|
||||
color: "#243B55",
|
||||
font: "Roboto",
|
||||
fontSize: "12px",
|
||||
weight: 400,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{t("search.filter.myCalendar")}
|
||||
</MenuItem>
|
||||
{CalendarItemList(personnalCalendars)}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
value="shared-calendars"
|
||||
sx={{
|
||||
color: "#243B55",
|
||||
font: "Roboto",
|
||||
fontSize: "12px",
|
||||
weight: 400,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{t("search.filter.sharedCalendars")}
|
||||
</MenuItem>
|
||||
{CalendarItemList(sharedCalendars)}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "140px 1fr",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<InputLabel id="keywords" sx={{ m: 0 }}>
|
||||
{t("search.keywords")}
|
||||
</InputLabel>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder={t("search.keywordsPlaceholder")}
|
||||
value={filters.keywords}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("keywords", e.target.value)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "140px 1fr",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<InputLabel id="from" sx={{ m: 0 }}>
|
||||
{t("search.organizers")}
|
||||
</InputLabel>
|
||||
<UserSearch
|
||||
attendees={filters.organizers}
|
||||
setAttendees={(users: userAttendee[]) =>
|
||||
handleFilterChange("organizers", users)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "140px 1fr",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<InputLabel id="participant" sx={{ m: 0 }}>
|
||||
{t("search.participants")}
|
||||
</InputLabel>
|
||||
<UserSearch
|
||||
attendees={filters.attendees}
|
||||
setAttendees={(users: userAttendee[]) =>
|
||||
handleFilterChange("participants", users)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ justifyContent: "flex-end", p: 2, gap: 2 }}>
|
||||
<Button variant="text" onClick={handleClearFilters}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSearch}>
|
||||
{t("common.search")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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%
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
<div className="right-menu">
|
||||
<div className="search-container">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<div className="menu-items">
|
||||
<IconButton
|
||||
className="refresh-button"
|
||||
@@ -350,9 +356,15 @@ export function Menubar({
|
||||
|
||||
export function MainTitle() {
|
||||
const { t } = useI18n();
|
||||
const dispatch = useAppDispatch();
|
||||
return (
|
||||
<div className="menubar-item tc-home">
|
||||
<img className="logo" src={logo} alt={t("menubar.logoAlt")} />
|
||||
<img
|
||||
className="logo"
|
||||
src={logo}
|
||||
alt={t("menubar.logoAlt")}
|
||||
onClick={() => dispatch(setView("calendar"))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+16
-1
@@ -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": {
|
||||
|
||||
+16
-1
@@ -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": {
|
||||
|
||||
+16
-1
@@ -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": {
|
||||
|
||||
+16
-1
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg width="108" height="108" viewBox="0 0 108 108" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="108" height="108" rx="35.6673" fill="white" />
|
||||
<rect width="108" height="108" rx="35.6673" fill="#F0F1F3" />
|
||||
<g filter="url(#filter0_d_4594_5504)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M36.6675 21.4402C36.0425 21.1055 35.3028 20.9272 34.3118 20.9272H33.9982C33.0071 20.9272 32.2675 21.1055 31.6424 21.4402C31.0174 21.7747 30.5268 22.2659 30.1927 22.8917C29.8584 23.5175 29.6804 24.258 29.6804 25.2503L29.6835 26.7027C28.2184 27.042 27.1226 27.5098 26.0226 28.0989C23.5225 29.4375 21.5603 31.402 20.2232 33.9051C18.8861 36.4083 18.1741 38.8912 18.1741 45.7381V67.7969C18.1741 74.6439 18.8861 77.1268 20.2232 79.6299C21.5603 82.1331 23.5225 84.0975 26.0226 85.4362C28.5227 86.7748 31.0024 87.4877 37.8411 87.4877H70.1012C76.9399 87.4877 79.4198 86.7748 81.9199 85.4362C84.4201 84.0975 86.3822 82.1331 87.7192 79.6299C89.0565 77.1268 89.7685 74.6439 89.7685 67.7969V45.7381C89.7685 38.8912 89.0565 36.4083 87.7192 33.9051C86.3822 31.402 84.4201 29.4375 81.9199 28.0989C80.8212 27.5105 79.7265 27.0429 78.2638 26.7039L78.2622 25.2503C78.2622 24.258 78.0842 23.5175 77.7499 22.8917C77.4155 22.2659 76.9251 21.7747 76.3001 21.4402C75.6751 21.1055 74.9352 20.9272 73.9444 20.9272H73.6308C72.6397 20.9272 71.9001 21.1055 71.275 21.4402C70.65 21.7747 70.1594 22.2659 69.8251 22.8917C69.491 23.5175 69.313 24.258 69.313 25.2503V26.0471L38.6296 26.0421V25.2503C38.6296 24.258 38.4516 23.5175 38.1172 22.8917C37.7832 22.2659 37.2925 21.7747 36.6675 21.4402ZM31.3846 45.2473H76.5579C78.0396 45.2473 78.577 45.4019 79.1186 45.6918C79.6602 45.9819 80.0853 46.4075 80.3751 46.9501C80.6649 47.4924 80.819 48.0303 80.819 49.5138V72.6267L80.7847 73.2682C80.4652 76.2185 77.9688 78.5148 74.9252 78.5148L34.6482 78.4263L33.8349 78.4144C31.819 78.3566 30.9747 78.0889 30.1245 77.6321C29.1683 77.1182 28.4179 76.3655 27.9068 75.4066C27.3954 74.4478 27.1233 73.4973 27.1233 70.8758V49.5138L27.1328 48.9331C27.1702 47.8765 27.3191 47.4148 27.5674 46.9501C27.857 46.4075 28.2821 45.9819 28.8239 45.6918C29.3656 45.4019 29.9029 45.2473 31.3846 45.2473ZM69.51 51.6474H66.5588C64.8491 51.6474 64.2293 51.8256 63.6043 52.1604C62.9792 52.4951 62.4886 52.9861 62.1543 53.6119C61.8202 54.2376 61.6422 54.8584 61.6422 56.5702V59.5248C61.6422 61.2365 61.8202 61.8573 62.1543 62.4831C62.4886 63.1089 62.9792 63.5999 63.6043 63.9346C64.2293 64.2693 64.8491 64.4476 66.5588 64.4476H69.51C71.2198 64.4476 71.8396 64.2693 72.4646 63.9346C73.0896 63.5999 73.5803 63.1089 73.9146 62.4831C74.2487 61.8573 74.4267 61.2365 74.4267 59.5248V56.5702C74.4267 54.8584 74.2487 54.2376 73.9146 53.6119C73.5803 52.9861 73.0896 52.4951 72.4646 52.1604C71.8396 51.8256 71.2198 51.6474 69.51 51.6474Z"
|
||||
fill="white" />
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_4594_5504" x="12.6668" y="16.5214" width="82.6089" height="77.5751"
|
||||
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix in="SourceAlpha" type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
|
||||
<feOffset dy="1.10145" />
|
||||
<feGaussianBlur stdDeviation="2.75363" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" />
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4594_5504" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4594_5504"
|
||||
result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
Reference in New Issue
Block a user