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."),
|
message="Exista deja o trimitere identica. Corectia a fost oprita."),
|
||||||
)
|
)
|
||||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||||
return templates.TemplateResponse(
|
resp = templates.TemplateResponse(
|
||||||
"_trimitere_detaliu.html",
|
"_trimitere_detaliu.html",
|
||||||
_detaliu_ctx(request, row2, message="Corectat — randul a fost re-pus in coada."),
|
_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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -1172,7 +1176,8 @@ async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLRes
|
|||||||
resp = HTMLResponse(
|
resp = HTMLResponse(
|
||||||
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
|
'<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
|
return resp
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{#
|
{#
|
||||||
_coada.html — repurposat in 3.6 (US-003).
|
_coada.html — repurposat in 3.6 (US-003).
|
||||||
Nu mai e un tab/panou separat: e sectiunea "Trimiterile tale" inclusa pe Acasa,
|
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
|
sub zona de upload. Pastreaza filtrele (US-009) si tabelul (_submissions.html); detaliul
|
||||||
panoul de detaliu (#trimitere-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
|
se deschide acum in modalul global (#modal-detaliu). Poll aliniat la 15s (anti dublu-poll, M5).
|
||||||
#}
|
#}
|
||||||
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
|
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
|
||||||
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
|
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
|
||||||
@@ -67,9 +67,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- US-008: detaliul traieste acum INLINE, ca rand-sibling expandabil sub randul
|
{# PRD 5.9 US-003: detaliul s-a mutat intr-un MODAL global (#modal-detaliu in base.html),
|
||||||
selectat (#detaliu-{id} in _submissions.html); poll-ul de 15s se pune pe pauza
|
in afara #submissions-wrap -> poll-ul de 15s nu-l mai atinge. Randul declanseaza
|
||||||
cat un rand e deschis (base.html). Acest div global e golit de rol (nu mai e
|
deschiderea (hx-target=#detaliu-modal-body). Vechiul panou inert #trimitere-detaliu
|
||||||
tinta de swap), pastrat doar ca ancora inerta. -->
|
a fost eliminat (rol preluat de modal). #}
|
||||||
<div id="trimitere-detaliu" hidden></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -29,18 +29,17 @@
|
|||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in rows %}
|
{% for r in rows %}
|
||||||
{# US-008: detaliul apare ca rand-sibling expandabil SUB acest rand (#detaliu-{id}),
|
{# PRD 5.9 US-003: randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body),
|
||||||
nu in panoul global de la baza. Randul e clickabil/focusabil (toggle prin JS in
|
nu un rand-sibling. Clickabil/focusabil (role=button); Enter/Space deschid modalul
|
||||||
base.html: single-open + pauza poll). #}
|
(JS in base.html). Vechiul rand-sibling de detaliu a fost eliminat. #}
|
||||||
<tr id="trimitere-row-{{ r.id }}"
|
<tr id="trimitere-row-{{ r.id }}"
|
||||||
class="trimitere-row"
|
class="trimitere-row"
|
||||||
data-detaliu-id="{{ r.id }}"
|
data-detaliu-id="{{ r.id }}"
|
||||||
hx-get="/_fragments/trimitere/{{ r.id }}"
|
hx-get="/_fragments/trimitere/{{ r.id }}"
|
||||||
hx-target="#detaliu-{{ r.id }}"
|
hx-target="#detaliu-modal-body"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-indicator="#ind-{{ r.id }}"
|
role="button" tabindex="0"
|
||||||
role="button" tabindex="0" aria-expanded="false"
|
aria-haspopup="dialog"
|
||||||
aria-controls="detaliu-{{ r.id }}"
|
|
||||||
style="cursor:pointer;"
|
style="cursor:pointer;"
|
||||||
title="Click pentru detaliul complet">
|
title="Click pentru detaliul complet">
|
||||||
<td class="col-chk" onclick="event.stopPropagation();">
|
<td class="col-chk" onclick="event.stopPropagation();">
|
||||||
@@ -49,8 +48,7 @@
|
|||||||
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
|
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="col-id muted" data-eticheta="#">
|
<td class="col-id muted" data-eticheta="#">{{ r.id }}</td>
|
||||||
<span class="chevron" aria-hidden="true">▸</span>{{ r.id }}</td>
|
|
||||||
<td class="col-stare" data-eticheta="Stare">
|
<td class="col-stare" data-eticheta="Stare">
|
||||||
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -72,15 +70,6 @@
|
|||||||
<td class="col-rar" data-eticheta="Nr. prezentare RAR">{{ r.id_prezentare or '—' }}</td>
|
<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>
|
<td class="col-actualizat muted" data-eticheta="Actualizat">{{ r.updated_at }}</td>
|
||||||
</tr>
|
</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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
{% from "_eroare.html" import card_erori %}
|
{% from "_eroare.html" import card_erori %}
|
||||||
{% import '_macros.html' as ui %}
|
{% import '_macros.html' as ui %}
|
||||||
{# US-008: conectorul detaliului = fundal subtil + border-top pe randul-sibling
|
{# PRD 5.9 US-003: fragmentul se swap-uieste in corpul modalului global
|
||||||
(.detaliu-rand, base.html), NU border-left accent (evita AI-slop). #}
|
(#detaliu-modal-body). Heading-ul poarta id-ul folosit de aria-labelledby al dialogului. #}
|
||||||
<div class="card" id="detaliu-card-{{ id }}" style="border-color:var(--line);">
|
<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;">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{% if stare_subtext %}
|
{% if stare_subtext %}
|
||||||
@@ -53,12 +49,12 @@
|
|||||||
{% if gestionabil %}
|
{% if gestionabil %}
|
||||||
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;">
|
<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"
|
<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 }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button type="submit">Re-pune in coada</button>
|
<button type="submit">Re-pune in coada</button>
|
||||||
</form>
|
</form>
|
||||||
<form hx-post="/trimitere/{{ id }}/sterge"
|
<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;">
|
hx-confirm="Stergi definitiv aceasta trimitere din coada?" style="margin:0;">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||||
@@ -79,7 +75,7 @@
|
|||||||
{% for op in nemapate_inline %}
|
{% for op in nemapate_inline %}
|
||||||
{% set top = op.suggestions[0] if op.suggestions else None %}
|
{% set top = op.suggestions[0] if op.suggestions else None %}
|
||||||
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
|
{% 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;">
|
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="csrf_token" value="{{ csrf_token }}">
|
||||||
<input type="hidden" name="cod_op_service" value="{{ op.cod_op_service }}">
|
<input type="hidden" name="cod_op_service" value="{{ op.cod_op_service }}">
|
||||||
@@ -128,7 +124,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form hx-post="/trimitere/{{ id }}/corecteaza"
|
<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 }}">
|
<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;">
|
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
|
||||||
|
|
||||||
@@ -157,20 +153,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{# PRD 5.9 US-003 (R4): focus-ul dupa swap (incl. re-render corectie/mapare) e mutat in
|
||||||
<script>
|
corpul modalului din base.html (htmx:afterSettle pe #detaliu-modal-body). Vechiul script
|
||||||
(function() {
|
inline (marcheazaDetaliuDeschis / scrollIntoView pe randul-sibling) a fost eliminat. #}
|
||||||
/* 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>
|
|
||||||
|
|||||||
@@ -176,27 +176,29 @@
|
|||||||
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
|
.tabel-trimiteri .col-operatie > div { line-height:1.35; }
|
||||||
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
|
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
|
||||||
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
|
.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 */
|
/* 768-1024px: ascunde Actualizat (e in detaliu) -> 7 coloane, fara scroll */
|
||||||
@media (max-width:1024px) {
|
@media (max-width:1024px) {
|
||||||
.tabel-trimiteri .col-actualizat { display:none; }
|
.tabel-trimiteri .col-actualizat { display:none; }
|
||||||
}
|
}
|
||||||
/* Tinta de atins >=44px pe touch (chevron-ul e ancora de toggle). */
|
/* === Modal detaliu (PRD 5.9 US-003): fereastra modala globala, in afara zonei de
|
||||||
@media (pointer:coarse) {
|
poll (#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
|
||||||
.tabel-trimiteri .chevron { min-width:44px; min-height:44px; line-height:44px; }
|
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 */
|
/* <768px: card per rand (eticheta:valoare stivuit), nu tabel -> fara scroll orizontal */
|
||||||
@media (max-width:767px) {
|
@media (max-width:767px) {
|
||||||
.tabel-trimiteri table { table-layout:auto; }
|
.tabel-trimiteri table { table-layout:auto; }
|
||||||
@@ -243,6 +245,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<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>
|
<script>
|
||||||
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
|
// 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).
|
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
|
||||||
@@ -418,84 +432,108 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Detaliu trimitere INLINE (PRD 5.8 US-008): randul de detaliu (#detaliu-{id}) e
|
// Modal detaliu trimitere (PRD 5.9 US-003): inlocuieste detaliul inline (5.8). Detaliul
|
||||||
// un <tr class="detaliu-rand"> sibling, ascuns pana la deschidere. La click pe rand:
|
// se incarca prin HTMX in #detaliu-modal-body (in afara #submissions-wrap, deci poll-ul
|
||||||
// - se inchid celelalte detalii (un singur rand deschis o data);
|
// de 15s nu-l atinge). Aici: deschidere la click pe rand, inchidere (x/Esc/backdrop),
|
||||||
// - se arata randul-sibling (placeholder „Se incarca…" prin hx-indicator);
|
// focus-trap, scroll-lock, inert+aria-hidden pe <main> (R7), stare de eroare la load
|
||||||
// - chevron ▸/▾ + fundal evidentiat + aria-expanded sincronizate.
|
// esuat (R5), inchidere pe succes corectie/sterge (HX-Trigger inchideModal, R5).
|
||||||
// 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.
|
|
||||||
(function() {
|
(function() {
|
||||||
function chevron(row, on) {
|
var overlay = document.getElementById('modal-detaliu');
|
||||||
var c = row.querySelector('.chevron');
|
if (!overlay) return;
|
||||||
if (c) c.innerHTML = on ? '▾' : '▸'; // ▾ / ▸
|
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) {
|
// R7: focus-trap — Tab/Shift+Tab cicleaza in interiorul dialogului.
|
||||||
row.setAttribute('aria-expanded', on ? 'true' : 'false');
|
function trapFocus(e) {
|
||||||
if (on) row.classList.add('rand-deschis'); else row.classList.remove('rand-deschis');
|
if (e.key !== 'Tab') return;
|
||||||
chevron(row, on);
|
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) {
|
function isOpen() { return !overlay.hidden; }
|
||||||
var cont = document.getElementById('detaliu-' + id);
|
|
||||||
return cont ? cont.closest('tr.detaliu-rand') : null;
|
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) {
|
function close() {
|
||||||
var id = row.getAttribute('data-detaliu-id');
|
if (!isOpen()) return;
|
||||||
var cont = document.getElementById('detaliu-' + id);
|
overlay.hidden = true;
|
||||||
if (cont) cont.innerHTML = '';
|
body.innerHTML = '';
|
||||||
var dr = detRowFor(id);
|
document.body.classList.remove('modal-open');
|
||||||
if (dr) dr.hidden = true;
|
if (main) { main.removeAttribute('inert'); main.removeAttribute('aria-hidden'); }
|
||||||
setExpanded(row, false);
|
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) {
|
// API public: butonul „Inchide" din fragment + inchiderea pe succes corectie/sterge.
|
||||||
document.querySelectorAll('tr.trimitere-row[aria-expanded="true"]').forEach(function(r) {
|
// (Semnatura veche inchideDetaliu(id) pastrata, dar exista un singur modal o data.)
|
||||||
if (r !== except) closeOne(r);
|
window.inchideDetaliu = function() { close(); };
|
||||||
});
|
|
||||||
}
|
// Inchidere: x si backdrop (elemente cu data-modal-close), Esc.
|
||||||
// Expus pentru butonul „Inchide" din _trimitere_detaliu.html: goleste containerul
|
overlay.addEventListener('click', function(e) {
|
||||||
// randului CURENT si readuce focusul pe randul declansator.
|
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-modal-close')) close();
|
||||||
window.inchideDetaliu = function(id) {
|
});
|
||||||
var row = document.getElementById('trimitere-row-' + id);
|
document.addEventListener('keydown', function(e) {
|
||||||
if (row) { closeOne(row); row.focus(); }
|
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); close(); }
|
||||||
else {
|
});
|
||||||
var cont = document.getElementById('detaliu-' + id);
|
|
||||||
if (cont) cont.innerHTML = '';
|
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
|
||||||
var dr = detRowFor(id);
|
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
|
||||||
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.
|
|
||||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||||
var elt = evt.detail && evt.detail.elt;
|
var elt = evt.detail && evt.detail.elt;
|
||||||
if (!elt) return;
|
if (elt && elt.classList && elt.classList.contains('trimitere-row')) open(elt);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
// 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) {
|
document.body.addEventListener('keydown', function(evt) {
|
||||||
var t = evt.target;
|
var t = evt.target;
|
||||||
if (!(t && t.classList && t.classList.contains('trimitere-row'))) return;
|
if (!(t && t.classList && t.classList.contains('trimitere-row'))) return;
|
||||||
|
|||||||
@@ -61,12 +61,12 @@
|
|||||||
"dependsOn": [],
|
"dependsOn": [],
|
||||||
"requiresBrowserCheck": true,
|
"requiresBrowserCheck": true,
|
||||||
"requiresDesignReview": true,
|
"requiresDesignReview": true,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"failed": false,
|
"failed": false,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"retries": 0,
|
"retries": 0,
|
||||||
"failureReason": "",
|
"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",
|
"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-003 (modal, ui, requiresDesignReview) — independent, priority 15.
|
||||||
- US-002 depinde de US-001 (acum done) + US-003.
|
- 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
|
html = r.text
|
||||||
assert 'id="filtre-trimiteri"' in html
|
assert 'id="filtre-trimiteri"' in html
|
||||||
assert "/_fragments/submissions" 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):
|
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