From 21651216afc5e0ce67ee272373f449330ca80795 Mon Sep 17 00:00:00 2001 From: Donkie Date: Thu, 30 Jul 2026 08:41:36 +0200 Subject: [PATCH] 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. 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. --- client_v2/e2e/a11y-mobile.spec.ts | 2 +- client_v2/locales/en/common.json | 33 +- client_v2/src/lib/api/map.ts | 3 + client_v2/src/lib/api/spoolSource.ts | 12 +- client_v2/src/lib/api/types.ts | 7 +- client_v2/src/lib/components/NavTabs.svelte | 2 +- client_v2/src/lib/dashboard/fields.ts | 106 ++ client_v2/src/lib/dashboard/layout.ts | 48 + client_v2/src/lib/dashboard/params.ts | 56 + client_v2/src/lib/stores/fields.svelte.ts | 13 +- client_v2/src/routes/dashboard/+page.svelte | 1105 +++++++++++++++++++ client_v2/src/routes/locations/+page.svelte | 960 ---------------- spoolman/api/v1/models.py | 4 +- spoolman/api/v1/spool.py | 24 +- spoolman/database/extra_field_query.py | 52 +- spoolman/database/spool.py | 72 +- spoolman/settings.py | 7 + tests_integration/tests/spool/test_group.py | 159 ++- 18 files changed, 1655 insertions(+), 1010 deletions(-) create mode 100644 client_v2/src/lib/dashboard/fields.ts create mode 100644 client_v2/src/lib/dashboard/layout.ts create mode 100644 client_v2/src/lib/dashboard/params.ts create mode 100644 client_v2/src/routes/dashboard/+page.svelte delete mode 100644 client_v2/src/routes/locations/+page.svelte diff --git a/client_v2/e2e/a11y-mobile.spec.ts b/client_v2/e2e/a11y-mobile.spec.ts index 76ced8e7..a8407164 100644 --- a/client_v2/e2e/a11y-mobile.spec.ts +++ b/client_v2/e2e/a11y-mobile.spec.ts @@ -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' } ]; diff --git a/client_v2/locales/en/common.json b/client_v2/locales/en/common.json index db4e6a3e..c627a2b8 100644 --- a/client_v2/locales/en/common.json +++ b/client_v2/locales/en/common.json @@ -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", diff --git a/client_v2/src/lib/api/map.ts b/client_v2/src/lib/api/map.ts index efd65edc..90f7200c 100644 --- a/client_v2/src/lib/api/map.ts +++ b/client_v2/src/lib/api/map.ts @@ -106,7 +106,10 @@ export function mapGroup(g: Json): GroupSummary { title = g.key ?? 'No location'; subtitle = `${g.in_use_count} in use`; } else { + // extra.: 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 { diff --git a/client_v2/src/lib/api/spoolSource.ts b/client_v2/src/lib/api/spoolSource.ts index f17b4807..e5660220 100644 --- a/client_v2/src/lib/api/spoolSource.ts +++ b/client_v2/src/lib/api/spoolSource.ts @@ -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.`, which is already the query param the backend expects. + params[scope.field] = exact(scope.key); break; } } diff --git a/client_v2/src/lib/api/types.ts b/client_v2/src/lib/api/types.ts index bbebd254..ef443610 100644 --- a/client_v2/src/lib/api/types.ts +++ b/client_v2/src/lib/api/types.ts @@ -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.` 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 { diff --git a/client_v2/src/lib/components/NavTabs.svelte b/client_v2/src/lib/components/NavTabs.svelte index dbb23855..6c6c6b1c 100644 --- a/client_v2/src/lib/components/NavTabs.svelte +++ b/client_v2/src/lib/components/NavTabs.svelte @@ -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 }[]; diff --git a/client_v2/src/lib/dashboard/fields.ts b/client_v2/src/lib/dashboard/fields.ts new file mode 100644 index 00000000..bfbdc640 --- /dev/null +++ b/client_v2/src/lib/dashboard/fields.ts @@ -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; + /** + * 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; + /** 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'; diff --git a/client_v2/src/lib/dashboard/layout.ts b/client_v2/src/lib/dashboard/layout.ts new file mode 100644 index 00000000..1d98c42a --- /dev/null +++ b/client_v2/src/lib/dashboard/layout.ts @@ -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; + /** field key → group key → ordered spool ids. */ + spoolOrders: Record>; +} + +/** The field key whose layout the pre-dashboard location board's settings describe. */ +const SEEDED_FIELD = 'location'; + +export async function loadLayout(signal?: AbortSignal): Promise { + const s = await getSettings(signal); + const groups = parseSetting>(s.dashboard_groups, {}); + const spoolOrders = parseSetting>>(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(s.locations, []).filter((l) => l != null); + if (seed.length) groups[SEEDED_FIELD] = seed; + } + if (!spoolOrders[SEEDED_FIELD]) { + const seed = parseSetting>(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) + ); +} diff --git a/client_v2/src/lib/dashboard/params.ts b/client_v2/src/lib/dashboard/params.ts new file mode 100644 index 00000000..aa9d563d --- /dev/null +++ b/client_v2/src/lib/dashboard/params.ts @@ -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 + }); +} diff --git a/client_v2/src/lib/stores/fields.svelte.ts b/client_v2/src/lib/stores/fields.svelte.ts index b9e26958..6f0cc8c5 100644 --- a/client_v2/src/lib/stores/fields.svelte.ts +++ b/client_v2/src/lib/stores/fields.svelte.ts @@ -18,22 +18,29 @@ function byOrder(list: FieldDef[]): FieldDef[] { class Fields { private defs = $state>({ spool: [], filament: [], vendor: [] }); - private loaded = new Set(); + // 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([]); 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); } diff --git a/client_v2/src/routes/dashboard/+page.svelte b/client_v2/src/routes/dashboard/+page.svelte new file mode 100644 index 00000000..072630bc --- /dev/null +++ b/client_v2/src/routes/dashboard/+page.svelte @@ -0,0 +1,1105 @@ + + + { + if (!dragging) cardsDragDisabled = true; + }} + onclick={() => (menuOpen = false)} +/> + + + {m['documentTitle.dashboard.list']()} + + +
+
+ {m['dashboard.dashboard']()} + +
e.stopPropagation()} role="none"> + + {#if menuOpen} + + {/if} +
+ + {#if !isEmpty} + {m['dashboard.dragHint']({ field: field.label })} + {/if} + {#if !field.choices} + + {/if} +
+ +
cardConsider(e as CustomEvent>)} + onfinalize={(e) => cardFinalize(e as CustomEvent>)} + > + {#each cards as card, idx (card.id)} +
ensureLoaded(card.key)} + > +
+ + {#if editingKey === card.key} + (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} + startEdit(card)} + onkeydown={(e) => e.key === 'Enter' && startEdit(card)} + > + {card.key === UNASSIGNED ? m['dashboard.unassigned']() : card.key} + + {/if} + {m['dashboard.spoolCount']({ count: card.total })} + {#if card.key !== UNASSIGNED && card.total === 0 && !field.choices && editingKey !== card.key} + + {/if} +
+ {#if editingKey === card.key && renameError} +
{renameError}
+ {/if} +
+
onBodyScroll(card.key, e)} + use:dndzone={{ items: card.spools, type: 'spool', flipDurationMs: FLIP, dropTargetStyle: {} }} + onconsider={(e) => spoolConsider(idx, e as CustomEvent>)} + onfinalize={(e) => spoolFinalize(idx, e as CustomEvent>)} + > + {#each card.spools as s (s.id)} + {@const f = inventory.filamentById(s.filamentId)!} + {@const v = inventory.vendorOf(f)} + + + +
+
+ #{s.id} + {#if v.name !== '?'}{v.name} - + {/if}{f.name} +
+
+ {f.material} - + {weightAuto(s.remaining)} + / {weightAuto(f.weight)}{#if s.lastUsedLabel} + - {m['dashboard.lastUsed']({ + time: s.lastUsedLabel + })}{/if} +
+
+
+ {/each} +
+ {#if card.spools.length === 0 && !card.loading} + {m['dashboard.dropHere']()} + {/if} +
+ {#if card.loading} + + {/if} +
+ {/each} +
+ + {#if isEmpty} +

{m['dashboard.emptyHelp']()}

+ {/if} +
+ + diff --git a/client_v2/src/routes/locations/+page.svelte b/client_v2/src/routes/locations/+page.svelte deleted file mode 100644 index fe25a576..00000000 --- a/client_v2/src/routes/locations/+page.svelte +++ /dev/null @@ -1,960 +0,0 @@ - - - { - if (!dragging) shelvesDragDisabled = true; - }} -/> - - - {m['documentTitle.locations.list']()} - - -
-
- {m['locations.locations']()} - {#if !isEmpty}{m['locations.dragHint']()}{/if} - -
- -
shelfConsider(e as CustomEvent>)} - onfinalize={(e) => shelfFinalize(e as CustomEvent>)} - > - {#each shelves as shelf, idx (shelf.id)} -
ensureLoaded(shelf.name)} - > -
- - {#if editingLocation === shelf.name} - (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} - startEdit(shelf.name)} - onkeydown={(e) => e.key === 'Enter' && startEdit(shelf.name)} - > - {shelf.name === NO_LOCATION ? m['locations.noLocation']() : shelf.name} - - {/if} - {m['locations.spoolCount']({ count: shelf.total })} - {#if shelf.name !== NO_LOCATION && shelf.total === 0 && editingLocation !== shelf.name} - - {/if} -
- {#if editingLocation === shelf.name && renameError} -
{renameError}
- {/if} -
-
onBodyScroll(shelf.name, e)} - use:dndzone={{ items: shelf.spools, type: 'spool', flipDurationMs: FLIP, dropTargetStyle: {} }} - onconsider={(e) => spoolConsider(idx, e as CustomEvent>)} - onfinalize={(e) => spoolFinalize(idx, e as CustomEvent>)} - > - {#each shelf.spools as s (s.id)} - {@const f = inventory.filamentById(s.filamentId)!} - {@const v = inventory.vendorOf(f)} - - - -
-
- #{s.id} - {#if v.name !== '?'}{v.name} - - {/if}{f.name} -
-
- {f.material} - - {weightAuto(s.remaining)} - / {weightAuto(f.weight)}{#if s.lastUsedLabel} - - {m['locations.lastUsed']({ - time: s.lastUsedLabel - })}{/if} -
-
-
- {/each} -
- {#if shelf.spools.length === 0 && !shelf.loading} - {m['locations.dropHere']()} - {/if} -
- {#if shelf.loading} - - {/if} -
- {/each} -
- - {#if isEmpty} -

{m['locations.noLocationsHelp']()}

- {/if} -
- - diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 73a81238..661d369e 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -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"], ) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index a5a45e9e..a938d010 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -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. " + "for one of the spool's custom fields (text and single-choice fields only)." + ), + examples=["location", "extra.shelf"], + ), ], filament_name: Annotated[ str | None, diff --git a/spoolman/database/extra_field_query.py b/spoolman/database/extra_field_query.py index 29b92285..4fb540db 100644 --- a/spoolman/database/extra_field_query.py +++ b/spoolman/database/extra_field_query.py @@ -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: diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index c3d7f356..ab951300 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -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}'.", + ) + + +def _extra_field_group_join(spool_fields: Sequence[ExtraField], group_by: str) -> ExtraFieldJoin: + """Validate a `group_by=extra.` 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] = {} diff --git a/spoolman/settings.py b/spoolman/settings.py index c2ff1f7a..57aab3ad 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -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({})) diff --git a/tests_integration/tests/spool/test_group.py b/tests_integration/tests/spool/test_group.py index 4bca7e98..86df7528 100644 --- a/tests_integration/tests/spool/test_group.py +++ b/tests_integration/tests/spool/test_group.py @@ -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()