Merge pull request '[UI] Convert milestone to HTMX' (#4542) from gusted/htmx-milestone into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4542
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-07-20 07:49:28 +00:00
commit eb61437a52
8 changed files with 120 additions and 41 deletions

1
release-notes/4547.md Normal file
View file

@ -0,0 +1 @@
The milestone section in the sidebar on the issue and pull request page now uses HTMX. If you update the milestone of a issue or pull request it will no longer reload the whole page and instead update the current page with the new information about the milestone update. This should provide a smoother user experience.

View file

@ -1370,6 +1370,22 @@ func getBranchData(ctx *context.Context, issue *issues_model.Issue) {
} }
} }
func prepareHiddenCommentType(ctx *context.Context) {
var hiddenCommentTypes *big.Int
if ctx.IsSigned {
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
if err != nil {
ctx.ServerError("GetUserSetting", err)
return
}
hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here
}
ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool {
return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0
}
}
// ViewIssue render issue view page // ViewIssue render issue view page
func ViewIssue(ctx *context.Context) { func ViewIssue(ctx *context.Context) {
if ctx.Params(":type") == "issues" { if ctx.Params(":type") == "issues" {
@ -2019,21 +2035,13 @@ func ViewIssue(ctx *context.Context) {
ctx.Data["NewPinAllowed"] = pinAllowed ctx.Data["NewPinAllowed"] = pinAllowed
ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0 ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0
var hiddenCommentTypes *big.Int prepareHiddenCommentType(ctx)
if ctx.IsSigned { if ctx.Written() {
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)
if err != nil {
ctx.ServerError("GetUserSetting", err)
return return
} }
hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here
}
ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool {
return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0
}
// For sidebar // For sidebar
PrepareBranchList(ctx) PrepareBranchList(ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -2342,8 +2350,50 @@ func UpdateIssueMilestone(ctx *context.Context) {
} }
} }
if ctx.FormBool("htmx") {
renderMilestones(ctx)
if ctx.Written() {
return
}
prepareHiddenCommentType(ctx)
if ctx.Written() {
return
}
issue := issues[0]
var err error
if issue.MilestoneID > 0 {
issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, issue.MilestoneID)
if err != nil {
ctx.ServerError("GetMilestoneByRepoID", err)
return
}
} else {
issue.Milestone = nil
}
comment := &issues_model.Comment{}
has, err := db.GetEngine(ctx).Where("issue_id = ? AND type = ?", issue.ID, issues_model.CommentTypeMilestone).OrderBy("id DESC").Limit(1).Get(comment)
if !has || err != nil {
ctx.ServerError("GetLatestMilestoneComment", err)
}
if err := comment.LoadMilestone(ctx); err != nil {
ctx.ServerError("LoadMilestone", err)
return
}
if err := comment.LoadPoster(ctx); err != nil {
ctx.ServerError("LoadPoster", err)
return
}
issue.Comments = issues_model.CommentList{comment}
ctx.Data["Issue"] = issue
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
ctx.HTML(http.StatusOK, "htmx/milestone_sidebar")
} else {
ctx.JSONOK() ctx.JSONOK()
} }
}
// UpdateIssueAssignee change issue's or pull's assignee // UpdateIssueAssignee change issue's or pull's assignee
func UpdateIssueAssignee(ctx *context.Context) { func UpdateIssueAssignee(ctx *context.Context) {

View file

@ -0,0 +1,4 @@
<div id="insert-timeline" hx-swap-oob="beforebegin">
{{template "repo/issue/view_content/comments" .}}
</div>
{{template "repo/issue/view_content/sidebar/milestones" .}}

View file

@ -5,7 +5,7 @@
</div> </div>
<div class="divider"></div> <div class="divider"></div>
{{end}} {{end}}
<div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}</div> <div class="no-select item" hx-post="{{$.RepoLink}}/issues/milestone?issue_ids={{$.Issue.ID}}&htmx=true">{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}</div>
{{if and (not .OpenMilestones) (not .ClosedMilestones)}} {{if and (not .OpenMilestones) (not .ClosedMilestones)}}
<div class="disabled item"> <div class="disabled item">
{{ctx.Locale.Tr "repo.issues.new.no_items"}} {{ctx.Locale.Tr "repo.issues.new.no_items"}}
@ -17,7 +17,7 @@
{{ctx.Locale.Tr "repo.issues.new.open_milestone"}} {{ctx.Locale.Tr "repo.issues.new.open_milestone"}}
</div> </div>
{{range .OpenMilestones}} {{range .OpenMilestones}}
<a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?milestone={{.ID}}"> <a class="item" hx-post="{{$.RepoLink}}/issues/milestone?id={{.ID}}&issue_ids={{$.Issue.ID}}&htmx=true">
{{svg "octicon-milestone" 16 "tw-mr-1"}} {{svg "octicon-milestone" 16 "tw-mr-1"}}
{{.Name}} {{.Name}}
</a> </a>
@ -29,7 +29,7 @@
{{ctx.Locale.Tr "repo.issues.new.closed_milestone"}} {{ctx.Locale.Tr "repo.issues.new.closed_milestone"}}
</div> </div>
{{range .ClosedMilestones}} {{range .ClosedMilestones}}
<a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?milestone={{.ID}}"> <a class="item" hx-post="{{$.RepoLink}}/issues/milestone?id={{.ID}}&issue_ids={{$.Issue.ID}}&htmx=true">
{{svg "octicon-milestone" 16 "tw-mr-1"}} {{svg "octicon-milestone" 16 "tw-mr-1"}}
{{.Name}} {{.Name}}
</a> </a>

View file

@ -73,6 +73,7 @@
</div> </div>
{{template "repo/issue/view_content/comments" .}} {{template "repo/issue/view_content/comments" .}}
<div id="insert-timeline"></div>
{{if and .Issue.IsPull (not $.Repository.IsArchived)}} {{if and .Issue.IsPull (not $.Repository.IsArchived)}}
{{template "repo/issue/view_content/pull".}} {{template "repo/issue/view_content/pull".}}

View file

@ -1,3 +1,4 @@
<div id="milestone-section" hx-swap="morph" hx-target="this" hx-indicator="this">
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown"> <div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown">
<a class="text muted flex-text-block"> <a class="text muted flex-text-block">
<strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> <strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong>
@ -5,7 +6,7 @@
{{svg "octicon-gear" 16 "tw-ml-1"}} {{svg "octicon-gear" 16 "tw-ml-1"}}
{{end}} {{end}}
</a> </a>
<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/milestone"> <div class="menu">
{{template "repo/issue/milestone/select_menu" .}} {{template "repo/issue/milestone/select_menu" .}}
</div> </div>
</div> </div>
@ -20,3 +21,4 @@
{{end}} {{end}}
</div> </div>
</div> </div>
</div>

View file

@ -84,3 +84,27 @@ test('Issue: Labels', async ({browser}, workerInfo) => {
await expect(labelList.filter({hasText: 'label2'})).not.toBeVisible(); await expect(labelList.filter({hasText: 'label2'})).not.toBeVisible();
await expect(labelList.filter({hasText: 'label1'})).toBeVisible(); await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
}); });
test('Issue: Milestone', async ({browser}, workerInfo) => {
test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
const page = await login({browser}, workerInfo);
const response = await page.goto('/user2/repo1/issues/1');
await expect(response?.status()).toBe(200);
const selectedMilestone = page.locator('.issue-content-right .select-milestone.list');
const milestoneDropdown = page.locator('.issue-content-right .select-milestone.dropdown');
await expect(selectedMilestone).toContainText('No milestone');
// Add milestone.
await milestoneDropdown.click();
await page.getByRole('option', {name: 'milestone1'}).click();
await expect(selectedMilestone).toContainText('milestone1');
await expect(page.locator('.timeline-item.event').last()).toContainText('user2 added this to the milestone1 milestone');
// Clear milestone.
await milestoneDropdown.click();
await page.getByText('Clear milestone', {exact: true}).click();
await expect(selectedMilestone).toContainText('No milestone');
await expect(page.locator('.timeline-item.event').last()).toContainText('user2 removed this from the milestone1 milestone');
});

View file

@ -270,9 +270,7 @@ export function initRepoCommentForm() {
} }
let icon = ''; let icon = '';
if (input_id === '#milestone_id') { if (input_id === '#project_id') {
icon = svg('octicon-milestone', 18, 'tw-mr-2');
} else if (input_id === '#project_id') {
icon = svg('octicon-project', 18, 'tw-mr-2'); icon = svg('octicon-project', 18, 'tw-mr-2');
} else if (input_id === '#assignee_id') { } else if (input_id === '#assignee_id') {
icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`; icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`;
@ -313,7 +311,6 @@ export function initRepoCommentForm() {
// Milestone, Assignee, Project // Milestone, Assignee, Project
selectItem('.select-project', '#project_id'); selectItem('.select-project', '#project_id');
selectItem('.select-milestone', '#milestone_id');
selectItem('.select-assignee', '#assignee_id'); selectItem('.select-assignee', '#assignee_id');
} }