diff --git a/CHANGELOG.md b/CHANGELOG.md index 25622f5..4f84ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.33.0 — 2026-10-26 + +### Ajouté — filtrage professionnel sur « Mes issues » (/my) + +Demande de Bruno : retrouver vite une issue parmi toutes +celles qui lui sont assignées (467 ouvertes au moment du +test réel). + +- **Barre de filtres** en carte, dans la langue visuelle du + compositeur 0.31.1 : recherche texte plein champ + (titre, projet, #numéro, étiquettes — insensible aux + accents et à la casse, raccourci « / », Échap pour + effacer), listes **Projet** et **Étiquette** avec + compteurs, **chips de colonnes** colorées avec compteurs + (multi-sélection), **tri** (activité récente, plus + anciennes, titre, projet). +- **Performance** : filtrage 100 % côté client sur des + attributs précalculés côté serveur — aucun aller-retour + réseau par frappe, application en requestAnimationFrame. + Compteur en direct (« 24 sur 467 issues »), compteurs de + section mis à jour, état vide propre avec réinitialisation + en un clic. +- **Persistance** : projet, colonnes, étiquette et tri sont + retenus en localStorage d'une visite à l'autre (la + recherche texte reste éphémère) ; bouton « Réinitialiser + les filtres » dès qu'un filtre est actif. + + ## 0.32.2 — 2026-10-25 ### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête diff --git a/app/config.py b/app/config.py index d5b4607..2508b7a 100644 --- a/app/config.py +++ b/app/config.py @@ -177,7 +177,7 @@ from urllib.parse import urlparse BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.32.2" +APP_VERSION = "0.33.0" def _env_bool(name: str, default: bool) -> bool: diff --git a/app/main.py b/app/main.py index 25a2871..9deeacc 100644 --- a/app/main.py +++ b/app/main.py @@ -4245,14 +4245,48 @@ async def my_issues(request: Request): logger.debug("search failed for /my (conn %s): %s", cid, exc) total = len(results) groups = _group_issue_results(results) + # Facettes de filtrage (0.33.0) : compteurs calculés une + # fois côté serveur ; le filtrage lui-même est instantané + # côté client, sur des attributs data-* des lignes. + names = _ws_names(request) + order = _ws_keys(request) + repo_facets = [ + {"full_name": g["full_name"], "count": len(g["rows"])} + for g in groups + ] + col_counts: dict[str, int] = {} + label_counts: dict[str, int] = {} + for g in groups: + for row in g["rows"]: + col_counts[row["column"]] = col_counts.get(row["column"], 0) + 1 + for label in row["issue"].get("labels") or []: + name = label.get("name") or "" + if name: + label_counts[name] = label_counts.get(name, 0) + 1 + column_facets = [ + {"key": key, "count": col_counts[key]} + for key in order + if key in col_counts + ] + [ + {"key": key, "count": count} + for key, count in col_counts.items() + if key not in order + ] + label_facets = [ + {"name": name, "count": label_counts[name]} + for name in sorted(label_counts, key=str.lower) + ] return render( request, "my.html", user=user, groups=groups, total=total, - column_labels=_ws_names(request), + column_labels=names, column_colors=_ws_colors(request), + repo_facets=repo_facets, + column_facets=column_facets, + label_facets=label_facets, ) diff --git a/app/templates/my.html b/app/templates/my.html index 663bc8c..c81ef97 100644 --- a/app/templates/my.html +++ b/app/templates/my.html @@ -5,7 +5,7 @@ Mes issues — Kanbante - + {% include "_header.html" %} @@ -15,17 +15,68 @@
🔑 Aucun utilisateur Gitea résolu : définissez GITEA_TOKEN pour voir les issues qui vous sont assignées.
{% else %}

Issues ouvertes assignées à {{ user.login }}, tous projets confondus — {{ total }} au total.

+ + {% if groups %} +
+
+
+ + +
+ + + +
+
+
+ + {% for f in column_facets %} + + {% endfor %} +
+ + +
+
+ {% endif %} + + {% if not groups %}
🎉 Aucune issue ne vous est assignée pour le moment.
{% endif %} {% for group in groups %} -
+

{{ group.full_name }} - {{ group.rows|length }}

+ {{ group.rows|length }}
{% for row in group.rows %} {% set issue = row.issue %} -
+
🟢 #{{ issue.number }} {{ issue.title }} @@ -46,6 +97,7 @@
Kanbante v{{ version }} · mes issues
+ {% include "_palette.html" %} diff --git a/pyproject.toml b/pyproject.toml index 2e8c4ac..2ca35c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.32.2" +version = "0.33.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/static/css/style.css b/static/css/style.css index 2b0e263..6652623 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1714,3 +1714,21 @@ body.sidebar-collapsed .ws-switch { display: none; } .composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; } .composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); } .composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; } + +/* ── Mes issues : barre de filtres (0.33.0) ── */ +.my-filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0 18px; } +.my-filters-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.my-filters-row + .my-filters-row { margin-top: 10px; } +.my-search-wrap { position: relative; flex: 1 1 280px; } +.my-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.65; pointer-events: none; } +.my-search-wrap input[type="search"] { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 9px 14px 9px 38px; color: inherit; font-size: 0.92rem; } +.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); } +.my-filters select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; color: inherit; font-size: 0.9rem; } +.my-filters select:focus { outline: none; border-color: var(--accent); } +.my-col-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; } +.filter-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border); color: inherit; border-radius: 999px; padding: 5px 12px; font-size: 0.85rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; } +.filter-chip:hover { border-color: var(--border-strong); } +.filter-chip.on { background: rgba(124, 92, 252, 0.18); border-color: var(--accent); font-weight: 600; } +.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.filter-chip-count { opacity: 0.7; font-size: 0.78rem; } +.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; } diff --git a/static/js/my.js b/static/js/my.js new file mode 100644 index 0000000..19216d1 --- /dev/null +++ b/static/js/my.js @@ -0,0 +1,190 @@ +// Mes issues — filtrage instantané côté client (0.33.0). +// Aucune requête réseau : les lignes portent leurs facettes en +// attributs data-*, le moteur ne fait que comparer des chaînes +// normalisées. Les filtres structurels (projet, colonnes, +// étiquette, tri) persistent en localStorage ; la recherche +// texte, elle, est éphémère. +(() => { + const search = document.getElementById('my-search'); + if (!search) return; // page sans résultats : rien à filtrer + const repoSel = document.getElementById('my-repo'); + const labelSel = document.getElementById('my-label'); + const sortSel = document.getElementById('my-sort'); + const chipsBox = document.getElementById('my-col-chips'); + const countEl = document.getElementById('my-count'); + const resetBtn = document.getElementById('my-reset'); + const emptyBox = document.getElementById('my-empty'); + const emptyReset = document.getElementById('my-empty-reset'); + const STORE_KEY = 'kanbante-my-filters'; + + const norm = (s) => (s || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + + const state = { + q: '', + repo: '', + label: '', + cols: new Set(), + sort: 'recent', + }; + + // ── Persistance des filtres structurels ──────────────── + try { + const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); + if (saved.repo) state.repo = saved.repo; + if (saved.label) state.label = saved.label; + if (Array.isArray(saved.cols)) state.cols = new Set(saved.cols); + if (saved.sort) state.sort = saved.sort; + } catch { /* stockage indisponible : état par défaut */ } + + function persist() { + try { + localStorage.setItem(STORE_KEY, JSON.stringify({ + repo: state.repo, + label: state.label, + cols: [...state.cols], + sort: state.sort, + })); + } catch { /* sans gravité */ } + } + + // Refléter l'état restauré dans les contrôles. + if (state.repo) repoSel.value = state.repo; + if (state.label) labelSel.value = state.label; + sortSel.value = state.sort; + // Un filtre restauré peut ne plus exister (projet disparu) : + // dans ce cas le select retombe à « tous » tout seul. + if (repoSel.value !== state.repo) state.repo = repoSel.value; + if (labelSel.value !== state.label) state.label = labelSel.value; + + const groups = [...document.querySelectorAll('.focus-group')]; + const allRows = [...document.querySelectorAll('.issue-row')]; + allRows.forEach((row) => { + row._search = norm(row.dataset.search); + row._labels = norm(row.dataset.labels).split(' ').filter(Boolean); + row._title = norm(row.dataset.title); + }); + + function syncChips() { + chipsBox.querySelectorAll('.filter-chip').forEach((chip) => { + const col = chip.dataset.col; + const on = col === '' ? state.cols.size === 0 : state.cols.has(col); + chip.classList.toggle('on', on); + if (col !== '') chip.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + } + + function sortRows() { + const mode = state.sort; + if (mode === 'repo') { + const main = document.querySelector('.focus-main'); + [...groups].sort((a, b) => + a.dataset.repo.localeCompare(b.dataset.repo) + ).forEach((g) => main.appendChild(g)); + } + groups.forEach((group) => { + const box = group.querySelector('.issue-results'); + const rows = [...box.querySelectorAll('.issue-row')]; + const by = { + recent: (a, b) => (b.dataset.updated || '').localeCompare(a.dataset.updated || ''), + oldest: (a, b) => (a.dataset.updated || '').localeCompare(b.dataset.updated || ''), + title: (a, b) => a._title.localeCompare(b._title), + repo: (a, b) => a._title.localeCompare(b._title), + }[mode]; + rows.sort(by).forEach((r) => box.appendChild(r)); + }); + } + + function apply() { + const q = norm(state.q).trim(); + let visible = 0; + groups.forEach((group) => { + let groupVisible = 0; + group.querySelectorAll('.issue-row').forEach((row) => { + const okText = !q || row._search.includes(q); + const okRepo = !state.repo || group.dataset.repo === state.repo; + const okCol = state.cols.size === 0 || state.cols.has(row.dataset.column); + const okLabel = !state.label + || row._labels.includes(norm(state.label)); + const ok = okText && okRepo && okCol && okLabel; + row.hidden = !ok; + if (ok) groupVisible++; + }); + group.hidden = groupVisible === 0; + const badge = group.querySelector('[data-group-count]'); + if (badge) badge.textContent = groupVisible; + visible += groupVisible; + }); + countEl.textContent = visible === allRows.length + ? `${visible} issue${visible > 1 ? 's' : ''}` + : `${visible} sur ${allRows.length} issues`; + const filtering = q || state.repo || state.label + || state.cols.size > 0; + resetBtn.hidden = !filtering; + emptyBox.hidden = visible !== 0; + syncChips(); + persist(); + } + + function resetAll() { + state.q = ''; + search.value = ''; + state.repo = ''; + repoSel.value = ''; + state.label = ''; + labelSel.value = ''; + state.cols.clear(); + sortSel.value = 'recent'; + state.sort = 'recent'; + sortRows(); + apply(); + search.focus(); + } + + // ── Événements ───────────────────────────────────────── + let raf = 0; + search.addEventListener('input', () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + state.q = search.value; + apply(); + }); + }); + search.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + search.value = ''; + state.q = ''; + apply(); + } + }); + repoSel.addEventListener('change', () => { state.repo = repoSel.value; apply(); }); + labelSel.addEventListener('change', () => { state.label = labelSel.value; apply(); }); + sortSel.addEventListener('change', () => { + state.sort = sortSel.value; + sortRows(); + apply(); + }); + chipsBox.addEventListener('click', (e) => { + const chip = e.target.closest('.filter-chip'); + if (!chip) return; + const col = chip.dataset.col; + if (col === '') state.cols.clear(); + else if (state.cols.has(col)) state.cols.delete(col); + else state.cols.add(col); + apply(); + }); + resetBtn.addEventListener('click', resetAll); + emptyReset?.addEventListener('click', resetAll); + document.addEventListener('keydown', (e) => { + if (e.key !== '/') return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + e.preventDefault(); + search.focus(); + }); + + sortRows(); + apply(); +})(); diff --git a/tests/test_palier22.py b/tests/test_palier22.py new file mode 100644 index 0000000..edb6c08 --- /dev/null +++ b/tests/test_palier22.py @@ -0,0 +1,39 @@ +"""Palier 22 (0.33.0): barre de filtres de la page Mes issues — +facettes serveur, lignes annotées, moteur client chargé.""" + + +def test_my_page_has_filter_bar_and_facets(client): + r = client.get("/my") + assert r.status_code == 200 + # La barre et ses contrôles. + assert 'id="my-filters"' in r.text + assert 'id="my-search"' in r.text + assert 'id="my-repo"' in r.text + assert 'id="my-label"' in r.text + assert 'id="my-sort"' in r.text + assert 'id="my-col-chips"' in r.text + assert 'id="my-count"' in r.text + assert 'id="my-reset"' in r.text + # Facettes avec compteurs (démo : 1 issue assignée, + # étiquette « bug »). + assert "Projets/demo (1)" in r.text + assert "bug (1)" in r.text + # Le moteur de filtrage est chargé, versionné. + assert "/static/js/my.js?v=" in r.text + + +def test_my_rows_carry_filter_attributes(client): + r = client.get("/my") + # Attributs data-* consommés par le moteur client. + assert 'data-column="backlog"' in r.text + assert 'data-search="#1 Tâche un Projets/demo bug"' in r.text + assert 'data-updated="2026-09-02T09:00:00Z"' in r.text + assert 'data-repo="Projets/demo"' in r.text + # Chips de colonnes présentes, avec compteurs. + assert 'data-col="backlog"' in r.text + assert 'data-col=""' in r.text # « Toutes les colonnes » + + +def test_my_empty_state_present_but_hidden(client): + r = client.get("/my") + assert 'id="my-empty" hidden' in r.text