BugReportTriggerPlacement.test.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * Where the bug-report trigger lives, and what shape its panel takes (#2750,
  3. * reporter @goodjaltman).
  4. *
  5. * The floating disc is pinned to the bottom-right corner, which is the most
  6. * contended region in the app — the Profiles scroll-to-top FAB, the floating
  7. * camera window, the Group Edit save bar, the bulk-selection toolbars and the
  8. * per-card action buttons on File Manager and Archives all sit there, and a
  9. * `fixed` disc covers whichever happens to be underneath at the time. Below the
  10. * sidebar-compact breakpoint the trigger moves into the compact header instead,
  11. * which frees the corner outright.
  12. */
  13. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  14. import { waitFor, fireEvent, within } from '@testing-library/react';
  15. import userEvent from '@testing-library/user-event';
  16. import { render } from '../utils';
  17. import { Layout } from '../../components/Layout';
  18. import { BugReportBubble } from '../../components/BugReportBubble';
  19. import { http, HttpResponse } from 'msw';
  20. import { server } from '../mocks/server';
  21. /** The always-false stub from setup.ts, restored after each test. */
  22. const defaultMatchMedia = (query: string) => ({
  23. matches: false,
  24. media: query,
  25. onchange: null,
  26. addListener: () => {},
  27. removeListener: () => {},
  28. addEventListener: () => {},
  29. removeEventListener: () => {},
  30. dispatchEvent: () => true,
  31. });
  32. /**
  33. * Pretend the viewport is ``width`` px wide.
  34. *
  35. * Both breakpoint hooks read `window.innerWidth` for their initial value and
  36. * `matchMedia('(max-width: Npx)')` thereafter, so the stub has to answer the
  37. * query rather than return a fixed boolean — useIsMobile (768) and
  38. * useIsSidebarCompact (1144) ask different questions and a flat `true` would
  39. * conflate them.
  40. */
  41. function stubViewport(width: number) {
  42. Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: width });
  43. Object.defineProperty(window, 'matchMedia', {
  44. writable: true,
  45. configurable: true,
  46. value: (query: string) => {
  47. const max = /max-width:\s*(\d+)px/.exec(query);
  48. return { ...defaultMatchMedia(query), matches: max ? width <= Number(max[1]) : false };
  49. },
  50. });
  51. }
  52. function setupLayoutHandlers() {
  53. server.use(
  54. http.get('/api/v1/printers/', () => HttpResponse.json([])),
  55. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ connected: true, state: 'IDLE' })),
  56. http.get('/api/v1/version', () => HttpResponse.json({ version: '0.1.6', build: 'test' })),
  57. http.get('/api/v1/settings/', () =>
  58. HttpResponse.json({ check_updates: false, check_printer_firmware: false, auto_archive: true }),
  59. ),
  60. http.get('/api/v1/external-links/', () => HttpResponse.json([])),
  61. http.get('/api/v1/smart-plugs/', () => HttpResponse.json([])),
  62. http.get('/api/v1/support/debug-logging', () => HttpResponse.json({ enabled: false })),
  63. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  64. http.get('/api/v1/pending-uploads/count', () => HttpResponse.json({ count: 0 })),
  65. http.get('/api/v1/updates/check', () => HttpResponse.json({ update_available: false })),
  66. http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
  67. http.get('/api/v1/printers/developer-mode-warnings', () => HttpResponse.json([])),
  68. http.get('/api/v1/system/health', () => HttpResponse.json({ findings: [] })),
  69. );
  70. }
  71. /** The floating disc, identified by the shape only it has. */
  72. const floatingDisc = () =>
  73. Array.from(document.querySelectorAll('button')).find(
  74. (b) => b.className.includes('rounded-full') && b.className.includes('bottom-4'),
  75. );
  76. describe('bug-report trigger placement', () => {
  77. beforeEach(() => {
  78. vi.mocked(localStorage.getItem).mockReturnValue(null);
  79. setupLayoutHandlers();
  80. });
  81. afterEach(() => {
  82. Object.defineProperty(window, 'matchMedia', {
  83. writable: true,
  84. configurable: true,
  85. value: defaultMatchMedia,
  86. });
  87. });
  88. it('puts the trigger in the compact header and frees the corner below 1144px', async () => {
  89. stubViewport(390);
  90. render(<Layout />);
  91. await waitFor(() => expect(document.querySelector('header')).toBeInTheDocument());
  92. const header = document.querySelector('header')!;
  93. expect(within(header).getByRole('button', { name: /report a bug|bug/i })).toBeInTheDocument();
  94. expect(floatingDisc()).toBeUndefined();
  95. });
  96. it('keeps the floating disc when the sidebar is not compact', async () => {
  97. stubViewport(1440);
  98. render(<Layout />);
  99. await waitFor(() => expect(floatingDisc()).toBeDefined());
  100. // No compact header exists at this width, so there is nowhere else for it.
  101. expect(document.querySelector('header')).toBeNull();
  102. });
  103. it('opens the same panel from the header trigger', async () => {
  104. stubViewport(390);
  105. render(<Layout />);
  106. await waitFor(() => expect(document.querySelector('header')).toBeInTheDocument());
  107. const header = document.querySelector('header')!;
  108. fireEvent.click(within(header).getByRole('button', { name: /report a bug|bug/i }));
  109. await waitFor(() => expect(document.getElementById('bug-report-modal')).toBeInTheDocument());
  110. });
  111. });
  112. describe('bug-report panel geometry', () => {
  113. afterEach(() => {
  114. Object.defineProperty(window, 'matchMedia', {
  115. writable: true,
  116. configurable: true,
  117. value: defaultMatchMedia,
  118. });
  119. });
  120. it('is a full-width bottom sheet on a phone', async () => {
  121. // The regression: `fixed ... right-4 w-full max-w-md` resolves w-full
  122. // against the viewport, so on a 390px screen the panel was 390px wide and
  123. // then inset 16px from the right — putting its left edge at -16px and
  124. // cutting a strip of the form off-screen. max-w-md hid this above ~464px.
  125. stubViewport(390);
  126. render(<BugReportBubble open onOpenChange={() => {}} />);
  127. const panel = await waitFor(() => {
  128. const el = document.getElementById('bug-report-modal');
  129. expect(el).toBeInTheDocument();
  130. return el!;
  131. });
  132. expect(panel.className).toContain('inset-x-0');
  133. expect(panel.className).not.toContain('right-4');
  134. expect(panel.className).not.toContain('w-full');
  135. });
  136. it('anchors under the header when the trigger lives there (tablet band)', async () => {
  137. // 768-1143px: past the bottom-sheet breakpoint but the trigger is in the
  138. // compact header, so the panel must not open in a corner the user never
  139. // touched.
  140. stubViewport(900);
  141. render(<BugReportBubble showTrigger={false} open onOpenChange={() => {}} />);
  142. const panel = await waitFor(() => {
  143. const el = document.getElementById('bug-report-modal');
  144. expect(el).toBeInTheDocument();
  145. return el!;
  146. });
  147. expect(panel.className).toContain('top-16');
  148. expect(panel.className).not.toContain('bottom-20');
  149. });
  150. it('keeps the anchored card on desktop', async () => {
  151. stubViewport(1440);
  152. render(<BugReportBubble open onOpenChange={() => {}} />);
  153. const panel = await waitFor(() => {
  154. const el = document.getElementById('bug-report-modal');
  155. expect(el).toBeInTheDocument();
  156. return el!;
  157. });
  158. expect(panel.className).toContain('right-4');
  159. expect(panel.className).toContain('max-w-md');
  160. });
  161. });
  162. describe('bug-report form reset', () => {
  163. it('clears a half-filled form when reopened from a controlled trigger', async () => {
  164. // The reset used to live in the floating disc's click handler. With the
  165. // header trigger only flipping a controlled flag, that would have left the
  166. // previous draft sitting there for compact layouts.
  167. const user = userEvent.setup();
  168. const { rerender } = render(<BugReportBubble showTrigger={false} open onOpenChange={() => {}} />);
  169. const textarea = await waitFor(() => document.querySelector('textarea')!);
  170. await user.type(textarea, 'half-written report');
  171. expect(textarea).toHaveValue('half-written report');
  172. rerender(<BugReportBubble showTrigger={false} open={false} onOpenChange={() => {}} />);
  173. rerender(<BugReportBubble showTrigger={false} open onOpenChange={() => {}} />);
  174. await waitFor(() => expect(document.querySelector('textarea')).toHaveValue(''));
  175. });
  176. it('renders no floating disc when the trigger is hosted elsewhere', () => {
  177. render(<BugReportBubble showTrigger={false} />);
  178. expect(floatingDisc()).toBeUndefined();
  179. });
  180. });