2023-04-06 19:11:02 -05:00
import $ from 'jquery' ;
import { updateIssuesMeta } from './repo-issue.js' ;
import { toggleElem } from '../utils/dom.js' ;
import { htmlEscape } from 'escape-goat' ;
2023-06-19 02:46:50 -05:00
import { confirmModal } from './comp/ConfirmModal.js' ;
2023-06-26 21:45:24 -05:00
import { showErrorToast } from '../modules/toast.js' ;
2023-07-17 13:06:37 -05:00
import { createSortable } from '../modules/sortable.js' ;
2023-04-06 19:11:02 -05:00
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
2023-04-30 10:51:20 -05:00
$ ( '#issue-filters, #issue-actions' ) . filter ( ':visible' ) . find ( '.issue-list-toolbar-left' ) . prepend ( $issueSelectAll ) ;
2023-04-06 19:11:02 -05:00
} ;
$issueCheckboxes . on ( 'change' , syncIssueSelectionState ) ;
$issueSelectAll . on ( 'change' , ( ) => {
$issueCheckboxes . prop ( 'checked' , $issueSelectAll . is ( ':checked' ) ) ;
syncIssueSelectionState ( ) ;
} ) ;
$ ( '.issue-action' ) . on ( 'click' , async function ( e ) {
e . preventDefault ( ) ;
2023-06-19 02:46:50 -05:00
const url = this . getAttribute ( 'data-url' ) ;
2023-04-06 19:11:02 -05:00
let action = this . getAttribute ( 'data-action' ) ;
let elementId = this . getAttribute ( 'data-element-id' ) ;
2023-06-19 02:46:50 -05:00
let issueIDs = [ ] ;
for ( const el of document . querySelectorAll ( '.issue-checkbox:checked' ) ) {
issueIDs . push ( el . getAttribute ( 'data-issue-id' ) ) ;
}
issueIDs = issueIDs . join ( ',' ) ;
if ( ! issueIDs ) return ;
// for assignee
if ( elementId === '0' && url . endsWith ( '/assignee' ) ) {
2023-04-06 19:11:02 -05:00
elementId = '' ;
action = 'clear' ;
}
2023-06-19 02:46:50 -05:00
// for toggle
2023-04-06 19:11:02 -05:00
if ( action === 'toggle' && e . altKey ) {
action = 'toggle-alt' ;
}
2023-06-19 02:46:50 -05:00
// for delete
if ( action === 'delete' ) {
const confirmText = e . target . getAttribute ( 'data-action-delete-confirm' ) ;
if ( ! await confirmModal ( { content : confirmText , buttonColor : 'orange' } ) ) {
return ;
}
}
2023-04-06 19:11:02 -05:00
updateIssuesMeta (
url ,
action ,
issueIDs ,
elementId
) . then ( ( ) => {
window . location . reload ( ) ;
2023-04-26 11:54:17 -05:00
} ) . catch ( ( reason ) => {
2023-06-26 21:45:24 -05:00
showErrorToast ( reason . responseJSON . error ) ;
2023-04-06 19:11:02 -05:00
} ) ;
} ) ;
}
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' ) ;
2023-06-29 07:24:22 -05:00
$menu . append ( '<div class="divider dynamic-item"></div>' , ... $newMenuItems ) ;
2023-04-06 19:11:02 -05:00
}
$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 ) ;
} ;
}
2023-05-25 08:17:19 -05:00
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' ,
} ,
} ) ;
}
2023-07-17 13:06:37 -05:00
async function initIssuePinSort ( ) {
2023-05-25 08:17:19 -05:00
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 ;
2023-07-17 13:06:37 -05:00
createSortable ( pinDiv , {
2023-05-25 08:17:19 -05:00
group : 'shared' ,
animation : 150 ,
ghostClass : 'card-ghost' ,
onEnd : pinMoveEnd ,
} ) ;
}
2023-04-06 19:11:02 -05:00
export function initRepoIssueList ( ) {
if ( ! document . querySelectorAll ( '.page-content.repository.issue-list, .page-content.repository.milestone-issue-list' ) . length ) return ;
initRepoIssueListCheckboxes ( ) ;
initRepoIssueListAuthorDropdown ( ) ;
2023-05-25 08:17:19 -05:00
initIssuePinSort ( ) ;
2023-04-06 19:11:02 -05:00
}