diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx index 3a6f1eea4..187cee94f 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.test.tsx @@ -1,5 +1,9 @@ import { screen } from "@testing-library/react"; -import { createMockStandaloneEvent } from "@core/util/test/ccal.event.factory"; +import { + createMockBaseEvent, + createMockInstance, + createMockStandaloneEvent, +} from "@core/util/test/ccal.event.factory"; import { render } from "@web/__tests__/__mocks__/mock.render"; import { createInitialState } from "@web/__tests__/utils/state/store.test.util"; import { type Schema_GridEvent } from "@web/common/types/web.event.types"; @@ -234,4 +238,149 @@ describe("GridEvent", () => { // but here we're testing that the selector only checks event1's pending state expect(renderSpy).toHaveBeenCalledTimes(2); // Rerender was called, but memo should prevent actual re-render }); + + describe("repeat icon for recurring events", () => { + it("should render repeat icon for base recurring event with rule", () => { + const baseEvent = createMockBaseEvent({ + _id: "base-event-1", + title: "Weekly Meeting", + recurrence: { rule: ["RRULE:FREQ=WEEKLY"] }, + }); + + const event = createMockGridEvent(baseEvent); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.getByLabelText("Recurring event"); + expect(icon).toBeInTheDocument(); + expect(screen.getByText("Weekly Meeting")).toBeInTheDocument(); + }); + + it("should render repeat icon for recurring instance with eventId", () => { + const baseEventId = "base-123"; + const gBaseId = "gbase-123"; + const instance = createMockInstance(baseEventId, gBaseId, { + _id: "instance-1", + title: "Weekly Meeting Instance", + }); + + const event = createMockGridEvent(instance); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.getByLabelText("Recurring event"); + expect(icon).toBeInTheDocument(); + }); + + it("should not render repeat icon for non-recurring event", () => { + const event = createMockGridEvent({ + _id: "standalone-1", + title: "One-time Meeting", + recurrence: undefined, + }); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.queryByLabelText("Recurring event"); + expect(icon).not.toBeInTheDocument(); + expect(screen.getByText("One-time Meeting")).toBeInTheDocument(); + }); + + it("should not render repeat icon when recurrence is disabled (rule is null)", () => { + const event = createMockGridEvent({ + _id: "disabled-recurring-1", + title: "Disabled Recurring Meeting", + recurrence: { rule: null }, + }); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.queryByLabelText("Recurring event"); + expect(icon).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx index eb82e7c88..0d98f791e 100644 --- a/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx +++ b/packages/web/src/views/Calendar/components/Event/Grid/GridEvent/GridEvent.tsx @@ -23,6 +23,7 @@ import { FlexDirections, FlexWrap, } from "@web/components/Flex/styled"; +import { RepeatIcon } from "@web/components/Icons/Repeat"; import { Text } from "@web/components/Text"; import { selectIsEventPending } from "@web/ducks/events/selectors/pending.selectors"; import { useAppSelector } from "@web/store/store.hooks"; @@ -74,7 +75,10 @@ const _GridEvent = ( const isPending = useAppSelector((state) => event._id ? selectIsEventPending(state, event._id) : false, ); - const isRecurring = event.recurrence && event.recurrence?.eventId !== null; + const rule = event.recurrence?.rule; + const recurrenceEventId = event.recurrence?.eventId; + const isRecurring = + Array.isArray(rule) || typeof recurrenceEventId === "string"; const position = getEventPosition( event, @@ -140,6 +144,13 @@ const _GridEvent = ( flexWrap={FlexWrap.WRAP} > + {isRecurring && ( + + )} {event.title} {!event.isAllDay && ( diff --git a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.test.tsx b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.test.tsx new file mode 100644 index 000000000..c2a7ca3a3 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.test.tsx @@ -0,0 +1,193 @@ +import { screen } from "@testing-library/react"; +import { + createMockBaseEvent, + createMockInstance, + createMockStandaloneEvent, +} from "@core/util/test/ccal.event.factory"; +import { render } from "@web/__tests__/__mocks__/mock.render"; +import { createInitialState } from "@web/__tests__/utils/state/store.test.util"; +import { type Schema_GridEvent } from "@web/common/types/web.event.types"; +import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; +import { type Measurements_Grid } from "@web/views/Calendar/hooks/grid/useGridLayout"; +import { AllDayEventMemo } from "./AllDayEvent"; + +const createMockGridEvent = ( + overrides: Partial = {}, +): Schema_GridEvent => { + const standaloneEvent = createMockStandaloneEvent(); + return { + ...standaloneEvent, + position: gridEventDefaultPosition, + isAllDay: true, + ...overrides, + } as Schema_GridEvent; +}; + +const mockMeasurements: Measurements_Grid = { + mainGrid: { + top: 0, + left: 0, + height: 1000, + width: 1000, + x: 0, + y: 0, + bottom: 1000, + right: 1000, + toJSON: () => ({}), + }, + hourHeight: 60, + colWidths: Array(7).fill(100), + allDayRow: null, + remeasure: jest.fn(), +}; + +const mockOnMouseDown = jest.fn(); +const mockOnScalerMouseDown = jest.fn(); + +describe("AllDayEvent - Repeat Icon", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("recurring event rendering", () => { + it("should render repeat icon for base recurring event with rule", () => { + const baseEvent = createMockBaseEvent({ + _id: "base-123", + title: "Weekly Team Standup", + }); + + const event = createMockGridEvent(baseEvent); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.getByLabelText("Recurring event"); + expect(icon).toBeInTheDocument(); + expect(screen.getByText("Weekly Team Standup")).toBeInTheDocument(); + }); + + it("should render repeat icon for recurring instance with eventId", () => { + const baseEventId = "base-456"; + const gBaseId = "gbase-456"; + const instance = createMockInstance(baseEventId, gBaseId, { + _id: "instance-1", + title: "Weekly Team Standup Instance", + isAllDay: true, + }); + + const event = createMockGridEvent(instance); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + const icon = screen.getByLabelText("Recurring event"); + expect(icon).toBeInTheDocument(); + }); + + it("should not render repeat icon for non-recurring all-day event", () => { + const event = createMockGridEvent({ + _id: "standalone-1", + title: "Company Holiday", + recurrence: undefined, + }); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + expect( + screen.queryByLabelText("Recurring event"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Company Holiday")).toBeInTheDocument(); + }); + + it("should not render repeat icon when recurrence is explicitly disabled", () => { + const event = createMockGridEvent({ + _id: "standalone-2", + title: "One-time Event", + recurrence: { + rule: null, + eventId: undefined, + }, + }); + + const initialState = createInitialState({ + events: { + pendingEvents: { + eventIds: [], + }, + }, + }); + + render( + , + { state: initialState }, + ); + + expect( + screen.queryByLabelText("Recurring event"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx index f15c0c7b0..00fe692f4 100644 --- a/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx +++ b/packages/web/src/views/Calendar/components/Grid/AllDayRow/AllDayEvent.tsx @@ -9,6 +9,7 @@ import { type Schema_GridEvent } from "@web/common/types/web.event.types"; import { getEventPosition } from "@web/common/utils/position/position.util"; import { Flex } from "@web/components/Flex"; import { AlignItems, FlexDirections } from "@web/components/Flex/styled"; +import { RepeatIcon } from "@web/components/Icons/Repeat"; import { SpaceCharacter } from "@web/components/SpaceCharacter"; import { Text } from "@web/components/Text"; import { selectIsEventPending } from "@web/ducks/events/selectors/pending.selectors"; @@ -53,7 +54,10 @@ const AllDayEvent = ({ const isPending = useAppSelector((state) => selectIsEventPending(state, event._id!), ); - const isRecurring = event.recurrence && event.recurrence?.eventId !== null; + const rule = event.recurrence?.rule; + const recurrenceEventId = event.recurrence?.eventId; + const isRecurring = + Array.isArray(rule) || typeof recurrenceEventId === "string"; const styledEventProps = { [DATA_EVENT_ELEMENT_ID]: event._id, @@ -96,6 +100,13 @@ const AllDayEvent = ({ direction={FlexDirections.COLUMN} > + {isRecurring && ( + + )} {event.title} diff --git a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.test.tsx b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.test.tsx new file mode 100644 index 000000000..622352870 --- /dev/null +++ b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.test.tsx @@ -0,0 +1,170 @@ +import "@testing-library/jest-dom"; +import { screen } from "@testing-library/react"; +import { Categories_Event, type Schema_Event } from "@core/types/event.types"; +import { + createMockBaseEvent, + createMockInstance, + createMockStandaloneEvent, +} from "@core/util/test/ccal.event.factory"; +import { render } from "@web/__tests__/__mocks__/mock.render"; +import { type Props_DraftForm } from "@web/views/Calendar/components/Draft/context/DraftContext"; +import { SomedayEventRectangle } from "./SomedayEventRectangle"; + +const mockFormProps: Props_DraftForm = { + context: {} as any, + refs: { + setFloating: jest.fn(), + setReference: jest.fn(), + }, + strategy: "absolute" as const, + x: 0, + y: 0, + getReferenceProps: () => ({}), + getFloatingProps: () => ({}), +} as any; + +const mockOnMigrate = jest.fn(); + +describe("SomedayEventRectangle - Repeat Icon", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("recurring event rendering", () => { + it("should render repeat icon for base recurring event with rule", () => { + const baseEvent = createMockBaseEvent({ + _id: "base-789", + title: "Weekly Review", + }) as Schema_Event; + + render( + , + ); + + const icon = screen.getByLabelText("Recurring event"); + expect(icon).toBeInTheDocument(); + expect(screen.getByText("Weekly Review")).toBeInTheDocument(); + }); + + it("should render repeat icon for recurring instance with eventId", () => { + const baseEventId = "base-101"; + const gBaseId = "gbase-101"; + const instance = createMockInstance(baseEventId, gBaseId, { + _id: "instance-2", + title: "Weekly Review Instance", + }) as Schema_Event; + + render( + , + ); + + const icon = screen.getByLabelText("Recurring event"); + expect(icon).toBeInTheDocument(); + expect(screen.getByText("Weekly Review Instance")).toBeInTheDocument(); + }); + + it("should not render repeat icon for non-recurring someday event", () => { + const event = createMockStandaloneEvent({ + _id: "standalone-3", + title: "Read Book", + recurrence: undefined, + }) as Schema_Event; + + render( + , + ); + + expect( + screen.queryByLabelText("Recurring event"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Read Book")).toBeInTheDocument(); + }); + + it("should not render repeat icon when recurrence is explicitly disabled", () => { + const event = createMockStandaloneEvent({ + _id: "standalone-4", + title: "One-time Task", + recurrence: { + rule: null, + eventId: undefined, + }, + }) as Schema_Event; + + render( + , + ); + + expect( + screen.queryByLabelText("Recurring event"), + ).not.toBeInTheDocument(); + expect(screen.getByText("One-time Task")).toBeInTheDocument(); + }); + }); + + describe("migration controls", () => { + it("should show migration arrows for non-recurring events", () => { + const event = createMockStandaloneEvent({ + _id: "standalone-5", + title: "Regular Task", + recurrence: undefined, + }) as Schema_Event; + + render( + , + ); + + const arrows = screen.getAllByRole("button"); + expect(arrows).toHaveLength(2); + expect(arrows[0]).toHaveTextContent("<"); + expect(arrows[1]).toHaveTextContent(">"); + }); + + it("should show warning indicator for recurring events", () => { + const baseEvent = createMockBaseEvent({ + _id: "base-202", + title: "Recurring Task", + }) as Schema_Event; + + render( + , + ); + + const warning = screen.getByText("☝️"); + expect(warning).toBeInTheDocument(); + expect(warning).toHaveAttribute( + "title", + "Can't migrate recurring events", + ); + }); + }); +}); diff --git a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx index 15fd46b16..054ad94c3 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx +++ b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx @@ -5,10 +5,11 @@ import { FlexDirections, JustifyContent, } from "@web/components/Flex/styled"; +import { RepeatIcon } from "@web/components/Icons/Repeat"; import { Text } from "@web/components/Text"; import { type Props_DraftForm } from "@web/views/Calendar/components/Draft/context/DraftContext"; import { type Actions_Sidebar } from "@web/views/Calendar/components/Draft/sidebar/hooks/useSidebarActions"; -import { StyledMigrateArrow, StyledRecurrenceText } from "./styled"; +import { StyledMigrateArrow, StyledRecurringWarning } from "./styled"; interface Props { category: Categories_Event; @@ -24,8 +25,11 @@ export const SomedayEventRectangle = ({ onMigrate, }: Props) => { const target = category === Categories_Event.SOMEDAY_WEEK ? "week" : "month"; - const canMigrate = - !event.recurrence?.rule || event.recurrence?.rule.length === 0; + const rule = event.recurrence?.rule; + const recurrenceEventId = event.recurrence?.eventId; + const isRecurring = + Array.isArray(rule) || typeof recurrenceEventId === "string"; + const canMigrate = !isRecurring; return (
@@ -34,7 +38,16 @@ export const SomedayEventRectangle = ({ direction={FlexDirections.ROW} justifyContent={JustifyContent.SPACE_BETWEEN} > - {event.title} + + {isRecurring && ( + + )} + {event.title} + {canMigrate ? ( @@ -61,7 +74,7 @@ export const SomedayEventRectangle = ({ ) : ( - { e.stopPropagation(); alert("Can't migrate recurring events"); @@ -69,7 +82,7 @@ export const SomedayEventRectangle = ({ title="Can't migrate recurring events" > ☝️ - + )} diff --git a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts index a363f106f..2762b5634 100644 --- a/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts +++ b/packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/styled.ts @@ -18,16 +18,17 @@ export const StyledMigrateArrow = styled.span` export const StyledMigrateArrowInForm = styled(StyledMigrateArrow)` font-size: 27px; `; -export const StyledRecurrenceText = styled.span` - border: 1px solid ${({ theme }) => theme.color.border.primary}; + +export const StyledRecurringWarning = styled.span` + border: 1px solid transparent; border-radius: 2px; + cursor: not-allowed; font-size: 10px; - opacity: 0; - transition: opacity 0.2s; - width: 43px; + opacity: 0.5; + padding: 2px 4px; &:hover { - opacity: 1; - transition: border ease-in 0.2s; + opacity: 0.7; + transition: opacity 0.2s; } `;