diff --git a/app/components/question/file_component/view.html.erb b/app/components/question/file_component/view.html.erb index b9147efc9..8b890be83 100644 --- a/app/components/question/file_component/view.html.erb +++ b/app/components/question/file_component/view.html.erb @@ -7,5 +7,6 @@ text: question.hint_text, class: "govuk-!-margin-bottom-7" }, - accept: Question::File::FILE_TYPES.join(", ") + accept: Question::File::FILE_TYPES.join(", "), + data: { max_file_size: Question::File::FILE_UPLOAD_MAX_SIZE_IN_MB * 1024 * 1024 } %> diff --git a/app/components/question/text_component/view.html.erb b/app/components/question/text_component/view.html.erb index 4619a4c95..2ece92f31 100644 --- a/app/components/question/text_component/view.html.erb +++ b/app/components/question/text_component/view.html.erb @@ -6,5 +6,7 @@ <%= form_builder.govuk_text_area :text, label: { text: question_text_with_extra_suffix, **question_text_size_and_tag }, hint: { text: question.hint_text }, + max_chars: Question::Text::MAX_LENGTH_LONG_TEXT, + threshold: 99, rows: 5 %> <% end %> diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index 2f6e9e690..b7a223e5c 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -14,6 +14,7 @@ import { } from '../javascript/utils/google-analytics' import { CookieBanner } from '../../components/cookie_banner_component/cookie-banner' import { CookiePage } from '../../components/cookie_consent_form_component/cookie-consent-form' +import { initFileValidation } from '../javascript/utils/file-validation' const analyticsConsentStatus = loadConsentStatus() @@ -52,5 +53,6 @@ if (document.body.dataset.googleAnalyticsEnabled === 'true') { } initAll() +initFileValidation() window.dfeAutocomplete = dfeAutocomplete diff --git a/app/frontend/javascript/utils/file-validation/index.js b/app/frontend/javascript/utils/file-validation/index.js new file mode 100644 index 000000000..fc8b1b689 --- /dev/null +++ b/app/frontend/javascript/utils/file-validation/index.js @@ -0,0 +1,189 @@ +/** + * Client-side file validation for file upload inputs + * Validates file size before form submission to provide immediate feedback + */ + +const BYTES_IN_MB = 1024 * 1024 + +/** + * Format bytes to a human-readable size + * @param {number} bytes - The number of bytes + * @returns {string} Formatted file size + */ +function formatFileSize (bytes) { + if (bytes === 0) return '0 Bytes' + + const mb = bytes / BYTES_IN_MB + if (mb >= 1) { + return `${mb.toFixed(1)} MB` + } + + const kb = bytes / 1024 + return `${kb.toFixed(1)} KB` +} + +/** + * Show error message using GOV.UK Design System error pattern + * @param {HTMLInputElement} input - The file input element + * @param {string} errorMessage - The error message to display + */ +function showError (input, errorMessage) { + const formGroup = input.closest('.govuk-form-group') + if (!formGroup) return + + // Add error class to form group + formGroup.classList.add('govuk-form-group--error') + + // Check if error message already exists + let errorSpan = formGroup.querySelector('.govuk-error-message') + + if (!errorSpan) { + // Create error message element + errorSpan = document.createElement('span') + errorSpan.className = 'govuk-error-message' + errorSpan.id = `${input.id}-error` + + const visuallyHiddenSpan = document.createElement('span') + visuallyHiddenSpan.className = 'govuk-visually-hidden' + visuallyHiddenSpan.textContent = 'Error: ' + + errorSpan.appendChild(visuallyHiddenSpan) + + // Insert error message before the file input (or after hint if present) + const hint = formGroup.querySelector('.govuk-hint') + const insertBefore = hint || input + insertBefore.parentNode.insertBefore(errorSpan, insertBefore) + } + + // Update error message text (preserving the visually-hidden span) + const visuallyHidden = errorSpan.querySelector('.govuk-visually-hidden') + errorSpan.textContent = errorMessage + if (visuallyHidden) { + errorSpan.insertBefore(visuallyHidden, errorSpan.firstChild) + } + + // Add error class to input + input.classList.add('govuk-file-upload--error') + + // Update aria-describedby + const ariaDescribedBy = input.getAttribute('aria-describedby') || '' + if (!ariaDescribedBy.includes(errorSpan.id)) { + input.setAttribute( + 'aria-describedby', + ariaDescribedBy ? `${ariaDescribedBy} ${errorSpan.id}` : errorSpan.id + ) + } +} + +/** + * Clear error message from a file input + * @param {HTMLInputElement} input - The file input element + */ +function clearError (input) { + const formGroup = input.closest('.govuk-form-group') + if (!formGroup) return + + // Remove error class from form group + formGroup.classList.remove('govuk-form-group--error') + + // Remove error message + const errorSpan = formGroup.querySelector('.govuk-error-message') + if (errorSpan) { + errorSpan.remove() + } + + // Remove error class from input + input.classList.remove('govuk-file-upload--error') + + // Clean up aria-describedby + const ariaDescribedBy = input.getAttribute('aria-describedby') + if (ariaDescribedBy) { + const errorId = `${input.id}-error` + const updatedAriaDescribedBy = ariaDescribedBy + .split(' ') + .filter(id => id !== errorId) + .join(' ') + + if (updatedAriaDescribedBy) { + input.setAttribute('aria-describedby', updatedAriaDescribedBy) + } else { + input.removeAttribute('aria-describedby') + } + } +} + +/** + * Validate file size for a file input + * @param {HTMLInputElement} input - The file input element + * @returns {boolean} Whether the file is valid + */ +function validateFileSize (input) { + const file = input.files[0] + + // No file selected - clear any existing errors + if (!file) { + clearError(input) + return true + } + + const maxSizeInBytes = parseInt(input.dataset.maxFileSize, 10) + + // No max size specified - skip validation + if (!maxSizeInBytes) { + return true + } + + // File is within size limits + if (file.size <= maxSizeInBytes) { + clearError(input) + return true + } + + // File is too large - show error + const maxSizeMB = maxSizeInBytes / BYTES_IN_MB + const actualSize = formatFileSize(file.size) + const errorMessage = `The selected file must be smaller than ${maxSizeMB}MB (file is ${actualSize})` + + showError(input, errorMessage) + + // Clear the file input + input.value = '' + + return false +} + +/** + * Initialize file validation for all file inputs with data-max-file-size attribute + */ +export function initFileValidation () { + const fileInputs = document.querySelectorAll('input[type="file"][data-max-file-size]') + + fileInputs.forEach(input => { + // Validate on file selection + input.addEventListener('change', (event) => { + validateFileSize(event.target) + }) + }) + + // Also validate on form submission as a final check + document.addEventListener('submit', (event) => { + const form = event.target + const fileInputs = form.querySelectorAll('input[type="file"][data-max-file-size]') + + let hasErrors = false + fileInputs.forEach(input => { + if (!validateFileSize(input)) { + hasErrors = true + } + }) + + if (hasErrors) { + event.preventDefault() + // Focus on the first error + const firstError = form.querySelector('.govuk-file-upload--error') + if (firstError) { + firstError.focus() + } + } + }) +} diff --git a/app/frontend/javascript/utils/file-validation/index.test.js b/app/frontend/javascript/utils/file-validation/index.test.js new file mode 100644 index 000000000..07513d4ee --- /dev/null +++ b/app/frontend/javascript/utils/file-validation/index.test.js @@ -0,0 +1,404 @@ +/** + * @vitest-environment jsdom + */ + +import { initFileValidation } from './index.js' +import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest' + +describe('File Validation', () => { + let container + + beforeEach(() => { + // Create a container for our test DOM + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + // Clean up + document.body.removeChild(container) + }) + + describe('initFileValidation', () => { + it('adds validation to file inputs with data-max-file-size attribute', () => { + // Setup DOM with GOV.UK form structure + container.innerHTML = ` +
+
+ + +
+
+ ` + + initFileValidation() + + const input = container.querySelector('#file-input') + const changeEvent = new Event('change', { bubbles: true }) + + // Create a mock file that's too large (8MB) + const largeFile = new File(['x'.repeat(8 * 1024 * 1024)], 'large.pdf', { + type: 'application/pdf' + }) + + // Mock the files property + Object.defineProperty(input, 'files', { + value: [largeFile], + writable: true + }) + + input.dispatchEvent(changeEvent) + + // Should show error + expect(container.querySelector('.govuk-error-message')).toBeTruthy() + expect(container.querySelector('.govuk-form-group--error')).toBeTruthy() + expect(input.classList.contains('govuk-file-upload--error')).toBe(true) + expect(input.value).toBe('') // File input should be cleared + }) + + it('does not show error for files within size limit', () => { + container.innerHTML = ` +
+
+ + +
+
+ ` + + initFileValidation() + + const input = container.querySelector('#file-input') + const changeEvent = new Event('change', { bubbles: true }) + + // Create a mock file that's within limit (1MB) + const smallFile = new File(['x'.repeat(1 * 1024 * 1024)], 'small.pdf', { + type: 'application/pdf' + }) + + Object.defineProperty(input, 'files', { + value: [smallFile], + writable: true + }) + + input.dispatchEvent(changeEvent) + + // Should not show error + expect(container.querySelector('.govuk-error-message')).toBeFalsy() + expect(container.querySelector('.govuk-form-group--error')).toBeFalsy() + expect(input.classList.contains('govuk-file-upload--error')).toBe(false) + }) + + it('clears previous errors when a valid file is selected', () => { + container.innerHTML = ` +
+
+ + + Error: + Previous error + + +
+
+ ` + + initFileValidation() + + const input = container.querySelector('#file-input') + const changeEvent = new Event('change', { bubbles: true }) + + // Create a valid file + const validFile = new File(['content'], 'valid.pdf', { + type: 'application/pdf' + }) + + Object.defineProperty(input, 'files', { + value: [validFile], + writable: true + }) + + input.dispatchEvent(changeEvent) + + // Error should be cleared + expect(container.querySelector('.govuk-error-message')).toBeFalsy() + expect(container.querySelector('.govuk-form-group--error')).toBeFalsy() + expect(input.classList.contains('govuk-file-upload--error')).toBe(false) + }) + + it('prevents form submission when file is too large', () => { + container.innerHTML = ` +
+
+ + +
+ +
+ ` + + initFileValidation() + + const form = container.querySelector('form') + const input = container.querySelector('#file-input') + + // Create a mock file that's too large + const largeFile = new File(['x'.repeat(8 * 1024 * 1024)], 'large.pdf', { + type: 'application/pdf' + }) + + Object.defineProperty(input, 'files', { + value: [largeFile], + writable: true + }) + + const submitEvent = new Event('submit', { + bubbles: true, + cancelable: true + }) + const preventDefaultSpy = vi.spyOn(submitEvent, 'preventDefault') + + form.dispatchEvent(submitEvent) + + expect(preventDefaultSpy).toHaveBeenCalled() + }) + + it('allows form submission when file is within size limit', () => { + container.innerHTML = ` +
+
+ + +
+ +
+ ` + + initFileValidation() + + const form = container.querySelector('form') + const input = container.querySelector('#file-input') + + // Create a valid file + const validFile = new File(['content'], 'valid.pdf', { + type: 'application/pdf' + }) + + Object.defineProperty(input, 'files', { + value: [validFile], + writable: true + }) + + const submitEvent = new Event('submit', { + bubbles: true, + cancelable: true + }) + const preventDefaultSpy = vi.spyOn(submitEvent, 'preventDefault') + + form.dispatchEvent(submitEvent) + + expect(preventDefaultSpy).not.toHaveBeenCalled() + }) + + it('displays error message with correct file size information', () => { + container.innerHTML = ` +
+
+ + +
+
+ ` + + initFileValidation() + + const input = container.querySelector('#file-input') + const changeEvent = new Event('change', { bubbles: true }) + + // Create a file that's 10MB + const largeFile = new File( + ['x'.repeat(10 * 1024 * 1024)], + 'large.pdf', + { type: 'application/pdf' } + ) + + Object.defineProperty(input, 'files', { + value: [largeFile], + writable: true + }) + + input.dispatchEvent(changeEvent) + + const errorMessage = container.querySelector('.govuk-error-message') + expect(errorMessage).toBeTruthy() + expect(errorMessage.textContent).toContain('10.0 MB') + }) + + it('handles file inputs without data-max-file-size attribute', () => { + container.innerHTML = ` +
+
+ + +
+
+ ` + + // Should not throw an error + expect(() => initFileValidation()).not.toThrow() + + const input = container.querySelector('#file-input') + const changeEvent = new Event('change', { bubbles: true }) + + // Create a large file + const largeFile = new File(['x'.repeat(100 * 1024 * 1024)], 'huge.pdf', { + type: 'application/pdf' + }) + + Object.defineProperty(input, 'files', { + value: [largeFile], + writable: true + }) + + // Should not show error (no validation without data attribute) + input.dispatchEvent(changeEvent) + expect(container.querySelector('.govuk-error-message')).toBeFalsy() + }) + + it('sets aria-describedby correctly when showing errors', () => { + container.innerHTML = ` +
+
+ + +
+
+ ` + + initFileValidation() + + const input = container.querySelector('#file-input') + const changeEvent = new Event('change', { bubbles: true }) + + const largeFile = new File(['x'.repeat(8 * 1024 * 1024)], 'large.pdf', { + type: 'application/pdf' + }) + + Object.defineProperty(input, 'files', { + value: [largeFile], + writable: true + }) + + input.dispatchEvent(changeEvent) + + expect(input.getAttribute('aria-describedby')).toBe('file-input-error') + }) + + it('preserves existing aria-describedby when adding error', () => { + container.innerHTML = ` +
+
+ + + File must be a PDF + + +
+
+ ` + + initFileValidation() + + const input = container.querySelector('#file-input') + const changeEvent = new Event('change', { bubbles: true }) + + const largeFile = new File(['x'.repeat(8 * 1024 * 1024)], 'large.pdf', { + type: 'application/pdf' + }) + + Object.defineProperty(input, 'files', { + value: [largeFile], + writable: true + }) + + input.dispatchEvent(changeEvent) + + expect(input.getAttribute('aria-describedby')).toBe( + 'file-hint file-input-error' + ) + }) + }) +}) diff --git a/app/models/question/text.rb b/app/models/question/text.rb index e82799f09..bc3325348 100644 --- a/app/models/question/text.rb +++ b/app/models/question/text.rb @@ -2,8 +2,11 @@ module Question class Text < QuestionBase attribute :text validates :text, presence: true, unless: :is_optional? - validates :text, length: { maximum: 499, message: I18n.t("activemodel.errors.models.question/text.attributes.text.single_line_too_long") }, if: :is_single_line? - validates :text, length: { maximum: 4999, message: I18n.t("activemodel.errors.models.question/text.attributes.text.long_text_too_long") }, unless: :is_single_line? + + MAX_LENGTH_SINGLE_LINE = 499 + MAX_LENGTH_LONG_TEXT = 4999 + validates :text, length: { maximum: MAX_LENGTH_SINGLE_LINE, message: I18n.t("activemodel.errors.models.question/text.attributes.text.single_line_too_long") }, if: :is_single_line? + validates :text, length: { maximum: MAX_LENGTH_LONG_TEXT, message: I18n.t("activemodel.errors.models.question/text.attributes.text.long_text_too_long") }, unless: :is_single_line? before_validation :strip_carriage_returns!, unless: :is_single_line? diff --git a/spec/components/question/text_component/view_spec.rb b/spec/components/question/text_component/view_spec.rb index 8fce580a8..7886f1775 100644 --- a/spec/components/question/text_component/view_spec.rb +++ b/spec/components/question/text_component/view_spec.rb @@ -101,7 +101,7 @@ let(:question_page) { build :page, :with_hints, :with_text_settings, input_type: } it "outputs the hint text" do - expect(page.find(".govuk-hint")).to have_text(question.hint_text) + expect(page.find("#form-text-hint.govuk-hint")).to have_text(question.hint_text) end end diff --git a/spec/features/fill_in_file_upload_question_spec.rb b/spec/features/fill_in_file_upload_question_spec.rb index c8fa7ad6d..f30940aa3 100644 --- a/spec/features/fill_in_file_upload_question_spec.rb +++ b/spec/features/fill_in_file_upload_question_spec.rb @@ -96,6 +96,30 @@ end end + context "when client-side file validation is active" do + let(:scan_status) { "NO_THREATS_FOUND" } + let(:large_test_file) { "tmp/large-file.txt" } + + before do + # Create a file larger than 7MB + File.write(large_test_file, "a" * (7 * 1024 * 1024 + 1)) + end + + after do + File.delete(large_test_file) if File.exist?(large_test_file) + end + + scenario "when the user tries to upload a file that is too large" do + when_i_visit_the_form_start_page + then_i_should_see_the_first_question + then_i_see_the_file_upload_component + then_the_file_input_has_max_size_validation + when_i_upload_a_large_file + then_i_see_a_client_side_file_size_error + and_the_file_input_is_cleared + end + end + def when_i_visit_the_form_start_page visit form_path(mode: "form", form_id: 1, form_slug: "fill-in-this-form") expect_page_to_have_no_axe_errors(page) @@ -160,4 +184,26 @@ def then_i_see_an_error_message_that_the_file_contains_a_virus expect(page.find(".govuk-error-summary")).to have_text "The selected file contains a virus" expect(page).to have_css("input[type=file]") end + + def then_the_file_input_has_max_size_validation + file_input = page.find("input[type=file]") + expect(file_input["data-max-file-size"]).to eq((Question::File::FILE_UPLOAD_MAX_SIZE_IN_MB * 1024 * 1024).to_s) + end + + def when_i_upload_a_large_file + attach_file question_text, large_test_file + # Give JavaScript time to process the file and show the error + sleep 0.5 + end + + def then_i_see_a_client_side_file_size_error + expect(page).to have_css(".govuk-form-group--error") + expect(page).to have_css(".govuk-error-message", text: /The selected file must be smaller than 7MB/) + expect(page).to have_css(".govuk-file-upload--error") + end + + def and_the_file_input_is_cleared + file_input = page.find("input[type=file]") + expect(file_input.value).to be_empty + end end