|
1 | 1 | /** |
2 | | - * Enhances export buttons on /account/import so that when an export starts: |
3 | | - * - The clicked button shows "Downloading..." |
4 | | - * - The button is disabled and styled as unavailable (grey) |
5 | | - * - Subsequent clicks are prevented while the download is in progress |
| 2 | + * Switches the given button to a disabled state. |
6 | 3 | * |
7 | | - * Implementation detail: We use fetch() to retrieve the CSV as a Blob so we |
8 | | - * can reliably detect completion across browsers (some do not fire iframe |
9 | | - * load events when Content-Disposition: attachment is used). Once the blob is |
10 | | - * received, we trigger a programmatic download and then restore button state. |
| 4 | + * @param {HTMLElement} buttonElement |
11 | 5 | */ |
12 | | - |
13 | | -function getSubmitButton(formElement) { |
14 | | - // Each export form has a single submit input |
15 | | - return formElement.querySelector('input[type="submit"], button[type="submit"]'); |
16 | | -} |
17 | | - |
18 | 6 | function disableButton(buttonElement) { |
19 | | - if (!buttonElement) return; |
20 | | - buttonElement.dataset.originalValue = buttonElement.value || buttonElement.textContent || ''; |
21 | | - if (buttonElement.tagName === 'INPUT') { |
22 | | - buttonElement.value = 'Downloading...'; |
23 | | - } else { |
24 | | - buttonElement.textContent = 'Downloading...'; |
25 | | - } |
26 | 7 | buttonElement.setAttribute('disabled', 'true'); |
27 | 8 | buttonElement.setAttribute('aria-disabled', 'true'); |
28 | | - buttonElement.classList.remove('cta-btn--available'); |
29 | | - buttonElement.classList.add('cta-btn--unavailable'); |
30 | | - // Show loading affordance consistent with other unavailable actions |
31 | | - buttonElement.classList.add('cta-btn--unavailable--load'); |
32 | | -} |
33 | | - |
34 | | -function enableButton(buttonElement) { |
35 | | - if (!buttonElement) return; |
36 | | - const original = buttonElement.dataset.originalValue || ''; |
37 | | - if (buttonElement.tagName === 'INPUT') { |
38 | | - buttonElement.value = original; |
39 | | - } else { |
40 | | - buttonElement.textContent = original; |
41 | | - } |
42 | | - buttonElement.removeAttribute('disabled'); |
43 | | - buttonElement.setAttribute('aria-disabled', 'false'); |
44 | | - buttonElement.classList.remove('cta-btn--unavailable'); |
45 | | - buttonElement.classList.remove('cta-btn--unavailable--load'); |
46 | | - buttonElement.classList.add('cta-btn--available'); |
47 | | - delete buttonElement.dataset.originalValue; |
48 | | -} |
49 | | - |
50 | | -function buildGetUrlFromForm(formElement) { |
51 | | - const action = formElement.getAttribute('action') || ''; |
52 | | - const params = new URLSearchParams(new FormData(formElement)); |
53 | | - const query = params.toString(); |
54 | | - return query ? `${action}?${query}` : action; |
55 | | -} |
56 | | - |
57 | | -function parseFilenameFromContentDisposition(headerValue) { |
58 | | - if (!headerValue) return null; |
59 | | - // RFC 5987 filename*= |
60 | | - const starMatch = /filename\*=UTF-8''([^;]+)/i.exec(headerValue); |
61 | | - if (starMatch && starMatch[1]) { |
62 | | - try { |
63 | | - return decodeURIComponent(starMatch[1]); |
64 | | - } catch (_) { |
65 | | - return starMatch[1]; |
66 | | - } |
67 | | - } |
68 | | - // Basic filename="..." |
69 | | - const plainMatch = /filename="?([^";]+)"?/i.exec(headerValue); |
70 | | - return plainMatch && plainMatch[1] ? plainMatch[1] : null; |
71 | 9 | } |
72 | 10 |
|
73 | | -async function fetchAndDownload(url) { |
74 | | - const response = await fetch(url, { credentials: 'same-origin' }); |
75 | | - if (!response.ok) { |
76 | | - const error = new Error(`Export request failed with status ${response.status}`); |
77 | | - error.status = response.status; |
78 | | - throw error; |
79 | | - } |
80 | | - const blob = await response.blob(); |
81 | | - const contentDisposition = response.headers.get('Content-Disposition') || response.headers.get('content-disposition'); |
82 | | - const fallbackName = 'OpenLibrary_Export.csv'; |
83 | | - const filename = parseFilenameFromContentDisposition(contentDisposition) || fallbackName; |
84 | | - const objectUrl = URL.createObjectURL(blob); |
85 | | - const a = document.createElement('a'); |
86 | | - a.href = objectUrl; |
87 | | - a.download = filename; |
88 | | - document.body.appendChild(a); |
89 | | - a.click(); |
90 | | - setTimeout(() => { |
91 | | - URL.revokeObjectURL(objectUrl); |
92 | | - if (a.parentNode) a.parentNode.removeChild(a); |
93 | | - }, 0); |
94 | | -} |
95 | | - |
96 | | -export function initPatronExportButtons() { |
97 | | - // Guard: only on the import page |
98 | | - if (location.pathname !== '/account/import') return; |
99 | | - |
100 | | - const exportForms = document.querySelectorAll('form[action="/account/export"][method="GET" i]'); |
101 | | - if (!exportForms.length) return; |
102 | | - |
103 | | - exportForms.forEach((form) => { |
104 | | - const submitButton = getSubmitButton(form); |
105 | | - if (!submitButton) return; |
106 | | - |
107 | | - // Prevent double-binding |
108 | | - if (form.dataset.patronExportBound === 'true') return; |
109 | | - form.dataset.patronExportBound = 'true'; |
110 | | - |
111 | | - form.addEventListener('submit', (event) => { |
112 | | - // If already disabled, block to prevent duplicates |
113 | | - if (submitButton.hasAttribute('disabled')) { |
114 | | - event.preventDefault(); |
115 | | - return; |
116 | | - } |
117 | | - |
118 | | - event.preventDefault(); |
| 11 | +/** |
| 12 | + * Adds `submit` listeners for the given form elements. |
| 13 | + * |
| 14 | + * When any of the given forms are submitted, the form's |
| 15 | + * submit button is disabled. |
| 16 | + * |
| 17 | + * @param {NodeList<HTMLFormElement>} elems |
| 18 | + */ |
| 19 | +export function initPatronExportForms(elems) { |
| 20 | + elems.forEach((form) => { |
| 21 | + const submitButton = form.querySelector('input[type=submit]') |
| 22 | + form.addEventListener("submit", () => { |
119 | 23 | disableButton(submitButton); |
120 | | - const startMs = performance.now(); |
121 | | - const url = buildGetUrlFromForm(form); |
122 | | - fetchAndDownload(url) |
123 | | - .catch(() => { |
124 | | - // Swallow errors but restore button; |
125 | | - }) |
126 | | - .finally(() => { |
127 | | - // Ensure state stays visible for at least a short time |
128 | | - const minDurationMs = 500; |
129 | | - const elapsed = performance.now() - startMs; |
130 | | - const remaining = Math.max(0, minDurationMs - elapsed); |
131 | | - setTimeout(() => enableButton(submitButton), remaining); |
132 | | - }); |
133 | | - }); |
134 | | - |
135 | | - // Also handle direct button clicks that might bypass submit event ordering |
136 | | - submitButton.addEventListener('click', (event) => { |
137 | | - if (submitButton.hasAttribute('disabled')) { |
138 | | - event.preventDefault(); |
139 | | - } |
140 | | - }); |
141 | | - }); |
| 24 | + }) |
| 25 | + }) |
142 | 26 | } |
143 | | - |
144 | | - |
0 commit comments