feat(sse): toast notifications for project-board and milestone events
Surfaces SSE board/milestone changes as Gitea toasts (showInfoToast / showWarningToast) in addition to the silent DOM patch: - project board: card moved/linked/unlinked, column renamed/removed, project deleted (warning + delayed redirect so the reason is visible) - milestone: progress change (title · closed/total · pct), milestone deleted (warning + delayed redirect on single view) Own-tab echoes are suppressed via the existing session_tag guard; toast.ts preventDuplicates (default) coalesces bursts. column.reordered and project.updated intentionally do not toast (noise). Frontend only; no backend/event changes. tsc + vite build clean.
This commit is contained in:
@@ -1,4 +1,21 @@
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
|
||||
|
||||
// milestoneTitle does a best-effort lookup of the milestone's display
|
||||
// name for toast text. List cards expose it as a heading/link inside the
|
||||
// card; the single-milestone view puts it in the page header. Falls back
|
||||
// to a generic label so a toast still fires if the markup shifts.
|
||||
function milestoneTitle(milestoneID: number): string {
|
||||
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${milestoneID}"]`);
|
||||
const fromCard = card?.querySelector('.milestone-card-title, h3, .title, a[href*="/milestone/"]')?.textContent?.trim();
|
||||
if (fromCard) return fromCard;
|
||||
const onSingle = document.querySelector<HTMLElement>(`progress[data-milestone-id="${milestoneID}"]`);
|
||||
if (onSingle) {
|
||||
const h = document.querySelector('.repository.milestone-issue-list .milestone-title, .page-content .milestone-title, h1, h2')?.textContent?.trim();
|
||||
if (h) return h;
|
||||
}
|
||||
return 'Milestone';
|
||||
}
|
||||
|
||||
// sessionTag is generated once per page load. The mutation requests on
|
||||
// milestone pages (close/open/delete/edit) flow through the existing
|
||||
@@ -86,11 +103,10 @@ function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void {
|
||||
const parts = window.location.pathname.split('/');
|
||||
// .../milestone/{id} -> go up to the milestones listing.
|
||||
const idx = parts.lastIndexOf('milestone');
|
||||
if (idx > 0) {
|
||||
window.location.href = `${parts.slice(0, idx).join('/')}/milestones`;
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
const dest = idx > 0 ? `${parts.slice(0, idx).join('/')}/milestones` : '/';
|
||||
// Delay so the "milestone deleted" warning toast is visible before
|
||||
// the page navigates out from under the viewer.
|
||||
setTimeout(() => { window.location.href = dest }, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +114,12 @@ function dispatchMilestoneEvent(payload: any): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
if (isProgressPayload(payload)) {
|
||||
patchMilestoneCard(payload);
|
||||
const total = payload.open_issues + payload.closed_issues;
|
||||
showInfoToast(`${milestoneTitle(payload.milestone_id)} · ${payload.closed_issues}/${total} closed (${payload.completeness}%)`);
|
||||
} else if ('milestone_id' in payload && 'repo_id' in payload) {
|
||||
const title = milestoneTitle(payload.milestone_id);
|
||||
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
|
||||
showWarningToast(title === 'Milestone' ? 'A milestone was deleted' : `Milestone “${title}” was deleted`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,20 @@ import {toggleFullScreen} from '../utils.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
|
||||
|
||||
// issueRef returns a short human label for a card, preferring the
|
||||
// rendered "#index" anchor text and falling back to the internal id.
|
||||
function issueRef(card: HTMLElement | null, issueID: number): string {
|
||||
const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim();
|
||||
const m = idx?.match(/#\d+/);
|
||||
return m ? m[0] : `#${issueID}`;
|
||||
}
|
||||
|
||||
function columnName(board: HTMLElement, columnID: number): string {
|
||||
const t = board.querySelector<HTMLElement>(`.project-column[data-id="${columnID}"] .project-column-title-text`)?.textContent?.trim();
|
||||
return t || `column ${columnID}`;
|
||||
}
|
||||
|
||||
const SESSION_TAG_HEADER = 'X-Session-Tag';
|
||||
|
||||
@@ -281,8 +295,10 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
|
||||
// Card is not currently rendered (filtered out, or new since
|
||||
// page load). A targeted column re-fetch is the safe fallback.
|
||||
refetchColumn(board, payload.to_column_id);
|
||||
showInfoToast(`#${payload.issue_id} → ${columnName(board, payload.to_column_id)}`);
|
||||
return;
|
||||
}
|
||||
const ref = issueRef(card, payload.issue_id);
|
||||
const target = board.querySelector<HTMLElement>(`#board_${payload.to_column_id}`);
|
||||
if (!target) return;
|
||||
const fromColumn = card.parentElement;
|
||||
@@ -293,6 +309,7 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
|
||||
}
|
||||
const toColumnEl = target.closest<HTMLElement>('.project-column');
|
||||
if (toColumnEl) updateColumnCount(toColumnEl);
|
||||
showInfoToast(`${ref} → ${columnName(board, payload.to_column_id)}`);
|
||||
}
|
||||
|
||||
async function refetchColumn(board: HTMLElement, columnID: number): Promise<void> {
|
||||
@@ -321,15 +338,18 @@ async function refetchColumn(board: HTMLElement, columnID: number): Promise<void
|
||||
function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
refetchColumn(board, payload.column_id); // no await
|
||||
showInfoToast(`#${payload.issue_id} added to ${columnName(board, payload.column_id)}`);
|
||||
}
|
||||
|
||||
function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
const card = board.querySelector<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
|
||||
if (!card) return;
|
||||
const ref = issueRef(card, payload.issue_id);
|
||||
const colEl = card.closest<HTMLElement>('.project-column');
|
||||
card.remove();
|
||||
if (colEl) updateColumnCount(colEl);
|
||||
showInfoToast(`${ref} removed from board`);
|
||||
}
|
||||
|
||||
function handleColumnCreated(): void {
|
||||
@@ -341,7 +361,11 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload):
|
||||
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
|
||||
if (!colEl) return;
|
||||
const titleEl = colEl.querySelector<HTMLElement>('.project-column-title-text');
|
||||
const oldTitle = titleEl?.textContent?.trim();
|
||||
if (titleEl) titleEl.textContent = payload.title;
|
||||
if (oldTitle && oldTitle !== payload.title) {
|
||||
showInfoToast(`Column renamed to “${payload.title}”`);
|
||||
}
|
||||
if (payload.color) {
|
||||
const textColor = contrastColor(payload.color);
|
||||
colEl.style.setProperty('background', payload.color, 'important');
|
||||
@@ -356,7 +380,10 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload):
|
||||
|
||||
function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void {
|
||||
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
|
||||
if (colEl) colEl.remove();
|
||||
if (!colEl) return;
|
||||
const name = colEl.querySelector<HTMLElement>('.project-column-title-text')?.textContent?.trim();
|
||||
colEl.remove();
|
||||
showInfoToast(name ? `Column “${name}” removed` : 'A column was removed');
|
||||
}
|
||||
|
||||
function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void {
|
||||
@@ -383,13 +410,12 @@ function handleProjectDeleted(): void {
|
||||
// The board lives at .../projects/{id}; the listing page is the
|
||||
// parent. Falling back to the homepage on any URL we don't
|
||||
// recognise is acceptable since this is a destructive event.
|
||||
// Show a sticky warning first and delay the redirect briefly so the
|
||||
// user understands why the page is about to change under them.
|
||||
showWarningToast('This project was deleted — returning to the project list');
|
||||
const parts = window.location.pathname.split('/');
|
||||
if (parts.length > 1) {
|
||||
parts.pop();
|
||||
window.location.href = parts.join('/') || '/';
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
const dest = parts.length > 1 ? (parts.slice(0, -1).join('/') || '/') : '/';
|
||||
setTimeout(() => { window.location.href = dest }, 1500);
|
||||
}
|
||||
|
||||
// dispatchProjectEvent picks the right handler for an SSE payload.
|
||||
|
||||
Reference in New Issue
Block a user