mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-10 19:21:05 +00:00
[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.
This commit is contained in:
parent
48587aca23
commit
d731dc793b
8 changed files with 120 additions and 41 deletions
1
release-notes/4547.md
Normal file
1
release-notes/4547.md
Normal 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.
|
|
@ -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)
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For sidebar
|
// For sidebar
|
||||||
PrepareBranchList(ctx)
|
PrepareBranchList(ctx)
|
||||||
|
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
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
|
// UpdateIssueAssignee change issue's or pull's assignee
|
||||||
|
|
4
templates/htmx/milestone_sidebar.tmpl
Normal file
4
templates/htmx/milestone_sidebar.tmpl
Normal 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" .}}
|
|
@ -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>
|
||||||
|
|
|
@ -72,7 +72,8 @@
|
||||||
</div>
|
</div>
|
||||||
</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".}}
|
||||||
|
|
|
@ -1,22 +1,24 @@
|
||||||
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown">
|
<div id="milestone-section" hx-swap="morph" hx-target="this" hx-indicator="this">
|
||||||
<a class="text muted flex-text-block">
|
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown">
|
||||||
<strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong>
|
<a class="text muted flex-text-block">
|
||||||
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}
|
<strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong>
|
||||||
{{svg "octicon-gear" 16 "tw-ml-1"}}
|
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}
|
||||||
{{end}}
|
{{svg "octicon-gear" 16 "tw-ml-1"}}
|
||||||
</a>
|
{{end}}
|
||||||
<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/milestone">
|
</a>
|
||||||
{{template "repo/issue/milestone/select_menu" .}}
|
<div class="menu">
|
||||||
</div>
|
{{template "repo/issue/milestone/select_menu" .}}
|
||||||
</div>
|
</div>
|
||||||
<div class="ui select-milestone list">
|
</div>
|
||||||
<span class="no-select item {{if .Issue.Milestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span>
|
<div class="ui select-milestone list">
|
||||||
<div class="selected">
|
<span class="no-select item {{if .Issue.Milestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span>
|
||||||
{{if .Issue.Milestone}}
|
<div class="selected">
|
||||||
<a class="item muted sidebar-item-link" href="{{.RepoLink}}/milestone/{{.Issue.Milestone.ID}}">
|
{{if .Issue.Milestone}}
|
||||||
{{svg "octicon-milestone" 18 "tw-mr-2"}}
|
<a class="item muted sidebar-item-link" href="{{.RepoLink}}/milestone/{{.Issue.Milestone.ID}}">
|
||||||
{{.Issue.Milestone.Name}}
|
{{svg "octicon-milestone" 18 "tw-mr-2"}}
|
||||||
</a>
|
{{.Issue.Milestone.Name}}
|
||||||
{{end}}
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue