LibraryFileDetailsModal.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. /**
  2. * Tests for the LibraryFileDetailsModal component (#3077).
  3. */
  4. import { describe, it, expect, vi, beforeEach } from 'vitest';
  5. import { screen, waitFor } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { LibraryFileDetailsModal } from '../../components/LibraryFileDetailsModal';
  9. import type { LibraryFileListItem } from '../../api/client';
  10. import { http, HttpResponse } from 'msw';
  11. import { server } from '../mocks/server';
  12. const listItem: LibraryFileListItem = {
  13. id: 7,
  14. folder_id: null,
  15. is_external: false,
  16. filename: 'benchy.gcode.3mf',
  17. file_type: 'gcode.3mf',
  18. file_size: 1048576,
  19. thumbnail_path: null,
  20. print_count: 2,
  21. duplicate_count: 0,
  22. created_by_id: null,
  23. created_by_username: null,
  24. created_at: '2024-01-01T00:00:00Z',
  25. fs_modified_at: null,
  26. print_name: 'Benchy',
  27. print_time_seconds: 3600,
  28. filament_used_grams: 12.5,
  29. sliced_for_model: 'X1C',
  30. tags: [],
  31. };
  32. const details = {
  33. ...listItem,
  34. folder_name: null,
  35. project_id: null,
  36. project_name: null,
  37. file_path: 'library/files/benchy.gcode.3mf',
  38. file_hash: null,
  39. metadata: null,
  40. last_printed_at: null,
  41. notes: 'Print with brim',
  42. external_url: 'https://www.printables.com/model/1',
  43. photos: ['abc123.jpg'],
  44. source_url: 'https://makerworld.com/models/42',
  45. duplicates: null,
  46. updated_at: '2024-02-01T00:00:00Z',
  47. };
  48. describe('LibraryFileDetailsModal', () => {
  49. const onClose = vi.fn();
  50. let lastUpdate: Record<string, unknown> | null = null;
  51. beforeEach(() => {
  52. vi.clearAllMocks();
  53. lastUpdate = null;
  54. server.use(
  55. http.get('/api/v1/library/files/7', () => HttpResponse.json(details)),
  56. http.put('/api/v1/library/files/7', async ({ request }) => {
  57. lastUpdate = (await request.json()) as Record<string, unknown>;
  58. return HttpResponse.json({ ...details, ...lastUpdate });
  59. }),
  60. http.delete('/api/v1/library/files/7/photos/:filename', () =>
  61. HttpResponse.json({ status: 'deleted', photos: [] })
  62. )
  63. );
  64. });
  65. it('renders the facts, notes, link and photos from the detail response', async () => {
  66. render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  67. expect(screen.getByText('benchy.gcode.3mf')).toBeInTheDocument();
  68. // Header badge and the Type fact.
  69. expect(screen.getAllByText('GCODE.3MF')).toHaveLength(2);
  70. expect(screen.getByText('1.0 MB')).toBeInTheDocument();
  71. await waitFor(() => {
  72. expect(screen.getByDisplayValue('Print with brim')).toBeInTheDocument();
  73. });
  74. expect(screen.getByDisplayValue('https://www.printables.com/model/1')).toBeInTheDocument();
  75. expect(screen.getByText('Benchy')).toBeInTheDocument();
  76. expect(screen.getByText('X1C')).toBeInTheDocument();
  77. expect(screen.getByText('12.5 g')).toBeInTheDocument();
  78. // Source provenance is a link, not an editable field.
  79. const source = screen.getByRole('link', { name: /makerworld\.com\/models\/42/ });
  80. expect(source).toHaveAttribute('href', 'https://makerworld.com/models/42');
  81. expect(source).toHaveAttribute('target', '_blank');
  82. const photo = screen.getByAltText('Photos') as HTMLImageElement;
  83. expect(photo.src).toContain('/library/files/7/photos/abc123.jpg');
  84. });
  85. it('saves edited notes and link through updateLibraryFile', async () => {
  86. const user = userEvent.setup();
  87. render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  88. const notes = await screen.findByDisplayValue('Print with brim');
  89. const saveButton = screen.getByRole('button', { name: /save/i });
  90. // Nothing changed yet, so there is nothing to save.
  91. expect(saveButton).toBeDisabled();
  92. await user.clear(notes);
  93. await user.type(notes, 'Use 0.2 mm layers');
  94. const link = screen.getByDisplayValue('https://www.printables.com/model/1');
  95. await user.clear(link);
  96. await user.type(link, 'https://example.com/part ');
  97. expect(saveButton).toBeEnabled();
  98. await user.click(saveButton);
  99. await waitFor(() => {
  100. expect(lastUpdate).toEqual({ notes: 'Use 0.2 mm layers', external_url: 'https://example.com/part' });
  101. });
  102. await waitFor(() => expect(onClose).toHaveBeenCalled());
  103. });
  104. it('sends an empty external_url when the link is cleared', async () => {
  105. const user = userEvent.setup();
  106. render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  107. const link = await screen.findByDisplayValue('https://www.printables.com/model/1');
  108. await user.clear(link);
  109. await user.click(screen.getByRole('button', { name: /save/i }));
  110. await waitFor(() => {
  111. expect(lastUpdate).toEqual({ notes: 'Print with brim', external_url: '' });
  112. });
  113. });
  114. it('is read-only without edit permission', async () => {
  115. render(<LibraryFileDetailsModal file={listItem} canEdit={false} onClose={onClose} />);
  116. const notes = await screen.findByDisplayValue('Print with brim');
  117. expect(notes).toBeDisabled();
  118. expect(screen.getByDisplayValue('https://www.printables.com/model/1')).toBeDisabled();
  119. expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
  120. expect(screen.queryByLabelText('Add photo')).not.toBeInTheDocument();
  121. expect(screen.queryByLabelText('Delete photo')).not.toBeInTheDocument();
  122. });
  123. it('removes a photo from the grid after deleting it', async () => {
  124. const user = userEvent.setup();
  125. render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  126. await screen.findByAltText('Photos');
  127. await user.click(screen.getByLabelText('Delete photo'));
  128. await waitFor(() => {
  129. expect(screen.queryByAltText('Photos')).not.toBeInTheDocument();
  130. });
  131. });
  132. it('keeps unsaved notes when a photo change refetches the file', async () => {
  133. // Deleting a photo invalidates the detail query; the refetched file has a
  134. // different photo list, so it is a new object and must not reseed the form.
  135. let serverPhotos = ['abc123.jpg'];
  136. let detailFetches = 0;
  137. server.use(
  138. http.get('/api/v1/library/files/7', () => {
  139. detailFetches += 1;
  140. return HttpResponse.json({ ...details, photos: serverPhotos });
  141. }),
  142. http.delete('/api/v1/library/files/7/photos/:filename', () => {
  143. serverPhotos = [];
  144. return HttpResponse.json({ status: 'deleted', photos: [] });
  145. })
  146. );
  147. const user = userEvent.setup();
  148. render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  149. const notes = await screen.findByDisplayValue('Print with brim');
  150. await user.clear(notes);
  151. await user.type(notes, 'Draft not saved yet');
  152. await user.click(screen.getByLabelText('Delete photo'));
  153. await waitFor(() => {
  154. expect(screen.queryByAltText('Photos')).not.toBeInTheDocument();
  155. });
  156. await waitFor(() => expect(detailFetches).toBeGreaterThan(1));
  157. expect(screen.getByDisplayValue('Draft not saved yet')).toBeInTheDocument();
  158. expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
  159. });
  160. describe('lightbox', () => {
  161. beforeEach(() => {
  162. server.use(
  163. http.get('/api/v1/library/files/7', () =>
  164. HttpResponse.json({ ...details, photos: ['one.jpg', 'two.jpg', 'three.jpg'] })
  165. )
  166. );
  167. });
  168. it('opens on the photo that was clicked, not the first one', async () => {
  169. const user = userEvent.setup();
  170. render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  171. const thumbnails = await screen.findAllByAltText('Photos');
  172. expect(thumbnails).toHaveLength(3);
  173. await user.click(thumbnails[2]);
  174. expect(await screen.findByText('Photo 3 of 3')).toBeInTheDocument();
  175. const shown = screen.getByAltText('Photo 3') as HTMLImageElement;
  176. expect(shown.src).toContain('/library/files/7/photos/three.jpg');
  177. });
  178. it('keeps the details modal open when the delete confirmation is dismissed', async () => {
  179. // The confirmation renders inside the details overlay, whose root closes
  180. // on a backdrop click — dismissing the confirmation used to discard the
  181. // unsaved notes and link along with it.
  182. const user = userEvent.setup();
  183. const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  184. const notes = await screen.findByDisplayValue('Print with brim');
  185. await user.clear(notes);
  186. await user.type(notes, 'Draft not saved yet');
  187. await user.click(screen.getAllByAltText('Photos')[0]);
  188. await screen.findByText('Photo 1 of 3');
  189. await user.click(container.querySelector('.text-red-400') as HTMLElement);
  190. const confirmation = await screen.findByText('Delete Photo');
  191. await user.click(confirmation.closest('div.fixed') as HTMLElement);
  192. await waitFor(() => expect(screen.queryByText('Delete Photo')).not.toBeInTheDocument());
  193. expect(screen.getByText('Photo 1 of 3')).toBeInTheDocument();
  194. expect(screen.getByDisplayValue('Draft not saved yet')).toBeInTheDocument();
  195. expect(onClose).not.toHaveBeenCalled();
  196. });
  197. it('closes the details modal on Escape, and only the lightbox while that is open', async () => {
  198. const user = userEvent.setup();
  199. render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  200. await screen.findByDisplayValue('Print with brim');
  201. await user.click(screen.getAllByAltText('Photos')[1]);
  202. await screen.findByText('Photo 2 of 3');
  203. await user.keyboard('{Escape}');
  204. await waitFor(() => expect(screen.queryByText('Photo 2 of 3')).not.toBeInTheDocument());
  205. expect(onClose).not.toHaveBeenCalled();
  206. await user.keyboard('{Escape}');
  207. await waitFor(() => expect(onClose).toHaveBeenCalled());
  208. });
  209. it('keeps the lightbox open when the delete confirmation is dismissed with Escape', async () => {
  210. // The confirmation has its own Escape handler. With the gallery's still
  211. // listening, one press cancelled the prompt and closed the gallery under
  212. // it, losing the user's place in the photo list.
  213. const user = userEvent.setup();
  214. const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  215. await screen.findByDisplayValue('Print with brim');
  216. await user.click(screen.getAllByAltText('Photos')[1]);
  217. await screen.findByText('Photo 2 of 3');
  218. await user.click(container.querySelector('.text-red-400') as HTMLElement);
  219. await screen.findByText('Delete Photo');
  220. await user.keyboard('{Escape}');
  221. await waitFor(() => expect(screen.queryByText('Delete Photo')).not.toBeInTheDocument());
  222. expect(screen.getByText('Photo 2 of 3')).toBeInTheDocument();
  223. expect(onClose).not.toHaveBeenCalled();
  224. });
  225. describe('after the last photo is deleted from inside the lightbox', () => {
  226. beforeEach(() => {
  227. server.use(
  228. http.get('/api/v1/library/files/7', () => HttpResponse.json({ ...details, photos: ['only.jpg'] }))
  229. );
  230. });
  231. const deleteTheOnlyPhotoFromTheLightbox = async (
  232. user: ReturnType<typeof userEvent.setup>,
  233. container: HTMLElement
  234. ) => {
  235. await screen.findByDisplayValue('Print with brim');
  236. await user.click(screen.getByAltText('Photos'));
  237. await screen.findByText('Photo 1 of 1');
  238. await user.click(container.querySelector('.text-red-400') as HTMLElement);
  239. await user.click(await screen.findByRole('button', { name: /^delete$/i }));
  240. await waitFor(() => expect(screen.queryByText('Photo 1 of 1')).not.toBeInTheDocument());
  241. };
  242. it('leaves Escape closing the details modal', async () => {
  243. // Emptying the list unmounts the gallery before its own "nothing left
  244. // to show" branch can call onClose, so the details modal never learned
  245. // the lightbox had gone and kept its Escape handler stood down.
  246. const user = userEvent.setup();
  247. const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  248. await deleteTheOnlyPhotoFromTheLightbox(user, container);
  249. await user.keyboard('{Escape}');
  250. await waitFor(() => expect(onClose).toHaveBeenCalled());
  251. });
  252. it('does not re-open the lightbox when the next photo is uploaded', async () => {
  253. server.use(
  254. http.post('/api/v1/library/files/7/photos', () =>
  255. HttpResponse.json({ status: 'uploaded', photos: ['replacement.jpg'] })
  256. )
  257. );
  258. const user = userEvent.setup();
  259. const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
  260. await deleteTheOnlyPhotoFromTheLightbox(user, container);
  261. await user.upload(
  262. screen.getByLabelText('Add photo'),
  263. new File(['jpeg'], 'replacement.jpg', { type: 'image/jpeg' })
  264. );
  265. const photo = (await screen.findByAltText('Photos')) as HTMLImageElement;
  266. expect(photo.src).toContain('/library/files/7/photos/replacement.jpg');
  267. expect(screen.queryByText('Photo 1 of 1')).not.toBeInTheDocument();
  268. });
  269. });
  270. });
  271. });