mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
File Manager: a third view mode, Miller columns (#3190)
This commit is contained in:
-3315
File diff suppressed because one or more lines are too long
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The framed app may ask Bambuddy to open one of its own pages in place
|
||||
* (Bambuddy Orders' "In Bambuddy" link): only from the frame, only from the
|
||||
* link's origin, only for a path inside Bambuddy.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { render } from '../utils';
|
||||
import { api } from '../../api/client';
|
||||
import { ExternalLinkPage } from '../../pages/ExternalLinkPage';
|
||||
|
||||
const ORIGIN = 'http://orders.local:8090';
|
||||
const LINK = {
|
||||
id: 5,
|
||||
name: 'Orders',
|
||||
url: `${ORIGIN}/`,
|
||||
icon: 'shopping-cart',
|
||||
open_in_new_tab: false,
|
||||
custom_icon: null,
|
||||
sort_order: 0,
|
||||
created_at: '2026-09-27T00:00:00Z',
|
||||
updated_at: '2026-09-27T00:00:00Z',
|
||||
};
|
||||
|
||||
function Where() {
|
||||
const location = useLocation();
|
||||
return <p data-testid="where">{location.pathname + location.search}</p>;
|
||||
}
|
||||
|
||||
async function renderFrame() {
|
||||
window.history.pushState({}, '', '/external/5');
|
||||
render(
|
||||
<Routes>
|
||||
<Route path="/external/:id" element={<ExternalLinkPage />} />
|
||||
<Route path="*" element={<Where />} />
|
||||
</Routes>,
|
||||
);
|
||||
return (await screen.findByTitle('Orders')) as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
function ask(frame: HTMLIFrameElement | Window, path: unknown, origin = ORIGIN) {
|
||||
const source = frame instanceof HTMLIFrameElement ? frame.contentWindow : frame;
|
||||
window.dispatchEvent(new MessageEvent('message', { data: { type: 'bambuddy:navigate', path }, origin, source }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'getExternalLink').mockResolvedValue(LINK);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('ExternalLinkPage navigate requests', () => {
|
||||
it('opens the asked-for Bambuddy page', async () => {
|
||||
const frame = await renderFrame();
|
||||
ask(frame, '/queue?batch=12');
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent('/queue?batch=12');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['another site', 'https://evil.example/login'],
|
||||
['a protocol-relative address', '//evil.example/login'],
|
||||
['a backslash trick', '/\\evil.example'],
|
||||
['a script', 'javascript:alert(1)'],
|
||||
['a relative path', 'queue'],
|
||||
['no string', 42],
|
||||
])('ignores %s', async (_name, path) => {
|
||||
const frame = await renderFrame();
|
||||
ask(frame, path);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.getByTitle('Orders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores requests from other windows or origins', async () => {
|
||||
const frame = await renderFrame();
|
||||
ask(window, '/queue');
|
||||
ask(frame, '/queue', 'http://evil.example');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.getByTitle('Orders')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('where')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ const THEME = {
|
||||
style: expect.any(String),
|
||||
background: expect.any(String),
|
||||
accent: expect.any(String),
|
||||
canNavigate: true,
|
||||
};
|
||||
|
||||
describe('ExternalLinkPage theme', () => {
|
||||
|
||||
@@ -27,6 +27,11 @@ vi.mock('../../components/SliceModal', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// The viewer pulls in three.js; the tests only care whether it opened.
|
||||
vi.mock('../../components/ModelViewerModal', () => ({
|
||||
ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockFolders = [
|
||||
{
|
||||
@@ -120,6 +125,84 @@ const mockFiles = [
|
||||
},
|
||||
];
|
||||
|
||||
// Per-folder contents. The columns view lists every level's files, so each
|
||||
// folder needs its own set — otherwise the same name shows up in two columns
|
||||
// and the queries below cannot tell them apart.
|
||||
const mockFolderFiles: Record<string, Record<string, unknown>[]> = {
|
||||
'1': [
|
||||
{
|
||||
id: 11,
|
||||
filename: 'spacer.3mf',
|
||||
file_path: '/library/functional/spacer.3mf',
|
||||
file_size: 131072,
|
||||
file_type: '3mf',
|
||||
folder_id: 1,
|
||||
thumbnail_path: null,
|
||||
print_name: 'Spacer',
|
||||
print_time_seconds: 900,
|
||||
print_count: 0,
|
||||
duplicate_count: 0,
|
||||
created_at: '2024-02-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
'2': [
|
||||
{
|
||||
id: 12,
|
||||
filename: 'clamp.stl',
|
||||
file_path: '/library/functional/brackets/clamp.stl',
|
||||
file_size: 65536,
|
||||
file_type: 'stl',
|
||||
folder_id: 2,
|
||||
thumbnail_path: null,
|
||||
print_name: null,
|
||||
print_time_seconds: null,
|
||||
print_count: 0,
|
||||
duplicate_count: 0,
|
||||
created_at: '2024-02-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
filename: 'hinge.3mf',
|
||||
file_path: '/library/functional/brackets/hinge.3mf',
|
||||
file_size: 98304,
|
||||
file_type: '3mf',
|
||||
folder_id: 2,
|
||||
thumbnail_path: null,
|
||||
print_name: 'Hinge',
|
||||
print_time_seconds: 1200,
|
||||
print_count: 1,
|
||||
duplicate_count: 0,
|
||||
created_at: '2024-02-03T00:00:00Z',
|
||||
},
|
||||
],
|
||||
'3': [
|
||||
{
|
||||
id: 14,
|
||||
filename: 'vase.3mf',
|
||||
file_path: '/library/art/vase.3mf',
|
||||
file_size: 262144,
|
||||
file_type: '3mf',
|
||||
folder_id: 3,
|
||||
thumbnail_path: null,
|
||||
print_name: 'Vase',
|
||||
print_time_seconds: 5400,
|
||||
print_count: 0,
|
||||
duplicate_count: 0,
|
||||
created_at: '2024-02-04T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// A request carrying folder_id wants that folder's own files. Without one,
|
||||
// include_root decides as the server does: true is the root level (the files in
|
||||
// no folder), false the whole library ("All Files", every folder included).
|
||||
const filesForRequest = (request: Request) => {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const folderId = params.get('folder_id');
|
||||
if (folderId) return mockFolderFiles[folderId] ?? [];
|
||||
return params.get('include_root') === 'true' ? mockFiles : [...mockFiles, ...Object.values(mockFolderFiles).flat()];
|
||||
};
|
||||
|
||||
const mockStats = {
|
||||
total_files: 10,
|
||||
total_folders: 3,
|
||||
@@ -137,8 +220,8 @@ describe('FileManagerPage', () => {
|
||||
http.get('/api/v1/library/folders', () => {
|
||||
return HttpResponse.json(mockFolders);
|
||||
}),
|
||||
http.get('/api/v1/library/files', () => {
|
||||
return HttpResponse.json(mockFiles);
|
||||
http.get('/api/v1/library/files', ({ request }) => {
|
||||
return HttpResponse.json(filesForRequest(request));
|
||||
}),
|
||||
http.get('/api/v1/library/stats', () => {
|
||||
return HttpResponse.json(mockStats);
|
||||
@@ -365,6 +448,666 @@ describe('FileManagerPage', () => {
|
||||
// Verify files are still displayed after toggling
|
||||
expect(screen.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can switch to columns view and descend through folder columns', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
|
||||
// Scoped to the columns pane — the folder names also live in the tree
|
||||
// sidebar, so unscoped queries would double-match.
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
expect(columns.getByText('Functional Parts')).toBeInTheDocument();
|
||||
expect(columns.getByText('Art Projects')).toBeInTheDocument();
|
||||
// Root files render in the files pane.
|
||||
expect(columns.getByText('Benchy')).toBeInTheDocument();
|
||||
|
||||
// Descend: clicking a folder opens its child column, and the level the
|
||||
// user just left keeps listing its own files.
|
||||
await user.click(columns.getByText('Functional Parts'));
|
||||
await waitFor(() => {
|
||||
expect(columns.getByText('Brackets')).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(columns.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
expect(within(screen.getByTestId('columns-files-pane')).getByText('Spacer')).toBeInTheDocument();
|
||||
|
||||
// Leaf folder: selecting it adds no further column and the pane swaps to
|
||||
// its contents.
|
||||
await user.click(columns.getByText('Brackets'));
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId('columns-files-pane')).getByText('clamp.stl')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides folder columns while a search filters across folders', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
expect(columns.getByText('Functional Parts')).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Search files...'), 'benchy');
|
||||
|
||||
// Search results span every folder — the per-level columns disappear,
|
||||
// the matching file stays.
|
||||
await waitFor(() => {
|
||||
expect(columns.queryByText('Functional Parts')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(columns.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates the columns with the arrow keys', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
// The highlight sits on the row, which also hosts the folder kebab —
|
||||
// the name button itself is unstyled.
|
||||
const activeClassOf = (name: string) =>
|
||||
columns.getByText(name).closest('[data-folder-id]')?.className ?? '';
|
||||
|
||||
// The pane auto-focuses when the view opens, so keys work immediately.
|
||||
// Down from the root selects the first folder (name-sorted: Art
|
||||
// Projects), Down again moves to its sibling.
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await waitFor(() => {
|
||||
expect(activeClassOf('Art Projects')).toContain('bg-bambu-green/20');
|
||||
});
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await waitFor(() => {
|
||||
expect(activeClassOf('Functional Parts')).toContain('bg-bambu-green/20');
|
||||
});
|
||||
|
||||
// Right descends into the first child folder…
|
||||
await user.keyboard('{ArrowRight}');
|
||||
await waitFor(() => {
|
||||
expect(activeClassOf('Brackets')).toContain('bg-bambu-green/20');
|
||||
});
|
||||
// …and Left climbs back up to the parent.
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
await waitFor(() => {
|
||||
expect(activeClassOf('Functional Parts')).toContain('bg-bambu-green/20');
|
||||
});
|
||||
|
||||
// Right on a folder without child folders moves the focus into the
|
||||
// files pane; Down walks the file rows. Wait for the refetched file
|
||||
// list before the second Right — during the folder switch the pane can
|
||||
// be momentarily empty.
|
||||
await user.keyboard('{ArrowRight}');
|
||||
await waitFor(() => {
|
||||
expect(activeClassOf('Brackets')).toContain('bg-bambu-green/20');
|
||||
expect(columns.getByText('clamp.stl')).toBeInTheDocument();
|
||||
});
|
||||
await user.keyboard('{ArrowRight}');
|
||||
await waitFor(() => {
|
||||
expect(columns.getByText('clamp.stl').closest('[title="clamp.stl"]')?.className).toContain('ring-1');
|
||||
});
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await waitFor(() => {
|
||||
expect(columns.getByText('Hinge').closest('[title="Hinge"]')?.className).toContain('ring-1');
|
||||
});
|
||||
// Left leaves the files pane again.
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
await waitFor(() => {
|
||||
expect(columns.getByText('Hinge').closest('[title="Hinge"]')?.className).not.toContain('ring-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the columns pane mounted while a folder\'s files load', async () => {
|
||||
const user = userEvent.setup();
|
||||
// First files request (initial load) resolves immediately, later ones
|
||||
// (the refetch a keyboard descent triggers) stay pending briefly.
|
||||
let filesCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/v1/library/files', async ({ request }) => {
|
||||
filesCalls += 1;
|
||||
if (filesCalls > 1) await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
return HttpResponse.json(filesForRequest(request));
|
||||
})
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Benchy')).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
expect(screen.getByTestId('columns-view')).toBeInTheDocument();
|
||||
|
||||
// Descend while the folder's file list is still in flight. Swapping
|
||||
// the pane for the global spinner here would strip its tabindex and,
|
||||
// in real browsers, drop keyboard focus to <body> for good.
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(screen.getByTestId('columns-view')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByTestId('columns-files-pane')).getByText('Vase')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// #3020 follow-up: the files pane carries the list row's icon strip, so
|
||||
// the columns view offers every per-file action in the same order.
|
||||
const listRow = (name: string) => screen.getByText(name).closest('div[class*="cursor-pointer"]') as HTMLElement;
|
||||
const actionTitlesOf = (row: HTMLElement) =>
|
||||
Array.from(row.querySelector('[data-file-actions]')!.querySelectorAll('button')).map((b) => b.title);
|
||||
|
||||
it('offers the same per-file actions as the list view, in the same order', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('List view'));
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
const listBenchy = actionTitlesOf(listRow('Benchy'));
|
||||
const listBracket = actionTitlesOf(listRow('bracket.stl'));
|
||||
expect(listBenchy).toEqual(['Print', '3D Preview', 'Download', 'File details', 'Rename', 'Delete']);
|
||||
expect(listBracket).toContain('Generate Thumbnail');
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
await waitFor(() => expect(columns.getByText('Benchy')).toBeInTheDocument());
|
||||
expect(actionTitlesOf(columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement)).toEqual(listBenchy);
|
||||
expect(actionTitlesOf(columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement)).toEqual(listBracket);
|
||||
});
|
||||
|
||||
it('fires a row action without toggling the row selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
const row = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
|
||||
|
||||
await user.click(within(row).getByTitle('Rename'));
|
||||
// The rename modal edits the base name; the extension is fixed.
|
||||
expect(await screen.findByDisplayValue('benchy')).toBeInTheDocument();
|
||||
expect(row.className).not.toContain('bg-bambu-green/10');
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
await user.click(within(row).getByTitle('Delete'));
|
||||
expect(await screen.findByText('Delete File')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals the focused row\'s actions and keeps the rest touch-reachable (#2865)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
const stripWrapper = (name: string) =>
|
||||
(columns.getByText(name).closest('[data-file-id]') as HTMLElement).querySelector('[data-file-actions]')!.parentElement!;
|
||||
|
||||
// Right from the root moves the focus into the files pane.
|
||||
await user.keyboard('{ArrowRight}');
|
||||
await waitFor(() => expect(stripWrapper('Benchy').className).not.toContain('opacity-0'));
|
||||
|
||||
// Other rows hide the strip only for pointers that can hover — never a
|
||||
// bare opacity-0 — and stay out of the Tab order.
|
||||
expect(stripWrapper('bracket.stl').className).not.toMatch(/(^|\s)opacity-0(\s|$)/);
|
||||
expect(stripWrapper('bracket.stl').className).toContain('can-hover:opacity-0');
|
||||
expect(within(stripWrapper('bracket.stl')).getByTitle('Download')).toHaveAttribute('tabindex', '-1');
|
||||
expect(within(stripWrapper('Benchy')).getByTitle('Download')).toHaveAttribute('tabindex', '0');
|
||||
|
||||
// Tab from the pane lands on the focused row's first action.
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(within(stripWrapper('Benchy')).getByTitle('Print'));
|
||||
});
|
||||
|
||||
it('reaches and triggers the focused file\'s actions from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
await user.keyboard('{ArrowRight}');
|
||||
await waitFor(() => {
|
||||
expect(columns.getByText('Benchy').closest('[title="Benchy"]')?.className).toContain('ring-1');
|
||||
});
|
||||
const row = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
|
||||
|
||||
// The context-menu key jumps into the strip; Left/Right walk it.
|
||||
await user.keyboard('{ContextMenu}');
|
||||
expect(document.activeElement).toBe(within(row).getByTitle('Print'));
|
||||
await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}');
|
||||
expect(document.activeElement).toBe(within(row).getByTitle('File details'));
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
expect(document.activeElement).toBe(within(row).getByTitle('Download'));
|
||||
|
||||
// Escape hands focus back to the pane, so the arrows walk rows again.
|
||||
await user.keyboard('{Escape}');
|
||||
expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await waitFor(() => {
|
||||
expect(columns.getByText('bracket.stl').closest('[title="bracket.stl"]')?.className).toContain('ring-1');
|
||||
});
|
||||
|
||||
// Enter on a strip button activates that button, not the row.
|
||||
await user.keyboard('{Shift>}{F10}{/Shift}');
|
||||
const bracketRow = columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement;
|
||||
expect(document.activeElement?.closest('[data-file-id]')).toBe(bracketRow);
|
||||
within(bracketRow).getByTitle('Rename').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
// .stl is not a split-off extension, so the whole name is editable.
|
||||
expect(await screen.findByDisplayValue('bracket.stl')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the tree\'s folder actions on folder rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
|
||||
const kebab = within(row).getByTitle('Actions');
|
||||
// Unselected row: kebab hidden only for pointers that can hover (#2865).
|
||||
expect(kebab.closest('.flex-shrink-0')!.className).toContain('can-hover:opacity-0');
|
||||
|
||||
await user.click(kebab);
|
||||
expect(within(row).getByRole('button', { name: 'Rename' })).toBeInTheDocument();
|
||||
// Art Projects is linked to a project, so the link entry offers a change.
|
||||
expect(within(row).getByRole('button', { name: 'Change Link...' })).toBeInTheDocument();
|
||||
expect(within(row).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
// Opening the menu did not select the folder.
|
||||
expect(row.className).not.toContain('bg-bambu-green/20');
|
||||
|
||||
await user.click(within(row).getByRole('button', { name: 'Rename' }));
|
||||
expect(await screen.findByDisplayValue('Art Projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the selected folder\'s kebab from the keyboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
await user.keyboard('{ArrowDown}');
|
||||
const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
|
||||
await waitFor(() => expect(row.className).toContain('bg-bambu-green/20'));
|
||||
// The selected row's kebab is the only folder control in the Tab order.
|
||||
expect(within(row).getByTitle('Actions')).toHaveAttribute('tabindex', '0');
|
||||
expect(
|
||||
within(columns.getByText('Functional Parts').closest('[data-folder-id]') as HTMLElement).getByTitle('Actions'),
|
||||
).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
await user.keyboard('{ContextMenu}');
|
||||
expect(within(row).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the folder kebab menu on a second click and keeps it opaque while open', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
|
||||
const kebab = within(row).getByTitle('Actions');
|
||||
const wrapper = kebab.closest('[data-folder-actions]') as HTMLElement;
|
||||
expect(wrapper.className).toContain('can-hover:opacity-0');
|
||||
|
||||
await user.click(kebab);
|
||||
expect(within(row).getByRole('button', { name: 'Rename' })).toBeInTheDocument();
|
||||
// The menu is a descendant of the hover-revealed wrapper: while it is
|
||||
// open the wrapper must not depend on hover/focus to stay visible.
|
||||
expect(wrapper.className).not.toContain('opacity-0');
|
||||
|
||||
await user.click(kebab);
|
||||
expect(within(row).queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument();
|
||||
expect(wrapper.className).toContain('can-hover:opacity-0');
|
||||
});
|
||||
|
||||
it('hands focus back to the pane after the folder kebab', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
await user.keyboard('{ArrowDown}');
|
||||
const artRow = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
|
||||
const functionalRow = columns.getByText('Functional Parts').closest('[data-folder-id]') as HTMLElement;
|
||||
await waitFor(() => expect(artRow.className).toContain('bg-bambu-green/20'));
|
||||
|
||||
// Escape closes the menu and returns focus to the pane, so the arrows
|
||||
// keep walking rows instead of dying on the kebab.
|
||||
await user.keyboard('{ContextMenu}');
|
||||
expect(within(artRow).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(within(artRow).getByTitle('Actions'));
|
||||
await user.keyboard('{Escape}');
|
||||
expect(within(artRow).queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await waitFor(() => expect(functionalRow.className).toContain('bg-bambu-green/20'));
|
||||
await user.keyboard('{ArrowUp}');
|
||||
await waitFor(() => expect(artRow.className).toContain('bg-bambu-green/20'));
|
||||
|
||||
// Down straight out of an open menu closes it and moves the selection.
|
||||
await user.keyboard('{ContextMenu}');
|
||||
expect(within(artRow).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
await user.keyboard('{ArrowDown}');
|
||||
expect(within(artRow).queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(functionalRow.className).toContain('bg-bambu-green/20'));
|
||||
expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
|
||||
|
||||
// Activating an entry with Enter must not strand focus on <body>.
|
||||
await user.keyboard('{ContextMenu}');
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(within(functionalRow).getByRole('button', { name: 'Rename' }));
|
||||
await user.keyboard('{Enter}');
|
||||
expect(await screen.findByDisplayValue('Functional Parts')).toBeInTheDocument();
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
});
|
||||
|
||||
it('does not open the row preview when a strip icon is double-clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
const row = columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement;
|
||||
|
||||
await user.dblClick(within(row).getByTitle('Rename'));
|
||||
expect(await screen.findByDisplayValue('bracket.stl')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('model-viewer-modal')).not.toBeInTheDocument();
|
||||
|
||||
// The row itself still opens the viewer.
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
await user.dblClick(columns.getByText('bracket.stl'));
|
||||
expect(await screen.findByTestId('model-viewer-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Every column is that level's contents, folders AND files. Before this a
|
||||
// folder holding files but no subfolders looked empty until it was the
|
||||
// selection.
|
||||
const descendToBrackets = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
await user.click(columns.getByText('Functional Parts'));
|
||||
await user.click(await columns.findByText('Brackets'));
|
||||
return columns;
|
||||
};
|
||||
|
||||
it('lists an intermediate level\'s folders and its files in the same column', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
|
||||
const level = within(screen.getByTestId('columns-level-folder-1'));
|
||||
expect(level.getByText('Brackets')).toBeInTheDocument();
|
||||
await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
|
||||
|
||||
// The root column lists the files that sit in no folder at all.
|
||||
const root = within(screen.getByTestId('columns-level-root'));
|
||||
await waitFor(() => expect(root.getByText('Benchy')).toBeInTheDocument());
|
||||
expect(root.getByText('Functional Parts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses a file in an intermediate column without moving the folder selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
const pane = within(screen.getByTestId('columns-files-pane'));
|
||||
await waitFor(() => expect(pane.getByText('clamp.stl')).toBeInTheDocument());
|
||||
|
||||
const level = within(screen.getByTestId('columns-level-folder-1'));
|
||||
await user.click(await level.findByText('Spacer'));
|
||||
|
||||
expect(level.getByText('Spacer').closest('[data-file-id]')?.className).toContain('ring-1');
|
||||
// Brackets is still the selection: its own files still fill the pane.
|
||||
expect(pane.getByText('clamp.stl')).toBeInTheDocument();
|
||||
expect(level.getByText('Brackets').closest('[data-folder-id]')?.className).toContain('bg-bambu-green/20');
|
||||
});
|
||||
|
||||
it('opens the preview on double-click from an intermediate column', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
const level = within(screen.getByTestId('columns-level-folder-1'));
|
||||
await user.dblClick(await level.findByText('Spacer'));
|
||||
|
||||
expect(await screen.findByTestId('model-viewer-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a level\'s folders visible while its files are still loading', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/v1/library/files', async ({ request }) => {
|
||||
if (new URL(request.url).searchParams.get('folder_id') === '1') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
return HttpResponse.json(filesForRequest(request));
|
||||
})
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
|
||||
// The column paints its folders straight away and marks the pending file
|
||||
// list instead of blocking.
|
||||
const level = within(screen.getByTestId('columns-level-folder-1'));
|
||||
expect(level.getByText('Brackets')).toBeInTheDocument();
|
||||
expect(level.getByText('…')).toBeInTheDocument();
|
||||
await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('leaves the rightmost pane as a leaf folder\'s contents', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
await user.click(columns.getByText('Art Projects'));
|
||||
|
||||
const pane = within(screen.getByTestId('columns-files-pane'));
|
||||
await waitFor(() => expect(pane.getByText('Vase')).toBeInTheDocument());
|
||||
// A leaf contributes no column of its own.
|
||||
expect(screen.queryByTestId('columns-level-folder-3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('walks from a column\'s last folder into that column\'s files and back', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
const level = within(screen.getByTestId('columns-level-folder-1'));
|
||||
await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
|
||||
screen.getByTestId('columns-view').focus();
|
||||
const spacerRow = () => level.getByText('Spacer').closest('[data-file-id]') as HTMLElement;
|
||||
|
||||
// Brackets is the column's only folder, so the next row down is the
|
||||
// column's first file: the only way a keyboard reaches it at all.
|
||||
await user.keyboard('{ArrowDown}');
|
||||
await waitFor(() => expect(spacerRow().className).toContain('ring-1'));
|
||||
|
||||
// Up steps back onto the folder, which stayed the selection throughout.
|
||||
await user.keyboard('{ArrowUp}');
|
||||
await waitFor(() => expect(spacerRow().className).not.toContain('ring-1'));
|
||||
expect(level.getByText('Brackets').closest('[data-folder-id]')?.className).toContain('bg-bambu-green/20');
|
||||
});
|
||||
|
||||
it('keeps the selection\'s count and actions while the selected folder\'s own pane is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/v1/library/files', ({ request }) =>
|
||||
HttpResponse.json(new URL(request.url).searchParams.get('folder_id') === '2' ? [] : filesForRequest(request))
|
||||
)
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
const level = within(screen.getByTestId('columns-level-folder-1'));
|
||||
await user.click(await level.findByText('Spacer'));
|
||||
|
||||
// Brackets lists nothing, yet a file ticked one column to the left still
|
||||
// gets its count, its bulk actions and a way to clear it.
|
||||
expect(await screen.findByText('1 selected')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Clear'));
|
||||
await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('drops the selection when another folder is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
const level = within(screen.getByTestId('columns-level-folder-1'));
|
||||
await user.click(await level.findByText('Spacer'));
|
||||
expect(await screen.findByText('1 selected')).toBeInTheDocument();
|
||||
|
||||
// Move or Delete must never act on rows that have left the screen.
|
||||
await user.click(within(screen.getByTestId('columns-level-root')).getByText('Art Projects'));
|
||||
await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('lists only the files in no folder beside the root column, like every other level', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
// Grid view at the root is "All Files", which descends into the folders.
|
||||
await waitFor(() => expect(screen.getByText('Spacer')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
|
||||
// The root level's own files, the same ones its column lists once a
|
||||
// folder is selected — nothing from inside a folder.
|
||||
const pane = within(await screen.findByTestId('columns-files-pane'));
|
||||
await waitFor(() => expect(pane.getByText('Benchy')).toBeInTheDocument());
|
||||
expect(pane.queryByText('Spacer')).not.toBeInTheDocument();
|
||||
expect(pane.queryByText('Vase')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says the root has no files outside folders, not that the library is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Every file sits in a folder: the root level's own list is empty.
|
||||
server.use(
|
||||
http.get('/api/v1/library/files', ({ request }) =>
|
||||
HttpResponse.json(
|
||||
new URL(request.url).searchParams.get('include_root') === 'true'
|
||||
? []
|
||||
: Object.values(mockFolderFiles).flat()
|
||||
)
|
||||
)
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Spacer')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
|
||||
const pane = within(await screen.findByTestId('columns-files-pane'));
|
||||
expect(await pane.findByText('No files outside folders')).toBeInTheDocument();
|
||||
expect(pane.queryByText('No files yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the toolbar actions for files ticked in an earlier column', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
const root = within(screen.getByTestId('columns-level-root'));
|
||||
await user.click(await root.findByText('Benchy'));
|
||||
|
||||
// One sliced file ticked in the root column, while the pane lists
|
||||
// Brackets: it can still be previewed and printed.
|
||||
const toolbar = () => screen.getByText(/\d selected/).closest('div') as HTMLElement;
|
||||
expect(await screen.findByText('1 selected')).toBeInTheDocument();
|
||||
expect(within(toolbar()).getByRole('button', { name: 'Preview' })).toBeInTheDocument();
|
||||
expect(within(toolbar()).getByRole('button', { name: 'Print' })).toBeInTheDocument();
|
||||
|
||||
// A second one makes it one job for either file.
|
||||
await user.click(root.getByText('Cube'));
|
||||
expect(await within(toolbar()).findByRole('button', { name: 'Print (2 alternatives)' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers Combine to 3MF for STLs ticked across columns', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
// bracket.stl sits in the root column, clamp.stl in the Brackets pane.
|
||||
await user.click(await within(screen.getByTestId('columns-level-root')).findByText('bracket.stl'));
|
||||
await user.click(await within(screen.getByTestId('columns-files-pane')).findByText('clamp.stl'));
|
||||
|
||||
expect(await screen.findByText('2 selected')).toBeInTheDocument();
|
||||
const toolbar = screen.getByText('2 selected').closest('div') as HTMLElement;
|
||||
expect(within(toolbar).getByRole('button', { name: 'Combine to 3MF' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers Select All until every file of the pane is ticked, and keeps other ticks', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await descendToBrackets(user);
|
||||
await user.click(await within(screen.getByTestId('columns-level-folder-1')).findByText('Spacer'));
|
||||
await user.click(await within(screen.getByTestId('columns-files-pane')).findByText('clamp.stl'));
|
||||
|
||||
// Two ticked, but only one of the pane's two files.
|
||||
expect(await screen.findByText('2 selected')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select All')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Select All'));
|
||||
|
||||
expect(await screen.findByText('3 selected')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deselect All')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a document\'s type icon in place of a missing thumbnail', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/v1/library/files', ({ request }) =>
|
||||
HttpResponse.json(
|
||||
new URL(request.url).searchParams.get('folder_id')
|
||||
? filesForRequest(request)
|
||||
: [...mockFiles, { ...mockFiles[0], id: 99, filename: 'manual.pdf', file_type: 'pdf', print_name: null, thumbnail_path: null }]
|
||||
)
|
||||
)
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const pane = within(screen.getByTestId('columns-files-pane'));
|
||||
const pdfRow = (await pane.findByText('manual.pdf')).closest('[data-file-id]') as HTMLElement;
|
||||
// The thumbnail slot, not the action strip (whose preview button uses
|
||||
// the same icon for a PDF).
|
||||
const thumbnailSlot = pdfRow.querySelector('.w-10.h-10') as HTMLElement;
|
||||
expect(thumbnailSlot.querySelector('.lucide-file-text')).not.toBeNull();
|
||||
expect(thumbnailSlot.querySelector('.lucide-file-box')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search and filter', () => {
|
||||
@@ -422,6 +1165,26 @@ describe('FileManagerPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Select All replaces a selection the search has hidden in the grid', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
|
||||
|
||||
const benchyCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
|
||||
expect(benchyCard).not.toBeNull();
|
||||
await user.click(benchyCard!);
|
||||
expect(await screen.findByText('1 selected')).toBeInTheDocument();
|
||||
|
||||
// Benchy leaves the screen; Select All must not keep it ticked, or a
|
||||
// bulk Delete would remove a file nobody can see.
|
||||
await user.type(screen.getByPlaceholderText('Search files...'), 'vase');
|
||||
await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument());
|
||||
await user.click(screen.getByText('Select All'));
|
||||
|
||||
expect(await screen.findByText('1 selected')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deselect All')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bulk actions when files selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
@@ -254,6 +254,46 @@ describe('FileManagerPage preview opening', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the columns view (#3020)', () => {
|
||||
beforeEach(() => {
|
||||
(localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
|
||||
key === 'library-view-mode' ? 'columns' : null,
|
||||
);
|
||||
});
|
||||
|
||||
const columnsRow = async (name: string) =>
|
||||
(await within(screen.getByTestId('columns-files-pane')).findByText(name)).closest('[data-file-id]') as HTMLElement;
|
||||
|
||||
it('opens the document preview for a PDF on double-click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await user.dblClick(await columnsRow('drawing.pdf'));
|
||||
|
||||
expect(await screen.findByTestId('pdf-preview-modal')).toHaveTextContent('drawing.pdf');
|
||||
});
|
||||
|
||||
it('opens the focused file\'s preview on Enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await user.click(await columnsRow('parts.csv'));
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(await screen.findByTestId('sheet-preview-modal')).toHaveTextContent('parts.csv');
|
||||
});
|
||||
|
||||
it('does nothing for a file with no preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FileManagerPage />);
|
||||
|
||||
await user.dblClick(await columnsRow('notes.md'));
|
||||
|
||||
await expectNoPreviewOpened();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the toolbar Preview button', () => {
|
||||
it('appears for a single previewable selection and opens the preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -132,5 +132,47 @@ describe('actions that were hover-only (#2865)', () => {
|
||||
expect(actions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
|
||||
expect(actions.className).toContain('can-hover:opacity-0');
|
||||
});
|
||||
|
||||
// #3020 follow-up: the columns view reveals its folder kebab and its
|
||||
// per-file icon strip on hover, under the same rule.
|
||||
it('does not hide the columns view folder and file actions from a pointer that cannot hover', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/library/files', () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
id: 1,
|
||||
filename: 'benchy.gcode.3mf',
|
||||
file_path: '/library/benchy.gcode.3mf',
|
||||
file_size: 1048576,
|
||||
file_type: 'gcode.3mf',
|
||||
folder_id: null,
|
||||
thumbnail_path: null,
|
||||
print_name: 'Benchy',
|
||||
print_time_seconds: 3600,
|
||||
print_count: 0,
|
||||
duplicate_count: 0,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
render(<FileManagerPage />);
|
||||
await waitFor(() => expect(screen.getByText('Brackets')).toBeInTheDocument());
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTitle('Column view'));
|
||||
const columns = within(screen.getByTestId('columns-view'));
|
||||
|
||||
const folderKebab = within(columns.getByText('Brackets').closest('[data-folder-id]') as HTMLElement).getByTitle('Actions');
|
||||
const folderActions = folderKebab.closest('div.flex-shrink-0')!;
|
||||
expect(folderActions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
|
||||
expect(folderActions.className).toContain('can-hover:opacity-0');
|
||||
|
||||
const fileRow = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
|
||||
const fileActions = fileRow.querySelector('[data-file-actions]')!.parentElement!;
|
||||
expect(fileActions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
|
||||
expect(fileActions.className).toContain('can-hover:opacity-0');
|
||||
expect(within(fileRow).getByTitle('Rename')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ interface ContextMenuProps {
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose: () => void;
|
||||
// The element that opened the menu. A mousedown on it is not "outside":
|
||||
// without this the anchor's click handler runs after the menu has already
|
||||
// been dismissed and cannot act as a close toggle.
|
||||
anchorRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
interface SubmenuPanelProps {
|
||||
@@ -114,7 +118,7 @@ function SubmenuPanel({
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
export function ContextMenu({ x, y, items, onClose, anchorRef }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
|
||||
const submenuTimeoutRef = useRef<number | null>(null);
|
||||
@@ -124,9 +128,9 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
const target = e.target as Node;
|
||||
if (menuRef.current?.contains(target) || anchorRef?.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
@@ -156,7 +160,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
}, [onClose, anchorRef]);
|
||||
|
||||
// Adjust position to keep menu in viewport - use useLayoutEffect for synchronous measurement
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -4002,6 +4002,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Vorschaubilder für STL- und PDF-Dateien ohne Vorschau generieren',
|
||||
gridView: 'Rasteransicht',
|
||||
listView: 'Listenansicht',
|
||||
columnsView: 'Spaltenansicht',
|
||||
lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',
|
||||
lowDiskSpaceDetails: 'Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.',
|
||||
files: 'Dateien',
|
||||
@@ -4084,6 +4085,7 @@ export default {
|
||||
adding: 'Hinzufügen...',
|
||||
loadingFiles: 'Dateien werden geladen...',
|
||||
folderIsEmpty: 'Ordner ist leer',
|
||||
noFilesOutsideFolders: 'Keine Dateien außerhalb von Ordnern',
|
||||
noFilesYet: 'Noch keine Dateien',
|
||||
folderEmptyDescription: 'Laden Sie Dateien hoch oder verschieben Sie Dateien in diesen Ordner.',
|
||||
noFilesDescription: 'Laden Sie Dateien hoch, um Ihre Druckdateien zu organisieren.',
|
||||
|
||||
@@ -4032,6 +4032,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Generate thumbnails for STL and PDF files missing them',
|
||||
gridView: 'Grid view',
|
||||
listView: 'List view',
|
||||
columnsView: 'Column view',
|
||||
lowDiskSpaceWarning: 'Low disk space warning',
|
||||
lowDiskSpaceDetails: 'Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.',
|
||||
files: 'Files',
|
||||
@@ -4114,6 +4115,7 @@ export default {
|
||||
adding: 'Adding...',
|
||||
loadingFiles: 'Loading files...',
|
||||
folderIsEmpty: 'Folder is empty',
|
||||
noFilesOutsideFolders: 'No files outside folders',
|
||||
noFilesYet: 'No files yet',
|
||||
folderEmptyDescription: 'Upload files or move files into this folder to get started.',
|
||||
noFilesDescription: 'Upload files to start organizing your print-related files.',
|
||||
|
||||
@@ -4004,6 +4004,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Generar miniaturas para los archivos STL y PDF que no las tienen',
|
||||
gridView: 'Vista de cuadrícula',
|
||||
listView: 'Vista de lista',
|
||||
columnsView: 'Vista de columnas',
|
||||
lowDiskSpaceWarning: 'Advertencia de poco espacio en disco',
|
||||
lowDiskSpaceDetails: 'Solo {{free}} libres de {{total}} en total. El umbral está establecido en {{threshold}} GB en los ajustes.',
|
||||
files: 'Archivos',
|
||||
@@ -4086,6 +4087,7 @@ export default {
|
||||
adding: 'Añadiendo...',
|
||||
loadingFiles: 'Cargando archivos...',
|
||||
folderIsEmpty: 'La carpeta está vacía',
|
||||
noFilesOutsideFolders: 'No hay archivos fuera de las carpetas',
|
||||
noFilesYet: 'Aún no hay archivos',
|
||||
folderEmptyDescription: 'Suba archivos o mueva archivos a esta carpeta para empezar.',
|
||||
noFilesDescription: 'Suba archivos para empezar a organizar sus archivos relacionados con la impresión.',
|
||||
|
||||
@@ -3990,6 +3990,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Vignettes STL et PDF manquantes',
|
||||
gridView: 'Grille',
|
||||
listView: 'Liste',
|
||||
columnsView: 'Colonnes',
|
||||
lowDiskSpaceWarning: 'Espace disque faible',
|
||||
lowDiskSpaceDetails: '{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.',
|
||||
files: 'Fichiers',
|
||||
@@ -4072,6 +4073,7 @@ export default {
|
||||
adding: 'Ajout...',
|
||||
loadingFiles: 'Chargement...',
|
||||
folderIsEmpty: 'Dossier vide',
|
||||
noFilesOutsideFolders: 'Aucun fichier hors des dossiers',
|
||||
noFilesYet: 'Aucun fichier',
|
||||
folderEmptyDescription: 'Téléversez ou déplacez des fichiers ici.',
|
||||
noFilesDescription: 'Téléversez des fichiers pour organiser.',
|
||||
|
||||
@@ -3989,6 +3989,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Genera miniature per STL e PDF senza miniatura',
|
||||
gridView: 'Vista griglia',
|
||||
listView: 'Vista elenco',
|
||||
columnsView: 'Vista a colonne',
|
||||
lowDiskSpaceWarning: 'Avviso spazio disco basso',
|
||||
lowDiskSpaceDetails: 'Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.',
|
||||
files: 'File',
|
||||
@@ -4071,6 +4072,7 @@ export default {
|
||||
adding: 'Aggiunta...',
|
||||
loadingFiles: 'Caricamento file...',
|
||||
folderIsEmpty: 'La cartella e vuota',
|
||||
noFilesOutsideFolders: 'Nessun file fuori dalle cartelle',
|
||||
noFilesYet: 'Nessun file ancora',
|
||||
folderEmptyDescription: 'Carica file o sposta file in questa cartella per iniziare.',
|
||||
noFilesDescription: 'Carica file per iniziare a organizzare i file di stampa.',
|
||||
|
||||
@@ -4002,6 +4002,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'サムネイルのないSTL・PDFファイルのサムネイルを生成',
|
||||
gridView: 'グリッド表示',
|
||||
listView: 'リスト表示',
|
||||
columnsView: 'カラム表示',
|
||||
lowDiskSpaceWarning: 'ディスク容量不足の警告',
|
||||
lowDiskSpaceDetails: '{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。',
|
||||
files: 'ファイル',
|
||||
@@ -4084,6 +4085,7 @@ export default {
|
||||
adding: '追加中...',
|
||||
loadingFiles: 'ファイルを読み込み中...',
|
||||
folderIsEmpty: 'フォルダーは空です',
|
||||
noFilesOutsideFolders: 'フォルダー外のファイルはありません',
|
||||
noFilesYet: 'ファイルはまだありません',
|
||||
folderEmptyDescription: 'ファイルをアップロードするか、このフォルダーにファイルを移動して開始しましょう。',
|
||||
noFilesDescription: '印刷関連ファイルの整理を始めるにはファイルをアップロードしてください。',
|
||||
|
||||
@@ -3811,6 +3811,7 @@ export default {
|
||||
generateThumbnailsForMissing: '썸네일이 없는 STL 및 PDF 파일의 썸네일 생성',
|
||||
gridView: '격자 보기',
|
||||
listView: '목록 보기',
|
||||
columnsView: '열 보기',
|
||||
lowDiskSpaceWarning: '디스크 공간 부족 경고',
|
||||
lowDiskSpaceDetails: '전체 {{total}} 중 {{free}}만 남음. 임계값은 설정에서 {{threshold}}GB로 설정됩니다.',
|
||||
files: '파일',
|
||||
@@ -3893,6 +3894,7 @@ export default {
|
||||
adding: '추가 중...',
|
||||
loadingFiles: '파일 불러오는 중...',
|
||||
folderIsEmpty: '폴더가 비어 있음',
|
||||
noFilesOutsideFolders: '폴더 밖에 있는 파일 없음',
|
||||
noFilesYet: '파일 없음',
|
||||
folderEmptyDescription: '파일을 업로드하거나 이 폴더로 이동하여 시작하세요.',
|
||||
noFilesDescription: '파일을 업로드하여 인쇄 관련 파일을 정리하세요.',
|
||||
|
||||
@@ -4032,6 +4032,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Miniaturen genereren voor STL- en PDF-bestanden die ze missen',
|
||||
gridView: 'Rasterweergave',
|
||||
listView: 'Lijstweergave',
|
||||
columnsView: 'Kolomweergave',
|
||||
lowDiskSpaceWarning: 'Waarschuwing weinig schijfruimte',
|
||||
lowDiskSpaceDetails: 'Slechts {{free}} vrij van {{total}} totaal. De drempel is in de instellingen ingesteld op {{threshold}} GB.',
|
||||
files: 'Bestanden',
|
||||
@@ -4114,6 +4115,7 @@ export default {
|
||||
adding: 'Toevoegen...',
|
||||
loadingFiles: 'Bestanden laden...',
|
||||
folderIsEmpty: 'Map is leeg',
|
||||
noFilesOutsideFolders: 'Geen bestanden buiten mappen',
|
||||
noFilesYet: 'Nog geen bestanden',
|
||||
folderEmptyDescription: 'Upload bestanden of verplaats bestanden naar deze map om te beginnen.',
|
||||
noFilesDescription: 'Upload bestanden om je afdrukgerelateerde bestanden te organiseren.',
|
||||
|
||||
@@ -3989,6 +3989,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL e PDF que não possuem',
|
||||
gridView: 'Visualização em grade',
|
||||
listView: 'Visualização em lista',
|
||||
columnsView: 'Visualização em colunas',
|
||||
lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
|
||||
lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
|
||||
files: 'Arquivos',
|
||||
@@ -4071,6 +4072,7 @@ export default {
|
||||
adding: 'Adicionando...',
|
||||
loadingFiles: 'Carregando arquivos...',
|
||||
folderIsEmpty: 'A pasta está vazia',
|
||||
noFilesOutsideFolders: 'Nenhum arquivo fora das pastas',
|
||||
noFilesYet: 'Nenhum arquivo ainda',
|
||||
folderEmptyDescription: 'Envie arquivos ou mova arquivos para esta pasta para começar.',
|
||||
noFilesDescription: 'Envie arquivos para começar a organizar seus arquivos relacionados à impressão.',
|
||||
|
||||
@@ -3803,6 +3803,7 @@ export default {
|
||||
generateThumbnailsForMissing: "Создать отсутствующие миниатюры для STL- и PDF-файлов",
|
||||
gridView: "Плитка",
|
||||
listView: "Список",
|
||||
columnsView: "Колонки",
|
||||
lowDiskSpaceWarning: "Мало свободного места",
|
||||
lowDiskSpaceDetails: "Свободно только {{free}} из {{total}}. Порог предупреждения в настройках: {{threshold}} ГБ.",
|
||||
files: "Файлы",
|
||||
@@ -3885,6 +3886,7 @@ export default {
|
||||
adding: "Добавление...",
|
||||
loadingFiles: "Загрузка файлов...",
|
||||
folderIsEmpty: "Папка пуста",
|
||||
noFilesOutsideFolders: "Нет файлов вне папок",
|
||||
noFilesYet: "Файлов пока нет",
|
||||
folderEmptyDescription: "Загрузите или переместите файлы в эту папку.",
|
||||
noFilesDescription: "Загрузите файлы, чтобы начать упорядочивать материалы для печати.",
|
||||
|
||||
@@ -4031,6 +4031,7 @@ errors: {
|
||||
generateThumbnailsForMissing: 'Generera minibilder för STL- och PDF-filer som saknar dem',
|
||||
gridView: 'Rutnätsvy',
|
||||
listView: 'Listvy',
|
||||
columnsView: 'Kolumnvy',
|
||||
lowDiskSpaceWarning: 'Varning för lågt diskutrymme',
|
||||
lowDiskSpaceDetails: 'Endast {{free}} ledigt av {{total}} totalt. Tröskelvärdet är inställt på {{threshold}} GB i inställningar.',
|
||||
files: 'Filer',
|
||||
@@ -4113,6 +4114,7 @@ errors: {
|
||||
adding: 'Lägger till...',
|
||||
loadingFiles: 'Laddar filer...',
|
||||
folderIsEmpty: 'Mappen är tom',
|
||||
noFilesOutsideFolders: 'Inga filer utanför mappar',
|
||||
noFilesYet: 'Inga filer än',
|
||||
folderEmptyDescription: 'Ladda upp filer eller flytta filer till denna mapp för att komma igång.',
|
||||
noFilesDescription: 'Ladda upp filer för att börja organisera dina utskriftsrelaterade filer.',
|
||||
|
||||
@@ -3996,6 +3996,7 @@ export default {
|
||||
generateThumbnailsForMissing: 'Eksik olan STL ve PDF dosyaları için küçük resimler oluştur',
|
||||
gridView: 'Izgara görünümü',
|
||||
listView: 'Liste görünümü',
|
||||
columnsView: 'Sütun görünümü',
|
||||
lowDiskSpaceWarning: 'Düşük disk alanı uyarısı',
|
||||
lowDiskSpaceDetails: 'Toplam {{total}} alandan yalnızca {{free}} boş. Eşik ayarlarda {{threshold}} GB olarak ayarlanmış.',
|
||||
files: 'Dosyalar',
|
||||
@@ -4078,6 +4079,7 @@ export default {
|
||||
adding: 'Ekleniyor...',
|
||||
loadingFiles: 'Dosyalar yükleniyor...',
|
||||
folderIsEmpty: 'Klasör boş',
|
||||
noFilesOutsideFolders: 'Klasör dışında dosya yok',
|
||||
noFilesYet: 'Henüz dosya yok',
|
||||
folderEmptyDescription: 'Başlamak için bu klasöre dosya yükleyin veya dosyaları taşıyın.',
|
||||
noFilesDescription: 'Baskıyla ilgili dosyalarınızı organize etmeye başlamak için dosya yükleyin.',
|
||||
|
||||
@@ -4029,6 +4029,7 @@ export default {
|
||||
generateThumbnailsForMissing: "Створити відсутні мініатюри файлів STL і PDF",
|
||||
gridView: "Вигляд сітки",
|
||||
listView: "Перегляд списку",
|
||||
columnsView: "Вигляд колонок",
|
||||
lowDiskSpaceWarning: "Попередження про брак місця на диску",
|
||||
lowDiskSpaceDetails: "Вільно лише {{free}} із {{total}}. У налаштуваннях задано поріг {{threshold}} ГБ.",
|
||||
files: "Файли",
|
||||
@@ -4111,6 +4112,7 @@ export default {
|
||||
adding: "Додавання...",
|
||||
loadingFiles: "Завантаження файлів...",
|
||||
folderIsEmpty: "Папка порожня",
|
||||
noFilesOutsideFolders: "Немає файлів поза папками",
|
||||
noFilesYet: "Файлів ще немає",
|
||||
folderEmptyDescription: "Щоб почати, вивантажте файли або перемістіть їх до цієї папки.",
|
||||
noFilesDescription: "Вивантажте файли, щоб упорядковувати матеріали для друку.",
|
||||
|
||||
@@ -3989,6 +3989,7 @@ export default {
|
||||
generateThumbnailsForMissing: '为缺少缩略图的 STL 和 PDF 文件生成缩略图',
|
||||
gridView: '网格视图',
|
||||
listView: '列表视图',
|
||||
columnsView: '分栏视图',
|
||||
lowDiskSpaceWarning: '磁盘空间不足警告',
|
||||
lowDiskSpaceDetails: '仅剩 {{free}}(总共 {{total}})。阈值设置为 {{threshold}} GB。',
|
||||
files: '文件',
|
||||
@@ -4071,6 +4072,7 @@ export default {
|
||||
adding: '添加中...',
|
||||
loadingFiles: '加载文件中...',
|
||||
folderIsEmpty: '文件夹为空',
|
||||
noFilesOutsideFolders: '文件夹外没有文件',
|
||||
noFilesYet: '暂无文件',
|
||||
folderEmptyDescription: '上传文件或将文件移入此文件夹以开始使用。',
|
||||
noFilesDescription: '上传文件以开始组织您的打印相关文件。',
|
||||
|
||||
@@ -3989,6 +3989,7 @@ export default {
|
||||
generateThumbnailsForMissing: '為缺少縮圖的 STL 和 PDF 檔案產生縮圖',
|
||||
gridView: '網格檢視',
|
||||
listView: '列表檢視',
|
||||
columnsView: '分欄檢視',
|
||||
lowDiskSpaceWarning: '磁碟空間不足警告',
|
||||
lowDiskSpaceDetails: '僅剩 {{free}}(總共 {{total}})。閾值設定為 {{threshold}} GB。',
|
||||
files: '檔案',
|
||||
@@ -4071,6 +4072,7 @@ export default {
|
||||
adding: '新增中...',
|
||||
loadingFiles: '載入檔案中...',
|
||||
folderIsEmpty: '資料夾為空',
|
||||
noFilesOutsideFolders: '資料夾外沒有檔案',
|
||||
noFilesYet: '尚無檔案',
|
||||
folderEmptyDescription: '上傳檔案或將檔案移入此資料夾以開始使用。',
|
||||
noFilesDescription: '上傳檔案以開始組織您的列印相關檔案。',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -12,6 +12,7 @@ export function ExternalLinkPage() {
|
||||
const { mode } = useTheme();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const sendTheme = useThemeMessenger(iframeRef);
|
||||
useNavigateMessages(iframeRef);
|
||||
|
||||
const { data: link, isLoading, error } = useQuery({
|
||||
queryKey: ['external-link', id],
|
||||
@@ -53,7 +54,9 @@ export function ExternalLinkPage() {
|
||||
* Tell the framed page which theme Bambuddy is showing, so an app built to
|
||||
* sit in the sidebar (Bambuddy Orders) can match it. Sent when the page
|
||||
* loads, when the theme changes, and when the page asks for it. Only to the
|
||||
* link's own origin, and it carries nothing but the theme names.
|
||||
* link's own origin, and it carries nothing but the theme names, plus
|
||||
* ``canNavigate``: this Bambuddy takes the page's navigate requests (see
|
||||
* useNavigateMessages), so the page can tell it apart from older builds.
|
||||
*/
|
||||
function useThemeMessenger(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
||||
const { resolvedMode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent } = useTheme();
|
||||
@@ -71,7 +74,10 @@ function useThemeMessenger(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
frame.contentWindow.postMessage({ type: 'bambuddy:theme', mode: resolvedMode, style, background, accent }, origin);
|
||||
frame.contentWindow.postMessage(
|
||||
{ type: 'bambuddy:theme', mode: resolvedMode, style, background, accent, canNavigate: true },
|
||||
origin,
|
||||
);
|
||||
}, [iframeRef, resolvedMode, style, background, accent]);
|
||||
|
||||
useEffect(() => sendTheme(), [sendTheme]);
|
||||
@@ -86,3 +92,57 @@ function useThemeMessenger(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
||||
|
||||
return sendTheme;
|
||||
}
|
||||
|
||||
/** Longest in-app path a framed page may ask for; real ones are far shorter. */
|
||||
const MAX_NAVIGATE_PATH = 512;
|
||||
|
||||
/**
|
||||
* Let the framed page open a Bambuddy page in place, e.g. Bambuddy Orders'
|
||||
* "In Bambuddy" link to a batch. The sandbox rightly doesn't let the frame
|
||||
* navigate the top window itself, so it asks:
|
||||
* ``{type: 'bambuddy:navigate', path: '/queue?batch=12'}``.
|
||||
*
|
||||
* Accepted only from the frame, only from the link's own origin, and only for
|
||||
* a path inside Bambuddy: anything else is ignored, so the request can never
|
||||
* lead anywhere a typed address bar couldn't. The theme message's
|
||||
* ``canNavigate`` tells the page it can ask; without it the page opens a new
|
||||
* tab instead.
|
||||
*/
|
||||
function useNavigateMessages(iframeRef: RefObject<HTMLIFrameElement | null>) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
const frame = iframeRef.current;
|
||||
if (!frame?.contentWindow || e.source !== frame.contentWindow) return;
|
||||
if (e.data?.type !== 'bambuddy:navigate') return;
|
||||
let origin: string;
|
||||
try {
|
||||
origin = new URL(frame.src).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (e.origin !== origin) return;
|
||||
const target = inAppPath(e.data.path);
|
||||
if (target === null) return;
|
||||
navigate(target);
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [iframeRef, navigate]);
|
||||
}
|
||||
|
||||
/** The path, query and hash of a same-origin path, or null for anything else. */
|
||||
function inAppPath(path: unknown): string | null {
|
||||
if (typeof path !== 'string' || path.length > MAX_NAVIGATE_PATH) return null;
|
||||
// "//host" and "/\\host" are other sites to a browser; only a plain absolute path is in-app.
|
||||
if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(path, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
return url.pathname + url.search + url.hash;
|
||||
}
|
||||
|
||||
+1095
-341
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{a as t,c as n,d as r,g as i,i as a,l as o,n as s,o as c,r as l,s as u,t as d,u as f,v as p,y as m}from"./index-DCfEMLCI.js";var h=e(m(),1),g=i(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:i,fileSize:m,onClose:_}){let{t:v}=p(),T=d(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let n=!1,r=null;if(j(null),N(!1),F(null),q(1,C),m>52428800){F(v(`fileManager.preview.tooLarge`,{size:a(m)}));return}let i={},o=c();return o&&(i.Authorization=`Bearer ${o}`),(async()=>{let a=await fetch(t.getLibraryFileDownloadUrl(e),{headers:i});if(!a.ok)throw Error(`HTTP ${a.status}`);let o=await a.blob();n||(r=URL.createObjectURL(o),j(r))})().catch(e=>{console.error(`[image-preview] load failed`,e),n||F(v(`fileManager.preview.error`))}),()=>{n=!0,r&&URL.revokeObjectURL(r)}},[e,m,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(s,{title:i,fullscreen:T,onClose:_,icon:(0,g.jsx)(r,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:l,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(u,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:l,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(n,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:l,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(o,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:i,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(f,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});export{l as i,s as n,u as r,o as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
var e=`/assets/pdf.worker.min-0idpi3xP.js`;export{e as default};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
àRCopyright 1990-2009 Adobe Systems Incorporated.
|
||||
All rights reserved.
|
||||
See ./LICENSEáCNS2-H
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
àRCopyright 1990-2009 Adobe Systems Incorporated.
|
||||
All rights reserved.
|
||||
See ./LICENSEá ETen-B5-H` ^
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user