mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-21 09:39:46 -05:00
Turn the locations page into a group-by-anything dashboard
The locations board could only ever answer one question: where is this spool. The same card-and-drag interaction works for any field a spool carries, so make the field a view mode and keep the rest. Location is still the default view and behaves exactly as before. The other views come from the user's own spool fields: any single-value text or choice field now appears in a Group-by menu. A choice field renders a card per choice, including the empty ones, which turns a "Status" field into a kanban board. Which field a card belongs to is decided by one small interface (src/lib/dashboard/fields.ts), so the page never special-cases a field. A field only qualifies if the SPOOL OWNS IT, because a drag moves one spool and must mean exactly one change. That rules out material and vendor, where writing one spool's card would silently rewrite every other spool of the same filament, and lot number, which is stamped on by the manufacturer and would be recording something untrue. Those stay library filters. It also rules out numbers, dates, booleans and multi-choice, whose values don't name a card you can drag into. Backing changes: - /spool/group grows group_by=extra.<key> for a spool's custom fields. The field table is aliased so it stays a distinct FROM element -- the extra-field filters reference the un-aliased table inside correlated subqueries, and without the alias those would correlate to this join instead of standing alone. Grouping is rejected for field types whose value isn't a single repeatable string. group_by is no longer a Literal, so an unsupported value is a 400 rather than a 422. - The saved layout is per field: dashboard_groups and dashboard_spoolorders replace the location-only pair. The old `locations` / `locations_spoolorders` are read once as a seed for the location view and never written back, so an upgrade keeps the shelves it had and the old client keeps working. - The view mode lives in the URL (?by=) so a board can be bookmarked and shared, and is mirrored to localStorage so a bare /dashboard resumes the last one looked at. The URL wins when it says something; a stale ?by= naming a deleted field falls back to location. Verified against SQLite, PostgreSQL, MariaDB and CockroachDB -- Postgres caught a real difference here, it sorts NULLs last where SQLite sorts them first, so the new group assertions are keyed rather than ordered.
This commit is contained in:
1 parent
bf3480cfba
commit
21651216af
18 files changed
+1655
-1010
No files matched your search
@@ -135,7 +135,7 @@ async function audit(page: Page, testInfo: TestInfo, label: string) {
|
||||
|
||||
const STATIC_ROUTES: { path: string; label: string }[] = [
|
||||
{ path: '/', label: 'library (home)' },
|
||||
{ path: '/locations', label: 'locations' },
|
||||
{ path: '/dashboard', label: 'dashboard' },
|
||||
{ path: '/labels', label: 'labels' },
|
||||
{ path: '/settings', label: 'settings' }
|
||||
];
|
||||
|
||||
@@ -53,6 +53,22 @@
|
||||
"common.increment": "Increment",
|
||||
"common.remove": "Remove",
|
||||
"common.search": "Search",
|
||||
"dashboard.addFieldHint": "Add a text or choice field to spools in Settings to group by it too",
|
||||
"dashboard.dashboard": "Dashboard",
|
||||
"dashboard.deleteGroup": "Delete group",
|
||||
"dashboard.dragHint": "Drag a spool to another card to change its {{field}}, or within a card to reorder",
|
||||
"dashboard.dropHere": "drop spools here",
|
||||
"dashboard.emptyHelp": "This page groups your spools by a field of your choosing, add some spools to get started!",
|
||||
"dashboard.errorEmptyName": "Name cannot be empty",
|
||||
"dashboard.errorExists": "A group with that name already exists",
|
||||
"dashboard.errorRename": "Failed to rename group",
|
||||
"dashboard.lastUsed": "Last used {{time}} ago",
|
||||
"dashboard.newGroup": "New group",
|
||||
"dashboard.newGroupName": "New group {{n}}",
|
||||
"dashboard.reorderGroup": "Drag to reorder group",
|
||||
"dashboard.spoolCount_one": "{{count}} spool",
|
||||
"dashboard.spoolCount_other": "{{count}} spools",
|
||||
"dashboard.unassigned": "Unassigned",
|
||||
"datetime.done": "Done",
|
||||
"datetime.hour": "Hour",
|
||||
"datetime.minute": "Minute",
|
||||
@@ -61,9 +77,9 @@
|
||||
"datetime.pick": "Pick date and time",
|
||||
"datetime.prevMonth": "Previous month",
|
||||
"datetime.time": "Time",
|
||||
"documentTitle.dashboard.list": "Dashboard | Spoolman",
|
||||
"documentTitle.labels.list": "Labels | Spoolman",
|
||||
"documentTitle.library.list": "Library | Spoolman",
|
||||
"documentTitle.locations.list": "Locations | Spoolman",
|
||||
"documentTitle.settings.list": "Settings | Spoolman",
|
||||
"filament.fields.addColor": "Add color",
|
||||
"filament.fields.articleNumber": "Article Number",
|
||||
@@ -256,21 +272,6 @@
|
||||
"library.unusedSubMixed": "{{location}}",
|
||||
"library.usedAgo": "used {{time}} ago",
|
||||
"loading": "Loading",
|
||||
"locations.dragHint": "Drag spools between locations to reassign them, or within a location to reorder them",
|
||||
"locations.dropHere": "drop spools here",
|
||||
"locations.errorEmpty": "Location name cannot be empty",
|
||||
"locations.errorExists": "A location with that name already exists",
|
||||
"locations.errorRename": "Failed to rename location",
|
||||
"locations.lastUsed": "Last used {{time}} ago",
|
||||
"locations.locations": "Locations",
|
||||
"locations.newLocation": "New Location",
|
||||
"locations.newShelf": "New location {{n}}",
|
||||
"locations.noLocation": "No Location",
|
||||
"locations.deleteLocation": "Delete location",
|
||||
"locations.noLocationsHelp": "This page lets you organize your spools in locations, add some spools to get started!",
|
||||
"locations.reorderLocation": "Drag to reorder location",
|
||||
"locations.spoolCount_one": "{{count}} spool",
|
||||
"locations.spoolCount_other": "{{count}} spools",
|
||||
"nav.labels": "Labels",
|
||||
"nav.library": "Library",
|
||||
"no": "No",
|
||||
|
||||
@@ -106,7 +106,10 @@ export function mapGroup(g: Json): GroupSummary {
|
||||
title = g.key ?? 'No location';
|
||||
subtitle = `${g.in_use_count} in use`;
|
||||
} else {
|
||||
// extra.<key>: the key IS the value, and there is no entity to name the group.
|
||||
// The dashboard, which is what groups on these, titles them itself.
|
||||
title = g.key ?? '';
|
||||
subtitle = `${g.spool_count} spool${g.spool_count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -89,6 +89,10 @@ function sortParam(sort: { field: string; dir: string }[]): string | undefined {
|
||||
|
||||
function scopeParams(params: QueryParams, scope: SpoolQuery['groupScope']) {
|
||||
if (!scope) return;
|
||||
// An empty key is the group of spools whose value is unset. Every string-valued
|
||||
// field spells that as an empty filter, which the backend reads as "null or empty"
|
||||
// (and, for an extra field, "no row for this key either").
|
||||
const exact = (v: string) => (v === '' ? '' : quote(v));
|
||||
switch (scope.field) {
|
||||
case 'filament':
|
||||
params['filament.id'] = scope.key;
|
||||
@@ -97,10 +101,14 @@ function scopeParams(params: QueryParams, scope: SpoolQuery['groupScope']) {
|
||||
params['filament.vendor.id'] = scope.key === '' ? '-1' : scope.key;
|
||||
break;
|
||||
case 'material':
|
||||
params['filament.material'] = scope.key === '' ? '' : quote(scope.key);
|
||||
params['filament.material'] = exact(scope.key);
|
||||
break;
|
||||
case 'location':
|
||||
params['location'] = scope.key === '' ? '' : quote(scope.key);
|
||||
params['location'] = exact(scope.key);
|
||||
break;
|
||||
default:
|
||||
// `extra.<key>`, which is already the query param the backend expects.
|
||||
params[scope.field] = exact(scope.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@ export interface SortField {
|
||||
dir: SortDir;
|
||||
}
|
||||
|
||||
/** The entity/axis a group is keyed on. */
|
||||
export type GroupField = 'filament' | 'vendor' | 'material' | 'location';
|
||||
/**
|
||||
* The entity/axis a group is keyed on. These are the API's own `group_by` values, so
|
||||
* they travel to the server unchanged — `extra.<key>` groups on a spool custom field.
|
||||
*/
|
||||
export type GroupField = 'filament' | 'vendor' | 'material' | 'location' | `extra.${string}`;
|
||||
|
||||
/** One group returned by listGroups — the header + server aggregates. */
|
||||
export interface GroupSummary {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: m['nav.library'] },
|
||||
{ href: '/locations', label: m['locations.locations'] },
|
||||
{ href: '/dashboard', label: m['dashboard.dashboard'] },
|
||||
{ href: '/labels', label: m['nav.labels'] },
|
||||
{ href: '/settings', label: m['settings.header'] }
|
||||
] satisfies { href: Pathname; label: () => string }[];
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Spool } from '$lib/types';
|
||||
import type { GroupField } from '$lib/api/types';
|
||||
import { FieldType, type FieldDef } from '$lib/api/fields';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// The dashboard groups spools into cards by ONE field, and dragging a spool between
|
||||
// cards writes that field. This module is the list of fields that can play that role,
|
||||
// and everything the board needs to read and write one without special-casing it.
|
||||
//
|
||||
// A field qualifies on two counts. It has to be OWNED BY THE SPOOL, because a drag moves
|
||||
// exactly one spool and must mean exactly one change — a filament-owned field like material
|
||||
// would rewrite every other spool of that filament as a side effect of dragging one. And its
|
||||
// value has to be a short, repeatable string a spool can be *assigned* to, which rules out
|
||||
// numbers, dates, booleans and multi-choice; the backend rejects grouping on those too (see
|
||||
// GROUPABLE_EXTRA_FIELD_TYPES).
|
||||
//
|
||||
// That leaves the built-in location plus any single-value text or choice field the user has
|
||||
// added to spools. Lot number is the near miss: spool-owned and short, but stamped on by the
|
||||
// manufacturer, so dragging a spool into another lot would record something untrue. Both it
|
||||
// and material are better served by a library filter than by a board.
|
||||
|
||||
/** The value a spool has for a field; the empty string is the "unassigned" group. */
|
||||
export type GroupKey = string;
|
||||
|
||||
export interface DashboardField {
|
||||
/** Identity: the API's `group_by` value, and the key this field's layout is saved under. */
|
||||
key: GroupField;
|
||||
label: string;
|
||||
/** This spool's current value, '' when unset. */
|
||||
valueOf(spool: Spool): GroupKey;
|
||||
/** The same spool with `value` applied, for the board's own copies. Pure — no request. */
|
||||
withValue(spool: Spool, value: GroupKey): Spool;
|
||||
/** Write `value` to the spool, updating the cache first. */
|
||||
assign(spool: Spool, value: GroupKey): Promise<void>;
|
||||
/**
|
||||
* Rename a whole group, moving every spool in it. Only defined where the backend can
|
||||
* do it in one shot; elsewhere the board only lets empty groups be renamed.
|
||||
*/
|
||||
rename?(from: GroupKey, to: GroupKey): Promise<void>;
|
||||
/** Fixed set of permitted values (single-choice fields); undefined means free text. */
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
const LOCATION: DashboardField = {
|
||||
key: 'location',
|
||||
get label() {
|
||||
return m['spool.fields.location']();
|
||||
},
|
||||
valueOf: (s) => s.location,
|
||||
withValue: (s, value) => ({ ...s, location: value }),
|
||||
async assign(spool, value) {
|
||||
inventory.patchSpool(spool.id, { location: value });
|
||||
await spoolSource.saveSpool(spool.id, { location: value });
|
||||
},
|
||||
rename: (from, to) => spoolSource.renameLocation(from, to)
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra-field values are stored JSON-encoded; the group key is the decoded string.
|
||||
* Clearing writes JSON null rather than dropping the key: the API patches the extra map
|
||||
* key by key, so an omitted key would leave the old value in place.
|
||||
*/
|
||||
function extraField(def: FieldDef): DashboardField {
|
||||
const key = `extra.${def.key}` as GroupField;
|
||||
const encode = (value: GroupKey) => (value === '' ? 'null' : JSON.stringify(value));
|
||||
return {
|
||||
key,
|
||||
label: def.name,
|
||||
valueOf(spool) {
|
||||
const raw = spool.extra[def.key];
|
||||
if (raw === undefined) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === 'string' ? parsed : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
withValue: (spool, value) => ({ ...spool, extra: { ...spool.extra, [def.key]: encode(value) } }),
|
||||
async assign(spool, value) {
|
||||
const extra = { [def.key]: encode(value) };
|
||||
inventory.patchSpool(spool.id, { extra: { ...spool.extra, ...extra } });
|
||||
await spoolSource.saveSpool(spool.id, { extra });
|
||||
},
|
||||
choices: def.field_type === FieldType.choice ? (def.choices ?? []) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/** Spool extra fields that hold one plain string, and so can be grouped and dragged into. */
|
||||
function isGroupable(def: FieldDef): boolean {
|
||||
if (def.field_type === FieldType.text) return true;
|
||||
return def.field_type === FieldType.choice && !def.multi_choice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field the dashboard can group by, in menu order: location (the default view) first,
|
||||
* then the user's own spool fields. A site with no custom spool fields therefore has exactly
|
||||
* one view — the location board — until it defines one.
|
||||
*/
|
||||
export function dashboardFields(spoolFieldDefs: FieldDef[]): DashboardField[] {
|
||||
return [LOCATION, ...spoolFieldDefs.filter(isGroupable).map(extraField)];
|
||||
}
|
||||
|
||||
export const DEFAULT_FIELD_KEY: GroupField = 'location';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getSettings, setSetting, parseSetting } from '$lib/api/settings';
|
||||
|
||||
// The dashboard's saved layout: which cards a field's board shows, in what order, and
|
||||
// the order of the spools inside each card. Both are stored per grouped-by field, so
|
||||
// switching the view mode brings back that view's own arrangement.
|
||||
//
|
||||
// A group key is the field's value; the empty string is the "unassigned" card.
|
||||
|
||||
export interface DashboardLayout {
|
||||
/** field key → ordered group keys. */
|
||||
groups: Record<string, string[]>;
|
||||
/** field key → group key → ordered spool ids. */
|
||||
spoolOrders: Record<string, Record<string, number[]>>;
|
||||
}
|
||||
|
||||
/** The field key whose layout the pre-dashboard location board's settings describe. */
|
||||
const SEEDED_FIELD = 'location';
|
||||
|
||||
export async function loadLayout(signal?: AbortSignal): Promise<DashboardLayout> {
|
||||
const s = await getSettings(signal);
|
||||
const groups = parseSetting<Record<string, string[]>>(s.dashboard_groups, {});
|
||||
const spoolOrders = parseSetting<Record<string, Record<string, number[]>>>(s.dashboard_spoolorders, {});
|
||||
|
||||
// Before the dashboard there was a location-only board, whose layout lived in
|
||||
// `locations` / `locations_spoolorders`. Those keys still belong to the old client, so
|
||||
// they are read as a one-time seed for the location view and never written back — an
|
||||
// upgrade keeps the shelves it had, and the old client keeps working unchanged.
|
||||
if (!groups[SEEDED_FIELD]) {
|
||||
const seed = parseSetting<string[]>(s.locations, []).filter((l) => l != null);
|
||||
if (seed.length) groups[SEEDED_FIELD] = seed;
|
||||
}
|
||||
if (!spoolOrders[SEEDED_FIELD]) {
|
||||
const seed = parseSetting<Record<string, number[]>>(s.locations_spoolorders, {});
|
||||
if (Object.keys(seed).length) spoolOrders[SEEDED_FIELD] = seed;
|
||||
}
|
||||
|
||||
return { groups, spoolOrders };
|
||||
}
|
||||
|
||||
export function saveGroups(groups: DashboardLayout['groups']): void {
|
||||
setSetting('dashboard_groups', groups).catch((e) => console.error('Failed to save dashboard groups', e));
|
||||
}
|
||||
|
||||
export function saveSpoolOrders(spoolOrders: DashboardLayout['spoolOrders']): void {
|
||||
setSetting('dashboard_spoolorders', spoolOrders).catch((e) =>
|
||||
console.error('Failed to save dashboard spool order', e)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// The dashboard's view mode — which field its cards group by — lives in the URL, so a
|
||||
// particular board can be bookmarked and shared. It is also mirrored to localStorage, so
|
||||
// arriving at a bare /dashboard brings back the view you last looked at.
|
||||
//
|
||||
// The URL always wins when it says something: a bookmark has to mean what it says, even
|
||||
// when this browser was last left on a different view. The parameter is written even for
|
||||
// the default field rather than being omitted, so "no parameter" unambiguously means
|
||||
// "nothing asked for" and the remembered view can take over.
|
||||
|
||||
export const FIELD_PARAM = 'by';
|
||||
|
||||
const LAST_VIEW_KEY = 'spoolman-v2-dashboard-field';
|
||||
|
||||
/** The field key the URL asks for, or null when it doesn't say. */
|
||||
export function fieldKeyFromUrl(params: URLSearchParams): string | null {
|
||||
return params.get(FIELD_PARAM);
|
||||
}
|
||||
|
||||
/** The view this browser was last left on, if any. */
|
||||
export function rememberedFieldKey(): string | null {
|
||||
if (!browser) return null;
|
||||
try {
|
||||
return localStorage.getItem(LAST_VIEW_KEY);
|
||||
} catch {
|
||||
// Private-mode / storage-disabled browsers: the URL and the default still work.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberFieldKey(key: string): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(LAST_VIEW_KEY, key);
|
||||
} catch {
|
||||
/* nothing to do — remembering the view is a convenience, not a requirement */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the address bar at `key`'s board.
|
||||
*
|
||||
* `replace` swaps the current history entry, used when canonicalising a URL that didn't
|
||||
* name a view; picking a view from the menu pushes, so Back returns to the previous board.
|
||||
*/
|
||||
export function gotoField(key: string, replace = false): void {
|
||||
// A bare `?query` resolves against the current URL, so this is base-path-independent.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
goto(`?${FIELD_PARAM}=${encodeURIComponent(key)}`, {
|
||||
replaceState: replace,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
@@ -18,22 +18,29 @@ function byOrder(list: FieldDef[]): FieldDef[] {
|
||||
|
||||
class Fields {
|
||||
private defs = $state<Record<EntityType, FieldDef[]>>({ spool: [], filament: [], vendor: [] });
|
||||
private loaded = new Set<EntityType>();
|
||||
// Reactive so views can wait for the definitions rather than render against an
|
||||
// empty list and then rearrange themselves once they arrive.
|
||||
private loaded = $state<EntityType[]>([]);
|
||||
|
||||
get(entity: EntityType): FieldDef[] {
|
||||
return this.defs[entity] ?? [];
|
||||
}
|
||||
|
||||
/** Whether this entity's definitions are in hand — an empty list is a real answer. */
|
||||
isLoaded(entity: EntityType): boolean {
|
||||
return this.loaded.includes(entity);
|
||||
}
|
||||
|
||||
/** Load once (idempotent). */
|
||||
ensure(entity: EntityType) {
|
||||
if (!this.loaded.has(entity)) void this.load(entity);
|
||||
if (!this.loaded.includes(entity)) void this.load(entity);
|
||||
}
|
||||
|
||||
async load(entity: EntityType) {
|
||||
try {
|
||||
const list = await getFields(entity);
|
||||
this.defs = { ...this.defs, [entity]: byOrder(list) };
|
||||
this.loaded.add(entity);
|
||||
if (!this.loaded.includes(entity)) this.loaded = [...this.loaded, entity];
|
||||
} catch (e) {
|
||||
console.error(`Failed to load ${entity} fields`, e);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,960 +0,0 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve --
|
||||
Every href below comes from a src/lib/library/params.ts helper, which already
|
||||
resolves against the deploy base path; resolving again would double-apply it. */
|
||||
import Swatch from '$components/Swatch.svelte';
|
||||
import type { Spool } from '$lib/types';
|
||||
import { inventory } from '$lib/stores/inventory.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { spoolSource } from '$lib/api/spoolSource';
|
||||
import { live, type LiveEvent } from '$lib/api/live';
|
||||
import { isAbortError } from '$lib/api/http';
|
||||
import { mapSpool } from '$lib/api/map';
|
||||
import { libraryHref } from '$lib/library/params';
|
||||
import { weightAuto } from '$lib/utils/format';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getSettings, setSetting, parseSetting } from '$lib/api/settings';
|
||||
import { dndzone, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flushSync } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import GripVertical from '@lucide/svelte/icons/grip-vertical';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
const NO_LOCATION = 'No location';
|
||||
const FLIP = 160;
|
||||
/** Spools fetched per request when filling (or extending) a location card. */
|
||||
const PAGE = 30;
|
||||
/** Distance from the bottom of a card's list at which the next page is fetched. */
|
||||
const SCROLL_MARGIN = 120;
|
||||
|
||||
// A card's body reserves its final height up front, derived from the location's
|
||||
// spool count (known from the aggregate query before any spool row is fetched),
|
||||
// so the card does not grow — and shove every card below it down — when its first
|
||||
// page arrives. Without this each card jumps from empty (~110px) to full as it
|
||||
// loads, reflowing the whole grid in waves: the load-time "flicker". The result
|
||||
// is clamped to the same cap as the body's `max-height`, so a big location fills
|
||||
// exactly to the cap with no jump. These constants must track the `.chip` /
|
||||
// `.shelf-body` CSS below (chip border-box height, row gap, vertical padding).
|
||||
const CHIP_H = 44;
|
||||
const CHIP_GAP = 6;
|
||||
const BODY_PAD = 20; // 10px top + 10px bottom
|
||||
const BODY_MIN = 42; // floor; matches .shelf-body min-height
|
||||
function bodyReserve(total: number): number {
|
||||
if (total <= 0) return BODY_MIN;
|
||||
return Math.max(BODY_MIN, BODY_PAD + total * CHIP_H + (total - 1) * CHIP_GAP);
|
||||
}
|
||||
|
||||
// A location card. `id` is what svelte-dnd-action keys on; it equals `name`,
|
||||
// the location's display name (the NO_LOCATION sentinel for the unlocated
|
||||
// bucket). `spools` is the card's own drop zone, in display order — only the
|
||||
// pages loaded so far, which is why `total` (the server's count for the whole
|
||||
// location) is what the header reports.
|
||||
type Shelf = { id: string; name: string; spools: Spool[]; total: number; loading: boolean };
|
||||
|
||||
let editingLocation = $state<string | null>(null);
|
||||
let editValue = $state('');
|
||||
let renameError = $state('');
|
||||
|
||||
// The page never loads the whole spool collection. One cheap aggregate query
|
||||
// (`/spool/group?group_by=location`) yields every location and its spool count;
|
||||
// each card then fetches its own spools a page at a time, starting when the card
|
||||
// scrolls into view and continuing as its list is scrolled. So the cost scales
|
||||
// with what is on screen, not with the size of the collection.
|
||||
//
|
||||
// The `locations` setting is an ordered JSON array of names that is the source
|
||||
// of truth for both the display order and any location added before it has
|
||||
// spools. An empty string marks the position of the "No location" bucket, so it
|
||||
// can be reordered like any other card. See the equivalent `useLocations` in the
|
||||
// old client (which pins it first instead).
|
||||
const EMPTY = '';
|
||||
let settingOrder = $state<string[]>([]);
|
||||
let settingsLoaded = $state(false);
|
||||
let locationsLoaded = $state(false);
|
||||
// Per-location custom spool order, keyed by the stored location name (EMPTY for
|
||||
// "No location"), each an array of spool ids. Source of truth for the order of
|
||||
// spools within a card. Shared with the old client's `locations_spoolorders`.
|
||||
let spoolOrders = $state<Record<string, number[]>>({});
|
||||
|
||||
// What has been loaded for one location, keyed by the STORED location name
|
||||
// (EMPTY for "No location"). `total` is authoritative and comes from the group
|
||||
// aggregate; `spools` holds only the pages fetched so far. `started` means the
|
||||
// card has come into view at least once, `done` that every page is in hand.
|
||||
type Bucket = { spools: Spool[]; total: number; loading: boolean; done: boolean; started: boolean };
|
||||
let buckets = $state<Record<string, Bucket>>({});
|
||||
|
||||
// Always read the entry back out of the state proxy after creating it — the
|
||||
// object literal itself isn't reactive, so mutating it wouldn't update the UI.
|
||||
function bucket(key: string): Bucket {
|
||||
if (!buckets[key]) buckets[key] = { spools: [], total: 0, loading: false, done: false, started: false };
|
||||
return buckets[key];
|
||||
}
|
||||
|
||||
// The live drag state. `shelves` is the structure the drag zones bind to and
|
||||
// mutate directly; it is rebuilt from the data above whenever nothing is being
|
||||
// dragged. `dragging` pauses that rebuild so an in-flight drag isn't clobbered.
|
||||
// Shelf dragging is gated to the grip handle by keeping the grid zone disabled
|
||||
// until a grip press enables it (svelte-dnd-action's drag-handle pattern), so
|
||||
// it doesn't hijack pointer presses on the spool zones nested inside each card.
|
||||
let shelves = $state<Shelf[]>([]);
|
||||
let dragging = $state(false);
|
||||
let shelvesDragDisabled = $state(true);
|
||||
let displayNames = $derived(shelves.map((sh) => sh.name));
|
||||
// Nothing at all to show: no saved locations and no spools. Only decided once
|
||||
// both loads have settled, so the help text doesn't flash on the way in.
|
||||
let isEmpty = $derived(settingsLoaded && locationsLoaded && shelves.length === 0);
|
||||
|
||||
// Map between the display sentinel and the stored empty-string marker.
|
||||
const toStored = (names: string[]) => names.map((n) => (n === NO_LOCATION ? EMPTY : n));
|
||||
const storedKey = (name: string) => (name === NO_LOCATION ? EMPTY : name);
|
||||
|
||||
// The one query that runs up front: every location that currently holds spools,
|
||||
// with its count, aggregated in the database. No spool rows cross the wire.
|
||||
// Everything this page reads is tied to one controller, aborted when the page
|
||||
// goes away. Without it, leaving mid-load leaves a screenful of card queries
|
||||
// running for a server that no longer has anyone to answer — and bouncing in
|
||||
// and out of the page stacks a fresh set on top of each abandoned one.
|
||||
let pageAbort = new AbortController();
|
||||
|
||||
async function loadLocations() {
|
||||
// Captured up front: by the time this settles, `pageAbort` may already be a
|
||||
// fresh controller for a later mount.
|
||||
const signal = pageAbort.signal;
|
||||
try {
|
||||
const page = await spoolSource.listGroups({
|
||||
field: 'location',
|
||||
filters: {},
|
||||
sort: [{ field: 'group.title', dir: 'asc' }],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
// A NULL location and an empty-string one are distinct rows to the database
|
||||
// but the same "No location" 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 [key, total] of totals) {
|
||||
const b = bucket(key);
|
||||
b.total = total;
|
||||
// Set both ways: spools added elsewhere reopen a card that had everything.
|
||||
b.done = b.spools.length >= total;
|
||||
}
|
||||
// A location that no longer has any spools keeps its card (if it is in the
|
||||
// setting) but drops whatever it had loaded.
|
||||
for (const [key, b] of Object.entries(buckets)) {
|
||||
if (totals.has(key)) continue;
|
||||
b.total = 0;
|
||||
b.spools = [];
|
||||
b.done = true;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load locations', e);
|
||||
} finally {
|
||||
locationsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A screenful can be a dozen cards, and firing that many requests at once both
|
||||
// queues behind the browser's per-host connection limit (which the live-update
|
||||
// sockets already eat into) and hammers the server. Cards are filled a few at a
|
||||
// time instead; the rest wait their turn.
|
||||
const MAX_INFLIGHT = 3;
|
||||
let inflight = 0;
|
||||
const waiting: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* Wait for a slot. Returns false if the page was abandoned while queued — the
|
||||
* queue is where most of the waste sits when someone navigates away from a
|
||||
* screenful of cards, so those turns must be dropped rather than sent.
|
||||
*/
|
||||
async function acquire(signal: AbortSignal): Promise<boolean> {
|
||||
if (inflight >= MAX_INFLIGHT) await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
if (signal.aborted) {
|
||||
// Hand the slot straight to the next in line instead of holding it.
|
||||
waiting.shift()?.();
|
||||
return false;
|
||||
}
|
||||
inflight++;
|
||||
return true;
|
||||
}
|
||||
function release() {
|
||||
inflight--;
|
||||
waiting.shift()?.();
|
||||
}
|
||||
|
||||
// Fetch one more page of a location's spools. Ordering is by id so paging is
|
||||
// stable; the card's custom order is applied to what has been loaded (see
|
||||
// `orderSpools`). Callers may fire this freely — it is a no-op while a request
|
||||
// is in flight or once everything is loaded.
|
||||
async function loadPage(name: string) {
|
||||
const key = storedKey(name);
|
||||
const b = bucket(key);
|
||||
if (b.loading || b.done) return;
|
||||
const signal = pageAbort.signal;
|
||||
b.started = true;
|
||||
b.loading = true;
|
||||
if (!(await acquire(signal))) {
|
||||
b.loading = false;
|
||||
b.started = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await spoolSource.listSpools({
|
||||
filters: {},
|
||||
sort: [{ field: 'id', dir: 'asc' }],
|
||||
groupScope: { field: 'location', key },
|
||||
limit: PAGE,
|
||||
offset: b.spools.length,
|
||||
lowThreshold: settings.lowThreshold,
|
||||
signal
|
||||
});
|
||||
// A spool moved in by a drag or a live event may already be held here.
|
||||
const seen = new Set(b.spools.map((s) => s.id));
|
||||
const fresh = page.items.filter((s) => !seen.has(s.id));
|
||||
b.spools = [...b.spools, ...fresh];
|
||||
b.total = page.total;
|
||||
b.done = page.items.length === 0 || b.spools.length >= page.total;
|
||||
} catch (e) {
|
||||
// Leave the card unstarted so scrolling it back into view retries, rather
|
||||
// than stranding it empty for the rest of the session. A cancelled request
|
||||
// is the same story without the noise.
|
||||
if (!isAbortError(e, signal)) console.error('Failed to load spools for location', name, e);
|
||||
b.started = false;
|
||||
} finally {
|
||||
release();
|
||||
b.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** First page for a card that just scrolled into view. */
|
||||
function ensureLoaded(name: string) {
|
||||
if (!bucket(storedKey(name)).started) loadPage(name);
|
||||
}
|
||||
|
||||
function onBodyScroll(name: string, e: Event) {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_MARGIN) loadPage(name);
|
||||
}
|
||||
|
||||
// Load a card's first page once it is (nearly) on screen, so a collection with
|
||||
// hundreds of locations only queries the ones actually being looked at.
|
||||
function inView(node: HTMLElement, onEnter: () => void) {
|
||||
let enter = onEnter;
|
||||
const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && enter(), {
|
||||
rootMargin: '250px'
|
||||
});
|
||||
io.observe(node);
|
||||
return {
|
||||
update(next: () => void) {
|
||||
enter = next;
|
||||
},
|
||||
destroy() {
|
||||
io.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLocationsSetting() {
|
||||
const signal = pageAbort.signal;
|
||||
try {
|
||||
const s = await getSettings(signal);
|
||||
settingOrder = parseSetting<string[]>(s.locations, []).filter((l) => l != null);
|
||||
spoolOrders = parseSetting<Record<string, number[]>>(s.locations_spoolorders, {});
|
||||
} catch (e) {
|
||||
if (isAbortError(e, signal)) return;
|
||||
console.error('Failed to load locations setting', e);
|
||||
} finally {
|
||||
settingsLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function saveOrder(order: string[]) {
|
||||
settingOrder = order;
|
||||
setSetting('locations', order).catch((e) => console.error('Failed to save location order', e));
|
||||
}
|
||||
|
||||
// A remote spool change is applied to the loaded cards directly — reloading them
|
||||
// all would undo the point of paging. Counts (and any location that just came
|
||||
// into or out of existence) come from a single debounced aggregate refresh,
|
||||
// which is also what repairs the count of a card whose spools aren't loaded and
|
||||
// whose previous location we therefore can't know.
|
||||
let countsTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function scheduleCountRefresh() {
|
||||
if (countsTimer) clearTimeout(countsTimer);
|
||||
countsTimer = setTimeout(() => {
|
||||
countsTimer = null;
|
||||
loadLocations();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function applyLiveEvent(event: LiveEvent) {
|
||||
// Keep the shared cache fresh first: the chips read their filament from it.
|
||||
inventory.ingest(event);
|
||||
|
||||
const id = Number(event.id);
|
||||
const spool = event.type !== 'deleted' && event.payload ? mapSpool(event.payload) : null;
|
||||
// Archived spools are not shown here, so they leave their card like a deletion.
|
||||
const newKey = spool && !spool.archived ? spool.location || EMPTY : null;
|
||||
|
||||
for (const [key, b] of Object.entries(buckets)) {
|
||||
const i = b.spools.findIndex((s) => s.id === id);
|
||||
if (i === -1) continue;
|
||||
if (key === newKey) b.spools[i] = spool!;
|
||||
else b.spools = b.spools.filter((s) => s.id !== id);
|
||||
}
|
||||
if (newKey !== null) {
|
||||
const b = buckets[newKey];
|
||||
// Pages are fetched in id order, so a card holds every spool up to the
|
||||
// highest id it has loaded: anything at or below that watermark (and
|
||||
// everything at all, once the card is complete) belongs on screen now and
|
||||
// is spliced into its id position. A higher id sits in a page that hasn't
|
||||
// been fetched, and arrives with it.
|
||||
const watermark = b?.spools.length ? b.spools[b.spools.length - 1].id : -1;
|
||||
if (b && (b.done || id <= watermark) && !b.spools.some((s) => s.id === id)) {
|
||||
const at = b.spools.findIndex((s) => s.id > id);
|
||||
b.spools = at === -1 ? [...b.spools, spool!] : b.spools.toSpliced(at, 0, spool!);
|
||||
}
|
||||
}
|
||||
scheduleCountRefresh();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
pageAbort = new AbortController();
|
||||
loadLocations();
|
||||
loadLocationsSetting();
|
||||
const off = live.subscribe('spool', {}, applyLiveEvent);
|
||||
return () => {
|
||||
off();
|
||||
if (countsTimer) clearTimeout(countsTimer);
|
||||
// Drops both the requests already on the wire and the ones still queued
|
||||
// behind MAX_INFLIGHT.
|
||||
pageAbort.abort();
|
||||
};
|
||||
});
|
||||
|
||||
// The full display order: the saved setting order (empty string → the
|
||||
// "No location" bucket), then any location discovered from spools that isn't
|
||||
// in the setting yet. The "No location" bucket only shows when unlocated
|
||||
// spools exist, and defaults to the front when the setting doesn't place it.
|
||||
function computeOrderedNames(present: Set<string>): string[] {
|
||||
const all: string[] = [];
|
||||
for (const l of settingOrder) {
|
||||
const name = l === EMPTY ? NO_LOCATION : l;
|
||||
if (!all.includes(name)) all.push(name);
|
||||
}
|
||||
if (present.has(NO_LOCATION) && !all.includes(NO_LOCATION)) all.unshift(NO_LOCATION);
|
||||
for (const name of present) if (name !== NO_LOCATION && !all.includes(name)) all.push(name);
|
||||
return all.filter((n) => n !== NO_LOCATION || present.has(NO_LOCATION));
|
||||
}
|
||||
|
||||
// Sort a location's LOADED spools by its saved custom order; any spool not in the
|
||||
// order keeps its incoming (id-sorted) position at the end. Array.sort is
|
||||
// stable, so ties preserve that order.
|
||||
function orderSpools(name: string, list: Spool[]): Spool[] {
|
||||
const order = spoolOrders[storedKey(name)];
|
||||
if (!order?.length) return list;
|
||||
const rank = new Map(order.map((id, i) => [id, i]));
|
||||
const at = (id: number) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return [...list].sort((a, b) => at(a.id) - at(b.id));
|
||||
}
|
||||
|
||||
// Which locations currently hold spools, by display name.
|
||||
function presentNames(): Set<string> {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient local, not reactive state
|
||||
const names = new Set<string>();
|
||||
for (const [key, b] of Object.entries(buckets)) {
|
||||
if (b.total > 0) names.add(key === EMPTY ? NO_LOCATION : key);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function buildShelves(): Shelf[] {
|
||||
return computeOrderedNames(presentNames()).map((name) => {
|
||||
const b = buckets[storedKey(name)];
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
spools: orderSpools(name, b?.spools ?? []),
|
||||
total: b?.total ?? 0,
|
||||
loading: b?.loading ?? false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild the displayed cards from the data whenever it changes, except while a
|
||||
// drag is in progress (the drag handlers own `shelves` then). Also keeps the
|
||||
// `locations` setting reflecting the displayed order so locations discovered
|
||||
// from spools are persisted; it settles once the setting matches.
|
||||
$effect(() => {
|
||||
// Both loads have to have settled: rebuilding before the locations are known
|
||||
// would drop the "No location" marker out of the order and then save that.
|
||||
if (!settingsLoaded || !locationsLoaded || dragging) return;
|
||||
const desired = buildShelves();
|
||||
shelves = desired;
|
||||
const stored = toStored(desired.map((sh) => sh.name));
|
||||
if (JSON.stringify(stored) !== JSON.stringify(settingOrder)) saveOrder(stored);
|
||||
});
|
||||
|
||||
// Arming the grid's drag zone from the grip has to happen BEFORE the press
|
||||
// reaches the card: svelte-dnd-action only attaches its mousedown/touchstart
|
||||
// listeners to an item while the zone's `dragDisabled` is false, and it does so
|
||||
// when the action updates. Svelte 5 flushes state changes asynchronously, so
|
||||
// setting the flag in a plain handler arrives too late and the press is simply
|
||||
// never seen — flushSync applies it (and re-runs the action) synchronously.
|
||||
// `pointerdown` is the hook because it precedes both mousedown and touchstart,
|
||||
// so mouse and touch are armed the same way.
|
||||
function enableShelfDrag() {
|
||||
flushSync(() => (shelvesDragDisabled = false));
|
||||
}
|
||||
|
||||
function shelfConsider(e: CustomEvent<DndEvent<Shelf>>) {
|
||||
dragging = true;
|
||||
shelves = e.detail.items;
|
||||
}
|
||||
|
||||
function shelfFinalize(e: CustomEvent<DndEvent<Shelf>>) {
|
||||
shelves = e.detail.items;
|
||||
dragging = false;
|
||||
shelvesDragDisabled = true;
|
||||
saveOrder(toStored(shelves.map((sh) => sh.name)));
|
||||
}
|
||||
|
||||
function spoolConsider(idx: number, e: CustomEvent<DndEvent<Spool>>) {
|
||||
dragging = true;
|
||||
shelves[idx].spools = e.detail.items;
|
||||
}
|
||||
|
||||
function spoolFinalize(idx: number, e: CustomEvent<DndEvent<Spool>>) {
|
||||
shelves[idx].spools = e.detail.items;
|
||||
dragging = false;
|
||||
commitSpoolLayout();
|
||||
}
|
||||
|
||||
// Persist the outcome of a spool drag from the current card layout: move any
|
||||
// spool that now sits in a different card to that location, then save every
|
||||
// card's spool order. Diff-based, so the duplicate finalize fired on the other
|
||||
// zone of a cross-card move is a harmless no-op.
|
||||
function commitSpoolLayout() {
|
||||
const changed: { id: number; location: string }[] = [];
|
||||
for (const sh of shelves) {
|
||||
const key = storedKey(sh.name);
|
||||
const b = bucket(key);
|
||||
const before = b.spools.length;
|
||||
b.spools = sh.spools.map((sp) => {
|
||||
if ((sp.location || '') === key) return sp;
|
||||
changed.push({ id: sp.id, location: key });
|
||||
return { ...sp, location: key };
|
||||
});
|
||||
// Cards hold a page, not the location, so the count has to be adjusted by
|
||||
// what moved rather than recomputed from the list.
|
||||
b.total += b.spools.length - before;
|
||||
}
|
||||
for (const c of changed) {
|
||||
inventory.patchSpool(c.id, { location: c.location });
|
||||
spoolSource.saveSpool(c.id, { location: c.location }).catch((e) => console.error('Move failed', e));
|
||||
}
|
||||
|
||||
// A card's saved order can only speak for the spools it has loaded. Ids from
|
||||
// the previous order that aren't loaded (and haven't moved to another card)
|
||||
// are kept, after the loaded ones, so a drag in a partially loaded card
|
||||
// doesn't discard the order of the pages below it.
|
||||
const moved = new Set(changed.map((c) => c.id));
|
||||
const nextOrders: Record<string, number[]> = { ...spoolOrders };
|
||||
for (const sh of shelves) {
|
||||
const key = storedKey(sh.name);
|
||||
const loaded = sh.spools.map((s) => s.id);
|
||||
const inCard = new Set(loaded);
|
||||
const carried = (spoolOrders[key] ?? []).filter((id) => !inCard.has(id) && !moved.has(id));
|
||||
nextOrders[key] = [...loaded, ...carried];
|
||||
}
|
||||
if (JSON.stringify(nextOrders) !== JSON.stringify(spoolOrders)) {
|
||||
spoolOrders = nextOrders;
|
||||
setSetting('locations_spoolorders', nextOrders).catch((e) =>
|
||||
console.error('Failed to save spool order', e)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addLocation() {
|
||||
const existing = new Set(displayNames);
|
||||
let n = 1;
|
||||
let name = m['locations.newShelf']({ n });
|
||||
while (existing.has(name)) name = m['locations.newShelf']({ n: ++n });
|
||||
saveOrder([...settingOrder, name]);
|
||||
}
|
||||
|
||||
function focusAndSelect(el: HTMLInputElement) {
|
||||
el.focus();
|
||||
el.select();
|
||||
}
|
||||
|
||||
function startEdit(loc: string) {
|
||||
if (loc === NO_LOCATION) return;
|
||||
editingLocation = loc;
|
||||
editValue = loc;
|
||||
renameError = '';
|
||||
}
|
||||
|
||||
// Only empty, non-default locations can be removed (matches the old client).
|
||||
function deleteLocation(name: string) {
|
||||
saveOrder(settingOrder.filter((l) => l !== name));
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingLocation = null;
|
||||
renameError = '';
|
||||
}
|
||||
|
||||
async function commitEdit() {
|
||||
if (editingLocation === null) return;
|
||||
const oldName = editingLocation;
|
||||
const newName = editValue.trim();
|
||||
|
||||
if (newName === oldName) {
|
||||
editingLocation = null;
|
||||
renameError = '';
|
||||
return;
|
||||
}
|
||||
if (!newName) {
|
||||
renameError = m['locations.errorEmpty']();
|
||||
return;
|
||||
}
|
||||
if (displayNames.includes(newName)) {
|
||||
renameError = m['locations.errorExists']();
|
||||
return;
|
||||
}
|
||||
|
||||
const old = buckets[oldName];
|
||||
try {
|
||||
if (old && old.total > 0) {
|
||||
await spoolSource.renameLocation(oldName, newName);
|
||||
// Carry the loaded page over so the card doesn't have to refetch.
|
||||
buckets[newName] = {
|
||||
...old,
|
||||
spools: old.spools.map((s) => ({ ...s, location: newName }))
|
||||
};
|
||||
delete buckets[oldName];
|
||||
}
|
||||
// Carry the card's custom spool order over to the new name.
|
||||
if (spoolOrders[oldName]) {
|
||||
const { [oldName]: moved, ...rest } = spoolOrders;
|
||||
spoolOrders = { ...rest, [newName]: moved };
|
||||
setSetting('locations_spoolorders', spoolOrders).catch((e) =>
|
||||
console.error('Failed to save spool order', e)
|
||||
);
|
||||
}
|
||||
// Rename in place in the setting so the card keeps its position.
|
||||
saveOrder(settingOrder.map((l) => (l === oldName ? newName : l)));
|
||||
editingLocation = null;
|
||||
renameError = '';
|
||||
} catch (e) {
|
||||
renameError = e instanceof Error ? e.message : m['locations.errorRename']();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onpointerup={() => {
|
||||
if (!dragging) shelvesDragDisabled = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m['documentTitle.locations.list']()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page scroll-y">
|
||||
<div class="head">
|
||||
<span class="title">{m['locations.locations']()}</span>
|
||||
{#if !isEmpty}<span class="hint">{m['locations.dragHint']()}</span>{/if}
|
||||
<button class="add" onclick={addLocation}><Plus size={14} /> {m['locations.newLocation']()}</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid"
|
||||
use:dndzone={{
|
||||
items: shelves,
|
||||
type: 'shelf',
|
||||
flipDurationMs: FLIP,
|
||||
dragDisabled: shelvesDragDisabled,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
onconsider={(e) => shelfConsider(e as CustomEvent<DndEvent<Shelf>>)}
|
||||
onfinalize={(e) => shelfFinalize(e as CustomEvent<DndEvent<Shelf>>)}
|
||||
>
|
||||
{#each shelves as shelf, idx (shelf.id)}
|
||||
<div
|
||||
class="shelf"
|
||||
role="list"
|
||||
animate:flip={{ duration: FLIP }}
|
||||
use:inView={() => ensureLoaded(shelf.name)}
|
||||
>
|
||||
<div class="shelf-head">
|
||||
<span
|
||||
class="grip"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label={m['locations.reorderLocation']()}
|
||||
onpointerdown={enableShelfDrag}><GripVertical size={16} /></span
|
||||
>
|
||||
{#if editingLocation === shelf.name}
|
||||
<input
|
||||
class="shelf-name-input"
|
||||
value={editValue}
|
||||
use:focusAndSelect
|
||||
oninput={(e) => (editValue = (e.target as HTMLInputElement).value)}
|
||||
onblur={commitEdit}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="shelf-name"
|
||||
class:editable={shelf.name !== NO_LOCATION}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => startEdit(shelf.name)}
|
||||
onkeydown={(e) => e.key === 'Enter' && startEdit(shelf.name)}
|
||||
>
|
||||
{shelf.name === NO_LOCATION ? m['locations.noLocation']() : shelf.name}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="shelf-meta">{m['locations.spoolCount']({ count: shelf.total })}</span>
|
||||
{#if shelf.name !== NO_LOCATION && shelf.total === 0 && editingLocation !== shelf.name}
|
||||
<button
|
||||
class="shelf-delete"
|
||||
aria-label={m['locations.deleteLocation']()}
|
||||
title={m['locations.deleteLocation']()}
|
||||
onclick={() => deleteLocation(shelf.name)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editingLocation === shelf.name && renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="shelf-body-wrap">
|
||||
<div
|
||||
class="shelf-body"
|
||||
style="min-height: min({bodyReserve(shelf.total)}px, var(--shelf-body-cap))"
|
||||
onscroll={(e) => onBodyScroll(shelf.name, e)}
|
||||
use:dndzone={{ items: shelf.spools, type: 'spool', flipDurationMs: FLIP, dropTargetStyle: {} }}
|
||||
onconsider={(e) => spoolConsider(idx, e as CustomEvent<DndEvent<Spool>>)}
|
||||
onfinalize={(e) => spoolFinalize(idx, e as CustomEvent<DndEvent<Spool>>)}
|
||||
>
|
||||
{#each shelf.spools as s (s.id)}
|
||||
{@const f = inventory.filamentById(s.filamentId)!}
|
||||
{@const v = inventory.vendorOf(f)}
|
||||
<!-- A real link to the spool's inspector (open-in-new-tab, copy-link),
|
||||
but also a svelte-dnd-action drag item. `draggable=false` keeps the
|
||||
browser's native link drag from fighting the pointer-based dnd; a
|
||||
genuine drag never fires a click, so it won't also navigate. -->
|
||||
<a
|
||||
class="chip"
|
||||
href={libraryHref('spool', String(s.id))}
|
||||
draggable="false"
|
||||
animate:flip={{ duration: FLIP }}
|
||||
>
|
||||
<Swatch colors={f.colors} direction={f.multiColorDirection} size={22} radius={5} />
|
||||
<div class="chip-info">
|
||||
<div class="chip-title">
|
||||
<span class="chip-id mono">#{s.id}</span>
|
||||
{#if v.name !== '?'}{v.name} -
|
||||
{/if}{f.name}
|
||||
</div>
|
||||
<div class="chip-subtitle">
|
||||
{f.material} -
|
||||
<span class:low={settings.isLow(s.remaining, s.unused)}>{weightAuto(s.remaining)}</span>
|
||||
/ {weightAuto(f.weight)}{#if s.lastUsedLabel}
|
||||
- {m['locations.lastUsed']({
|
||||
time: s.lastUsedLabel
|
||||
})}{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if shelf.spools.length === 0 && !shelf.loading}
|
||||
<span class="empty">{m['locations.dropHere']()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if shelf.loading}
|
||||
<div class="shelf-loading" aria-hidden="true"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isEmpty}
|
||||
<p class="no-locations">{m['locations.noLocationsHelp']()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px 22px 40px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.add {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed var(--accent-border);
|
||||
color: var(--accent-link);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 12px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.add:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* Centered when there are only a few shelves; wraps and fills the full width
|
||||
* once enough shelves are present to need it. */
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.shelf {
|
||||
flex: 1 1 300px;
|
||||
max-width: 380px;
|
||||
/* Without this a flex item's min-width is `auto` (its min-content width), which
|
||||
* grows once the card's spool chips load. That can push the card past the point
|
||||
* where N of them still fit on a row, so the grid drops a column and re-wraps —
|
||||
* cards jumping sideways as each location's query lands (the width-wise
|
||||
* "flicker"). Pinning min-width to 0 makes the column count depend only on the
|
||||
* flex-basis and available width, so it stays put as cards fill. */
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 110px;
|
||||
}
|
||||
.shelf-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.grip {
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
cursor: grab;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
touch-action: none;
|
||||
}
|
||||
.grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.shelf-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shelf-name.editable {
|
||||
cursor: text;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.shelf-name.editable:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
.shelf-name-input {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.shelf-name-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.rename-error {
|
||||
font-size: 11px;
|
||||
color: var(--danger-soft);
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
.shelf-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.shelf-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.shelf-delete:hover {
|
||||
color: var(--danger-soft);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* Wraps the drop zone so the "drop here" hint can overlay an empty card
|
||||
* without being a child of the zone (which maps its children 1:1 to items). */
|
||||
.shelf-body-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
/* Capped so a location with hundreds of spools stays a card rather than an
|
||||
* endless column; scrolling it to the bottom fetches the next page. */
|
||||
.shelf-body {
|
||||
/* Single source of truth for the height cap: the reserved min-height (set
|
||||
* inline from the spool count) is clamped to this too, so it never exceeds
|
||||
* max-height. */
|
||||
--shelf-body-cap: min(52vh, 420px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
max-height: var(--shelf-body-cap);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/* Indeterminate bar along the bottom of a card while a page is in flight. */
|
||||
.shelf-loading {
|
||||
height: 2px;
|
||||
margin: 0 12px 6px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: var(--border-soft);
|
||||
}
|
||||
.shelf-loading::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 35%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
animation: shelf-load 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shelf-load {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(320%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shelf-loading::after {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 7px;
|
||||
padding: 6px 9px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.chip-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
.chip-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip-id {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
.chip-subtitle {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chip-subtitle .low {
|
||||
color: var(--danger-soft);
|
||||
}
|
||||
.no-locations {
|
||||
margin: 18vh auto 0;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -382,8 +382,8 @@ class SpoolGroup(BaseModel):
|
||||
None,
|
||||
description=(
|
||||
"The group key. For group_by=filament/vendor this is the entity ID as a string; for "
|
||||
"material/location it is the value. Null when the grouped field is unset (e.g. spools "
|
||||
"with no location or a filament with no vendor)."
|
||||
"material/location and extra fields it is the value. Null when the grouped "
|
||||
"field is unset (e.g. spools with no location or a filament with no vendor)."
|
||||
),
|
||||
examples=["12"],
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
@@ -379,11 +379,12 @@ async def notify_any(
|
||||
"/group",
|
||||
name="Find spool groups",
|
||||
description=(
|
||||
"Group spools that match the search query by one axis (filament, vendor, material or "
|
||||
"location) and return per-group aggregates: spool count, in-use count, total remaining "
|
||||
"weight and most recent usage. Pagination is over groups, so a group is never split and "
|
||||
"its aggregates are always complete. Uses the same filters as the spool search endpoint. "
|
||||
"The total number of matching groups is returned in the x-total-count header."
|
||||
"Group spools that match the search query by one axis (filament, vendor, material, "
|
||||
"location, or a spool extra field) and return per-group aggregates: spool count, "
|
||||
"in-use count, total remaining weight and most recent usage. Pagination is over groups, so "
|
||||
"a group is never split and its aggregates are always complete. Uses the same filters as "
|
||||
"the spool search endpoint. The total number of matching groups is returned in the "
|
||||
"x-total-count header."
|
||||
),
|
||||
response_model_exclude_none=True,
|
||||
responses={
|
||||
@@ -396,8 +397,15 @@ async def find_groups(
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
group_by: Annotated[
|
||||
Literal["filament", "vendor", "material", "location"],
|
||||
Query(title="Group By", description="The field to group spools by."),
|
||||
str,
|
||||
Query(
|
||||
title="Group By",
|
||||
description=(
|
||||
"The field to group spools by: filament, vendor, material, location, or extra.<key> "
|
||||
"for one of the spool's custom fields (text and single-choice fields only)."
|
||||
),
|
||||
examples=["location", "extra.shelf"],
|
||||
),
|
||||
],
|
||||
filament_name: Annotated[
|
||||
str | None,
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import Select
|
||||
from sqlalchemy import Alias, ColumnElement, Select
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.sql.expression import FunctionElement
|
||||
@@ -107,6 +108,55 @@ def _compile_json_array_second_default(element: _JsonArraySecondElement, compile
|
||||
return f"JSON_EXTRACT({col_sql}, '$[1]')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraFieldJoin:
|
||||
"""One extra field pulled onto a query as a plain column, ready to group or order by.
|
||||
|
||||
Built by :func:`extra_field_join`, applied with :meth:`apply`. The two steps are separate
|
||||
because the value expression has to exist before the SELECT is constructed, while the join
|
||||
can only be added afterwards.
|
||||
"""
|
||||
|
||||
alias: Alias
|
||||
field_key: str
|
||||
id_column_name: str
|
||||
#: The DB-decoded scalar, i.e. the string the user typed rather than its JSON encoding.
|
||||
value: ColumnElement[str]
|
||||
|
||||
def apply(self, stmt: Select, base_id_column: InstrumentedAttribute[int]) -> Select:
|
||||
"""Outer-join the field's row onto `stmt`.
|
||||
|
||||
OUTER, so entities with no row for this key survive with a NULL value — the same
|
||||
"unset" bucket a nullable built-in column produces.
|
||||
"""
|
||||
return stmt.join(
|
||||
self.alias,
|
||||
sqlalchemy.and_(
|
||||
self.alias.c[self.id_column_name] == base_id_column,
|
||||
self.alias.c.key == self.field_key,
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
|
||||
|
||||
def extra_field_join(entity_type: EntityType, field_key: str) -> ExtraFieldJoin:
|
||||
"""Prepare an extra field to be selected as a column of an entity query.
|
||||
|
||||
The field table is aliased so it stays a distinct FROM element: the extra-field *filters*
|
||||
reference the un-aliased table inside correlated subqueries, and without the alias those
|
||||
subqueries would correlate to this join instead of standing on their own.
|
||||
"""
|
||||
field_table = _get_field_table_for_entity(entity_type)
|
||||
id_column_name = _get_entity_id_column(field_table).key
|
||||
alias = field_table.__table__.alias()
|
||||
return ExtraFieldJoin(
|
||||
alias=alias,
|
||||
field_key=field_key,
|
||||
id_column_name=id_column_name,
|
||||
value=_JsonScalarText(alias.c.value),
|
||||
)
|
||||
|
||||
|
||||
def _get_field_table_for_entity(entity_type: EntityType) -> type[models.Base]:
|
||||
"""Map an entity type to its extra-field table."""
|
||||
if entity_type == EntityType.spool:
|
||||
|
||||
+60
-12
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy import ColumnElement, case, func
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import contains_eager, joinedload
|
||||
@@ -15,8 +15,10 @@ from sqlalchemy.sql.functions import coalesce
|
||||
from spoolman.api.v1.models import EventType, Spool, SpoolEvent
|
||||
from spoolman.database import filament, models
|
||||
from spoolman.database.extra_field_query import (
|
||||
ExtraFieldJoin,
|
||||
apply_extra_field_filters_and_sort,
|
||||
apply_spool_related_extra_filters,
|
||||
extra_field_join,
|
||||
)
|
||||
from spoolman.database.utils import (
|
||||
SortOrder,
|
||||
@@ -27,7 +29,7 @@ from spoolman.database.utils import (
|
||||
parse_nested_field,
|
||||
)
|
||||
from spoolman.exceptions import ItemCreateError, ItemNotFoundError, SpoolMeasureError
|
||||
from spoolman.extra_field_registry import EntityType
|
||||
from spoolman.extra_field_registry import EntityType, ExtraField, ExtraFieldType, get_extra_fields
|
||||
from spoolman.math import weight_from_length
|
||||
from spoolman.ws import websocket_manager
|
||||
|
||||
@@ -234,6 +236,16 @@ GROUP_BY_COLUMNS = {
|
||||
"location": models.Spool.location,
|
||||
}
|
||||
|
||||
# Prefix of a group_by that names a spool extra field instead of a built-in column.
|
||||
EXTRA_GROUP_BY_PREFIX = "extra."
|
||||
|
||||
# Extra fields that can be grouped on. Grouping collects every spool that shares a value, so
|
||||
# it only makes sense for the field types whose value is a single repeatable string.
|
||||
# Multi-choice is excluded because its value is a JSON *array*: two spools with
|
||||
# overlapping-but-unequal selections would land in different groups, and a group key could
|
||||
# not be turned back into a value to assign.
|
||||
GROUPABLE_EXTRA_FIELD_TYPES = (ExtraFieldType.text, ExtraFieldType.choice)
|
||||
|
||||
|
||||
def _apply_spool_filters(
|
||||
stmt: sqlalchemy.Select,
|
||||
@@ -269,6 +281,47 @@ def _apply_spool_filters(
|
||||
return stmt
|
||||
|
||||
|
||||
async def _resolve_group_by(
|
||||
db: AsyncSession,
|
||||
group_by: str,
|
||||
) -> tuple[ColumnElement, ColumnElement, ExtraFieldJoin | None]:
|
||||
"""Resolve a group_by into the column to group on, the column to title groups by, and any join.
|
||||
|
||||
Grouping is either on a built-in column or on one of the spool's extra fields. The latter
|
||||
lives in its own table, so it comes with a join the caller has to apply once the SELECT exists.
|
||||
"""
|
||||
if group_by.startswith(EXTRA_GROUP_BY_PREFIX):
|
||||
join = _extra_field_group_join(await get_extra_fields(db, EntityType.spool), group_by)
|
||||
# The value is the group's title as well; there is no separate entity to name it.
|
||||
return join.value, join.value, join
|
||||
if group_by in GROUP_BY_COLUMNS:
|
||||
title_col = {
|
||||
"filament": models.Filament.name,
|
||||
"vendor": models.Vendor.name,
|
||||
"material": models.Filament.material,
|
||||
"location": models.Spool.location,
|
||||
}[group_by]
|
||||
return GROUP_BY_COLUMNS[group_by], title_col, None
|
||||
raise ValueError(
|
||||
f"Invalid group_by field '{group_by}'. Must be one of {sorted(GROUP_BY_COLUMNS)} "
|
||||
f"or '{EXTRA_GROUP_BY_PREFIX}<spool extra field key>'.",
|
||||
)
|
||||
|
||||
|
||||
def _extra_field_group_join(spool_fields: Sequence[ExtraField], group_by: str) -> ExtraFieldJoin:
|
||||
"""Validate a `group_by=extra.<key>` against the registered spool extra fields."""
|
||||
field_key = group_by[len(EXTRA_GROUP_BY_PREFIX) :]
|
||||
field = next((f for f in spool_fields if f.key == field_key), None)
|
||||
if field is None:
|
||||
raise ValueError(f"Unknown spool extra field '{field_key}'.")
|
||||
is_multi_choice = field.field_type == ExtraFieldType.choice and field.multi_choice
|
||||
if field.field_type not in GROUPABLE_EXTRA_FIELD_TYPES or is_multi_choice:
|
||||
raise ValueError(
|
||||
f"Spool extra field '{field_key}' cannot be grouped by. Only single-value text and choice fields can.",
|
||||
)
|
||||
return extra_field_join(EntityType.spool, field_key)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpoolGroupResult:
|
||||
"""One aggregated spool group, with the grouped entity hydrated for its header."""
|
||||
@@ -309,15 +362,7 @@ async def find_groups(
|
||||
|
||||
Returns a tuple of the requested page of groups and the total number of matching groups.
|
||||
"""
|
||||
if group_by not in GROUP_BY_COLUMNS:
|
||||
raise ValueError(f"Invalid group_by field '{group_by}'. Must be one of {sorted(GROUP_BY_COLUMNS)}.")
|
||||
group_col = GROUP_BY_COLUMNS[group_by]
|
||||
title_col = {
|
||||
"filament": models.Filament.name,
|
||||
"vendor": models.Vendor.name,
|
||||
"material": models.Filament.material,
|
||||
"location": models.Spool.location,
|
||||
}[group_by]
|
||||
group_col, title_col, extra_join = await _resolve_group_by(db, group_by)
|
||||
|
||||
# Remaining weight is computed (see Spool.from_db); mirror that formula so the sum is correct.
|
||||
# The fallback literal is 0.0 (float, not int): the weights are floats, and CockroachDB rejects
|
||||
@@ -354,6 +399,8 @@ async def find_groups(
|
||||
lot_nr=lot_nr,
|
||||
allow_archived=allow_archived,
|
||||
)
|
||||
if extra_join is not None:
|
||||
stmt = extra_join.apply(stmt, models.Spool.id)
|
||||
stmt = await apply_extra_field_filters_and_sort(
|
||||
db=db,
|
||||
stmt=stmt,
|
||||
@@ -399,7 +446,8 @@ async def find_groups(
|
||||
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
# Hydrate the grouped entity for the header (filament/vendor); material/location need nothing.
|
||||
# Hydrate the grouped entity for the header (filament/vendor). The value-keyed axes —
|
||||
# material, location, extra fields — are their own header and need nothing.
|
||||
keys = [row.group_key for row in rows if row.group_key is not None]
|
||||
filament_map: dict[int, models.Filament] = {}
|
||||
vendor_map: dict[int, models.Vendor] = {}
|
||||
|
||||
@@ -73,3 +73,10 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
|
||||
|
||||
register_setting("locations", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
|
||||
|
||||
# The dashboard groups spools by a field the user picks (location, lot_nr, material, or a spool
|
||||
# extra field), so its layout is stored per field rather than per location like the two settings
|
||||
# above. dashboard_groups maps a field key to its ordered group keys ("" being the unset group);
|
||||
# dashboard_spoolorders maps a field key to a group key to its ordered spool ids.
|
||||
register_setting("dashboard_groups", SettingType.OBJECT, json.dumps({}))
|
||||
register_setting("dashboard_spoolorders", SettingType.OBJECT, json.dumps({}))
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Integration tests for the Spool group (aggregate) API endpoint."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -139,6 +141,159 @@ def test_group_total_remaining_clamps_over_used_spool(random_filament: dict[str,
|
||||
|
||||
|
||||
def test_group_invalid_group_by():
|
||||
"""An unsupported group_by is rejected by request validation."""
|
||||
"""An unsupported group_by is rejected."""
|
||||
result = httpx.get(f"{URL}/api/v1/spool/group", params={"group_by": "banana"})
|
||||
assert result.status_code == 422
|
||||
assert result.status_code == 400
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraFixture:
|
||||
filament_id: int
|
||||
field_key: str
|
||||
other_field_key: str
|
||||
spool_ids: list[int]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def extra_field_spools(random_filament: dict[str, Any]) -> Iterable[ExtraFixture]:
|
||||
"""Spools carrying a "shelf" text field: two on A, one on B, one with the field unset.
|
||||
|
||||
A second, unrelated text field is set on every spool so a query can both group by one
|
||||
extra field and filter by another.
|
||||
"""
|
||||
field_key = f"shelf_{uuid.uuid4().hex[:8]}"
|
||||
other_field_key = f"room_{uuid.uuid4().hex[:8]}"
|
||||
for key, name in ((field_key, "Shelf"), (other_field_key, "Room")):
|
||||
httpx.post(f"{URL}/api/v1/field/spool/{key}", json={"name": name, "field_type": "text"}).raise_for_status()
|
||||
|
||||
filament_id = random_filament["id"]
|
||||
spool_ids: list[int] = []
|
||||
for shelf in ("A", "A", "B", None):
|
||||
extra = {other_field_key: json.dumps("Workshop")}
|
||||
if shelf is not None:
|
||||
extra[field_key] = json.dumps(shelf)
|
||||
result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": filament_id, "extra": extra})
|
||||
result.raise_for_status()
|
||||
spool_ids.append(result.json()["id"])
|
||||
|
||||
yield ExtraFixture(
|
||||
filament_id=filament_id,
|
||||
field_key=field_key,
|
||||
other_field_key=other_field_key,
|
||||
spool_ids=spool_ids,
|
||||
)
|
||||
|
||||
for spool_id in spool_ids:
|
||||
httpx.delete(f"{URL}/api/v1/spool/{spool_id}").raise_for_status()
|
||||
for key in (field_key, other_field_key):
|
||||
httpx.delete(f"{URL}/api/v1/field/spool/{key}").raise_for_status()
|
||||
|
||||
|
||||
def _counts_by_key(groups: list[dict[str, Any]]) -> dict[str | None, int]:
|
||||
"""Group spool counts keyed by group key.
|
||||
|
||||
Keyed rather than ordered because the databases disagree on where a NULL key sorts
|
||||
(SQLite/MariaDB put it first, PostgreSQL/CockroachDB last), and none of these tests
|
||||
are about that. The dashboard orders its cards from its own saved layout anyway.
|
||||
"""
|
||||
return {group.get("key"): group["spool_count"] for group in groups}
|
||||
|
||||
|
||||
def test_group_by_extra_field(extra_field_spools: ExtraFixture):
|
||||
"""Grouping by a spool extra field keys on the decoded value, with a null group for unset."""
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool/group",
|
||||
params={
|
||||
"group_by": f"extra.{extra_field_spools.field_key}",
|
||||
"filament.id": str(extra_field_spools.filament_id),
|
||||
},
|
||||
)
|
||||
result.raise_for_status()
|
||||
assert result.headers["x-total-count"] == "3"
|
||||
|
||||
groups = result.json()
|
||||
# The value is the key, decoded — not the "A" the JSON encoding would give.
|
||||
assert _counts_by_key(groups) == {None: 1, "A": 2, "B": 1}
|
||||
assert groups[0]["group_by"] == f"extra.{extra_field_spools.field_key}"
|
||||
|
||||
|
||||
def test_group_by_extra_field_with_extra_filter(extra_field_spools: ExtraFixture):
|
||||
"""Grouping by one extra field while filtering on another keeps both intact.
|
||||
|
||||
The filter is a subquery over the same extra-field table the grouping joins, so this
|
||||
guards against the two collapsing into each other.
|
||||
"""
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool/group",
|
||||
params={
|
||||
"group_by": f"extra.{extra_field_spools.field_key}",
|
||||
f"extra.{extra_field_spools.other_field_key}": '"Workshop"',
|
||||
"filament.id": str(extra_field_spools.filament_id),
|
||||
},
|
||||
)
|
||||
result.raise_for_status()
|
||||
assert _counts_by_key(result.json()) == {None: 1, "A": 2, "B": 1}
|
||||
|
||||
# A filter that matches nothing leaves no groups at all.
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool/group",
|
||||
params={
|
||||
"group_by": f"extra.{extra_field_spools.field_key}",
|
||||
f"extra.{extra_field_spools.other_field_key}": '"Garage"',
|
||||
"filament.id": str(extra_field_spools.filament_id),
|
||||
},
|
||||
)
|
||||
result.raise_for_status()
|
||||
assert result.json() == []
|
||||
|
||||
|
||||
def test_group_by_extra_field_scope_matches_spool_search(extra_field_spools: ExtraFixture):
|
||||
"""Each group's spools can be fetched back with an exact-match filter on the same field."""
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
params={
|
||||
f"extra.{extra_field_spools.field_key}": '"A"',
|
||||
"filament.id": str(extra_field_spools.filament_id),
|
||||
},
|
||||
)
|
||||
result.raise_for_status()
|
||||
assert len(result.json()) == 2
|
||||
|
||||
# An empty filter is how the client scopes the unset group.
|
||||
result = httpx.get(
|
||||
f"{URL}/api/v1/spool",
|
||||
params={
|
||||
f"extra.{extra_field_spools.field_key}": "",
|
||||
"filament.id": str(extra_field_spools.filament_id),
|
||||
},
|
||||
)
|
||||
result.raise_for_status()
|
||||
assert len(result.json()) == 1
|
||||
|
||||
|
||||
def test_group_by_unknown_extra_field():
|
||||
"""Grouping by an extra field that isn't registered is a client error."""
|
||||
result = httpx.get(f"{URL}/api/v1/spool/group", params={"group_by": "extra.does_not_exist"})
|
||||
assert result.status_code == 400
|
||||
|
||||
|
||||
def test_group_by_ungroupable_extra_field():
|
||||
"""Numeric and multi-choice fields hold no single repeatable value, so they can't be grouped by."""
|
||||
numeric_key = f"count_{uuid.uuid4().hex[:8]}"
|
||||
multi_key = f"tags_{uuid.uuid4().hex[:8]}"
|
||||
httpx.post(
|
||||
f"{URL}/api/v1/field/spool/{numeric_key}",
|
||||
json={"name": "Count", "field_type": "integer"},
|
||||
).raise_for_status()
|
||||
httpx.post(
|
||||
f"{URL}/api/v1/field/spool/{multi_key}",
|
||||
json={"name": "Tags", "field_type": "choice", "choices": ["a", "b"], "multi_choice": True},
|
||||
).raise_for_status()
|
||||
|
||||
try:
|
||||
for key in (numeric_key, multi_key):
|
||||
result = httpx.get(f"{URL}/api/v1/spool/group", params={"group_by": f"extra.{key}"})
|
||||
assert result.status_code == 400, key
|
||||
finally:
|
||||
for key in (numeric_key, multi_key):
|
||||
httpx.delete(f"{URL}/api/v1/field/spool/{key}").raise_for_status()
|
||||
Reference in new issue
Block a user