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 <wxiaoguang@gmail.com>
This commit is contained in:
@@ -64,36 +64,6 @@ func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID i
|
|||||||
return result, nil
|
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
|
// IssueAssignOrRemoveProject changes the project associated with an issue
|
||||||
// If newProjectID is 0, the issue is removed from the project
|
// 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) error {
|
||||||
|
|||||||
+24
-19
@@ -257,9 +257,12 @@ func (p *Project) GetColumns(ctx context.Context) (ColumnList, error) {
|
|||||||
return columns, nil
|
return columns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDefaultColumn return default column and ensure only one exists
|
// getDefaultColumnWithFallback return default column if one exists
|
||||||
func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) {
|
// 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
|
var column Column
|
||||||
|
|
||||||
|
// try to find a column "default=true"
|
||||||
has, err := db.GetEngine(ctx).
|
has, err := db.GetEngine(ctx).
|
||||||
Where("project_id=? AND `default` = ?", p.ID, true).
|
Where("project_id=? AND `default` = ?", p.ID, true).
|
||||||
Desc("id").Get(&column)
|
Desc("id").Get(&column)
|
||||||
@@ -270,23 +273,9 @@ func (p *Project) getDefaultColumn(ctx context.Context) (*Column, error) {
|
|||||||
if has {
|
if has {
|
||||||
return &column, nil
|
return &column, nil
|
||||||
}
|
}
|
||||||
return nil, ErrProjectColumnNotExist{ColumnID: 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustDefaultColumn returns the default column for a project.
|
// try to find the first column by sorting
|
||||||
// If one exists, it is returned
|
has, err = db.GetEngine(ctx).Where("project_id=?", p.ID).OrderBy("sorting, id").Get(&column)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -298,8 +287,24 @@ func (p *Project) MustDefaultColumn(ctx context.Context) (*Column, error) {
|
|||||||
return &column, nil
|
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
|
// create a default column if none is found
|
||||||
column = Column{
|
column := Column{
|
||||||
ProjectID: p.ID,
|
ProjectID: p.ID,
|
||||||
Default: true,
|
Default: true,
|
||||||
Title: "Uncategorized",
|
Title: "Uncategorized",
|
||||||
|
|||||||
@@ -121,11 +121,9 @@ func NewIssue(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
|
pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
|
||||||
pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project")
|
pageMetaData.ProjectsData.SelectedProjectIDs, _ = base.StringsToInt64s(strings.Split(ctx.FormString("project"), ","))
|
||||||
if pageMetaData.ProjectsData.SelectedProjectID > 0 {
|
if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 {
|
||||||
if len(ctx.Req.URL.Query().Get("project")) > 0 {
|
ctx.Data["redirect_after_creation"] = "project"
|
||||||
ctx.Data["redirect_after_creation"] = "project"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
|
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)
|
ctx.NotFound(nil)
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID
|
pageMetaData.ProjectsData.SelectedProjectIDs = util.Iif(form.ProjectID > 0, []int64{form.ProjectID}, nil)
|
||||||
|
|
||||||
// prepare assignees
|
// prepare assignees
|
||||||
candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
|
candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
|
||||||
|
|||||||
@@ -34,9 +34,14 @@ type issueSidebarAssigneesData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type issueSidebarProjectsData struct {
|
type issueSidebarProjectsData struct {
|
||||||
SelectedProjectID int64
|
SelectedProjectIDs []int64 // TODO: support multiple projects in the future
|
||||||
OpenProjects []*project_model.Project
|
|
||||||
ClosedProjects []*project_model.Project
|
// 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 {
|
type IssuePageMetaData struct {
|
||||||
@@ -92,6 +97,11 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.retrieveProjectData(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: the issue/pull permissions are quite complex and unclear
|
// 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 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.
|
// 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
|
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) {
|
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
|
||||||
if d.Issue != nil && d.Issue.Project != nil {
|
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)
|
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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) {
|
issueList, err := issues_model.Issues(ctx, opts.Copy(func(o *issues_model.IssuesOptions) {
|
||||||
o.ProjectID = project.ID
|
o.ProjectID = project.ID
|
||||||
o.SortType = "project-column-sorting"
|
o.SortType = "project-column-sorting"
|
||||||
@@ -95,7 +95,10 @@ func LoadIssuesFromProject(ctx context.Context, project *project_model.Project,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err := issueList.LoadComments(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -110,7 +113,7 @@ func LoadIssuesFromProject(ctx context.Context, project *project_model.Project,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
results := make(map[int64]issues_model.IssueList)
|
results = make(map[int64]issues_model.IssueList)
|
||||||
for _, issue := range issueList {
|
for _, issue := range issueList {
|
||||||
projectColumnID, ok := issueColumnMap[issue.ID]
|
projectColumnID, ok := issueColumnMap[issue.ID]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<span class="text">
|
<span class="text">
|
||||||
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}
|
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}
|
||||||
<span class="only-mobile">{{.SignedUser.Name}}</span>
|
<span class="only-mobile">{{.SignedUser.Name}}</span>
|
||||||
<span class="not-mobile">{{svg "octicon-triangle-down"}}</span>
|
<span class="not-mobile flex-text-block">{{svg "octicon-triangle-down"}}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="menu user-menu">
|
<div class="menu user-menu">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -64,9 +64,9 @@
|
|||||||
{{else if .IsSigned}}
|
{{else if .IsSigned}}
|
||||||
{{template "base/head_navbar_icons" dict "ItemExtraClass" "not-mobile" "PageGlobalData" .PageGlobalData}}
|
{{template "base/head_navbar_icons" dict "ItemExtraClass" "not-mobile" "PageGlobalData" .PageGlobalData}}
|
||||||
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "create_new"}}">
|
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "create_new"}}">
|
||||||
<span class="text">
|
<span class="flex-text-block">
|
||||||
{{svg "octicon-plus"}}
|
{{svg "octicon-plus"}}
|
||||||
<span class="not-mobile">{{svg "octicon-triangle-down"}}</span>
|
<span class="not-mobile flex-text-block">{{svg "octicon-triangle-down"}}</span>
|
||||||
<span class="only-mobile">{{ctx.Locale.Tr "create_new"}}</span>
|
<span class="only-mobile">{{ctx.Locale.Tr "create_new"}}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
{{if .IsAdmin}}{{svg "octicon-shield-check" 16 "navbar-admin-badge"}}{{end}}
|
{{if .IsAdmin}}{{svg "octicon-shield-check" 16 "navbar-admin-badge"}}{{end}}
|
||||||
</span>
|
</span>
|
||||||
<span class="only-mobile">{{.SignedUser.Name}}</span>
|
<span class="only-mobile">{{.SignedUser.Name}}</span>
|
||||||
<span class="not-mobile">{{svg "octicon-triangle-down"}}</span>
|
<span class="not-mobile flex-text-block">{{svg "octicon-triangle-down"}}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="menu user-menu">
|
<div class="menu user-menu">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
{{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
|
{{- $notificationUnreadCount := call $data.GetNotificationUnreadCount -}}
|
||||||
{{if $activeStopwatch}}
|
{{if $activeStopwatch}}
|
||||||
<a class="item active-stopwatch {{$itemExtraClass}}" href="{{$activeStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{$activeStopwatch.Seconds}}">
|
<a class="item active-stopwatch {{$itemExtraClass}}" href="{{$activeStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{$activeStopwatch.Seconds}}">
|
||||||
<div class="tw-relative">
|
<div class="tw-relative flex-text-block">
|
||||||
{{svg "octicon-stopwatch"}}
|
{{svg "octicon-stopwatch"}}
|
||||||
<span class="header-stopwatch-dot"></span>
|
<span class="header-stopwatch-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a class="item {{$itemExtraClass}}" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}">
|
<a class="item {{$itemExtraClass}}" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}">
|
||||||
<div class="tw-relative">
|
<div class="tw-relative flex-text-block">
|
||||||
{{svg "octicon-bell"}}
|
{{svg "octicon-bell"}}
|
||||||
<span class="notification_count{{if not $notificationUnreadCount}} tw-hidden{{end}}">{{$notificationUnreadCount}}</span>
|
<span class="notification_count{{if not $notificationUnreadCount}} tw-hidden{{end}}">{{$notificationUnreadCount}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="issue-content-right ui segment">
|
<div class="issue-content-right ui segment" data-global-init="initRepoIssueSidebar">
|
||||||
{{template "repo/issue/branch_selector_field" $}}{{/* TODO: RemoveIssueRef: template "repo/issue/branch_selector_field" $*/}}
|
{{template "repo/issue/branch_selector_field" $}}{{/* TODO: RemoveIssueRef: template "repo/issue/branch_selector_field" $*/}}
|
||||||
|
|
||||||
{{if .PageIsComparePull}}
|
{{if .PageIsComparePull}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all"
|
<div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all"
|
||||||
{{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
|
{{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
|
||||||
>
|
>
|
||||||
<input class="combo-value" name="project_id" type="hidden" value="{{$data.SelectedProjectID}}">
|
<input class="combo-value" name="project_id" type="hidden" value="{{if and $pageMeta.CanModifyIssueOrPull $data.SelectedProjectIDs}}{{index $data.SelectedProjectIDs 0}}{{end}}">
|
||||||
<div class="ui dropdown full-width {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}">
|
<div class="ui dropdown full-width {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}">
|
||||||
<a class="fixed-text muted">
|
<a class="fixed-text muted">
|
||||||
<strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
|
<strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "repo/issue/view_content/comments" .}}
|
{{template "repo/issue/view_content/comments" .}}
|
||||||
|
<div class="timeline-item tw-hidden" id="timeline-comments-end"></div>
|
||||||
|
|
||||||
{{if and .Issue.IsPull (not $.Repository.IsArchived)}}
|
{{if and .Issue.IsPull (not $.Repository.IsArchived)}}
|
||||||
{{template "repo/issue/view_content/pull_merge_box".}}
|
{{template "repo/issue/view_content/pull_merge_box".}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="issue-content-right ui segment">
|
<div class="issue-content-right ui segment" data-global-init="initRepoIssueSidebar">
|
||||||
{{template "repo/issue/branch_selector_field" $}}{{/* TODO: RemoveIssueRef: template "repo/issue/branch_selector_field" $*/}}
|
{{template "repo/issue/branch_selector_field" $}}{{/* TODO: RemoveIssueRef: template "repo/issue/branch_selector_field" $*/}}
|
||||||
|
|
||||||
{{if .Issue.IsPull}}
|
{{if .Issue.IsPull}}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<input type="hidden" name="watch" value="{{if $.IssueWatch.IsWatching}}0{{else}}1{{end}}">
|
<input type="hidden" name="watch" value="{{if $.IssueWatch.IsWatching}}0{{else}}1{{end}}">
|
||||||
<button class="fluid ui button">
|
<button class="fluid ui button">
|
||||||
{{if $.IssueWatch.IsWatching}}
|
{{if $.IssueWatch.IsWatching}}
|
||||||
{{svg "octicon-mute" 16 "tw-mr-2"}}
|
{{svg "octicon-mute" 16}}
|
||||||
{{ctx.Locale.Tr "repo.issues.unsubscribe"}}
|
{{ctx.Locale.Tr "repo.issues.unsubscribe"}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{svg "octicon-unmute" 16 "tw-mr-2"}}
|
{{svg "octicon-unmute" 16}}
|
||||||
{{ctx.Locale.Tr "repo.issues.subscribe"}}
|
{{ctx.Locale.Tr "repo.issues.subscribe"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div class="flex-item-leading">
|
<div class="flex-item-leading">
|
||||||
{{/* using some tw helpers is the only way to align the checkbox */}}
|
{{/* using some tw helpers is the only way to align the checkbox */}}
|
||||||
<div class="flex-text-inline tw-mt-[2px]">
|
<div class="flex-text-inline tw-mt-[3px]">
|
||||||
{{if $.CanWriteIssuesOrPulls}}
|
{{if $.CanWriteIssuesOrPulls}}
|
||||||
<input type="checkbox" autocomplete="off" class="issue-checkbox tw-mr-[14px]" data-issue-id={{.ID}} aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}} "{{.Title}}"">
|
<input type="checkbox" autocomplete="off" class="issue-checkbox tw-mr-[14px]" data-issue-id={{.ID}} aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}} "{{.Title}}"">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -214,7 +214,6 @@
|
|||||||
|
|
||||||
.ui.fluid.button {
|
.ui.fluid.button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.primary.button,
|
.ui.primary.button,
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ If the labels-list itself needs some layouts, use extra classes or "tw" helpers.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.labels-list a {
|
.labels-list a {
|
||||||
|
display: inline-flex;
|
||||||
max-width: 100%; /* for ellipsis */
|
max-width: 100%; /* for ellipsis */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {syncIssueMainContentTimelineItems} from './repo-issue-sidebar-combolist.ts';
|
||||||
|
import {createElementFromHTML} from '../utils/dom.ts';
|
||||||
|
|
||||||
|
describe('syncIssueMainContentTimelineItems', () => {
|
||||||
|
test('InsertNew', () => {
|
||||||
|
const oldContent = createElementFromHTML(`
|
||||||
|
<div>
|
||||||
|
<div class="timeline-item">First</div>
|
||||||
|
<div class="timeline-item" id="timeline-comments-end"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
const newContent = createElementFromHTML(`
|
||||||
|
<div>
|
||||||
|
<div class="timeline-item" id="a">New</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
syncIssueMainContentTimelineItems(oldContent, newContent);
|
||||||
|
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
|
||||||
|
`<div class="timeline-item">First</div>` +
|
||||||
|
`<div class="timeline-item" id="a">New</div>` +
|
||||||
|
`<div class="timeline-item" id="timeline-comments-end"></div>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sync', () => {
|
||||||
|
const oldContent = createElementFromHTML(`
|
||||||
|
<div>
|
||||||
|
<div class="timeline-item">First</div>
|
||||||
|
<div class="timeline-item" id="it-1">Item 1</div>
|
||||||
|
<div class="timeline-item event" id="it-2">Item 2</div>
|
||||||
|
<div class="timeline-item" id="it-3">Item 3</div>
|
||||||
|
<div class="timeline-item event" id="it-4">Item 4</div>
|
||||||
|
<div class="timeline-item" id="timeline-comments-end"></div>
|
||||||
|
<div class="timeline-item">Other</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
const newContent = createElementFromHTML(`
|
||||||
|
<div>
|
||||||
|
<div class="timeline-item" id="it-1">New 1</div>
|
||||||
|
<div class="timeline-item event" id="it-2">New 2</div>
|
||||||
|
<div class="timeline-item" id="it-x">New X</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
syncIssueMainContentTimelineItems(oldContent, newContent);
|
||||||
|
|
||||||
|
// Item 1 won't be replaced because it's not an event
|
||||||
|
// Item 2 will be replaced with New 2
|
||||||
|
// Item 3 will be kept because it's not in new content
|
||||||
|
// Item 4 will be removed because it's not in new content, and it's an event
|
||||||
|
// New X will be inserted at the end of timeline items (before timeline-comments-end)
|
||||||
|
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
|
||||||
|
`<div class="timeline-item">First</div>` +
|
||||||
|
`<div class="timeline-item" id="it-1">Item 1</div>` +
|
||||||
|
`<div class="timeline-item event" id="it-2">New 2</div>` +
|
||||||
|
`<div class="timeline-item" id="it-3">Item 3</div>` +
|
||||||
|
`<div class="timeline-item" id="it-x">New X</div>` +
|
||||||
|
`<div class="timeline-item" id="timeline-comments-end"></div>` +
|
||||||
|
`<div class="timeline-item">Other</div>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,40 @@
|
|||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {POST} from '../modules/fetch.ts';
|
import {GET, POST} from '../modules/fetch.ts';
|
||||||
|
import {showErrorToast} from '../modules/toast.ts';
|
||||||
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||||
|
import {parseDom} from '../utils.ts';
|
||||||
|
|
||||||
// if there are draft comments, confirm before reloading, to avoid losing comments
|
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
|
||||||
function issueSidebarReloadConfirmDraftComment() {
|
// find the end of comments timeline by "id=timeline-comments-end" in current main content, and insert new items before it
|
||||||
const commentTextareas = [
|
const timelineEnd = oldMainContent.querySelector('.timeline-item[id="timeline-comments-end"]');
|
||||||
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
|
if (!timelineEnd) return;
|
||||||
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
|
|
||||||
];
|
const oldTimelineItems = oldMainContent.querySelectorAll(`.timeline-item[id]`);
|
||||||
for (const textarea of commentTextareas) {
|
for (const oldItem of oldTimelineItems) {
|
||||||
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
|
const oldItemId = oldItem.getAttribute('id')!;
|
||||||
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
|
const newItem = newMainContent.querySelector(`.timeline-item[id="${CSS.escape(oldItemId)}"]`);
|
||||||
if (textarea && textarea.value.trim().length > 10) {
|
if (oldItem.classList.contains('event') && !newItem) {
|
||||||
textarea.parentElement!.scrollIntoView();
|
// if the item is not in new content, we want to remove it from old content only if it's an event item, otherwise we keep it
|
||||||
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
|
oldItem.remove();
|
||||||
return;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.location.reload();
|
|
||||||
|
const newTimelineItems = newMainContent.querySelectorAll(`.timeline-item[id]`);
|
||||||
|
for (const newItem of newTimelineItems) {
|
||||||
|
const newItemId = newItem.getAttribute('id')!;
|
||||||
|
const oldItem = oldMainContent.querySelector(`.timeline-item[id="${CSS.escape(newItemId)}"]`);
|
||||||
|
if (oldItem) {
|
||||||
|
if (oldItem.classList.contains('event')) {
|
||||||
|
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
|
||||||
|
// because the label operations can be merged into one event item, so the new item might be different from the old one
|
||||||
|
oldItem.replaceWith(newItem);
|
||||||
|
window.htmx.process(newItem);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
timelineEnd.insertAdjacentElement('beforebegin', newItem);
|
||||||
|
window.htmx.process(newItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IssueSidebarComboList {
|
export class IssueSidebarComboList {
|
||||||
@@ -27,11 +42,14 @@ export class IssueSidebarComboList {
|
|||||||
updateAlgo: string;
|
updateAlgo: string;
|
||||||
selectionMode: string;
|
selectionMode: string;
|
||||||
elDropdown: HTMLElement;
|
elDropdown: HTMLElement;
|
||||||
elList: HTMLElement;
|
elList: HTMLElement | null;
|
||||||
elComboValue: HTMLInputElement;
|
elComboValue: HTMLInputElement;
|
||||||
initialValues: string[];
|
initialValues: string[];
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
||||||
|
elIssueMainContent: HTMLElement;
|
||||||
|
elIssueSidebar: HTMLElement;
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.updateUrl = container.getAttribute('data-update-url')!;
|
this.updateUrl = container.getAttribute('data-update-url')!;
|
||||||
@@ -40,8 +58,11 @@ export class IssueSidebarComboList {
|
|||||||
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
|
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
|
||||||
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
|
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
|
||||||
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown')!;
|
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown')!;
|
||||||
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list')!;
|
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list');
|
||||||
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value')!;
|
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value')!;
|
||||||
|
|
||||||
|
this.elIssueMainContent = document.querySelector('.issue-content-left')!;
|
||||||
|
this.elIssueSidebar = document.querySelector('.issue-content-right')!;
|
||||||
}
|
}
|
||||||
|
|
||||||
collectCheckedValues() {
|
collectCheckedValues() {
|
||||||
@@ -49,6 +70,7 @@ export class IssueSidebarComboList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUiList(changedValues: Array<string>) {
|
updateUiList(changedValues: Array<string>) {
|
||||||
|
if (!this.elList) return;
|
||||||
const elEmptyTip = this.elList.querySelector('.item.empty-list')!;
|
const elEmptyTip = this.elList.querySelector('.item.empty-list')!;
|
||||||
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||||
for (const value of changedValues) {
|
for (const value of changedValues) {
|
||||||
@@ -62,22 +84,58 @@ export class IssueSidebarComboList {
|
|||||||
toggleElem(elEmptyTip, !hasItems);
|
toggleElem(elEmptyTip, !hasItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateToBackend(changedValues: Array<string>) {
|
async reloadPagePartially() {
|
||||||
|
const resp = await GET(window.location.href);
|
||||||
|
if (!resp.ok) throw new Error(`Failed to reload page: ${resp.statusText}`);
|
||||||
|
const doc = parseDom(await resp.text(), 'text/html');
|
||||||
|
|
||||||
|
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
|
||||||
|
const newSidebar = doc.querySelector('.issue-content-right')!;
|
||||||
|
this.elIssueSidebar.replaceWith(newSidebar);
|
||||||
|
window.htmx.process(newSidebar);
|
||||||
|
|
||||||
|
// for the main content (left side), at the moment we only support handling known timeline items
|
||||||
|
const newMainContent = doc.querySelector('.issue-content-left')!;
|
||||||
|
syncIssueMainContentTimelineItems(this.elIssueMainContent, newMainContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendRequestToBackend(changedValues: Array<string>): Promise<Response | null> {
|
||||||
|
let lastResp: Response | null = null;
|
||||||
if (this.updateAlgo === 'diff') {
|
if (this.updateAlgo === 'diff') {
|
||||||
for (const value of this.initialValues) {
|
for (const value of this.initialValues) {
|
||||||
if (!changedValues.includes(value)) {
|
if (!changedValues.includes(value)) {
|
||||||
await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
|
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
|
||||||
|
if (!lastResp.ok) return lastResp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const value of changedValues) {
|
for (const value of changedValues) {
|
||||||
if (!this.initialValues.includes(value)) {
|
if (!this.initialValues.includes(value)) {
|
||||||
await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
|
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
|
||||||
|
if (!lastResp.ok) return lastResp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
|
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
|
||||||
|
}
|
||||||
|
return lastResp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateToBackend(changedValues: Array<string>) {
|
||||||
|
this.elIssueSidebar.classList.add('is-loading');
|
||||||
|
try {
|
||||||
|
const resp = await this.sendRequestToBackend(changedValues);
|
||||||
|
if (!resp) return; // no request sent, no need to reload
|
||||||
|
if (!resp.ok) {
|
||||||
|
showErrorToast(`Failed to update to backend: ${resp.statusText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.reloadPagePartially();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update to backend', e);
|
||||||
|
showErrorToast(`Failed to update to backend: ${e}`);
|
||||||
|
} finally {
|
||||||
|
this.elIssueSidebar.classList.remove('is-loading');
|
||||||
}
|
}
|
||||||
issueSidebarReloadConfirmDraftComment();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async doUpdate() {
|
async doUpdate() {
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ If there is `data-update-url`, it also calls backend to attach/detach the change
|
|||||||
Also, the changed items will be synchronized to the `ui list` items.
|
Also, the changed items will be synchronized to the `ui list` items.
|
||||||
The menu items must have correct `href`, otherwise the links of synchronized (cloned) items would be wrong.
|
The menu items must have correct `href`, otherwise the links of synchronized (cloned) items would be wrong.
|
||||||
|
|
||||||
|
The `ui list` is optional, so a single dropdown can also work, to select items and update them to backend.
|
||||||
|
|
||||||
Synchronization logic:
|
Synchronization logic:
|
||||||
* On page load:
|
* On page load:
|
||||||
* If the dropdown menu contains checked items, there will be no synchronization.
|
* If the dropdown menu contains checked items, there will be no synchronization.
|
||||||
In this case, it's assumed that the dropdown menu is already in sync with the list.
|
In this case, it's assumed that the dropdown menu is already in sync with the list.
|
||||||
* If the dropdown menu doesn't contain checked items, it will use dropdown's value to mark the selected items as checked.
|
* If the dropdown menu doesn't contain checked items, it will use dropdown's value to mark the selected items as checked.
|
||||||
And the selected (checked) items will be synchronized to the list.
|
And the selected (checked) items will be synchronized to the list.
|
||||||
|
Dropdown's value should be empty if the there is no dropdown item but a pre-defined list item need to be displayed.
|
||||||
* On dropdown selection change:
|
* On dropdown selection change:
|
||||||
* The selected items will be synchronized to the list after the dropdown is hidden
|
* The selected items will be synchronized to the list after the dropdown is hidden
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import {POST} from '../modules/fetch.ts';
|
import {POST} from '../modules/fetch.ts';
|
||||||
import {queryElems, toggleElem} from '../utils/dom.ts';
|
import {queryElems, toggleElem} from '../utils/dom.ts';
|
||||||
import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
|
import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
|
||||||
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
|
import {parseIssuePageInfo} from '../utils.ts';
|
||||||
|
import {html} from '../utils/html.ts';
|
||||||
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
|
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||||
|
|
||||||
function initBranchSelector() {
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
|
function initRepoIssueBranchSelector(elSidebar: HTMLElement) {
|
||||||
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
|
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
|
||||||
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch.branch-selector-dropdown');
|
const elSelectBranch = elSidebar.querySelector('.ui.dropdown.select-branch.branch-selector-dropdown');
|
||||||
if (!elSelectBranch) return;
|
if (!elSelectBranch) return;
|
||||||
|
|
||||||
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
|
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
|
||||||
const elBranchMenu = elSelectBranch.querySelector('.reference-list-menu')!;
|
const elBranchMenu = elSelectBranch.querySelector('.reference-list-menu')!;
|
||||||
queryElems(elBranchMenu, '.item:not(.no-select)', (el) => el.addEventListener('click', async function (e) {
|
queryElems(elBranchMenu, '.item:not(.no-select)', (el) => el.addEventListener('click', async function (e) {
|
||||||
@@ -30,23 +36,84 @@ function initBranchSelector() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function initRepoIssueDue() {
|
function initRepoIssueDue(elSidebar: HTMLElement) {
|
||||||
const form = document.querySelector<HTMLFormElement>('.issue-due-form');
|
const form = elSidebar.querySelector<HTMLFormElement>('.issue-due-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]')!;
|
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]')!;
|
||||||
document.querySelector('.issue-due-edit')?.addEventListener('click', () => {
|
elSidebar.querySelector('.issue-due-edit')?.addEventListener('click', () => {
|
||||||
toggleElem(form);
|
toggleElem(form);
|
||||||
});
|
});
|
||||||
document.querySelector('.issue-due-remove')?.addEventListener('click', () => {
|
elSidebar.querySelector('.issue-due-remove')?.addEventListener('click', () => {
|
||||||
deadline.value = '';
|
deadline.value = '';
|
||||||
form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
|
form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRepoIssueSidebar() {
|
export function initRepoIssueSidebarDependency(elSidebar: HTMLElement) {
|
||||||
initBranchSelector();
|
const elDropdown = elSidebar.querySelector('#new-dependency-drop-list');
|
||||||
initRepoIssueDue();
|
if (!elDropdown) return;
|
||||||
|
|
||||||
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
|
const issuePageInfo = parseIssuePageInfo();
|
||||||
queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
|
const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
|
||||||
|
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
|
||||||
|
if (crossRepoSearch === 'true') {
|
||||||
|
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
|
||||||
|
}
|
||||||
|
fomanticQuery(elDropdown).dropdown({
|
||||||
|
fullTextSearch: true,
|
||||||
|
apiSettings: {
|
||||||
|
cache: false,
|
||||||
|
rawResponse: true,
|
||||||
|
url: issueSearchUrl,
|
||||||
|
onResponse(response: any) {
|
||||||
|
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
||||||
|
const currIssueId = elDropdown.getAttribute('data-issue-id');
|
||||||
|
// Parse the response from the api to work with our dropdown
|
||||||
|
for (const issue of response) {
|
||||||
|
// Don't list current issue in the dependency list.
|
||||||
|
if (String(issue.id) === currIssueId) continue;
|
||||||
|
filteredResponse.results.push({
|
||||||
|
value: issue.id,
|
||||||
|
name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return filteredResponse;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRepoPullRequestAllowMaintainerEdit(elSidebar: HTMLElement) {
|
||||||
|
const wrapper = elSidebar.querySelector('#allow-edits-from-maintainers')!;
|
||||||
|
if (!wrapper) return;
|
||||||
|
const checkbox = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
|
||||||
|
checkbox.addEventListener('input', async () => {
|
||||||
|
const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
|
||||||
|
wrapper.classList.add('is-loading');
|
||||||
|
try {
|
||||||
|
const resp = await POST(url, {data: new URLSearchParams({allow_maintainer_edit: String(checkbox.checked)})});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error('Failed to update maintainer edit permission');
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
checkbox.checked = data.allow_maintainer_edit;
|
||||||
|
} catch (error) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
console.error(error);
|
||||||
|
showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error')!);
|
||||||
|
} finally {
|
||||||
|
wrapper.classList.remove('is-loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRepoIssueSidebar() {
|
||||||
|
registerGlobalInitFunc('initRepoIssueSidebar', (elSidebar) => {
|
||||||
|
initRepoIssueBranchSelector(elSidebar);
|
||||||
|
initRepoIssueDue(elSidebar);
|
||||||
|
initRepoIssueSidebarDependency(elSidebar);
|
||||||
|
initRepoPullRequestAllowMaintainerEdit(elSidebar);
|
||||||
|
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
|
||||||
|
queryElems(elSidebar, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {html, htmlEscape} from '../utils/html.ts';
|
import {htmlEscape} from '../utils/html.ts';
|
||||||
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
|
import {createTippy} from '../modules/tippy.ts';
|
||||||
import {
|
import {
|
||||||
addDelegatedEventListener,
|
addDelegatedEventListener,
|
||||||
createElementFromHTML,
|
createElementFromHTML,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '../utils/dom.ts';
|
} from '../utils/dom.ts';
|
||||||
import {setFileFolding} from './file-fold.ts';
|
import {setFileFolding} from './file-fold.ts';
|
||||||
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||||
import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts';
|
import {toAbsoluteUrl} from '../utils.ts';
|
||||||
import {GET, POST} from '../modules/fetch.ts';
|
import {GET, POST} from '../modules/fetch.ts';
|
||||||
import {showErrorToast} from '../modules/toast.ts';
|
import {showErrorToast} from '../modules/toast.ts';
|
||||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||||
@@ -20,40 +20,6 @@ import {registerGlobalInitFunc} from '../modules/observer.ts';
|
|||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
export function initRepoIssueSidebarDependency() {
|
|
||||||
const elDropdown = document.querySelector('#new-dependency-drop-list');
|
|
||||||
if (!elDropdown) return;
|
|
||||||
|
|
||||||
const issuePageInfo = parseIssuePageInfo();
|
|
||||||
const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
|
|
||||||
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
|
|
||||||
if (crossRepoSearch === 'true') {
|
|
||||||
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
|
|
||||||
}
|
|
||||||
fomanticQuery(elDropdown).dropdown({
|
|
||||||
fullTextSearch: true,
|
|
||||||
apiSettings: {
|
|
||||||
cache: false,
|
|
||||||
rawResponse: true,
|
|
||||||
url: issueSearchUrl,
|
|
||||||
onResponse(response: any) {
|
|
||||||
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
|
||||||
const currIssueId = elDropdown.getAttribute('data-issue-id');
|
|
||||||
// Parse the response from the api to work with our dropdown
|
|
||||||
for (const issue of response) {
|
|
||||||
// Don't list current issue in the dependency list.
|
|
||||||
if (String(issue.id) === currIssueId) continue;
|
|
||||||
filteredResponse.results.push({
|
|
||||||
value: issue.id,
|
|
||||||
name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return filteredResponse;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
|
function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const showArchivedLabels = url.searchParams.get('archived_labels') === 'true';
|
const showArchivedLabels = url.searchParams.get('archived_labels') === 'true';
|
||||||
@@ -197,32 +163,6 @@ export function initRepoIssueCodeCommentCancel() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRepoPullRequestAllowMaintainerEdit() {
|
|
||||||
const wrapper = document.querySelector('#allow-edits-from-maintainers')!;
|
|
||||||
if (!wrapper) return;
|
|
||||||
const checkbox = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
|
|
||||||
checkbox.addEventListener('input', async () => {
|
|
||||||
const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
|
|
||||||
wrapper.classList.add('is-loading');
|
|
||||||
try {
|
|
||||||
const resp = await POST(url, {data: new URLSearchParams({
|
|
||||||
allow_maintainer_edit: String(checkbox.checked),
|
|
||||||
})});
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error('Failed to update maintainer edit permission');
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
checkbox.checked = data.allow_maintainer_edit;
|
|
||||||
} catch (error) {
|
|
||||||
checkbox.checked = !checkbox.checked;
|
|
||||||
console.error(error);
|
|
||||||
showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error')!);
|
|
||||||
} finally {
|
|
||||||
wrapper.classList.remove('is-loading');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initRepoIssueComments() {
|
export function initRepoIssueComments() {
|
||||||
if (!document.querySelector('.repository.view.issue .timeline')) return;
|
if (!document.querySelector('.repository.view.issue .timeline')) return;
|
||||||
|
|
||||||
@@ -566,6 +506,8 @@ function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initRepoCommentFormAndSidebar() {
|
export function initRepoCommentFormAndSidebar() {
|
||||||
|
initRepoIssueSidebar();
|
||||||
|
|
||||||
const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
|
const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
|
||||||
if (!commentForm) return;
|
if (!commentForm) return;
|
||||||
|
|
||||||
@@ -576,6 +518,4 @@ export function initRepoCommentFormAndSidebar() {
|
|||||||
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
|
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
|
||||||
initSingleCommentEditor(commentForm);
|
initSingleCommentEditor(commentForm);
|
||||||
}
|
}
|
||||||
|
|
||||||
initRepoIssueSidebar();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -21,7 +21,7 @@ import {initRepoFileSearch} from './features/repo-findfile.ts';
|
|||||||
import {initMarkupContent} from './markup/content.ts';
|
import {initMarkupContent} from './markup/content.ts';
|
||||||
import {initRepoFileView} from './features/file-view.ts';
|
import {initRepoFileView} from './features/file-view.ts';
|
||||||
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
|
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
|
||||||
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
|
import {initRepoPullRequestReview, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
|
||||||
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
||||||
import {initRepoTopicBar} from './features/repo-home.ts';
|
import {initRepoTopicBar} from './features/repo-home.ts';
|
||||||
import {initAdminCommon} from './features/admin/common.ts';
|
import {initAdminCommon} from './features/admin/common.ts';
|
||||||
@@ -130,11 +130,9 @@ const initPerformanceTracer = callInitFunctions([
|
|||||||
initRepoIssueContentHistory,
|
initRepoIssueContentHistory,
|
||||||
initRepoIssueList,
|
initRepoIssueList,
|
||||||
initRepoIssueFilterItemLabel,
|
initRepoIssueFilterItemLabel,
|
||||||
initRepoIssueSidebarDependency,
|
|
||||||
initRepoMigration,
|
initRepoMigration,
|
||||||
initRepoMigrationStatusChecker,
|
initRepoMigrationStatusChecker,
|
||||||
initRepoProject,
|
initRepoProject,
|
||||||
initRepoPullRequestAllowMaintainerEdit,
|
|
||||||
initRepoPullRequestReview,
|
initRepoPullRequestReview,
|
||||||
initRepoReleaseNew,
|
initRepoReleaseNew,
|
||||||
initRepoTopicBar,
|
initRepoTopicBar,
|
||||||
|
|||||||
Reference in New Issue
Block a user