diff --git a/CHANGELOG.md b/CHANGELOG.md index 001c23eab..a78635816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.7b] - Not released +### Enhancements +- **Spoolman: Open in Spoolman Button** (Issue #210): + - FilamentHoverCard now shows "Open in Spoolman" button when spool is already linked in Spoolman + - Button links directly to the spool's page in Spoolman for quick editing + - "Link to Spoolman" button now only shows when spool is not yet linked + - Link button correctly disabled when no unlinked spools are available in Spoolman + - Toast notification shown on successful/failed spool linking + - Added `/api/v1/spoolman/spools/linked` endpoint returning map of linked spool tags to IDs + ## [0.1.6.2] - 2026-02-02 > **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately. diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index 437e2a5bb..741708c67 100644 --- a/backend/app/api/routes/spoolman.py +++ b/backend/app/api/routes/spoolman.py @@ -468,7 +468,9 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)): # Check if spool has a tag in extra field extra = spool.get("extra", {}) or {} tag = extra.get("tag", "") - if not tag: + # Remove quotes if present (JSON encoded string) and check if empty + clean_tag = tag.strip('"') if tag else "" + if not clean_tag: filament = spool.get("filament", {}) or {} unlinked.append( UnlinkedSpool( @@ -484,6 +486,39 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)): return unlinked +@router.get("/spools/linked") +async def get_linked_spools(db: AsyncSession = Depends(get_db)): + """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned.""" + enabled, url, _ = await get_spoolman_settings(db) + if not enabled: + raise HTTPException(status_code=400, detail="Spoolman integration is not enabled") + + client = await get_spoolman_client() + if not client: + if url: + client = await init_spoolman_client(url) + else: + raise HTTPException(status_code=400, detail="Spoolman URL is not configured") + + if not await client.health_check(): + raise HTTPException(status_code=503, detail="Spoolman is not reachable") + + spools = await client.get_spools() + linked: dict[str, int] = {} + + for spool in spools: + # Check if spool has a tag in extra field + extra = spool.get("extra", {}) or {} + tag = extra.get("tag", "") + if tag: + # Remove quotes if present (JSON encoded string) + clean_tag = tag.strip('"').upper() + if clean_tag: + linked[clean_tag] = spool["id"] + + return {"linked": linked} + + class LinkSpoolRequest(BaseModel): """Request to link a Spoolman spool to an AMS tray.""" diff --git a/backend/tests/integration/test_spoolman_api.py b/backend/tests/integration/test_spoolman_api.py index fab955e55..347acb7b6 100644 --- a/backend/tests/integration/test_spoolman_api.py +++ b/backend/tests/integration/test_spoolman_api.py @@ -260,6 +260,83 @@ class TestSpoolmanAPI: assert len(data) == 1 assert data[0]["id"] == 2 # Only unlinked spool + # ========================================================================= + # Linked Spools Tests + # ========================================================================= + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_linked_spools_not_enabled(self, async_client: AsyncClient): + """Verify get linked spools fails when not enabled.""" + response = await async_client.get("/api/v1/spoolman/spools/linked") + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_linked_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + """Verify get linked spools returns map of tag -> spool_id.""" + # Mock spool with extra.tag (linked) + mock_spool = { + "id": 42, + "remaining_weight": 800, + "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}, + "filament": {"id": 1, "name": "PLA Red", "material": "PLA"}, + } + mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool]) + + response = await async_client.get("/api/v1/spoolman/spools/linked") + assert response.status_code == 200 + data = response.json() + assert "linked" in data + assert isinstance(data["linked"], dict) + # Tag should be uppercase and stripped of quotes + assert "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" in data["linked"] + assert data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"] == 42 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_linked_spools_excludes_unlinked( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Verify unlinked spools (without tag) are excluded.""" + # Mock spool with tag (linked) + mock_spool_linked = { + "id": 1, + "extra": {"tag": '"ABC12345678901234567890123456789A"'}, + "filament": {"id": 1, "name": "PLA Red", "material": "PLA"}, + } + # Mock spool without tag (unlinked) + mock_spool_unlinked = { + "id": 2, + "extra": {}, + "filament": {"id": 2, "name": "PLA Blue", "material": "PLA"}, + } + mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked]) + + response = await async_client.get("/api/v1/spoolman/spools/linked") + assert response.status_code == 200 + data = response.json() + assert len(data["linked"]) == 1 # Only linked spool + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_linked_spools_empty_tag_excluded( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Verify spools with empty tag (JSON-encoded empty string) are excluded.""" + # Mock spool with empty JSON-encoded tag + mock_spool = { + "id": 1, + "extra": {"tag": '""'}, # JSON-encoded empty string + "filament": {"id": 1, "name": "PLA Red", "material": "PLA"}, + } + mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool]) + + response = await async_client.get("/api/v1/spoolman/spools/linked") + assert response.status_code == 200 + data = response.json() + assert len(data["linked"]) == 0 # Empty tag should be excluded + # ========================================================================= # Link Spool Tests # ========================================================================= diff --git a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx new file mode 100644 index 000000000..c9da66686 --- /dev/null +++ b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx @@ -0,0 +1,290 @@ +/** + * Tests for the LinkSpoolModal component. + * + * Tests the Spoolman link spool modal including: + * - Displaying unlinked spools + * - Selecting a spool to link + * - Link success with toast notification + * - Link error with toast notification + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import { render } from '../utils'; +import { LinkSpoolModal } from '../../components/LinkSpoolModal'; + +// Mock the API client +vi.mock('../../api/client', () => ({ + api: { + getUnlinkedSpools: vi.fn(), + linkSpool: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }), + }, +})); + +// Mock the toast context +const mockShowToast = vi.fn(); +vi.mock('../../contexts/ToastContext', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useToast: () => ({ showToast: mockShowToast }), + }; +}); + +// Import mocked module +import { api } from '../../api/client'; + +describe('LinkSpoolModal', () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4', + trayInfo: { + type: 'PLA Basic', + color: 'FF0000', + location: 'AMS A1', + }, + }; + + const mockUnlinkedSpools = [ + { + id: 1, + filament_name: 'PLA Red', + filament_material: 'PLA', + filament_color_hex: 'FF0000', + remaining_weight: 800, + location: 'Shelf A', + }, + { + id: 2, + filament_name: 'PETG Blue', + filament_material: 'PETG', + filament_color_hex: '0000FF', + remaining_weight: 500, + location: null, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools); + vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' }); + }); + + describe('rendering', () => { + it('renders modal title', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + }); + }); + + it('displays tray info', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Basic')).toBeInTheDocument(); + expect(screen.getByText('(AMS A1)')).toBeInTheDocument(); + }); + }); + + it('displays tray UUID', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument(); + }); + }); + + it('shows loading state while fetching spools', async () => { + // Delay the response + vi.mocked(api.getUnlinkedSpools).mockImplementation( + () => new Promise(() => {}) + ); + + render(); + + await waitFor(() => { + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + }); + + it('displays unlinked spools list', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Red')).toBeInTheDocument(); + expect(screen.getByText('PETG Blue')).toBeInTheDocument(); + }); + }); + + it('shows message when no unlinked spools', async () => { + vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument(); + }); + }); + + it('does not render when isOpen is false', () => { + render(); + expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument(); + }); + }); + + describe('spool selection', () => { + it('allows selecting a spool', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Red')).toBeInTheDocument(); + }); + + // Click to select spool + fireEvent.click(screen.getByText('PLA Red')); + + // Should show check mark (via visual styling) + const selectedButton = screen.getByText('PLA Red').closest('button'); + expect(selectedButton).toHaveClass('border-bambu-green'); + }); + + it('link button is disabled until spool is selected', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Red')).toBeInTheDocument(); + }); + + const linkButton = screen.getByRole('button', { name: /link spool/i }); + expect(linkButton).toBeDisabled(); + + // Select a spool + fireEvent.click(screen.getByText('PLA Red')); + + expect(linkButton).not.toBeDisabled(); + }); + }); + + describe('linking', () => { + it('calls linkSpool API on submit', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Red')).toBeInTheDocument(); + }); + + // Select a spool + fireEvent.click(screen.getByText('PLA Red')); + + // Click link button + fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + + await waitFor(() => { + expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid); + }); + }); + + it('shows success toast on successful link', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Red')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('PLA Red')); + fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith( + 'Spool linked to Spoolman successfully', + 'success' + ); + }); + }); + + it('calls onClose after successful link', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Red')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('PLA Red')); + fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + + await waitFor(() => { + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + }); + + it('shows error toast on link failure', async () => { + const errorMessage = 'Failed to update spool'; + vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage)); + + render(); + + await waitFor(() => { + expect(screen.getByText('PLA Red')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('PLA Red')); + fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith( + `Failed to link spool: ${errorMessage}`, + 'error' + ); + }); + }); + }); + + describe('modal actions', () => { + it('calls onClose when cancel button is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Cancel')); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('calls onClose when backdrop is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + }); + + // Click the backdrop (the element with bg-black/60) + const backdrop = document.querySelector('.bg-black\\/60'); + if (backdrop) { + fireEvent.click(backdrop); + expect(defaultProps.onClose).toHaveBeenCalled(); + } + }); + + it('calls onClose when X button is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + }); + + // Find and click the X button in the header + const closeButtons = screen.getAllByRole('button'); + const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x')); + if (xButton) { + fireEvent.click(xButton); + expect(defaultProps.onClose).toHaveBeenCalled(); + } + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 52da75f17..786f650c0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1604,6 +1604,10 @@ export interface UnlinkedSpool { location: string | null; } +export interface LinkedSpoolsMap { + linked: Record; // tag (uppercase) -> spool_id +} + // Update types export interface VersionInfo { version: string; @@ -2944,6 +2948,8 @@ export const api = { request<{ filaments: unknown[] }>('/spoolman/filaments'), getUnlinkedSpools: () => request('/spoolman/spools/unlinked'), + getLinkedSpools: () => + request('/spoolman/spools/linked'), linkSpool: (spoolId: number, trayUuid: string) => request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, { method: 'POST', diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index 92c396a72..e19f425d8 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, type ReactNode } from 'react'; -import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react'; +import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react'; interface FilamentData { vendor: 'Bambu Lab' | 'Generic'; @@ -15,6 +15,8 @@ interface SpoolmanConfig { enabled: boolean; onLinkSpool?: (trayUuid: string) => void; hasUnlinkedSpools?: boolean; // Whether there are spools available to link + linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked + spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link) } interface ConfigureSlotConfig { @@ -270,8 +272,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp - {/* Link Spool button */} - {spoolman.onLinkSpool && ( + {/* Open in Spoolman button (when already linked) */} + {spoolman.linkedSpoolId && spoolman.spoolmanUrl && ( + e.stopPropagation()} + className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green" + title="View this spool in Spoolman" + > + + Open in Spoolman + + )} + + {/* Link Spool button (when not linked) */} + {!spoolman.linkedSpoolId && spoolman.onLinkSpool && (