Browse Source

fix(inventory): restore Spoolman spool ID search + Unassign button (#1336)

  Two regressions reported in #1336:

  1. spoolMatchesQuery did not include spool.id in the predicate, so
     typing a numeric Spoolman ID into the Assign Spool dialog or the
     Inventory page search returned no matches. Predicate now also
     tests String(spool.id).includes(q).

  2. The Unassign button in the spool edit modal was permanently
     disabled for Spoolman-mode spools. The modal only ever queried
     the legacy spool_assignments table (keyed by spool_id), but in
     Spoolman mode the assignment lives in spoolman_slot_assignments
     (keyed by spoolman_spool_id). Both the lookup query and the
     unassign mutation now branch on the spoolmanMode prop and call
     the Spoolman-flavored endpoints.
maziggy 1 tuần trước cách đây
mục cha
commit
5c17cd4973

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
CHANGELOG.md


+ 104 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -38,6 +38,10 @@ vi.mock('../../api/client', () => ({
       failed_count: 0,
     }),
     getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
+    getAssignments: vi.fn().mockResolvedValue([]),
+    getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
+    unassignSpool: vi.fn().mockResolvedValue({}),
+    unassignSpoolmanSlot: vi.fn().mockResolvedValue({}),
   },
   ApiError: class ApiError extends Error {
     status: number;
@@ -828,6 +832,106 @@ describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
   });
 });
 
+describe('SpoolFormModal — Unassign button (#1336)', () => {
+  const spoolmanSpool: InventorySpool = {
+    id: 42,
+    material: 'PLA',
+    subtype: 'Basic',
+    brand: 'BrandX',
+    color_name: 'Black',
+    rgba: '000000FF',
+    extra_colors: null,
+    effect_type: null,
+    label_weight: 1000,
+    core_weight: 250,
+    core_weight_catalog_id: null,
+    weight_used: 200,
+    slicer_filament: '',
+    slicer_filament_name: '',
+    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: 'spoolman',
+    tag_type: 'spoolman',
+    archived_at: null,
+    created_at: '2025-01-01T00:00:00Z',
+    updated_at: '2025-01-01T00:00:00Z',
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+    category: null,
+    low_stock_threshold_pct: null,
+    k_profiles: [],
+  } as InventorySpool;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('enables Unassign in Spoolman mode when a spoolman_slot_assignment exists for the spool', async () => {
+    vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([
+      {
+        printer_id: 1,
+        printer_name: 'Test Printer',
+        ams_id: 0,
+        tray_id: 2,
+        spoolman_spool_id: 42,
+        ams_label: 'AMS 1',
+      },
+    ]);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolmanSpool}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+
+    const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
+    await waitFor(() => {
+      expect(unassignBtn).not.toBeDisabled();
+    });
+
+    fireEvent.click(unassignBtn);
+
+    await waitFor(() => {
+      expect(api.unassignSpoolmanSlot).toHaveBeenCalledWith(42);
+    });
+    expect(api.unassignSpool).not.toHaveBeenCalled();
+  });
+
+  it('keeps Unassign disabled in Spoolman mode when no slot assignment exists', async () => {
+    vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValueOnce([]);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolmanSpool}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+
+    const unassignBtn = await screen.findByRole('button', { name: /unassign/i });
+    // Wait one tick for the (empty) query result to settle so the disabled state is final.
+    await waitFor(() => {
+      expect(api.getSpoolmanSlotAssignments).toHaveBeenCalled();
+    });
+    expect(unassignBtn).toBeDisabled();
+  });
+});
+
 describe('SpoolFormModal storageLocationTouched', () => {
   /**
    * Regression tests for the round-trip bug: saving the edit modal without

+ 7 - 0
frontend/src/__tests__/utils/inventorySearch.test.ts

@@ -73,6 +73,13 @@ describe('spoolMatchesQuery', () => {
     const spool = makeSpool({ brand: null, color_name: null, subtype: null });
     expect(spoolMatchesQuery(spool, 'bambu')).toBe(false);
   });
+
+  it('matches on numeric id (#1336)', () => {
+    const spool = makeSpool({ id: 42, material: 'PLA', brand: null, color_name: null, subtype: null, note: null });
+    expect(spoolMatchesQuery(spool, '42')).toBe(true);
+    expect(spoolMatchesQuery(spool, '4')).toBe(true);
+    expect(spoolMatchesQuery(spool, '99')).toBe(false);
+  });
 });
 
 describe('filterSpoolsByQuery', () => {

+ 30 - 6
frontend/src/components/SpoolFormModal.tsx

@@ -523,13 +523,27 @@ export function SpoolFormModal({
     },
   });
 
-  // Fetch assignment for this spool (to show Unassign button)
+  // Fetch assignment for this spool (to show Unassign button). In Spoolman mode
+  // the slot assignment lives in the spoolman_slot_assignments table keyed by
+  // spoolman_spool_id, not in the legacy spool_assignments table — #1336 was the
+  // resulting "Unassign button is always disabled" report.
   const { data: assignments } = useQuery({
     queryKey: ['spool-assignments'],
     queryFn: () => api.getAssignments(),
-    enabled: isOpen && isEditing,
+    enabled: isOpen && isEditing && !spoolmanMode,
   });
-  const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
+  const { data: spoolmanSlotAssignments } = useQuery({
+    queryKey: ['spoolman-slot-assignments-all'],
+    queryFn: () => api.getSpoolmanSlotAssignments(),
+    enabled: isOpen && isEditing && spoolmanMode,
+  });
+  const spoolAssignment = (() => {
+    if (!spool) return undefined;
+    if (spoolmanMode) {
+      return spoolmanSlotAssignments?.find(a => a.spoolman_spool_id === spool.id);
+    }
+    return assignments?.find(a => a.spool_id === spool.id);
+  })();
 
   // Read inventory + settings caches (already populated by InventoryPage) to
   // drive the category autocomplete and low-stock-threshold placeholder. #729
@@ -554,12 +568,22 @@ export function SpoolFormModal({
   const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
 
   const unassignMutation = useMutation({
-    mutationFn: () => {
+    mutationFn: async () => {
       if (!spoolAssignment) throw new Error('No assignment');
-      return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
+      if (spoolmanMode) {
+        if (!spool) throw new Error('No spool');
+        await api.unassignSpoolmanSlot(spool.id);
+        return;
+      }
+      await api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
     },
     onSuccess: async () => {
-      await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+      if (spoolmanMode) {
+        await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
+        await queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
+      } else {
+        await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+      }
       showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
       onClose();
     },

+ 1 - 0
frontend/src/utils/inventorySearch.ts

@@ -8,6 +8,7 @@ export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean
   if (!query) return true;
   const q = query.toLowerCase();
   return (
+    String(spool.id).includes(q) ||
     spool.material.toLowerCase().includes(q) ||
     (spool.brand?.toLowerCase().includes(q) ?? false) ||
     (spool.color_name?.toLowerCase().includes(q) ?? false) ||

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-D3mBzx5f.js


+ 1 - 1
static/index.html

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

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác