mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import
Two-part fix for the reporter's "removed profiles still show on the slice
menu" symptom.
Local half (real bug). LocalProfilesView's import and delete mutations
invalidated ['localPresets'] (the management view's own query) but not
['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
A freshly-deleted preset kept rendering in the slice dropdown until that
staleTime elapsed plus a refocus/remount. Both mutations now also call
queryClient.invalidateQueries({queryKey: ['slicerPresets']}).
Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
per-(user, token) in-process cache (slicer_presets.py:69, balances
"users see freshly-saved presets quickly" against "busy install doesn't
hit Bambu Cloud once per modal open"). Users delete cloud presets in
Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
to invalidate on. Rather than shorten the TTL globally, the listing
endpoint gains an opt-in ?refresh=true query param that bypasses both
the cloud cache AND the 1-hour bundled-preset cache for that one call;
the fresh result is still written back so subsequent normal callers
keep hitting the cache.
New SliceModal "Refresh" button. Lives in the preset section header
next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
and writes the fresh slots into the ['slicerPresets'] cache via
queryClient.setQueryData so the spinner stops immediately rather than
triggering a second refetch. RefreshCw icon spins while in-flight;
disabled during slice enqueue to prevent double-fire.
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* Tests for LocalProfilesView component.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor, fireEvent, render as rawRender } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { server } from '../mocks/server';
|
||||
import { render } from '../utils';
|
||||
import { ThemeProvider } from '../../contexts/ThemeContext';
|
||||
import { ToastProvider } from '../../contexts/ToastContext';
|
||||
import { AuthProvider } from '../../contexts/AuthContext';
|
||||
import { LocalProfilesView } from '../../components/LocalProfilesView';
|
||||
|
||||
const mockLocalPresets = {
|
||||
@@ -209,4 +215,62 @@ describe('LocalProfilesView', () => {
|
||||
|
||||
expect(screen.getByText(/\.bbscfg/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invalidates the slicerPresets query after a delete (#1581)', async () => {
|
||||
// Without this invalidation a preset deleted in Local Profiles still
|
||||
// shows in the SliceModal until the modal's ['slicerPresets'] query
|
||||
// staleTime (60s) expires + a refocus / remount. The bug report:
|
||||
// "Removed local profiles still show on the slice menu even tho they
|
||||
// have been deleted." We mirror the production provider tree but inject
|
||||
// our own QueryClient so we can spy on invalidateQueries.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
rawRender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<LocalProfilesView />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the delete confirmation, then confirm. The card-level delete
|
||||
// icon buttons and the modal's Delete button both expose the accessible
|
||||
// name "Delete"; the modal button is the last one to mount, so it's the
|
||||
// tail of the findAllByRole result.
|
||||
const deleteButtons = screen.getAllByTitle(/delete/i);
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
await screen.findByText(/are you sure/i);
|
||||
const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
invalidateSpy.mock.calls.some(
|
||||
([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'slicerPresets',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
// Sanity: the local-only invalidation is still there too.
|
||||
expect(
|
||||
invalidateSpy.mock.calls.some(
|
||||
([arg]) => arg && (arg as { queryKey: unknown[] }).queryKey?.[0] === 'localPresets',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5796,8 +5796,13 @@ export const api = {
|
||||
// Unified slicer-preset listing — cloud + local + standard, deduped by name.
|
||||
// Used by the SliceModal; see UnifiedPresetsResponse for the shape and
|
||||
// backend/app/api/routes/slicer_presets.py for the priority rules.
|
||||
getSlicerPresets: () =>
|
||||
request<UnifiedPresetsResponse>('/slicer/presets'),
|
||||
// `refresh` bypasses the in-process cloud and bundled-preset caches on the
|
||||
// backend; the SliceModal's Refresh button passes true so a preset deleted
|
||||
// in Bambu Studio or Bambu Handy shows up without the 5-min TTL wait.
|
||||
getSlicerPresets: (options?: { refresh?: boolean }) =>
|
||||
request<UnifiedPresetsResponse>(
|
||||
options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
|
||||
),
|
||||
|
||||
// Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
|
||||
// Single source of truth shared with backend (PRINTER_MODEL_MAP); the
|
||||
|
||||
@@ -245,6 +245,11 @@ export function LocalProfilesView() {
|
||||
},
|
||||
onSuccess: (results) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['localPresets'] });
|
||||
// The SliceModal reads from a separate `slicerPresets` query that lists
|
||||
// cloud + local + standard in one shot. Without this second invalidation
|
||||
// freshly-imported profiles wouldn't appear in the SliceModal dropdown
|
||||
// until that query's staleTime elapsed plus a refocus / remount (#1581).
|
||||
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
|
||||
let totalImported = 0;
|
||||
let totalSkipped = 0;
|
||||
let totalErrors = 0;
|
||||
@@ -273,6 +278,11 @@ export function LocalProfilesView() {
|
||||
mutationFn: (id: number) => api.deleteLocalPreset(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['localPresets'] });
|
||||
// Match the import path: the SliceModal's `slicerPresets` query needs
|
||||
// to be invalidated too, otherwise the deleted preset keeps appearing
|
||||
// in the slice dropdown until its 60s staleTime expires plus a
|
||||
// refocus / remount (#1581).
|
||||
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
|
||||
setDeleteConfirm(null);
|
||||
showToast(t('profiles.localProfiles.toast.deleted'));
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
|
||||
import { Cloud, CloudOff, Cog, Loader2, Package, RefreshCw, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
api,
|
||||
type PresetRef,
|
||||
@@ -312,6 +312,7 @@ function formatElapsed(seconds: number): string {
|
||||
export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackJob } = useSliceJobTracker();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
|
||||
const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
|
||||
@@ -431,6 +432,28 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
enabled: !platesQuery.isLoading && !needsPlatePicker,
|
||||
});
|
||||
|
||||
// Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
|
||||
// bundled cache for one call so users who deleted a preset in Bambu
|
||||
// Studio / Bambu Handy see the change immediately (#1581). The cache write
|
||||
// inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
|
||||
// fresh result so subsequent normal callers still get cached responses.
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const handleRefreshPresets = async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const fresh = await api.getSlicerPresets({ refresh: true });
|
||||
queryClient.setQueryData(['slicerPresets'], fresh);
|
||||
} catch {
|
||||
// Fall through to invalidate so React Query retries via its normal
|
||||
// path on the next render — surfacing the failure through the existing
|
||||
// presetsQuery.isError banner instead of duplicating error UI here.
|
||||
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
|
||||
// configured / no bundles imported yet; the bundle picker hides itself
|
||||
// in that case so users without bundles see the original modal layout.
|
||||
@@ -723,7 +746,26 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
|
||||
{presetsQuery.data && (
|
||||
<>
|
||||
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshPresets}
|
||||
disabled={isRefreshing || isEnqueuing}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title={t('slice.refreshPresetsTitle')}
|
||||
aria-label={t('slice.refreshPresets')}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{t('slice.refreshPresets')}
|
||||
</button>
|
||||
</div>
|
||||
{/* CloudStatusBanner above is hidden via flex-1 wrapper when
|
||||
status === 'ok' (returns null in that case), but the Refresh
|
||||
button stays visible regardless so users can pick up cloud /
|
||||
bundled changes even when sign-in is healthy. */}
|
||||
{/* Bundle picker — only renders when at least one .bbscfg has
|
||||
been imported via Settings → Slicer Bundles. Lets the user
|
||||
trade the cloud/local/standard tier for a single curated
|
||||
|
||||
@@ -3481,6 +3481,8 @@ export default {
|
||||
noPresetsForSlot: 'Keine Profile verfügbar',
|
||||
otherPrinters: 'Andere Drucker',
|
||||
presetsLoadFailed: 'Profile konnten nicht geladen werden. Importiere sie zuerst unter Einstellungen → Profile.',
|
||||
refreshPresets: 'Aktualisieren',
|
||||
refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
|
||||
allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
|
||||
bundle: 'Slicer-Bundle',
|
||||
bundleNone: '— Keines (Profile einzeln auswählen) —',
|
||||
|
||||
@@ -3484,6 +3484,8 @@ export default {
|
||||
noPresetsForSlot: 'No presets available',
|
||||
otherPrinters: 'Other printers',
|
||||
presetsLoadFailed: 'Failed to load presets. Open Settings → Profiles to import them first.',
|
||||
refreshPresets: 'Refresh',
|
||||
refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
|
||||
allPresetsRequired: 'All presets must be selected',
|
||||
bundle: 'Slicer bundle',
|
||||
bundleNone: '— None (pick presets individually) —',
|
||||
|
||||
@@ -3484,6 +3484,8 @@ export default {
|
||||
noPresetsForSlot: 'No hay preajustes disponibles',
|
||||
otherPrinters: 'Otras impresoras',
|
||||
presetsLoadFailed: 'Error al cargar los preajustes. Abra Ajustes → Perfiles para importarlos primero.',
|
||||
refreshPresets: 'Actualizar',
|
||||
refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
|
||||
allPresetsRequired: 'Deben seleccionarse todos los preajustes',
|
||||
bundle: 'Paquete del laminador',
|
||||
bundleNone: '— Ninguno (elegir preajustes individualmente) —',
|
||||
|
||||
@@ -3470,6 +3470,8 @@ export default {
|
||||
noPresetsForSlot: 'Aucun préréglage disponible',
|
||||
otherPrinters: 'Autres imprimantes',
|
||||
presetsLoadFailed: 'Échec du chargement des préréglages. Ouvrez Paramètres → Profils pour les importer d\'abord.',
|
||||
refreshPresets: 'Actualiser',
|
||||
refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
|
||||
allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
|
||||
bundle: 'Pack de découpage',
|
||||
bundleNone: '— Aucun (choisir les préréglages individuellement) —',
|
||||
|
||||
@@ -3469,6 +3469,8 @@ export default {
|
||||
noPresetsForSlot: 'Nessun preset disponibile',
|
||||
otherPrinters: 'Altre stampanti',
|
||||
presetsLoadFailed: 'Caricamento preset fallito. Apri Impostazioni → Profili per importarli prima.',
|
||||
refreshPresets: 'Aggiorna',
|
||||
refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
|
||||
allPresetsRequired: 'Tutti i preset devono essere selezionati',
|
||||
bundle: 'Bundle slicer',
|
||||
bundleNone: '— Nessuno (scegli i preset singolarmente) —',
|
||||
|
||||
@@ -3481,6 +3481,8 @@ export default {
|
||||
noPresetsForSlot: 'プリセットなし',
|
||||
otherPrinters: '他のプリンター',
|
||||
presetsLoadFailed: 'プリセットの読み込みに失敗。先に設定 → プロファイルからインポートしてください。',
|
||||
refreshPresets: '再読み込み',
|
||||
refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
|
||||
allPresetsRequired: 'すべてのプリセットを選択する必要があります',
|
||||
bundle: 'スライサーバンドル',
|
||||
bundleNone: '— なし(プリセットを個別に選択)—',
|
||||
|
||||
@@ -3469,6 +3469,8 @@ export default {
|
||||
noPresetsForSlot: 'Nenhuma predefinição disponível',
|
||||
otherPrinters: 'Outras impressoras',
|
||||
presetsLoadFailed: 'Falha ao carregar predefinições. Abra Configurações → Perfis para importá-las primeiro.',
|
||||
refreshPresets: 'Atualizar',
|
||||
refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
|
||||
allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
|
||||
bundle: 'Pacote do fatiador',
|
||||
bundleNone: '— Nenhum (escolher predefinições individualmente) —',
|
||||
|
||||
@@ -3469,6 +3469,8 @@ export default {
|
||||
noPresetsForSlot: '无可用预设',
|
||||
otherPrinters: '其他打印机',
|
||||
presetsLoadFailed: '加载预设失败。请先打开设置 → 配置文件以导入。',
|
||||
refreshPresets: '刷新',
|
||||
refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
|
||||
allPresetsRequired: '必须选择所有预设',
|
||||
bundle: '切片器套装',
|
||||
bundleNone: '— 无(单独选择预设)—',
|
||||
|
||||
@@ -3469,6 +3469,8 @@ export default {
|
||||
noPresetsForSlot: '無可用預設',
|
||||
otherPrinters: '其他印表機',
|
||||
presetsLoadFailed: '載入預設失敗。請先開啟設定 → 設定檔以匯入。',
|
||||
refreshPresets: '重新整理',
|
||||
refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
|
||||
allPresetsRequired: '必須選擇所有預設',
|
||||
bundle: '切片器套裝',
|
||||
bundleNone: '— 無(單獨選擇預設)—',
|
||||
|
||||
Reference in New Issue
Block a user