0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-03 14:43:03 +02:00

Merge 680437fbcae59471efd507dfc01b4de5490efe34 into 7b17234945ff3f7c6f09c54d9c4ffc93dc137212

This commit is contained in:
Myers Carpenter 2026-04-03 10:41:37 +08:00 committed by GitHub
commit 3a3df30c00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 159 additions and 0 deletions

View File

@ -460,7 +460,39 @@ func UpdateIssueProject(ctx *context.Context) {
return
}
}
ctx.JSONOK()
}
// UpdateIssueProjectSetColumn moves an issue to a different column within its current project
func UpdateIssueProjectSetColumn(ctx *context.Context) {
issues := getActionIssues(ctx)
if ctx.Written() {
return
}
if err := issues.LoadProjects(ctx); err != nil {
ctx.ServerError("LoadProjects", err)
return
}
column, err := project_model.GetColumn(ctx, ctx.FormInt64("id"))
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound(nil)
return
} else if err != nil {
ctx.ServerError("GetColumn", err)
return
}
for _, issue := range issues {
if column.ProjectID != issue.Project.ID {
continue
}
if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{0 /*sorting=0*/ : issue.ID}); err != nil {
ctx.ServerError("MoveIssuesOnProjectColumn", err)
return
}
}
ctx.JSONOK()
}

View File

@ -1350,6 +1350,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone)
m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject)
m.Post("/projects/set-column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectSetColumn)
m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)

View File

@ -0,0 +1,25 @@
{{$pageMeta := .}}
{{$data := .ProjectsData}}
{{$selectedColumn := $data.SelectedProjectColumn}}
{{if and $pageMeta.Issue $pageMeta.Issue.Project $data.SelectedProjectColumns}}
<div class="tw-mt-1 flex-text-block">
{{svg "octicon-columns" 18}}
{{if $pageMeta.CanModifyIssueOrPull}}
<div class="issue-sidebar-combo" id="sidebar-project-column"
data-selection-mode="single" data-update-algo="all"
data-update-url="{{$pageMeta.RepoLink}}/issues/projects/set-column?issue_ids={{$pageMeta.Issue.ID}}">
<input class="combo-value" type="hidden" value="{{$selectedColumn.ID}}">
<div class="ui dropdown fluid">
<div class="text">{{$selectedColumn.Title}}</div>{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
{{range $column := $data.SelectedProjectColumns}}
<div class="item" data-value="{{$column.ID}}">{{$column.Title}}</div>
{{end}}
</div>
</div>
</div>
{{else if $selectedColumn}}
<span>{{$selectedColumn.Title}}</span>
{{end}}
</div>
{{end}}

View File

@ -49,3 +49,7 @@
{{end}}
</div>
</div>
{{if eq 1 (len $data.SelectedProjectIDs)}}
{{/* the project column selection is only supported when the issue is only in one project */}}
{{template "repo/issue/sidebar/project_column" $pageMeta}}
{{end}}

View File

@ -0,0 +1,47 @@
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo} from './utils.ts';
test('project column picker', async ({page}) => {
const repoName = `e2e-colpicker-${Date.now()}`;
const owner = env.GITEA_TEST_E2E_USER;
await login(page);
await apiCreateRepo(page.request, {name: repoName});
await apiCreateIssue(page.request, owner, repoName, {title: 'Test Issue'});
// Create a project with board type
await page.goto(`/${owner}/${repoName}/projects/new`);
await page.getByPlaceholder('Title').fill('Test Board');
await page.locator('#project_template').selectOption('Basic Kanban');
await page.getByRole('button', {name: 'Create Project'}).click();
await expect(page.locator('.project-column')).toHaveCount(3); // Basic Kanban: To Do, In Progress, Done
// Assign the issue to the project via the issue sidebar
await page.goto(`/${owner}/${repoName}/issues/1`);
await page.locator('.issue-sidebar-combo[data-update-url*="/issues/projects?"] .ui.dropdown').click();
await page.locator('.issue-sidebar-combo[data-update-url*="/issues/projects?"] .menu .item').filter({hasText: 'Test Board'}).click();
// Wait for the column picker to appear
await expect(page.locator('#sidebar-project-column .ui.dropdown')).toBeVisible();
// Verify the column dropdown has items
await page.locator('#sidebar-project-column .ui.dropdown').click();
const columnItems = page.locator('#sidebar-project-column .menu .item');
await expect(columnItems).toHaveCount(3);
// Select a different column
await columnItems.filter({hasText: 'In Progress'}).click();
// Verify the dropdown closed and shows the new selection
await expect(page.locator('#sidebar-project-column .menu')).toBeHidden();
await expect(page.locator('#sidebar-project-column .default.text')).toHaveText('In Progress');
// Verify a timeline event appeared for the column move
await expect(page.locator('.timeline-item').last()).toContainText('moved this to In Progress');
// Reload and verify the column persisted
await page.reload();
await expect(page.locator('#sidebar-project-column .default.text')).toHaveText('In Progress');
await apiDeleteRepo(page.request, owner, repoName);
});

View File

@ -89,6 +89,56 @@ func TestMoveRepoProjectColumns(t *testing.T) {
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
}
func TestUpdateIssueProjectColumn(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Issue 1 is in project 1, column 1 (To Do) — see fixtures
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
sess := loginUser(t, "user2")
t.Run("MoveToColumn", func(t *testing.T) {
// Move issue 1 from To Do (column 1) to In Progress (column 2)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
"issue_id": strconv.FormatInt(issue.ID, 10),
"id": "2",
})
sess.MakeRequest(t, req, http.StatusOK)
// Verify the column changed
columnID, err := issue.ProjectColumnID(t.Context())
require.NoError(t, err)
assert.EqualValues(t, 2, columnID)
})
t.Run("InvalidColumn", func(t *testing.T) {
// Column 4 belongs to project 4, not project 1
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
"issue_id": strconv.FormatInt(issue.ID, 10),
"id": "4",
})
sess.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("NonexistentColumn", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
"issue_id": strconv.FormatInt(issue.ID, 10),
"id": "99999",
})
sess.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("IssueFromOtherRepo", func(t *testing.T) {
// Issue 4 belongs to repo 2, not repo 1
otherIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/issues/projects/column", repo.FullName()), map[string]string{
"issue_id": strconv.FormatInt(otherIssue.ID, 10),
"id": "2",
})
sess.MakeRequest(t, req, http.StatusNotFound)
})
}
// 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()