diff --git a/.jules/palette.md b/.jules/palette.md index a1cf208b..977c8128 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,6 @@ +## 2026-07-29 - Dynamic File Size Limits and Batch Validation +**Learning:** Hardcoding server limit variables like `MAX_UPLOAD_BYTES` in client-side error strings creates a brittle UX that goes out of sync. Furthermore, omitting total size checks for batch uploads forces users to wait for server-side rejection rather than getting immediate feedback. +**Action:** Always dynamically format backend limits for client-side error strings using shared utilities, and implement inline validation with `setCustomValidity` and `aria-invalid` for both single and batch upload scenarios. ## 2024-07-12 - Intercepting batch form submissions for testing visual loading states **Learning:** Extending the learning from 2024-06-13, intercepting form submissions using `e.preventDefault()` via `page.evaluate()` is essential for capturing screenshot and video evidence of loading states (e.g., button disabling, spinner appearing) on forms like batch upload where the submission would normally reload the page or download an archive. **Action:** When testing visual loading states with Playwright, always inject an event listener using `page.evaluate()` to call `e.preventDefault()` on the form's `submit` event to freeze the UI in its loading state for verification. diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfe94a6..09c98631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +* 🎨 Palette: [UX improvement] 클라이언트 측 폼 검증 시 동적인 파일 크기 제한 메시지를 제공하고, 일괄 업로드의 총 용량 제한에 대한 인라인 에러 피드백을 추가했습니다. # Changelog ## [Unreleased] diff --git a/saas_web.py b/saas_web.py index 3a7b0352..b80170e0 100644 --- a/saas_web.py +++ b/saas_web.py @@ -230,9 +230,10 @@ async def add_security_headers(request: Request, call_next): } const text = formatBinaryBytes(file.size); if (file.size > MAX_UPLOAD_BYTES) { - input.setCustomValidity('File exceeds 5 GiB limit.'); + const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES); + input.setCustomValidity('File exceeds ' + limitText + ' limit.'); input.setAttribute('aria-invalid', 'true'); - preview.innerText = 'Selected file size: ' + text + ' (exceeds 5 GiB limit)'; + preview.innerText = 'Selected file size: ' + text + ' (exceeds ' + limitText + ' limit)'; preview.style.color = '#dc3545'; return; } @@ -325,6 +326,16 @@ async def add_security_headers(request: Request, call_next): preview.style.color = '#dc3545'; return; } + + if (totalSize > MAX_UPLOAD_BYTES) { + const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES); + input.setCustomValidity('Total size exceeds ' + limitText + ' limit.'); + input.setAttribute('aria-invalid', 'true'); + preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ', exceeds ' + limitText + ' limit)'; + preview.style.color = '#dc3545'; + return; + } + preview.innerText = 'Selected ' + files.length + ' file(s) (' + formatBinaryBytes(totalSize) + ')'; } diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 57b879d1..98fdc0e2 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -52,7 +52,9 @@ def test_get_ui_includes_binary_file_size_validation(self): self.assertIn("const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024;", html) self.assertIn("['B', 'KiB', 'MiB', 'GiB']", html) - self.assertIn("File exceeds 5 GiB limit.", html) + self.assertIn("const limitText = formatBinaryBytes(MAX_UPLOAD_BYTES);", html) + self.assertIn("input.setCustomValidity('File exceeds ' + limitText + ' limit.');", html) + self.assertIn("input.setCustomValidity('Total size exceeds ' + limitText + ' limit.');", html) self.assertIn("preview.style.color = '#0f6674';", html) self.assertIn('onchange="updateFileSizePreview(this)"', html)