TouchReachableActions.test.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /**
  2. * Card and row actions must stay reachable without a hover-capable pointer (#2865).
  3. *
  4. * Tailwind v4 compiles `group-hover:` inside `@media (hover: hover)`, so on a
  5. * touch-only device the reveal rule is never applied and a control written as
  6. * `opacity-0 group-hover:opacity-100` is invisible for good — which is how the
  7. * project card's action menu and the File Manager's folder actions became
  8. * unusable on a phone. The fix moves the HIDING half behind a `can-hover`
  9. * variant, so with no such pointer the control simply keeps its own opacity.
  10. *
  11. * jsdom does not evaluate media queries, so these tests pin the class contract
  12. * rather than the computed style: a bare `opacity-0` is the defect, because it
  13. * applies unconditionally while everything that undoes it does not.
  14. */
  15. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  16. import { screen, waitFor, within } from '@testing-library/react';
  17. import userEvent from '@testing-library/user-event';
  18. import { http, HttpResponse } from 'msw';
  19. import { render } from '../utils';
  20. import { server } from '../mocks/server';
  21. import { ProjectsPage } from '../../pages/ProjectsPage';
  22. import { FileManagerPage } from '../../pages/FileManagerPage';
  23. import { setAuthToken } from '../../api/client';
  24. /** `opacity-0` on its own — with no variant in front of it. */
  25. const UNCONDITIONALLY_HIDDEN = /(^|\s)opacity-0(\s|$)/;
  26. const mockProjects = [
  27. {
  28. id: 1,
  29. name: 'Functional Parts',
  30. description: 'Useful household items',
  31. color: '#00ae42',
  32. archive_count: 10,
  33. total_print_time_seconds: 36000,
  34. total_filament_grams: 500,
  35. created_at: '2024-01-01T00:00:00Z',
  36. updated_at: '2024-01-15T00:00:00Z',
  37. },
  38. ];
  39. const mockFolders = [
  40. {
  41. id: 1,
  42. name: 'Brackets',
  43. parent_id: null,
  44. file_count: 0,
  45. project_id: null,
  46. archive_id: null,
  47. project_name: null,
  48. archive_name: null,
  49. is_external: false,
  50. children: [],
  51. },
  52. ];
  53. describe('actions that were hover-only (#2865)', () => {
  54. afterEach(() => {
  55. setAuthToken(null);
  56. });
  57. describe('project card', () => {
  58. beforeEach(() => {
  59. server.use(http.get('/api/v1/projects/', () => HttpResponse.json(mockProjects)));
  60. });
  61. it('does not hide the action menu from a pointer that cannot hover', async () => {
  62. render(<ProjectsPage />);
  63. await waitFor(() => expect(screen.getByText('Functional Parts')).toBeInTheDocument());
  64. const card = screen.getByText('Functional Parts').closest('div.group')!;
  65. const menuButton = within(card).getAllByRole('button').slice(-1)[0];
  66. expect(menuButton.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
  67. expect(menuButton.className).toContain('can-hover:opacity-0');
  68. });
  69. it('still opens Edit and Delete once the menu is tapped', async () => {
  70. render(<ProjectsPage />);
  71. await waitFor(() => expect(screen.getByText('Functional Parts')).toBeInTheDocument());
  72. const card = screen.getByText('Functional Parts').closest('div.group')!;
  73. const user = userEvent.setup();
  74. await user.click(within(card).getAllByRole('button').slice(-1)[0]);
  75. expect(within(card).getByRole('button', { name: 'Edit' })).toBeInTheDocument();
  76. expect(within(card).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
  77. });
  78. });
  79. describe('file manager folder row', () => {
  80. beforeEach(() => {
  81. localStorage.clear();
  82. setAuthToken('test-token', 'session');
  83. server.use(
  84. http.get('*/api/v1/auth/status', () =>
  85. HttpResponse.json({ auth_enabled: true, requires_setup: false }),
  86. ),
  87. http.get('*/api/v1/auth/me', () =>
  88. HttpResponse.json({
  89. id: 7,
  90. username: 'operator1',
  91. is_admin: false,
  92. permissions: ['library:read_own', 'library:update_all', 'library:delete_all'],
  93. }),
  94. ),
  95. http.get('/api/v1/library/folders', () => HttpResponse.json(mockFolders)),
  96. http.get('/api/v1/library/files', () => HttpResponse.json([])),
  97. http.get('/api/v1/library/stats', () =>
  98. HttpResponse.json({
  99. total_files: 0,
  100. total_folders: 1,
  101. total_size_bytes: 0,
  102. disk_free_bytes: 10737418240,
  103. disk_total_bytes: 107374182400,
  104. }),
  105. ),
  106. http.get('/api/v1/projects/', () => HttpResponse.json([])),
  107. http.get('/api/v1/archives/', () => HttpResponse.json([])),
  108. );
  109. });
  110. it('does not hide the folder actions from a pointer that cannot hover', async () => {
  111. render(<FileManagerPage />);
  112. await waitFor(() => expect(screen.getByText('Brackets')).toBeInTheDocument());
  113. const row = screen.getByText('Brackets').closest('div.group')!;
  114. // The kebab menu's wrapper is what carries the visibility classes.
  115. const actions = within(row).getAllByRole('button').slice(-1)[0].closest('div.flex-shrink-0')!;
  116. expect(actions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
  117. expect(actions.className).toContain('can-hover:opacity-0');
  118. });
  119. // #3020 follow-up: the columns view reveals its folder kebab and its
  120. // per-file icon strip on hover, under the same rule.
  121. it('does not hide the columns view folder and file actions from a pointer that cannot hover', async () => {
  122. server.use(
  123. http.get('/api/v1/library/files', () =>
  124. HttpResponse.json([
  125. {
  126. id: 1,
  127. filename: 'benchy.gcode.3mf',
  128. file_path: '/library/benchy.gcode.3mf',
  129. file_size: 1048576,
  130. file_type: 'gcode.3mf',
  131. folder_id: null,
  132. thumbnail_path: null,
  133. print_name: 'Benchy',
  134. print_time_seconds: 3600,
  135. print_count: 0,
  136. duplicate_count: 0,
  137. created_at: '2024-01-01T00:00:00Z',
  138. },
  139. ]),
  140. ),
  141. );
  142. render(<FileManagerPage />);
  143. await waitFor(() => expect(screen.getByText('Brackets')).toBeInTheDocument());
  144. const user = userEvent.setup();
  145. await user.click(screen.getByTitle('Column view'));
  146. const columns = within(screen.getByTestId('columns-view'));
  147. const folderKebab = within(columns.getByText('Brackets').closest('[data-folder-id]') as HTMLElement).getByTitle('Actions');
  148. const folderActions = folderKebab.closest('div.flex-shrink-0')!;
  149. expect(folderActions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
  150. expect(folderActions.className).toContain('can-hover:opacity-0');
  151. const fileRow = columns.getByText('Benchy').closest('[data-file-id]') as HTMLElement;
  152. const fileActions = fileRow.querySelector('[data-file-actions]')!.parentElement!;
  153. expect(fileActions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
  154. expect(fileActions.className).toContain('can-hover:opacity-0');
  155. expect(within(fileRow).getByTitle('Rename')).toBeInTheDocument();
  156. });
  157. });
  158. });