Merge pull request 'Inbox: All page-size option (show every application unpaged)' (#50) from Talha into main
Deploy to S3 / deploy (push) Successful in 33s Details

pull/51/head^2
talha.ahmed 2026-09-02 11:29:11 +00:00
commit b3c890476f
5 changed files with 1169 additions and 12 deletions

108
frontend/mobile.test.mjs Normal file
View File

@ -0,0 +1,108 @@
/* ============================================================
mobile.test.mjs mobile-viewport regression test.
Opens every app route at phone/tablet widths against the running dev
server, logs in as the local seeded tester, and fails if any route lets
the main pane scroll sideways (the classic clipped-card / blown-out-grid
symptom) or throws a runtime error. Intended horizontal scrollers
(.table-wrap, .kanban, .tabs, .stepper) are exempt by design: they scroll
inside themselves, never the pane.
Requirements (not part of the default `npm run verify` chain):
- dev server running (npm run dev) and the backend reachable
- Chrome installed (CHROME_PATH overrides the default location)
- puppeteer-core available: npm i -D puppeteer-core (no download;
it drives the installed Chrome)
Run: node mobile.test.mjs
Env: ATS_BASE_URL, CHROME_PATH, ATS_TEST_EMAIL, ATS_TEST_PASSWORD
============================================================ */
let puppeteer
try {
puppeteer = (await import('puppeteer-core')).default
} catch {
console.error('puppeteer-core is not installed. Run: npm i -D puppeteer-core')
process.exit(2)
}
const BASE = process.env.ATS_BASE_URL || 'http://localhost:5173'
const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
const EMAIL = process.env.ATS_TEST_EMAIL || 'ats.tester@example.com'
const PASSWORD = process.env.ATS_TEST_PASSWORD || 'Test12345!'
// Keep in sync with src/app/routes.js (paths only — titles don't matter here).
const ROUTES = [
'dashboard', 'inbox', 'matching', 'jobs', 'candidates', 'talentpool', 'pipeline',
'progress', 'import', 'jobboard', 'recruiterhub', 'talent', 'tasks', 'aiassistant',
'interviews', 'requisitions', 'assessments', 'offers', 'managers', 'calendar',
'reports', 'analytics', 'aistudio', 'notifications', 'rbac', 'settings', 'help',
]
const WIDTHS = [320, 375, 390, 430, 768]
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: true,
args: ['--no-sandbox'],
defaultViewport: { width: 320, height: 800, isMobile: true, hasTouch: true },
})
const failures = []
const runtimeErrors = []
try {
const page = await browser.newPage()
page.on('pageerror', (e) => runtimeErrors.push(`${page.url()}: ${e.message.split('\n')[0]}`))
await page.goto(`${BASE}/auth/login`, { waitUntil: 'domcontentloaded', timeout: 30000 })
await sleep(1000)
if (await page.$('input[type=password]')) {
const email = await page.$('input:not([type=password])')
await email.type(EMAIL)
await page.type('input[type=password]', PASSWORD)
await Promise.all([
page.click('button[type=submit]'),
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}),
])
await sleep(1500)
}
for (const route of ROUTES) {
for (const w of WIDTHS) {
await page.setViewport({ width: w, height: 800, isMobile: w < 768, hasTouch: w < 768 })
await page.goto(`${BASE}/${route}`, { waitUntil: 'domcontentloaded', timeout: 30000 })
await sleep(1500)
let rep
try {
rep = await page.evaluate(() => {
const doc = document.documentElement
const content = document.querySelector('.content')
return {
docOverflow: doc.scrollWidth - doc.clientWidth,
contentOverflow: content ? content.scrollWidth - content.clientWidth : 0,
}
})
} catch {
await sleep(1500)
continue // page navigated mid-evaluate; the next loop iteration re-covers it
}
if (rep.docOverflow > 1) failures.push(`${route} @${w}: document scrolls sideways by ${rep.docOverflow}px`)
if (rep.contentOverflow > 1) failures.push(`${route} @${w}: .content scrolls sideways by ${rep.contentOverflow}px`)
}
process.stdout.write('.')
}
console.log('')
} finally {
await browser.close()
}
// 4xx/5xx fetch noise is backend behaviour, not a layout bug — only genuine
// script errors (pageerror) fail the run.
if (failures.length || runtimeErrors.length) {
for (const f of failures) console.error('FAIL ' + f)
for (const e of runtimeErrors) console.error('ERROR ' + e)
console.error(`\n${failures.length} overflow failure(s), ${runtimeErrors.length} runtime error(s)`)
process.exit(1)
}
console.log(`All ${ROUTES.length} routes clean at ${WIDTHS.join('/')}px — no sideways scroll, no runtime errors`)

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
"smoke": "node smoke.test.mjs",
"test:token": "node token.test.mjs",
"test:theme": "node theme.test.mjs",
"test:mobile": "node mobile.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs"
},
"dependencies": {
@ -24,6 +25,7 @@
"@vitejs/plugin-react": "^4.5.0",
"esbuild": "^0.28.1",
"jsdom": "^30.0.1",
"puppeteer-core": "^23.11.1",
"vite": "^6.3.5"
}
}

View File

@ -732,7 +732,8 @@ export default function Inbox() {
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
top: pageSize,
// 'all' drops the param entirely the endpoint reads a missing top as unpaged.
top: pageSize === 'all' ? undefined : pageSize,
skip,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, skip, pageSize, q])
@ -740,7 +741,7 @@ export default function Inbox() {
const formParams = useMemo(() => ({
sheet: formSheet || undefined,
offset: skip,
limit: pageSize,
limit: pageSize === 'all' ? undefined : pageSize,
...formTabFilter,
...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, skip, pageSize, q, formTabFilter])
@ -841,15 +842,19 @@ export default function Inbox() {
const total = q.trim()
? (activeQuery.data?.total ?? 0)
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
const pages = Math.max(1, Math.ceil(total / pageSize))
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
// page size as no LIMIT), so the whole tab is one page.
const showAll = pageSize === 'all'
const pages = showAll ? 1 : Math.max(1, Math.ceil(total / pageSize))
const from = total ? skip + 1 : 0
const to = Math.min(skip + pageSize, total)
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
const to = showAll ? total : Math.min(skip + pageSize, total)
const currentPage = showAll ? 1 : Math.min(Math.floor(skip / pageSize) + 1, pages)
useEffect(() => {
if (showAll) { if (skip !== 0) setSkip(0); return }
if (total <= 0 || skip < total) return
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
}, [total, pageSize, skip])
}, [total, pageSize, skip, showAll])
const list = inbox
@ -1208,12 +1213,14 @@ export default function Inbox() {
total={total}
page={currentPage}
pages={pages}
setPage={(p) => { setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
setPage={(p) => { if (showAll) return; setSkip((p - 1) * pageSize); setSelectedId(null); selection.clear() }}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
pageSizeMax={PAGE_SIZE_MAX}
allowAll
onPageSizeChange={(n) => {
setPageSize(n)
if (n === 'all') setSkip(0) // numeric sizes keep their page (clamp effect above)
setSelectedId(null)
selection.clear()
}}

View File

@ -110,10 +110,14 @@ export function pageAfterSizeChange(currentPage, total, nextSize) {
return Math.min(Math.max(1, currentPage || 1), pages)
}
/** Per page dropdown: 10, 50, 100 (values above `max` are omitted). */
export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id }) {
/** Per page dropdown: 10, 50, 100 (values above `max` are omitted).
With `allowAll`, an extra "All" entry reports the sentinel 'all' the
caller drops its top/limit param, which the inbox endpoints read as
unpaged (Query(None) -> no LIMIT). Opt-in per screen. */
export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id, allowAll = false }) {
const options = PAGE_SIZE_OPTIONS.filter((n) => n <= max)
const selected = clampPageSize(value, max)
const isAll = allowAll && value === 'all'
const selected = isAll ? 'all' : clampPageSize(value, max)
return (
<label className="page-size">
@ -122,12 +126,13 @@ export function PageSizeField({ value, onChange, max = 100, label = 'Per page',
id={id}
className="select page-size-select"
value={selected}
onChange={(e) => onChange(clampPageSize(e.target.value, max))}
onChange={(e) => onChange(e.target.value === 'all' ? 'all' : clampPageSize(e.target.value, max))}
aria-label={label}
>
{options.map((n) => (
<option key={n} value={n}>{n}</option>
))}
{allowAll && <option value="all">All</option>}
</select>
</label>
)
@ -135,7 +140,7 @@ export function PageSizeField({ value, onChange, max = 100, label = 'Per page',
export function Pagination({
from, to, total, page, pages, setPage, pageButtons,
pageSize, onPageSizeChange, pageSizeMax = 100,
pageSize, onPageSizeChange, pageSizeMax = 100, allowAll = false,
}) {
return (
<div className="pagination">
@ -149,6 +154,7 @@ export function Pagination({
value={pageSize ?? DEFAULT_PAGE_SIZE}
onChange={onPageSizeChange}
max={pageSizeMax}
allowAll={allowAll}
/>
<span className="page-size-total">of <b>{total}</b></span>
</>