Hi, first off - I really like this plugin. It enables product owners and teams to reflect on their flow, and “inspect and adapt” their way forward to a more relayed and predictable flow!
Some suggestions:
- Instead of keeping the documentation separate, some of the more opaque options (“Column” / “Activity”) would really profit from an “info”-button or similar next to it, that provides a short explanation what the option does.
- Option to get ticket details: It would be much easier to determine causes for delays if the ticket and epic names could be displayed by the plugin as well, maybe on request
- Enable selectable text in the table: Since the latest version and the switch to elements, the text of the table cannot be copied and pasted anymore. This makes it difficult to quickly share observations with team members or copy the list over to a retrospective board.
As a workaround to remedy the last two issues, I created a script that I copy and paste into console once a table has loaded in JMP. It also renders the text a bit larger, to make it easier to read. It would be even better if the cycle time was shown completely as individual table rows, but this is what I could come up with quickly, it already makes it easier to get a quick overview of potential causes for delays.
thanks again for your work, excited to see agile metrics becoming more accessible!
Here is a long version of the script that works with the current version of JMP, it can be inserted into the Console of the chrome dev tools after the table has loaded. It only works on the Lead time view though.
(() => {
/**
* Enhances ticket display in Jira Metrics Plugin (https://chromewebstore.google.com/detail/jira-metrics-plugin-analy/)
* - Replaces ticket keys (e.g. ABC-123) with summary text fetched from the Jira API
* - Allows the replacement text to remain selectable (copyable), even though it's inside a <button>
* - Preserves the button behavior to ensure Jira plugin functions are not broken
*/
// Define which fields to fetch from the Jira API
const parentField = 'parent';
const summaryFields = ['summary', parentField];
// Abort if not running inside the extension UI
if (!location.href.startsWith('chrome-extension://')) {
throw 'Not in JMP Extension UI: Script terminated.';
}
// Tries to determine the Jira base URL dynamically
const findBaseOrigin = () => {
const a = document.querySelector('a[href*="/issues/"]') || document.querySelector('a[href*="/browse/"]');
if (a) {
const url = new URL(a.href);
return url.origin;
}
const matches = document.body.innerHTML.match(/https:\/\/[a-zA-Z0-9.-]+\.atlassian\.net/);
if (matches) return matches[0];
return null;
};
const baseOrigin = findBaseOrigin();
if (!baseOrigin) {
throw 'Base origin could not be determined automatically.';
}
// Injects custom CSS for styling buttons and making text selectable
document.head.appendChild(
Object.assign(document.createElement('style'), {
innerHTML: `
table tbody td:nth-child(3) a,
table tbody td:nth-child(3) button {
display: block;
margin: 2px 0;
text-align: left;
font-size: 16px;
}
/* Styling to make button text selectable */
.selectable-button {
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
.selectable-button > * {
pointer-events: none;
}
`
})
);
// Regex to detect issue keys like ABC-123
const issueKeyRegex = /^[A-Z]+-\d+$/;
const seen = new WeakSet();
// Fetches issue details (summary and optional parent) from Jira API
const fetchIssue = (key) =>
fetch(`${baseOrigin}/rest/api/2/issue/${key}?fields=${summaryFields.join(',')}`, {
credentials: 'include'
})
.then(r => r.json())
.then(j => ({
summary: j.fields.summary,
parentKey: j.fields[parentField] && j.fields[parentField].key
}))
.catch(() => null);
// Fetches only the summary for a parent issue
const fetchSummary = (key) =>
fetch(`${baseOrigin}/rest/api/2/issue/${key}?fields=summary`, {
credentials: 'include'
})
.then(r => r.json())
.then(j => j.fields.summary)
.catch(() => null);
// Processes a single element (button or link) to replace its text
const processElement = el => {
if (seen.has(el)) return;
seen.add(el);
const key = el.textContent.trim();
if (!issueKeyRegex.test(key)) return;
fetchIssue(key).then(data => {
if (!data) return;
const { summary, parentKey } = data;
const updateContent = (displayText) => {
el.innerHTML = `<span style="pointer-events: none;">${displayText}</span>`;
el.classList.add('selectable-button');
};
if (parentKey) {
fetchSummary(parentKey).then(parentTitle => {
if (parentTitle) {
updateContent(`<strong>${parentTitle}</strong> | ${key} ${summary}`);
}
});
} else {
updateContent(`${key} ${summary}`);
}
});
};
// Scans a root element for relevant table cells and processes their buttons/links
const scan = root => {
root.querySelectorAll('table tbody td:nth-child(3)').forEach(td => {
[...td.childNodes].forEach(n => {
if (n.nodeType === 3 && n.textContent.trim().replace(/,/g, '') === '') {
td.removeChild(n);
}
});
const elements = [...td.querySelectorAll('a, button')];
td.textContent = '';
elements.forEach(el => {
td.appendChild(el);
processElement(el);
});
});
};
// Initial scan of the document
scan(document);
// Observe DOM changes and re-scan added nodes
new MutationObserver(mutations =>
mutations.forEach(m =>
m.addedNodes.forEach(n =>
n.nodeType === 1 && scan(n)
)
)
).observe(document.body, { childList: true, subtree: true });
})();