feat(5.9): US-003 - modal reutilizabil (overlay, focus-trap, a11y) + cleanup inline-expand 5.8

- base.html: #modal-detaliu (role=dialog, aria-modal) + #detaliu-modal-body swap target;
  focus-trap, inert+aria-hidden pe <main>, Esc/backdrop/x inchid, listener trimiteriChanged (R5/R7)
- _coada.html: ancora modal in afara #submissions-wrap; sters #trimitere-detaliu inert vechi
- _submissions.html: randul declanseaza modalul; sters tr.detaliu-rand sibling (R3)
- _trimitere_detaliu.html: script rescris pentru modal, fara marcheazaDetaliuDeschis/scrollIntoView (R4)
- teste: test_web_modal.py nou (3); test_web_detaliu_inline.py sters; test_acasa_trimiteri.py curatat (R3)
- gates: pytest PASS (suita completa 819). Browser E2E + design-review deferate la VERIFY.

Salvat manual: iteratiile Ralph 2-12 au ramas fara turns (30) inainte de commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-24 22:48:27 +00:00
parent 6d10f92452
commit fd4a05436d
10 changed files with 324 additions and 291 deletions

View File

@@ -1112,10 +1112,14 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
message="Exista deja o trimitere identica. Corectia a fost oprita."),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
resp = templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
)
# PRD 5.9 US-003 (R5): pe succes, lista se reincarca (trimiteriChanged) si modalul
# se inchide (inchideModal). After-settle ca inchiderea sa urmeze swap-ul fragmentului.
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
return resp
finally:
conn.close()
@@ -1172,7 +1176,8 @@ async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLRes
resp = HTMLResponse(
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
)
resp.headers["HX-Trigger"] = "trimiteriChanged"
# PRD 5.9 US-003 (R5): pe succes, lista se reincarca + modalul se inchide.
resp.headers["HX-Trigger-After-Settle"] = "trimiteriChanged, inchideModal"
return resp
finally:
conn.close()

View File

@@ -1,8 +1,8 @@
{#
_coada.html — repurposat in 3.6 (US-003).
Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa,
sub zona de upload. Pastreaza filtrele (US-009), tabelul (_submissions.html) si
panoul de detaliu (#trimitere-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
sub zona de upload. Pastreaza filtrele (US-009) si tabelul (_submissions.html); detaliul
se deschide acum in modalul global (#modal-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
#}
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
@@ -67,9 +67,8 @@
</div>
</div>
<!-- US-008: detaliul traieste acum INLINE, ca rand-sibling expandabil sub randul
selectat (#detaliu-{id} in _submissions.html); poll-ul de 15s se pune pe pauza
cat un rand e deschis (base.html). Acest div global e golit de rol (nu mai e
tinta de swap), pastrat doar ca ancora inerta. -->
<div id="trimitere-detaliu" hidden></div>
{# PRD 5.9 US-003: detaliul s-a mutat intr-un MODAL global (#modal-detaliu in base.html),
in afara #submissions-wrap -> poll-ul de 15s nu-l mai atinge. Randul declanseaza
deschiderea (hx-target=#detaliu-modal-body). Vechiul panou inert #trimitere-detaliu
a fost eliminat (rol preluat de modal). #}
</section>

View File

@@ -29,18 +29,17 @@
</tr></thead>
<tbody>
{% for r in rows %}
{# US-008: detaliul apare ca rand-sibling expandabil SUB acest rand (#detaliu-{id}),
nu in panoul global de la baza. Randul e clickabil/focusabil (toggle prin JS in
base.html: single-open + pauza poll). #}
{# PRD 5.9 US-003: randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body),
nu un rand-sibling. Clickabil/focusabil (role=button); Enter/Space deschid modalul
(JS in base.html). Vechiul rand-sibling de detaliu a fost eliminat. #}
<tr id="trimitere-row-{{ r.id }}"
class="trimitere-row"
data-detaliu-id="{{ r.id }}"
hx-get="/_fragments/trimitere/{{ r.id }}"
hx-target="#detaliu-{{ r.id }}"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-indicator="#ind-{{ r.id }}"
role="button" tabindex="0" aria-expanded="false"
aria-controls="detaliu-{{ r.id }}"
role="button" tabindex="0"
aria-haspopup="dialog"
style="cursor:pointer;"
title="Click pentru detaliul complet">
<td class="col-chk" onclick="event.stopPropagation();">
@@ -49,8 +48,7 @@
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
{% endif %}
</td>
<td class="col-id muted" data-eticheta="#">
<span class="chevron" aria-hidden="true">&#9656;</span>{{ r.id }}</td>
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
<td class="col-stare" data-eticheta="Stare">
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
</td>
@@ -72,15 +70,6 @@
<td class="col-rar" data-eticheta="Nr. prezentare RAR">{{ r.id_prezentare or '—' }}</td>
<td class="col-actualizat muted" data-eticheta="Actualizat">{{ r.updated_at }}</td>
</tr>
{# US-008: rand-sibling de detaliu, ascuns pana la deschidere. Placeholder „Se
incarca…" prin hx-indicator cat raspunde HTMX. #}
<tr class="detaliu-rand" hidden>
<td colspan="8">
<span id="ind-{{ r.id }}" class="htmx-indicator muted"
style="padding:8px 4px;">Se incarca&hellip;</span>
<div id="detaliu-{{ r.id }}"></div>
</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -1,15 +1,11 @@
{% from "_eroare.html" import card_erori %}
{% import '_macros.html' as ui %}
{# US-008: conectorul detaliului = fundal subtil + border-top pe randul-sibling
(.detaliu-rand, base.html), NU border-left accent (evita AI-slop). #}
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--line);">
{# PRD 5.9 US-003: fragmentul se swap-uieste in corpul modalului global
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului. #}
<div class="card" id="detaliu-card-{{ id }}" style="border:none; padding:0; margin:0;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">Detaliu trimitere #{{ id }}</h2>
<span class="pill {{ stare_css }}">{{ stare_text }}</span>
<button type="button" style="margin-left:auto; background:var(--card); color:var(--muted); border-color:var(--line);"
onclick="window.inchideDetaliu && window.inchideDetaliu('{{ id }}');">
Inchide
</button>
</div>
{% if stare_subtext %}
@@ -53,12 +49,12 @@
{% if gestionabil %}
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;">
<form hx-post="/trimitere/{{ id }}/repune"
hx-target="#detaliu-{{ id }}" hx-swap="innerHTML" style="margin:0;">
hx-target="#detaliu-modal-body" hx-swap="innerHTML" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Re-pune in coada</button>
</form>
<form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#detaliu-{{ id }}" hx-swap="innerHTML"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-confirm="Stergi definitiv aceasta trimitere din coada?" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
@@ -79,7 +75,7 @@
{% for op in nemapate_inline %}
{% set top = op.suggestions[0] if op.suggestions else None %}
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
<form hx-post="/trimitere/{{ id }}/mapeaza" hx-target="#detaliu-{{ id }}" hx-swap="innerHTML"
<form hx-post="/trimitere/{{ id }}/mapeaza" hx-target="#detaliu-modal-body" hx-swap="innerHTML"
style="margin:0 0 12px; padding:10px; border:1px solid var(--line); border-radius:8px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="cod_op_service" value="{{ op.cod_op_service }}">
@@ -128,7 +124,7 @@
{% endif %}
<form hx-post="/trimitere/{{ id }}/corecteaza"
hx-target="#detaliu-{{ id }}" hx-swap="innerHTML">
hx-target="#detaliu-modal-body" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
@@ -157,20 +153,6 @@
</div>
{% endif %}
</div>
<script>
(function() {
/* US-008: detaliul traieste acum in randul-sibling #detaliu-{id}. Asiguram ca randul
de detaliu e vizibil (la re-swap dupa corectie/mapare HTMX poate readuce continut
intr-un container ascuns) si ca randul declansator e marcat ca deschis. Single-open
+ pauza poll sunt gestionate global in base.html. */
var cont = document.getElementById('detaliu-{{ id }}');
if (cont) {
var detRow = cont.closest('tr.detaliu-rand');
if (detRow) detRow.hidden = false;
cont.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
var rand = document.getElementById('trimitere-row-{{ id }}');
if (rand && window.marcheazaDetaliuDeschis) window.marcheazaDetaliuDeschis(rand);
})();
</script>
{# PRD 5.9 US-003 (R4): focus-ul dupa swap (incl. re-render corectie/mapare) e mutat in
corpul modalului din base.html (htmx:afterSettle pe #detaliu-modal-body). Vechiul script
inline (marcheazaDetaliuDeschis / scrollIntoView pe randul-sibling) a fost eliminat. #}

View File

@@ -176,27 +176,29 @@
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
/* === Detaliu inline (PRD 5.8 US-008): rand-sibling expandabil sub randul selectat. === */
/* Chevron de stare (▸ inchis / ▾ deschis), rotit prin schimbarea glifei in JS. */
.tabel-trimiteri .chevron { display:inline-block; color:var(--muted); font-size:11px;
width:1.1em; text-align:center; margin-right:2px; }
/* Randul deschis: fundal evidentiat (nu doar culoare de text -> a11y). */
.tabel-trimiteri tr.rand-deschis > td { background:#1d212b; }
[data-theme="light"] .tabel-trimiteri tr.rand-deschis > td { background:#eef1f6; }
/* Conectorul detaliului = fundal subtil + border-top (NU border-left accent / slop). */
.tabel-trimiteri tr.detaliu-rand > td { padding:0; border-top:2px solid var(--accent);
background:color-mix(in srgb, var(--accent) 6%, var(--card)); }
.tabel-trimiteri tr.detaliu-rand .card { margin:10px; }
/* `hidden` trebuie sa invinga `display:block` din banda <768 (specificitate). */
.tabel-trimiteri tr.detaliu-rand[hidden] { display:none; }
/* 768-1024px: ascunde Actualizat (e in detaliu) -> 7 coloane, fara scroll */
@media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; }
}
/* Tinta de atins >=44px pe touch (chevron-ul e ancora de toggle). */
@media (pointer:coarse) {
.tabel-trimiteri .chevron { min-width:44px; min-height:44px; line-height:44px; }
}
/* === Modal detaliu (PRD 5.9 US-003): fereastra modala globala, in afara zonei de
poll (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil vine in US-006. === */
.modal-overlay { position:fixed; inset:0; z-index:1100; display:flex;
align-items:flex-start; justify-content:center; padding:40px 16px; overflow-y:auto; }
.modal-overlay[hidden] { display:none; }
.modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.55); }
.modal-dialog { position:relative; z-index:1; width:100%; max-width:680px;
background:var(--card); border:1px solid var(--line); border-radius:12px;
box-shadow:0 16px 48px rgba(0,0,0,.35); padding:18px 20px;
max-height:calc(100vh - 80px); overflow-y:auto; }
.modal-close { position:absolute; top:10px; right:10px; background:transparent;
border:1px solid var(--line); color:var(--muted); width:36px; height:36px;
border-radius:8px; font-size:20px; line-height:1; cursor:pointer;
display:inline-flex; align-items:center; justify-content:center; }
.modal-close:hover { background:var(--line); color:var(--ink); }
body.modal-open { overflow:hidden; }
.modal-eroare { padding:16px 4px; }
.modal-eroare .actiuni { margin-top:12px; display:flex; gap:10px; flex-wrap:wrap; }
/* <768px: card per rand (eticheta:valoare stivuit), nu tabel -> fara scroll orizontal */
@media (max-width:767px) {
.tabel-trimiteri table { table-layout:auto; }
@@ -243,6 +245,18 @@
</div>
</header>
<main>{% block content %}{% endblock %}</main>
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main>
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7).
Corpul #detaliu-modal-body e tinta de swap pentru fragment + rutele corectie/
mapare/lifecycle. Traieste in afara #submissions-wrap -> poll-ul de 15s nu-l atinge. #}
<div id="modal-detaliu" class="modal-overlay" role="dialog" aria-modal="true"
aria-labelledby="detaliu-modal-titlu" hidden>
<div class="modal-backdrop" data-modal-close></div>
<div class="modal-dialog" role="document">
<button type="button" class="modal-close" data-modal-close aria-label="Inchide detaliul">&times;</button>
<div id="detaliu-modal-body"></div>
</div>
</div>
<script>
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
@@ -418,84 +432,108 @@
})();
</script>
<script>
// Detaliu trimitere INLINE (PRD 5.8 US-008): randul de detaliu (#detaliu-{id}) e
// un <tr class="detaliu-rand"> sibling, ascuns pana la deschidere. La click pe rand:
// - se inchid celelalte detalii (un singur rand deschis o data);
// - se arata randul-sibling (placeholder „Se incarca…" prin hx-indicator);
// - chevron ▸/▾ + fundal evidentiat + aria-expanded sincronizate.
// Re-click pe acelasi rand inchide fara re-fetch. Cat un detaliu e deschis, poll-ul
// de 15s (#submissions-wrap) e pus pe pauza (D-eng-2) ca lista sa nu se miste sub
// operator. Delegare pe document.body -> supravietuieste swap-urilor HTMX ale listei.
// Modal detaliu trimitere (PRD 5.9 US-003): inlocuieste detaliul inline (5.8). Detaliul
// se incarca prin HTMX in #detaliu-modal-body (in afara #submissions-wrap, deci poll-ul
// de 15s nu-l atinge). Aici: deschidere la click pe rand, inchidere (x/Esc/backdrop),
// focus-trap, scroll-lock, inert+aria-hidden pe <main> (R7), stare de eroare la load
// esuat (R5), inchidere pe succes corectie/sterge (HX-Trigger inchideModal, R5).
(function() {
function chevron(row, on) {
var c = row.querySelector('.chevron');
if (c) c.innerHTML = on ? '&#9662;' : '&#9656;'; // ▾ / ▸
var overlay = document.getElementById('modal-detaliu');
if (!overlay) return;
var dialog = overlay.querySelector('.modal-dialog');
var body = document.getElementById('detaliu-modal-body');
var main = document.querySelector('main');
var trigger = null; // randul care a deschis modalul (focus return la inchidere)
var triggerId = null; // id-ul randului: re-query la inchidere daca poll-ul l-a re-swapuit
var onKeyTrap = null;
function focusable() {
return Array.prototype.filter.call(
dialog.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]),' +
' select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'),
function(el) { return el.offsetParent !== null || el === document.activeElement; });
}
function setExpanded(row, on) {
row.setAttribute('aria-expanded', on ? 'true' : 'false');
if (on) row.classList.add('rand-deschis'); else row.classList.remove('rand-deschis');
chevron(row, on);
// R7: focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
function trapFocus(e) {
if (e.key !== 'Tab') return;
var f = focusable();
if (!f.length) { e.preventDefault(); return; }
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function detRowFor(id) {
var cont = document.getElementById('detaliu-' + id);
return cont ? cont.closest('tr.detaliu-rand') : null;
function isOpen() { return !overlay.hidden; }
function open(triggerRow) {
trigger = triggerRow || null;
triggerId = (triggerRow && triggerRow.id) || null;
body.innerHTML = '<div class="empty muted" style="padding:24px;">Se incarca&hellip;</div>';
overlay.hidden = false;
document.body.classList.add('modal-open'); // scroll-lock pe body
if (main) { main.setAttribute('inert', ''); main.setAttribute('aria-hidden', 'true'); }
onKeyTrap = trapFocus;
document.addEventListener('keydown', onKeyTrap, true);
var x = overlay.querySelector('.modal-close');
if (x) x.focus(); // focus initial in modal
}
function closeOne(row) {
var id = row.getAttribute('data-detaliu-id');
var cont = document.getElementById('detaliu-' + id);
if (cont) cont.innerHTML = '';
var dr = detRowFor(id);
if (dr) dr.hidden = true;
setExpanded(row, false);
function close() {
if (!isOpen()) return;
overlay.hidden = true;
body.innerHTML = '';
document.body.classList.remove('modal-open');
if (main) { main.removeAttribute('inert'); main.removeAttribute('aria-hidden'); }
if (onKeyTrap) { document.removeEventListener('keydown', onKeyTrap, true); onKeyTrap = null; }
var t = trigger; trigger = null;
if (t && t.focus) t.focus(); // focus readus pe rand
}
function closeAllDetalii(except) {
document.querySelectorAll('tr.trimitere-row[aria-expanded="true"]').forEach(function(r) {
if (r !== except) closeOne(r);
});
}
// Expus pentru butonul „Inchide" din _trimitere_detaliu.html: goleste containerul
// randului CURENT si readuce focusul pe randul declansator.
window.inchideDetaliu = function(id) {
var row = document.getElementById('trimitere-row-' + id);
if (row) { closeOne(row); row.focus(); }
else {
var cont = document.getElementById('detaliu-' + id);
if (cont) cont.innerHTML = '';
var dr = detRowFor(id);
if (dr) dr.hidden = true;
}
};
// Expus pentru scriptul fragmentului: marcheaza randul ca deschis dupa un re-swap
// (corectie/mapare inline), inchizand orice alt detaliu ramas deschis.
window.marcheazaDetaliuDeschis = function(row) {
closeAllDetalii(row);
setExpanded(row, true);
};
// htmx:beforeRequest — single point: pauza poll + toggle deschidere/inchidere.
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge.
// (Semnatura veche inchideDetaliu(id) pastrata, dar exista un singur modal o data.)
window.inchideDetaliu = function() { close(); };
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
overlay.addEventListener('click', function(e) {
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-modal-close')) close();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); close(); }
});
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
document.body.addEventListener('htmx:beforeRequest', function(evt) {
var elt = evt.detail && evt.detail.elt;
if (!elt) return;
// Pauza poll periodic cat un detaliu e deschis (cererea vine chiar de pe wrap).
if (elt.id === 'submissions-wrap' &&
document.querySelector('tr.detaliu-rand:not([hidden])')) {
evt.preventDefault();
return;
}
if (!(elt.classList && elt.classList.contains('trimitere-row'))) return;
var id = elt.getAttribute('data-detaliu-id');
if (elt.getAttribute('aria-expanded') === 'true') {
// Re-click pe randul deschis -> inchide, fara re-fetch.
evt.preventDefault();
window.inchideDetaliu(id);
return;
}
// Deschidere: inchide celelalte, arata randul-sibling (placeholder loading).
closeAllDetalii(elt);
var dr = detRowFor(id);
if (dr) dr.hidden = false;
setExpanded(elt, true);
if (elt && elt.classList && elt.classList.contains('trimitere-row')) open(elt);
});
// Tastatura (role=button): Enter/Space deschid/inchid randul focusat.
// Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal.
body.addEventListener('htmx:afterSettle', function() {
if (!isOpen()) return;
var f = focusable();
if (f.length) f[0].focus();
});
// R5: load-error al fragmentului (GET esuat) -> stare Reincearca/Inchide, nu placeholder blocat.
body.addEventListener('htmx:responseError', function(evt) {
if (!isOpen()) return;
var elt = evt.detail && evt.detail.elt;
var url = (elt && elt.getAttribute && elt.getAttribute('hx-get')) || '';
body.innerHTML = '<div class="modal-eroare"><p>Nu s-a putut incarca detaliul.</p>' +
'<div class="actiuni">' +
(url ? '<button type="button" data-modal-retry="' + url + '">Reincearca</button>' : '') +
'<button type="button" data-modal-close' +
' style="background:var(--card); color:var(--muted); border-color:var(--line);">Inchide</button>' +
'</div></div>';
});
body.addEventListener('click', function(e) {
var r = e.target.closest && e.target.closest('[data-modal-retry]');
if (r && window.htmx) htmx.ajax('GET', r.getAttribute('data-modal-retry'),
{ target: body, swap: 'innerHTML' });
});
// R5: inchidere pe succes corectie/sterge — ruta emite HX-Trigger `inchideModal`.
// Lista se reincarca separat prin `trimiteriChanged` (#submissions-wrap). Maparea
// inline NU emite inchideModal -> modalul ramane deschis sa arate codul rezolvat.
document.body.addEventListener('inchideModal', function() { close(); });
// Tastatura pe rand (role=button): Enter/Space deschid modalul.
document.body.addEventListener('keydown', function(evt) {
var t = evt.target;
if (!(t && t.classList && t.classList.contains('trimitere-row'))) return;

View File

@@ -61,12 +61,12 @@
"dependsOn": [],
"requiresBrowserCheck": true,
"requiresDesignReview": true,
"passes": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": ""
"notes": "Cod-complet, salvat manual din working-tree dupa ce iteratiile 2-12 au ramas fara turns (30) inainte de commit. Atins: base.html (markup #modal-detaliu role=dialog aria-modal + #detaliu-modal-body swap target, +212 linii JS focus-trap/inert pe <main>/Esc/backdrop/trimiteriChanged listener), _coada.html (ancora globala in afara #submissions-wrap, sters #trimitere-detaliu inert vechi), _submissions.html (rand declanseaza modalul, sters tr.detaliu-rand sibling), _trimitere_detaliu.html (script rescris R4 fara marcheazaDetaliuDeschis/scrollIntoView), routes.py (minor), tests/test_web_detaliu_inline.py STERS, tests/test_acasa_trimiteri.py (scos assert #trimitere-detaliu), tests/test_web_modal.py NOU (3 teste). Gates: pytest PASS (test_web_modal 3/3; suita completa 819 passed). R3/R5/R7 cod-level verificate (grep: niciun refer rezidual la identificatorii stersi; aria-expanded ramas e doar pe meniul cont). DEFERAT la VERIFY (ROADMAP 5.6): requiresBrowserCheck (E2E gstack) + requiresDesignReview NErulate (loop fara browser/gstack)."
},
{
"id": "US-006",

View File

@@ -33,3 +33,4 @@ Note: PRD APROBAT 2026-06-24 cu revizii obligatorii R1-R12 (raport AUTOPLAN). R1
- US-003 (modal, ui, requiresDesignReview) — independent, priority 15.
- US-002 depinde de US-001 (acum done) + US-003.
---
## Rate limit la iter 10 — sleep 1800

View File

@@ -68,7 +68,10 @@ def test_acasa_contine_sectiunea_trimiteri(client):
html = r.text
assert 'id="filtre-trimiteri"' in html
assert "/_fragments/submissions" in html
assert 'id="trimitere-detaliu"' in html
# PRD 5.9 US-003: detaliul s-a mutat in modalul global (#modal-detaliu); vechiul
# panou inert #trimitere-detaliu a fost eliminat.
assert 'id="modal-detaliu"' in html
assert 'id="trimitere-detaliu"' not in html
def test_sectiune_trimiteri_are_heading(client):

View File

@@ -1,145 +0,0 @@
"""Teste PRD 5.8 US-008: detaliul trimiterii apare ca rand expandabil SUB randul
selectat (nu in panoul global de la baza tabelului).
Verificam markup-ul server-side: fiecare rand de date are un rand-sibling de detaliu
`<tr class="detaliu-rand">` cu container per-rand `#detaliu-{id}`, randul clickabil
tinteste acel container, iar fragmentul de detaliu (Inchide + forme) tinteste tot
containerul per-rand — NU `#trimitere-detaliu` global. Single-open + pauza poll sunt
logica JS in base.html (verificam prezenta hook-urilor).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert_submission(acct: int, status: str = "sent", *, payload: dict | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
p = payload if payload is not None else {
"vin": "WVWZZZ1JZXW000777",
"nr_inmatriculare": "B777ZZZ",
"data_prestatie": "2026-06-18",
"odometru_final": "55000",
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "subm.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_fragment_detaliu_se_randeaza_in_container_pe_rand(client):
"""Tabelul are un rand-sibling de detaliu per rand (#detaliu-{id}), iar fragmentul
de detaliu tinteste acel container, nu panoul global #trimitere-detaliu."""
acct = _create_account_user("inl@test.com")
sid = _insert_submission(acct, "needs_data")
_login(client, "inl@test.com")
# 1. Tabelul: rand-sibling de detaliu + retargeting pe randul clickabil
lista = client.get("/_fragments/submissions")
assert lista.status_code == 200
h = lista.text
assert 'class="detaliu-rand"' in h, "lipseste randul-sibling de detaliu"
assert f'id="detaliu-{sid}"' in h, "lipseste containerul per-rand"
assert 'colspan="8"' in h, "td-ul de detaliu trebuie sa acopere cele 8 coloane"
assert f'hx-target="#detaliu-{sid}"' in h, "randul de date trebuie sa tinteasca containerul per-rand"
# randul de date NU mai tinteste panoul global
assert 'hx-target="#trimitere-detaliu"' not in h
# 2. Fragmentul de detaliu: Inchide + forme tintesc containerul per-rand
det = client.get(f"/_fragments/trimitere/{sid}")
assert det.status_code == 200
d = det.text
# butonul Inchide opereaza pe containerul randului curent (nu pe panoul global)
assert f"detaliu-{sid}" in d
assert "getElementById('trimitere-detaliu')" not in d
# formele de corectie/mapare tintesc containerul per-rand
assert f'hx-target="#detaliu-{sid}"' in d
assert 'hx-target="#trimitere-detaliu"' not in d
def test_un_singur_detaliu_deschis(client):
"""Logica JS din base.html asigura un singur detaliu deschis (inchide celelalte la
deschidere) si pune poll-ul pe pauza cat un rand e expandat (D-eng-2)."""
_create_account_user("one@test.com")
_login(client, "one@test.com")
pagina = client.get("/")
assert pagina.status_code == 200
js = pagina.text
# randul clickabil e accesibil (role/aria pentru toggle)
assert 'class="trimitere-row"' not in js or True # markup-ul randului traieste in fragment
# hook-uri de single-open: inchiderea altor detalii + sincronizarea starii aria
assert "closeAllDetalii" in js, "lipseste logica de inchidere a celorlalte detalii"
assert "detaliu-rand" in js, "logica trebuie sa opereze pe randurile de detaliu"
assert "aria-expanded" in js, "starea expandata trebuie sincronizata"
# pauza poll cat un rand e deschis: anuleaza request-ul periodic pe #submissions-wrap
assert "submissions-wrap" in js
assert "preventDefault" in js
def test_rand_clickabil_accesibil(client):
"""Randul de date e focusabil la tastatura (role=button, tabindex, aria-expanded)."""
acct = _create_account_user("a11y@test.com")
sid = _insert_submission(acct, "sent")
_login(client, "a11y@test.com")
h = client.get("/_fragments/submissions").text
# randul de date
m = re.search(r'<tr id="trimitere-row-%d".*?>' % sid, h, re.S)
assert m, "lipseste randul de date"
rand = m.group(0)
assert 'role="button"' in rand
assert 'tabindex="0"' in rand
assert 'aria-expanded="false"' in rand

161
tests/test_web_modal.py Normal file
View File

@@ -0,0 +1,161 @@
"""Teste PRD 5.9 US-003: detaliul trimiterii se deschide intr-un MODAL global
(#modal-detaliu), in afara zonei de poll (#submissions-wrap).
Verificam markup-ul server-side: containerul modal e global si plasat IN AFARA
#submissions-wrap (de fapt sibling al <main>, ca `inert` pe <main> sa nu-l prinda),
corpul #detaliu-modal-body e tinta de swap, iar fragmentul de detaliu (forme corectie/
mapare/lifecycle) tinteste corpul modalului — NU vechiul #detaliu-{id} / #trimitere-detaliu.
Focus-trap / scroll-lock / inert sunt logica JS in base.html (verificam hook-urile).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert_submission(acct: int, status: str = "needs_data") -> int:
from app.db import get_connection
conn = get_connection()
try:
p = {
"vin": "WVWZZZ1JZXW000777",
"nr_inmatriculare": "B777ZZZ",
"data_prestatie": "2026-06-18",
"odometru_final": "55000",
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "modal.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def test_modal_container_in_afara_submissions_wrap(client):
"""Containerul modal global exista, e dialog a11y si e plasat IN AFARA
#submissions-wrap (sibling al <main>, dupa </main>)."""
acct = _create_account_user("modal@test.com")
_insert_submission(acct, "needs_data") # sectiunea Trimiteri (wrap) apare doar cu randuri
_login(client, "modal@test.com")
html = client.get("/?tab=acasa").text
# containerul modal + corpul de swap
assert 'id="modal-detaliu"' in html, "lipseste containerul modal global"
assert 'id="detaliu-modal-body"' in html, "lipseste corpul de swap al modalului"
# rol de dialog modal + heading legat prin aria-labelledby
assert 'role="dialog"' in html
assert 'aria-modal="true"' in html
assert 'aria-labelledby="detaliu-modal-titlu"' in html
# buton de inchidere cu aria-label (R7)
assert "modal-close" in html
assert 'aria-label="Inchide detaliul"' in html
# Plasament: modalul e DUPA </main>, deci in afara <main> si a #submissions-wrap
# (care traieste in panoul din <main>). inert pe <main> nu-l prinde (R7).
idx_wrap = html.find('id="submissions-wrap"')
idx_main_close = html.find("</main>")
idx_modal = html.find('id="modal-detaliu"')
assert idx_wrap != -1 and idx_main_close != -1 and idx_modal != -1
assert idx_wrap < idx_main_close < idx_modal, "modalul trebuie sa fie in afara <main>/#submissions-wrap"
# Vechiul panou inert eliminat; fara mecanismul inline 5.8 in pagina.
assert 'id="trimitere-detaliu"' not in html
assert 'class="detaliu-rand"' not in html
assert "marcheazaDetaliuDeschis" not in html
def test_fragment_detaliu_tinteste_modalul(client):
"""Randul declanseaza modalul (hx-target=#detaliu-modal-body) si fragmentul de
detaliu (forme corectie/mapare/lifecycle) tinteste tot corpul modalului — NU
vechiul container per-rand #detaliu-{id} sau #trimitere-detaliu."""
acct = _create_account_user("frag@test.com")
sid = _insert_submission(acct, "needs_data")
_login(client, "frag@test.com")
# 1. Randul din tabel tinteste corpul modalului; fara rand-sibling / chevron / aria-expanded.
lista = client.get("/_fragments/submissions")
assert lista.status_code == 200
h = lista.text
assert 'hx-target="#detaliu-modal-body"' in h, "randul trebuie sa tinteasca corpul modalului"
assert 'hx-target="#detaliu-%d"' % sid not in h
assert 'class="detaliu-rand"' not in h
assert 'aria-expanded' not in h
assert "chevron" not in h
assert 'aria-haspopup="dialog"' in h
assert 'role="button"' in h and 'tabindex="0"' in h
# 2. Fragmentul de detaliu: formele tintesc corpul modalului, nu containerul vechi.
det = client.get(f"/_fragments/trimitere/{sid}")
assert det.status_code == 200
d = det.text
assert 'hx-target="#detaliu-modal-body"' in d
assert 'hx-target="#detaliu-%d"' % sid not in d
assert 'hx-target="#trimitere-detaliu"' not in d
# heading legat de aria-labelledby al dialogului
assert 'id="detaliu-modal-titlu"' in d
def test_modal_hookuri_js_prezente(client):
"""Logica de modal (focus-trap, scroll-lock, inert pe <main>, inchidere pe succes)
e prezenta in base.html — hook-urile cheie exista."""
_create_account_user("hook@test.com")
_login(client, "hook@test.com")
js = client.get("/?tab=acasa").text
assert "modal-detaliu" in js
# focus-trap + scroll-lock + inert pe ancestor stabil
assert "trapFocus" in js or "Tab" in js
assert "modal-open" in js
assert "inert" in js
# inchidere pe succes corectie/sterge (listener pe evenimentul HX-Trigger)
assert "inchideModal" in js
# API public pastrat (butoanele/rutele pot inchide modalul)
assert "window.inchideDetaliu" in js