diff --git a/docs/doctoring/webdav-write-confirmation-accessibility.md b/docs/doctoring/webdav-write-confirmation-accessibility.md new file mode 100644 index 000000000..82c4e65a4 --- /dev/null +++ b/docs/doctoring/webdav-write-confirmation-accessibility.md @@ -0,0 +1,46 @@ +# WebDAV write-confirmation accessibility boundary + +Status: **Proposed** until the exact integrated PR head has current frontend/browser evidence and an independent accessibility review. + +## Problem + +The Data workspace can dispatch an explicit customer WebDAV write after the user selects a server-authoritative workspace document and a write-capable WebDAV source. The confirmation surface therefore changes customer-owned source-of-truth data when confirmed. A visual warning alone is insufficient: keyboard and assistive-technology users must receive the same bounded decision surface, and `aria-modal="true"` must not claim modality while pointer or keyboard interaction with the underlying page remains possible. + +A document action also captures the selected repository asset and, for WebDAV materialization, the selected WebDAV source before the request is dispatched. Allowing either selection to change while that action remains pending would let the completion/status UI appear beside a different document or source than the one actually submitted. + +## Constraints and decision + +Naruon keeps the confirmation as an `alertdialog` because it interrupts an explicit external-write action and requires a binary decision. The interaction contract is: + +- `aria-labelledby` and `aria-describedby` identify the warning and consequence; +- `aria-modal="true"` is used only with a full-viewport modal layer that visually obscures and blocks pointer interaction with the underlying page; +- opening moves focus to the non-destructive **취소** action; +- Tab and Shift+Tab remain within the two confirmation actions; +- Escape cancels without dispatching the provider write; +- body scrolling is locked while the confirmation is open; +- cancel and confirmed completion restore focus to the invoking WebDAV write control; +- while any document action is pending, repository-asset and WebDAV-account selection are disabled so the visible selection cannot drift away from the request context; and +- the confirmed provider request remains the existing server-authoritative, conflict-aware WebDAV materialization path rather than a client-side write. + +An inline `alertdialog` with `aria-modal="true"` was rejected. WAI-ARIA APG explicitly warns that `aria-modal="true"` is appropriate only when application code prevents interaction outside the dialog and the visual presentation also obscures outside content. A non-modal inline confirmation was also rejected because this action mutates a customer-owned external source and the existing product contract intentionally requires an interruptive confirmation. + +The implementation follows the same repository accessibility shape already used by `frontend/src/components/SourceDrawer.tsx`: visible modal layer, focus entry, keyboard containment, Escape handling, body scroll lock, and focus restoration. It does not introduce a new design-system dependency or claim that a shadcn/ui source component is being used. + +## Test and change traceability + +- `190fb909627074d15b03f750c72fec556510496c` introduced a focused component regression before the focus/keyboard repair. +- `364895b7a246f35e0faacba163760315e2f78acd` restored focus entry, cyclic Tab/Shift+Tab, Escape cancellation, and invoker focus restoration while preserving the newer per-action busy-state changes. +- `2e6881395e204083041aa92122a7df233d8b3edb` removed unrelated `.jules/codeql.md` and product-gap receipt changes from this product UI writer. +- `e29f7e58ff1dbc4cb35c20d053016090de9a26ea` aligned the focused fixture with the typed WebDAV/account and repository-asset contracts. +- `dfd8ec72a3f23e257b42853fbff4eb8d03350a7c` strengthened the regression so `aria-modal` must correspond to a full-viewport interaction boundary and body scroll lock. +- `43cf974a50d69f3109893f73a53a97d40dd1d70f` implemented the full-viewport modal layer and scroll lock while retaining the focus contract. +- `78c690de92e2767834c6fac99e331c263410063e` added the source-order regression that requires WebDAV-account and repository-asset selection to remain frozen during a pending document action. +- `517c8e7ccd6081250ebbceba82dc1dac2fb533aa` disabled WebDAV-account selection and made repository-asset mouse/keyboard selection inert with `aria-disabled` while the action is pending. + +These commits establish source-order RED→repair provenance. They are not, by themselves, exact-head GREEN. Promotion from Proposed requires current-head frontend/component and browser/E2E checks without warning-class failures, plus the normal repository review gates. + +## References + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Alert and message dialogs pattern*. WAI-ARIA Authoring Practices Guide. https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Dialog (modal) pattern*. WAI-ARIA Authoring Practices Guide. https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ diff --git a/frontend/scripts/full-product-ui-smoke.mjs b/frontend/scripts/full-product-ui-smoke.mjs index 008d61267..a94bcf3f4 100644 --- a/frontend/scripts/full-product-ui-smoke.mjs +++ b/frontend/scripts/full-product-ui-smoke.mjs @@ -1253,11 +1253,12 @@ async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) { } if (routeSpec.name === "data") { - await page.getByRole("button", { name: "임베딩 재생성 의도", exact: true }).click(); + await page.getByRole("button", { name: "임베딩 재생성 요청 등록", exact: true }).click(); await page.getByText("Embedding regeneration intent recorded", { exact: false }).waitFor({ state: "visible", timeout: 10_000 }); - await page.getByRole("button", { name: "HWP 변환 의도", exact: true }).click(); + await page.getByRole("button", { name: "HWP 변환 요청 등록", exact: true }).click(); await page.getByText("HWP conversion intent recorded", { exact: false }).waitFor({ state: "visible", timeout: 10_000 }); - await page.getByRole("button", { name: "WebDAV 문서 실행 요청", exact: true }).click(); + await page.getByRole("button", { name: "고객 WebDAV에 문서 쓰기", exact: true }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "WebDAV 쓰기 확인", exact: true }).click(); await page.getByText("WebDAV materialization executed by the connector.", { exact: false }).waitFor({ state: "visible", timeout: 10_000 }); await page.getByText("외부 쓰기 실행됨", { exact: false }).waitFor({ state: "visible", timeout: 10_000 }); await page.getByRole("button", { name: "WebDAV 반영 의도 점검", exact: true }).click(); diff --git a/frontend/src/app/data/page.test.tsx b/frontend/src/app/data/page.test.tsx index d18afe06a..b4c257d9f 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -1903,7 +1903,7 @@ describe("DataPage", () => { expect(container.textContent).toContain("decision-note.md"); expect(container.textContent).toContain("의도만 기록"); - for (const buttonLabel of ["재파싱 실행", "임베딩 재생성 의도", "HWP 변환 의도"]) { + for (const buttonLabel of ["파싱 완료로 표시", "임베딩 재생성 요청 등록", "HWP 변환 요청 등록"]) { const button = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes(buttonLabel), ); @@ -1919,12 +1919,28 @@ describe("DataPage", () => { expect(container.textContent).toContain("HWP conversion intent recorded"); const materializeButton = Array.from(container.querySelectorAll("button")).find((candidate) => - candidate.textContent?.includes("WebDAV 문서 실행 요청"), + candidate.textContent?.includes("고객 WebDAV에 문서 쓰기"), ); expect(materializeButton).toBeDefined(); await act(async () => { materializeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("webdav-materialization-intent"))).toBe(false); + expect(container.textContent).toContain("운영 문서 원본에 현재 문서를 기록합니다"); + + const cancelWriteButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent === "취소"); + await act(async () => { + cancelWriteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(fetchMock.mock.calls.some(([input]) => String(input).includes("webdav-materialization-intent"))).toBe(false); + + await act(async () => { + materializeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const confirmWriteButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent === "WebDAV 쓰기 확인"); + await act(async () => { + confirmWriteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); const materializeCall = fetchMock.mock.calls.find(([input]) => ( String(input) === "/api/data/documents/doc_repository_ready/webdav-materialization-intent" @@ -1965,6 +1981,160 @@ describe("DataPage", () => { expect(container.textContent).not.toContain("runner_req_data_doc_1"); }); + it("shows loading feedback only on the document action that is pending", async () => { + const fetchMock = mockWebdavFetch(); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + const originalFetch = fetchMock.getMockImplementation(); + const reparsePath = "/api/data/documents/doc_repository_ready/reparse"; + let releaseReparse: (() => void) | null = null; + const pendingReparse = new Promise>((resolve) => { + releaseReparse = () => resolve(jsonResponse({ + document_id: "doc_repository_ready", + workspace_id: "workspace-org-acme", + document_name: "roadmap.md", + document_type: "text/markdown", + document_status: "parsed", + content_chars: 128, + provider_write_executed: false, + provenance: "server-authoritative", + audit_event: "data.document.reparsed", + message: "Document parse metadata refreshed in the signed workspace scope.", + })); + }); + fetchMock.mockImplementation((input, init) => { + if (String(input) === reparsePath) return pendingReparse; + return originalFetch?.(input, init) ?? Promise.reject(new Error("missing fetch implementation")); + }); + + const findButton = (label: string) => Array.from(container?.querySelectorAll("button") ?? []).find((candidate) => ( + candidate.textContent?.includes(label) + )); + const reparseButton = findButton("파싱 완료로 표시"); + const embeddingButton = findButton("임베딩 재생성 요청 등록"); + const hwpButton = findButton("HWP 변환 요청 등록"); + const materializeButton = findButton("고객 WebDAV에 문서 쓰기"); + expect(reparseButton).toBeDefined(); + expect(embeddingButton).toBeDefined(); + expect(hwpButton).toBeDefined(); + expect(materializeButton).toBeDefined(); + + await act(async () => { + reparseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(reparseButton?.getAttribute("aria-busy")).toBe("true"); + expect(reparseButton?.textContent).toContain("상태 변경 중"); + const documentFileInput = container?.querySelector('input[accept=".txt,.md,.markdown,text/plain,text/markdown"]'); + expect(documentFileInput).toHaveProperty("disabled", true); + for (const button of [embeddingButton, hwpButton, materializeButton]) { + expect(button?.getAttribute("aria-busy")).toBe("false"); + expect(button?.disabled).toBe(true); + expect(button?.textContent).not.toContain("요청 등록 중"); + expect(button?.textContent).not.toContain("WebDAV에 쓰는 중"); + } + + await act(async () => { + releaseReparse?.(); + await pendingReparse; + await Promise.resolve(); + await Promise.resolve(); + }); + }); + + it.each([ + [409, "문서 처리가 아직 끝나지 않았습니다. 처리가 완료된 후 재시도하세요."], + [422, "쓸 수 있는 문서 내용 또는 WebDAV 저장소를 확인한 후 재시도하세요."], + [401, "로그인이 만료되었습니다. 다시 로그인한 후 재시도하세요."], + [500, "문서 작업을 완료하지 못했습니다. 잠시 후 재시도하세요."], + ])("shows a bounded actionable document error for HTTP %s and allows retry", async (status, message) => { + const fetchMock = mockWebdavFetch(); + const originalFetch = fetchMock.getMockImplementation(); + let attempts = 0; + fetchMock.mockImplementation((input, init) => { + if (String(input).endsWith("/doc_repository_ready/reparse")) { + attempts += 1; + return Promise.resolve(jsonResponse({}, false, status)); + } + return originalFetch?.(input, init) ?? Promise.reject(new Error("missing fetch implementation")); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + const findReparseButton = () => Array.from(container?.querySelectorAll("button") ?? []).find((candidate) => candidate.textContent?.includes("파싱 완료로 표시")); + await act(async () => { + findReparseButton()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(container.textContent).toContain(message); + expect(findReparseButton()?.disabled).toBe(false); + + await act(async () => { + findReparseButton()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(attempts).toBe(2); + }); + + it("reports a connector failure after explicit WebDAV write confirmation", async () => { + const fetchMock = mockWebdavFetch(); + const originalFetch = fetchMock.getMockImplementation(); + fetchMock.mockImplementation((input, init) => { + if (String(input).endsWith("/doc_repository_ready/webdav-materialization-intent")) { + return Promise.resolve(jsonResponse({ + document_id: "doc_repository_ready", + workspace_id: "workspace-org-acme", + document_name: "roadmap.md", + document_type: "text/markdown", + document_status: "parsed", + content_chars: 128, + provider_write_executed: false, + provenance: "server-authoritative", + audit_event: "data.document.webdav_materialization.dispatch_failed", + error_code: "connector_unavailable", + message: "dispatch failed", + })); + } + return originalFetch?.(input, init) ?? Promise.reject(new Error("missing fetch implementation")); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + const writeButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes("고객 WebDAV에 문서 쓰기")); + await act(async () => writeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + const confirmButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent === "WebDAV 쓰기 확인"); + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(container.textContent).toContain("WebDAV 쓰기를 완료하지 못했습니다. 연결 상태를 확인한 후 재시도하거나 관리자에게 문의하세요."); + expect(container.textContent).not.toContain("connector_unavailable"); + }); + it("loads signed data quality surface without public identity headers", async () => { const fetchMock = mockWebdavFetch(); vi.stubGlobal("fetch", fetchMock); diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index f0c062b82..f53558c5d 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -23,6 +23,7 @@ import { DataQualitySurfaceResponse, EmailFileImportResponse, DataDocumentActionResponse, + DocumentActionKind, duplicateImportCandidates } from './data-layout/types'; import { @@ -65,6 +66,7 @@ export function DataLayout() { const [emailImportResult, setEmailImportResult] = useState(null); const [emailImportFiles, setEmailImportFiles] = useState([]); const [documentActionStatus, setDocumentActionStatus] = useState('idle'); + const [documentActionPendingAction, setDocumentActionPendingAction] = useState(null); const [documentActionResult, setDocumentActionResult] = useState(null); const [documentUploadFiles, setDocumentUploadFiles] = useState([]); const [dataSurfaceStatus, setDataSurfaceStatus] = useState('loading'); @@ -229,21 +231,25 @@ export function DataLayout() { const requestDocumentUpload = useCallback(async () => { const [file] = documentUploadFiles; if (!file) { + setDocumentActionPendingAction(null); setDocumentActionStatus('error'); return; } // 50MB file size limit if (file.size > 50 * 1024 * 1024) { + setDocumentActionPendingAction(null); setDocumentActionStatus('error'); return; } + setDocumentActionPendingAction('upload'); setDocumentActionStatus('loading'); setDocumentActionResult(null); try { const documentType = getDocumentTypeForFile(file); if (!isTextDocumentUploadType(documentType)) { + setDocumentActionPendingAction(null); setDocumentActionStatus('error'); return; } @@ -257,22 +263,25 @@ export function DataLayout() { }, ); setDocumentActionResult(result); + setDocumentActionPendingAction(null); setDocumentActionStatus('success'); setDataSurfaceStatus('loading'); await loadDataQualitySurface(); } catch (error: unknown) { const status = getApiErrorStatus(error); + setDocumentActionPendingAction(null); setDocumentActionStatus(status === 401 || status === 403 ? 'auth' : 'error'); } }, [documentUploadFiles, loadDataQualitySurface]); const requestDocumentAction = useCallback(async ( - action: 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent', + action: Exclude, ) => { const asset = dataQualitySurface?.repository_assets.find((candidate) => ( candidate.asset_key === selectedRepositoryAssetKey )) ?? dataQualitySurface?.repository_assets[0] ?? null; if (!asset || asset.asset_type !== 'workspace_document') { + setDocumentActionPendingAction(null); setDocumentActionStatus('error'); return; } @@ -280,10 +289,12 @@ export function DataLayout() { account.source_id === selectedWebdavSourceId && account.writeback_enabled ))?.source_id ?? webdavAccounts.find((account) => account.writeback_enabled)?.source_id; if (action === 'webdav-materialization-intent' && (webdavAccountStatus !== 'ready' || !targetSourceId)) { + setDocumentActionPendingAction(null); setDocumentActionStatus('error'); return; } + setDocumentActionPendingAction(action); setDocumentActionStatus('loading'); setDocumentActionResult(null); try { @@ -294,12 +305,26 @@ export function DataLayout() { : {}, ); setDocumentActionResult(result); + setDocumentActionPendingAction(null); + if (action === 'webdav-materialization-intent' && !result.provider_write_executed) { + setDocumentActionStatus('connector_error'); + return; + } setDocumentActionStatus('success'); setDataSurfaceStatus('loading'); await loadDataQualitySurface(); } catch (error: unknown) { const status = getApiErrorStatus(error); - setDocumentActionStatus(status === 401 || status === 403 ? 'auth' : 'error'); + setDocumentActionPendingAction(null); + setDocumentActionStatus( + status === 401 || status === 403 + ? 'auth' + : status === 409 + ? 'conflict' + : status === 422 + ? 'invalid' + : 'error', + ); } }, [ dataQualitySurface, @@ -315,7 +340,6 @@ export function DataLayout() { const canRequestWebdavWriteback = webdavAccountStatus === 'ready'; const isUniqueThreadLoading = uniqueThreadStatus === 'loading'; const isEmailImportLoading = emailImportStatus === 'loading'; - const isDocumentActionLoading = documentActionStatus === 'loading'; const selectedWebdavAccount = webdavAccounts.find((account) => ( account.source_id === selectedWebdavSourceId && account.writeback_enabled )) ?? webdavAccounts.find((account) => account.writeback_enabled) ?? null; @@ -440,7 +464,7 @@ export function DataLayout() { emailImportResult={emailImportResult} handleDocumentFileChange={handleDocumentFileChange} requestDocumentUpload={requestDocumentUpload} - isDocumentActionLoading={isDocumentActionLoading} + documentActionPendingAction={documentActionPendingAction} documentUploadFiles={documentUploadFiles} documentActionStatus={documentActionStatus} documentActionResult={documentActionResult} diff --git a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx index 78a257061..1cc8007b8 100644 --- a/frontend/src/components/data-layout/DocumentRepositoryTab.tsx +++ b/frontend/src/components/data-layout/DocumentRepositoryTab.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { type ChangeEvent } from 'react'; +import React, { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { HardDrive, Upload, Loader2, FileText, FolderOpen, Database, RefreshCw, CheckCircle2, Server } from 'lucide-react'; import { toSafeReactText } from '@/lib/safe-text'; import { @@ -8,6 +8,7 @@ import { DocumentActionStatus, EmailFileImportResponse, DataDocumentActionResponse, + DocumentActionKind, WebdavAccountStatus, WebdavAccount, WebdavAccountLookup, @@ -52,7 +53,7 @@ interface DocumentRepositoryTabProps { emailImportResult: EmailFileImportResponse | null; handleDocumentFileChange: (event: ChangeEvent) => void; requestDocumentUpload: () => void; - isDocumentActionLoading: boolean; + documentActionPendingAction: DocumentActionKind | null; documentUploadFiles: File[]; documentActionStatus: DocumentActionStatus; documentActionResult: DataDocumentActionResponse | null; @@ -64,7 +65,7 @@ interface DocumentRepositoryTabProps { setSelectedRepositoryAssetKey: (key: string | null) => void; repositoryAssets: any[]; selectedWorkspaceDocument: any; - requestDocumentAction: (action: 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent') => void; + requestDocumentAction: (action: Exclude) => void; } export function DocumentRepositoryTab({ @@ -81,7 +82,7 @@ export function DocumentRepositoryTab({ emailImportResult, handleDocumentFileChange, requestDocumentUpload, - isDocumentActionLoading, + documentActionPendingAction, documentUploadFiles, documentActionStatus, documentActionResult, @@ -109,13 +110,67 @@ export function DocumentRepositoryTab({ requestUniqueThreadIntent, isUniqueThreadLoading, }: DocumentRepositoryTabProps) { + const [webdavWriteConfirmationKey, setWebdavWriteConfirmationKey] = useState(null); + const webdavWriteTriggerRef = useRef(null); + const webdavWriteCancelRef = useRef(null); + const webdavWriteConfirmRef = useRef(null); + const restoreWebdavWriteFocusRef = useRef(false); + useEffect(() => { + if (webdavWriteConfirmationKey === null) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + webdavWriteCancelRef.current?.focus(); + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [webdavWriteConfirmationKey]); + + useEffect(() => { + if ( + webdavWriteConfirmationKey === null + && documentActionPendingAction === null + && restoreWebdavWriteFocusRef.current + ) { + restoreWebdavWriteFocusRef.current = false; + webdavWriteTriggerRef.current?.focus(); + } + }, [documentActionPendingAction, webdavWriteConfirmationKey]); + + const closeWebdavWriteConfirmation = () => { + restoreWebdavWriteFocusRef.current = true; + setWebdavWriteConfirmationKey(null); + }; + + const handleWebdavWriteConfirmationKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeWebdavWriteConfirmation(); + return; + } + if (event.key !== 'Tab') return; + + const first = webdavWriteCancelRef.current; + const last = webdavWriteConfirmRef.current; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + }; const selectedRepositoryAsset = repositoryAssets.find((asset) => asset.asset_key === selectedRepositoryAssetKey) ?? repositoryAssets[0] ?? null; + const selectedWebdavAccountIndex = webdavAccounts.findIndex((account) => account.source_id === selectedWebdavAccount?.source_id); + const selectedWebdavAccountLabel = selectedWebdavAccount + ? getWebdavAccountLabel(selectedWebdavAccount, Math.max(selectedWebdavAccountIndex, 0)) + : '선택된 WebDAV 저장소'; + const currentWebdavWriteConfirmationKey = `${selectedRepositoryAsset?.asset_key ?? ''}:${selectedWebdavAccount?.source_id ?? ''}`; return (
@@ -184,26 +239,34 @@ return ( type="file" accept=".txt,.md,.markdown,text/plain,text/markdown" className="sr-only" + disabled={documentActionPendingAction !== null} onChange={handleDocumentFileChange} />
{documentActionStatus === 'idle' && documentUploadFiles.length === 0 && '텍스트, Markdown, HWP 원본을 워크스페이스 문서 근거로 저장합니다.'} {documentActionStatus === 'idle' && documentUploadFiles.length > 0 && `${documentUploadFiles[0]?.name ?? '문서'} 선택됨`} {documentActionStatus === 'loading' && '문서 작업을 처리하는 중입니다.'} - {documentActionStatus === 'auth' && signed session이 필요합니다. 공개 identity header로는 문서 작업을 실행할 수 없습니다.} - {documentActionStatus === 'error' && 문서 작업에 실패했습니다.} + {documentActionStatus === 'auth' && 로그인이 만료되었습니다. 다시 로그인한 후 재시도하세요.} + {documentActionStatus === 'conflict' && 문서 처리가 아직 끝나지 않았습니다. 처리가 완료된 후 재시도하세요.} + {documentActionStatus === 'invalid' && 쓸 수 있는 문서 내용 또는 WebDAV 저장소를 확인한 후 재시도하세요.} + {documentActionStatus === 'connector_error' && WebDAV 쓰기를 완료하지 못했습니다. 연결 상태를 확인한 후 재시도하거나 관리자에게 문의하세요.} + {documentActionStatus === 'error' && 문서 작업을 완료하지 못했습니다. 잠시 후 재시도하세요.} {documentActionStatus === 'success' && documentActionResult && ( {toSafeReactText(documentActionResult.document_name)} · {toSafeReactText(documentActionResult.message)} · {getWriteBoundaryLabel(documentActionResult.provider_write_executed)} @@ -233,9 +296,13 @@ return (
+ {webdavWriteConfirmationKey === currentWebdavWriteConfirmationKey && documentActionPendingAction === null ? ( +
+ + ) : null} + )}
@@ -452,9 +582,10 @@ return ( onClick={() => void requestWebdavWritebackIntent()} disabled={isWritebackLoading || !canRequestWebdavWriteback} aria-busy={isWebdavSourceLoading || isWritebackLoading} - className="w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto" + className="inline-flex items-center justify-center gap-2 w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto" > - WebDAV 반영 의도 점검 + {isWritebackLoading &&
@@ -518,9 +649,11 @@ return ( type="button" onClick={() => void requestUniqueThreadIntent()} disabled={isUniqueThreadLoading} - className="w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60 sm:w-auto" + aria-busy={isUniqueThreadLoading} + className="inline-flex items-center justify-center gap-2 w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60 sm:w-auto" > - 중복 메일 스레드 의도 점검 + {isUniqueThreadLoading &&
diff --git a/frontend/src/components/data-layout/DocumentRepositoryTab.webdav-dialog.test.tsx b/frontend/src/components/data-layout/DocumentRepositoryTab.webdav-dialog.test.tsx new file mode 100644 index 000000000..78c78dd20 --- /dev/null +++ b/frontend/src/components/data-layout/DocumentRepositoryTab.webdav-dialog.test.tsx @@ -0,0 +1,191 @@ +/* @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('lucide-react', () => ({ + HardDrive: () => , + Upload: () => , + Loader2: () => , + FileText: () => , + FolderOpen: () => , + Database: () => , + RefreshCw: () => , + CheckCircle2: () => , + Server: () => , +})); + +import { DocumentRepositoryTab } from './DocumentRepositoryTab'; + +const webdavAccount = { + source_id: 'webdav-customer', + display_label: 'Customer WebDAV', + writeback_enabled: true, + etag: 'etag-1', +}; + +const repositoryAsset = { + asset_key: 'asset-roadmap', + asset_type: 'workspace_document' as const, + display_name: 'roadmap.md', + source_label: 'customer-webdav', + state_code: 'ready' as const, + detail_text: 'Signed workspace document evidence', + content_chars: 128, + captured_at: '2026-09-06T00:00:00Z', + evidence_source: 'signed-workspace', + thread_key: 'thread-roadmap', + provider_write_executed: false, +}; + +function buildProps(requestDocumentAction = vi.fn()) { + return { + writebackStatus: 'idle', + writebackResult: null, + requestWebdavWritebackIntent: vi.fn(), + isWritebackLoading: false, + canRequestWebdavWriteback: true, + selectedWebdavAccount: webdavAccount, + isWebdavSourceLoading: false, + setSelectedWebdavSourceId: vi.fn(), + uniqueThreadStatus: 'idle', + uniqueThreadResult: null, + requestUniqueThreadIntent: vi.fn(), + isUniqueThreadLoading: false, + connectorEvents: [], + dataSurfaceStatus: 'ready' as const, + dataQualitySurface: null, + embeddingStage: null, + emailRepository: { object_count: 0 }, + attachmentRepository: { object_count: 0 }, + handleEmailImportFileChange: vi.fn(), + requestEmailFileImport: vi.fn(), + isEmailImportLoading: false, + emailImportFiles: [], + emailImportStatus: 'idle' as const, + emailImportResult: null, + handleDocumentFileChange: vi.fn(), + requestDocumentUpload: vi.fn(), + documentActionPendingAction: null, + documentUploadFiles: [], + documentActionStatus: 'idle' as const, + documentActionResult: null, + webdavAccountStatus: 'ready' as const, + webdavAccounts: [webdavAccount], + webdavAccountMap: new Map([[webdavAccount.source_id, { account: webdavAccount, index: 0 }]]), + projectFolders: [], + selectedRepositoryAssetKey: repositoryAsset.asset_key, + setSelectedRepositoryAssetKey: vi.fn(), + repositoryAssets: [repositoryAsset], + selectedWorkspaceDocument: { state_code: 'ready' }, + requestDocumentAction, + }; +} + +describe('DocumentRepositoryTab WebDAV confirmation', () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()); + } + document.body.style.overflow = ''; + container?.remove(); + container = null; + root = null; + }); + + it('keeps focus and interaction inside the modal confirmation and restores the write trigger', async () => { + const requestDocumentAction = vi.fn(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + const findButton = (label: string) => Array.from(container?.querySelectorAll('button') ?? []) + .find((button) => button.textContent?.includes(label)); + const writeTrigger = findButton('고객 WebDAV에 문서 쓰기'); + expect(writeTrigger).toBeDefined(); + writeTrigger?.focus(); + + await act(async () => { + writeTrigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + const dialog = container?.querySelector('[role="alertdialog"]'); + const cancelButton = findButton('취소'); + const confirmButton = findButton('WebDAV 쓰기 확인'); + expect(dialog?.getAttribute('aria-modal')).toBe('true'); + expect(dialog?.parentElement?.className).toContain('fixed inset-0'); + expect(dialog?.previousElementSibling?.getAttribute('aria-hidden')).toBe('true'); + expect(document.body.style.overflow).toBe('hidden'); + expect(document.activeElement).toBe(cancelButton); + + confirmButton?.focus(); + await act(async () => { + confirmButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + }); + expect(document.activeElement).toBe(cancelButton); + + cancelButton?.focus(); + await act(async () => { + cancelButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true })); + }); + expect(document.activeElement).toBe(confirmButton); + + await act(async () => { + confirmButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + expect(container?.querySelector('[role="alertdialog"]')).toBeNull(); + expect(document.body.style.overflow).toBe(''); + expect(document.activeElement).toBe(writeTrigger); + + await act(async () => { + writeTrigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await act(async () => { + findButton('WebDAV 쓰기 확인')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(requestDocumentAction).toHaveBeenCalledWith('webdav-materialization-intent'); + expect(document.activeElement).toBe(writeTrigger); + }); + + it('locks WebDAV account and repository-asset selection while a document action is pending', async () => { + const setSelectedWebdavSourceId = vi.fn(); + const setSelectedRepositoryAssetKey = vi.fn(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const webdavAccountButton = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Customer WebDAV')); + expect(webdavAccountButton).toBeDefined(); + expect(webdavAccountButton?.disabled).toBe(true); + + const assetCard = container.querySelector('article[role="button"]'); + expect(assetCard?.getAttribute('aria-disabled')).toBe('true'); + await act(async () => { + assetCard?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + assetCard?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assetCard?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + }); + + expect(setSelectedWebdavSourceId).not.toHaveBeenCalled(); + expect(setSelectedRepositoryAssetKey).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/data-layout/types.ts b/frontend/src/components/data-layout/types.ts index 0aada1b80..e7e7dc67c 100644 --- a/frontend/src/components/data-layout/types.ts +++ b/frontend/src/components/data-layout/types.ts @@ -42,7 +42,8 @@ export type UniqueThreadIntentResponse = { export type UniqueThreadStatus = 'idle' | 'loading' | 'success' | 'auth' | 'error'; export type EmailImportStatus = 'idle' | 'loading' | 'success' | 'auth' | 'error'; -export type DocumentActionStatus = 'idle' | 'loading' | 'success' | 'auth' | 'error'; +export type DocumentActionKind = 'upload' | 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent'; +export type DocumentActionStatus = 'idle' | 'loading' | 'success' | 'auth' | 'conflict' | 'invalid' | 'connector_error' | 'error'; export type DataSurfaceStatus = 'loading' | 'ready' | 'error'; @@ -489,6 +490,7 @@ export type DataDocumentActionResponse = { provenance: 'server-authoritative'; audit_event: string; message: string; + error_code?: string | null; }; export const duplicateImportCandidates = [ diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts index f89f5149e..73222a1de 100644 --- a/frontend/tests/e2e/dashboard-branding.spec.ts +++ b/frontend/tests/e2e/dashboard-branding.spec.ts @@ -1190,7 +1190,10 @@ test('renders data WebDAV writeback intent and document materialization status', && request.method() === 'POST' ); }, { timeout: 60_000 }); - await page.getByRole('button', { name: 'WebDAV 문서 실행 요청' }).click(); + await page.getByRole('button', { name: '고객 WebDAV에 문서 쓰기' }).click(); + const writeConfirmation = page.getByRole('alertdialog', { name: '고객 WebDAV에 문서를 쓰시겠습니까?' }); + await expect(writeConfirmation).toContainText('이 작업은 고객 원본 저장소를 변경합니다.'); + await writeConfirmation.getByRole('button', { name: 'WebDAV 쓰기 확인' }).click(); const desktopMaterializationCall = await desktopMaterializationRequest; const desktopMaterializationHeaders = desktopMaterializationCall.headers(); expectBrowserCookieSession(desktopMaterializationHeaders, expectedNaruonToken); diff --git a/frontend/tests/e2e/live-smoke.spec.ts b/frontend/tests/e2e/live-smoke.spec.ts index 79a950cb3..ad1db6887 100644 --- a/frontend/tests/e2e/live-smoke.spec.ts +++ b/frontend/tests/e2e/live-smoke.spec.ts @@ -135,7 +135,11 @@ test('live data workspace reaches backend-backed WebDAV and document APIs withou ); }); await page - .getByRole('button', { name: 'WebDAV 문서 실행 요청' }) + .getByRole('button', { name: '고객 WebDAV에 문서 쓰기' }) + .click(); + await page + .getByRole('alertdialog', { name: '고객 WebDAV에 문서를 쓰시겠습니까?' }) + .getByRole('button', { name: 'WebDAV 쓰기 확인' }) .click(); const webdavMaterializationResponse = await materializationResponse; expect(webdavMaterializationResponse.status()).toBeLessThan(400);