Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
@@ -572,7 +572,7 @@ describe("calendar Availability search", () => {
|
|||||||
|
|
||||||
const callCount = spy.mock.calls.length;
|
const callCount = spy.mock.calls.length;
|
||||||
expect(callCount).toBeGreaterThan(0);
|
expect(callCount).toBeGreaterThan(0);
|
||||||
expect(callCount).toBeLessThanOrEqual(24);
|
expect(callCount).toBeLessThanOrEqual(36);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefetches hidden calendars only after active load completes", async () => {
|
it("prefetches hidden calendars only after active load completes", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import CalendarResources from "@/components/Calendar/CalendarResources";
|
||||||
|
import { getCalendars } from "@/features/Calendars/CalendarApi";
|
||||||
|
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
|
||||||
|
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { renderWithProviders } from "../utils/Renderwithproviders";
|
||||||
|
|
||||||
|
jest.mock("@/features/Calendars/CalendarApi");
|
||||||
|
jest.mock("@/features/Calendars/api/addCalendarResourceAsync");
|
||||||
|
jest.mock("@/components/Attendees/ResourceSearch", () => ({
|
||||||
|
ResourceSearch: ({
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
onChange: (
|
||||||
|
event: null,
|
||||||
|
value: { displayName: string; openpaasId: string }[]
|
||||||
|
) => void;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="resource-search">
|
||||||
|
<button
|
||||||
|
data-testid="mock-resource-search-select"
|
||||||
|
onClick={() =>
|
||||||
|
onChange(null, [
|
||||||
|
{ displayName: "Room A", openpaasId: "room-a-id" },
|
||||||
|
{ displayName: "Room B", openpaasId: "room-b-id" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Select Resources
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetCalendars = getCalendars as jest.Mock;
|
||||||
|
const mockedAddCalendarResourceAsync =
|
||||||
|
addCalendarResourceAsync as unknown as jest.Mock;
|
||||||
|
|
||||||
|
describe("CalendarResources", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUser = {
|
||||||
|
userData: {
|
||||||
|
sub: "test",
|
||||||
|
email: "test@test.com",
|
||||||
|
sid: "mockSid",
|
||||||
|
openpaasId: "user1",
|
||||||
|
},
|
||||||
|
tokens: { accessToken: "token" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (isOpen = true) => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
renderWithProviders(<CalendarResources onClose={onClose} open={isOpen} />, {
|
||||||
|
user: baseUser,
|
||||||
|
});
|
||||||
|
return { onClose };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders correctly and closes on cancel", () => {
|
||||||
|
const { onClose } = setup();
|
||||||
|
|
||||||
|
expect(screen.getByText("calendar.browseResources")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const cancelButton = screen.getByRole("button", {
|
||||||
|
name: "common.cancel",
|
||||||
|
});
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when isOpen is false", () => {
|
||||||
|
setup(false);
|
||||||
|
expect(
|
||||||
|
screen.queryByText("calendar.browseResources")
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches resource calendars and adds them using Promise.allSettled logic", async () => {
|
||||||
|
const mockCalendarDataA = {
|
||||||
|
_embedded: {
|
||||||
|
"dav:calendar": [
|
||||||
|
{
|
||||||
|
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } },
|
||||||
|
"dav:name": "Room A Calendar",
|
||||||
|
"caldav:description": "Main room A calendar",
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedGetCalendars.mockImplementation((userId) => {
|
||||||
|
if (userId === "room-a-id") return Promise.resolve(mockCalendarDataA);
|
||||||
|
// Simulate failure for room-b
|
||||||
|
if (userId === "room-b-id")
|
||||||
|
return Promise.reject(new Error("Failed fetching Room B"));
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockDispatchResult = {
|
||||||
|
unwrap: () => Promise.resolve({ type: "success" }),
|
||||||
|
};
|
||||||
|
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult);
|
||||||
|
|
||||||
|
const { onClose } = setup();
|
||||||
|
|
||||||
|
// Trigger resource selection
|
||||||
|
const mockSelectBtn = screen.getByTestId("mock-resource-search-select");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(mockSelectBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// After selection, the button should be enabled and say Add
|
||||||
|
const addButton = await screen.findByRole("button", {
|
||||||
|
name: "actions.add",
|
||||||
|
});
|
||||||
|
expect(addButton).not.toBeDisabled();
|
||||||
|
|
||||||
|
// Click add to trigger addCalendarResourceAsync
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// It should only have been called for Room A because Room B failed (Promise.allSettled)
|
||||||
|
expect(mockedAddCalendarResourceAsync).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const payload = mockedAddCalendarResourceAsync.mock.calls[0][0];
|
||||||
|
expect(payload).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: "user1",
|
||||||
|
calId: expect.any(String),
|
||||||
|
cal: expect.objectContaining({
|
||||||
|
cal: mockCalendarDataA._embedded["dav:calendar"][0],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds existing user details fallback logic within submit handler", async () => {
|
||||||
|
const mockCalendarDataA = {
|
||||||
|
_embedded: {
|
||||||
|
"dav:calendar": [
|
||||||
|
{
|
||||||
|
_links: { self: { href: "/calendars/room-a-id/cal-a.json" } },
|
||||||
|
"dav:name": "Room A Calendar",
|
||||||
|
"caldav:description": "Main room A calendar",
|
||||||
|
color: "red",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockedGetCalendars.mockResolvedValueOnce(mockCalendarDataA);
|
||||||
|
mockedGetCalendars.mockResolvedValueOnce([]); // Mock array of size 2 (for both A and B but second resolves empty)
|
||||||
|
|
||||||
|
// Simulate successful API response
|
||||||
|
const mockDispatchResult = {
|
||||||
|
unwrap: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult);
|
||||||
|
|
||||||
|
const { onClose } = setup();
|
||||||
|
|
||||||
|
// Trigger resource selection
|
||||||
|
const mockSelectBtn = screen.getByTestId("mock-resource-search-select");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(mockSelectBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addButton = await screen.findByRole("button", {
|
||||||
|
name: "actions.add",
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,5 +98,5 @@ describe("Event Error Handling", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
consoleWarnSpy.mockRestore();
|
consoleWarnSpy.mockRestore();
|
||||||
});
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
ResourceSearch,
|
||||||
|
Resource,
|
||||||
|
} from "@/components/Attendees/ResourceSearch";
|
||||||
|
import { searchUsers } from "@/features/User/userAPI";
|
||||||
|
import { act, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderWithProviders } from "../utils/Renderwithproviders";
|
||||||
|
|
||||||
|
jest.mock("@/features/User/userAPI");
|
||||||
|
const mockedSearchUsers = searchUsers as jest.MockedFunction<
|
||||||
|
typeof searchUsers
|
||||||
|
>;
|
||||||
|
|
||||||
|
describe("ResourceSearch", () => {
|
||||||
|
const baseResource: Resource = {
|
||||||
|
displayName: "Projector Room",
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
selectedResources: Resource[] = [],
|
||||||
|
props?: Partial<React.ComponentProps<typeof ResourceSearch>>
|
||||||
|
) {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<ResourceSearch
|
||||||
|
objectTypes={["resource"]}
|
||||||
|
selectedResources={selectedResources}
|
||||||
|
onChange={onChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return { onChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockedSearchUsers.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls searchUsers after debounce when typing", async () => {
|
||||||
|
mockedSearchUsers.mockResolvedValueOnce([
|
||||||
|
baseResource,
|
||||||
|
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
|
||||||
|
setup();
|
||||||
|
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
await userEvent.type(input, "Room");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedSearchUsers).toHaveBeenCalledWith("Room", ["resource"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders search results and allows selection", async () => {
|
||||||
|
mockedSearchUsers.mockResolvedValueOnce([
|
||||||
|
baseResource,
|
||||||
|
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
|
||||||
|
const { onChange } = setup();
|
||||||
|
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
await userEvent.type(input, "Room");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
const option = await screen.findByText("Projector Room");
|
||||||
|
await userEvent.click(option);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show already selected resources in options", async () => {
|
||||||
|
mockedSearchUsers.mockResolvedValueOnce([
|
||||||
|
baseResource,
|
||||||
|
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
|
||||||
|
setup([baseResource]);
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
await userEvent.type(input, "Projector");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// It shouldn't be in the dropdown options anymore
|
||||||
|
const options = screen.queryAllByRole("option");
|
||||||
|
expect(
|
||||||
|
options.find((opt) => opt.textContent === "Projector Room")
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects disabled state", () => {
|
||||||
|
setup([], { disabled: true });
|
||||||
|
expect(screen.getByRole("combobox")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no options doesn't show dropdown when input is empty", async () => {
|
||||||
|
mockedSearchUsers.mockResolvedValueOnce([
|
||||||
|
baseResource,
|
||||||
|
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
|
||||||
|
setup();
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
|
||||||
|
await userEvent.type(input, "Room");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.clear(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'No results' when search succeeds but returns empty array", async () => {
|
||||||
|
mockedSearchUsers.mockResolvedValueOnce([]);
|
||||||
|
setup();
|
||||||
|
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
await userEvent.type(input, "Room");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const noResults = await screen.findByText(
|
||||||
|
"resourceSearch.noResults",
|
||||||
|
{},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
expect(noResults).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears options when search fails and shows error snackbar", async () => {
|
||||||
|
mockedSearchUsers.mockResolvedValueOnce([
|
||||||
|
baseResource,
|
||||||
|
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
|
||||||
|
setup();
|
||||||
|
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
await userEvent.type(input, "Room");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Projector Room")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedSearchUsers.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "Error");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage = await screen.findByText("resourceSearch.searchError");
|
||||||
|
expect(errorMessage).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.queryByText("Projector Room")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
mockedSearchUsers.mockResolvedValueOnce([
|
||||||
|
baseResource,
|
||||||
|
] as unknown as Awaited<ReturnType<typeof searchUsers>>);
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "Room");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Projector Room")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading text when searching", async () => {
|
||||||
|
let resolveSearch: (value: Resource[]) => void;
|
||||||
|
const searchPromise = new Promise<Resource[]>((resolve) => {
|
||||||
|
resolveSearch = resolve;
|
||||||
|
});
|
||||||
|
mockedSearchUsers.mockReturnValueOnce(
|
||||||
|
searchPromise as unknown as ReturnType<typeof searchUsers>
|
||||||
|
);
|
||||||
|
setup();
|
||||||
|
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
await userEvent.type(input, "Room");
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingText = await screen.findByText(
|
||||||
|
"resourceSearch.loading",
|
||||||
|
{},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
expect(loadingText).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveSearch!([baseResource]);
|
||||||
|
await searchPromise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useUserSearch } from "@/components/Attendees/useUserSearch";
|
||||||
|
import { searchUsers } from "@/features/User/userAPI";
|
||||||
|
|
||||||
|
jest.mock("@/features/User/userAPI", () => ({
|
||||||
|
searchUsers: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useUserSearch", () => {
|
||||||
|
const mockSearchUsers = searchUsers as jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize with default values", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.query).toBe("");
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.options).toEqual([]);
|
||||||
|
expect(result.current.hasSearched).toBe(false);
|
||||||
|
expect(result.current.isOpen).toBe(false);
|
||||||
|
expect(result.current.inputError).toBeNull();
|
||||||
|
expect(result.current.snackbarOpen).toBe(false);
|
||||||
|
expect(result.current.snackbarMessage).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should debounce and fetch users when query changes", async () => {
|
||||||
|
const mockUsers = [{ displayName: "John Doe", email: "john@example.com" }];
|
||||||
|
mockSearchUsers.mockResolvedValueOnce(mockUsers);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery("John");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false); // Before debounce
|
||||||
|
|
||||||
|
// Wait for the mock promise to resolve within act
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
await Promise.resolve(); // allow microtasks to flush
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSearchUsers).toHaveBeenCalledWith("John", ["user"]);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.options).toEqual(mockUsers);
|
||||||
|
expect(result.current.hasSearched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear options and handle empty query", () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useUserSearch({ objectTypes: ["user"], errorMessage: "Error" })
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery(" "); // empty query with spaces
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSearchUsers).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.options).toEqual([]);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.hasSearched).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle search errors and show the custom error message", async () => {
|
||||||
|
mockSearchUsers.mockRejectedValueOnce(new Error("API Error"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useUserSearch({ objectTypes: ["user"], errorMessage: "Custom Error" })
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setQuery("FailedSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
await Promise.resolve(); // allow microtasks to flush
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.hasSearched).toBe(false);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.snackbarOpen).toBe(true);
|
||||||
|
expect(result.current.snackbarMessage).toBe("Custom Error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
|
||||||
|
import { addSharedCalendar } from "@/features/Calendars/CalendarApi";
|
||||||
|
import { getResourceDetails } from "@/features/User/userAPI";
|
||||||
|
import { toRejectedError } from "@/utils/errorUtils";
|
||||||
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
jest.mock("@/features/Calendars/CalendarApi");
|
||||||
|
jest.mock("@/features/User/userAPI");
|
||||||
|
jest.mock("@/utils/errorUtils");
|
||||||
|
|
||||||
|
const mockedAddSharedCalendar = addSharedCalendar as jest.Mock;
|
||||||
|
const mockedGetResourceDetails = getResourceDetails as jest.Mock;
|
||||||
|
const mockedToRejectedError = toRejectedError as jest.Mock;
|
||||||
|
|
||||||
|
describe("addCalendarResourceAsync thunk", () => {
|
||||||
|
let store: ReturnType<typeof configureStore>;
|
||||||
|
const dispatch = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
store = configureStore({
|
||||||
|
reducer: () => ({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockPayload = {
|
||||||
|
userId: "user-123",
|
||||||
|
calId: "cal-123",
|
||||||
|
cal: {
|
||||||
|
color: {
|
||||||
|
background: "#000000",
|
||||||
|
foreground: "#FFFFFF",
|
||||||
|
},
|
||||||
|
cal: {
|
||||||
|
"dav:name": "Resource Room A",
|
||||||
|
"caldav:description": "A meeting room",
|
||||||
|
_links: {
|
||||||
|
self: {
|
||||||
|
href: "/calendars/res-456/cal-123.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResolvedResourceData = {
|
||||||
|
_id: "res-456",
|
||||||
|
id: "res-456",
|
||||||
|
name: "Resource Room A",
|
||||||
|
description: "A meeting room",
|
||||||
|
creator: "user-789",
|
||||||
|
deleted: false,
|
||||||
|
_rev: "1",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should add shared calendar, fetch resource details, map userData", async () => {
|
||||||
|
mockedGetResourceDetails.mockResolvedValueOnce(mockResolvedResourceData);
|
||||||
|
mockedAddSharedCalendar.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const result = await addCalendarResourceAsync(
|
||||||
|
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
|
||||||
|
)(dispatch, store.getState, undefined);
|
||||||
|
|
||||||
|
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
|
||||||
|
mockPayload.userId,
|
||||||
|
mockPayload.calId,
|
||||||
|
mockPayload.cal
|
||||||
|
);
|
||||||
|
expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456");
|
||||||
|
|
||||||
|
expect(result.type).toBe("calendars/addCalendarResource/fulfilled");
|
||||||
|
expect(result.payload).toEqual({
|
||||||
|
calId: "res-456/cal-123",
|
||||||
|
color: { background: "#000000", foreground: "#FFFFFF" },
|
||||||
|
desc: "A meeting room",
|
||||||
|
link: "/calendars/user-123/cal-123.json",
|
||||||
|
name: "Resource Room A",
|
||||||
|
owner: mockResolvedResourceData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to name if resource details fetch fails", async () => {
|
||||||
|
mockedAddSharedCalendar.mockResolvedValueOnce({});
|
||||||
|
const errorDetails = new Error("Fetch failed");
|
||||||
|
mockedGetResourceDetails.mockRejectedValueOnce(errorDetails);
|
||||||
|
const mockRejectedErrorResult = { message: "Fetch failed" };
|
||||||
|
mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult);
|
||||||
|
|
||||||
|
const result = await addCalendarResourceAsync(
|
||||||
|
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
|
||||||
|
)(dispatch, store.getState, undefined);
|
||||||
|
|
||||||
|
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
|
||||||
|
mockPayload.userId,
|
||||||
|
mockPayload.calId,
|
||||||
|
mockPayload.cal
|
||||||
|
);
|
||||||
|
expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456");
|
||||||
|
|
||||||
|
expect(mockedToRejectedError).toHaveBeenCalledWith(errorDetails);
|
||||||
|
|
||||||
|
expect(result.type).toBe("calendars/addCalendarResource/rejected");
|
||||||
|
expect(result.payload).toEqual(mockRejectedErrorResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle error if addSharedCalendar fails", async () => {
|
||||||
|
const errorAdd = new Error("Add failed");
|
||||||
|
mockedAddSharedCalendar.mockRejectedValueOnce(errorAdd);
|
||||||
|
const mockRejectedErrorResult = { message: "Add failed" };
|
||||||
|
mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult);
|
||||||
|
|
||||||
|
const result = await addCalendarResourceAsync(
|
||||||
|
mockPayload as unknown as Parameters<typeof addCalendarResourceAsync>[0]
|
||||||
|
)(dispatch, store.getState, undefined);
|
||||||
|
|
||||||
|
expect(mockedAddSharedCalendar).toHaveBeenCalledWith(
|
||||||
|
mockPayload.userId,
|
||||||
|
mockPayload.calId,
|
||||||
|
mockPayload.cal
|
||||||
|
);
|
||||||
|
expect(mockedGetResourceDetails).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd);
|
||||||
|
|
||||||
|
expect(result.type).toBe("calendars/addCalendarResource/rejected");
|
||||||
|
expect(result.payload).toEqual(mockRejectedErrorResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,10 +15,9 @@ jest.mock("@/features/Calendars/utils/normalizeCalendar");
|
|||||||
|
|
||||||
const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock;
|
const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock;
|
||||||
const mockedGetUserDetails = getUserDetails as jest.Mock;
|
const mockedGetUserDetails = getUserDetails as jest.Mock;
|
||||||
const mockedgetResourceDetails = getResourceDetails as jest.Mock;
|
const mockedGetResourceDetails = getResourceDetails as jest.Mock;
|
||||||
const mockedGetCalendars = getCalendars as jest.Mock;
|
const mockedGetCalendars = getCalendars as jest.Mock;
|
||||||
const mockedFormatReduxError = formatReduxError as jest.Mock;
|
const mockedFormatReduxError = formatReduxError as jest.Mock;
|
||||||
const mockedToRejectedError = toRejectedError as jest.Mock;
|
|
||||||
const mockedNormalizeCalendar = normalizeCalendar as jest.Mock;
|
const mockedNormalizeCalendar = normalizeCalendar as jest.Mock;
|
||||||
|
|
||||||
describe("getCalendarsListAsync", () => {
|
describe("getCalendarsListAsync", () => {
|
||||||
@@ -143,7 +142,12 @@ describe("getCalendarsListAsync", () => {
|
|||||||
response: { status: 500 },
|
response: { status: 500 },
|
||||||
message: "Server Error",
|
message: "Server Error",
|
||||||
});
|
});
|
||||||
mockedToRejectedError.mockReturnValueOnce({
|
|
||||||
|
// toRejectedError is imported from a mocked module; provide the expected return
|
||||||
|
const { toRejectedError } = jest.requireMock("@/utils/errorUtils") as {
|
||||||
|
toRejectedError: jest.Mock;
|
||||||
|
};
|
||||||
|
toRejectedError.mockReturnValueOnce({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: "Server Error",
|
message: "Server Error",
|
||||||
});
|
});
|
||||||
@@ -175,7 +179,7 @@ describe("getCalendarsListAsync", () => {
|
|||||||
// getUserDetails fails with 404 for the resource ID
|
// getUserDetails fails with 404 for the resource ID
|
||||||
mockedGetUserDetails.mockRejectedValueOnce({ response: { status: 404 } });
|
mockedGetUserDetails.mockRejectedValueOnce({ response: { status: 404 } });
|
||||||
// Then getResourceDetails is called and succeeds
|
// Then getResourceDetails is called and succeeds
|
||||||
mockedgetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" });
|
mockedGetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" });
|
||||||
// Then getUserDetails is called for the creator and succeeds
|
// Then getUserDetails is called for the creator and succeeds
|
||||||
mockedGetUserDetails.mockResolvedValueOnce({
|
mockedGetUserDetails.mockResolvedValueOnce({
|
||||||
firstname: "Creator",
|
firstname: "Creator",
|
||||||
@@ -187,7 +191,7 @@ describe("getCalendarsListAsync", () => {
|
|||||||
const result = await thunk(dispatch, getState, undefined);
|
const result = await thunk(dispatch, getState, undefined);
|
||||||
|
|
||||||
const payload = result.payload as any;
|
const payload = result.payload as any;
|
||||||
expect(mockedgetResourceDetails).toHaveBeenCalledWith("resource-123");
|
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123");
|
||||||
expect(mockedGetUserDetails).toHaveBeenCalledWith("creator-456");
|
expect(mockedGetUserDetails).toHaveBeenCalledWith("creator-456");
|
||||||
expect(payload.importedCalendars["cal-1"].owner).toEqual({
|
expect(payload.importedCalendars["cal-1"].owner).toEqual({
|
||||||
firstname: "Creator",
|
firstname: "Creator",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils";
|
import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils";
|
||||||
import { stringAvatar } from "@/components/Event/utils/eventUtils";
|
import { stringAvatar } from "@/components/Event/utils/eventUtils";
|
||||||
|
import { useUserSearch } from "./useUserSearch";
|
||||||
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
|
||||||
import { searchUsers } from "@/features/User/userAPI";
|
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -22,8 +21,6 @@ import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined
|
|||||||
import {
|
import {
|
||||||
HTMLAttributes,
|
HTMLAttributes,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SyntheticEvent,
|
type SyntheticEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -82,21 +79,29 @@ export function PeopleSearch({
|
|||||||
getChipIcon?: (user: User) => ReactNode;
|
getChipIcon?: (user: User) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [query, setQuery] = useState("");
|
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder");
|
||||||
const [loading, setLoading] = useState(false);
|
const errorMessage = t("peopleSearch.searchError");
|
||||||
const [options, setOptions] = useState<User[]>([]);
|
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
const {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
options,
|
||||||
|
hasSearched,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
inputError,
|
||||||
|
setInputError,
|
||||||
|
snackbarOpen,
|
||||||
|
setSnackbarOpen,
|
||||||
|
snackbarMessage,
|
||||||
|
setSnackbarMessage,
|
||||||
|
} = useUserSearch<User>({ objectTypes, errorMessage });
|
||||||
|
|
||||||
const isValidEmail = (email: string) =>
|
const isValidEmail = (email: string) =>
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
const [inputError, setInputError] = useState<string | null>(null);
|
|
||||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
|
||||||
const [snackbarMessage, setSnackbarMessage] = useState("");
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder");
|
|
||||||
|
|
||||||
const handleBlurCommit = useCallback(
|
const handleBlurCommit = useCallback(
|
||||||
(event: React.SyntheticEvent) => {
|
(event: React.SyntheticEvent) => {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
@@ -116,52 +121,9 @@ export function PeopleSearch({
|
|||||||
onChange(event, [...selectedUsers, newUser]);
|
onChange(event, [...selectedUsers, newUser]);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
},
|
},
|
||||||
[query, selectedUsers, onChange, t]
|
[query, selectedUsers, onChange, t, setInputError, setQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const delayDebounceFn = setTimeout(async () => {
|
|
||||||
if (!query.trim()) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setOptions([]);
|
|
||||||
setLoading(false);
|
|
||||||
setHasSearched(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(true);
|
|
||||||
setHasSearched(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await searchUsers(query, objectTypes);
|
|
||||||
if (!cancelled) {
|
|
||||||
setOptions(res);
|
|
||||||
setHasSearched(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setHasSearched(false);
|
|
||||||
setSnackbarMessage(t("peopleSearch.searchError"));
|
|
||||||
setSnackbarOpen(true);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(delayDebounceFn);
|
|
||||||
};
|
|
||||||
}, [objectTypes, query, t]);
|
|
||||||
|
|
||||||
const defaultRenderInput = useCallback(
|
const defaultRenderInput = useCallback(
|
||||||
(params: AutocompleteRenderInputParams) => {
|
(params: AutocompleteRenderInputParams) => {
|
||||||
const inputProps = {
|
const inputProps = {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Avatar } from "@linagora/twake-mui";
|
||||||
|
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
|
||||||
|
import CalendarMonthOutlinedIcon from "@mui/icons-material/CalendarMonthOutlined";
|
||||||
|
import PhoneIphoneOutlinedIcon from "@mui/icons-material/PhoneIphoneOutlined";
|
||||||
|
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||||
|
import VideoCameraBackOutlinedIcon from "@mui/icons-material/VideoCameraBackOutlined";
|
||||||
|
import MeetingRoomOutlinedIcon from "@mui/icons-material/MeetingRoomOutlined";
|
||||||
|
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
|
||||||
|
import ViewComfyAltOutlinedIcon from "@mui/icons-material/ViewComfyAltOutlined";
|
||||||
|
import TvOutlinedIcon from "@mui/icons-material/TvOutlined";
|
||||||
|
import type { SvgIconComponent } from "@mui/icons-material";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* displayName → MUI icon mapping.
|
||||||
|
* Add new entries here when new resources are created.
|
||||||
|
*/
|
||||||
|
const RESOURCE_ICON_MAP: Record<string, SvgIconComponent> = {
|
||||||
|
"Astreinte OSSA": PeopleAltOutlinedIcon,
|
||||||
|
"Chômage Partiel": CalendarMonthOutlinedIcon,
|
||||||
|
Congés: CalendarMonthOutlinedIcon,
|
||||||
|
COPIL: CalendarMonthOutlinedIcon,
|
||||||
|
"Iphone 11 Pro Max": PhoneIphoneOutlinedIcon,
|
||||||
|
"Note de Service": DescriptionOutlinedIcon,
|
||||||
|
"OSS Events": CalendarMonthOutlinedIcon,
|
||||||
|
"Permanence OSSA": PeopleAltOutlinedIcon,
|
||||||
|
"Projo salle 404": VideoCameraBackOutlinedIcon,
|
||||||
|
"Salle-bat-A-S215-visio-15p": MeetingRoomOutlinedIcon,
|
||||||
|
"Salle-bat-A-S216-ecran-5p": MeetingRoomOutlinedIcon,
|
||||||
|
"Salle-bat-B-S217-10p": MeetingRoomOutlinedIcon,
|
||||||
|
"Salle-bat-C-S218-ecran-20p": MeetingRoomOutlinedIcon,
|
||||||
|
"Salle-bat-D-S218-visio-amphi-200p": MeetingRoomOutlinedIcon,
|
||||||
|
Télétravail: LayersOutlinedIcon,
|
||||||
|
"TV - VN": TvOutlinedIcon,
|
||||||
|
"Veille COPIL": LayersOutlinedIcon,
|
||||||
|
"White Board - VN": ViewComfyAltOutlinedIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getIconForDisplayName(displayName: string): SvgIconComponent {
|
||||||
|
return RESOURCE_ICON_MAP[displayName] ?? LayersOutlinedIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceIconProps {
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceIcon({ displayName }: ResourceIconProps) {
|
||||||
|
const IconComponent = getIconForDisplayName(displayName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar sx={{ backgroundColor: "transparent" }}>
|
||||||
|
<IconComponent fontSize="medium" />
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils";
|
||||||
|
import { useUserSearch } from "./useUserSearch";
|
||||||
|
import { ResourceIcon } from "./ResourceIcon";
|
||||||
|
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
ListItem,
|
||||||
|
ListItemAvatar,
|
||||||
|
ListItemText,
|
||||||
|
PaperProps,
|
||||||
|
PopperProps,
|
||||||
|
TextField,
|
||||||
|
useTheme,
|
||||||
|
Typography,
|
||||||
|
type AutocompleteRenderInputParams,
|
||||||
|
} from "@linagora/twake-mui";
|
||||||
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
import {
|
||||||
|
HTMLAttributes,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from "react";
|
||||||
|
import { useI18n } from "twake-i18n";
|
||||||
|
|
||||||
|
export interface Resource {
|
||||||
|
email?: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
openpaasId?: string;
|
||||||
|
color?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string | null;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceSearch({
|
||||||
|
selectedResources,
|
||||||
|
onChange,
|
||||||
|
objectTypes,
|
||||||
|
disabled,
|
||||||
|
freeSolo,
|
||||||
|
onToggleEventPreview,
|
||||||
|
placeholder,
|
||||||
|
inputSlot,
|
||||||
|
customRenderInput,
|
||||||
|
customSlotProps,
|
||||||
|
hideLabel,
|
||||||
|
}: {
|
||||||
|
selectedResources: Resource[];
|
||||||
|
onChange: (event: SyntheticEvent, users: Resource[]) => void;
|
||||||
|
objectTypes: string[];
|
||||||
|
disabled?: boolean;
|
||||||
|
freeSolo?: boolean;
|
||||||
|
onToggleEventPreview?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
inputSlot?: (
|
||||||
|
params: ExtendedAutocompleteRenderInputParams
|
||||||
|
) => React.ReactNode;
|
||||||
|
customRenderInput?: (
|
||||||
|
params: AutocompleteRenderInputParams,
|
||||||
|
query: string,
|
||||||
|
setQuery: (value: string) => void
|
||||||
|
) => ReactNode;
|
||||||
|
customSlotProps?: {
|
||||||
|
popper?: Partial<PopperProps>;
|
||||||
|
paper?: Partial<PaperProps>;
|
||||||
|
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
|
||||||
|
};
|
||||||
|
hideLabel?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const searchPlaceholder = placeholder ?? t("resourceSearch.placeholder");
|
||||||
|
const errorMessage = t("resourceSearch.searchError");
|
||||||
|
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
options,
|
||||||
|
hasSearched,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
inputError,
|
||||||
|
setInputError,
|
||||||
|
snackbarOpen,
|
||||||
|
setSnackbarOpen,
|
||||||
|
snackbarMessage,
|
||||||
|
setSnackbarMessage,
|
||||||
|
} = useUserSearch<Resource>({ objectTypes, errorMessage });
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const handleBlurCommit = useCallback(
|
||||||
|
(event: React.SyntheticEvent) => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (selectedResources.find((u) => u.displayName === trimmed)) {
|
||||||
|
setQuery("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInputError(null);
|
||||||
|
const newResource: Resource = { displayName: trimmed };
|
||||||
|
onChange(event, [...selectedResources, newResource]);
|
||||||
|
setQuery("");
|
||||||
|
},
|
||||||
|
[query, selectedResources, onChange, setInputError, setQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultRenderInput = useCallback(
|
||||||
|
(params: AutocompleteRenderInputParams) => {
|
||||||
|
const inputProps = {
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: (
|
||||||
|
<>
|
||||||
|
{!selectedResources?.length ? (
|
||||||
|
<SearchIcon
|
||||||
|
fontSize="small"
|
||||||
|
sx={{ mr: 1, color: "action.active" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.startAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{loading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||||
|
{!selectedResources?.length ? params.InputProps.endAdornment : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const enhancedParams = {
|
||||||
|
...params,
|
||||||
|
InputProps: inputProps,
|
||||||
|
inputProps: {
|
||||||
|
...params.inputProps,
|
||||||
|
autoComplete: "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter" && onToggleEventPreview) {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggleEventPreview();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultTextFieldProps = {
|
||||||
|
error: !!inputError,
|
||||||
|
helperText: inputError,
|
||||||
|
placeholder: searchPlaceholder,
|
||||||
|
label: "",
|
||||||
|
onKeyDown: handleEnterKey,
|
||||||
|
slotProps: {
|
||||||
|
input: {
|
||||||
|
...inputProps,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (inputSlot) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!hideLabel && (
|
||||||
|
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
|
||||||
|
{t("resourceSearch.label")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{inputSlot({
|
||||||
|
...enhancedParams,
|
||||||
|
error: !!inputError,
|
||||||
|
helperText: inputError,
|
||||||
|
placeholder: searchPlaceholder,
|
||||||
|
label: "",
|
||||||
|
onKeyDown: handleEnterKey,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!hideLabel && (
|
||||||
|
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
|
||||||
|
{t("resourceSearch.label")}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
{...enhancedParams}
|
||||||
|
{...defaultTextFieldProps}
|
||||||
|
InputProps={inputProps}
|
||||||
|
size="medium"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[
|
||||||
|
inputError,
|
||||||
|
t,
|
||||||
|
onToggleEventPreview,
|
||||||
|
loading,
|
||||||
|
searchPlaceholder,
|
||||||
|
selectedResources?.length,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Autocomplete
|
||||||
|
popupIcon={null}
|
||||||
|
freeSolo={freeSolo}
|
||||||
|
multiple
|
||||||
|
options={options}
|
||||||
|
autoComplete={false}
|
||||||
|
clearOnBlur={false}
|
||||||
|
onBlur={freeSolo ? handleBlurCommit : undefined}
|
||||||
|
open={
|
||||||
|
customRenderInput
|
||||||
|
? isOpen && !!query && (loading || options.length > 0)
|
||||||
|
: isOpen && !!query && (loading || hasSearched)
|
||||||
|
}
|
||||||
|
onOpen={() => setIsOpen(true)}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
disabled={disabled}
|
||||||
|
loading={loading}
|
||||||
|
filterOptions={(options: Resource[]) => options}
|
||||||
|
fullWidth
|
||||||
|
noOptionsText={t("resourceSearch.noResults")}
|
||||||
|
loadingText={t("resourceSearch.loading")}
|
||||||
|
getOptionLabel={(option: Resource | string) => {
|
||||||
|
if (typeof option === "object") {
|
||||||
|
return option.displayName;
|
||||||
|
} else {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
"& .MuiAutocomplete-inputRoot": {
|
||||||
|
py: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
filterSelectedOptions
|
||||||
|
value={selectedResources}
|
||||||
|
inputValue={query}
|
||||||
|
onInputChange={(_event, value: string) => setQuery(value)}
|
||||||
|
onChange={(event, value: string[] | Resource[]) => {
|
||||||
|
setInputError(null);
|
||||||
|
const mapped = value
|
||||||
|
.map((v: string | Resource) =>
|
||||||
|
typeof v === "string" ? { displayName: v.trim() } : v
|
||||||
|
)
|
||||||
|
.filter((v) => v.displayName.trim().length > 0);
|
||||||
|
onChange(event, mapped);
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
...customSlotProps,
|
||||||
|
popper: {
|
||||||
|
placement: "bottom-start",
|
||||||
|
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
|
||||||
|
...customSlotProps?.popper,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
// When render input is custom, the adornments should be handled by the custom component
|
||||||
|
forcePopupIcon={!customRenderInput}
|
||||||
|
disableClearable={!!customRenderInput}
|
||||||
|
renderInput={(params) =>
|
||||||
|
customRenderInput
|
||||||
|
? customRenderInput(params, query, setQuery)
|
||||||
|
: defaultRenderInput(params)
|
||||||
|
}
|
||||||
|
renderOption={(props, option: Resource) => {
|
||||||
|
if (
|
||||||
|
selectedResources.find((u) => u.displayName === option.displayName)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const { key, ...otherProps } = props;
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={key + option?.displayName}
|
||||||
|
{...otherProps}
|
||||||
|
disableGutters
|
||||||
|
>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<ResourceIcon displayName={option.displayName} />
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText primary={option.displayName} />
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderValue={(value: string[] | Resource[], getTagProps) =>
|
||||||
|
value.map((option: string | Resource, index) => {
|
||||||
|
const isString = typeof option === "string";
|
||||||
|
const label = isString ? option : option.displayName;
|
||||||
|
const chipColor = isString
|
||||||
|
? theme.palette.grey[300]
|
||||||
|
: (option.color?.light ?? theme.palette.grey[300]);
|
||||||
|
const textColor = getAccessiblePair(chipColor, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
{...getTagProps({ index })}
|
||||||
|
key={label}
|
||||||
|
style={{
|
||||||
|
backgroundColor: chipColor,
|
||||||
|
color: textColor,
|
||||||
|
}}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SnackbarAlert
|
||||||
|
open={snackbarOpen}
|
||||||
|
setOpen={(open: boolean) => {
|
||||||
|
setSnackbarOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setSnackbarMessage("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
message={snackbarMessage}
|
||||||
|
severity="error"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { searchUsers } from "@/features/User/userAPI";
|
||||||
|
|
||||||
|
export interface UseUserSearchProps {
|
||||||
|
objectTypes: string[];
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserSearch<T>({
|
||||||
|
objectTypes,
|
||||||
|
errorMessage,
|
||||||
|
}: UseUserSearchProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [options, setOptions] = useState<T[]>([]);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null);
|
||||||
|
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||||
|
const [snackbarMessage, setSnackbarMessage] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const delayDebounceFn = setTimeout(async () => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setOptions([]);
|
||||||
|
setLoading(false);
|
||||||
|
setHasSearched(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(true);
|
||||||
|
setHasSearched(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await searchUsers(query, objectTypes);
|
||||||
|
if (!cancelled) {
|
||||||
|
setOptions(res as unknown as T[]);
|
||||||
|
setHasSearched(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasSearched(false);
|
||||||
|
setSnackbarMessage(errorMessage);
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(delayDebounceFn);
|
||||||
|
};
|
||||||
|
}, [objectTypes, query, errorMessage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
options,
|
||||||
|
hasSearched,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
inputError,
|
||||||
|
setInputError,
|
||||||
|
snackbarOpen,
|
||||||
|
setSnackbarOpen,
|
||||||
|
snackbarMessage,
|
||||||
|
setSnackbarMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
||||||
|
import { getCalendars } from "@/features/Calendars/CalendarApi";
|
||||||
|
import { Calendar } from "@/features/Calendars/CalendarTypes";
|
||||||
|
import { CalendarData } from "@/features/Calendars/types/CalendarData";
|
||||||
|
import { renameDefault } from "@/utils/renameDefault";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
useTheme,
|
||||||
|
} from "@linagora/twake-mui";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useI18n } from "twake-i18n";
|
||||||
|
import { ResponsiveDialog } from "../Dialog";
|
||||||
|
import { ColorPicker } from "./CalendarColorPicker";
|
||||||
|
import { getAccessiblePair } from "./utils/calendarColorsUtils";
|
||||||
|
import { defaultColors } from "@/utils/defaultColors";
|
||||||
|
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
|
||||||
|
import { Resource, ResourceSearch } from "../Attendees/ResourceSearch";
|
||||||
|
import { ResourceIcon } from "../Attendees/ResourceIcon";
|
||||||
|
|
||||||
|
interface CalendarWithOwner {
|
||||||
|
cal: CalendarData;
|
||||||
|
owner: Resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarItem({
|
||||||
|
cal,
|
||||||
|
onRemove,
|
||||||
|
onColorChange,
|
||||||
|
}: {
|
||||||
|
cal: CalendarWithOwner;
|
||||||
|
onRemove: () => void;
|
||||||
|
onColorChange: (color: Record<string, string>) => void;
|
||||||
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={cal.cal["dav:name"]}
|
||||||
|
display="flex"
|
||||||
|
justifyContent="space-between"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<ResourceIcon displayName={cal.owner.displayName} />
|
||||||
|
<Typography variant="body1">
|
||||||
|
{renameDefault(cal.cal["dav:name"], cal.owner.displayName, t, false)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<ColorPicker
|
||||||
|
selectedColor={{
|
||||||
|
light: cal.cal["apple:color"] ?? defaultColors[0].light,
|
||||||
|
dark: cal.cal["apple:color"]
|
||||||
|
? getAccessiblePair(cal.cal["apple:color"], theme)
|
||||||
|
: defaultColors[0].dark,
|
||||||
|
}}
|
||||||
|
onChange={onColorChange}
|
||||||
|
/>
|
||||||
|
<IconButton size="small" onClick={onRemove}>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedCalendarsList({
|
||||||
|
calendars,
|
||||||
|
selectedCal,
|
||||||
|
onRemove,
|
||||||
|
onColorChange,
|
||||||
|
}: {
|
||||||
|
calendars: Record<string, Calendar>;
|
||||||
|
selectedCal: CalendarWithOwner[];
|
||||||
|
onRemove: (cal: CalendarWithOwner) => void;
|
||||||
|
onColorChange: (
|
||||||
|
cal: CalendarWithOwner,
|
||||||
|
color: Record<string, string>
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (selectedCal.length === 0) return null;
|
||||||
|
|
||||||
|
const groupedByOwner = selectedCal.reduce<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
owner: Resource;
|
||||||
|
visibleCals: CalendarWithOwner[];
|
||||||
|
alreadyExisting: boolean;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>((acc, cal) => {
|
||||||
|
const exists = Object.values(calendars).some(
|
||||||
|
(existing: Calendar) =>
|
||||||
|
existing.id ===
|
||||||
|
cal.cal?._links?.self?.href
|
||||||
|
?.replace("/calendars/", "")
|
||||||
|
.replace(".json", "")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!acc[cal.owner.displayName]) {
|
||||||
|
acc[cal.owner.displayName] = {
|
||||||
|
owner: cal.owner,
|
||||||
|
visibleCals: [],
|
||||||
|
alreadyExisting: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
acc[cal.owner.displayName].alreadyExisting = true;
|
||||||
|
} else {
|
||||||
|
acc[cal.owner.displayName].visibleCals.push(cal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mt={2}>
|
||||||
|
<Typography variant="h6" sx={{ margin: 0 }}>
|
||||||
|
{t("common.resource")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{Object.values(groupedByOwner).map(
|
||||||
|
({ owner, visibleCals, alreadyExisting }) => (
|
||||||
|
<Box key={owner.displayName} mb={2}>
|
||||||
|
{visibleCals.length > 0 ? (
|
||||||
|
visibleCals.map((cal) =>
|
||||||
|
cal.cal ? (
|
||||||
|
<CalendarItem
|
||||||
|
key={cal.owner.displayName + cal.cal["dav:name"]}
|
||||||
|
cal={cal}
|
||||||
|
onRemove={() => onRemove(cal)}
|
||||||
|
onColorChange={(color) => onColorChange(cal, color)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
key={t("calendar.noPublicCalendarsFor", {
|
||||||
|
name: owner.displayName,
|
||||||
|
})}
|
||||||
|
color="textSecondary"
|
||||||
|
>
|
||||||
|
{t("calendar.noPublicCalendarsFor", {
|
||||||
|
name: owner.displayName,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : alreadyExisting ? (
|
||||||
|
<Typography
|
||||||
|
key={t("calendar.noMoreCalendarsFor", {
|
||||||
|
name: owner.displayName,
|
||||||
|
})}
|
||||||
|
color="textSecondary"
|
||||||
|
>
|
||||||
|
{t("calendar.noMoreCalendarsFor", { name: owner.displayName })}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarResources({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: (ids?: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const openpaasId =
|
||||||
|
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
||||||
|
const calendars = useAppSelector((state) => state.calendars.list);
|
||||||
|
|
||||||
|
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([]);
|
||||||
|
const [selectedResources, setSelectedResources] = useState<Resource[]>([]);
|
||||||
|
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (selectedCal.length > 0) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
selectedCal.map(async (cal) => {
|
||||||
|
const calId = crypto.randomUUID();
|
||||||
|
const exists = Object.values(calendars).some(
|
||||||
|
(existing: Calendar) =>
|
||||||
|
existing.id ===
|
||||||
|
cal.cal?._links?.self?.href
|
||||||
|
?.replace("/calendars/", "")
|
||||||
|
.replace(".json", "")
|
||||||
|
);
|
||||||
|
if (!exists && cal.cal) {
|
||||||
|
await dispatch(
|
||||||
|
addCalendarResourceAsync({
|
||||||
|
userId: openpaasId,
|
||||||
|
calId,
|
||||||
|
cal: {
|
||||||
|
...cal,
|
||||||
|
color: cal.cal["apple:color"]
|
||||||
|
? {
|
||||||
|
light: cal.cal["apple:color"],
|
||||||
|
dark: getAccessiblePair(cal.cal["apple:color"], theme),
|
||||||
|
}
|
||||||
|
: defaultColors[0],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).unwrap();
|
||||||
|
return cal.cal._links.self?.href
|
||||||
|
?.replace("/calendars/", "")
|
||||||
|
.replace(".json", "");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const idList = results
|
||||||
|
.filter((r) => r.status === "fulfilled")
|
||||||
|
.map((r) => (r as PromiseFulfilledResult<string | null>).value)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
onClose(idList);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
setSelectedCalendars([]);
|
||||||
|
setSelectedResources([]);
|
||||||
|
};
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
fetchSeqRef.current += 1; // invalidate in-flight fetch results
|
||||||
|
onClose();
|
||||||
|
setSelectedCalendars([]);
|
||||||
|
setSelectedResources([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialog
|
||||||
|
open={open}
|
||||||
|
contentSx={{ paddingTop: "8px !important" }}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={t("calendar.browseResources")}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="outlined" onClick={handleClose}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={handleSave}>
|
||||||
|
{t("actions.add")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResourceSearch
|
||||||
|
objectTypes={["resource"]}
|
||||||
|
selectedResources={selectedResources}
|
||||||
|
inputSlot={(params) => <TextField {...params} size="small" />}
|
||||||
|
onChange={async (_event: React.SyntheticEvent, value: Resource[]) => {
|
||||||
|
const requestSeq = ++fetchSeqRef.current;
|
||||||
|
setSelectedResources(value);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
value.map(async (user: Resource) => {
|
||||||
|
if (user?.openpaasId) {
|
||||||
|
const cals = await getCalendars(
|
||||||
|
user.openpaasId,
|
||||||
|
"sharedPublic=true&"
|
||||||
|
);
|
||||||
|
return cals._embedded?.["dav:calendar"]
|
||||||
|
? cals._embedded["dav:calendar"].map((cal) => ({
|
||||||
|
cal,
|
||||||
|
owner: user,
|
||||||
|
}))
|
||||||
|
: { cal: undefined, owner: user };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const successfulCals = results
|
||||||
|
.filter((result) => result.status === "fulfilled")
|
||||||
|
.map((result) => (result as PromiseFulfilledResult<unknown>).value)
|
||||||
|
.flat()
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (requestSeq !== fetchSeqRef.current) return;
|
||||||
|
setSelectedCalendars(successfulCals as CalendarWithOwner[]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectedCalendarsList
|
||||||
|
calendars={calendars}
|
||||||
|
selectedCal={selectedCal}
|
||||||
|
onRemove={(cal) => {
|
||||||
|
if (!cal.cal?._links?.self?.href) return;
|
||||||
|
setSelectedCalendars((prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(c) => c.cal?._links?.self?.href !== cal.cal._links.self?.href
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!selectedCal.find(
|
||||||
|
(c) =>
|
||||||
|
cal.owner.displayName === c.owner.displayName &&
|
||||||
|
c.cal?._links?.self?.href !== cal.cal._links.self?.href
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setSelectedResources((prev) =>
|
||||||
|
prev.filter((u) => u.displayName !== cal.owner.displayName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onColorChange={(cal, color) =>
|
||||||
|
setSelectedCalendars((prev) =>
|
||||||
|
prev.map((prevcal) =>
|
||||||
|
prevcal.owner.displayName === cal.owner.displayName &&
|
||||||
|
prevcal.cal._links.self?.href === cal.cal._links.self?.href
|
||||||
|
? {
|
||||||
|
...prevcal,
|
||||||
|
cal: {
|
||||||
|
...prevcal.cal,
|
||||||
|
"apple:color": color.light,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: prevcal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import CalendarPopover from "./CalendarModal";
|
|||||||
import CalendarSearch from "./CalendarSearch";
|
import CalendarSearch from "./CalendarSearch";
|
||||||
import { DeleteCalendarDialog } from "./DeleteCalendarDialog";
|
import { DeleteCalendarDialog } from "./DeleteCalendarDialog";
|
||||||
import { OwnerCaption } from "./OwnerCaption";
|
import { OwnerCaption } from "./OwnerCaption";
|
||||||
|
import CalendarResources from "./CalendarResources";
|
||||||
|
|
||||||
function CalendarAccordion({
|
function CalendarAccordion({
|
||||||
title,
|
title,
|
||||||
@@ -161,7 +162,8 @@ export default function CalendarSelection({
|
|||||||
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null);
|
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null);
|
||||||
const [anchorElCalOthers, setAnchorElCalOthers] =
|
const [anchorElCalOthers, setAnchorElCalOthers] =
|
||||||
useState<HTMLElement | null>(null);
|
useState<HTMLElement | null>(null);
|
||||||
// const [anchorElCalResources, setAnchorElCalResources] = useState<HTMLElement | null>(null);
|
const [anchorElCalResources, setAnchorElCalResources] =
|
||||||
|
useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -212,7 +214,10 @@ export default function CalendarSelection({
|
|||||||
title={t("calendar.resources")}
|
title={t("calendar.resources")}
|
||||||
calendars={resourceCalendars}
|
calendars={resourceCalendars}
|
||||||
selectedCalendars={selectedCalendars}
|
selectedCalendars={selectedCalendars}
|
||||||
showAddButton={false}
|
onAddClick={() => {
|
||||||
|
setAnchorElCalResources(document.body);
|
||||||
|
}}
|
||||||
|
showAddButton
|
||||||
handleToggle={handleCalendarToggle}
|
handleToggle={handleCalendarToggle}
|
||||||
setOpen={(_) => {
|
setOpen={(_) => {
|
||||||
// TO DO: Implement open resource selection
|
// TO DO: Implement open resource selection
|
||||||
@@ -238,6 +243,17 @@ export default function CalendarSelection({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<CalendarResources
|
||||||
|
open={Boolean(anchorElCalResources)}
|
||||||
|
onClose={(newResourceIds?: string[]) => {
|
||||||
|
setAnchorElCalResources(null);
|
||||||
|
if (newResourceIds?.length) {
|
||||||
|
newResourceIds.forEach((id) => {
|
||||||
|
handleCalendarToggle(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
|
|||||||
import { browserDefaultTimeZone } from "@/utils/timezone";
|
import { browserDefaultTimeZone } from "@/utils/timezone";
|
||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
import { CalendarEvent } from "../Events/EventsTypes";
|
import { CalendarEvent } from "../Events/EventsTypes";
|
||||||
|
import { addCalendarResourceAsync } from "./api/addCalendarResourceAsync";
|
||||||
import { Calendar } from "./CalendarTypes";
|
import { Calendar } from "./CalendarTypes";
|
||||||
import {
|
import {
|
||||||
addSharedCalendarAsync,
|
addSharedCalendarAsync,
|
||||||
@@ -272,6 +273,19 @@ const CalendarSlice = createSlice({
|
|||||||
} as Calendar;
|
} as Calendar;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
|
.addCase(addCalendarResourceAsync.fulfilled, (state, action) => {
|
||||||
|
state.pending = false;
|
||||||
|
state.list[action.payload.calId] = {
|
||||||
|
color: action.payload.color,
|
||||||
|
id: action.payload.calId,
|
||||||
|
link: action.payload.link,
|
||||||
|
description: action.payload.desc,
|
||||||
|
name: action.payload.name,
|
||||||
|
events: {},
|
||||||
|
owner: action.payload.owner,
|
||||||
|
} as Calendar;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
.addCase(removeCalendarAsync.fulfilled, (state) => {
|
.addCase(removeCalendarAsync.fulfilled, (state) => {
|
||||||
state.pending = false;
|
state.pending = false;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
@@ -362,6 +376,9 @@ const CalendarSlice = createSlice({
|
|||||||
.addCase(addSharedCalendarAsync.pending, (state) => {
|
.addCase(addSharedCalendarAsync.pending, (state) => {
|
||||||
state.pending = true;
|
state.pending = true;
|
||||||
})
|
})
|
||||||
|
.addCase(addCalendarResourceAsync.pending, (state) => {
|
||||||
|
state.pending = true;
|
||||||
|
})
|
||||||
.addCase(removeCalendarAsync.pending, (state) => {
|
.addCase(removeCalendarAsync.pending, (state) => {
|
||||||
state.pending = true;
|
state.pending = true;
|
||||||
})
|
})
|
||||||
@@ -480,6 +497,13 @@ const CalendarSlice = createSlice({
|
|||||||
action.error.message ||
|
action.error.message ||
|
||||||
"Failed to add shared calendar";
|
"Failed to add shared calendar";
|
||||||
})
|
})
|
||||||
|
.addCase(addCalendarResourceAsync.rejected, (state, action) => {
|
||||||
|
state.pending = false;
|
||||||
|
state.error =
|
||||||
|
action.payload?.message ||
|
||||||
|
action.error.message ||
|
||||||
|
"Failed to add calendar resource";
|
||||||
|
})
|
||||||
.addCase(removeCalendarAsync.rejected, (state, action) => {
|
.addCase(removeCalendarAsync.rejected, (state, action) => {
|
||||||
state.pending = false;
|
state.pending = false;
|
||||||
state.error =
|
state.error =
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
|
||||||
|
import { getResourceDetails } from "@/features/User/userAPI";
|
||||||
|
import { toRejectedError } from "@/utils/errorUtils";
|
||||||
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
|
import { addSharedCalendar } from "../CalendarApi";
|
||||||
|
import { CalendarInput } from "../types/CalendarData";
|
||||||
|
import { RejectedError } from "../types/RejectedError";
|
||||||
|
import { User } from "@/components/Attendees/PeopleSearch";
|
||||||
|
|
||||||
|
export const addCalendarResourceAsync = createAsyncThunk<
|
||||||
|
{
|
||||||
|
calId: string;
|
||||||
|
color: Record<string, string>;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
owner: OpenPaasUserData;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: string;
|
||||||
|
calId: string;
|
||||||
|
cal: Omit<CalendarInput, "owner"> & {
|
||||||
|
owner?: Omit<User, "email"> & { email?: string };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ rejectValue: RejectedError }
|
||||||
|
>(
|
||||||
|
"calendars/addCalendarResource",
|
||||||
|
async ({ userId, calId, cal }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await addSharedCalendar(userId, calId, cal);
|
||||||
|
const ownerData = await getResourceDetails(
|
||||||
|
cal.cal._links.self.href
|
||||||
|
.replace("/calendars/", "")
|
||||||
|
.replace(".json", "")
|
||||||
|
.split("/")[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
calId: cal.cal._links.self?.href
|
||||||
|
?.replace("/calendars/", "")
|
||||||
|
.replace(".json", ""),
|
||||||
|
color: cal.color,
|
||||||
|
link: `/calendars/${userId}/${calId}.json`,
|
||||||
|
desc: cal.cal["caldav:description"] ?? "",
|
||||||
|
name: cal.cal["dav:name"] ?? "",
|
||||||
|
owner: ownerData,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return rejectWithValue(toRejectedError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -257,6 +257,19 @@ export function useCalendarDataLoader({
|
|||||||
[selectedCalendars, calendars]
|
[selectedCalendars, calendars]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nonSelectedCalendars = Object.keys(
|
||||||
|
fetchedIntervalsRef.current
|
||||||
|
).filter((calId) => !selectedCalendars.includes(calId));
|
||||||
|
if (nonSelectedCalendars?.length) {
|
||||||
|
nonSelectedCalendars.forEach((calId) => {
|
||||||
|
// Remove these non-selected calendar IDs to ensure these IDs will be fetched again on every subscribe / selection
|
||||||
|
delete fetchedIntervalsRef.current[calId];
|
||||||
|
delete inFlightRef.current[calId];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedCalendars]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms));
|
const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms));
|
||||||
calendarsWithClearedCache.forEach(({ id, cleared }) => {
|
calendarsWithClearedCache.forEach(({ id, cleared }) => {
|
||||||
|
|||||||
+13
-2
@@ -26,7 +26,8 @@
|
|||||||
"personalWarning": "You will lose all events in this calendar.",
|
"personalWarning": "You will lose all events in this calendar.",
|
||||||
"sharedWarning": "You will lose access to its events. You will still be able to add it back later."
|
"sharedWarning": "You will lose access to its events. You will still be able to add it back later."
|
||||||
},
|
},
|
||||||
"resources": "Resources"
|
"resources": "Resources",
|
||||||
|
"browseResources": "Browse resources"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"modify": "Modify",
|
"modify": "Modify",
|
||||||
@@ -54,7 +55,8 @@
|
|||||||
"you": "You",
|
"you": "You",
|
||||||
"select_timezone": "Select Timezone",
|
"select_timezone": "Select Timezone",
|
||||||
"moreOptions": "More options",
|
"moreOptions": "More options",
|
||||||
"search": "Search"
|
"search": "Search",
|
||||||
|
"resource": "Resource"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"searchIn": "Search in",
|
"searchIn": "Search in",
|
||||||
@@ -347,5 +349,14 @@
|
|||||||
"subtitle": "Please open Twake Calendar on a desktop to enjoy all features. A fully optimized mobile version is coming soon.",
|
"subtitle": "Please open Twake Calendar on a desktop to enjoy all features. A fully optimized mobile version is coming soon.",
|
||||||
"title": "Mobile version coming soon"
|
"title": "Mobile version coming soon"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"resourceSearch": {
|
||||||
|
"label": "Search for resources",
|
||||||
|
"placeholder": "Search by name",
|
||||||
|
"availabilityPlaceholder": "Check availability",
|
||||||
|
"invalidEmail": "%{email} is not a valid email address",
|
||||||
|
"searchError": "Unable to fetch resources right now. Please try again.",
|
||||||
|
"noResults": "No results",
|
||||||
|
"loading": "Loading..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -26,7 +26,8 @@
|
|||||||
"personalWarning": "Vous allez perdre tous les événements de ce calendrier.",
|
"personalWarning": "Vous allez perdre tous les événements de ce calendrier.",
|
||||||
"sharedWarning": "Vous allez perdre l’accès à ses événements. Vous pourrez toujours le rajouter plus tard."
|
"sharedWarning": "Vous allez perdre l’accès à ses événements. Vous pourrez toujours le rajouter plus tard."
|
||||||
},
|
},
|
||||||
"resources": "Ressources"
|
"resources": "Ressources",
|
||||||
|
"browseResources": "Parcourir les ressources"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"modify": "Modifier",
|
"modify": "Modifier",
|
||||||
@@ -54,7 +55,8 @@
|
|||||||
"you": "Vous",
|
"you": "Vous",
|
||||||
"select_timezone": "Sélectionner le fuseau horaire",
|
"select_timezone": "Sélectionner le fuseau horaire",
|
||||||
"moreOptions": "Plus d'options",
|
"moreOptions": "Plus d'options",
|
||||||
"search": "Rechercher"
|
"search": "Rechercher",
|
||||||
|
"resource": "Ressources"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"searchIn": "Rechercher dans",
|
"searchIn": "Rechercher dans",
|
||||||
@@ -348,5 +350,14 @@
|
|||||||
"subtitle": "Veuillez ouvrir Twake Calendar sur un ordinateur pour profiter de toutes les fonctionnalités. Une version mobile optimisée arrive bientôt.",
|
"subtitle": "Veuillez ouvrir Twake Calendar sur un ordinateur pour profiter de toutes les fonctionnalités. Une version mobile optimisée arrive bientôt.",
|
||||||
"title": "Version mobile bientôt disponible"
|
"title": "Version mobile bientôt disponible"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"resourceSearch": {
|
||||||
|
"label": "Rechercher des ressources",
|
||||||
|
"placeholder": "Rechercher par nom",
|
||||||
|
"availabilityPlaceholder": "Vérifier la disponibilité",
|
||||||
|
"invalidEmail": "%{email} n'est pas une adresse e-mail valide",
|
||||||
|
"searchError": "Impossible de récupérer les ressources pour le moment. Veuillez réessayer.",
|
||||||
|
"noResults": "Aucun résultat",
|
||||||
|
"loading": "Chargement..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -26,7 +26,8 @@
|
|||||||
"personalWarning": "Вы потеряете все события в этом календаре.",
|
"personalWarning": "Вы потеряете все события в этом календаре.",
|
||||||
"sharedWarning": "Вы потеряете доступ. Позже можно добавить снова."
|
"sharedWarning": "Вы потеряете доступ. Позже можно добавить снова."
|
||||||
},
|
},
|
||||||
"resources": "Ресурсы"
|
"resources": "Ресурсы",
|
||||||
|
"browseResources": "Просмотреть ресурсы"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"modify": "Изменить",
|
"modify": "Изменить",
|
||||||
@@ -54,7 +55,8 @@
|
|||||||
"you": "Вы",
|
"you": "Вы",
|
||||||
"select_timezone": "Выберите часовой пояс",
|
"select_timezone": "Выберите часовой пояс",
|
||||||
"moreOptions": "Больше параметров",
|
"moreOptions": "Больше параметров",
|
||||||
"search": "Поиск"
|
"search": "Поиск",
|
||||||
|
"resource": "Ресурсы"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"searchIn": "Поиск в",
|
"searchIn": "Поиск в",
|
||||||
@@ -348,5 +350,14 @@
|
|||||||
"subtitle": "Пожалуйста, откройте Twake Calendar на компьютере, чтобы воспользоваться всеми функциями. Полностью оптимизированная мобильная версия скоро появится.",
|
"subtitle": "Пожалуйста, откройте Twake Calendar на компьютере, чтобы воспользоваться всеми функциями. Полностью оптимизированная мобильная версия скоро появится.",
|
||||||
"title": "Мобильная версия скоро будет доступна"
|
"title": "Мобильная версия скоро будет доступна"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"resourceSearch": {
|
||||||
|
"label": "Поиск ресурсов",
|
||||||
|
"placeholder": "Поиск по имени",
|
||||||
|
"availabilityPlaceholder": "Проверить доступность",
|
||||||
|
"invalidEmail": "%{email} не является действительным адресом электронной почты",
|
||||||
|
"searchError": "Не удалось получить ресурсы. Попробуйте снова.",
|
||||||
|
"noResults": "Нет результатов",
|
||||||
|
"loading": "Загрузка..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -26,7 +26,8 @@
|
|||||||
"personalWarning": "Bạn sẽ mất tất cả sự kiện trong lịch này.",
|
"personalWarning": "Bạn sẽ mất tất cả sự kiện trong lịch này.",
|
||||||
"sharedWarning": "Bạn sẽ mất quyền truy cập các sự kiện của lịch. Bạn vẫn có thể thêm lại sau."
|
"sharedWarning": "Bạn sẽ mất quyền truy cập các sự kiện của lịch. Bạn vẫn có thể thêm lại sau."
|
||||||
},
|
},
|
||||||
"resources": "Tài nguyên"
|
"resources": "Tài nguyên",
|
||||||
|
"browseResources": "Tìm kiếm tài nguyên"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"modify": "Chỉnh sửa",
|
"modify": "Chỉnh sửa",
|
||||||
@@ -54,7 +55,8 @@
|
|||||||
"you": "Bạn",
|
"you": "Bạn",
|
||||||
"select_timezone": "Chọn múi giờ",
|
"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": "Tìm kiếm",
|
||||||
|
"resource": "Tài nguyên"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"searchIn": "Tìm kiếm trong",
|
"searchIn": "Tìm kiếm trong",
|
||||||
@@ -346,5 +348,14 @@
|
|||||||
"subtitle": "Vui lòng mở Twake Calendar trên máy tính để sử dụng đầy đủ tính năng. Phiên bản di động được tối ưu hóa hoàn toàn sẽ sớm ra mắt.",
|
"subtitle": "Vui lòng mở Twake Calendar trên máy tính để sử dụng đầy đủ tính năng. Phiên bản di động được tối ưu hóa hoàn toàn sẽ sớm ra mắt.",
|
||||||
"title": "Phiên bản di động sắp ra mắt"
|
"title": "Phiên bản di động sắp ra mắt"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"resourceSearch": {
|
||||||
|
"label": "Tìm kiếm tài nguyên",
|
||||||
|
"placeholder": "Tìm kiếm theo tên",
|
||||||
|
"availabilityPlaceholder": "Kiểm tra tình trạng",
|
||||||
|
"invalidEmail": "%{email} không phải là địa chỉ email hợp lệ",
|
||||||
|
"searchError": "Không thể lấy tài nguyên. Vui lòng thử lại.",
|
||||||
|
"noResults": "Không có kết quả",
|
||||||
|
"loading": "Đang tải..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user