diff --git a/Client/svelte/package.json b/Client/svelte/package.json index f08f8581..4a0a6699 100644 --- a/Client/svelte/package.json +++ b/Client/svelte/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "node --test 'src/**/*.test.ts'" }, "devDependencies": { "@internationalized/date": "^3.11.0", diff --git a/Client/svelte/src/lib/amcf/ListPager.svelte b/Client/svelte/src/lib/amcf/ListPager.svelte new file mode 100644 index 00000000..83c7272d --- /dev/null +++ b/Client/svelte/src/lib/amcf/ListPager.svelte @@ -0,0 +1,34 @@ + + +{#if listPage.pageCount > 1} +
+ + {listPage.first}–{listPage.last} of {listPage.total} + + + + + {listPage.page} / {listPage.pageCount} + + + +
+{/if} diff --git a/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte b/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte index 328d58ad..eeb679b5 100644 --- a/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte +++ b/Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte @@ -6,6 +6,8 @@ import Image from '@lucide/svelte/icons/image'; import { useModuleTick } from '$lib/amcf/poll.svelte'; + import { paginate } from '$lib/amcf/paging'; + import ListPager from '$lib/amcf/ListPager.svelte'; let { module, app }: { module: any; app: any } = $props(); const poll = useModuleTick(() => module); @@ -14,6 +16,9 @@ let entries = $derived.by(() => { poll.v; return [...(module.entries || [])]; }); let entrybuttons = $derived.by(() => { poll.v; return [...(module.entrybuttons || [])]; }); let loaded = $derived.by(() => { poll.v; return module.loaded === true; }); + let entriesPerPage = $derived.by(() => { poll.v; return module.entriesperpage; }); + let pageNumber = $state(1); + let listPage = $derived(paginate(entries, pageNumber, entriesPerPage)); let detailItem: any = $state(null); const nullUUID = '00000000-0000-0000-0000-000000000000'; @@ -53,8 +58,8 @@ {#if visible} -
- +
+ @@ -75,7 +80,7 @@ {:else} - {#each entries as build, idx (build.buildUUID || idx)} + {#each listPage.rows as build, idx (build.buildUUID || idx)} { if (entrybuttons.length > 0) { detailItem = build; } else { selectBuild(build); } }} @@ -117,6 +122,7 @@ + { pageNumber = page; }} />
diff --git a/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte b/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte index 7eca6a71..24857be9 100644 --- a/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte +++ b/Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte @@ -7,6 +7,8 @@ import Image from '@lucide/svelte/icons/image'; import { useModuleTick } from '$lib/amcf/poll.svelte'; + import { paginate } from '$lib/amcf/paging'; + import ListPager from '$lib/amcf/ListPager.svelte'; let { module, app }: { module: any; app: any } = $props(); const poll = useModuleTick(() => module); @@ -15,6 +17,9 @@ let entries = $derived.by(() => { poll.v; return [...(module.entries || [])]; }); let entrybuttons = $derived.by(() => { poll.v; return [...(module.entrybuttons || [])]; }); let loaded = $derived.by(() => { poll.v; return module.loaded === true; }); + let entriesPerPage = $derived.by(() => { poll.v; return module.entriesperpage; }); + let pageNumber = $state(1); + let listPage = $derived(paginate(entries, pageNumber, entriesPerPage)); let detailItem: any = $state(null); const nullUUID = '00000000-0000-0000-0000-000000000000'; @@ -70,7 +75,7 @@ {#if visible}
- + @@ -91,7 +96,7 @@ {:else} - {#each entries as exec, idx (exec.executionUUID || idx)} + {#each listPage.rows as exec, idx (exec.executionUUID || idx)} { detailItem = exec; }} @@ -137,6 +142,7 @@ + { pageNumber = page; }} />
diff --git a/Client/svelte/src/lib/amcf/paging.test.ts b/Client/svelte/src/lib/amcf/paging.test.ts new file mode 100644 index 00000000..b82f5c43 --- /dev/null +++ b/Client/svelte/src/lib/amcf/paging.test.ts @@ -0,0 +1,49 @@ +// @ts-nocheck -- run by `node --test` (npm test); the project has no @types/node. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { paginate } from './paging.ts'; + +const executions = Array.from({ length: 10000 }, (_, i) => ({ executionUUID: `exec-${i}` })); + +test('a long list shows only the first page of entries', () => { + const p = paginate(executions, 1, 20); + + assert.equal(p.rows.length, 20); + assert.equal(p.rows[0].executionUUID, 'exec-0'); + assert.equal(p.rows[19].executionUUID, 'exec-19'); + assert.equal(p.page, 1); + assert.equal(p.pageCount, 500); + assert.equal(p.first, 1); + assert.equal(p.last, 20); + assert.equal(p.total, 10000); +}); + +test('the last page holds the remaining entries', () => { + const p = paginate(executions.slice(0, 43), 3, 20); + + assert.deepEqual(p.rows.map((r) => r.executionUUID), ['exec-40', 'exec-41', 'exec-42']); + assert.equal(p.pageCount, 3); + assert.equal(p.first, 41); + assert.equal(p.last, 43); +}); + +test('a page past the end shows the last page, e.g. after entries were deleted', () => { + const p = paginate(executions.slice(0, 25), 3, 20); + + assert.equal(p.page, 2); + assert.deepEqual(p.rows.map((r) => r.executionUUID), ['exec-20', 'exec-21', 'exec-22', 'exec-23', 'exec-24']); + assert.equal(p.first, 21); + assert.equal(p.last, 25); +}); + +test('an empty list is a single empty page', () => { + const p = paginate([], 1, 20); + + assert.deepEqual(p.rows, []); + assert.equal(p.page, 1); + assert.equal(p.pageCount, 1); + assert.equal(p.first, 0); + assert.equal(p.last, 0); + assert.equal(p.total, 0); +}); diff --git a/Client/svelte/src/lib/amcf/paging.ts b/Client/svelte/src/lib/amcf/paging.ts new file mode 100644 index 00000000..922c488d --- /dev/null +++ b/Client/svelte/src/lib/amcf/paging.ts @@ -0,0 +1,22 @@ +/* + * Client-side paging of list modules. Only the rows of one page are rendered, so that long + * lists (e.g. thousands of executions) do not block the browser. + */ +export interface ListPage { + rows: T[]; + page: number; // 1-based + pageCount: number; + first: number; // 1-based index of the first row shown + last: number; // 1-based index of the last row shown + total: number; +} + +export function paginate (entries: T[], page: number, perPage: number): ListPage { + const total = entries.length; + const pageCount = Math.max(1, Math.ceil(total / perPage)); + page = Math.min(page, pageCount); + const start = (page - 1) * perPage; + const rows = entries.slice(start, start + perPage); + + return { rows, page, pageCount, first: rows.length > 0 ? start + 1 : 0, last: start + rows.length, total }; +}