444 use websocket to trigger reloads (#458)
* [#444] added refresh on sync token calendar update * [#444] added refresh on register and tests * [#444] extracted logic for useEffects and updates * [#444] refactored code structure to use absolute paths
This commit is contained in:
@@ -1,15 +1,15 @@
|
|||||||
import { cleanup, render, waitFor, act } from "@testing-library/react";
|
import { cleanup, render, waitFor, act } from "@testing-library/react";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection";
|
import { createWebSocketConnection } from "@/websocket/connection/createConnection";
|
||||||
import { registerToCalendars } from "../../../src/websocket/ws/registerToCalendars";
|
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
|
||||||
import { unregisterToCalendars } from "../../../src/websocket/ws/unregisterToCalendars";
|
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
|
||||||
import { WebSocketGate } from "../../../src/websocket/WebSocketGate";
|
import { WebSocketGate } from "@/websocket/WebSocketGate";
|
||||||
import { setSelectedCalendars } from "../../../src/utils/storage/setSelectedCalendars";
|
import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars";
|
||||||
|
|
||||||
jest.mock("../../../src/websocket/createWebSocketConnection");
|
jest.mock("@/websocket/connection/createConnection");
|
||||||
jest.mock("../../../src/websocket/ws/registerToCalendars");
|
jest.mock("@/websocket/operations/registerToCalendars");
|
||||||
jest.mock("../../../src/websocket/ws/unregisterToCalendars");
|
jest.mock("@/websocket/operations/unregisterToCalendars");
|
||||||
|
|
||||||
describe("WebSocketGate", () => {
|
describe("WebSocketGate", () => {
|
||||||
let store: any;
|
let store: any;
|
||||||
@@ -101,7 +101,7 @@ describe("WebSocketGate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Socket Connection Management", () => {
|
describe("Socket Connection Management", () => {
|
||||||
it("should add close event listener on socket connection", async () => {
|
it("should create connection with callbacks", async () => {
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -111,22 +111,25 @@ describe("WebSocketGate", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockSocket.addEventListener).toHaveBeenCalledWith(
|
expect(createWebSocketConnection).toHaveBeenCalledWith(
|
||||||
"close",
|
expect.objectContaining({
|
||||||
expect.any(Function)
|
onMessage: expect.any(Function),
|
||||||
|
onClose: expect.any(Function),
|
||||||
|
onError: expect.any(Function),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle socket close event", async () => {
|
it("should handle socket close via callback", async () => {
|
||||||
let closeHandler: Function;
|
let onCloseCallback: Function | undefined;
|
||||||
mockSocket.addEventListener = jest.fn((event, handler) => {
|
|
||||||
if (event === "close") {
|
|
||||||
closeHandler = handler;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket);
|
(createWebSocketConnection as jest.Mock).mockImplementation(
|
||||||
|
(callbacks) => {
|
||||||
|
onCloseCallback = callbacks.onClose;
|
||||||
|
return Promise.resolve(mockSocket);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
@@ -135,11 +138,12 @@ describe("WebSocketGate", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockSocket.addEventListener).toHaveBeenCalled();
|
expect(createWebSocketConnection).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Simulate close event
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
closeHandler!();
|
onCloseCallback?.(new CloseEvent("close"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify that subsequent calendar changes don't try to register
|
// Verify that subsequent calendar changes don't try to register
|
||||||
@@ -349,7 +353,7 @@ describe("WebSocketGate", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(consoleError).toHaveBeenCalledWith(
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
"Failed to update calendar registrations:",
|
"Failed to register calendar:",
|
||||||
expect.any(Error)
|
expect.any(Error)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -443,8 +447,9 @@ describe("WebSocketGate", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait a bit to ensure the effect would have run if it was going to
|
// Wait a bit to ensure the effect would have run if it was going to
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
jest.useFakeTimers();
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
jest.useRealTimers();
|
||||||
expect(registerToCalendars).not.toHaveBeenCalled();
|
expect(registerToCalendars).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -477,7 +482,7 @@ describe("WebSocketGate", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(consoleError).toHaveBeenCalledWith(
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
"Failed to update calendar registrations:",
|
"Failed to unregister calendar:",
|
||||||
expect.any(Error)
|
expect.any(Error)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { api } from "../../../../src/utils/apiUtils";
|
import { api } from "@/utils/apiUtils";
|
||||||
import { fetchWebSocketTicket } from "../../../../src/websocket/api/fetchWebSocketTicket";
|
import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
|
||||||
|
|
||||||
jest.mock("../../../../src/utils/apiUtils");
|
jest.mock("@/utils/apiUtils");
|
||||||
|
|
||||||
describe("fetchWebSocketTicket", () => {
|
describe("fetchWebSocketTicket", () => {
|
||||||
const mockTicket = {
|
const mockTicket = {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { waitFor } from "@testing-library/dom";
|
import { waitFor } from "@testing-library/dom";
|
||||||
import { fetchWebSocketTicket } from "../../../src/websocket/api/fetchWebSocketTicket";
|
import { fetchWebSocketTicket } from "@/websocket/api/fetchWebSocketTicket";
|
||||||
import { createWebSocketConnection } from "../../../src/websocket/createWebSocketConnection";
|
import { createWebSocketConnection } from "@/websocket/connection/createConnection";
|
||||||
import { WS_INBOUND_EVENTS } from "../../../src/websocket/protocols";
|
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
|
||||||
import { setupWebsocket } from "./utils/setupWebsocket";
|
import { setupWebsocket } from "./utils/setupWebsocket";
|
||||||
|
|
||||||
jest.mock("../../../src/websocket/api/fetchWebSocketTicket");
|
jest.mock("@/websocket/api/fetchWebSocketTicket");
|
||||||
|
|
||||||
describe("createWebSocketConnection", () => {
|
describe("createWebSocketConnection", () => {
|
||||||
let mockWebSocket: jest.Mock;
|
let mockWebSocket: jest.Mock;
|
||||||
@@ -28,7 +28,12 @@ describe("createWebSocketConnection", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createAndOpenConnection = async () => {
|
const createAndOpenConnection = async () => {
|
||||||
const promise = createWebSocketConnection();
|
const mockCallbacks = {
|
||||||
|
onMessage: jest.fn(),
|
||||||
|
onClose: jest.fn(),
|
||||||
|
onError: jest.fn(),
|
||||||
|
};
|
||||||
|
const promise = createWebSocketConnection(mockCallbacks);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(webSocketInstances.length).toBe(1);
|
expect(webSocketInstances.length).toBe(1);
|
||||||
@@ -37,7 +42,7 @@ describe("createWebSocketConnection", () => {
|
|||||||
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED);
|
triggerEvent(getWs(), WS_INBOUND_EVENTS.CONNECTION_OPENED);
|
||||||
const socket = await promise;
|
const socket = await promise;
|
||||||
|
|
||||||
return { socket, ws: getWs(), promise };
|
return { socket, ws: getWs(), promise, mockCallbacks };
|
||||||
};
|
};
|
||||||
|
|
||||||
/** ---------- Setup ---------- */
|
/** ---------- Setup ---------- */
|
||||||
@@ -60,8 +65,11 @@ describe("createWebSocketConnection", () => {
|
|||||||
|
|
||||||
it("throws when WEBSOCKET_URL is not defined", async () => {
|
it("throws when WEBSOCKET_URL is not defined", async () => {
|
||||||
delete (window as any).WEBSOCKET_URL;
|
delete (window as any).WEBSOCKET_URL;
|
||||||
|
const mockCallbacks = {
|
||||||
|
onMessage: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
await expect(createWebSocketConnection()).rejects.toThrow(
|
await expect(createWebSocketConnection(mockCallbacks)).rejects.toThrow(
|
||||||
"WEBSOCKET_URL is not defined"
|
"WEBSOCKET_URL is not defined"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -95,7 +103,10 @@ describe("createWebSocketConnection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when connection fails", async () => {
|
it("rejects when connection fails", async () => {
|
||||||
const promise = createWebSocketConnection();
|
const mockCallbacks = {
|
||||||
|
onMessage: jest.fn(),
|
||||||
|
};
|
||||||
|
const promise = createWebSocketConnection(mockCallbacks);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(webSocketInstances.length).toBe(1);
|
expect(webSocketInstances.length).toBe(1);
|
||||||
@@ -118,23 +129,6 @@ describe("createWebSocketConnection", () => {
|
|||||||
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined();
|
expect(ws._listeners[WS_INBOUND_EVENTS.CONNECTION_CLOSED]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses and logs incoming messages", async () => {
|
|
||||||
const logSpy = jest.spyOn(console, "log").mockImplementation();
|
|
||||||
|
|
||||||
const { ws } = await createAndOpenConnection();
|
|
||||||
|
|
||||||
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, {
|
|
||||||
data: JSON.stringify({ type: "test", payload: "data" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logSpy).toHaveBeenCalledWith("WebSocket message received:", {
|
|
||||||
type: "test",
|
|
||||||
payload: "data",
|
|
||||||
});
|
|
||||||
|
|
||||||
logSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles invalid JSON messages", async () => {
|
it("handles invalid JSON messages", async () => {
|
||||||
const errorSpy = jest.spyOn(console, "error").mockImplementation();
|
const errorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
|
||||||
@@ -149,4 +143,69 @@ describe("createWebSocketConnection", () => {
|
|||||||
|
|
||||||
errorSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects on timeout", async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const mockCallbacks = {
|
||||||
|
onMessage: jest.fn(),
|
||||||
|
};
|
||||||
|
const promise = createWebSocketConnection(mockCallbacks);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(webSocketInstances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(10000);
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow("WebSocket connection timed out");
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onMessage callback when message received", async () => {
|
||||||
|
const { ws, mockCallbacks } = await createAndOpenConnection();
|
||||||
|
|
||||||
|
const testMessage = { type: "test", payload: "data" };
|
||||||
|
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, {
|
||||||
|
data: JSON.stringify(testMessage),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCallbacks.onMessage).toHaveBeenCalledWith(testMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call onMessage when JSON parsing fails", async () => {
|
||||||
|
const errorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||||
|
const { ws, mockCallbacks } = await createAndOpenConnection();
|
||||||
|
|
||||||
|
triggerEvent(ws, WS_INBOUND_EVENTS.MESSAGE, { data: "invalid json" });
|
||||||
|
|
||||||
|
expect(mockCallbacks.onMessage).not.toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to parse WebSocket message:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose callback when connection closes", async () => {
|
||||||
|
const { ws, mockCallbacks } = await createAndOpenConnection();
|
||||||
|
|
||||||
|
const closeEvent = new CloseEvent("close", {
|
||||||
|
code: 1000,
|
||||||
|
reason: "Normal closure",
|
||||||
|
});
|
||||||
|
triggerEvent(ws, WS_INBOUND_EVENTS.CONNECTION_CLOSED, closeEvent);
|
||||||
|
|
||||||
|
expect(mockCallbacks.onClose).toHaveBeenCalledWith(closeEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onError callback when error occurs", async () => {
|
||||||
|
const { ws, mockCallbacks } = await createAndOpenConnection();
|
||||||
|
|
||||||
|
const errorEvent = new Event("error");
|
||||||
|
triggerEvent(ws, WS_INBOUND_EVENTS.ERROR, errorEvent);
|
||||||
|
|
||||||
|
expect(mockCallbacks.onError).toHaveBeenCalledWith(errorEvent);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { updateCalendars } from "@/websocket/messaging/updateCalendars";
|
||||||
|
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar";
|
||||||
|
import { RootState, store } from "@/app/store";
|
||||||
|
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
|
||||||
|
import { getDisplayedCalendarRange } from "@/utils/CalendarRangeManager";
|
||||||
|
import { waitFor } from "@testing-library/dom";
|
||||||
|
|
||||||
|
jest.mock("@/features/Calendars/services/refreshCalendar");
|
||||||
|
jest.mock("@/utils/CalendarRangeManager");
|
||||||
|
jest.mock("@/app/store", () => ({
|
||||||
|
store: {
|
||||||
|
getState: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("updateCalendars", () => {
|
||||||
|
let mockDispatch: jest.Mock;
|
||||||
|
const mockRange = {
|
||||||
|
start: new Date("2025-01-15T10:00:00Z"),
|
||||||
|
end: new Date("2025-01-16T10:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockState = {
|
||||||
|
calendars: {
|
||||||
|
list: {
|
||||||
|
"cal1/entry1": { id: "cal1/entry1", name: "Calendar 1", syncToken: 1 },
|
||||||
|
"cal2/entry2": { id: "cal2/entry2", name: "Calendar 2", syncToken: 1 },
|
||||||
|
},
|
||||||
|
templist: {},
|
||||||
|
},
|
||||||
|
} as unknown as RootState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockDispatch = jest.fn();
|
||||||
|
(getDisplayedCalendarRange as jest.Mock).mockReturnValue(mockRange);
|
||||||
|
(store.getState as jest.Mock).mockReturnValue(mockState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not dispatch for non-object messages", () => {
|
||||||
|
updateCalendars(null, mockDispatch);
|
||||||
|
updateCalendars("string", mockDispatch);
|
||||||
|
updateCalendars(123, mockDispatch);
|
||||||
|
|
||||||
|
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should dispatch for registered calendars", () => {
|
||||||
|
const message = {
|
||||||
|
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
|
||||||
|
"/calendars/cal1/entry1",
|
||||||
|
"/calendars/cal2/entry2",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCalendars(message, mockDispatch);
|
||||||
|
|
||||||
|
expect(refreshCalendarWithSyncToken).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should dispatch for calendar path updates", () => {
|
||||||
|
const message = {
|
||||||
|
"/calendars/cal1/entry1": { updated: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCalendars(message, mockDispatch);
|
||||||
|
|
||||||
|
expect(refreshCalendarWithSyncToken).toHaveBeenCalled();
|
||||||
|
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
|
||||||
|
calendar: mockState.calendars.list["cal1/entry1"],
|
||||||
|
calType: undefined,
|
||||||
|
calendarRange: mockRange,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use current displayed calendar range", () => {
|
||||||
|
const message = {
|
||||||
|
"/calendars/cal1/entry1": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCalendars(message, mockDispatch);
|
||||||
|
|
||||||
|
expect(getDisplayedCalendarRange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle temp calendars", async () => {
|
||||||
|
const stateWithTemp = {
|
||||||
|
calendars: {
|
||||||
|
list: {},
|
||||||
|
templist: {
|
||||||
|
"temp1/entry1": {
|
||||||
|
id: "temp1/entry1",
|
||||||
|
name: "Temp Calendar",
|
||||||
|
syncToken: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(store.getState as jest.Mock).mockReturnValue(stateWithTemp);
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
"/calendars/temp1/entry1": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCalendars(message, mockDispatch);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(refreshCalendarWithSyncToken).toHaveBeenCalledWith({
|
||||||
|
calendar: stateWithTemp.calendars.templist["temp1/entry1"],
|
||||||
|
calType: "temp",
|
||||||
|
calendarRange: mockRange,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid calendar paths gracefully", () => {
|
||||||
|
const message = {
|
||||||
|
"/invalid/path": {},
|
||||||
|
"not-a-path": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCalendars(message, mockDispatch);
|
||||||
|
|
||||||
|
expect(refreshCalendarWithSyncToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { WS_INBOUND_EVENTS } from "@/websocket/protocols";
|
||||||
|
import { parseMessage } from "@/websocket/messaging/parseMessage";
|
||||||
|
|
||||||
|
describe("parseMessage", () => {
|
||||||
|
it("should return empty set for non-object messages", () => {
|
||||||
|
const result1 = parseMessage(null);
|
||||||
|
const result2 = parseMessage("string");
|
||||||
|
const result3 = parseMessage(123);
|
||||||
|
|
||||||
|
expect(result1.calendarsToRefresh).toEqual(new Set<string>());
|
||||||
|
expect(result2.calendarsToRefresh).toEqual(new Set<string>());
|
||||||
|
expect(result3.calendarsToRefresh).toEqual(new Set<string>());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle registered event", () => {
|
||||||
|
const message = {
|
||||||
|
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: [
|
||||||
|
"/calendars/cal1/entry1",
|
||||||
|
"/calendars/cal2/entry2",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseMessage(message);
|
||||||
|
|
||||||
|
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
|
||||||
|
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2");
|
||||||
|
expect(result.calendarsToRefresh.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unregistered event", () => {
|
||||||
|
const message = {
|
||||||
|
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal1/entry1"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseMessage(message);
|
||||||
|
|
||||||
|
expect(result.calendarsToHide).toContain("/calendars/cal1/entry1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle calendar path updates", () => {
|
||||||
|
const message = {
|
||||||
|
"/calendars/cal1/entry1": { updated: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseMessage(message);
|
||||||
|
|
||||||
|
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
|
||||||
|
expect(result.calendarsToRefresh.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse multiple calendar paths", () => {
|
||||||
|
const message = {
|
||||||
|
"/calendars/cal1/entry1": {},
|
||||||
|
"/calendars/cal2/entry2": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseMessage(message);
|
||||||
|
|
||||||
|
expect(result.calendarsToRefresh.size).toBe(2);
|
||||||
|
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
|
||||||
|
expect(result.calendarsToRefresh).toContain("/calendars/cal2/entry2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple event types in single message", () => {
|
||||||
|
const message = {
|
||||||
|
[WS_INBOUND_EVENTS.CLIENT_REGISTERED]: ["/calendars/cal1/entry1"],
|
||||||
|
[WS_INBOUND_EVENTS.CLIENT_UNREGISTERED]: ["/calendars/cal2/entry2"],
|
||||||
|
"/calendars/cal1/entry1": {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseMessage(message);
|
||||||
|
|
||||||
|
expect(result.calendarsToRefresh.size).toBe(1);
|
||||||
|
expect(result.calendarsToRefresh).toContain("/calendars/cal1/entry1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { registerToCalendars } from "../../../../src/websocket/ws/registerToCalendars";
|
import { registerToCalendars } from "@/websocket/operations/registerToCalendars";
|
||||||
|
|
||||||
describe("registerToCalendars", () => {
|
describe("registerToCalendars", () => {
|
||||||
let mockSocket: any;
|
let mockSocket: any;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/websocket/__tests__/unregisterToCalendars.test.ts
|
// src/websocket/__tests__/unregisterToCalendars.test.ts
|
||||||
|
|
||||||
import { unregisterToCalendars } from "../../../../src/websocket/ws/unregisterToCalendars";
|
import { unregisterToCalendars } from "@/websocket/operations/unregisterToCalendars";
|
||||||
|
|
||||||
describe("unregisterToCalendars", () => {
|
describe("unregisterToCalendars", () => {
|
||||||
let mockSocket: any;
|
let mockSocket: any;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
getDisplayedDate,
|
||||||
|
setDisplayedDateAndRange,
|
||||||
|
calendarRangeManager,
|
||||||
|
getDisplayedCalendarRange,
|
||||||
|
} from "../../src/utils/CalendarRangeManager";
|
||||||
|
|
||||||
|
describe("CalendarRangeManager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setDisplayedDateAndRange(new Date());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return a singleton instance", () => {
|
||||||
|
const instance1 = calendarRangeManager;
|
||||||
|
const instance2 = calendarRangeManager;
|
||||||
|
|
||||||
|
expect(instance1).toStrictEqual(instance2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should compute and return calendar range when date is set", () => {
|
||||||
|
const testDate = new Date("2025-06-15T10:00:00Z");
|
||||||
|
setDisplayedDateAndRange(testDate);
|
||||||
|
|
||||||
|
const range = getDisplayedCalendarRange();
|
||||||
|
expect(range.start).toBeInstanceOf(Date);
|
||||||
|
expect(range.end).toBeInstanceOf(Date);
|
||||||
|
expect(range.start.getTime()).toBeLessThanOrEqual(testDate.getTime());
|
||||||
|
expect(range.end.getTime()).toBeGreaterThanOrEqual(testDate.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get the default date", () => {
|
||||||
|
const date = getDisplayedDate();
|
||||||
|
expect(date).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set and get a date", () => {
|
||||||
|
const testDate = new Date("2025-01-15T10:00:00Z");
|
||||||
|
setDisplayedDateAndRange(testDate);
|
||||||
|
|
||||||
|
const retrievedDate = getDisplayedDate();
|
||||||
|
expect(retrievedDate).toStrictEqual(testDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist date across multiple calls", () => {
|
||||||
|
const testDate = new Date("2025-06-20T15:30:00Z");
|
||||||
|
setDisplayedDateAndRange(testDate);
|
||||||
|
|
||||||
|
const date1 = getDisplayedDate();
|
||||||
|
const date2 = getDisplayedDate();
|
||||||
|
|
||||||
|
expect(date1).toStrictEqual(date2);
|
||||||
|
expect(date1).toStrictEqual(testDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update date when set multiple times", () => {
|
||||||
|
const date1 = new Date("2025-01-01T00:00:00Z");
|
||||||
|
const date2 = new Date("2025-12-31T23:59:59Z");
|
||||||
|
|
||||||
|
setDisplayedDateAndRange(date1);
|
||||||
|
expect(getDisplayedDate()).toStrictEqual(date1);
|
||||||
|
|
||||||
|
setDisplayedDateAndRange(date2);
|
||||||
|
expect(getDisplayedDate()).toStrictEqual(date2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain shared state (singleton behavior)", () => {
|
||||||
|
const testDate = new Date("2025-03-01");
|
||||||
|
setDisplayedDateAndRange(testDate);
|
||||||
|
|
||||||
|
// Verify CalendarRangeManager reflects the same state
|
||||||
|
expect(calendarRangeManager.getDate()).toStrictEqual(testDate);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { findCalendarById } from "../../src/utils/findCalendarById";
|
||||||
|
import { Calendar } from "../../src/features/Calendars/CalendarTypes";
|
||||||
|
import { RootState } from "../../src/app/store";
|
||||||
|
|
||||||
|
describe("findCalendarById", () => {
|
||||||
|
const mockCalendar1: Calendar = {
|
||||||
|
id: "cal1",
|
||||||
|
name: "Personal",
|
||||||
|
color: { light: "#FF0000" },
|
||||||
|
} as unknown as Calendar;
|
||||||
|
|
||||||
|
const mockCalendar2: Calendar = {
|
||||||
|
id: "cal2",
|
||||||
|
name: "Work",
|
||||||
|
color: { light: "#0000FF" },
|
||||||
|
} as unknown as Calendar;
|
||||||
|
|
||||||
|
const mockTempCalendar: Calendar = {
|
||||||
|
id: "temp1",
|
||||||
|
name: "Temporary",
|
||||||
|
color: { light: "#00FF00" },
|
||||||
|
} as unknown as Calendar;
|
||||||
|
|
||||||
|
const mockState = {
|
||||||
|
calendars: {
|
||||||
|
list: {
|
||||||
|
cal1: mockCalendar1,
|
||||||
|
cal2: mockCalendar2,
|
||||||
|
},
|
||||||
|
templist: {
|
||||||
|
temp1: mockTempCalendar,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Partial<RootState>;
|
||||||
|
|
||||||
|
it("should find calendar in main list", () => {
|
||||||
|
const result = findCalendarById(mockState, "cal1");
|
||||||
|
|
||||||
|
expect(result?.calendar).toEqual(mockCalendar1);
|
||||||
|
expect(result?.type).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find calendar in temp list", () => {
|
||||||
|
const result = findCalendarById(mockState, "temp1");
|
||||||
|
|
||||||
|
expect(result?.calendar).toEqual(mockTempCalendar);
|
||||||
|
expect(result?.type).toBe("temp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not return calendar for non-existent id", () => {
|
||||||
|
const result = findCalendarById(mockState, "nonexistent");
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not return calendar for empty string", () => {
|
||||||
|
const result = findCalendarById(mockState, "");
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize main list over temp list", () => {
|
||||||
|
const stateWithDuplicate = {
|
||||||
|
calendars: {
|
||||||
|
list: {
|
||||||
|
dup1: mockCalendar1,
|
||||||
|
},
|
||||||
|
templist: {
|
||||||
|
dup1: mockTempCalendar,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Partial<RootState>;
|
||||||
|
|
||||||
|
const result = findCalendarById(stateWithDuplicate, "dup1");
|
||||||
|
|
||||||
|
expect(result?.calendar).toEqual(mockCalendar1);
|
||||||
|
expect(result?.type).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined list or templist", () => {
|
||||||
|
const stateWithPartialCalendars = {
|
||||||
|
calendars: {
|
||||||
|
list: undefined,
|
||||||
|
templist: { temp1: mockTempCalendar },
|
||||||
|
},
|
||||||
|
} as unknown as Partial<RootState>;
|
||||||
|
|
||||||
|
const result = findCalendarById(stateWithPartialCalendars, "temp1");
|
||||||
|
expect(result?.calendar).toEqual(mockTempCalendar);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing calendars state", () => {
|
||||||
|
const emptyState = {};
|
||||||
|
|
||||||
|
const result = findCalendarById(emptyState, "cal1");
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,7 @@ const config: Config = {
|
|||||||
"^preact(/(.*)|$)": "preact$1",
|
"^preact(/(.*)|$)": "preact$1",
|
||||||
"^react$": "<rootDir>/node_modules/react",
|
"^react$": "<rootDir>/node_modules/react",
|
||||||
"^react-dom$": "<rootDir>/node_modules/react-dom",
|
"^react-dom$": "<rootDir>/node_modules/react-dom",
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
},
|
},
|
||||||
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
|
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
|
||||||
},
|
},
|
||||||
@@ -67,6 +68,9 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"],
|
transformIgnorePatterns: ["/node_modules/(?!(ky|@linagora/twake-mui)/)"],
|
||||||
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
|
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import ImportAlert from "../../features/Events/ImportAlert";
|
|||||||
import {
|
import {
|
||||||
formatDateToYYYYMMDDTHHMMSS,
|
formatDateToYYYYMMDDTHHMMSS,
|
||||||
getCalendarRange,
|
getCalendarRange,
|
||||||
} from "../../utils/dateUtils";
|
} from "@/utils/dateUtils";
|
||||||
import { push } from "redux-first-history";
|
import { push } from "redux-first-history";
|
||||||
import EventPreviewModal from "../../features/Events/EventDisplayPreview";
|
import EventPreviewModal from "../../features/Events/EventDisplayPreview";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
@@ -43,9 +43,10 @@ import ruLocale from "@fullcalendar/core/locales/ru";
|
|||||||
import viLocale from "@fullcalendar/core/locales/vi";
|
import viLocale from "@fullcalendar/core/locales/vi";
|
||||||
import SearchResultsPage from "../../features/Search/SearchResultsPage";
|
import SearchResultsPage from "../../features/Search/SearchResultsPage";
|
||||||
import { setTimeZone } from "../../features/Settings/SettingsSlice";
|
import { setTimeZone } from "../../features/Settings/SettingsSlice";
|
||||||
import { browserDefaultTimeZone } from "../../utils/timezone";
|
import { browserDefaultTimeZone } from "@/utils/timezone";
|
||||||
import { extractEventBaseUuid } from "../../utils/extractEventBaseUuid";
|
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
|
||||||
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "../../utils/storage/setSelectedCalendars";
|
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
|
||||||
|
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
|
||||||
|
|
||||||
const localeMap: Record<string, any> = {
|
const localeMap: Record<string, any> = {
|
||||||
fr: frLocale,
|
fr: frLocale,
|
||||||
@@ -741,6 +742,7 @@ export default function CalendarApp({
|
|||||||
setCurrentView(arg.view.type);
|
setCurrentView(arg.view.type);
|
||||||
const calendarCurrentDate =
|
const calendarCurrentDate =
|
||||||
calendarRef.current?.getDate() || new Date(arg.start);
|
calendarRef.current?.getDate() || new Date(arg.start);
|
||||||
|
setDisplayedDateAndRange(calendarCurrentDate);
|
||||||
|
|
||||||
if (arg.view.type === "dayGridMonth") {
|
if (arg.view.type === "dayGridMonth") {
|
||||||
const start = new Date(arg.start).getTime();
|
const start = new Date(arg.start).getTime();
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { getCalendarRange } from "./dateUtils";
|
||||||
|
|
||||||
|
class CalendarRangeManager {
|
||||||
|
private static instance: CalendarRangeManager;
|
||||||
|
private displayedDate: Date = new Date();
|
||||||
|
private displayedCalendarRange: { start: Date; end: Date } = getCalendarRange(
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): CalendarRangeManager {
|
||||||
|
if (!CalendarRangeManager.instance) {
|
||||||
|
CalendarRangeManager.instance = new CalendarRangeManager();
|
||||||
|
}
|
||||||
|
return CalendarRangeManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDate(): Date {
|
||||||
|
return new Date(this.displayedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setDate(date: Date) {
|
||||||
|
this.displayedDate = new Date(date);
|
||||||
|
this.displayedCalendarRange = getCalendarRange(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRange(): { start: Date; end: Date } {
|
||||||
|
return {
|
||||||
|
start: this.displayedCalendarRange.start,
|
||||||
|
end: this.displayedCalendarRange.end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calendarRangeManager = CalendarRangeManager.getInstance();
|
||||||
|
|
||||||
|
export const getDisplayedDate = (): Date => calendarRangeManager.getDate();
|
||||||
|
|
||||||
|
export const setDisplayedDateAndRange = (date: Date) =>
|
||||||
|
calendarRangeManager.setDate(date);
|
||||||
|
|
||||||
|
export const getDisplayedCalendarRange = (): { start: Date; end: Date } =>
|
||||||
|
calendarRangeManager.getRange();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { RootState } from "../app/store";
|
||||||
|
import { Calendar } from "../features/Calendars/CalendarTypes";
|
||||||
|
|
||||||
|
export function findCalendarById(
|
||||||
|
state: Partial<RootState>,
|
||||||
|
calendarId: string
|
||||||
|
): { calendar: Calendar; type?: "temp" } | undefined {
|
||||||
|
if (calendarId.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.calendars?.list?.[calendarId]) {
|
||||||
|
return { calendar: state.calendars.list[calendarId] };
|
||||||
|
}
|
||||||
|
if (state.calendars?.templist?.[calendarId]) {
|
||||||
|
return { calendar: state.calendars.templist[calendarId], type: "temp" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
getDisplayedDate,
|
||||||
|
setDisplayedDateAndRange,
|
||||||
|
getDisplayedCalendarRange,
|
||||||
|
calendarRangeManager,
|
||||||
|
} from "./CalendarRangeManager";
|
||||||
|
|
||||||
|
export { findCalendarById } from "./findCalendarById";
|
||||||
@@ -1,103 +1,79 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useAppDispatch, useAppSelector } from "@/app/hooks";
|
||||||
import { useAppSelector } from "../app/hooks";
|
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
|
||||||
import { useSelectedCalendars } from "../utils/storage/useSelectedCalendars";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import type { WebSocketWithCleanup } from "./connection";
|
||||||
createWebSocketConnection,
|
import { closeWebSocketConnection } from "./connection/lifecycle/closeWebSocketConnection";
|
||||||
WebSocketWithCleanup,
|
import { establishWebSocketConnection } from "./connection/lifecycle/establishWebSocketConnection";
|
||||||
} from "./createWebSocketConnection";
|
import { updateCalendars } from "./messaging";
|
||||||
import { registerToCalendars } from "./ws/registerToCalendars";
|
import { syncCalendarRegistrations } from "./operations";
|
||||||
import { unregisterToCalendars } from "./ws/unregisterToCalendars";
|
|
||||||
|
|
||||||
export function WebSocketGate() {
|
export function WebSocketGate() {
|
||||||
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
const socketRef = useRef<WebSocketWithCleanup | null>(null);
|
||||||
const previousCalendarListRef = useRef<string[]>([]);
|
const previousCalendarListRef = useRef<string[]>([]);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const isAuthenticated = useAppSelector((state) =>
|
const isAuthenticated = useAppSelector((state) =>
|
||||||
Boolean(state.user.userData && state.user.tokens)
|
Boolean(state.user.userData && state.user.tokens)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isSocketOpen, setIsSocketOpen] = useState(false);
|
const [isSocketOpen, setIsSocketOpen] = useState(false);
|
||||||
|
|
||||||
const calendarList = useSelectedCalendars();
|
const calendarList = useSelectedCalendars();
|
||||||
|
const onMessage = useCallback(
|
||||||
|
(message: unknown) => {
|
||||||
|
updateCalendars(message, dispatch);
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = useCallback((event: CloseEvent) => {
|
||||||
|
// Socket already cleaned up by internal handler before this callback fires
|
||||||
|
socketRef.current = null;
|
||||||
|
setIsSocketOpen(false);
|
||||||
|
// TODO: Add reconnection logic here
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onError = useCallback((error: Event) => {
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const callBacks = useMemo(
|
||||||
|
() => ({
|
||||||
|
onMessage,
|
||||||
|
onClose,
|
||||||
|
onError,
|
||||||
|
}),
|
||||||
|
[onMessage, onClose, onError]
|
||||||
|
);
|
||||||
|
|
||||||
// Manage WebSocket connection
|
// Manage WebSocket connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
if (socketRef.current) {
|
closeWebSocketConnection(socketRef, setIsSocketOpen);
|
||||||
socketRef.current.cleanup();
|
|
||||||
socketRef.current.close();
|
|
||||||
socketRef.current = null;
|
|
||||||
setIsSocketOpen(false);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connect = async () => {
|
establishWebSocketConnection(
|
||||||
try {
|
callBacks,
|
||||||
const socket = await createWebSocketConnection();
|
socketRef,
|
||||||
socketRef.current = socket;
|
setIsSocketOpen,
|
||||||
socket.addEventListener("close", () => {
|
abortController.signal
|
||||||
setIsSocketOpen(false);
|
);
|
||||||
socketRef.current = null;
|
|
||||||
});
|
|
||||||
// Check if socket closed during setup
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
|
||||||
setIsSocketOpen(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create WebSocket connection:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (socketRef.current) {
|
abortController.abort();
|
||||||
socketRef.current.cleanup();
|
closeWebSocketConnection(socketRef, setIsSocketOpen);
|
||||||
socketRef.current.close();
|
|
||||||
socketRef.current = null;
|
|
||||||
setIsSocketOpen(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated, callBacks]);
|
||||||
|
|
||||||
// Register using a diff with previous calendars
|
// Register using a diff with previous calendars
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
syncCalendarRegistrations(
|
||||||
!isSocketOpen ||
|
isSocketOpen,
|
||||||
!socketRef.current ||
|
socketRef,
|
||||||
socketRef.current.readyState !== WebSocket.OPEN
|
calendarList,
|
||||||
)
|
previousCalendarListRef
|
||||||
return;
|
|
||||||
|
|
||||||
const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
|
|
||||||
const previousPaths = previousCalendarListRef.current.map(
|
|
||||||
(cal) => `/calendars/${cal}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// calendars to register
|
|
||||||
const toRegister = currentPaths.filter(
|
|
||||||
(path) => !previousPaths.includes(path)
|
|
||||||
);
|
|
||||||
|
|
||||||
// calendars to unregister
|
|
||||||
const toUnregister = previousPaths.filter(
|
|
||||||
(path) => !currentPaths.includes(path)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (toRegister.length > 0) {
|
|
||||||
registerToCalendars(socketRef.current, toRegister);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toUnregister.length > 0) {
|
|
||||||
unregisterToCalendars(socketRef.current, toUnregister);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update the ref if operations succeeded
|
|
||||||
previousCalendarListRef.current = calendarList;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update calendar registrations:", error);
|
|
||||||
}
|
|
||||||
}, [isSocketOpen, calendarList]);
|
}, [isSocketOpen, calendarList]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+11
-13
@@ -1,16 +1,15 @@
|
|||||||
import { fetchWebSocketTicket } from "./api/fetchWebSocketTicket";
|
import { fetchWebSocketTicket } from "../api/fetchWebSocketTicket";
|
||||||
import { WS_INBOUND_EVENTS } from "./protocols";
|
import { WS_INBOUND_EVENTS } from "../protocols";
|
||||||
|
import { WebSocketCallbacks, WebSocketWithCleanup } from "./types";
|
||||||
|
|
||||||
export interface WebSocketWithCleanup extends WebSocket {
|
export async function createWebSocketConnection(
|
||||||
cleanup: () => void;
|
callbacks: WebSocketCallbacks
|
||||||
}
|
): Promise<WebSocketWithCleanup> {
|
||||||
|
|
||||||
export async function createWebSocketConnection(): Promise<WebSocketWithCleanup> {
|
|
||||||
const wsBaseUrl =
|
const wsBaseUrl =
|
||||||
(window as any).WEBSOCKET_URL ??
|
(window as any).WEBSOCKET_URL ??
|
||||||
(window as any).CALENDAR_BASE_URL?.replace(
|
(window as any).CALENDAR_BASE_URL?.replace(
|
||||||
/^http(s)?:/,
|
/^http(s)?:/,
|
||||||
(_: boolean, s: boolean) => (s ? "wss:" : "ws:")
|
(_: string, s: string | undefined) => (s ? "wss:" : "ws:")
|
||||||
) ??
|
) ??
|
||||||
"";
|
"";
|
||||||
|
|
||||||
@@ -36,6 +35,7 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
|
|||||||
socket.close();
|
socket.close();
|
||||||
reject(new Error("WebSocket connection timed out"));
|
reject(new Error("WebSocket connection timed out"));
|
||||||
}, CONNECTION_TIMEOUT_MS);
|
}, CONNECTION_TIMEOUT_MS);
|
||||||
|
|
||||||
const openHandler = () => {
|
const openHandler = () => {
|
||||||
console.log("WebSocket connection opened");
|
console.log("WebSocket connection opened");
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
@@ -66,9 +66,7 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
|
|||||||
const messageHandler = (event: MessageEvent) => {
|
const messageHandler = (event: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
console.log("WebSocket message received:", message);
|
callbacks.onMessage(message);
|
||||||
|
|
||||||
// TODO: Handle different message types
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse WebSocket message:", error);
|
console.error("Failed to parse WebSocket message:", error);
|
||||||
}
|
}
|
||||||
@@ -76,13 +74,13 @@ export async function createWebSocketConnection(): Promise<WebSocketWithCleanup>
|
|||||||
|
|
||||||
const closeHandler = (event: CloseEvent) => {
|
const closeHandler = (event: CloseEvent) => {
|
||||||
console.log("WebSocket closed:", event.code, event.reason);
|
console.log("WebSocket closed:", event.code, event.reason);
|
||||||
// Clean up all event listeners when socket closes
|
|
||||||
cleanup();
|
cleanup();
|
||||||
// TODO: Add reconnection logic
|
callbacks.onClose?.(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorHandler = (error: Event) => {
|
const errorHandler = (error: Event) => {
|
||||||
console.error("WebSocket error:", error);
|
console.error("WebSocket error:", error);
|
||||||
|
callbacks.onError?.(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup function to remove all event listeners
|
// Cleanup function to remove all event listeners
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { createWebSocketConnection } from "./createConnection";
|
||||||
|
export type { WebSocketWithCleanup, WebSocketCallbacks } from "./types";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { WebSocketWithCleanup } from "../types";
|
||||||
|
|
||||||
|
export function closeWebSocketConnection(
|
||||||
|
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
|
||||||
|
setIsSocketOpen: (value: boolean) => void
|
||||||
|
) {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.cleanup();
|
||||||
|
socketRef.current.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
setIsSocketOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createWebSocketConnection } from "../createConnection";
|
||||||
|
import { WebSocketCallbacks, WebSocketWithCleanup } from "../types";
|
||||||
|
|
||||||
|
export async function establishWebSocketConnection(
|
||||||
|
callbacks: WebSocketCallbacks,
|
||||||
|
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
|
||||||
|
setIsSocketOpen: (value: boolean) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const socket = await createWebSocketConnection(callbacks);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
socket.cleanup();
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
setIsSocketOpen(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create WebSocket connection:", error);
|
||||||
|
setIsSocketOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface WebSocketWithCleanup extends WebSocket {
|
||||||
|
cleanup: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebSocketCallbacks {
|
||||||
|
onMessage: (data: unknown) => void;
|
||||||
|
onClose?: (event: CloseEvent) => void;
|
||||||
|
onError?: (error: Event) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { WebSocketGate } from "./WebSocketGate";
|
||||||
|
export type { WebSocketWithCleanup, WebSocketCallbacks } from "./connection";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { parseMessage } from "./parseMessage";
|
||||||
|
export { updateCalendars } from "./updateCalendars";
|
||||||
|
export { parseCalendarPath } from "./parseCalendarPath";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const CALENDAR_PATH_REGEX = /^\/calendars\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
export function parseCalendarPath(key: string) {
|
||||||
|
if (!CALENDAR_PATH_REGEX.test(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, , userId, calendarId] = key.split("/");
|
||||||
|
|
||||||
|
return `${userId}/${calendarId}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { WS_INBOUND_EVENTS } from "../protocols";
|
||||||
|
|
||||||
|
export function parseMessage(message: unknown) {
|
||||||
|
console.log("WebSocket message received:", message);
|
||||||
|
const calendarsToRefresh = new Set<string>();
|
||||||
|
const calendarsToHide = new Set<string>();
|
||||||
|
if (typeof message !== "object" || message === null) {
|
||||||
|
return { calendarsToRefresh, calendarsToHide };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(message)) {
|
||||||
|
switch (key) {
|
||||||
|
case WS_INBOUND_EVENTS.CLIENT_REGISTERED:
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((cal: string) => calendarsToRefresh.add(cal));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case WS_INBOUND_EVENTS.CLIENT_UNREGISTERED:
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((cal: string) => calendarsToHide.add(cal));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
calendarsToRefresh.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { calendarsToRefresh, calendarsToHide };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { AppDispatch } from "@/app/store";
|
||||||
|
import { store } from "@/app/store";
|
||||||
|
import { refreshCalendarWithSyncToken } from "@/features/Calendars/services/refreshCalendar";
|
||||||
|
import { findCalendarById, getDisplayedCalendarRange } from "@/utils";
|
||||||
|
import { setSelectedCalendars } from "@/utils/storage/setSelectedCalendars";
|
||||||
|
import { parseCalendarPath } from "./parseCalendarPath";
|
||||||
|
import { parseMessage } from "./parseMessage";
|
||||||
|
|
||||||
|
export function updateCalendars(message: unknown, dispatch: AppDispatch) {
|
||||||
|
const currentRange = getDisplayedCalendarRange();
|
||||||
|
const state = store.getState();
|
||||||
|
const { calendarsToRefresh, calendarsToHide } = parseMessage(message);
|
||||||
|
calendarsToRefresh.forEach((calendarPath) => {
|
||||||
|
const calendarId = parseCalendarPath(calendarPath);
|
||||||
|
if (!calendarId) {
|
||||||
|
console.warn("Invalid calendar path received:", calendarPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const calendar = findCalendarById(state, calendarId);
|
||||||
|
if (calendar) {
|
||||||
|
dispatch(
|
||||||
|
refreshCalendarWithSyncToken({
|
||||||
|
calendar: calendar.calendar,
|
||||||
|
calType: calendar.type,
|
||||||
|
calendarRange: currentRange,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn("Calendar not found for id:", calendarId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const currentSelectedCalendars = JSON.parse(
|
||||||
|
localStorage.getItem("selectedCalendars") ?? "[]"
|
||||||
|
) as string[];
|
||||||
|
|
||||||
|
const calendarIdsToHide = [...calendarsToHide]
|
||||||
|
.map(parseCalendarPath)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
const updatedSelectedCalendars = currentSelectedCalendars.filter(
|
||||||
|
(id) => !calendarIdsToHide.includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedCalendars(updatedSelectedCalendars);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { registerToCalendars } from "./registerToCalendars";
|
||||||
|
export { unregisterToCalendars } from "./unregisterToCalendars";
|
||||||
|
export { syncCalendarRegistrations } from "./syncCalendarRegistrations";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { WebSocketWithCleanup } from "../connection";
|
||||||
|
import { registerToCalendars } from "./registerToCalendars";
|
||||||
|
import { unregisterToCalendars } from "./unregisterToCalendars";
|
||||||
|
|
||||||
|
export function syncCalendarRegistrations(
|
||||||
|
isSocketOpen: boolean,
|
||||||
|
socketRef: React.MutableRefObject<WebSocketWithCleanup | null>,
|
||||||
|
calendarList: string[],
|
||||||
|
previousCalendarListRef: React.MutableRefObject<string[]>
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!isSocketOpen ||
|
||||||
|
!socketRef.current ||
|
||||||
|
socketRef.current.readyState !== WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPaths = calendarList.map((cal) => `/calendars/${cal}`);
|
||||||
|
const previousPaths = previousCalendarListRef.current.map(
|
||||||
|
(cal) => `/calendars/${cal}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const toRegister = currentPaths.filter(
|
||||||
|
(path) => !previousPaths.includes(path)
|
||||||
|
);
|
||||||
|
const toUnregister = previousPaths.filter(
|
||||||
|
(path) => !currentPaths.includes(path)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (toRegister.length > 0) {
|
||||||
|
registerToCalendars(socketRef.current, toRegister);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to register calendar:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (toUnregister.length > 0) {
|
||||||
|
unregisterToCalendars(socketRef.current, toUnregister);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to unregister calendar:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previousCalendarListRef.current = calendarList;
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// WebSocket event listeners (browser events)
|
|
||||||
export const WS_INBOUND_EVENTS = {
|
|
||||||
CONNECTION_OPENED: "open",
|
|
||||||
MESSAGE: "message",
|
|
||||||
ERROR: "error",
|
|
||||||
CONNECTION_CLOSED: "close",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// WebSocket message types sent to server
|
|
||||||
export const WS_OUTBOUND_EVENTS = {
|
|
||||||
REGISTER_CLIENT: "register",
|
|
||||||
UNREGISTER_CLIENT: "unregister",
|
|
||||||
} as const;
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const WS_INBOUND_EVENTS = {
|
||||||
|
CONNECTION_OPENED: "open",
|
||||||
|
MESSAGE: "message",
|
||||||
|
ERROR: "error",
|
||||||
|
CONNECTION_CLOSED: "close",
|
||||||
|
CLIENT_REGISTERED: "registered",
|
||||||
|
CLIENT_UNREGISTERED: "unregistered",
|
||||||
|
} as const;
|
||||||
+10
-1
@@ -14,7 +14,16 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@/websocket": ["src/websocket/index.ts"],
|
||||||
|
"@/websocket/*": ["src/websocket/*"],
|
||||||
|
"@/utils": ["src/utils/index.ts"],
|
||||||
|
"@/features/*": ["src/features/*"],
|
||||||
|
"@/app/*": ["src/app/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src", "__test__"]
|
"include": ["src", "__test__"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user