feat: "assign to me" button on PRs and issues

includes:
Tests for assignees on issues
Move assignees selector of new Issue to assignees.tmpl
This commit is contained in:
TimedIn 2024-10-08 10:39:52 +02:00 committed by Otto Richter
parent b5b9cc1c38
commit 2feb3d03d7
8 changed files with 172 additions and 68 deletions

View file

@ -1509,6 +1509,7 @@ issues.new.closed_milestone = Closed milestones
issues.new.assignees = Assignees issues.new.assignees = Assignees
issues.new.clear_assignees = Clear assignees issues.new.clear_assignees = Clear assignees
issues.new.no_assignees = No assignees issues.new.no_assignees = No assignees
issues.new.assign_to_me = Assign to me
issues.new.no_reviewers = No reviewers issues.new.no_reviewers = No reviewers
issues.edit.already_changed = Unable to save changes to the issue. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes issues.edit.already_changed = Unable to save changes to the issue. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes
issues.choose.get_started = Get started issues.choose.get_started = Get started

View file

@ -317,7 +317,7 @@ type WebhookForm struct {
type CreateIssueForm struct { type CreateIssueForm struct {
Title string `binding:"Required;MaxSize(255)"` Title string `binding:"Required;MaxSize(255)"`
LabelIDs string `form:"label_ids"` LabelIDs string `form:"label_ids"`
AssigneeIDs string `form:"assignee_ids"` AssigneeIDs string `form:"assignee_id"`
Ref string `form:"ref"` Ref string `form:"ref"`
MilestoneID int64 MilestoneID int64
ProjectID int64 ProjectID int64

View file

@ -140,42 +140,7 @@
</div> </div>
{{end}} {{end}}
<div class="divider"></div> <div class="divider"></div>
<input id="assignee_ids" name="assignee_ids" type="hidden" value="{{.assignee_ids}}"> {{template "repo/issue/view_content/sidebar/assignees" dict "isExistingIssue" false "." .}}
<div class="ui {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}} floating jump select-assignees dropdown">
<span class="text flex-text-block">
<strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong>
{{if .HasIssuesOrPullsWritePermission}}
{{svg "octicon-gear" 16 "tw-ml-1"}}
{{end}}
</span>
<div class="filter menu" data-id="#assignee_ids">
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}">
</div>
<div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}</div>
{{range .Assignees}}
<a class="item muted" href="#" data-id="{{.ID}}" data-id-selector="#assignee_{{.ID}}">
<span class="octicon-check tw-invisible">{{svg "octicon-check"}}</span>
<span class="text">
{{ctx.AvatarUtils.Avatar . 28 "tw-mr-2"}}{{template "repo/search_name" .}}
</span>
</a>
{{end}}
</div>
</div>
<div class="ui assignees list">
<span class="no-select item {{if .HasSelectedLabel}}tw-hidden{{end}}">
{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}
</span>
<div class="selected">
{{range .Assignees}}
<a class="item tw-p-1 muted tw-hidden" id="assignee_{{.ID}}" href="{{$.RepoLink}}/issues?assignee={{.ID}}">
{{ctx.AvatarUtils.Avatar . 28 "tw-mr-2 tw-align-middle"}}{{.GetDisplayName}}
</a>
{{end}}
</div>
</div>
{{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}} {{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}}
<div class="divider"></div> <div class="divider"></div>
<div class="inline field"> <div class="inline field">

View file

@ -17,7 +17,7 @@
{{template "repo/issue/view_content/sidebar/projects" .}} {{template "repo/issue/view_content/sidebar/projects" .}}
<div class="divider"></div> <div class="divider"></div>
{{template "repo/issue/view_content/sidebar/assignees" .}} {{template "repo/issue/view_content/sidebar/assignees" dict "isExistingIssue" true "." .}}
<div class="divider"></div> <div class="divider"></div>
{{if .Participants}} {{if .Participants}}

View file

@ -1,12 +1,12 @@
<input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}"> <input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}">
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-assignees-modify dropdown"> <div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-assignees{{if .isExistingIssue}}-modify{{end}} dropdown">
<a class="text muted flex-text-block"> <a class="text muted flex-text-block">
<strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong> <strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong>
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}
{{svg "octicon-gear" 16 "tw-ml-1"}} {{svg "octicon-gear" 16 "tw-ml-1"}}
{{end}} {{end}}
</a> </a>
<div class="filter menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee"> <div class="filter menu" {{if .isExistingIssue}} data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee" {{else}} data-id="#assignee_id" {{end}}>
<div class="ui icon search input"> <div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i> <i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}">
@ -31,15 +31,32 @@
</div> </div>
</div> </div>
<div class="ui assignees list"> <div class="ui assignees list">
<span class="no-select item {{if .Issue.Assignees}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}</span> <span class="no-select item {{if .Issue.Assignees}}tw-hidden{{end}}">
{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}
{{with index .Assignees 0}}
<a class="item select-assign-me" href="#" data-id="{{.ID}}" data-id-selector="#assignee_{{.ID}}" {{if $.isExistingIssue}} data-action="update" {{end}} data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee" role="option">
{{ctx.Locale.Tr "repo.issues.new.assign_to_me"}}
</a>
{{end}}
{{end}}
</span>
<div class="selected"> <div class="selected">
{{if .isExistingIssue}}
{{range .Issue.Assignees}} {{range .Issue.Assignees}}
<div class="item"> <div class="item">
<a class="muted sidebar-item-link" href="{{$.RepoLink}}/{{if $.Issue.IsPull}}pulls{{else}}issues{{end}}?assignee={{.ID}}"> <a class="muted sidebar-item-link" href="{{$.RepoLink}}/{{if $.Issue.IsPull}}pulls{{else}}issues{{end}}?assignee={{.ID}}">
{{ctx.AvatarUtils.Avatar . 28 "tw-mr-2"}} {{ctx.AvatarUtils.Avatar . 28 "tw-mr-2"}}{{.GetDisplayName}}
{{.GetDisplayName}}
</a> </a>
</div> </div>
{{end}} {{end}}
{{else}}
{{range .Assignees}}
<a class="item tw-p-1 muted tw-hidden" id="assignee_{{.ID}}" href="{{$.RepoLink}}/{{if $.Issue.IsPull}}pulls{{else}}issues{{end}}?assignee={{.ID}}">
{{ctx.AvatarUtils.Avatar . 28 "tw-mr-2"}}{{.GetDisplayName}}
</a>
{{end}}
{{end}}
</div> </div>
</div> </div>

View file

@ -103,6 +103,91 @@ test('Issue: Labels', async ({browser}, workerInfo) => {
await expect(labelList.filter({hasText: 'label1'})).toBeVisible(); await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
}); });
test('Issue: Assignees', 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);
// select label list in sidebar only
const assigneesList = page.locator('.issue-content-right .assignees.list .selected .item a');
const response = await page.goto('/org3/repo3/issues/1');
await expect(response?.status()).toBe(200);
// preconditions
await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
// Clear all assignees
await page.locator('.select-assignees-modify.dropdown').click();
await page.locator('.select-assignees-modify.dropdown .no-select.item').click();
await page.waitForLoadState('networkidle');
await expect(assigneesList.filter({hasText: 'user2'})).toBeHidden();
await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
// Assign other user (with searchbox)
await page.locator('.select-assignees-modify.dropdown').click();
await page.type('.select-assignees-modify .menu .search input', 'user4');
await expect(page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user2'})).toBeHidden();
await expect(page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'})).toBeVisible();
await page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'}).click();
await page.locator('.select-assignees-modify.dropdown').click();
await page.waitForLoadState('networkidle');
await expect(assigneesList.filter({hasText: 'user4'})).toBeVisible();
// remove user4
await page.locator('.select-assignees-modify.dropdown').click();
await page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'}).click();
await page.locator('.select-assignees-modify.dropdown').click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
// Test assign me
await page.locator('.ui.assignees .select-assign-me').click();
await page.waitForLoadState('networkidle');
await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
});
test('New Issue: Assignees', 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);
// select label list in sidebar only
const assigneesList = page.locator('.issue-content-right .assignees.list .selected .item');
const response = await page.goto('/org3/repo3/issues/new');
await expect(response?.status()).toBe(200);
// preconditions
await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
await expect(assigneesList.filter({hasText: 'user2'})).toBeHidden();
await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
// Assign other user (with searchbox)
await page.locator('.select-assignees.dropdown').click();
await page.type('.select-assignees .menu .search input', 'user4');
await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user2'})).toBeHidden();
await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user4'})).toBeVisible();
await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click();
await page.locator('.select-assignees.dropdown').click();
await expect(assigneesList.filter({hasText: 'user4'})).toBeVisible();
// remove user4
await page.locator('.select-assignees.dropdown').click();
await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click();
await page.locator('.select-assignees.dropdown').click();
await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
// Test assign me
await page.locator('.ui.assignees .select-assign-me').click();
await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
await page.locator('.select-assignees.dropdown').click();
await page.fill('.select-assignees .menu .search input', '');
await page.locator('.select-assignees.dropdown .no-select.item').click();
});
test('Issue: Milestone', async ({browser}, workerInfo) => { 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'); 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 page = await login({browser}, workerInfo);

View file

@ -12,6 +12,26 @@ import {emojiHTML} from './emoji.js';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
// if there are draft comments, confirm before reloading, to avoid losing comments
export function reloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector('#comment-form textarea'),
];
for (const textarea of commentTextareas) {
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
if (textarea && textarea.value.trim().length > 10) {
textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return;
}
break;
}
}
window.location.reload();
}
export function initRepoIssueTimeTracking() { export function initRepoIssueTimeTracking() {
$(document).on('click', '.issue-add-time', () => { $(document).on('click', '.issue-add-time', () => {
$('.issue-start-time-modal').modal({ $('.issue-start-time-modal').modal({
@ -668,6 +688,40 @@ export function initRepoIssueBranchSelect() {
}); });
} }
export function initRepoIssueAssignMe() {
// Assign to me button
document.querySelector('.ui.assignees.list .item.no-select .select-assign-me')
?.addEventListener('click', (e) => {
e.preventDefault();
const selectMe = e.target;
const noSelect = selectMe.parentElement;
const selectorList = document.querySelector('.ui.select-assignees .menu');
if (selectMe.getAttribute('data-action') === 'update') {
(async () => {
await updateIssuesMeta(
selectMe.getAttribute('data-update-url'),
selectMe.getAttribute('data-action'),
selectMe.getAttribute('data-issue-id'),
selectMe.getAttribute('data-id'),
);
reloadConfirmDraftComment();
})();
} else {
for (const item of selectorList.querySelectorAll('.item')) {
if (item.getAttribute('data-id') === selectMe.getAttribute('data-id')) {
item.classList.add('checked');
item.querySelector('.octicon-check').classList.remove('tw-invisible');
}
}
document.querySelector(selectMe.getAttribute('data-id-selector')).classList.remove('tw-hidden');
noSelect.classList.add('tw-hidden');
document.querySelector(selectorList.getAttribute('data-id')).value = selectMe.getAttribute('data-id');
return false;
}
});
}
export function initSingleCommentEditor($commentForm) { export function initSingleCommentEditor($commentForm) {
// pages: // pages:
// * normal new issue/pr page, no status-button // * normal new issue/pr page, no status-button

View file

@ -4,6 +4,7 @@ import {
initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue, initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
initRepoIssueTitleEdit, initRepoIssueWipToggle, initRepoIssueTitleEdit, initRepoIssueWipToggle,
initRepoPullRequestUpdate, updateIssuesMeta, handleReply, initIssueTemplateCommentEditors, initSingleCommentEditor, initRepoPullRequestUpdate, updateIssuesMeta, handleReply, initIssueTemplateCommentEditors, initSingleCommentEditor,
initRepoIssueAssignMe, reloadConfirmDraftComment,
} from './repo-issue.js'; } from './repo-issue.js';
import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; import {initUnicodeEscapeButton} from './repo-unicode-escape.js';
import {svg} from '../svg.js'; import {svg} from '../svg.js';
@ -29,26 +30,6 @@ import {POST, GET} from '../modules/fetch.js';
const {csrfToken} = window.config; const {csrfToken} = window.config;
// if there are draft comments, confirm before reloading, to avoid losing comments
function reloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector('#comment-form textarea'),
];
for (const textarea of commentTextareas) {
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
if (textarea && textarea.value.trim().length > 10) {
textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return;
}
break;
}
}
window.location.reload();
}
export function initRepoCommentForm() { export function initRepoCommentForm() {
const $commentForm = $('.comment.form'); const $commentForm = $('.comment.form');
if (!$commentForm.length) return; if (!$commentForm.length) return;
@ -243,6 +224,7 @@ export function initRepoCommentForm() {
// Init labels and assignees // Init labels and assignees
initListSubmits('select-label', 'labels'); initListSubmits('select-label', 'labels');
initListSubmits('select-assignees', 'assignees'); initListSubmits('select-assignees', 'assignees');
initRepoIssueAssignMe();
initListSubmits('select-assignees-modify', 'assignees'); initListSubmits('select-assignees-modify', 'assignees');
initListSubmits('select-reviewers-modify', 'assignees'); initListSubmits('select-reviewers-modify', 'assignees');