Article summary
On my current project, we publish new builds of our mobile application every two weeks, in line with our sprint cadence. Sometimes we put out mid-sprint releases to keep the client QA team busy and to shorten the feedback loop on work in progress. Each time we put out a release, we send the client a document detailing what work we’d included in the latest build. Over time, a consistent format for these documents has emerged. That means each Jira work item included in the new build would get its own line, formatted in a particular way.
I got sick of copy-pasting info back and forth between Jira and Basecamp almost immediately. So, I went looking for ways to automate the process.
Implementation
After a lot of trial and error and learning a lot about the limitations of various DOM APIs, I arrived at a solution based on TamperMonkey and the the MutationObserver
API.
The following JS snippet is included in a userscript and runs via TamperMonkey, Greesemonkey, or a similar browser extension. The snippet modifies the main Jira backlog so that clicking on a work item copies the properly formatted description into the clipboard.
(function () {
"use strict";
const workItemRowSelector =
'[data-test-id="software-backlog.card-list.card.card-contents.accessible-card-key"] > a:first-child';
const observer = new MutationObserver((mutations) => {
mutations
.flatMap((mutation) => Array.from(mutation.addedNodes))
.flatMap((node) => Array.from(node.querySelectorAll(workItemRowSelector)))
.forEach((row) => {
row.parentElement.addEventListener("click", () => {
const id = row.childNodes.item(0).textContent;
const description = row.childNodes.item(1).textContent;
const link = row.href;
navigator.clipboard.writeText(`- ${id} - ${description} (${link})`);
});
});
});
observer.observe(document.body, { subtree: true, childList: true });
})();
Explanation
The script starts by constructing a MutationObserver. The constructor accepts a lambda, which receives an array of MutationRecord
s. These objects represent changes made to the DOM within the scope the observer is tied to (more on that later). We need to transform that array of deltas into an array of work item rows so we can parse our description from the DOM.
.flatMap()
, a handy method defined on arrays in JavaScript, is our friend here. We map each mutation record into an array of the added nodes represented in the record. Then, flatMap flattens the results out into a single, flat array. Next, we map each added node into an array of elements matching the selector for the work item row. flatMap works its magic again, and we’re left with an array of the work item rows.
From there, we iterate over each row and attach an onClick
handler that copies the formatted description into the clipboard. Finally, we call .observe()
on our mutation observer. We set the observer up to watch the entire document body and all its children with the config object. The mutation observer then ensures that, as data loads from the back end and the DOM updates, the rows we care about always get a click handler mounted.
Discussion
Two concerns drive using the MutationObserver
API and onClick
handlers. First, Jira implements as a React app. This means that Jira dynamically loads data from the back end and updates the DOM over time as new rows load. This makes it difficult to determine when to iterate over the DOM mounting our listener callbacks. But even if we added a wait of some kind, React makes no promises about how it uses or reuses DOM elements.
As React handles API requests and other user and network events, various elements are completely torn down and remounted. This means that any click handlers set up by iterating once will be torn down in no time. The MutationObserver
provides a mechanism to deal with the churn in elements caused by React doing its job.
Second, the navigation.clipboard.writeText
API only works in the context of a user interaction handler like onClick
or onKeyUp
. Giving arbitrary Javascript the ability to interact with the user’s clipboard absent any interaction is a security risk. So, most browsers enforce the event handling restriction.
Other Options
There are probably a thousand subtly-different approaches to tackling the challenge presented above. Other options I considered or spiked out included using Jira’s rest API or putting parts of the snippet above in a browser bookmarklet. The API call didn’t make sense at the time since it wasn’t easy to query out which stories we’d included in the most recent build. Simply clicking through the UI represented a much lighter-weight way to capture that information. A browser bookmarklet could get around that problem by inspecting the “currently selected” story. However, it ran directly into the security restrictions around the clipboard.
In the end, I think this approach stayed within its budget. But, either way, it made for a fun side project, and I learned a lot along the way.