diff --git a/webapp/src/components/link_tooltip/index.jsx b/webapp/src/components/link_tooltip/index.jsx index 00bd19fa4..7f1448e18 100644 --- a/webapp/src/components/link_tooltip/index.jsx +++ b/webapp/src/components/link_tooltip/index.jsx @@ -8,7 +8,10 @@ import manifest from '@/manifest'; import {LinkTooltip} from './link_tooltip'; const mapStateToProps = (state) => { - return {connected: state[`plugins-${manifest.id}`].connected}; + return { + connected: state[`plugins-${manifest.id}`].connected, + enterpriseURL: state[`plugins-${manifest.id}`].enterpriseURL, + }; }; export default connect(mapStateToProps, null)(LinkTooltip); diff --git a/webapp/src/components/link_tooltip/link_tooltip.jsx b/webapp/src/components/link_tooltip/link_tooltip.jsx index feff6df51..bc4bf0019 100644 --- a/webapp/src/components/link_tooltip/link_tooltip.jsx +++ b/webapp/src/components/link_tooltip/link_tooltip.jsx @@ -1,44 +1,55 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState} from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import './tooltip.css'; -import {GitMergeIcon, GitPullRequestIcon, IssueClosedIcon, IssueOpenedIcon} from '@primer/octicons-react'; +import { GitMergeIcon, GitPullRequestIcon, IssueClosedIcon, IssueOpenedIcon } from '@primer/octicons-react'; import ReactMarkdown from 'react-markdown'; import Client from '@/client'; -import {getLabelFontColor, hexToRGB} from '../../utils/styles'; +import { getLabelFontColor, hexToRGB } from '../../utils/styles'; const maxTicketDescriptionLength = 160; -export const LinkTooltip = ({href, connected, show, theme}) => { +export const LinkTooltip = ({ href, connected, show, theme, enterpriseURL }) => { const [data, setData] = useState(null); useEffect(() => { const initData = async () => { - if (href.includes('github.com/')) { - const [owner, repo, type, number] = href.split('github.com/')[1].split('/'); - if (!owner | !repo | !type | !number) { - return; + let owner; + let repo; + let type; + let number; + + if (enterpriseURL) { + const entURL = enterpriseURL.endsWith('/') ? enterpriseURL : enterpriseURL + '/'; + if (href.startsWith(entURL)) { + [owner, repo, type, number] = href.substring(entURL.length).split('/'); } + } else if (href.includes('github.com/')) { + [owner, repo, type, number] = href.split('github.com/')[1].split('/'); + } + + if (!owner || !repo || !type || !number) { + return; + } - let res; - switch (type) { + let res; + switch (type) { case 'issues': res = await Client.getIssue(owner, repo, number); break; case 'pull': res = await Client.getPullRequest(owner, repo, number); break; - } - if (res) { - res.owner = owner; - res.repo = repo; - res.type = type; - } - setData(res); } + if (res) { + res.owner = owner; + res.repo = repo; + res.type = type; + } + setData(res); }; // show is not provided for Mattermost Server < 5.28 @@ -47,7 +58,26 @@ export const LinkTooltip = ({href, connected, show, theme}) => { } initData(); - }, [connected, data, href, show]); + }, [connected, data, href, show, enterpriseURL]); + + const openedByLink = useMemo(() => { + if (!data?.user?.login) { + return null; + } + // Immediately map the html_url value when present (which should work for both Enterprise and Cloud) + if (data.user.html_url) { + return data.user.html_url; + } + + // Fallback to a generic enterprise URL when appropriate, handling possible trailing slashes + if (enterpriseURL) { + const entURL = enterpriseURL.endsWith('/') ? enterpriseURL : enterpriseURL + '/'; + return `${entURL}${data.user.login}`; + } + + // Assume it's GitHub cloud and fallback to the original path (unlikely to ever run unless there are breaking changes in GitHub's API + return `https://github.com/${data.user.login}`; + }, [data, enterpriseURL]); const getIconElement = () => { const iconProps = { @@ -58,32 +88,32 @@ export const LinkTooltip = ({href, connected, show, theme}) => { let icon; let color; switch (data.type) { - case 'pull': - icon = ; - - color = '#28a745'; - if (data.state === 'closed') { - if (data.merged) { - color = '#6f42c1'; - icon = ; - } else { - color = '#cb2431'; + case 'pull': + icon = ; + + color = '#28a745'; + if (data.state === 'closed') { + if (data.merged) { + color = '#6f42c1'; + icon = ; + } else { + color = '#cb2431'; + } } - } - break; - case 'issues': - color = data.state === 'open' ? '#28a745' : '#cb2431'; + break; + case 'issues': + color = data.state === 'open' ? '#28a745' : '#cb2431'; - if (data.state === 'open') { - icon = ; - } else { - icon = ; - } - break; + if (data.state === 'open') { + icon = ; + } else { + icon = ; + } + break; } return ( - + {icon} ); @@ -105,10 +135,10 @@ export const LinkTooltip = ({href, connected, show, theme}) => {
- + {data.repo} {' on '} @@ -117,7 +147,7 @@ export const LinkTooltip = ({href, connected, show, theme}) => {
- { getIconElement() } + {getIconElement()} {/* info */} @@ -126,7 +156,7 @@ export const LinkTooltip = ({href, connected, show, theme}) => { href={href} target='_blank' rel='noopener noreferrer' - style={{color: theme.centerChannelColor}} + style={{ color: theme.centerChannelColor }} >
{data.title}
{'#' + data.number} @@ -135,7 +165,7 @@ export const LinkTooltip = ({href, connected, show, theme}) => {

{'Opened by '} @@ -172,7 +202,7 @@ export const LinkTooltip = ({href, connected, show, theme}) => { key={idx} className='label mr-1' title={label.description} - style={{backgroundColor: '#' + label.color, color: getLabelFontColor(label.color)}} + style={{ backgroundColor: '#' + label.color, color: getLabelFontColor(label.color) }} > {label.name} @@ -193,4 +223,5 @@ LinkTooltip.propTypes = { connected: PropTypes.bool.isRequired, theme: PropTypes.object.isRequired, show: PropTypes.bool, + enterpriseURL: PropTypes.string, }; diff --git a/webapp/src/components/link_tooltip/link_tooltip.test.jsx b/webapp/src/components/link_tooltip/link_tooltip.test.jsx new file mode 100644 index 000000000..0fa4f59ea --- /dev/null +++ b/webapp/src/components/link_tooltip/link_tooltip.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import Client from '@/client'; + +import { LinkTooltip } from './link_tooltip'; + +jest.mock('@/client', () => ({ + getIssue: jest.fn(), + getPullRequest: jest.fn(), +})); + +jest.mock('react-markdown', () => () =>

); + +describe('LinkTooltip', () => { + const baseProps = { + href: 'https://github.com/mattermost/mattermost-plugin-github/issues/1', + connected: true, + show: true, + theme: { + centerChannelBg: '#ffffff', + centerChannelColor: '#333333', + }, + enterpriseURL: '', + }; + + let wrapper; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + if (wrapper && wrapper.length) { + wrapper.unmount(); + } + }); + + test('should fetch issue for github.com link', () => { + wrapper = mount(); + expect(Client.getIssue).toHaveBeenCalledWith('mattermost', 'mattermost-plugin-github', '1'); + }); + + test('should fetch pull request for github.com link', () => { + const props = { + ...baseProps, + href: 'https://github.com/mattermost/mattermost-plugin-github/pull/2', + }; + wrapper = mount(); + expect(Client.getPullRequest).toHaveBeenCalledWith('mattermost', 'mattermost-plugin-github', '2'); + }); + + test('should fetch issue for enterprise link', () => { + const props = { + ...baseProps, + href: 'https://github.example.com/mattermost/mattermost-plugin-github/issues/3', + enterpriseURL: 'https://github.example.com', + }; + wrapper = mount(); + expect(Client.getIssue).toHaveBeenCalledWith('mattermost', 'mattermost-plugin-github', '3'); + }); + + test('should fetch pull request for enterprise link', () => { + const props = { + ...baseProps, + href: 'https://github.example.com/mattermost/mattermost-plugin-github/pull/4', + enterpriseURL: 'https://github.example.com', + }; + wrapper = mount(); + expect(Client.getPullRequest).toHaveBeenCalledWith('mattermost', 'mattermost-plugin-github', '4'); + }); + + test('should handle enterprise URL with trailing slash', () => { + const props = { + ...baseProps, + href: 'https://github.example.com/mattermost/mattermost-plugin-github/issues/5', + enterpriseURL: 'https://github.example.com/', + }; + wrapper = mount(); + expect(Client.getIssue).toHaveBeenCalledWith('mattermost', 'mattermost-plugin-github', '5'); + }); + + test('should not fetch if enterprise URL does not match', () => { + const props = { + ...baseProps, + href: 'https://other-github.com/mattermost/mattermost-plugin-github/issues/6', + enterpriseURL: 'https://github.example.com', + }; + wrapper = mount(); + expect(Client.getIssue).not.toHaveBeenCalled(); + }); + + test('should use html_url for opened by link if available', async () => { + Client.getIssue.mockResolvedValueOnce({ + id: 1, + title: 'Test Issue', + body: 'Description', + user: { + login: 'testuser', + html_url: 'https://github.com/testuser/profile', + }, + state: 'open', + labels: [], + created_at: '2023-01-01T00:00:00Z', + }); + + wrapper = mount(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + const link = wrapper.find('.opened-by a'); + expect(link.exists()).toBe(true); + expect(link.prop('href')).toBe('https://github.com/testuser/profile'); + }); + + test('should fallback to enterprise URL for opened by link if html_url missing', async () => { + Client.getIssue.mockResolvedValueOnce({ + id: 1, + title: 'Test Enterprise Issue', + body: 'Description', + user: { + login: 'entuser', + }, + state: 'open', + labels: [], + created_at: '2023-01-01T00:00:00Z', + }); + + const props = { + ...baseProps, + href: 'https://github.example.com/mattermost/mattermost-plugin-github/issues/3', + enterpriseURL: 'https://github.example.com', + }; + wrapper = mount(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + const link = wrapper.find('.opened-by a'); + expect(link.exists()).toBe(true); + expect(link.prop('href')).toBe('https://github.example.com/entuser'); + }); + + test('should handle enterprise URL with trailing slash for opened by link fallback', async () => { + Client.getIssue.mockResolvedValueOnce({ + id: 1, + title: 'Test Enterprise Issue', + body: 'Description', + user: { + login: 'entuser', + }, + state: 'open', + labels: [], + created_at: '2023-01-01T00:00:00Z', + }); + + const props = { + ...baseProps, + href: 'https://github.example.com/mattermost/mattermost-plugin-github/issues/3', + enterpriseURL: 'https://github.example.com/', + }; + wrapper = mount(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + const link = wrapper.find('.opened-by a'); + expect(link.prop('href')).toBe('https://github.example.com/entuser'); + }); + + test('should default to github.com for opened by link if no enterpriseURL and no html_url', async () => { + Client.getIssue.mockResolvedValueOnce({ + id: 1, + title: 'Test Issue', + body: 'Description', + user: { + login: 'clouduser', + }, + state: 'open', + labels: [], + created_at: '2023-01-01T00:00:00Z', + }); + + wrapper = mount(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + const link = wrapper.find('.opened-by a'); + expect(link.prop('href')).toBe('https://github.com/clouduser'); + }); +});