From 8f2ebef235ed3726243a7daa924b316dc503667a Mon Sep 17 00:00:00 2001 From: Mark Daugherty Date: Thu, 13 Aug 2026 16:43:37 -0500 Subject: [PATCH] feat: add loading spinners to translate view action buttons Every async action button (Connect, Get status, Translate all, Cancel project, Copy all, per-language Cancel) now disables and shows a spinner while its handler is in flight, instead of giving no feedback during the request. Reuses/combines the two spinner patterns already in the repo rather than adding a third: the .nx-loading-spinner/da-spin naming from nx2/styles/buttons.css (defined but never wired to anything) with the currentcolor border technique from ew-actions.js's spinner, needed since these buttons render with different text colors per variant. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 2b29434ddf7d4cc9b718a115f3b3a4b11db89098) --- nx/blocks/loc/views/translate/translate.css | 20 +++ nx/blocks/loc/views/translate/translate.js | 144 ++++++++++++++------ 2 files changed, 120 insertions(+), 44 deletions(-) diff --git a/nx/blocks/loc/views/translate/translate.css b/nx/blocks/loc/views/translate/translate.css index 4ebf1f0dd..c4b0d4b97 100644 --- a/nx/blocks/loc/views/translate/translate.css +++ b/nx/blocks/loc/views/translate/translate.css @@ -2,6 +2,26 @@ svg { display: none; } +.nx-loading-spinner { + display: block; + width: 14px; + height: 14px; + border: 2px solid currentcolor; + border-top-color: transparent; + border-radius: 50%; + animation: da-spin 0.8s linear infinite; +} + +@keyframes da-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .nx-loading-spinner { + animation-duration: 1.5s; + } +} + p { margin: 0; line-height: 1; diff --git a/nx/blocks/loc/views/translate/translate.js b/nx/blocks/loc/views/translate/translate.js index f8a4445c9..53bcd1009 100644 --- a/nx/blocks/loc/views/translate/translate.js +++ b/nx/blocks/loc/views/translate/translate.js @@ -24,6 +24,12 @@ class NxLocTranslate extends LitElement { _translateLangs: { state: true }, _copyLangs: { state: true }, _message: { state: true }, + _connectBusy: { state: true }, + _sendAllBusy: { state: true }, + _getStatusBusy: { state: true }, + _cancelAllBusy: { state: true }, + _copyAllBusy: { state: true }, + _cancelingLangs: { state: true }, }; connectedCallback() { @@ -80,7 +86,12 @@ class NxLocTranslate extends LitElement { } async handleConnect() { - this._connected = await this._service.connector.connect(this._service); + this._connectBusy = true; + try { + this._connected = await this._service.connector.connect(this._service); + } finally { + this._connectBusy = false; + } } async fetchUrls(service, fetchContent, langs) { @@ -194,67 +205,91 @@ class NxLocTranslate extends LitElement { // return; // } - const conf = await this.getBaseTranslationConf(false); + this._getStatusBusy = true; + try { + const conf = await this.getBaseTranslationConf(false); - await this._service.connector.getStatusAll(removeWaitingLanguagesFromConf(conf)); + await this._service.connector.getStatusAll(removeWaitingLanguagesFromConf(conf)); - await this.checkAndSaveLangs(conf); + await this.checkAndSaveLangs(conf); - this.handleSaveLangs(); + this.handleSaveLangs(); + } finally { + this._getStatusBusy = false; + } } async handleCancelAll() { - const sendMessage = this.handleMessage.bind(this); + this._cancelAllBusy = true; + try { + const sendMessage = this.handleMessage.bind(this); - const { cancelTranslation } = this._service.connector; + const { cancelTranslation } = this._service.connector; - let shouldRefresh = false; - for (const lang of this._translateLangs) { - const result = await cancelTranslation({ service: this._service, lang, sendMessage }); - if (result?.ok !== false) shouldRefresh = true; - } + let shouldRefresh = false; + for (const lang of this._translateLangs) { + const result = await cancelTranslation({ service: this._service, lang, sendMessage }); + if (result?.ok !== false) shouldRefresh = true; + } - if (shouldRefresh) { - // Refresh locales GLaaS accepted; skip when every cancel was rejected. - await this.handleGetStatus(); + if (shouldRefresh) { + // Refresh locales GLaaS accepted; skip when every cancel was rejected. + await this.handleGetStatus(); + } + } finally { + this._cancelAllBusy = false; } } async handleCancelLang(lang) { - const sendMessage = this.handleMessage.bind(this); + this._cancelingLangs ??= new Set(); + this._cancelingLangs.add(lang.code); + this.requestUpdate(); + + try { + const sendMessage = this.handleMessage.bind(this); - const { cancelTranslation } = this._service.connector; + const { cancelTranslation } = this._service.connector; - const result = await cancelTranslation({ service: this._service, lang, sendMessage }); + const result = await cancelTranslation({ service: this._service, lang, sendMessage }); - if (result?.ok !== false) { - await this.handleGetStatus(); + if (result?.ok !== false) { + await this.handleGetStatus(); + } + } finally { + this._cancelingLangs.delete(lang.code); + this.requestUpdate(); } } async handleCopyAll() { - const { _copyLangs: langs } = this; + this._copyAllBusy = true; + try { + const { _copyLangs: langs } = this; - // langsWithUrls is an in-memory object that contains all URL fetches. - const { langsWithUrls, urls } = await this.fetchUrls({}, true, langs); - - langsWithUrls.forEach((lang) => { - const errors = lang.urls.filter((url) => url.error); - if (errors.length) { - // Create an errors array if it doesn't exist - this._urlErrors ??= []; - this._urlErrors.push(...errors); - } - }); + // langsWithUrls is an in-memory object that contains all URL fetches. + const { langsWithUrls, urls } = await this.fetchUrls({}, true, langs); + + langsWithUrls.forEach((lang) => { + const errors = lang.urls.filter((url) => url.error); + if (errors.length) { + // Create an errors array if it doesn't exist + this._urlErrors ??= []; + this._urlErrors.push(...errors); + } + }); - // Do not continue if any errors - if (this._urlErrors?.length) return; + // Do not continue if any errors + if (this._urlErrors?.length) return; - const { org, site, title, options } = this.project; + const { org, site, title, options } = this.project; - await copySourceLangs(org, site, title, options, this._copyLangs, urls, langsWithUrls); - this.handleSaveLangs(); - this.requestUpdate(); + await copySourceLangs(org, site, title, options, this._copyLangs, urls, langsWithUrls); + this.handleSaveLangs(); + this.requestUpdate(); + } finally { + this._copyAllBusy = false; + } } get _project() { @@ -281,11 +316,17 @@ class NxLocTranslate extends LitElement { return html`

Conflict behavior: ${this._options['translate.conflict.behavior']}

`; } + renderSpinner() { + return html``; + } + renderTranslateAction() { if (this._connected === false) { return html` ${this.renderBehavior()} - Connect + + ${this._connectBusy ? this.renderSpinner() : nothing} Connect + `; } @@ -298,14 +339,22 @@ class NxLocTranslate extends LitElement { if (sent) { return html` ${this.renderBehavior()} - ${this.canCancel ? html`Cancel project` : nothing} - Get status + ${this.canCancel ? html` + + ${this._cancelAllBusy ? this.renderSpinner() : nothing} Cancel project + + ` : nothing} + + ${this._getStatusBusy ? this.renderSpinner() : nothing} Get status + `; } return html` ${this.renderBehavior()} - Translate all + + ${this._sendAllBusy ? this.renderSpinner() : nothing} Translate all + `; } } @@ -327,7 +376,12 @@ class NxLocTranslate extends LitElement { renderCancelLang(lang) { if (!this.canCancel || !this._connected || !lang.translation || lang.translation?.status === 'cancelled') return nothing; - return html` this.handleCancelLang(lang)} class="primary outline">Cancel`; + const busy = this._cancelingLangs?.has(lang.code); + return html` + this.handleCancelLang(lang)} class="primary outline" ?disabled=${busy}> + ${busy ? this.renderSpinner() : nothing} Cancel + + `; } renderUrlErrors() { @@ -398,7 +452,9 @@ class NxLocTranslate extends LitElement {

Copy (${this._options['source.language'].name})

Conflict behavior: ${this._options['copy.conflict.behavior']}

- Copy all + + ${this._copyAllBusy ? this.renderSpinner() : nothing} Copy all +