- Barre de filtres en carte : recherche texte (titre, projet, #numéro, étiquettes ; accents/casse ignorés, raccourci /), listes Projet et Étiquette avec compteurs, chips de colonnes colorées multi-sélection, 4 tris. - Facettes calculées côté serveur ; filtrage instantané côté client (attributs data-*), compteur en direct, compteurs de section, état vide + réinitialisation. - Filtres structurels persistés en localStorage. - 281 tests verts (3 nouveaux, palier 22) ; vérifié en Chromium réel sur les vraies données (467 issues : recherche 'agent' -> 24 résultats instantanés).
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+35
-1
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+56
-4
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mes issues — Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
@@ -15,17 +15,68 @@
|
||||
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
|
||||
{% else %}
|
||||
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
|
||||
|
||||
{% if groups %}
|
||||
<div class="my-filters" id="my-filters">
|
||||
<div class="my-filters-row">
|
||||
<div class="my-search-wrap">
|
||||
<span class="my-search-icon" aria-hidden="true">🔍</span>
|
||||
<input type="search" id="my-search" autocomplete="off"
|
||||
placeholder="Rechercher un titre, un projet, un #numéro, une étiquette… ( / )">
|
||||
</div>
|
||||
<select id="my-repo" title="Filtrer par projet">
|
||||
<option value="">Tous les projets</option>
|
||||
{% for f in repo_facets %}
|
||||
<option value="{{ f.full_name }}">{{ f.full_name }} ({{ f.count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="my-label" title="Filtrer par étiquette">
|
||||
<option value="">Toutes les étiquettes</option>
|
||||
{% for f in label_facets %}
|
||||
<option value="{{ f.name }}">{{ f.name }} ({{ f.count }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="my-sort" title="Trier les résultats">
|
||||
<option value="recent">Activité récente d'abord</option>
|
||||
<option value="oldest">Plus anciennes d'abord</option>
|
||||
<option value="title">Titre (A → Z)</option>
|
||||
<option value="repo">Projet (A → Z)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="my-filters-row my-filters-meta">
|
||||
<div class="my-col-chips" id="my-col-chips" role="group" aria-label="Filtrer par colonne">
|
||||
<button type="button" class="filter-chip on" data-col="">Toutes les colonnes</button>
|
||||
{% for f in column_facets %}
|
||||
<button type="button" class="filter-chip" data-col="{{ f.key }}" aria-pressed="false">
|
||||
<span class="filter-chip-dot" style="background: {{ column_colors[f.key] }}"></span>{{ column_labels[f.key] }} <span class="filter-chip-count">{{ f.count }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="my-count" id="my-count" role="status"></span>
|
||||
<button type="button" id="my-reset" class="btn-sm" hidden>↺ Réinitialiser les filtres</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="empty-state" id="my-empty" hidden>🔍 Aucune issue ne correspond à ces filtres.
|
||||
<button type="button" id="my-empty-reset" class="btn-sm">↺ Réinitialiser les filtres</button>
|
||||
</div>
|
||||
{% if not groups %}
|
||||
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
<section class="focus-group" data-repo="{{ group.full_name }}">
|
||||
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
||||
<span class="badge secondary">{{ group.rows|length }}</span></h2>
|
||||
<span class="badge secondary" data-group-count>{{ group.rows|length }}</span></h2>
|
||||
<div class="issue-results">
|
||||
{% for row in group.rows %}
|
||||
{% set issue = row.issue %}
|
||||
<div class="issue-row">
|
||||
<div class="issue-row"
|
||||
data-column="{{ row.column }}"
|
||||
data-labels="{{ (issue.labels or [])|map(attribute='name')|join(' ') }}"
|
||||
data-updated="{{ issue.updated_at or '' }}"
|
||||
data-title="{{ issue.title }}"
|
||||
data-search="#{{ issue.number }} {{ issue.title }} {{ group.full_name }} {{ (issue.labels or [])|map(attribute='name')|join(' ') }}">
|
||||
<span class="issue-state">🟢</span>
|
||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||
@@ -46,6 +97,7 @@
|
||||
<footer>
|
||||
Kanbante v{{ version }} · mes issues
|
||||
</footer>
|
||||
<script src="/static/js/my.js?v={{ version }}" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+190
@@ -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();
|
||||
})();
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user