Merge pull request #1259 from Donkie/fix/silent-list-caps

Page through filament spools and dashboard groups
This commit is contained in:
Donkie authored and GitHub committed 2026-10-11 10:11:51 +02:00
commit 6b6c13e4f1
4 files changed
+92 -25

No files matched your search

+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildCatalogQuery, buildGroupQuery, buildVendorTotalsQuery } from './query';
import { allPages, buildCatalogQuery, buildGroupQuery, buildVendorTotalsQuery } from './query';
import type { LibraryState } from '$lib/library/params';
// A filament with no spools is the one you need to re-order, and grouping by
@@ -171,3 +171,35 @@ describe('buildCatalogQuery', () => {
expect(q.filters).toEqual({ material: ['PLA'], vendorId: ['1', '4'] });
});
});
// The dashboard and the filament inspector used to stop at their first page
// (1000 groups, 100 spools) and show the rest as if it didn't exist.
describe('allPages', () => {
const rows = Array.from({ length: 25 }, (_, i) => i);
const pager = (size: number) => {
const offsets: number[] = [];
const fetchPage = async (offset: number) => {
offsets.push(offset);
return { items: rows.slice(offset, offset + size), total: rows.length };
};
return { offsets, fetchPage };
};
it('keeps fetching until it has every row', async () => {
const { offsets, fetchPage } = pager(10);
expect(await allPages(10, fetchPage)).toEqual(rows);
expect(offsets).toEqual([0, 10, 20]);
});
it('stops after one request when the first page holds everything', async () => {
const { offsets, fetchPage } = pager(100);
expect(await allPages(100, fetchPage)).toEqual(rows);
expect(offsets).toEqual([0]);
});
it('stops on an exact multiple without asking for an empty page', async () => {
const { offsets, fetchPage } = pager(5);
expect(await allPages(5, fetchPage)).toHaveLength(25);
expect(offsets).toEqual([0, 5, 10, 15, 20]);
});
});
+18 -1
View File
@@ -1,4 +1,4 @@
import type { GroupField, GroupQuery, GroupSummary, SortField, SpoolQuery } from './types';
import type { GroupField, GroupQuery, GroupSummary, Page, SortField, SpoolQuery } from './types';
import { isSpoolScopedFilter, type CatalogView, type LibraryState } from '$lib/library/params';
import { resolveSortField, resolveGroupSortField, catalogSortField } from '$lib/utils/library';
import { settings } from '$lib/stores/settings.svelte';
@@ -170,3 +170,20 @@ export function buildFlatSpoolQuery(state: LibraryState, signal?: AbortSignal):
signal
};
}
/**
* Every row of a paged list, fetched `size` at a time: for views that need the
* whole set (every dashboard card, every spool of one filament) without asking
* the server for all of it in one response.
*/
export async function allPages<T>(
size: number,
fetchPage: (offset: number) => Promise<Page<T>>
): Promise<T[]> {
const all: T[] = [];
for (;;) {
const page = await fetchPage(all.length);
all.push(...page.items);
if (page.items.length < size || all.length >= page.total) return all;
}
}
@@ -34,6 +34,7 @@
import { pct, weightAuto } from '$lib/utils/format';
import { usageLabel } from '$lib/utils/library';
import { spoolSource } from '$lib/api/spoolSource';
import { allPages } from '$lib/api/query';
import { isAbortError } from '$lib/api/http';
import { classifyDeleteFailure, planFilamentDelete } from '$lib/library/deletion';
import { toasts } from '$lib/stores/toasts.svelte';
@@ -80,6 +81,7 @@
// fetch's own cache upsert (which replaces the `filament` prop object) doesn't
// re-trigger this effect in an infinite loop.
let filamentId = $derived(filament.id);
const SPOOL_PAGE = 100;
let spools = $state<Spool[]>([]);
// Bumped by live spool events so adding/removing/editing a spool refetches
// this filament's list (the fetch below is server-ordered, not read from the
@@ -96,24 +98,33 @@
const id = filamentId;
const archived = showArchived;
void revision; // refetch on live spool events
let cancelled = false;
spoolSource
.listSpools({
const ctrl = new AbortController();
// Every spool, not the first page: the count and total above are read off
// this list. That renders all of them; a filament with thousands of spools
// would want a "show more" here and the group endpoint's totals for the header.
allPages(SPOOL_PAGE, (offset) =>
spoolSource.listSpools({
filters: {},
sort: [{ field: 'last_used', dir: 'desc' }],
sort: [
{ field: 'last_used', dir: 'desc' },
{ field: 'id', dir: 'asc' }
],
groupScope: { field: 'filament', key: id },
allowArchived: archived,
limit: 100,
offset: 0,
lowThreshold: settings.lowThreshold
limit: SPOOL_PAGE,
offset,
lowThreshold: settings.lowThreshold,
signal: ctrl.signal
})
.then((page) => {
if (!cancelled) spools = page.items;
)
.then((items) => {
// A spool used mid-load moves up the sort and can turn up on two pages.
if (!ctrl.signal.aborted) spools = [...new Map(items.map((s) => [s.id, s])).values()];
})
.catch((e) => console.error('Failed to load filament spools', e));
return () => {
cancelled = true;
};
.catch((e) => {
if (!isAbortError(e, ctrl.signal)) console.error('Failed to load filament spools', e);
});
return () => ctrl.abort();
});
const saver = makeSaver<string, Partial<Filament>>((id, patch) =>
+17 -10
View File
@@ -8,6 +8,7 @@
import { settings } from '$lib/stores/settings.svelte';
import { fields } from '$lib/stores/fields.svelte';
import { spoolSource } from '$lib/api/spoolSource';
import { allPages } from '$lib/api/query';
import { live, type LiveEvent } from '$lib/api/live';
import { isAbortError } from '$lib/api/http';
import { mapSpool } from '$lib/api/map';
@@ -36,6 +37,8 @@
const FLIP = 160;
/** Spools fetched per request when filling (or extending) a card. */
const PAGE = 30;
/** Groups fetched per request when listing the cards. */
const GROUP_PAGE = 1000;
/** Distance from the bottom of a card's list at which the next page is fetched. */
const SCROLL_MARGIN = 120;
/** The unassigned card's key: no value for the grouped-by field. */
@@ -170,20 +173,24 @@
// controller for a later view.
const signal = pageAbort.signal;
try {
const page = await spoolSource.listGroups({
field: fieldKey,
filters: {},
sort: [{ field: 'group.title', dir: 'asc' }],
limit: 1000,
offset: 0,
lowThreshold: settings.lowThreshold,
signal
});
// Every group gets a card, so page through all of them rather than stopping at
// the first page; a group past it would never show up at all.
const groups = await allPages(GROUP_PAGE, (offset) =>
spoolSource.listGroups({
field: fieldKey,
filters: {},
sort: [{ field: 'group.title', dir: 'asc' }],
limit: GROUP_PAGE,
offset,
lowThreshold: settings.lowThreshold,
signal
})
);
// A NULL value and an empty-string one are distinct rows to the database but the
// same "unassigned" card here, so counts are summed by key.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
const totals = new Map<string, number>();
for (const g of page.items) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
for (const g of groups) totals.set(g.key, (totals.get(g.key) ?? 0) + g.spoolCount);
for (const [key, total] of totals) {
const b = bucket(key);