diff --git a/.jules/palette.md b/.jules/palette.md index a1633627..77b98dcd 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,7 @@ ## 2024-05-18 - Disabled and Loading States for Async Actions **Learning:** Adding explicit loading and disabled states to asynchronous action buttons (like "Refresh") provides immediate feedback, reducing user confusion and preventing double-submissions. **Action:** Always ensure that buttons triggering network requests visually indicate the loading state and are disabled until the request completes. + +## 2026-07-06 - Async Button Loading States +**Learning:** Asynchronous action buttons (e.g., Refresh, Submit) in this app require explicit visual loading states (updating textContent and disabled=true) to prevent double-submissions and improve UX. +**Action:** Always implement disabled and text state changes on buttons triggering network requests, restoring them in a finally block. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..3f327cd6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# 변경 사항 (CHANGELOG) + +## [Unreleased] +### 추가된 기능 (Added) +- `demo.js` 내의 비동기 버튼(제출, 재시도, 새로고침)에 명시적인 시각적 로딩 상태(버튼 비활성화 및 텍스트 변경)를 추가하여 중복 제출을 방지하고 사용자 경험(UX)을 향상시켰습니다. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 84eb8f3a..ddf5b2f0 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -271,6 +271,8 @@ async function retryActiveJob() { const jobId = activeJobDetail.jobId; setStatus("Requesting operator retry..."); el.retryJobBtn.disabled = true; + const originalText = el.retryJobBtn.textContent; + el.retryJobBtn.textContent = "Retrying..."; try { const res = await fetch(`/api/v1/convert/jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST", @@ -308,6 +310,7 @@ async function retryActiveJob() { setError("Network error while requesting retry. Retry when the service is reachable."); } finally { el.retryJobBtn.disabled = false; + el.retryJobBtn.textContent = originalText; } } @@ -380,6 +383,9 @@ async function refreshKpis() { } async function refreshKpiEvidence() { + el.refreshEvidenceBtn.disabled = true; + const originalText = el.refreshEvidenceBtn.textContent; + el.refreshEvidenceBtn.textContent = "Refreshing..."; try { const { res, data } = await fetchJson(KPI_EXPORTS_ENDPOINT); if (!res.ok) { @@ -390,6 +396,9 @@ async function refreshKpiEvidence() { renderKpiEvidence(data); } catch (err) { el.kpiExportStatus.textContent = "Snapshot evidence is unavailable while the service is unreachable."; + } finally { + el.refreshEvidenceBtn.disabled = false; + el.refreshEvidenceBtn.textContent = originalText; } } @@ -436,6 +445,8 @@ async function submitDocument(event) { } el.submitBtn.disabled = true; + const originalText = el.submitBtn.textContent; + el.submitBtn.textContent = "Submitting..."; setStatus("Submitting document..."); try { @@ -479,6 +490,7 @@ async function submitDocument(event) { setError("Network error while submitting. Retry when the service is reachable."); } finally { el.submitBtn.disabled = false; + el.submitBtn.textContent = originalText; } } diff --git a/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java index dadb8949..4bb69a5a 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ViewerUiControllerTest.java @@ -121,6 +121,9 @@ void demoScriptUsesExistingApiAndSessionHistory() throws Exception { assertTrue(script.contains("X-Clearfolio-Tenant-Id")); assertTrue(script.contains("X-Clearfolio-Permissions")); assertTrue(script.contains("deadLettered")); + assertTrue(script.contains("Refreshing...")); + assertTrue(script.contains("Submitting...")); + assertTrue(script.contains("Retrying...")); } }