feat(frontend): My Documents screen with ingestion status, search, filter, sort

Closes #75
This commit is contained in:
Pier-Jean Malandrino 2026-04-10 21:19:09 +02:00
parent ae37e8e96b
commit 5ba953fc58
7 changed files with 447 additions and 16 deletions

View file

@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ingestAnalysis, deleteIngested, fetchIngestionStatus } from './api'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
beforeEach(() => {
mockFetch.mockReset()
})
describe('ingestAnalysis', () => {
it('posts to /api/ingestion/:jobId', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ docId: 'doc-1', chunksIndexed: 5, embeddingDimension: 384 }),
})
const result = await ingestAnalysis('job-1')
expect(mockFetch).toHaveBeenCalledWith(
'/api/ingestion/job-1',
expect.objectContaining({ method: 'POST' }),
)
expect(result.chunksIndexed).toBe(5)
})
})
describe('deleteIngested', () => {
it('deletes /api/ingestion/:docId', async () => {
mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => Promise.resolve(null) })
await deleteIngested('doc-1')
expect(mockFetch).toHaveBeenCalledWith(
'/api/ingestion/doc-1',
expect.objectContaining({ method: 'DELETE' }),
)
})
})
describe('fetchIngestionStatus', () => {
it('gets /api/ingestion/status', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ available: true }),
})
const result = await fetchIngestionStatus()
expect(result.available).toBe(true)
})
})

View file

@ -0,0 +1,25 @@
import { apiFetch } from '../../shared/api/http'
export interface IngestionResult {
docId: string
chunksIndexed: number
embeddingDimension: number
}
export interface IngestionStatus {
available: boolean
}
export function ingestAnalysis(jobId: string): Promise<IngestionResult> {
return apiFetch<IngestionResult>(`/api/ingestion/${jobId}`, {
method: 'POST',
})
}
export function deleteIngested(docId: string): Promise<unknown> {
return apiFetch(`/api/ingestion/${docId}`, { method: 'DELETE' })
}
export function fetchIngestionStatus(): Promise<IngestionStatus> {
return apiFetch<IngestionStatus>('/api/ingestion/status')
}

View file

@ -0,0 +1 @@
export { useIngestionStore } from './store'

View file

@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useIngestionStore } from './store'
import * as api from './api'
vi.mock('./api', () => ({
fetchIngestionStatus: vi.fn(),
ingestAnalysis: vi.fn(),
deleteIngested: vi.fn(),
}))
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('useIngestionStore', () => {
describe('checkAvailability', () => {
it('sets available to true when API responds', async () => {
vi.mocked(api.fetchIngestionStatus).mockResolvedValue({ available: true })
const store = useIngestionStore()
await store.checkAvailability()
expect(store.available).toBe(true)
})
it('sets available to false on error', async () => {
vi.mocked(api.fetchIngestionStatus).mockRejectedValue(new Error('fail'))
const store = useIngestionStore()
await store.checkAvailability()
expect(store.available).toBe(false)
})
})
describe('ingest', () => {
it('calls API and tracks ingested doc', async () => {
vi.mocked(api.ingestAnalysis).mockResolvedValue({
docId: 'doc-1',
chunksIndexed: 5,
embeddingDimension: 384,
})
const store = useIngestionStore()
const result = await store.ingest('job-1')
expect(result?.chunksIndexed).toBe(5)
expect(store.ingestedDocs['doc-1']).toBe(5)
expect(store.ingesting).toBe(false)
})
it('sets error on failure', async () => {
vi.mocked(api.ingestAnalysis).mockRejectedValue(new Error('fail'))
const store = useIngestionStore()
const result = await store.ingest('job-1')
expect(result).toBeNull()
expect(store.error).toBe('fail')
})
})
describe('deleteIngested', () => {
it('removes doc from tracked map', async () => {
vi.mocked(api.deleteIngested).mockResolvedValue(null)
const store = useIngestionStore()
store.ingestedDocs['doc-1'] = 5
await store.deleteIngested('doc-1')
expect(store.ingestedDocs['doc-1']).toBeUndefined()
})
})
})

View file

@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from './api'
export const useIngestionStore = defineStore('ingestion', () => {
const available = ref(false)
const ingesting = ref(false)
const error = ref<string | null>(null)
/** Map of docId → chunks indexed count (tracks which docs are ingested) */
const ingestedDocs = ref<Record<string, number>>({})
async function checkAvailability(): Promise<void> {
try {
const status = await api.fetchIngestionStatus()
available.value = status.available
} catch {
available.value = false
}
}
async function ingest(jobId: string): Promise<api.IngestionResult | null> {
ingesting.value = true
error.value = null
try {
const result = await api.ingestAnalysis(jobId)
ingestedDocs.value[result.docId] = result.chunksIndexed
return result
} catch (e) {
error.value = (e as Error).message || 'Ingestion failed'
console.error('Ingestion failed', e)
return null
} finally {
ingesting.value = false
}
}
async function deleteIngested(docId: string): Promise<void> {
try {
await api.deleteIngested(docId)
delete ingestedDocs.value[docId]
} catch (e) {
error.value = (e as Error).message || 'Failed to delete ingested data'
console.error('Failed to delete ingested data', e)
}
}
return {
available,
ingesting,
error,
ingestedDocs,
checkAvailability,
ingest,
deleteIngested,
}
})

View file

@ -2,13 +2,40 @@
<div class="documents-page"> <div class="documents-page">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">{{ t('nav.documents') }}</h1> <h1 class="page-title">{{ t('nav.documents') }}</h1>
<div class="header-actions">
<input
v-model="searchQuery"
type="text"
class="search-input"
:placeholder="t('ingestion.search')"
/>
<div class="filter-group">
<button
v-for="f in filters"
:key="f.value"
class="filter-btn"
:class="{ active: activeFilter === f.value }"
@click="activeFilter = f.value"
>
{{ f.label }}
</button>
</div>
<div class="sort-group">
<button class="sort-btn" :class="{ active: sortBy === 'name' }" @click="sortBy = 'name'">
{{ t('ingestion.sortName') }}
</button>
<button class="sort-btn" :class="{ active: sortBy === 'date' }" @click="sortBy = 'date'">
{{ t('ingestion.sortDate') }}
</button>
</div>
</div>
</div> </div>
<div class="page-content"> <div class="page-content">
<div v-if="docStore.documents.length === 0" class="tab-empty"> <div v-if="filteredDocs.length === 0" class="tab-empty">
{{ t('history.emptyDocs') }} {{ t('history.emptyDocs') }}
</div> </div>
<div v-else class="doc-items"> <div v-else class="doc-items">
<div v-for="doc in docStore.documents" :key="doc.id" class="doc-row"> <div v-for="doc in filteredDocs" :key="doc.id" class="doc-row">
<div class="doc-row-info"> <div class="doc-row-info">
<svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor"> <svg class="doc-row-icon" viewBox="0 0 20 20" fill="currentColor">
<path <path
@ -26,15 +53,39 @@
</span> </span>
</div> </div>
</div> </div>
<button class="doc-row-delete" @click="docStore.remove(doc.id)"> <div class="doc-row-actions">
<svg viewBox="0 0 20 20" fill="currentColor"> <span
<path v-if="ingestionStore.ingestedDocs[doc.id]"
fill-rule="evenodd" class="status-badge indexed"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" :title="t('ingestion.chunksIndexed', { n: ingestionStore.ingestedDocs[doc.id] })"
clip-rule="evenodd" >
/> {{ t('ingestion.indexed') }}
</svg> <span class="badge-count">{{ ingestionStore.ingestedDocs[doc.id] }}</span>
</button> </span>
<span v-else class="status-badge not-indexed">
{{ t('ingestion.notIndexed') }}
</span>
<button
class="action-btn"
:title="t('ingestion.openInStudio')"
@click="openInStudio(doc)"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3z"
/>
</svg>
</button>
<button class="action-btn delete" @click="handleDelete(doc.id)">
<svg viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -42,21 +93,75 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDocumentStore } from '../features/document/store' import { useDocumentStore } from '../features/document/store'
import { useIngestionStore } from '../features/ingestion/store'
import { useI18n } from '../shared/i18n' import { useI18n } from '../shared/i18n'
import { formatSize } from '../shared/format' import { formatSize } from '../shared/format'
import type { Document } from '../shared/types'
const docStore = useDocumentStore() const docStore = useDocumentStore()
const ingestionStore = useIngestionStore()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const searchQuery = ref('')
const activeFilter = ref<'all' | 'indexed' | 'not-indexed'>('all')
const sortBy = ref<'name' | 'date'>('date')
const filters = computed(() => [
{ value: 'all' as const, label: t('ingestion.filterAll') },
{ value: 'indexed' as const, label: t('ingestion.filterIndexed') },
{ value: 'not-indexed' as const, label: t('ingestion.filterNotIndexed') },
])
const filteredDocs = computed(() => {
let docs = [...docStore.documents]
// Search filter
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
docs = docs.filter((d) => d.filename.toLowerCase().includes(q))
}
// Status filter
if (activeFilter.value === 'indexed') {
docs = docs.filter((d) => ingestionStore.ingestedDocs[d.id])
} else if (activeFilter.value === 'not-indexed') {
docs = docs.filter((d) => !ingestionStore.ingestedDocs[d.id])
}
// Sort
if (sortBy.value === 'name') {
docs.sort((a, b) => a.filename.localeCompare(b.filename))
} else {
docs.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
}
return docs
})
function formatDate(iso: string) { function formatDate(iso: string) {
if (!iso) return '' if (!iso) return ''
return new Date(iso).toLocaleString() return new Date(iso).toLocaleString()
} }
function openInStudio(doc: Document) {
docStore.select(doc.id)
router.push('/studio')
}
async function handleDelete(docId: string) {
if (ingestionStore.ingestedDocs[docId]) {
await ingestionStore.deleteIngested(docId)
}
await docStore.remove(docId)
}
onMounted(() => { onMounted(() => {
docStore.load() docStore.load()
ingestionStore.checkAvailability()
}) })
</script> </script>
@ -72,6 +177,11 @@ onMounted(() => {
padding: 16px 24px; padding: 16px 24px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
} }
.page-title { .page-title {
@ -80,6 +190,57 @@ onMounted(() => {
color: var(--text); color: var(--text);
} }
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.search-input {
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font-size: 13px;
width: 180px;
outline: none;
transition: border-color var(--transition);
}
.search-input:focus {
border-color: var(--accent);
}
.filter-group,
.sort-group {
display: flex;
gap: 2px;
background: var(--bg-surface);
border-radius: var(--radius-sm);
padding: 2px;
border: 1px solid var(--border);
}
.filter-btn,
.sort-btn {
padding: 4px 10px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
transition: all var(--transition);
}
.filter-btn.active,
.sort-btn.active {
background: var(--accent);
color: white;
}
.page-content { .page-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -152,7 +313,41 @@ onMounted(() => {
font-family: 'IBM Plex Mono', monospace; font-family: 'IBM Plex Mono', monospace;
} }
.doc-row-delete { .doc-row-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-badge.indexed {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.status-badge.not-indexed {
background: rgba(156, 163, 175, 0.15);
color: var(--text-muted);
}
.badge-count {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
}
.action-btn {
background: none; background: none;
border: none; border: none;
padding: 6px; padding: 6px;
@ -164,14 +359,21 @@ onMounted(() => {
transition: all var(--transition); transition: all var(--transition);
} }
.doc-row:hover .doc-row-delete { .doc-row:hover .action-btn {
opacity: 1; opacity: 1;
} }
.doc-row-delete:hover {
.action-btn:hover {
color: var(--accent);
background: rgba(249, 115, 22, 0.1);
}
.action-btn.delete:hover {
color: var(--error); color: var(--error);
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
} }
.doc-row-delete svg {
.action-btn svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }

View file

@ -131,6 +131,23 @@ const messages: Messages = {
'chunking.batchNotice': 'chunking.batchNotice':
'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage.', 'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage.',
// Ingestion / My Documents
'ingestion.ingest': 'Ingérer',
'ingestion.ingesting': 'Ingestion...',
'ingestion.reindex': 'Ré-indexer',
'ingestion.indexed': 'Indexé',
'ingestion.notIndexed': 'Non indexé',
'ingestion.chunksIndexed': '{n} chunks indexés',
'ingestion.openInStudio': 'Ouvrir dans le Studio',
'ingestion.deleteIndex': "Supprimer de l'index",
'ingestion.unavailable': 'Ingestion non disponible',
'ingestion.filterAll': 'Tous',
'ingestion.filterIndexed': 'Indexés',
'ingestion.filterNotIndexed': 'Non indexés',
'ingestion.sortName': 'Nom',
'ingestion.sortDate': 'Date',
'ingestion.search': 'Rechercher...',
// Pagination // Pagination
'pagination.pageOf': 'Page {current} sur {total}', 'pagination.pageOf': 'Page {current} sur {total}',
'pagination.perPage': '/ page', 'pagination.perPage': '/ page',
@ -266,6 +283,22 @@ const messages: Messages = {
'chunking.batchNotice': 'chunking.batchNotice':
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.', 'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.',
'ingestion.ingest': 'Ingest',
'ingestion.ingesting': 'Ingesting...',
'ingestion.reindex': 'Re-index',
'ingestion.indexed': 'Indexed',
'ingestion.notIndexed': 'Not indexed',
'ingestion.chunksIndexed': '{n} chunks indexed',
'ingestion.openInStudio': 'Open in Studio',
'ingestion.deleteIndex': 'Remove from index',
'ingestion.unavailable': 'Ingestion unavailable',
'ingestion.filterAll': 'All',
'ingestion.filterIndexed': 'Indexed',
'ingestion.filterNotIndexed': 'Not indexed',
'ingestion.sortName': 'Name',
'ingestion.sortDate': 'Date',
'ingestion.search': 'Search...',
'pagination.pageOf': 'Page {current} of {total}', 'pagination.pageOf': 'Page {current} of {total}',
'pagination.perPage': '/ page', 'pagination.perPage': '/ page',