forgejo/web_src/js/features/repo-issue-list.js
JakobDev aaa1094663
Add the ability to pin Issues ()
This adds the ability to pin important Issues and Pull Requests. You can
also move pinned Issues around to change their Position. Resolves .

## Screenshots

![grafik](https://user-images.githubusercontent.com/15185051/235123207-0aa39869-bb48-45c3-abe2-ba1e836046ec.png)

![grafik](https://user-images.githubusercontent.com/15185051/235123297-152a16ea-a857-451d-9a42-61f2cd54dd75.png)

![grafik](https://user-images.githubusercontent.com/15185051/235640782-cbfe25ec-6254-479a-a3de-133e585d7a2d.png)

The Design was mostly copied from the Projects Board.

## Implementation
This uses a new `pin_order` Column in the `issue` table. If the value is
set to 0, the Issue is not pinned. If it's set to a bigger value, the
value is the Position. 1 means it's the first pinned Issue, 2 means it's
the second one etc. This is dived into Issues and Pull requests for each
Repo.

## TODO
- [x] You can currently pin as many Issues as you want. Maybe we should
add a Limit, which is configurable. GitHub uses 3, but I prefer 6, as
this is better for bigger Projects, but I'm open for suggestions.
- [x] Pin and Unpin events need to be added to the Issue history.
- [x] Tests
- [x] Migration

**The feature itself is currently fully working, so tester who may find
weird edge cases are very welcome!**

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
2023-05-25 15:17:19 +02:00

186 lines
6.7 KiB
JavaScript

import $ from 'jquery';
import {updateIssuesMeta} from './repo-issue.js';
import {toggleElem} from '../utils/dom.js';
import {htmlEscape} from 'escape-goat';
import {Sortable} from 'sortablejs';
function initRepoIssueListCheckboxes() {
const $issueSelectAll = $('.issue-checkbox-all');
const $issueCheckboxes = $('.issue-checkbox');
const syncIssueSelectionState = () => {
const $checked = $issueCheckboxes.filter(':checked');
const anyChecked = $checked.length !== 0;
const allChecked = anyChecked && $checked.length === $issueCheckboxes.length;
if (allChecked) {
$issueSelectAll.prop({'checked': true, 'indeterminate': false});
} else if (anyChecked) {
$issueSelectAll.prop({'checked': false, 'indeterminate': true});
} else {
$issueSelectAll.prop({'checked': false, 'indeterminate': false});
}
// if any issue is selected, show the action panel, otherwise show the filter panel
toggleElem($('#issue-filters'), !anyChecked);
toggleElem($('#issue-actions'), anyChecked);
// there are two panels but only one select-all checkbox, so move the checkbox to the visible panel
$('#issue-filters, #issue-actions').filter(':visible').find('.issue-list-toolbar-left').prepend($issueSelectAll);
};
$issueCheckboxes.on('change', syncIssueSelectionState);
$issueSelectAll.on('change', () => {
$issueCheckboxes.prop('checked', $issueSelectAll.is(':checked'));
syncIssueSelectionState();
});
$('.issue-action').on('click', async function (e) {
e.preventDefault();
let action = this.getAttribute('data-action');
let elementId = this.getAttribute('data-element-id');
const url = this.getAttribute('data-url');
const issueIDs = $('.issue-checkbox:checked').map((_, el) => {
return el.getAttribute('data-issue-id');
}).get().join(',');
if (elementId === '0' && url.slice(-9) === '/assignee') {
elementId = '';
action = 'clear';
}
if (action === 'toggle' && e.altKey) {
action = 'toggle-alt';
}
updateIssuesMeta(
url,
action,
issueIDs,
elementId
).then(() => {
window.location.reload();
}).catch((reason) => {
window.alert(reason.responseJSON.error);
});
});
}
function initRepoIssueListAuthorDropdown() {
const $searchDropdown = $('.user-remote-search');
if (!$searchDropdown.length) return;
let searchUrl = $searchDropdown.attr('data-search-url');
const actionJumpUrl = $searchDropdown.attr('data-action-jump-url');
const selectedUserId = $searchDropdown.attr('data-selected-user-id');
if (!searchUrl.includes('?')) searchUrl += '?';
$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
apiSettings: {
cache: false,
url: `${searchUrl}&q={query}`,
onResponse(resp) {
// the content is provided by backend IssuePosters handler
const processedResults = []; // to be used by dropdown to generate menu items
for (const item of resp.results) {
let html = `<img class="ui avatar gt-vm" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
if (item.full_name) html += `<span class="search-fullname gt-ml-3">${htmlEscape(item.full_name)}</span>`;
processedResults.push({value: item.user_id, name: html});
}
resp.results = processedResults;
return resp;
},
},
action: (_text, value) => {
window.location.href = actionJumpUrl.replace('{user_id}', encodeURIComponent(value));
},
onShow: () => {
$searchDropdown.dropdown('filter', ' '); // trigger a search on first show
},
});
// we want to generate the dropdown menu items by ourselves, replace its internal setup functions
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values) {
const $menu = $searchDropdown.find('> .menu');
$menu.find('> .dynamic-item').remove(); // remove old dynamic items
const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
if (newMenuHtml) {
const $newMenuItems = $(newMenuHtml);
$newMenuItems.addClass('dynamic-item');
$menu.append('<div class="ui divider dynamic-item"></div>', ...$newMenuItems);
}
$searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
setTimeout(() => {
$menu.find('.item.active, .item.selected').removeClass('active selected');
$menu.find(`.item[data-value="${selectedUserId}"]`).addClass('selected');
}, 0);
};
}
function initPinRemoveButton() {
for (const button of document.getElementsByClassName('pinned-issue-unpin')) {
button.addEventListener('click', async (event) => {
const el = event.currentTarget;
const id = Number(el.getAttribute('data-issue-id'));
// Send the unpin request
const response = await fetch(el.getAttribute('data-unpin-url'), {
method: 'delete',
headers: {
'X-Csrf-Token': window.config.csrfToken,
'Content-Type': 'application/json',
},
});
if (response.ok) {
// Delete the tooltip
el._tippy.destroy();
// Remove the Card
el.closest(`div.pinned-issue-card[data-issue-id="${id}"]`).remove();
}
});
}
}
async function pinMoveEnd(e) {
const url = e.item.getAttribute('data-move-url');
const id = Number(e.item.getAttribute('data-issue-id'));
await fetch(url, {
method: 'post',
body: JSON.stringify({id, position: e.newIndex + 1}),
headers: {
'X-Csrf-Token': window.config.csrfToken,
'Content-Type': 'application/json',
},
});
}
function initIssuePinSort() {
const pinDiv = document.getElementById('issue-pins');
if (pinDiv === null) return;
// If the User is not a Repo Admin, we don't need to proceed
if (!pinDiv.hasAttribute('data-is-repo-admin')) return;
initPinRemoveButton();
// If only one issue pinned, we don't need to make this Sortable
if (pinDiv.children.length < 2) return;
new Sortable(pinDiv, {
group: 'shared',
animation: 150,
ghostClass: 'card-ghost',
onEnd: pinMoveEnd,
});
}
export function initRepoIssueList() {
if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return;
initRepoIssueListCheckboxes();
initRepoIssueListAuthorDropdown();
initIssuePinSort();
}