Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Use Node.js 24 for executable accessibility tests
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
package-manager-cache: false
- name: Use preinstalled Temurin JDK 21
# Uses the runner image's bundled JDK instead of actions/setup-java to
# keep every workflow dependency hash-pinned (Scorecard Pinned-Dependencies).
Expand Down
39 changes: 27 additions & 12 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
## 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.
# Palette engineering journal

## 2024-07-10 - Async Button Loading States
**Learning:** Temporarily modifying the `innerHTML` of buttons for loading states requires saving and restoring the exact original `innerHTML` so nested DOM nodes (like icons or SVG paths) are not destroyed, rather than overwriting `textContent`.
**Action:** Always store the original `innerHTML` dynamically in a local variable before updating a button to a loading state, and restore it in the `finally` block to preserve nested structure.
## 2026-08-05 — Shared accessible async controls

## 2024-05-18 - 비동기 버튼 로딩 피드백 및 상태 복원
**Learning:** 비동기 작업 시 버튼에 명시적인 로딩 상태를 제공하면 사용자의 혼란을 줄이고 중복 요청을 방지할 수 있습니다.
**Action:** 비동기 버튼 텍스트 변경 시, `innerHTML`을 임시 변수에 저장하고 `finally` 블록에서 복원하여 내부 DOM 구조 손실 없이 상태 피드백을 제공해야 합니다.
### Learning

## 2026-07-13 - Async Table Actions UX
**Learning:** Adding explicit loading and disabled states to table action buttons that invoke asynchronous processes helps prevent redundant API calls and visually assures the user that their request is being handled.
**Action:** Consistently apply `disabled` state and `Loading...` text changes to inline table action buttons linked to async workflows, and carefully preserve underlying DOM structures with `Array.from(btn.childNodes)` during the loading cycle to avoid rendering regressions.
Repeated table actions need row-specific accessible names such as `View details for report.pdf`; visible labels alone are ambiguous when a screen reader lists controls out of table context.

Asynchronous controls must preserve their original child-node identities, disabled state, `aria-busy`, and `aria-label`. Backing up markup with `innerHTML` reparses untrusted-looking filenames and can destroy event listeners or element state. The project therefore uses `Array.from(button.childNodes)` and `replaceChildren(...)` through one shared, nested-safe helper.

### Applied pattern

- `createActionButton` and `createLink` use `textContent` for visible labels so markup-like filenames remain inert text.
- `setBusyState` sets visible loading text, `disabled`, `aria-busy="true"`, and a contextual loading accessible name.
- The helper reference-counts overlapping operations and returns an idempotent restore callback.
- The original state is restored only after every caller releases its busy-state claim.
- Executable Node tests cover enabled and initially disabled controls, pre-existing and empty ARIA values, nested callers, duplicate restores, contextual labels, and markup-like text.
- Maven runs those tests with 100% line, branch, and function coverage thresholds for the production DOM helper.

### Future rule

New asynchronous UI actions must reuse the shared helper rather than introducing local state-restoration code. Browser-level flows should additionally verify focus, cancellation, authorization failure, network failure, and duplicate activation prevention when those states are introduced.

## 2026-07-13 — Details loading feedback

The session history `Details` action now gives immediate visible loading feedback and prevents duplicate activation while job evidence is loading. The shared helper preserves nested DOM content and restores the exact original state after success or failure.

## 2024-05-18 — Refresh-evidence feedback

KPI evidence refresh benefits from explicit pending feedback because network latency otherwise looks like an unresponsive control. The same shared busy-state contract applies to refresh, retry, seeded-demo loading, and document submission actions.
28 changes: 16 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
## [Unreleased]
### Added
- **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다.

### Changed
- PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다.

# Changelog

## [Unreleased]

### 추가된 기능 (Added)

- **접근 가능한 비동기 버튼 및 테이블 작업**
- 반복되는 `Details`, `Status JSON`, `Open viewer` 작업에 문서명을 포함한 문맥별 `aria-label`을 추가했습니다.
- 제출, 데모 로드, KPI 증거 새로고침, 재시도, 상세 조회가 하나의 중첩 안전한 `setBusyState` helper를 사용하도록 통합했습니다.
- 원래 child node identity, 비활성화 상태, `aria-busy`, `aria-label`을 모든 중첩 작업이 끝난 뒤 정확히 복원합니다.
- 표시 문자열은 `textContent`로 생성해 마크업처럼 보이는 파일명도 실행되지 않는 텍스트로 유지합니다.
- production DOM helper에 대해 Node line, branch, function coverage 100%를 exact-head gate로 검증하고, 실제 `demo.js`를 non-empty history와 함께 실행해 helper wiring 및 inert filename rendering을 회귀 테스트합니다.

- **관리자용 단건 작업 삭제 및 재시도 API 추가**
- 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다.
- 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다.

- **비동기 버튼 로딩 피드백 및 상태 복원 개선**
- KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다.
- 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다.
- KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..."이라는 피드백을 제공하여 중복 클릭을 방지했습니다.
- 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `replaceChildren(...)`으로 안전하게 복원합니다.

### 변경 사항 (Changed)

- PDF.js WebJar를 `6.1.200`으로 올리고, 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링합니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다.

## [0.1.0] - 2026-06-25

### 추가된 기능 (Added)

- **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)**
- 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다.
- 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다.
Expand All @@ -37,9 +42,11 @@
- 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다.

### 테스트 커버리지 (Tests)

- 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다.

### 보안 (Security)

- **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다.
- Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다.
- Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다.
Expand All @@ -48,6 +55,3 @@
- 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다.
- logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다.
- 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다.

### Fixed
- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정
28 changes: 27 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@
<version>0.30.0</version>
<scope>test</scope>
</dependency>

</dependencies>

<build>
Expand Down Expand Up @@ -196,6 +195,33 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>run-node-tests</id>
<phase>test</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>node</executable>
<arguments>
<argument>--test</argument>
<argument>--experimental-test-coverage</argument>
<argument>--test-coverage-include=src/main/resources/static/assets/viewer/dom-utils.js</argument>
<argument>--test-coverage-lines=100</argument>
<argument>--test-coverage-branches=100</argument>
<argument>--test-coverage-functions=100</argument>
<argument>src/test/js/dom-utils.test.mjs</argument>
<argument>src/test/js/demo-integration.test.mjs</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
71 changes: 19 additions & 52 deletions src/main/resources/static/assets/viewer/demo.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createActionButton, createLink, setBusyState } from "./dom-utils.js";

const STORAGE_KEY = "clearfolio-demo-history-v1";
const KPI_ENDPOINT = "/api/v1/analytics/kpi-snapshot";
const KPI_EXPORTS_ENDPOINT = "/api/v1/analytics/kpi-snapshot-exports";
Expand Down Expand Up @@ -81,16 +83,6 @@ function updateJob(jobId, patch, { refreshKpisAfterUpdate = true } = {}) {
}
}

function createLink(href, label) {
const link = document.createElement("a");
link.href = href;
link.textContent = label;
link.className = "table-link";
link.target = "_blank";
link.rel = "noopener noreferrer";
return link;
}

async function openJsonDocument(url, title) {
const popup = window.open("", "_blank");
if (!popup) {
Expand All @@ -110,15 +102,6 @@ async function openJsonDocument(url, title) {
: "Unable to load JSON evidence with the current tenant claim.";
}

function createActionButton(label, onClick) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.className = "btn btn-secondary btn-compact";
button.addEventListener("click", onClick);
return button;
}

function jsonHeaders(extra = {}) {
return {
Accept: "application/json",
Expand Down Expand Up @@ -146,20 +129,17 @@ function renderHistory(history = loadHistory()) {
if (job.statusUrl) {
actionsCell.appendChild(createActionButton("Details", (e) => {
const btn = e.currentTarget;
const initialChildren = Array.from(btn.childNodes);
btn.disabled = true;
btn.textContent = "Loading...";
openJobDetail(job).finally(() => {
btn.replaceChildren(...initialChildren);
btn.disabled = false;
});
}));
actionsCell.appendChild(createActionButton("Status JSON", () => {
void openJsonDocument(job.statusUrl, "Clearfolio status JSON");
}));
const restore = setBusyState(btn, "Loading...");
openJobDetail(job).finally(restore);
}, `View details for ${job.fileName || "Document"}`));
actionsCell.appendChild(createActionButton("Status JSON", (e) => {
const btn = e.currentTarget;
const restore = setBusyState(btn, "Loading status JSON...");
openJsonDocument(job.statusUrl, "Clearfolio status JSON").finally(restore);
}, `View status JSON for ${job.fileName || "Document"}`));
}
if (job.jobId) {
actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer"));
actionsCell.appendChild(createLink(`/viewer/${encodeURIComponent(job.jobId)}`, "Open viewer", `Open viewer for ${job.fileName || "Document"}`));
}

row.append(fileCell, statusCell, submittedCell, actionsCell);
Expand Down Expand Up @@ -264,7 +244,6 @@ async function openJobDetail(job) {
setStatus("Seeded job detail loaded.");
return;
}

if (!job.statusUrl) {
return;
}
Expand Down Expand Up @@ -297,9 +276,7 @@ async function retryActiveJob() {
}

const jobId = activeJobDetail.jobId;
const initialChildren = Array.from(el.retryJobBtn.childNodes);
el.retryJobBtn.disabled = true;
el.retryJobBtn.textContent = "Retrying...";
const restore = setBusyState(el.retryJobBtn, "Retrying...");
setStatus("Requesting operator retry...");

try {
Expand Down Expand Up @@ -338,8 +315,7 @@ async function retryActiveJob() {
} catch (err) {
setError("Network error while requesting retry. Retry when the service is reachable.");
} finally {
el.retryJobBtn.replaceChildren(...initialChildren);
el.retryJobBtn.disabled = false;
restore();
}
}

Expand Down Expand Up @@ -412,9 +388,7 @@ async function refreshKpis() {
}

async function refreshKpiEvidence() {
const initialChildren = Array.from(el.refreshEvidenceBtn.childNodes);
el.refreshEvidenceBtn.disabled = true;
el.refreshEvidenceBtn.textContent = "Refreshing...";
const restore = setBusyState(el.refreshEvidenceBtn, "Refreshing...");

try {
const { res, data } = await fetchJson(KPI_EXPORTS_ENDPOINT);
Expand All @@ -427,15 +401,12 @@ async function refreshKpiEvidence() {
} catch (err) {
el.kpiExportStatus.textContent = "Snapshot evidence is unavailable while the service is unreachable.";
} finally {
el.refreshEvidenceBtn.replaceChildren(...initialChildren);
el.refreshEvidenceBtn.disabled = false;
restore();
}
}

async function loadDemoData() {
const initialChildren = Array.from(el.loadDemoDataBtn.childNodes);
el.loadDemoDataBtn.disabled = true;
el.loadDemoDataBtn.textContent = "Loading...";
const restore = setBusyState(el.loadDemoDataBtn, "Loading...");
setStatus("Loading seeded buyer-demo story...");

try {
Expand All @@ -458,8 +429,7 @@ async function loadDemoData() {
} catch (err) {
setError("Unable to load seeded demo story.");
} finally {
el.loadDemoDataBtn.replaceChildren(...initialChildren);
el.loadDemoDataBtn.disabled = false;
restore();
}
}

Expand Down Expand Up @@ -505,9 +475,7 @@ async function submitDocument(event) {
return;
}

const initialChildren = Array.from(el.submitBtn.childNodes);
el.submitBtn.disabled = true;
el.submitBtn.textContent = "Submitting...";
const restore = setBusyState(el.submitBtn, "Submitting...");
setStatus("Submitting document...");

try {
Expand Down Expand Up @@ -550,8 +518,7 @@ async function submitDocument(event) {
addFailedHistory(file.name, "FAILED");
setError("Network error while submitting. Retry when the service is reachable.");
} finally {
el.submitBtn.replaceChildren(...initialChildren);
el.submitBtn.disabled = false;
restore();
}
}

Expand Down
Loading
Loading