Add organizer cancel-event flow to EventList
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 26s
CI / frontend-e2e (push) Successful in 1m35s
CI / build-and-publish (push) Has been skipped

Organizers can now cancel events directly from the event list via the
existing PATCH /events/{eventToken} API. The confirmation dialog shows
role-differentiated messaging: "Cancel event?" with a severity warning
for organizers vs. "Remove event?" for attendees. Responses 204, 409,
and 404 all result in successful removal from the local list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 16:23:04 +01:00
parent 51ab99fc61
commit b067c0ef1e
12 changed files with 838 additions and 21 deletions

View File

@@ -27,7 +27,7 @@
</section>
<ConfirmDialog
:open="!!pendingDeleteToken"
title="Remove event?"
:title="deleteDialogTitle"
:message="deleteDialogMessage"
confirm-label="Remove"
cancel-label="Cancel"
@@ -49,13 +49,26 @@ import DateSubheader from './DateSubheader.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import type { StoredEvent } from '../composables/useEventStorage'
const { getStoredEvents, getRsvp, removeEvent } = useEventStorage()
const { getStoredEvents, getRsvp, getOrganizerToken, removeEvent } = useEventStorage()
const pendingDeleteToken = ref<string | null>(null)
const deleteError = ref('')
const pendingDeleteRole = computed(() => {
if (!pendingDeleteToken.value) return null
const event = getStoredEvents().find((e) => e.eventToken === pendingDeleteToken.value)
return event ? getRole(event) : null
})
const deleteDialogTitle = computed(() => {
return pendingDeleteRole.value === 'organizer' ? 'Cancel event?' : 'Remove event?'
})
const deleteDialogMessage = computed(() => {
if (!pendingDeleteToken.value) return ''
if (pendingDeleteRole.value === 'organizer') {
return 'This will permanently cancel the event for all attendees.'
}
const rsvp = getRsvp(pendingDeleteToken.value)
if (rsvp) {
return 'This event will be removed from your list and your attendance will be cancelled.'
@@ -77,6 +90,32 @@ async function confirmDelete() {
if (!pendingDeleteToken.value) return
const eventToken = pendingDeleteToken.value
const organizerToken = getOrganizerToken(eventToken)
if (organizerToken) {
try {
const { response } = await api.PATCH('/events/{eventToken}', {
params: {
path: { eventToken },
query: { organizerToken },
},
body: { cancelled: true },
})
if (response.status !== 204 && response.status !== 409 && response.status !== 404) {
deleteError.value = 'Could not cancel event. Please try again.'
return
}
} catch {
deleteError.value = 'Could not cancel event. Please try again.'
return
}
removeEvent(eventToken)
pendingDeleteToken.value = null
return
}
const rsvp = getRsvp(eventToken)
if (rsvp) {

View File

@@ -1,8 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import EventList from '../EventList.vue'
vi.mock('../../api/client', () => ({
api: {
PATCH: vi.fn(),
DELETE: vi.fn(),
},
}))
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -24,6 +31,8 @@ const mockEvents = [
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
]
const removeEventMock = vi.fn()
vi.mock('../../composables/useEventStorage', () => ({
isValidStoredEvent: (e: unknown) => {
if (typeof e !== 'object' || e === null) return false
@@ -41,7 +50,14 @@ vi.mock('../../composables/useEventStorage', () => ({
}
return undefined
},
removeEvent: vi.fn(),
getOrganizerToken: (token: string) => {
const evt = mockEvents.find((e) => e.eventToken === token)
if (evt && 'organizerToken' in evt) {
return (evt as Record<string, unknown>).organizerToken as string
}
return undefined
},
removeEvent: removeEventMock,
}),
}))
@@ -60,7 +76,10 @@ vi.mock('../../composables/useRelativeTime', () => ({
function mountList() {
return mount(EventList, {
global: { plugins: [router] },
global: {
plugins: [router],
stubs: { Teleport: true },
},
})
}
@@ -170,3 +189,118 @@ describe('EventList', () => {
expect(badge.text()).toBe('Attending')
})
})
describe('EventList — Organizer Cancel (US1)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('T005: confirmDelete calls PATCH cancel-event API when role is organizer', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 204 } } as never)
const wrapper = mountList()
// Find the organizer event delete button and click it
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
expect(orgCard).toBeTruthy()
await orgCard!.find('.event-card__delete').trigger('click')
// Confirm the dialog
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
expect(patchMock).toHaveBeenCalledWith(
'/events/{eventToken}',
expect.objectContaining({
params: expect.objectContaining({
path: { eventToken: 'org-1' },
query: { organizerToken: 'org-token' },
}),
body: { cancelled: true },
}),
)
})
it('T006: confirmDelete treats 409 response as success (removes event from list)', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 409 } } as never)
removeEventMock.mockClear()
const wrapper = mountList()
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
// 409 should be treated as success — removeEvent should have been called
expect(removeEventMock).toHaveBeenCalledWith('org-1')
})
it('T006b: confirmDelete treats 404 response as success (removes event from list)', async () => {
const { api } = await import('../../api/client')
const patchMock = vi.mocked(api.PATCH)
patchMock.mockResolvedValue({ response: { status: 404 } } as never)
removeEventMock.mockClear()
const wrapper = mountList()
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const confirmBtn = wrapper.find('.confirm-dialog__btn--confirm')
await confirmBtn.trigger('click')
await flushPromises()
expect(removeEventMock).toHaveBeenCalledWith('org-1')
})
})
describe('EventList — Dialog Differentiation (US2)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('T013: deleteDialogTitle returns organizer-specific text when role is organizer', async () => {
const wrapper = mountList()
// Click delete on organizer event
const orgCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Organized Event'))
await orgCard!.find('.event-card__delete').trigger('click')
const dialogTitle = wrapper.find('.confirm-dialog__title')
expect(dialogTitle.text()).toBe('Cancel event?')
})
it('T014: deleteDialogMessage returns existing attendee text when role is attendee', async () => {
const wrapper = mountList()
// Click delete on attendee event
const attCard = wrapper.findAll('.event-card').find((c) => c.text().includes('Attending Event'))
await attCard!.find('.event-card__delete').trigger('click')
const dialogTitle = wrapper.find('.confirm-dialog__title')
expect(dialogTitle.text()).toBe('Remove event?')
const dialogMsg = wrapper.find('.confirm-dialog__message')
expect(dialogMsg.text()).toContain('attendance will be cancelled')
})
})