Просмотр исходного кода

fix(ams): close the slot popup before it covers the filament dialog (#2631)

Tapping Configure on an AMS slot left the slot popup standing on top of
the filament type/colour dialog it had just opened, so both layers were
on screen at once.

The popup is portaled at z-[60] so it can escape the stacking contexts
sibling printer cards create on the dashboard (#1336), which also puts
it above ConfigureAmsSlotModal and LinkSpoolModal at z-50. Nothing
dismissed it: it is hidden only by the pointer leaving it, and a touch
device never sends that after the tap that opened it. On desktop the
next mouse movement cleared it, which is why this is a tablet report.

FilamentHoverCard and EmptySlotHoverCard now dismiss themselves before
running any action that opens a dialog or navigates away - Configure,
Assign Spool, Unassign Spool, and both Open in Inventory links. The
dismissal clears the pending timer as well, so a queued open cannot
resurrect the card over the dialog.

Actions that report progress inside the popup are unchanged: RFID
re-read, Load and Unload render their spinner there, and Copy UUID its
confirmation tick.
maziggy 1 месяц назад
Родитель
Сommit
21c6182c8d

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 124 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -326,6 +326,93 @@ describe('FilamentHoverCard', () => {
       });
     });
   });
+
+  // The card is portaled at z-[60] — above ConfigureAmsSlotModal and
+  // LinkSpoolModal at z-50 — so a card left standing draws OVER the dialog its
+  // own button just opened. Mouseleave is the only thing that used to hide it,
+  // and a touch device never sends one after the tap that opened the card, so on
+  // a tablet it hung there indefinitely: two overlapping layers, competing focus.
+  describe('dismissal when an action opens a dialog (#2631)', () => {
+    it('closes the card when Configure is pressed, and still configures', async () => {
+      const onConfigure = vi.fn();
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          configureSlot={{ enabled: true, onConfigure }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/configure/i));
+
+      expect(onConfigure).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+    });
+
+    it('stays closed with no mouseleave, which is all a tablet ever gives us', async () => {
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          configureSlot={{ enabled: true, onConfigure: vi.fn() }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/configure/i));
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+
+      // A pending show timer would resurrect the card on top of the dialog.
+      vi.advanceTimersByTime(1000);
+      expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
+    });
+
+    it('closes the card when Assign Spool is pressed', async () => {
+      const onAssignSpool = vi.fn();
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          inventory={{ assignedSpool: null, onAssignSpool }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/assign/i));
+
+      expect(onAssignSpool).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+    });
+
+    it('closes the card when Unassign Spool is pressed', async () => {
+      const onUnassignSpool = vi.fn();
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          inventory={{
+            assignedSpool: { id: 7, material: 'PLA', brand: 'eSun', color_name: 'Black' },
+            onUnassignSpool,
+          }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/unassign/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/unassign/i));
+
+      expect(onUnassignSpool).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+    });
+  });
 });
 
 // EmptySlotHoverCard is the hover wrapper rendered for a physically empty
@@ -397,4 +484,41 @@ describe('EmptySlotHoverCard (#1133)', () => {
     fireEvent.click(screen.getByText(/assign spool/i));
     expect(onAssign).toHaveBeenCalledTimes(1);
   });
+
+  // Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
+  describe('dismissal when an action opens a dialog (#2631)', () => {
+    it('closes the card when Configure is pressed, and still configures', async () => {
+      const onConfigure = vi.fn();
+      const result = render(
+        <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
+          <div>trigger</div>
+        </EmptySlotHoverCard>
+      );
+      fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/configure/i));
+
+      expect(onConfigure).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
+    });
+
+    it('closes the card when Assign Spool is pressed', async () => {
+      const onAssign = vi.fn();
+      const result = render(
+        <EmptySlotHoverCard onAssignSpool={onAssign}>
+          <div>trigger</div>
+        </EmptySlotHoverCard>
+      );
+      fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/assign spool/i));
+
+      expect(onAssign).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
+    });
+  });
 });

+ 27 - 1
frontend/src/components/FilamentHoverCard.tsx

@@ -156,6 +156,19 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
     timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
   };
 
+  // Dismiss the card immediately, for actions that open a dialog or navigate away.
+  //
+  // The card is portaled at z-[60] so it can escape sibling printer cards' stacking
+  // contexts, which puts it ABOVE ConfigureAmsSlotModal and LinkSpoolModal at z-50 —
+  // so a card left standing draws over the very dialog it just opened. Mouseleave is
+  // the only thing that normally hides it, and a touch device never sends one after
+  // the tap that opened the card, so on a tablet it stays up indefinitely (#2631).
+  // Clearing the timeout is not optional: a pending show timer would re-open it.
+  const dismiss = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    setIsVisible(false);
+  };
+
   // Cleanup timeout on unmount
   useEffect(() => {
     return () => {
@@ -328,6 +341,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                       <button
                         onClick={(e) => {
                           e.stopPropagation();
+                          dismiss();
                           navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
                         }}
                         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"
@@ -380,6 +394,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         <button
                           onClick={(e) => {
                             e.stopPropagation();
+                            dismiss();
                             navigate(`/inventory?spool=${inventory.assignedSpool!.id}`);
                           }}
                           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"
@@ -393,6 +408,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         <button
                           onClick={(e) => {
                             e.stopPropagation();
+                            dismiss();
                             inventory.onUnassignSpool?.();
                           }}
                           className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400"
@@ -406,6 +422,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     <button
                       onClick={inventory.isAssigned ? undefined : (e) => {
                         e.stopPropagation();
+                        dismiss();
                         inventory.onAssignSpool?.();
                       }}
                       disabled={!!inventory.isAssigned}
@@ -426,6 +443,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   <button
                     onClick={(e) => {
                       e.stopPropagation();
+                      dismiss();
                       configureSlot.onConfigure?.();
                     }}
                     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-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
@@ -534,6 +552,13 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
     timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
   };
 
+  // See FilamentHoverCard.dismiss — same z-[60]-over-a-z-50-dialog problem, and the
+  // same missing mouseleave on touch (#2631).
+  const dismiss = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    setIsVisible(false);
+  };
+
   useEffect(() => {
     return () => {
       if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -601,6 +626,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
                   <button
                     onClick={(e) => {
                       e.stopPropagation();
+                      dismiss();
                       configureSlot.onConfigure?.();
                     }}
                     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-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
@@ -612,7 +638,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
                 )}
                 {onAssignSpool && (
                   <button
-                    onClick={(e) => { e.stopPropagation(); onAssignSpool(); }}
+                    onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
                     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-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
                   >
                     <Package className="w-3.5 h-3.5" />

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-tuTcZo3j.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-ZS6qKcto.js"></script>
+    <script type="module" crossorigin src="/assets/index-tuTcZo3j.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-kl51qImb.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов