Add event list feature (009-list-events)
Enable users to see all their saved events on the home screen, sorted by date with upcoming events first. Key capabilities: - EventCard with title, relative time display, and organizer/attendee role badge - Sortable EventList with past-event visual distinction (faded style) - Empty state when no events are stored - Swipe-to-delete gesture with confirmation dialog - Floating action button for quick event creation - Rename router param :token → :eventToken across all views - useRelativeTime composable (Intl.RelativeTimeFormat) - useEventStorage: add validation, removeEvent(), reactive versioning - Full E2E and unit test coverage for all new components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
111
frontend/src/components/__tests__/ConfirmDialog.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
|
||||
let wrapper: VueWrapper
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
wrapper = mount(ConfirmDialog, {
|
||||
props: {
|
||||
open: true,
|
||||
...props,
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector('.confirm-dialog')
|
||||
}
|
||||
|
||||
function overlay() {
|
||||
return document.body.querySelector('.confirm-dialog__overlay')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('renders when open is true', () => {
|
||||
mountDialog()
|
||||
expect(dialog()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
mountDialog({ open: false })
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('displays default title', () => {
|
||||
mountDialog()
|
||||
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Are you sure?')
|
||||
})
|
||||
|
||||
it('displays custom title and message', () => {
|
||||
mountDialog({
|
||||
title: 'Remove event?',
|
||||
message: 'This cannot be undone.',
|
||||
})
|
||||
expect(dialog()!.querySelector('.confirm-dialog__title')!.textContent).toBe('Remove event?')
|
||||
expect(dialog()!.querySelector('.confirm-dialog__message')!.textContent).toBe('This cannot be undone.')
|
||||
})
|
||||
|
||||
it('displays custom button labels', () => {
|
||||
mountDialog({
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Keep',
|
||||
})
|
||||
const buttons = dialog()!.querySelectorAll('.confirm-dialog__btn')
|
||||
expect(buttons[0]!.textContent!.trim()).toBe('Keep')
|
||||
expect(buttons[1]!.textContent!.trim()).toBe('Delete')
|
||||
})
|
||||
|
||||
it('emits confirm when confirm button is clicked', async () => {
|
||||
mountDialog()
|
||||
const btn = dialog()!.querySelector('.confirm-dialog__btn--confirm') as HTMLElement
|
||||
btn.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('confirm')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits cancel when cancel button is clicked', async () => {
|
||||
mountDialog()
|
||||
const btn = dialog()!.querySelector('.confirm-dialog__btn--cancel') as HTMLElement
|
||||
btn.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits cancel when overlay is clicked', async () => {
|
||||
mountDialog()
|
||||
const el = overlay() as HTMLElement
|
||||
el.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits cancel when Escape key is pressed', async () => {
|
||||
mountDialog()
|
||||
const el = dialog() as HTMLElement
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('focuses cancel button when opened', async () => {
|
||||
mountDialog({ open: false })
|
||||
await wrapper.setProps({ open: true })
|
||||
await wrapper.vm.$nextTick()
|
||||
const cancelBtn = dialog()!.querySelector('.confirm-dialog__btn--cancel')
|
||||
expect(document.activeElement).toBe(cancelBtn)
|
||||
})
|
||||
|
||||
it('has alertdialog role and aria-modal', () => {
|
||||
mountDialog()
|
||||
const el = dialog() as HTMLElement
|
||||
expect(el.getAttribute('role')).toBe('alertdialog')
|
||||
expect(el.getAttribute('aria-modal')).toBe('true')
|
||||
})
|
||||
})
|
||||
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
35
frontend/src/components/__tests__/EmptyState.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EmptyState from '../EmptyState.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/create', name: 'create', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
function mountEmptyState() {
|
||||
return mount(EmptyState, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders an inviting message', () => {
|
||||
const wrapper = mountEmptyState()
|
||||
expect(wrapper.text()).toContain('No events yet')
|
||||
})
|
||||
|
||||
it('renders a Create Event link', () => {
|
||||
const wrapper = mountEmptyState()
|
||||
const link = wrapper.find('a')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toContain('Create Event')
|
||||
expect(link.attributes('href')).toBe('/create')
|
||||
})
|
||||
})
|
||||
76
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
76
frontend/src/components/__tests__/EventCard.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventCard from '../EventCard.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
function mountCard(props: Record<string, unknown> = {}) {
|
||||
return mount(EventCard, {
|
||||
props: {
|
||||
eventToken: 'abc-123',
|
||||
title: 'Birthday Party',
|
||||
relativeTime: 'in 3 days',
|
||||
isPast: false,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('EventCard', () => {
|
||||
it('renders the event title', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Birthday Party')
|
||||
})
|
||||
|
||||
it('renders relative time', () => {
|
||||
const wrapper = mountCard({ relativeTime: 'yesterday' })
|
||||
expect(wrapper.text()).toContain('yesterday')
|
||||
})
|
||||
|
||||
it('links to the event detail page', () => {
|
||||
const wrapper = mountCard({ eventToken: 'xyz-789' })
|
||||
const link = wrapper.find('a')
|
||||
expect(link.attributes('href')).toBe('/events/xyz-789')
|
||||
})
|
||||
|
||||
it('applies past modifier class when isPast is true', () => {
|
||||
const wrapper = mountCard({ isPast: true })
|
||||
expect(wrapper.find('.event-card--past').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply past modifier class when isPast is false', () => {
|
||||
const wrapper = mountCard({ isPast: false })
|
||||
expect(wrapper.find('.event-card--past').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders organizer badge when eventRole is organizer', () => {
|
||||
const wrapper = mountCard({ eventRole: 'organizer' })
|
||||
expect(wrapper.text()).toContain('Organizer')
|
||||
})
|
||||
|
||||
it('renders attendee badge when eventRole is attendee', () => {
|
||||
const wrapper = mountCard({ eventRole: 'attendee' })
|
||||
expect(wrapper.text()).toContain('Attendee')
|
||||
})
|
||||
|
||||
it('renders no badge when eventRole is undefined', () => {
|
||||
const wrapper = mountCard({ eventRole: undefined })
|
||||
expect(wrapper.find('.event-card__badge').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits delete event with eventToken when delete button is clicked', async () => {
|
||||
const wrapper = mountCard({ eventToken: 'abc-123' })
|
||||
await wrapper.find('.event-card__delete').trigger('click')
|
||||
expect(wrapper.emitted('delete')).toEqual([['abc-123']])
|
||||
})
|
||||
})
|
||||
79
frontend/src/components/__tests__/EventList.spec.ts
Normal file
79
frontend/src/components/__tests__/EventList.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventList from '../EventList.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/events/:eventToken', name: 'event', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
const mockEvents = [
|
||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2025-01-01T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'future-1', title: 'Future Event', dateTime: '2027-06-15T10:00:00Z', expiryDate: '' },
|
||||
{ eventToken: 'future-2', title: 'Soon Event', dateTime: '2027-01-01T10:00:00Z', expiryDate: '' },
|
||||
]
|
||||
|
||||
vi.mock('../../composables/useEventStorage', () => ({
|
||||
isValidStoredEvent: (e: unknown) => {
|
||||
if (typeof e !== 'object' || e === null) return false
|
||||
const obj = e as Record<string, unknown>
|
||||
return typeof obj.eventToken === 'string' && obj.eventToken.length > 0
|
||||
&& typeof obj.title === 'string' && obj.title.length > 0
|
||||
&& typeof obj.dateTime === 'string' && obj.dateTime.length > 0
|
||||
},
|
||||
useEventStorage: () => ({
|
||||
getStoredEvents: () => mockEvents,
|
||||
removeEvent: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../composables/useRelativeTime', () => ({
|
||||
formatRelativeTime: (dateTime: string) => {
|
||||
if (dateTime.startsWith('2025')) return '1 year ago'
|
||||
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||
return 'in 10 months'
|
||||
},
|
||||
}))
|
||||
|
||||
function mountList() {
|
||||
return mount(EventList, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('EventList', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-08T12:00:00Z'))
|
||||
})
|
||||
|
||||
it('renders all valid events', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('sorts upcoming events before past events', () => {
|
||||
const wrapper = mountList()
|
||||
const titles = wrapper.findAll('.event-card__title').map((el) => el.text())
|
||||
// Upcoming events first (sorted ascending), then past events
|
||||
expect(titles[0]).toBe('Soon Event')
|
||||
expect(titles[1]).toBe('Future Event')
|
||||
expect(titles[2]).toBe('Past Event')
|
||||
})
|
||||
|
||||
it('marks past events with isPast class', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
// Last card should be past
|
||||
expect(cards[2]!.classes()).toContain('event-card--past')
|
||||
// First two should not be past
|
||||
expect(cards[0]!.classes()).not.toContain('event-card--past')
|
||||
expect(cards[1]!.classes()).not.toContain('event-card--past')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user