Adding details to ticket descriptions + selectable button text for retrospectives (add-on script)

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 });
})();


1 Like

Welcome to the community, @m.neuschaefer!

Thanks for your thoughtful feedback on JMP. You’ve raised some good points about the plugin.

Regarding your suggestions:

  1. Copying text in tables: In the next version, I’ll restore the ability to copy data from tables. This regression after switching to new elements does impact sharing information during retrospectives and team discussions.
  2. Displaying ticket and epic names: One of JMP’s core principles is data security. The plugin intentionally doesn’t load sensitive information like issue names and descriptions. That said, you’ve raised a valid point about analyzing delay causes. I’ll consider how we might implement this as an opt-in feature with appropriate warnings about the data being extracted.
  3. Info buttons with tooltips: Good idea. The “Column”/“Activity” modes and other concepts could definitely benefit from in-context explanations rather than requiring users to check separate documentation.

The script you shared is a clever workaround - thanks for contributing that to help other users in the meantime.

Your feedback helps prioritize what to improve next!

1 Like

Yep, same here - the new update broke copy/paste for me too. I often need to pull table data quickly, so definitely felt the change right away.

That said, still really impressed with how useful and lightweight the plugin is — it’s become a go-to tool in my day-to-day.

Good to hear a fix is coming in the next release — looking forward to getting that functionality back!

1 Like

updated “workaround script” for the new version

  /**
   * Enhances ticket display in Jira Metrics Plugin
   * - Replaces ticket keys (e.g. ABC-123) with summary text fetched from the Jira API
   * - Allows the replacement text to remain selectable, even though it's inside a clickable element
   * - Preserves the plugin's click behavior
   * - Replaces commas with line breaks in issue list for readability
   */


(() => {
  const parentField = 'parent';
  const summaryFields = ['summary', parentField];

  if (!location.href.startsWith('chrome-extension://')) {
    throw 'Not in JMP Extension UI: Script terminated.';
  }

  const findBaseOrigin = () => {
    const a = document.querySelector('a[href*="/issues/"]') ||
              document.querySelector('a[href*="/browse/"]');
    if (a) return new URL(a.href).origin;
    const m = document.body.innerHTML.match(/https:\/\/[a-zA-Z0-9.-]+\.atlassian\.net/);
    return m ? m[0] : null;
  };
  const baseOrigin = findBaseOrigin();
  if (!baseOrigin) throw 'Base origin could not be determined automatically.';

  // CSS: keep spans selectable & clickable in 3rd column
  document.head.appendChild(Object.assign(
    document.createElement('style'),
    {
      innerHTML: `
        table tbody td:nth-child(3) span {
          cursor: pointer;
          user-select: text;
          -webkit-user-select: text;
          font-size: 14px;
          font-weight: 500;
          color: rgb(25, 118, 210);
        }
        .selectable-button > * {
          pointer-events: none;
        }
      `
    }
  ));

  const issueKeyRegex = /^[A-Z]+-\d+$/;
  const seen = new WeakSet();

  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);

  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);

  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}`);
      }
    });
  };

  const scan = root => {
    root.querySelectorAll('table tbody td:nth-child(3)').forEach(td => {
      // pick only real issue-key spans
      const issueSpans = [...td.querySelectorAll('span')]
        .filter(el => issueKeyRegex.test(el.textContent.trim()));

      td.innerHTML = '';
      issueSpans.forEach((el, idx) => {
        td.appendChild(el);
        processElement(el);
        if (idx < issueSpans.length - 1) {
          td.appendChild(document.createElement('br'));
        }
      });
    });
  };

  // initial run + watch for updates
  scan(document);
  new MutationObserver(muts =>
    muts.forEach(m =>
      m.addedNodes.forEach(n =>
        n.nodeType === 1 && scan(n)
      )
    )
  ).observe(document.body, { childList: true, subtree: true });
})();


1 Like

Nice, that could be useful. Thanks :+1:

1 Like