myconnectwise.net enhancements

Modifies Manage page to allow for collapsible ticket notes

  1. // ==UserScript==
  2. // @name myconnectwise.net enhancements
  3. // @namespace Violentmonkey Scripts
  4. // @match https://aus.myconnectwise.net/v202*ise.aspx
  5. // @grant none
  6. // @version 2.25
  7. // @author mike-Inside
  8. // @description Modifies Manage page to allow for collapsible ticket notes
  9. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/dom@2
  10. // @require https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
  11. // ==/UserScript==
  12.  
  13. // watch the page for elements that are created dynamically and may not be ready even on document-end
  14. const disconnect = VM.observe(document.body, () => {
  15.  
  16. const autosortServiceBoardByAge = true;
  17.  
  18. // find the target node
  19. const ticketField = document.querySelector('#SR_Service_RecID-input');
  20. const rowWrap = document.querySelectorAll(".TicketNote-rowWrap");
  21. //const comboBox = document.querySelectorAll(".cw_CwComboBox"); //might be needed later if I want to do different sorting depending on the board
  22.  
  23. // runs on service board pages to stop autocomplete from blocking visibility
  24. if (ticketField) {
  25. ticketField.setAttribute("autocomplete", "off");
  26.  
  27. // wait an additonal couple of seconds for page to finish loading
  28. setTimeout(function() {
  29. if (autosortServiceBoardByAge) {
  30. clickHeader("Age"); // sort by age
  31. }
  32. }, 1000);
  33.  
  34. return true;
  35. }
  36.  
  37. // runs on individual ticket pages
  38. if (rowWrap[0]) { // wait until the ticket notes have been generated before starting the script
  39. // start by generating the primary controller buttons
  40. generateControls();
  41.  
  42. // wait an additonal couple of seconds for all ticket notes to finish loading
  43. setTimeout(function() {
  44. modifyNotes();
  45. }, 2000);
  46.  
  47. // disconnect observer
  48. return true;
  49. }
  50. });
  51.  
  52.  
  53. function generateControls(){
  54. let noteTab = document.querySelectorAll(".TicketNote-newNoteButton");
  55. let controlButtons = document.getElementsByClassName("control-button"); // make sure we don't add them more than once
  56.  
  57. if(noteTab[0] && controlButtons.length == 0) {
  58. 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
  59. noteTab[0].after(createButton("Next"));
  60. noteTab[0].after(createButton("Previous"));
  61. noteTab[0].after(createButton("Expand All"));
  62. noteTab[0].after(createButton("Collapse All"));
  63. }
  64. }
  65.  
  66. // used for the controller buttons above the notes, they are all exactly the same except for their content
  67. function createButton(buttName){
  68. let bCss = "padding:3px; margin-left:12px; margin-bottom:0px; margin-top:22px; font-size:0.90em; width:125px";
  69. let butt;
  70.  
  71. butt = document.createElement("button");
  72. butt.classList.add("control-button");
  73. butt.style.cssText = bCss;
  74. butt.textContent = buttName;
  75. butt.addEventListener("click", buttonPress);
  76. return butt;
  77. }
  78.  
  79. // runs when a controller button is pressed, loops through ticket notes to set the display attribute
  80. function buttonPress(evt){
  81. let buttName = evt.currentTarget.textContent;
  82. let newDisplayState = 0;
  83. if (buttName == "Expand All") {
  84. newDisplayState = 1;
  85. }
  86. let coll = document.getElementsByClassName('collapsible');
  87. if (coll.length == 0) { // check if collapsibles have been wiped out (eg. by a ticket note refresh)
  88. modifyNotes();
  89. }
  90. let found = -1; // value to store the location of the first ticket note which is visible / not hidden
  91. for (let i = 0; i < coll.length; ++i) {
  92. let initialState = displayChange(coll[i], newDisplayState);
  93.  
  94. if(newDisplayState == 0 && initialState != "none" && found < 0) {
  95. found = i;
  96. }
  97. }
  98.  
  99. if(buttName == "Previous") {
  100. if (found > 0) { // open the note prior to the one that was open before the 'previous' button was clicked, unless...
  101. displayChange(coll[found-1], 1);
  102. } else { // ...if no note open, or the first note was open, then open the final note
  103. displayChange(coll[coll.length-1], 1);
  104. }
  105. } else if (buttName == "Next") {
  106. 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
  107. displayChange(coll[found+1], 1);
  108. } else { // otherwise open the first note
  109. displayChange(coll[0], 1);
  110. }
  111. }
  112. }
  113.  
  114. function modifyNotes(){
  115. const rowWrap = document.querySelectorAll('.TicketNote-rowWrap');
  116. rowWrap.forEach((rowItem) => {
  117.  
  118. // create button to be placed above each ticket note
  119. let butt = document.createElement("button");
  120. butt.classList.add("collapsible");
  121. butt.style.cssText = 'padding:3px; margin-bottom:0px; margin-top:0px; width:100%; font-size:0.90em';
  122. butt.innerHTML = "";
  123.  
  124. let basicName = classText(rowItem, "TicketNote-basicName", "<strong>", "</strong>")
  125. let clickableName = classText(rowItem, "TicketNote-clickableName", "<strong>", "</strong>")
  126. butt.innerHTML += basicName + clickableName; // add name to button
  127.  
  128. // let timeDateText = classText(rowItem, "TimeText-date", " [","]"); // copy date to button without any changes - nah
  129. // let's instead change date to Australian locale:
  130. let timeDateChild = rowItem.getElementsByClassName("TimeText-date");
  131. if (timeDateChild[0]) {
  132. let timeDateText = timeDateChild[0].textContent;
  133. let timeDateHTML = timeDateChild[0].innerHTML;
  134. 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
  135. // set language to English Australia in browser, then clear cookies and cache
  136. //let timeDateFormat = timeDateText.replace(regexDate, "$2/$1/$3"); // simple method to just swap month and day around - nah let's go all in
  137. let timeDateMatch = regexDate.exec(timeDateText);
  138. 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
  139. const dateOptions = {
  140. weekday: "long",
  141. year: "numeric",
  142. month: "long",
  143. day: "numeric",
  144. };
  145. let dateFormat = dateObject.toLocaleString("en-AU", dateOptions);
  146. let timeDateFormat = timeDateText.replace(regexDate, dateFormat); //adds the time info back to formatted date
  147. butt.innerHTML += " [" + timeDateFormat + "]"; //adds date to button
  148. //timeDateChild[0].innerHTML = timeDateChild[0].innerHTML.replace(timeDateText, ""); // we could just delete the existing date, but people may want to copy/paste it
  149. timeDateFormat = "<span style=\"color:#fff;opacity:0.1\">" + timeDateFormat + "<\/span>"; //date can still be selected and copied, but is not too noticible
  150. timeDateChild[0].innerHTML = timeDateChild[0].innerHTML.replace(timeDateText, timeDateFormat); //replaces the existing date inside ticket note with formatted date
  151. }
  152.  
  153. // this is the pill shaped icon that shows for 'resolved' or 'internal' notes
  154. let pill = rowItem.getElementsByClassName("TicketNote-pill");
  155. let pillText = "";
  156. if (pill[0]) {
  157. pillText = pill[0].innerText;
  158. }
  159.  
  160. //rowItem.style.removeProperty('margin-top');
  161. rowItem.style.cssText = "margin-top:6px";
  162.  
  163. // set custom styles for each ticket button
  164. if (pillText == "Internal") {
  165. butt.style.cssText += ";background-color:#026CCF;color:#DDEEFF;text-align:right";
  166. rowItem.style.cssText += ";background-color:#ecf2ff";
  167. } else if (pillText == "Resolution") {
  168. butt.style.cssText += ";background-color:#549c05;color:#DDFFCC;text-align:right";
  169. rowItem.style.cssText += ";background-color:#ecfff2";
  170. } else if (clickableName.length > 0) { //only falco team have this class
  171. butt.style.cssText += ";background-color:#AABBEE;color:#3366AA;text-align:right";
  172. } else if (basicName.length > 0) { // only end users have this class
  173. butt.style.cssText += ";background-color:#CCAAEE;color:#6644AA;text-align:left";
  174. }
  175. // rowItem.parentElement.insertBefore(butt, rowItem); //previous method, works but causes issues
  176. // instead we place the button inside the "TicketNote-rowWrap" class so that it will get wiped if the ticket notes are refreshed
  177. let rowChild = rowItem.getElementsByClassName("TicketNote-row");
  178. if (rowChild[0]) {
  179. rowChild[0].before(butt);
  180. }
  181.  
  182. } );
  183.  
  184. //add click listener functions to all collapsible buttons
  185. var coll = document.getElementsByClassName("collapsible");
  186. for (let i = 0; i < coll.length; i++) {
  187. coll[i].addEventListener("click", function() {
  188. this.classList.toggle("active");
  189. displayChange(this, -1);
  190. });
  191. }
  192. }
  193.  
  194. // toggle the display state of the sibling element that is directly after the passed parameter
  195. // (We use this to hide or show the 'TicketNote-row' located directly after the 'collapsible' button in the DOM)
  196. function displayChange(collapsible, state = -1) {
  197. // state -1 toggle, 0 off, 1 on
  198. let content = collapsible.nextElementSibling;
  199. let initialState = content.style.display;
  200. if ((state != 0 && initialState === "none")) {
  201. content.style.display = "flex";
  202. } else if (state < 1) {
  203. content.style.display = "none";
  204. }
  205. return initialState; // returns the state the element was in *prior* to being changed
  206. }
  207.  
  208.  
  209. // find first matching classString that is a child of the startParent, return its innerText with optional pre/postfix text
  210. // ended up not using this much, special handling required too often
  211. function classText(startParent, classString, prefix = "", postfix = "") {
  212. let foundChild = startParent.getElementsByClassName(classString);
  213. if (foundChild[0]) {
  214. return prefix + foundChild[0].innerText + postfix;
  215. } else {
  216. return "";
  217. }
  218. }
  219.  
  220.  
  221. function clickHeader(text){
  222. //const boardHeaders = document.querySelectorAll('.GL4OBY5BBEI');
  223. const boardHeaders = document.querySelectorAll('.GL4OBY5BDEI');
  224. if(boardHeaders[0]) {
  225. for (var i=0, im=boardHeaders.length; im>i; i++) {
  226. if(boardHeaders[i].nextSibling.innerHTML == text){
  227. boardHeaders[i].nextSibling.click();
  228. }
  229. }
  230. }
  231. }
  232.  
  233. // Press Control-i in order to:
  234. // * Regenerate the controls and buttons on ticket page
  235. // * sort by Age on a service board page
  236. VM.shortcut.register('c-i', () => {
  237. generateControls();
  238. modifyNotes();
  239. clickHeader("Age");
  240. // just a reminder to self on ways to debug output:
  241. // console.log('You just pressed Ctrl-I');
  242. // alert("I am an alert box!");
  243. });
  244.  
  245. // sort by Status on a service board page
  246. VM.shortcut.register('c-o', () => {
  247. clickHeader("Status");
  248. });