@@ -33,6 +33,7 @@ import { openIssue as openSonarLintIssue, probeSonarLintServers } from '../../.. | |||
import { Ide } from '../../../types/sonarlint'; | |||
export interface Props { | |||
branchName?: string; | |||
issueKey: string; | |||
projectKey: string; | |||
} | |||
@@ -46,7 +47,7 @@ const showError = () => addGlobalErrorMessage(translate('issues.open_in_ide.fail | |||
const showSuccess = () => addGlobalSuccessMessage(translate('issues.open_in_ide.success')); | |||
export function IssueOpenInIdeButton({ issueKey, projectKey }: Readonly<Props>) { | |||
export function IssueOpenInIdeButton({ branchName, issueKey, projectKey }: Readonly<Props>) { | |||
const [state, setState] = React.useState<State>({ ides: [], mounted: false }); | |||
React.useEffect(() => { | |||
@@ -67,7 +68,7 @@ export function IssueOpenInIdeButton({ issueKey, projectKey }: Readonly<Props>) | |||
const openIssue = (ide: Ide) => { | |||
setState({ ...state, ides: [] }); | |||
return openSonarLintIssue(ide.port, projectKey, issueKey) | |||
return openSonarLintIssue(ide.port, projectKey, issueKey, branchName) | |||
.then(showSuccess) | |||
.catch(showError) | |||
.finally(cleanState); |
@@ -103,6 +103,7 @@ it('handles button click with one ide found', async () => { | |||
MOCK_IDES[0].port, | |||
MOCK_PROJECT_KEY, | |||
MOCK_ISSUE_KEY, | |||
undefined, | |||
); | |||
expect(addGlobalSuccessMessage).toHaveBeenCalledWith('issues.open_in_ide.success'); | |||
@@ -147,6 +148,7 @@ it('handles button click with several ides found', async () => { | |||
MOCK_IDES[1].port, | |||
MOCK_PROJECT_KEY, | |||
MOCK_ISSUE_KEY, | |||
undefined, | |||
); | |||
expect(addGlobalSuccessMessage).toHaveBeenCalledWith('issues.open_in_ide.success'); |
@@ -43,7 +43,7 @@ import { | |||
Issue as TypeIssue, | |||
} from '../../../types/types'; | |||
import { IssueSourceViewerScrollContext } from '../components/IssueSourceViewerScrollContext'; | |||
import IssueSourceViewerHeader from './IssueSourceViewerHeader'; | |||
import { IssueSourceViewerHeader } from './IssueSourceViewerHeader'; | |||
import SnippetViewer from './SnippetViewer'; | |||
import { | |||
EXPAND_BY_LINES, | |||
@@ -264,7 +264,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone | |||
}; | |||
render() { | |||
const { branchLike, isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props; | |||
const { isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props; | |||
const { additionalLines, loading, snippets } = this.state; | |||
const snippetLines = linesForSnippets(snippets, { | |||
@@ -302,7 +302,6 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone | |||
)} | |||
<IssueSourceViewerHeader | |||
branchLike={branchLike} | |||
className={issueIsClosed && !issueIsFileLevel ? 'null-spacer-bottom' : ''} | |||
expandable={isExpandable(snippets, snippetGroup)} | |||
issueKey={issue.key} |
@@ -18,6 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { useTheme } from '@emotion/react'; | |||
import styled from '@emotion/styled'; | |||
import classNames from 'classnames'; | |||
import { | |||
@@ -28,31 +29,28 @@ import { | |||
LightLabel, | |||
Link, | |||
Spinner, | |||
ThemeProp, | |||
UnfoldIcon, | |||
themeColor, | |||
withTheme, | |||
} from 'design-system'; | |||
import * as React from 'react'; | |||
import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; | |||
import { ComponentContext } from '../../../app/components/componentContext/ComponentContext'; | |||
import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; | |||
import Tooltip from '../../../components/controls/Tooltip'; | |||
import { ClipboardBase } from '../../../components/controls/clipboard'; | |||
import { getBranchLikeQuery } from '../../../helpers/branch-like'; | |||
import { getBranchLikeQuery, isBranch, isPullRequest } from '../../../helpers/branch-like'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { collapsedDirFromPath, fileFromPath } from '../../../helpers/path'; | |||
import { getBranchLikeUrl, getComponentIssuesUrl } from '../../../helpers/urls'; | |||
import { BranchLike } from '../../../types/branch-like'; | |||
import { useBranchesQuery } from '../../../queries/branch'; | |||
import { ComponentQualifier } from '../../../types/component'; | |||
import { SourceViewerFile } from '../../../types/types'; | |||
import { CurrentUser, isLoggedIn } from '../../../types/users'; | |||
import { isLoggedIn } from '../../../types/users'; | |||
import { IssueOpenInIdeButton } from '../components/IssueOpenInIdeButton'; | |||
export const INTERACTIVE_TOOLTIP_DELAY = 0.5; | |||
export interface Props { | |||
branchLike: BranchLike | undefined; | |||
className?: string; | |||
currentUser: CurrentUser; | |||
displayProjectName?: boolean; | |||
expandable?: boolean; | |||
issueKey: string; | |||
@@ -62,11 +60,16 @@ export interface Props { | |||
sourceViewerFile: SourceViewerFile; | |||
} | |||
function IssueSourceViewerHeader(props: Readonly<Props> & ThemeProp) { | |||
export function IssueSourceViewerHeader(props: Readonly<Props>) { | |||
const { component } = React.useContext(ComponentContext); | |||
const { data: branchData, isLoading: isLoadingBranches } = useBranchesQuery(component); | |||
const currentUser = useCurrentUser(); | |||
const theme = useTheme(); | |||
const branchLike = branchData?.branchLike; | |||
const { | |||
branchLike, | |||
className, | |||
currentUser, | |||
displayProjectName = true, | |||
expandable, | |||
issueKey, | |||
@@ -74,7 +77,6 @@ function IssueSourceViewerHeader(props: Readonly<Props> & ThemeProp) { | |||
loading, | |||
onExpand, | |||
sourceViewerFile, | |||
theme, | |||
} = props; | |||
const { measures, path, project, projectName, q } = sourceViewerFile; | |||
@@ -88,6 +90,18 @@ function IssueSourceViewerHeader(props: Readonly<Props> & ThemeProp) { | |||
border-bottom: none; | |||
`; | |||
const branchName = React.useMemo(() => { | |||
if (isBranch(branchLike)) { | |||
return branchLike.name; | |||
} | |||
if (isPullRequest(branchLike)) { | |||
return branchLike.branch; | |||
} | |||
return undefined; // should never end up here, but needed for consistent returns | |||
}, [branchLike]); | |||
return ( | |||
<IssueSourceViewerStyle | |||
aria-label={sourceViewerFile.path} | |||
@@ -149,13 +163,13 @@ function IssueSourceViewerHeader(props: Readonly<Props> & ThemeProp) { | |||
</div> | |||
{!isProjectRoot && isLoggedIn(currentUser) && ( | |||
<IssueOpenInIdeButton issueKey={issueKey} projectKey={project} /> | |||
<IssueOpenInIdeButton branchName={branchName} issueKey={issueKey} projectKey={project} /> | |||
)} | |||
{!isProjectRoot && measures.issues !== undefined && ( | |||
<div | |||
className={classNames('sw-ml-4', { | |||
'sw-mr-1': !expandable || loading, | |||
'sw-mr-1': (!expandable || loading) ?? isLoadingBranches, | |||
})} | |||
> | |||
<Link | |||
@@ -170,9 +184,9 @@ function IssueSourceViewerHeader(props: Readonly<Props> & ThemeProp) { | |||
</div> | |||
)} | |||
<Spinner className="sw-mr-1" loading={loading} /> | |||
<Spinner className="sw-mr-1" loading={loading ?? isLoadingBranches} /> | |||
{expandable && !loading && ( | |||
{expandable && !(loading ?? isLoadingBranches) && ( | |||
<div className="sw-ml-4"> | |||
<InteractiveIcon | |||
Icon={UnfoldIcon} | |||
@@ -185,5 +199,3 @@ function IssueSourceViewerHeader(props: Readonly<Props> & ThemeProp) { | |||
</IssueSourceViewerStyle> | |||
); | |||
} | |||
export default withCurrentUserContext(withTheme(IssueSourceViewerHeader)); |
@@ -17,12 +17,18 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { screen, waitFor } from '@testing-library/react'; | |||
import * as React from 'react'; | |||
import { mockMainBranch } from '../../../../helpers/mocks/branch-like'; | |||
import BranchesServiceMock from '../../../../api/mocks/BranchesServiceMock'; | |||
import { AvailableFeaturesContext } from '../../../../app/components/available-features/AvailableFeaturesContext'; | |||
import { ComponentContext } from '../../../../app/components/componentContext/ComponentContext'; | |||
import { mockComponent } from '../../../../helpers/mocks/component'; | |||
import { mockSourceViewerFile } from '../../../../helpers/mocks/sources'; | |||
import { renderComponent } from '../../../../helpers/testReactTestingUtils'; | |||
import { byRole, byText } from '../../../../helpers/testSelector'; | |||
import IssueSourceViewerHeader, { Props } from '../IssueSourceViewerHeader'; | |||
import { Feature } from '../../../../types/features'; | |||
import { IssueSourceViewerHeader, Props } from '../IssueSourceViewerHeader'; | |||
const ui = { | |||
expandAllLines: byRole('button', { name: 'source_viewer.expand_all_lines' }), | |||
@@ -31,54 +37,87 @@ const ui = { | |||
viewAllIssues: byRole('link', { name: 'source_viewer.view_all_issues' }), | |||
}; | |||
it('should render correctly', () => { | |||
const branchHandler = new BranchesServiceMock(); | |||
afterEach(() => { | |||
branchHandler.reset(); | |||
}); | |||
it('should render correctly', async () => { | |||
branchHandler.emptyBranchesAndPullRequest(); | |||
renderIssueSourceViewerHeader(); | |||
expect(await screen.findByText('loading')).toBeInTheDocument(); | |||
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); | |||
expect(ui.expandAllLines.get()).toBeInTheDocument(); | |||
expect(ui.projectLink.get()).toBeInTheDocument(); | |||
expect(ui.projectName.get()).toBeInTheDocument(); | |||
expect(ui.viewAllIssues.get()).toBeInTheDocument(); | |||
}); | |||
it('should not render expandable link', () => { | |||
it('should not render expandable link', async () => { | |||
renderIssueSourceViewerHeader({ expandable: false }); | |||
expect(await screen.findByText('loading')).toBeInTheDocument(); | |||
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); | |||
expect(ui.expandAllLines.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should not render link to project', () => { | |||
renderIssueSourceViewerHeader({ linkToProject: false }); | |||
it('should not render link to project', async () => { | |||
renderIssueSourceViewerHeader({ linkToProject: false }, '?id=my-project&branch=normal-branch'); | |||
expect(await screen.findByText('loading')).toBeInTheDocument(); | |||
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); | |||
expect(ui.projectLink.query()).not.toBeInTheDocument(); | |||
expect(ui.projectName.get()).toBeInTheDocument(); | |||
}); | |||
it('should not render project name', () => { | |||
renderIssueSourceViewerHeader({ displayProjectName: false }); | |||
it('should not render project name', async () => { | |||
renderIssueSourceViewerHeader({ displayProjectName: false }, '?id=my-project&pullRequest=01'); | |||
expect(await screen.findByText('loading')).toBeInTheDocument(); | |||
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); | |||
expect(ui.projectLink.query()).not.toBeInTheDocument(); | |||
expect(ui.projectName.query()).not.toBeInTheDocument(); | |||
}); | |||
it('should render without issue expand all when no issue', () => { | |||
it('should render without issue expand all when no issue', async () => { | |||
renderIssueSourceViewerHeader({ | |||
sourceViewerFile: mockSourceViewerFile('foo/bar.ts', 'my-project', { | |||
measures: {}, | |||
}), | |||
}); | |||
expect(await screen.findByText('loading')).toBeInTheDocument(); | |||
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); | |||
expect(ui.viewAllIssues.query()).not.toBeInTheDocument(); | |||
}); | |||
function renderIssueSourceViewerHeader(props: Partial<Props> = {}) { | |||
function renderIssueSourceViewerHeader(props: Partial<Props> = {}, path = '?id=my-project') { | |||
return renderComponent( | |||
<IssueSourceViewerHeader | |||
branchLike={mockMainBranch()} | |||
expandable | |||
issueKey="issue-key" | |||
onExpand={jest.fn()} | |||
sourceViewerFile={mockSourceViewerFile('foo/bar.ts', 'my-project')} | |||
{...props} | |||
/>, | |||
<AvailableFeaturesContext.Provider value={[Feature.BranchSupport]}> | |||
<ComponentContext.Provider | |||
value={{ | |||
component: mockComponent(), | |||
onComponentChange: jest.fn(), | |||
fetchComponent: jest.fn(), | |||
}} | |||
> | |||
<IssueSourceViewerHeader | |||
expandable | |||
issueKey="issue-key" | |||
onExpand={jest.fn()} | |||
sourceViewerFile={mockSourceViewerFile('foo/bar.ts', 'my-project')} | |||
{...props} | |||
/> | |||
</ComponentContext.Provider> | |||
</AvailableFeaturesContext.Provider>, | |||
path, | |||
); | |||
} |
@@ -54,7 +54,7 @@ export interface HotspotHeaderProps { | |||
export function HotspotHeader(props: HotspotHeaderProps) { | |||
const { branchLike, component, hotspot, standards } = props; | |||
const { message, messageFormattings, rule, key } = hotspot; | |||
const refrechBranchStatus = useRefreshBranchStatus(); | |||
const refreshBranchStatus = useRefreshBranchStatus(); | |||
const permalink = getPathUrlAsString( | |||
getComponentSecurityHotspotsUrl(component.key, { | |||
@@ -67,7 +67,7 @@ export function HotspotHeader(props: HotspotHeaderProps) { | |||
const categoryStandard = standards?.[SecurityStandard.SONARSOURCE][rule.securityCategory]?.title; | |||
const handleStatusChange = async (statusOption: HotspotStatusOption) => { | |||
await props.onUpdateHotspot(true, statusOption); | |||
refrechBranchStatus(); | |||
refreshBranchStatus(); | |||
}; | |||
return ( |
@@ -81,7 +81,7 @@ export default function HotspotViewerTabs(props: Props) { | |||
branchLike, | |||
} = props; | |||
const refrechBranchStatus = useRefreshBranchStatus(); | |||
const refreshBranchStatus = useRefreshBranchStatus(); | |||
const isSticky = useStickyDetection('.hotspot-tabs', { | |||
offset: TABS_OFFSET, | |||
}); | |||
@@ -163,7 +163,7 @@ export default function HotspotViewerTabs(props: Props) { | |||
const handleStatusChange = async (statusOption: HotspotStatusOption) => { | |||
await props.onUpdateHotspot(true, statusOption); | |||
refrechBranchStatus(); | |||
refreshBranchStatus(); | |||
}; | |||
React.useEffect(() => { |
@@ -95,6 +95,8 @@ describe('openHotspot', () => { | |||
describe('openIssue', () => { | |||
it('should send the correct request to the IDE to open an issue', async () => { | |||
let branchName: string | undefined = undefined; | |||
const issueKey = 'my-issue-key'; | |||
const resp = new Response(); | |||
window.fetch = jest.fn((input: RequestInfo) => { | |||
@@ -103,7 +105,9 @@ describe('openIssue', () => { | |||
try { | |||
expect(calledUrl.searchParams.get('server')).toStrictEqual('http://localhost'); | |||
expect(calledUrl.searchParams.get('project')).toStrictEqual(PROJECT_KEY); | |||
expect(calledUrl.searchParams.get('issue')).toStrictEqual('my-issue-key'); | |||
expect(calledUrl.searchParams.get('issue')).toStrictEqual(issueKey); | |||
// eslint-disable-next-line jest/no-conditional-in-test | |||
expect(calledUrl.searchParams.get('branch') ?? undefined).toStrictEqual(branchName); | |||
} catch (error) { | |||
return Promise.reject(error); | |||
} | |||
@@ -111,7 +115,13 @@ describe('openIssue', () => { | |||
return Promise.resolve(resp); | |||
}); | |||
const result = await openIssue(SONARLINT_PORT_START, PROJECT_KEY, 'my-issue-key'); | |||
let result = await openIssue(SONARLINT_PORT_START, PROJECT_KEY, issueKey); | |||
expect(result).toBe(resp); | |||
branchName = 'branch-1'; | |||
result = await openIssue(SONARLINT_PORT_START, PROJECT_KEY, issueKey, branchName); | |||
expect(result).toBe(resp); | |||
}); |
@@ -55,13 +55,22 @@ export function openHotspot(calledPort: number, projectKey: string, hotspotKey: | |||
return fetch(showUrl.toString()).then((response: Response) => checkStatus(response, true)); | |||
} | |||
export function openIssue(calledPort: number, projectKey: string, issueKey: string) { | |||
export function openIssue( | |||
calledPort: number, | |||
projectKey: string, | |||
issueKey: string, | |||
branchName?: string, | |||
) { | |||
const showUrl = new URL(buildSonarLintEndpoint(calledPort, '/issues/show')); | |||
showUrl.searchParams.set('server', getHostUrl()); | |||
showUrl.searchParams.set('project', projectKey); | |||
showUrl.searchParams.set('issue', issueKey); | |||
if (branchName !== undefined) { | |||
showUrl.searchParams.set('branch', branchName); | |||
} | |||
return fetch(showUrl.toString()).then((response: Response) => checkStatus(response, true)); | |||
} | |||
@@ -128,9 +128,10 @@ export function useBranchesQuery(component?: Component, refetchInterval?: number | |||
: branchLikes.find( | |||
(b) => isBranch(b) && (prOrBranch === 'branch' ? b.name === name : b.isMain), | |||
); | |||
return { branchLikes, branchLike }; | |||
}, | |||
// The check of the key must desapear once component state is in react-query | |||
// The check of the key must disappear once component state is in react-query | |||
enabled: !!component && component.key === key[1], | |||
staleTime: refetchInterval ?? BRANCHES_STALE_TIME, | |||
refetchInterval, | |||
@@ -293,10 +294,10 @@ export function useSetMainBranchMutation() { | |||
} | |||
/** | |||
* Helper functions that sould be avoid. Instead convert the component into functional | |||
* Helper functions that sould be avoided. Instead convert the component into a functional one | |||
* and/or use proper react-query | |||
*/ | |||
const DELAY_REFRECH = 1_000; | |||
const REFRESH_INTERVAL = 1_000; | |||
export function useRefreshBranchStatus(): () => void { | |||
const queryClient = useQueryClient(); | |||
@@ -311,7 +312,7 @@ export function useRefreshBranchStatus(): () => void { | |||
queryClient.invalidateQueries({ | |||
queryKey: invalidateDetailsKey, | |||
}); | |||
}, DELAY_REFRECH), | |||
}, REFRESH_INTERVAL), | |||
[invalidateDetailsKey, invalidateStatusKey], | |||
); | |||
} |