diff --git a/dashboard/app.js b/dashboard/app.js index 0a0d5c6..f5f7c51 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -9,6 +9,10 @@ const STATUS_COLORS = { Released: 'badge-released', }; +// Ordered list of statuses used for deterministic status-column sorting. +// A status not in this list sorts after all known statuses. +const STATUS_ORDER = Object.keys(STATUS_COLORS); + let invoices = []; let sortField = 'id'; let sortAsc = true; @@ -68,6 +72,12 @@ function render() { if (sortField === 'id' || sortField === 'amount') { va = Number(va); vb = Number(vb); + } else if (sortField === 'status') { + // Sort by the defined STATUS_ORDER; unknown statuses sort to the end + const ia = STATUS_ORDER.indexOf(va); + const ib = STATUS_ORDER.indexOf(vb); + va = ia === -1 ? STATUS_ORDER.length : ia; + vb = ib === -1 ? STATUS_ORDER.length : ib; } if (va < vb) return sortAsc ? -1 : 1; if (va > vb) return sortAsc ? 1 : -1; diff --git a/dashboard/app.test.js b/dashboard/app.test.js index 554ac10..f369dfc 100644 --- a/dashboard/app.test.js +++ b/dashboard/app.test.js @@ -314,3 +314,117 @@ describe('fetchInvoices() error handling', () => { expect(summary.querySelectorAll('.summary-card').length).toBe(7); }); }); + +// ── Issue 2: Sortable Amount and Status columns ────────────────────────────── + +/** + * Helper: click a column header by its data-sort value. + * The first click on a new field sorts ascending; a second click reverses. + * Requires loadAppScriptWithHandlers() to register th click listeners. + */ +function clickSortHeader(field) { + const th = document.querySelector(`thead th[data-sort="${field}"]`); + if (th) th.click(); +} + +/** + * Load app.js and dispatch DOMContentLoaded so that the header click handlers + * registered inside the DOMContentLoaded callback are wired up to the current + * DOM elements. Must be called after setupDOM(). + */ +function loadAppScriptWithHandlers() { + loadAppScript(); + // readyState is 'complete' in jsdom, so DOMContentLoaded never fires + // automatically; dispatch it manually to register th click listeners. + document.dispatchEvent(new Event('DOMContentLoaded')); +} + +describe('sort by amount (numeric)', () => { + beforeEach(async () => { + setupDOM(); + loadAppScriptWithHandlers(); + await populateInvoices(); + }); + + it('sorts invoices by amount ascending (numeric, not lexicographic)', () => { + clickSortHeader('amount'); + const rows = document.querySelectorAll('#invoiceBody tr'); + const amounts = Array.from(rows).map(r => { + const cell = r.querySelectorAll('td')[3]; + return parseFloat(cell.textContent); + }); + for (let i = 1; i < amounts.length; i++) { + expect(amounts[i]).toBeGreaterThanOrEqual(amounts[i - 1]); + } + }); + + it('sorts invoices by amount descending', () => { + clickSortHeader('amount'); // ascending + clickSortHeader('amount'); // toggle to descending + const rows = document.querySelectorAll('#invoiceBody tr'); + const amounts = Array.from(rows).map(r => { + const cell = r.querySelectorAll('td')[3]; + return parseFloat(cell.textContent); + }); + for (let i = 1; i < amounts.length; i++) { + expect(amounts[i]).toBeLessThanOrEqual(amounts[i - 1]); + } + }); + + it('correctly orders amounts numerically: smallest first is 1200, largest is 8000', () => { + clickSortHeader('amount'); + const rows = document.querySelectorAll('#invoiceBody tr'); + const first = parseFloat(rows[0].querySelectorAll('td')[3].textContent); + const last = parseFloat(rows[rows.length - 1].querySelectorAll('td')[3].textContent); + expect(first).toBeLessThan(last); + expect(first).toBe(1200); + expect(last).toBe(8000); + }); +}); + +describe('sort by status (defined STATUS_ORDER)', () => { + beforeEach(async () => { + setupDOM(); + loadAppScriptWithHandlers(); + await populateInvoices(); + }); + + it('sorts invoices by status following STATUS_ORDER ascending', () => { + clickSortHeader('status'); + const rows = document.querySelectorAll('#invoiceBody tr'); + const statuses = Array.from(rows).map(r => { + const cell = r.querySelectorAll('td')[4]; + const badge = cell.querySelector('.badge'); + const text = badge ? badge.textContent.trim() : ''; + return text === 'Refund Requested' ? 'RefundRequested' : text; + }); + const ORDER = ['Pending', 'Paid', 'Expired', 'Cancelled', 'RefundRequested', 'Released']; + for (let i = 1; i < statuses.length; i++) { + const prev = ORDER.indexOf(statuses[i - 1]); + const curr = ORDER.indexOf(statuses[i]); + if (prev !== -1 && curr !== -1) { + expect(curr).toBeGreaterThanOrEqual(prev); + } + } + }); + + it('sorts invoices by status descending', () => { + clickSortHeader('status'); // ascending + clickSortHeader('status'); // toggle to descending + const rows = document.querySelectorAll('#invoiceBody tr'); + const statuses = Array.from(rows).map(r => { + const cell = r.querySelectorAll('td')[4]; + const badge = cell.querySelector('.badge'); + const text = badge ? badge.textContent.trim() : ''; + return text === 'Refund Requested' ? 'RefundRequested' : text; + }); + const ORDER = ['Pending', 'Paid', 'Expired', 'Cancelled', 'RefundRequested', 'Released']; + for (let i = 1; i < statuses.length; i++) { + const prev = ORDER.indexOf(statuses[i - 1]); + const curr = ORDER.indexOf(statuses[i]); + if (prev !== -1 && curr !== -1) { + expect(curr).toBeLessThanOrEqual(prev); + } + } + }); +});