loading up the forgejo repo on tangled to test page performance

[UI] Convert milestone to HTMX

- Currently if you want to update the milestone of an issue or pull
request, your whole page will be reloaded to reflect the newly set
milestone. This is quite unecessary, as only the milestone text is
updated and a new timeline event is added.
- This patch converts the milestone section in the issue/pull request
sidebar to use HTMX, so it becomes a progressive element and avoids
reloading the whole page to update the milestone.
- The update of the milestone section itself is quite straightforward
and nothing special is happening. To support adding new timeline events,
a new element `#insert-timeline` is conviently placed after the last
timeline event, which can be used with
[`hx-swap-oob`](https://htmx.org/attributes/hx-swap-oob/) to position
new timeline events before that element.
- Adds E2E test.

Gusted d731dc79 48587aca

Changed files
+119 -40
release-notes
routers
web
repo
templates
htmx
repo
issue
milestone
view_content
sidebar
tests
web_src
js
features
+1
release-notes/4547.md
··· 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.
+63 -13
routers/web/repo/issue.go
··· 1370 1370 } 1371 1371 } 1372 1372 1373 + func prepareHiddenCommentType(ctx *context.Context) { 1374 + var hiddenCommentTypes *big.Int 1375 + if ctx.IsSigned { 1376 + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) 1377 + if err != nil { 1378 + ctx.ServerError("GetUserSetting", err) 1379 + return 1380 + } 1381 + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here 1382 + } 1383 + 1384 + ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool { 1385 + return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0 1386 + } 1387 + } 1388 + 1373 1389 // ViewIssue render issue view page 1374 1390 func ViewIssue(ctx *context.Context) { 1375 1391 if ctx.Params(":type") == "issues" { ··· 2019 2035 ctx.Data["NewPinAllowed"] = pinAllowed 2020 2036 ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0 2021 2037 2022 - var hiddenCommentTypes *big.Int 2023 - if ctx.IsSigned { 2024 - val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) 2025 - if err != nil { 2026 - ctx.ServerError("GetUserSetting", err) 2027 - return 2028 - } 2029 - hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here 2030 - } 2031 - ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool { 2032 - return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0 2038 + prepareHiddenCommentType(ctx) 2039 + if ctx.Written() { 2040 + return 2033 2041 } 2042 + 2034 2043 // For sidebar 2035 2044 PrepareBranchList(ctx) 2036 - 2037 2045 if ctx.Written() { 2038 2046 return 2039 2047 } ··· 2342 2350 } 2343 2351 } 2344 2352 2345 - ctx.JSONOK() 2353 + if ctx.FormBool("htmx") { 2354 + renderMilestones(ctx) 2355 + if ctx.Written() { 2356 + return 2357 + } 2358 + prepareHiddenCommentType(ctx) 2359 + if ctx.Written() { 2360 + return 2361 + } 2362 + 2363 + issue := issues[0] 2364 + var err error 2365 + if issue.MilestoneID > 0 { 2366 + issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, issue.MilestoneID) 2367 + if err != nil { 2368 + ctx.ServerError("GetMilestoneByRepoID", err) 2369 + return 2370 + } 2371 + } else { 2372 + issue.Milestone = nil 2373 + } 2374 + 2375 + comment := &issues_model.Comment{} 2376 + has, err := db.GetEngine(ctx).Where("issue_id = ? AND type = ?", issue.ID, issues_model.CommentTypeMilestone).OrderBy("id DESC").Limit(1).Get(comment) 2377 + if !has || err != nil { 2378 + ctx.ServerError("GetLatestMilestoneComment", err) 2379 + } 2380 + if err := comment.LoadMilestone(ctx); err != nil { 2381 + ctx.ServerError("LoadMilestone", err) 2382 + return 2383 + } 2384 + if err := comment.LoadPoster(ctx); err != nil { 2385 + ctx.ServerError("LoadPoster", err) 2386 + return 2387 + } 2388 + issue.Comments = issues_model.CommentList{comment} 2389 + 2390 + ctx.Data["Issue"] = issue 2391 + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) 2392 + ctx.HTML(http.StatusOK, "htmx/milestone_sidebar") 2393 + } else { 2394 + ctx.JSONOK() 2395 + } 2346 2396 } 2347 2397 2348 2398 // UpdateIssueAssignee change issue's or pull's assignee
+4
templates/htmx/milestone_sidebar.tmpl
··· 1 + <div id="insert-timeline" hx-swap-oob="beforebegin"> 2 + {{template "repo/issue/view_content/comments" .}} 3 + </div> 4 + {{template "repo/issue/view_content/sidebar/milestones" .}}
+3 -3
templates/repo/issue/milestone/select_menu.tmpl
··· 5 5 </div> 6 6 <div class="divider"></div> 7 7 {{end}} 8 - <div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}</div> 8 + <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> 9 9 {{if and (not .OpenMilestones) (not .ClosedMilestones)}} 10 10 <div class="disabled item"> 11 11 {{ctx.Locale.Tr "repo.issues.new.no_items"}} ··· 17 17 {{ctx.Locale.Tr "repo.issues.new.open_milestone"}} 18 18 </div> 19 19 {{range .OpenMilestones}} 20 - <a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?milestone={{.ID}}"> 20 + <a class="item" hx-post="{{$.RepoLink}}/issues/milestone?id={{.ID}}&issue_ids={{$.Issue.ID}}&htmx=true"> 21 21 {{svg "octicon-milestone" 16 "tw-mr-1"}} 22 22 {{.Name}} 23 23 </a> ··· 29 29 {{ctx.Locale.Tr "repo.issues.new.closed_milestone"}} 30 30 </div> 31 31 {{range .ClosedMilestones}} 32 - <a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?milestone={{.ID}}"> 32 + <a class="item" hx-post="{{$.RepoLink}}/issues/milestone?id={{.ID}}&issue_ids={{$.Issue.ID}}&htmx=true"> 33 33 {{svg "octicon-milestone" 16 "tw-mr-1"}} 34 34 {{.Name}} 35 35 </a>
+2 -1
templates/repo/issue/view_content.tmpl
··· 72 72 </div> 73 73 </div> 74 74 75 - {{template "repo/issue/view_content/comments" .}} 75 + {{template "repo/issue/view_content/comments" .}} 76 + <div id="insert-timeline"></div> 76 77 77 78 {{if and .Issue.IsPull (not $.Repository.IsArchived)}} 78 79 {{template "repo/issue/view_content/pull".}}
+21 -19
templates/repo/issue/view_content/sidebar/milestones.tmpl
··· 1 - <div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown"> 2 - <a class="text muted flex-text-block"> 3 - <strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> 4 - {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} 5 - {{svg "octicon-gear" 16 "tw-ml-1"}} 6 - {{end}} 7 - </a> 8 - <div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/milestone"> 9 - {{template "repo/issue/milestone/select_menu" .}} 1 + <div id="milestone-section" hx-swap="morph" hx-target="this" hx-indicator="this"> 2 + <div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown"> 3 + <a class="text muted flex-text-block"> 4 + <strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> 5 + {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} 6 + {{svg "octicon-gear" 16 "tw-ml-1"}} 7 + {{end}} 8 + </a> 9 + <div class="menu"> 10 + {{template "repo/issue/milestone/select_menu" .}} 11 + </div> 10 12 </div> 11 - </div> 12 - <div class="ui select-milestone list"> 13 - <span class="no-select item {{if .Issue.Milestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span> 14 - <div class="selected"> 15 - {{if .Issue.Milestone}} 16 - <a class="item muted sidebar-item-link" href="{{.RepoLink}}/milestone/{{.Issue.Milestone.ID}}"> 17 - {{svg "octicon-milestone" 18 "tw-mr-2"}} 18 - {{.Issue.Milestone.Name}} 19 - </a> 20 - {{end}} 13 + <div class="ui select-milestone list"> 14 + <span class="no-select item {{if .Issue.Milestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span> 15 + <div class="selected"> 16 + {{if .Issue.Milestone}} 17 + <a class="item muted sidebar-item-link" href="{{.RepoLink}}/milestone/{{.Issue.Milestone.ID}}"> 18 + {{svg "octicon-milestone" 18 "tw-mr-2"}} 19 + {{.Issue.Milestone.Name}} 20 + </a> 21 + {{end}} 22 + </div> 21 23 </div> 22 24 </div>
+24
tests/e2e/issue-sidebar.test.e2e.js
··· 84 84 await expect(labelList.filter({hasText: 'label2'})).not.toBeVisible(); 85 85 await expect(labelList.filter({hasText: 'label1'})).toBeVisible(); 86 86 }); 87 + 88 + test('Issue: Milestone', async ({browser}, workerInfo) => { 89 + 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'); 90 + const page = await login({browser}, workerInfo); 91 + 92 + const response = await page.goto('/user2/repo1/issues/1'); 93 + await expect(response?.status()).toBe(200); 94 + 95 + const selectedMilestone = page.locator('.issue-content-right .select-milestone.list'); 96 + const milestoneDropdown = page.locator('.issue-content-right .select-milestone.dropdown'); 97 + await expect(selectedMilestone).toContainText('No milestone'); 98 + 99 + // Add milestone. 100 + await milestoneDropdown.click(); 101 + await page.getByRole('option', {name: 'milestone1'}).click(); 102 + await expect(selectedMilestone).toContainText('milestone1'); 103 + await expect(page.locator('.timeline-item.event').last()).toContainText('user2 added this to the milestone1 milestone'); 104 + 105 + // Clear milestone. 106 + await milestoneDropdown.click(); 107 + await page.getByText('Clear milestone', {exact: true}).click(); 108 + await expect(selectedMilestone).toContainText('No milestone'); 109 + await expect(page.locator('.timeline-item.event').last()).toContainText('user2 removed this from the milestone1 milestone'); 110 + });
+1 -4
web_src/js/features/repo-legacy.js
··· 270 270 } 271 271 272 272 let icon = ''; 273 - if (input_id === '#milestone_id') { 274 - icon = svg('octicon-milestone', 18, 'tw-mr-2'); 275 - } else if (input_id === '#project_id') { 273 + if (input_id === '#project_id') { 276 274 icon = svg('octicon-project', 18, 'tw-mr-2'); 277 275 } else if (input_id === '#assignee_id') { 278 276 icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`; ··· 313 311 314 312 // Milestone, Assignee, Project 315 313 selectItem('.select-project', '#project_id'); 316 - selectItem('.select-milestone', '#milestone_id'); 317 314 selectItem('.select-assignee', '#assignee_id'); 318 315 } 319 316