Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions routers/web/repo/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions templates/repo/issue/sidebar/project_column.tmpl
Original file line number Diff line number Diff line change
@@ -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}}
4 changes: 4 additions & 0 deletions templates/repo/issue/sidebar/project_list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 47 additions & 0 deletions tests/e2e/project-column-picker.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
50 changes: 50 additions & 0 deletions tests/integration/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading