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:
commit
3a3df30c00
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
25
templates/repo/issue/sidebar/project_column.tmpl
Normal file
25
templates/repo/issue/sidebar/project_column.tmpl
Normal 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}}
|
||||
@ -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}}
|
||||
|
||||
47
tests/e2e/project-column-picker.test.ts
Normal file
47
tests/e2e/project-column-picker.test.ts
Normal 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);
|
||||
});
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user