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 };
+}