Browse Source

Add bulk project assignment to multi-select toolbar
- New BatchProjectModal component for assigning archives to projects
- "Project" button in selection toolbar (next to Tags)
- Select a project to assign all selected archives at once
- "Remove from project" option to clear assignments in bulk
- Proper query invalidation for Projects page and Project Detail page

Closes #70

maziggy 4 months ago
parent
commit
b1653acc71

+ 5 - 0
CHANGELOG.md

@@ -39,6 +39,11 @@ All notable changes to Bambuddy will be documented in this file.
   - Automatic detection skips data channel SSL for A1 and A1 Mini models
   - Control channel remains encrypted via implicit FTPS (port 990)
   - Fixes "read operation timed out" errors during file uploads
+- **Bulk project assignment** - Assign multiple archives to a project at once:
+  - New "Project" button in multi-select toolbar (next to Tags)
+  - Select a project to assign all selected archives
+  - "Remove from project" option to clear assignments
+  - Updates Projects page and Project Detail page instantly
 
 ### Fixed
 - **QR code endpoint** - Fixed 500 error on archive QR code generation:

+ 1 - 1
README.md

@@ -80,7 +80,7 @@
 - Track progress with target counts
 - Quantity tracking for batch prints
 - Color-coded project badges
-- Assign archives via context menu
+- Bulk assign archives via multi-select toolbar
 
 </td>
 <td width="50%" valign="top">

+ 82 - 0
backend/tests/integration/test_printers_api.py

@@ -711,3 +711,85 @@ class TestSkipObjectsAPI:
             assert 100 in result["skipped_objects"]
             assert 200 in result["skipped_objects"]
             mock_client.skip_objects.assert_called_once_with([100, 200])
+
+
+class TestChamberLightAPI:
+    """Integration tests for chamber light control endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/chamber-light?on=true")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_on_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful chamber light on request."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_chamber_light.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "on" in result["message"].lower()
+            mock_client.set_chamber_light.assert_called_once_with(True)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_off_success(self, async_client: AsyncClient, printer_factory):
+        """Verify successful chamber light off request."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_chamber_light.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=false")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "off" in result["message"].lower()
+            mock_client.set_chamber_light.assert_called_once_with(False)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_chamber_light_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify error handling when chamber light control fails."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_chamber_light.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/chamber-light?on=true")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()

+ 187 - 0
frontend/src/components/BatchProjectModal.tsx

@@ -0,0 +1,187 @@
+import { useEffect } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { X, FolderKanban, Loader2, XCircle } from 'lucide-react';
+import { api } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface BatchProjectModalProps {
+  selectedIds: number[];
+  onClose: () => void;
+}
+
+export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const { data: projects, isLoading } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => api.getProjects(),
+  });
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Helper to invalidate all project-related queries
+  const invalidateProjectQueries = () => {
+    queryClient.invalidateQueries({ queryKey: ['archives'] });
+    queryClient.invalidateQueries({ queryKey: ['projects'] });
+    // Invalidate project detail pages (partial match catches all project IDs)
+    queryClient.invalidateQueries({ queryKey: ['project'] });
+    queryClient.invalidateQueries({ queryKey: ['project-archives'] });
+  };
+
+  // Assign to project mutation (uses bulk API)
+  const assignMutation = useMutation({
+    mutationFn: async (projectId: number) => {
+      await api.addArchivesToProject(projectId, selectedIds);
+      return projectId;
+    },
+    onSuccess: (projectId) => {
+      const project = projects?.find(p => p.id === projectId);
+      invalidateProjectQueries();
+      showToast(`Added ${selectedIds.length} archive${selectedIds.length !== 1 ? 's' : ''} to "${project?.name}"`);
+      onClose();
+    },
+    onError: () => {
+      showToast('Failed to assign project', 'error');
+    },
+  });
+
+  // Remove from project mutation (updates each archive individually)
+  const removeMutation = useMutation({
+    mutationFn: async () => {
+      for (const id of selectedIds) {
+        await api.updateArchive(id, { project_id: null });
+      }
+      return selectedIds.length;
+    },
+    onSuccess: (count) => {
+      invalidateProjectQueries();
+      showToast(`Removed ${count} archive${count !== 1 ? 's' : ''} from project`);
+      onClose();
+    },
+    onError: () => {
+      showToast('Failed to remove from project', 'error');
+    },
+  });
+
+  const isPending = assignMutation.isPending || removeMutation.isPending;
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-md max-h-[80vh] flex flex-col">
+        <CardContent className="p-0 flex flex-col min-h-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
+            <div className="flex items-center gap-2">
+              <FolderKanban className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">
+                Assign to Project
+              </h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+              disabled={isPending}
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Content */}
+          <div className="p-4 space-y-3 overflow-y-auto min-h-0">
+            <p className="text-sm text-bambu-gray">
+              Assign {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} to a project
+            </p>
+
+            {isLoading ? (
+              <div className="flex items-center justify-center py-8">
+                <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
+              </div>
+            ) : (
+              <div className="space-y-2">
+                {/* Remove from project option */}
+                <button
+                  onClick={() => removeMutation.mutate()}
+                  disabled={isPending}
+                  className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50"
+                >
+                  <div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0">
+                    <XCircle className="w-4 h-4 text-red-400" />
+                  </div>
+                  <div className="min-w-0 flex-1">
+                    <p className="text-white font-medium">Remove from project</p>
+                    <p className="text-sm text-bambu-gray truncate">Clear project assignment</p>
+                  </div>
+                  {removeMutation.isPending && (
+                    <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
+                  )}
+                </button>
+
+                {/* Divider */}
+                {projects && projects.length > 0 && (
+                  <div className="flex items-center gap-2 py-2">
+                    <div className="flex-1 h-px bg-bambu-dark-tertiary" />
+                    <span className="text-xs text-bambu-gray">or assign to</span>
+                    <div className="flex-1 h-px bg-bambu-dark-tertiary" />
+                  </div>
+                )}
+
+                {/* Project list */}
+                {projects?.map((project) => (
+                  <button
+                    key={project.id}
+                    onClick={() => assignMutation.mutate(project.id)}
+                    disabled={isPending}
+                    className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary transition-colors text-left disabled:opacity-50"
+                  >
+                    <div
+                      className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
+                      style={{ backgroundColor: project.color ? `${project.color}20` : 'rgb(var(--bambu-green) / 0.2)' }}
+                    >
+                      <FolderKanban
+                        className="w-4 h-4"
+                        style={{ color: project.color || 'rgb(var(--bambu-green))' }}
+                      />
+                    </div>
+                    <div className="min-w-0 flex-1">
+                      <p className="text-white font-medium truncate">{project.name}</p>
+                      <p className="text-sm text-bambu-gray truncate">
+                        {project.archive_count} archive{project.archive_count !== 1 ? 's' : ''}
+                        {project.status && ` • ${project.status}`}
+                      </p>
+                    </div>
+                    {assignMutation.isPending && assignMutation.variables === project.id && (
+                      <Loader2 className="w-4 h-4 animate-spin text-bambu-gray shrink-0" />
+                    )}
+                  </button>
+                ))}
+
+                {(!projects || projects.length === 0) && (
+                  <p className="text-center text-bambu-gray py-4">
+                    No projects yet. Create one from the Projects page.
+                  </p>
+                )}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary shrink-0">
+            <Button variant="secondary" onClick={onClose} className="flex-1" disabled={isPending}>
+              Cancel
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 18 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -55,6 +55,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { EditArchiveModal } from '../components/EditArchiveModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { BatchTagModal } from '../components/BatchTagModal';
+import { BatchProjectModal } from '../components/BatchProjectModal';
 import { CalendarView } from '../components/CalendarView';
 import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
@@ -1670,6 +1671,7 @@ export function ArchivesPage() {
   const [isSelectionMode, setIsSelectionMode] = useState(false);
   const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
   const [showBatchTag, setShowBatchTag] = useState(false);
+  const [showBatchProject, setShowBatchProject] = useState(false);
   const [viewMode, setViewMode] = useState<ViewMode>(() =>
     (localStorage.getItem('archiveViewMode') as ViewMode) || 'grid'
   );
@@ -2045,6 +2047,14 @@ export function ArchivesPage() {
             <Tag className="w-4 h-4" />
             Tags
           </Button>
+          <Button
+            variant="secondary"
+            size="sm"
+            onClick={() => setShowBatchProject(true)}
+          >
+            <FolderKanban className="w-4 h-4" />
+            Project
+          </Button>
           <Button
             variant="secondary"
             size="sm"
@@ -2506,6 +2516,14 @@ export function ArchivesPage() {
         />
       )}
 
+      {/* Batch Project Modal */}
+      {showBatchProject && (
+        <BatchProjectModal
+          selectedIds={Array.from(selectedIds)}
+          onClose={() => setShowBatchProject(false)}
+        />
+      )}
+
       {/* Compare Archives Modal */}
       {showCompareModal && selectedIds.size >= 2 && selectedIds.size <= 5 && (
         <CompareArchivesModal

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DNaTWne2.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ClrkCtKg.js"></script>
+    <script type="module" crossorigin src="/assets/index-DNaTWne2.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff