@@ -38,8 +38,15 @@ export const CurrentUserContext = React.createContext<CurrentUserContextInterfac | |||
updateDismissedNotices: noop, | |||
}); | |||
export function useCurrentLoginUser() { | |||
export function useCurrentUser() { | |||
const { currentUser } = useContext(CurrentUserContext); | |||
return currentUser; | |||
} | |||
export function useCurrentLoginUser() { | |||
const currentUser = useCurrentUser(); | |||
if (!currentUser.isLoggedIn) { | |||
handleRequiredAuthentication(); | |||
} |
@@ -21,6 +21,7 @@ | |||
import { ChevronRightIcon, DangerButtonSecondary } from 'design-system'; | |||
import React from 'react'; | |||
import { useIntl } from 'react-intl'; | |||
import { isIssueMeasure, propsToIssueParams } from '../../../components/shared/utils'; | |||
import { getBranchLikeQuery } from '../../../helpers/branch-like'; | |||
import { getLocalizedMetricName } from '../../../helpers/l10n'; | |||
import { formatMeasure, getShortType, isDiffMetric } from '../../../helpers/measures'; | |||
@@ -168,10 +169,10 @@ function getQGConditionUrl( | |||
) { | |||
const { metric } = condition; | |||
const sinceLeakPeriod = isDiffMetric(metric); | |||
const issueType = RATING_METRICS_MAPPING[metric]; | |||
const ratingIssueType = RATING_METRICS_MAPPING[metric]; | |||
if (issueType) { | |||
if (issueType === IssueType.SecurityHotspot) { | |||
if (ratingIssueType) { | |||
if (ratingIssueType === IssueType.SecurityHotspot) { | |||
return getComponentSecurityHotspotsUrl(componentKey, { | |||
...getBranchLikeQuery(branchLike), | |||
...(sinceLeakPeriod ? { sinceLeakPeriod: 'true' } : {}), | |||
@@ -179,15 +180,22 @@ function getQGConditionUrl( | |||
} | |||
return getComponentIssuesUrl(componentKey, { | |||
resolved: 'false', | |||
types: issueType, | |||
types: ratingIssueType, | |||
...getBranchLikeQuery(branchLike), | |||
...(sinceLeakPeriod ? { sinceLeakPeriod: 'true' } : {}), | |||
...(issueType !== IssueType.CodeSmell | |||
...(ratingIssueType !== IssueType.CodeSmell | |||
? { severities: RATING_TO_SEVERITIES_MAPPING[Number(condition.error) - 1] } | |||
: {}), | |||
}); | |||
} | |||
if (isIssueMeasure(condition.measure.metric.key)) { | |||
return getComponentIssuesUrl(componentKey, { | |||
...propsToIssueParams(condition.measure.metric.key, condition.period != null), | |||
...getBranchLikeQuery(branchLike), | |||
}); | |||
} | |||
return getComponentDrilldownUrl({ | |||
componentKey, | |||
metric, |
@@ -33,32 +33,44 @@ it('renders failed QG', () => { | |||
renderBranchQualityGate(); | |||
// Maintainability rating condition | |||
expect( | |||
byRole('link', { | |||
name: 'overview.failed_condition.x_requiredmetric_domain.Maintainability metric.type.rating A', | |||
}).get(), | |||
).toBeInTheDocument(); | |||
const maintainabilityRatingLink = byRole('link', { | |||
name: 'overview.failed_condition.x_requiredmetric_domain.Maintainability metric.type.rating A', | |||
}).get(); | |||
expect(maintainabilityRatingLink).toBeInTheDocument(); | |||
expect(maintainabilityRatingLink).toHaveAttribute( | |||
'href', | |||
'/project/issues?resolved=false&types=CODE_SMELL&pullRequest=1001&sinceLeakPeriod=true&id=my-project', | |||
); | |||
// Security Hotspots rating condition | |||
expect( | |||
byRole('link', { | |||
name: 'overview.failed_condition.x_requiredmetric_domain.Security Review metric.type.rating A', | |||
}).get(), | |||
).toBeInTheDocument(); | |||
const securityHotspotsRatingLink = byRole('link', { | |||
name: 'overview.failed_condition.x_requiredmetric_domain.Security Review metric.type.rating A', | |||
}).get(); | |||
expect(securityHotspotsRatingLink).toBeInTheDocument(); | |||
expect(securityHotspotsRatingLink).toHaveAttribute( | |||
'href', | |||
'/security_hotspots?id=my-project&pullRequest=1001', | |||
); | |||
// New code smells | |||
expect( | |||
byRole('link', { | |||
name: 'overview.failed_condition.x_required 5 Code Smells ≤ 1', | |||
}).get(), | |||
).toBeInTheDocument(); | |||
const codeSmellsLink = byRole('link', { | |||
name: 'overview.failed_condition.x_required 5 Code Smells ≤ 1', | |||
}).get(); | |||
expect(codeSmellsLink).toBeInTheDocument(); | |||
expect(codeSmellsLink).toHaveAttribute( | |||
'href', | |||
'/project/issues?resolved=false&types=CODE_SMELL&pullRequest=1001&id=my-project', | |||
); | |||
// Conditions to cover | |||
expect( | |||
byRole('link', { | |||
name: 'overview.failed_condition.x_required 5 Conditions to cover ≥ 10', | |||
}).get(), | |||
).toBeInTheDocument(); | |||
const conditionToCoverLink = byRole('link', { | |||
name: 'overview.failed_condition.x_required 5 Conditions to cover ≥ 10', | |||
}).get(); | |||
expect(conditionToCoverLink).toBeInTheDocument(); | |||
expect(conditionToCoverLink).toHaveAttribute( | |||
'href', | |||
'/component_measures?id=my-project&metric=conditions_to_cover&pullRequest=1001&view=list', | |||
); | |||
expect(byLabelText('overview.quality_gate_x.overview.gate.ERROR').get()).toBeInTheDocument(); | |||
}); |
@@ -32,9 +32,9 @@ import MeasuresCardPanel from '../branches/MeasuresCardPanel'; | |||
import BranchQualityGate from '../components/BranchQualityGate'; | |||
import IgnoredConditionWarning from '../components/IgnoredConditionWarning'; | |||
import MetaTopBar from '../components/MetaTopBar'; | |||
import SonarLintPromotion from '../components/SonarLintPromotion'; | |||
import '../styles.css'; | |||
import { PR_METRICS, Status } from '../utils'; | |||
import SonarLintAd from './SonarLintAd'; | |||
interface Props { | |||
branchLike: PullRequest; | |||
@@ -89,13 +89,13 @@ export default function PullRequestOverview(props: Props) { | |||
} | |||
const failedConditions = conditions | |||
.filter((condition) => condition.level === 'ERROR') | |||
.filter((condition) => condition.level === Status.ERROR) | |||
.map((c) => enhanceConditionWithMeasure(c, measures)) | |||
.filter(isDefined); | |||
return ( | |||
<CenteredLayout> | |||
<div className="it__pr-overview sw-mt-12 sw-grid sw-grid-cols-12"> | |||
<div className="it__pr-overview sw-mt-12 sw-mb-8 sw-grid sw-grid-cols-12"> | |||
<div className="sw-col-start-2 sw-col-span-10"> | |||
<MetaTopBar branchLike={branchLike} measures={measures} /> | |||
<BasicSeparator className="sw-my-4" /> | |||
@@ -119,7 +119,7 @@ export default function PullRequestOverview(props: Props) { | |||
measures={measures} | |||
/> | |||
<SonarLintPromotion qgConditions={conditions} /> | |||
<SonarLintAd status={status} /> | |||
</div> | |||
</div> | |||
</CenteredLayout> |
@@ -0,0 +1,111 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2023 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import styled from '@emotion/styled'; | |||
import { | |||
Card, | |||
CheckIcon, | |||
CloseIcon, | |||
DiscreetInteractiveIcon, | |||
LightLabel, | |||
ListItem, | |||
StandoutLink, | |||
SubTitle, | |||
SubnavigationFlowSeparator, | |||
} from 'design-system'; | |||
import React from 'react'; | |||
import { useIntl } from 'react-intl'; | |||
import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; | |||
import useLocalStorage from '../../../hooks/useLocalStorage'; | |||
import { Status } from '../../../types/types'; | |||
import { isLoggedIn } from '../../../types/users'; | |||
import { Status as QGStatus } from '../utils'; | |||
interface Props { | |||
status?: Status; | |||
} | |||
const SONARLINT_PR_LS_KEY = 'sonarqube.pr_overview.show_sonarlint_promotion'; | |||
export default function SonarLintAd({ status }: Readonly<Props>) { | |||
const intl = useIntl(); | |||
const user = useCurrentUser(); | |||
const [showSLPromotion, setSLPromotion] = useLocalStorage(SONARLINT_PR_LS_KEY, true); | |||
const onDismiss = React.useCallback(() => { | |||
setSLPromotion(false); | |||
}, [setSLPromotion]); | |||
if ( | |||
!isLoggedIn(user) || | |||
user.usingSonarLintConnectedMode || | |||
status !== QGStatus.ERROR || | |||
!showSLPromotion | |||
) { | |||
return null; | |||
} | |||
return ( | |||
<StyledSummaryCard className="it__overview__sonarlint-promotion sw-flex sw-flex-col sw-mt-4"> | |||
<div className="sw-flex sw-justify-between"> | |||
<SubTitle as="h2" className="sw-body-md-highlight"> | |||
{intl.formatMessage({ id: 'overview.sonarlint_ad.header' })} | |||
</SubTitle> | |||
<DiscreetInteractiveIcon | |||
Icon={CloseIcon} | |||
aria-label={intl.formatMessage({ id: 'overview.sonarlint_ad.close_promotion' })} | |||
onClick={onDismiss} | |||
size="medium" | |||
/> | |||
</div> | |||
<ul> | |||
<TickLink message={intl.formatMessage({ id: 'overview.sonarlint_ad.details_1' })} /> | |||
<TickLink message={intl.formatMessage({ id: 'overview.sonarlint_ad.details_2' })} /> | |||
<TickLink message={intl.formatMessage({ id: 'overview.sonarlint_ad.details_3' })} /> | |||
<TickLink message={intl.formatMessage({ id: 'overview.sonarlint_ad.details_4' })} /> | |||
<TickLink | |||
className="sw-body-sm-highlight" | |||
message={intl.formatMessage({ id: 'overview.sonarlint_ad.details_5' })} | |||
/> | |||
</ul> | |||
<SubnavigationFlowSeparator className="sw-mb-4" /> | |||
<div> | |||
<StandoutLink | |||
className="sw-text-left sw-body-sm-highlight" | |||
to="https://www.sonarsource.com/products/sonarlint/features/connected-mode/?referrer=sonarqube" | |||
> | |||
{intl.formatMessage({ id: 'overview.sonarlint_ad.learn_more' })} | |||
</StandoutLink> | |||
</div> | |||
</StyledSummaryCard> | |||
); | |||
} | |||
function TickLink({ className, message }: Readonly<{ className?: string; message: string }>) { | |||
return ( | |||
<ListItem className={`sw-body-sm ${className}`}> | |||
<CheckIcon fill="iconTrendPositive" /> | |||
<LightLabel className="sw-pl-1">{message}</LightLabel> | |||
</ListItem> | |||
); | |||
} | |||
const StyledSummaryCard = styled(Card)` | |||
background-color: transparent; | |||
`; |
@@ -18,6 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { screen, waitFor } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import * as React from 'react'; | |||
import { getQualityGateProjectStatus } from '../../../../api/quality-gates'; | |||
import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider'; | |||
@@ -185,6 +186,36 @@ it('should render correctly for a failed QG', async () => { | |||
).toBeInTheDocument(); | |||
}); | |||
it('renders SL promotion', async () => { | |||
const user = userEvent.setup(); | |||
jest.mocked(getQualityGateProjectStatus).mockResolvedValueOnce({ | |||
status: 'ERROR', | |||
conditions: [ | |||
mockQualityGateProjectCondition({ | |||
errorThreshold: '2.0', | |||
metricKey: MetricKey.new_coverage, | |||
periodIndex: 1, | |||
}), | |||
], | |||
caycStatus: CaycStatus.Compliant, | |||
ignoredConditions: true, | |||
}); | |||
renderPullRequestOverview(); | |||
await waitFor(async () => | |||
expect( | |||
await byRole('heading', { name: 'overview.sonarlint_ad.header' }).find(), | |||
).toBeInTheDocument(), | |||
); | |||
// Close promotion | |||
await user.click(byRole('button', { name: 'overview.sonarlint_ad.close_promotion' }).get()); | |||
expect( | |||
byRole('heading', { name: 'overview.sonarlint_ad.header' }).query(), | |||
).not.toBeInTheDocument(); | |||
}); | |||
function renderPullRequestOverview( | |||
props: Partial<ComponentPropsType<typeof PullRequestOverview>> = {}, | |||
) { |
@@ -0,0 +1,79 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2023 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* 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 } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import React from 'react'; | |||
import { renderComponent } from '../../helpers/testReactTestingUtils'; | |||
import { FCProps } from '../../types/misc'; | |||
import useLocalStorage from '../useLocalStorage'; | |||
describe('useLocalStorage hook', () => { | |||
it('gets/sets boolean value', async () => { | |||
const user = userEvent.setup(); | |||
renderLSComponent(); | |||
expect(screen.getByRole('button', { name: 'show' })).toBeInTheDocument(); | |||
user.click(screen.getByRole('button', { name: 'show' })); | |||
expect(await screen.findByText('text')).toBeInTheDocument(); | |||
}); | |||
it('gets/sets string value', async () => { | |||
const user = userEvent.setup(); | |||
const props = { condition: (value: string) => value === 'ok', valueToSet: 'wow' }; | |||
const { rerender } = renderLSComponent(props); | |||
expect(screen.getByRole('button', { name: 'show' })).toBeInTheDocument(); | |||
user.click(screen.getByRole('button', { name: 'show' })); | |||
expect(screen.queryByText('text')).not.toBeInTheDocument(); | |||
rerender(<LSComponent lsKey="test_ls" {...props} valueToSet="ok" />); | |||
user.click(screen.getByRole('button', { name: 'show' })); | |||
expect(await screen.findByText('text')).toBeInTheDocument(); | |||
}); | |||
}); | |||
function renderLSComponent(props: Partial<FCProps<typeof LSComponent>> = {}) { | |||
return renderComponent( | |||
<LSComponent lsKey="test_ls" valueToSet condition={(value) => Boolean(value)} {...props} />, | |||
); | |||
} | |||
function LSComponent({ | |||
lsKey, | |||
condition, | |||
initialValue, | |||
valueToSet, | |||
}: Readonly<{ | |||
lsKey: string; | |||
condition: (value: boolean | string) => boolean; | |||
valueToSet: boolean | string; | |||
initialValue?: boolean | string; | |||
}>) { | |||
const [value, setValue] = useLocalStorage(lsKey, initialValue); | |||
return ( | |||
<div> | |||
<button type="button" onClick={() => setValue(valueToSet)}> | |||
show | |||
</button> | |||
{condition(value) && <span>text</span>} | |||
</div> | |||
); | |||
} |
@@ -0,0 +1,48 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2023 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import React from 'react'; | |||
import { get, save } from '../helpers/storage'; | |||
export default function useLocalStorage<T>(key: string, initialValue?: T) { | |||
const lsValue = React.useCallback(() => { | |||
const v = get(key); | |||
try { | |||
return JSON.parse(v as string); | |||
} catch { | |||
return v; | |||
} | |||
}, [key]); | |||
const [storedValue, setStoredValue] = React.useState(lsValue() ?? initialValue); | |||
const changeValue = React.useCallback( | |||
(value: T) => { | |||
save(key, JSON.stringify(value)); | |||
setStoredValue(lsValue()); | |||
}, | |||
[key, lsValue], | |||
); | |||
React.useEffect(() => { | |||
setStoredValue(lsValue() ?? initialValue); | |||
}, [lsValue, initialValue]); | |||
return [storedValue, changeValue]; | |||
} |
@@ -3865,6 +3865,15 @@ overview.deprecated_profile=This Quality Profile uses {0} deprecated rules and s | |||
overview.deleted_profile={0} has been deleted since the last analysis. | |||
overview.link_to_x_profile_y=Go to {0} profile "{1}" | |||
overview.sonarlint_ad.header=Catch issues before they fail your Quality Gate with our IDE extension, SonarLint | |||
overview.sonarlint_ad.details_1=The power of Sonar analyzers directly as you type | |||
overview.sonarlint_ad.details_2=No need to wait for your PR to pass all checks | |||
overview.sonarlint_ad.details_3=Repair flagged issues in real-time with quick fixes | |||
overview.sonarlint_ad.details_4=12 major IDE's supported (including key JetBrains and Microsoft IDE's | |||
overview.sonarlint_ad.details_5=Free forever | |||
overview.sonarlint_ad.learn_more=Learn more about SonarLint | |||
overview.sonarlint_ad.close_promotion=Close SonarLint romotion | |||
overview.badges.get_badge=Badges | |||
overview.badges.title=Get project badges | |||
overview.badges.description.TRK=Show the status of your project metrics on your README or website. Pick your style: |