您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Modifies Manage page to allow for collapsible ticket notes
// ==UserScript== // @name myconnectwise.net enhancements // @namespace Violentmonkey Scripts // @match https://aus.myconnectwise.net/v202*ise.aspx // @grant none // @version 2.25 // @author mike-Inside // @description Modifies Manage page to allow for collapsible ticket notes // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2 // @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1 // ==/UserScript== // watch the page for elements that are created dynamically and may not be ready even on document-end const disconnect = VM.observe(document.body, () => { const autosortServiceBoardByAge = true; // find the target node const ticketField = document.querySelector('#SR_Service_RecID-input'); const rowWrap = document.querySelectorAll(".TicketNote-rowWrap"); //const comboBox = document.querySelectorAll(".cw_CwComboBox"); //might be needed later if I want to do different sorting depending on the board // runs on service board pages to stop autocomplete from blocking visibility if (ticketField) { ticketField.setAttribute("autocomplete", "off"); // wait an additonal couple of seconds for page to finish loading setTimeout(function() { if (autosortServiceBoardByAge) { clickHeader("Age"); // sort by age } }, 1000); return true; } // runs on individual ticket pages if (rowWrap[0]) { // wait until the ticket notes have been generated before starting the script // start by generating the primary controller buttons generateControls(); // wait an additonal couple of seconds for all ticket notes to finish loading setTimeout(function() { modifyNotes(); }, 2000); // disconnect observer return true; } }); function generateControls(){ let noteTab = document.querySelectorAll(".TicketNote-newNoteButton"); let controlButtons = document.getElementsByClassName("control-button"); // make sure we don't add them more than once if(noteTab[0] && controlButtons.length == 0) { noteTab[0].nextElementSibling.style.display = "none"; //hide the buttons that filter by ticket type - this conflicts with my changes and sometimes causes the entire notes section to disappear noteTab[0].after(createButton("Next")); noteTab[0].after(createButton("Previous")); noteTab[0].after(createButton("Expand All")); noteTab[0].after(createButton("Collapse All")); } } // used for the controller buttons above the notes, they are all exactly the same except for their content function createButton(buttName){ let bCss = "padding:3px; margin-left:12px; margin-bottom:0px; margin-top:22px; font-size:0.90em; width:125px"; let butt; butt = document.createElement("button"); butt.classList.add("control-button"); butt.style.cssText = bCss; butt.textContent = buttName; butt.addEventListener("click", buttonPress); return butt; } // runs when a controller button is pressed, loops through ticket notes to set the display attribute function buttonPress(evt){ let buttName = evt.currentTarget.textContent; let newDisplayState = 0; if (buttName == "Expand All") { newDisplayState = 1; } let coll = document.getElementsByClassName('collapsible'); if (coll.length == 0) { // check if collapsibles have been wiped out (eg. by a ticket note refresh) modifyNotes(); } let found = -1; // value to store the location of the first ticket note which is visible / not hidden for (let i = 0; i < coll.length; ++i) { let initialState = displayChange(coll[i], newDisplayState); if(newDisplayState == 0 && initialState != "none" && found < 0) { found = i; } } if(buttName == "Previous") { if (found > 0) { // open the note prior to the one that was open before the 'previous' button was clicked, unless... displayChange(coll[found-1], 1); } else { // ...if no note open, or the first note was open, then open the final note displayChange(coll[coll.length-1], 1); } } else if (buttName == "Next") { if (found < coll.length-1) { // if no note was open, or any note except the last note was open, then open the next note after the opened one displayChange(coll[found+1], 1); } else { // otherwise open the first note displayChange(coll[0], 1); } } } function modifyNotes(){ const rowWrap = document.querySelectorAll('.TicketNote-rowWrap'); rowWrap.forEach((rowItem) => { // create button to be placed above each ticket note let butt = document.createElement("button"); butt.classList.add("collapsible"); butt.style.cssText = 'padding:3px; margin-bottom:0px; margin-top:0px; width:100%; font-size:0.90em'; butt.innerHTML = ""; let basicName = classText(rowItem, "TicketNote-basicName", "<strong>", "</strong>") let clickableName = classText(rowItem, "TicketNote-clickableName", "<strong>", "</strong>") butt.innerHTML += basicName + clickableName; // add name to button // let timeDateText = classText(rowItem, "TimeText-date", " [","]"); // copy date to button without any changes - nah // let's instead change date to Australian locale: let timeDateChild = rowItem.getElementsByClassName("TimeText-date"); if (timeDateChild[0]) { let timeDateText = timeDateChild[0].textContent; let timeDateHTML = timeDateChild[0].innerHTML; const regexDate = new RegExp(/(\d{1,2})\/(\d{1,2})\/(\d{4})/, "g"); //matches date, make sure you have got connectwise using (en-AU) D/M/YYYY format // set language to English Australia in browser, then clear cookies and cache //let timeDateFormat = timeDateText.replace(regexDate, "$2/$1/$3"); // simple method to just swap month and day around - nah let's go all in let timeDateMatch = regexDate.exec(timeDateText); let dateObject = new Date(timeDateMatch[3],timeDateMatch[2]-1,timeDateMatch[1]); //creates JS Date object, note month parameter is inexplicably 0-11 to represent jan-dec const dateOptions = { weekday: "long", year: "numeric", month: "long", day: "numeric", }; let dateFormat = dateObject.toLocaleString("en-AU", dateOptions); let timeDateFormat = timeDateText.replace(regexDate, dateFormat); //adds the time info back to formatted date butt.innerHTML += " [" + timeDateFormat + "]"; //adds date to button //timeDateChild[0].innerHTML = timeDateChild[0].innerHTML.replace(timeDateText, ""); // we could just delete the existing date, but people may want to copy/paste it timeDateFormat = "<span style=\"color:#fff;opacity:0.1\">" + timeDateFormat + "<\/span>"; //date can still be selected and copied, but is not too noticible timeDateChild[0].innerHTML = timeDateChild[0].innerHTML.replace(timeDateText, timeDateFormat); //replaces the existing date inside ticket note with formatted date } // this is the pill shaped icon that shows for 'resolved' or 'internal' notes let pill = rowItem.getElementsByClassName("TicketNote-pill"); let pillText = ""; if (pill[0]) { pillText = pill[0].innerText; } //rowItem.style.removeProperty('margin-top'); rowItem.style.cssText = "margin-top:6px"; // set custom styles for each ticket button if (pillText == "Internal") { butt.style.cssText += ";background-color:#026CCF;color:#DDEEFF;text-align:right"; rowItem.style.cssText += ";background-color:#ecf2ff"; } else if (pillText == "Resolution") { butt.style.cssText += ";background-color:#549c05;color:#DDFFCC;text-align:right"; rowItem.style.cssText += ";background-color:#ecfff2"; } else if (clickableName.length > 0) { //only falco team have this class butt.style.cssText += ";background-color:#AABBEE;color:#3366AA;text-align:right"; } else if (basicName.length > 0) { // only end users have this class butt.style.cssText += ";background-color:#CCAAEE;color:#6644AA;text-align:left"; } // rowItem.parentElement.insertBefore(butt, rowItem); //previous method, works but causes issues // instead we place the button inside the "TicketNote-rowWrap" class so that it will get wiped if the ticket notes are refreshed let rowChild = rowItem.getElementsByClassName("TicketNote-row"); if (rowChild[0]) { rowChild[0].before(butt); } } ); //add click listener functions to all collapsible buttons var coll = document.getElementsByClassName("collapsible"); for (let i = 0; i < coll.length; i++) { coll[i].addEventListener("click", function() { this.classList.toggle("active"); displayChange(this, -1); }); } } // toggle the display state of the sibling element that is directly after the passed parameter // (We use this to hide or show the 'TicketNote-row' located directly after the 'collapsible' button in the DOM) function displayChange(collapsible, state = -1) { // state -1 toggle, 0 off, 1 on let content = collapsible.nextElementSibling; let initialState = content.style.display; if ((state != 0 && initialState === "none")) { content.style.display = "flex"; } else if (state < 1) { content.style.display = "none"; } return initialState; // returns the state the element was in *prior* to being changed } // find first matching classString that is a child of the startParent, return its innerText with optional pre/postfix text // ended up not using this much, special handling required too often function classText(startParent, classString, prefix = "", postfix = "") { let foundChild = startParent.getElementsByClassName(classString); if (foundChild[0]) { return prefix + foundChild[0].innerText + postfix; } else { return ""; } } function clickHeader(text){ //const boardHeaders = document.querySelectorAll('.GL4OBY5BBEI'); const boardHeaders = document.querySelectorAll('.GL4OBY5BDEI'); if(boardHeaders[0]) { for (var i=0, im=boardHeaders.length; im>i; i++) { if(boardHeaders[i].nextSibling.innerHTML == text){ boardHeaders[i].nextSibling.click(); } } } } // Press Control-i in order to: // * Regenerate the controls and buttons on ticket page // * sort by Age on a service board page VM.shortcut.register('c-i', () => { generateControls(); modifyNotes(); clickHeader("Age"); // just a reminder to self on ways to debug output: // console.log('You just pressed Ctrl-I'); // alert("I am an alert box!"); }); // sort by Status on a service board page VM.shortcut.register('c-o', () => { clickHeader("Status"); });