From d731dc793b839a7cb6f92abadcffae99480e379b Mon Sep 17 00:00:00 2001 From: Gusted Date: Wed, 17 Jul 2024 04:25:35 +0200 Subject: [PATCH] [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. --- release-notes/4547.md | 1 + routers/web/repo/issue.go | 76 +++++++++++++++---- templates/htmx/milestone_sidebar.tmpl | 4 + .../repo/issue/milestone/select_menu.tmpl | 6 +- templates/repo/issue/view_content.tmpl | 3 +- .../view_content/sidebar/milestones.tmpl | 42 +++++----- tests/e2e/issue-sidebar.test.e2e.js | 24 ++++++ web_src/js/features/repo-legacy.js | 5 +- 8 files changed, 120 insertions(+), 41 deletions(-) create mode 100644 release-notes/4547.md create mode 100644 templates/htmx/milestone_sidebar.tmpl diff --git a/release-notes/4547.md b/release-notes/4547.md new file mode 100644 index 0000000000..08f131fccd --- /dev/null +++ b/release-notes/4547.md @@ -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. diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index e34f90c73b..dcc1cdd467 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -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 func ViewIssue(ctx *context.Context) { if ctx.Params(":type") == "issues" { @@ -2019,21 +2035,13 @@ func ViewIssue(ctx *context.Context) { ctx.Data["NewPinAllowed"] = pinAllowed ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0 - 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 + prepareHiddenCommentType(ctx) + if ctx.Written() { + return } + // For sidebar PrepareBranchList(ctx) - if ctx.Written() { return } @@ -2342,7 +2350,49 @@ func UpdateIssueMilestone(ctx *context.Context) { } } - ctx.JSONOK() + 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() + } } // UpdateIssueAssignee change issue's or pull's assignee diff --git a/templates/htmx/milestone_sidebar.tmpl b/templates/htmx/milestone_sidebar.tmpl new file mode 100644 index 0000000000..458dabc5b1 --- /dev/null +++ b/templates/htmx/milestone_sidebar.tmpl @@ -0,0 +1,4 @@ +
+ {{template "repo/issue/view_content/comments" .}} +
+{{template "repo/issue/view_content/sidebar/milestones" .}} diff --git a/templates/repo/issue/milestone/select_menu.tmpl b/templates/repo/issue/milestone/select_menu.tmpl index 9b0492ce52..eae2f3baa9 100644 --- a/templates/repo/issue/milestone/select_menu.tmpl +++ b/templates/repo/issue/milestone/select_menu.tmpl @@ -5,7 +5,7 @@
{{end}} -
{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}
+
{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}
{{if and (not .OpenMilestones) (not .ClosedMilestones)}}
{{ctx.Locale.Tr "repo.issues.new.no_items"}} @@ -17,7 +17,7 @@ {{ctx.Locale.Tr "repo.issues.new.open_milestone"}}
{{range .OpenMilestones}} - + {{svg "octicon-milestone" 16 "tw-mr-1"}} {{.Name}} @@ -29,7 +29,7 @@ {{ctx.Locale.Tr "repo.issues.new.closed_milestone"}} {{range .ClosedMilestones}} - + {{svg "octicon-milestone" 16 "tw-mr-1"}} {{.Name}} diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 543191e02d..b97ce8266f 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -72,7 +72,8 @@ - {{template "repo/issue/view_content/comments" .}} + {{template "repo/issue/view_content/comments" .}} +
{{if and .Issue.IsPull (not $.Repository.IsArchived)}} {{template "repo/issue/view_content/pull".}} diff --git a/templates/repo/issue/view_content/sidebar/milestones.tmpl b/templates/repo/issue/view_content/sidebar/milestones.tmpl index 661ca80743..44d9419f9b 100644 --- a/templates/repo/issue/view_content/sidebar/milestones.tmpl +++ b/templates/repo/issue/view_content/sidebar/milestones.tmpl @@ -1,22 +1,24 @@ - -
- {{ctx.Locale.Tr "repo.issues.new.no_milestone"}} -
- {{if .Issue.Milestone}} - - {{svg "octicon-milestone" 18 "tw-mr-2"}} - {{.Issue.Milestone.Name}} - - {{end}} + diff --git a/tests/e2e/issue-sidebar.test.e2e.js b/tests/e2e/issue-sidebar.test.e2e.js index 41b1b2064a..4bd211abe5 100644 --- a/tests/e2e/issue-sidebar.test.e2e.js +++ b/tests/e2e/issue-sidebar.test.e2e.js @@ -84,3 +84,27 @@ test('Issue: Labels', async ({browser}, workerInfo) => { await expect(labelList.filter({hasText: 'label2'})).not.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'); +}); diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index 76de8daf56..d3566fb121 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -270,9 +270,7 @@ export function initRepoCommentForm() { } let icon = ''; - if (input_id === '#milestone_id') { - icon = svg('octicon-milestone', 18, 'tw-mr-2'); - } else if (input_id === '#project_id') { + if (input_id === '#project_id') { icon = svg('octicon-project', 18, 'tw-mr-2'); } else if (input_id === '#assignee_id') { icon = `avatar`; @@ -313,7 +311,6 @@ export function initRepoCommentForm() { // Milestone, Assignee, Project selectItem('.select-project', '#project_id'); - selectItem('.select-milestone', '#milestone_id'); selectItem('.select-assignee', '#assignee_id'); }