FileManagerPage.test.tsx 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356
  1. /**
  2. * Tests for the FileManagerPage component.
  3. */
  4. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  5. import { screen, waitFor, within } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { FileManagerPage } from '../../pages/FileManagerPage';
  9. import { openInSlicer } from '../../utils/slicer';
  10. import { setAuthToken } from '../../api/client';
  11. import { http, HttpResponse } from 'msw';
  12. import { server } from '../mocks/server';
  13. // The page reads its settings from /settings, or from /settings/ui-flags for a
  14. // user who may not read /settings, as in the permission tests below.
  15. const settingsHandlers = (body: Record<string, unknown>) => [
  16. http.get('/api/v1/settings/', () => HttpResponse.json(body)),
  17. http.get('/api/v1/settings/ui-flags', () => HttpResponse.json(body)),
  18. http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({})),
  19. ];
  20. // Only the protocol-handler launch is stubbed — it would navigate the jsdom
  21. // window. Everything else in the module is a pure predicate, so keep the real
  22. // implementations: isSliceableFilename decides which rows even offer the
  23. // action these tests click.
  24. vi.mock('../../utils/slicer', async (importOriginal) => ({
  25. ...(await importOriginal<typeof import('../../utils/slicer')>()),
  26. openInSlicer: vi.fn(),
  27. }));
  28. vi.mock('../../components/SliceModal', () => ({
  29. SliceModal: ({ source }: { source: { filename: string } }) => (
  30. <div data-testid="slice-modal">{source.filename}</div>
  31. ),
  32. }));
  33. // The viewer pulls in three.js; the tests only care whether it opened.
  34. vi.mock('../../components/ModelViewerModal', () => ({
  35. ModelViewerModal: ({ title }: { title: string }) => <div data-testid="model-viewer-modal">{title}</div>,
  36. }));
  37. // Mock data
  38. const mockFolders = [
  39. {
  40. id: 1,
  41. name: 'Functional Parts',
  42. parent_id: null,
  43. file_count: 5,
  44. project_id: null,
  45. archive_id: null,
  46. project_name: null,
  47. archive_name: null,
  48. // #2680: distinctive year so the folder-pane display test can assert on it
  49. // without colliding with the file mtimes below.
  50. latest_activity_at: '2031-04-05T10:00:00Z',
  51. children: [
  52. {
  53. id: 2,
  54. name: 'Brackets',
  55. parent_id: 1,
  56. file_count: 3,
  57. project_id: null,
  58. archive_id: null,
  59. project_name: null,
  60. archive_name: null,
  61. latest_activity_at: '2032-06-07T10:00:00Z',
  62. children: [],
  63. },
  64. ],
  65. },
  66. {
  67. id: 3,
  68. name: 'Art Projects',
  69. parent_id: null,
  70. file_count: 2,
  71. project_id: 1,
  72. archive_id: null,
  73. project_name: 'My Art Project',
  74. archive_name: null,
  75. // No activity timestamp — must render no date line rather than an
  76. // "Invalid Date" placeholder.
  77. latest_activity_at: null,
  78. children: [],
  79. },
  80. ];
  81. const mockFiles = [
  82. {
  83. id: 1,
  84. filename: 'benchy.gcode.3mf',
  85. file_path: '/library/benchy.gcode.3mf',
  86. file_size: 1048576,
  87. file_type: '3mf',
  88. folder_id: null,
  89. thumbnail_path: '/thumbnails/1.png',
  90. print_name: 'Benchy',
  91. print_time_seconds: 3600,
  92. print_count: 5,
  93. duplicate_count: 0,
  94. created_at: '2024-01-01T00:00:00Z',
  95. // #2680: real on-disk mtime in a distinctive year so the display test can
  96. // prove fs_modified_at is preferred over created_at (2024).
  97. fs_modified_at: '2030-06-15T12:00:00Z',
  98. },
  99. {
  100. id: 2,
  101. filename: 'bracket.stl',
  102. file_path: '/library/bracket.stl',
  103. file_size: 524288,
  104. file_type: 'stl',
  105. folder_id: null,
  106. thumbnail_path: null,
  107. print_name: null,
  108. print_time_seconds: null,
  109. print_count: 0,
  110. duplicate_count: 2,
  111. created_at: '2024-01-02T00:00:00Z',
  112. },
  113. {
  114. id: 3,
  115. filename: 'cube.gcode.3mf',
  116. file_path: '/library/cube.gcode.3mf',
  117. file_size: 2048576,
  118. file_type: '3mf',
  119. folder_id: null,
  120. thumbnail_path: '/thumbnails/3.png',
  121. print_name: 'Cube',
  122. print_time_seconds: 1800,
  123. print_count: 2,
  124. duplicate_count: 0,
  125. created_at: '2024-01-03T00:00:00Z',
  126. },
  127. ];
  128. // Per-folder contents. The columns view lists every level's files, so each
  129. // folder needs its own set — otherwise the same name shows up in two columns
  130. // and the queries below cannot tell them apart.
  131. const mockFolderFiles: Record<string, Record<string, unknown>[]> = {
  132. '1': [
  133. {
  134. id: 11,
  135. filename: 'spacer.3mf',
  136. file_path: '/library/functional/spacer.3mf',
  137. file_size: 131072,
  138. file_type: '3mf',
  139. folder_id: 1,
  140. thumbnail_path: null,
  141. print_name: 'Spacer',
  142. print_time_seconds: 900,
  143. print_count: 0,
  144. duplicate_count: 0,
  145. created_at: '2024-02-01T00:00:00Z',
  146. },
  147. ],
  148. '2': [
  149. {
  150. id: 12,
  151. filename: 'clamp.stl',
  152. file_path: '/library/functional/brackets/clamp.stl',
  153. file_size: 65536,
  154. file_type: 'stl',
  155. folder_id: 2,
  156. thumbnail_path: null,
  157. print_name: null,
  158. print_time_seconds: null,
  159. print_count: 0,
  160. duplicate_count: 0,
  161. created_at: '2024-02-02T00:00:00Z',
  162. },
  163. {
  164. id: 13,
  165. filename: 'hinge.3mf',
  166. file_path: '/library/functional/brackets/hinge.3mf',
  167. file_size: 98304,
  168. file_type: '3mf',
  169. folder_id: 2,
  170. thumbnail_path: null,
  171. print_name: 'Hinge',
  172. print_time_seconds: 1200,
  173. print_count: 1,
  174. duplicate_count: 0,
  175. created_at: '2024-02-03T00:00:00Z',
  176. },
  177. ],
  178. '3': [
  179. {
  180. id: 14,
  181. filename: 'vase.3mf',
  182. file_path: '/library/art/vase.3mf',
  183. file_size: 262144,
  184. file_type: '3mf',
  185. folder_id: 3,
  186. thumbnail_path: null,
  187. print_name: 'Vase',
  188. print_time_seconds: 5400,
  189. print_count: 0,
  190. duplicate_count: 0,
  191. created_at: '2024-02-04T00:00:00Z',
  192. },
  193. ],
  194. };
  195. // A request carrying folder_id wants that folder's own files. Without one,
  196. // include_root decides as the server does: true is the root level (the files in
  197. // no folder), false the whole library ("All Files", every folder included).
  198. const filesForRequest = (request: Request) => {
  199. const params = new URL(request.url).searchParams;
  200. const folderId = params.get('folder_id');
  201. if (folderId) return mockFolderFiles[folderId] ?? [];
  202. return params.get('include_root') === 'true' ? mockFiles : [...mockFiles, ...Object.values(mockFolderFiles).flat()];
  203. };
  204. const mockStats = {
  205. total_files: 10,
  206. total_folders: 3,
  207. total_size_bytes: 104857600,
  208. disk_free_bytes: 10737418240,
  209. disk_total_bytes: 107374182400,
  210. };
  211. describe('FileManagerPage', () => {
  212. beforeEach(() => {
  213. // Clear localStorage to ensure consistent view mode
  214. localStorage.clear();
  215. server.use(
  216. http.get('/api/v1/library/folders', () => {
  217. return HttpResponse.json(mockFolders);
  218. }),
  219. http.get('/api/v1/library/files', ({ request }) => {
  220. return HttpResponse.json(filesForRequest(request));
  221. }),
  222. http.get('/api/v1/library/stats', () => {
  223. return HttpResponse.json(mockStats);
  224. }),
  225. http.get('/api/v1/settings/', () => {
  226. return HttpResponse.json({
  227. check_updates: false,
  228. check_printer_firmware: false,
  229. library_disk_warning_gb: 5,
  230. });
  231. }),
  232. http.post('/api/v1/library/folders', async ({ request }) => {
  233. const body = await request.json() as { name: string };
  234. return HttpResponse.json({ id: 4, name: body.name, parent_id: null, children: [] });
  235. }),
  236. http.delete('/api/v1/library/folders/:id', () => {
  237. return HttpResponse.json({ success: true });
  238. }),
  239. http.delete('/api/v1/library/files/:id', () => {
  240. return HttpResponse.json({ success: true });
  241. }),
  242. http.post('/api/v1/library/files/move', () => {
  243. return HttpResponse.json({ success: true });
  244. }),
  245. http.post('/api/v1/library/files/add-to-queue', () => {
  246. return HttpResponse.json({ added: [{ file_id: 1, queue_id: 1 }], errors: [] });
  247. }),
  248. http.get('/api/v1/projects/', () => {
  249. return HttpResponse.json([{ id: 1, name: 'Test Project', color: '#00ae42' }]);
  250. }),
  251. http.get('/api/v1/archives/', () => {
  252. return HttpResponse.json([{ id: 1, print_name: 'Test Archive', filename: 'test.3mf' }]);
  253. })
  254. );
  255. });
  256. describe('rendering', () => {
  257. it('renders the page title', async () => {
  258. render(<FileManagerPage />);
  259. await waitFor(() => {
  260. expect(screen.getByText('File Manager')).toBeInTheDocument();
  261. });
  262. });
  263. it('renders the page description', async () => {
  264. render(<FileManagerPage />);
  265. await waitFor(() => {
  266. expect(screen.getByText('Organize and manage your print files')).toBeInTheDocument();
  267. });
  268. });
  269. it('shows New Folder button', async () => {
  270. render(<FileManagerPage />);
  271. await waitFor(() => {
  272. expect(screen.getByText('New Folder')).toBeInTheDocument();
  273. });
  274. });
  275. it('shows Upload button', async () => {
  276. render(<FileManagerPage />);
  277. await waitFor(() => {
  278. expect(screen.getByText('Upload')).toBeInTheDocument();
  279. });
  280. });
  281. it('wraps the header buttons onto more lines instead of running off a narrow screen (#3295)', async () => {
  282. // Up to nine controls sit in this row. Without wrapping they pushed
  283. // the whole page sideways on a phone. jsdom does no layout, so this
  284. // pins the classes that let the row wrap, and next to the title take
  285. // only the room left over rather than squeezing the title.
  286. render(<FileManagerPage />);
  287. const upload = await screen.findByRole('button', { name: /upload/i });
  288. const row = upload.parentElement as HTMLElement;
  289. expect(row).toContainElement(screen.getByRole('button', { name: /new folder/i }));
  290. expect(row.className.split(/\s+/)).toEqual(expect.arrayContaining(['flex-wrap', 'min-w-0', 'sm:justify-end', 'sm:flex-1']));
  291. });
  292. });
  293. describe('stats display', () => {
  294. it('shows file count', async () => {
  295. render(<FileManagerPage />);
  296. await waitFor(() => {
  297. expect(screen.getByText('Files:')).toBeInTheDocument();
  298. expect(screen.getByText('10')).toBeInTheDocument();
  299. });
  300. });
  301. it('shows folder count', async () => {
  302. render(<FileManagerPage />);
  303. await waitFor(() => {
  304. expect(screen.getByText('Folders:')).toBeInTheDocument();
  305. // Folder count appears multiple places, just verify the label is present
  306. const foldersLabel = screen.getByText('Folders:');
  307. expect(foldersLabel.nextElementSibling?.textContent).toBe('3');
  308. });
  309. });
  310. it('shows total size', async () => {
  311. render(<FileManagerPage />);
  312. await waitFor(() => {
  313. expect(screen.getByText('Size:')).toBeInTheDocument();
  314. expect(screen.getByText('100.0 MB')).toBeInTheDocument();
  315. });
  316. });
  317. it('shows free space', async () => {
  318. render(<FileManagerPage />);
  319. await waitFor(() => {
  320. expect(screen.getByText('Free:')).toBeInTheDocument();
  321. });
  322. });
  323. });
  324. describe('folder sidebar', () => {
  325. it('shows All Files option', async () => {
  326. render(<FileManagerPage />);
  327. await waitFor(() => {
  328. expect(screen.getByText('All Files')).toBeInTheDocument();
  329. });
  330. });
  331. it('shows folder tree', async () => {
  332. render(<FileManagerPage />);
  333. await waitFor(() => {
  334. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  335. expect(screen.getByText('Art Projects')).toBeInTheDocument();
  336. });
  337. });
  338. it('shows nested folders', async () => {
  339. render(<FileManagerPage />);
  340. await waitFor(() => {
  341. expect(screen.getByText('Brackets')).toBeInTheDocument();
  342. });
  343. });
  344. it('shows linked folder indicator', async () => {
  345. render(<FileManagerPage />);
  346. await waitFor(() => {
  347. // Art Projects has a project_id
  348. expect(screen.getByText('Art Projects')).toBeInTheDocument();
  349. });
  350. });
  351. });
  352. describe('file display', () => {
  353. it('shows files in grid', async () => {
  354. render(<FileManagerPage />);
  355. await waitFor(() => {
  356. expect(screen.getByText('Benchy')).toBeInTheDocument();
  357. });
  358. });
  359. it('shows file type badges', async () => {
  360. render(<FileManagerPage />);
  361. await waitFor(() => {
  362. // File type badges show uppercase type
  363. expect(screen.getAllByText('3MF').length).toBeGreaterThan(0);
  364. expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
  365. });
  366. });
  367. it('shows print count', async () => {
  368. render(<FileManagerPage />);
  369. await waitFor(() => {
  370. expect(screen.getByText('Printed 5x')).toBeInTheDocument();
  371. });
  372. });
  373. it('shows duplicate badge', async () => {
  374. render(<FileManagerPage />);
  375. await waitFor(() => {
  376. // Duplicate badge shows count, there may be multiple "2"s on the page
  377. // so we check that at least one element with "2" exists
  378. const elements = screen.getAllByText('2');
  379. expect(elements.length).toBeGreaterThan(0);
  380. });
  381. });
  382. });
  383. describe('view modes', () => {
  384. it('has grid view button', async () => {
  385. render(<FileManagerPage />);
  386. await waitFor(() => {
  387. expect(screen.getByTitle('Grid view')).toBeInTheDocument();
  388. });
  389. });
  390. it('has list view button', async () => {
  391. render(<FileManagerPage />);
  392. await waitFor(() => {
  393. expect(screen.getByTitle('List view')).toBeInTheDocument();
  394. });
  395. });
  396. it('can switch to list view', async () => {
  397. const user = userEvent.setup();
  398. render(<FileManagerPage />);
  399. // Wait for files to load first
  400. await waitFor(() => {
  401. expect(screen.getByText('Benchy')).toBeInTheDocument();
  402. });
  403. // Both view mode buttons should be present and clickable
  404. const gridButton = screen.getByTitle('Grid view');
  405. const listButton = screen.getByTitle('List view');
  406. expect(gridButton).toBeInTheDocument();
  407. expect(listButton).toBeInTheDocument();
  408. // Click list view button - verify no errors occur
  409. await user.click(listButton);
  410. // Clicking grid button should also work
  411. await user.click(gridButton);
  412. // Verify files are still displayed after toggling
  413. expect(screen.getByText('Benchy')).toBeInTheDocument();
  414. });
  415. it('can switch to columns view and descend through folder columns', async () => {
  416. const user = userEvent.setup();
  417. render(<FileManagerPage />);
  418. await waitFor(() => {
  419. expect(screen.getByText('Benchy')).toBeInTheDocument();
  420. });
  421. await user.click(screen.getByTitle('Column view'));
  422. // Scoped to the columns pane — the folder names also live in the tree
  423. // sidebar, so unscoped queries would double-match.
  424. const columns = within(screen.getByTestId('columns-view'));
  425. expect(columns.getByText('Functional Parts')).toBeInTheDocument();
  426. expect(columns.getByText('Art Projects')).toBeInTheDocument();
  427. // Root files render in the files pane.
  428. expect(columns.getByText('Benchy')).toBeInTheDocument();
  429. // Descend: clicking a folder opens its child column, and the level the
  430. // user just left keeps listing its own files.
  431. await user.click(columns.getByText('Functional Parts'));
  432. await waitFor(() => {
  433. expect(columns.getByText('Brackets')).toBeInTheDocument();
  434. });
  435. await waitFor(() => {
  436. expect(columns.getByText('Benchy')).toBeInTheDocument();
  437. });
  438. expect(within(screen.getByTestId('columns-files-pane')).getByText('Spacer')).toBeInTheDocument();
  439. // Leaf folder: selecting it adds no further column and the pane swaps to
  440. // its contents.
  441. await user.click(columns.getByText('Brackets'));
  442. await waitFor(() => {
  443. expect(within(screen.getByTestId('columns-files-pane')).getByText('clamp.stl')).toBeInTheDocument();
  444. });
  445. });
  446. it('hides folder columns while a search filters across folders', async () => {
  447. const user = userEvent.setup();
  448. render(<FileManagerPage />);
  449. await waitFor(() => {
  450. expect(screen.getByText('Benchy')).toBeInTheDocument();
  451. });
  452. await user.click(screen.getByTitle('Column view'));
  453. const columns = within(screen.getByTestId('columns-view'));
  454. expect(columns.getByText('Functional Parts')).toBeInTheDocument();
  455. await user.type(screen.getByPlaceholderText('Search files...'), 'benchy');
  456. // Search results span every folder — the per-level columns disappear,
  457. // the matching file stays.
  458. await waitFor(() => {
  459. expect(columns.queryByText('Functional Parts')).not.toBeInTheDocument();
  460. });
  461. expect(columns.getByText('Benchy')).toBeInTheDocument();
  462. });
  463. it('navigates the columns with the arrow keys', async () => {
  464. const user = userEvent.setup();
  465. render(<FileManagerPage />);
  466. await waitFor(() => {
  467. expect(screen.getByText('Benchy')).toBeInTheDocument();
  468. });
  469. await user.click(screen.getByTitle('Column view'));
  470. const columns = within(screen.getByTestId('columns-view'));
  471. // The highlight sits on the row, which also hosts the folder kebab —
  472. // the name button itself is unstyled.
  473. const activeClassOf = (name: string) =>
  474. columns.getByText(name).closest('[data-folder-id]')?.className ?? '';
  475. // The pane auto-focuses when the view opens, so keys work immediately.
  476. // Down from the root selects the first folder (name-sorted: Art
  477. // Projects), Down again moves to its sibling.
  478. await user.keyboard('{ArrowDown}');
  479. await waitFor(() => {
  480. expect(activeClassOf('Art Projects')).toContain('bg-bambu-green/20');
  481. });
  482. await user.keyboard('{ArrowDown}');
  483. await waitFor(() => {
  484. expect(activeClassOf('Functional Parts')).toContain('bg-bambu-green/20');
  485. });
  486. // Right descends into the first child folder…
  487. await user.keyboard('{ArrowRight}');
  488. await waitFor(() => {
  489. expect(activeClassOf('Brackets')).toContain('bg-bambu-green/20');
  490. });
  491. // …and Left climbs back up to the parent.
  492. await user.keyboard('{ArrowLeft}');
  493. await waitFor(() => {
  494. expect(activeClassOf('Functional Parts')).toContain('bg-bambu-green/20');
  495. });
  496. // Right on a folder without child folders moves the focus into the
  497. // files pane; Down walks the file rows. Wait for the refetched file
  498. // list before the second Right — during the folder switch the pane can
  499. // be momentarily empty.
  500. await user.keyboard('{ArrowRight}');
  501. await waitFor(() => {
  502. expect(activeClassOf('Brackets')).toContain('bg-bambu-green/20');
  503. expect(columns.getByText('clamp.stl')).toBeInTheDocument();
  504. });
  505. await user.keyboard('{ArrowRight}');
  506. await waitFor(() => {
  507. expect(columns.getByText('clamp.stl').closest('[title="clamp.stl"]')?.className).toContain('ring-1');
  508. });
  509. await user.keyboard('{ArrowDown}');
  510. await waitFor(() => {
  511. expect(columns.getByText('Hinge').closest('[title="Hinge"]')?.className).toContain('ring-1');
  512. });
  513. // Left leaves the files pane again.
  514. await user.keyboard('{ArrowLeft}');
  515. await waitFor(() => {
  516. expect(columns.getByText('Hinge').closest('[title="Hinge"]')?.className).not.toContain('ring-1');
  517. });
  518. });
  519. it('keeps the columns pane mounted while a folder\'s files load', async () => {
  520. const user = userEvent.setup();
  521. // First files request (initial load) resolves immediately, later ones
  522. // (the refetch a keyboard descent triggers) stay pending briefly.
  523. let filesCalls = 0;
  524. server.use(
  525. http.get('/api/v1/library/files', async ({ request }) => {
  526. filesCalls += 1;
  527. if (filesCalls > 1) await new Promise((resolve) => setTimeout(resolve, 150));
  528. return HttpResponse.json(filesForRequest(request));
  529. })
  530. );
  531. render(<FileManagerPage />);
  532. await waitFor(() => {
  533. expect(screen.getByText('Benchy')).toBeInTheDocument();
  534. });
  535. await user.click(screen.getByTitle('Column view'));
  536. expect(screen.getByTestId('columns-view')).toBeInTheDocument();
  537. // Descend while the folder's file list is still in flight. Swapping
  538. // the pane for the global spinner here would strip its tabindex and,
  539. // in real browsers, drop keyboard focus to <body> for good.
  540. await user.keyboard('{ArrowDown}');
  541. expect(screen.getByTestId('columns-view')).toBeInTheDocument();
  542. await waitFor(() => {
  543. expect(within(screen.getByTestId('columns-files-pane')).getByText('Vase')).toBeInTheDocument();
  544. });
  545. });
  546. // #3020 follow-up: the files pane carries the list row's icon strip, so
  547. // the columns view offers every per-file action in the same order.
  548. const listRow = (name: string) => screen.getByText(name).closest('div[class*="cursor-pointer"]') as HTMLElement;
  549. const actionTitlesOf = (row: HTMLElement) =>
  550. Array.from(row.querySelector('[data-file-actions]')!.querySelectorAll('button')).map((b) => b.title);
  551. it('offers the same per-file actions as the list view, in the same order', async () => {
  552. const user = userEvent.setup();
  553. render(<FileManagerPage />);
  554. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  555. await user.click(screen.getByTitle('List view'));
  556. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  557. const listBenchy = actionTitlesOf(listRow('Benchy'));
  558. const listBracket = actionTitlesOf(listRow('bracket.stl'));
  559. expect(listBenchy).toEqual(['Print', '3D Preview', 'Download', 'File details', 'Rename', 'Delete']);
  560. expect(listBracket).toContain('Generate Thumbnail');
  561. await user.click(screen.getByTitle('Column view'));
  562. const columns = within(screen.getByTestId('columns-view'));
  563. await waitFor(() => expect(columns.getByText('Benchy')).toBeInTheDocument());
  564. expect(actionTitlesOf(columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement)).toEqual(listBenchy);
  565. expect(actionTitlesOf(columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement)).toEqual(listBracket);
  566. });
  567. it('fires a row action without toggling the row selection', async () => {
  568. const user = userEvent.setup();
  569. render(<FileManagerPage />);
  570. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  571. await user.click(screen.getByTitle('Column view'));
  572. const columns = within(screen.getByTestId('columns-view'));
  573. const row = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
  574. await user.click(within(row).getByTitle('Rename'));
  575. // The rename modal edits the base name; the extension is fixed.
  576. expect(await screen.findByDisplayValue('benchy')).toBeInTheDocument();
  577. expect(row.className).not.toContain('bg-bambu-green/10');
  578. await user.click(screen.getByText('Cancel'));
  579. await user.click(within(row).getByTitle('Delete'));
  580. expect(await screen.findByText('Delete File')).toBeInTheDocument();
  581. });
  582. it('reveals the focused row\'s actions and keeps the rest touch-reachable (#2865)', async () => {
  583. const user = userEvent.setup();
  584. render(<FileManagerPage />);
  585. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  586. await user.click(screen.getByTitle('Column view'));
  587. const columns = within(screen.getByTestId('columns-view'));
  588. const stripWrapper = (name: string) =>
  589. (columns.getByText(name).closest('[data-file-id]') as HTMLElement).querySelector('[data-file-actions]')!.parentElement!;
  590. // Right from the root moves the focus into the files pane.
  591. await user.keyboard('{ArrowRight}');
  592. await waitFor(() => expect(stripWrapper('Benchy').className).not.toContain('opacity-0'));
  593. // Other rows hide the strip only for pointers that can hover — never a
  594. // bare opacity-0 — and stay out of the Tab order.
  595. expect(stripWrapper('bracket.stl').className).not.toMatch(/(^|\s)opacity-0(\s|$)/);
  596. expect(stripWrapper('bracket.stl').className).toContain('can-hover:opacity-0');
  597. expect(within(stripWrapper('bracket.stl')).getByTitle('Download')).toHaveAttribute('tabindex', '-1');
  598. expect(within(stripWrapper('Benchy')).getByTitle('Download')).toHaveAttribute('tabindex', '0');
  599. // Tab from the pane lands on the focused row's first action.
  600. await user.tab();
  601. expect(document.activeElement).toBe(within(stripWrapper('Benchy')).getByTitle('Print'));
  602. });
  603. it('reaches and triggers the focused file\'s actions from the keyboard', async () => {
  604. const user = userEvent.setup();
  605. render(<FileManagerPage />);
  606. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  607. await user.click(screen.getByTitle('Column view'));
  608. const columns = within(screen.getByTestId('columns-view'));
  609. await user.keyboard('{ArrowRight}');
  610. await waitFor(() => {
  611. expect(columns.getByText('Benchy').closest('[title="Benchy"]')?.className).toContain('ring-1');
  612. });
  613. const row = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
  614. // The context-menu key jumps into the strip; Left/Right walk it.
  615. await user.keyboard('{ContextMenu}');
  616. expect(document.activeElement).toBe(within(row).getByTitle('Print'));
  617. await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}');
  618. expect(document.activeElement).toBe(within(row).getByTitle('File details'));
  619. await user.keyboard('{ArrowLeft}');
  620. expect(document.activeElement).toBe(within(row).getByTitle('Download'));
  621. // Escape hands focus back to the pane, so the arrows walk rows again.
  622. await user.keyboard('{Escape}');
  623. expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
  624. await user.keyboard('{ArrowDown}');
  625. await waitFor(() => {
  626. expect(columns.getByText('bracket.stl').closest('[title="bracket.stl"]')?.className).toContain('ring-1');
  627. });
  628. // Enter on a strip button activates that button, not the row.
  629. await user.keyboard('{Shift>}{F10}{/Shift}');
  630. const bracketRow = columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement;
  631. expect(document.activeElement?.closest('[data-file-id]')).toBe(bracketRow);
  632. within(bracketRow).getByTitle('Rename').focus();
  633. await user.keyboard('{Enter}');
  634. // .stl is not a split-off extension, so the whole name is editable.
  635. expect(await screen.findByDisplayValue('bracket.stl')).toBeInTheDocument();
  636. });
  637. it('offers the tree\'s folder actions on folder rows', async () => {
  638. const user = userEvent.setup();
  639. render(<FileManagerPage />);
  640. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  641. await user.click(screen.getByTitle('Column view'));
  642. const columns = within(screen.getByTestId('columns-view'));
  643. const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
  644. const kebab = within(row).getByTitle('Actions');
  645. // Unselected row: kebab hidden only for pointers that can hover (#2865).
  646. expect(kebab.closest('.flex-shrink-0')!.className).toContain('can-hover:opacity-0');
  647. await user.click(kebab);
  648. expect(within(row).getByRole('button', { name: 'Rename' })).toBeInTheDocument();
  649. // Art Projects is linked to a project, so the link entry offers a change.
  650. expect(within(row).getByRole('button', { name: 'Change Link...' })).toBeInTheDocument();
  651. expect(within(row).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
  652. // Opening the menu did not select the folder.
  653. expect(row.className).not.toContain('bg-bambu-green/20');
  654. await user.click(within(row).getByRole('button', { name: 'Rename' }));
  655. expect(await screen.findByDisplayValue('Art Projects')).toBeInTheDocument();
  656. });
  657. it('opens the selected folder\'s kebab from the keyboard', async () => {
  658. const user = userEvent.setup();
  659. render(<FileManagerPage />);
  660. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  661. await user.click(screen.getByTitle('Column view'));
  662. const columns = within(screen.getByTestId('columns-view'));
  663. await user.keyboard('{ArrowDown}');
  664. const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
  665. await waitFor(() => expect(row.className).toContain('bg-bambu-green/20'));
  666. // The selected row's kebab is the only folder control in the Tab order.
  667. expect(within(row).getByTitle('Actions')).toHaveAttribute('tabindex', '0');
  668. expect(
  669. within(columns.getByText('Functional Parts').closest('[data-folder-id]') as HTMLElement).getByTitle('Actions'),
  670. ).toHaveAttribute('tabindex', '-1');
  671. await user.keyboard('{ContextMenu}');
  672. expect(within(row).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
  673. });
  674. it('closes the folder kebab menu on a second click and keeps it opaque while open', async () => {
  675. const user = userEvent.setup();
  676. render(<FileManagerPage />);
  677. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  678. await user.click(screen.getByTitle('Column view'));
  679. const columns = within(screen.getByTestId('columns-view'));
  680. const row = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
  681. const kebab = within(row).getByTitle('Actions');
  682. const wrapper = kebab.closest('[data-folder-actions]') as HTMLElement;
  683. expect(wrapper.className).toContain('can-hover:opacity-0');
  684. await user.click(kebab);
  685. expect(within(row).getByRole('button', { name: 'Rename' })).toBeInTheDocument();
  686. // The menu is a descendant of the hover-revealed wrapper: while it is
  687. // open the wrapper must not depend on hover/focus to stay visible.
  688. expect(wrapper.className).not.toContain('opacity-0');
  689. await user.click(kebab);
  690. expect(within(row).queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument();
  691. expect(wrapper.className).toContain('can-hover:opacity-0');
  692. });
  693. it('hands focus back to the pane after the folder kebab', async () => {
  694. const user = userEvent.setup();
  695. render(<FileManagerPage />);
  696. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  697. await user.click(screen.getByTitle('Column view'));
  698. const columns = within(screen.getByTestId('columns-view'));
  699. await user.keyboard('{ArrowDown}');
  700. const artRow = columns.getByText('Art Projects').closest('[data-folder-id]') as HTMLElement;
  701. const functionalRow = columns.getByText('Functional Parts').closest('[data-folder-id]') as HTMLElement;
  702. await waitFor(() => expect(artRow.className).toContain('bg-bambu-green/20'));
  703. // Escape closes the menu and returns focus to the pane, so the arrows
  704. // keep walking rows instead of dying on the kebab.
  705. await user.keyboard('{ContextMenu}');
  706. expect(within(artRow).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
  707. expect(document.activeElement).toBe(within(artRow).getByTitle('Actions'));
  708. await user.keyboard('{Escape}');
  709. expect(within(artRow).queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
  710. expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
  711. await user.keyboard('{ArrowDown}');
  712. await waitFor(() => expect(functionalRow.className).toContain('bg-bambu-green/20'));
  713. await user.keyboard('{ArrowUp}');
  714. await waitFor(() => expect(artRow.className).toContain('bg-bambu-green/20'));
  715. // Down straight out of an open menu closes it and moves the selection.
  716. await user.keyboard('{ContextMenu}');
  717. expect(within(artRow).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
  718. await user.keyboard('{ArrowDown}');
  719. expect(within(artRow).queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
  720. await waitFor(() => expect(functionalRow.className).toContain('bg-bambu-green/20'));
  721. expect(document.activeElement).toBe(screen.getByTestId('columns-view'));
  722. // Activating an entry with Enter must not strand focus on <body>.
  723. await user.keyboard('{ContextMenu}');
  724. await user.tab();
  725. expect(document.activeElement).toBe(within(functionalRow).getByRole('button', { name: 'Rename' }));
  726. await user.keyboard('{Enter}');
  727. expect(await screen.findByDisplayValue('Functional Parts')).toBeInTheDocument();
  728. expect(document.activeElement).not.toBe(document.body);
  729. });
  730. it('does not open the row preview when a strip icon is double-clicked', async () => {
  731. const user = userEvent.setup();
  732. render(<FileManagerPage />);
  733. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  734. await user.click(screen.getByTitle('Column view'));
  735. const columns = within(screen.getByTestId('columns-view'));
  736. const row = columns.getByText('bracket.stl').closest('[data-file-id]') as HTMLElement;
  737. await user.dblClick(within(row).getByTitle('Rename'));
  738. expect(await screen.findByDisplayValue('bracket.stl')).toBeInTheDocument();
  739. expect(screen.queryByTestId('model-viewer-modal')).not.toBeInTheDocument();
  740. // The row itself still opens the viewer.
  741. await user.click(screen.getByText('Cancel'));
  742. await user.dblClick(columns.getByText('bracket.stl'));
  743. expect(await screen.findByTestId('model-viewer-modal')).toBeInTheDocument();
  744. });
  745. // Every column is that level's contents, folders AND files. Before this a
  746. // folder holding files but no subfolders looked empty until it was the
  747. // selection.
  748. const descendToBrackets = async (user: ReturnType<typeof userEvent.setup>) => {
  749. await user.click(screen.getByTitle('Column view'));
  750. const columns = within(screen.getByTestId('columns-view'));
  751. await user.click(columns.getByText('Functional Parts'));
  752. await user.click(await columns.findByText('Brackets'));
  753. return columns;
  754. };
  755. it('lists an intermediate level\'s folders and its files in the same column', async () => {
  756. const user = userEvent.setup();
  757. render(<FileManagerPage />);
  758. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  759. await descendToBrackets(user);
  760. const level = within(screen.getByTestId('columns-level-folder-1'));
  761. expect(level.getByText('Brackets')).toBeInTheDocument();
  762. await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
  763. // The root column lists the files that sit in no folder at all.
  764. const root = within(screen.getByTestId('columns-level-root'));
  765. await waitFor(() => expect(root.getByText('Benchy')).toBeInTheDocument());
  766. expect(root.getByText('Functional Parts')).toBeInTheDocument();
  767. });
  768. it('focuses a file in an intermediate column without moving the folder selection', async () => {
  769. const user = userEvent.setup();
  770. render(<FileManagerPage />);
  771. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  772. await descendToBrackets(user);
  773. const pane = within(screen.getByTestId('columns-files-pane'));
  774. await waitFor(() => expect(pane.getByText('clamp.stl')).toBeInTheDocument());
  775. const level = within(screen.getByTestId('columns-level-folder-1'));
  776. await user.click(await level.findByText('Spacer'));
  777. expect(level.getByText('Spacer').closest('[data-file-id]')?.className).toContain('ring-1');
  778. // Brackets is still the selection: its own files still fill the pane.
  779. expect(pane.getByText('clamp.stl')).toBeInTheDocument();
  780. expect(level.getByText('Brackets').closest('[data-folder-id]')?.className).toContain('bg-bambu-green/20');
  781. });
  782. it('opens the preview on double-click from an intermediate column', async () => {
  783. const user = userEvent.setup();
  784. render(<FileManagerPage />);
  785. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  786. await descendToBrackets(user);
  787. const level = within(screen.getByTestId('columns-level-folder-1'));
  788. await user.dblClick(await level.findByText('Spacer'));
  789. expect(await screen.findByTestId('model-viewer-modal')).toBeInTheDocument();
  790. });
  791. it('keeps a level\'s folders visible while its files are still loading', async () => {
  792. const user = userEvent.setup();
  793. server.use(
  794. http.get('/api/v1/library/files', async ({ request }) => {
  795. if (new URL(request.url).searchParams.get('folder_id') === '1') {
  796. await new Promise((resolve) => setTimeout(resolve, 200));
  797. }
  798. return HttpResponse.json(filesForRequest(request));
  799. })
  800. );
  801. render(<FileManagerPage />);
  802. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  803. await descendToBrackets(user);
  804. // The column paints its folders straight away and marks the pending file
  805. // list instead of blocking.
  806. const level = within(screen.getByTestId('columns-level-folder-1'));
  807. expect(level.getByText('Brackets')).toBeInTheDocument();
  808. expect(level.getByText('…')).toBeInTheDocument();
  809. await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
  810. });
  811. it('leaves the rightmost pane as a leaf folder\'s contents', async () => {
  812. const user = userEvent.setup();
  813. render(<FileManagerPage />);
  814. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  815. await user.click(screen.getByTitle('Column view'));
  816. const columns = within(screen.getByTestId('columns-view'));
  817. await user.click(columns.getByText('Art Projects'));
  818. const pane = within(screen.getByTestId('columns-files-pane'));
  819. await waitFor(() => expect(pane.getByText('Vase')).toBeInTheDocument());
  820. // A leaf contributes no column of its own.
  821. expect(screen.queryByTestId('columns-level-folder-3')).not.toBeInTheDocument();
  822. });
  823. it('walks from a column\'s last folder into that column\'s files and back', async () => {
  824. const user = userEvent.setup();
  825. render(<FileManagerPage />);
  826. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  827. await descendToBrackets(user);
  828. const level = within(screen.getByTestId('columns-level-folder-1'));
  829. await waitFor(() => expect(level.getByText('Spacer')).toBeInTheDocument());
  830. screen.getByTestId('columns-view').focus();
  831. const spacerRow = () => level.getByText('Spacer').closest('[data-file-id]') as HTMLElement;
  832. // Brackets is the column's only folder, so the next row down is the
  833. // column's first file: the only way a keyboard reaches it at all.
  834. await user.keyboard('{ArrowDown}');
  835. await waitFor(() => expect(spacerRow().className).toContain('ring-1'));
  836. // Up steps back onto the folder, which stayed the selection throughout.
  837. await user.keyboard('{ArrowUp}');
  838. await waitFor(() => expect(spacerRow().className).not.toContain('ring-1'));
  839. expect(level.getByText('Brackets').closest('[data-folder-id]')?.className).toContain('bg-bambu-green/20');
  840. });
  841. it('keeps the selection\'s count and actions while the selected folder\'s own pane is empty', async () => {
  842. const user = userEvent.setup();
  843. server.use(
  844. http.get('/api/v1/library/files', ({ request }) =>
  845. HttpResponse.json(new URL(request.url).searchParams.get('folder_id') === '2' ? [] : filesForRequest(request))
  846. )
  847. );
  848. render(<FileManagerPage />);
  849. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  850. await descendToBrackets(user);
  851. const level = within(screen.getByTestId('columns-level-folder-1'));
  852. await user.click(await level.findByText('Spacer'));
  853. // Brackets lists nothing, yet a file ticked one column to the left still
  854. // gets its count, its bulk actions and a way to clear it.
  855. expect(await screen.findByText('1 selected')).toBeInTheDocument();
  856. await user.click(screen.getByText('Clear'));
  857. await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument());
  858. });
  859. it('drops the selection when another folder is selected', async () => {
  860. const user = userEvent.setup();
  861. render(<FileManagerPage />);
  862. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  863. await descendToBrackets(user);
  864. const level = within(screen.getByTestId('columns-level-folder-1'));
  865. await user.click(await level.findByText('Spacer'));
  866. expect(await screen.findByText('1 selected')).toBeInTheDocument();
  867. // Move or Delete must never act on rows that have left the screen.
  868. await user.click(within(screen.getByTestId('columns-level-root')).getByText('Art Projects'));
  869. await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument());
  870. });
  871. it('lists only the files in no folder beside the root column, like every other level', async () => {
  872. const user = userEvent.setup();
  873. render(<FileManagerPage />);
  874. // Grid view at the root is "All Files", which descends into the folders.
  875. await waitFor(() => expect(screen.getByText('Spacer')).toBeInTheDocument());
  876. await user.click(screen.getByTitle('Column view'));
  877. // The root level's own files, the same ones its column lists once a
  878. // folder is selected — nothing from inside a folder.
  879. const pane = within(await screen.findByTestId('columns-files-pane'));
  880. await waitFor(() => expect(pane.getByText('Benchy')).toBeInTheDocument());
  881. expect(pane.queryByText('Spacer')).not.toBeInTheDocument();
  882. expect(pane.queryByText('Vase')).not.toBeInTheDocument();
  883. });
  884. it('says the root has no files outside folders, not that the library is empty', async () => {
  885. const user = userEvent.setup();
  886. // Every file sits in a folder: the root level's own list is empty.
  887. server.use(
  888. http.get('/api/v1/library/files', ({ request }) =>
  889. HttpResponse.json(
  890. new URL(request.url).searchParams.get('include_root') === 'true'
  891. ? []
  892. : Object.values(mockFolderFiles).flat()
  893. )
  894. )
  895. );
  896. render(<FileManagerPage />);
  897. await waitFor(() => expect(screen.getByText('Spacer')).toBeInTheDocument());
  898. await user.click(screen.getByTitle('Column view'));
  899. const pane = within(await screen.findByTestId('columns-files-pane'));
  900. expect(await pane.findByText('No files outside folders')).toBeInTheDocument();
  901. expect(pane.queryByText('No files yet')).not.toBeInTheDocument();
  902. });
  903. it('offers the toolbar actions for files ticked in an earlier column', async () => {
  904. const user = userEvent.setup();
  905. render(<FileManagerPage />);
  906. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  907. await descendToBrackets(user);
  908. const root = within(screen.getByTestId('columns-level-root'));
  909. await user.click(await root.findByText('Benchy'));
  910. // One sliced file ticked in the root column, while the pane lists
  911. // Brackets: it can still be previewed and printed.
  912. const toolbar = () => screen.getByText(/\d selected/).closest('div') as HTMLElement;
  913. expect(await screen.findByText('1 selected')).toBeInTheDocument();
  914. expect(within(toolbar()).getByRole('button', { name: 'Preview' })).toBeInTheDocument();
  915. expect(within(toolbar()).getByRole('button', { name: 'Print' })).toBeInTheDocument();
  916. // A second one makes it one job for either file.
  917. await user.click(root.getByText('Cube'));
  918. expect(await within(toolbar()).findByRole('button', { name: 'Print (2 alternatives)' })).toBeInTheDocument();
  919. });
  920. it('offers Combine to 3MF for STLs ticked across columns', async () => {
  921. const user = userEvent.setup();
  922. render(<FileManagerPage />);
  923. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  924. await descendToBrackets(user);
  925. // bracket.stl sits in the root column, clamp.stl in the Brackets pane.
  926. await user.click(await within(screen.getByTestId('columns-level-root')).findByText('bracket.stl'));
  927. await user.click(await within(screen.getByTestId('columns-files-pane')).findByText('clamp.stl'));
  928. expect(await screen.findByText('2 selected')).toBeInTheDocument();
  929. const toolbar = screen.getByText('2 selected').closest('div') as HTMLElement;
  930. expect(within(toolbar).getByRole('button', { name: 'Combine to 3MF' })).toBeInTheDocument();
  931. });
  932. it('offers Select All until every file of the pane is ticked, and keeps other ticks', async () => {
  933. const user = userEvent.setup();
  934. render(<FileManagerPage />);
  935. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  936. await descendToBrackets(user);
  937. await user.click(await within(screen.getByTestId('columns-level-folder-1')).findByText('Spacer'));
  938. await user.click(await within(screen.getByTestId('columns-files-pane')).findByText('clamp.stl'));
  939. // Two ticked, but only one of the pane's two files.
  940. expect(await screen.findByText('2 selected')).toBeInTheDocument();
  941. expect(screen.getByText('Select All')).toBeInTheDocument();
  942. await user.click(screen.getByText('Select All'));
  943. expect(await screen.findByText('3 selected')).toBeInTheDocument();
  944. expect(screen.getByText('Deselect All')).toBeInTheDocument();
  945. });
  946. it('shows a document\'s type icon in place of a missing thumbnail', async () => {
  947. const user = userEvent.setup();
  948. server.use(
  949. http.get('/api/v1/library/files', ({ request }) =>
  950. HttpResponse.json(
  951. new URL(request.url).searchParams.get('folder_id')
  952. ? filesForRequest(request)
  953. : [...mockFiles, { ...mockFiles[0], id: 99, filename: 'manual.pdf', file_type: 'pdf', print_name: null, thumbnail_path: null }]
  954. )
  955. )
  956. );
  957. render(<FileManagerPage />);
  958. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  959. await user.click(screen.getByTitle('Column view'));
  960. const pane = within(screen.getByTestId('columns-files-pane'));
  961. const pdfRow = (await pane.findByText('manual.pdf')).closest('[data-file-id]') as HTMLElement;
  962. // The thumbnail slot, not the action strip (whose preview button uses
  963. // the same icon for a PDF).
  964. const thumbnailSlot = pdfRow.querySelector('.w-10.h-10') as HTMLElement;
  965. expect(thumbnailSlot.querySelector('.lucide-file-text')).not.toBeNull();
  966. expect(thumbnailSlot.querySelector('.lucide-file-box')).toBeNull();
  967. });
  968. });
  969. describe('search and filter', () => {
  970. it('has search input', async () => {
  971. render(<FileManagerPage />);
  972. await waitFor(() => {
  973. expect(screen.getByPlaceholderText('Search files...')).toBeInTheDocument();
  974. });
  975. });
  976. it('has type filter', async () => {
  977. render(<FileManagerPage />);
  978. await waitFor(() => {
  979. expect(screen.getByText('All types')).toBeInTheDocument();
  980. });
  981. });
  982. it('has sort options', async () => {
  983. render(<FileManagerPage />);
  984. await waitFor(() => {
  985. // Sort dropdown should show Name as default option (persisted to localStorage)
  986. expect(screen.getByDisplayValue('Name')).toBeInTheDocument();
  987. });
  988. });
  989. });
  990. describe('selection', () => {
  991. it('shows select all button', async () => {
  992. render(<FileManagerPage />);
  993. await waitFor(() => {
  994. expect(screen.getByText('Select All')).toBeInTheDocument();
  995. });
  996. });
  997. it('can select files', async () => {
  998. const user = userEvent.setup();
  999. render(<FileManagerPage />);
  1000. await waitFor(() => {
  1001. expect(screen.getByText('Benchy')).toBeInTheDocument();
  1002. });
  1003. // Click on the file card to select it
  1004. const fileCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
  1005. if (fileCard) {
  1006. await user.click(fileCard);
  1007. }
  1008. await waitFor(() => {
  1009. expect(screen.getByText('1 selected')).toBeInTheDocument();
  1010. });
  1011. });
  1012. it('Select All replaces a selection the search has hidden in the grid', async () => {
  1013. const user = userEvent.setup();
  1014. render(<FileManagerPage />);
  1015. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  1016. const benchyCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
  1017. expect(benchyCard).not.toBeNull();
  1018. await user.click(benchyCard!);
  1019. expect(await screen.findByText('1 selected')).toBeInTheDocument();
  1020. // Benchy leaves the screen; Select All must not keep it ticked, or a
  1021. // bulk Delete would remove a file nobody can see.
  1022. await user.type(screen.getByPlaceholderText('Search files...'), 'vase');
  1023. await waitFor(() => expect(screen.queryByText('Benchy')).not.toBeInTheDocument());
  1024. await user.click(screen.getByText('Select All'));
  1025. expect(await screen.findByText('1 selected')).toBeInTheDocument();
  1026. expect(screen.getByText('Deselect All')).toBeInTheDocument();
  1027. });
  1028. it('shows bulk actions when files selected', async () => {
  1029. const user = userEvent.setup();
  1030. render(<FileManagerPage />);
  1031. await waitFor(() => {
  1032. expect(screen.getByText('Select All')).toBeInTheDocument();
  1033. });
  1034. await user.click(screen.getByText('Select All'));
  1035. await waitFor(() => {
  1036. expect(screen.getByText('Move')).toBeInTheDocument();
  1037. expect(screen.getByText('Delete')).toBeInTheDocument();
  1038. });
  1039. });
  1040. });
  1041. describe('new folder modal', () => {
  1042. it('opens new folder modal', async () => {
  1043. const user = userEvent.setup();
  1044. render(<FileManagerPage />);
  1045. await waitFor(() => {
  1046. expect(screen.getByText('New Folder')).toBeInTheDocument();
  1047. });
  1048. await user.click(screen.getByText('New Folder'));
  1049. await waitFor(() => {
  1050. expect(screen.getByText('Folder Name')).toBeInTheDocument();
  1051. expect(screen.getByPlaceholderText('e.g., Functional Parts')).toBeInTheDocument();
  1052. });
  1053. });
  1054. it('can create a folder', async () => {
  1055. const user = userEvent.setup();
  1056. render(<FileManagerPage />);
  1057. await waitFor(() => {
  1058. expect(screen.getByText('New Folder')).toBeInTheDocument();
  1059. });
  1060. await user.click(screen.getByText('New Folder'));
  1061. await waitFor(() => {
  1062. expect(screen.getByPlaceholderText('e.g., Functional Parts')).toBeInTheDocument();
  1063. });
  1064. const input = screen.getByPlaceholderText('e.g., Functional Parts');
  1065. await user.type(input, 'My New Folder');
  1066. const createButton = screen.getByRole('button', { name: 'Create' });
  1067. await user.click(createButton);
  1068. // Modal should close after creation
  1069. await waitFor(() => {
  1070. expect(screen.queryByText('Folder Name')).not.toBeInTheDocument();
  1071. });
  1072. });
  1073. });
  1074. describe('empty state', () => {
  1075. it('shows empty state when no files', async () => {
  1076. server.use(
  1077. http.get('/api/v1/library/files', () => {
  1078. return HttpResponse.json([]);
  1079. })
  1080. );
  1081. render(<FileManagerPage />);
  1082. await waitFor(() => {
  1083. expect(screen.getByText('No files yet')).toBeInTheDocument();
  1084. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1085. });
  1086. });
  1087. });
  1088. describe('bulk-action print button', () => {
  1089. // PR #1625 consolidated print actions: the old single-file-selected
  1090. // "Schedule" button now opens the unified PrintModal (which carries
  1091. // schedule options inside). The bulk-action toolbar shows a single
  1092. // "Print" button only when exactly one sliced file is selected, and
  1093. // hides it for multi-selection. The button is targeted by its accessible
  1094. // name ("Print") + role to disambiguate from the file-card dropdown's
  1095. // own Print entry, which stays collapsed unless its kebab is opened.
  1096. it('shows a Print button in the bulk toolbar when one sliced file is selected', async () => {
  1097. const user = userEvent.setup();
  1098. render(<FileManagerPage />);
  1099. await waitFor(() => {
  1100. expect(screen.getByText('Benchy')).toBeInTheDocument();
  1101. });
  1102. // Select a sliced file (benchy.gcode.3mf) by clicking on its card
  1103. const fileCard = screen.getByText('Benchy').closest('div[class*="cursor-pointer"]');
  1104. if (fileCard) {
  1105. await user.click(fileCard);
  1106. }
  1107. await waitFor(() => {
  1108. expect(screen.getByRole('button', { name: /^Print$/ })).toBeInTheDocument();
  1109. });
  1110. });
  1111. it('hides the bulk Print button when multiple files are selected', async () => {
  1112. const user = userEvent.setup();
  1113. render(<FileManagerPage />);
  1114. await waitFor(() => {
  1115. expect(screen.getByText('Select All')).toBeInTheDocument();
  1116. });
  1117. // Select all files
  1118. await user.click(screen.getByText('Select All'));
  1119. await waitFor(() => {
  1120. expect(screen.queryByRole('button', { name: /^Print$/ })).not.toBeInTheDocument();
  1121. });
  1122. });
  1123. });
  1124. describe('STL thumbnail generation', () => {
  1125. it('shows Generate Thumbnails button', async () => {
  1126. render(<FileManagerPage />);
  1127. await waitFor(() => {
  1128. expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
  1129. });
  1130. });
  1131. it('Generate Thumbnails button has correct title', async () => {
  1132. render(<FileManagerPage />);
  1133. await waitFor(() => {
  1134. const button = screen.getByTitle('Generate thumbnails for STL and PDF files missing them');
  1135. expect(button).toBeInTheDocument();
  1136. });
  1137. });
  1138. it('can click Generate Thumbnails button', async () => {
  1139. const user = userEvent.setup();
  1140. server.use(
  1141. http.post('/api/v1/library/generate-stl-thumbnails', () => {
  1142. return HttpResponse.json({
  1143. processed: 1,
  1144. succeeded: 1,
  1145. failed: 0,
  1146. results: [{ file_id: 2, success: true }],
  1147. });
  1148. })
  1149. );
  1150. render(<FileManagerPage />);
  1151. await waitFor(() => {
  1152. expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
  1153. });
  1154. const button = screen.getByText('Generate Thumbnails');
  1155. await user.click(button);
  1156. // Button should work without error
  1157. await waitFor(() => {
  1158. expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
  1159. });
  1160. });
  1161. it('shows STL file without thumbnail in file list', async () => {
  1162. render(<FileManagerPage />);
  1163. await waitFor(() => {
  1164. // bracket.stl has no thumbnail_path
  1165. expect(screen.getByText('bracket.stl')).toBeInTheDocument();
  1166. expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
  1167. });
  1168. });
  1169. // Since #2976 the server renders PDF thumbnails too, so the per-file
  1170. // action is offered for PDFs and stays hidden for types it cannot render.
  1171. describe('per-file action for PDFs', () => {
  1172. const pdfFile = {
  1173. id: 40,
  1174. filename: 'drawing.pdf',
  1175. file_path: '/library/drawing.pdf',
  1176. file_size: 4096,
  1177. file_type: 'pdf',
  1178. folder_id: null,
  1179. thumbnail_path: null,
  1180. print_name: null,
  1181. print_time_seconds: null,
  1182. print_count: 0,
  1183. duplicate_count: 0,
  1184. created_at: '2024-01-04T00:00:00Z',
  1185. };
  1186. const stepFile = { ...pdfFile, id: 41, filename: 'part.step', file_path: '/library/part.step', file_type: 'step' };
  1187. beforeEach(() => {
  1188. server.use(
  1189. http.get('/api/v1/library/files', () => HttpResponse.json([...mockFiles, pdfFile, stepFile])),
  1190. );
  1191. });
  1192. const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
  1193. const card = screen.getByText(filename).closest('.group') as HTMLElement;
  1194. const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
  1195. await user.click(kebab);
  1196. return card;
  1197. };
  1198. it('offers Generate Thumbnail in the card menu of a PDF', async () => {
  1199. const user = userEvent.setup();
  1200. server.use(
  1201. http.post('/api/v1/library/generate-stl-thumbnails', async ({ request }) => {
  1202. const body = (await request.json()) as { file_ids?: number[] };
  1203. return HttpResponse.json({
  1204. processed: 1,
  1205. succeeded: 1,
  1206. failed: 0,
  1207. results: [{ file_id: body.file_ids?.[0], success: true }],
  1208. });
  1209. })
  1210. );
  1211. render(<FileManagerPage />);
  1212. await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
  1213. const card = await openMenu(user, 'drawing.pdf');
  1214. await user.click(within(card).getByText('Generate Thumbnail'));
  1215. expect(await screen.findByText('Thumbnail generated')).toBeInTheDocument();
  1216. });
  1217. it('does not offer it for STEP, which only the browser can render', async () => {
  1218. const user = userEvent.setup();
  1219. render(<FileManagerPage />);
  1220. await waitFor(() => expect(screen.getByText('part.step')).toBeInTheDocument());
  1221. const card = await openMenu(user, 'part.step');
  1222. expect(within(card).queryByText('Generate Thumbnail')).not.toBeInTheDocument();
  1223. });
  1224. it('offers the action in the list view strip of a PDF', async () => {
  1225. const user = userEvent.setup();
  1226. render(<FileManagerPage />);
  1227. await waitFor(() => expect(screen.getByText('drawing.pdf')).toBeInTheDocument());
  1228. await user.click(screen.getByRole('button', { name: /list/i }));
  1229. // List rows are CSS grids; the row is the nearest grid ancestor.
  1230. await waitFor(() => {
  1231. const row = screen.getByText('drawing.pdf').closest('.grid') as HTMLElement;
  1232. expect(within(row).getByTitle('Generate Thumbnail')).toBeInTheDocument();
  1233. });
  1234. const stepRow = screen.getByText('part.step').closest('.grid') as HTMLElement;
  1235. expect(within(stepRow).queryByTitle('Generate Thumbnail')).not.toBeInTheDocument();
  1236. });
  1237. });
  1238. });
  1239. describe('upload modal (FileUploadModal)', () => {
  1240. it('opens upload modal when Upload button is clicked', async () => {
  1241. const user = userEvent.setup();
  1242. render(<FileManagerPage />);
  1243. await waitFor(() => {
  1244. expect(screen.getByText('Upload')).toBeInTheDocument();
  1245. });
  1246. await user.click(screen.getByText('Upload'));
  1247. await waitFor(() => {
  1248. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1249. expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
  1250. });
  1251. });
  1252. it('closes upload modal when Cancel is clicked', async () => {
  1253. const user = userEvent.setup();
  1254. render(<FileManagerPage />);
  1255. await waitFor(() => {
  1256. expect(screen.getByText('Upload')).toBeInTheDocument();
  1257. });
  1258. await user.click(screen.getByText('Upload'));
  1259. await waitFor(() => {
  1260. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1261. });
  1262. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  1263. await waitFor(() => {
  1264. expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
  1265. });
  1266. });
  1267. it('shows 3MF extraction info when 3MF file is added', async () => {
  1268. const user = userEvent.setup();
  1269. render(<FileManagerPage />);
  1270. await waitFor(() => {
  1271. expect(screen.getByText('Upload')).toBeInTheDocument();
  1272. });
  1273. await user.click(screen.getByText('Upload'));
  1274. await waitFor(() => {
  1275. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1276. });
  1277. const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
  1278. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  1279. expect(fileInput).toBeInTheDocument();
  1280. await user.upload(fileInput, threemfFile);
  1281. await waitFor(() => {
  1282. expect(screen.getByText('3MF files detected')).toBeInTheDocument();
  1283. expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
  1284. });
  1285. });
  1286. it('shows STL thumbnail option when STL file is added', async () => {
  1287. const user = userEvent.setup();
  1288. render(<FileManagerPage />);
  1289. await waitFor(() => {
  1290. expect(screen.getByText('Upload')).toBeInTheDocument();
  1291. });
  1292. await user.click(screen.getByText('Upload'));
  1293. await waitFor(() => {
  1294. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1295. });
  1296. const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
  1297. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  1298. expect(fileInput).toBeInTheDocument();
  1299. await user.upload(fileInput, stlFile);
  1300. await waitFor(() => {
  1301. expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
  1302. expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
  1303. });
  1304. });
  1305. it('shows ZIP options when ZIP file is added', async () => {
  1306. const user = userEvent.setup();
  1307. render(<FileManagerPage />);
  1308. await waitFor(() => {
  1309. expect(screen.getByText('Upload')).toBeInTheDocument();
  1310. });
  1311. await user.click(screen.getByText('Upload'));
  1312. await waitFor(() => {
  1313. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1314. });
  1315. const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
  1316. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  1317. await user.upload(fileInput, zipFile);
  1318. await waitFor(() => {
  1319. expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
  1320. expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
  1321. });
  1322. });
  1323. it('can add a file via the file input', async () => {
  1324. const user = userEvent.setup();
  1325. render(<FileManagerPage />);
  1326. await waitFor(() => {
  1327. expect(screen.getByText('Upload')).toBeInTheDocument();
  1328. });
  1329. await user.click(screen.getByText('Upload'));
  1330. await waitFor(() => {
  1331. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1332. });
  1333. const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
  1334. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  1335. await user.upload(fileInput, file);
  1336. await waitFor(() => {
  1337. expect(screen.getByText('model.3mf')).toBeInTheDocument();
  1338. expect(screen.getByRole('button', { name: /Upload \(1\)/i })).toBeInTheDocument();
  1339. });
  1340. });
  1341. it('uploads file and refreshes file list', async () => {
  1342. server.use(
  1343. http.post('/api/v1/library/files', () => {
  1344. return HttpResponse.json({
  1345. id: 10,
  1346. filename: 'uploaded.3mf',
  1347. file_type: '3mf',
  1348. file_size: 1024,
  1349. thumbnail_path: null,
  1350. duplicate_of: null,
  1351. metadata: null,
  1352. });
  1353. })
  1354. );
  1355. const user = userEvent.setup();
  1356. render(<FileManagerPage />);
  1357. await waitFor(() => {
  1358. expect(screen.getByText('Upload')).toBeInTheDocument();
  1359. });
  1360. await user.click(screen.getByText('Upload'));
  1361. await waitFor(() => {
  1362. expect(screen.getByText('Upload Files')).toBeInTheDocument();
  1363. });
  1364. const file = new File(['content'], 'uploaded.3mf', { type: 'application/octet-stream' });
  1365. const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
  1366. await user.upload(fileInput, file);
  1367. const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
  1368. await user.click(uploadButton);
  1369. // Modal should auto-close after upload completes
  1370. await waitFor(() => {
  1371. expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
  1372. });
  1373. });
  1374. });
  1375. describe('authentication-based UI changes', () => {
  1376. it('hides "Uploaded By" column and user filter when auth is disabled', async () => {
  1377. // Mock auth disabled (default)
  1378. server.use(
  1379. http.get('*/api/v1/auth/status', () => {
  1380. return HttpResponse.json({
  1381. auth_enabled: false,
  1382. requires_setup: false,
  1383. });
  1384. }),
  1385. http.get('/api/v1/library/files', () => {
  1386. return HttpResponse.json([
  1387. {
  1388. id: 1,
  1389. filename: 'test.3mf',
  1390. file_path: '/library/test.3mf',
  1391. file_size: 1048576,
  1392. file_type: '3mf',
  1393. folder_id: null,
  1394. thumbnail_path: null,
  1395. print_name: 'Test File',
  1396. print_time_seconds: 3600,
  1397. print_count: 0,
  1398. duplicate_count: 0,
  1399. created_at: '2024-01-01T00:00:00Z',
  1400. created_by_username: 'testuser',
  1401. },
  1402. ]);
  1403. })
  1404. );
  1405. render(<FileManagerPage />);
  1406. // Switch to list view to see the column headers
  1407. await waitFor(() => {
  1408. expect(screen.getByText('Test File')).toBeInTheDocument();
  1409. });
  1410. const user = userEvent.setup();
  1411. const listViewButton = screen.getByRole('button', { name: /list/i });
  1412. await user.click(listViewButton);
  1413. // "Uploaded By" column header should not be present
  1414. await waitFor(() => {
  1415. expect(screen.queryByText('Uploaded By')).not.toBeInTheDocument();
  1416. });
  1417. // User filter dropdown should not be present
  1418. expect(screen.queryByPlaceholderText('Filter by user')).not.toBeInTheDocument();
  1419. // #3105: the logged-out column set needs the same floors as the
  1420. // authenticated one, minus the Uploaded By track.
  1421. const header = screen.getByTestId('file-list-grid-header');
  1422. const rows = screen.getAllByTestId('file-list-grid-row');
  1423. for (const el of [header, ...rows]) {
  1424. expect(el).toHaveClass('min-w-min');
  1425. expect(el.className).toContain('grid-cols-[24px_minmax(240px,1fr)_100px_100px_100px_minmax(96px,200px)_220px]');
  1426. }
  1427. });
  1428. it('shows "Uploaded By" column and user filter when auth is enabled', async () => {
  1429. // Mock auth enabled
  1430. server.use(
  1431. http.get('*/api/v1/auth/status', () => {
  1432. return HttpResponse.json({
  1433. auth_enabled: true,
  1434. requires_setup: false,
  1435. });
  1436. }),
  1437. http.get('/api/v1/library/files', () => {
  1438. return HttpResponse.json([
  1439. {
  1440. id: 1,
  1441. filename: 'test.3mf',
  1442. file_path: '/library/test.3mf',
  1443. file_size: 1048576,
  1444. file_type: '3mf',
  1445. folder_id: null,
  1446. thumbnail_path: null,
  1447. print_name: 'Test File',
  1448. print_time_seconds: 3600,
  1449. print_count: 0,
  1450. duplicate_count: 0,
  1451. created_at: '2024-01-01T00:00:00Z',
  1452. created_by_username: 'testuser',
  1453. },
  1454. ]);
  1455. }),
  1456. http.get('/api/v1/users/', () => {
  1457. return HttpResponse.json([
  1458. { id: 1, username: 'testuser' },
  1459. { id: 2, username: 'admin' },
  1460. ]);
  1461. })
  1462. );
  1463. render(<FileManagerPage />);
  1464. // Switch to list view to see the column headers
  1465. await waitFor(() => {
  1466. expect(screen.getByText('Test File')).toBeInTheDocument();
  1467. });
  1468. const user = userEvent.setup();
  1469. const listViewButton = screen.getByRole('button', { name: /list/i });
  1470. await user.click(listViewButton);
  1471. // "Uploaded By" column header should be present
  1472. await waitFor(() => {
  1473. expect(screen.getByText('Uploaded By')).toBeInTheDocument();
  1474. });
  1475. // User filter dropdown should be present
  1476. expect(screen.getByPlaceholderText('Filter by user')).toBeInTheDocument();
  1477. // Username should be displayed in the column
  1478. expect(screen.getByText('testuser')).toBeInTheDocument();
  1479. // #3105: the authenticated grid has enough fixed-width columns to
  1480. // squeeze a bare 1fr filename track to zero. Header and rows must share
  1481. // the same minimum width and non-collapsible filename and tags tracks
  1482. // so the existing overflow wrapper scrolls instead.
  1483. const header = screen.getByTestId('file-list-grid-header');
  1484. const rows = screen.getAllByTestId('file-list-grid-row');
  1485. for (const el of [header, ...rows]) {
  1486. expect(el).toHaveClass('min-w-min');
  1487. expect(el.className).toContain('grid-cols-[24px_minmax(240px,1fr)_120px_100px_100px_100px_minmax(96px,200px)_220px]');
  1488. }
  1489. });
  1490. });
  1491. describe('folder tree collapse preference (#996)', () => {
  1492. // localStorage is globally mocked in setup.ts (returns undefined by default),
  1493. // so we program each test's getItem return value explicitly.
  1494. const getItemMock = localStorage.getItem as ReturnType<typeof vi.fn>;
  1495. const setItemMock = localStorage.setItem as ReturnType<typeof vi.fn>;
  1496. beforeEach(() => {
  1497. getItemMock.mockReset();
  1498. setItemMock.mockReset();
  1499. });
  1500. // The mock is module-global, so an implementation left behind here would
  1501. // silently change every later describe (e.g. collapsing the folder tree).
  1502. afterEach(() => {
  1503. getItemMock.mockReset();
  1504. setItemMock.mockReset();
  1505. });
  1506. it('defaults to expanded (nested folders visible) when library-collapse-folders is unset', async () => {
  1507. getItemMock.mockReturnValue(null);
  1508. render(<FileManagerPage />);
  1509. await waitFor(() => {
  1510. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  1511. });
  1512. expect(screen.getByText('Brackets')).toBeInTheDocument();
  1513. });
  1514. it('honors library-collapse-folders=true on load (nested folders hidden)', async () => {
  1515. getItemMock.mockImplementation((key: string) =>
  1516. key === 'library-collapse-folders' ? 'true' : null
  1517. );
  1518. render(<FileManagerPage />);
  1519. await waitFor(() => {
  1520. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  1521. });
  1522. expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
  1523. });
  1524. it('collapses nested folders and persists preference when Collapse is clicked', async () => {
  1525. getItemMock.mockReturnValue(null);
  1526. const user = userEvent.setup();
  1527. render(<FileManagerPage />);
  1528. await waitFor(() => {
  1529. expect(screen.getByText('Brackets')).toBeInTheDocument();
  1530. });
  1531. // The Collapse button sits next to Wrap in the sidebar header.
  1532. // Its text content is "Collapse" (from fileManager.collapse).
  1533. await user.click(screen.getByRole('button', { name: 'Collapse' }));
  1534. await waitFor(() => {
  1535. expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
  1536. });
  1537. expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'true');
  1538. });
  1539. it('re-expands nested folders and persists preference when Collapse is toggled off', async () => {
  1540. getItemMock.mockImplementation((key: string) =>
  1541. key === 'library-collapse-folders' ? 'true' : null
  1542. );
  1543. const user = userEvent.setup();
  1544. render(<FileManagerPage />);
  1545. await waitFor(() => {
  1546. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  1547. });
  1548. expect(screen.queryByText('Brackets')).not.toBeInTheDocument();
  1549. await user.click(screen.getByRole('button', { name: 'Collapse' }));
  1550. await waitFor(() => {
  1551. expect(screen.getByText('Brackets')).toBeInTheDocument();
  1552. });
  1553. expect(setItemMock).toHaveBeenCalledWith('library-collapse-folders', 'false');
  1554. });
  1555. });
  1556. describe('Internal / External top-level views (#1621)', () => {
  1557. const externalMockFolders = [
  1558. ...mockFolders,
  1559. {
  1560. id: 99,
  1561. name: 'NAS Library',
  1562. parent_id: null,
  1563. file_count: 200,
  1564. project_id: null,
  1565. archive_id: null,
  1566. project_name: null,
  1567. archive_name: null,
  1568. is_external: true,
  1569. external_readonly: false,
  1570. external_path: '/mnt/nas',
  1571. children: [],
  1572. },
  1573. ];
  1574. it('shows the External sidebar entry only when at least one external folder is linked', async () => {
  1575. // Default mockFolders have no is_external entries → no External row.
  1576. const { unmount } = render(<FileManagerPage />);
  1577. await waitFor(() => {
  1578. expect(screen.getByText('All Files')).toBeInTheDocument();
  1579. });
  1580. expect(screen.queryByText('External')).not.toBeInTheDocument();
  1581. unmount();
  1582. // With an external folder linked, the row appears.
  1583. server.use(
  1584. http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
  1585. );
  1586. render(<FileManagerPage />);
  1587. await waitFor(() => {
  1588. expect(screen.getByText('External')).toBeInTheDocument();
  1589. });
  1590. });
  1591. it('sends internal_only=true by default ("All Files" = managed storage only)', async () => {
  1592. const scopes: string[] = [];
  1593. server.use(
  1594. http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
  1595. http.get('/api/v1/library/files', ({ request }) => {
  1596. const url = new URL(request.url);
  1597. scopes.push(
  1598. url.searchParams.get('internal_only') === 'true'
  1599. ? 'internal'
  1600. : url.searchParams.get('external_only') === 'true'
  1601. ? 'external'
  1602. : 'all',
  1603. );
  1604. return HttpResponse.json(mockFiles);
  1605. }),
  1606. );
  1607. render(<FileManagerPage />);
  1608. await waitFor(() => {
  1609. expect(scopes).toContain('internal');
  1610. });
  1611. });
  1612. it('switches to external_only=true when the External sidebar entry is clicked', async () => {
  1613. const scopes: string[] = [];
  1614. server.use(
  1615. http.get('/api/v1/library/folders', () => HttpResponse.json(externalMockFolders)),
  1616. http.get('/api/v1/library/files', ({ request }) => {
  1617. const url = new URL(request.url);
  1618. scopes.push(
  1619. url.searchParams.get('internal_only') === 'true'
  1620. ? 'internal'
  1621. : url.searchParams.get('external_only') === 'true'
  1622. ? 'external'
  1623. : 'all',
  1624. );
  1625. return HttpResponse.json([]);
  1626. }),
  1627. );
  1628. const { default: userEvent } = await import('@testing-library/user-event');
  1629. const user = userEvent.setup();
  1630. render(<FileManagerPage />);
  1631. await waitFor(() => expect(screen.getByText('External')).toBeInTheDocument());
  1632. await user.click(screen.getByText('External'));
  1633. await waitFor(() => {
  1634. expect(scopes).toContain('external');
  1635. });
  1636. });
  1637. });
  1638. describe('"All Files" view (#1499)', () => {
  1639. it('requests every file (include_root=false) so subfolder contents are visible', async () => {
  1640. const rootFile = {
  1641. id: 10,
  1642. filename: 'root-file.3mf',
  1643. file_path: '/library/root-file.3mf',
  1644. file_size: 1024,
  1645. file_type: '3mf',
  1646. folder_id: null,
  1647. thumbnail_path: null,
  1648. print_name: 'Root File',
  1649. print_time_seconds: 0,
  1650. print_count: 0,
  1651. duplicate_count: 0,
  1652. created_at: '2024-01-01T00:00:00Z',
  1653. };
  1654. const nestedFile = {
  1655. ...rootFile,
  1656. id: 11,
  1657. filename: 'nested-file.3mf',
  1658. file_path: '/library/Functional Parts/nested-file.3mf',
  1659. folder_id: 1,
  1660. print_name: 'Nested File',
  1661. };
  1662. const includeRootValues: string[] = [];
  1663. server.use(
  1664. http.get('/api/v1/library/files', ({ request }) => {
  1665. const url = new URL(request.url);
  1666. const includeRoot = url.searchParams.get('include_root');
  1667. includeRootValues.push(includeRoot ?? '');
  1668. // Mirror the backend: include_root=false returns everything; true
  1669. // returns only files with folder_id IS NULL.
  1670. if (includeRoot === 'false') {
  1671. return HttpResponse.json([rootFile, nestedFile]);
  1672. }
  1673. return HttpResponse.json([rootFile]);
  1674. }),
  1675. );
  1676. render(<FileManagerPage />);
  1677. await waitFor(() => {
  1678. expect(screen.getByText('Root File')).toBeInTheDocument();
  1679. expect(screen.getByText('Nested File')).toBeInTheDocument();
  1680. });
  1681. // Sanity-check: the buggy call would have sent include_root=true here.
  1682. expect(includeRootValues).toContain('false');
  1683. });
  1684. });
  1685. describe('last-modified date display (#2680)', () => {
  1686. it('is hidden by default and revealed by the toolbar toggle', async () => {
  1687. const user = userEvent.setup();
  1688. render(<FileManagerPage />);
  1689. await waitFor(() => {
  1690. expect(screen.getByText('Benchy')).toBeInTheDocument();
  1691. });
  1692. // Hidden by default.
  1693. expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
  1694. // Toggle on via the toolbar button.
  1695. await user.click(screen.getByTitle('Show modified dates'));
  1696. // benchy carries fs_modified_at in 2030, which must be preferred over its
  1697. // created_at (2024) — proving the real on-disk mtime drives the display.
  1698. await waitFor(() => {
  1699. expect(screen.getByText(/2030/)).toBeInTheDocument();
  1700. });
  1701. // Toggling off hides it again.
  1702. await user.click(screen.getByTitle('Hide modified dates'));
  1703. await waitFor(() => {
  1704. expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
  1705. });
  1706. });
  1707. it('the same toggle reveals latest activity on folder rows, including nested ones', async () => {
  1708. const user = userEvent.setup();
  1709. render(<FileManagerPage />);
  1710. await waitFor(() => {
  1711. expect(screen.getByText('Functional Parts')).toBeInTheDocument();
  1712. });
  1713. expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
  1714. await user.click(screen.getByTitle('Show modified dates'));
  1715. await waitFor(() => {
  1716. expect(screen.getByText(/2031/)).toBeInTheDocument();
  1717. });
  1718. // Nested folders get it too — the prop must survive the recursion.
  1719. expect(screen.getByText(/2032/)).toBeInTheDocument();
  1720. // A folder with no activity timestamp renders nothing rather than an
  1721. // "Invalid Date" string.
  1722. const artRow = screen.getByText('Art Projects').closest('div.group')!;
  1723. expect(artRow.textContent).not.toMatch(/Invalid/);
  1724. await user.click(screen.getByTitle('Hide modified dates'));
  1725. await waitFor(() => {
  1726. expect(screen.queryByText(/2031/)).not.toBeInTheDocument();
  1727. });
  1728. });
  1729. });
  1730. describe('slice action', () => {
  1731. beforeEach(() => {
  1732. vi.mocked(openInSlicer).mockClear();
  1733. server.use(
  1734. http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
  1735. // The only sliceable fixture is an STL, and since #3029 the desktop
  1736. // handoff is only offered to a slicer whose protocol handler will
  1737. // actually load one -- Bambu Studio's takes 3MF only. These tests are
  1738. // about the handoff mechanics and the permission gate, not about which
  1739. // slicer, so they run against OrcaSlicer. The Bambu Studio side is
  1740. // covered by its own tests below.
  1741. ...settingsHandlers({ preferred_slicer: 'orcaslicer' }),
  1742. );
  1743. });
  1744. afterEach(() => {
  1745. // Permission tests set a token; clear it so it can't leak into the
  1746. // list-view tests that follow (mirrors FileManagerFolderDelete.test.tsx).
  1747. setAuthToken(null);
  1748. });
  1749. const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
  1750. const card = screen.getByText(filename).closest('.group') as HTMLElement;
  1751. // Target the kebab (ellipsis) toggle specifically rather than the card's
  1752. // first button — a button added ahead of the kebab would otherwise
  1753. // break the menu-opening assumption.
  1754. const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
  1755. await user.click(kebab);
  1756. return card;
  1757. };
  1758. it('opens the desktop slicer when the slicer API is disabled', async () => {
  1759. const user = userEvent.setup();
  1760. render(<FileManagerPage />);
  1761. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1762. const card = await openMenu(user, 'bracket.stl');
  1763. await user.click(within(card).getByText('Slice'));
  1764. await waitFor(() => {
  1765. expect(openInSlicer).toHaveBeenCalledWith(
  1766. expect.stringContaining('/library/files/2/dl/test-token/'),
  1767. 'orcaslicer',
  1768. );
  1769. });
  1770. });
  1771. it('opens the in-app SliceModal when the slicer API is enabled', async () => {
  1772. server.use(
  1773. ...settingsHandlers({ use_slicer_api: true }),
  1774. );
  1775. const user = userEvent.setup();
  1776. render(<FileManagerPage />);
  1777. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1778. const card = await openMenu(user, 'bracket.stl');
  1779. await user.click(within(card).getByText('Slice'));
  1780. expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
  1781. expect(openInSlicer).not.toHaveBeenCalled();
  1782. });
  1783. // #2846: the menu used to be an absolutely-positioned child of the card,
  1784. // and the card clipped its own overflow. A bare STL card is only about
  1785. // 270px tall -- thumbnail plus name and size -- which is shorter than the
  1786. // seven-entry menu, so the top entry was cut off. That entry is Slice,
  1787. // because Print is suppressed for an unsliced file. A 3MF card carries two
  1788. // more metadata rows and was tall enough, which is why the report said 3MF
  1789. // worked. Nothing about STL was special; the card was just the shortest.
  1790. it('keeps the first menu entry out of the card so it cannot be clipped (#2846)', async () => {
  1791. server.use(
  1792. ...settingsHandlers({ use_slicer_api: true }),
  1793. );
  1794. const user = userEvent.setup();
  1795. render(<FileManagerPage />);
  1796. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1797. const card = await openMenu(user, 'bracket.stl');
  1798. const menu = within(card).getByText('Slice').closest('.fixed');
  1799. // Viewport-positioned, so no ancestor's overflow can cut it down.
  1800. expect(menu).not.toBeNull();
  1801. expect(within(menu as HTMLElement).getAllByRole('button')[0]).toHaveTextContent('Slice');
  1802. // And the card itself no longer clips what its children draw.
  1803. expect(card.className).not.toContain('overflow-hidden');
  1804. });
  1805. // #3029: Bambu Studio's protocol handler refuses anything that is not a
  1806. // 3MF before it even fetches the URL -- "Download failed, unknown file
  1807. // format." Offering the handoff anyway put an action on an STL card that
  1808. // could only fail, with an error that blamed the file.
  1809. it('hides the desktop handoff for an STL when the target is Bambu Studio', async () => {
  1810. server.use(
  1811. ...settingsHandlers({ preferred_slicer: 'bambu_studio' }),
  1812. );
  1813. const user = userEvent.setup();
  1814. render(<FileManagerPage />);
  1815. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1816. const card = await openMenu(user, 'bracket.stl');
  1817. expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
  1818. });
  1819. it('honours the open_in_slicer override over the preferred slicer', async () => {
  1820. // The desktop target is its own setting (#1329), so it -- not
  1821. // preferred_slicer, which drives the sidecar -- decides whether an STL
  1822. // can be handed over at all.
  1823. server.use(
  1824. ...settingsHandlers({ preferred_slicer: 'bambu_studio', open_in_slicer: 'orcaslicer' }),
  1825. );
  1826. const user = userEvent.setup();
  1827. render(<FileManagerPage />);
  1828. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1829. const card = await openMenu(user, 'bracket.stl');
  1830. await user.click(within(card).getByText('Slice'));
  1831. await waitFor(() => {
  1832. expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
  1833. });
  1834. });
  1835. it('still offers an STL to the in-app slicer with Bambu Studio as the desktop target', async () => {
  1836. // The restriction is on the URL handoff, not on the file: the sidecar
  1837. // slices an STL regardless of which desktop slicer is configured.
  1838. server.use(
  1839. ...settingsHandlers({ use_slicer_api: true, preferred_slicer: 'bambu_studio' }),
  1840. );
  1841. const user = userEvent.setup();
  1842. render(<FileManagerPage />);
  1843. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1844. const card = await openMenu(user, 'bracket.stl');
  1845. await user.click(within(card).getByText('Slice'));
  1846. expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
  1847. });
  1848. it('hides the slice item for already-sliced files', async () => {
  1849. const user = userEvent.setup();
  1850. render(<FileManagerPage />);
  1851. await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
  1852. const card = await openMenu(user, 'Benchy');
  1853. expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
  1854. });
  1855. // Permission gating is the security-relevant half of the slice action: the
  1856. // in-app API path needs library:upload, and the desktop handoff mirrors the
  1857. // ownership check the slicer-token endpoint runs — library:read_all or
  1858. // library:read_own. The legacy library:read is deliberately not accepted;
  1859. // it satisfies neither the token endpoint nor the folder listing that gets
  1860. // a user to this page at all.
  1861. const mockAuthUser = (permissions: string[]) => {
  1862. setAuthToken('test-token', 'session');
  1863. server.use(
  1864. http.get('*/api/v1/auth/status', () =>
  1865. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  1866. ),
  1867. http.get('*/api/v1/auth/me', () =>
  1868. HttpResponse.json({
  1869. id: 7,
  1870. username: 'operator1',
  1871. is_admin: false,
  1872. permissions,
  1873. }),
  1874. ),
  1875. http.get('/api/v1/users/', () => HttpResponse.json([])),
  1876. );
  1877. };
  1878. it('disables the Slice menu item without library:upload when the slicer API is enabled', async () => {
  1879. mockAuthUser([]);
  1880. server.use(
  1881. ...settingsHandlers({ use_slicer_api: true }),
  1882. );
  1883. const user = userEvent.setup();
  1884. render(<FileManagerPage />);
  1885. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1886. const card = await openMenu(user, 'bracket.stl');
  1887. const sliceItem = within(card).getByText('Slice').closest('button');
  1888. expect(sliceItem).toBeDisabled();
  1889. await user.click(sliceItem!);
  1890. expect(openInSlicer).not.toHaveBeenCalled();
  1891. });
  1892. it('enables the Slice menu item with library:upload when the slicer API is enabled', async () => {
  1893. mockAuthUser(['library:upload']);
  1894. server.use(
  1895. ...settingsHandlers({ use_slicer_api: true }),
  1896. );
  1897. const user = userEvent.setup();
  1898. render(<FileManagerPage />);
  1899. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1900. const card = await openMenu(user, 'bracket.stl');
  1901. const sliceItem = within(card).getByText('Slice').closest('button');
  1902. expect(sliceItem).not.toBeDisabled();
  1903. });
  1904. it('disables the Slice menu item without any library read permission for the desktop handoff', async () => {
  1905. mockAuthUser(['library:upload']);
  1906. const user = userEvent.setup();
  1907. render(<FileManagerPage />);
  1908. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1909. const card = await openMenu(user, 'bracket.stl');
  1910. const sliceItem = within(card).getByText('Slice').closest('button');
  1911. expect(sliceItem).toBeDisabled();
  1912. await user.click(sliceItem!);
  1913. expect(openInSlicer).not.toHaveBeenCalled();
  1914. });
  1915. it('enables the Slice menu item with library:read_own for the desktop handoff', async () => {
  1916. mockAuthUser(['library:read_own']);
  1917. const user = userEvent.setup();
  1918. render(<FileManagerPage />);
  1919. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1920. const card = await openMenu(user, 'bracket.stl');
  1921. const sliceItem = within(card).getByText('Slice').closest('button');
  1922. expect(sliceItem).not.toBeDisabled();
  1923. });
  1924. it('does not accept the legacy library:read for the desktop handoff', async () => {
  1925. // require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN) does no
  1926. // legacy expansion, so this group 403s on the slicer-token endpoint.
  1927. // Enabling the item would offer an action the server refuses, and the
  1928. // failure would look like "no slicer installed" once the fallback URL is
  1929. // handed over.
  1930. mockAuthUser(['library:read']);
  1931. const user = userEvent.setup();
  1932. render(<FileManagerPage />);
  1933. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1934. const card = await openMenu(user, 'bracket.stl');
  1935. const sliceItem = within(card).getByText('Slice').closest('button');
  1936. expect(sliceItem).toBeDisabled();
  1937. await user.click(sliceItem!);
  1938. expect(openInSlicer).not.toHaveBeenCalled();
  1939. });
  1940. it('slices from the list-view button when the slicer API is disabled', async () => {
  1941. const user = userEvent.setup();
  1942. render(<FileManagerPage />);
  1943. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1944. await user.click(screen.getByTitle('List view'));
  1945. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1946. const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
  1947. await user.click(within(row).getByTitle('Slice'));
  1948. await waitFor(() => {
  1949. expect(openInSlicer).toHaveBeenCalledWith(
  1950. expect.stringContaining('/library/files/2/dl/test-token/'),
  1951. 'orcaslicer',
  1952. );
  1953. });
  1954. });
  1955. it('slices from the list-view button into the in-app modal when the slicer API is enabled', async () => {
  1956. server.use(
  1957. ...settingsHandlers({ use_slicer_api: true }),
  1958. );
  1959. const user = userEvent.setup();
  1960. render(<FileManagerPage />);
  1961. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1962. await user.click(screen.getByTitle('List view'));
  1963. await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
  1964. const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
  1965. await user.click(within(row).getByTitle('Slice'));
  1966. expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
  1967. expect(openInSlicer).not.toHaveBeenCalled();
  1968. });
  1969. });
  1970. });