From 5ec19a2c752fc52a9e3767747dff18f98ffd5709 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 6 Oct 2026 21:54:53 +0200 Subject: [PATCH] fix(settings): show an inline error when the default coverage field is empty --- frontend/src/__tests__/settings.test.ts | 19 +++++++++++++++++++ frontend/src/settings.ts | 11 +++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts index a5cd806ca..9bfcc1051 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -1584,6 +1584,25 @@ describe('Settings Module', () => { expect(errEl!.textContent).toBe(msg); }); + it('clearing the default coverage field shows the inline error instead of passing as 0 (#522)', () => { + const input = document.getElementById('setting-default-coverage') as HTMLInputElement; + input.value = ''; + fire(input, 'input'); + + expect(input.getAttribute('aria-invalid')).toBe('true'); + const errEl = document.getElementById('setting-default-coverage-range-error'); + expect(errEl!.classList.contains('hidden')).toBe(false); + expect(errEl!.textContent).toBe('Must be a whole number between 0 and 100'); + }); + + it('clearing an optional numeric field stays without an inline error', () => { + const input = document.getElementById('setting-notification-days') as HTMLInputElement; + input.value = ''; + fire(input, 'input'); + + expect(input.getAttribute('aria-invalid')).toBeNull(); + }); + it('typing a fractional value into notification-days is rejected inline', () => { const input = document.getElementById('setting-notification-days') as HTMLInputElement; input.value = '1.5'; diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index ba9e6e88d..36a99fbc0 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -261,11 +261,12 @@ function byId(id: string): T | null { * The element's min/max attributes are the source of truth so the * range stays in sync with the HTML. An empty value clears the error * (the input may have its own "required" enforcement; we don't fight - * that here). + * that here). Pass `required` for a field whose empty value would + * save as 0 (the save path rejects it too), so the error shows inline. */ function wireInlineRangeValidation( inputId: string, - { signal, requireInteger = false }: { signal?: AbortSignal; requireInteger?: boolean } = {}, + { signal, requireInteger = false, required = false }: { signal?: AbortSignal; requireInteger?: boolean; required?: boolean } = {}, ): void { const input = document.getElementById(inputId) as HTMLInputElement | null; if (!input) return; @@ -312,13 +313,15 @@ function wireInlineRangeValidation( const check = (): void => { const raw = input.value.trim(); - if (raw === '') { + if (raw === '' && !required) { input.removeAttribute('aria-invalid'); error.classList.add('hidden'); return; } + // Number('') is 0, so a required empty field is invalid explicitly. const n = Number(raw); const invalid = + raw === '' || !Number.isFinite(n) || (requireInteger && !Number.isInteger(n)) || n < min || @@ -2703,7 +2706,7 @@ export function setupSettingsHandlers(signal?: AbortSignal): void { // across Settings. wireInlineRangeValidation('setting-notification-days', { signal, requireInteger: true }); wireInlineRangeValidation('setting-recs-stale-hours', { signal, requireInteger: true }); - wireInlineRangeValidation('setting-default-coverage', { signal, requireInteger: true }); + wireInlineRangeValidation('setting-default-coverage', { signal, requireInteger: true, required: true }); wireInlineRangeValidation('setting-grace-aws', { signal, requireInteger: true }); wireInlineRangeValidation('setting-grace-azure', { signal, requireInteger: true }); wireInlineRangeValidation('setting-grace-gcp', { signal, requireInteger: true });