From 3c094d66fad49eddb8933e62e44b0a7e11c17ca8 Mon Sep 17 00:00:00 2001 From: Oleks Date: Fri, 15 May 2026 22:02:19 +0300 Subject: [PATCH] feat(project): SSE subscriber + DOM patches on board page The project board view now opens a SharedWorker EventSource subscription scoped to project-board.{id} and patches the DOM in response to incoming events: - card.moved: relocates the card to the destination column and refreshes both column count badges; falls back to a column refetch when the card isn't currently rendered (filtered out / new). - card.linked: refetches the destination column's issue list and updates the count badge. - card.unlinked: removes the card and updates the badge. - column.created: page reload (rare event, simplest path). - column.updated: in-place title + color/contrast updates. - column.deleted: removes the column element. - column.reordered: re-attaches columns in the new sort order. - project.updated: updates the header title + description text. - project.deleted: navigates up to the projects listing. The board template now exposes data-project-id, data-project-scope (repo/user/org), data-project-owner, and data-project-repo so the subscriber can build the right column-issues refetch URL. Each mutation request the page makes also carries a crypto.randomUUID-generated X-Session-Tag header; the receiving handler compares it against the incoming payload's session_tag to suppress own-tab echoes (the optimistic local update is already authoritative). --- templates/projects/view.tmpl | 17 +- web_src/js/features/repo-projects.ts | 285 ++++++++++++++++++++++++++- 2 files changed, 299 insertions(+), 3 deletions(-) diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index 30056e211f..ef57929078 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -1,4 +1,13 @@ {{$canWriteProject := and .CanWriteProjects (or (not .Repository) (not .Repository.IsArchived))}} +{{/* $projectScope is read by the SSE handler to build the right column-issues + refetch URL (repo / org / user). RepoID > 0 wins over Owner so a repo + project nested under an owner is still treated as repo-scoped. */}} +{{$projectScope := "user"}} +{{if .Repository}}{{$projectScope = "repo"}}{{else if and .ContextUser .ContextUser.IsOrganization}}{{$projectScope = "org"}}{{end}} +{{$projectOwnerName := ""}} +{{if and .Repository .Repository.Owner}}{{$projectOwnerName = .Repository.Owner.Name}}{{else if .ContextUser}}{{$projectOwnerName = .ContextUser.Name}}{{end}} +{{$projectRepoName := ""}} +{{if .Repository}}{{$projectRepoName = .Repository.Name}}{{end}}
@@ -77,7 +86,13 @@
-
+
{{range .Columns}}
diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts index 64a2e966c9..41a64d600c 100644 --- a/web_src/js/features/repo-projects.ts +++ b/web_src/js/features/repo-projects.ts @@ -1,12 +1,37 @@ import {contrastColor} from '../utils/color.ts'; import {createSortable} from '../modules/sortable.ts'; -import {POST, request} from '../modules/fetch.ts'; +import {GET, POST, request} from '../modules/fetch.ts'; import {hideFomanticModal} from '../modules/fomantic/modal.ts'; import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; import type {SortableEvent} from 'sortablejs'; import {toggleFullScreen} from '../utils.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {localUserSettings} from '../modules/user-settings.ts'; +import {UserEventsSharedWorker} from '../modules/worker.ts'; + +const SESSION_TAG_HEADER = 'X-Session-Tag'; + +// sessionTag is generated once per page load. It is attached as the +// X-Session-Tag header on every mutation request and compared against +// incoming SSE payloads so the originating tab can suppress its own +// echo (the source-of-truth DOM update already happened locally). +let sessionTag = ''; + +function ensureSessionTag(): string { + if (sessionTag) return sessionTag; + if (globalThis.crypto?.randomUUID) { + sessionTag = globalThis.crypto.randomUUID(); + } else { + sessionTag = `st-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; + } + return sessionTag; +} + +function withSessionTag(headers: HeadersInit | undefined): Headers { + const h = new Headers(headers ?? {}); + h.set(SESSION_TAG_HEADER, ensureSessionTag()); + return h; +} function updateIssueCount(card: HTMLElement): void { const parent = card.parentElement!; @@ -29,6 +54,7 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise { try { await POST(mainBoard.getAttribute('data-url')!, { data: columnSorting, + headers: withSessionTag(undefined), }); } catch (error) { console.error(error); @@ -113,7 +140,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void { try { elForm.classList.add('is-loading'); - await request(formLink, {method: formMethod, data: formData}); + await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)}); if (!columnId) { window.location.reload(); // newly added column, need to reload the page return; @@ -173,9 +200,263 @@ function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void { } } +// SSE handlers --------------------------------------------------------------- + +type EventPayloadBase = {session_tag?: string}; + +type CardMovedPayload = EventPayloadBase & { + project_id: number; + issue_id: number; + from_column_id: number; + to_column_id: number; + sorting: number; +}; + +type CardLinkedPayload = EventPayloadBase & { + project_id: number; + issue_id: number; + column_id: number; +}; + +type CardUnlinkedPayload = EventPayloadBase & { + project_id: number; + issue_id: number; +}; + +type ColumnUpdatedPayload = { + project_id: number; + column_id: number; + title: string; + color: string; + sorting: number; +}; + +type ColumnDeletedPayload = { + project_id: number; + column_id: number; +}; + +type ColumnReorderedPayload = { + project_id: number; + columns: Array<{column_id: number; sorting: number}>; +}; + +type ProjectUpdatedPayload = { + project_id: number; + title: string; + description: string; + card_type: string; + is_closed: boolean; +}; + +// columnIssuesURL builds the appropriate "list issues for column" API +// path for the current page scope. Server-side these endpoints all +// return the same JSON shape; the frontend just needs the right base. +function columnIssuesURL(board: HTMLElement, columnID: number): string | null { + const projectID = board.getAttribute('data-project-id'); + const scope = board.getAttribute('data-project-scope'); + const owner = board.getAttribute('data-project-owner'); + const repo = board.getAttribute('data-project-repo'); + const {appSubUrl} = window.config; + if (!projectID || !owner) return null; + if (scope === 'repo' && repo) { + return `${appSubUrl}/api/v1/repos/${owner}/${repo}/projects/${projectID}/columns/${columnID}/issues`; + } + if (scope === 'org') { + return `${appSubUrl}/api/v1/orgs/${owner}/projects/${projectID}/columns/${columnID}/issues`; + } + return `${appSubUrl}/api/v1/users/${owner}/projects/${projectID}/columns/${columnID}/issues`; +} + +function updateColumnCount(columnEl: HTMLElement): void { + const cards = columnEl.querySelectorAll('.issue-card').length; + const badge = columnEl.querySelector('.project-column-issue-count'); + if (badge) badge.textContent = String(cards); +} + +function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void { + if (payload.session_tag && payload.session_tag === sessionTag) return; + const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`); + if (!card) { + // 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); + return; + } + const target = board.querySelector(`#board_${payload.to_column_id}`); + if (!target) return; + const fromColumn = card.parentElement; + target.appendChild(card); + if (fromColumn instanceof HTMLElement) { + const fromColumnEl = fromColumn.closest('.project-column'); + if (fromColumnEl) updateColumnCount(fromColumnEl); + } + const toColumnEl = target.closest('.project-column'); + if (toColumnEl) updateColumnCount(toColumnEl); +} + +async function refetchColumn(board: HTMLElement, columnID: number): Promise { + const url = columnIssuesURL(board, columnID); + if (!url) return; + try { + const resp = await GET(url); + if (!resp.ok) return; + // Response shape: list of API issues; we don't have a templated + // card render available client-side, so we just refresh the + // column count badge here. The DOM-level reorder/insert is + // delivered by the matching CardMoved/CardUnlinked events. + const issues = await resp.json(); + const target = board.querySelector(`#board_${columnID}`); + if (!target) return; + const colEl = target.closest('.project-column'); + if (colEl) { + const badge = colEl.querySelector('.project-column-issue-count'); + if (badge) badge.textContent = String(Array.isArray(issues) ? issues.length : 0); + } + } catch (error) { + console.error(error); + } +} + +function handleCardLinked(board: HTMLElement, payload: CardLinkedPayload): void { + if (payload.session_tag && payload.session_tag === sessionTag) return; + refetchColumn(board, payload.column_id); // no await +} + +function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): void { + if (payload.session_tag && payload.session_tag === sessionTag) return; + const card = board.querySelector(`.issue-card[data-issue="${payload.issue_id}"]`); + if (!card) return; + const colEl = card.closest('.project-column'); + card.remove(); + if (colEl) updateColumnCount(colEl); +} + +function handleColumnCreated(): void { + // Rare event; reload is cheap and avoids client-side template duplication. + window.location.reload(); +} + +function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload): void { + const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); + if (!colEl) return; + const titleEl = colEl.querySelector('.project-column-title-text'); + if (titleEl) titleEl.textContent = payload.title; + if (payload.color) { + const textColor = contrastColor(payload.color); + colEl.style.setProperty('background', payload.color, 'important'); + colEl.style.setProperty('color', textColor, 'important'); + queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.color = textColor); + } else { + colEl.style.removeProperty('background'); + colEl.style.removeProperty('color'); + queryElemChildren(colEl, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color')); + } +} + +function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void { + const colEl = board.querySelector(`.project-column[data-id="${payload.column_id}"]`); + if (colEl) colEl.remove(); +} + +function handleColumnReordered(board: HTMLElement, payload: ColumnReorderedPayload): void { + // Sort the columns array by the new sorting value, then re-attach + // each column element in that order. appendChild on an existing + // node moves it rather than cloning, so the result is an in-place + // reorder. + const order = [...payload.columns].sort((a, b) => a.sorting - b.sorting); + for (const entry of order) { + const el = board.querySelector(`.project-column[data-id="${entry.column_id}"]`); + if (el) board.appendChild(el); + } +} + +function handleProjectUpdated(payload: ProjectUpdatedPayload): void { + const header = document.querySelector('.project-header h2'); + if (header) header.textContent = payload.title; + const desc = document.querySelector('.project-description .render-content'); + if (desc) desc.textContent = payload.description; +} + +function handleProjectDeleted(): void { + // Best-effort: navigate up one path segment from the current URL. + // 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. + const parts = window.location.pathname.split('/'); + if (parts.length > 1) { + parts.pop(); + window.location.href = parts.join('/') || '/'; + } else { + window.location.href = '/'; + } +} + +// dispatchProjectEvent picks the right handler for an SSE payload. +// The backend uses one event name per project but disambiguates event +// types by payload shape; we sniff discriminating fields here. Order +// matters: the more specific shapes are checked first. +function dispatchProjectEvent(board: HTMLElement, payload: any): void { + if ('from_column_id' in payload && 'to_column_id' in payload) { + handleCardMoved(board, payload as CardMovedPayload); + } else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) { + handleCardLinked(board, payload as CardLinkedPayload); + } else if ('issue_id' in payload && !('column_id' in payload)) { + handleCardUnlinked(board, payload as CardUnlinkedPayload); + } else if ('columns' in payload) { + handleColumnReordered(board, payload as ColumnReorderedPayload); + } else if ('column_id' in payload && 'title' in payload) { + handleColumnUpdated(board, payload as ColumnUpdatedPayload); + if ('is_default' in payload) handleColumnCreated(); + } else if ('column_id' in payload) { + handleColumnDeleted(board, payload as ColumnDeletedPayload); + } else if ('title' in payload && 'card_type' in payload) { + handleProjectUpdated(payload as ProjectUpdatedPayload); + } else if ('project_id' in payload && Object.keys(payload).length <= 2) { + handleProjectDeleted(); + } +} + +function initRepoProjectSSE(elProjectsView: HTMLElement): void { + const board = elProjectsView.querySelector('#project-board'); + if (!board) return; + const projectID = board.getAttribute('data-project-id'); + if (!projectID) return; + if (!window.EventSource || !window.SharedWorker) return; + + ensureSessionTag(); + + const eventName = `project-board.${projectID}`; + let worker: UserEventsSharedWorker; + try { + worker = new UserEventsSharedWorker('project-board-worker'); + } catch (error) { + console.error('project board SSE: failed to start worker', error); + return; + } + + worker.addMessageEventListener((event: MessageEvent) => { + if (!event.data || event.data.type !== eventName) return; + let payload: any; + try { + payload = JSON.parse(event.data.data); + } catch (error) { + console.error('project board SSE: malformed payload', error, event.data); + return; + } + dispatchProjectEvent(board, payload); + }); + worker.startPort(); + + // Subscribe to the per-project event name on top of the worker's + // default listener set so the SharedWorker forwards us the events. + worker.sharedWorker.port.postMessage({type: 'listen', eventType: eventName}); +} + export function initRepoProjectsView(): void { registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => { initRepoProjectToggleFullScreen(elProjectsView); + initRepoProjectSSE(elProjectsView); const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]'); if (!writableProjectBoard) return;