Skip to content

Commit c8cc3be

Browse files
authored
Merge pull request #11342 from isaactony/123/feature/patron-exports-disabled
Patron exports: disable buttons, show “Downloading…”, prevent duplicates, restore on completion
2 parents 1e8486f + 05b9759 commit c8cc3be

File tree

2 files changed

+150
-0
lines changed

2 files changed

+150
-0
lines changed

openlibrary/plugins/openlibrary/js/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,12 @@ jQuery(function () {
228228
.then((module) => new module.LazyThingPreview().init());
229229
}
230230

231+
// Enhance patron export buttons on /account/import
232+
if (location.pathname === '/account/import') {
233+
import(/* webpackChunkName: "patron-exports" */ './patron_exports')
234+
.then(module => module.initPatronExportButtons());
235+
}
236+
231237
const $observationModalLinks = $('.observations-modal-link');
232238
const $notesModalLinks = $('.notes-modal-link');
233239
const $notesPageButtons = $('.note-page-buttons');
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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
6+
*
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.
11+
*/
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+
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+
buttonElement.setAttribute('disabled', 'true');
27+
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+
}
72+
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();
119+
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+
});
142+
}
143+
144+

0 commit comments

Comments
 (0)