Implement event creation frontend (EventCreateView)
Form with client-side validation, server error handling, aria-invalid/ aria-describedby for a11y, localStorage persistence via useEventStorage composable. Routes for /create and /events/:token. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
258
frontend/src/views/EventCreateView.vue
Normal file
258
frontend/src/views/EventCreateView.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<main class="create">
|
||||
<header class="create__header">
|
||||
<RouterLink to="/" class="create__back" aria-label="Back to home">←</RouterLink>
|
||||
<h1 class="create__title">Create</h1>
|
||||
</header>
|
||||
|
||||
<form class="create__form" novalidate @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label for="title" class="form-label">Title *</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="form-field"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="What's the event?"
|
||||
:aria-invalid="!!errors.title"
|
||||
:aria-describedby="errors.title ? 'title-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.title" id="title-error" class="field-error" role="alert">{{ errors.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
class="form-field"
|
||||
maxlength="2000"
|
||||
placeholder="Tell people more about it…"
|
||||
:aria-invalid="!!errors.description"
|
||||
:aria-describedby="errors.description ? 'description-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.description" id="description-error" class="field-error" role="alert">{{ errors.description }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dateTime" class="form-label">Date & Time *</label>
|
||||
<input
|
||||
id="dateTime"
|
||||
v-model="form.dateTime"
|
||||
type="datetime-local"
|
||||
class="form-field"
|
||||
required
|
||||
:aria-invalid="!!errors.dateTime"
|
||||
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.dateTime" id="dateTime-error" class="field-error" role="alert">{{ errors.dateTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location" class="form-label">Location</label>
|
||||
<input
|
||||
id="location"
|
||||
v-model="form.location"
|
||||
type="text"
|
||||
class="form-field"
|
||||
maxlength="500"
|
||||
placeholder="Where is it?"
|
||||
:aria-invalid="!!errors.location"
|
||||
:aria-describedby="errors.location ? 'location-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiryDate" class="form-label">Expiry Date *</label>
|
||||
<input
|
||||
id="expiryDate"
|
||||
v-model="form.expiryDate"
|
||||
type="date"
|
||||
class="form-field"
|
||||
required
|
||||
:min="tomorrow"
|
||||
:aria-invalid="!!errors.expiryDate"
|
||||
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
|
||||
/>
|
||||
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Creating…' : 'Create Event' }}
|
||||
</button>
|
||||
|
||||
<p v-if="serverError" class="field-error text-center" role="alert">{{ serverError }}</p>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useEventStorage } from '@/composables/useEventStorage'
|
||||
|
||||
const router = useRouter()
|
||||
const { saveCreatedEvent } = useEventStorage()
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
dateTime: '',
|
||||
location: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
const tomorrow = computed(() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 1)
|
||||
return d.toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
function clearErrors() {
|
||||
errors.title = ''
|
||||
errors.description = ''
|
||||
errors.dateTime = ''
|
||||
errors.location = ''
|
||||
errors.expiryDate = ''
|
||||
serverError.value = ''
|
||||
}
|
||||
|
||||
// Clear individual field errors when the user types
|
||||
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
||||
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
||||
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
|
||||
watch(() => form.description, () => { serverError.value = '' })
|
||||
watch(() => form.location, () => { serverError.value = '' })
|
||||
|
||||
function validate(): boolean {
|
||||
clearErrors()
|
||||
let valid = true
|
||||
|
||||
if (!form.title.trim()) {
|
||||
errors.title = 'Title is required.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.dateTime) {
|
||||
errors.dateTime = 'Date and time are required.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.expiryDate) {
|
||||
errors.expiryDate = 'Expiry date is required.'
|
||||
valid = false
|
||||
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
|
||||
errors.expiryDate = 'Expiry date must be in the future.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!validate()) return
|
||||
|
||||
submitting.value = true
|
||||
|
||||
// Build ISO 8601 dateTime with local timezone offset
|
||||
const localDate = new Date(form.dateTime)
|
||||
const offsetMinutes = -localDate.getTimezoneOffset()
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-'
|
||||
const absOffset = Math.abs(offsetMinutes)
|
||||
const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, '0')
|
||||
const offsetMins = String(absOffset % 60).padStart(2, '0')
|
||||
const dateTimeWithOffset = form.dateTime + ':00' + sign + offsetHours + ':' + offsetMins
|
||||
|
||||
try {
|
||||
const { data, error } = await api.POST('/events', {
|
||||
body: {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
dateTime: dateTimeWithOffset,
|
||||
location: form.location.trim() || undefined,
|
||||
expiryDate: form.expiryDate,
|
||||
},
|
||||
})
|
||||
|
||||
submitting.value = false
|
||||
|
||||
if (error) {
|
||||
if ('fieldErrors' in error && Array.isArray(error.fieldErrors)) {
|
||||
for (const fe of error.fieldErrors) {
|
||||
const field = fe.field as keyof typeof errors
|
||||
if (field in errors) {
|
||||
errors[field] = fe.message
|
||||
}
|
||||
}
|
||||
} else {
|
||||
serverError.value = error.detail || 'Something went wrong. Please try again.'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
saveCreatedEvent({
|
||||
eventToken: data.eventToken,
|
||||
organizerToken: data.organizerToken,
|
||||
title: data.title,
|
||||
dateTime: data.dateTime,
|
||||
expiryDate: data.expiryDate,
|
||||
})
|
||||
|
||||
router.push({ name: 'event', params: { token: data.eventToken } })
|
||||
}
|
||||
} catch {
|
||||
submitting.value = false
|
||||
serverError.value = 'Could not reach the server. Please try again.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.create {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.create__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.create__back {
|
||||
color: var(--color-text-on-gradient);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.create__title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.create__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
<main class="home">
|
||||
<h1 class="home__title">fete</h1>
|
||||
<p class="home__subtitle">No events yet.<br />Create your first one!</p>
|
||||
<RouterLink to="/create" class="btn-primary home__cta">+ Create Event</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home__title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-on-gradient);
|
||||
}
|
||||
|
||||
.home__subtitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-on-gradient);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.home__cta {
|
||||
margin-top: var(--spacing-md);
|
||||
max-width: 280px;
|
||||
}
|
||||
</style>
|
||||
|
||||
255
frontend/src/views/__tests__/EventCreateView.spec.ts
Normal file
255
frontend/src/views/__tests__/EventCreateView.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import EventCreateView from '../EventCreateView.vue'
|
||||
import { api } from '@/api/client'
|
||||
vi.mock('@/api/client', () => ({
|
||||
api: {
|
||||
POST: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useEventStorage', () => ({
|
||||
useEventStorage: vi.fn(() => ({
|
||||
saveCreatedEvent: vi.fn(),
|
||||
getStoredEvents: vi.fn(() => []),
|
||||
getOrganizerToken: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||
{ path: '/create', name: 'create-event', component: EventCreateView },
|
||||
{ path: '/events/:token', name: 'event', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('EventCreateView', () => {
|
||||
it('renders all form fields', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('#title').exists()).toBe(true)
|
||||
expect(wrapper.find('#description').exists()).toBe(true)
|
||||
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
||||
expect(wrapper.find('#location').exists()).toBe(true)
|
||||
expect(wrapper.find('#expiryDate').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has required attribute on required fields', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
||||
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not have required attribute on optional fields', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('#description').attributes('required')).toBeUndefined()
|
||||
expect(wrapper.find('#location').attributes('required')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has a submit button', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text()).toBe('Create Event')
|
||||
})
|
||||
|
||||
it('shows server error when network request fails', async () => {
|
||||
vi.mocked(api.POST).mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
// Fill required fields
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const alerts = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(alerts).toContain('Could not reach the server. Please try again.')
|
||||
|
||||
// Submit button should not remain disabled
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears field error when user types into that field', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
// Submit empty form to trigger validation errors
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Type into title field
|
||||
await wrapper.find('#title').setValue('My Event')
|
||||
|
||||
// Title error should be cleared (span removed from DOM), but other errors should remain
|
||||
const titleError = wrapper.find('#title').element.closest('.form-group')!.querySelector('[role="alert"]')
|
||||
expect(titleError).toBeNull()
|
||||
|
||||
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(dateTimeError.textContent).not.toBe('')
|
||||
|
||||
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||
expect(expiryError.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('shows validation errors when submitting empty form', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const errorElements = wrapper.findAll('[role="alert"]')
|
||||
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
||||
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
||||
const mockSave = vi.fn()
|
||||
vi.mocked(vi.importActual<typeof import('@/composables/useEventStorage')>)
|
||||
const { useEventStorage } = await import('@/composables/useEventStorage')
|
||||
vi.mocked(useEventStorage).mockReturnValue({
|
||||
saveCreatedEvent: mockSave,
|
||||
getStoredEvents: vi.fn(() => []),
|
||||
getOrganizerToken: vi.fn(),
|
||||
})
|
||||
|
||||
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||
data: {
|
||||
eventToken: 'abc-123',
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
error: undefined,
|
||||
response: new Response(),
|
||||
})
|
||||
|
||||
const router = createTestRouter()
|
||||
const pushSpy = vi.spyOn(router, 'push')
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('#title').setValue('Birthday Party')
|
||||
await wrapper.find('#description').setValue('Come celebrate!')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#location').setValue('Berlin')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.POST)).toHaveBeenCalledWith('/events', {
|
||||
body: expect.objectContaining({
|
||||
title: 'Birthday Party',
|
||||
description: 'Come celebrate!',
|
||||
location: 'Berlin',
|
||||
expiryDate: '2026-12-24',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(mockSave).toHaveBeenCalledWith({
|
||||
eventToken: 'abc-123',
|
||||
organizerToken: 'org-456',
|
||||
title: 'Birthday Party',
|
||||
dateTime: '2026-12-25T18:00:00+01:00',
|
||||
expiryDate: '2026-12-24',
|
||||
})
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
name: 'event',
|
||||
params: { token: 'abc-123' },
|
||||
})
|
||||
})
|
||||
|
||||
it('displays server-side field errors on the correct fields', async () => {
|
||||
vi.mocked(api.POST).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: {
|
||||
fieldErrors: [{ field: 'title', message: 'Title already taken' }],
|
||||
},
|
||||
response: new Response(),
|
||||
} as ReturnType<typeof api.POST> extends Promise<infer R> ? R : never)
|
||||
|
||||
const router = createTestRouter()
|
||||
await router.push('/create')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(EventCreateView, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await wrapper.find('#title').setValue('Duplicate Event')
|
||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const titleError = wrapper.find('#title-error')
|
||||
expect(titleError.exists()).toBe(true)
|
||||
expect(titleError.text()).toBe('Title already taken')
|
||||
|
||||
// Other field errors should not be present
|
||||
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user