Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c45ea82fd1 | |||
| c19ecab35d | |||
| ad46f6cde8 | |||
| 078459c497 | |||
| d4de99f96b | |||
| 1cd81ff925 |
@@ -12,8 +12,27 @@ import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
milestone_events "code.gitea.io/gitea/services/milestone_events"
|
||||
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.
|
||||
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error {
|
||||
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)
|
||||
publishProjectCardStateChanged(ctx, issue, true)
|
||||
|
||||
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)
|
||||
publishProjectCardStateChanged(ctx, issue, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,6 +69,17 @@ type CardUnlinked struct {
|
||||
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.
|
||||
type ColumnCreated struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
@@ -277,6 +288,14 @@ func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) {
|
||||
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.
|
||||
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
|
||||
go publishEvent(detach(ctx), payload.ProjectID, payload)
|
||||
|
||||
@@ -112,6 +112,14 @@ func TestPublishHelpers_NameAndPayload(t *testing.T) {
|
||||
},
|
||||
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",
|
||||
wantName: "project-board.13",
|
||||
|
||||
@@ -93,7 +93,16 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
|
||||
// Scope the update to this issue *in this project*. Without the
|
||||
// project_id predicate, an issue that belongs to several projects
|
||||
// would have every project_issue row rewritten to the target
|
||||
// column, detaching it from all other projects.
|
||||
_, err = db.GetEngine(ctx).Table("project_issue").
|
||||
Where("issue_id = ? AND project_id = ?", issueID, column.ProjectID).
|
||||
Update(map[string]any{
|
||||
"project_board_id": column.ID,
|
||||
"sorting": sorting,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func TestAPIUserProjects(t *testing.T) {
|
||||
t.Run("RemoveIssueFromProjectColumn", testAPIUserRemoveIssueFromProjectColumn)
|
||||
t.Run("ListProjectColumnIssues", testAPIUserListProjectColumnIssues)
|
||||
t.Run("MoveProjectIssue", testAPIUserMoveProjectIssue)
|
||||
t.Run("MoveProjectIssueMultiProjectIsolation", testAPIUserMoveProjectIssueMultiProjectIsolation)
|
||||
t.Run("Permissions", testAPIUserProjectPermissions)
|
||||
}
|
||||
|
||||
@@ -502,6 +503,44 @@ func testAPIUserMoveProjectIssue(t *testing.T) {
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// Regression for #17: moving an issue's column in one user project must not
|
||||
// rewrite its column in other projects the issue also belongs to.
|
||||
func testAPIUserMoveProjectIssueMultiProjectIsolation(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
|
||||
p1 := makeUserProject(t, owner)
|
||||
p2 := makeUserProject(t, owner)
|
||||
|
||||
p1ColA := &project_model.Column{Title: "p1-A", ProjectID: p1.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColA))
|
||||
p1ColB := &project_model.Column{Title: "p1-B", ProjectID: p1.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), p1ColB))
|
||||
p2Col := &project_model.Column{Title: "p2", ProjectID: p2.ID, CreatorID: owner.ID}
|
||||
assert.NoError(t, project_model.NewColumn(t.Context(), p2Col))
|
||||
|
||||
assert.NoError(t, issues_model.IssueAssignOrRemoveProject(t.Context(), issue, owner, []int64{p1.ID, p2.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p1ColA, map[int64]int64{0: issue.ID}))
|
||||
assert.NoError(t, projects_service.MoveIssuesOnProjectColumn(t.Context(), owner, p2Col, map[int64]int64{0: issue.ID}))
|
||||
|
||||
token := getUserToken(t, owner.Name, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
// move the issue inside p1 only
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
fmt.Sprintf("/api/v1/users/%s/projects/%d/issues/%d/move", owner.Name, p1.ID, issue.ID),
|
||||
&api.MoveProjectIssueOption{ColumnID: p1ColB.ID},
|
||||
).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// p1 updated as requested
|
||||
pi1 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p1.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, p1ColB.ID, pi1.ProjectColumnID)
|
||||
|
||||
// p2 must be untouched
|
||||
pi2 := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{ProjectID: p2.ID, IssueID: issue.ID})
|
||||
assert.Equal(t, p2Col.ID, pi2.ProjectColumnID, "issue must remain in its original column in other projects")
|
||||
}
|
||||
|
||||
func testAPIUserProjectPermissions(t *testing.T) {
|
||||
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
other := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
import {showInfoToast, showWarningToast} from '../modules/toast.ts';
|
||||
|
||||
// milestoneTitle does a best-effort lookup of the milestone's display
|
||||
// name for toast text. List cards expose it as a heading/link inside the
|
||||
// card; the single-milestone view puts it in the page header. Falls back
|
||||
// to a generic label so a toast still fires if the markup shifts.
|
||||
function milestoneTitle(milestoneID: number): string {
|
||||
const card = document.querySelector<HTMLElement>(`li.milestone-card[data-milestone-id="${milestoneID}"]`);
|
||||
const fromCard = card?.querySelector('.milestone-card-title, h3, .title, a[href*="/milestone/"]')?.textContent?.trim();
|
||||
if (fromCard) return fromCard;
|
||||
const onSingle = document.querySelector<HTMLElement>(`progress[data-milestone-id="${milestoneID}"]`);
|
||||
if (onSingle) {
|
||||
const h = document.querySelector('.repository.milestone-issue-list .milestone-title, .page-content .milestone-title, h1, h2')?.textContent?.trim();
|
||||
if (h) return h;
|
||||
}
|
||||
return 'Milestone';
|
||||
}
|
||||
|
||||
// sessionTag is generated once per page load. The mutation requests on
|
||||
// milestone pages (close/open/delete/edit) flow through the existing
|
||||
@@ -86,11 +103,10 @@ function handleMilestoneDeleted(payload: MilestoneDeletedPayload): void {
|
||||
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 = '/';
|
||||
}
|
||||
const dest = idx > 0 ? `${parts.slice(0, idx).join('/')}/milestones` : '/';
|
||||
// Delay so the "milestone deleted" warning toast is visible before
|
||||
// the page navigates out from under the viewer.
|
||||
setTimeout(() => { window.location.href = dest }, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +114,12 @@ function dispatchMilestoneEvent(payload: any): void {
|
||||
if (payload.session_tag && payload.session_tag === sessionTag) return;
|
||||
if (isProgressPayload(payload)) {
|
||||
patchMilestoneCard(payload);
|
||||
const total = payload.open_issues + payload.closed_issues;
|
||||
showInfoToast(`${milestoneTitle(payload.milestone_id)} · ${payload.closed_issues}/${total} closed (${payload.completeness}%)`);
|
||||
} else if ('milestone_id' in payload && 'repo_id' in payload) {
|
||||
const title = milestoneTitle(payload.milestone_id);
|
||||
handleMilestoneDeleted(payload as MilestoneDeletedPayload);
|
||||
showWarningToast(title === 'Milestone' ? 'A milestone was deleted' : `Milestone “${title}” was deleted`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,20 @@ 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';
|
||||
|
||||
@@ -223,6 +237,12 @@ type CardUnlinkedPayload = EventPayloadBase & {
|
||||
issue_id: number;
|
||||
};
|
||||
|
||||
type CardStateChangedPayload = EventPayloadBase & {
|
||||
project_id: number;
|
||||
issue_id: number;
|
||||
is_closed: boolean;
|
||||
};
|
||||
|
||||
type ColumnUpdatedPayload = {
|
||||
project_id: number;
|
||||
column_id: number;
|
||||
@@ -281,8 +301,10 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
|
||||
// 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;
|
||||
@@ -293,6 +315,7 @@ function handleCardMoved(board: HTMLElement, payload: CardMovedPayload): void {
|
||||
}
|
||||
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> {
|
||||
@@ -321,15 +344,42 @@ async function refetchColumn(board: HTMLElement, columnID: number): Promise<void
|
||||
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 {
|
||||
@@ -341,7 +391,11 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload):
|
||||
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');
|
||||
@@ -356,7 +410,10 @@ function handleColumnUpdated(board: HTMLElement, payload: ColumnUpdatedPayload):
|
||||
|
||||
function handleColumnDeleted(board: HTMLElement, payload: ColumnDeletedPayload): void {
|
||||
const colEl = board.querySelector<HTMLElement>(`.project-column[data-id="${payload.column_id}"]`);
|
||||
if (colEl) colEl.remove();
|
||||
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 {
|
||||
@@ -383,13 +440,12 @@ function handleProjectDeleted(): void {
|
||||
// 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('/');
|
||||
if (parts.length > 1) {
|
||||
parts.pop();
|
||||
window.location.href = parts.join('/') || '/';
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
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.
|
||||
@@ -397,7 +453,12 @@ function handleProjectDeleted(): void {
|
||||
// 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) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user