diff --git a/app.js b/app.js index 5824f133..fa67b115 100644 --- a/app.js +++ b/app.js @@ -42,11 +42,29 @@ function trackUsageEvent(eventName, params) { } } +// Row download buttons fetch (and sometimes zip) before the browser saves a file. +// Without a busy state the button looks unresponsive for the whole wait, so users +// click again and start a second download of the same preset. +function setRowDownloadBusy(link, busy) { + if (!link) return; + if (busy) { + link.classList.add('is-busy'); + link.setAttribute('aria-busy', 'true'); + } else { + link.classList.remove('is-busy'); + link.removeAttribute('aria-busy'); + } +} + +function isRowDownloadBusy(link) { + return !!link && link.classList.contains('is-busy'); +} + function initBannerCta() { var cta = document.getElementById('layerhub-banner-cta'); if (!cta) return; cta.addEventListener('click', function () { - trackGaEvent('banner_explore', { destination: 'layerhub3d.com' }); + trackUsageEvent('banner_explore', { destination: 'layerhub3d.com' }); }); } @@ -466,26 +484,27 @@ function init() { */ function downloadAsBbsflmt(presets) { if (!presets || presets.length === 0) { - console.warn('No presets to bundle'); - return; + return Promise.resolve(); } - resolveBambuMappingsWithDedup(presets, function(filteredMappings) { - function doGenerate() { - generateAndDownloadBbsflmt(filteredMappings, presets); - } - if (!isBambuStudioSlicerSelected()) { - doGenerate(); - return; - } - showBambuRestartWarning(doGenerate, function () {}); - }, function(err) { - if (err) { - console.error('Error fetching presets:', err); - alert(t('alert.error.download', { msg: err.message })); - } else { - console.log('Export cancelled by user'); - } + // Keep the caller pending through mapping, dialogs, and ZIP generation. + // Cancellation resolves normally; failures reject to the download handler. + return new Promise(function(resolve, reject) { + resolveBambuMappingsWithDedup(presets, function(filteredMappings) { + function doGenerate() { + Promise.resolve().then(function() { + return generateAndDownloadBbsflmt(filteredMappings, presets); + }).then(resolve, reject); + } + if (!isBambuStudioSlicerSelected()) { + doGenerate(); + return; + } + showBambuRestartWarning(doGenerate, resolve); + }, function(err) { + if (err) reject(err); + else resolve(); + }); }); } @@ -544,7 +563,7 @@ function init() { zip.file('bundle_structure.json', JSON.stringify(structure, null, 2)); // Generate and download - zip.generateAsync({ type: 'blob' }).then(function(content) { + return zip.generateAsync({ type: 'blob' }).then(function(content) { var objectUrl = URL.createObjectURL(content); var a = document.createElement('a'); a.href = objectUrl; @@ -565,8 +584,6 @@ function init() { setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 1000); - }).catch(function(err) { - console.error('Error generating bundle:', err); }); } @@ -1830,12 +1847,14 @@ function init() { var directJsonLink = e.target.closest('a[data-download-url]'); if (directJsonLink) { e.preventDefault(); + if (isRowDownloadBusy(directJsonLink)) return; var djUrl = directJsonLink.getAttribute('data-download-url'); var djFilename = directJsonLink.getAttribute('data-download-filename') || 'preset.json'; if (!djUrl || djUrl === '#') { alert(t('alert.invalid.url')); return; } + setRowDownloadBusy(directJsonLink, true); fetch(djUrl, { mode: 'cors' }) .then(function (r) { if (!r.ok) throw new Error('Failed to fetch preset: ' + r.statusText); @@ -1858,9 +1877,11 @@ function init() { model: rowContext.model }); setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 1000); + setRowDownloadBusy(directJsonLink, false); }) .catch(function (err) { console.error('Error downloading JSON:', err); + setRowDownloadBusy(directJsonLink, false); alert(t('alert.error.preset', { msg: err.message })); }); return; @@ -1871,6 +1892,7 @@ function init() { var bambuJsonLink = e.target.closest('a[data-bambu-json="1"]'); if (bambuJsonLink) { e.preventDefault(); + if (isRowDownloadBusy(bambuJsonLink)) return; var bjUrl = bambuJsonLink.getAttribute('data-bundle-url'); var bjFilename = bambuJsonLink.getAttribute('data-bundle-filename') || 'preset.json'; var bjMaterial = bambuJsonLink.getAttribute('data-bundle-material') || ''; @@ -1881,6 +1903,7 @@ function init() { } function doDownloadBambuJson() { + setRowDownloadBusy(bambuJsonLink, true); fetch(bjUrl, { mode: 'cors' }) .then(function (r) { if (!r.ok) throw new Error('Failed to fetch preset: ' + r.statusText); @@ -1914,10 +1937,12 @@ function init() { model: rowContext.model }); setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 1000); + setRowDownloadBusy(bambuJsonLink, false); }); }) .catch(function (err) { console.error('Error downloading JSON:', err); + setRowDownloadBusy(bambuJsonLink, false); alert(t('alert.error.preset', { msg: err.message })); }); } @@ -1932,6 +1957,7 @@ function init() { var bundleLink = e.target.closest('a.btn-bundle'); if (bundleLink) { e.preventDefault(); + if (isRowDownloadBusy(bundleLink)) return; var url = bundleLink.getAttribute('data-bundle-url'); var filename = bundleLink.getAttribute('data-bundle-filename'); var material = bundleLink.getAttribute('data-bundle-material'); @@ -1943,6 +1969,8 @@ function init() { } function doDownloadBundle() { + // Keep busy through dialogs and ZIP generation, including cancellation. + setRowDownloadBusy(bundleLink, true); // Fetch the preset JSON to get filament_vendor fetch(url, { mode: 'cors' }) .then(function (r) { @@ -1962,10 +1990,14 @@ function init() { filament_vendor: data.filament_vendor || ['Polymaker'], presetData: data }; - downloadAsBbsflmt([preset]); + return downloadAsBbsflmt([preset]); + }) + .then(function () { + setRowDownloadBusy(bundleLink, false); }) .catch(function (err) { console.error('Error downloading bundle:', err); + setRowDownloadBusy(bundleLink, false); alert(t('alert.error.preset', { msg: err.message })); }); } diff --git a/style.css b/style.css index e3e2a156..408e8ff1 100644 --- a/style.css +++ b/style.css @@ -1075,7 +1075,8 @@ body.theme-wiki .dropdown-menu::-webkit-scrollbar-thumb { .preset-table th:nth-child(7), .preset-table td:nth-child(7) { - width: 10%; + /* Fit both download buttons and their busy indicators without ellipsis. */ + width: 12rem; } .preset-table th { @@ -1152,6 +1153,38 @@ body.theme-wiki .preset-table th { box-shadow: 0 2px 8px rgba(0, 204, 204, 0.4); } +/* Busy state while a row download fetches and packs its file, so the button + visibly reacts to the first click instead of looking unresponsive. */ +.preset-table td.td-actions .btn-download.is-busy, +.preset-table td.td-actions .btn-bundle.is-busy { + opacity: 0.6; + cursor: progress; + pointer-events: none; +} + +.preset-table td.td-actions .btn-download.is-busy::after, +.preset-table td.td-actions .btn-bundle.is-busy::after { + content: ''; + width: 0.7em; + height: 0.7em; + margin-left: 0.4em; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: row-download-spin 0.7s linear infinite; +} + +@keyframes row-download-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .preset-table td.td-actions .btn-download.is-busy::after, + .preset-table td.td-actions .btn-bundle.is-busy::after { + animation: none; + } +} + body.theme-wiki .preset-table td.td-actions .btn-download { color: #006E6E; background: rgba(0, 120, 124, 0.1); diff --git a/tests/banner-subscribe.test.js b/tests/banner-subscribe.test.js index 3be3c687..6cb0c466 100644 --- a/tests/banner-subscribe.test.js +++ b/tests/banner-subscribe.test.js @@ -119,10 +119,12 @@ describe('app.js CTA wiring', () => { it('initializes the banner CTA and tracks banner_explore', () => { assert.ok(appContent.includes('function initBannerCta()')); assert.ok(appContent.includes('initBannerCta();')); - assert.ok(appContent.includes("trackGaEvent('banner_explore'")); + assert.ok(appContent.includes("trackUsageEvent('banner_explore'")); assert.ok(appContent.includes("getElementById('layerhub-banner-cta')")); + // trackGaEvent was never defined, so the CTA threw instead of tracking. + assert.ok(!appContent.includes('trackGaEvent')); assert.ok(!appContent.includes('function initBannerSubscribe()')); - assert.ok(!appContent.includes("trackGaEvent('banner_subscribe'")); + assert.ok(!appContent.includes("trackUsageEvent('banner_subscribe'")); assert.ok(!appContent.includes('isSubscribeHoneypotFilled')); }); }); diff --git a/tests/row-download-busy.test.js b/tests/row-download-busy.test.js new file mode 100644 index 00000000..418b48d0 --- /dev/null +++ b/tests/row-download-busy.test.js @@ -0,0 +1,159 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); +const appContent = fs.readFileSync(path.join(root, 'app.js'), 'utf-8'); +const styleContent = fs.readFileSync(path.join(root, 'style.css'), 'utf-8'); + +describe('Row download busy state', () => { + it('exposes helpers that mark a row download button busy', () => { + assert.ok(appContent.includes('function setRowDownloadBusy(link, busy)')); + assert.ok(appContent.includes('function isRowDownloadBusy(link)')); + assert.ok(appContent.includes("link.classList.add('is-busy')")); + assert.ok(appContent.includes("link.setAttribute('aria-busy', 'true')")); + }); + + it('guards every async row download against a repeat click', () => { + assert.ok(appContent.includes('if (isRowDownloadBusy(directJsonLink)) return;')); + assert.ok(appContent.includes('if (isRowDownloadBusy(bambuJsonLink)) return;')); + assert.ok(appContent.includes('if (isRowDownloadBusy(bundleLink)) return;')); + }); + + it('clears the busy state on both success and failure', () => { + ['directJsonLink', 'bambuJsonLink', 'bundleLink'].forEach((link) => { + assert.ok(appContent.includes('setRowDownloadBusy(' + link + ', true)')); + const cleared = appContent.split('setRowDownloadBusy(' + link + ', false)').length - 1; + assert.ok(cleared >= 2, link + ' must clear busy on success and on error'); + }); + }); + + it('styles the busy state in a theme-neutral way', () => { + assert.ok(styleContent.includes('.btn-download.is-busy')); + assert.ok(styleContent.includes('.btn-bundle.is-busy')); + assert.ok(styleContent.includes('@keyframes row-download-spin')); + assert.ok(styleContent.includes('prefers-reduced-motion')); + }); +}); + +// Execute the production click handler and bundle lifecycle with a deferred ZIP. +// This catches early busy resets that source-string assertions cannot detect. +function bundleHarness(options = {}) { + const classes = new Set(); + const attrs = new Map(Object.entries({ + 'data-bundle-url': '/preset.json', 'data-bundle-filename': 'preset.json', + 'data-bundle-material': 'PLA', 'data-bundle-model': 'X1' + })); + const link = { + classList: { add: x => classes.add(x), remove: x => classes.delete(x), contains: x => classes.has(x) }, + setAttribute: (k, v) => attrs.set(k, v), removeAttribute: k => attrs.delete(k), + getAttribute: k => attrs.get(k) + }; + let resolveZip, rejectZip, handler, confirm, cancel; + let fetches = 0, saves = 0, alerts = 0; + const zipResult = new Promise((resolve, reject) => { resolveZip = resolve; rejectZip = reject; }); + const context = { + console: { log() {}, warn() {}, error() {} }, Promise, Blob, + setTimeout() {}, URL: { createObjectURL: () => 'blob:test', revokeObjectURL() {} }, + document: { createElement: () => ({ click: () => saves++ }), body: { appendChild() {}, removeChild() {} } }, + t: x => x, alert: () => alerts++, trackUsageEvent() {}, getGaValue: x => x, + fetch: () => { + fetches++; + return options.fetchError ? Promise.reject(new Error('Fetch failed')) : Promise.resolve({ + ok: true, json: () => Promise.resolve({ name: 'PLA', compatible_printers: ['X1'] }) + }); + }, + JSZip: function() { + if (options.syncZipError) throw new Error('ZIP unavailable'); + this.folder = () => ({ file() {} }); + this.file = () => {}; + this.generateAsync = () => zipResult; + }, + generateBundleStructureFromMappings: () => ({}), extractVendorFromPreset: () => 'Polymaker', + generateBundleFilename: () => 'PLA.bbsflmt', + resolveBambuMappingsWithDedup: (presets, resolved, cancelled) => { + if (options.mappingCancel) return cancelled(); + if (options.mappingError) return cancelled(new Error('Mapping failed')); + resolved([{ originalPreset: presets[0], presetData: presets[0].presetData, + generatedFilename: 'PLA @X1.json', printerName: 'X1' }]); + }, + isBambuStudioSlicerSelected: () => true, + showBambuRestartWarning: (yes, no) => { confirm = yes; cancel = no; }, + withMissingVariantWarning: (link, action) => action(), + tbody: { addEventListener: (name, callback) => { handler = callback; } } + }; + vm.createContext(context); + const helperStart = appContent.indexOf('function setRowDownloadBusy'); + vm.runInContext(appContent.slice(helperStart, appContent.indexOf('function initBannerCta', helperStart)), context); + for (const name of ['downloadAsBbsflmt', 'generateAndDownloadBbsflmt']) { + const start = appContent.indexOf(' function ' + name + '('); + const end = appContent.indexOf('\n }', start) + 4; + vm.runInContext(appContent.slice(start, end), context); + } + const start = appContent.indexOf(" tbody.addEventListener('click', function (e) {"); + vm.runInContext(appContent.slice(start, appContent.indexOf('\n render();', start)), context); + return { + click: () => handler({ preventDefault() {}, target: { closest: selector => selector === 'a.btn-bundle' ? link : null } }), + confirm: () => confirm(), cancel: () => cancel(), resolveZip, rejectZip, + busy: () => classes.has('is-busy'), ariaBusy: () => attrs.get('aria-busy'), + fetches: () => fetches, saves: () => saves, alerts: () => alerts + }; +} +const flush = () => new Promise(resolve => setImmediate(resolve)); + +describe('Bundle download lifecycle', () => { + it('stays busy through the dialog and ZIP generation and suppresses repeat clicks', async () => { + const h = bundleHarness(); + h.click(); + await flush(); + assert.equal(h.busy(), true); + assert.equal(h.ariaBusy(), 'true'); + h.click(); + assert.equal(h.fetches(), 1); + h.confirm(); + await flush(); + h.click(); + assert.equal(h.busy(), true); + assert.equal(h.fetches(), 1); + assert.equal(h.saves(), 0); + h.resolveZip(new Blob(['bundle'])); + await flush(); + assert.equal(h.saves(), 1); + assert.equal(h.busy(), false); + assert.equal(h.ariaBusy(), undefined); + }); + + it('clears busy after restart-warning cancellation and allows retry', async () => { + const h = bundleHarness(); + h.click(); + await flush(); + h.cancel(); + await flush(); + assert.equal(h.busy(), false); + assert.equal(h.saves(), 0); + h.click(); + assert.equal(h.fetches(), 2); + }); + + for (const option of ['fetchError', 'mappingCancel', 'mappingError', 'syncZipError', 'zipError']) { + it('clears busy on ' + option, async () => { + const h = bundleHarness({ [option]: true }); + h.click(); + await flush(); + if (option === 'syncZipError' || option === 'zipError') { + h.confirm(); + await flush(); + if (option === 'zipError') h.rejectZip(new Error('ZIP failed')); + await flush(); + } + assert.equal(h.busy(), false); + assert.equal(h.ariaBusy(), undefined); + assert.equal(h.saves(), 0); + assert.equal(h.alerts(), option === 'mappingCancel' ? 0 : 1); + }); + } +});