Fahne mit externen Tickets klickbar: Modal listet die Tickets (#117)
Klick auf die rote Fahne am PO oeffnet ein Modal im Stil der Board-Spalten-Modals mit allen offenen Tickets externer User (Repo, Nummer mit Link, Titel, Typ, Beschreibung) plus der jeweiligen Spalte, spaltenuebergreifend nach created_at sortiert, neuestes zuerst. Die Fahne liegt dafuer ueber der Trefferflaeche des PO-Platzes und stoppt die Klick-Weitergabe, damit nicht zusaetzlich die Projektverwaltung aufgeht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a89b75277e
commit
5c9c2c8c9b
6 changed files with 159 additions and 34 deletions
|
|
@ -4,6 +4,9 @@ Format in Anlehnung an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Klick auf die rote Fahne am PO oeffnet ein Modal, das alle offenen Tickets externer User
|
||||
wie bei den Kanban-Spalten listet (Repo, Nummer mit Link, Titel, Typ, Beschreibung), jeweils
|
||||
mit ihrer Spalte, neuestes zuerst (#117).
|
||||
- `update.sh` erzeugt die Container `api` und `sync` bei jedem Update neu. Bisher lief der
|
||||
`sync`-Prozess nach einem Update mit dem alten Code weiter, schrieb deshalb keinen
|
||||
Ticket-Ersteller und die rote Fahne am PO (#113) erschien nicht (#115).
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
|
|||
den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser.
|
||||
- Liegen offene Tickets (Issues oder PRs, alles außer „Done") vor, die nicht von `fds2610`
|
||||
oder `fds2610-bot` angelegt wurden, steht auf dem Tisch des PO eine kleine rote Fahne mit
|
||||
deren Anzahl (Issue #113). Den Ersteller liest der Codeberg-Sync mit (`author`).
|
||||
deren Anzahl (Issue #113). Den Ersteller liest der Codeberg-Sync mit (`author`). Ein Klick
|
||||
auf die Fahne listet diese Tickets in einem Modal wie bei den Board-Spalten, zusätzlich mit
|
||||
ihrer jeweiligen Spalte (Issue #117).
|
||||
- Klick auf das Zahnrad neben der Überschrift „Systeme" öffnet die Verwaltung der per Ping
|
||||
überwachten Hosts (Issue #70): Server hinzufügen, umbenennen und entfernen, ohne sich auf
|
||||
den Rechner mit `check_fds.sh` einzuloggen — der holt seine `hosts.conf` bei jedem Lauf von
|
||||
|
|
|
|||
|
|
@ -382,6 +382,16 @@ header.office-header h1 {
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Issue #117: die Fahne oeffnet die Liste der externen Tickets. */
|
||||
.external-ticket-flag-hit {
|
||||
fill: transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.external-ticket-flag:hover .external-ticket-flag-cloth {
|
||||
fill: #b91c1c;
|
||||
}
|
||||
|
||||
.person-head {
|
||||
fill: #f3d9b1;
|
||||
stroke: #d8b98a;
|
||||
|
|
@ -795,6 +805,15 @@ header.office-header h1 {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Spalte eines Tickets im Modal der externen Tickets (Issue #117). */
|
||||
.office-modal-column {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.office-modal-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
aggregateTicketsByColumn,
|
||||
groupTicketsByColumn,
|
||||
countExternalOpenTickets,
|
||||
buildExternalTicketModalContent,
|
||||
buildTicketModalContent,
|
||||
mapEventForDisplay,
|
||||
formatEventLineText,
|
||||
|
|
@ -791,17 +792,15 @@ function renderDesk({ deskIndex, agent }) {
|
|||
* PO gilt unabhaengig von der API-Erreichbarkeit als anwesend, Issue #19/#97).
|
||||
* @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27).
|
||||
* @param {number} externalTicketCount - offene Tickets externer User (Issue #113).
|
||||
* @param {() => void} [onExternalTicketsClick] - oeffnet die Liste dieser Tickets (Issue #117).
|
||||
*/
|
||||
function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0) {
|
||||
function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0, onExternalTicketsClick = () => {}) {
|
||||
const g = el("g", { class: "desks" });
|
||||
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
|
||||
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
|
||||
poDeskGroup.classList.add("po-desk", "clickable");
|
||||
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
|
||||
poDeskGroup.appendChild(renderPoConfigHint());
|
||||
if (externalTicketCount > 0) {
|
||||
poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount));
|
||||
}
|
||||
// Eine <g> selbst faengt keine Klicks - nur ihre gefuellten Kinder. Zwischen Tischplatte,
|
||||
// Stuhl und Namensschild liegen Luecken, in denen ein Klick "auf den PO" beim Boden landete
|
||||
// (Fehlerbericht zu Issue #27). Diese unsichtbare Flaeche deckt den ganzen Platz ab, analog
|
||||
|
|
@ -815,6 +814,10 @@ function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0) {
|
|||
class: "po-desk-hit",
|
||||
})
|
||||
);
|
||||
// Issue #117: die Fahne liegt ueber der Trefferflaeche, sonst kaeme ihr eigener Klick nie an.
|
||||
if (externalTicketCount > 0) {
|
||||
poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount, onExternalTicketsClick));
|
||||
}
|
||||
poDeskGroup.addEventListener("click", onPoClick);
|
||||
g.appendChild(poDeskGroup);
|
||||
return g;
|
||||
|
|
@ -841,9 +844,10 @@ function renderPoConfigHint() {
|
|||
// Issue #113: kleine rote Fahne auf dem PO-Tisch, sobald offene Tickets von externen Usern
|
||||
// (nicht fds2610/fds2610-bot) vorliegen - die Zahl steht im Fahnentuch. Sitzt zwischen
|
||||
// Bildschirm (x 20-80) und Blumen-Deko (ab x ~122), damit sie mit keinem von beiden kollidiert.
|
||||
function renderExternalTicketFlag(count) {
|
||||
// Issue #117: Klick auf die Fahne listet diese Tickets in einem Modal wie bei den Board-Spalten.
|
||||
function renderExternalTicketFlag(count, onClick) {
|
||||
const poleX = 92;
|
||||
const g = el("g", { class: "external-ticket-flag" });
|
||||
const g = el("g", { class: "external-ticket-flag clickable" });
|
||||
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = `${count} offene${count === 1 ? "s" : ""} Ticket${count === 1 ? "" : "s"} externer User`;
|
||||
|
|
@ -860,6 +864,15 @@ function renderExternalTicketFlag(count) {
|
|||
"text-anchor": "middle",
|
||||
})
|
||||
);
|
||||
// Groessere unsichtbare Trefferflaeche (Stange + Tuch), analog zu .board-reload-hint-hit.
|
||||
g.appendChild(el("rect", { x: poleX - 6, y: 0, width: 34, height: 50, class: "external-ticket-flag-hit" }));
|
||||
|
||||
g.addEventListener("click", (event) => {
|
||||
// Der ganze PO-Platz oeffnet die Projektverwaltung (Issue #27) - ohne das Stoppen laege
|
||||
// sie sofort ueber der Ticketliste.
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
|
||||
return g;
|
||||
}
|
||||
|
|
@ -1470,7 +1483,7 @@ function triggerDoorVisit(visit, onArrived) {
|
|||
|
||||
function renderTicketModalBody(body, content) {
|
||||
if (content.items.length === 0) {
|
||||
body.appendChild(emptyMessage("Keine Tickets in dieser Spalte."));
|
||||
body.appendChild(emptyMessage(content.emptyText || "Keine Tickets in dieser Spalte."));
|
||||
return;
|
||||
}
|
||||
const list = document.createElement("ul");
|
||||
|
|
@ -1501,6 +1514,13 @@ function renderTicketModalBody(body, content) {
|
|||
const titleEl = document.createElement("strong");
|
||||
titleEl.textContent = item.title;
|
||||
heading.appendChild(titleEl);
|
||||
// Issue #117: im Modal der externen Tickets stammen die Eintraege aus mehreren Spalten.
|
||||
if (item.column) {
|
||||
const columnEl = document.createElement("span");
|
||||
columnEl.className = "office-modal-column";
|
||||
columnEl.textContent = item.column;
|
||||
heading.appendChild(columnEl);
|
||||
}
|
||||
if (item.type) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "office-modal-badge";
|
||||
|
|
@ -2460,7 +2480,8 @@ export function renderOfficeScene(container, data = {}) {
|
|||
deskLayout,
|
||||
poDeskAgent,
|
||||
openRepoConfigModal,
|
||||
countExternalOpenTickets(tickets)
|
||||
countExternalOpenTickets(tickets),
|
||||
() => openModal(buildExternalTicketModalContent(tickets), renderTicketModalBody)
|
||||
);
|
||||
const existingDesks = svg.querySelector(".desks");
|
||||
if (existingDesks) {
|
||||
|
|
|
|||
|
|
@ -217,13 +217,13 @@ export function groupTicketsByColumn(tickets = []) {
|
|||
export const INTERNAL_TICKET_AUTHORS = ["fds2610", "fds2610-bot"];
|
||||
|
||||
/**
|
||||
* Zaehlt offene Tickets (Issues und PRs, alles ausser "done") externer User. Tickets ohne
|
||||
* author (per API angelegt oder noch nicht vom Sync nachgetragen) zaehlen nicht mit.
|
||||
* Offene Tickets (Issues und PRs, alles ausser "done") externer User. Tickets ohne author
|
||||
* (per API angelegt oder noch nicht vom Sync nachgetragen) zaehlen nicht mit.
|
||||
*
|
||||
* @param {Array<{column_name: string, author?: string|null}>} tickets
|
||||
* @returns {number}
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function countExternalOpenTickets(tickets = []) {
|
||||
export function filterExternalOpenTickets(tickets = []) {
|
||||
const internal = INTERNAL_TICKET_AUTHORS.map((login) => login.toLowerCase());
|
||||
return tickets.filter(
|
||||
(ticket) =>
|
||||
|
|
@ -231,7 +231,17 @@ export function countExternalOpenTickets(tickets = []) {
|
|||
typeof ticket.author === "string" &&
|
||||
ticket.author !== "" &&
|
||||
!internal.includes(ticket.author.toLowerCase())
|
||||
).length;
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt offene Tickets externer User (siehe filterExternalOpenTickets()).
|
||||
*
|
||||
* @param {Array<{column_name: string, author?: string|null}>} tickets
|
||||
* @returns {number}
|
||||
*/
|
||||
export function countExternalOpenTickets(tickets = []) {
|
||||
return filterExternalOpenTickets(tickets).length;
|
||||
}
|
||||
|
||||
// Issue #88: Beschreibungen im Ticket-Modal werden auf diese Zeilenzahl gekuerzt.
|
||||
|
|
@ -273,26 +283,64 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
|
|||
|
||||
return {
|
||||
title: column ? column.label : columnKey,
|
||||
items: newestFirst.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
description: truncateDescriptionLines(ticket.description),
|
||||
type: ticket.type || null,
|
||||
color: ticket.color || null,
|
||||
// Issue #88: Repo und Ticket-Nummer stehen in der Ueberschrift, die Nummer ist der Link.
|
||||
repo:
|
||||
ticket.source === "codeberg" && ticket.external_ref
|
||||
? ticket.repo || `${codebergOwner}/${codebergRepo}`
|
||||
: null,
|
||||
number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null,
|
||||
isPullRequest: isPullRequest(ticket),
|
||||
link:
|
||||
ticket.source === "codeberg" && ticket.external_ref
|
||||
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/${
|
||||
isPullRequest(ticket) ? "pulls" : "issues"
|
||||
}/${ticket.external_ref}`
|
||||
: null,
|
||||
})),
|
||||
items: newestFirst.map((ticket) => buildTicketModalItem(ticket, codebergOwner, codebergRepo)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #117: Inhaltsmodell fuer das Modal hinter der roten Fahne am PO - alle offenen
|
||||
* Tickets externer User (filterExternalOpenTickets()), aufgebaut wie ein Board-Spalten-Modal.
|
||||
* Weil die Tickets aus mehreren Spalten stammen, traegt jeder Eintrag zusaetzlich seine
|
||||
* Spalte (column); sortiert wird spaltenuebergreifend nach created_at, neuestes zuerst
|
||||
* (wie im Spalten-Modal, Issue #102).
|
||||
*
|
||||
* @param {Array<object>} tickets - rohe Ticketliste (GET /api/v1/tickets)
|
||||
* @param {{codebergOwner?: string, codebergRepo?: string}} [opts]
|
||||
*/
|
||||
export function buildExternalTicketModalContent(tickets = [], opts = {}) {
|
||||
const { codebergOwner = "fds2610", codebergRepo = "monitor" } = opts;
|
||||
const newestFirst = filterExternalOpenTickets(tickets)
|
||||
.map((ticket, index) => ({ ticket, index }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
String(b.ticket.created_at || "").localeCompare(String(a.ticket.created_at || "")) ||
|
||||
b.index - a.index
|
||||
)
|
||||
.map(({ ticket }) => ticket);
|
||||
|
||||
return {
|
||||
title: "Externe Tickets",
|
||||
emptyText: "Keine offenen Tickets externer User.",
|
||||
items: newestFirst.map((ticket) => {
|
||||
const column = BOARD_COLUMNS.find((c) => c.key === ticket.column_name);
|
||||
return {
|
||||
...buildTicketModalItem(ticket, codebergOwner, codebergRepo),
|
||||
column: column ? column.label : ticket.column_name,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTicketModalItem(ticket, codebergOwner, codebergRepo) {
|
||||
return {
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
description: truncateDescriptionLines(ticket.description),
|
||||
type: ticket.type || null,
|
||||
color: ticket.color || null,
|
||||
// Issue #88: Repo und Ticket-Nummer stehen in der Ueberschrift, die Nummer ist der Link.
|
||||
repo:
|
||||
ticket.source === "codeberg" && ticket.external_ref
|
||||
? ticket.repo || `${codebergOwner}/${codebergRepo}`
|
||||
: null,
|
||||
number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null,
|
||||
isPullRequest: isPullRequest(ticket),
|
||||
link:
|
||||
ticket.source === "codeberg" && ticket.external_ref
|
||||
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/${
|
||||
isPullRequest(ticket) ? "pulls" : "issues"
|
||||
}/${ticket.external_ref}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ import {
|
|||
resolveReloadIntervalSeconds,
|
||||
nextCountdownSeconds,
|
||||
countExternalOpenTickets,
|
||||
filterExternalOpenTickets,
|
||||
buildExternalTicketModalContent,
|
||||
INTERNAL_TICKET_AUTHORS,
|
||||
} from "../../html/js/office-scene.js";
|
||||
|
||||
|
|
@ -1006,3 +1008,33 @@ test("countExternalOpenTickets zaehlt nur offene Tickets externer User (Issue #1
|
|||
assert.equal(countExternalOpenTickets(), 0);
|
||||
assert.deepEqual(INTERNAL_TICKET_AUTHORS, ["fds2610", "fds2610-bot"]);
|
||||
});
|
||||
|
||||
test("buildExternalTicketModalContent listet offene Tickets externer User wie ein Spalten-Modal (Issue #117)", () => {
|
||||
const tickets = [
|
||||
{ id: 1, column_name: "backlog", author: "jemand", title: "Alt", source: "codeberg", external_ref: "3", repo: "fds2610/monitor", created_at: "2026-10-01T10:00:00+02:00", type: "bug", color: "#ff0000" },
|
||||
{ id: 2, column_name: "fertig", author: "extern-pr", title: "PR", source: "codeberg", external_ref: "9", repo: "fds2610/andere", is_pull_request: 1, created_at: "2026-10-05T10:00:00+02:00" },
|
||||
{ id: 3, column_name: "in_arbeit", author: "fds2610", title: "Intern", created_at: "2026-10-06T10:00:00+02:00" },
|
||||
{ id: 4, column_name: "done", author: "jemand", title: "Erledigt", created_at: "2026-10-07T10:00:00+02:00" },
|
||||
{ id: 5, column_name: "qa", author: "jemand", title: "Neu", source: "api", created_at: "2026-10-08T10:00:00+02:00" },
|
||||
];
|
||||
|
||||
assert.deepEqual(filterExternalOpenTickets(tickets).map((t) => t.id), [1, 2, 5]);
|
||||
|
||||
const content = buildExternalTicketModalContent(tickets);
|
||||
assert.equal(content.title, "Externe Tickets");
|
||||
assert.equal(typeof content.emptyText, "string");
|
||||
assert.deepEqual(content.items.map((item) => item.id), [5, 2, 1]);
|
||||
assert.deepEqual(content.items.map((item) => item.column), ["QA", "Fertig", "Backlog"]);
|
||||
|
||||
const [api, pr, issue] = content.items;
|
||||
assert.equal(api.link, null);
|
||||
assert.equal(api.number, null);
|
||||
assert.equal(pr.isPullRequest, true);
|
||||
assert.equal(pr.link, "https://codeberg.org/fds2610/andere/pulls/9");
|
||||
assert.equal(issue.link, "https://codeberg.org/fds2610/monitor/issues/3");
|
||||
assert.equal(issue.repo, "fds2610/monitor");
|
||||
assert.equal(issue.type, "bug");
|
||||
assert.equal(issue.color, "#ff0000");
|
||||
|
||||
assert.deepEqual(buildExternalTicketModalContent().items, []);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue