feat(web): list filters submit once via „Търси" button instead of per-toggle - #228
feat(web): list filters submit once via „Търси" button instead of per-toggle#228DiyanaDimitrova wants to merge 7 commits into
Conversation
…-toggle FilterRail auto-submitted on every checkbox change — one Worker request + a full D1 loader pass per click (N filters = N navigations; self-inflicted D1 read cost, cf. midt-bg#122). Controlled checkboxes also felt unresponsive on mobile/slow links, since their state was owned by the server-rendered loader data. Switch to a native, progressively-enhanced GET form: - one visible „Търси" button applies the whole selection in a single navigation (works with JS off) - checkboxes/radios are uncontrolled (defaultChecked) — instant, no JS; the form is keyed on the applied filter set so clear / back-forward / shared links re-apply it (cursor/page/sort excluded so paging or re-sorting doesn't collapse open filter groups) - the native submit carries every non-form URL param via hidden inputs (the in-table search q, the authority/bidder scope, …) and drops cursor/page, so the keyset cursor resets for free - empty „Всички" radios are pruned on submit so the applied URL stays canonical - the „Търси" bar is a floating pill that stays above the site footer - select-all stays JS-driven but no longer submits No route or lib/filters.ts changes (the component is shared by contracts / companies / authorities). Extract the pure logic (categorySelectionState, preservedParamInputs, shouldPruneField, filterFormKey) with unit tests, plus a native-GET round-trip test through the loader parser. Closes midt-bg#181
05f51a3 to
33cbf22
Compare
Ревю на PR #228 — филтрите се подават наведнъж през бутон „Търси"Общ преглед
Коректност🔴 СРЕДЕН —
🟡 НИСЪК — при недовършена селекция състоянието на uncontrolled полетата изостава (заложено в дизайна). Тъй като нищо не се пре-render-ва до изпращане: броячът Качество на кода и конвенции — силно
Производителност
Тестове
СигурностБез забележки. Стойностите на скритите полета идват от Дребни
ЗаключениеДобре направена промяна, по конвенция, с добри тестове и реална печалба в производителността. Една СРЕДНА находка за оправяне преди merge (изключените „Всички" радио бутони след изпращане без remount — лесна поправка с microtask) плюс предложение за e2e тест на основния поток. Останалото е по избор. |
| 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; |
There was a problem hiding this comment.
🔴 СРЕДЕН (находката от ревюто по-горе): тук disabled се задава, но полетата никога не се връщат активни. Възстановяват се единствено при remount на <Form key={formKey}>, а filterFormKey изключва cursor/page/sort — значи „Търси" от страница ≥2 (или повторно изпращане на идентична селекция) дава същия formKey, няма remount и радио бутоните „Всички" остават disabled и не реагират до смяна на филтър / презареждане.
Поправка — връщане на полетата активни веднага след изпращането:
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-ът връща контролите, без да засяга вече подадените данни. (Алтернатива: „Всички" да е радио без name — тогава изключването изобщо не трябва.)
…idt-bg#228 review) The onSubmit empty-field prune disabled the „Всички" radios so they don't emit `?value=&eu=`, but never re-enabled them. A submit that doesn't remount the form — re-submitting an unchanged selection only drops cursor/page, keeping the same filterFormKey — left those radios permanently disabled and unresponsive until a filter changed or the page reloaded (imperative `disabled`, never in the JSX, so no re-render resets it). Re-enable the pruned controls in a queueMicrotask after submit: React Router reads FormData synchronously in the event, so the microtask restores them without affecting the submitted query. Also soften the 960.02px media-query comment (it shrinks the dead zone rather than eliminating it outright). Refs midt-bg#181
The „Търси" apply button rendered wider than its filter column and overflowed it on narrow viewports. Root cause: the button computed as box-sizing:content-box (the global reset wasn't applying to it), so inline-size excluded the 32px horizontal padding + border and inline-size:100% spilled ~66px past the column. Force box-sizing:border-box so the width includes padding and 100% fits the column exactly. Also add two overflow guards so a wide results table can't stretch the layout past the viewport: - .split grid tracks use minmax(0, 1fr) instead of a bare 1fr (whose auto minimum lets a nowrap child blow the track past the screen). - main uses overflow-x: clip (not hidden, so the sticky rail keeps working; the results table keeps its own internal overflow: auto).
nedda76
left a comment
There was a problem hiding this comment.
Повторно ревю — последните промени (33cbf22..34381c4)
Блокиращата находка (MEDIUM) е решена коректно. 5a8e70a събира изключените полета и ги връща активни през queueMicrotask след submit-а (React Router вече е прочел FormData синхронно), с guard на pruned.length — точно поправката от коментара. „Всички" радио бутоните вече не могат да останат заключени след повторно изпращане без remount.
CSS-промените също са добри и обосновани:
chrome.css—overflow-x: clipнаmainспира широка таблица без пренасяне да избутва страницата и да поражда хоризонтален скрол (който отрязваше левия край и правеше бутона „Търси" да изглежда по-широк).clip, неhidden— не създава скрол контейнер, така че sticky и fixed позиционирането остават невредими.layout.css—grid-template-columns: 220px minmax(0, 1fr)(базово + мобилно) оправя класическото разливане на1frгрида;box-sizing: border-boxна.filter-applyспира бутона да излиза 66px извън колоната си на мобилно.- Коментарът за
960.02вече е точен (свива, а не премахва мъртвата зона) — добре уловено.
Без регресии: тестовете за чистата логика не са засегнати (filterRail.logic.ts е непроменен), а TSX промяната е малка и типизирана.
Остават две незадължителни неща (не блокират): броячът и indeterminate при недовършена селекция остават спрямо приложеното (заложено в uncontrolled дизайна), а самата re-enable поправка няма регресионен тест (DOM + microtask поведение, трудно за unit тест; логиката shouldPruneField вече е покрита). Playwright/RTL тест за потока „маркиране → Търси → странициране → Търси → бутоните пак работят" би затворил и това, но за после.
Одобрявам след зелена CI. Чиста, добре тествана промяна с реална печалба в производителността.
lyubomir-bozhinov
left a comment
There was a problem hiding this comment.
Прегледах на връх 34381c4 — sound, без блокери. Auto-submit-ът е напълно махнат; submit-ва се веднъж през „Търси“. Три дребни (non-blocking) наблюдения:
-
„Избери всички“ остава stale след взаимодействие с child-ите (
FilterRail.tsx~190). Чекбоксът е uncontrolled (defaultChecked={allSelected}); inline ref-ът обновява самоindeterminateна всеки render, ноcheckedне се пресинхронизира. След ръчно махане на всички child-и в категориятаsomeSelected=false→indeterminate=false, аcheckedоставаtrueот предишното състояние → „избери всички“ се вижда отметнато при нула избрани. Козметично и се нулира при следващ submit/навигация (form-ът е keyed), но между два submit-а подвежда. Fix: пресметниchecked/indeterminateи вonCategoryChange. -
filterFormKeyе чувствителен на реда на параметрите (filterRail.logic.ts:58-63). Връщаnext.toString(), който пази insertion order наsp. Два URL-а със същия filter set, но разбъркан ред (споделен линк, ръчно редактиран URL, back/forward), дават различен key → form-ът се remount-ва и колапсва<details>/фокуса — точно това, което key-ът съществува да ИЗБЕГНЕ (виж собствения docstring). Fix: sort-ни параметрите предиtoString()(напр. по канонична подредба à la #222) за стабилен key. -
Няма
aria-busy/aria-liveна rail-а по време на навигация (a11y enhancement). Докато loader-ът върви след „Търси“, screen-reader потребител няма сигнал. Вържиaria-busyна<aside className="filter-rail">къмuseNavigation().state !== 'idle'.
Нищо от трите не блокира merge.
nikimilenkov
left a comment
There was a problem hiding this comment.
Благодаря за PR-а — работата е солидна: чисто минаване към uncontrolled inputs, изнесена и тествана логика, работи без JS, и Medium-ът на @nedda76 (заключени „Всички" радио бутони) е коректно оправен на HEAD. Остават обаче два проблема — и двата на основния път — които блокират merge.
🔴 Висок — сигурност: preservedParamInputs връща #197 cache poisoning (CWE-349)
filterRail.logic.ts:28-38 пренася всеки URL параметър освен собствените на формата — включително непознати. Затова GET /contracts?zzz=<payload> рендерира <input type="hidden" name="zzz" value="…"> в SSR тялото; cache-key.ts маха zzz от ключа → отровеното тяло се кешира под чистия /contracts (contracts.tsx е publicCache(1800)) и се сервира на всички посетители.
- Това е регресия от този PR — старият код пренасяше само
q/authority/bidder(всички в cache allow-list-а). „Пренасяй всичко" чупи инварианта на #197 (нищо unkeyed да не влиза в кешираното тяло). - Не е XSS (React escape-ва и името, и стойността; input-ът е инертен) — а инжектиране на unkeyed съдържание / cache poisoning, на трите кеширани списъчни route-а.
- Drift guard-ът в
cache-key.test.tsне го хваща — сканираroutes/**+lib/filters.tsза литерални четения, а тук параметрите се четат генерично презsp.entries()вcomponents/. - Поправка: ограничи пренасянето до allow-list-а —
if (CACHE_QUERY_PARAMS.has(key) && !owned.has(key))— така непознатите падат. Плюс тест:preservedParamInputs(sp('zzz=poison'), …)→[].
🔴 Висок — достъпност: фокусът се губи при всяко „Търси" (WCAG 2.4.3)
<Form key={filterFormKey(sp)}> — при „Търси" след смяна на филтър ключът се сменя → React remount-ва <Form>-а и размонтира бутона, върху който е бил клавиатурният фокус → фокусът пада на <body>, безшумно, без нищо да се премести към заглавие на резултатите / live region. Това е основният сценарий, който PR-ът въвежда (един бутон за прилагане): клавиатурният потребител трябва да табва от началото, а screen reader не получава съобщение какво се е случило.
- Поправка: след навигацията премести фокуса умишлено (към заглавие на резултатите или
aria-liveстатус), или не remount-вай целия<Form>(нулирай само input-ите).
🟡 Среден — достъпност: няма обратна връзка за неприложена селекция (WCAG 4.1.3)
Натискането на чекбокси вече е безшумно (нито визуално „N избрани, неприложени", нито aria-live) до „Търси" — SR потребител няма как да разбере, че отметките му са регистрирани. Шаблонът вече го има в кода (ListControls.tsx ползва role="status"/aria-live); приложи го и тук.
🔵 Ниски
- Достъпност (възможен): императивният
disabledна „Всички" радио бутони при submit може да blur-не фокуса към<body>, ако някой е фокусиран — зависи от браузъра, вероятно недостижимо днес, но лесно се чупи при бъдеща промяна на кода. - Тест-пропуск: няма тест с непознат параметър, който да заключи allow-list инварианта.
- React: неприложени отметки оцеляват при paging навигация със същия
formKey; „избери всички" чекбоксът застоява след ръчна смяна на дъщерна отметка (и двете са присъщи на модела „натрупай и приложи" — продуктово решение).
✅ Проверено и чисто
Uncontrolled defaultChecked (без hydration mismatch); re-enable на радио бутоните (queueMicrotask е timing-safe, RR чете FormData синхронно преди него); native GET без JS; cursor/page reset към страница 1; multi-value/encoding; label/форма семантика, клавиатура, contrast, видим фокус, prefers-reduced-motion. Medium-ът на @nedda76 е оправен и пълен на HEAD.
Вердикт: Изисквам промени — блокиращи са двата „Висок" (сигурност + достъпност/фокус). Останалото е дребно.
lyubomir-bozhinov
left a comment
There was a problem hiding this comment.
Одобрявам на връх 34381c4. Auto-submit-ът е напълно махнат; submit-ва се веднъж през „Търси“, логиката е чиста. Трите бележки в предходния ми коментар (stale „избери всички“, order-sensitive filterFormKey, липсващ aria-busy) са non-blocking — не са условие за merge; хубаво е да се адресират в follow-up. mergeable, остава required CI да мине зелено.
…a11y Security (HIGH, CWE-349 / midt-bg#197 regression): preservedParamInputs carried every non-form URL param into a hidden input, so an arbitrary ?zzz=<payload> was rendered into the SSR body while cache-key.ts keys only on CACHE_QUERY_PARAMS — the poisoned body could be cached under the clean URL on the publicCache'd list routes. Restrict the carry-forward to CACHE_QUERY_PARAMS (minus the form's own keys); unknown params are dropped (loaders ignore them). Adds regression tests. Accessibility (HIGH, WCAG 2.4.3): applying filters remounts the keyed <Form>, unmounting the „Търси" button and dropping keyboard focus to <body>. Track the submit and, once navigation settles, return focus to the remounted button and announce via a polite live region. Accessibility (WCAG 4.1.3): add aria-busy on the rail during navigation and a sr-only role=status/aria-live region announcing „Зареждане…", the applied update, and (once per editing burst) that toggles are pending apply. filterFormKey is now order-insensitive (sort params) so a re-ordered URL (shared link, back/forward) no longer remounts the form and collapses <details>/focus — the very thing the key exists to avoid. Fix stale „Избери всички": a category select-all now resyncs its checked + indeterminate state when a child option toggles, via a delegated form onChange.
Ревю на PR #228 — преглед по dimensionsПрегледах кода спрямо действителното състояние на клона ( 🔒 Сигурност — ЧИСТОНяма твърдо кодирани тайни, нови зависимости или подозрителни URL-и. Ключовата промяна тук всъщност затваря регресия за cache poisoning (CWE-349, #197): ✅ Коректност и дизайн — силно
📋 Забележки1. Дребно / локализация — противоречиво съобщение за екранни четци.
„непроменени" противоречи на „приложите ги". Предлагам напр. 2. Дребно / покритие. Тествана е само чистата логика. React-логиката, която регресира два пъти по време на ревюто — възстановяване на фокуса след keyed remount (WCAG 2.4.3), 3. Инфо / визуална проверка. 4. Дребно / остаряло. Бележката в описанието, че клонът изостава от ЗаключениеCI: зелено. Препоръка: APPROVE. Единствено забележка №1 бих оправил преди merge; останалите са по желание/за проверка. Много чиста commit хигиена и коментари, обясняващи „защо" — включително истинска печалба за сигурността. |
ydimitrof
left a comment
There was a problem hiding this comment.
Преглед на PR: feat(web): филтрите се подават еднократно чрез бутона „Търси“ вместо при всяко превключване
ВЕРДИКТ: COMMENT — няма блокиращи проблеми по сигурност или коректност; има 2 дребни забележки (текст за екранни четци + един пропуск по достъпност).
Обобщение
Много добре изпипан PR. Преходът от авто-подаване при всяко превключване към еднократно подаване с „Търси“ (issue #181) е реализиран чисто: неконтролирани input-и (defaultChecked), нативна <Form method="get">, key-ната форма се ремоунтва при смяна на URL, добавени са грижи за достъпност (възстановяване на фокус, aria-busy, polite live region) и чисти помощни функции с добро unit покритие. Тестовете са смислени и целят да разкрият дефекти, а не да минат тривиално.
Фаза 0 — Скан за сигурност: ЧИСТО ✅
- Тайни/ключове: няма hardcoded секрети, токени или пароли.
- Зависимости: няма нови или променени пакети.
- URL адреси: няма нови външни URL/endpoint-и.
- Инжекции/XSS: стойностите на скритите
<input>идват отURLSearchParamsи се рендират като атрибути през JSX → React ги екранира. НямаdangerouslySetInnerHTML, няма конкатенация в SQL/HTML, няма backdoor/обфускация. - Cache poisoning (CWE-349):
preservedParamInputsумишлено пренася само параметри от allow-листатаCACHE_QUERY_PARAMS, така че непознат?zzz=<payload>не попада в SSR тялото наpublicCache-нат маршрут. Коректна защита, покрита с тестове (utm_source=poison,zzz=<script>). Одобрено.
Забележка за проверка: тъй като apps/web не е наличен локално в тази среда, не можах да потвърдя, че CACHE_QUERY_PARAMS в workers/cache-key.ts съвпада 1:1 с ключа на кеша. Логиката разчита изцяло на това съвпадение — моля потвърдете, че всеки параметър, който loader-ът чете и който влияе на отговора, е в CACHE_QUERY_PARAMS; иначе такъв параметър ще бъде тихо изхвърлен при подаване на филтри.
Коректност и жизнен цикъл (React) — ОК
- Ремоунтът по
filterFormKey(изключваcursor/page/sort, сортиран → нечувствителен към реда) коректно преприлагаdefaultCheckedпри „Изчисти“, back/forward и споделени връзки, без да събаря отворените<details>и фокуса при странициране/сортиране. onSubmitprune-ва празните group-key контроли чрез временноdisabledи ги възстановява презqueueMicrotaskслед синхронния прочит наFormDataот React Router — правилно решен и коментиран крайният случай „повторно подаване без ремоунт“.- Скрол-ефектът за повдигане над footer-а чисти listener-ите и
requestAnimationFrame— няма изтичане на ресурси. - Възстановяването на фокус е предпазливо (само ако фокусът е паднал на
<body>), което е правилно.
Забележки (не-блокиращи)
-
Текст за екранни четци — вероятна грешка в думата. Статусът гласи „Има непроменени филтри…“, но смисълът (и коментарът в кода: „pending, not-yet-applied changes“) е за НЕПРИЛОЖЕНИ промени. „непроменени“ = unchanged, което е обратното. Предложение: „Има неприложени промени. Натиснете „Търси“, за да ги приложите.“ Виж inline коментара.
-
Достъпност (WCAG 4.1.3) — „Избери всички“ не задейства анонса за чакащи промени.
onCategoryChangeвикаe.stopPropagation(), така че change събитието на „Избери всички“ не стига до делегиранияonFormChange, който вдигаdirtyRefи обявява „Има неприложени промени“. Понеже програматичните.checkedзаписвания по децата не пораждат change събития, кликът върху „Избери всички“ променя селекцията, но остава напълно тих за екранния четец — за разлика от превключването на отделен чекбокс. Виж inline коментара за възможен фикс.
Тестове
Покритието на чистата логика е добро и смислено (categorySelectionState, preservedParamInputs, shouldPruneField, filterFormKey, round-trip на нативния GET през loader-а). Самият компонент (DOM/фокус/live region/footer-lift) няма тестове — приемливо предвид native-form подхода, но горните два случая (анонс при „Избери всички“ и текстът на статуса) не са покрити и затова дефектите се промъкнаха.
Заключение
Няма проблеми по сигурност или коректност, блокиращи merge. Препоръчвам да се коригира текстът на статуса (т.1) и по желание пропускът по достъпност (т.2) преди merge. Останалото е готово за продукция.
| const onFormChange = (e: ChangeEvent<HTMLFormElement>) => { | ||
| if (!dirtyRef.current) { | ||
| dirtyRef.current = true; | ||
| setStatus('Има непроменени филтри. Натиснете „Търси", за да ги приложите.'); |
There was a problem hiding this comment.
Вероятна грешка в текста за екранни четци: „непроменени“ означава unchanged, но намерението (и коментарът по-горе — „pending, not-yet-applied changes“) е за НЕПРИЛОЖЕНИ промени. Предложение:
setStatus('Има неприложени промени. Натиснете „Търси“, за да ги приложите.');Това е единственият видим (за екранен четец) низ, който описва състоянието, така че точността тук има значение за WCAG 4.1.3.
| // „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(); |
There was a problem hiding this comment.
Достъпност (WCAG 4.1.3): e.stopPropagation() тук спира change събитието на „Избери всички“ да достигне делегирания onFormChange (ред 156+), който вдига dirtyRef и обявява „Има неприложени промени“. Понеже програматичните .checked записвания по децата в onCategoryChange не пораждат change събития, кликът върху „Избери всички“ променя селекцията, но остава напълно тих за екранния четец — за разлика от превключването на отделен чекбокс.
Възможен фикс: маркирайте dirty състоянието директно в onCategoryChange (напр. извикайте същата логика, която вдига dirtyRef/setStatus), вместо да разчитате на бълбукането, което тук умишлено спирате.
lyubomir-bozhinov
left a comment
There was a problem hiding this comment.
Одобрявам наново на връх c3e1800. Трите бележки от ревюто са адресирани, и — браво — сама намери и затвори реален cache-poisoning вектор (CWE-349), който аз бях пропуснал:
- cache-poisoning fix (коректен и без регресия):
preservedParamInputsвече емитва само параметри отCACHE_QUERY_PARAMS. Проверих: това е точно множеството, по коетоcache-key.ts(ред 51) строи ключа, а CI drift-guard-ът гарантира, че всеки консумиран от loader параметър е вътре — тъй че ограничението не може да изпусне легитимен параметър (пада само реално неизползван шум катоutm_source/zzz). Точно допълва #222 откъм body-то. filterFormKey.sort()— order-insensitive, коректно; тестът го покрива.- a11y —
aria-busy, polite live region и връщане на фокуса към „Търси“ след remount (WCAG 2.4.3/4.1.3) — над това, което поисках.
Едно дребно (non-blocking) за follow-up: „избери всички“ се клобва при първия toggle на editing burst. Inline ref-ът (FilterRail.tsx:265-266) пише indeterminate от APPLIED състоянието на всеки render, а setStatus в onFormChange предизвиква точно такъв render, който презаписва живата стойност от syncSelectAll. Repro: категория с applied „всички избрани“, махни първото дете → „избери всички“ се показва без тире/без отметка, докато деца още са чекнати (само първият toggle; следващите не викат setStatus, тъй че остават верни). Козметично — децата носят реалния submit. Fix: махни indeterminate write от inline ref-а и остави syncSelectAll да е единственият източник (или re-sync след render).
Одобрението стои; дребното не блокира merge.
nikimilenkov
left a comment
There was a problem hiding this comment.
Прегледах отново на HEAD c3e1800 — двата блокиращи проблема са затворени и проверени. Оттеглям „Изисквам промени" и одобрявам.
✅ Сигурност — cache poisoning (CWE-349): оправено. preservedParamInputs вече пренася само параметри от CACHE_QUERY_PARAMS, така че непознат ?zzz=… не влиза в SSR тялото. Плюс два регресионни теста (zzz=<script> → пада, utm_source → []), които заключват инварианта. 👍
✅ Достъпност — загуба на фокус (WCAG 2.4.3): оправено. След „Търси" фокусът се връща на бутона; таймингът е коректен (buttonRef сочи новия бутон след remount-а), guard-ът activeElement === body не краде фокус, а submittedRef ограничава ефекта само до реален submit. Като бонус syncSelectAll оправи и застоялия „Избери всички".
Няколко неблокиращи дреболии за после:
- Текст (@cefothe също го отбеляза):
FilterRail.tsx:159„Има непроменени филтри…" — „непроменени" противоречи на „приложите"; трябва „неприложени" (напр. „Има неприложени промени по филтрите."). - Обхват на live region-а.
busy/aria-busyидват от глобалнияuseNavigation(), затова при paging/сортиране панелът също маркира „зареждане" иstatusможе да се преобяви — дублира собствения live region наListControls. Не е WCAG 4.1.3 нарушение (съобщенията присъстват коректно), само малко шум; при желание — ограничи го доsubmittedRef. - „Избери всички" не задейства съобщението за неприложени промени (
stopPropagationго спира) → редакция само през него е безшумна за screen reader до „Търси". - Index-базиран key на preserved hidden input-ите (
${key}-${i}) — по-добре${key}-${value}-${i}.
Иначе — много чиста работа: истинска печалба за сигурността и солиден a11y fix. Одобрявам.
Resolves the PR's conflicts with main:
- filters.test.ts: keep both test sets in describe('withParams') — this
branch's array/cursor overrides + native FilterRail GET round-trip, and
main's midt-bg#197 unknown-param drop + canonical-order invariants.
- filterRail.logic.ts: main moved the cache allow-list from
cache-key.ts (CACHE_QUERY_PARAMS) to lib/query-params.ts
(CANONICAL_QUERY_PARAMS); repoint the import + usage (semantic conflict the
text merge missed).
Verified: @sigma/web 405 tests pass, typecheck clean.
lyubomir-bozhinov
left a comment
There was a problem hiding this comment.
Прегледах стриктно на връх 402123a. Силна, добре обмислена работа — single-submit („Търси") вместо навигация на всеки toggle (#181). Логиката е изнесена в чисти функции с реални тестове; a11y и cache-безопасността са адресирани. Одобрявам, с няколко дребни бележки (не блокират).
Каквото проверих, че държи:
- CWE-349 (cache poisoning) е затворен коректно:
preservedParamInputsпренася като hidden inputs само параметри отCANONICAL_QUERY_PARAMS(и не-owned) — произволен?zzz=payloadсе дропва, тъй че не може да инжектира неключиран body вpublicCache-нат list route. Тестът заковава и регресиите от #181 (submit да не изтриваq/bids). - Uncontrolled inputs + keyed
<Form>:defaultChecked+ remount поfilterFormKey(без cursor/page/sort) → clear/back-forward/споделен линк пре-прилагат URL-състоянието, а paging/sort не ремоунтват (пазят отворените групи + фокуса). Коректен модел. - Мобилно: floating pill-ът е само
@media (min-width: 960.02px); под 960px барът е in-flow в края на рейла (в CSS-only свития disclosure) — няма fixed-overlay да покрива последния ред.minmax(0, 1fr)(вместо1fr) спира хоризонталния overflow от широката таблица/дълги имена — добра диагноза;box-sizing: border-boxфиксът на бутона също. - A11y: фокусът се връща на ремоунтнатия „Търси" след settle (WCAG 2.4.3), polite live-region за „Зареждане…/обновени",
aria-busy. Ресурсите (scroll/resize/raf) се чистят в cleanup-а.
Дребни (низходящ приоритет, не блокират):
qостава вfilterFormKey(за разлика отsort/cursor/page), тъй че смяна на търсенето ремоунтва рейла. По собствената ти логика („изключваме каквото не мени кои чекбокса са отметнати")qсъщо отговаря на условието. Ефектът е малък, защотоopen={someSelected}връща детерминиран изглед, но за консистентност — обмислиnext.delete('q')вfilterFormKey.- Toggle на „Избери всички" е тих за screen reader:
onCategoryChangeвикаstopPropagation(), тъй чеonFormChange-announcement-ът „има непроменени филтри" не гръмва, а програматичните.checkedпо децата не пускат change-събития. За иначе много a11y-внимателен PR — струва си да се announce-не и тук. - Prune→re-enable в
onSubmitразчита React Router да чете FormData синхронно преди микротаска. Worst case при промяна е фрагментиране на edge-кеша (?value=/?eu=празни), не грешни данни — приемливо, но една e2e проверка (вържи с #238) би заковала таймингa.
Одобрявам.
ydimitrof
left a comment
There was a problem hiding this comment.
Ревю на PR: списъчните филтри се подават наведнъж чрез бутона „Търси“ (issue #181)
ВЕРДИКТ: COMMENT — препоръчвам корекции преди merge; няма блокиращи проблеми със сигурността.
Фаза 0 — Скан за сигурност: ЧИСТО ✅
- Без hardcoded тайни (API ключове/пароли/токени) — няма нито една.
- Без промени по URL / нови външни домейни — цялата промяна е клиентска (native
<Form method=get>). - Без зловреден код — няма backdoor-и, code injection, обфускация,
evalилиdangerouslySetInnerHTML. - Без нови зависимости —
vitestвече е в проекта; няма добавени пакети. - Без SQL повърхност — промяната е чисто фронтенд; няма заявки към D1/база, така че SQL injection е неприложимо тук.
- XSS: стойностите на скритите инпути идват от URL, но се рендират като атрибути през React (авто-escape); неизвестните параметри дори не се пренасят. Няма XSS вектор.
- Cache poisoning (CWE-349) — това всъщност е силна страна на PR-а:
preservedParamInputsпренася само параметри от allow-листатаCANONICAL_QUERY_PARAMS, което гарантира, че всеки пренесен параметър е част от cache ключа и не може да инжектира unkeyed съдържание вpublicCache-нат route. Отлично покрито и с тест.
OWASP: не откривам нарушения (A03 Injection / A01 Access control / A05 Misconfiguration) в обхвата на промяната.
Силни страни
- Чисто разделяне на логиката (
filterRail.logic.ts) от компонента; чистите функции са смислено покрити с тестове (празна категория, стойности извън категорията, повтарящи се параметри, order-insensitive ключ). - Прогресивно подобрение: работи и без JS; keyset cursor се нулира коректно чрез изпускане на
cursor/page. - Внимание към достъпност (live region, връщане на фокус,
aria-busy) и към round-trip на URL през loader-а (тест „native FilterRail GET submit round-trips“). - Реализацията съответства на issue #181: една навигация вместо заявка на всяко превключване.
Забележки (некритични)
- Подвеждащо съобщение за екранни четци (локализация).
setStatus('Има непроменени филтри…')— „непроменени“ означава „unchanged“, а намерението е „неприложени/непотвърдени“ промени. Виж inline коментара. - Пропуск в достъпността при „Изчисти филтрите“. Възстановяването на фокуса е само за подаване през „Търси“ (
submittedRef); линкът „Изчисти“ е вътре в keyed<Form>и при remount фокусът пада на<body>(същият WCAG 2.4.3 проблем, който бутонът избягва). Виж inline коментара. - Крехка връзка с реда на изпълнение в React Router.
onSubmitразчита, че RR извиква потребителскияonSubmitпреди да прочетеFormData(за да сработи disable-пруненето). Ако бъдеща версия на RR смени този ред, пруненето тихо ще спре да работи и празните?value=&eu=ще се върнат. Струва си кратък коментар/тест-guard около това допускане.
Тестове и покритие
Чистата логика е добре тествана. Ефектите на компонента (връщане на фокус, footer-lift, syncSelectAll) не са покрити с unit тестове — авторите го обосновават (браузърно поведение на native form). Приемливо, но покритието на клоновете на компонента остава под целевото; при възможност добавете лек DOM/e2e тест за „select all“ синхронизацията и връщането на фокуса.
Извод: висококачествен, добре обоснован PR без проблеми със сигурността; горните три козметични/UX бележки да се адресират преди merge.
| const onFormChange = (e: ChangeEvent<HTMLFormElement>) => { | ||
| if (!dirtyRef.current) { | ||
| dirtyRef.current = true; | ||
| setStatus('Има непроменени филтри. Натиснете „Търси", за да ги приложите.'); |
There was a problem hiding this comment.
Локализация: съобщението за екранни четци е подвеждащо. „непроменени“ означава „unchanged“, но тук намерението е точно обратното — има променени, но още неприложени филтри (виж коментара по-горе: „pending, not-yet-applied changes“). Предложение: „Има неприложени филтри. Натиснете „Търси“, за да ги приложите.“ (или „непотвърдени филтри“). Иначе screen-reader обявява семантично грешно състояние (WCAG 4.1.3).
| if (busy || !submittedRef.current) return; | ||
| submittedRef.current = false; | ||
| dirtyRef.current = false; | ||
| // Only reclaim focus if it was actually lost to <body> by the remount — never steal it from wherever |
There was a problem hiding this comment.
Пропуск в достъпността, симетричен на грижливо решения случай с „Търси“: този ефект връща фокуса само когато навигацията идва от submit (submittedRef). Но линковете „Изчисти филтрите“ и „Изтегли CSV“ са вътре в keyed <Form> — кликването на „Изчисти“ сменя formKey, ремонтира формата и фокусът пада на <body> без възстановяване (същият WCAG 2.4.3 проблем, който покривате за бутона). Обмислете да маркирате и clear-навигацията (напр. флаг при onClick на линка) и да върнете фокуса към стабилен елемент след remount.
| const owned = new Set<string>([...groupKeys, 'sort', 'cursor', 'page']); | ||
| const out: { key: string; value: string }[] = []; | ||
| for (const [key, value] of sp.entries()) { | ||
| if (CANONICAL_QUERY_PARAMS.has(key) && !owned.has(key)) out.push({ key, value }); |
There was a problem hiding this comment.
Добра защита срещу cache poisoning (CWE-349): allow-листата CANONICAL_QUERY_PARAMS гарантира, че всеки пренесен параметър е част от cache ключа. Едно съображение за поддръжка: тази проверка създава невидима зависимост — ако бъде добавен нов канонизиран параметър, който НЕ е част от cache ключа в cache-key.ts, guard-ът тихо ще започне да го пренася в SSR тялото. Струва си кратък коментар/тест, който да закрепи инварианта „CANONICAL_QUERY_PARAMS ⊆ cache-key params“, за да не се разминат двата списъка при бъдещи промени.
|
Прегледах #228 на дълбочина срещу дифа (връх CWE-349 защитата е плътна, защото двете страни делят един източник. a11y е добре обмислено. Keyed Прогресивно (native GET |
nedda76
left a comment
There was a problem hiding this comment.
Прегледах компонента, чистите помощници и стиловете. Много добър рефакторинг — изнасянето на дериватите в filterRail.logic.ts с unit тестове е точно правилният ход, а прогресивното подобрение (native <Form>, uncontrolled defaultChecked, keyed remount) е издържано.
Потвърждава се
- Един submit вместо N навигации — пряко сваля повърхността от #122/#181; бутонът „Търси" винаги е видим (не само в
<noscript>), тъй че работи и без JS. preservedParamInputsноси само параметри отCANONICAL_QUERY_PARAMS— това затваря cache-poisoning дупката (CWE-349, същият клас като #197): скрит input в SSR тялото наpublicCache-ната листа не може да вкара некийнат payload. Тестътzzz=<script>го заковава.filterFormKeyизключваcursor/page/sortи сортира параметрите → remount само при реална промяна на филтрите, стабилен при пагинация/пресортиране и при разбъркан ред на URL-а (shared link). Добре тествано.- Фокус мениджмънтът след remount (връщане на фокуса към „Търси" само ако е паднал на
<body>) иaria-liveрегионът адресират WCAG 2.4.3 / 4.1.3. Мислено.
Въпроси / бележки
- Формулировка (ЗА поправка). Живият регион при промяна казва „Има непроменени филтри. Натиснете „Търси"…". „непроменени" е подвеждащо — потребителят току-що ги е променил, но още не са приложени. По-точно е „Има неприложени филтри" (или „непотвърдени промени").
queueMicrotaskза връщане наdisabled. Разчита, че React Router четеFormDataсинхронно вsubmitсъбитието (вярно за RR v7). Ако някога това стане отложено, изчистените празни „Всички" радио-та ще изтекат обратно в URL-а. Коректно е днес — само си струва един ред коментар, който да закове допускането (или мутационен тест: submit без remount → URL без?value=).overflow-x: clip(дребно). Safari го поддържа от 16 (2022). На по-стар Safari декларацията се игнорира и хоризонталният overflow, който тя пази, може да се върне. Приемливо като прогресивно, само го отбелязвам.
Солидно откъм моя страна, готово за мърдж след дребната корекция на текста в т.1. Само преглед — самият мърдж не е мой.
Проблем
Списъчните филтри (
FilterRail) събмитваха формата при всяка промяна на чекбокс (<Form method="get" onChange={submitForm}>) — по една пълна Worker заявка + цял loader D1 pass на всяко цъкване; избор на N филтъра = N навигации (N× D1 read cost — същата повърхност като Denial-of-Wallet риска в #122). Освен това чекбоксовете бяха controlled (checked=…+ no-oponChange), та състоянието им се притежаваше от server-rendered loader данните — цъкването не се отразяваше визуално докато навигацията не приключи (усещане за „заковани"/неотзивчиви на мобилен/бавна връзка).Closes #181
Промени
defaultChecked) — реагират мигновено и работят напълно без JS (прогресивно подобрение към native SSR форма).qот търсенето,authority/bidderscope, …) се пренася през hidden inputs (preservedParamInputs), аcursor/pageнямат поле → keyset курсорът се ресетва „безплатно".value=(празно);onSubmitпрунва само празните group-key полета, за да не изтича?value=&eu=(без риск заsort/q).lib/filters.ts— компонентът е споделен отcontracts/companies/authorities.categorySelectionState,preservedParamInputs,shouldPruneField,filterFormKey) + round-trip тест, който доказва, че native GET submit минава коректно през loader parser-а.Запазват се споделяемите URL-и: при „Търси" GET формата сериализира избора в query string-а; при зареждане от споделен линк loader-ът го чете и чекбоксовете се рендерират
defaultChecked. URL-ът остава източникът на истина.Test plan
pnpm --filter @sigma/web test— web тестове (вкл. новите за FilterRail/filters)pnpm typecheck— 7 successful/contracts,/companies,/authorities:cursor/page?sector=45&year=2026) рендерира съответните чекбокси отметнатиЗабележка: клонът изостава от
mainс 2 commit-а (amendment history #165, in-table search #204). Мога да merge-наmainпри нужда — вероятно има конфликт вFilterRail.tsx/filters.test.ts.