diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f8a34bc..fa8af36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "docling-studio", "version": "0.1.0", "dependencies": { - "@vitest/mocker": "^4.1.2", "dompurify": "^3.3.3", "marked": "^17.0.4", "pinia": "^2.3.0", @@ -19,6 +18,7 @@ "@eslint/js": "^9.0.0", "@types/dompurify": "^3.2.0", "@vitejs/plugin-vue": "^6.0.5", + "@vitest/mocker": "^4.1.2", "eslint": "^9.0.0", "eslint-plugin-vue": "^9.32.0", "prettier": "^3.4.0", @@ -78,6 +78,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -93,6 +94,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -108,6 +110,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -123,6 +126,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -138,6 +142,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -153,6 +158,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -168,6 +174,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -183,6 +190,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -198,6 +206,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -213,6 +222,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -228,6 +238,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -243,6 +254,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -258,6 +270,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -273,6 +286,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -288,6 +302,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -303,6 +318,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -318,6 +334,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -333,6 +350,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -348,6 +366,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -363,6 +382,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -378,6 +398,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -393,6 +414,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -408,6 +430,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -423,6 +446,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -438,6 +462,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -453,6 +478,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -661,6 +687,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -673,6 +700,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -685,6 +713,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -697,6 +726,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -709,6 +739,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -721,6 +752,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -733,6 +765,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -745,6 +778,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -757,6 +791,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -769,6 +804,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -781,6 +817,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -793,6 +830,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -805,6 +843,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -817,6 +856,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -829,6 +869,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -841,6 +882,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -853,6 +895,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -865,6 +908,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -877,6 +921,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -889,6 +934,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -901,6 +947,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -913,6 +960,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -925,6 +973,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -937,6 +986,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -949,6 +999,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -989,7 +1040,8 @@ "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -1307,6 +1359,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", @@ -1372,6 +1425,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, "funding": { "url": "https://opencollective.com/vitest" } @@ -1828,7 +1882,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -2055,6 +2109,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -2099,7 +2154,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -2163,6 +2218,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -2540,7 +2596,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=12" }, @@ -2655,7 +2711,7 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "devOptional": true, + "dev": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -2797,7 +2853,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "devOptional": true, + "dev": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -2909,7 +2965,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "devOptional": true, + "dev": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/frontend/src/shared/composables/usePagination.test.ts b/frontend/src/shared/composables/usePagination.test.ts new file mode 100644 index 0000000..25009e3 --- /dev/null +++ b/frontend/src/shared/composables/usePagination.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest' +import { ref, nextTick } from 'vue' +import { usePagination } from './usePagination' + +function makeItems(n: number): string[] { + return Array.from({ length: n }, (_, i) => `item-${i + 1}`) +} + +describe('usePagination', () => { + it('returns all items when total fits in one page', () => { + const items = ref(makeItems(5)) + const { paginatedItems, pageCount, page } = usePagination(items, { pageSize: 20 }) + + expect(paginatedItems.value).toHaveLength(5) + expect(pageCount.value).toBe(1) + expect(page.value).toBe(1) + }) + + it('slices items according to page size', () => { + const items = ref(makeItems(25)) + const { paginatedItems, pageCount } = usePagination(items, { pageSize: 10 }) + + expect(paginatedItems.value).toHaveLength(10) + expect(paginatedItems.value[0]).toBe('item-1') + expect(pageCount.value).toBe(3) + }) + + it('navigates with next and prev', () => { + const items = ref(makeItems(30)) + const { paginatedItems, page, next, prev } = usePagination(items, { pageSize: 10 }) + + next() + expect(page.value).toBe(2) + expect(paginatedItems.value[0]).toBe('item-11') + + next() + expect(page.value).toBe(3) + expect(paginatedItems.value[0]).toBe('item-21') + + prev() + expect(page.value).toBe(2) + expect(paginatedItems.value[0]).toBe('item-11') + }) + + it('clamps page within valid range', () => { + const items = ref(makeItems(10)) + const { page, prev, next, goTo } = usePagination(items, { pageSize: 5 }) + + prev() + expect(page.value).toBe(1) + + next() + next() + expect(page.value).toBe(2) + + goTo(99) + expect(page.value).toBe(2) + + goTo(0) + expect(page.value).toBe(1) + }) + + it('goTo navigates to a specific page', () => { + const items = ref(makeItems(50)) + const { page, paginatedItems, goTo } = usePagination(items, { pageSize: 10 }) + + goTo(3) + expect(page.value).toBe(3) + expect(paginatedItems.value[0]).toBe('item-21') + + goTo(5) + expect(page.value).toBe(5) + expect(paginatedItems.value[0]).toBe('item-41') + }) + + it('resets to page 1 when items change', async () => { + const items = ref(makeItems(30)) + const { page, next } = usePagination(items, { pageSize: 10 }) + + next() + next() + expect(page.value).toBe(3) + + items.value = makeItems(5) + await nextTick() + expect(page.value).toBe(1) + }) + + it('resets to page 1 when page size changes', async () => { + const items = ref(makeItems(50)) + const { page, next, setPageSize } = usePagination(items, { pageSize: 10 }) + + next() + next() + expect(page.value).toBe(3) + + setPageSize(20) + await nextTick() + expect(page.value).toBe(1) + }) + + it('handles last page with fewer items', () => { + const items = ref(makeItems(23)) + const { paginatedItems, goTo } = usePagination(items, { pageSize: 10 }) + + goTo(3) + expect(paginatedItems.value).toHaveLength(3) + expect(paginatedItems.value[0]).toBe('item-21') + }) + + it('handles empty items', () => { + const items = ref([]) + const { paginatedItems, pageCount, totalItems } = usePagination(items) + + expect(paginatedItems.value).toHaveLength(0) + expect(pageCount.value).toBe(1) + expect(totalItems.value).toBe(0) + }) + + it('exposes totalItems as reactive count', async () => { + const items = ref(makeItems(10)) + const { totalItems } = usePagination(items) + + expect(totalItems.value).toBe(10) + + items.value = makeItems(42) + await nextTick() + expect(totalItems.value).toBe(42) + }) + + it('defaults page size to 20', () => { + const items = ref(makeItems(50)) + const { pageSize, paginatedItems } = usePagination(items) + + expect(pageSize.value).toBe(20) + expect(paginatedItems.value).toHaveLength(20) + }) + + it('ignores invalid page size', () => { + const items = ref(makeItems(10)) + const { pageSize, setPageSize } = usePagination(items, { pageSize: 5 }) + + setPageSize(0) + expect(pageSize.value).toBe(5) + + setPageSize(-10) + expect(pageSize.value).toBe(5) + }) +}) diff --git a/frontend/src/shared/composables/usePagination.ts b/frontend/src/shared/composables/usePagination.ts new file mode 100644 index 0000000..fd7a3f3 --- /dev/null +++ b/frontend/src/shared/composables/usePagination.ts @@ -0,0 +1,71 @@ +import { ref, computed, watch, type Ref } from 'vue' + +export interface PaginationOptions { + pageSize?: number +} + +export interface PaginationReturn { + page: Ref + pageSize: Ref + pageCount: Ref + paginatedItems: Ref + totalItems: Ref + next: () => void + prev: () => void + goTo: (target: number) => void + setPageSize: (size: number) => void +} + +export function usePagination( + items: Ref, + options: PaginationOptions = {}, +): PaginationReturn { + const page = ref(1) + const pageSize = ref(options.pageSize ?? 20) + + const totalItems = computed(() => items.value.length) + + const pageCount = computed(() => Math.max(1, Math.ceil(totalItems.value / pageSize.value))) + + const paginatedItems = computed(() => { + const start = (page.value - 1) * pageSize.value + return items.value.slice(start, start + pageSize.value) + }) + + // Reset to page 1 when source data or page size changes + watch([items, pageSize], () => { + page.value = 1 + }) + + function clamp(target: number): number { + return Math.max(1, Math.min(target, pageCount.value)) + } + + function goTo(target: number): void { + page.value = clamp(target) + } + + function next(): void { + goTo(page.value + 1) + } + + function prev(): void { + goTo(page.value - 1) + } + + function setPageSize(size: number): void { + if (size > 0) pageSize.value = size + } + + return { + page, + pageSize, + pageCount, + paginatedItems, + totalItems, + next, + prev, + goTo, + setPageSize, + } +} diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 6c4b759..503967e 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -106,6 +106,11 @@ const messages: Messages = { 'chunking.chunking': 'Chunking...', 'chunking.chunks': 'chunks', 'chunking.noChunks': 'Lancez le chunking pour préparer les segments.', + 'chunking.noChunksOnPage': 'Aucun chunk sur cette page.', + + // Pagination + 'pagination.pageOf': 'Page {current} sur {total}', + 'pagination.perPage': '/ page', // Settings 'settings.title': 'Paramètres', @@ -207,6 +212,10 @@ const messages: Messages = { 'chunking.chunking': 'Chunking...', 'chunking.chunks': 'chunks', 'chunking.noChunks': 'Run chunking to prepare segments.', + 'chunking.noChunksOnPage': 'No chunks on this page.', + + 'pagination.pageOf': 'Page {current} of {total}', + 'pagination.perPage': '/ page', 'settings.title': 'Settings', 'settings.apiUrl': 'API URL', diff --git a/frontend/src/shared/ui/PaginationBar.vue b/frontend/src/shared/ui/PaginationBar.vue new file mode 100644 index 0000000..9d519f9 --- /dev/null +++ b/frontend/src/shared/ui/PaginationBar.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index a8769d5..46da3f7 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -1 +1,2 @@ export { default as AppSidebar } from './AppSidebar.vue' +export { default as PaginationBar } from './PaginationBar.vue'