-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): list filters submit once via „Търси" button instead of per-toggle #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
33cbf22
5a8e70a
c7237a2
34381c4
c3e1800
402123a
b0b6c7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,12 @@ | ||
| import type { ChangeEvent, FormEvent } from 'react'; | ||
| import { Form, Link, useNavigate, useSearchParams } from 'react-router'; | ||
| import { useEffect, useRef, type ChangeEvent, type FormEvent } from 'react'; | ||
| import { Form, Link, useSearchParams } from 'react-router'; | ||
| import { count as fmtCount } from '@sigma/shared'; | ||
| import { withParams } from '../lib/filters'; | ||
| import { | ||
| categorySelectionState, | ||
| filterFormKey, | ||
| preservedParamInputs, | ||
| shouldPruneField, | ||
| } from './filterRail.logic'; | ||
|
|
||
| export interface FilterOption { | ||
| value: string; | ||
|
|
@@ -27,10 +32,14 @@ export interface FilterGroup { | |
| more?: { href: string; label: string }; | ||
| } | ||
|
|
||
| // Sticky filter rail. Filters live in the URL (shareable). A `<Form method="get">` auto-submits on | ||
| // change when JS is on (instant filtering) and still works via the visible button without JS. The | ||
| // current `sort` is preserved through a hidden field; `cursor`/`page` are intentionally omitted so a | ||
| // new filter resets to page 1. | ||
| // Filter rail (a sticky sidebar on desktop; the „Търси" bar itself is a fixed floating pill — see | ||
| // layout.css). Filters live in the URL (shareable). A native `<Form method="get">` accumulates the | ||
| // selection client-side and applies it in ONE navigation when the visitor presses „Търси" — no | ||
| // per-toggle Worker request / D1 pass (issue #181). Checkboxes are uncontrolled (`defaultChecked`), so | ||
| // they respond instantly and work with JS off. The current `sort` and any `authority`/`bidder` scope | ||
| // are preserved through hidden fields; `cursor`/`page` have no field, so the native submit drops them | ||
| // and the keyset cursor resets to page 1 for free. The URL stays the source of truth: the form is keyed | ||
| // on the query string so „Изчисти", back/forward and shared links remount it and re-apply defaultChecked. | ||
| // | ||
| // All groups render expanded by default so the available filters are visible at a glance; the visitor | ||
| // can collapse any of them by clicking its summary (the `<details>` element preserves that local | ||
|
|
@@ -47,26 +56,47 @@ export function FilterRail({ | |
| csvHref?: string; | ||
| }) { | ||
| const [sp] = useSearchParams(); | ||
| const navigate = useNavigate(); | ||
| const preservedScope = ['authority', 'bidder'].flatMap((key) => | ||
| sp.getAll(key).map((value) => ({ key, value })), | ||
| ); | ||
| const groupKeys = groups.map((g) => g.key); | ||
| const submitForm = (form: HTMLFormElement) => { | ||
| const data = new FormData(form); | ||
| const overrides: Record<string, string | string[] | null> = { | ||
| sort: String(data.get('sort') ?? sort), | ||
| cursor: null, | ||
| page: null, | ||
| const groupKeys = new Set(groups.map((g) => g.key)); | ||
| // Carry forward every current URL param that isn't a form control (search `q`, `authority`/`bidder` | ||
| // scope, …) so the native GET submit doesn't erase it — the native form serialises only its own | ||
| // fields. Group keys, `sort`, `cursor`, `page` are excluded (see preservedParamInputs). | ||
| const preserved = preservedParamInputs(sp, groupKeys); | ||
| // Count of currently-applied filters (from the loader/URL, not pending toggles), shown on the | ||
| // submit button so the sticky bar doubles as a „N filters active" indicator. | ||
| const appliedCount = groups.reduce((total, g) => total + g.selected.length, 0); | ||
| // The form is keyed on the applied filter set so it remounts (re-applying defaultChecked) on „Изчисти", | ||
| // back/forward and shared links — see the <Form> below. | ||
| const formKey = filterFormKey(sp); | ||
| // Keep the floating apply pill from covering the site footer: as the footer scrolls into view, lift | ||
| // the pill by however much the footer intrudes into the viewport, so it comes to rest just above it | ||
| // (the footer constrains it). Enhancement only — with JS off the pill stays at its base offset. | ||
| // Re-runs on `formKey` because the keyed <Form> remounts the pill (new node) on every filter apply; | ||
| // an empty dep array would leave the listener writing to the old, detached div. | ||
| const barRef = useRef<HTMLDivElement>(null); | ||
| useEffect(() => { | ||
| const bar = barRef.current; | ||
| const footer = document.querySelector<HTMLElement>('.site-footer'); | ||
| if (!bar || !footer) return; | ||
| let raf = 0; | ||
| const update = () => { | ||
| raf = 0; | ||
| const overlap = Math.max(0, window.innerHeight - footer.getBoundingClientRect().top); | ||
| bar.style.setProperty('--filter-bar-lift', `${overlap}px`); | ||
| }; | ||
| for (const key of groupKeys) { | ||
| overrides[key] = data.getAll(key).map(String).filter(Boolean); | ||
| } | ||
| navigate(withParams(sp, overrides)); | ||
| }; | ||
| const onChange = (e: FormEvent<HTMLFormElement>) => { | ||
| submitForm(e.currentTarget); | ||
| }; | ||
| const onScroll = () => { | ||
| if (!raf) raf = requestAnimationFrame(update); | ||
| }; | ||
| update(); | ||
| window.addEventListener('scroll', onScroll, { passive: true }); | ||
| window.addEventListener('resize', onScroll, { passive: true }); | ||
| return () => { | ||
| window.removeEventListener('scroll', onScroll); | ||
| window.removeEventListener('resize', onScroll); | ||
| if (raf) cancelAnimationFrame(raf); | ||
| }; | ||
| }, [formKey]); | ||
| // „Select all" only toggles its category's child checkboxes in the DOM; it never submits. The visitor | ||
| // reviews the accumulated selection and presses „Търси" to apply it in one navigation. | ||
| const onCategoryChange = (e: ChangeEvent<HTMLInputElement>, groupKey: string) => { | ||
| e.stopPropagation(); | ||
| const input = e.currentTarget; | ||
|
|
@@ -77,9 +107,15 @@ export function FilterRail({ | |
| .forEach((member) => { | ||
| if (member.name === groupKey) member.checked = input.checked; | ||
| }); | ||
|
|
||
| const form = input.form ?? (input.closest('form') as HTMLFormElement | null); | ||
| if (form) submitForm(form); | ||
| }; | ||
| // Progressive enhancement: drop empty-valued controls (the „Всички" radios submit `value=`/`eu=`) so | ||
| // the applied URL stays canonical. Disabled controls are omitted from the native GET; with JS off this | ||
| // never runs and the empty params are emitted but harmless (loaders treat empty as unset). | ||
| const onSubmit = (e: FormEvent<HTMLFormElement>) => { | ||
| for (const el of Array.from(e.currentTarget.elements)) { | ||
| const input = el as HTMLInputElement; | ||
| if (shouldPruneField(input, groupKeys)) input.disabled = true; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 СРЕДЕН (находката от ревюто по-горе): тук Поправка — връщане на полетата активни веднага след изпращането: const onSubmit = (e: FormEvent<HTMLFormElement>) => {
const pruned: HTMLInputElement[] = [];
for (const el of Array.from(e.currentTarget.elements)) {
const input = el as HTMLInputElement;
if (shouldPruneField(input, groupKeys)) {
input.disabled = true;
pruned.push(input);
}
}
queueMicrotask(() => pruned.forEach((el) => (el.disabled = false)));
};React Router чете FormData синхронно в същото събитие, така че microtask-ът връща контролите, без да засяга вече подадените данни. (Алтернатива: „Всички" да е радио без |
||
| } | ||
| }; | ||
| return ( | ||
| <aside className="filter-rail" aria-label="Филтри"> | ||
|
|
@@ -95,13 +131,15 @@ export function FilterRail({ | |
| <label htmlFor="filter-rail-toggle" className="filter-rail-summary"> | ||
| Филтри | ||
| </label> | ||
| <Form method="get" onChange={onChange}> | ||
| {/* Keyed on the applied filter set (cursor/page/sort excluded) so a new URL from clear, back/forward | ||
| or a shared link remounts the form and re-applies `defaultChecked` — uncontrolled inputs would | ||
| otherwise keep stale DOM state — while paging or re-sorting preserves open groups + focus. */} | ||
| <Form method="get" key={formKey} onSubmit={onSubmit}> | ||
| <input type="hidden" name="sort" value={sort} /> | ||
| {/* Preserve an active in-table search when filters change without JS (the JS path already | ||
| carries `q` through withParams). */} | ||
| {sp.get('q') && <input type="hidden" name="q" value={sp.get('q')!} />} | ||
| {preservedScope.map(({ key, value }) => ( | ||
| <input type="hidden" name={key} value={value} key={`${key}-${value}`} /> | ||
| {/* preservedParamInputs already carries every non-form URL param — the in-table search `q` | ||
| (#204), the authority/bidder scope, etc. — so the native GET submit never erases them. */} | ||
| {preserved.map(({ key, value }, i) => ( | ||
| <input type="hidden" name={key} value={value} key={`${key}-${i}`} /> | ||
| ))} | ||
| {groups.map((g) => { | ||
| return ( | ||
|
|
@@ -118,28 +156,24 @@ export function FilterRail({ | |
| type="radio" | ||
| name={g.key} | ||
| value="" | ||
| checked={g.selected.length === 0} | ||
| onChange={() => {}} | ||
| defaultChecked={g.selected.length === 0} | ||
| />{' '} | ||
| {g.allLabel ?? 'Всички'} | ||
| </label> | ||
| )} | ||
| {g.categories | ||
| ? g.categories.map((category) => { | ||
| const selected = new Set(g.selected); | ||
| const selectedCount = category.options.filter((option) => | ||
| selected.has(option.value), | ||
| ).length; | ||
| const allSelected = | ||
| category.options.length > 0 && selectedCount === category.options.length; | ||
| const someSelected = selectedCount > 0; | ||
| const { allSelected, someSelected } = categorySelectionState( | ||
| category.options.map((o) => o.value), | ||
| g.selected, | ||
| ); | ||
|
|
||
| return ( | ||
| <details className="filter-subgroup" key={category.key} open={someSelected}> | ||
| <summary> | ||
| <input | ||
| type="checkbox" | ||
| checked={allSelected} | ||
| defaultChecked={allSelected} | ||
| aria-checked={ | ||
| someSelected && !allSelected | ||
| ? 'mixed' | ||
|
|
@@ -165,8 +199,7 @@ export function FilterRail({ | |
| type={g.type} | ||
| name={g.key} | ||
| value={o.value} | ||
| checked={g.selected.includes(o.value)} | ||
| onChange={() => {}} | ||
| defaultChecked={g.selected.includes(o.value)} | ||
| />{' '} | ||
| {o.label} | ||
| {o.count != null && ( | ||
|
|
@@ -183,8 +216,7 @@ export function FilterRail({ | |
| type={g.type} | ||
| name={g.key} | ||
| value={o.value} | ||
| checked={g.selected.includes(o.value)} | ||
| onChange={() => {}} | ||
| defaultChecked={g.selected.includes(o.value)} | ||
| />{' '} | ||
| {o.label} | ||
| {o.count != null && <span className="muted small">{fmtCount(o.count)}</span>} | ||
|
|
@@ -198,20 +230,25 @@ export function FilterRail({ | |
| </details> | ||
| ); | ||
| })} | ||
| <noscript> | ||
| <button type="submit" className="filter-apply"> | ||
| Покажи резултатите | ||
| </button> | ||
| </noscript> | ||
| <p className="small muted mt-s4"> | ||
| <Link to={clearHref}>Изчисти филтрите</Link> | ||
| {csvHref && ( | ||
| <> | ||
| {' · '} | ||
| <a href={csvHref}>Изтегли CSV</a> | ||
| </> | ||
| )} | ||
| </p> | ||
| {/* One native submit applies the whole accumulated selection. Works with JS off; with JS on it | ||
| still avoids a Worker request per toggle (issue #181). On desktop this is a floating pill | ||
| fixed to the bottom of the viewport (see layout.css); the ref feeds the footer-lift effect. */} | ||
| <div className="filter-apply-bar" ref={barRef}> | ||
| <div className="filter-apply-inner"> | ||
| <button type="submit" className="filter-apply"> | ||
| Търси{appliedCount > 0 ? ` · ${fmtCount(appliedCount)}` : ''} | ||
| </button> | ||
| <p className="small muted filter-apply-links"> | ||
| <Link to={clearHref}>Изчисти филтрите</Link> | ||
| {csvHref && ( | ||
| <> | ||
| {' · '} | ||
| <a href={csvHref}>Изтегли CSV</a> | ||
| </> | ||
| )} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </Form> | ||
| </aside> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { | ||
| categorySelectionState, | ||
| filterFormKey, | ||
| preservedParamInputs, | ||
| shouldPruneField, | ||
| } from './filterRail.logic'; | ||
|
|
||
| describe('categorySelectionState', () => { | ||
| it('reports none selected when the intersection is empty', () => { | ||
| const state = categorySelectionState(['a', 'b', 'c'], []); | ||
| expect(state.selectedCount).toBe(0); | ||
| expect(state.someSelected).toBe(false); | ||
| expect(state.allSelected).toBe(false); | ||
| }); | ||
|
|
||
| it('reports a partial (indeterminate) selection', () => { | ||
| const state = categorySelectionState(['a', 'b', 'c'], ['b']); | ||
| expect(state.selectedCount).toBe(1); | ||
| expect(state.someSelected).toBe(true); | ||
| expect(state.allSelected).toBe(false); | ||
| }); | ||
|
|
||
| it('reports all selected when every option is present', () => { | ||
| const state = categorySelectionState(['a', 'b'], ['a', 'b']); | ||
| expect(state.selectedCount).toBe(2); | ||
| expect(state.someSelected).toBe(true); | ||
| expect(state.allSelected).toBe(true); | ||
| }); | ||
|
|
||
| it('ignores selected values outside the category', () => { | ||
| const state = categorySelectionState(['a', 'b'], ['a', 'x', 'y']); | ||
| expect(state.selectedCount).toBe(1); | ||
| expect(state.allSelected).toBe(false); | ||
| expect(state.someSelected).toBe(true); | ||
| }); | ||
|
|
||
| it('is never allSelected for an empty category', () => { | ||
| const state = categorySelectionState([], ['a']); | ||
| expect(state.selectedCount).toBe(0); | ||
| expect(state.allSelected).toBe(false); | ||
| expect(state.someSelected).toBe(false); | ||
| }); | ||
|
|
||
| it('accepts a Set as the selected collection', () => { | ||
| const state = categorySelectionState(['a', 'b'], new Set(['a', 'b'])); | ||
| expect(state.allSelected).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('preservedParamInputs', () => { | ||
| const groupKeys = ['sector', 'procedure', 'year', 'value', 'eu']; | ||
|
|
||
| it('returns nothing when the URL holds only form-owned params', () => { | ||
| const sp = new URLSearchParams('sort=value-desc§or=45&year=2026&cursor=after:x&page=3'); | ||
| expect(preservedParamInputs(sp, groupKeys)).toEqual([]); | ||
| }); | ||
|
|
||
| it('preserves the search param `q` so a filter submit cannot erase it (regression #181)', () => { | ||
| const sp = new URLSearchParams('q=път§or=45&sort=value-desc'); | ||
| expect(preservedParamInputs(sp, groupKeys)).toEqual([{ key: 'q', value: 'път' }]); | ||
| }); | ||
|
|
||
| it('preserves `bids` so the single-offer view survives a filter submit (regression #181)', () => { | ||
| const sp = new URLSearchParams('bids=1&year=2026&sort=date-desc'); | ||
| expect(preservedParamInputs(sp, groupKeys)).toEqual([{ key: 'bids', value: '1' }]); | ||
| }); | ||
|
|
||
| it('preserves the authority/bidder scope params', () => { | ||
| const sp = new URLSearchParams('authority=abc&bidder=xyz§or=45'); | ||
| expect(preservedParamInputs(sp, groupKeys)).toEqual([ | ||
| { key: 'authority', value: 'abc' }, | ||
| { key: 'bidder', value: 'xyz' }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('never re-emits a group key, sort, cursor or page', () => { | ||
| const sp = new URLSearchParams('sector=45&sort=value-desc&cursor=after:x&page=2&q=x'); | ||
| expect(preservedParamInputs(sp, groupKeys)).toEqual([{ key: 'q', value: 'x' }]); | ||
| }); | ||
|
|
||
| it('preserves repeated values of a carried param', () => { | ||
| const sp = new URLSearchParams('bidder=a&bidder=b'); | ||
| expect(preservedParamInputs(sp, groupKeys)).toEqual([ | ||
| { key: 'bidder', value: 'a' }, | ||
| { key: 'bidder', value: 'b' }, | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('shouldPruneField', () => { | ||
| const groupKeys = new Set(['value', 'eu', 'year']); | ||
|
|
||
| it('prunes an empty „Всички" radio (a group-key control)', () => { | ||
| expect(shouldPruneField({ name: 'value', value: '' }, groupKeys)).toBe(true); | ||
| expect(shouldPruneField({ name: 'eu', value: '' }, groupKeys)).toBe(true); | ||
| }); | ||
|
|
||
| it('keeps a group control that carries a value', () => { | ||
| expect(shouldPruneField({ name: 'year', value: '2026' }, groupKeys)).toBe(false); | ||
| }); | ||
|
|
||
| it('never prunes a non-group field, even when empty (hidden sort / preserved q)', () => { | ||
| expect(shouldPruneField({ name: 'sort', value: '' }, groupKeys)).toBe(false); | ||
| expect(shouldPruneField({ name: 'q', value: '' }, groupKeys)).toBe(false); | ||
| }); | ||
|
|
||
| it('never prunes an unnamed control (the submit button)', () => { | ||
| expect(shouldPruneField({ name: '', value: '' }, groupKeys)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('filterFormKey', () => { | ||
| it('changes when a filter param changes (so the form remounts and re-reads defaultChecked)', () => { | ||
| expect(filterFormKey(new URLSearchParams('year=2026'))).not.toBe( | ||
| filterFormKey(new URLSearchParams('year=2025')), | ||
| ); | ||
| }); | ||
|
|
||
| it('is stable across pagination so paging preserves open groups and focus', () => { | ||
| expect(filterFormKey(new URLSearchParams('year=2026&cursor=after:x&page=2'))).toBe( | ||
| filterFormKey(new URLSearchParams('year=2026')), | ||
| ); | ||
| }); | ||
|
|
||
| it('is stable across re-sorting (sort is a view option, not a filter)', () => { | ||
| expect(filterFormKey(new URLSearchParams('year=2026&sort=date-desc'))).toBe( | ||
| filterFormKey(new URLSearchParams('year=2026')), | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Достъпност (WCAG 4.1.3):
e.stopPropagation()тук спира change събитието на „Избери всички“ да достигне делегиранияonFormChange(ред 156+), който вдигаdirtyRefи обявява „Има неприложени промени“. Понеже програматичните.checkedзаписвания по децата вonCategoryChangeне пораждат change събития, кликът върху „Избери всички“ променя селекцията, но остава напълно тих за екранния четец — за разлика от превключването на отделен чекбокс.Възможен фикс: маркирайте dirty състоянието директно в
onCategoryChange(напр. извикайте същата логика, която вдигаdirtyRef/setStatus), вместо да разчитате на бълбукането, което тук умишлено спирате.