feat(webui): Statusfilter als Mehrfachauswahl, Roadmap raus aus der Bugliste
Drei Aenderungen an derselben Liste, alle mit demselben Ziel: Sichtbar sein soll, was zu tun ist - und wenn etwas ausgeblendet wird, soll es gesagt werden. 1. Status als Ankreuzfelder statt Klappliste. Vorher liess sich genau eine Lage waehlen; "offen UND in Nachpruefung" ging nur, wenn jemand die Kombination vorher in die Liste geschrieben hatte. Angehakt ist ab Werk, was laeuft oder Aufmerksamkeit verlangt. Erledigtes, Geschlossenes und Abgelehntes bleibt draussen - es ist der groesste Teil des Bestandes. Das versteckte Feld vor den Ankreuzfeldern ist kein Schoenheitsfehler: HTML sendet fuer nicht angehakte Kaestchen nichts. Ohne es waere "alle Haken entfernt" von "erster Aufruf" nicht zu unterscheiden, und die Vorgabe kaeme bei jedem Versuch zurueck, sie loszuwerden. 2. Die Suche meldet verdeckte Treffer. Wer "288" sucht und nichts bekommt, schliesst daraus, es gebe den Eintrag nicht - dabei ist er nur erledigt. Eine Suche, die schweigt, obwohl sie etwas gefunden hat, ist schlimmer als gar keine. Die zweite Abfrage laeuft nur bei aktiver Suche. 3. Roadmap-Eintraege sind ab Werk aus der Bugliste heraus. Seit 2.7.0 IST ein Roadmap-Eintrag ein bugtracker_item; damit lag die Planung aller Projekte in derselben Liste wie die Meldungen - 217 von 229 Feature Requests. Die zwoelf echten Vorschlaege lagen darunter begraben. Getrennte Orte, keine geaenderten Daten: Der Filter holt die Planung jederzeit zurueck. Dazu ein Testfall, den es haette laengst geben muessen: index.php wurde von keinem einzigen Fall angefasst - die Suite prueft /api/. Ein Syntaxfehler in der groessten Datei des Projekts waere durch das Deploy-Tor gelaufen, ohne dass ein Test rot geworden waere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9c821a44a
commit
669b165785
+283
-27
@@ -1897,18 +1897,101 @@ $coreAllTokens = $coreTokenManager->getAllTokens();
|
||||
$btPerPage = 50;
|
||||
$btPage = max(1, (int)($_GET['bt_page'] ?? 1));
|
||||
|
||||
/**
|
||||
* Status, die beim ersten Aufruf angehakt sind.
|
||||
*
|
||||
* Alles, was gerade laeuft oder Aufmerksamkeit verlangt. Erledigtes,
|
||||
* Geschlossenes und Abgelehntes bleibt draussen - es ist der weitaus groesste
|
||||
* Teil des Bestandes und verdeckt sonst das, was noch zu tun ist.
|
||||
*
|
||||
* "deferred" fehlt mit Absicht: Der ganze Sinn dieses Status ist "nicht jetzt".
|
||||
* Ein zurueckgestelltes Vorhaben in der Standardliste waere ein Widerspruch in
|
||||
* sich - es steht unter "Zurueckgestellt" bereit, wenn jemand danach sucht.
|
||||
*/
|
||||
const BT_STATUS_VORGABE = [
|
||||
'open', 'planned', 'in_progress', 'triage_external',
|
||||
'awaiting_verification', 'in_verification', 'fix_insufficient',
|
||||
];
|
||||
|
||||
/**
|
||||
* Liest die angehakten Status aus der Anfrage.
|
||||
*
|
||||
* Drei Faelle, und der mittlere ist der Grund fuer das versteckte Feld im
|
||||
* Formular:
|
||||
*
|
||||
* Parameter fehlt ganz -> erster Aufruf, es gilt die Vorgabe
|
||||
* Parameter ist leer -> alle Haken entfernt, also KEINE Einschraenkung
|
||||
* Parameter hat Werte -> genau diese Status
|
||||
*
|
||||
* Ohne den zweiten Fall waere "alle Haken weg" von "noch nie etwas gewaehlt"
|
||||
* nicht zu unterscheiden - HTML sendet fuer leere Ankreuzfelder nichts. Die
|
||||
* Vorgabe kaeme dann bei jedem Versuch zurueck, sie loszuwerden.
|
||||
*
|
||||
* Zeichenketten werden weiterhin angenommen: Aeltere Lesezeichen und die
|
||||
* Verweise aus den Kennzahlen kommen als bt_status=open,in_progress herein.
|
||||
*
|
||||
* @return list<string> Leer bedeutet "keine Einschraenkung".
|
||||
*/
|
||||
function btStatusAuswahl(): array
|
||||
{
|
||||
if (!array_key_exists('bt_status', $_GET)) {
|
||||
return BT_STATUS_VORGABE;
|
||||
}
|
||||
|
||||
$roh = $_GET['bt_status'];
|
||||
$werte = is_array($roh) ? $roh : explode(',', (string)$roh);
|
||||
|
||||
$werte = array_map(static fn($v): string => trim((string)$v), $werte);
|
||||
$werte = array_values(array_filter(
|
||||
$werte,
|
||||
static fn(string $v): bool => in_array($v, BugRepo::STATUSES, true)
|
||||
));
|
||||
|
||||
// "all" als einzelner Wert ist die alte Schreibweise fuer "ohne Filter".
|
||||
if (!is_array($roh) && trim((string)$roh) === 'all') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique($werte));
|
||||
}
|
||||
|
||||
$btStatusAuswahl = btStatusAuswahl();
|
||||
|
||||
/**
|
||||
* Roadmap-Eintraege gehoeren in die Roadmap, nicht in die Bugliste.
|
||||
*
|
||||
* Seit 2.7.0 IST ein Roadmap-Eintrag ein Bugtracker-Item - es gibt keine
|
||||
* zweite Ablage, und ein Item steht auf der Roadmap, sobald es einen
|
||||
* Themenbereich hat. Richtig so; nur landet damit die gesamte Planung aller
|
||||
* Projekte in derselben Liste wie die Meldungen. Nach der Uebernahme der
|
||||
* Projekt-Roadmaps waren das 217 von 229 Feature Requests - die zwoelf
|
||||
* Vorschlaege, um die es in dieser Liste geht, lagen darunter begraben.
|
||||
*
|
||||
* Die Bugliste zeigt deshalb ab Werk nur, was NICHT auf der Roadmap steht.
|
||||
* Sichtbar bleibt beides, nur an getrennten Orten - und der Filter holt die
|
||||
* Planung jederzeit wieder herein.
|
||||
*/
|
||||
$btRoadmap = (string)($_GET['bt_roadmap'] ?? 'ohne');
|
||||
if (!in_array($btRoadmap, ['ohne', 'nur', 'alle'], true)) {
|
||||
$btRoadmap = 'ohne';
|
||||
}
|
||||
|
||||
$btFilters = [
|
||||
'project_slug' => (string)($_GET['bt_project'] ?? 'all'),
|
||||
'environment' => (string)($_GET['bt_env'] ?? 'all'),
|
||||
'type' => (string)($_GET['bt_type'] ?? 'all'),
|
||||
'severity' => (string)($_GET['bt_severity'] ?? 'all'),
|
||||
'status' => (string)($_GET['bt_status'] ?? 'all'),
|
||||
'status' => $btStatusAuswahl === [] ? 'all' : $btStatusAuswahl,
|
||||
'search' => (string)($_GET['bt_q'] ?? ''),
|
||||
'order' => (string)($_GET['bt_order'] ?? 'newest'),
|
||||
'limit' => $btPerPage,
|
||||
'offset' => ($btPage - 1) * $btPerPage,
|
||||
];
|
||||
|
||||
if ($btRoadmap !== 'alle') {
|
||||
$btFilters['on_roadmap'] = ($btRoadmap === 'nur');
|
||||
}
|
||||
|
||||
$bugRepo = new BugRepo($pdo);
|
||||
$bugtrackerResult = $bugRepo->getItems($btFilters);
|
||||
$bugtrackerItems = $bugtrackerResult['items'];
|
||||
@@ -1918,6 +2001,29 @@ $bugtrackerStats = $bugRepo->getStats(
|
||||
$btFilters['project_slug'] !== 'all' ? $btFilters['project_slug'] : null
|
||||
);
|
||||
|
||||
/**
|
||||
* Wie viele Treffer der Statusfilter gerade verschluckt.
|
||||
*
|
||||
* Der Fall, um den es geht: Jemand sucht nach "288", bekommt nichts und
|
||||
* schliesst daraus, dass es den Eintrag nicht gibt - dabei ist er nur
|
||||
* erledigt und deshalb ausgeblendet. Eine Suche, die schweigt, obwohl sie
|
||||
* etwas gefunden hat, ist schlimmer als gar keine Suche.
|
||||
*
|
||||
* Die zusaetzliche Abfrage laeuft nur bei aktiver Suche UND aktivem
|
||||
* Statusfilter - also genau dann, wenn die Verwechslung moeglich ist. Sie
|
||||
* holt eine einzige Zeile; gebraucht wird nur die Gesamtzahl.
|
||||
*/
|
||||
$btVerdeckt = 0;
|
||||
if ($btFilters['search'] !== '' && ($btStatusAuswahl !== [] || $btRoadmap !== 'alle')) {
|
||||
$ohneEinschraenkung = $btFilters;
|
||||
unset($ohneEinschraenkung['on_roadmap']);
|
||||
$ohneEinschraenkung['status'] = 'all';
|
||||
$ohneEinschraenkung['limit'] = 1;
|
||||
$ohneEinschraenkung['offset'] = 0;
|
||||
|
||||
$btVerdeckt = max(0, $bugRepo->getItems($ohneEinschraenkung)['total'] - $bugtrackerTotal);
|
||||
}
|
||||
|
||||
/** Baut eine URL mit den aktuellen Bugtracker-Filtern und einzelnen Ueberschreibungen. */
|
||||
function btUrl(array $overrides = []): string
|
||||
{
|
||||
@@ -1927,6 +2033,7 @@ function btUrl(array $overrides = []): string
|
||||
'bt_type' => $_GET['bt_type'] ?? null,
|
||||
'bt_severity' => $_GET['bt_severity'] ?? null,
|
||||
'bt_status' => $_GET['bt_status'] ?? null,
|
||||
'bt_roadmap' => $_GET['bt_roadmap'] ?? null,
|
||||
'bt_q' => $_GET['bt_q'] ?? null,
|
||||
'bt_order' => $_GET['bt_order'] ?? null,
|
||||
'bt_page' => $_GET['bt_page'] ?? null,
|
||||
@@ -2428,6 +2535,41 @@ $csrfField = Csrf::field();
|
||||
.zelle-aktionen > div { display: flex; gap: 0.3rem; flex-wrap: nowrap; }
|
||||
|
||||
.tab-content, .subtab-content { display: none; }
|
||||
|
||||
/* Mehrfachauswahl im Filterbalken.
|
||||
<details> statt eigenem Klappmechanismus: Auf- und Zuklappen kann der
|
||||
Browser, und ohne JavaScript bleibt es bedienbar. */
|
||||
.filter-mehrfach { position: relative; display: inline-block; }
|
||||
.filter-mehrfach > summary {
|
||||
list-style: none; cursor: pointer; white-space: nowrap;
|
||||
padding: 0.3rem 0.6rem; font-size: 0.8rem;
|
||||
border: 1px solid var(--border-glass, rgba(255,255,255,0.12));
|
||||
border-radius: 6px; background: rgba(255,255,255,0.04);
|
||||
}
|
||||
.filter-mehrfach > summary::-webkit-details-marker { display: none; }
|
||||
.filter-mehrfach[open] > summary { border-color: #7aa2ff; }
|
||||
.filter-mehrfach-inhalt {
|
||||
position: absolute; z-index: 50; top: calc(100% + 4px); left: 0;
|
||||
min-width: 240px; padding: 0.6rem 0.75rem;
|
||||
background: #161c28;
|
||||
border: 1px solid var(--border-glass, rgba(255,255,255,0.12));
|
||||
border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.filter-mehrfach-inhalt label {
|
||||
display: flex; align-items: center; gap: 0.45rem;
|
||||
padding: 0.15rem 0; font-size: 0.82rem; cursor: pointer; font-weight: 400;
|
||||
}
|
||||
.filter-gruppe {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.55; margin: 0.55rem 0 0.2rem;
|
||||
}
|
||||
.filter-gruppe:first-child { margin-top: 0; }
|
||||
.filter-mehrfach-fuss {
|
||||
display: flex; gap: 0.35rem; margin-top: 0.7rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--border-glass, rgba(255,255,255,0.12));
|
||||
}
|
||||
.filter-hinweis { font-size: 0.7rem; opacity: 0.5; margin-top: 0.4rem; }
|
||||
.tab-content.active, .subtab-content.active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -4706,34 +4848,100 @@ chmod +x update-agent
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<select name="bt_status" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||||
<?php
|
||||
$statusOptions = [
|
||||
'all' => '📌 Alle Status',
|
||||
'open,planned,in_progress' => '📋 Nur offene',
|
||||
'open' => 'Offen',
|
||||
'planned' => 'Geplant',
|
||||
'in_progress' => 'In Bearbeitung',
|
||||
'resolved' => 'Gelöst',
|
||||
'closed' => 'Geschlossen',
|
||||
'rejected' => 'Abgelehnt',
|
||||
// Meldungen aus einem anderen Projekt. Ohne eigenen
|
||||
// Filtereintrag wären sie nur unter "Alle Status" zu
|
||||
// finden - ein Eingang, den niemand sieht, ist keiner.
|
||||
'triage_external' => '📥 Fremdmeldungen',
|
||||
// Die Nachpruefung braucht einen eigenen Einstieg:
|
||||
// Wer wissen will, welche Fixes noch unbestaetigt
|
||||
// sind, findet sie sonst nur unter "Alle Status"
|
||||
// zwischen allem anderen.
|
||||
'awaiting_verification,in_verification'
|
||||
=> '🔎 In Nachprüfung',
|
||||
'fix_insufficient' => '❗ Fix trägt nicht',
|
||||
];
|
||||
foreach ($statusOptions as $value => $label): ?>
|
||||
<option value="<?= e($value) ?>" <?= $btFilters['status'] === $value ? 'selected' : '' ?>><?= e($label) ?></option>
|
||||
<!--
|
||||
Status als Mehrfachauswahl statt als Klappliste.
|
||||
Vorher liess sich genau eine Lage waehlen, und wer
|
||||
"offen und in Nachpruefung" sehen wollte, brauchte
|
||||
eine vorgefertigte Kombination - stand sie nicht in
|
||||
der Liste, ging es nicht.
|
||||
|
||||
Das versteckte Feld davor ist kein Schoenheitsfehler:
|
||||
HTML sendet fuer nicht angehakte Kaestchen NICHTS.
|
||||
Ohne es waere "alle Haken entfernt" von "erster
|
||||
Aufruf" nicht zu unterscheiden, und die Vorgabe kaeme
|
||||
bei jedem Versuch zurueck, sie loszuwerden.
|
||||
|
||||
Kein Absenden bei jedem Klick: Wer drei Status waehlt,
|
||||
will einmal laden und nicht dreimal. Deshalb der
|
||||
eigene Knopf im Klappfeld.
|
||||
-->
|
||||
<select name="bt_roadmap" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)"
|
||||
title="Ein Vorhaben steht auf der Roadmap, sobald es einen Themenbereich hat. Es bleibt ein Item wie jedes andere - es wird hier nur nicht doppelt gezeigt.">
|
||||
<?php foreach ([
|
||||
'ohne' => '🗺️ Ohne Roadmap',
|
||||
'nur' => '🗺️ Nur Roadmap',
|
||||
'alle' => '🗺️ Mit Roadmap',
|
||||
] as $wert => $beschriftung): ?>
|
||||
<option value="<?= e($wert) ?>" <?= $btRoadmap === $wert ? 'selected' : '' ?>><?= e($beschriftung) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<details class="filter-mehrfach">
|
||||
<summary title="Status auswaehlen">
|
||||
<?php
|
||||
$statusGruppen = [
|
||||
'Laufend' => [
|
||||
'open' => 'Offen',
|
||||
'planned' => 'Geplant',
|
||||
'in_progress' => 'In Bearbeitung',
|
||||
],
|
||||
'Verlangt Aufmerksamkeit' => [
|
||||
'triage_external' => '📥 Fremdmeldung',
|
||||
'awaiting_verification' => '🔎 Wartet auf Nachpruefung',
|
||||
'in_verification' => '🔎 In Nachpruefung',
|
||||
'fix_insufficient' => '❗ Fix traegt nicht',
|
||||
],
|
||||
'Abgeschlossen' => [
|
||||
'resolved' => 'Gelöst',
|
||||
'closed' => 'Geschlossen',
|
||||
'rejected' => 'Abgelehnt',
|
||||
'ignored' => 'Ignoriert',
|
||||
'deferred' => 'Zurückgestellt',
|
||||
],
|
||||
];
|
||||
|
||||
$btStatusLabels = [];
|
||||
foreach ($statusGruppen as $eintraege) {
|
||||
$btStatusLabels += $eintraege;
|
||||
}
|
||||
|
||||
$anzahl = count($btStatusAuswahl);
|
||||
if ($anzahl === 0) {
|
||||
$btStatusText = '📌 Alle Status';
|
||||
} elseif ($anzahl === 1) {
|
||||
$btStatusText = '📌 ' . ($btStatusLabels[$btStatusAuswahl[0]] ?? $btStatusAuswahl[0]);
|
||||
} else {
|
||||
$btStatusText = sprintf('📌 %d Status', $anzahl);
|
||||
}
|
||||
?>
|
||||
<?= e($btStatusText) ?> ▾
|
||||
</summary>
|
||||
|
||||
<div class="filter-mehrfach-inhalt">
|
||||
<input type="hidden" name="bt_status[]" value="">
|
||||
|
||||
<?php foreach ($statusGruppen as $gruppe => $eintraege): ?>
|
||||
<div class="filter-gruppe"><?= e($gruppe) ?></div>
|
||||
<?php foreach ($eintraege as $wert => $beschriftung): ?>
|
||||
<label>
|
||||
<input type="checkbox" name="bt_status[]" value="<?= e($wert) ?>"
|
||||
<?= in_array($wert, $btStatusAuswahl, true) ? 'checked' : '' ?>>
|
||||
<?= e($beschriftung) ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="filter-mehrfach-fuss">
|
||||
<button type="button" class="btn btn-sm btn-secondary"
|
||||
onclick="dcAlleHaken(this, false)">Keine</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary"
|
||||
onclick="dcAlleHaken(this, true)">Alle</button>
|
||||
<button type="submit" class="btn btn-sm">Übernehmen</button>
|
||||
</div>
|
||||
<div class="filter-hinweis">Kein Haken = keine Einschränkung.</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<select name="bt_order" class="form-input" style="width:auto; padding:0.3rem 0.6rem; font-size:0.8rem;" onchange="dcSubmit(this.form)">
|
||||
<?php
|
||||
$orderOptions = [
|
||||
@@ -4753,10 +4961,45 @@ chmod +x update-agent
|
||||
title="Volltext ueber Titel, Beschreibung, Fehlermeldung, Tags und Dateipfad. Eine Zahl findet zusaetzlich das Item mit dieser Nummer; mit Raute (#288) nur dieses eine.">
|
||||
|
||||
<button type="submit" class="btn btn-sm">Filtern</button>
|
||||
<?php if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all' || $btFilters['status'] !== 'all' || $btFilters['severity'] !== 'all' || $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?>
|
||||
<?php
|
||||
// Der Statusfilter zaehlt hier nicht mehr als "gesetzt":
|
||||
// Er ist ab Werk eingeschraenkt, sonst stuende der
|
||||
// Zuruecksetzen-Knopf immer da und bedeutete nichts.
|
||||
$btAbweichend = $btStatusAuswahl !== BT_STATUS_VORGABE || $btRoadmap !== 'ohne';
|
||||
if ($btFilters['search'] !== '' || $btFilters['project_slug'] !== 'all'
|
||||
|| $btAbweichend || $btFilters['severity'] !== 'all'
|
||||
|| $btFilters['type'] !== 'all' || $btFilters['environment'] !== 'all'): ?>
|
||||
<a href="index.php#tab-bugtracker" class="btn btn-sm btn-secondary">Zurücksetzen</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<?php if ($btVerdeckt > 0): ?>
|
||||
<!--
|
||||
Der Fall: Jemand sucht "288", bekommt nichts und
|
||||
schliesst daraus, es gebe den Eintrag nicht - dabei ist
|
||||
er nur erledigt und deshalb ausgeblendet. Eine Suche,
|
||||
die schweigt, obwohl sie etwas gefunden hat, ist
|
||||
schlimmer als gar keine.
|
||||
-->
|
||||
<div class="alert" style="margin-top:0.75rem; display:flex; flex-wrap:wrap; align-items:center; gap:0.6rem; background:rgba(122,162,255,0.12); border:1px solid #7aa2ff; color:#cfe0ff;">
|
||||
<span>
|
||||
🔎 <strong><?= (int)$btVerdeckt ?></strong>
|
||||
<?= $btVerdeckt === 1 ? 'weiterer Treffer' : 'weitere Treffer' ?>
|
||||
für „<?= e($btFilters['search']) ?>"
|
||||
<?= $btVerdeckt === 1 ? 'wird' : 'werden' ?>
|
||||
ausgeblendet - vom Statusfilter oder weil sie auf der Roadmap stehen.
|
||||
</span>
|
||||
<a class="btn btn-sm btn-secondary"
|
||||
<?php
|
||||
// Ein leerer Eintrag, keine Zeichenkette "all":
|
||||
// btUrl() wirft 'all' als "nicht gesetzt" heraus,
|
||||
// und dann griffe wieder die Vorgabe. Als Liste mit
|
||||
// einem leeren Wert bleibt der Parameter in der URL
|
||||
// stehen und bedeutet ausdruecklich "ohne Filter".
|
||||
?>
|
||||
href="<?= e(btUrl(['bt_status' => [''], 'bt_roadmap' => 'alle', 'bt_page' => null])) ?>">Überall suchen</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
@@ -6350,6 +6593,19 @@ SYSTEM
|
||||
* requestSubmit() tut es. Wo es fehlt, wird die Ansicht vorher von
|
||||
* Hand angehaengt.
|
||||
*/
|
||||
/**
|
||||
* Setzt oder entfernt alle Haken einer Mehrfachauswahl.
|
||||
*
|
||||
* Ohne das waere "ich will jetzt wirklich alles sehen" ein Dutzend
|
||||
* Klicks - und genau dann braucht man es, wenn man etwas sucht und
|
||||
* nicht findet.
|
||||
*/
|
||||
function dcAlleHaken(knopf, gesetzt) {
|
||||
knopf.closest('.filter-mehrfach-inhalt')
|
||||
.querySelectorAll('input[type=checkbox]')
|
||||
.forEach(k => { k.checked = gesetzt; });
|
||||
}
|
||||
|
||||
function dcSubmit(form) {
|
||||
if (typeof form.requestSubmit === 'function') {
|
||||
form.requestSubmit();
|
||||
|
||||
Reference in New Issue
Block a user