Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
35 changes: 35 additions & 0 deletions routers/web/repo/issue_page_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type issueSidebarProjectsData struct {
SelectedProjectID int64
OpenProjects []*project_model.Project
ClosedProjects []*project_model.Project
ProjectColumns []*project_model.Column
SelectedColumnID int64
}

type IssuePageMetaData struct {
Expand Down Expand Up @@ -97,6 +99,12 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository
// A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore.
// For non-creator users, only writers could update some meta (eg: assignees, milestone, project)
// Need to clarify the logic and add some tests in the future
// Load project column data for all users (read-only display for non-writers)
data.retrieveProjectColumnsData(ctx)
if ctx.Written() {
return data
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment and code was added to wrong place.


data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived
if !data.CanModifyIssueOrPull {
return data
Expand Down Expand Up @@ -158,6 +166,33 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
}

func (d *IssuePageMetaData) retrieveProjectColumnsData(ctx *context.Context) {
if d.Issue == nil || d.Issue.Project == nil {
return
}
d.ProjectsData.SelectedProjectID = d.Issue.Project.ID
columns, err := d.Issue.Project.GetColumns(ctx)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
}
d.ProjectsData.ProjectColumns = columns
columnID, err := d.Issue.ProjectColumnID(ctx)
if err != nil {
ctx.ServerError("ProjectColumnID", err)
return
}
if columnID == 0 {
defaultColumn, err := d.Issue.Project.MustDefaultColumn(ctx)
if err != nil {
ctx.ServerError("MustDefaultColumn", err)
return
}
columnID = defaultColumn.ID
}
d.ProjectsData.SelectedColumnID = columnID
}

func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
if d.Issue != nil && d.Issue.Project != nil {
d.ProjectsData.SelectedProjectID = d.Issue.Project.ID
Expand Down
94 changes: 94 additions & 0 deletions routers/web/repo/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ const (
tplProjectsView templates.TplName = "repo/projects/view"
)

type projectColumnInfo struct {
ID int64 `json:"id"`
Title string `json:"title"`
}

// MustEnableRepoProjects check if repo projects are enabled in settings
func MustEnableRepoProjects(ctx *context.Context) {
if unit.TypeProjects.UnitGlobalDisabled() {
Expand Down Expand Up @@ -461,6 +466,95 @@ func UpdateIssueProject(ctx *context.Context) {
}
}

result := map[string]any{"ok": true}
if projectID > 0 {
project, err := project_model.GetProjectByID(ctx, projectID)
if err != nil {
ctx.ServerError("GetProjectByID", err)
return
}
columns, err := project.GetColumns(ctx)
if err != nil {
ctx.ServerError("GetProjectColumns", err)
return
}
cols := make([]projectColumnInfo, 0, len(columns))
for _, c := range columns {
cols = append(cols, projectColumnInfo{ID: c.ID, Title: c.Title})
}
// The issue was assigned to the default column
var selectedColumnID int64
if len(issues) > 0 {
selectedColumnID, err = issues[0].ProjectColumnID(ctx)
if err != nil {
ctx.ServerError("ProjectColumnID", err)
return
}
if selectedColumnID == 0 {
defaultColumn, err := project.MustDefaultColumn(ctx)
if err != nil {
ctx.ServerError("MustDefaultColumn", err)
return
}
selectedColumnID = defaultColumn.ID
}
}
result["columns"] = cols
result["selected_column_id"] = selectedColumnID
} else {
result["columns"] = []projectColumnInfo{}
result["selected_column_id"] = 0
}
ctx.JSON(http.StatusOK, result)
}

// UpdateIssueProjectColumn moves an issue to a different column within its current project
func UpdateIssueProjectColumn(ctx *context.Context) {
issueID := ctx.FormInt64("issue_id")
columnID := ctx.FormInt64("id")

issue, err := issues_model.GetIssueByID(ctx, issueID)
if err != nil {
if issues_model.IsErrIssueNotExist(err) {
ctx.NotFound(nil)
return
}
ctx.ServerError("GetIssueByID", err)
return
}
if issue.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}

if err := issue.LoadProject(ctx); err != nil {
ctx.ServerError("LoadProject", err)
return
}
if issue.Project == nil {
ctx.NotFound(nil)
return
}

column, err := project_model.GetColumn(ctx, columnID)
if err != nil {
if project_model.IsErrProjectColumnNotExist(err) {
ctx.NotFound(nil)
return
}
ctx.ServerError("GetColumn", err)
return
}
if column.ProjectID != issue.Project.ID {
ctx.NotFound(nil)
return
}

if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{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 @@ -1354,6 +1354,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/column", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProjectColumn)
m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
Expand Down
31 changes: 31 additions & 0 deletions templates/repo/issue/sidebar/project_column.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{{$pageMeta := .}}
{{$data := .ProjectsData}}
{{if and $pageMeta.Issue $pageMeta.Issue.Project $data.ProjectColumns (gt (len $data.ProjectColumns) 1)}}
{{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/column?issue_id={{$pageMeta.Issue.ID}}">
<input class="combo-value" name="column_id" type="hidden" value="{{$data.SelectedColumnID}}">
<div class="ui dropdown selection fluid">
<div class="default text">{{range $data.ProjectColumns}}{{if eq .ID $data.SelectedColumnID}}{{.Title}}{{end}}{{end}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
{{range $data.ProjectColumns}}
<div class="item {{if eq .ID $data.SelectedColumnID}}active selected checked{{end}}" data-value="{{.ID}}">{{.Title}}</div>
{{end}}
</div>
</div>
<div class="ui list tw-hidden">
<span class="item empty-list">No column</span>
</div>
</div>
{{else}}
{{range $data.ProjectColumns}}
{{if eq .ID $data.SelectedColumnID}}
<div class="tw-mt-1">
<span class="muted">{{svg "octicon-columns" 16 "tw-mr-1"}}{{.Title}}</span>
</div>
{{end}}
{{end}}
{{end}}
{{end}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't write code without indent, it is unmaintainable.

image

1 change: 1 addition & 0 deletions templates/repo/issue/sidebar/project_list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@
{{end}}
</div>
</div>
{{template "repo/issue/sidebar/project_column" $pageMeta}}
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
Loading