From 6ca557371882871ab994b51df204942b45b5cf3b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 31 Mar 2026 10:03:52 +0800 Subject: [PATCH] Refactor issue sidebar and fix various problems (#37045) Fix various legacy problems, including: * Don't create default column when viewing an empty project * Fix layouts for Windows * Fix (partially) #15509 * Fix (partially) #17705 The sidebar refactoring: it is a clear partial-reloading approach, brings better user experiences, and it makes "Multiple projects" / "Project column on issue sidebar" feature easy to be added. --------- Signed-off-by: wxiaoguang --- models/issues/issue_project.go | 30 ----- models/project/column.go | 43 +++---- routers/web/repo/issue_new.go | 10 +- routers/web/repo/issue_page_meta.go | 42 ++++++- services/projects/issue.go | 9 +- templates/base/head_navbar.tmpl | 8 +- templates/base/head_navbar_icons.tmpl | 4 +- templates/repo/issue/new_form.tmpl | 2 +- .../repo/issue/sidebar/project_list.tmpl | 2 +- templates/repo/issue/view_content.tmpl | 1 + .../repo/issue/view_content/sidebar.tmpl | 2 +- .../repo/issue/view_content/watching.tmpl | 4 +- templates/shared/issuelist.tmpl | 2 +- web_src/css/modules/button.css | 1 - web_src/css/modules/label.css | 1 + .../repo-issue-sidebar-combolist.test.ts | 61 ++++++++++ .../features/repo-issue-sidebar-combolist.ts | 106 ++++++++++++++---- web_src/js/features/repo-issue-sidebar.md | 3 + web_src/js/features/repo-issue-sidebar.ts | 91 +++++++++++++-- web_src/js/features/repo-issue.ts | 70 +----------- web_src/js/index.ts | 4 +- 21 files changed, 317 insertions(+), 179 deletions(-) create mode 100644 web_src/js/features/repo-issue-sidebar-combolist.test.ts diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 0185244783..3bb0936301 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -64,36 +64,6 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i return result, nil } -// LoadIssuesFromColumn load issues assigned to this column -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.SortType = "project-column-sorting" - })) - if err != nil { - return nil, err - } - - if b.Default { - issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) { - o.ProjectColumnID = db.NoConditionID - o.ProjectID = b.ProjectID - o.SortType = "project-column-sorting" - })) - if err != nil { - return nil, err - } - issueList = append(issueList, issues...) - } - - if err := issueList.LoadComments(ctx); err != nil { - return nil, err - } - - return issueList, nil -} - // 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 { diff --git a/models/project/column.go b/models/project/column.go index 79f6dfe911..7365204f18 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -257,9 +257,12 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) { return columns, nil } -// getDefaultColumn return default column and ensure only one exists -func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) { +// getDefaultColumnWithFallback return default column if one exists +// otherwise return the first column by sorting and set it as default column +func (p *Project) getDefaultColumnWithFallback(ctx context.Context) (*Column, error) { var column Column + + // try to find a column "default=true" has, err := db.GetEngine(ctx). Where("project_id=? AND `default` = ?", p.ID, true). Desc("id").Get(&column) @@ -270,23 +273,9 @@ func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) { if has { return &column, nil } - return nil, ErrProjectColumnNotExist{ColumnID: 0} -} -// MustDefaultColumn returns the default column for a project. -// If one exists, it is returned -// If none exists, the first column will be elevated to the default column of this project -func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { - c, err := p.getDefaultColumn(ctx) - if err != nil && !IsErrProjectColumnNotExist(err) { - return nil, err - } - if c != nil { - return c, nil - } - - var column Column - has, err := db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column) + // try to find the first column by sorting + has, err = db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column) if err != nil { return nil, err } @@ -298,8 +287,24 @@ func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { return &column, nil } + return nil, ErrProjectColumnNotExist{ColumnID: 0} +} + +// MustDefaultColumn returns the default column for a project. +// If one exists, it is returned +// If none exists, the first column will be elevated to the default column of this project +// If there is no column, it creates a default column and returns it +func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) { + c, err := p.getDefaultColumnWithFallback(ctx) + if err != nil && !IsErrProjectColumnNotExist(err) { + return nil, err + } + if c != nil { + return c, nil + } + // create a default column if none is found - column = Column{ + column := Column{ ProjectID: p.ID, Default: true, Title: "Uncategorized", diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 1393f62fa5..98fb842ddf 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -121,11 +121,9 @@ func NewIssue(ctx *context.Context) { } pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone") - pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project") - if pageMetaData.ProjectsData.SelectedProjectID > 0 { - if len(ctx.Req.URL.Query().Get("project")) > 0 { - ctx.Data["redirect_after_creation"] = "project" - } + pageMetaData.ProjectsData.SelectedProjectIDs, _ = base.StringsToInt64s(strings.Split(ctx.FormString("project"), ",")) + if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 { + ctx.Data["redirect_after_creation"] = "project" } tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) @@ -273,7 +271,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo ctx.NotFound(nil) return ret } - pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID + pageMetaData.ProjectsData.SelectedProjectIDs = util.Iif(form.ProjectID > 0, []int64{form.ProjectID}, nil) // prepare assignees candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID }) diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 719b485bc5..639333ab42 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -34,9 +34,14 @@ type issueSidebarAssigneesData struct { } type issueSidebarProjectsData struct { - SelectedProjectID int64 - OpenProjects []*project_model.Project - ClosedProjects []*project_model.Project + 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 + + OpenProjects []*project_model.Project + ClosedProjects []*project_model.Project } type IssuePageMetaData struct { @@ -92,6 +97,11 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository return data } + data.retrieveProjectData(ctx) + if ctx.Written() { + return data + } + // TODO: the issue/pull permissions are quite complex and unclear // A reader could create an issue/PR with setting some meta (eg: assignees from issue template, reviewers, target branch) // A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore. @@ -158,9 +168,33 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees } +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 + } + for _, col := range columns { + if col.ID == columnID { + d.ProjectsData.SelectedProjectColumn = col + break + } + } +} + func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { if d.Issue != nil && d.Issue.Project != nil { - d.ProjectsData.SelectedProjectID = d.Issue.Project.ID + d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} } d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/services/projects/issue.go b/services/projects/issue.go index 590fe960d5..377c4b9d58 100644 --- a/services/projects/issue.go +++ b/services/projects/issue.go @@ -87,7 +87,7 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum } // LoadIssuesFromProject load issues assigned to each project column inside the given project -func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (map[int64]issues_model.IssueList, error) { +func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, opts *issues_model.IssuesOptions) (results map[int64]issues_model.IssueList, _ error) { issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) { o.ProjectID = project.ID o.SortType = "project-column-sorting" @@ -95,7 +95,10 @@ func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, if err != nil { return nil, err } - + if len(issueList) == 0 { + // if no issue, return directly, then no need to create a default column for an empty project + return results, nil + } if err := issueList.LoadComments(ctx); err != nil { return nil, err } @@ -110,7 +113,7 @@ func LoadIssuesFromProject(ctx context.Context, project *project_model.Project, return nil, err } - results := make(map[int64]issues_model.IssueList) + results = make(map[int64]issues_model.IssueList) for _, issue := range issueList { projectColumnID, ok := issueColumnMap[issue.ID] if !ok { diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 447f78565e..cc7e4e6775 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -47,7 +47,7 @@ {{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}} {{.SignedUser.Name}} - {{svg "octicon-triangle-down"}} + {{svg "octicon-triangle-down"}}