You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

repo-issue-list.js 8.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import $ from 'jquery';
  2. import {updateIssuesMeta} from './repo-issue.js';
  3. import {toggleElem, hideElem, isElemHidden} from '../utils/dom.js';
  4. import {htmlEscape} from 'escape-goat';
  5. import {confirmModal} from './comp/ConfirmModal.js';
  6. import {showErrorToast} from '../modules/toast.js';
  7. import {createSortable} from '../modules/sortable.js';
  8. import {DELETE, POST} from '../modules/fetch.js';
  9. function initRepoIssueListCheckboxes() {
  10. const issueSelectAll = document.querySelector('.issue-checkbox-all');
  11. if (!issueSelectAll) return; // logged out state
  12. const issueCheckboxes = document.querySelectorAll('.issue-checkbox');
  13. const syncIssueSelectionState = () => {
  14. const checkedCheckboxes = Array.from(issueCheckboxes).filter((el) => el.checked);
  15. const anyChecked = Boolean(checkedCheckboxes.length);
  16. const allChecked = anyChecked && checkedCheckboxes.length === issueCheckboxes.length;
  17. if (allChecked) {
  18. issueSelectAll.checked = true;
  19. issueSelectAll.indeterminate = false;
  20. } else if (anyChecked) {
  21. issueSelectAll.checked = false;
  22. issueSelectAll.indeterminate = true;
  23. } else {
  24. issueSelectAll.checked = false;
  25. issueSelectAll.indeterminate = false;
  26. }
  27. // if any issue is selected, show the action panel, otherwise show the filter panel
  28. toggleElem($('#issue-filters'), !anyChecked);
  29. toggleElem($('#issue-actions'), anyChecked);
  30. // there are two panels but only one select-all checkbox, so move the checkbox to the visible panel
  31. const panels = document.querySelectorAll('#issue-filters, #issue-actions');
  32. const visiblePanel = Array.from(panels).find((el) => !isElemHidden(el));
  33. const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left');
  34. toolbarLeft.prepend(issueSelectAll);
  35. };
  36. for (const el of issueCheckboxes) {
  37. el.addEventListener('change', syncIssueSelectionState);
  38. }
  39. issueSelectAll.addEventListener('change', () => {
  40. for (const el of issueCheckboxes) {
  41. el.checked = issueSelectAll.checked;
  42. }
  43. syncIssueSelectionState();
  44. });
  45. $('.issue-action').on('click', async function (e) {
  46. e.preventDefault();
  47. const url = this.getAttribute('data-url');
  48. let action = this.getAttribute('data-action');
  49. let elementId = this.getAttribute('data-element-id');
  50. let issueIDs = [];
  51. for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
  52. issueIDs.push(el.getAttribute('data-issue-id'));
  53. }
  54. issueIDs = issueIDs.join(',');
  55. if (!issueIDs) return;
  56. // for assignee
  57. if (elementId === '0' && url.endsWith('/assignee')) {
  58. elementId = '';
  59. action = 'clear';
  60. }
  61. // for toggle
  62. if (action === 'toggle' && e.altKey) {
  63. action = 'toggle-alt';
  64. }
  65. // for delete
  66. if (action === 'delete') {
  67. const confirmText = e.target.getAttribute('data-action-delete-confirm');
  68. if (!await confirmModal({content: confirmText, buttonColor: 'orange'})) {
  69. return;
  70. }
  71. }
  72. try {
  73. await updateIssuesMeta(url, action, issueIDs, elementId);
  74. window.location.reload();
  75. } catch (err) {
  76. showErrorToast(err.responseJSON?.error ?? err.message);
  77. }
  78. });
  79. }
  80. function initRepoIssueListAuthorDropdown() {
  81. const $searchDropdown = $('.user-remote-search');
  82. if (!$searchDropdown.length) return;
  83. let searchUrl = $searchDropdown[0].getAttribute('data-search-url');
  84. const actionJumpUrl = $searchDropdown[0].getAttribute('data-action-jump-url');
  85. const selectedUserId = $searchDropdown[0].getAttribute('data-selected-user-id');
  86. if (!searchUrl.includes('?')) searchUrl += '?';
  87. $searchDropdown.dropdown('setting', {
  88. fullTextSearch: true,
  89. selectOnKeydown: false,
  90. apiSettings: {
  91. cache: false,
  92. url: `${searchUrl}&q={query}`,
  93. onResponse(resp) {
  94. // the content is provided by backend IssuePosters handler
  95. const processedResults = []; // to be used by dropdown to generate menu items
  96. for (const item of resp.results) {
  97. let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
  98. if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
  99. processedResults.push({value: item.user_id, name: html});
  100. }
  101. resp.results = processedResults;
  102. return resp;
  103. },
  104. },
  105. action: (_text, value) => {
  106. window.location.href = actionJumpUrl.replace('{user_id}', encodeURIComponent(value));
  107. },
  108. onShow: () => {
  109. $searchDropdown.dropdown('filter', ' '); // trigger a search on first show
  110. },
  111. });
  112. // we want to generate the dropdown menu items by ourselves, replace its internal setup functions
  113. const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
  114. const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
  115. $searchDropdown.dropdown('internal', 'setup', dropdownSetup);
  116. dropdownSetup.menu = function (values) {
  117. const $menu = $searchDropdown.find('> .menu');
  118. $menu.find('> .dynamic-item').remove(); // remove old dynamic items
  119. const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
  120. if (newMenuHtml) {
  121. const $newMenuItems = $(newMenuHtml);
  122. $newMenuItems.addClass('dynamic-item');
  123. const div = document.createElement('div');
  124. div.classList.add('divider', 'dynamic-item');
  125. $menu[0].append(div, ...$newMenuItems);
  126. }
  127. $searchDropdown.dropdown('refresh');
  128. // defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
  129. setTimeout(() => {
  130. $menu.find('.item.active, .item.selected').removeClass('active selected');
  131. $menu.find(`.item[data-value="${selectedUserId}"]`).addClass('selected');
  132. }, 0);
  133. };
  134. }
  135. function initPinRemoveButton() {
  136. for (const button of document.getElementsByClassName('issue-card-unpin')) {
  137. button.addEventListener('click', async (event) => {
  138. const el = event.currentTarget;
  139. const id = Number(el.getAttribute('data-issue-id'));
  140. // Send the unpin request
  141. const response = await DELETE(el.getAttribute('data-unpin-url'));
  142. if (response.ok) {
  143. // Delete the tooltip
  144. el._tippy.destroy();
  145. // Remove the Card
  146. el.closest(`div.issue-card[data-issue-id="${id}"]`).remove();
  147. }
  148. });
  149. }
  150. }
  151. async function pinMoveEnd(e) {
  152. const url = e.item.getAttribute('data-move-url');
  153. const id = Number(e.item.getAttribute('data-issue-id'));
  154. await POST(url, {data: {id, position: e.newIndex + 1}});
  155. }
  156. async function initIssuePinSort() {
  157. const pinDiv = document.getElementById('issue-pins');
  158. if (pinDiv === null) return;
  159. // If the User is not a Repo Admin, we don't need to proceed
  160. if (!pinDiv.hasAttribute('data-is-repo-admin')) return;
  161. initPinRemoveButton();
  162. // If only one issue pinned, we don't need to make this Sortable
  163. if (pinDiv.children.length < 2) return;
  164. createSortable(pinDiv, {
  165. group: 'shared',
  166. onEnd: pinMoveEnd,
  167. });
  168. }
  169. function initArchivedLabelFilter() {
  170. const archivedLabelEl = document.querySelector('#archived-filter-checkbox');
  171. if (!archivedLabelEl) {
  172. return;
  173. }
  174. const url = new URL(window.location.href);
  175. const archivedLabels = document.querySelectorAll('[data-is-archived]');
  176. if (!archivedLabels.length) {
  177. hideElem('.archived-label-filter');
  178. return;
  179. }
  180. const selectedLabels = (url.searchParams.get('labels') || '')
  181. .split(',')
  182. .map((id) => id < 0 ? `${~id + 1}` : id); // selectedLabels contains -ve ids, which are excluded so convert any -ve value id to +ve
  183. const archivedElToggle = () => {
  184. for (const label of archivedLabels) {
  185. const id = label.getAttribute('data-label-id');
  186. toggleElem(label, archivedLabelEl.checked || selectedLabels.includes(id));
  187. }
  188. };
  189. archivedElToggle();
  190. archivedLabelEl.addEventListener('change', () => {
  191. archivedElToggle();
  192. if (archivedLabelEl.checked) {
  193. url.searchParams.set('archived', 'true');
  194. } else {
  195. url.searchParams.delete('archived');
  196. }
  197. window.location.href = url.href;
  198. });
  199. }
  200. export function initRepoIssueList() {
  201. if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return;
  202. initRepoIssueListCheckboxes();
  203. initRepoIssueListAuthorDropdown();
  204. initIssuePinSort();
  205. initArchivedLabelFilter();
  206. }