feat(milestone): frontend SSE subscribe + progress-bar DOM patch
This commit is contained in:
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||||
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100"></progress>
|
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100" data-milestone-id="{{.Milestone.ID}}" data-repo-id="{{.Repository.ID}}"></progress>
|
||||||
<div class="flex-text-block tw-gap-4">
|
<div class="flex-text-block tw-gap-4">
|
||||||
<div class="flex-text-inline">
|
<div class="flex-text-inline">
|
||||||
{{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}}
|
{{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div>{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
|
<div class="milestone-completeness-pct">{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness}}</div>
|
||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
||||||
{{svg "octicon-clock"}}
|
{{svg "octicon-clock"}}
|
||||||
|
|||||||
@@ -15,16 +15,16 @@
|
|||||||
{{template "repo/issue/filters" .}}
|
{{template "repo/issue/filters" .}}
|
||||||
|
|
||||||
<!-- milestone list -->
|
<!-- milestone list -->
|
||||||
<div class="milestone-list">
|
<div class="milestone-list" data-repo-id="{{$.Repository.ID}}">
|
||||||
{{range .Milestones}}
|
{{range .Milestones}}
|
||||||
<li class="milestone-card">
|
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{$.Repository.ID}}">
|
||||||
<div class="milestone-header">
|
<div class="milestone-header">
|
||||||
<h3 class="flex-text-block tw-m-0">
|
<h3 class="flex-text-block tw-m-0">
|
||||||
{{svg "octicon-milestone" 16}}
|
{{svg "octicon-milestone" 16}}
|
||||||
<a class="muted" href="{{$.RepoLink}}/milestone/{{.ID}}">{{.Name}}</a>
|
<a class="muted" href="{{$.RepoLink}}/milestone/{{.ID}}">{{.Name}}</a>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="tw-flex tw-items-center">
|
<div class="tw-flex tw-items-center">
|
||||||
<span class="tw-mr-2">{{.Completeness}}%</span>
|
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
|
||||||
<progress value="{{.Completeness}}" max="100"></progress>
|
<progress value="{{.Completeness}}" max="100"></progress>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{svg "octicon-issue-opened" 14}}
|
{{svg "octicon-issue-opened" 14}}
|
||||||
{{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}}
|
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span> {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{svg "octicon-check" 14}}
|
{{svg "octicon-check" 14}}
|
||||||
{{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span> {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||||
</div>
|
</div>
|
||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="milestone-list">
|
<div class="milestone-list">
|
||||||
{{range .Milestones}}
|
{{range .Milestones}}
|
||||||
<li class="milestone-card">
|
<li class="milestone-card" data-milestone-id="{{.ID}}" data-repo-id="{{.Repo.ID}}">
|
||||||
<div class="milestone-header">
|
<div class="milestone-header">
|
||||||
<h3 class="flex-text-block tw-m-0">
|
<h3 class="flex-text-block tw-m-0">
|
||||||
<span class="ui large label">
|
<span class="ui large label">
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
<a class="muted" href="{{.Repo.Link}}/milestone/{{.ID}}">{{.Name}}</a>
|
<a class="muted" href="{{.Repo.Link}}/milestone/{{.ID}}">{{.Name}}</a>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="tw-flex tw-items-center">
|
<div class="tw-flex tw-items-center">
|
||||||
<span class="tw-mr-2">{{.Completeness}}%</span>
|
<span class="tw-mr-2"><span class="milestone-completeness-pct">{{.Completeness}}</span>%</span>
|
||||||
<progress value="{{.Completeness}}" max="100"></progress>
|
<progress value="{{.Completeness}}" max="100"></progress>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,11 +91,11 @@
|
|||||||
<div class="group">
|
<div class="group">
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{svg "octicon-issue-opened" 14}}
|
{{svg "octicon-issue-opened" 14}}
|
||||||
{{ctx.Locale.PrettyNumber .NumOpenIssues}} {{ctx.Locale.Tr "repo.issues.open_title"}}
|
<span class="milestone-open-count">{{ctx.Locale.PrettyNumber .NumOpenIssues}}</span> {{ctx.Locale.Tr "repo.issues.open_title"}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{svg "octicon-check" 14}}
|
{{svg "octicon-check" 14}}
|
||||||
{{ctx.Locale.PrettyNumber .NumClosedIssues}} {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
<span class="milestone-closed-count">{{ctx.Locale.PrettyNumber .NumClosedIssues}}</span> {{ctx.Locale.Tr "repo.issues.closed_title"}}
|
||||||
</div>
|
</div>
|
||||||
{{if .TotalTrackedTime}}
|
{{if .TotalTrackedTime}}
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import {createApp} from 'vue';
|
import {createApp} from 'vue';
|
||||||
import DashboardRepoList from '../components/DashboardRepoList.vue';
|
import DashboardRepoList from '../components/DashboardRepoList.vue';
|
||||||
|
import {initRepoMilestoneListSSE} from './repo-milestone-sse.ts';
|
||||||
|
|
||||||
export function initDashboardRepoList() {
|
export function initDashboardRepoList() {
|
||||||
const el = document.querySelector('#dashboard-repo-list');
|
const el = document.querySelector('#dashboard-repo-list');
|
||||||
if (el) {
|
if (el) {
|
||||||
createApp(DashboardRepoList).mount(el);
|
createApp(DashboardRepoList).mount(el);
|
||||||
}
|
}
|
||||||
|
// The dashboard milestones page lists milestones across many repos;
|
||||||
|
// subscribe to live progress for each. subscribeRepos is guarded so
|
||||||
|
// this is a no-op if repo-legacy already wired it on the same page.
|
||||||
|
if (document.querySelector('.page-content.dashboard.milestones li.milestone-card[data-repo-id]')) {
|
||||||
|
initRepoMilestoneListSSE();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {initRepoSettings} from './repo-settings.ts';
|
|||||||
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
|
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
|
||||||
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
|
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
|
||||||
import {initRepoMilestone} from './repo-milestone.ts';
|
import {initRepoMilestone} from './repo-milestone.ts';
|
||||||
|
import {initRepoMilestoneListSSE, initRepoMilestoneSingleSSE} from './repo-milestone-sse.ts';
|
||||||
import {initRepoNew} from './repo-new.ts';
|
import {initRepoNew} from './repo-new.ts';
|
||||||
import {createApp} from 'vue';
|
import {createApp} from 'vue';
|
||||||
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
|
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
|
||||||
@@ -50,6 +51,12 @@ export function initRepository() {
|
|||||||
// Labels
|
// Labels
|
||||||
initCompLabelEdit('.page-content.repository.labels');
|
initCompLabelEdit('.page-content.repository.labels');
|
||||||
initRepoMilestone();
|
initRepoMilestone();
|
||||||
|
if (pageContent.matches('.page-content.repository.milestones')) {
|
||||||
|
initRepoMilestoneListSSE();
|
||||||
|
}
|
||||||
|
if (pageContent.matches('.page-content.repository.milestone-issue-list')) {
|
||||||
|
initRepoMilestoneSingleSSE();
|
||||||
|
}
|
||||||
initRepoNew();
|
initRepoNew();
|
||||||
|
|
||||||
initRepoCloneButtons();
|
initRepoCloneButtons();
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||||
|
|
||||||
|
// sessionTag is generated once per page load. The mutation requests on
|
||||||
|
// milestone pages (close/open/delete/edit) flow through the existing
|
||||||
|
// fetch helpers which attach the X-Session-Tag header; the backend
|
||||||
|
// echoes it back inside SSE payloads so the originating tab can suppress
|
||||||
|
// its own echo. We only need the read side here: skip any event whose
|
||||||
|
// session_tag matches ours.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MilestoneProgressPayload = {
|
||||||
|
repo_id: number;
|
||||||
|
milestone_id: number;
|
||||||
|
open_issues: number;
|
||||||
|
closed_issues: number;
|
||||||
|
completeness: number;
|
||||||
|
session_tag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MilestoneDeletedPayload = {
|
||||||
|
repo_id: number;
|
||||||
|
milestone_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isProgressPayload(p: any): p is MilestoneProgressPayload {
|
||||||
|
return p && typeof p.completeness === 'number' && 'open_issues' in p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchMilestoneCard updates every progress-bar / counter site for a
|
||||||
|
// single milestone id, covering both the list-card layout (milestones
|
||||||
|
// list, dashboard) and the single-milestone big progress bar.
|
||||||
|
function patchMilestoneCard(payload: MilestoneProgressPayload): void {
|
||||||
|
const selector = `[data-milestone-id="${payload.milestone_id}"]`;
|
||||||
|
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
||||||
|
// The element itself may be a <progress> (single view) or a card
|
||||||
|
// <li> containing a <progress> (list views).
|
||||||
|
const progressEls = el instanceof HTMLProgressElement
|
||||||
|
? [el]
|
||||||
|
: Array.from(el.querySelectorAll<HTMLProgressElement>('progress'));
|
||||||
|
for (const pe of progressEls) {
|
||||||
|
pe.value = payload.completeness;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope: ParentNode = el instanceof HTMLProgressElement ? document : el;
|
||||||
|
|
||||||
|
for (const pct of scope.querySelectorAll<HTMLElement>('.milestone-completeness-pct')) {
|
||||||
|
// The list cards render just the number; the single-milestone
|
||||||
|
// view renders an i18n HTML fragment ("<strong>N%</strong>
|
||||||
|
// Completed"). Detect which by whether the node already holds a
|
||||||
|
// <strong> child.
|
||||||
|
const strong = pct.querySelector('strong');
|
||||||
|
if (strong) {
|
||||||
|
strong.textContent = `${payload.completeness}%`;
|
||||||
|
} else {
|
||||||
|
pct.textContent = String(payload.completeness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const oc of scope.querySelectorAll<HTMLElement>('.milestone-open-count')) {
|
||||||
|
oc.textContent = String(payload.open_issues);
|
||||||
|
}
|
||||||
|
for (const cc of scope.querySelectorAll<HTMLElement>('.milestone-closed-count')) {
|
||||||
|
cc.textContent = String(payload.closed_issues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void {
|
||||||
|
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${payload.milestone_id}"]`);
|
||||||
|
if (card) {
|
||||||
|
card.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Single-milestone view: the milestone we are looking at is gone.
|
||||||
|
const single = document.querySelector<HTMLElement>(`progress[data-milestone-id="${payload.milestone_id}"]`);
|
||||||
|
if (single) {
|
||||||
|
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 = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchMilestoneEvent(payload: any): void {
|
||||||
|
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||||
|
if (isProgressPayload(payload)) {
|
||||||
|
patchMilestoneCard(payload);
|
||||||
|
} else if ('milestone_id' in payload && 'repo_id' in payload) {
|
||||||
|
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribed guards against a double subscription if more than one init
|
||||||
|
// entry point matches the same page (e.g. the dashboard milestones page
|
||||||
|
// is wired both from repo-legacy and dashboard).
|
||||||
|
let subscribed = false;
|
||||||
|
|
||||||
|
// subscribeRepos opens one SharedWorker subscription per distinct repo
|
||||||
|
// id and dispatches every "repo-milestones.{repoID}" event by payload.
|
||||||
|
function subscribeRepos(repoIDs: Set<string>): void {
|
||||||
|
if (subscribed) return;
|
||||||
|
if (!repoIDs.size) return;
|
||||||
|
if (!window.EventSource || !window.SharedWorker) return;
|
||||||
|
subscribed = true;
|
||||||
|
|
||||||
|
ensureSessionTag();
|
||||||
|
|
||||||
|
let worker: UserEventsSharedWorker;
|
||||||
|
try {
|
||||||
|
worker = new UserEventsSharedWorker('repo-milestone-worker');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('milestone SSE: failed to start worker', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventNames = new Set<string>();
|
||||||
|
for (const repoID of repoIDs) {
|
||||||
|
eventNames.add(`repo-milestones.${repoID}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.addMessageEventListener((event: MessageEvent) => {
|
||||||
|
if (!event.data || !eventNames.has(event.data.type)) return;
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(event.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('milestone SSE: malformed payload', error, event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatchMilestoneEvent(payload);
|
||||||
|
});
|
||||||
|
worker.startPort();
|
||||||
|
|
||||||
|
for (const name of eventNames) {
|
||||||
|
worker.sharedWorker.port.postMessage({type: 'listen', eventType: name});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initRepoMilestoneListSSE wires the milestone list page and the
|
||||||
|
// dashboard milestones page: collect every distinct data-repo-id present
|
||||||
|
// on the cards (the dashboard mixes many repos) and subscribe to each.
|
||||||
|
export function initRepoMilestoneListSSE(): void {
|
||||||
|
const cards = document.querySelectorAll<HTMLElement>('li.milestone-card[data-repo-id]');
|
||||||
|
if (!cards.length) return;
|
||||||
|
const repoIDs = new Set<string>();
|
||||||
|
for (const card of cards) {
|
||||||
|
const id = card.getAttribute('data-repo-id');
|
||||||
|
if (id) repoIDs.add(id);
|
||||||
|
}
|
||||||
|
subscribeRepos(repoIDs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// initRepoMilestoneSingleSSE wires the single-milestone issue list view.
|
||||||
|
export function initRepoMilestoneSingleSSE(): void {
|
||||||
|
const progress = document.querySelector<HTMLElement>('progress[data-milestone-id][data-repo-id]');
|
||||||
|
if (!progress) return;
|
||||||
|
const repoID = progress.getAttribute('data-repo-id');
|
||||||
|
if (!repoID) return;
|
||||||
|
subscribeRepos(new Set([repoID]));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user