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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">▸</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…</span>
|
||||
<div id="detaliu-{{ r.id }}"></div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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. #}
|
||||
|
||||
@@ -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">×</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 ? '▾' : '▸'; // ▾ / ▸
|
||||
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…</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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
161
tests/test_web_modal.py
Normal 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
|
||||
Reference in New Issue
Block a user