diff --git a/e2e/README.md b/e2e/README.md index 84688c7498..5bd0db0bf1 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -223,6 +223,10 @@ Known gap: SaaS-mode Stripe checkout requires a connected Stripe account (`organizer_stripe_platforms`), which cannot be onboarded headlessly. The Stripe spec targets non-SaaS platform-account charges until a seeded-connected-account helper exists. +Known gap: in SaaS mode the message composer hides its form behind "Connect Stripe to +enable messaging" until the account is manually verified, so composer-driven specs only +run on a non-SaaS stack. + ## CI `.github/workflows/e2e.yml` builds the backend and frontend images (GHA layer cache), diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts index 9de65071d5..4c39360024 100644 --- a/e2e/api/api-client.ts +++ b/e2e/api/api-client.ts @@ -294,6 +294,12 @@ export class ApiClient { ); } + createOccurrence(eventId: number, payload: UpdateOccurrencePayload): Promise { + return unwrap( + this.request.post(`events/${eventId}/occurrences`, { headers: jsonHeaders, data: payload }), + ); + } + updateOccurrence(eventId: number, occurrenceId: number, payload: UpdateOccurrencePayload): Promise { return check( this.request.put(`events/${eventId}/occurrences/${occurrenceId}`, { headers: jsonHeaders, data: payload }), diff --git a/e2e/api/factory.ts b/e2e/api/factory.ts index d10e9fa486..e891a1529d 100644 --- a/e2e/api/factory.ts +++ b/e2e/api/factory.ts @@ -1,6 +1,14 @@ import type { APIRequestContext } from '@playwright/test'; import type { ApiClient } from './api-client'; -import type { EventType, Occurrence, Organizer, ProductPriceType, PublicOrder, QuestionRecord } from './types'; +import type { + AttendeeDetailsCollection, + EventType, + Occurrence, + Organizer, + ProductPriceType, + PublicOrder, + QuestionRecord, +} from './types'; import { awaitOfflinePayment, completePublicOrder, @@ -31,8 +39,15 @@ interface SeedOptions { waitlistEnabled?: boolean; taxIds?: number[]; prices?: { price: number; label?: string }[]; + attendeeDetails?: AttendeeDetailsCollection; } +export const setAttendeeDetailsCollection = ( + api: ApiClient, + eventId: number, + method: AttendeeDetailsCollection, +): Promise => api.updateEventSettings(eventId, { attendee_details_collection_method: method }); + const futureStartDate = (): string => { const date = new Date(); date.setDate(date.getDate() + 30); @@ -61,6 +76,10 @@ export async function createLiveEventWithProduct(api: ApiClient, opts: SeedOptio timezone: 'UTC', }); + if (eventType === 'SINGLE') { + await setAttendeeDetailsCollection(api, event.id, opts.attendeeDetails ?? 'PER_TICKET'); + } + const categories = await api.listProductCategories(event.id); const categoryId = categories[0].id; @@ -97,7 +116,7 @@ export interface SeededDraftEvent { export async function createDraftEvent( api: ApiClient, organizerId: number, - opts: { title?: string } = {}, + opts: { title?: string; attendeeDetails?: AttendeeDetailsCollection } = {}, ): Promise { const title = opts.title ?? uniqueName('E2E Event'); const event = await api.createEvent({ @@ -109,6 +128,7 @@ export async function createDraftEvent( currency: 'USD', timezone: 'UTC', }); + await setAttendeeDetailsCollection(api, event.id, opts.attendeeDetails ?? 'PER_TICKET'); return { eventId: event.id, slug: event.slug, title }; } diff --git a/e2e/api/types.ts b/e2e/api/types.ts index 1cf60f9fd2..125c874859 100644 --- a/e2e/api/types.ts +++ b/e2e/api/types.ts @@ -175,6 +175,7 @@ export interface UpdateOccurrencePayload { start_date: string; end_date?: string | null; label?: string; + capacity?: number | null; event_location?: | { type: 'IN_PERSON'; location_id: number } | { type: 'ONLINE'; online_event_connection_details: string }; @@ -215,10 +216,13 @@ export interface EmailTemplate { subject: string; } +export type AttendeeDetailsCollection = 'PER_TICKET' | 'PER_ORDER'; + export interface EventSettings { payment_providers?: string[]; offline_payment_instructions?: string | null; waitlist_enabled?: boolean; + attendee_details_collection_method?: AttendeeDetailsCollection; [key: string]: unknown; } diff --git a/e2e/pages/occurrence.page.ts b/e2e/pages/occurrence.page.ts index b28db66bd3..f43a1a914d 100644 --- a/e2e/pages/occurrence.page.ts +++ b/e2e/pages/occurrence.page.ts @@ -1,5 +1,12 @@ import { expect, type Locator, type Page } from '@playwright/test'; +export type BulkAction = + | 'shift-times' + | 'change-duration' + | 'update-capacity' + | 'update-label' + | 'update-location'; + export class OccurrencePage { constructor(private readonly page: Page) {} @@ -12,6 +19,10 @@ export class OccurrencePage { return this.page.getByRole('dialog'); } + async selectTimePeriod(period: 'Upcoming' | 'Past' | 'All'): Promise { + await this.page.locator('[class*="toolbar"] label').filter({ hasText: new RegExp(`^${period}$`) }).click(); + } + async openScheduleSetup(): Promise { await this.page.getByRole('button', { name: 'Set Up Schedule' }).click(); } @@ -41,6 +52,22 @@ export class OccurrencePage { return this.page.locator(`[class*="statusBadge"][data-status="${status}"]`); } + timeCells(): Locator { + return this.page.locator('[class*="dateTimePrimary"]'); + } + + labelCells(): Locator { + return this.page.locator('[class*="dateTimeMeta"]'); + } + + locationCells(): Locator { + return this.page.locator('[class*="dateTimeLocation"]'); + } + + capacityCells(): Locator { + return this.page.locator('[class*="ticketsSoldNumbers"]'); + } + rowWithStatus(status: 'ACTIVE' | 'CANCELLED'): Locator { return this.occurrenceRows().filter({ has: this.statusBadges(status) }); } @@ -74,6 +101,55 @@ export class OccurrencePage { await this.page.locator('[class*="saveButton"]').click(); } + selectAllCheckbox(): Locator { + return this.page.getByRole('checkbox', { name: 'Select all', exact: true }); + } + + selectionSummary(): Locator { + return this.page.locator('[class*="selectionCount"]'); + } + + async clearSelection(): Promise { + await this.page.getByTestId('occurrence-bulk-clear-selection-button').click(); + } + + async cancelSelected(): Promise { + const count = parseInt(await this.selectionSummary().innerText(), 10); + await this.page.getByTestId('occurrence-bulk-cancel-button').click(); + const confirm = this.dialog().filter({ hasText: `Cancel ${count} date(s)` }); + await confirm.getByRole('button', { name: `Cancel ${count} date(s)` }).click(); + } + + async deleteSelected(): Promise { + await this.page.getByTestId('occurrence-bulk-delete-button').click(); + await this.dialog().getByRole('button', { name: 'Confirm' }).click(); + } + + bulkEditModal(): Locator { + return this.dialog().filter({ hasText: 'Bulk Edit Dates' }); + } + + async openBulkEdit(action: BulkAction): Promise { + await this.page.getByTestId('occurrence-bulk-edit-button').click(); + await this.page.getByTestId(`occurrence-bulk-action-${action}`).click(); + } + + async setBulkScope(scope: 'Loaded dates' | 'All matching dates'): Promise { + await this.bulkEditModal().getByText(scope, { exact: true }).click(); + } + + bulkOption(label: string): Locator { + return this.bulkEditModal().getByRole('checkbox', { name: label }); + } + + affectedCount(): Locator { + return this.bulkEditModal().getByText(/This will affect \d+ date\(s\)\./); + } + + async applyBulkEdit(): Promise { + await this.page.getByTestId('occurrence-bulk-edit-submit-button').click(); + } + async closeModal(): Promise { await this.page.keyboard.press('Escape'); } diff --git a/e2e/tests/checkout/per-order-details-checkout.spec.ts b/e2e/tests/checkout/per-order-details-checkout.spec.ts new file mode 100644 index 0000000000..1db9439617 --- /dev/null +++ b/e2e/tests/checkout/per-order-details-checkout.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '../../fixtures'; +import { AttendeePage } from '../../pages/attendee.page'; +import { CheckoutPage } from '../../pages/checkout.page'; +import { createLiveEventWithProduct } from '../../api/factory'; +import { uniqueEmail } from '../../utils/unique'; + +test.describe('per-order attendee details checkout', () => { + test('a buyer books two tickets with one set of contact details', async ({ page, authedPage, api, account, mailpit }) => { + const event = await createLiveEventWithProduct(api, { + organizerId: account.organizerId, + attendeeDetails: 'PER_ORDER', + }); + const buyerEmail = uniqueEmail('perorderbuyer'); + const buyer = { firstName: 'Per', lastName: 'Order', email: buyerEmail }; + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.setFirstProductQuantity(2); + await checkout.continueToCheckout(); + + await expect(page.getByLabel(/^First Name/)).toHaveCount(1); + + await checkout.fillOrderDetails(buyer); + await checkout.completeFreeOrder(); + + await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible(); + await mailpit.waitForMessage(buyerEmail); + + const attendees = new AttendeePage(authedPage); + await attendees.goto(event.eventId); + await expect(attendees.rowByText(buyerEmail)).toHaveCount(2); + await expect(attendees.rowByText('Per Order')).toHaveCount(2); + }); +}); diff --git a/e2e/tests/management/occurrence-bulk-edit.spec.ts b/e2e/tests/management/occurrence-bulk-edit.spec.ts new file mode 100644 index 0000000000..a22222bc83 --- /dev/null +++ b/e2e/tests/management/occurrence-bulk-edit.spec.ts @@ -0,0 +1,318 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures'; +import { OccurrencePage } from '../../pages/occurrence.page'; +import { createCompletedOrder, createRecurringLiveEvent } from '../../api/factory'; +import type { Occurrence } from '../../api/types'; +import { IS_SAAS_MODE } from '../../utils/env'; +import { uniqueName } from '../../utils/unique'; + +const times = (start: string, end: string): RegExp => new RegExp(`${start}.*${end}`); + +const earliest = (occurrences: Occurrence[]): Occurrence => + [...occurrences].sort((a, b) => a.start_date.localeCompare(b.start_date))[0]; + +const daysFromNow = (days: number, hour: number): string => { + const date = new Date(); + date.setDate(date.getDate() + days); + date.setUTCHours(hour, 0, 0, 0); + return date.toISOString(); +}; + +const reloadUntil = async ( + occurrences: OccurrencePage, + eventId: number, + assertion: () => Promise, +): Promise => { + await expect(async () => { + await occurrences.goto(eventId); + await assertion(); + }).toPass({ timeout: 45_000 }); +}; + +const waitForOrderStatistics = (page: Page, occurrences: OccurrencePage, eventId: number): Promise => + reloadUntil(occurrences, eventId, async () => { + await expect(page.getByText('1 order')).toBeVisible({ timeout: 3_000 }); + }); + +test.describe('occurrence bulk edit', () => { + test('an organizer shifts every loaded date an hour later', { tag: '@smoke' }, async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + await expect(occurrences.timeCells()).toHaveText(Array(3).fill(times('7:00 PM', '9:00 PM'))); + + await occurrences.openBulkEdit('shift-times'); + await expect(occurrences.affectedCount()).toHaveText('This will affect 3 date(s).'); + await occurrences.bulkEditModal().getByLabel('Hours').fill('1'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Shifted times for 3 date(s)')).toBeVisible(); + await expect(occurrences.timeCells()).toHaveText(Array(3).fill(times('8:00 PM', '10:00 PM'))); + }); + + test('an organizer moves every date earlier', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + + await occurrences.openBulkEdit('shift-times'); + await occurrences.bulkEditModal().getByText('Earlier', { exact: true }).click(); + await occurrences.bulkEditModal().getByLabel('Hours').fill('1'); + await occurrences.bulkEditModal().getByLabel('Minutes').fill('30'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Shifted times for 3 date(s)')).toBeVisible(); + await expect(occurrences.timeCells()).toHaveText(Array(3).fill(times('5:30 PM', '7:30 PM'))); + }); + + test('an organizer stretches every date to a new duration', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + + await occurrences.openBulkEdit('change-duration'); + await occurrences.bulkEditModal().getByLabel('Hours').fill('3'); + await occurrences.bulkEditModal().getByLabel('Minutes').fill('30'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Changed duration for 3 date(s)')).toBeVisible(); + await expect(occurrences.timeCells()).toHaveText(Array(3).fill(times('7:00 PM', '10:30 PM'))); + }); + + test('an organizer sets capacity on every date except the hand-edited one', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + const handEdited = earliest(event.occurrences); + await api.updateOccurrence(event.eventId, handEdited.id, { + start_date: handEdited.start_date, + end_date: handEdited.end_date, + capacity: 100, + }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + await expect(occurrences.capacityCells().filter({ hasText: '/ 100' })).toHaveCount(1); + + await occurrences.openBulkEdit('update-capacity'); + await expect(occurrences.affectedCount()).toHaveText('This will affect 2 date(s).'); + await occurrences.bulkOption('Skip manually edited dates').uncheck(); + await expect(occurrences.affectedCount()).toHaveText('This will affect 3 date(s).'); + await occurrences.bulkOption('Skip manually edited dates').check(); + + await occurrences.bulkEditModal().getByLabel('New capacity').fill('40'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Updated capacity for 2 date(s)')).toBeVisible(); + await expect(occurrences.capacityCells().filter({ hasText: '/ 40' })).toHaveCount(2); + await expect(occurrences.capacityCells().filter({ hasText: '/ 100' })).toHaveCount(1); + }); + + test('an organizer clears capacity back to unlimited', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + for (const occurrence of event.occurrences) { + await api.updateOccurrence(event.eventId, occurrence.id, { + start_date: occurrence.start_date, + end_date: occurrence.end_date, + capacity: 25, + }); + } + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + await expect(occurrences.capacityCells().filter({ hasText: '/ 25' })).toHaveCount(3); + + await occurrences.openBulkEdit('update-capacity'); + await occurrences.bulkOption('Skip manually edited dates').uncheck(); + await occurrences.bulkOption('Set to unlimited (remove limit)').check(); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Updated capacity for 3 date(s)')).toBeVisible(); + await expect(occurrences.capacityCells().filter({ hasText: '/' })).toHaveCount(0); + }); + + test('an organizer labels every date and then removes the label', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + + await occurrences.openBulkEdit('update-label'); + await occurrences.bulkEditModal().getByLabel('New label').fill('Morning Session'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Updated label for 3 date(s)')).toBeVisible(); + await expect(occurrences.labelCells()).toHaveText(Array(3).fill('Morning Session')); + + await occurrences.openBulkEdit('update-label'); + await occurrences.bulkOption('Remove label from all dates').check(); + await occurrences.applyBulkEdit(); + + await expect(occurrences.labelCells()).toHaveCount(0); + }); + + test('an organizer moves every date online and then clears the override', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + + await occurrences.openBulkEdit('update-location'); + await occurrences.bulkEditModal().getByText('Online — provide connection details').click(); + const editor = occurrences.bulkEditModal().locator('.ProseMirror').first(); + await editor.click(); + await editor.fill('Join at https://meet.example.test/e2e'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Updated location for 3 date(s)')).toBeVisible(); + await expect(occurrences.locationCells()).toHaveText(Array(3).fill('Online')); + + await occurrences.openBulkEdit('update-location'); + await occurrences.bulkOption('Skip manually edited dates').uncheck(); + await occurrences.bulkEditModal().getByText('Clear location — fall back to the event default').click(); + await occurrences.applyBulkEdit(); + + await expect(occurrences.locationCells()).toHaveCount(0); + }); + + test('an organizer points every date at a saved venue', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + const venue = uniqueName('Dockside Hall'); + await api.createOrganizerLocation(account.organizerId, { + name: venue, + structured_address: { venue_name: venue, address_line_1: '9 Dock Road', city: 'Brooklyn', country: 'US' }, + }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + + await occurrences.openBulkEdit('update-location'); + await occurrences.bulkEditModal().getByPlaceholder('Search saved locations or find an address...').fill(venue); + await authedPage.getByRole('option', { name: venue }).click(); + await expect(occurrences.bulkEditModal().getByText('Saved location')).toBeVisible(); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Updated location for 3 date(s)')).toBeVisible(); + await expect(occurrences.locationCells()).toHaveText(Array(3).fill(`${venue}, Brooklyn`)); + }); + + test('an organizer applies a change to every matching date, not just the loaded page', async ({ authedPage, api, account }) => { + const event = await api.createEvent({ + title: uniqueName('E2E Bulk Scope'), + type: 'RECURRING', + organizer_id: account.organizerId, + start_date: daysFromNow(30, 21), + category: 'MUSIC', + currency: 'USD', + timezone: 'UTC', + }); + await api.generateOccurrences(event.id, { + frequency: 'daily', + range: { type: 'count', count: 55 }, + times_of_day: ['19:00'], + duration_minutes: 120, + }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.id); + await expect(authedPage.getByText('Showing 1–50 of 55')).toBeVisible(); + + await occurrences.openBulkEdit('update-capacity'); + await expect(occurrences.affectedCount()).toHaveText('This will affect 50 date(s).'); + + await occurrences.setBulkScope('All matching dates'); + await expect(occurrences.affectedCount()).toHaveCount(0); + await occurrences.bulkEditModal().getByLabel('New capacity').fill('15'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Updated capacity for 55 date(s)')).toBeVisible(); + await expect(occurrences.capacityCells().filter({ hasText: '/ 15' })).toHaveCount(50); + }); + + test('an organizer includes past dates by turning off the future-only filter', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + await api.createOccurrence(event.eventId, { + start_date: daysFromNow(-7, 19), + end_date: daysFromNow(-7, 21), + }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + await occurrences.selectTimePeriod('All'); + await expect(occurrences.occurrenceRows()).toHaveCount(4); + + await occurrences.openBulkEdit('update-label'); + await occurrences.bulkOption('Skip manually edited dates').uncheck(); + await expect(occurrences.affectedCount()).toHaveText('This will affect 3 date(s).'); + await occurrences.bulkOption('Future dates only').uncheck(); + await expect(occurrences.affectedCount()).toHaveText('This will affect 4 date(s).'); + + await occurrences.bulkEditModal().getByLabel('New label').fill('Season One'); + await occurrences.applyBulkEdit(); + + await expect(authedPage.getByText('Updated label for 4 date(s)')).toBeVisible(); + await expect(occurrences.labelCells()).toHaveText(Array(4).fill('Season One')); + }); + + test('an organizer cancels the selected dates from the toolbar', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + await occurrences.selectAllCheckbox().check(); + await expect(occurrences.selectionSummary()).toHaveText('3 selected'); + + await occurrences.cancelSelected(); + await expect(authedPage.getByText('Cancelling 3 date(s). This may take a moment to complete.')).toBeVisible(); + + await reloadUntil(occurrences, event.eventId, async () => { + await expect(occurrences.statusBadges('CANCELLED')).toHaveCount(3); + }); + }); + + test('an organizer deletes the selected dates but keeps the one with an order', async ({ authedPage, api, account, publicApi }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + const sold = earliest(event.occurrences); + await createCompletedOrder(publicApi, event, { eventOccurrenceId: sold.id }); + + const occurrences = new OccurrencePage(authedPage); + await occurrences.goto(event.eventId); + + await occurrences.selectAllCheckbox().check(); + await expect(occurrences.selectionSummary()).toHaveText('3 selected'); + await occurrences.clearSelection(); + await expect(occurrences.selectionSummary()).toHaveCount(0); + + await occurrences.selectAllCheckbox().check(); + await occurrences.deleteSelected(); + + await expect(authedPage.getByText('Deleted 2 date(s)')).toBeVisible(); + await expect(occurrences.occurrenceRows()).toHaveCount(1); + await expect(authedPage.getByText('Showing 1–1 of 1')).toBeVisible(); + }); + + test('an organizer is warned about registered attendees and lands in the message composer', async ({ authedPage, api, account, publicApi }) => { + test.skip(IS_SAAS_MODE, 'The composer form is gated behind Stripe/manual account verification in SaaS mode.'); + + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); + const sold = earliest(event.occurrences); + await createCompletedOrder(publicApi, event, { eventOccurrenceId: sold.id }); + + const occurrences = new OccurrencePage(authedPage); + await waitForOrderStatistics(authedPage, occurrences, event.eventId); + + await occurrences.openBulkEdit('shift-times'); + await occurrences.bulkEditModal().getByLabel('Hours').fill('2'); + await occurrences.applyBulkEdit(); + + const warning = occurrences.dialog().filter({ hasText: "You're changing session times" }); + await expect(warning.getByText('1 attendee is registered across the affected sessions.')).toBeVisible(); + await warning.getByRole('button', { name: 'Save', exact: true }).click(); + + await expect(authedPage.getByText('Shifted times for 3 date(s)')).toBeVisible(); + await expect(authedPage.getByRole('heading', { name: 'Send a message' })).toBeVisible(); + await expect(authedPage.getByLabel(/^Subject/)).toHaveValue(/schedule changes$/); + await expect(authedPage.locator('.ProseMirror').first()).toContainText('affecting 3 session(s)'); + }); +}); diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/index.tsx index b496808e19..1a77f2eed2 100644 --- a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/index.tsx +++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/index.tsx @@ -29,12 +29,12 @@ interface OccurrenceBulkEditModalProps extends GenericModalProps { } export const OccurrenceBulkEditModal = ({onClose, occurrences}: OccurrenceBulkEditModalProps) => { - const ACTIONS: { value: BulkAction; label: string; icon: typeof IconClock; description: string }[] = [ - {value: 'shift_times', label: t`Shift times`, icon: IconClock, description: t`Move all dates earlier or later`}, - {value: 'change_duration', label: t`Change duration`, icon: IconRuler, description: t`Set how long each date lasts`}, - {value: 'update_capacity', label: t`Update capacity`, icon: IconUsers, description: t`Change the attendee limit`}, - {value: 'update_label', label: t`Update label`, icon: IconTag, description: t`Set or clear the date label`}, - {value: 'update_location', label: t`Update location`, icon: IconMapPin, description: t`Set, change, or remove the date's location or online details`}, + const ACTIONS: { value: BulkAction; label: string; icon: typeof IconClock; description: string; testId: string }[] = [ + {value: 'shift_times', label: t`Shift times`, icon: IconClock, description: t`Move all dates earlier or later`, testId: 'occurrence-bulk-action-shift-times'}, + {value: 'change_duration', label: t`Change duration`, icon: IconRuler, description: t`Set how long each date lasts`, testId: 'occurrence-bulk-action-change-duration'}, + {value: 'update_capacity', label: t`Update capacity`, icon: IconUsers, description: t`Change the attendee limit`, testId: 'occurrence-bulk-action-update-capacity'}, + {value: 'update_label', label: t`Update label`, icon: IconTag, description: t`Set or clear the date label`, testId: 'occurrence-bulk-action-update-label'}, + {value: 'update_location', label: t`Update location`, icon: IconMapPin, description: t`Set, change, or remove the date's location or online details`, testId: 'occurrence-bulk-action-update-location'}, ]; const {eventId} = useParams(); const bulkUpdateMutation = useBulkUpdateOccurrences(); @@ -308,11 +308,12 @@ export const OccurrenceBulkEditModal = ({onClose, occurrences}: OccurrenceBulkEd {!selectedAction ? (
- {ACTIONS.map(({value, label, icon: Icon, description}) => ( + {ACTIONS.map(({value, label, icon: Icon, description, testId}) => ( - -
@@ -513,6 +527,7 @@ const OccurrencesTab = () => { variant="light" leftSection={} onClick={openBulkEdit} + data-testid="occurrence-bulk-edit-button" > {t`Bulk Edit`}