Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Client/svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions Client/svelte/src/lib/amcf/ListPager.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import ChevronsLeft from '@lucide/svelte/icons/chevrons-left';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ChevronsRight from '@lucide/svelte/icons/chevrons-right';

import type { ListPage } from '$lib/amcf/paging';

let { listPage, onPageChange }: { listPage: ListPage<unknown>; onPageChange: (page: number) => void } = $props();
</script>

{#if listPage.pageCount > 1}
<div class="flex-shrink-0 flex items-center justify-end gap-1 px-2 py-1 border-t bg-muted/40">
<span class="mr-2 text-xs text-muted-foreground tabular-nums">
{listPage.first}–{listPage.last} of {listPage.total}
</span>
<Button variant="ghost" size="icon" class="size-7" aria-label="First page" disabled={listPage.page <= 1} onclick={() => onPageChange(1)}>
<ChevronsLeft class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="size-7" aria-label="Previous page" disabled={listPage.page <= 1} onclick={() => onPageChange(listPage.page - 1)}>
<ChevronLeft class="h-3.5 w-3.5" />
</Button>
<span class="px-1 text-xs text-muted-foreground tabular-nums">
{listPage.page} / {listPage.pageCount}
</span>
<Button variant="ghost" size="icon" class="size-7" aria-label="Next page" disabled={listPage.page >= listPage.pageCount} onclick={() => onPageChange(listPage.page + 1)}>
<ChevronRight class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="size-7" aria-label="Last page" disabled={listPage.page >= listPage.pageCount} onclick={() => onPageChange(listPage.pageCount)}>
<ChevronsRight class="h-3.5 w-3.5" />
</Button>
</div>
{/if}
12 changes: 9 additions & 3 deletions Client/svelte/src/lib/amcf/modules/ModuleBuildList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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';
Expand Down Expand Up @@ -53,8 +58,8 @@
</script>

{#if visible}
<div class="w-full border rounded-md overflow-hidden flex-1 min-h-0">
<ScrollArea class="h-full max-h-[60vh]">
<div class="w-full border rounded-md overflow-hidden flex-1 min-h-0 flex flex-col">
<ScrollArea class="h-full min-h-0 max-h-[60vh]">
<Table.Root>
<Table.Header class="sticky top-0 bg-muted z-10">
<Table.Row>
Expand All @@ -75,7 +80,7 @@
</Table.Cell>
</Table.Row>
{:else}
{#each entries as build, idx (build.buildUUID || idx)}
{#each listPage.rows as build, idx (build.buildUUID || idx)}
<Table.Row
class="hover:bg-muted/50 transition-colors cursor-pointer"
onclick={() => { if (entrybuttons.length > 0) { detailItem = build; } else { selectBuild(build); } }}
Expand Down Expand Up @@ -117,6 +122,7 @@
</Table.Body>
</Table.Root>
</ScrollArea>
<ListPager {listPage} onPageChange={(page) => { pageNumber = page; }} />
</div>

<!-- Detail dialog -->
Expand Down
10 changes: 8 additions & 2 deletions Client/svelte/src/lib/amcf/modules/ModuleExecutionList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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';
Expand Down Expand Up @@ -70,7 +75,7 @@

{#if visible}
<div class="w-full border rounded-md overflow-hidden">
<ScrollArea class="max-h-[500px]">
<ScrollArea class="*:data-[slot=scroll-area-viewport]:max-h-[500px]">
<Table.Root>
<Table.Header class="sticky top-0 bg-muted z-10">
<Table.Row>
Expand All @@ -91,7 +96,7 @@
</Table.Cell>
</Table.Row>
{:else}
{#each entries as exec, idx (exec.executionUUID || idx)}
{#each listPage.rows as exec, idx (exec.executionUUID || idx)}
<Table.Row
class="hover:bg-muted/50 transition-colors cursor-pointer"
onclick={() => { detailItem = exec; }}
Expand Down Expand Up @@ -137,6 +142,7 @@
</Table.Body>
</Table.Root>
</ScrollArea>
<ListPager {listPage} onPageChange={(page) => { pageNumber = page; }} />
</div>

<!-- Detail dialog -->
Expand Down
49 changes: 49 additions & 0 deletions Client/svelte/src/lib/amcf/paging.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
22 changes: 22 additions & 0 deletions Client/svelte/src/lib/amcf/paging.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<T> (entries: T[], page: number, perPage: number): ListPage<T> {
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 };
}
Loading