Add project column picker to issue and pull request sidebar (#37037)
Why? You are working on a ticket, it's ready to be moved to the QA column in your project. Currently you have to go to the project, find the issue card, then move it. With this change you can move the issue's column on the issue page. When an issue or pull request belongs to a project board, a dropdown appears in the sidebar to move it between columns without opening the board view. Read-only users see the current column name instead. * Fix #13520 * Replace #30617 This was written using Claude Code and Opus. Closed: <img width="1346" height="507" alt="image" src="https://github.com/user-attachments/assets/7c1ea7ee-b71c-40af-bb14-aeb1d2beff73" /> Open: <img width="1315" height="577" alt="image" src="https://github.com/user-attachments/assets/4d64b065-44c2-42c7-8d20-84b5caea589a" /> --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Nicolas <bircni@icloud.com> Co-authored-by: Cursor <cursor@cursor.com>
This commit is contained in:
67
tests/e2e/issue-project.test.ts
Normal file
67
tests/e2e/issue-project.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProjectColumn, randomString, timeoutFactor} from './utils.ts';
|
||||
|
||||
test('assign issue to project and change column', async ({page}) => {
|
||||
const repoName = `e2e-issue-project-${randomString(8)}`;
|
||||
const user = env.GITEA_TEST_E2E_USER;
|
||||
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName})]);
|
||||
|
||||
await page.goto(`/${user}/${repoName}/projects/new`);
|
||||
await page.locator('input[name="title"]').fill('Kanban Board');
|
||||
await page.getByRole('button', {name: 'Create Project'}).click();
|
||||
|
||||
const projectLink = page.locator('.milestone-list a', {hasText: 'Kanban Board'}).first();
|
||||
await expect(projectLink).toBeVisible();
|
||||
const href = await projectLink.getAttribute('href');
|
||||
const projectID = href!.split('/').pop();
|
||||
|
||||
// columns created via POST because the web UI uses modals that are hard to drive
|
||||
await Promise.all(['Backlog', 'In Progress', 'Done'].map((title) =>
|
||||
createProjectColumn(page.request, user, repoName, projectID!, title),
|
||||
));
|
||||
|
||||
await apiCreateIssue(page.request, user, repoName, {title: 'Column picker test'});
|
||||
|
||||
// Same ceiling as tests/e2e/events.test.ts; Playwright defaults are 5000*factor (see playwright.config.ts).
|
||||
const slowTimeout = 15_000 * timeoutFactor;
|
||||
|
||||
await page.goto(`/${user}/${repoName}/issues/1`);
|
||||
await page.locator('.sidebar-project-combo .ui.dropdown').click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => resp.url().includes('/issues/projects') && resp.status() === 200,
|
||||
{timeout: slowTimeout},
|
||||
),
|
||||
page.locator('.sidebar-project-combo .menu .item', {hasText: 'Kanban Board'}).click(),
|
||||
]);
|
||||
|
||||
const columnCombo = page.locator('.sidebar-project-column-combo');
|
||||
await expect(columnCombo).toBeVisible({timeout: slowTimeout});
|
||||
await columnCombo.locator('.ui.dropdown').click();
|
||||
await columnCombo.locator('.menu').waitFor({state: 'visible', timeout: slowTimeout});
|
||||
|
||||
const inProgressItem = columnCombo.locator('a.item', {hasText: 'In Progress'});
|
||||
await expect(inProgressItem).toBeVisible({timeout: slowTimeout});
|
||||
await inProgressItem.scrollIntoViewIfNeeded();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.url().includes('/issues/projects/column') &&
|
||||
resp.ok(),
|
||||
{timeout: slowTimeout},
|
||||
),
|
||||
inProgressItem.click(),
|
||||
]);
|
||||
|
||||
await expect(columnCombo.getByTestId('sidebar-project-column-text')).toContainText('In Progress', {
|
||||
timeout: slowTimeout,
|
||||
});
|
||||
await expect(page.locator('.timeline-item', {hasText: 'moved this to In Progress'})).toBeVisible({
|
||||
timeout: slowTimeout,
|
||||
});
|
||||
|
||||
await apiDeleteRepo(page.request, user, repoName);
|
||||
});
|
||||
@@ -74,6 +74,13 @@ export async function apiCreateBranch(requestContext: APIRequestContext, owner:
|
||||
}), 'apiCreateBranch');
|
||||
}
|
||||
|
||||
export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/${owner}/${repo}/projects/${projectID}/columns/new`, {
|
||||
headers: apiHeaders(),
|
||||
form: {title},
|
||||
}), 'createProjectColumn');
|
||||
}
|
||||
|
||||
export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) {
|
||||
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, {
|
||||
headers: apiHeaders(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
@@ -89,6 +90,110 @@ func TestMoveRepoProjectColumns(t *testing.T) {
|
||||
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
|
||||
}
|
||||
|
||||
func TestUpdateIssueProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2)
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||
assert.EqualValues(t, 1, issue.RepoID)
|
||||
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
t.Run("MoveColumn", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "3",
|
||||
"id": "3",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3})
|
||||
assert.EqualValues(t, 3, pi.ProjectColumnID)
|
||||
})
|
||||
|
||||
t.Run("InvalidIssueID", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "0",
|
||||
"id": "3",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("WrongRepo", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "6",
|
||||
"id": "3",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("WrongProject", func(t *testing.T) {
|
||||
project2 := project_model.Project{
|
||||
Title: "second project on repo1",
|
||||
RepoID: 1,
|
||||
Type: project_model.TypeRepository,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), &project2))
|
||||
require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{
|
||||
Title: "other column",
|
||||
ProjectID: project2.ID,
|
||||
}))
|
||||
columns, err := project2.GetColumns(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, columns)
|
||||
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "1",
|
||||
"id": strconv.FormatInt(columns[0].ID, 10),
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3)
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
req := NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||
resp := sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards := htmlDoc.Find(".sidebar-project-card")
|
||||
assert.Equal(t, 1, cards.Length())
|
||||
|
||||
title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis")
|
||||
assert.Contains(t, strings.TrimSpace(title.Text()), "First project")
|
||||
|
||||
columnCombo := cards.Find(".sidebar-project-column-combo")
|
||||
assert.Equal(t, 1, columnCombo.Length())
|
||||
|
||||
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
|
||||
assert.Equal(t, 1, defaultItem.Length())
|
||||
|
||||
inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`)
|
||||
assert.Equal(t, 1, inProgressItem.Length())
|
||||
doneItem := columnCombo.Find(`.menu .item[data-value="3"]`)
|
||||
assert.Equal(t, 1, doneItem.Length())
|
||||
|
||||
comboVal, exists := columnCombo.Find("input.combo-value").Attr("value")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "3", comboVal)
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{
|
||||
"id": "0",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||
resp = sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc = NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards = htmlDoc.Find(".sidebar-project-card")
|
||||
assert.Equal(t, 0, cards.Length())
|
||||
}
|
||||
|
||||
// getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page.
|
||||
func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user