From 85502412e30163555f780994a26c344ee39dadf0 Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Wed, 17 Dec 2025 10:25:13 +0000 Subject: [PATCH 01/13] fix:resolve conflicts & failing e2e tests --- cspell/custom-dict.txt | 1 + .../e2e/pages/CalendarButton.spec.ts | 47 +++ frontend/__tests__/e2e/pages/Home.spec.ts | 4 +- .../unit/components/CalendarButton.test.tsx | 286 +++++++----------- .../unit/utils/getIcsFileUrl.test.ts | 129 ++++++++ frontend/jest.setup.ts | 7 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 48 +++ frontend/src/components/CalendarButton.tsx | 44 ++- frontend/src/utils/getIcsFileUrl.ts | 53 ++++ 10 files changed, 428 insertions(+), 192 deletions(-) create mode 100644 frontend/__tests__/e2e/pages/CalendarButton.spec.ts create mode 100644 frontend/__tests__/unit/utils/getIcsFileUrl.test.ts create mode 100644 frontend/src/utils/getIcsFileUrl.ts diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 6e228a0bf3..eee0da1ebe 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -69,6 +69,7 @@ gunicorn hackathon heroui hsl +ics igoat inlinehilite isanori diff --git a/frontend/__tests__/e2e/pages/CalendarButton.spec.ts b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts new file mode 100644 index 0000000000..425d7f5032 --- /dev/null +++ b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import { mockHomeData } from '@e2e/data/mockHomeData' +import { test, expect } from '@playwright/test' + +test.describe('Calendar Export Functionality', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/graphql/', async (route) => { + await route.fulfill({ + status: 200, + json: mockHomeData, + }) + }) + await page.context().addCookies([ + { + name: 'csrftoken', + value: 'abc123', + domain: 'localhost', + path: '/', + }, + ]) + await page.goto('/') + }) + + test('should download a valid ICS file when clicked', async ({ page }) => { + const calendarButton = page.getByRole('button', { name: 'Add Event 1 to Calendar' }) + await expect(calendarButton).toBeVisible() + + const downloadPromise = page.waitForEvent('download') + + await calendarButton.click() + + const download = await downloadPromise + + expect(download.suggestedFilename()).toBe('invite.ics') + + const path = await download.path() + expect(path, 'Expected Playwright to provide a download path').toBeTruthy() + const content = fs.readFileSync(path, 'utf-8') + + expect(content).toContain('BEGIN:VCALENDAR') + expect(content).toContain('VERSION:2.0') + expect(content).toContain('BEGIN:VEVENT') + + expect(content).toContain('SUMMARY:') + expect(content).toContain('END:VCALENDAR') + }) +}) diff --git a/frontend/__tests__/e2e/pages/Home.spec.ts b/frontend/__tests__/e2e/pages/Home.spec.ts index 7d0733054e..d82c879abe 100644 --- a/frontend/__tests__/e2e/pages/Home.spec.ts +++ b/frontend/__tests__/e2e/pages/Home.spec.ts @@ -100,9 +100,9 @@ test.describe('Home Page', () => { await expect(page.getByRole('heading', { name: 'Upcoming Events' })).toBeVisible({ timeout: 10000, }) - await expect(page.getByRole('button', { name: 'Event 1' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Event 1', exact: true })).toBeVisible() await expect(page.getByText('Apr 5 — 6, 2025')).toBeVisible() - await page.getByRole('button', { name: 'Event 1' }).click() + await page.getByRole('button', { name: 'Event 1', exact: true }).click() }) test('should have stats', async ({ page }) => { diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index 14e8c45b0a..ece9fd6d62 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -1,8 +1,15 @@ +/** + * @jest-environment jsdom + */ + import { faCalendarDay, faCalendarPlus } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import getIcsFileUrl from 'utils/getIcsFileUrl' import CalendarButton from 'components/CalendarButton' +jest.mock('utils/getIcsFileUrl') + const mockEvent = { title: 'Test Event', description: 'Test description', @@ -12,17 +19,40 @@ const mockEvent = { } describe('CalendarButton', () => { + const mockUrl = 'blob:http://localhost/mock-file' + + let appendSpy: jest.SpyInstance + let clickSpy: jest.SpyInstance + let createSpy: jest.SpyInstance + + beforeEach(() => { + globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') + globalThis.URL.revokeObjectURL = jest.fn() + ;(getIcsFileUrl as jest.Mock).mockResolvedValue(mockUrl) + + appendSpy = jest.spyOn(document.body, 'appendChild') + createSpy = jest.spyOn(document, 'createElement') + + clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + jest.spyOn(globalThis, 'alert').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + describe('rendering', () => { it('renders without crashing', () => { render() - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() }) - it('renders as an anchor element', () => { + it('renders as a button element', () => { render() - const link = screen.getByRole('link') - expect(link.tagName).toBe('A') + const button = screen.getByRole('button') + expect(button.tagName).toBe('BUTTON') }) it('renders default FontAwesome calendar-plus icon', () => { @@ -48,65 +78,86 @@ describe('CalendarButton', () => { }) }) - describe('link attributes', () => { - it('has correct href with Google Calendar URL', () => { + describe('functionality (download)', () => { + it('generates ICS file and triggers download on click', async () => { render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('href') - expect(link.getAttribute('href')).toContain('calendar.google.com') - }) + const button = screen.getByRole('button') - it('opens in new tab', () => { - render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('target', '_blank') + fireEvent.click(button) + + expect(button).toBeDisabled() + + await waitFor(() => { + expect(getIcsFileUrl).toHaveBeenCalledWith(mockEvent) + }) + + expect(createSpy).toHaveBeenCalledWith('a') + + const createdLink = createSpy.mock.results.find( + (call) => call.value instanceof HTMLAnchorElement && call.value.href === mockUrl + )?.value + + expect(createdLink).toBeDefined() + expect(createdLink.download).toBe('invite.ics') + + expect(appendSpy).toHaveBeenCalledWith(createdLink) + + expect(clickSpy).toHaveBeenCalled() + + await waitFor(() => { + expect(button).not.toBeDisabled() + }) }) - it('has security attributes for external link', () => { + it('handles errors gracefully when generation fails', async () => { + const errorMock = new Error('Failed to generate') + ;(getIcsFileUrl as jest.Mock).mockRejectedValueOnce(errorMock) + render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') + const button = screen.getByRole('button') + + fireEvent.click(button) + + await waitFor(() => { + expect(globalThis.alert).toHaveBeenCalledWith('Could not download calendar file.') + }) + + expect(button).not.toBeDisabled() }) }) describe('accessibility', () => { it('has aria-label with event title', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('aria-label', 'Add Test Event to Google Calendar') - }) - - it('has title attribute for tooltip', () => { - render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('title', 'Add Test Event to Google Calendar') + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label', 'Add Test Event to Calendar') }) it('uses fallback for events without explicit title', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveAttribute('aria-label', 'Add Untitled to Google Calendar') + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label', 'Add Untitled to Calendar') }) }) describe('className prop', () => { - it('applies className to anchor', () => { + it('applies className to button', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveClass('custom-class') + const button = screen.getByRole('button') + expect(button).toHaveClass('custom-class') }) it('applies multiple classes', () => { render() - const link = screen.getByRole('link') - expect(link).toHaveClass('class-one') - expect(link).toHaveClass('class-two') + const button = screen.getByRole('button') + expect(button).toHaveClass('class-one') + expect(button).toHaveClass('class-two') }) it('handles empty className', () => { render() - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() }) }) @@ -128,11 +179,6 @@ describe('CalendarButton', () => { svg = document.querySelector('svg') expect(svg).toHaveClass('h-6', 'w-6') expect(svg).not.toHaveClass('h-4') - - rerender() - svg = document.querySelector('svg') - expect(svg).toHaveClass('h-8', 'w-8') - expect(svg).not.toHaveClass('h-6') }) it('uses default iconClassName when not provided', () => { @@ -157,25 +203,25 @@ describe('CalendarButton', () => { describe('showLabel prop', () => { it('does not show label by default', () => { render() - expect(screen.queryByText('Add to Google Calendar')).not.toBeInTheDocument() + expect(screen.queryByText('Add to Calendar')).not.toBeInTheDocument() }) it('shows default label when showLabel is true', () => { render() - expect(screen.getByText('Add to Google Calendar')).toBeInTheDocument() + expect(screen.getByText('Add to Calendar')).toBeInTheDocument() }) it('shows custom label when provided', () => { render() expect(screen.getByText('Save Event')).toBeInTheDocument() - expect(screen.queryByText('Add to Google Calendar')).not.toBeInTheDocument() + expect(screen.queryByText('Add to Calendar')).not.toBeInTheDocument() }) }) describe('label prop', () => { it('uses custom label text', () => { - render() - expect(screen.getByText('Add to Calendar')).toBeInTheDocument() + render() + expect(screen.getByText('Export ICS')).toBeInTheDocument() }) it('label is not rendered without showLabel', () => { @@ -215,69 +261,6 @@ describe('CalendarButton', () => { ) expect(screen.getByTestId('svg-icon')).toBeInTheDocument() }) - - it('accepts emoji as icon', () => { - render(📅} />) - expect(screen.getByText('📅')).toBeInTheDocument() - }) - - it('accepts custom element as icon', () => { - render( - 🗓️} - /> - ) - expect(screen.getByTestId('custom-element-icon')).toBeInTheDocument() - }) - }) - - describe('event data handling', () => { - it('handles minimal event data', () => { - render( - - ) - const link = screen.getByRole('link') - expect(link.getAttribute('href')).toContain('text=Minimal') - }) - - it('handles full event data', () => { - render( - - ) - const link = screen.getByRole('link') - const href = link.getAttribute('href') || '' - expect(href).toContain('text=Full') - expect(href).toContain('details=') - expect(href).toContain('location=') - }) - - it('handles Date objects', () => { - render( - - ) - const link = screen.getByRole('link') - expect(link.getAttribute('href')).toContain('calendar.google.com') - }) }) describe('reusability scenarios', () => { @@ -295,9 +278,9 @@ describe('CalendarButton', () => { iconClassName="h-4 w-4" /> ) - const link = screen.getByRole('link') - expect(link).toHaveClass('text-gray-600') - expect(link).toHaveClass('dark:text-gray-400') + const button = screen.getByRole('button') + expect(button).toHaveClass('text-gray-600') + expect(button).toHaveClass('dark:text-gray-400') }) it('works in poster page context with label', () => { @@ -313,25 +296,8 @@ describe('CalendarButton', () => { /> ) expect(screen.getByText('Save to Calendar')).toBeInTheDocument() - const link = screen.getByRole('link') - expect(link).toHaveClass('btn-primary') - }) - - it('works with custom styled icon', () => { - render( - - } - /> - ) - const svg = document.querySelector('svg') - expect(svg).toHaveClass('h-6') - expect(svg).toHaveClass('text-blue-500') + const button = screen.getByRole('button') + expect(button).toHaveClass('btn-primary') }) }) @@ -349,9 +315,9 @@ describe('CalendarButton', () => { className="flex-shrink-0" /> ) - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() - expect(link).toHaveAttribute('aria-label', `Add ${longTitle} to Google Calendar`) + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button).toHaveAttribute('aria-label', `Add ${longTitle} to Calendar`) }) it('maintains visibility with flex-shrink-0 class', () => { @@ -364,9 +330,9 @@ describe('CalendarButton', () => { className="flex-shrink-0 text-gray-600" /> ) - const link = screen.getByRole('link') - expect(link).toHaveClass('flex-shrink-0') - expect(link).toBeVisible() + const button = screen.getByRole('button') + expect(button).toHaveClass('flex-shrink-0') + expect(button).toBeVisible() }) it('works correctly in flex container with long text sibling', () => { @@ -384,47 +350,9 @@ describe('CalendarButton', () => { /> ) - const link = container.querySelector('a') - expect(link).toBeInTheDocument() - expect(link).toHaveClass('flex-shrink-0') - }) - - it('handles extremely long titles gracefully', () => { - const extremelyLongTitle = - 'This Is An Extremely Long Event Title With Many Words That Could Cause Layout Issues And Overflow Problems In Flex Containers With Limited Space Available' - render( - - ) - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() - expect(link.getAttribute('href')).toContain('calendar.google.com') - }) - - it('remains clickable when adjacent to truncated text', () => { - const { container } = render( -
- - Another Very Long Text Element That Should Be Truncated With Ellipsis - - -
- ) - const link = container.querySelector('a') - expect(link).toBeInTheDocument() - expect(link).not.toBeNull() - const computedStyle = globalThis.getComputedStyle(link as Element) - expect(computedStyle.display).not.toBe('none') + const button = container.querySelector('button[aria-label="Add Event to Calendar"]') + expect(button).toBeInTheDocument() + expect(button).toHaveClass('flex-shrink-0') }) }) }) diff --git a/frontend/__tests__/unit/utils/getIcsFileUrl.test.ts b/frontend/__tests__/unit/utils/getIcsFileUrl.test.ts new file mode 100644 index 0000000000..dadd248f2b --- /dev/null +++ b/frontend/__tests__/unit/utils/getIcsFileUrl.test.ts @@ -0,0 +1,129 @@ +import { createEvent } from 'ics' +import getIcsFileUrl from 'utils/getIcsFileUrl' + +describe('getIcsFileUrl', () => { + const originalCreateObjectURL = globalThis.URL.createObjectURL + + const mockEvent = { + title: 'Conference', + description: 'Discuss Q4 goals', + location: 'Conference Room A', + url: 'https://meet.google.com/abc-defg-hij', + startDate: '2025-01-01', + endDate: '2025-01-03', + } + + beforeAll(() => { + globalThis.URL.createObjectURL = jest.fn(() => 'blob:http://localhost/mock-uuid') + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(() => { + globalThis.URL.createObjectURL = originalCreateObjectURL + }) + + it('should generate a blob URL when event creation is successful', async () => { + ;(createEvent as jest.Mock).mockImplementation((attributes, callback) => { + callback(null, 'BEGIN:VCALENDAR...') + }) + + const result = await getIcsFileUrl(mockEvent) + + expect(result).toBe('blob:http://localhost/mock-uuid') + expect(createEvent).toHaveBeenCalledTimes(1) + expect(globalThis.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + }) + + it('should correctly format string dates into DateArray', async () => { + ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val')) + + const eventWithStrings = { + ...mockEvent, + startDate: '2025-01-01', + endDate: '2025-01-03', + } + + await getIcsFileUrl(eventWithStrings) + + expect(createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + start: [2025, 1, 1], + end: [2025, 1, 3], + }), + expect.any(Function) + ) + }) + + it('should correctly format Date objects into DateArray (handling 0-index months)', async () => { + ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val')) + + const eventWithDateObjects = { + ...mockEvent, + startDate: new Date(2025, 0, 1), + endDate: new Date(2025, 0, 3), + } + + await getIcsFileUrl(eventWithDateObjects) + + expect(createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + start: [2025, 1, 1], + end: [2025, 1, 3], + }), + expect.any(Function) + ) + }) + + it('should automatically increment the end date by 1 day if start and end dates are the same', async () => { + ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val')) + + const singleDayEvent = { + ...mockEvent, + startDate: '2025-01-01', + endDate: '2025-01-01', + } + + await getIcsFileUrl(singleDayEvent) + + expect(createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + start: [2025, 1, 1], + end: [2025, 1, 2], + }), + expect.any(Function) + ) + }) + + it('should reject the promise if createEvent returns an error', async () => { + const mockError = new Error('ICS generation failed') + + ;(createEvent as jest.Mock).mockImplementation((attr, callback) => { + callback(mockError, null) + }) + + await expect(getIcsFileUrl(mockEvent)).rejects.toThrow('ICS generation failed') + }) + + it('should pass all event attributes correctly to createEvent', async () => { + ;(createEvent as jest.Mock).mockImplementation((attr, cb) => cb(null, 'val')) + + await getIcsFileUrl(mockEvent) + + expect(createEvent).toHaveBeenCalledWith( + { + start: [2025, 1, 1], + end: [2025, 1, 3], + title: mockEvent.title, + description: mockEvent.description, + location: mockEvent.location, + url: mockEvent.url, + status: 'CONFIRMED', + busyStatus: 'BUSY', + }, + expect.any(Function) + ) + }) +}) diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index 63f36750dd..dd410b6e98 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -121,3 +121,10 @@ beforeEach(() => { globalThis.runAnimationFrameCallbacks = jest.fn() globalThis.removeAnimationFrameCallbacks = jest.fn() }) + +jest.mock('ics', () => { + return { + __esModule: true, + createEvent: jest.fn(), + } +}) diff --git a/frontend/package.json b/frontend/package.json index 7cee9f9a1b..6ac5d9c12e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,7 @@ "eslint-plugin-import": "^2.32.0", "framer-motion": "^12.23.26", "graphql": "^16.12.0", + "ics": "^3.8.1", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", "lodash": "^4.17.21", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b6d6754fce..4f1ab5407f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: graphql: specifier: ^16.12.0 version: 16.12.0 + ics: + specifier: ^3.8.1 + version: 3.8.1 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -5071,6 +5074,9 @@ packages: resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} engines: {node: '>=0.10.0'} + ics@3.8.1: + resolution: {integrity: sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==} + identity-obj-proxy@3.0.0: resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==} engines: {node: '>=4'} @@ -6439,6 +6445,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -6656,6 +6665,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + runes2@1.1.4: + resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==} + rxjs@6.6.7: resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} engines: {npm: '>=2.0.0'} @@ -7064,6 +7076,9 @@ packages: resolution: {integrity: sha512-YBGpG4bWsHoPvofT6y/5iqulfXIiIErl5B0LdtHT1mGXDFTAhhRrbUpTvBgYbovr+3cKblya2WAOcpoy90XguA==} engines: {node: '>=16'} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -7100,6 +7115,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -7193,6 +7211,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -7560,6 +7582,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -13813,6 +13838,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ics@3.8.1: + dependencies: + nanoid: 3.3.11 + runes2: 1.1.4 + yup: 1.7.1 + identity-obj-proxy@3.0.0: dependencies: harmony-reflect: 1.6.2 @@ -15319,6 +15350,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + property-expr@2.0.6: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -15578,6 +15611,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + runes2@1.1.4: {} + rxjs@6.6.7: dependencies: tslib: 1.14.1 @@ -16083,6 +16118,8 @@ snapshots: timeout-signal@2.0.0: {} + tiny-case@1.0.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -16118,6 +16155,8 @@ snapshots: toidentifier@1.0.1: {} + toposort@2.0.2: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -16199,6 +16238,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@2.19.0: {} + type-fest@4.41.0: {} type-is@1.6.18: @@ -16617,6 +16658,13 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + zod-validation-error@4.0.2(zod@4.2.1): dependencies: zod: 4.2.1 diff --git a/frontend/src/components/CalendarButton.tsx b/frontend/src/components/CalendarButton.tsx index 089cc5fe10..eb7369ae2f 100644 --- a/frontend/src/components/CalendarButton.tsx +++ b/frontend/src/components/CalendarButton.tsx @@ -1,38 +1,60 @@ +'use client' + import { faCalendar, faCalendarPlus } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useState } from 'react' import type { CalendarButtonProps } from 'types/calendar' -import getGoogleCalendarUrl from 'utils/getGoogleCalendarUrl' +import getIcsFileUrl from 'utils/getIcsFileUrl' export default function CalendarButton(props: Readonly) { const [isHovered, setIsHovered] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) const { event, className = '', iconClassName = 'h-4 w-4', icon, showLabel = false, - label = 'Add to Google Calendar', + label = 'Add to Calendar', } = props - const href = getGoogleCalendarUrl(event) const safeTitle = event.title || 'event' - const ariaLabel = `Add ${safeTitle} to Google Calendar` + const ariaLabel = `Add ${safeTitle} to Calendar` + + const handleDownload = async () => { + let url: string | null = null + let link: HTMLAnchorElement | null = null + try { + setIsDownloading(true) + url = await getIcsFileUrl(event) + link = document.createElement('a') + link.href = url + link.setAttribute('download', 'invite.ics') + document.body.appendChild(link) + link.click() + } catch { + alert('Could not download calendar file.') + } finally { + if (link) link.remove() + if (url) URL.revokeObjectURL(url) + setIsDownloading(false) + } + } return ( - setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + disabled={isDownloading} aria-label={ariaLabel} title={ariaLabel} className={className} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {icon || ( )} {showLabel && {label}} - + ) } diff --git a/frontend/src/utils/getIcsFileUrl.ts b/frontend/src/utils/getIcsFileUrl.ts new file mode 100644 index 0000000000..0b63ac420c --- /dev/null +++ b/frontend/src/utils/getIcsFileUrl.ts @@ -0,0 +1,53 @@ +import { createEvent, DateArray, EventAttributes } from 'ics' +import type { CalendarEvent } from 'types/calendar' + +export default function getIcsFileUrl(event: CalendarEvent): Promise { + return new Promise((resolve, reject) => { + if (globalThis.window === undefined) { + reject(new Error('Window not defined (server-side generation not supported)')) + return + } + const parseDate = (date: string | Date): DateArray => { + if (typeof date === 'string') { + const [year, month, day] = date.split('-').map(Number) + if (!year || !month || !day) throw new Error(`Invalid date string: ${date}`) + return [year, month, day] + } + return [date.getFullYear(), date.getMonth() + 1, date.getDate()] + } + + const getEndDate = (start: DateArray, end: DateArray): DateArray => { + if (start.join('-') === end.join('-')) { + const [y, m, d] = end + const nextDay = new Date(y, m - 1, d + 1) + return [nextDay.getFullYear(), nextDay.getMonth() + 1, nextDay.getDate()] + } + return end + } + + const startArray = parseDate(event.startDate) + const rawEndArray = parseDate(event.endDate ?? event.startDate) + const finalEndArray = getEndDate(startArray, rawEndArray) + + const eventAttributes: EventAttributes = { + start: startArray, + end: finalEndArray, + title: event.title, + status: 'CONFIRMED', + busyStatus: 'BUSY', + ...(event.description && { description: event.description }), + ...(event.location && { location: event.location }), + ...(event.url && { url: event.url }), + } + + createEvent(eventAttributes, (error, value) => { + if (error) { + reject(error) + return + } + + const blob = new Blob([value], { type: 'text/calendar;charset=utf-8' }) + resolve(globalThis.URL.createObjectURL(blob)) + }) + }) +} From d27405b8ee8184ecf3febbac8376b5b2372c29f9 Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Mon, 15 Dec 2025 05:49:07 +0000 Subject: [PATCH 02/13] fix: coderabbit suggestions --- frontend/__tests__/unit/components/CalendarButton.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index ece9fd6d62..9b0f085d99 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -25,6 +25,11 @@ describe('CalendarButton', () => { let clickSpy: jest.SpyInstance let createSpy: jest.SpyInstance + beforeAll(() => { + global.URL.createObjectURL = jest.fn(() => 'mock-url'); + global.URL.revokeObjectURL = jest.fn(); + }); + beforeEach(() => { globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') globalThis.URL.revokeObjectURL = jest.fn() From 6ad14e4c59f412cbb77b90b3722bb109027b359f Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Mon, 15 Dec 2025 15:13:06 +0000 Subject: [PATCH 03/13] fix:sonarqube_fix --- frontend/__tests__/unit/components/CalendarButton.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index 9b0f085d99..7613c89938 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -26,8 +26,8 @@ describe('CalendarButton', () => { let createSpy: jest.SpyInstance beforeAll(() => { - global.URL.createObjectURL = jest.fn(() => 'mock-url'); - global.URL.revokeObjectURL = jest.fn(); + globalThis.URL.createObjectURL = jest.fn(() => 'mock-url'); + globalThis.URL.revokeObjectURL = jest.fn(); }); beforeEach(() => { From f90940cfcb502b08f8c8411e0b99c077ccfdafbd Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Mon, 15 Dec 2025 15:14:04 +0000 Subject: [PATCH 04/13] fix:coderabbit_nitpicks --- .../unit/components/CalendarButton.test.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index 7613c89938..f2b60cf83a 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -24,8 +24,8 @@ describe('CalendarButton', () => { let appendSpy: jest.SpyInstance let clickSpy: jest.SpyInstance let createSpy: jest.SpyInstance - - beforeAll(() => { + + beforeEach(() => { globalThis.URL.createObjectURL = jest.fn(() => 'mock-url'); globalThis.URL.revokeObjectURL = jest.fn(); }); @@ -98,14 +98,10 @@ describe('CalendarButton', () => { expect(createSpy).toHaveBeenCalledWith('a') - const createdLink = createSpy.mock.results.find( - (call) => call.value instanceof HTMLAnchorElement && call.value.href === mockUrl - )?.value - - expect(createdLink).toBeDefined() - expect(createdLink.download).toBe('invite.ics') - - expect(appendSpy).toHaveBeenCalledWith(createdLink) + const appendedElement = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement + expect(appendedElement).toBeInstanceOf(HTMLAnchorElement) + expect(appendedElement.href).toBe(mockUrl) + expect(appendedElement.download).toBe('invite.ics') expect(clickSpy).toHaveBeenCalled() From 5676e7228cae080d63995dd0dc211b53fe851327 Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Mon, 15 Dec 2025 16:02:38 +0000 Subject: [PATCH 05/13] fix:formatting --- .../unit/components/CalendarButton.test.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index f2b60cf83a..db03bfe905 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -24,7 +24,7 @@ describe('CalendarButton', () => { let appendSpy: jest.SpyInstance let clickSpy: jest.SpyInstance let createSpy: jest.SpyInstance - + beforeEach(() => { globalThis.URL.createObjectURL = jest.fn(() => 'mock-url'); globalThis.URL.revokeObjectURL = jest.fn(); @@ -98,10 +98,14 @@ describe('CalendarButton', () => { expect(createSpy).toHaveBeenCalledWith('a') - const appendedElement = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement - expect(appendedElement).toBeInstanceOf(HTMLAnchorElement) - expect(appendedElement.href).toBe(mockUrl) - expect(appendedElement.download).toBe('invite.ics') + const createdLink = createSpy.mock.results.find( + (call) => call.value instanceof HTMLAnchorElement && call.value.href === mockUrl + )?.value + + expect(createdLink).toBeDefined() + expect(createdLink.download).toBe('invite.ics') + + expect(appendSpy).toHaveBeenCalledWith(createdLink) expect(clickSpy).toHaveBeenCalled() From 3eaf0d2b3a300a3c53844421e878f6ce424239eb Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Wed, 17 Dec 2025 10:39:27 +0000 Subject: [PATCH 06/13] fix:make check issues --- frontend/__tests__/unit/components/CalendarButton.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index db03bfe905..83eb3f64ca 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -26,9 +26,9 @@ describe('CalendarButton', () => { let createSpy: jest.SpyInstance beforeEach(() => { - globalThis.URL.createObjectURL = jest.fn(() => 'mock-url'); - globalThis.URL.revokeObjectURL = jest.fn(); - }); + globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') + globalThis.URL.revokeObjectURL = jest.fn() + }) beforeEach(() => { globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') From a9e9fc8f64da9aed8edbe9510d1770384e745c7a Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Wed, 17 Dec 2025 12:57:59 +0000 Subject: [PATCH 07/13] fix:coderabbit suggestions and merge conflicts --- .../__tests__/unit/components/CalendarButton.test.tsx | 8 +------- frontend/src/components/CalendarButton.tsx | 2 -- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index 7d2d65dee4..f088c22526 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -2,9 +2,8 @@ * @jest-environment jsdom */ -import { faCalendarDay, faCalendarPlus } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FaCalendarDay, FaCalendarPlus } from 'react-icons/fa6' import getIcsFileUrl from 'utils/getIcsFileUrl' import CalendarButton from 'components/CalendarButton' @@ -25,11 +24,6 @@ describe('CalendarButton', () => { let clickSpy: jest.SpyInstance let createSpy: jest.SpyInstance - beforeEach(() => { - globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') - globalThis.URL.revokeObjectURL = jest.fn() - }) - beforeEach(() => { globalThis.URL.createObjectURL = jest.fn(() => 'mock-url') globalThis.URL.revokeObjectURL = jest.fn() diff --git a/frontend/src/components/CalendarButton.tsx b/frontend/src/components/CalendarButton.tsx index 6c204cbcdc..efe894ee6a 100644 --- a/frontend/src/components/CalendarButton.tsx +++ b/frontend/src/components/CalendarButton.tsx @@ -1,7 +1,5 @@ 'use client' -import { faCalendar, faCalendarPlus } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useState } from 'react' import { FaCalendar, FaCalendarPlus } from 'react-icons/fa6' import type { CalendarButtonProps } from 'types/calendar' From f874d977a89e0015c63e986c96896d7ac2945db3 Mon Sep 17 00:00:00 2001 From: Utkarsh Agarwal Date: Thu, 18 Dec 2025 14:02:20 +0000 Subject: [PATCH 08/13] fix:ui changes and download file name updation --- .../e2e/pages/CalendarButton.spec.ts | 3 ++- .../unit/components/CalendarButton.test.tsx | 17 +++++++++++++++-- frontend/src/components/CalendarButton.tsx | 19 ++++++++++++++----- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/frontend/__tests__/e2e/pages/CalendarButton.spec.ts b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts index 425d7f5032..c4fd2a875b 100644 --- a/frontend/__tests__/e2e/pages/CalendarButton.spec.ts +++ b/frontend/__tests__/e2e/pages/CalendarButton.spec.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import { mockHomeData } from '@e2e/data/mockHomeData' import { test, expect } from '@playwright/test' +import slugify from 'utils/slugify' test.describe('Calendar Export Functionality', () => { test.beforeEach(async ({ page }) => { @@ -31,7 +32,7 @@ test.describe('Calendar Export Functionality', () => { const download = await downloadPromise - expect(download.suggestedFilename()).toBe('invite.ics') + expect(download.suggestedFilename()).toBe(`${slugify('Event 1')}.ics`) const path = await download.path() expect(path, 'Expected Playwright to provide a download path').toBeTruthy() diff --git a/frontend/__tests__/unit/components/CalendarButton.test.tsx b/frontend/__tests__/unit/components/CalendarButton.test.tsx index f088c22526..650c4532eb 100644 --- a/frontend/__tests__/unit/components/CalendarButton.test.tsx +++ b/frontend/__tests__/unit/components/CalendarButton.test.tsx @@ -2,13 +2,19 @@ * @jest-environment jsdom */ +import { addToast } from '@heroui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { FaCalendarDay, FaCalendarPlus } from 'react-icons/fa6' import getIcsFileUrl from 'utils/getIcsFileUrl' +import slugify from 'utils/slugify' import CalendarButton from 'components/CalendarButton' jest.mock('utils/getIcsFileUrl') +jest.mock('@heroui/toast', () => ({ + addToast: jest.fn(), +})) + const mockEvent = { title: 'Test Event', description: 'Test description', @@ -94,7 +100,7 @@ describe('CalendarButton', () => { )?.value expect(createdLink).toBeDefined() - expect(createdLink.download).toBe('invite.ics') + expect(createdLink.download).toBe(`${slugify(mockEvent.title)}.ics`) expect(appendSpy).toHaveBeenCalledWith(createdLink) @@ -115,7 +121,14 @@ describe('CalendarButton', () => { fireEvent.click(button) await waitFor(() => { - expect(globalThis.alert).toHaveBeenCalledWith('Could not download calendar file.') + expect(addToast).toHaveBeenCalledWith({ + description: "couldn't export your calendar. Please try again.", + title: 'Download Failed', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) }) expect(button).not.toBeDisabled() diff --git a/frontend/src/components/CalendarButton.tsx b/frontend/src/components/CalendarButton.tsx index efe894ee6a..1863faf8dd 100644 --- a/frontend/src/components/CalendarButton.tsx +++ b/frontend/src/components/CalendarButton.tsx @@ -1,9 +1,11 @@ 'use client' +import { addToast } from '@heroui/toast' import { useState } from 'react' import { FaCalendar, FaCalendarPlus } from 'react-icons/fa6' import type { CalendarButtonProps } from 'types/calendar' import getIcsFileUrl from 'utils/getIcsFileUrl' +import slugify from 'utils/slugify' export default function CalendarButton(props: Readonly) { const [isHovered, setIsHovered] = useState(false) @@ -27,11 +29,18 @@ export default function CalendarButton(props: Readonly) { url = await getIcsFileUrl(event) link = document.createElement('a') link.href = url - link.setAttribute('download', 'invite.ics') + link.setAttribute('download', `${slugify(event.title)}.ics`) document.body.appendChild(link) link.click() } catch { - alert('Could not download calendar file.') + addToast({ + description: "couldn't export your calendar. Please try again.", + title: 'Download Failed', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) } finally { if (link) link.remove() if (url) URL.revokeObjectURL(url) @@ -43,12 +52,12 @@ export default function CalendarButton(props: Readonly) {