diff --git a/AGENTS.md b/AGENTS.md index 589dea7865f..6c7e50fea4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,5 +7,6 @@ - Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates - Preserve existing code comments, do not remove or rewrite comments that are still relevant - In TypeScript, use `!` (non-null assertion) instead of `?.`/`??` when a value is known to always exist +- For CSS layout, prefer `flex-*` helpers over per-child `tw-ml-*` / `tw-mr-*` margins; fall back to `tw-*` utilities when specificity requires `!important` - Include authorship attribution in issue and pull request comments - Add `Co-Authored-By` lines to all commits, indicating name and model used diff --git a/models/issues/issue.go b/models/issues/issue.go index 655cdebdfc6..838d41a3005 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -592,6 +592,17 @@ func GetIssueByID(ctx context.Context, id int64) (*Issue, error) { return issue, nil } +func GetIssueByRepoID(ctx context.Context, repoID, issueID int64) (*Issue, error) { + issue := new(Issue) + has, err := db.GetEngine(ctx).ID(issueID).Where("repo_id=?", repoID).Get(issue) + if err != nil { + return nil, err + } else if !has { + return nil, ErrIssueNotExist{issueID, repoID, 0} + } + return issue, nil +} + // GetIssuesByIDs return issues with the given IDs. // If keepOrder is true, the order of the returned issues will be the same as the given IDs. func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) { diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 3bb09363019..f78daf77f88 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -115,17 +115,10 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo panic("newColumnID must not be zero") // shouldn't happen } - res := struct { - MaxSorting int64 - IssueCount int64 - }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). - Where("project_id=?", newProjectID). - And("project_board_id=?", newColumnID). - Get(&res); err != nil { + newSorting, err := project_model.GetColumnIssueNextSorting(ctx, newProjectID, newColumnID) + if err != nil { return err } - newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) return db.Insert(ctx, &project_model.ProjectIssue{ IssueID: issue.ID, ProjectID: newProjectID, diff --git a/models/project/column.go b/models/project/column.go index 7365204f18e..997e82ddf90 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error { return err } - if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil { + if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil { return err } diff --git a/models/project/column_test.go b/models/project/column_test.go index 948e012c62d..6437a764ed3 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -59,7 +59,7 @@ func Test_moveIssuesToAnotherColumn(t *testing.T) { assert.Len(t, issues, 1) assert.EqualValues(t, 3, issues[0].ID) - err = column1.moveIssuesToAnotherColumn(t.Context(), column2) + err = moveIssuesToAnotherColumn(t.Context(), column1, column2) assert.NoError(t, err) issues, err = column1.GetIssues(t.Context()) diff --git a/models/project/issue.go b/models/project/issue.go index 47d1537ec73..c89f5243054 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -33,38 +33,45 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error return err } -func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error { - if c.ProjectID != newColumn.ProjectID { - return errors.New("columns have to be in the same project") - } - - if c.ID == newColumn.ID { - return nil - } - +// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column. +func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) { res := struct { MaxSorting int64 IssueCount int64 }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). + if _, err := db.GetEngine(ctx).Select("max(sorting) AS max_sorting, count(*) AS issue_count"). Table("project_issue"). - Where("project_id=?", newColumn.ProjectID). - And("project_board_id=?", newColumn.ID). + Where("project_id=?", projectID). + And("project_board_id=?", columnID). Get(&res); err != nil { - return err + return 0, err + } + return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil +} + +func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column) error { + if oldColumn.ProjectID != newColumn.ProjectID { + return errors.New("columns have to be in the same project") } - issues, err := c.GetIssues(ctx) - if err != nil { - return err - } - if len(issues) == 0 { + if oldColumn.ID == newColumn.ID { return nil } - nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + movedIssues, err := oldColumn.GetIssues(ctx) + if err != nil { + return err + } + if len(movedIssues) == 0 { + return nil + } + + nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID) + if err != nil { + return err + } return db.WithTx(ctx, func(ctx context.Context) error { - for i, issue := range issues { + for i, issue := range movedIssues { issue.ProjectColumnID = newColumn.ID issue.Sorting = nextSorting + int64(i) if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 8efafd5c4b7..819195efe2d 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1407,11 +1407,12 @@ "repo.issues.new": "New Issue", "repo.issues.new.title_empty": "Title cannot be empty", "repo.issues.new.labels": "Labels", - "repo.issues.new.no_label": "No Label", + "repo.issues.new.no_labels": "No labels", "repo.issues.new.clear_labels": "Clear labels", "repo.issues.new.projects": "Projects", "repo.issues.new.clear_projects": "Clear projects", - "repo.issues.new.no_projects": "No project", + "repo.issues.new.no_projects": "No projects", + "repo.issues.new.no_column": "No column", "repo.issues.new.open_projects": "Open Projects", "repo.issues.new.closed_projects": "Closed Projects", "repo.issues.new.no_items": "No items", diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 639333ab425..be609d8cdfb 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -33,12 +33,15 @@ type issueSidebarAssigneesData struct { CandidateAssignees []*user_model.User } +type issueSidebarProjectCardData struct { + Project *project_model.Project + Columns []*project_model.Column + SelectedColumn *project_model.Column +} + type issueSidebarProjectsData struct { SelectedProjectIDs []int64 // TODO: support multiple projects in the future - - // the "selected" fields are only valid when len(SelectedProjectIDs)==1 - SelectedProjectColumns []*project_model.Column - SelectedProjectColumn *project_model.Column + ProjectCards []*issueSidebarProjectCardData OpenProjects []*project_model.Project ClosedProjects []*project_model.Project @@ -172,30 +175,37 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { if d.Issue == nil || d.Issue.Project == nil { return } - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} columns, err := d.Issue.Project.GetColumns(ctx) if err != nil { ctx.ServerError("GetProjectColumns", err) return } - d.ProjectsData.SelectedProjectColumns = columns columnID, err := d.Issue.ProjectColumnID(ctx) if err != nil { ctx.ServerError("ProjectColumnID", err) return } + var selectedColumn *project_model.Column for _, col := range columns { if col.ID == columnID { - d.ProjectsData.SelectedProjectColumn = col + selectedColumn = col break } } + d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{ + { + Project: d.Issue.Project, + Columns: columns, + SelectedColumn: selectedColumn, + }, + } + d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards)) + for _, card := range d.ProjectsData.ProjectCards { + d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID) + } } func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { - if d.Issue != nil && d.Issue.Project != nil { - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} - } d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index c9bdc5be76e..be12674223e 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -464,6 +464,54 @@ func UpdateIssueProject(ctx *context.Context) { ctx.JSONOK() } +// UpdateIssueProjectColumn moves an issue to a different column within its project +func UpdateIssueProjectColumn(ctx *context.Context) { + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("issue_id")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + column, err := project_model.GetColumn(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.NotFoundOrServerError("GetColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if err := issue.LoadProject(ctx); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + issueProjects := []*project_model.Project{issue.Project} // TODO: this is for the multiple project support in the future + + // it must make sure the requested column is in this issue's projects + var columnProject *project_model.Project + for _, project := range issueProjects { + if column.ProjectID == project.ID { + columnProject = project + break + } + } + if columnProject == nil { + ctx.NotFound(nil) + return + } + + // append to the end of the target column so we don't collide with existing sorting values + newSorting, err := project_model.GetColumnIssueNextSorting(ctx, columnProject.ID, column.ID) + if err != nil { + ctx.ServerError("GetColumnIssueNextSorting", err) + return + } + + if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{newSorting: issue.ID}); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSONOK() +} + // DeleteProjectColumn allows for the deletion of a project column func DeleteProjectColumn(ctx *context.Context) { if ctx.Doer == nil { diff --git a/routers/web/web.go b/routers/web/web.go index 1dff6cbc04e..e0ff54fcff5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1355,6 +1355,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, reqRepoProjectsWriter, repo.UpdateIssueProjectColumn) 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/label_list.tmpl b/templates/repo/issue/sidebar/label_list.tmpl index d30196326b3..3eb7ba8d7a5 100644 --- a/templates/repo/issue/sidebar/label_list.tmpl +++ b/templates/repo/issue/sidebar/label_list.tmpl @@ -45,7 +45,7 @@