diff --git a/app/javascript/__tests__/report_submit_controller.test.js b/app/javascript/__tests__/report_submit_controller.test.js new file mode 100644 index 0000000000..27aa46d293 --- /dev/null +++ b/app/javascript/__tests__/report_submit_controller.test.js @@ -0,0 +1,126 @@ +/* eslint-env jest, browser */ +import { Application } from '@hotwired/stimulus' +import ReportSubmitController from '../controllers/report_submit_controller' + +describe('report_submit_controller', () => { + let application + let form + let button + let originalHTML + + const formHTML = ` +
` + + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + + beforeEach(async () => { + document.body.innerHTML = formHTML + application = Application.start() + application.register('report-submit', ReportSubmitController) + await settle() + + form = document.querySelector('form') + form.submit = jest.fn() + button = form.querySelector('button') + originalHTML = button.innerHTML + jest.useFakeTimers() + }) + + afterEach(async () => { + jest.useRealTimers() + document.body.innerHTML = '' + await settle() + application.stop() + jest.restoreAllMocks() + }) + + test('prevents the default submit, disables the button, and submits the form natively once', () => { + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + button.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(button.disabled).toBe(true) + expect(button.textContent).toBe('Downloading report') + expect(form.submit).toHaveBeenCalledTimes(1) + expect(form.querySelector('input').value).toBe('2026-11-01') + }) + + test('restores the original markup and allows another export after three seconds', () => { + button.click() + button.click() + expect(form.submit).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(2999) + expect(button.disabled).toBe(true) + expect(button.textContent).toBe('Downloading report') + + jest.advanceTimersByTime(1) + expect(button.disabled).toBe(false) + expect(button.innerHTML).toBe(originalHTML) + + button.click() + expect(form.submit).toHaveBeenCalledTimes(2) + jest.advanceTimersByTime(3000) + expect(button.innerHTML).toBe(originalHTML) + }) + + test('handles a click on an icon inside the button', () => { + button.querySelector('i').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + + expect(button.disabled).toBe(true) + expect(button.textContent).toBe('Downloading report') + expect(form.submit).toHaveBeenCalledTimes(1) + }) + + test('connects buttons added after the initial page load and keeps their timers independent', async () => { + jest.useRealTimers() + document.body.insertAdjacentHTML('beforeend', formHTML) + await settle() + const secondForm = document.querySelectorAll('form')[1] + secondForm.submit = jest.fn() + const secondButton = secondForm.querySelector('button') + secondButton.dataset.disableWith = 'Downloading mileage report' + jest.useFakeTimers() + + button.click() + expect(secondButton.disabled).toBe(false) + jest.advanceTimersByTime(1000) + secondButton.click() + expect(secondButton.textContent).toBe('Downloading mileage report') + + jest.advanceTimersByTime(2000) + expect(button.disabled).toBe(false) + expect(secondButton.disabled).toBe(true) + jest.advanceTimersByTime(1000) + expect(secondButton.disabled).toBe(false) + expect(form.submit).toHaveBeenCalledTimes(1) + expect(secondForm.submit).toHaveBeenCalledTimes(1) + }) + + test('cancels a pending reset on disconnect and restores the button for reconnection', async () => { + button.click() + form.remove() + // Stimulus observes removals asynchronously, independently of the timer. + await Promise.resolve() + await Promise.resolve() + + expect(jest.getTimerCount()).toBe(0) + expect(button.disabled).toBe(false) + expect(button.innerHTML).toBe(originalHTML) + + jest.useRealTimers() + document.body.appendChild(form) + await settle() + jest.useFakeTimers() + button.click() + expect(form.submit).toHaveBeenCalledTimes(2) + jest.advanceTimersByTime(3000) + expect(button.disabled).toBe(false) + }) +}) diff --git a/app/javascript/application.js b/app/javascript/application.js index a959785cdf..bf1587d71d 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -18,7 +18,6 @@ require('./src/emancipations') require('./src/import') require('./src/password_confirmation') require('./src/read_more') -require('./src/reports') require('./src/require_communication_preference') require('./src/time_zone') require('./src/session_timeout_poller.js') diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 1be6b0a168..aa3a85b29a 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -64,6 +64,9 @@ application.register("multiple-select", MultipleSelectController) import NavDrawerController from "./nav_drawer_controller" application.register("nav-drawer", NavDrawerController) +import ReportSubmitController from "./report_submit_controller" +application.register("report-submit", ReportSubmitController) + import RevealController from "./reveal_controller" application.register("reveal", RevealController) diff --git a/app/javascript/controllers/report_submit_controller.js b/app/javascript/controllers/report_submit_controller.js new file mode 100644 index 0000000000..7cd5c653ee --- /dev/null +++ b/app/javascript/controllers/report_submit_controller.js @@ -0,0 +1,30 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + submit (event) { + event.preventDefault() + if (this.element.disabled) return + + this.originalHTML = this.element.innerHTML + this.element.disabled = true + this.element.innerHTML = this.element.dataset.disableWith + // A native submit starts the CSV download without Turbo handling the response. + this.element.form.submit() + + // Downloads do not reload the page, so restore the button for another export. + this.timer = setTimeout(() => this.restore(), 3000) + } + + disconnect () { + clearTimeout(this.timer) + this.restore() + } + + restore () { + if (this.originalHTML === undefined) return + + this.element.disabled = false + this.element.innerHTML = this.originalHTML + this.originalHTML = undefined + } +} diff --git a/app/javascript/src/reports.js b/app/javascript/src/reports.js deleted file mode 100644 index f8d5180d87..0000000000 --- a/app/javascript/src/reports.js +++ /dev/null @@ -1,22 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - document.addEventListener('click', (event) => { - if (event.target.matches('.report-form-submit')) { - return handleReportFormSubmit(event) - } - }) -}) - -const handleReportFormSubmit = (event) => { - event.preventDefault() - - const buttonText = event.target.innerHTML - - event.target.disabled = 'disabled' - event.target.innerHTML = event.target.dataset.disableWith - event.target.form.submit() - - setTimeout(() => { - event.target.disabled = false - event.target.innerHTML = buttonText - }, 3000) -} diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index b0a1813d7b..da5cc81d91 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -1,9 +1,8 @@ <% content_for :page_title, "Reports" %> <%# reports#index (the reports hub) on the casa_app shell. A list of one-click CSV exports + a - filterable Case Contacts Report builder. Export/submit buttons keep `.report-form-submit` + - `data-disable-with` — src/reports.js intercepts the click, swaps the whole button to the - disable-with text, and native-submits the form (so the CSV download bypasses Turbo); EVERY icon - inside these buttons is pointer-events-none so `event.target` is always the button. The four + filterable Case Contacts Report builder. Export/submit buttons use the `report-submit` + controller to swap the whole button to the `data-disable-with` text, native-submit the form + (so the CSV download bypasses Turbo), and restore the button after three seconds. The four filter selects are TomSelect multi-select type-aheads (`multiple-select` controller), each on its own line; they keep their spec-locked ids (multiple-select-field1..4) and specs drive them via the .ts-control (see spec/system/reports). The column filter is a native