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