Implement watch-event feature (017) with bookmark in RsvpBar
Add client-side watch/bookmark functionality: users can save events to localStorage without RSVPing via a bookmark button next to the "I'm attending" CTA. Watched events appear in the event list with a "Watching" label. Bookmark is only visible for visitors (not attendees or organizers). Includes spec, plan, research, tasks, unit tests, and E2E tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
<span class="event-card__time">{{ displayTime }}</span>
|
||||
</RouterLink>
|
||||
<span v-if="eventRole" class="event-card__badge" :class="`event-card__badge--${eventRole}`">
|
||||
{{ eventRole === 'organizer' ? 'Organizer' : 'Attendee' }}
|
||||
{{ eventRole === 'organizer' ? 'Organizer' : eventRole === 'attendee' ? 'Attendee' : 'Watching' }}
|
||||
</span>
|
||||
<button
|
||||
class="event-card__delete"
|
||||
@@ -34,7 +34,7 @@ const props = defineProps<{
|
||||
title: string
|
||||
relativeTime: string
|
||||
isPast: boolean
|
||||
eventRole?: 'organizer' | 'attendee'
|
||||
eventRole?: 'organizer' | 'attendee' | 'watcher'
|
||||
timeDisplayMode?: 'clock' | 'relative'
|
||||
dateTime?: string
|
||||
}>()
|
||||
@@ -152,6 +152,12 @@ function onTouchEnd() {
|
||||
color: var(--color-text-bright);
|
||||
}
|
||||
|
||||
.event-card__badge--watcher {
|
||||
background: var(--color-glass);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
}
|
||||
|
||||
.event-card__delete {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
|
||||
@@ -65,6 +65,11 @@ const deleteDialogMessage = computed(() => {
|
||||
|
||||
function requestDelete(eventToken: string) {
|
||||
deleteError.value = ''
|
||||
const role = getRole(getStoredEvents().find((e) => e.eventToken === eventToken)!)
|
||||
if (role === 'watcher') {
|
||||
removeEvent(eventToken)
|
||||
return
|
||||
}
|
||||
pendingDeleteToken.value = eventToken
|
||||
}
|
||||
|
||||
@@ -105,10 +110,10 @@ function cancelDelete() {
|
||||
pendingDeleteToken.value = null
|
||||
}
|
||||
|
||||
function getRole(event: StoredEvent): 'organizer' | 'attendee' | undefined {
|
||||
function getRole(event: StoredEvent): 'organizer' | 'attendee' | 'watcher' {
|
||||
if (event.organizerToken) return 'organizer'
|
||||
if (event.rsvpToken) return 'attendee'
|
||||
return undefined
|
||||
return 'watcher'
|
||||
}
|
||||
|
||||
const groupedSections = computed(() => {
|
||||
|
||||
@@ -31,10 +31,22 @@
|
||||
</div>
|
||||
|
||||
<!-- CTA state: no RSVP yet -->
|
||||
<div v-else class="rsvp-bar__cta glow-border glow-border--animated">
|
||||
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
|
||||
I'm attending
|
||||
</button>
|
||||
<div v-else class="rsvp-bar__row">
|
||||
<div class="rsvp-bar__cta glow-border glow-border--animated">
|
||||
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
|
||||
I'm attending
|
||||
</button>
|
||||
</div>
|
||||
<div class="rsvp-bar__bookmark glow-border glow-border--animated">
|
||||
<button
|
||||
class="rsvp-bar__bookmark-inner glass-inner"
|
||||
type="button"
|
||||
:aria-label="bookmarked ? 'Stop watching this event' : 'Watch this event'"
|
||||
@click="$emit('bookmark')"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" :fill="bookmarked ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,11 +57,13 @@ import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
hasRsvp?: boolean
|
||||
bookmarked?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
open: []
|
||||
cancel: []
|
||||
bookmark: []
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
@@ -92,8 +106,14 @@ watch(expanded, (isExpanded) => {
|
||||
max-width: var(--content-max-width);
|
||||
}
|
||||
|
||||
.rsvp-bar__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rsvp-bar__cta {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
@@ -206,4 +226,37 @@ watch(expanded, (isExpanded) => {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-button);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: calc(var(--radius-button) - 2px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-on-gradient);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.rsvp-bar__bookmark-inner svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -63,6 +63,12 @@ describe('EventCard', () => {
|
||||
expect(wrapper.text()).toContain('Attendee')
|
||||
})
|
||||
|
||||
it('renders watcher badge when eventRole is watcher', () => {
|
||||
const wrapper = mountCard({ eventRole: 'watcher' })
|
||||
expect(wrapper.find('.event-card__badge--watcher').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Watching')
|
||||
})
|
||||
|
||||
it('renders no badge when eventRole is undefined', () => {
|
||||
const wrapper = mountCard({ eventRole: undefined })
|
||||
expect(wrapper.find('.event-card__badge').exists()).toBe(false)
|
||||
|
||||
@@ -20,6 +20,8 @@ const mockEvents = [
|
||||
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
|
||||
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
|
||||
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
|
||||
{ eventToken: 'org-1', title: 'Organized Event', dateTime: '2026-03-11T19:00:00', organizerToken: 'org-token' },
|
||||
{ eventToken: 'rsvp-1', title: 'Attending Event', dateTime: '2026-03-11T20:00:00', rsvpToken: 'rsvp-token', rsvpName: 'Max' },
|
||||
]
|
||||
|
||||
vi.mock('../../composables/useEventStorage', () => ({
|
||||
@@ -32,6 +34,13 @@ vi.mock('../../composables/useEventStorage', () => ({
|
||||
},
|
||||
useEventStorage: () => ({
|
||||
getStoredEvents: () => mockEvents,
|
||||
getRsvp: (token: string) => {
|
||||
const evt = mockEvents.find((e) => e.eventToken === token)
|
||||
if (evt && 'rsvpToken' in evt && 'rsvpName' in evt) {
|
||||
return { rsvpToken: evt.rsvpToken, rsvpName: evt.rsvpName }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
removeEvent: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
@@ -40,7 +49,9 @@ vi.mock('../../composables/useRelativeTime', () => ({
|
||||
formatRelativeTime: (dateTime: string) => {
|
||||
if (dateTime.includes('03-01')) return '10 days ago'
|
||||
if (dateTime.includes('06-15')) return 'in 1 year'
|
||||
if (dateTime.includes('03-11')) return 'in 6 hours'
|
||||
if (dateTime.includes('03-11T18')) return 'in 6 hours'
|
||||
if (dateTime.includes('03-11T19')) return 'in 7 hours'
|
||||
if (dateTime.includes('03-11T20')) return 'in 8 hours'
|
||||
if (dateTime.includes('03-13')) return 'in 2 days'
|
||||
if (dateTime.includes('03-16')) return 'in 5 days'
|
||||
return 'sometime'
|
||||
@@ -89,7 +100,7 @@ describe('EventList', () => {
|
||||
it('renders all valid events as cards', () => {
|
||||
const wrapper = mountList()
|
||||
const cards = wrapper.findAll('.event-card')
|
||||
expect(cards).toHaveLength(5)
|
||||
expect(cards).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('marks past events with isPast class', () => {
|
||||
@@ -137,4 +148,25 @@ describe('EventList', () => {
|
||||
const pastSection = wrapper.findAll('.event-section')[4]!
|
||||
expect(pastSection.find('.date-subheader').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('assigns watcher role when event has no organizerToken and no rsvpToken', () => {
|
||||
const wrapper = mountList()
|
||||
const badges = wrapper.findAll('.event-card__badge--watcher')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
expect(badges[0]!.text()).toBe('Watching')
|
||||
})
|
||||
|
||||
it('assigns organizer role when event has organizerToken', () => {
|
||||
const wrapper = mountList()
|
||||
const badge = wrapper.find('.event-card__badge--organizer')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('Organizer')
|
||||
})
|
||||
|
||||
it('assigns attendee role when event has rsvpToken', () => {
|
||||
const wrapper = mountList()
|
||||
const badge = wrapper.find('.event-card__badge--attendee')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toBe('Attendee')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,6 +194,71 @@ describe('useEventStorage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEventStorage – saveWatch / isStored', () => {
|
||||
beforeEach(() => {
|
||||
clearStorage()
|
||||
})
|
||||
|
||||
it('saves a watch-only event (no rsvpToken, no organizerToken)', () => {
|
||||
const { saveWatch, getStoredEvents } = useEventStorage()
|
||||
|
||||
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
const events = getStoredEvents()
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]!.eventToken).toBe('watch-1')
|
||||
expect(events[0]!.title).toBe('Concert')
|
||||
expect(events[0]!.dateTime).toBe('2026-07-01T20:00:00+02:00')
|
||||
expect(events[0]!.rsvpToken).toBeUndefined()
|
||||
expect(events[0]!.organizerToken).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not duplicate if event already stored', () => {
|
||||
const { saveWatch, saveRsvp, getStoredEvents } = useEventStorage()
|
||||
|
||||
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
|
||||
saveWatch('evt-1', 'Party', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
expect(getStoredEvents()).toHaveLength(1)
|
||||
expect(getStoredEvents()[0]!.rsvpToken).toBe('rsvp-1')
|
||||
})
|
||||
|
||||
it('isStored returns true for watched events', () => {
|
||||
const { saveWatch, isStored } = useEventStorage()
|
||||
|
||||
saveWatch('watch-1', 'Concert', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
expect(isStored('watch-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStored returns true for attended events', () => {
|
||||
const { saveRsvp, isStored } = useEventStorage()
|
||||
|
||||
saveRsvp('evt-1', 'rsvp-1', 'Max', 'Party', '2026-07-01T20:00:00+02:00')
|
||||
|
||||
expect(isStored('evt-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStored returns true for organized events', () => {
|
||||
const { saveCreatedEvent, isStored } = useEventStorage()
|
||||
|
||||
saveCreatedEvent({
|
||||
eventToken: 'evt-1',
|
||||
organizerToken: 'org-1',
|
||||
title: 'My Event',
|
||||
dateTime: '2026-07-01T20:00:00+02:00',
|
||||
})
|
||||
|
||||
expect(isStored('evt-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStored returns false for unknown tokens', () => {
|
||||
const { isStored } = useEventStorage()
|
||||
|
||||
expect(isStored('unknown')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidStoredEvent', () => {
|
||||
// Import directly since it's an exported function
|
||||
let isValidStoredEvent: (e: unknown) => boolean
|
||||
|
||||
@@ -88,10 +88,24 @@ export function useEventStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
function saveWatch(eventToken: string, title: string, dateTime: string): void {
|
||||
const events = readEvents()
|
||||
const existing = events.find((e) => e.eventToken === eventToken)
|
||||
if (!existing) {
|
||||
events.push({ eventToken, title, dateTime })
|
||||
writeEvents(events)
|
||||
}
|
||||
}
|
||||
|
||||
function isStored(eventToken: string): boolean {
|
||||
void version.value
|
||||
return readEvents().some((e) => e.eventToken === eventToken)
|
||||
}
|
||||
|
||||
function removeEvent(eventToken: string): void {
|
||||
const events = readEvents().filter((e) => e.eventToken !== eventToken)
|
||||
writeEvents(events)
|
||||
}
|
||||
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, removeEvent }
|
||||
return { saveCreatedEvent, getStoredEvents, getOrganizerToken, saveRsvp, getRsvp, removeRsvp, saveWatch, isStored, removeEvent }
|
||||
}
|
||||
|
||||
@@ -35,21 +35,21 @@
|
||||
|
||||
<dl class="detail__meta">
|
||||
<div class="detail__meta-item">
|
||||
<dt class="detail__meta-icon glass" aria-label="Date and time">
|
||||
<dt class="detail__meta-icon" aria-label="Date and time">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ formattedDateTime }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="event.location" class="detail__meta-item">
|
||||
<dt class="detail__meta-icon glass" aria-label="Location">
|
||||
<dt class="detail__meta-icon" aria-label="Location">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ event.location }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="detail__meta-item">
|
||||
<dt class="detail__meta-icon glass" aria-label="Attendees">
|
||||
<dt class="detail__meta-icon" aria-label="Attendees">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</dt>
|
||||
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
|
||||
@@ -120,8 +120,10 @@
|
||||
<RsvpBar
|
||||
v-if="state === 'loaded' && event && !isOrganizer && !event.cancelled"
|
||||
:has-rsvp="!!rsvpName"
|
||||
:bookmarked="eventIsStored"
|
||||
@open="sheetOpen = true"
|
||||
@cancel="confirmCancelOpen = true"
|
||||
@bookmark="handleBookmarkClick"
|
||||
/>
|
||||
|
||||
<!-- Cancel confirmation dialog -->
|
||||
@@ -179,7 +181,7 @@ type GetEventResponse = components['schemas']['GetEventResponse']
|
||||
type State = 'loading' | 'loaded' | 'not-found' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken } = useEventStorage()
|
||||
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
|
||||
|
||||
const state = ref<State>('loading')
|
||||
const event = ref<GetEventResponse | null>(null)
|
||||
@@ -202,6 +204,20 @@ const cancelReasonInput = ref('')
|
||||
const cancelEventError = ref('')
|
||||
const cancellingEvent = ref(false)
|
||||
|
||||
const eventToken = computed(() => route.params.eventToken as string)
|
||||
|
||||
const eventIsStored = computed(() => isStored(eventToken.value))
|
||||
|
||||
function handleBookmarkClick() {
|
||||
if (!event.value) return
|
||||
if (isOrganizer.value || rsvpName.value) return
|
||||
if (eventIsStored.value) {
|
||||
removeEvent(eventToken.value)
|
||||
} else {
|
||||
saveWatch(eventToken.value, event.value.title, event.value.dateTime)
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDateTime = computed(() => {
|
||||
if (!event.value) return ''
|
||||
const formatted = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -469,6 +485,10 @@ onMounted(fetchEvent)
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.detail__meta-icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.detail__title {
|
||||
font-size: 2rem;
|
||||
@@ -501,6 +521,11 @@ onMounted(fetchEvent)
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: var(--color-text-on-gradient);
|
||||
line-height: 0;
|
||||
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.detail__meta-text {
|
||||
|
||||
@@ -164,6 +164,8 @@ describe('EventCreateView', () => {
|
||||
saveRsvp: vi.fn(),
|
||||
getRsvp: vi.fn(),
|
||||
removeRsvp: vi.fn(),
|
||||
saveWatch: vi.fn(),
|
||||
isStored: vi.fn(() => false),
|
||||
removeEvent: vi.fn(),
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ vi.mock('@/api/client', () => ({
|
||||
const mockSaveRsvp = vi.fn()
|
||||
const mockGetRsvp = vi.fn()
|
||||
const mockGetOrganizerToken = vi.fn()
|
||||
const mockSaveWatch = vi.fn()
|
||||
const mockIsStored = vi.fn()
|
||||
const mockRemoveEvent = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useEventStorage', () => ({
|
||||
useEventStorage: vi.fn(() => ({
|
||||
@@ -22,7 +25,10 @@ vi.mock('@/composables/useEventStorage', () => ({
|
||||
getOrganizerToken: mockGetOrganizerToken,
|
||||
saveRsvp: mockSaveRsvp,
|
||||
getRsvp: mockGetRsvp,
|
||||
removeEvent: vi.fn(),
|
||||
removeRsvp: vi.fn(),
|
||||
saveWatch: mockSaveWatch,
|
||||
isStored: mockIsStored,
|
||||
removeEvent: mockRemoveEvent,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -68,6 +74,9 @@ beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockGetRsvp.mockReturnValue(undefined)
|
||||
mockGetOrganizerToken.mockReturnValue(undefined)
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockSaveWatch.mockClear()
|
||||
mockRemoveEvent.mockClear()
|
||||
})
|
||||
|
||||
describe('EventDetailView', () => {
|
||||
@@ -366,4 +375,89 @@ describe('EventDetailView', () => {
|
||||
expect(document.body.textContent).toContain('Could not submit RSVP. Please try again.')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Bookmark — T007: bookmark state is passed to RsvpBar via props
|
||||
it('passes bookmarked=false to RsvpBar when event is not in storage', async () => {
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes bookmarked=true to RsvpBar when event is in storage', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event emitted from RsvpBar calls saveWatch', async () => {
|
||||
mockIsStored.mockReturnValue(false)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSaveWatch).toHaveBeenCalledWith('test-token', 'Summer BBQ', '2026-03-15T20:00:00+01:00')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event emitted from RsvpBar calls removeEvent when user is watcher', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRemoveEvent).toHaveBeenCalledWith('test-token')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('bookmark event ignored when user is attendee', async () => {
|
||||
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
rsvpBar.vm.$emit('bookmark')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockRemoveEvent).not.toHaveBeenCalled()
|
||||
expect(mockSaveWatch).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes bookmarked=true to RsvpBar after removeRsvp (event still in storage)', async () => {
|
||||
mockIsStored.mockReturnValue(true)
|
||||
mockGetRsvp.mockReturnValue(undefined)
|
||||
mockLoadedEvent()
|
||||
|
||||
const wrapper = await mountWithToken()
|
||||
await flushPromises()
|
||||
|
||||
const rsvpBar = wrapper.findComponent({ name: 'RsvpBar' })
|
||||
expect(rsvpBar.props('bookmarked')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user