mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Add visual spool grouping and optional brand/subtype in quick-add (#480)
Group toggle in inventory toolbar collapses identical unused/unassigned spools into expandable rows/cards with count badges. Grouping key uses material, subtype, brand, color, and label weight. Brand and subtype fields now appear in quick-add mode as optional (no asterisk, no validation). Quantity field restricted to quick-add mode only.
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
*
|
||||
* Verifies:
|
||||
* - Quick-add toggle appears only in create mode
|
||||
* - Quick-add mode hides slicer preset, brand, subtype fields
|
||||
* - Quick-add mode shows brand and subtype as optional (no asterisk)
|
||||
* - Quick-add mode hides slicer preset field
|
||||
* - Quick-add mode hides PA Profile tab
|
||||
* - Quantity field is rendered in filament section
|
||||
* - Quantity field is only rendered in quick-add mode
|
||||
* - Quantity field is hidden in edit mode
|
||||
* - Bulk create calls bulkCreateSpools when quantity > 1
|
||||
* - Single quantity calls createSpool as before
|
||||
* - validateForm with quickAdd=true only requires material
|
||||
@@ -199,7 +201,7 @@ describe('SpoolFormModal quick-add toggle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders quantity field', async () => {
|
||||
it('hides quantity field by default (non-quick-add)', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
@@ -212,8 +214,88 @@ describe('SpoolFormModal quick-add toggle', () => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Quantity field should be visible
|
||||
expect(screen.getByText('Quantity')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('1')).toBeInTheDocument();
|
||||
// Quantity field should NOT be visible in normal create mode
|
||||
expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows quantity field only in quick-add mode', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle quick-add on
|
||||
const toggleButtons = screen.getAllByRole('button');
|
||||
const quickAddToggle = toggleButtons.find(btn =>
|
||||
btn.getAttribute('type') === 'button' &&
|
||||
btn.className.includes('rounded-full') &&
|
||||
btn.closest('div')?.textContent?.includes('Quick Add')
|
||||
);
|
||||
expect(quickAddToggle).toBeTruthy();
|
||||
fireEvent.click(quickAddToggle!);
|
||||
|
||||
// Quantity field should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Quantity')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides quantity field in edit mode', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={existingSpool}
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Quantity field should NOT be visible in edit mode
|
||||
expect(screen.queryByText('Quantity')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows brand and subtype in quick-add mode without asterisk', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
currencySymbol="$"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Toggle quick-add on
|
||||
const toggleButtons = screen.getAllByRole('button');
|
||||
const quickAddToggle = toggleButtons.find(btn =>
|
||||
btn.getAttribute('type') === 'button' &&
|
||||
btn.className.includes('rounded-full') &&
|
||||
btn.closest('div')?.textContent?.includes('Quick Add')
|
||||
);
|
||||
fireEvent.click(quickAddToggle!);
|
||||
|
||||
// Brand and Subtype should be visible (without asterisk = optional)
|
||||
await waitFor(() => {
|
||||
const brandLabel = screen.getByText('Brand');
|
||||
expect(brandLabel).toBeInTheDocument();
|
||||
expect(brandLabel.textContent).not.toContain('*');
|
||||
|
||||
const subtypeLabel = screen.getByText('Subtype');
|
||||
expect(subtypeLabel).toBeInTheDocument();
|
||||
expect(subtypeLabel.textContent).not.toContain('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Tests for the spool grouping logic used in InventoryPage.
|
||||
*
|
||||
* The grouping is a pure client-side computation:
|
||||
* - Spools with identical material+subtype+brand+color_name+rgba+label_weight are grouped
|
||||
* - Only unused (weight_used === 0) and unassigned spools are eligible for grouping
|
||||
* - Used or assigned spools always appear individually
|
||||
* - Groups with only 1 member remain as singles
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { InventorySpool, SpoolAssignment } from '../../api/client';
|
||||
|
||||
// Replicate the grouping key function from InventoryPage (not exported)
|
||||
function spoolGroupKey(s: InventorySpool): string {
|
||||
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
|
||||
}
|
||||
|
||||
type DisplayItem =
|
||||
| { type: 'single'; spool: InventorySpool }
|
||||
| { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
|
||||
|
||||
// Replicate the grouping logic from InventoryPage
|
||||
function computeDisplayItems(
|
||||
sortedSpools: InventorySpool[],
|
||||
assignmentMap: Record<number, SpoolAssignment>,
|
||||
): DisplayItem[] {
|
||||
const groups = new Map<string, InventorySpool[]>();
|
||||
|
||||
for (const spool of sortedSpools) {
|
||||
if (spool.weight_used > 0 || assignmentMap[spool.id]) {
|
||||
// Will be added as singles in the walk below
|
||||
} else {
|
||||
const key = spoolGroupKey(spool);
|
||||
const arr = groups.get(key);
|
||||
if (arr) arr.push(spool);
|
||||
else groups.set(key, [spool]);
|
||||
}
|
||||
}
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
const processedKeys = new Set<string>();
|
||||
|
||||
for (const spool of sortedSpools) {
|
||||
if (spool.weight_used > 0 || assignmentMap[spool.id]) {
|
||||
items.push({ type: 'single', spool });
|
||||
continue;
|
||||
}
|
||||
const key = spoolGroupKey(spool);
|
||||
if (processedKeys.has(key)) continue;
|
||||
processedKeys.add(key);
|
||||
const members = groups.get(key)!;
|
||||
if (members.length === 1) {
|
||||
items.push({ type: 'single', spool: members[0] });
|
||||
} else {
|
||||
items.push({ type: 'group', key, spools: members, representative: members[0] });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {
|
||||
return {
|
||||
material: 'PLA',
|
||||
subtype: 'Basic',
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
core_weight_catalog_id: null,
|
||||
weight_used: 0,
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
k_profiles: [],
|
||||
cost_per_kg: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('spoolGroupKey', () => {
|
||||
it('generates same key for identical spools', () => {
|
||||
const a = makeSpool({ id: 1 });
|
||||
const b = makeSpool({ id: 2 });
|
||||
expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when material differs', () => {
|
||||
const a = makeSpool({ id: 1, material: 'PLA' });
|
||||
const b = makeSpool({ id: 2, material: 'PETG' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when subtype differs', () => {
|
||||
const a = makeSpool({ id: 1, subtype: 'Basic' });
|
||||
const b = makeSpool({ id: 2, subtype: 'Matte' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when brand differs', () => {
|
||||
const a = makeSpool({ id: 1, brand: 'Polymaker' });
|
||||
const b = makeSpool({ id: 2, brand: 'Bambu Lab' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when color_name differs', () => {
|
||||
const a = makeSpool({ id: 1, color_name: 'Red' });
|
||||
const b = makeSpool({ id: 2, color_name: 'Blue' });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('generates different key when label_weight differs', () => {
|
||||
const a = makeSpool({ id: 1, label_weight: 1000 });
|
||||
const b = makeSpool({ id: 2, label_weight: 500 });
|
||||
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
|
||||
});
|
||||
|
||||
it('treats null and empty string subtype the same', () => {
|
||||
const a = makeSpool({ id: 1, subtype: null as unknown as string });
|
||||
const b = makeSpool({ id: 2, subtype: '' });
|
||||
expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeDisplayItems', () => {
|
||||
it('groups identical unused unassigned spools', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1 }),
|
||||
makeSpool({ id: 2 }),
|
||||
makeSpool({ id: 3 }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].type).toBe('group');
|
||||
if (items[0].type === 'group') {
|
||||
expect(items[0].spools).toHaveLength(3);
|
||||
expect(items[0].representative.id).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not group spools with different properties', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PLA' }),
|
||||
makeSpool({ id: 2, material: 'PETG' }),
|
||||
makeSpool({ id: 3, material: 'ABS' }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items.every((i) => i.type === 'single')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes used spools from groups', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, weight_used: 0 }),
|
||||
makeSpool({ id: 2, weight_used: 100 }), // used
|
||||
makeSpool({ id: 3, weight_used: 0 }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
// 1 group (id:1, id:3) + 1 single (id:2)
|
||||
expect(items).toHaveLength(2);
|
||||
const group = items.find((i) => i.type === 'group');
|
||||
const single = items.find((i) => i.type === 'single');
|
||||
expect(group).toBeDefined();
|
||||
expect(single).toBeDefined();
|
||||
if (group?.type === 'group') {
|
||||
expect(group.spools).toHaveLength(2);
|
||||
expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
|
||||
}
|
||||
if (single?.type === 'single') {
|
||||
expect(single.spool.id).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes assigned spools from groups', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1 }),
|
||||
makeSpool({ id: 2 }), // assigned
|
||||
makeSpool({ id: 3 }),
|
||||
];
|
||||
const assignmentMap: Record<number, SpoolAssignment> = {
|
||||
2: {
|
||||
spool_id: 2,
|
||||
printer_id: 1,
|
||||
printer_name: 'P1S',
|
||||
ams_id: 0,
|
||||
tray_id: 0,
|
||||
configured: true,
|
||||
fingerprint_color: null,
|
||||
fingerprint_type: null,
|
||||
},
|
||||
};
|
||||
const items = computeDisplayItems(spools, assignmentMap);
|
||||
// 1 group (id:1, id:3) + 1 single (id:2)
|
||||
expect(items).toHaveLength(2);
|
||||
const group = items.find((i) => i.type === 'group');
|
||||
expect(group?.type).toBe('group');
|
||||
if (group?.type === 'group') {
|
||||
expect(group.spools.map((s) => s.id).sort()).toEqual([1, 3]);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not group a single spool', () => {
|
||||
const spools = [makeSpool({ id: 1 })];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].type).toBe('single');
|
||||
});
|
||||
|
||||
it('preserves order — group appears at first member position', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PETG' }), // unique
|
||||
makeSpool({ id: 2, material: 'PLA' }), // group member
|
||||
makeSpool({ id: 3, material: 'PLA' }), // group member
|
||||
makeSpool({ id: 4, material: 'ABS' }), // unique
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].type).toBe('single'); // PETG
|
||||
expect(items[1].type).toBe('group'); // PLA group at position of id:2
|
||||
expect(items[2].type).toBe('single'); // ABS
|
||||
if (items[1].type === 'group') {
|
||||
expect(items[1].spools.map((s) => s.id)).toEqual([2, 3]);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles mix of groupable and non-groupable spools', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PLA' }), // groupable
|
||||
makeSpool({ id: 2, material: 'PLA', weight_used: 50 }), // used → single
|
||||
makeSpool({ id: 3, material: 'PLA' }), // groupable
|
||||
makeSpool({ id: 4, material: 'PETG' }), // different → single
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
// PLA group (id:1,3) + PLA used single (id:2) + PETG single (id:4)
|
||||
expect(items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns all singles when no spools can be grouped', () => {
|
||||
const spools = [
|
||||
makeSpool({ id: 1, material: 'PLA', weight_used: 100 }),
|
||||
makeSpool({ id: 2, material: 'PETG', weight_used: 200 }),
|
||||
];
|
||||
const items = computeDisplayItems(spools, {});
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.every((i) => i.type === 'single')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const items = computeDisplayItems([], {});
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user