mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
feat(printers): AMS slot Load / Unload from the printer card (#891)
The ams_load_filament / ams_unload_filament MQTT primitives existed
in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
both as POST /printers/{id}/ams/load?tray_id={int} and
POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.
Wire them into the existing AMS slot popover (next to "Re-read RFID")
and add a popover wrapper on the external spool slot which had none.
Hidden while the printer is RUNNING, mirroring the RFID re-read
gating. Both buttons enabled when permission is granted; the printer
no-ops gracefully if there's nothing to do (matches BambuStudio).
Dual-extruder H2D Ext-R support is the trickier piece. The existing
ams_load_filament(254) capture came from a single-extruder printer
and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
extruder index, NOT a slot index), target=255, and curr/tar = the
actual right-nozzle temp (read from state.temperatures["nozzle_2"],
falling back to 215 °C if cold so the printer doesn't reject the
command on a nonsensical temp). Added that as a new branch in
ams_load_filament; the existing tray_id=254 branch is preserved
verbatim — no risk of regression on single-external setups.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Tests for the AMS slot load / unload buttons on PrintersPage (#891).
|
||||
*
|
||||
* Verifies that the menu in each AMS slot popover exposes Load and Unload,
|
||||
* that clicking them POSTs to the right endpoint with the right tray_id, and
|
||||
* that the buttons are hidden while the printer is RUNNING.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { PrintersPage } from '../../pages/PrintersPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockPrinter = {
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
ip_address: '192.168.1.100',
|
||||
serial_number: '00M09A350100001',
|
||||
access_code: '12345678',
|
||||
model: 'X1C',
|
||||
enabled: true,
|
||||
nozzle_diameter: 0.4,
|
||||
nozzle_type: 'hardened_steel',
|
||||
location: 'Workshop',
|
||||
auto_archive: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const baseTray = {
|
||||
tray_color: 'FF0000FF',
|
||||
tray_type: 'PLA',
|
||||
tray_sub_brands: 'PLA Basic',
|
||||
tray_id_name: 'A00-R0',
|
||||
tray_info_idx: 'GFA00',
|
||||
remain: 80,
|
||||
k: 0.02,
|
||||
cali_idx: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
nozzle_temp_min: 190,
|
||||
nozzle_temp_max: 230,
|
||||
drying_temp: null,
|
||||
drying_time: null,
|
||||
state: 11,
|
||||
};
|
||||
|
||||
const mockIdleStatusWithAms = {
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
progress: 0,
|
||||
layer_num: 0,
|
||||
total_layers: 0,
|
||||
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
|
||||
remaining_time: 0,
|
||||
filename: null,
|
||||
wifi_signal: -50,
|
||||
speed_level: 2,
|
||||
vt_tray: [],
|
||||
ams: [
|
||||
{
|
||||
id: 0,
|
||||
humidity: 30,
|
||||
temp: 25,
|
||||
is_ams_ht: false,
|
||||
serial_number: 'AMS00',
|
||||
sw_ver: '1.0.0',
|
||||
dry_time: 0,
|
||||
dry_status: 0,
|
||||
dry_sub_status: 0,
|
||||
dry_sf_reason: [],
|
||||
module_type: 'ams',
|
||||
tray: [
|
||||
{ id: 0, ...baseTray },
|
||||
{ id: 1, ...baseTray, tray_color: '00FF00FF', tray_type: 'PETG' },
|
||||
{ id: 2, ...baseTray, tray_color: '0000FFFF', tray_type: 'ABS' },
|
||||
{ id: 3, ...baseTray, tray_color: 'FFFF00FF', tray_type: 'TPU' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockRunningStatus = {
|
||||
...mockIdleStatusWithAms,
|
||||
state: 'RUNNING',
|
||||
};
|
||||
|
||||
describe('PrintersPage - AMS load/unload (#891)', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
it('Load posts to /ams/load with tray_id derived from amsId*4 + slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
let captured: { tray_id: string | null } | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockIdleStatusWithAms)),
|
||||
http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
captured = { tray_id: url.searchParams.get('tray_id') };
|
||||
return HttpResponse.json({ success: true, message: 'Loading filament from AMS 0 slot 3' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// The slot menu button is hidden until we hover. Pull it directly out of the DOM.
|
||||
expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Slot 2 (third one, slotIdx=2) → expected tray_id = 0*4 + 2 = 2
|
||||
const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
|
||||
await user.click(menuButtons[2]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Load')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Load'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.tray_id).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('Unload posts to /ams/unload (no body, no params)', async () => {
|
||||
const user = userEvent.setup();
|
||||
let unloadCalled = false;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockIdleStatusWithAms)),
|
||||
http.post('/api/v1/printers/:id/ams/unload', () => {
|
||||
unloadCalled = true;
|
||||
return HttpResponse.json({ success: true, message: 'Unloading filament' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
|
||||
await user.click(menuButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unload')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Unload'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(unloadCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the slot menu while the printer is RUNNING', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockRunningStatus)),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
// Wait for the page to render the printer card.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// No "Slot options" menu trigger should be present at all while running.
|
||||
expect(document.querySelectorAll('[title="Slot options"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it('external spool slot exposes Load and posts tray_id=254', async () => {
|
||||
const user = userEvent.setup();
|
||||
let captured: string | null = null;
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () =>
|
||||
HttpResponse.json({
|
||||
...mockIdleStatusWithAms,
|
||||
ams: [], // external-only
|
||||
vt_tray: [{ id: 254, ...baseTray, tray_type: 'PLA', tray_color: 'FFFFFFFF' }],
|
||||
}),
|
||||
),
|
||||
http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
|
||||
captured = new URL(request.url).searchParams.get('tray_id');
|
||||
return HttpResponse.json({ success: true, message: 'Loading filament from external spool' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll('[title="Slot options"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const menuButtons = document.querySelectorAll<HTMLButtonElement>('[title="Slot options"]');
|
||||
await user.click(menuButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Load')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Load'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(captured).toBe('254');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3095,6 +3095,20 @@ export const api = {
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 254 for external spool.
|
||||
loadAmsTray: (printerId: number, trayId: number) =>
|
||||
request<{ success: boolean; message: string }>(
|
||||
`/printers/${printerId}/ams/load?tray_id=${trayId}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Unload the currently loaded filament.
|
||||
unloadAms: (printerId: number) =>
|
||||
request<{ success: boolean; message: string }>(
|
||||
`/printers/${printerId}/ams/unload`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// MQTT Debug Logging
|
||||
enableMQTTLogging: (printerId: number) =>
|
||||
request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {
|
||||
|
||||
@@ -311,6 +311,10 @@ export default {
|
||||
calibrationSaved: 'Kalibrierung gespeichert!',
|
||||
calibrationFailed: 'Kalibrierung fehlgeschlagen',
|
||||
rfidRereadInitiated: 'RFID-Neueinlesen gestartet',
|
||||
loadInitiated: 'Filament wird geladen…',
|
||||
unloadInitiated: 'Filament wird entladen…',
|
||||
failedToLoad: 'Filament konnte nicht geladen werden',
|
||||
failedToUnload: 'Filament konnte nicht entladen werden',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -334,6 +338,11 @@ export default {
|
||||
rfid: {
|
||||
reread: 'RFID neu lesen',
|
||||
},
|
||||
// AMS Laden/Entladen (#891)
|
||||
ams: {
|
||||
load: 'Laden',
|
||||
unload: 'Entladen',
|
||||
},
|
||||
bedJog: {
|
||||
title: 'Druckbett bewegen',
|
||||
bed: 'Bett',
|
||||
|
||||
@@ -311,6 +311,10 @@ export default {
|
||||
calibrationSaved: 'Calibration saved!',
|
||||
calibrationFailed: 'Calibration failed',
|
||||
rfidRereadInitiated: 'RFID re-read initiated',
|
||||
loadInitiated: 'Loading filament…',
|
||||
unloadInitiated: 'Unloading filament…',
|
||||
failedToLoad: 'Failed to load filament',
|
||||
failedToUnload: 'Failed to unload filament',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -334,6 +338,11 @@ export default {
|
||||
rfid: {
|
||||
reread: 'Re-read RFID',
|
||||
},
|
||||
// AMS load/unload (#891)
|
||||
ams: {
|
||||
load: 'Load',
|
||||
unload: 'Unload',
|
||||
},
|
||||
bedJog: {
|
||||
title: 'Move build plate',
|
||||
bed: 'Bed',
|
||||
|
||||
@@ -311,6 +311,10 @@ export default {
|
||||
calibrationSaved: 'Calibration enregistrée !',
|
||||
calibrationFailed: 'Échec de la calibration',
|
||||
rfidRereadInitiated: 'Lecture RFID initiée',
|
||||
loadInitiated: 'Loading filament…',
|
||||
unloadInitiated: 'Unloading filament…',
|
||||
failedToLoad: 'Failed to load filament',
|
||||
failedToUnload: 'Failed to unload filament',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -334,6 +338,11 @@ export default {
|
||||
rfid: {
|
||||
reread: 'Relire RFID',
|
||||
},
|
||||
// AMS load/unload (#891)
|
||||
ams: {
|
||||
load: 'Load',
|
||||
unload: 'Unload',
|
||||
},
|
||||
bedJog: {
|
||||
title: 'Déplacer le plateau',
|
||||
bed: 'Plateau',
|
||||
|
||||
@@ -311,6 +311,10 @@ export default {
|
||||
calibrationSaved: 'Calibrazione salvata!',
|
||||
calibrationFailed: 'Calibrazione non riuscita',
|
||||
rfidRereadInitiated: 'Rilettura RFID avviata',
|
||||
loadInitiated: 'Loading filament…',
|
||||
unloadInitiated: 'Unloading filament…',
|
||||
failedToLoad: 'Failed to load filament',
|
||||
failedToUnload: 'Failed to unload filament',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -334,6 +338,11 @@ export default {
|
||||
rfid: {
|
||||
reread: 'Rileggi RFID',
|
||||
},
|
||||
// AMS load/unload (#891)
|
||||
ams: {
|
||||
load: 'Load',
|
||||
unload: 'Unload',
|
||||
},
|
||||
bedJog: {
|
||||
title: 'Muovi il piano di stampa',
|
||||
bed: 'Piano',
|
||||
|
||||
@@ -310,6 +310,10 @@ export default {
|
||||
calibrationSaved: 'キャリブレーションを保存しました!',
|
||||
calibrationFailed: 'キャリブレーションに失敗しました',
|
||||
rfidRereadInitiated: 'RFID再読み取りを開始しました',
|
||||
loadInitiated: 'Loading filament…',
|
||||
unloadInitiated: 'Unloading filament…',
|
||||
failedToLoad: 'Failed to load filament',
|
||||
failedToUnload: 'Failed to unload filament',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -333,6 +337,11 @@ export default {
|
||||
rfid: {
|
||||
reread: 'RFID再読み取り',
|
||||
},
|
||||
// AMS load/unload (#891)
|
||||
ams: {
|
||||
load: 'Load',
|
||||
unload: 'Unload',
|
||||
},
|
||||
bedJog: {
|
||||
title: 'ビルドプレートを移動',
|
||||
bed: 'ベッド',
|
||||
|
||||
@@ -311,6 +311,10 @@ export default {
|
||||
calibrationSaved: 'Calibração salva!',
|
||||
calibrationFailed: 'Falha na calibração',
|
||||
rfidRereadInitiated: 'Releitura de RFID iniciada',
|
||||
loadInitiated: 'Loading filament…',
|
||||
unloadInitiated: 'Unloading filament…',
|
||||
failedToLoad: 'Failed to load filament',
|
||||
failedToUnload: 'Failed to unload filament',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -334,6 +338,11 @@ export default {
|
||||
rfid: {
|
||||
reread: 'Releitura de RFID',
|
||||
},
|
||||
// AMS load/unload (#891)
|
||||
ams: {
|
||||
load: 'Load',
|
||||
unload: 'Unload',
|
||||
},
|
||||
bedJog: {
|
||||
title: 'Mover a mesa de impressão',
|
||||
bed: 'Mesa',
|
||||
|
||||
@@ -311,6 +311,10 @@ export default {
|
||||
calibrationSaved: '校准已保存!',
|
||||
calibrationFailed: '校准失败',
|
||||
rfidRereadInitiated: '已发起 RFID 重新读取',
|
||||
loadInitiated: 'Loading filament…',
|
||||
unloadInitiated: 'Unloading filament…',
|
||||
failedToLoad: 'Failed to load filament',
|
||||
failedToUnload: 'Failed to unload filament',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -334,6 +338,11 @@ export default {
|
||||
rfid: {
|
||||
reread: '重新读取 RFID',
|
||||
},
|
||||
// AMS load/unload (#891)
|
||||
ams: {
|
||||
load: 'Load',
|
||||
unload: 'Unload',
|
||||
},
|
||||
bedJog: {
|
||||
title: '移动热床',
|
||||
bed: '热床',
|
||||
|
||||
@@ -311,6 +311,10 @@ export default {
|
||||
calibrationSaved: '校準已儲存!',
|
||||
calibrationFailed: '校準失敗',
|
||||
rfidRereadInitiated: '已發起 RFID 重新讀取',
|
||||
loadInitiated: 'Loading filament…',
|
||||
unloadInitiated: 'Unloading filament…',
|
||||
failedToLoad: 'Failed to load filament',
|
||||
failedToUnload: 'Failed to unload filament',
|
||||
},
|
||||
// Connection status
|
||||
connection: {
|
||||
@@ -334,6 +338,11 @@ export default {
|
||||
rfid: {
|
||||
reread: '重新讀取 RFID',
|
||||
},
|
||||
// AMS load/unload (#891)
|
||||
ams: {
|
||||
load: 'Load',
|
||||
unload: 'Unload',
|
||||
},
|
||||
bedJog: {
|
||||
title: '移動熱床',
|
||||
bed: '熱床',
|
||||
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
DoorOpen,
|
||||
DoorClosed,
|
||||
MoveVertical,
|
||||
LogIn,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -1997,6 +1999,27 @@ function PrinterCard({
|
||||
},
|
||||
});
|
||||
|
||||
// AMS load/unload mutations (#891)
|
||||
const loadAmsTrayMutation = useMutation({
|
||||
mutationFn: ({ trayId }: { trayId: number }) => api.loadAmsTray(printer.id, trayId),
|
||||
onSuccess: (data) => {
|
||||
showToast(data.message || t('printers.toast.loadInitiated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('printers.toast.failedToLoad'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const unloadAmsMutation = useMutation({
|
||||
mutationFn: () => api.unloadAms(printer.id),
|
||||
onSuccess: (data) => {
|
||||
showToast(data.message || t('printers.toast.unloadInitiated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('printers.toast.failedToUnload'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Plate references state
|
||||
const [plateReferences, setPlateReferences] = useState<{
|
||||
references: Array<{ index: number; label: string; timestamp: string; has_image: boolean; thumbnail_url: string }>;
|
||||
@@ -3534,6 +3557,42 @@ function PrinterCard({
|
||||
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{t('printers.rfid.reread')}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
|
||||
hasPermission('printers:control')
|
||||
? 'text-white hover:bg-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasPermission('printers:control')) return;
|
||||
loadAmsTrayMutation.mutate({ trayId: ams.id * 4 + slotIdx });
|
||||
setAmsSlotMenu(null);
|
||||
}}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
|
||||
>
|
||||
<LogIn className="w-3 h-3" />
|
||||
{t('printers.ams.load')}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
|
||||
hasPermission('printers:control')
|
||||
? 'text-white hover:bg-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasPermission('printers:control')) return;
|
||||
unloadAmsMutation.mutate();
|
||||
setAmsSlotMenu(null);
|
||||
}}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
|
||||
>
|
||||
<LogOut className="w-3 h-3" />
|
||||
{t('printers.ams.unload')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Hover card wraps only the visual content */}
|
||||
@@ -3840,6 +3899,42 @@ function PrinterCard({
|
||||
<RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
|
||||
{t('printers.rfid.reread')}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
|
||||
hasPermission('printers:control')
|
||||
? 'text-white hover:bg-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasPermission('printers:control')) return;
|
||||
loadAmsTrayMutation.mutate({ trayId: ams.id * 4 + htSlotId });
|
||||
setAmsSlotMenu(null);
|
||||
}}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
|
||||
>
|
||||
<LogIn className="w-3 h-3" />
|
||||
{t('printers.ams.load')}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
|
||||
hasPermission('printers:control')
|
||||
? 'text-white hover:bg-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasPermission('printers:control')) return;
|
||||
unloadAmsMutation.mutate();
|
||||
setAmsSlotMenu(null);
|
||||
}}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
|
||||
>
|
||||
<LogOut className="w-3 h-3" />
|
||||
{t('printers.ams.unload')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Hover card wraps only the visual content */}
|
||||
@@ -4043,8 +4138,65 @@ function PrinterCard({
|
||||
</div>
|
||||
);
|
||||
|
||||
const extMenuKey = 255 * 10 + slotTrayId; // unique slotId space for external menu state
|
||||
const isExtMenuOpen = amsSlotMenu?.amsId === 255 && amsSlotMenu?.slotId === extMenuKey;
|
||||
|
||||
return (
|
||||
<div key={extTrayId} className="relative group">
|
||||
{/* Menu button - appears on hover, hidden when printer busy */}
|
||||
{status?.state !== 'RUNNING' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAmsSlotMenu(isExtMenuOpen ? null : { amsId: 255, slotId: extMenuKey });
|
||||
}}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10 hover:bg-bambu-dark-tertiary"
|
||||
title={t('printers.slotOptions')}
|
||||
>
|
||||
<MoreVertical className="w-2.5 h-2.5 text-bambu-gray" />
|
||||
</button>
|
||||
)}
|
||||
{/* Dropdown menu */}
|
||||
{status?.state !== 'RUNNING' && isExtMenuOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
|
||||
hasPermission('printers:control')
|
||||
? 'text-white hover:bg-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasPermission('printers:control')) return;
|
||||
loadAmsTrayMutation.mutate({ trayId: extTrayId });
|
||||
setAmsSlotMenu(null);
|
||||
}}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
|
||||
>
|
||||
<LogIn className="w-3 h-3" />
|
||||
{t('printers.ams.load')}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
|
||||
hasPermission('printers:control')
|
||||
? 'text-white hover:bg-bambu-dark-tertiary'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasPermission('printers:control')) return;
|
||||
unloadAmsMutation.mutate();
|
||||
setAmsSlotMenu(null);
|
||||
}}
|
||||
disabled={!hasPermission('printers:control')}
|
||||
title={!hasPermission('printers:control') ? t('printers.permission.noControl') : undefined}
|
||||
>
|
||||
<LogOut className="w-3 h-3" />
|
||||
{t('printers.ams.unload')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty ? (
|
||||
<FilamentHoverCard
|
||||
data={extFilamentData}
|
||||
|
||||
Reference in New Issue
Block a user