diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index c9bdc5be76..b0116726b7 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -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() } diff --git a/routers/web/web.go b/routers/web/web.go index e3dcf27cc4..41a59e4f1a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/templates/repo/issue/sidebar/project_column.tmpl b/templates/repo/issue/sidebar/project_column.tmpl new file mode 100644 index 0000000000..5aca90c2e5 --- /dev/null +++ b/templates/repo/issue/sidebar/project_column.tmpl @@ -0,0 +1,25 @@ +{{$pageMeta := .}} +{{$data := .ProjectsData}} +{{$selectedColumn := $data.SelectedProjectColumn}} +{{if and $pageMeta.Issue $pageMeta.Issue.Project $data.SelectedProjectColumns}} +
+ {{svg "octicon-columns" 18}} + {{if $pageMeta.CanModifyIssueOrPull}} + + {{else if $selectedColumn}} + {{$selectedColumn.Title}} + {{end}} +
+{{end}} diff --git a/templates/repo/issue/sidebar/project_list.tmpl b/templates/repo/issue/sidebar/project_list.tmpl index ca3b6bdd7a..8b09cf12bd 100644 --- a/templates/repo/issue/sidebar/project_list.tmpl +++ b/templates/repo/issue/sidebar/project_list.tmpl @@ -49,3 +49,7 @@ {{end}} +{{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}} diff --git a/tests/e2e/project-column-picker.test.ts b/tests/e2e/project-column-picker.test.ts new file mode 100644 index 0000000000..e35ccbf9ee --- /dev/null +++ b/tests/e2e/project-column-picker.test.ts @@ -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); +}); diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 1e38322dbf..bc97d7ef30 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -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()