Skip to content
Merged
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
187 changes: 186 additions & 1 deletion frontend/src/__tests__/plans-range-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* but the digits-only regex ^\d+$ rejects the exponent form.
*/

import { openNewPlanModal, closePlanModal } from '../plans';
import { openNewPlanModal, closePlanModal, openAddPurchasesModal } from '../plans';
import type { ToastOptions } from '../toast';

jest.mock('../api', () => ({
Expand Down Expand Up @@ -418,3 +418,188 @@ describe('plan numeric inputs: live range validation (#702)', () => {
expect(describedBy).toContain('plan-coverage-range-error');
});
});

// ---------------------------------------------------------------------------
// Add Purchases modal: inline range validation on Number of Purchases (#771)
// ---------------------------------------------------------------------------

/**
* Wire live range validation on the "Number of Purchases" field
* in the Add Purchases modal (issue #771).
*
* The field has min=1 max=52 (weekly cadence cap). Mirrors the same
* wireRangeInput pattern as the plan-creation modal (#702/#714):
* - aria-invalid + sibling .field-error on out-of-range / non-integer
* input / blur
* - Submit button is disabled while the field is invalid
* - Save-time guard uses Number() + Number.isInteger() (not parseInt)
* so fractional values like 2.5 are rejected before reaching the API
*/
describe('Add Purchases modal: inline range validation (#771)', () => {
beforeEach(async () => {
jest.clearAllMocks();
// Provide a minimal body with the plans-list container that
// openAddPurchasesModal's DOM-removal path expects.
const plansList = document.createElement('div');
plansList.id = 'plans-list';
const ppList = document.createElement('div');
ppList.id = 'planned-purchases-list';
document.body.replaceChildren(plansList, ppList);

await openAddPurchasesModal('plan-xyz', 'My Test Plan');
});

function countInput(): HTMLInputElement {
return document.getElementById('add-purchases-count') as HTMLInputElement;
}

function submitBtn(): HTMLButtonElement {
return document.querySelector<HTMLButtonElement>('#add-purchases-modal button[type="submit"]') as HTMLButtonElement;
}

function errorEl(): HTMLElement | null {
return document.getElementById('add-purchases-count-range-error');
}

// --- out-of-range values show inline error --------------------------------

it('typing 0 (below min) sets aria-invalid and shows the error span', () => {
const input = countInput();
input.value = '0';
input.dispatchEvent(new Event('input'));

expect(input.getAttribute('aria-invalid')).toBe('true');
const err = errorEl();
expect(err).not.toBeNull();
expect(err!.classList.contains('hidden')).toBe(false);
expect(err!.textContent).toBe('Must be a whole number between 1 and 52');
});

it('typing 53 (above max) sets aria-invalid and shows the error span', () => {
const input = countInput();
input.value = '53';
input.dispatchEvent(new Event('input'));

expect(input.getAttribute('aria-invalid')).toBe('true');
const err = errorEl();
expect(err).not.toBeNull();
expect(err!.classList.contains('hidden')).toBe(false);
expect(err!.textContent).toBe('Must be a whole number between 1 and 52');
});

// --- submit button disabled while field is invalid -----------------------

it('submit button is disabled when value is 0', () => {
const input = countInput();
input.value = '0';
input.dispatchEvent(new Event('input'));

expect(submitBtn().disabled).toBe(true);
});

it('submit button is disabled when value is 53', () => {
const input = countInput();
input.value = '53';
input.dispatchEvent(new Event('input'));

expect(submitBtn().disabled).toBe(true);
});

it('submit button is re-enabled when value comes back in range', () => {
const input = countInput();
input.value = '0';
input.dispatchEvent(new Event('input'));
expect(submitBtn().disabled).toBe(true);

input.value = '4';
input.dispatchEvent(new Event('input'));
expect(submitBtn().disabled).toBe(false);
});

// --- valid boundary values clear the error --------------------------------

it.each([['1'], ['26'], ['52']])(
'valid value %s clears aria-invalid and hides the error span',
(value) => {
const input = countInput();
// First trigger an error.
input.value = '0';
input.dispatchEvent(new Event('input'));
expect(input.getAttribute('aria-invalid')).toBe('true');

input.value = value;
input.dispatchEvent(new Event('input'));
expect(input.getAttribute('aria-invalid')).toBeNull();
const err = errorEl();
expect(err).not.toBeNull();
expect(err!.classList.contains('hidden')).toBe(true);
},
);

// --- fractional input rejected (feedback_strict_int_parse) ---------------

it('fractional value 2.5 is rejected as non-integer', () => {
const input = countInput();
input.value = '2.5';
input.dispatchEvent(new Event('input'));

expect(input.getAttribute('aria-invalid')).toBe('true');
expect(errorEl()!.classList.contains('hidden')).toBe(false);
});

// --- error span a11y attributes ------------------------------------------

it('error span has role=status and aria-live=polite', () => {
const input = countInput();
input.value = '0';
input.dispatchEvent(new Event('input'));

const err = errorEl();
expect(err).not.toBeNull();
expect(err!.getAttribute('role')).toBe('status');
expect(err!.getAttribute('aria-live')).toBe('polite');
});

it('input aria-describedby references the error span id', () => {
const input = countInput();
input.value = '0';
input.dispatchEvent(new Event('input'));

expect(input.getAttribute('aria-describedby')).toContain('add-purchases-count-range-error');
});

// --- save-time guard prevents API call on out-of-range value -------------

it('submitting count=0 is blocked by save-time guard without calling the API', async () => {
(require('../api').createPlannedPurchases as jest.Mock).mockResolvedValue({});

const input = countInput();
input.value = '0';
input.dispatchEvent(new Event('input'));

const form = document.getElementById('add-purchases-form') as HTMLFormElement;
form.dispatchEvent(new Event('submit'));

await new Promise(resolve => setTimeout(resolve, 20));

expect(require('../api').createPlannedPurchases).not.toHaveBeenCalled();
const errDiv = document.getElementById('add-purchases-error');
expect(errDiv?.classList.contains('hidden')).toBe(false);
expect(errDiv?.textContent).toMatch(/whole number between 1 and 52/i);
});

it('submitting count=53 is blocked by save-time guard without calling the API', async () => {
(require('../api').createPlannedPurchases as jest.Mock).mockResolvedValue({});

const input = countInput();
input.value = '53';
input.dispatchEvent(new Event('input'));

const form = document.getElementById('add-purchases-form') as HTMLFormElement;
form.dispatchEvent(new Event('submit'));

await new Promise(resolve => setTimeout(resolve, 20));

expect(require('../api').createPlannedPurchases).not.toHaveBeenCalled();
});
});
36 changes: 34 additions & 2 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1247,7 +1247,7 @@ export function closePurchaseModal(): void {
/**
* Open modal to add planned purchases for a plan
*/
async function openAddPurchasesModal(planId: string, planName: string): Promise<void> {
export async function openAddPurchasesModal(planId: string, planName: string): Promise<void> {
// Remove existing modal if present
document.getElementById('add-purchases-modal')?.remove();

Expand Down Expand Up @@ -1294,6 +1294,27 @@ async function openAddPurchasesModal(planId: string, planName: string): Promise<
document.getElementById('add-purchases-cancel')?.addEventListener('click', closeAddPurchasesModal);
document.getElementById('add-purchases-form')?.addEventListener('submit', (e) => void handleAddPurchases(e));

// Inline range validation on the count field: surfaces a
// "Must be a whole number between 1 and 52" message under the input
// as the user types, instead of waiting for the API call to reject
// the value (mirrors the wireRangeInput pattern from #702/#714,
// closes #771). The modal is built fresh on every open so no
// data-range-wired guard is needed here.
wireRangeInput('add-purchases-count', 1, 52);

// Keep the submit button disabled while the count field is invalid.
// Checked after every input/blur so the button reflects the live
// validation state set by wireRangeInput above.
const countInput = document.getElementById('add-purchases-count') as HTMLInputElement | null;
const submitBtn = modal.querySelector<HTMLButtonElement>('button[type="submit"]');
if (countInput && submitBtn) {
const syncSubmitBtn = (): void => {
submitBtn.disabled = countInput.getAttribute('aria-invalid') === 'true';
};
countInput.addEventListener('input', syncSubmitBtn);
countInput.addEventListener('blur', syncSubmitBtn);
}

// Engage focus trap + Escape handler. The modal element itself is
// removed from the DOM on close (see closeAddPurchasesModal) instead
// of just toggling .hidden, so the closeModal call there is what
Expand Down Expand Up @@ -1321,7 +1342,18 @@ async function handleAddPurchases(e: Event): Promise<void> {

try {
const planId = (document.getElementById('add-purchases-plan-id') as HTMLInputElement).value;
const count = parseInt((document.getElementById('add-purchases-count') as HTMLInputElement).value, 10);
// Use Number() (not parseInt) so fractional input like "2.5" fails
// Number.isInteger() instead of silently truncating to 2. Mirrors
// the strict parse pattern from #471/#702 (feedback_strict_int_parse).
const rawCount = Number((document.getElementById('add-purchases-count') as HTMLInputElement).value);
if (!Number.isFinite(rawCount) || !Number.isInteger(rawCount) || rawCount < 1 || rawCount > 52) {
if (errorDiv) {
errorDiv.textContent = 'Number of Purchases must be a whole number between 1 and 52';
errorDiv.classList.remove('hidden');
}
return;
}
const count = rawCount;
const startDate = (document.getElementById('add-purchases-start-date') as HTMLInputElement).value;

await api.createPlannedPurchases(planId, count, startDate);
Expand Down
Loading