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:
wxiaoguang
2026-03-31 10:03:52 +08:00
committed by GitHub
parent daf581fa89
commit 6ca5573718
21 changed files with 317 additions and 179 deletions
-30
View File
@@ -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
View File
@@ -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",
+4 -6
View File
@@ -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 })
+38 -4
View File
@@ -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)
} }
+6 -3
View File
@@ -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 {
+4 -4
View File
@@ -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">
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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}}
+1
View File
@@ -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>
+1 -1
View File
@@ -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"}} &quot;{{.Title}}&quot;"> <input type="checkbox" autocomplete="off" class="issue-checkbox tw-mr-[14px]" data-issue-id={{.ID}} aria-label="{{ctx.Locale.Tr "repo.issues.action_check"}} &quot;{{.Title}}&quot;">
{{end}} {{end}}
-1
View File
@@ -214,7 +214,6 @@
.ui.fluid.button { .ui.fluid.button {
width: 100%; width: 100%;
display: block;
} }
.ui.primary.button, .ui.primary.button,
+1
View File
@@ -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
+79 -12
View File
@@ -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());
});
} }
+5 -65
View File
@@ -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
View File
@@ -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,