mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Always show slice option in filemanager context menu but fallback to local slicer
This commit is contained in:
@@ -3,13 +3,24 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { FileManagerPage } from '../../pages/FileManagerPage';
|
||||
import { openInSlicer } from '../../utils/slicer';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
vi.mock('../../utils/slicer', () => ({
|
||||
openInSlicer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/SliceModal', () => ({
|
||||
SliceModal: ({ source }: { source: { filename: string } }) => (
|
||||
<div data-testid="slice-modal">{source.filename}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockFolders = [
|
||||
{
|
||||
@@ -1155,4 +1166,62 @@ describe('FileManagerPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('slice action', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(openInSlicer).mockClear();
|
||||
server.use(
|
||||
http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
|
||||
);
|
||||
});
|
||||
|
||||
const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
|
||||
const card = screen.getByText(filename).closest('.group') as HTMLElement;
|
||||
await user.click(card.querySelector('button')!);
|
||||
return card;
|
||||
};
|
||||
|
||||
it('opens the desktop slicer when the slicer API is disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
|
||||
|
||||
const card = await openMenu(user, 'bracket.stl');
|
||||
await user.click(within(card).getByText('Slice'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openInSlicer).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/library/files/2/dl/test-token/'),
|
||||
'bambu_studio',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the in-app SliceModal when the slicer API is enabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
|
||||
|
||||
const card = await openMenu(user, 'bracket.stl');
|
||||
await user.click(within(card).getByText('Slice'));
|
||||
|
||||
expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
|
||||
expect(openInSlicer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides the slice item for already-sliced files', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
const card = await openMenu(user, 'Benchy');
|
||||
expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5337,6 +5337,8 @@ export default {
|
||||
// Model Viewer
|
||||
modelViewer: {
|
||||
openInSlicer: "Відкрити у слайсері",
|
||||
openInSlicerWith: "Відкрити у {{slicer}}",
|
||||
moreSlicerOptions: "Більше варіантів слайсера",
|
||||
tabs: {
|
||||
model: "3D-модель",
|
||||
gcode: "Попередній перегляд G-коду",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Upload,
|
||||
Trash2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
MoreVertical,
|
||||
ChevronRight,
|
||||
FolderPlus,
|
||||
@@ -73,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
import { openInSlicer, type SlicerType } from '../utils/slicer';
|
||||
|
||||
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
@@ -759,6 +761,7 @@ interface FileCardProps {
|
||||
onDownload: (id: number) => void;
|
||||
onPrint?: (file: LibraryFileListItem) => void;
|
||||
onSlice?: (file: LibraryFileListItem) => void;
|
||||
onOpenInSlicer?: (file: LibraryFileListItem) => void;
|
||||
onRunPipeline?: (file: LibraryFileListItem) => void;
|
||||
useSlicerApi?: boolean;
|
||||
onPreview3d?: (file: LibraryFileListItem) => void;
|
||||
@@ -773,9 +776,11 @@ interface FileCardProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
||||
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
const sliceDisabled = useSlicerApi ? !hasPermission('library:upload') : !hasPermission('library:read');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${
|
||||
@@ -899,16 +904,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
|
||||
{t('common.print')}
|
||||
</button>
|
||||
)}
|
||||
{onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
|
||||
{isSliceableFilename(file.filename) && (onSlice || onOpenInSlicer) && (
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
|
||||
hasPermission('library:upload') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
|
||||
!sliceDisabled ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
|
||||
}`}
|
||||
onClick={() => { if (hasPermission('library:upload')) { onSlice(file); setShowActions(false); } }}
|
||||
disabled={!hasPermission('library:upload')}
|
||||
title={!hasPermission('library:upload') ? t('fileManager.noPermissionSlice') : undefined}
|
||||
onClick={() => {
|
||||
if (sliceDisabled) return;
|
||||
if (useSlicerApi) onSlice?.(file);
|
||||
else onOpenInSlicer?.(file);
|
||||
setShowActions(false);
|
||||
}}
|
||||
disabled={sliceDisabled}
|
||||
title={sliceDisabled ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
|
||||
>
|
||||
<Cog className="w-3.5 h-3.5" />
|
||||
{useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
|
||||
{t('slice.action')}
|
||||
</button>
|
||||
)}
|
||||
@@ -1145,6 +1155,27 @@ export function FileManagerPage() {
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => api.getSettings() as Promise<AppSettings>,
|
||||
});
|
||||
|
||||
const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
|
||||
|
||||
const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
|
||||
try {
|
||||
const { token } = await api.createLibrarySlicerToken(file.id);
|
||||
const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
|
||||
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
|
||||
} catch {
|
||||
const path = api.getLibraryFileDownloadUrl(file.id);
|
||||
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
|
||||
}
|
||||
}, [preferredSlicer]);
|
||||
|
||||
// Slice permission: API mode needs upload rights, desktop handoff is a download.
|
||||
const canSlice = useCallback(() => {
|
||||
if (settings?.use_slicer_api) {
|
||||
return hasPermission('library:upload');
|
||||
}
|
||||
return hasPermission('library:read');
|
||||
}, [settings?.use_slicer_api, hasPermission]);
|
||||
const { data: folders, isLoading: foldersLoading } = useQuery({
|
||||
queryKey: ['library-folders'],
|
||||
queryFn: () => api.getLibraryFolders(),
|
||||
@@ -2420,6 +2451,7 @@ export function FileManagerPage() {
|
||||
onDownload={handleDownload}
|
||||
onPrint={setPrintFile}
|
||||
onSlice={setSliceFile}
|
||||
onOpenInSlicer={handleOpenInSlicer}
|
||||
onRunPipeline={setRunPipelineFile}
|
||||
useSlicerApi={settings?.use_slicer_api ?? false}
|
||||
onPreview3d={(f) => {
|
||||
@@ -2597,18 +2629,21 @@ export function FileManagerPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
|
||||
{isSliceableFilename(file.filename) && (
|
||||
<button
|
||||
onClick={() => hasPermission('library:upload') && setSliceFile(file)}
|
||||
onClick={() => {
|
||||
if (!canSlice()) return;
|
||||
(settings?.use_slicer_api ? setSliceFile : handleOpenInSlicer)(file);
|
||||
}}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
hasPermission('library:upload')
|
||||
canSlice()
|
||||
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
|
||||
: 'text-bambu-gray/50 cursor-not-allowed'
|
||||
}`}
|
||||
title={hasPermission('library:upload') ? t('slice.action') : t('fileManager.noPermissionSlice')}
|
||||
disabled={!hasPermission('library:upload')}
|
||||
title={canSlice() ? t('slice.action') : (settings?.use_slicer_api ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload'))}
|
||||
disabled={!canSlice()}
|
||||
>
|
||||
<Cog className="w-4 h-4" />
|
||||
{settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
|
||||
|
||||
Reference in New Issue
Block a user