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).
This commit is contained in:
@@ -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}}
|
||||
|
||||
<div class="ui container fluid padded projects-view" data-global-init="initRepoProjectsView">
|
||||
<div class="ui container flex-text-block project-header">
|
||||
@@ -77,7 +86,13 @@
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}" data-project-board-writable="{{$canWriteProject}}" {{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
|
||||
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}"
|
||||
data-project-board-writable="{{$canWriteProject}}"
|
||||
data-project-id="{{.Project.ID}}"
|
||||
data-project-scope="{{$projectScope}}"
|
||||
data-project-owner="{{$projectOwnerName}}"
|
||||
data-project-repo="{{$projectRepoName}}"
|
||||
{{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
|
||||
{{range .Columns}}
|
||||
<div class="project-column" {{if .Color}}style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
|
||||
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
|
||||
|
||||
@@ -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<voi
|
||||
try {
|
||||
await POST(`${to.getAttribute('data-url')}/move`, {
|
||||
data: columnSorting,
|
||||
headers: withSessionTag(undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -61,6 +87,7 @@ async function initRepoProjectSortable(): Promise<void> {
|
||||
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<HTMLElement>(`.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<HTMLElement>(`#board_${payload.to_column_id}`);
|
||||
if (!target) return;
|
||||
const fromColumn = card.parentElement;
|
||||
target.appendChild(card);
|
||||
if (fromColumn instanceof HTMLElement) {
|
||||
const fromColumnEl = fromColumn.closest<HTMLElement>('.project-column');
|
||||
if (fromColumnEl) updateColumnCount(fromColumnEl);
|
||||
}
|
||||
const toColumnEl = target.closest<HTMLElement>('.project-column');
|
||||
if (toColumnEl) updateColumnCount(toColumnEl);
|
||||
}
|
||||
|
||||
async function refetchColumn(board: HTMLElement, columnID: number): Promise<void> {
|
||||
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<HTMLElement>(`#board_${columnID}`);
|
||||
if (!target) return;
|
||||
const colEl = target.closest<HTMLElement>('.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<HTMLElement>(`.issue-card[data-issue="${payload.issue_id}"]`);
|
||||
if (!card) return;
|
||||
const colEl = card.closest<HTMLElement>('.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<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
|
||||
if (!colEl) return;
|
||||
const titleEl = colEl.querySelector<HTMLElement>('.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<HTMLElement>(`.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<HTMLElement>(`.project-column[data-id="${entry.column_id}"]`);
|
||||
if (el) board.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProjectUpdated(payload: ProjectUpdatedPayload): void {
|
||||
const header = document.querySelector<HTMLElement>('.project-header h2');
|
||||
if (header) header.textContent = payload.title;
|
||||
const desc = document.querySelector<HTMLElement>('.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<HTMLElement>('#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;
|
||||
|
||||
Reference in New Issue
Block a user