Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
**Action:** When using `multiple` file inputs, always implement an `onchange` event listener to validate the file count and file size limits on the client side, using `setCustomValidity` and `aria-invalid` to provide immediate inline feedback before submission.
## 2024-07-13 - 일괄 업로드 폼에 프리셋 버튼 및 파일 크기 미리보기 추가
**Learning:** 일괄 파일 업로드 폼에서 대상 바이트(target_bytes) 입력 필드만 제공하면 사용자가 원하는 용량을 바이트 단위로 정확히 계산하기 어려워 사용성이 떨어집니다. 사용자가 여러 파일을 업로드할 때 총 파일 크기를 파악하지 못해 업로드 제한을 초과하거나 잘못된 대상 바이트를 설정할 위험이 큽니다.
**Action:** 일괄 파일 업로드 폼에도 단일 파일 업로드 폼과 동일하게 대상 바이트를 쉽게 선택할 수 있는 빠른 프리셋 버튼을 추가하고, `onchange` 이벤트 발생 시 선택된 모든 파일의 크기를 합산하여 사람이 읽기 쉬운 단위(MiB, GiB 등)로 미리보기를 제공하도록 JavaScript 로직을 개선했습니다.
**Action:** 일괄 업로드 폼에도 단일 파일 업로드 폼과 동일하게 대상 바이트를 쉽게 선택할 수 있는 빠른 프리셋 버튼을 추가하고, `onchange` 이벤트 발생 시 선택된 모든 파일의 크기를 합산하여 사람이 읽기 쉬운 단위(MiB, GiB 등)로 미리보기를 제공하도록 JavaScript 로직을 개선했습니다.
## 2024-08-04 - 숫자 입력 필드 빈 문자열 상태 초기화 처리
**학습:** 숫자 입력 필드에서 빈 문자열('')을 입력할 때 브라우저는 이전의 유효하지 않은 상태를 암시적으로 유지하므로, 사용자 정의 검증을 명시적으로 초기화하지 않으면 네이티브 HTML5 유효성 검사가 정상 작동하지 않을 수 있음을 확인했습니다.
**실행:** 인라인 검증 스크립트 작성 시 빈 문자열 상태를 별도로 확인하여 this.setCustomValidity('') 및 this.removeAttribute('aria-invalid')를 명시적으로 호출하는 로직을 추가해야 합니다.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
### Added
- 파일 드롭 영역에서 지원되지 않는 파일 형식을 드래그 앤 드롭했을 때 즉각적인 경고 및 접근성 피드백을 제공하도록 클라이언트 측 파일 유형 검증 로직을 추가했습니다.
- 다중 파일 업로드 선택 시 즉각적인 파일 개수 피드백 및 제한 초과 경고 메시지 추가
- 일괄 업로드 폼에 대상 바이트 프리셋 버튼과 총 파일 크기 미리보기를 추가하여 사용성을 개선했습니다.
- 클라이언트 측 폼 검증 시 하드코딩된 '5 GiB' 텍스트를 동적으로 변환되도록 수정하고 일괄 업로드 폼에 최대 크기(MAX_UPLOAD_BYTES) 검증 피드백을 추가했습니다.
Expand Down
42 changes: 37 additions & 5 deletions saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,28 @@ async def add_security_headers(request: Request, call_next):
preview.innerText = '';
return;
}

let unknownMimeNotice = '';
if (!file.type) {
unknownMimeNotice = ' File type could not be identified; it will be validated after upload.';
} else if (!file.type.startsWith('audio/') && !file.type.startsWith('video/')) {
input.setCustomValidity('Unsupported file type. Please select an audio or video file.');
input.setAttribute('aria-invalid', 'true');
preview.innerText = 'Selected file is not an audio or video file.';
preview.style.color = '#dc3545';
return;
}

const text = formatBinaryBytes(file.size);
if (file.size > MAX_UPLOAD_BYTES) {
const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES);
input.setCustomValidity('File exceeds ' + limitText + ' limit.');
input.setAttribute('aria-invalid', 'true');
preview.innerText = 'Selected file size: ' + text + ' (exceeds ' + limitText + ' limit)';
preview.innerText = 'Selected file size: ' + text + ' (exceeds ' + limitText + ' limit)' + unknownMimeNotice;
preview.style.color = '#dc3545';
return;
}
preview.innerText = 'Selected file size: ' + text;
preview.innerText = 'Selected file size: ' + text + unknownMimeNotice;
}

document.getElementById('target_bytes').addEventListener('input', function(e) {
Expand Down Expand Up @@ -329,14 +341,34 @@ async def add_security_headers(request: Request, call_next):
}

let totalSize = 0;
let invalidTypeCount = 0;
let unknownTypeCount = 0;
for (let i = 0; i < files.length; i++) {
totalSize += files[i].size;
if (!files[i].type) {
unknownTypeCount++;
} else if (!files[i].type.startsWith('audio/') && !files[i].type.startsWith('video/')) {
invalidTypeCount++;
}
}

if (invalidTypeCount > 0) {
input.setCustomValidity('Unsupported file type. Please select audio or video files only.');
input.setAttribute('aria-invalid', 'true');
preview.innerText = invalidTypeCount + ' of ' + files.length + ' selected files are not audio or video files.';
preview.style.color = '#dc3545';
return;
}

let unknownMimeNotice = '';
if (unknownTypeCount > 0) {
unknownMimeNotice = ' ' + (unknownTypeCount === 1 ? 'File' : unknownTypeCount + ' files') + ' type could not be identified; it will be validated after upload.';
}

if (files.length > 20) {
input.setCustomValidity('Maximum is 20 files per batch.');
input.setAttribute('aria-invalid', 'true');
preview.innerText = 'Selected ' + files.length + ' files (' + formatBinaryBytes(totalSize) + ', exceeds 20 files limit)';
preview.innerText = 'Selected ' + files.length + ' files (' + formatBinaryBytes(totalSize) + ', exceeds 20 files limit)' + unknownMimeNotice;
preview.style.color = '#dc3545';
return;
}
Expand All @@ -345,11 +377,11 @@ async def add_security_headers(request: Request, call_next):
const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES);
input.setCustomValidity('Total file size exceeds ' + limitText + ' limit.');
input.setAttribute('aria-invalid', 'true');
preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ', exceeds ' + limitText + ' limit)';
preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ', exceeds ' + limitText + ' limit)' + unknownMimeNotice;
preview.style.color = '#dc3545';
return;
}
preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ')';
preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ')' + unknownMimeNotice;
}

document.getElementById('shrink-batch-form').addEventListener('submit', function() {
Expand Down
31 changes: 31 additions & 0 deletions tests/test_upload_mime_ux_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import unittest

try:
from fastapi.testclient import TestClient

from saas_web import app

_HAS_FASTAPI = True
except ImportError:
_HAS_FASTAPI = False


@unittest.skipUnless(
_HAS_FASTAPI, "fastapi not installed (optional integration dependency)"
)
class UploadMimeUxContractTests(unittest.TestCase):
"""Rendered-page contract for browser MIME uncertainty."""

def test_unknown_browser_mime_is_not_silently_treated_as_verified_media(self) -> None:
"""An empty File.type must receive explicit, non-blocking server-validation copy."""
html = TestClient(app).get("/").text

self.assertIn("if (!file.type)", html)
self.assertIn(
"File type could not be identified; it will be validated after upload.",
html,
)


if __name__ == "__main__":
unittest.main()
Loading