// ==UserScript==
// @name Jira Backlog Enhancements
// @namespace miffin
// @version 2024-11-07
// @description Collapse/Expand all buttons, filter by sprint name
// @author Craig Whiffin
// @match https://*.atlassian.net/jira/*/backlog*
// @icon https://www.google.com/s2/favicons?sz=64&domain=atlassian.net
// @license MIT
// @grant window.onurlchange
// @downloadURL https://update.greasyfork.icu/scripts/494404/Jira%20Backlog%20Enhancements.user.js
// @updateURL https://update.greasyfork.icu/scripts/494404/Jira%20Backlog%20Enhancements.meta.js
// ==/UserScript==
/* jshint esversion: 8 */
// ###################################################################
// JBE stuff here
// ###################################################################
var JBE = (window.JBE = {});
let JBE_define = () => {
JBE.top_bar_selector = 'div[class="_1e0c1txw _1ul9idpf"]';
// stolen from: https://stackoverflow.com/a/61511955
function waitForElm(selector) {
return new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver((mutations) => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
// If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}
JBE.get_top_bar = async () => {
console.log("JBE: waiting for top bar to load...");
const top_bar = await waitForElm(JBE.top_bar_selector);
console.log("JBE: top_bar loaded!");
return top_bar;
};
let top_bar_button_style = "css-48ccbj"; // ??
let spawn_top_bar_container = () => {
let outer = document.createElement("div");
outer.className = "sc-1krxkwp-0 jcJsAc"; // ??!
let inner = document.createElement("div");
inner.className = "_19bv1b66 _u5f31b66"; // ???!1
outer.appendChild(inner);
return inner;
};
JBE.get_sprints = () => {
// They keep changing the tags for these.
return document.querySelectorAll(
".ahoa2g-3, .ahoa2g-2, .css-1w12hrg, .css-14lcwon"
); // ???1!1?
};
// ===================================================================
JBE.collapse_all = () => {
document
.querySelectorAll(
'div[aria-controls^="backlog-accordion"][aria-expanded=true]'
)
.forEach((elem) => elem.click());
};
JBE.expand_all = () => {
document
.querySelectorAll(
'div[aria-controls^="backlog-accordion"][aria-expanded=false]'
)
.forEach((elem) => {
// only do this for visible elements, otherwise it takes forever
if (elem.offsetParent !== null) {
elem.click();
}
});
};
JBE.show_only = (input_element) => {
console.info("Showing only sprints matching:", input_element.value);
JBE.get_sprints().forEach((elem) => {
let sprint_name = elem.innerHTML.toLowerCase();
let query = input_element.value.toLowerCase();
let is_match = sprint_name.includes(query);
let parent_block = elem.closest(
'div[data-testid^="software-backlog.card-list.container"]'
);
console.assert(parent_block !== null, "Couldn't get the parent card for " + sprint_name)
parent_block.style.display = is_match ? "block" : "none";
});
};
// ===================================================================
// Should be run once the page has loaded, or else it won't be able to find the top_bar
JBE.add_UI = async () => {
let top_bar = await JBE.get_top_bar();
console.assert(top_bar !== null, "JBE: couldn't find top_bar!");
{
let container = spawn_top_bar_container();
container.style.flexDirection = "column";
container.className = "JBE_container";
{
let btn = document.createElement("button");
btn.setAttribute("id", "collapse_all_sprints");
btn.setAttribute("title", "Collapse All Sprints");
btn.addEventListener("click", () => JBE.collapse_all(), false);
btn.innerHTML = `
`;
btn.className = top_bar_button_style;
container.appendChild(btn);
}
{
let btn = document.createElement("button");
btn.setAttribute("id", "expand_all_sprints");
btn.setAttribute("title", "Expand All Sprints");
btn.addEventListener("click", () => JBE.expand_all(), false);
btn.innerHTML = `
`;
btn.className = top_bar_button_style;
container.appendChild(btn);
}
top_bar.appendChild(container);
}
// filter by sprint name
{
let filter_input = document.createElement("input");
filter_input.id = "filter_by_sprint";
filter_input.class = "css-1cab8vv";
filter_input.placeholder = "Sprint Name";
filter_input.addEventListener(
"input",
() => JBE.show_only(filter_input),
false
);
let filter_icon = document.createElement("div");
filter_icon.className = "css-tww5fb";
filter_icon.innerHTML = `
`;
let filter_container = document.createElement("div");
filter_container.className = "css-19p3uok";
filter_container.style.minWidth = "64px";
filter_container.appendChild(filter_input);
filter_container.appendChild(filter_icon);
top_bar.appendChild(filter_container);
}
};
JBE.UI_already_added = () => {
var found_it = document.querySelector(".JBE_container");
return found_it !== null;
};
window.JBE = JBE;
return JBE;
};
JBE = JBE_define();
// ###################################################################
// Event hookups here:
// ###################################################################
// Add UI on page load
let on_load_handler = async () => { await JBE.add_UI(); };
window.addEventListener("load", on_load_handler, false);
// ..._and_ if the URL changes to '*/backlog' and we don't have the UI
if (window.onurlchange === null) {
// feature is supported
// yes, !== null _would_ make more sense, but who cares
let url_change_handler = async (info) => {
let is_valid_url = info.url.endsWith("/backlog");
let JBE = JBE_define();
if (!JBE.UI_already_added() && is_valid_url) {
console.log("JBE: URL ends with '/backlog', re-injecting JBE...");
JBE_define();
await JBE.add_UI();
}
};
window.addEventListener("urlchange", (info) => url_change_handler(info));
}