- Add e2e/ui/ as peer project to e2e/api/ (own pom.xml, runner, config) - 5 critical UI journeys: upload, delete, analysis, batch-progress, rechunk - 4 local-only tests: sidebar, i18n, error-states, pipeline-options - 1 full happy path workflow covering all modes - Add data-e2e attributes on all tested Vue components (decoupled from CSS) - Add CONVENTIONS.md with 7 golden rules for writing Karate UI tests - Update CI with dedicated e2e-ui job (Chrome headless, --no-sandbox) - Update docs/contributing.md with UI test instructions Closes #124
159 lines
3.6 KiB
Vue
159 lines
3.6 KiB
Vue
<template>
|
|
<div
|
|
class="upload-zone"
|
|
data-e2e="upload-zone"
|
|
:class="{ dragging, uploading: store.uploading }"
|
|
@dragover.prevent="dragging = true"
|
|
@dragleave.prevent="dragging = false"
|
|
@drop.prevent="onDrop"
|
|
@click="openFilePicker"
|
|
>
|
|
<input ref="fileInput" type="file" accept=".pdf" hidden @change="onFileSelect" />
|
|
<div v-if="store.uploading" class="upload-state">
|
|
<div class="spinner" />
|
|
<span>{{ t('upload.uploading') }}</span>
|
|
</div>
|
|
<div v-else class="upload-state">
|
|
<svg
|
|
class="upload-icon"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
|
</svg>
|
|
<span class="upload-text">{{ t('upload.drop') }}</span>
|
|
<span class="upload-hint" data-e2e="upload-hint">{{ uploadHint }}</span>
|
|
<span v-if="store.error" class="upload-error" data-e2e="upload-error">{{ store.error }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useDocumentStore } from '../store'
|
|
import { useFeatureFlagStore } from '../../feature-flags/store'
|
|
import { useI18n } from '../../../shared/i18n'
|
|
|
|
const emit = defineEmits<{ uploaded: [docId: string] }>()
|
|
|
|
const store = useDocumentStore()
|
|
const flags = useFeatureFlagStore()
|
|
const { t } = useI18n()
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const dragging = ref(false)
|
|
|
|
const uploadHint = computed(() => {
|
|
const parts: string[] = []
|
|
if (flags.maxFileSizeMb > 0) {
|
|
parts.push(t('upload.maxSize').replace('{n}', String(flags.maxFileSizeMb)))
|
|
}
|
|
if (flags.maxPageCount > 0) {
|
|
parts.push(t('upload.maxPages').replace('{n}', String(flags.maxPageCount)))
|
|
}
|
|
return parts.join(' · ')
|
|
})
|
|
|
|
function openFilePicker() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
function isPdf(file: File): boolean {
|
|
return file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
|
|
}
|
|
|
|
async function handleFile(file: File) {
|
|
store.clearError()
|
|
if (!isPdf(file)) {
|
|
store.error = t('upload.invalidFormat')
|
|
return
|
|
}
|
|
try {
|
|
const doc = await store.upload(file)
|
|
if (doc) emit('uploaded', doc.id)
|
|
} catch {
|
|
// error is already set in store.upload
|
|
}
|
|
}
|
|
|
|
async function onFileSelect(e: Event) {
|
|
const target = e.target as HTMLInputElement
|
|
const file = target.files?.[0]
|
|
if (file) await handleFile(file)
|
|
target.value = ''
|
|
}
|
|
|
|
async function onDrop(e: DragEvent) {
|
|
dragging.value = false
|
|
const file = e.dataTransfer?.files?.[0]
|
|
if (file) await handleFile(file)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.upload-zone {
|
|
border: 2px dashed var(--border-light);
|
|
border-radius: var(--radius);
|
|
padding: 32px 16px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.upload-zone:hover,
|
|
.upload-zone.dragging {
|
|
border-color: var(--accent);
|
|
background: var(--accent-muted);
|
|
}
|
|
|
|
.upload-zone.uploading {
|
|
pointer-events: none;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.upload-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.upload-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.upload-text {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.upload-hint {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.upload-error {
|
|
font-size: 13px;
|
|
color: var(--error, #e53e3e);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid var(--border-light);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|