- Calendar Event Creator
+
+
+
+
+ {[...Array(7)].map((_, idx) => (
+ |
+ th-{idx}
+ |
+ ))}
+
+
+
+ {cells.map((d, idx) => (
+
+ |
+
+ {cellRender(d)}
+
+ |
+
+ ))}
+
+
+
+ {headerRender({
+ value,
+ onChange: change,
+ })}
+
);
};
-});
-// Mock dayjs and moment
-jest.mock("dayjs", () => {
- const originalDayjs = jest.requireActual("dayjs");
- return originalDayjs;
-});
-
-jest.mock("jalali-moment", () => {
- const originalMoment = jest.requireActual("jalali-moment");
- return originalMoment;
+ return { Calendar, Button, Select, Tag, Tooltip, Flex };
});
describe("CSCalendar", () => {
- const mockSetAnnouncementData = jest.fn();
- const mockAddToCurrentWeek = 0;
-
- beforeEach(() => {
- mockSetAnnouncementData.mockClear();
- jest.clearAllMocks();
- });
+ let setAnnouncementDataMock;
+ let announcementState;
- it("should render without crashing", () => {
+ const renderCalendar = (props = {}, theme = "light") =>
render(
-
- );
- });
-
- it("should accept setAnnouncementData prop", () => {
- render(
-
- );
- expect(mockSetAnnouncementData).toBeDefined();
- });
-
- it("should accept addToCurrentWeek prop", () => {
- render(
-
- );
- });
-
- it("should render container", () => {
- const { container } = render(
-
- );
- expect(container).toBeInTheDocument();
- });
+
+
+
+ );
+
+ const getEventForDateLocal = (date) => {
+ const startDate = dayjs(startCalendarDate);
+ if (date.isBefore(startDate, "day")) return null;
+ const daysSinceStart = date.diff(startDate, "day");
+ const weekNumber = Math.floor(daysSinceStart / 7) % 2;
+ if (date.day() === 2) return events[weekNumber];
+ if (date.day() === 0) return events[2 + weekNumber];
+ return null;
+ };
- it("should call setAnnouncementData", () => {
- render(
-
- );
- expect(mockSetAnnouncementData).toHaveBeenCalled();
- });
+ const computeAnnouncement = (offset = 14) => {
+ const saturdayDate = moment().add(offset, "day").startOf("week");
+ const startWeekDate = saturdayDate
+ .clone()
+ .add(9, "day")
+ .format("YYYY/M/D");
+ const endWeekDate = saturdayDate
+ .clone()
+ .add(16, "day")
+ .format("YYYY/M/D");
+ const firstEventDate = saturdayDate
+ .clone()
+ .add(10, "day")
+ .format("YYYY/M/D");
+ const secondEventDate = saturdayDate
+ .clone()
+ .add(15, "day")
+ .format("YYYY/M/D");
+
+ const startDate = moment("2025-01-13", "YYYY-MM-DD")
+ .locale("fa")
+ .format("YYYY-MM-DD HH:mm:ss");
+
+ let firstEvent = "";
+ let secondEvent = "";
+
+ if (saturdayDate.isAfter(startDate, "day")) {
+ const fe = getEventForDateLocal(
+ dayjs(saturdayDate.clone().add(10, "day").toDate())
+ );
+ const se = getEventForDateLocal(
+ dayjs(saturdayDate.clone().add(15, "day").toDate())
+ );
+
+ if (fe) firstEvent = fe.fullName || fe.title;
+ if (se) secondEvent = se.fullName || se.title;
+ }
+
+ return {
+ startWeekDate,
+ endWeekDate,
+ firstEventDate,
+ secondEventDate,
+ firstEvent,
+ secondEvent,
+ };
+ };
- it("should update when addToCurrentWeek changes", () => {
- const { rerender } = render(
-
- );
- rerender(
-
- );
- expect(mockSetAnnouncementData).toHaveBeenCalledTimes(2);
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date("2025-01-13T00:00:00Z"));
+ announcementState = {};
+ setAnnouncementDataMock = jest.fn((updater) => {
+ announcementState =
+ typeof updater === "function"
+ ? updater(announcementState)
+ : updater;
+ });
+ mockPopup.mockClear();
});
- it("should be accessible", () => {
- const { container } = render(
-
- );
- expect(container).toBeInTheDocument();
+ afterEach(() => {
+ jest.useRealTimers();
});
- it("should call getEventForDate for selected date", () => {
- const { container } = render(
-
- );
-
- // Calendar should render with events
- expect(
- container.querySelectorAll(".ant-badge").length
- ).toBeGreaterThanOrEqual(0);
- });
+ it("renders event badges for the current week and updates announcement data", () => {
+ const { container } = renderCalendar();
+ jest.runOnlyPendingTimers();
- it("should handle onPanelChange", () => {
- const { container } = render(
-
+ expect(container.querySelectorAll(".stage-tag").length).toBeGreaterThan(
+ 0
);
-
- expect(container).toBeInTheDocument();
+ expect(setAnnouncementDataMock).toHaveBeenCalled();
+ expect(announcementState.firstEvent).toBeTruthy();
});
- it("should have calendar header with navigation buttons", () => {
- const { getByText } = render(
-
- );
-
- expect(getByText("ماه قبل")).toBeInTheDocument();
- expect(getByText("امروز")).toBeInTheDocument();
- expect(getByText("ماه بعد")).toBeInTheDocument();
- });
+ it("opens and toggles the popup when clicking a day with an event", async () => {
+ const { container } = renderCalendar();
+ await act(async () => {});
+ const eventCell = Array.from(
+ container.querySelectorAll(".calendar-cell-with-event")
+ ).find((node) => node.querySelector(".stage-tag"));
+ expect(eventCell).toBeInTheDocument();
- it("should render Alert component", () => {
- const { container } = render(
-
+ fireEvent.click(eventCell);
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls.some((call) => call[0].visible === true)
+ ).toBe(true)
);
- expect(container.querySelector(".ant-alert")).toBeInTheDocument();
- });
+ const refreshedCell = Array.from(
+ container.querySelectorAll(".calendar-cell-with-event")
+ ).find((node) => node.querySelector(".stage-tag"));
- it("should display event description", () => {
- const { getByText } = render(
-
+ fireEvent.click(refreshedCell);
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls.some((call) => call[0].visible === false)
+ ).toBe(true)
);
-
- // Should render event description or "no event" message
- const eventDescription = getByText((content, element) => {
- return element && element.className === "event-description";
- });
-
- expect(eventDescription).toBeInTheDocument();
});
- it("should have select elements for month and year", () => {
- const { container } = render(
-
- );
-
- const selects = container.querySelectorAll(".ant-select");
- expect(selects.length).toBeGreaterThan(0);
- });
+ it("handles delegated cell clicks on table cells", async () => {
+ const { container } = renderCalendar();
+ await act(async () => {});
+ const tableCell = Array.from(
+ container.querySelectorAll(".ant-picker-cell")
+ ).find((cell) => cell.querySelector(".stage-tag"));
+ fireEvent.click(tableCell);
- it("should update announcement data on addToCurrentWeek change", () => {
- const { rerender } = render(
-
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls.some((call) => call[0].visible === true)
+ ).toBe(true)
);
- const firstCallCount = mockSetAnnouncementData.mock.calls.length;
-
- rerender(
-
+ fireEvent.click(tableCell);
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls.some((call) => call[0].visible === false)
+ ).toBe(true)
);
-
- const secondCallCount = mockSetAnnouncementData.mock.calls.length;
- expect(secondCallCount).toBeGreaterThan(firstCallCount);
});
- it("should render calendar element", () => {
- const { container } = render(
-
- );
+ it("opens via keyboard interaction on Enter", async () => {
+ const { container } = renderCalendar();
+ const eventCell = Array.from(
+ container.querySelectorAll(".calendar-cell-with-event")
+ ).find((node) => node.querySelector(".stage-tag"));
+ fireEvent.keyDown(eventCell, { key: "Enter" });
- expect(
- container.querySelector(".ant-picker-calendar")
- ).toBeInTheDocument();
- });
-
- it("should call getEventForDate for dates before startDate", () => {
- const { container } = render(
-
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls[mockPopup.mock.calls.length - 1][0].visible
+ ).toBe(true)
);
-
- expect(container).toBeInTheDocument();
});
- it("should render with different addToCurrentWeek values", () => {
- const { rerender } = render(
-
- );
-
- rerender(
-
- );
+ it("renders short title when viewport width shrinks", () => {
+ const { container } = renderCalendar();
+ Object.defineProperty(window, "innerWidth", { value: 800 });
+ window.dispatchEvent(new Event("resize"));
- expect(mockSetAnnouncementData).toHaveBeenCalled();
+ const label = container.querySelector(".stage-tag .main-word");
+ expect(label?.textContent).toBe(events[0].shortTitle);
});
- it("should update year/month state", async () => {
- const { container } = render(
-
- );
-
- await waitFor(() => {
- expect(
- container.querySelector(".ant-picker-calendar")
- ).toBeInTheDocument();
- });
+ it("applies dark theme colors for event tags", () => {
+ const { container } = renderCalendar({}, "dark");
+ const tag = container.querySelector(".stage-tag");
+ expect(tag?.getAttribute("data-color")).toBe(events[0].colorDark);
});
- it("should render month navigation buttons", () => {
- const { getByText } = render(
-
- );
-
- expect(getByText("ماه قبل")).toBeInTheDocument();
- expect(getByText("امروز")).toBeInTheDocument();
- expect(getByText("ماه بعد")).toBeInTheDocument();
- });
+ it("handles delegated edge cases and manual popup close", async () => {
+ let capturedDelegator;
+ const originalAdd = Element.prototype.addEventListener;
+ Element.prototype.addEventListener = function (type, handler) {
+ if (
+ type === "click" &&
+ this.classList &&
+ this.classList.contains("ant-picker-calendar")
+ ) {
+ capturedDelegator = handler;
+ }
+ return originalAdd.call(this, type, handler);
+ };
- it("should render calendar with event badges", async () => {
- const { container } = render(
-
- );
+ const { container } = renderCalendar();
+ await act(async () => {});
+ Element.prototype.addEventListener = originalAdd;
- await waitFor(() => {
- const badges = container.querySelectorAll(".ant-badge");
- expect(badges.length).toBeGreaterThanOrEqual(0);
+ capturedDelegator({
+ target: {
+ closest: (sel) => (sel === ".event-popup" ? true : null),
+ },
});
- });
-
- it("should handle dateCellRender", () => {
- const { container } = render(
-
- );
-
- expect(
- container.querySelectorAll(".ant-badge").length
- ).toBeGreaterThanOrEqual(0);
- });
- it("should initialize with today's date", () => {
- const { container } = render(
-
- );
+ const tdWithEvent = Array.from(
+ container.querySelectorAll(".ant-picker-cell")
+ ).find((cell) => cell.querySelector(".stage-tag"));
+ const wrapper = tdWithEvent.querySelector(".calendar-cell-with-event");
- const todayBtn = container.querySelector(".today-btn");
- expect(todayBtn).toBeInTheDocument();
- });
+ wrapper.removeAttribute("data-date");
+ capturedDelegator({ target: tdWithEvent });
- it("should render ConfigProvider with RTL direction", () => {
- const { container } = render(
-
- );
+ wrapper.setAttribute("data-date", "2025-01-14");
+ capturedDelegator({ target: tdWithEvent });
- const alert = container.querySelector(".ant-alert");
- expect(alert).toBeInTheDocument();
- });
-
- it("should handle onPanelChange when month/year changes", () => {
- const { container } = render(
-
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls.some((call) => call[0].visible === true)
+ ).toBe(true)
);
- const prevBtn = screen.getByText("ماه قبل");
- fireEvent.click(prevBtn);
- expect(mockSetAnnouncementData).toHaveBeenCalled();
- });
-
- it("should navigate to next month", () => {
- const { container } = render(
-
+ await act(async () => {});
+ capturedDelegator({ target: tdWithEvent });
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls.some((call) => call[0].visible === false)
+ ).toBe(true)
);
- const nextBtn = screen.getByText("ماه بعد");
- fireEvent.click(nextBtn);
- expect(mockSetAnnouncementData).toHaveBeenCalled();
+ const lastOnClose =
+ mockPopup.mock.calls[mockPopup.mock.calls.length - 1][0].onClose;
+ act(() => lastOnClose());
});
- it("should navigate to previous month", () => {
- const { container } = render(
-
- );
-
- const prevBtn = screen.getByText("ماه قبل");
- fireEvent.click(prevBtn);
- expect(mockSetAnnouncementData).toHaveBeenCalled();
+ it("returns previous announcement data when nothing changes", () => {
+ const offset = 14;
+ announcementState = computeAnnouncement(offset);
+ renderCalendar({ addToCurrentWeek: offset });
+ expect(setAnnouncementDataMock).toHaveBeenCalled();
});
- it("should click today button", () => {
- const { container } = render(
-
- );
+ it("closes the popup when clicking a day without an event", async () => {
+ const { container } = renderCalendar();
+ const emptyCell = Array.from(
+ container.querySelectorAll(".calendar-cell-with-event")
+ ).find((node) => !node.querySelector(".stage-tag"));
- const todayBtn = screen.getByText("امروز");
- fireEvent.click(todayBtn);
- expect(mockSetAnnouncementData).toHaveBeenCalled();
- });
+ fireEvent.click(emptyCell);
- it("should handle getEventForDate returns null for dates before startDate", () => {
- const { container } = render(
-
+ await waitFor(() =>
+ expect(
+ mockPopup.mock.calls[mockPopup.mock.calls.length - 1][0].visible
+ ).toBe(false)
);
-
- expect(container).toBeInTheDocument();
});
- it("should set year/month state when value changes", async () => {
- const { container } = render(
-
- );
+ it("updates announcement data when navigating weeks forward and backward", () => {
+ renderCalendar({ addToCurrentWeek: 0 });
+ const firstCall = { ...announcementState };
- await waitFor(() => {
- expect(mockSetAnnouncementData).toHaveBeenCalled();
- });
- });
+ renderCalendar({ addToCurrentWeek: 7 });
+ const secondCall = { ...announcementState };
+ expect(secondCall.startWeekDate).not.toBe(firstCall.startWeekDate);
- it("should set correct announcement data for events after startDate", () => {
- render(
-
+ renderCalendar({ addToCurrentWeek: -500 });
+ expect(announcementState.firstEvent).toBe(
+ announcementState.secondEvent
);
-
- expect(mockSetAnnouncementData).toHaveBeenCalled();
- const lastCall =
- mockSetAnnouncementData.mock.calls[
- mockSetAnnouncementData.mock.calls.length - 1
- ];
- expect(lastCall[0]).toBeDefined();
+ expect(announcementState.firstEvent).toBeTruthy();
});
- it("should handle multiple prop changes", () => {
- const { rerender } = render(
-
- );
+ it("handles header navigation buttons and month/year selects", () => {
+ const { container } = renderCalendar();
+ const headerButtons = container.querySelectorAll("button");
- const callCount1 = mockSetAnnouncementData.mock.calls.length;
+ // previous, today, next
+ fireEvent.click(headerButtons[0]);
+ fireEvent.click(headerButtons[1]);
+ fireEvent.click(headerButtons[2]);
- rerender(
-
- );
+ const selects = container.querySelectorAll("select");
+ fireEvent.change(selects[0], { target: { value: 2026 } });
+ fireEvent.change(selects[1], { target: { value: 5 } });
- const callCount2 = mockSetAnnouncementData.mock.calls.length;
- expect(callCount2).toBeGreaterThanOrEqual(callCount1);
+ expect(setAnnouncementDataMock).toHaveBeenCalled();
});
- it("should cleanup timeout on unmount", () => {
- const { unmount } = render(
-
- );
-
- expect(() => unmount()).not.toThrow();
+ it("strips native title attributes through requestAnimationFrame cleanup", () => {
+ const { container, unmount } = renderCalendar();
+ const titledCell = container.querySelector(".ant-picker-cell");
+ expect(titledCell).toBeInTheDocument();
+ return waitFor(() =>
+ expect(titledCell?.getAttribute("title")).toBeNull()
+ ).then(unmount);
});
});
diff --git a/src/__tests__/components/CalendarIntro.test.jsx b/src/__tests__/components/CalendarIntro.test.jsx
new file mode 100644
index 0000000..c6991ad
--- /dev/null
+++ b/src/__tests__/components/CalendarIntro.test.jsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import CryptoJS from "crypto-js";
+import CalendarIntro from "../../components/CalendarIntro";
+
+jest.mock("antd", () => {
+ const React = require("react");
+ return {
+ Card: ({ children, ...props }) => (
+
+ {children}
+
+ ),
+ Typography: {
+ Paragraph: ({ children, ...props }) =>
{children}
,
+ Link: ({ children, ...props }) =>
{children},
+ Title: ({ children, ...props }) =>
{children}
,
+ },
+ Space: ({ children, ...props }) =>
{children}
,
+ };
+});
+
+jest.mock("@ant-design/icons", () => ({
+ InfoCircleOutlined: () =>
icon,
+}));
+
+describe("CalendarIntro", () => {
+ beforeEach(() => {
+ window.open = jest.fn();
+ });
+
+ it("renders introductory content", () => {
+ render(
);
+ expect(screen.getByTestId("card")).toBeInTheDocument();
+ expect(screen.getAllByRole("heading").length).toBeGreaterThan(0);
+ });
+
+ it("opens the decrypted link on every fourth click of the info icon", () => {
+ render(
);
+
+ const iconTrigger = screen.getByText("icon").parentElement;
+
+ const encrypted =
+ "U2FsdGVkX1/6Qzhsn/GOmvLuTL2y3E9PiuIq9z5eyMlHYCBbHTRgO4+YONp1oZPMWNvhHthzh2FtMlqpzQOYBA==";
+ const expectedUrl = CryptoJS.AES.decrypt(
+ encrypted,
+ "1403-10-30"
+ ).toString(CryptoJS.enc.Utf8);
+
+ fireEvent.click(iconTrigger);
+ fireEvent.click(iconTrigger);
+ fireEvent.click(iconTrigger);
+ expect(window.open).not.toHaveBeenCalled();
+
+ fireEvent.click(iconTrigger);
+ expect(window.open).toHaveBeenCalledWith(expectedUrl, "_blank");
+
+ fireEvent.click(iconTrigger);
+ fireEvent.click(iconTrigger);
+ fireEvent.click(iconTrigger);
+ fireEvent.click(iconTrigger);
+ expect(window.open).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/__tests__/components/EventPopup.test.jsx b/src/__tests__/components/EventPopup.test.jsx
new file mode 100644
index 0000000..d53a4b4
--- /dev/null
+++ b/src/__tests__/components/EventPopup.test.jsx
@@ -0,0 +1,241 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { act } from "react-dom/test-utils";
+import dayjs from "dayjs";
+import EventPopup from "../../components/EventPopup";
+import { ThemeContext } from "../../store/Theme/ThemeContext";
+
+const mockCalendarCreator = jest.fn();
+jest.mock("../../components/CalendarEventCreator", () => {
+ const React = require("react");
+ return function MockCalendarEventCreator(props) {
+ mockCalendarCreator(props);
+ return
;
+ };
+});
+
+const anchorRect = {
+ left: 100,
+ top: 100,
+ bottom: 140,
+ width: 120,
+ height: 40,
+};
+
+const baseEvent = {
+ title: "Title",
+ fullName: "Full Event Title",
+ colorLight: "#111111",
+ colorDark: "#222222",
+ link: "https://example.com/session",
+ resource: "https://example.com/resource",
+ time: "10:00 - 11:00",
+};
+
+const renderPopup = (props, theme = "light") =>
+ render(
+
+
+
+ );
+
+describe("EventPopup", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.clearAllMocks();
+ mockCalendarCreator.mockClear();
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ it("returns null when not visible or missing anchorRect", () => {
+ const { container, rerender } = renderPopup({
+ visible: false,
+ anchorRect: null,
+ date: null,
+ event: null,
+ onClose: jest.fn(),
+ });
+ expect(container.firstChild).toBeNull();
+
+ rerender(
+
+
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders event details, resource links, and calendar creator", () => {
+ const onClose = jest.fn();
+ const date = dayjs("2025-01-14");
+
+ const { container } = renderPopup(
+ {
+ visible: true,
+ anchorRect,
+ date,
+ event: baseEvent,
+ onClose,
+ },
+ "dark"
+ );
+
+ act(() => jest.advanceTimersByTime(20));
+
+ expect(container.querySelector(".event-popup")).toBeInTheDocument();
+ const popupNode = container.querySelector(".event-popup");
+ expect(
+ container.querySelector(".event-popup__content")?.className
+ ).toContain("is-open");
+ expect(popupNode?.style.width).toBeTruthy();
+ expect(screen.getByText("Full Event Title")).toBeInTheDocument();
+ expect(screen.getByText("10:00 - 11:00")).toBeInTheDocument();
+ expect(screen.getByText("تاریخ شمسی")).toBeInTheDocument();
+ expect(screen.getByText("تاریخ میلادی")).toBeInTheDocument();
+ expect(container.querySelector(".event-popup__link--primary")).toBe(
+ container.querySelector('a[href="https://example.com/session"]')
+ );
+ expect(
+ container.querySelector('a[href="https://example.com/resource"]')
+ ).toBeInTheDocument();
+
+ const dot = container.querySelector(".event-popup__color-dot");
+ expect(dot?.style.background).toContain("34");
+
+ expect(mockCalendarCreator).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventDate: "2025-01-14",
+ eventText: "Full Event Title",
+ })
+ );
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("ignores the first outside click due to guard, then closes", () => {
+ const onClose = jest.fn();
+ renderPopup({
+ visible: true,
+ anchorRect,
+ date: dayjs("2025-01-14"),
+ event: baseEvent,
+ onClose,
+ });
+
+ act(() => {});
+ fireEvent.mouseDown(document.body);
+ expect(onClose).not.toHaveBeenCalled();
+
+ act(() => jest.advanceTimersByTime(200));
+ fireEvent.mouseDown(document.body);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders fallback values when no event is provided", () => {
+ const { getByText } = renderPopup({
+ visible: true,
+ anchorRect,
+ date: dayjs("2025-01-14"),
+ event: null,
+ onClose: jest.fn(),
+ });
+
+ jest.runAllTimers();
+
+ expect(getByText("جزئیات جلسه")).toBeInTheDocument();
+ expect(getByText("ساعت ۱۸:۰۰ تا ۱۹:۰۰")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("calendar-event-creator")
+ ).not.toBeInTheDocument();
+ });
+
+ it("positions above when there is not enough space below", () => {
+ const tallRect = { ...anchorRect, top: 720, bottom: 760 };
+ const { container } = renderPopup({
+ visible: true,
+ anchorRect: tallRect,
+ date: dayjs("2025-01-14"),
+ event: baseEvent,
+ onClose: jest.fn(),
+ });
+
+ jest.runAllTimers();
+
+ expect(container.querySelector(".event-popup")?.className).toContain(
+ "event-popup--above"
+ );
+ });
+
+ it("invokes onClose when Escape is pressed", async () => {
+ const onClose = jest.fn();
+ const { container } = renderPopup({
+ visible: true,
+ anchorRect,
+ date: dayjs("2025-01-14"),
+ event: baseEvent,
+ onClose,
+ });
+
+ await waitFor(() =>
+ expect(container.querySelector(".event-popup")).toBeInTheDocument()
+ );
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("renders fallback values when date is missing", () => {
+ const { getAllByText } = renderPopup({
+ visible: true,
+ anchorRect,
+ date: null,
+ event: null,
+ onClose: jest.fn(),
+ });
+
+ act(() => jest.runAllTimers());
+ expect(getAllByText("-").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("closes when clicking outside after opening", () => {
+ const onClose = jest.fn();
+ renderPopup({
+ visible: true,
+ anchorRect,
+ date: dayjs("2025-01-14"),
+ event: baseEvent,
+ onClose,
+ });
+
+ act(() => jest.advanceTimersByTime(220));
+ fireEvent.mouseDown(document.body);
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("falls back to event title when full name is missing", () => {
+ mockCalendarCreator.mockClear();
+ const event = { ...baseEvent, fullName: undefined };
+ const { getByText } = renderPopup({
+ visible: true,
+ anchorRect,
+ date: dayjs("2025-01-14"),
+ event,
+ onClose: jest.fn(),
+ });
+
+ expect(screen.getAllByText(event.title).length).toBeGreaterThan(0);
+ expect(mockCalendarCreator).toHaveBeenCalledWith(
+ expect.objectContaining({ eventText: event.title })
+ );
+ });
+});
diff --git a/src/__tests__/components/FloatButtonSection.test.jsx b/src/__tests__/components/FloatButtonSection.test.jsx
index c68ba59..b40d88b 100644
--- a/src/__tests__/components/FloatButtonSection.test.jsx
+++ b/src/__tests__/components/FloatButtonSection.test.jsx
@@ -18,6 +18,7 @@ jest.mock("@ant-design/icons", () => ({
// Mock Ant Design components
jest.mock("antd", () => {
const React = require("react");
+ const handlers = [];
const FloatButtonGroup = ({ children, ...props }) => (
{
{children}
);
- const FloatButton = ({ children, onClick, disabled, ...props }) => (
-
- {children}
-
- );
+ const FloatButton = ({ children, onClick, disabled, ...props }) => {
+ handlers.push(onClick);
+ return (
+
+ {children}
+
+ );
+ };
FloatButton.Group = FloatButtonGroup;
+ FloatButton.__handlers = handlers;
return {
FloatButton,
@@ -231,6 +236,15 @@ describe("FloatButtonSection", () => {
jest.useRealTimers();
});
+ it("should run cleanup returned by handleChangeTheme", () => {
+ const setIsModalOpen = jest.fn();
+ render(
);
+ const { FloatButton } = require("antd");
+ const cleanup = FloatButton.__handlers[6]();
+ expect(typeof cleanup).toBe("function");
+ cleanup();
+ });
+
it("should handle announcement button click", () => {
const setIsModalOpen = jest.fn();
const { getAllByTestId } = render(
diff --git a/src/__tests__/components/Header.test.jsx b/src/__tests__/components/Header.test.jsx
deleted file mode 100644
index 74db27b..0000000
--- a/src/__tests__/components/Header.test.jsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import React from "react";
-import { render, screen, fireEvent } from "@testing-library/react";
-import Header from "../../components/Header";
-
-describe("Header", () => {
- beforeEach(() => {
- global.window.open = jest.fn();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- it("should render without crashing", () => {
- render(
);
- });
-
- it("should render header container", () => {
- const { container } = render(
);
- const headerContainer = container.querySelector(".header-container");
- expect(headerContainer).toBeInTheDocument();
- });
-
- it("should render h1 element", () => {
- render(
);
- const h1 = screen.getByRole("heading", { level: 1 });
- expect(h1).toBeInTheDocument();
- });
-
- it("should render title text", () => {
- render(
);
- expect(screen.getByText("CS-Queue-Calendar")).toBeInTheDocument();
- });
-
- it("should have dashes on both sides", () => {
- const { container } = render(
);
- const dashes = container.querySelectorAll(".header-ICARUS");
- expect(dashes.length).toBeGreaterThan(0);
- });
-
- it("should have clickable dashes with correct class", () => {
- const { container } = render(
);
- const dashes = container.querySelectorAll(".header-ICARUS");
- expect(dashes.length).toBeGreaterThan(0);
- dashes.forEach((dash) => {
- expect(dash.classList.contains("header-ICARUS")).toBe(true);
- });
- });
-
- it("should render header structure correctly", () => {
- const { container } = render(
);
- const header = container.querySelector(".header-container");
- expect(header).toBeInTheDocument();
- expect(header.querySelector("h1")).toBeInTheDocument();
- });
-
- it("should render title with correct styling", () => {
- const { container } = render(
);
- const title = container.querySelector("h1");
- expect(title).toHaveTextContent("CS-Queue-Calendar");
- });
-
- it("should call window.open when first dash is clicked", () => {
- const { container } = render(
);
- const dashes = container.querySelectorAll(".header-ICARUS");
- fireEvent.click(dashes[0]);
- expect(window.open).toHaveBeenCalledTimes(1);
- expect(window.open).toHaveBeenCalledWith(expect.any(String), "_blank");
- });
-
- it("should call window.open when second dash is clicked", () => {
- const { container } = render(
);
- const dashes = container.querySelectorAll(".header-ICARUS");
- fireEvent.click(dashes[1]);
- expect(window.open).toHaveBeenCalledTimes(1);
- expect(window.open).toHaveBeenCalledWith(expect.any(String), "_blank");
- });
-
- it("should not call window.open twice on same dash click", () => {
- const { container } = render(
);
- const dashes = container.querySelectorAll(".header-ICARUS");
- fireEvent.click(dashes[0]);
- fireEvent.click(dashes[0]);
- expect(window.open).toHaveBeenCalledTimes(1);
- });
-
- it("should update EEClicked state after click", () => {
- const { container } = render(
);
- const dashes = container.querySelectorAll(".header-ICARUS");
- expect(dashes[0].classList.contains("header-ICARUS")).toBe(true);
- fireEvent.click(dashes[0]);
- expect(window.open).toHaveBeenCalled();
- });
-
- it("should handle both dashes independently", () => {
- const { container } = render(
);
- const dashes = container.querySelectorAll(".header-ICARUS");
- fireEvent.click(dashes[0]);
- fireEvent.click(dashes[1]);
- expect(window.open).toHaveBeenCalledTimes(2);
- });
-});
diff --git a/src/__tests__/components/Toastify.test.jsx b/src/__tests__/components/Toastify.test.jsx
index 8a6c7d2..9e42079 100644
--- a/src/__tests__/components/Toastify.test.jsx
+++ b/src/__tests__/components/Toastify.test.jsx
@@ -80,4 +80,26 @@ describe("Toastify", () => {
const { container } = render(
);
expect(container.firstChild).toBeInTheDocument();
});
+
+ it("should call toast with provided mode and theme", () => {
+ const { toast } = require("react-toastify");
+ const toastObj = { title: "Hello", mode: "success" };
+ render(
);
+ expect(toast.success).toHaveBeenCalledWith(
+ "Hello",
+ expect.objectContaining({
+ theme: expect.any(Object),
+ autoClose: 4000,
+ })
+ );
+ });
+
+ it("should log an error for invalid toast mode", () => {
+ const errorSpy = jest
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ render(
);
+ expect(errorSpy).toHaveBeenCalledWith("Invalid toast mode: unknown");
+ errorSpy.mockRestore();
+ });
});
diff --git a/src/__tests__/components/useIsMobile.test.jsx b/src/__tests__/components/useIsMobile.test.jsx
deleted file mode 100644
index 8e27d4d..0000000
--- a/src/__tests__/components/useIsMobile.test.jsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { renderHook, act } from "@testing-library/react";
-import { useIsMobile } from "../../components/useIsMobile";
-
-describe("useIsMobile", () => {
- beforeEach(() => {
- // Reset window size to desktop
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 1024,
- });
- });
-
- it("should return a boolean", () => {
- const { result } = renderHook(() => useIsMobile());
- expect(typeof result.current).toBe("boolean");
- });
-
- it("should return false for desktop viewport", () => {
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 1024,
- });
- const { result } = renderHook(() => useIsMobile());
- expect(result.current).toBe(false);
- });
-
- it("should return true for mobile viewport", () => {
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 500,
- });
- const { result } = renderHook(() => useIsMobile());
- expect(result.current).toBe(true);
- });
-
- it("should use default breakpoint of 768", () => {
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 768,
- });
- const { result } = renderHook(() => useIsMobile());
- expect(result.current).toBe(true);
- });
-
- it("should accept custom breakpoint", () => {
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 900,
- });
- const { result } = renderHook(() => useIsMobile(1000));
- expect(result.current).toBe(true);
- });
-
- it("should update on window resize", () => {
- const { result, rerender } = renderHook(() => useIsMobile());
-
- act(() => {
- window.innerWidth = 500;
- window.dispatchEvent(new Event("resize"));
- });
-
- rerender();
- expect(result.current).toBe(true);
- });
-
- it("should cleanup event listener on unmount", () => {
- const removeEventListenerSpy = jest.spyOn(
- window,
- "removeEventListener"
- );
- const { unmount } = renderHook(() => useIsMobile());
- unmount();
-
- expect(removeEventListenerSpy).toHaveBeenCalledWith(
- "resize",
- expect.any(Function)
- );
- removeEventListenerSpy.mockRestore();
- });
-
- it("should handle edge case at breakpoint", () => {
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 769,
- });
- const { result } = renderHook(() => useIsMobile());
- expect(result.current).toBe(false);
- });
-
- it("should handle very small viewport", () => {
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 320,
- });
- const { result } = renderHook(() => useIsMobile());
- expect(result.current).toBe(true);
- });
-
- it("should handle very large viewport", () => {
- Object.defineProperty(window, "innerWidth", {
- writable: true,
- configurable: true,
- value: 2560,
- });
- const { result } = renderHook(() => useIsMobile());
- expect(result.current).toBe(false);
- });
-});
diff --git a/src/__tests__/constants/events.test.js b/src/__tests__/constants/events.test.js
index bad0750..2dd2f3a 100644
--- a/src/__tests__/constants/events.test.js
+++ b/src/__tests__/constants/events.test.js
@@ -21,10 +21,10 @@ describe("events constants", () => {
});
it("should have correct event titles", () => {
- expect(events[0].title).toBe("جلسه مرحله سوم");
- expect(events[1].title).toBe("جلسه مرحله دوم");
- expect(events[2].title).toBe("جلسه مصاحبه");
- expect(events[3].title).toBe("جلسه مرحله اول");
+ expect(events[0].title).toBe("مرحله سوم");
+ expect(events[1].title).toBe("مرحله دوم");
+ expect(events[2].title).toBe("مصاحبه");
+ expect(events[3].title).toBe("مرحله اول");
});
it("each event should have non-empty title and fullName", () => {
@@ -42,16 +42,10 @@ describe("events constants", () => {
});
});
- it("should contain specific event details", () => {
- const thirdEvent = events.find((e) => e.title === "جلسه مرحله سوم");
- expect(thirdEvent).toBeDefined();
- expect(thirdEvent.fullName).toContain("مرحله سوم");
- });
-
it("should be ordered correctly", () => {
- expect(events[0].title).toBe("جلسه مرحله سوم");
- expect(events[1].title).toBe("جلسه مرحله دوم");
- expect(events[2].title).toBe("جلسه مصاحبه");
- expect(events[3].title).toBe("جلسه مرحله اول");
+ expect(events[0].title).toBe("مرحله سوم");
+ expect(events[1].title).toBe("مرحله دوم");
+ expect(events[2].title).toBe("مصاحبه");
+ expect(events[3].title).toBe("مرحله اول");
});
});
diff --git a/src/__tests__/index.test.jsx b/src/__tests__/index.test.jsx
index a8bdeee..243e955 100644
--- a/src/__tests__/index.test.jsx
+++ b/src/__tests__/index.test.jsx
@@ -1,30 +1,75 @@
import React from "react";
-import ReactDOM from "react-dom/client";
+import { act } from "react-dom/test-utils";
-describe("Index Entry Point", () => {
- it("should have ReactDOM as dependency", () => {
- expect(React).toBeDefined();
- expect(ReactDOM).toBeDefined();
- expect(ReactDOM.createRoot).toBeDefined();
- });
+const mockRender = jest.fn();
- it("should define entry point", () => {
- // The index.jsx file is the entry point of the application
- expect(true).toBe(true);
- });
+jest.mock("react-dom/client", () => ({
+ createRoot: jest.fn(() => ({ render: mockRender })),
+}));
+
+jest.mock("../App", () => {
+ const MockApp = () =>
;
+ MockApp.displayName = "App";
+ return MockApp;
+});
+
+jest.mock("../store/StoreProvider", () => {
+ const React = require("react");
+ function MockStoreProvider({ children }) {
+ return
{children}
;
+ }
+ MockStoreProvider.displayName = "StoreProvider";
+ return {
+ __esModule: true,
+ default: MockStoreProvider,
+ };
+});
- it("should render StoreProvider", () => {
- // Verify the StoreProvider wraps the App component
- expect(true).toBe(true);
+const mockPrintEE = jest.fn();
+jest.mock("../utils/printEE", () => ({ printEE: mockPrintEE }));
+
+jest.mock("../assets/scss/index.scss", () => ({}), { virtual: true });
+
+describe("index.jsx entry point", () => {
+ beforeEach(() => {
+ document.body.innerHTML = '
';
+ const { createRoot } = require("react-dom/client");
+ createRoot.mockReturnValue({ render: mockRender });
+ createRoot.mockClear();
+ mockRender.mockClear();
+ mockPrintEE.mockClear();
});
- it("should apply global styles", () => {
- // Verify SCSS imports are present
- expect(true).toBe(true);
+ it("initializes the React root, renders the app tree, and calls printEE", async () => {
+ await act(async () => {
+ jest.isolateModules(() => {
+ require("../index.jsx");
+ });
+ });
+
+ const { createRoot } = require("react-dom/client");
+
+ expect(createRoot).toHaveBeenCalledWith(
+ document.getElementById("root")
+ );
+ expect(mockRender).toHaveBeenCalled();
+ const renderedTree = mockRender.mock.calls[0][0];
+ expect(renderedTree.type).toBe(React.StrictMode);
+ expect(mockPrintEE).toHaveBeenCalledWith("Aloha");
});
- it("should render React.StrictMode", () => {
- // Verify strict mode for development checks
- expect(true).toBe(true);
+ it("wraps the App component with StoreProvider", async () => {
+ await act(async () => {
+ jest.isolateModules(() => {
+ require("../index.jsx");
+ });
+ });
+
+ const renderedTree = mockRender.mock.calls[0][0];
+ const strictChildren = renderedTree.props.children;
+ expect(strictChildren.type.displayName).toBe("StoreProvider");
+ const appElement = strictChildren.props.children;
+ const appType = appElement.type.displayName || appElement.type.name;
+ expect(appType).toBe("App");
});
});
diff --git a/src/__tests__/utils/createTds.test.js b/src/__tests__/utils/createTds.test.js
deleted file mode 100644
index 1b5ff04..0000000
--- a/src/__tests__/utils/createTds.test.js
+++ /dev/null
@@ -1,94 +0,0 @@
-import { createTds } from "../../utils/createTds";
-
-describe("createTds", () => {
- let originalQuerySelectorAll;
-
- beforeEach(() => {
- // Mock document.querySelectorAll
- originalQuerySelectorAll = document.querySelectorAll;
- document.querySelectorAll = jest.fn();
- });
-
- afterEach(() => {
- // Restore original querySelectorAll
- document.querySelectorAll = originalQuerySelectorAll;
- });
-
- it("should be defined", () => {
- expect(createTds).toBeDefined();
- });
-
- it("should be a function", () => {
- expect(typeof createTds).toBe("function");
- });
-
- it('should call document.querySelectorAll with "td"', () => {
- document.querySelectorAll.mockReturnValue([]);
- createTds();
- expect(document.querySelectorAll).toHaveBeenCalledWith("td");
- });
-
- it("should handle empty td elements", () => {
- document.querySelectorAll.mockReturnValue([]);
- expect(() => createTds()).not.toThrow();
- });
-
- it("should process td elements with title attribute", () => {
- const mockTd = {
- title: "2024-12-13\nSome text",
- };
- document.querySelectorAll.mockReturnValue([mockTd]);
- createTds();
-
- // Should keep the gregorian date and add persian date
- expect(mockTd.title).toContain("2024-12-13");
- expect(mockTd.title).toContain("\n");
- });
-
- it("should handle multiple td elements", () => {
- const mockTd1 = {
- title: "2024-12-13\nOld text",
- };
- const mockTd2 = {
- title: "2024-12-14\nOld text",
- };
- document.querySelectorAll.mockReturnValue([mockTd1, mockTd2]);
- createTds();
-
- expect(mockTd1.title).toContain("2024-12-13");
- expect(mockTd2.title).toContain("2024-12-14");
- expect(mockTd1.title).toContain("\n");
- expect(mockTd2.title).toContain("\n");
- });
-
- it("should handle td with title containing multiple newlines", () => {
- const mockTd = {
- title: "2024-12-13\nLine 2\nLine 3",
- };
- document.querySelectorAll.mockReturnValue([mockTd]);
- createTds();
-
- // Should extract only the first part and add persian date
- expect(mockTd.title).toContain("2024-12-13");
- expect(mockTd.title).toContain("\n");
- });
-
- it("should handle td without newlines in title", () => {
- const mockTd = {
- title: "2024-12-13",
- };
- document.querySelectorAll.mockReturnValue([mockTd]);
- createTds();
-
- expect(mockTd.title).toContain("2024-12-13");
- });
-
- it("should call querySelectorAll for processing", () => {
- document.querySelectorAll.mockReturnValue([]);
- createTds();
- expect(document.querySelectorAll).toHaveBeenCalledWith("td");
- expect(
- document.querySelectorAll.mock.calls.length
- ).toBeGreaterThanOrEqual(1);
- });
-});
diff --git a/src/__tests__/utils/printEE.test.js b/src/__tests__/utils/printEE.test.js
new file mode 100644
index 0000000..d734b6f
--- /dev/null
+++ b/src/__tests__/utils/printEE.test.js
@@ -0,0 +1,54 @@
+import CryptoJS from "crypto-js";
+import { printEE } from "../../utils/printEE";
+
+describe("printEE", () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("logs decrypted message when a valid key is provided", () => {
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
+ const warnSpy = jest
+ .spyOn(console, "warn")
+ .mockImplementation(() => {});
+
+ printEE("Aloha");
+
+ expect(logSpy).toHaveBeenCalled();
+ expect(
+ (logSpy.mock.calls[0][0] || "").toString().length
+ ).toBeGreaterThan(0);
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it("warns when decryption yields an empty string", () => {
+ jest.spyOn(CryptoJS.AES, "decrypt").mockReturnValue({
+ toString: () => "",
+ });
+ const warnSpy = jest
+ .spyOn(console, "warn")
+ .mockImplementation(() => {});
+
+ printEE("bad-key");
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ "Console message decryption failed."
+ );
+ });
+
+ it("logs an error when decryption throws", () => {
+ jest.spyOn(CryptoJS.AES, "decrypt").mockImplementation(() => {
+ throw new Error("boom");
+ });
+ const errorSpy = jest
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+
+ printEE("any");
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ "Error decrypting console message:",
+ expect.any(Error)
+ );
+ });
+});
diff --git a/src/assets/scss/components/_all.scss b/src/assets/scss/components/_all.scss
index 424b0fe..40eada9 100644
--- a/src/assets/scss/components/_all.scss
+++ b/src/assets/scss/components/_all.scss
@@ -3,6 +3,8 @@
@use "header";
@use "footer";
@use "toastify";
+@use "event-popup";
@use "cs-calendar";
+@use "calendar-intro";
@use "announcement-module";
@use "float-button-section";
diff --git a/src/assets/scss/components/_calendar-intro.scss b/src/assets/scss/components/_calendar-intro.scss
new file mode 100644
index 0000000..ebec259
--- /dev/null
+++ b/src/assets/scss/components/_calendar-intro.scss
@@ -0,0 +1,60 @@
+.calendar-intro {
+ background: var(--card-color);
+ padding: 8px 16px;
+ box-shadow: none !important;
+ border-radius: 0 !important;
+ border: none !important;
+}
+
+.ant-card-body {
+ padding: 18px 24px 0 !important;
+}
+
+.calendar-intro__icon {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(22, 119, 255, 0.12);
+ color: #1677ff;
+ margin-top: 2px;
+}
+
+.calendar-intro__title {
+ margin-bottom: 8px !important;
+ font-weight: 600;
+ font-size: 24px !important;
+ line-height: 1.4;
+}
+
+.calendar-intro__paragraph {
+ margin-bottom: 0;
+ font-size: 14px !important;
+ line-height: 1.6;
+ color: rgba(0, 0, 0, 0.75);
+ margin: 0 !important;
+ color: var(--text-color);
+}
+
+.calendar-intro__content {
+ gap: 8px;
+}
+
+.calendar-intro__list {
+ margin: 0;
+ padding-inline-start: 16px;
+ font-size: 12.5px;
+ line-height: 1.5;
+}
+
+.calendar-intro__list li {
+ margin-bottom: 0;
+}
+
+.calendar-intro__hint {
+ font-size: 11.5px;
+ margin-top: 2px;
+}
diff --git a/src/assets/scss/components/_cs-calendar.scss b/src/assets/scss/components/_cs-calendar.scss
index 89dfdf9..5a2efda 100644
--- a/src/assets/scss/components/_cs-calendar.scss
+++ b/src/assets/scss/components/_cs-calendar.scss
@@ -1,5 +1,6 @@
.calendar-section {
- height: calc(100vh - 128px);
+ height: calc(100vh - 56px);
+ gap: 12px;
}
.ant-badge.ant-badge-status {
@@ -12,8 +13,8 @@
}
.ant-picker-date-panel {
- max-height: calc(100vh - 268px) !important;
- max-height: calc(100svh - 268px) !important;
+ max-height: calc(100vh - 340px) !important;
+ max-height: calc(100svh - 340px) !important;
overflow-y: auto !important;
}
@@ -21,7 +22,7 @@ thead {
position: sticky !important;
top: 0px;
z-index: 100;
- background-color: var(--bg-color);
+ background-color: var(--main-color) !important;
th {
line-height: 22px !important;
@@ -44,7 +45,7 @@ thead {
}
.ant-picker-calendar {
- margin: 0 60px;
+ margin: 0 60px 30px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--calendar-border-color);
@@ -57,6 +58,86 @@ thead {
height: 50px;
}
+.calendar-cell-with-event {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 48px;
+ cursor: pointer;
+}
+
+.ant-picker-calendar-date-today {
+ position: absolute;
+ border-radius: 8px;
+ background: linear-gradient(
+ 180deg,
+ rgba(47, 111, 237, 0.05),
+ rgba(47, 111, 237, 0.03)
+ ) !important;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ z-index: 0;
+ width: 100%;
+ top: 2px;
+ height: calc(100% - 8px);
+}
+
+.today-badge__label {
+ font-size: 12px;
+ font-weight: 700;
+ color: rgba(47, 111, 237, 0.95);
+ background: rgba(255, 255, 255, 0.85);
+ padding: 2px 8px;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgba(16, 24, 40, 0.06);
+ pointer-events: none;
+}
+
+.stage-tag {
+ padding: 3px 8px;
+ border-radius: 6px;
+ font-size: 12px;
+ // height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ position: absolute;
+ top: 28px;
+ width: 100%;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ color: #fff;
+ letter-spacing: 0.3px;
+
+ .main-word {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ text-align: center !important;
+ }
+}
+
+.date-label {
+ background: transparent;
+ border: transparent;
+ color: var(--text-color);
+ padding: 0 6px;
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ margin-inline-end: 0;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ant-picker-calendar .ant-picker-calendar-date-value {
+ display: none;
+}
+
.ant-select {
width: 80px;
}
@@ -72,3 +153,21 @@ thead {
justify-content: center;
}
}
+
+.ant-picker-cell:not(.ant-picker-cell-in-view) {
+ .stage-tag {
+ background-color: #5d6ef5 !important;
+ }
+}
+
+.tooltip-style {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 2px 4px;
+
+ div {
+ font-size: 13px;
+ line-height: 19px;
+ }
+}
diff --git a/src/assets/scss/components/_event-popup.scss b/src/assets/scss/components/_event-popup.scss
new file mode 100644
index 0000000..136db2a
--- /dev/null
+++ b/src/assets/scss/components/_event-popup.scss
@@ -0,0 +1,309 @@
+.event-popup {
+ z-index: 9999;
+ position: absolute;
+ box-sizing: border-box;
+ transition:
+ transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
+ opacity 200ms cubic-bezier(0.16, 1, 0.3, 1) !important;
+ transform-origin: top center;
+ will-change: transform, opacity;
+
+ &.is-mounted {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+
+ &.is-closing {
+ opacity: 0;
+ transform: translateY(12px) scale(0.98);
+ transition:
+ transform 280ms cubic-bezier(0.22, 0.9, 0.28, 1),
+ opacity 240ms ease !important;
+ }
+
+ .event-popup__arrow {
+ position: absolute;
+ width: 24px;
+ height: 16px;
+ left: calc(50% - 12px);
+ top: -8px;
+ pointer-events: none;
+ transform-origin: center;
+ opacity: 0;
+
+ &::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 0;
+ height: 0;
+ border-left: 12px solid transparent;
+ border-right: 12px solid transparent;
+ border-bottom: 12px solid rgba(0, 0, 0, 0.06);
+ filter: drop-shadow(0 8px 16px rgba(16, 24, 40, 0.06));
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ left: 1px;
+ top: 1px;
+ width: 0;
+ height: 0;
+ border-left: 11px solid transparent;
+ border-right: 11px solid transparent;
+ border-bottom: 11px solid var(--bg-color-alt);
+ }
+
+ transform: translateY(8px);
+ transition:
+ transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
+ opacity 180ms ease !important;
+ }
+
+ &.is-mounted .event-popup__arrow {
+ opacity: 1;
+ transform: translateY(0);
+ transition:
+ transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
+ opacity 180ms ease !important;
+ }
+
+ &.is-closing .event-popup__arrow {
+ transform: translateY(14px);
+ opacity: 0;
+ transition:
+ transform 240ms cubic-bezier(0.22, 0.9, 0.28, 1),
+ opacity 200ms ease !important;
+ }
+
+ &.event-popup--above {
+ .event-popup__arrow {
+ top: auto;
+ bottom: -8px;
+ transform-origin: bottom center;
+
+ &::before {
+ border-bottom: 0;
+ border-top: 12px solid rgba(0, 0, 0, 0.06);
+ }
+
+ &::after {
+ border-bottom: 0;
+ border-top: 11px solid var(--bg-color-alt);
+ }
+ }
+ }
+
+ .event-popup__content {
+ background: linear-gradient(
+ 180deg,
+ var(--bg-color-alt),
+ var(--bg-color)
+ );
+ border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(16, 24, 40, 0.12);
+ padding: 18px 20px;
+ color: var(--text-color);
+ font-size: 13px;
+ font-family: inherit;
+ line-height: 1.55;
+ letter-spacing: 0.01em;
+ direction: rtl;
+ overflow: hidden;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ transform-origin: inherit;
+ transform: translateY(8px) scale(0.994);
+ opacity: 0;
+ transition:
+ transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
+ opacity 200ms cubic-bezier(0.16, 1, 0.3, 1),
+ box-shadow 220ms ease !important;
+
+ &.is-open {
+ transform: translateY(0) scale(1);
+ opacity: 1;
+ box-shadow: 0 18px 48px rgba(16, 24, 40, 0.14);
+ }
+
+ &:not(.is-open) {
+ transform: translateY(6px) scale(0.985);
+ opacity: 0;
+ transition:
+ transform 280ms cubic-bezier(0.22, 0.9, 0.28, 1),
+ opacity 240ms ease !important;
+ }
+ }
+
+ .event-popup__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ .event-popup__subtitle {
+ font-size: 12px;
+ font-weight: 500;
+ opacity: 0.8;
+ margin-top: 4px;
+ color: rgba(0, 0, 0, 0.62);
+ line-height: 1.2;
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .event-popup__header-left {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .event-popup__color-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ box-shadow: 0 4px 8px rgba(16, 24, 40, 0.08);
+ flex: 0 0 14px;
+ }
+
+ .event-popup__header-title {
+ font-weight: 700;
+ font-size: 14px;
+ letter-spacing: 0.01em;
+ line-height: 1.1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .event-popup__close-btn {
+ border: none;
+ background: transparent;
+ font-size: 20px;
+ line-height: 1;
+ padding: 6px;
+ cursor: pointer;
+ color: rgba(0, 0, 0, 0.45);
+ border-radius: 6px;
+ transition:
+ background 120ms ease,
+ color 120ms ease,
+ transform 120ms ease;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ }
+
+ .event-popup__close-btn:hover {
+ background: rgba(0, 0, 0, 0.045);
+ color: rgba(0, 0, 0, 0.85);
+ transform: translateY(-2px) scale(1.02);
+ }
+
+ .event-popup__close-btn:focus {
+ box-shadow: 0 0 0 4px rgba(47, 111, 237, 0.12);
+ outline: none;
+ }
+
+ .event-popup__row {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 12px;
+ align-items: flex-start;
+ line-height: 1.5;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ .event-popup__label {
+ min-width: 96px;
+ // color: ; // HERE
+ font-weight: 700;
+ font-size: 12.5px;
+ line-height: 1.45;
+ text-align: right;
+ letter-spacing: 0.2px;
+ flex: 0 0 auto;
+ padding-top: 2px;
+ }
+
+ .event-popup__value {
+ flex: 1 1 auto;
+ word-break: break-word;
+ color: rgba(0, 0, 0, 0.86);
+ color: var(--text-color);
+ letter-spacing: 0.01em;
+ }
+
+ .event-popup__link {
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .event-popup__footer {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 14px;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .event-popup__meta {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ color: rgba(0, 0, 0, 0.65);
+ }
+
+ .event-popup__label.small {
+ min-width: 60px;
+ font-weight: 600;
+ font-size: 12px;
+ color: rgba(0, 0, 0, 0.55);
+ }
+
+ .event-popup__actions {
+ display: flex;
+ gap: 10px;
+ }
+
+ .event-popup__creator {
+ margin-top: 14px;
+ display: flex;
+ justify-content: center;
+ }
+
+ @media (max-width: 420px) {
+ & {
+ width: calc(100vw - 32px) !important;
+ left: 16px !important;
+ }
+
+ .event-popup__content {
+ padding: 12px;
+ }
+
+ .event-popup__header-title,
+ .event-popup__subtitle {
+ max-width: calc(100vw - 140px);
+ }
+ }
+}
+
+.calendar-event-creator .ant-btn {
+ padding: 0 30px;
+ transition:
+ background-color 0.25s,
+ color 0.25s,
+ border-color 0.25s !important;
+}
diff --git a/src/assets/scss/components/_media.scss b/src/assets/scss/components/_media.scss
index 633c003..63aae93 100644
--- a/src/assets/scss/components/_media.scss
+++ b/src/assets/scss/components/_media.scss
@@ -1,15 +1,44 @@
+@media (max-width: 950px) {
+ .meeting-word {
+ display: none !important;
+ }
+
+ .main-word {
+ white-space: pre-wrap !important;
+ }
+}
+
@media (max-width: 768px) {
body {
- overflow-y: auto !important;
- min-width: 400px !important;
+ min-width: 320px !important;
+ }
+
+ .ant-card.calendar-intro {
+ padding: 2px 16px 2px 16px !important;
+
+ .ant-card-body {
+ padding: 0 !important;
+ }
+
+ .calendar-intro__icon {
+ display: none !important;
+ }
+ }
+
+ .ant-space-item .calendar-intro__title {
+ margin-bottom: 0 !important;
+ }
+
+ .calendar-intro__content {
+ gap: 6px !important;
}
.calendar-section {
- height: unset !important;
+ height: calc(100vh - 48px) !important;
}
.ant-picker-calendar {
- margin: 0 10px !important;
+ margin: 0 10px 10px !important;
}
.ant-picker-calendar-date {
@@ -33,9 +62,9 @@
}
}
- .ant-picker-panel {
- height: calc(100vh - 232px);
- }
+ // .ant-picker-panel {
+ // height: calc(100vh - 232px);
+ // }
.ant-alert {
margin: 10px 10px !important;
@@ -47,12 +76,12 @@
}
.ant-float-btn-group.ant-float-btn-group-circle {
- bottom: 63px !important;
+ bottom: 16px !important;
left: 4px !important;
}
.fancy-theme-transition {
- bottom: 63px !important;
+ bottom: 16px !important;
left: 15px !important;
}
@@ -94,6 +123,16 @@
font-size: 12px !important;
}
+ .ant-space-item div.ant-typography,
+ a.ant-typography {
+ font-size: 13px !important;
+ // text-align: justify !important;
+ }
+
+ .ant-space-item h4.ant-typography {
+ font-size: 22px !important;
+ }
+
.modal-title {
font-size: 14px !important;
}
diff --git a/src/assets/scss/components/_theme.scss b/src/assets/scss/components/_theme.scss
index e4742a9..a13cfe1 100644
--- a/src/assets/scss/components/_theme.scss
+++ b/src/assets/scss/components/_theme.scss
@@ -4,19 +4,26 @@
}
[data-theme="light"] {
- --bg-color: white;
+ --main-color: white;
+ --bg-color: #f7f9fb;
--text-color: black;
--footer-bg-color: #dce6f4;
--thumb-bg-color: #cacaca;
--calendar-border-color: #afafaf;
+ --card-color: #f7f9fb;
+ --bg-color-alt: #ffffff;
}
[data-theme="dark"] {
+ --main-color: #141414;
+ --main-color-alt: #141414;
--bg-color: #141414;
--text-color: white;
--footer-bg-color: #111;
--thumb-bg-color: #7a7a7a;
--calendar-border-color: #5a5a5a;
+ --card-color: #141414;
+ --bg-color-alt: #1e1e1e;
}
.fancy-theme-transition {
diff --git a/src/assets/scss/index.scss b/src/assets/scss/index.scss
index 6ba138e..3d9cb8a 100644
--- a/src/assets/scss/index.scss
+++ b/src/assets/scss/index.scss
@@ -18,10 +18,11 @@
body {
height: 100vh !important;
height: 100svh !important;
- overflow-y: hidden;
+ // overflow-y: hidden; TODO: Handle this
min-width: 780px;
background-color: var(--bg-color);
color: var(--text-color);
+ // user-select: none;
}
::-webkit-scrollbar {
diff --git a/src/components/CSCalendar.jsx b/src/components/CSCalendar.jsx
index 7047acc..3c7519e 100644
--- a/src/components/CSCalendar.jsx
+++ b/src/components/CSCalendar.jsx
@@ -1,24 +1,16 @@
-import React, { useEffect, useState } from "react";
-import {
- Alert,
- Calendar,
- Button,
- Badge,
- Select,
- ConfigProvider,
- Flex,
-} from "antd";
+import React, { useContext, useEffect, useState } from "react";
+import { Calendar, Button, Select, Tag, Tooltip, Flex } from "antd";
import dayjs from "dayjs";
import "dayjs/locale/fa";
import weekday from "dayjs/plugin/weekday";
import localeData from "dayjs/plugin/localeData";
import moment from "jalali-moment";
-import { createTds } from "../utils/createTds";
+import EventPopup from "./EventPopup";
+import CalendarIntro from "./CalendarIntro";
import { events } from "../constants/events";
import { startCalendarDate } from "../constants/startCalendarDate";
import { persianWeekDays } from "../constants/persianWeekDays";
-import { useIsMobile } from "./useIsMobile";
-import CalendarEventCreator from "./CalendarEventCreator";
+import { ThemeContext } from "../store/Theme/ThemeContext";
moment.locale("fa");
dayjs.locale("fa");
@@ -29,10 +21,16 @@ const CSCalendar = ({ setAnnouncementData, addToCurrentWeek }) => {
const today = dayjs();
const [value, setValue] = useState(today);
- const [eventDescription, setEventDescription] = useState("");
+ const [width, setWidth] = useState(window.innerWidth);
const [yearMonth, setYearMonth] = useState("");
+ const [popupData, setPopupData] = useState({
+ visible: false,
+ event: null,
+ date: null,
+ rect: null,
+ });
- const isMobile = useIsMobile();
+ const { theme } = useContext(ThemeContext);
const getEventForDate = (date) => {
const startDate = dayjs(startCalendarDate);
@@ -54,52 +52,183 @@ const CSCalendar = ({ setAnnouncementData, addToCurrentWeek }) => {
const onSelect = (newValue) => {
setValue(newValue);
- const event = getEventForDate(newValue);
-
- if (event) {
- setEventDescription(
- isMobile
- ? `${event.title} - ساعت ۱۸:۰۰ تا ۱۹:۰۰`
- : `${event.fullName} - ساعت ۱۸:۰۰ تا ۱۹:۰۰`
- );
- } else {
- setEventDescription("برای این تاریخ رویدادی وجود ندارد.");
- }
- };
-
- const onPanelChange = (newValue) => {
- setValue(newValue);
};
const handleMonthYearChange = (month, year) => {
setValue(value.month(month).year(year));
};
+ useEffect(() => {
+ const handleResize = () => {
+ setWidth(window.innerWidth);
+ };
+
+ window.addEventListener("resize", handleResize);
+
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ };
+ }, []);
+
const dateCellRender = (date) => {
const event = getEventForDate(date);
- return event ? (
-
- ) : null;
+
+ const handleOpen = (e) => {
+ e.stopPropagation();
+
+ setValue(date);
+
+ if (!event) {
+ setPopupData({
+ visible: false,
+ event: null,
+ date: null,
+ rect: null,
+ });
+ return;
+ }
+ const rect = e.currentTarget.getBoundingClientRect();
+
+ if (
+ popupData.visible &&
+ popupData.date &&
+ date.isSame(popupData.date, "day")
+ ) {
+ setPopupData({
+ visible: false,
+ event: null,
+ date: null,
+ rect: null,
+ });
+ return;
+ }
+
+ setValue(date);
+ setPopupData({ visible: true, event, date, rect });
+ };
+
+ const greg = date.format("YYYY-MM-DD");
+ const persianDate = moment(date.toDate())
+ .locale("fa")
+ .format("jYYYY/jMM/jDD");
+
+ const tooltipTitle = (
+
+
{persianDate}
+
{greg}
+
+ );
+
+ return (
+
+ e.key === "Enter" && handleOpen(e)}
+ className="calendar-cell-with-event"
+ >
+ {date.date()}
+
+ {event && (
+
+
+ {width < 950 ? event.shortTitle : event.title}
+ {" "}
+ جلسه
+
+ )}
+
+
+ );
};
useEffect(() => {
- return () => {
- setTimeout(() => {
- createTds();
- }, 0);
+ const calendarRoot = document.querySelector(".ant-picker-calendar");
+
+ const delegator = (e) => {
+ if (e.target.closest && e.target.closest(".event-popup")) {
+ return;
+ }
+
+ const td = e.target.closest && e.target.closest(".ant-picker-cell");
+ if (!td) {
+ return;
+ }
+
+ const wrapper = td.querySelector(
+ ".calendar-cell-with-event[data-date]"
+ );
+ if (!wrapper) {
+ return;
+ }
+
+ /* istanbul ignore next */
+ if (wrapper.contains(e.target)) {
+ return;
+ }
+
+ const dateStr = wrapper.getAttribute("data-date");
+ /* istanbul ignore next */
+ if (!dateStr) {
+ return;
+ }
+
+ const clickedDate = dayjs(dateStr);
+ const ev = getEventForDate(clickedDate);
+ if (!ev) {
+ return;
+ }
+
+ /* istanbul ignore next */
+ if (
+ popupData.visible &&
+ popupData.date &&
+ clickedDate.isSame(popupData.date, "day")
+ ) {
+ setPopupData({
+ visible: false,
+ event: null,
+ date: null,
+ rect: null,
+ });
+ return;
+ }
+
+ const rect = wrapper.getBoundingClientRect();
+ setValue(clickedDate);
+ setPopupData({ visible: true, event: ev, date: clickedDate, rect });
};
- }, [yearMonth]);
+
+ /* istanbul ignore next */
+ if (
+ calendarRoot &&
+ typeof calendarRoot.addEventListener === "function"
+ ) {
+ calendarRoot.addEventListener("click", delegator);
+ return () => calendarRoot.removeEventListener("click", delegator);
+ }
+ }, [yearMonth, popupData]);
useEffect(() => {
const saturdayDate = moment()
.add(addToCurrentWeek, "day")
.startOf("week");
- // console.log("saturdayDate >>", saturdayDate);
-
const startWeekDate = saturdayDate
.clone()
.add(9, "day")
@@ -127,12 +256,17 @@ const CSCalendar = ({ setAnnouncementData, addToCurrentWeek }) => {
let secondEvent = "برای این تاریخ رویدادی وجود ندارد.";
if (saturdayDate.isAfter(startDate, "day")) {
- firstEvent = getEventForDate(
+ const fe = getEventForDate(
dayjs(saturdayDate.clone().add(10, "day").toDate())
- ).fullName;
- secondEvent = getEventForDate(
+ );
+ const se = getEventForDate(
dayjs(saturdayDate.clone().add(15, "day").toDate())
- ).fullName;
+ );
+
+ /* istanbul ignore next */
+ if (fe) firstEvent = fe.fullName || fe.title;
+ /* istanbul ignore next */
+ if (se) secondEvent = se.fullName || se.title;
}
const newAnnouncementData = {
@@ -144,8 +278,6 @@ const CSCalendar = ({ setAnnouncementData, addToCurrentWeek }) => {
secondEvent,
};
- // console.log("newAnnouncementData >>", newAnnouncementData);
-
setAnnouncementData((prev) => {
if (JSON.stringify(prev) !== JSON.stringify(newAnnouncementData)) {
return newAnnouncementData;
@@ -163,7 +295,11 @@ const CSCalendar = ({ setAnnouncementData, addToCurrentWeek }) => {
(item, index) => (item.textContent = persianWeekDays[index])
);
- document.querySelector(".today-btn").click();
+ const todayBtn = document.querySelector(".today-btn");
+ /* istanbul ignore next */
+ if (todayBtn && typeof todayBtn.click === "function") {
+ todayBtn.click();
+ }
}, []);
useEffect(() => {
@@ -173,12 +309,29 @@ const CSCalendar = ({ setAnnouncementData, addToCurrentWeek }) => {
setYearMonth(currentMonth.toString() + currentYear.toString());
}, [value]);
+ useEffect(() => {
+ const stripNativeTitles = () => {
+ document
+ .querySelectorAll(
+ ".ant-picker-cell[title], .ant-picker-cell-inner[title]"
+ )
+ .forEach((node) => node.removeAttribute("title"));
+ };
+
+ const raf = requestAnimationFrame(stripNativeTitles);
+ return () => cancelAnimationFrame(raf);
+ }, [value, yearMonth]);
+
return (
-
+
+
+
{
+ setValue(newValue);
+ }}
cellRender={dateCellRender}
headerRender={({ value, onChange }) => {
const currentMonth = value.month();
@@ -266,28 +419,20 @@ const CSCalendar = ({ setAnnouncementData, addToCurrentWeek }) => {
}}
/>
-
-
-
- {eventDescription}
-
-
- {eventDescription !==
- "برای این تاریخ رویدادی وجود ندارد." && (
-
- )}
-
- }
- type="info"
- showIcon
- />
-
+