diff --git a/models/db/search.go b/models/db/search.go index 37565f45e1f9e..31507edded852 100644 --- a/models/db/search.go +++ b/models/db/search.go @@ -24,6 +24,7 @@ const ( SearchOrderByStarsReverse SearchOrderBy = "num_stars DESC" SearchOrderByForks SearchOrderBy = "num_forks ASC" SearchOrderByForksReverse SearchOrderBy = "num_forks DESC" + SearchOrderByTitle SearchOrderBy = "title ASC" ) const ( diff --git a/models/issues/issue.go b/models/issues/issue.go index 40462ed09dfd6..f1f7b4c8c55a3 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -105,17 +105,17 @@ type Issue struct { PosterID int64 `xorm:"INDEX"` Poster *user_model.User `xorm:"-"` OriginalAuthor string - OriginalAuthorID int64 `xorm:"index"` - Title string `xorm:"name"` - Content string `xorm:"LONGTEXT"` - RenderedContent template.HTML `xorm:"-"` - ContentVersion int `xorm:"NOT NULL DEFAULT 0"` - Labels []*Label `xorm:"-"` - isLabelsLoaded bool `xorm:"-"` - MilestoneID int64 `xorm:"INDEX"` - Milestone *Milestone `xorm:"-"` - isMilestoneLoaded bool `xorm:"-"` - Project *project_model.Project `xorm:"-"` + OriginalAuthorID int64 `xorm:"index"` + Title string `xorm:"name"` + Content string `xorm:"LONGTEXT"` + RenderedContent template.HTML `xorm:"-"` + ContentVersion int `xorm:"NOT NULL DEFAULT 0"` + Labels []*Label `xorm:"-"` + isLabelsLoaded bool `xorm:"-"` + MilestoneID int64 `xorm:"INDEX"` + Milestone *Milestone `xorm:"-"` + isMilestoneLoaded bool `xorm:"-"` + Projects []*project_model.Project `xorm:"-"` Priority int AssigneeID int64 `xorm:"-"` Assignee *user_model.User `xorm:"-"` @@ -332,7 +332,7 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) { return err } - if err = issue.LoadProject(ctx); err != nil { + if err = issue.LoadProjects(ctx); err != nil { return err } diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go index 22a4548adc21e..3d3f2f0ed99a6 100644 --- a/models/issues/issue_list.go +++ b/models/issues/issue_list.go @@ -253,14 +253,19 @@ func (issues IssueList) LoadProjects(ctx context.Context) error { return err } for _, project := range projects { - projectMaps[project.IssueID] = project.Project + projectMaps[project.ID] = project.Project } left -= limit issueIDs = issueIDs[limit:] } for _, issue := range issues { - issue.Project = projectMaps[issue.ID] + projectIDs := issue.projectIDs(ctx) + for _, i := range projectIDs { + if projectMaps[i] != nil { + issue.Projects = append(issue.Projects, projectMaps[i]) + } + } } return nil } diff --git a/models/issues/issue_list_test.go b/models/issues/issue_list_test.go index 9069e1012da53..e9f254d488042 100644 --- a/models/issues/issue_list_test.go +++ b/models/issues/issue_list_test.go @@ -66,10 +66,10 @@ func TestIssueList_LoadAttributes(t *testing.T) { } if issue.ID == int64(1) { assert.Equal(t, int64(400), issue.TotalTrackedTime) - assert.NotNil(t, issue.Project) - assert.Equal(t, int64(1), issue.Project.ID) + assert.NotNil(t, issue.Projects) + assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Project) + assert.Nil(t, issue.Projects) } } } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index c4515fd898595..2e014dbe4ed16 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -13,28 +13,21 @@ import ( ) // LoadProject load the project the issue was assigned to -func (issue *Issue) LoadProject(ctx context.Context) (err error) { - if issue.Project == nil { - var p project_model.Project - has, err := db.GetEngine(ctx).Table("project"). +func (issue *Issue) LoadProjects(ctx context.Context) (err error) { + if issue.Projects == nil { + err = db.GetEngine(ctx).Table("project"). Join("INNER", "project_issue", "project.id=project_issue.project_id"). - Where("project_issue.issue_id = ?", issue.ID).Get(&p) - if err != nil { - return err - } else if has { - issue.Project = &p - } + Where("project_issue.issue_id = ?", issue.ID).Find(&issue.Projects) } return err } -func (issue *Issue) projectID(ctx context.Context) int64 { - var ip project_model.ProjectIssue - has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip) - if err != nil || !has { - return 0 +func (issue *Issue) projectIDs(ctx context.Context) []int64 { + var ips []int64 + if err := db.GetEngine(ctx).Table("project_issue").Select("project_id").Where("issue_id=?", issue.ID).Find(&ips); err != nil { + return nil } - return ip.ProjectID + return ips } // ProjectColumnID return project column id if issue was assigned to one @@ -51,7 +44,7 @@ func (issue *Issue) ProjectColumnID(ctx context.Context) int64 { func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *IssuesOptions) (IssueList, error) { issueList, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { o.ProjectColumnID = b.ID - o.ProjectID = b.ProjectID + o.ProjectIDs = append(o.ProjectIDs, b.ProjectID) o.SortType = "project-column-sorting" })) if err != nil { @@ -61,7 +54,7 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is if b.Default { issues, err := Issues(ctx, &IssuesOptions{ ProjectColumnID: db.NoConditionID, - ProjectID: b.ProjectID, + ProjectIDs: []int64{b.ProjectID}, SortType: "project-column-sorting", }) if err != nil { @@ -92,10 +85,10 @@ func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList, // IssueAssignOrRemoveProject changes the project associated with an issue // If newProjectID is 0, the issue is removed from the project -func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error { +func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64, action string) error { return db.WithTx(ctx, func(ctx context.Context) error { - oldProjectID := issue.projectID(ctx) - + var oldProjectIDs []int64 + var err error if err := issue.LoadRepo(ctx); err != nil { return err } @@ -118,11 +111,47 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo } } - if _, err := db.GetEngine(ctx).Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil { - return err + if action == "attach" || (action == "null" && newProjectID > 0) { + if newProjectID == 0 { + return nil + } + if newColumnID == 0 { + panic("newColumnID must not be zero") // shouldn't happen + } + res := struct { + IssueCount int64 + }{} + if _, err := db.GetEngine(ctx).Select("count(*) as issue_count").Table("project_issue"). + Where("project_id=?", newProjectID). + And("issue_id=?", issue.ID). + Get(&res); err != nil { + return err + } + if res.IssueCount == 0 { + err = db.Insert(ctx, &project_model.ProjectIssue{ + IssueID: issue.ID, + ProjectID: newProjectID, + ProjectColumnID: newColumnID, + }) + oldProjectIDs = []int64{0} + } else { + _, err = db.GetEngine(ctx).Where("issue_id=? AND project_id=?", issue.ID, newProjectID).Delete(&project_model.ProjectIssue{}) + oldProjectIDs = []int64{newProjectID} + newProjectID = 0 + } + } else if action == "detach" { + _, err = db.GetEngine(ctx).Where("issue_id=? AND project_id=?", issue.ID, newProjectID).Delete(&project_model.ProjectIssue{}) + oldProjectIDs = append(oldProjectIDs, newProjectID) + newProjectID = 0 + } else if action == "clear" || (action == "null" && newProjectID == 0) { + if err = db.GetEngine(ctx).Table("project_issue").Select("project_id").Where("issue_id=?", issue.ID).Find(&oldProjectIDs); err != nil { + return err + } + _, err = db.GetEngine(ctx).Where("issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}) + newProjectID = 0 } - if oldProjectID > 0 || newProjectID > 0 { + for _, oldProjectID := range oldProjectIDs { if _, err := CreateComment(ctx, &CreateCommentOptions{ Type: CommentTypeProject, Doer: doer, @@ -134,29 +163,6 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo return err } } - if newProjectID == 0 { - return nil - } - if newColumnID == 0 { - 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 { - return err - } - newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) - return db.Insert(ctx, &project_model.ProjectIssue{ - IssueID: issue.ID, - ProjectID: newProjectID, - ProjectColumnID: newColumnID, - Sorting: newSorting, - }) + return err }) } diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go index 5948a67d4ebc6..3a0815f7a5eb6 100644 --- a/models/issues/issue_search.go +++ b/models/issues/issue_search.go @@ -34,7 +34,7 @@ type IssuesOptions struct { //nolint ReviewedID int64 SubscriberID int64 MilestoneIDs []int64 - ProjectID int64 + ProjectIDs []int64 ProjectColumnID int64 IsClosed optional.Option[bool] IsPull optional.Option[bool] @@ -183,11 +183,13 @@ func applyMilestoneCondition(sess *xorm.Session, opts *IssuesOptions) { } func applyProjectCondition(sess *xorm.Session, opts *IssuesOptions) { - if opts.ProjectID > 0 { // specific project - sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). - And("project_issue.project_id=?", opts.ProjectID) - } else if opts.ProjectID == db.NoConditionID { // show those that are in no project - sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue").And(builder.Neq{"project_id": 0}))) + if len(opts.ProjectIDs) > 0 { // specific project + if opts.ProjectIDs[0] == db.NoConditionID { // show those that are in no project + sess.And(builder.NotIn("issue.id", builder.Select("issue_id").From("project_issue").And(builder.Neq{"project_id": 0}))) + } else { + sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). + In("project_issue.project_id", opts.ProjectIDs) + } } // opts.ProjectID == 0 means all projects, // do not need to apply any condition @@ -198,6 +200,8 @@ func applyProjectColumnCondition(sess *xorm.Session, opts *IssuesOptions) { // do not need to apply any condition if opts.ProjectColumnID > 0 { sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectColumnID})) + } else if len(opts.ProjectIDs) > 0 { + sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}).And(builder.In("project_id", opts.ProjectIDs))) } else if opts.ProjectColumnID == db.NoConditionID { sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0})) } diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 1bbc0eee564fd..0bac24da191c5 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -418,10 +418,10 @@ func TestIssueLoadAttributes(t *testing.T) { } if issue.ID == int64(1) { assert.Equal(t, int64(400), issue.TotalTrackedTime) - assert.NotNil(t, issue.Project) - assert.Equal(t, int64(1), issue.Project.ID) + assert.NotNil(t, issue.Projects) + assert.Equal(t, int64(1), issue.Projects[0].ID) } else { - assert.Nil(t, issue.Project) + assert.Nil(t, issue.Projects) } } } diff --git a/models/project/project.go b/models/project/project.go index 050ccf44e02e4..3d0c21a00425c 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -236,6 +236,8 @@ func GetSearchOrderByBySortType(sortType string) db.SearchOrderBy { return db.SearchOrderByRecentUpdated case "leastupdate": return db.SearchOrderByLeastUpdated + case "title": + return db.SearchOrderByTitle default: return db.SearchOrderByNewest } diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index 7ef370e89c5d2..022de1af28e79 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -23,7 +23,7 @@ import ( const ( issueIndexerAnalyzer = "issueIndexer" issueIndexerDocType = "issueIndexerDocType" - issueIndexerLatestVersion = 4 + issueIndexerLatestVersion = 5 ) const unicodeNormalizeName = "unicodeNormalize" @@ -78,7 +78,8 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) { docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping) docMapping.AddFieldMappingsAt("no_label", boolFieldMapping) docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping) - docMapping.AddFieldMappingsAt("project_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("project_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("no_project", boolFieldMapping) docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping) docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping) docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping) @@ -221,8 +222,12 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...)) } - if options.ProjectID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id")) + if len(options.ProjectIDs) > 0 { + var projectQueries []query.Query + for _, projectID := range options.ProjectIDs { + projectQueries = append(projectQueries, inner_bleve.NumericEqualityQuery(projectID, "project_ids")) + } + queries = append(queries, bleve.NewDisjunctionQuery(projectQueries...)) } if options.ProjectColumnID.Has() { queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id")) diff --git a/modules/indexer/issues/db/options.go b/modules/indexer/issues/db/options.go index 875a4ca279dd6..e2b0ee18f9b5e 100644 --- a/modules/indexer/issues/db/options.go +++ b/modules/indexer/issues/db/options.go @@ -60,7 +60,6 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m ReviewRequestedID: convertID(options.ReviewRequestedID), ReviewedID: convertID(options.ReviewedID), SubscriberID: convertID(options.SubscriberID), - ProjectID: convertID(options.ProjectID), ProjectColumnID: convertID(options.ProjectColumnID), IsClosed: options.IsClosed, IsPull: options.IsPull, @@ -84,6 +83,12 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*issue_m opts.MilestoneIDs = options.MilestoneIDs } + if len(options.ProjectIDs) == 1 && options.ProjectIDs[0] == 0 { + opts.ProjectIDs = []int64{db.NoConditionID} + } else { + opts.ProjectIDs = options.ProjectIDs + } + if options.NoLabelOnly { opts.LabelIDs = []int64{0} // Be careful, it's zero, not db.NoConditionID } else { diff --git a/modules/indexer/issues/dboptions.go b/modules/indexer/issues/dboptions.go index c1f454eeee563..c97ba545bedb0 100644 --- a/modules/indexer/issues/dboptions.go +++ b/modules/indexer/issues/dboptions.go @@ -38,10 +38,10 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp searchOpt.MilestoneIDs = opts.MilestoneIDs } - if opts.ProjectID > 0 { - searchOpt.ProjectID = optional.Some(opts.ProjectID) - } else if opts.ProjectID == -1 { // FIXME: this is inconsistent from other places - searchOpt.ProjectID = optional.Some[int64](0) // Those issues with no project(projectid==0) + if len(opts.ProjectIDs) == 1 && opts.ProjectIDs[0] == db.NoConditionID { + searchOpt.ProjectIDs = []int64{0} + } else { + searchOpt.ProjectIDs = opts.ProjectIDs // Those issues with no project(projectid==0) } if opts.AssigneeID > 0 { diff --git a/modules/indexer/issues/elasticsearch/elasticsearch.go b/modules/indexer/issues/elasticsearch/elasticsearch.go index 6f705150097f6..5c4755fbf32c9 100644 --- a/modules/indexer/issues/elasticsearch/elasticsearch.go +++ b/modules/indexer/issues/elasticsearch/elasticsearch.go @@ -18,7 +18,7 @@ import ( ) const ( - issueIndexerLatestVersion = 1 + issueIndexerLatestVersion = 2 // multi-match-types, currently only 2 types are used // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types esMultiMatchTypeBestFields = "best_fields" @@ -61,7 +61,8 @@ const ( "label_ids": { "type": "integer", "index": true }, "no_label": { "type": "boolean", "index": true }, "milestone_id": { "type": "integer", "index": true }, - "project_id": { "type": "integer", "index": true }, + "project_ids": { "type": "integer", "index": true }, + "no_project": { "type": "boolean", "index": true }, "project_board_id": { "type": "integer", "index": true }, "poster_id": { "type": "integer", "index": true }, "assignee_id": { "type": "integer", "index": true }, @@ -194,8 +195,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...)) } - if options.ProjectID.Has() { - query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value())) + if len(options.ProjectIDs) > 0 { + query.Must(elastic.NewTermsQuery("project_ids", toAnySlice(options.ProjectIDs)...)) } if options.ProjectColumnID.Has() { query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value())) diff --git a/modules/indexer/issues/indexer_test.go b/modules/indexer/issues/indexer_test.go index 0dce6541816dd..a5ac683136f8b 100644 --- a/modules/indexer/issues/indexer_test.go +++ b/modules/indexer/issues/indexer_test.go @@ -407,9 +407,9 @@ func searchIssueInProject(t *testing.T) { }{ { SearchOptions{ - ProjectID: optional.Some(int64(1)), + ProjectIDs: []int64{1}, }, - []int64{5, 3, 2, 1}, + []int64{2}, }, { SearchOptions{ diff --git a/modules/indexer/issues/internal/model.go b/modules/indexer/issues/internal/model.go index a43c6be005975..6d34d7a2f4ad6 100644 --- a/modules/indexer/issues/internal/model.go +++ b/modules/indexer/issues/internal/model.go @@ -28,7 +28,8 @@ type IndexerData struct { LabelIDs []int64 `json:"label_ids"` NoLabel bool `json:"no_label"` // True if LabelIDs is empty MilestoneID int64 `json:"milestone_id"` - ProjectID int64 `json:"project_id"` + ProjectIDs []int64 `json:"project_ids"` + NoProject bool `json:"no_project"` // True if ProjectIDs is empty ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible PosterID int64 `json:"poster_id"` AssigneeID int64 `json:"assignee_id"` @@ -91,7 +92,7 @@ type SearchOptions struct { MilestoneIDs []int64 // milestones the issues have - ProjectID optional.Option[int64] // project the issues belong to + ProjectIDs []int64 // project the issues belong to ProjectColumnID optional.Option[int64] // project column the issues belong to PosterID optional.Option[int64] // poster of the issues diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index 16f0a78ec0411..3b73bc58d18ad 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -302,38 +302,38 @@ var cases = []*testIndexerCase{ }, }, { - Name: "ProjectID", + Name: "ProjectIDs", SearchOptions: &internal.SearchOptions{ Paginator: &db.ListOptions{ PageSize: 5, }, - ProjectID: optional.Some(int64(1)), + ProjectIDs: []int64{1}, }, Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { assert.Equal(t, 5, len(result.Hits)) for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].ProjectID) + assert.Contains(t, []int64{1}, data[v.ID].ProjectIDs) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 1 + return slices.Contains(v.ProjectIDs, 1) }), result.Total) }, }, { - Name: "no ProjectID", + Name: "no ProjectIDs", SearchOptions: &internal.SearchOptions{ Paginator: &db.ListOptions{ PageSize: 5, }, - ProjectID: optional.Some(int64(0)), + ProjectIDs: []int64{0}, }, Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { assert.Equal(t, 5, len(result.Hits)) for _, v := range result.Hits { - assert.Equal(t, int64(0), data[v.ID].ProjectID) + assert.Empty(t, data[v.ID].ProjectIDs) } assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 0 + return len(v.ProjectIDs) == 0 }), result.Total) }, }, @@ -692,6 +692,10 @@ func generateDefaultIndexerData() []*internal.IndexerData { for i := range subscriberIDs { subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0 } + projectIDs := make([]int64, id%5) + for i := range projectIDs { + projectIDs[i] = int64(i) + 1 // ProjectID should not be 0 + } data = append(data, &internal.IndexerData{ ID: id, @@ -705,7 +709,8 @@ func generateDefaultIndexerData() []*internal.IndexerData { LabelIDs: labelIDs, NoLabel: len(labelIDs) == 0, MilestoneID: issueIndex % 4, - ProjectID: issueIndex % 5, + ProjectIDs: projectIDs, + NoProject: len(projectIDs) == 0, ProjectColumnID: issueIndex % 6, PosterID: id%10 + 1, // PosterID should not be 0 AssigneeID: issueIndex % 10, diff --git a/modules/indexer/issues/meilisearch/meilisearch.go b/modules/indexer/issues/meilisearch/meilisearch.go index 9332319339215..88ed6f14f80a9 100644 --- a/modules/indexer/issues/meilisearch/meilisearch.go +++ b/modules/indexer/issues/meilisearch/meilisearch.go @@ -18,7 +18,7 @@ import ( ) const ( - issueIndexerLatestVersion = 3 + issueIndexerLatestVersion = 4 // TODO: make this configurable if necessary maxTotalHits = 10000 @@ -64,7 +64,8 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer { "label_ids", "no_label", "milestone_id", - "project_id", + "project_ids", + "no_project", "project_board_id", "poster_id", "assignee_id", @@ -171,8 +172,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...)) } - if options.ProjectID.Has() { - query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value())) + if len(options.ProjectIDs) > 0 { + query.And(inner_meilisearch.NewFilterIn("project_ids", options.ProjectIDs...)) } if options.ProjectColumnID.Has() { query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value())) diff --git a/modules/indexer/issues/util.go b/modules/indexer/issues/util.go index e752ae6f2436c..a13757951b751 100644 --- a/modules/indexer/issues/util.go +++ b/modules/indexer/issues/util.go @@ -87,9 +87,9 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD return nil, false, err } - var projectID int64 - if issue.Project != nil { - projectID = issue.Project.ID + projectIDs := make([]int64, 0, len(issue.Projects)) + for _, project := range issue.Projects { + projectIDs = append(projectIDs, project.ID) } return &internal.IndexerData{ @@ -104,7 +104,7 @@ func getIssueIndexerData(ctx context.Context, issueID int64) (*internal.IndexerD LabelIDs: labels, NoLabel: len(labels) == 0, MilestoneID: issue.MilestoneID, - ProjectID: projectID, + ProjectIDs: projectIDs, ProjectColumnID: issue.ProjectColumnID(ctx), PosterID: issue.PosterID, AssigneeID: issue.AssigneeID, diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 507b5af9d904a..01cae9ca15fa8 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -202,12 +202,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt mileIDs = []int64{milestoneID} } + var projIDs []int64 + if projectID > 0 || projectID == db.NoConditionID { + projIDs = []int64{projectID} + } + var issueStats *issues_model.IssueStats statsOpts := &issues_model.IssuesOptions{ RepoIDs: []int64{repo.ID}, LabelIDs: labelIDs, MilestoneIDs: mileIDs, - ProjectID: projectID, + ProjectIDs: projIDs, AssigneeID: assigneeID, MentionedID: mentionedID, PosterID: posterID, @@ -297,7 +302,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ReviewRequestedID: reviewRequestedID, ReviewedID: reviewedID, MilestoneIDs: mileIDs, - ProjectID: projectID, + ProjectIDs: projIDs, IsClosed: isShowClosed, IsPull: isPullOption, LabelIDs: labelIDs, @@ -2707,7 +2712,7 @@ func SearchIssues(ctx *context.Context) { IsClosed: isClosed, IncludedAnyLabelIDs: includedAnyLabels, MilestoneIDs: includedMilestones, - ProjectID: projectID, + ProjectIDs: projectID, SortBy: issue_indexer.SortByCreatedDesc, } @@ -2870,12 +2875,12 @@ func ListIssues(ctx *context.Context) { Page: ctx.FormInt("page"), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }, - Keyword: keyword, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - IsPull: isPull, - IsClosed: isClosed, - ProjectID: projectID, - SortBy: issue_indexer.SortByCreatedDesc, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + ProjectIDs: projectID, + SortBy: issue_indexer.SortByCreatedDesc, } if since != 0 { searchOpt.UpdatedAfterUnix = optional.Some(since) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 664ea7eb76d79..9fa9501082b57 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -465,11 +465,9 @@ func UpdateIssueProject(ctx *context.Context) { } projectID := ctx.FormInt64("id") + action := ctx.FormString("action") for _, issue := range issues { - if issue.Project != nil && issue.Project.ID == projectID { - continue - } - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, ctx.Doer, projectID, 0, action); err != nil { if errors.Is(err, util.ErrPermissionDenied) { continue } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index ced0bbc15a00e..2ff21aea4e569 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1371,7 +1371,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { } if projectID > 0 && ctx.Repo.CanWrite(unit.TypeProjects) { - if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, pullIssue, ctx.Doer, projectID, 0, "attach"); err != nil { if !errors.Is(err, util.ErrPermissionDenied) { ctx.ServerError("IssueAssignOrRemoveProject", err) return diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 2b16142f6d591..1923137497763 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -360,7 +360,6 @@ func Issues(ctx *context.Context) { ctx.Status(http.StatusNotFound) return } - ctx.Data["Title"] = ctx.Tr("issues") ctx.Data["PageIsIssues"] = true buildIssueOverview(ctx, unit.TypeIssues) diff --git a/services/issue/issue.go b/services/issue/issue.go index 72ea66c8d98c5..f451797d58b5e 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -42,7 +42,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo } } if projectID > 0 { - if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0); err != nil { + if err := issues_model.IssueAssignOrRemoveProject(ctx, issue, issue.Poster, projectID, 0, "attach"); err != nil { return err } } diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index e49e90df56c9f..3bfd623bc6fde 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -158,7 +158,7 @@ {{if .IsProjectsEnabled}}
- {{range .ClosedProjects}} - + {{$ProjectID := $.Projects.IssueID}} + {{$checked := false}} + {{range $.Issue.Projects}} + {{if eq .IssueID $ProjectID}} + {{$checked = true}} + {{break}} + {{end}} + {{end}} + + {{svg "octicon-check"}} + {{svg .IconName 18 "tw-mr-2"}}{{.Title}} + {{end}} {{end}} -
- {{ctx.Locale.Tr "repo.issues.new.no_projects"}} +
+ {{ctx.Locale.Tr "repo.issues.new.no_projects"}}
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index 16c650ee3e45e..4149c9411b250 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -93,10 +93,10 @@ {{.Milestone.Name}} {{end}} - {{if .Project}} - - {{svg .Project.IconName 14}} - {{.Project.Title}} + {{range .Projects}} + + {{svg .IconName 14}} + {{.Title}} {{end}} {{if .Ref}} diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index 5844037770cf3..c6f7b904d7748 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -241,6 +241,7 @@ export function initRepoCommentForm() { // Init labels and assignees initListSubmits('select-label', 'labels'); + initListSubmits('select-projects', 'projects'); initListSubmits('select-assignees', 'assignees'); initListSubmits('select-assignees-modify', 'assignees'); initListSubmits('select-reviewers-modify', 'assignees');