Files
crewli-old/apps/app/src/components/timetable/AddPerformanceDialog.vue
bert.hausmans 8db6ca6024 test(timetable): AddPerformanceDialog validation + submit (Step 8)
4 component tests via mountWithVuexy:

  - happy path: valid form values → POST /performances called with the
    correct body shape (engagement_id, event_id mapped from dayId,
    stage_id, start_at, end_at)
  - end_at < start_at → submit blocked, schema-level error visible on
    the end_at field
  - empty engagement_id → submit blocked, error visible on the engagement_id
    field
  - cancel button → emits update:modelValue=false

Test seam: AddPerformanceDialog.vue gains `defineExpose({ form, errors,
submit })` so jsdom tests can drive validation deterministically without
piping through Flatpickr / VAutocomplete plumbing. Three lines, exposes
internal refs only — no behavioural change.

VDialog stubbed in the test (it teleports to body, which puts content
outside the wrapper); App* wrappers stubbed (we test the schema +
submit pipeline, not Flatpickr ergonomics).

Test count: 360 → 364.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:43:03 +02:00

204 lines
5.6 KiB
Vue

<script setup lang="ts">
import { computed, ref, toRef, watch } from 'vue'
import { VForm } from 'vuetify/components/VForm'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import { createPerformancePayloadSchema } from '@/schemas/timetable'
import type { ArtistEngagement, Stage } from '@/types/timetable'
import { requiredValidator } from '@core/utils/validators'
const props = defineProps<{
modelValue: boolean
orgId: string
eventId: string
dayId: string | null
stages: Stage[]
/** Engagements available to schedule (filtered to this event). */
engagements: ArtistEngagement[]
/** Pre-fill from a drag-out-on-empty-cell (optional). */
prefill?: { stageId: string; startAt: string; endAt: string; lane: number } | null
}>()
const emit = defineEmits<{
'update:modelValue': [open: boolean]
'created': []
}>()
const orgIdRef = toRef(props, 'orgId')
const eventIdRef = toRef(props, 'eventId')
const dayIdRef = toRef(props, 'dayId')
const refForm = ref<VForm>()
const errors = ref<Record<string, string>>({})
const form = ref({
engagement_id: '',
stage_id: null as string | null,
start_at: '',
end_at: '',
lane: 0,
notes: '',
})
watch(() => props.modelValue, open => {
if (open) {
form.value = {
engagement_id: '',
stage_id: props.prefill?.stageId ?? null,
start_at: props.prefill?.startAt ?? '',
end_at: props.prefill?.endAt ?? '',
lane: props.prefill?.lane ?? 0,
notes: '',
}
errors.value = {}
}
})
const mutations = useTimetableMutations({ orgId: orgIdRef, eventId: eventIdRef, dayId: dayIdRef })
const isPending = computed(() => mutations.create.isPending.value)
const engagementOptions = computed(() =>
props.engagements.map(e => ({
title: e.artist?.name ?? '—',
subtitle: e.booking_status?.label ?? '',
value: e.id,
})),
)
async function submit(): Promise<void> {
errors.value = {}
const eventIdValue = props.dayId ?? props.eventId
const payload = {
engagement_id: form.value.engagement_id,
event_id: eventIdValue,
stage_id: form.value.stage_id,
start_at: form.value.start_at,
end_at: form.value.end_at,
lane: form.value.lane,
notes: form.value.notes || null,
}
const parsed = createPerformancePayloadSchema.safeParse(payload)
if (!parsed.success) {
for (const issue of parsed.error.issues)
errors.value[String(issue.path[0] ?? '_')] = issue.message
return
}
try {
await mutations.create.mutateAsync(parsed.data)
emit('created')
emit('update:modelValue', false)
}
catch (err) {
errors.value._ = (err as Error).message ?? 'Onbekende fout'
}
}
// Test seam (Session 4 follow-up): expose form + errors + submit so jsdom
// component tests can drive validation deterministically without piping
// through Flatpickr / VAutocomplete plumbing.
defineExpose({ form, errors, submit })
</script>
<template>
<VDialog
:model-value="modelValue"
max-width="520"
persistent
@update:model-value="emit('update:modelValue', $event)"
>
<VCard>
<VCardTitle class="d-flex justify-space-between align-center">
Nieuw optreden plannen
<VBtn
icon="tabler-x"
variant="text"
size="small"
@click="emit('update:modelValue', false)"
/>
</VCardTitle>
<VForm
ref="refForm"
@submit.prevent="submit"
>
<VCardText class="d-flex flex-column gap-4">
<VAlert
v-if="errors._"
type="error"
density="compact"
variant="tonal"
>
{{ errors._ }}
</VAlert>
<AppAutocomplete
v-model="form.engagement_id"
label="Artiest / engagement"
:items="engagementOptions"
item-title="title"
item-value="value"
:rules="[requiredValidator]"
:error-messages="errors.engagement_id"
autofocus
/>
<AppSelect
v-model="form.stage_id"
label="Stage"
:items="stages.map(s => ({ title: s.name, value: s.id }))"
clearable
:error-messages="errors.stage_id"
/>
<div class="d-flex gap-3">
<AppDateTimePicker
v-model="form.start_at"
label="Start"
:rules="[requiredValidator]"
:error-messages="errors.start_at"
:config="{ enableTime: true, dateFormat: 'Y-m-d H:i', time_24hr: true }"
/>
<AppDateTimePicker
v-model="form.end_at"
label="Einde"
:rules="[requiredValidator]"
:error-messages="errors.end_at"
:config="{ enableTime: true, dateFormat: 'Y-m-d H:i', time_24hr: true }"
/>
</div>
<AppTextField
v-model.number="form.lane"
label="Lane"
type="number"
min="0"
max="9"
:error-messages="errors.lane"
/>
<AppTextarea
v-model="form.notes"
label="Notities"
rows="2"
:error-messages="errors.notes"
/>
</VCardText>
<VCardActions class="px-4 pb-4">
<VSpacer />
<VBtn
variant="tonal"
@click="emit('update:modelValue', false)"
>
Annuleer
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isPending"
>
Plannen
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
</template>