فهرست منبع

fix(inventory): enable Clear RFID Tag for a tray-UUID-only spool (issue #3109)

The button gated on tag_uid alone. A spool linked by its 32-character
Bambu tray UUID carries none -- Bambuddy splits a stored tag by length,
so a 32-char value becomes tray_uuid and tag_uid stays empty. In
Spoolman mode that is every Bambu Lab spool synced from the AMS; on the
reporter's instance, 35 of 39 tagged spools, none of which could have
its tag cleared from the dialog. The documented workaround was to edit
extra.tag in Spoolman's own interface.

Everything around the button already treated those spools as tagged.
The Tag ID column renders whichever identifier is present, and the
payload the button sends nulls both fields -- which both inventory
modes honour: the built-in PATCH applies them through exclude_unset,
and the Spoolman route keys its tag-removal branch off either field
being explicitly null.

Either identifier now enables it, and clearing still removes both.
maziggy 4 روز پیش
والد
کامیت
885c4156ae

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
CHANGELOG.md


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

@@ -1063,6 +1063,118 @@ describe('SpoolFormModal — Unassign button (#1336)', () => {
   });
 });
 
+describe('SpoolFormModal — Clear RFID Tag for a tray-UUID-only spool (#3109)', () => {
+  const trayUuidOnly = (overrides: Partial<InventorySpool>): InventorySpool =>
+    ({
+      ...existingSpool,
+      id: 42,
+      tag_uid: null,
+      tray_uuid: 'A1B2C3D4E5F60718293A4B5C6D7E8F90',
+      ...overrides,
+    }) as InventorySpool;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('clears the tag on a Spoolman spool linked only by its tray UUID', async () => {
+    // _map_spoolman_spool splits extra.tag by length: a 32-char value becomes
+    // tray_uuid and tag_uid stays None. That is every Bambu Lab spool synced
+    // from the AMS, and not one of them could have its tag cleared here.
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={trayUuidOnly({ data_origin: 'spoolman', tag_type: 'spoolman' })}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+
+    const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
+    expect(clearBtn).not.toBeDisabled();
+
+    fireEvent.click(clearBtn);
+
+    await waitFor(() => {
+      expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledWith(
+        42,
+        expect.objectContaining({ tag_uid: null, tray_uuid: null })
+      );
+    });
+    expect(api.updateSpool).not.toHaveBeenCalled();
+  });
+
+  it('clears the tag on a built-in spool linked only by its tray UUID', async () => {
+    // PATCH /inventory/spools/{id}/link-tag takes tray_uuid on its own, so the
+    // built-in inventory reaches the same state without Spoolman involved.
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={trayUuidOnly({})}
+        mode="edit"
+        currencySymbol="$"
+      />
+    );
+
+    const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
+    expect(clearBtn).not.toBeDisabled();
+
+    fireEvent.click(clearBtn);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledWith(
+        42,
+        expect.objectContaining({ tag_uid: null, tray_uuid: null })
+      );
+    });
+    expect(api.updateSpoolmanInventorySpool).not.toHaveBeenCalled();
+  });
+
+  it('stays disabled for a spool carrying neither identifier', async () => {
+    // The button still has something to gate on -- it is not simply always on.
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={trayUuidOnly({ tray_uuid: null })}
+        mode="edit"
+        currencySymbol="$"
+      />
+    );
+
+    const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
+    expect(clearBtn).toBeDisabled();
+  });
+
+  it('still clears the tag on a spool carrying a tag_uid', async () => {
+    const clearBtnSpool = trayUuidOnly({ tag_uid: 'DEADBEEF', tray_uuid: null });
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={clearBtnSpool}
+        mode="edit"
+        currencySymbol="$"
+      />
+    );
+
+    const clearBtn = await screen.findByRole('button', { name: /clear rfid tag/i });
+    expect(clearBtn).not.toBeDisabled();
+
+    fireEvent.click(clearBtn);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledWith(
+        42,
+        expect.objectContaining({ tag_uid: null, tray_uuid: null })
+      );
+    });
+  });
+});
+
 describe('SpoolFormModal locationIdTouched', () => {
   /**
    * Regression tests for the round-trip bug: saving the edit modal without

+ 8 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -1128,10 +1128,17 @@ export function SpoolFormModal({
         <div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
           {isEditing && (
             <div className="flex gap-2 mr-auto">
+              {/* Either identifier counts as "tagged". A Bambu Lab spool is
+                  linked by its 32-char tray UUID and carries no tag_uid at all
+                  -- in Spoolman mode that is every Bambu spool, because
+                  _map_spoolman_spool splits extra.tag by length -- so gating on
+                  tag_uid alone left this permanently greyed out for them, while
+                  the Tag ID column beside it showed the UUID and the payload
+                  below already cleared both fields (#3109). */}
               <Button
                 variant="secondary"
                 onClick={() => deleteTagMutation.mutate()}
-                disabled={isPending || !spool?.tag_uid}
+                disabled={isPending || !(spool?.tag_uid || spool?.tray_uuid)}
               >
                 <Tag className="w-4 h-4" />
                 {t('inventory.clearRfid', 'Clear RFID Tag')}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-B9X0fIlJ.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-Z-jaTirY.js"></script>
+    <script type="module" crossorigin src="/assets/index-B9X0fIlJ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است