fix(project): push SSE update when an issue on a board is closed/reopened
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
This commit is contained in:
@@ -12,8 +12,27 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
milestone_events "code.gitea.io/gitea/services/milestone_events"
|
milestone_events "code.gitea.io/gitea/services/milestone_events"
|
||||||
notify_service "code.gitea.io/gitea/services/notify"
|
notify_service "code.gitea.io/gitea/services/notify"
|
||||||
|
project_events "code.gitea.io/gitea/services/project_events"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// publishProjectCardStateChanged notifies every project board the issue is a
|
||||||
|
// card on that its open/closed state changed, so subscribed board tabs can
|
||||||
|
// re-render the card live instead of showing stale state until reload.
|
||||||
|
// Best-effort: a failure here must not fail the close/reopen operation.
|
||||||
|
func publishProjectCardStateChanged(ctx context.Context, issue *issues_model.Issue, isClosed bool) {
|
||||||
|
if err := issue.LoadProjects(ctx); err != nil {
|
||||||
|
log.Error("LoadProjects for issue[%d]: %v", issue.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range issue.Projects {
|
||||||
|
project_events.PublishCardStateChanged(ctx, project_events.CardStateChanged{
|
||||||
|
ProjectID: p.ID,
|
||||||
|
IssueID: issue.ID,
|
||||||
|
IsClosed: isClosed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CloseIssue close an issue.
|
// CloseIssue close an issue.
|
||||||
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
|
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
|
||||||
var comment *issues_model.Comment
|
var comment *issues_model.Comment
|
||||||
@@ -40,6 +59,7 @@ func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model
|
|||||||
}
|
}
|
||||||
|
|
||||||
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
|
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, true)
|
||||||
|
publishProjectCardStateChanged(ctx, issue, true)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -57,6 +77,7 @@ func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
|
notify_service.IssueChangeStatus(ctx, doer, commitID, issue, comment, false)
|
||||||
|
publishProjectCardStateChanged(ctx, issue, false)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ type CardUnlinked struct {
|
|||||||
SessionTag string `json:"session_tag,omitempty"`
|
SessionTag string `json:"session_tag,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CardStateChanged is emitted when an issue's open/closed state changes
|
||||||
|
// while it is a card on the project. It lets boards re-render the card's
|
||||||
|
// state live (and is the hook state-filtered boards use to correct their
|
||||||
|
// column counts) without a full page reload.
|
||||||
|
type CardStateChanged struct {
|
||||||
|
ProjectID int64 `json:"project_id"`
|
||||||
|
IssueID int64 `json:"issue_id"`
|
||||||
|
IsClosed bool `json:"is_closed"`
|
||||||
|
SessionTag string `json:"session_tag,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ColumnCreated is emitted when a new column is added to a project.
|
// ColumnCreated is emitted when a new column is added to a project.
|
||||||
type ColumnCreated struct {
|
type ColumnCreated struct {
|
||||||
ProjectID int64 `json:"project_id"`
|
ProjectID int64 `json:"project_id"`
|
||||||
@@ -277,6 +288,14 @@ func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) {
|
|||||||
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishCardStateChanged fans out a CardStateChanged event for the given payload.
|
||||||
|
func PublishCardStateChanged(ctx context.Context, payload CardStateChanged) {
|
||||||
|
if payload.SessionTag == "" {
|
||||||
|
payload.SessionTag = SessionTagFromContext(ctx)
|
||||||
|
}
|
||||||
|
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||||
|
}
|
||||||
|
|
||||||
// PublishColumnCreated fans out a ColumnCreated event for the given payload.
|
// PublishColumnCreated fans out a ColumnCreated event for the given payload.
|
||||||
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
|
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
|
||||||
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ func TestPublishHelpers_NameAndPayload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantData: CardUnlinked{ProjectID: 12, IssueID: 8},
|
wantData: CardUnlinked{ProjectID: 12, IssueID: 8},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "card.state_changed",
|
||||||
|
wantName: "project-board.12",
|
||||||
|
invoke: func(ctx context.Context) {
|
||||||
|
PublishCardStateChanged(ctx, CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true})
|
||||||
|
},
|
||||||
|
wantData: CardStateChanged{ProjectID: 12, IssueID: 8, IsClosed: true},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "column.created",
|
name: "column.created",
|
||||||
wantName: "project-board.13",
|
wantName: "project-board.13",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {showInfoToast, showWarningToast} from '../modules/toast.ts';
|
|||||||
// rendered "#index" anchor text and falling back to the internal id.
|
// rendered "#index" anchor text and falling back to the internal id.
|
||||||
function issueRef(card: HTMLElement | null, issueID: number): string {
|
function issueRef(card: HTMLElement | null, issueID: number): string {
|
||||||
const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim();
|
const idx = card?.querySelector('.issue-card-title, .ref-issue, a[href*="/issues/"]')?.textContent?.trim();
|
||||||
const m = idx?.match(/#\d+/);
|
const m = /#\d+/.exec(idx ?? '');
|
||||||
return m ? m[0] : `#${issueID}`;
|
return m ? m[0] : `#${issueID}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +237,12 @@ type CardUnlinkedPayload = EventPayloadBase & {
|
|||||||
issue_id: number;
|
issue_id: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CardStateChangedPayload = EventPayloadBase & {
|
||||||
|
project_id: number;
|
||||||
|
issue_id: number;
|
||||||
|
is_closed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type ColumnUpdatedPayload = {
|
type ColumnUpdatedPayload = {
|
||||||
project_id: number;
|
project_id: number;
|
||||||
column_id: number;
|
column_id: number;
|
||||||
@@ -352,6 +358,30 @@ function handleCardUnlinked(board: HTMLElement, payload: CardUnlinkedPayload): v
|
|||||||
showInfoToast(`${ref} removed from board`);
|
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 {
|
function handleColumnCreated(): void {
|
||||||
// Rare event; reload is cheap and avoids client-side template duplication.
|
// Rare event; reload is cheap and avoids client-side template duplication.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -423,7 +453,12 @@ function handleProjectDeleted(): void {
|
|||||||
// types by payload shape; we sniff discriminating fields here. Order
|
// types by payload shape; we sniff discriminating fields here. Order
|
||||||
// matters: the more specific shapes are checked first.
|
// matters: the more specific shapes are checked first.
|
||||||
function dispatchProjectEvent(board: HTMLElement, payload: any): void {
|
function dispatchProjectEvent(board: HTMLElement, payload: any): void {
|
||||||
if ('from_column_id' in payload && 'to_column_id' in payload) {
|
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);
|
handleCardMoved(board, payload as CardMovedPayload);
|
||||||
} else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) {
|
} else if ('column_id' in payload && 'issue_id' in payload && 'project_id' in payload) {
|
||||||
handleCardLinked(board, payload as CardLinkedPayload);
|
handleCardLinked(board, payload as CardLinkedPayload);
|
||||||
|
|||||||
Reference in New Issue
Block a user