mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
Make lot numbers on spool rows a Filter menu toggle
Always showing the lot was too bulky. It now sits behind "Show lot numbers" in the Filter menu, next to the archived and no-spools toggles, off by default. When on, every spool row in any grouping ends its details line with the lot. Like showEmpty it lives in the URL (lots=1) and is remembered per browser, since it only adds to what's shown.
This commit is contained in:
1 parent
dc18a3017f
commit
abdfc73b8d
15 files changed
+197
-55
No files matched your search
@@ -62,6 +62,7 @@
|
||||
"buttons.filter": "Filter",
|
||||
"buttons.save": "Save",
|
||||
"buttons.showArchived": "Show Archived",
|
||||
"buttons.showLotNumbers": "Show lot numbers",
|
||||
"buttons.showNoSpools": "Show filaments with no spools",
|
||||
"buttons.unArchive": "Unarchive",
|
||||
"changeFilament.adoptWeight": "Set this spool’s full weight to {{full}}",
|
||||
|
||||
@@ -22,6 +22,7 @@ function state(over: Partial<LibraryState> = {}): LibraryState {
|
||||
filters: [],
|
||||
showArchived: false,
|
||||
showEmpty: true,
|
||||
showLotNumbers: false,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...over
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
{/each}
|
||||
{:else}
|
||||
{#each flatVMs as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} showSwatch indent={14} />
|
||||
<SpoolRow showLotNumbers={libraryState.showLotNumbers} {vm} showSwatch indent={14} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -182,15 +182,33 @@
|
||||
/>
|
||||
{#if !collapsed && !noSpools}
|
||||
{#each inUse as vm (vm.spool.id)}
|
||||
<SpoolRow {vm} {showSwatch} indent={26} context={group.field} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{vm}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/each}
|
||||
{#each unusedByFilament as unused (unused[0].spool.id)}
|
||||
{#if unused.length === 1}
|
||||
<!-- A lone unused spool gains nothing from a collapsing header — it just
|
||||
adds a click and a row — so render it inline like the used spools. -->
|
||||
<SpoolRow vm={unused[0]} {showSwatch} indent={26} context={group.field} />
|
||||
<SpoolRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
vm={unused[0]}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{:else}
|
||||
<UnusedRow {unused} {showSwatch} indent={26} context={group.field} />
|
||||
<UnusedRow
|
||||
showLotNumbers={libraryState.showLotNumbers}
|
||||
{unused}
|
||||
{showSwatch}
|
||||
indent={26}
|
||||
context={group.field}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if moreCount > 0}
|
||||
|
||||
@@ -366,6 +366,9 @@
|
||||
let noSpoolsMatches = $derived(
|
||||
libraryState.group === 'filament' && matchesTerms(m['buttons.showNoSpools'](), searchTerms(menuQuery))
|
||||
);
|
||||
let lotNumbersMatches = $derived(
|
||||
libraryState.view === 'spools' && matchesTerms(m['buttons.showLotNumbers'](), searchTerms(menuQuery))
|
||||
);
|
||||
// An option's secondary text is on screen, so it has to be searchable: typing
|
||||
// "petg" against a list that visibly says PETG must not come back empty.
|
||||
let visibleOptions = $derived(
|
||||
@@ -524,7 +527,28 @@
|
||||
<span class="mi-label">{m['buttons.showNoSpools']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !archivedMatches && !noSpoolsMatches && !visibleCategories.length}
|
||||
{#if lotNumbersMatches}
|
||||
{#if visibleCategories.length && !archivedMatches && !noSpoolsMatches}<div
|
||||
class="menu-sep"
|
||||
></div>{/if}
|
||||
<button
|
||||
class="menu-item"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={libraryState.showLotNumbers}
|
||||
onclick={() => {
|
||||
params.setShowLotNumbers(!libraryState.showLotNumbers);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span class="mi-check"
|
||||
>{#if libraryState.showLotNumbers}<SquareCheck size={15} />{:else}<Square
|
||||
size={15}
|
||||
/>{/if}</span
|
||||
>
|
||||
<span class="mi-label">{m['buttons.showLotNumbers']()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !archivedMatches && !noSpoolsMatches && !lotNumbersMatches && !visibleCategories.length}
|
||||
<div class="menu-item"><span class="mi-label mi-meta">{m['search.noResults']()}</span></div>
|
||||
{/if}
|
||||
{:else}
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
indent?: number;
|
||||
/** Listing context — drops whatever the enclosing group already implies. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { vm, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
let identity = $derived(rowIdentity(vm, context));
|
||||
let identity = $derived(rowIdentity(vm, context, showLotNumbers));
|
||||
let selected = $derived(params.isSelected(page.url.searchParams, 'spool', String(vm.spool.id)));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
indent?: number;
|
||||
/** Listing context, forwarded to the expanded per-instance rows. */
|
||||
context?: RowContext;
|
||||
showLotNumbers?: boolean;
|
||||
}
|
||||
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat' }: Props = $props();
|
||||
let { unused, showSwatch = false, indent = 14, context = 'flat', showLotNumbers = false }: Props = $props();
|
||||
|
||||
// Unused spools (never drawn from — used_weight 0) of one filament are
|
||||
// collapsed into a single summary row. Clicking can't know *which* instance
|
||||
@@ -109,7 +110,7 @@
|
||||
{#each unused as vm (vm.spool.id)}
|
||||
<!-- Divider-mode instances align with the sibling used rows (same indent)
|
||||
so the divider reads as the header for the section beneath it. -->
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} />
|
||||
<SpoolRow {vm} {showSwatch} indent={asDivider ? indent : indent + 14} {context} {showLotNumbers} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -217,3 +217,22 @@ describe('catalog views', () => {
|
||||
expect(selectHrefFromState(state, 'filament', '7')).toBe('?view=filaments&group=none&sel=filament%3A7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lot number visibility', () => {
|
||||
it('parses off by default, opt-in on spools, and off in catalogs', async () => {
|
||||
const { parseLibraryState, selectHrefFromState } = await import('./params');
|
||||
expect(parseLibraryState(new URLSearchParams()).showLotNumbers).toBe(false);
|
||||
const state = parseLibraryState(new URLSearchParams('lots=1'));
|
||||
expect(state.showLotNumbers).toBe(true);
|
||||
expect(selectHrefFromState(state, 'spool', '7')).toBe('?lots=1&sel=spool%3A7');
|
||||
for (const view of ['filaments', 'manufacturers']) {
|
||||
expect(parseLibraryState(new URLSearchParams(`view=${view}&lots=1`)).showLotNumbers).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the preference and lets an explicit URL win', async () => {
|
||||
const stored = '{"group":"filament","sort":"last_used","asc":false,"lots":true}';
|
||||
expect(await hrefFor(stored, '')).toBe('/?lots=1');
|
||||
expect(await hrefFor(stored, '?lots=0')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,8 @@ export interface LibraryState {
|
||||
showArchived: boolean;
|
||||
/** Also list filaments you hold no spools of, when grouping by filament. */
|
||||
showEmpty: boolean;
|
||||
/** Append lot numbers to spool row secondary text. */
|
||||
showLotNumbers: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
@@ -62,7 +64,7 @@ const VIEWS: LibraryView[] = ['spools', 'filaments', 'manufacturers'];
|
||||
* holds. A URL naming none of them is the one that defers to the remembered
|
||||
* view; naming any of them describes a view of its own, which is taken whole so
|
||||
* a stored grouping never gets spliced onto a link's sort. */
|
||||
const VIEW_PARAMS = ['view', 'group', 'sort', 'dir', 'empty'];
|
||||
const VIEW_PARAMS = ['view', 'group', 'sort', 'dir', 'empty', 'lots'];
|
||||
|
||||
const DEFAULTS = {
|
||||
group: 'filament' as GroupMode,
|
||||
@@ -70,6 +72,7 @@ const DEFAULTS = {
|
||||
sortAsc: false,
|
||||
showArchived: false,
|
||||
showEmpty: false,
|
||||
showLotNumbers: false,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
@@ -152,6 +155,7 @@ export function parseLibraryState(params: URLSearchParams): LibraryState {
|
||||
filters: parseFilters(params.getAll('f')),
|
||||
showArchived: params.get('arch') === '1',
|
||||
showEmpty: params.get('empty') === '1',
|
||||
showLotNumbers: params.get('lots') === '1',
|
||||
page: parsePositiveInt(params.get('page'), DEFAULTS.page),
|
||||
pageSize: parsePositiveInt(params.get('size'), DEFAULTS.pageSize)
|
||||
};
|
||||
@@ -164,7 +168,8 @@ export function parseLibraryState(params: URLSearchParams): LibraryState {
|
||||
...common,
|
||||
group: view === 'filaments' && group === 'none' ? 'none' : layout.group,
|
||||
sortKey: sort && catalogSortField(view, sort) ? sort : layout.sortKey,
|
||||
showEmpty: false
|
||||
showEmpty: false,
|
||||
showLotNumbers: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +215,7 @@ function serializeState(s: LibraryState): string {
|
||||
}
|
||||
if (s.showArchived !== DEFAULTS.showArchived) p.set('arch', s.showArchived ? '1' : '0');
|
||||
if (s.showEmpty !== DEFAULTS.showEmpty) p.set('empty', s.showEmpty ? '1' : '0');
|
||||
if (s.showLotNumbers !== DEFAULTS.showLotNumbers) p.set('lots', s.showLotNumbers ? '1' : '0');
|
||||
if (s.page !== DEFAULTS.page) p.set('page', String(s.page));
|
||||
if (s.pageSize !== DEFAULTS.pageSize) p.set('size', String(s.pageSize));
|
||||
if (s.selection) p.set('sel', `${s.selection.kind}:${s.selection.id}`);
|
||||
@@ -251,7 +257,8 @@ export function rememberedViewHref(url: URL): string | null {
|
||||
group === DEFAULTS.group &&
|
||||
stored.sortKey === DEFAULTS.sortKey &&
|
||||
stored.sortAsc === DEFAULTS.sortAsc &&
|
||||
stored.showEmpty === DEFAULTS.showEmpty
|
||||
stored.showEmpty === DEFAULTS.showEmpty &&
|
||||
stored.showLotNumbers === DEFAULTS.showLotNumbers
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -264,7 +271,8 @@ export function rememberedViewHref(url: URL): string | null {
|
||||
group,
|
||||
sortKey: stored.sortKey,
|
||||
sortAsc: stored.sortAsc,
|
||||
showEmpty: stored.showEmpty
|
||||
showEmpty: stored.showEmpty,
|
||||
showLotNumbers: stored.showLotNumbers
|
||||
});
|
||||
return `${url.pathname}?${qs}`;
|
||||
}
|
||||
@@ -288,7 +296,8 @@ function navigate(next: LibraryState, replace = false): void {
|
||||
group: next.group,
|
||||
sortKey: next.sortKey,
|
||||
sortAsc: next.sortAsc,
|
||||
showEmpty: next.showEmpty
|
||||
showEmpty: next.showEmpty,
|
||||
showLotNumbers: next.showLotNumbers
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,6 +381,10 @@ export function setShowEmpty(showEmpty: boolean): void {
|
||||
navigate({ ...currentState(), showEmpty, page: DEFAULTS.page });
|
||||
}
|
||||
|
||||
export function setShowLotNumbers(showLotNumbers: boolean): void {
|
||||
navigate({ ...currentState(), showLotNumbers, page: DEFAULTS.page });
|
||||
}
|
||||
|
||||
export function setShowArchived(showArchived: boolean): void {
|
||||
navigate({ ...currentState(), showArchived, page: DEFAULTS.page });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseStoredView } from './viewPrefs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseStoredView, rememberView } from './viewPrefs';
|
||||
|
||||
// The stored view is the only piece of Library state that doesn't come from the
|
||||
// URL (#1036), so it's also the only piece that can arrive malformed — from an
|
||||
@@ -13,7 +13,8 @@ describe('parseStoredView', () => {
|
||||
group: 'location',
|
||||
sortKey: 'name',
|
||||
sortAsc: true,
|
||||
showEmpty: false
|
||||
showEmpty: false,
|
||||
showLotNumbers: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +23,8 @@ describe('parseStoredView', () => {
|
||||
group: 'filament',
|
||||
sortKey: 'name',
|
||||
sortAsc: true,
|
||||
showEmpty: true
|
||||
showEmpty: true,
|
||||
showLotNumbers: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +56,31 @@ describe('parseStoredView', () => {
|
||||
group: 'colour',
|
||||
sortKey: 'name',
|
||||
sortAsc: false,
|
||||
showEmpty: false
|
||||
showEmpty: false,
|
||||
showLotNumbers: false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('round-trips the lot number preference through storage', () => {
|
||||
let stored: string | null = null;
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => stored,
|
||||
setItem: (_key: string, value: string) => {
|
||||
stored = value;
|
||||
}
|
||||
});
|
||||
const view = { group: 'filament', sortKey: 'name', sortAsc: true, showEmpty: false, showLotNumbers: true };
|
||||
rememberView(view);
|
||||
expect(parseStoredView(stored)).toEqual(view);
|
||||
rememberView({ ...view, showLotNumbers: false });
|
||||
expect(parseStoredView(stored)?.showLotNumbers).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults lot numbers off for an old stored view with empty enabled', () => {
|
||||
expect(parseStoredView('{"group":"filament","sort":"name","asc":true,"empty":true}')?.showLotNumbers).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
@@ -16,12 +16,12 @@
|
||||
// cache, one URL per distinct view — depends on the address bar being the whole
|
||||
// truth about what's on screen.
|
||||
//
|
||||
// Only grouping and sort are remembered, plus the one toggle that ADDS rows
|
||||
// (showEmpty). Filters, search and archived-visibility hide spools, and a hidden
|
||||
// filter silently restored days later reads as missing data rather than as a
|
||||
// preference. Showing filaments you own no spools of cannot produce that
|
||||
// confusion -- restoring it can only put more on screen, never less -- so it is
|
||||
// remembered while the hiding toggles still are not.
|
||||
// Only grouping and sort are remembered, plus toggles that ADD information
|
||||
// (showEmpty adds rows, showLotNumbers adds text). Filters, search and archived-
|
||||
// visibility hide spools, and a hidden filter silently restored days later reads
|
||||
// as missing data rather than as a preference. Showing empty filaments or lot
|
||||
// numbers cannot produce that confusion -- restoring them can only put more on
|
||||
// screen, never less -- so they are remembered while the hiding toggles are not.
|
||||
|
||||
const KEY = 'spoolman-v2-library-view';
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface StoredView {
|
||||
sortKey: string;
|
||||
sortAsc: boolean;
|
||||
showEmpty: boolean;
|
||||
showLotNumbers: boolean;
|
||||
}
|
||||
|
||||
/** Rebuild a stored view from its JSON, or null if there isn't a usable one. */
|
||||
@@ -43,15 +44,15 @@ export function parseStoredView(raw: string | null): StoredView | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const { group, sort, asc, empty } = parsed as Record<string, unknown>;
|
||||
const { group, sort, asc, empty, lots } = parsed as Record<string, unknown>;
|
||||
if (typeof group !== 'string' || typeof sort !== 'string' || typeof asc !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
// `empty` arrived after this key shipped, so an entry written by an older
|
||||
// build simply has no such field. Treating that as "off" keeps those
|
||||
// `empty` and `lots` arrived after this key shipped, so an entry written
|
||||
// by an older build simply has no such fields. Treating that as "off" keeps those
|
||||
// entries valid; rejecting them would throw away a grouping the user
|
||||
// picked long ago the first time they loaded a new version.
|
||||
return { group, sortKey: sort, sortAsc: asc, showEmpty: empty === true };
|
||||
return { group, sortKey: sort, sortAsc: asc, showEmpty: empty === true, showLotNumbers: lots === true };
|
||||
} catch {
|
||||
// Corrupt entry: the shipped view is no worse than what a first-time
|
||||
// visitor gets.
|
||||
@@ -80,7 +81,8 @@ export function rememberView(view: StoredView): void {
|
||||
prev.group === view.group &&
|
||||
prev.sortKey === view.sortKey &&
|
||||
prev.sortAsc === view.sortAsc &&
|
||||
prev.showEmpty === view.showEmpty
|
||||
prev.showEmpty === view.showEmpty &&
|
||||
prev.showLotNumbers === view.showLotNumbers
|
||||
) {
|
||||
// Selecting a spool or turning a page navigates too; only the changes that
|
||||
// actually move the view are worth a synchronous localStorage write.
|
||||
@@ -109,7 +111,8 @@ function write(view: StoredView): void {
|
||||
group: view.group,
|
||||
sort: view.sortKey,
|
||||
asc: view.sortAsc,
|
||||
empty: view.showEmpty
|
||||
empty: view.showEmpty,
|
||||
lots: view.showLotNumbers
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -99,25 +99,35 @@ describe('rowIdentity', () => {
|
||||
rightLabel: unused ? 'unused' : 'used 3 days ago'
|
||||
}) as SpoolVM;
|
||||
|
||||
it('shows the trimmed lot alongside usage in filament groups', () => {
|
||||
expect(rowIdentity(vm(' LOT-2026-0915-A7 '), 'filament')).toEqual({
|
||||
title: '',
|
||||
sub: 'used 3 days ago · Lot Nr LOT-2026-0915-A7'
|
||||
const contexts = ['flat', 'filament', 'vendor', 'material', 'location'] as const;
|
||||
const subs = ['PLA · 1.75 mm', 'used 3 days ago', 'PLA · 1.75 mm', '1.75 mm', 'PLA · 1.75 mm'];
|
||||
|
||||
it.each(contexts)('defaults to master text in %s rows', (context) => {
|
||||
expect(rowIdentity(vm('LOT'), context)).toEqual({
|
||||
title: context === 'filament' ? '' : context === 'vendor' ? 'Galaxy Black' : 'Prusament Galaxy Black',
|
||||
sub: subs[contexts.indexOf(context)]
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the lot for individual unused spools too', () => {
|
||||
expect(rowIdentity(vm('24A-1187', true), 'filament').sub).toBe('unused · Lot Nr 24A-1187');
|
||||
it.each(contexts)('appends the trimmed lot when enabled in %s rows', (context) => {
|
||||
const identity = rowIdentity(vm(''), context);
|
||||
expect(rowIdentity(vm(' LOT-2026-0915-A7 '), context, true)).toEqual({
|
||||
...identity,
|
||||
sub: `${identity.sub} · Lot Nr LOT-2026-0915-A7`
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['', ' '])('keeps usage alone for a blank lot (%j)', (lot) => {
|
||||
expect(rowIdentity(vm(lot), 'filament')).toEqual({ title: '', sub: 'used 3 days ago' });
|
||||
});
|
||||
|
||||
it.each(['flat', 'vendor', 'material', 'location'] as const)(
|
||||
'keeps the identity unchanged in %s rows',
|
||||
(context) => {
|
||||
expect(rowIdentity(vm('LOT-2026-0915-A7'), context)).toEqual(rowIdentity(vm(''), context));
|
||||
it.each(contexts)('adds nothing for blank lots in %s rows', (context) => {
|
||||
for (const lot of ['', ' ']) {
|
||||
expect(rowIdentity(vm(lot), context, true)).toEqual(rowIdentity(vm(lot), context));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the lot for individual unused spools too', () => {
|
||||
expect(rowIdentity(vm('24A-1187', true), 'filament', true).sub).toBe('unused · Lot Nr 24A-1187');
|
||||
});
|
||||
|
||||
it('omits the separator when the existing secondary text is empty', () => {
|
||||
expect(rowIdentity({ ...vm('LOT'), rightLabel: '' }, 'filament', true).sub).toBe('Lot Nr LOT');
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,16 @@ export interface RowIdentity {
|
||||
* only causes truncation — so we drop it and surface the part that actually
|
||||
* distinguishes this spool instead.
|
||||
*/
|
||||
export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
export function rowIdentity(vm: SpoolVM, ctx: RowContext, showLotNumbers = false): RowIdentity {
|
||||
const identity = contextualIdentity(vm, ctx);
|
||||
const lot = showLotNumbers ? vm.spool.lot.trim() : '';
|
||||
if (lot) {
|
||||
identity.sub = [identity.sub, `${m['spool.fields.lotNr']()} ${lot}`].filter(Boolean).join(' · ');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
function contextualIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
const { vendor, filament } = vm;
|
||||
const dims = `${filament.material} · ${filament.diameter} mm`;
|
||||
switch (ctx) {
|
||||
@@ -142,8 +151,9 @@ export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
* how they've been used and which lot they came from — #id, fill, weight and
|
||||
* location already sit in their own columns — so there's nothing to make a bold
|
||||
* title out of. The usage status leads, since every row can say it; the lot
|
||||
* number follows when there is one (#1173), and a long one is what the ellipsis
|
||||
* cuts, with the full text on hover. The inspector has the rest.
|
||||
* number joins only when the user turns it on and there is one (#1173). A long
|
||||
* lot is what the ellipsis cuts, with the full text on hover. The inspector has
|
||||
* the rest.
|
||||
*
|
||||
* The comment deliberately stays out of it. It reads like a label on the handful
|
||||
* of spools whose owner treats it as one, but it's a free-form textarea: people
|
||||
@@ -152,11 +162,7 @@ export function rowIdentity(vm: SpoolVM, ctx: RowContext): RowIdentity {
|
||||
* truncates the usage status — the one thing every row can say — to make room.
|
||||
*/
|
||||
function spoolIdentity(vm: SpoolVM): RowIdentity {
|
||||
const lot = vm.spool.lot.trim();
|
||||
return {
|
||||
title: '',
|
||||
sub: lot ? `${vm.rightLabel} · ${m['spool.fields.lotNr']()} ${lot}` : vm.rightLabel
|
||||
};
|
||||
return { title: '', sub: vm.rightLabel };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,7 +71,7 @@ test("the filter menu searches its properties, archived included", async ({ page
|
||||
await searchBox(menu).fill("lot");
|
||||
// A substring match, not a fuzzy one: "Location" contains those letters in
|
||||
// order but not together, and must not be offered here.
|
||||
expect(await itemLabels(menu)).toEqual(["Lot Nr"]);
|
||||
expect(await itemLabels(menu)).toEqual(["Lot Nr", "Show lot numbers"]);
|
||||
|
||||
// "Show Archived" is a filter like any other and answers to the same box.
|
||||
await searchBox(menu).fill("arch");
|
||||
@@ -101,3 +101,21 @@ test("the group menu is short enough to need no search box", async ({ page }) =>
|
||||
await expect(menu.getByRole("button", { name: "None (flat)" })).toBeVisible();
|
||||
await expect(searchBox(menu)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("lot numbers toggle is searchable and updates the URL", async ({ page }) => {
|
||||
await openApp(page);
|
||||
let menu = await openToolbarMenu(page, "Filter");
|
||||
await searchBox(menu).fill("show lot");
|
||||
const toggle = menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" });
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await toggle.click();
|
||||
await expect(menu).toBeHidden();
|
||||
await expect(page).toHaveURL(/lots=1/);
|
||||
menu = await openToolbarMenu(page, "Filter");
|
||||
await expect(menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" })).toHaveAttribute("aria-checked", "true");
|
||||
await searchBox(menu).fill("arch");
|
||||
await expect(menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" })).toHaveCount(0);
|
||||
await searchBox(menu).fill("show lot");
|
||||
await menu.getByRole("menuitemcheckbox", { name: "Show lot numbers" }).click();
|
||||
await expect(page).not.toHaveURL(/lots=1/);
|
||||
});
|
||||
@@ -90,6 +90,7 @@ test.beforeAll(async ({ request }) => {
|
||||
|
||||
const PAGES = [
|
||||
{ path: "/", name: "library" },
|
||||
{ path: "/?lots=1", name: "library with lot numbers" },
|
||||
{ path: "/dashboard", name: "dashboard" },
|
||||
{ path: "/labels", name: "labels" },
|
||||
{ path: "/settings", name: "settings" },
|
||||
|
||||
Reference in new issue
Block a user