test(e2e): add data-testid attributes to all Vue components

- Add data-testid to 18 Vue components (Pages, Blocks, Features, Layouts, Primitives)
- Naming convention: {component-kebab}-{element-description}
- 98 total data-testid attributes added
- Target elements: buttons, links, inputs, modals, navigation
- No logic/styling changes - attributes only
This commit is contained in:
Thorsten Bus 2026-03-01 22:45:13 +01:00
parent 3a1ba1fc7d
commit 4520c1ce5f
19 changed files with 98 additions and 16 deletions

View file

@ -169,7 +169,7 @@ const deleteArrangement = () => {
</script>
<template>
<div class="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
<div data-testid="arrangement-configurator" class="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-64 flex-1">
<label
@ -179,6 +179,7 @@ const deleteArrangement = () => {
Arrangement
</label>
<select
data-testid="arrangement-select"
id="arrangement-select"
v-model="selectedId"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
@ -194,6 +195,7 @@ const deleteArrangement = () => {
</div>
<button
data-testid="arrangement-add-button"
type="button"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
@click="addArrangement"
@ -202,6 +204,7 @@ const deleteArrangement = () => {
</button>
<button
data-testid="arrangement-clone-button"
type="button"
class="rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
@click="cloneArrangement"
@ -210,6 +213,7 @@ const deleteArrangement = () => {
</button>
<button
data-testid="arrangement-delete-button"
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700"
@click="deleteArrangement"
@ -271,7 +275,7 @@ const deleteArrangement = () => {
:key="`${group.id}-${index}`"
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
>
<span class="cursor-move text-gray-500"></span>
<span data-testid="arrangement-drag-handle" class="cursor-move text-gray-500"></span>
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
@ -291,6 +295,7 @@ const deleteArrangement = () => {
</label>
<button
data-testid="arrangement-remove-button"
type="button"
class="rounded px-2 py-1 text-xs font-semibold text-red-600 hover:bg-red-50"
@click="removeGroupAt(index)"

View file

@ -54,7 +54,7 @@ function handleSlideUpdated() {
</script>
<template>
<div class="information-block space-y-6">
<div data-testid="information-block" class="information-block space-y-6">
<!-- Block header -->
<div class="border-b border-amber-200/60 pb-4">
<div class="flex items-center justify-between">
@ -103,6 +103,7 @@ function handleSlideUpdated() {
<!-- Slide uploader information slides are GLOBAL (service_id = null) -->
<SlideUploader
data-testid="information-block-uploader"
type="information"
:service-id="null"
:show-expire-date="true"
@ -111,6 +112,7 @@ function handleSlideUpdated() {
<!-- Slide grid with prominent expire dates -->
<SlideGrid
data-testid="information-block-grid"
:slides="informationSlides"
type="information"
:show-expire-date="true"

View file

@ -37,7 +37,7 @@ function handleSlideUpdated() {
</script>
<template>
<div class="moderation-block space-y-6">
<div data-testid="moderation-block" class="moderation-block space-y-6">
<!-- Block header -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900">
@ -50,6 +50,7 @@ function handleSlideUpdated() {
<!-- Slide uploader -->
<SlideUploader
data-testid="moderation-block-uploader"
type="moderation"
:service-id="serviceId"
:show-expire-date="false"
@ -58,6 +59,7 @@ function handleSlideUpdated() {
<!-- Slide grid -->
<SlideGrid
data-testid="moderation-block-grid"
:slides="moderationSlides"
type="moderation"
:show-expire-date="false"

View file

@ -37,7 +37,7 @@ function handleSlideUpdated() {
</script>
<template>
<div class="sermon-block space-y-6">
<div data-testid="sermon-block" class="sermon-block space-y-6">
<!-- Block header -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900">
@ -50,6 +50,7 @@ function handleSlideUpdated() {
<!-- Slide uploader -->
<SlideUploader
data-testid="sermon-block-uploader"
type="sermon"
:service-id="serviceId"
:show-expire-date="false"
@ -58,6 +59,7 @@ function handleSlideUpdated() {
<!-- Slide grid -->
<SlideGrid
data-testid="sermon-block-grid"
:slides="sermonSlides"
type="sermon"
:show-expire-date="false"

View file

@ -167,7 +167,7 @@ const toastClasses = () => {
</script>
<template>
<div class="space-y-4">
<div data-testid="songs-block" class="space-y-4">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
@ -187,6 +187,7 @@ const toastClasses = () => {
</Transition>
<div
data-testid="songs-block-song-card"
v-for="serviceSong in sortedSongs()"
:key="serviceSong.id"
class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
@ -242,6 +243,7 @@ const toastClasses = () => {
</div>
<button
data-testid="songs-block-request-button"
type="button"
class="inline-flex items-center rounded-md border border-amber-300 bg-white px-3 py-2 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
@click="requestCreation(serviceSong.id)"
@ -256,6 +258,7 @@ const toastClasses = () => {
Song suchen
</label>
<input
data-testid="songs-block-search-input"
v-model="searchTerms[serviceSong.id]"
type="text"
placeholder="Titel oder CCLI eingeben"
@ -268,6 +271,7 @@ const toastClasses = () => {
Song aus DB auswaehlen
</label>
<select
data-testid="songs-block-song-select"
v-model="selectedSongIds[serviceSong.id]"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
>
@ -285,6 +289,7 @@ const toastClasses = () => {
</div>
<button
data-testid="songs-block-assign-button"
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700"
@click="assignSong(serviceSong.id)"
@ -308,6 +313,7 @@ const toastClasses = () => {
class="inline-flex items-center gap-2 text-sm font-medium text-gray-800"
>
<input
data-testid="songs-block-translation-checkbox"
v-model="translationValues[serviceSong.id]"
type="checkbox"
class="rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
@ -327,6 +333,7 @@ const toastClasses = () => {
<div class="flex flex-wrap gap-2">
<button
data-testid="songs-block-preview-button"
type="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
@click="showPlaceholder"
@ -334,6 +341,7 @@ const toastClasses = () => {
Vorschau
</button>
<button
data-testid="songs-block-download-button"
type="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
@click="showPlaceholder"

View file

@ -81,6 +81,7 @@ const iconColors = {
<div class="mt-6 flex justify-end gap-3">
<button
data-testid="confirm-dialog-cancel-button"
type="button"
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
@click="emit('cancel')"
@ -88,6 +89,7 @@ const iconColors = {
{{ cancelLabel }}
</button>
<button
data-testid="confirm-dialog-confirm-button"
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium text-white shadow-sm transition focus:outline-none focus:ring-2 focus:ring-offset-2"
:class="variantClasses[variant]"

View file

@ -141,7 +141,7 @@ function isExpired(expireDate) {
</script>
<template>
<div class="slide-grid">
<div data-testid="slide-grid" class="slide-grid">
<!-- Empty state -->
<div
v-if="sortedSlides.length === 0"
@ -192,6 +192,7 @@ function isExpired(expireDate) {
<!-- Delete button overlay -->
<button
data-testid="slide-grid-delete-button"
@click.stop="promptDelete(slide)"
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-red-600 hover:text-white group-hover:opacity-100"
title="Löschen"
@ -203,6 +204,7 @@ function isExpired(expireDate) {
<!-- Full image link overlay -->
<a
data-testid="slide-grid-fullimage-link"
v-if="fullImageUrl(slide)"
:href="fullImageUrl(slide)"
target="_blank"
@ -285,6 +287,7 @@ function isExpired(expireDate) {
class="flex items-center gap-1.5"
>
<input
data-testid="slide-grid-expire-input"
v-model="editingExpireValue"
type="date"
class="w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 shadow-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400/40"
@ -292,6 +295,7 @@ function isExpired(expireDate) {
@keydown.escape="cancelEditExpire"
/>
<button
data-testid="slide-grid-expire-save"
@click="saveExpireDate(slide)"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-amber-500 text-white shadow-sm transition hover:bg-amber-600"
title="Speichern"
@ -301,6 +305,7 @@ function isExpired(expireDate) {
</svg>
</button>
<button
data-testid="slide-grid-expire-cancel"
@click="cancelEditExpire"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white text-gray-500 shadow-sm transition hover:bg-gray-50"
title="Abbrechen"

View file

@ -122,7 +122,7 @@ function dismissError() {
</script>
<template>
<div class="slide-uploader">
<div data-testid="slide-uploader" class="slide-uploader">
<!-- Expire date picker for information slides -->
<div
v-if="showExpireDate"
@ -132,6 +132,7 @@ function dismissError() {
Ablaufdatum für neue Folien
</label>
<input
data-testid="slide-uploader-expire-input"
v-model="expireDate"
type="date"
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm transition focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
@ -156,6 +157,7 @@ function dismissError() {
</svg>
<span class="flex-1">{{ uploadError }}</span>
<button
data-testid="slide-uploader-error-dismiss"
@click="dismissError"
class="shrink-0 rounded-lg p-0.5 text-red-400 transition hover:text-red-600"
>
@ -196,6 +198,7 @@ function dismissError() {
<!-- Dropzone -->
<Vue3Dropzone
data-testid="slide-uploader-dropzone"
v-model="files"
:multiple="true"
:accept="acceptedTypes"

View file

@ -210,7 +210,7 @@ onUnmounted(() => {
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:p-8"
@click="closeOnBackdrop"
>
<div class="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl">
<div data-testid="song-edit-modal" class="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div class="flex items-center gap-3">
@ -299,6 +299,7 @@ onUnmounted(() => {
</Transition>
<button
data-testid="song-edit-modal-close-button"
type="button"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('close')"
@ -371,6 +372,7 @@ onUnmounted(() => {
<p class="text-red-600">{{ error }}</p>
<button
data-testid="song-edit-modal-error-close-button"
type="button"
class="mt-4 rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="emit('close')"
@ -399,6 +401,7 @@ onUnmounted(() => {
Titel
</label>
<input
data-testid="song-edit-modal-title-input"
id="song-edit-title"
v-model="title"
type="text"
@ -417,6 +420,7 @@ onUnmounted(() => {
CCLI-ID
</label>
<input
data-testid="song-edit-modal-ccli-input"
id="song-edit-ccli"
v-model="ccliId"
type="text"
@ -464,6 +468,7 @@ onUnmounted(() => {
Copyright-Text
</label>
<textarea
data-testid="song-edit-modal-copyright-textarea"
id="song-edit-copyright"
v-model="copyrightText"
rows="3"

View file

@ -92,7 +92,7 @@ const closeOnBackdrop = (e) => {
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:p-8"
@click="closeOnBackdrop"
>
<div class="relative w-full max-w-3xl rounded-xl bg-white shadow-2xl">
<div data-testid="song-preview-modal" class="relative w-full max-w-3xl rounded-xl bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
@ -107,6 +107,7 @@ const closeOnBackdrop = (e) => {
<div class="flex items-center gap-3">
<a
data-testid="song-preview-modal-pdf-link"
:href="pdfUrl"
class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-indigo-700"
target="_blank"
@ -128,6 +129,7 @@ const closeOnBackdrop = (e) => {
</a>
<button
data-testid="song-preview-modal-close-button"
type="button"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('close')"
@ -325,6 +327,7 @@ const contrastColor = (hexColor) => {
>
<p class="text-red-600">{{ error }}</p>
<button
data-testid="song-preview-modal-error-close-button"
type="button"
class="mt-4 rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="close"
@ -391,6 +394,7 @@ const contrastColor = (hexColor) => {
<!-- Close Button -->
<div class="mt-6 flex justify-end">
<button
data-testid="song-preview-modal-bottom-close-button"
type="button"
class="rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="close"

View file

@ -67,6 +67,7 @@ function triggerSync() {
<div class="flex items-center gap-1">
<!-- Logo / App Name -->
<Link
data-testid="auth-layout-logo"
:href="route('dashboard')"
class="mr-6 flex items-center gap-2.5 transition-opacity hover:opacity-80"
>
@ -83,12 +84,14 @@ function triggerSync() {
<!-- Desktop Navigation -->
<div class="hidden items-center gap-1 sm:flex">
<NavLink
data-testid="auth-layout-nav-services"
:href="route('services.index')"
:active="route().current('services.*')"
>
Services
</NavLink>
<NavLink
data-testid="auth-layout-nav-songs"
v-if="$page.props.ziggy?.routes?.['songs.index']"
:href="route('songs.index')"
:active="route().current('songs.*')"
@ -111,6 +114,7 @@ function triggerSync() {
<!-- Sync Info & Button -->
<div class="flex items-center gap-3">
<span
data-testid="auth-layout-sync-timestamp"
v-if="formattedSyncDate"
class="text-xs text-gray-400"
>
@ -118,6 +122,7 @@ function triggerSync() {
</span>
<button
data-testid="auth-layout-sync-button"
@click="triggerSync"
:disabled="syncing"
class="group inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-400/40 focus:ring-offset-1 disabled:cursor-wait disabled:opacity-60"
@ -142,6 +147,7 @@ function triggerSync() {
<Dropdown align="right" width="48">
<template #trigger>
<button
data-testid="auth-layout-user-dropdown-trigger"
type="button"
class="flex items-center gap-2 rounded-lg p-1.5 text-sm transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-amber-400/40"
>
@ -180,6 +186,7 @@ function triggerSync() {
{{ user?.email }}
</div>
<DropdownLink
data-testid="auth-layout-logout-link"
:href="route('logout')"
method="post"
as="button"
@ -194,6 +201,7 @@ function triggerSync() {
<!-- Mobile Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button
data-testid="auth-layout-mobile-hamburger"
@click="showingNavigationDropdown = !showingNavigationDropdown"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 focus:bg-gray-100 focus:outline-none"
>
@ -231,12 +239,14 @@ function triggerSync() {
<!-- Mobile Navigation -->
<div class="space-y-1 pb-3 pt-2">
<ResponsiveNavLink
data-testid="auth-layout-mobile-nav-services"
:href="route('services.index')"
:active="route().current('services.*')"
>
Services
</ResponsiveNavLink>
<ResponsiveNavLink
data-testid="auth-layout-mobile-nav-songs"
v-if="$page.props.ziggy?.routes?.['songs.index']"
:href="route('songs.index')"
:active="route().current('songs.*')"
@ -248,6 +258,7 @@ function triggerSync() {
<!-- Mobile Sync -->
<div class="border-t border-gray-100 px-4 py-3">
<button
data-testid="auth-layout-mobile-sync-button"
@click="triggerSync"
:disabled="syncing"
class="flex w-full items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-amber-50 hover:text-amber-700 disabled:opacity-60"
@ -286,6 +297,7 @@ function triggerSync() {
<div class="mt-3 space-y-1">
<ResponsiveNavLink
data-testid="auth-layout-mobile-logout-link"
:href="route('logout')"
method="post"
as="button"

View file

@ -8,7 +8,7 @@ import { Link } from '@inertiajs/vue3';
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0"
>
<div>
<Link href="/">
<Link data-testid="guest-layout-logo-link" href="/">
<ApplicationLogo class="h-20 w-20 fill-current text-gray-500" />
</Link>
</div>

View file

@ -1,5 +1,5 @@
<template>
<div>
<div data-testid="main-layout">
<slot />
</div>
</template>

View file

@ -23,6 +23,7 @@ defineProps({
<a
:href="route('auth.churchtools')"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-indigo-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
data-testid="login-oauth-button"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
@ -31,6 +32,7 @@ defineProps({
</a>
<button
data-testid="login-test-button"
v-if="canDevLogin"
@click="router.post(route('dev-login'))"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-amber-500 px-6 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"

View file

@ -21,7 +21,7 @@ import { Head } from '@inertiajs/vue3';
class="overflow-hidden bg-white shadow-sm sm:rounded-lg"
>
<div class="p-6 text-gray-900">
Du bist angemeldet als {{ $page.props.auth.user.name }}.
<span data-testid="dashboard-welcome-text">Du bist angemeldet als {{ $page.props.auth.user.name }}.</span>
</div>
</div>
</div>

View file

@ -123,6 +123,7 @@ function blockBadgeLabel(key) {
<div class="min-w-0">
<div class="flex items-center gap-3">
<button
data-testid="service-edit-back-icon-button"
type="button"
class="group flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-600"
@click="goBack"
@ -150,6 +151,7 @@ function blockBadgeLabel(key) {
<div class="flex items-center gap-2">
<button
data-testid="service-edit-back-button"
type="button"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 py-2 text-sm font-medium text-gray-600 shadow-sm transition-all hover:border-gray-300 hover:bg-gray-50"
@click="goBack"
@ -174,6 +176,7 @@ function blockBadgeLabel(key) {
>
<!-- Block header (clickable) -->
<button
data-testid="service-edit-block-toggle"
type="button"
class="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-gray-50/60"
@click="toggleBlock(block.key)"

View file

@ -176,12 +176,12 @@ function stateIconClass(isDone) {
</Transition>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<div v-if="services.length === 0" class="p-8 text-center text-sm text-gray-500">
<div v-if="services.length === 0" data-testid="service-list-empty" class="p-8 text-center text-sm text-gray-500">
Aktuell gibt es keine heutigen oder kommenden Services.
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<table data-testid="service-list-table" class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Titel</th>
@ -195,7 +195,7 @@ function stateIconClass(isDone) {
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr v-for="service in services" :key="service.id" class="align-top hover:bg-gray-50/60">
<tr v-for="service in services" :key="service.id" :data-testid="`service-list-row-${service.id}`" class="align-top hover:bg-gray-50/60">
<td class="px-4 py-4">
<div class="font-medium text-gray-900">{{ service.title }}</div>
<div class="mt-1 text-xs text-gray-500">{{ formatDate(service.date) }}</div>
@ -251,6 +251,7 @@ function stateIconClass(isDone) {
<div class="flex flex-col gap-2">
<template v-if="service.finalized_at">
<button
data-testid="service-list-reopen-button"
type="button"
class="inline-flex items-center justify-center rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
@click="reopenService(service.id)"
@ -258,6 +259,7 @@ function stateIconClass(isDone) {
Wieder öffnen
</button>
<button
data-testid="service-list-download-button"
type="button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
@click="downloadService(service.id)"
@ -268,6 +270,7 @@ function stateIconClass(isDone) {
<template v-else>
<button
data-testid="service-list-edit-button"
type="button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
@click="router.get(route('services.edit', service.id))"
@ -275,6 +278,7 @@ function stateIconClass(isDone) {
Bearbeiten
</button>
<button
data-testid="service-list-finalize-button"
type="button"
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-emerald-50 px-3 py-1.5 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100"
:disabled="finalizing"
@ -327,6 +331,7 @@ function stateIconClass(isDone) {
<div class="mt-5 flex justify-end gap-3">
<button
data-testid="service-list-confirm-cancel-button"
type="button"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
@click="cancelFinalize"
@ -334,6 +339,7 @@ function stateIconClass(isDone) {
Abbrechen
</button>
<button
data-testid="service-list-confirm-submit-button"
type="button"
class="rounded-md border border-emerald-300 bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-700"
@click="confirmFinalize"

View file

@ -193,6 +193,7 @@ function pageRange() {
<!-- Upload Area -->
<div
data-testid="song-list-upload-area"
class="group relative mb-6 cursor-pointer overflow-hidden rounded-xl border-2 border-dashed transition-all duration-300"
:class="isDragging
? 'border-amber-400 bg-amber-50/80 shadow-lg shadow-amber-100/50'
@ -203,6 +204,7 @@ function pageRange() {
@click="triggerFileInput"
>
<input
data-testid="song-list-file-input"
ref="fileInput"
type="file"
multiple
@ -257,6 +259,7 @@ function pageRange() {
</svg>
</div>
<input
data-testid="song-list-search-input"
v-model="search"
type="text"
placeholder="Songs durchsuchen (Name oder CCLI-ID)…"
@ -271,6 +274,7 @@ function pageRange() {
leave-to-class="opacity-0"
>
<button
data-testid="song-list-search-clear"
v-if="search"
@click="search = ''"
class="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-400 hover:text-gray-600"
@ -394,6 +398,7 @@ function pageRange() {
<div class="flex items-center justify-end gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
<!-- Bearbeiten -->
<button
data-testid="song-list-edit-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
title="Bearbeiten"
@ -407,6 +412,7 @@ function pageRange() {
<!-- Übersetzen -->
<a
data-testid="song-list-translate-link"
:href="`/songs/${song.id}/translate`"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700"
title="Übersetzen"
@ -419,6 +425,7 @@ function pageRange() {
<!-- Herunterladen -->
<button
data-testid="song-list-download-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700"
title="Als .pro herunterladen"
@ -432,6 +439,7 @@ function pageRange() {
<!-- Löschen -->
<button
data-testid="song-list-delete-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-red-300 hover:bg-red-50 hover:text-red-700"
title="Löschen"
@ -461,6 +469,7 @@ function pageRange() {
<nav class="flex items-center gap-1">
<button
data-testid="song-list-pagination-prev"
:disabled="meta.current_page <= 1"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-xs text-gray-600 shadow-sm transition-all hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
@click="goToPage(meta.current_page - 1)"
@ -488,6 +497,7 @@ function pageRange() {
</template>
<button
data-testid="song-list-pagination-next"
:disabled="meta.current_page >= meta.last_page"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-xs text-gray-600 shadow-sm transition-all hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
@click="goToPage(meta.current_page + 1)"
@ -539,6 +549,7 @@ function pageRange() {
<div class="mt-5 flex items-center justify-end gap-2.5">
<button
data-testid="song-list-delete-cancel-button"
type="button"
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
@click="cancelDelete"
@ -546,6 +557,7 @@ function pageRange() {
Abbrechen
</button>
<button
data-testid="song-list-delete-confirm-button"
type="button"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-300 bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-red-700 disabled:opacity-60"
:disabled="deleting"
@ -565,6 +577,7 @@ function pageRange() {
<!-- Song Edit Modal -->
<SongEditModal
data-testid="song-list-edit-modal"
:show="showEditModal"
:song-id="editSongId"
@close="closeEditModal"

View file

@ -161,6 +161,7 @@ function rowsForSlide(slide) {
</div>
<button
data-testid="translate-back-button"
type="button"
class="inline-flex items-center rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
@click="router.visit('/songs')"
@ -180,12 +181,14 @@ function rowsForSlide(slide) {
<div class="mt-4 grid gap-3 md:grid-cols-[1fr,auto]">
<input
data-testid="translate-url-input"
v-model="sourceUrl"
type="url"
placeholder="https://beispiel.de/lyrics"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
>
<button
data-testid="translate-fetch-button"
type="button"
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isFetching"
@ -200,6 +203,7 @@ function rowsForSlide(slide) {
Text manuell einfuegen
</label>
<textarea
data-testid="translate-source-textarea"
v-model="sourceText"
rows="10"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
@ -207,6 +211,7 @@ function rowsForSlide(slide) {
/>
<div class="mt-3 flex justify-end">
<button
data-testid="translate-apply-button"
type="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
@click="applyManualText"
@ -244,6 +249,7 @@ function rowsForSlide(slide) {
</div>
<button
data-testid="translate-save-button"
type="button"
class="inline-flex items-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSaving"
@ -292,6 +298,7 @@ function rowsForSlide(slide) {
Original
</label>
<textarea
data-testid="translate-original-textarea"
:value="slide.text_content"
:rows="rowsForSlide(slide)"
readonly
@ -304,6 +311,7 @@ function rowsForSlide(slide) {
Uebersetzung
</label>
<textarea
data-testid="translate-translation-textarea"
:value="slide.translated_text"
:rows="rowsForSlide(slide)"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"