Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions app/javascript/__tests__/report_submit_controller.test.js
Original file line number Diff line number Diff line change
@@ -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 = `
<form action="/case_contact_reports.csv" method="get">
<input name="report[start_date]" value="2026-11-01">
<button type="submit" data-controller="report-submit"
data-action="click->report-submit#submit" data-disable-with="Downloading report">
<i class="bi bi-download" aria-hidden="true"></i> Download report
</button>
</form>`

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)
})
})
1 change: 0 additions & 1 deletion app/javascript/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
3 changes: 3 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
30 changes: 30 additions & 0 deletions app/javascript/controllers/report_submit_controller.js
Original file line number Diff line number Diff line change
@@ -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
}
}
22 changes: 0 additions & 22 deletions app/javascript/src/reports.js

This file was deleted.

11 changes: 5 additions & 6 deletions app/views/reports/index.html.erb
Original file line number Diff line number Diff line change
@@ -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 <details>. Several
Expand Down Expand Up @@ -39,7 +38,7 @@
<%= form_with url: url, method: :get do %>
<%# flex-1 on the label makes it fill the row so the download icon sits hard right and the
label reads from the left -- button_classes bakes in justify-center, which flex-1 makes moot. %>
<%= button_tag type: "submit", class: "report-form-submit #{button_classes(:secondary)} w-full", data: {disable_with: feedback} do %>
<%= button_tag type: "submit", class: "report-form-submit #{button_classes(:secondary)} w-full", data: {controller: "report-submit", action: "click->report-submit#submit", disable_with: feedback} do %>
<span class="pointer-events-none flex flex-1 items-center gap-2 text-left"><i class="bi <%= icon %>" aria-hidden="true"></i> <%= label %></span>
<i class="bi bi-download pointer-events-none text-slate-400" aria-hidden="true"></i>
<% end %>
Expand Down Expand Up @@ -122,7 +121,7 @@
</details>

<div>
<%= button_tag type: "submit", class: "report-form-submit #{button_classes(:primary)}", data: {disable_with: "Downloading report"} do %>
<%= button_tag type: "submit", class: "report-form-submit #{button_classes(:primary)}", data: {controller: "report-submit", action: "click->report-submit#submit", disable_with: "Downloading report"} do %>
<i class="bi bi-download pointer-events-none" aria-hidden="true"></i> Download report
<% end %>
</div>
Expand Down
Loading