c19ecab35d
Closing or reopening an issue did not notify project boards that carry it as a card, so board tabs showed stale state until a manual reload. CloseIssue/ReopenIssue only published milestone events and the issue timeline notification — nothing project-scoped. Add a CardStateChanged project event, published per linked project from CloseIssue/ReopenIssue (best-effort; never fails the state change). The board frontend flips the issue-state octicon in place and refetches the affected column so state-filtered boards and counts stay correct. The dispatch check precedes the CardUnlinked branch so a close/reopen is not mistaken for a card removal. Also switch a pre-existing String#match to RegExp#exec in the same file to keep it lint-clean. Closes #19
529 lines
22 KiB
TypeScript
529 lines
22 KiB
TypeScript
import {contrastColor} from '../utils/color.ts';
|
|
import {createSortable} from '../modules/sortable.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';
|
|
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 = /#\d+/.exec(idx ?? '');
|
|
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';
|
|
|
|
// 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!;
|
|
const count = parent.querySelectorAll('.issue-card').length;
|
|
parent.querySelector('.project-column-issue-count')!.textContent = String(count);
|
|
}
|
|
|
|
async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<void> {
|
|
const columnCards = to.querySelectorAll('.issue-card');
|
|
updateIssueCount(from);
|
|
updateIssueCount(to);
|
|
|
|
const columnSorting = {
|
|
issues: Array.from(columnCards, (card, i) => ({
|
|
issueID: parseInt(card.getAttribute('data-issue')!),
|
|
sorting: i,
|
|
})),
|
|
};
|
|
|
|
try {
|
|
await POST(`${to.getAttribute('data-url')}/move`, {
|
|
data: columnSorting,
|
|
headers: withSessionTag(undefined),
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
if (oldIndex !== undefined) {
|
|
from.insertBefore(item, from.children[oldIndex]);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function initRepoProjectSortable(): Promise<void> {
|
|
// the HTML layout is: #project-board.board > .project-column .cards > .issue-card
|
|
const mainBoard = document.querySelector<HTMLElement>('#project-board')!;
|
|
let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
|
|
createSortable(mainBoard, {
|
|
group: 'project-column',
|
|
draggable: '.project-column',
|
|
handle: '.project-column-header',
|
|
delayOnTouchOnly: true,
|
|
delay: 500,
|
|
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
|
|
boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
|
|
|
|
const columnSorting = {
|
|
columns: Array.from(boardColumns, (column, i) => ({
|
|
columnID: parseInt(column.getAttribute('data-id')!),
|
|
sorting: i,
|
|
})),
|
|
};
|
|
|
|
try {
|
|
await POST(mainBoard.getAttribute('data-url')!, {
|
|
data: columnSorting,
|
|
headers: withSessionTag(undefined),
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
},
|
|
});
|
|
|
|
for (const boardColumn of boardColumns) {
|
|
const boardCardList = boardColumn.querySelector<HTMLElement>('.cards')!;
|
|
createSortable(boardCardList, {
|
|
group: 'shared',
|
|
onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
|
|
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
|
|
delayOnTouchOnly: true,
|
|
delay: 500,
|
|
});
|
|
}
|
|
}
|
|
|
|
function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
|
const elModal = document.querySelector<HTMLElement>('.ui.modal#project-column-modal-edit')!;
|
|
const elForm = elModal.querySelector<HTMLFormElement>('form')!;
|
|
|
|
const elColumnId = elForm.querySelector<HTMLInputElement>('input[name="id"]')!;
|
|
const elColumnTitle = elForm.querySelector<HTMLInputElement>('input[name="title"]')!;
|
|
const elColumnColor = elForm.querySelector<HTMLInputElement>('input[name="color"]')!;
|
|
|
|
const attrDataColumnId = 'data-modal-project-column-id';
|
|
const attrDataColumnTitle = 'data-modal-project-column-title-input';
|
|
const attrDataColumnColor = 'data-modal-project-column-color-input';
|
|
|
|
// the "new" button is not in project board, so need to query from document
|
|
queryElems(document, '.show-project-column-modal-edit', (el) => {
|
|
el.addEventListener('click', () => {
|
|
elColumnId.value = el.getAttribute(attrDataColumnId)!;
|
|
elColumnTitle.value = el.getAttribute(attrDataColumnTitle)!;
|
|
elColumnColor.value = el.getAttribute(attrDataColumnColor)!;
|
|
elColumnColor.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
|
|
});
|
|
});
|
|
|
|
elForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const columnId = elColumnId.value;
|
|
const actionBaseLink = elForm.getAttribute('data-action-base-link');
|
|
|
|
const formData = new FormData(elForm);
|
|
const formLink = columnId ? `${actionBaseLink}/${columnId}` : `${actionBaseLink}/columns/new`;
|
|
const formMethod = columnId ? 'PUT' : 'POST';
|
|
|
|
try {
|
|
elForm.classList.add('is-loading');
|
|
await request(formLink, {method: formMethod, data: formData, headers: withSessionTag(undefined)});
|
|
if (!columnId) {
|
|
window.location.reload(); // newly added column, need to reload the page
|
|
return;
|
|
}
|
|
|
|
// update the newly saved column title and color in the project board (to avoid reload)
|
|
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`)!;
|
|
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
|
|
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
|
|
|
|
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`)!;
|
|
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
|
|
elBoardColumnTitle.textContent = elColumnTitle.value;
|
|
if (elColumnColor.value) {
|
|
const textColor = contrastColor(elColumnColor.value);
|
|
elBoardColumn.style.setProperty('background', elColumnColor.value, 'important');
|
|
elBoardColumn.style.setProperty('color', textColor, 'important');
|
|
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
|
|
} else {
|
|
elBoardColumn.style.removeProperty('background');
|
|
elBoardColumn.style.removeProperty('color');
|
|
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
|
|
}
|
|
|
|
hideFomanticModal(elModal);
|
|
} finally {
|
|
elForm.classList.remove('is-loading');
|
|
}
|
|
});
|
|
}
|
|
|
|
function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
|
|
const enterFullscreenBtn = document.querySelector('.screen-full');
|
|
const exitFullscreenBtn = document.querySelector('.screen-normal');
|
|
if (!enterFullscreenBtn || !exitFullscreenBtn) return;
|
|
|
|
const settingKey = 'projects-view-options';
|
|
type ProjectsViewOptions = {
|
|
fullScreen: boolean;
|
|
};
|
|
const opts = localUserSettings.getJsonObject<ProjectsViewOptions>(settingKey, {fullScreen: false});
|
|
const toggleFullscreenState = (isFullScreen: boolean) => {
|
|
toggleFullScreen(elProjectsView, isFullScreen);
|
|
toggleElem(enterFullscreenBtn, !isFullScreen);
|
|
toggleElem(exitFullscreenBtn, isFullScreen);
|
|
|
|
opts.fullScreen = isFullScreen;
|
|
localUserSettings.setJsonObject(settingKey, opts);
|
|
};
|
|
|
|
enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true));
|
|
exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false));
|
|
if (opts.fullScreen) {
|
|
// a temporary solution to remember the full screen state, not perfect,
|
|
// just make UX better than before, especially for users who need to change the label filter frequently and want to keep full screen mode.
|
|
toggleFullscreenState(true);
|
|
}
|
|
}
|
|
|
|
// 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 CardStateChangedPayload = EventPayloadBase & {
|
|
project_id: number;
|
|
issue_id: number;
|
|
is_closed: boolean;
|
|
};
|
|
|
|
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);
|
|
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;
|
|
target.append(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);
|
|
showInfoToast(`${ref} → ${columnName(board, payload.to_column_id)}`);
|
|
}
|
|
|
|
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
|
|
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 handleCardStateChanged(board: HTMLElement, payload: CardStateChangedPayload): 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;
|
|
// Flip the issue state octicon in place (matches templates/shared/issueicon.tmpl).
|
|
// PR cards carry merged/draft variants we don't recompute here; the column
|
|
// refetch below keeps state-filtered boards and counts correct regardless.
|
|
const icon = card.querySelector<SVGElement>('.issue-card-icon svg');
|
|
if (icon && !icon.classList.contains('octicon-git-pull-request')) {
|
|
icon.classList.remove('octicon-issue-opened', 'octicon-issue-closed', 'tw-text-green', 'tw-text-red');
|
|
icon.classList.add(
|
|
payload.is_closed ? 'octicon-issue-closed' : 'octicon-issue-opened',
|
|
payload.is_closed ? 'tw-text-red' : 'tw-text-green',
|
|
);
|
|
}
|
|
const ref = issueRef(card, payload.issue_id);
|
|
// The card's containing column is `#board_{columnID}` (its direct parent).
|
|
const parent = card.parentElement;
|
|
if (parent instanceof HTMLElement && parent.id.startsWith('board_')) {
|
|
refetchColumn(board, Number(parent.id.slice('board_'.length)));
|
|
}
|
|
showInfoToast(`${ref} ${payload.is_closed ? 'closed' : 'reopened'}`);
|
|
}
|
|
|
|
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');
|
|
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');
|
|
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) 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 {
|
|
// 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 = Array.from(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.append(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.
|
|
// 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('/');
|
|
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.
|
|
// 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 ('issue_id' in payload && 'is_closed' in payload) {
|
|
// CardStateChanged: must precede the CardUnlinked branch below, whose
|
|
// "issue_id and no column_id" shape would otherwise swallow it and
|
|
// wrongly remove the card on a close/reopen.
|
|
handleCardStateChanged(board, payload as CardStateChangedPayload);
|
|
} else 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;
|
|
|
|
initRepoProjectSortable(); // no await
|
|
initRepoProjectColumnEdit(writableProjectBoard);
|
|
});
|
|
}
|