From aafbb77fcce9cf80316c6dea6e674fc0264baedf Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:23:48 +0000 Subject: [PATCH] Fix undefined trackGaEvent and give row downloads a busy state The LayerHub banner CTA called trackGaEvent, which is not defined anywhere in the project, so every click threw a ReferenceError and the banner_explore event was never recorded. Route it through trackUsageEvent like every other event in app.js, which already swallows provider failures. Row download buttons (JSON, BambuStudio JSON, .bbsflmt) fetch and sometimes zip before the browser saves a file, with no feedback for the whole wait and no guard against a repeat click. The bulk Download Selected / Download Bundle buttons already disable themselves and show a loading label; the per-row ones now get the equivalent through an is-busy class, aria-busy, and a spinner that works in both themes and respects prefers-reduced-motion. Generated-By: PostHog Desktop Task-Id: bab76398-4c5b-411a-812f-ebc00953b582 --- app.js | 34 +++++++++++++++++++++++++++- style.css | 32 ++++++++++++++++++++++++++ tests/banner-subscribe.test.js | 6 +++-- tests/row-download-busy.test.js | 40 +++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 tests/row-download-busy.test.js diff --git a/app.js b/app.js index 5824f133..a92e47d1 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' }); }); } @@ -1830,12 +1848,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 +1878,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 +1893,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 +1904,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 +1938,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 +1958,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 +1970,9 @@ function init() { } function doDownloadBundle() { + // Busy until downloadAsBbsflmt takes over — from there its own modals + // and progress are the visible feedback. + setRowDownloadBusy(bundleLink, true); // Fetch the preset JSON to get filament_vendor fetch(url, { mode: 'cors' }) .then(function (r) { @@ -1962,10 +1992,12 @@ function init() { filament_vendor: data.filament_vendor || ['Polymaker'], presetData: data }; + setRowDownloadBusy(bundleLink, false); downloadAsBbsflmt([preset]); }) .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..212ce2d3 100644 --- a/style.css +++ b/style.css @@ -1152,6 +1152,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..7cb7fa69 --- /dev/null +++ b/tests/row-download-busy.test.js @@ -0,0 +1,40 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +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')); + }); +});