FileManagerPage.test.tsx 88 KB

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