@@ -18,13 +18,14 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import styled from '@emotion/styled'; | |||
import { inRange } from 'lodash'; | |||
import tw from 'twin.macro'; | |||
import { getProp, themeColor, themeContrast } from '../helpers/theme'; | |||
import { SizeLabel } from '../types/measures'; | |||
export interface Props { | |||
size?: 'xs' | 'sm' | 'md'; | |||
value: SizeLabel; | |||
value: number; | |||
} | |||
const SIZE_MAPPING = { | |||
@@ -34,9 +35,22 @@ const SIZE_MAPPING = { | |||
}; | |||
export function SizeIndicator({ size = 'sm', value }: Props) { | |||
let letter: SizeLabel; | |||
if (inRange(value, 0, 1000)) { | |||
letter = 'XS'; | |||
} else if (inRange(value, 1000, 10000)) { | |||
letter = 'S'; | |||
} else if (inRange(value, 10000, 100000)) { | |||
letter = 'M'; | |||
} else if (inRange(value, 100000, 500000)) { | |||
letter = 'L'; | |||
} else { | |||
letter = 'XL'; | |||
} | |||
return ( | |||
<StyledContainer aria-hidden="true" size={SIZE_MAPPING[size]}> | |||
{value} | |||
{letter} | |||
</StyledContainer> | |||
); | |||
} | |||
@@ -44,7 +58,7 @@ export function SizeIndicator({ size = 'sm', value }: Props) { | |||
const StyledContainer = styled.div<{ size: string }>` | |||
width: ${getProp('size')}; | |||
height: ${getProp('size')}; | |||
font-size: ${({ size }) => (size === '2rem' ? '0.875rem' : '0.75rem')}; | |||
font-size: ${({ size }) => (size === '2rem' ? '0.875rem' : `calc(${size}/2)`)}; | |||
color: ${themeContrast('sizeIndicator')}; | |||
background-color: ${themeColor('sizeIndicator')}; | |||
@@ -25,14 +25,17 @@ import { FCProps } from '../../types/misc'; | |||
import { SizeLabel } from '../../types/measures'; | |||
import { SizeIndicator } from '../SizeIndicator'; | |||
it.each(['XS', 'S', 'M', 'L', 'XL'])( | |||
'should display SizeIndicator with size', | |||
(value: SizeLabel) => { | |||
setupWithProps({ value }); | |||
expect(screen.getByText(value)).toBeInTheDocument(); | |||
} | |||
); | |||
it.each([ | |||
[100, 'XS'], | |||
[1100, 'S'], | |||
[20_000, 'M'], | |||
[200_000, 'L'], | |||
[1_000_000, 'XL'], | |||
])('should display SizeIndicator with size', (value: number, letter: SizeLabel) => { | |||
setupWithProps({ value }); | |||
expect(screen.getByText(letter)).toBeInTheDocument(); | |||
}); | |||
function setupWithProps(props: Partial<FCProps<typeof SizeIndicator>> = {}) { | |||
return render(<SizeIndicator value="XS" {...props} />); | |||
return render(<SizeIndicator value={0} {...props} />); | |||
} |
@@ -67,7 +67,7 @@ it('renders correctly when the project binding is incorrect', () => { | |||
it('correctly returns focus to the Project Information link when the drawer is closed', () => { | |||
renderComponentNav(); | |||
screen.getByRole('link', { name: 'project.info.title' }).click(); | |||
expect(screen.getByText('/project/info?id=my-project')).toBeInTheDocument(); | |||
expect(screen.getByText('/project/information?id=my-project')).toBeInTheDocument(); | |||
}); | |||
function renderComponentNav(props: Partial<ComponentNavProps> = {}) { |
@@ -19,6 +19,7 @@ | |||
*/ | |||
import * as React from 'react'; | |||
import Link from '../../../components/common/Link'; | |||
import MetaLink from '../../../components/common/MetaLink'; | |||
import HelpTooltip from '../../../components/controls/HelpTooltip'; | |||
import DateFromNow from '../../../components/intl/DateFromNow'; | |||
import Level from '../../../components/ui/Level'; | |||
@@ -26,7 +27,6 @@ import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { orderLinks } from '../../../helpers/projectLinks'; | |||
import { getProjectUrl } from '../../../helpers/urls'; | |||
import { MyProject, ProjectLink } from '../../../types/types'; | |||
import MetaLink from '../../projectInformation/meta/MetaLink'; | |||
interface Props { | |||
project: MyProject; |
@@ -28,7 +28,7 @@ import withCurrentUserContext from '../../app/components/current-user/withCurren | |||
import withMetricsContext from '../../app/components/metrics/withMetricsContext'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { BranchLike } from '../../types/branch-like'; | |||
import { ComponentQualifier } from '../../types/component'; | |||
import { ComponentQualifier, isApplication, isProject } from '../../types/component'; | |||
import { Feature } from '../../types/features'; | |||
import { MetricKey } from '../../types/metrics'; | |||
import { Component, Dict, Measure, Metric } from '../../types/types'; | |||
@@ -82,14 +82,12 @@ export class ProjectInformationApp extends React.PureComponent<Props, State> { | |||
const { branchLike, component, currentUser, metrics } = this.props; | |||
const { measures } = this.state; | |||
const canConfigureNotifications = | |||
isLoggedIn(currentUser) && component.qualifier === ComponentQualifier.Project; | |||
const canConfigureNotifications = isLoggedIn(currentUser) && isProject(component.qualifier); | |||
const canUseBadges = | |||
metrics !== undefined && | |||
(component.qualifier === ComponentQualifier.Application || | |||
component.qualifier === ComponentQualifier.Project); | |||
(isApplication(component.qualifier) || isProject(component.qualifier)); | |||
const regulatoryReportFeatureEnabled = this.props.hasFeature(Feature.RegulatoryReport); | |||
const isApp = component.qualifier === ComponentQualifier.Application; | |||
const isApp = isApplication(component.qualifier); | |||
return ( | |||
<main> |
@@ -0,0 +1,56 @@ | |||
/* | |||
* 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 ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock'; | |||
import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; | |||
import { byRole } from '../../../helpers/testSelector'; | |||
import routes from '../routes'; | |||
const componentsMock = new ComponentsServiceMock(); | |||
const ui = { | |||
projectPageTitle: byRole('heading', { name: 'project.info.title' }), | |||
applicationPageTitle: byRole('heading', { name: 'application.info.title' }), | |||
qualityGateList: byRole('list', { name: 'project.info.quality_gate' }), | |||
qualityProfilesList: byRole('list', { name: 'project.info.qualit_profiles' }), | |||
link: byRole('link'), | |||
tags: byRole('generic', { name: /tags:/ }), | |||
size: byRole('link', { name: /project.info.see_more_info_on_x_locs/ }), | |||
newKeyInput: byRole('textbox'), | |||
updateInputButton: byRole('button', { name: 'update_verb' }), | |||
resetInputButton: byRole('button', { name: 'reset_verb' }), | |||
}; | |||
afterEach(() => { | |||
componentsMock.reset(); | |||
}); | |||
it('can update project key', async () => { | |||
renderProjectInformationApp(); | |||
expect(await ui.projectPageTitle.find()).toBeInTheDocument(); | |||
}); | |||
function renderProjectInformationApp() { | |||
return renderAppWithComponentContext( | |||
'project/information', | |||
routes, | |||
{}, | |||
{ component: componentsMock.components[0].component } | |||
); | |||
} |
@@ -17,17 +17,24 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import PrivacyBadgeContainer from '../../../components/common/PrivacyBadgeContainer'; | |||
import classNames from 'classnames'; | |||
import { BasicSeparator, SubTitle } from 'design-system'; | |||
import React, { PropsWithChildren, useContext, useEffect, useState } from 'react'; | |||
import { getProjectLinks } from '../../../api/projectLinks'; | |||
import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { ComponentQualifier } from '../../../types/component'; | |||
import { Component, Measure } from '../../../types/types'; | |||
import MetaKey from '../meta/MetaKey'; | |||
import MetaLinks from '../meta/MetaLinks'; | |||
import MetaQualityGate from '../meta/MetaQualityGate'; | |||
import MetaQualityProfiles from '../meta/MetaQualityProfiles'; | |||
import MetaSize from '../meta/MetaSize'; | |||
import MetaTags from '../meta/MetaTags'; | |||
import { ComponentQualifier, Visibility } from '../../../types/component'; | |||
import { Component, Measure, ProjectLink } from '../../../types/types'; | |||
import { isLoggedIn } from '../../../types/users'; | |||
import MetaDescription from './components/MetaDescription'; | |||
import MetaHome from './components/MetaHome'; | |||
import MetaKey from './components/MetaKey'; | |||
import MetaLinks from './components/MetaLinks'; | |||
import MetaQualityGate from './components/MetaQualityGate'; | |||
import MetaQualityProfiles from './components/MetaQualityProfiles'; | |||
import MetaSize from './components/MetaSize'; | |||
import MetaTags from './components/MetaTags'; | |||
import MetaVisibility from './components/MetaVisibility'; | |||
export interface AboutProjectProps { | |||
component: Component; | |||
@@ -35,73 +42,90 @@ export interface AboutProjectProps { | |||
onComponentChange: (changes: {}) => void; | |||
} | |||
export function AboutProject(props: AboutProjectProps) { | |||
export default function AboutProject(props: AboutProjectProps) { | |||
const { currentUser } = useContext(CurrentUserContext); | |||
const { component, measures = [] } = props; | |||
const heading = React.useRef<HTMLHeadingElement>(null); | |||
const isApp = component.qualifier === ComponentQualifier.Application; | |||
const [links, setLinks] = useState<ProjectLink[] | undefined>(undefined); | |||
React.useEffect(() => { | |||
if (heading.current) { | |||
// a11y: provide focus to the heading when the Project Information is opened. | |||
heading.current.focus(); | |||
useEffect(() => { | |||
if (!isApp) { | |||
getProjectLinks(component.key).then( | |||
(links) => setLinks(links), | |||
() => {} | |||
); | |||
} | |||
}, [heading]); | |||
}, [component.key, isApp]); | |||
return ( | |||
<> | |||
<div> | |||
<h2 className="big-padded bordered-bottom" tabIndex={-1} ref={heading}> | |||
{translate(isApp ? 'application' : 'project', 'info.title')} | |||
</h2> | |||
<SubTitle>{translate(isApp ? 'application' : 'project', 'about.title')}</SubTitle> | |||
</div> | |||
<div className="overflow-y-auto"> | |||
<div className="big-padded bordered-bottom"> | |||
<div className="display-flex-center"> | |||
<h3 className="spacer-right">{translate('project.info.description')}</h3> | |||
{component.visibility && ( | |||
<PrivacyBadgeContainer | |||
qualifier={component.qualifier} | |||
visibility={component.visibility} | |||
{!isApp && | |||
(component.qualityGate || | |||
(component.qualityProfiles && component.qualityProfiles.length > 0)) && ( | |||
<ProjectInformationSection className="sw-pt-0"> | |||
{component.qualityGate && <MetaQualityGate qualityGate={component.qualityGate} />} | |||
{component.qualityProfiles && component.qualityProfiles.length > 0 && ( | |||
<MetaQualityProfiles | |||
headerClassName={component.qualityGate ? 'big-spacer-top' : undefined} | |||
profiles={component.qualityProfiles} | |||
/> | |||
)} | |||
</div> | |||
</ProjectInformationSection> | |||
)} | |||
{component.description && ( | |||
<p className="it__project-description">{component.description}</p> | |||
)} | |||
<ProjectInformationSection> | |||
<MetaKey componentKey={component.key} qualifier={component.qualifier} /> | |||
</ProjectInformationSection> | |||
<MetaTags component={component} onComponentChange={props.onComponentChange} /> | |||
</div> | |||
{component.visibility === Visibility.Private && ( | |||
<ProjectInformationSection> | |||
<MetaVisibility qualifier={component.qualifier} visibility={component.visibility} /> | |||
</ProjectInformationSection> | |||
)} | |||
<div className="big-padded bordered-bottom it__project-loc-value"> | |||
<MetaSize component={component} measures={measures} /> | |||
</div> | |||
<ProjectInformationSection> | |||
<MetaDescription description={component.description} isApp={isApp} /> | |||
</ProjectInformationSection> | |||
{!isApp && | |||
(component.qualityGate || | |||
(component.qualityProfiles && component.qualityProfiles.length > 0)) && ( | |||
<div className="big-padded bordered-bottom"> | |||
{component.qualityGate && <MetaQualityGate qualityGate={component.qualityGate} />} | |||
<ProjectInformationSection> | |||
<MetaTags component={component} onComponentChange={props.onComponentChange} /> | |||
</ProjectInformationSection> | |||
{component.qualityProfiles && component.qualityProfiles.length > 0 && ( | |||
<MetaQualityProfiles | |||
headerClassName={component.qualityGate ? 'big-spacer-top' : undefined} | |||
profiles={component.qualityProfiles} | |||
/> | |||
)} | |||
</div> | |||
)} | |||
<ProjectInformationSection> | |||
<MetaSize component={component} measures={measures} /> | |||
</ProjectInformationSection> | |||
{!isApp && <MetaLinks component={component} />} | |||
{!isApp && links && links.length > 0 && ( | |||
<ProjectInformationSection last={!isLoggedIn(currentUser)}> | |||
<MetaLinks links={links} /> | |||
</ProjectInformationSection> | |||
)} | |||
<div className="big-padded bordered-bottom"> | |||
<MetaKey componentKey={component.key} qualifier={component.qualifier} /> | |||
</div> | |||
</div> | |||
{isLoggedIn(currentUser) && ( | |||
<ProjectInformationSection last> | |||
<MetaHome componentKey={component.key} currentUser={currentUser} isApp={isApp} /> | |||
</ProjectInformationSection> | |||
)} | |||
</> | |||
); | |||
} | |||
export default AboutProject; | |||
interface ProjectInformationSectionProps { | |||
last?: boolean; | |||
className?: string; | |||
} | |||
function ProjectInformationSection(props: PropsWithChildren<ProjectInformationSectionProps>) { | |||
const { children, className, last = false } = props; | |||
return ( | |||
<> | |||
<div className={classNames('sw-py-6', className)}>{children}</div> | |||
{!last && <BasicSeparator />} | |||
</> | |||
); | |||
} |
@@ -0,0 +1,40 @@ | |||
/* | |||
* 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 { TextMuted } from 'design-system'; | |||
import * as React from 'react'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
interface Props { | |||
description?: string; | |||
isApp?: boolean; | |||
} | |||
export default function MetaDescription({ description, isApp }: Props) { | |||
return ( | |||
<> | |||
<h3>{translate('project.info.description')}</h3> | |||
{description ? ( | |||
<p className="it__project-description">{description}</p> | |||
) : ( | |||
<TextMuted text={translate(isApp ? 'application' : 'project', 'info.empty_description')} /> | |||
)} | |||
</> | |||
); | |||
} |
@@ -0,0 +1,83 @@ | |||
/* | |||
* 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 { Checkbox, HelperHintIcon } from 'design-system'; | |||
import React, { useContext } from 'react'; | |||
import { setHomePage } from '../../../../api/users'; | |||
import { CurrentUserContext } from '../../../../app/components/current-user/CurrentUserContext'; | |||
import HelpTooltip from '../../../../components/controls/HelpTooltip'; | |||
import { DEFAULT_HOMEPAGE } from '../../../../components/controls/HomePageSelect'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { isSameHomePage } from '../../../../helpers/users'; | |||
import { HomePage, LoggedInUser, isLoggedIn } from '../../../../types/users'; | |||
export interface MetaHomeProps { | |||
componentKey: string; | |||
currentUser: LoggedInUser; | |||
isApp?: boolean; | |||
} | |||
export default function MetaHome({ componentKey, currentUser, isApp }: MetaHomeProps) { | |||
const { updateCurrentUserHomepage } = useContext(CurrentUserContext); | |||
const currentPage: HomePage = { | |||
component: componentKey, | |||
type: 'PROJECT', | |||
branch: undefined, | |||
}; | |||
const setCurrentUserHomepage = async (homepage: HomePage) => { | |||
if (isLoggedIn(currentUser)) { | |||
await setHomePage(homepage); | |||
updateCurrentUserHomepage(homepage); | |||
} | |||
}; | |||
const handleClick = (value: boolean) => { | |||
setCurrentUserHomepage(value ? currentPage : DEFAULT_HOMEPAGE); | |||
}; | |||
return ( | |||
<> | |||
<div className="sw-flex sw-items-center"> | |||
<h3>{translate('project.info.make_home.title')}</h3> | |||
<HelpTooltip | |||
className="sw-ml-1" | |||
overlay={ | |||
<p className="sw-max-w-abs-250"> | |||
{translate(isApp ? 'application' : 'project', 'info.make_home.tooltip')} | |||
</p> | |||
} | |||
> | |||
<HelperHintIcon /> | |||
</HelpTooltip> | |||
</div> | |||
<Checkbox | |||
checked={ | |||
currentUser.homepage !== undefined && isSameHomePage(currentUser.homepage, currentPage) | |||
} | |||
onCheck={handleClick} | |||
> | |||
<span className="sw-ml-2"> | |||
{translate(isApp ? 'application' : 'project', 'info.make_home.label')} | |||
</span> | |||
</Checkbox> | |||
</> | |||
); | |||
} |
@@ -0,0 +1,60 @@ | |||
/* | |||
* 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 { ClipboardIconButton, CodeSnippet, HelperHintIcon } from 'design-system'; | |||
import * as React from 'react'; | |||
import HelpTooltip from '../../../../components/controls/HelpTooltip'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
interface MetaKeyProps { | |||
componentKey: string; | |||
qualifier: string; | |||
} | |||
export default function MetaKey({ componentKey, qualifier }: MetaKeyProps) { | |||
return ( | |||
<> | |||
<div className="sw-flex sw-items-center"> | |||
<h3>{translate('overview.project_key', qualifier)}</h3> | |||
<HelpTooltip | |||
className="sw-ml-1" | |||
overlay={ | |||
<p className="sw-max-w-abs-250"> | |||
{translate('overview.project_key.tooltip', qualifier)} | |||
</p> | |||
} | |||
> | |||
<HelperHintIcon /> | |||
</HelpTooltip> | |||
</div> | |||
<div className="sw-w-full"> | |||
<div className="sw-flex sw-gap-2 sw-items-center sw-min-w-0"> | |||
<CodeSnippet | |||
className="sw-min-w-0" | |||
isOneLine | |||
noCopy | |||
highlight={false} | |||
snippet={componentKey} | |||
/> | |||
<ClipboardIconButton copyValue={componentKey} /> | |||
</div> | |||
</div> | |||
</> | |||
); | |||
} |
@@ -0,0 +1,43 @@ | |||
/* | |||
* 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 MetaLink from '../../../../components/common/MetaLink'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { orderLinks } from '../../../../helpers/projectLinks'; | |||
import { ProjectLink } from '../../../../types/types'; | |||
interface Props { | |||
links: ProjectLink[]; | |||
} | |||
export default function MetaLinks({ links }: Props) { | |||
const orderedLinks = orderLinks(links); | |||
return ( | |||
<> | |||
<h3>{translate('overview.external_links')}</h3> | |||
<ul className="project-info-list"> | |||
{orderedLinks.map((link) => ( | |||
<MetaLink miui key={link.id} link={link} /> | |||
))} | |||
</ul> | |||
</> | |||
); | |||
} |
@@ -17,10 +17,10 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { Link } from 'design-system'; | |||
import * as React from 'react'; | |||
import Link from '../../../components/common/Link'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { getQualityGateUrl } from '../../../helpers/urls'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { getQualityGateUrl } from '../../../../helpers/urls'; | |||
interface Props { | |||
qualityGate: { isDefault?: boolean; name: string }; | |||
@@ -29,9 +29,9 @@ interface Props { | |||
export default function MetaQualityGate({ qualityGate }: Props) { | |||
return ( | |||
<> | |||
<h3>{translate('project.info.quality_gate')}</h3> | |||
<h3 id="quality-gate-header">{translate('project.info.quality_gate')}</h3> | |||
<ul className="project-info-list"> | |||
<ul className="project-info-list" aria-labelledby="quality-gate-header"> | |||
<li> | |||
{qualityGate.isDefault && ( | |||
<span className="note spacer-right">({translate('default')})</span> |
@@ -0,0 +1,140 @@ | |||
/* | |||
* 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 { Badge, Link } from 'design-system'; | |||
import React, { useContext, useEffect } from 'react'; | |||
import { searchRules } from '../../../../api/rules'; | |||
import { LanguagesContext } from '../../../../app/components/languages/LanguagesContext'; | |||
import Tooltip from '../../../../components/controls/Tooltip'; | |||
import { translate, translateWithParameters } from '../../../../helpers/l10n'; | |||
import { getQualityProfileUrl } from '../../../../helpers/urls'; | |||
import { Languages } from '../../../../types/languages'; | |||
import { ComponentQualityProfile, Dict } from '../../../../types/types'; | |||
interface Props { | |||
headerClassName?: string; | |||
profiles: ComponentQualityProfile[]; | |||
} | |||
export function MetaQualityProfiles({ headerClassName, profiles }: Props) { | |||
const [deprecatedByKey, setDeprecatedByKey] = React.useState<Dict<number>>({}); | |||
const languages = useContext(LanguagesContext); | |||
useEffect(() => { | |||
const existingProfiles = profiles.filter((p) => !p.deleted); | |||
const requests = existingProfiles.map((profile) => { | |||
const data = { | |||
activation: 'true', | |||
ps: 1, | |||
qprofile: profile.key, | |||
statuses: 'DEPRECATED', | |||
}; | |||
return searchRules(data).then((r) => r.paging.total); | |||
}); | |||
Promise.all(requests).then( | |||
(responses) => { | |||
const deprecatedByKey: Dict<number> = {}; | |||
responses.forEach((count, i) => { | |||
const profileKey = existingProfiles[i].key; | |||
deprecatedByKey[profileKey] = count; | |||
}); | |||
setDeprecatedByKey(deprecatedByKey); | |||
}, | |||
() => {} | |||
); | |||
}, [profiles]); | |||
return ( | |||
<> | |||
<h3 className={headerClassName} id="quality-profiles-list"> | |||
{translate('overview.quality_profiles')} | |||
</h3> | |||
<ul className="project-info-list" aria-labelledby="quality-profiles-list"> | |||
{profiles.map((profile) => ( | |||
<ProfileItem | |||
key={profile.key} | |||
profile={profile} | |||
languages={languages} | |||
deprecatedByKey={deprecatedByKey} | |||
/> | |||
))} | |||
</ul> | |||
</> | |||
); | |||
} | |||
function ProfileItem({ | |||
profile, | |||
languages, | |||
deprecatedByKey, | |||
}: { | |||
profile: ComponentQualityProfile; | |||
languages: Languages; | |||
deprecatedByKey: Dict<number>; | |||
}) { | |||
const languageFromStore = languages[profile.language]; | |||
const languageName = languageFromStore ? languageFromStore.name : profile.language; | |||
const count = deprecatedByKey[profile.key] || 0; | |||
return ( | |||
<li> | |||
<div className="sw-grid sw-grid-cols-[1fr_3fr]"> | |||
<span>{languageName}</span> | |||
<div> | |||
{profile.deleted ? ( | |||
<Tooltip | |||
key={profile.key} | |||
overlay={translateWithParameters('overview.deleted_profile', profile.name)} | |||
> | |||
<div className="project-info-deleted-profile">{profile.name}</div> | |||
</Tooltip> | |||
) : ( | |||
<> | |||
<Link to={getQualityProfileUrl(profile.name, profile.language)}> | |||
<span | |||
aria-label={translateWithParameters( | |||
'overview.link_to_x_profile_y', | |||
languageName, | |||
profile.name | |||
)} | |||
> | |||
{profile.name} | |||
</span> | |||
</Link> | |||
{count > 0 && ( | |||
<Tooltip | |||
key={profile.key} | |||
overlay={translateWithParameters('overview.deprecated_profile', count)} | |||
> | |||
<span> | |||
<Badge className="sw-ml-6" variant="deleted"> | |||
{translate('deprecated')} | |||
</Badge> | |||
</span> | |||
</Tooltip> | |||
)} | |||
</> | |||
)} | |||
</div> | |||
</div> | |||
</li> | |||
); | |||
} | |||
export default MetaQualityProfiles; |
@@ -17,16 +17,16 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { DrilldownLink, SizeIndicator } from 'design-system'; | |||
import * as React from 'react'; | |||
import DrilldownLink from '../../../components/shared/DrilldownLink'; | |||
import SizeRating from '../../../components/ui/SizeRating'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { formatMeasure, localizeMetric } from '../../../helpers/measures'; | |||
import { ComponentQualifier } from '../../../types/component'; | |||
import { MetricKey } from '../../../types/metrics'; | |||
import { Component, Measure } from '../../../types/types'; | |||
import { translate, translateWithParameters } from '../../../../helpers/l10n'; | |||
import { formatMeasure, localizeMetric } from '../../../../helpers/measures'; | |||
import { getComponentDrilldownUrl } from '../../../../helpers/urls'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { MetricKey } from '../../../../types/metrics'; | |||
import { Component, Measure } from '../../../../types/types'; | |||
export interface MetaSizeProps { | |||
interface MetaSizeProps { | |||
component: Component; | |||
measures: Measure[]; | |||
} | |||
@@ -37,17 +37,22 @@ export default function MetaSize({ component, measures }: MetaSizeProps) { | |||
const projects = isApp | |||
? measures.find((measure) => measure.metric === MetricKey.projects) | |||
: undefined; | |||
const url = getComponentDrilldownUrl({ | |||
componentKey: component.key, | |||
metric: MetricKey.ncloc, | |||
listView: true, | |||
}); | |||
return ( | |||
<> | |||
<div className="display-flex-row display-inline-flex-baseline"> | |||
<div className="sw-flex sw-items-center"> | |||
<h3>{localizeMetric(MetricKey.ncloc)}</h3> | |||
<span className="spacer-left small">({translate('project.info.main_branch')})</span> | |||
<span className="sw-ml-1 small">({translate('project.info.main_branch')})</span> | |||
</div> | |||
<div className="display-flex-center"> | |||
<div className="sw-flex sw-items-center"> | |||
{ncloc && ncloc.value ? ( | |||
<> | |||
<DrilldownLink className="huge" component={component.key} metric={MetricKey.ncloc}> | |||
<DrilldownLink className="huge" to={url}> | |||
<span | |||
aria-label={translateWithParameters( | |||
'project.info.see_more_info_on_x_locs', | |||
@@ -59,7 +64,7 @@ export default function MetaSize({ component, measures }: MetaSizeProps) { | |||
</DrilldownLink> | |||
<span className="spacer-left"> | |||
<SizeRating value={Number(ncloc.value)} /> | |||
<SizeIndicator value={Number(ncloc.value)} size="xs" /> | |||
</span> | |||
</> | |||
) : ( | |||
@@ -69,7 +74,7 @@ export default function MetaSize({ component, measures }: MetaSizeProps) { | |||
{isApp && ( | |||
<span className="huge-spacer-left display-inline-flex-center"> | |||
{projects ? ( | |||
<DrilldownLink component={component.key} metric={MetricKey.projects}> | |||
<DrilldownLink to={url}> | |||
<span className="big">{formatMeasure(projects.value, 'SHORT_INT')}</span> | |||
</DrilldownLink> | |||
) : ( |
@@ -0,0 +1,131 @@ | |||
/* | |||
* 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 { Tags, TagsSelector } from 'design-system'; | |||
import { difference, without } from 'lodash'; | |||
import React, { useState } from 'react'; | |||
import { searchProjectTags, setApplicationTags, setProjectTags } from '../../../../api/components'; | |||
import Tooltip from '../../../../components/controls/Tooltip'; | |||
import { PopupPlacement } from '../../../../components/ui/popups'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { Component } from '../../../../types/types'; | |||
interface Props { | |||
component: Component; | |||
onComponentChange: (changes: {}) => void; | |||
} | |||
export default function MetaTags(props: Props) { | |||
const [open, setOpen] = React.useState(false); | |||
const canUpdateTags = () => { | |||
const { configuration } = props.component; | |||
return configuration && configuration.showSettings; | |||
}; | |||
const setTags = (values: string[]) => { | |||
const { component } = props; | |||
if (component.qualifier === ComponentQualifier.Application) { | |||
return setApplicationTags({ | |||
application: component.key, | |||
tags: values.join(','), | |||
}); | |||
} | |||
return setProjectTags({ | |||
project: component.key, | |||
tags: values.join(','), | |||
}); | |||
}; | |||
const handleSetProjectTags = (values: string[]) => { | |||
setTags(values).then( | |||
() => props.onComponentChange({ tags: values }), | |||
() => {} | |||
); | |||
}; | |||
const tags = props.component.tags || []; | |||
return ( | |||
<> | |||
<h3>{translate('tags')}</h3> | |||
<Tags | |||
allowUpdate={canUpdateTags()} | |||
ariaTagsListLabel={translate('tags')} | |||
className="js-issue-edit-tags" | |||
emptyText={translate('no_tags')} | |||
overlay={<MetaTagsSelector selectedTags={tags} setProjectTags={handleSetProjectTags} />} | |||
popupPlacement={PopupPlacement.Bottom} | |||
tags={tags} | |||
tagsToDisplay={2} | |||
tooltip={Tooltip} | |||
open={open} | |||
onClose={() => setOpen(false)} | |||
/> | |||
</> | |||
); | |||
} | |||
interface MetaTagsSelectorProps { | |||
selectedTags: string[]; | |||
setProjectTags: (tags: string[]) => void; | |||
} | |||
const LIST_SIZE = 10; | |||
function MetaTagsSelector({ selectedTags, setProjectTags }: MetaTagsSelectorProps) { | |||
const [searchResult, setSearchResult] = useState<string[]>([]); | |||
const availableTags = difference(searchResult, selectedTags); | |||
const onSearch = (query: string) => { | |||
return searchProjectTags({ | |||
q: query, | |||
ps: Math.min(selectedTags.length - 1 + LIST_SIZE, 100), | |||
}).then( | |||
({ tags }) => setSearchResult(tags), | |||
() => {} | |||
); | |||
}; | |||
const onSelect = (tag: string) => { | |||
setProjectTags([...selectedTags, tag]); | |||
}; | |||
const onUnselect = (tag: string) => { | |||
setProjectTags(without(selectedTags, tag)); | |||
}; | |||
return ( | |||
<TagsSelector | |||
headerLabel={translate('tags')} | |||
searchInputAriaLabel={translate('search.search_for_tags')} | |||
clearIconAriaLabel={translate('clear')} | |||
createElementLabel={translate('issue.create_tag')} | |||
noResultsLabel={translate('no_results')} | |||
onSearch={onSearch} | |||
onSelect={onSelect} | |||
onUnselect={onUnselect} | |||
selectedTags={selectedTags} | |||
tags={availableTags} | |||
/> | |||
); | |||
} |
@@ -18,32 +18,20 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { ClipboardButton } from '../../../components/controls/clipboard'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import PrivacyBadgeContainer from '../../../../components/common/PrivacyBadgeContainer'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { Visibility } from '../../../../types/component'; | |||
export interface MetaKeyProps { | |||
componentKey: string; | |||
interface Props { | |||
qualifier: string; | |||
visibility: Visibility; | |||
} | |||
export default function MetaKey({ componentKey, qualifier }: MetaKeyProps) { | |||
export default function MetaVisibility({ qualifier, visibility }: Props) { | |||
return ( | |||
<> | |||
<h3 id="project-key">{translate('overview.project_key', qualifier)}</h3> | |||
<div className="display-flex-center"> | |||
<input | |||
className="overview-key" | |||
aria-labelledby="project-key" | |||
readOnly | |||
type="text" | |||
value={componentKey} | |||
/> | |||
<ClipboardButton | |||
aria-label={translate('overview.project_key.click_to_copy')} | |||
className="little-spacer-left" | |||
copyValue={componentKey} | |||
/> | |||
</div> | |||
<h3>{translate('visibility')}</h3> | |||
<PrivacyBadgeContainer qualifier={qualifier} visibility={visibility} /> | |||
</> | |||
); | |||
} |
@@ -19,21 +19,19 @@ | |||
*/ | |||
import { screen } from '@testing-library/react'; | |||
import * as React from 'react'; | |||
import { renderComponent } from '../../../../helpers/testReactTestingUtils'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import MetaKey, { MetaKeyProps } from '../MetaKey'; | |||
import { renderComponent } from '../../../../../helpers/testReactTestingUtils'; | |||
import { ComponentQualifier } from '../../../../../types/component'; | |||
import MetaKey from '../MetaKey'; | |||
it('should render correctly', () => { | |||
renderMetaKey(); | |||
expect( | |||
screen.getByLabelText(`overview.project_key.${ComponentQualifier.Project}`) | |||
).toBeInTheDocument(); | |||
expect( | |||
screen.getByRole('button', { name: 'overview.project_key.click_to_copy' }) | |||
screen.getByText(`overview.project_key.${ComponentQualifier.Project}`) | |||
).toBeInTheDocument(); | |||
expect(screen.getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument(); | |||
}); | |||
function renderMetaKey(props: Partial<MetaKeyProps> = {}) { | |||
function renderMetaKey(props: Partial<Parameters<typeof MetaKey>[0]> = {}) { | |||
return renderComponent( | |||
<MetaKey componentKey="foo" qualifier={ComponentQualifier.Project} {...props} /> | |||
); |
@@ -19,14 +19,15 @@ | |||
*/ | |||
import { screen } from '@testing-library/react'; | |||
import * as React from 'react'; | |||
import { searchRules } from '../../../../api/rules'; | |||
import { mockLanguage, mockPaging, mockQualityProfile } from '../../../../helpers/testMocks'; | |||
import { renderComponent } from '../../../../helpers/testReactTestingUtils'; | |||
import { SearchRulesResponse } from '../../../../types/coding-rules'; | |||
import { Dict } from '../../../../types/types'; | |||
import { searchRules } from '../../../../../api/rules'; | |||
import { LanguagesContext } from '../../../../../app/components/languages/LanguagesContext'; | |||
import { mockLanguage, mockPaging, mockQualityProfile } from '../../../../../helpers/testMocks'; | |||
import { renderComponent } from '../../../../../helpers/testReactTestingUtils'; | |||
import { SearchRulesResponse } from '../../../../../types/coding-rules'; | |||
import { Dict } from '../../../../../types/types'; | |||
import { MetaQualityProfiles } from '../MetaQualityProfiles'; | |||
jest.mock('../../../../api/rules', () => { | |||
jest.mock('../../../../../api/rules', () => { | |||
return { | |||
searchRules: jest.fn().mockResolvedValue({ | |||
total: 10, | |||
@@ -57,24 +58,27 @@ it('should render correctly', async () => { | |||
expect(screen.getByText('overview.deprecated_profile.10')).toBeInTheDocument(); | |||
}); | |||
function renderMetaQualityprofiles(overrides: Partial<MetaQualityProfiles['props']> = {}) { | |||
function renderMetaQualityprofiles( | |||
overrides: Partial<Parameters<typeof MetaQualityProfiles>[0]> = {} | |||
) { | |||
return renderComponent( | |||
<MetaQualityProfiles | |||
languages={{ css: mockLanguage() }} | |||
profiles={[ | |||
{ ...mockQualityProfile({ key: 'js', name: 'javascript' }), deleted: true }, | |||
{ ...mockQualityProfile({ key: 'ts', name: 'typescript' }), deleted: false }, | |||
{ | |||
...mockQualityProfile({ | |||
key: 'css', | |||
name: 'style', | |||
language: 'css', | |||
languageName: 'CSS', | |||
}), | |||
deleted: false, | |||
}, | |||
]} | |||
{...overrides} | |||
/> | |||
<LanguagesContext.Provider value={{ css: mockLanguage() }}> | |||
<MetaQualityProfiles | |||
profiles={[ | |||
{ ...mockQualityProfile({ key: 'js', name: 'javascript' }), deleted: true }, | |||
{ ...mockQualityProfile({ key: 'ts', name: 'typescript' }), deleted: false }, | |||
{ | |||
...mockQualityProfile({ | |||
key: 'css', | |||
name: 'style', | |||
language: 'css', | |||
languageName: 'CSS', | |||
}), | |||
deleted: false, | |||
}, | |||
]} | |||
{...overrides} | |||
/> | |||
</LanguagesContext.Provider> | |||
); | |||
} |
@@ -17,19 +17,19 @@ | |||
* 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 { act, screen } from '@testing-library/react'; | |||
import userEvent from '@testing-library/user-event'; | |||
import * as React from 'react'; | |||
import { searchProjectTags, setApplicationTags, setProjectTags } from '../../../../api/components'; | |||
import { mockComponent } from '../../../../helpers/mocks/component'; | |||
import { renderComponent } from '../../../../helpers/testReactTestingUtils'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { setApplicationTags, setProjectTags } from '../../../../../api/components'; | |||
import { mockComponent } from '../../../../../helpers/mocks/component'; | |||
import { renderComponent } from '../../../../../helpers/testReactTestingUtils'; | |||
import { ComponentQualifier } from '../../../../../types/component'; | |||
import MetaTags from '../MetaTags'; | |||
jest.mock('../../../../api/components', () => ({ | |||
jest.mock('../../../../../api/components', () => ({ | |||
setApplicationTags: jest.fn().mockResolvedValue(true), | |||
setProjectTags: jest.fn().mockResolvedValue(true), | |||
searchProjectTags: jest.fn(), | |||
searchProjectTags: jest.fn().mockResolvedValue({ tags: ['best', 'useless'] }), | |||
})); | |||
beforeEach(() => { | |||
@@ -45,7 +45,6 @@ it('should render without tags and admin rights', async () => { | |||
it('should allow to edit tags for a project', async () => { | |||
const user = userEvent.setup(); | |||
jest.mocked(searchProjectTags).mockResolvedValue({ tags: ['best', 'useless'] }); | |||
const onComponentChange = jest.fn(); | |||
const component = mockComponent({ | |||
@@ -62,11 +61,11 @@ it('should allow to edit tags for a project', async () => { | |||
expect(await screen.findByText('foo, bar')).toBeInTheDocument(); | |||
expect(screen.getByRole('button')).toBeInTheDocument(); | |||
await user.click(screen.getByRole('button', { name: 'tags_list_x.foo, bar' })); | |||
await act(() => user.click(screen.getByRole('button', { name: 'foo, bar +' }))); | |||
expect(await screen.findByText('best')).toBeInTheDocument(); | |||
expect(await screen.findByRole('checkbox', { name: 'best' })).toBeInTheDocument(); | |||
await user.click(screen.getByText('best')); | |||
await user.click(screen.getByRole('checkbox', { name: 'best' })); | |||
expect(onComponentChange).toHaveBeenCalledWith({ tags: ['foo', 'bar', 'best'] }); | |||
onComponentChange.mockClear(); | |||
@@ -74,7 +73,7 @@ it('should allow to edit tags for a project', async () => { | |||
/* | |||
* Since we're not actually updating the tags, we're back to having the foo, bar only | |||
*/ | |||
await user.click(screen.getByText('bar')); | |||
await user.click(screen.getByRole('checkbox', { name: 'bar' })); | |||
expect(onComponentChange).toHaveBeenCalledWith({ tags: ['foo'] }); | |||
expect(setProjectTags).toHaveBeenCalled(); | |||
@@ -93,15 +92,15 @@ it('should set tags for an app', async () => { | |||
}), | |||
}); | |||
await user.click(screen.getByRole('button', { name: 'tags_list_x.no_tags' })); | |||
await act(() => user.click(screen.getByRole('button', { name: 'no_tags +' }))); | |||
await user.click(screen.getByText('best')); | |||
await user.click(await screen.findByRole('checkbox', { name: 'best' })); | |||
expect(setProjectTags).not.toHaveBeenCalled(); | |||
expect(setApplicationTags).toHaveBeenCalled(); | |||
}); | |||
function renderMetaTags(overrides: Partial<MetaTags['props']> = {}) { | |||
function renderMetaTags(overrides: Partial<Parameters<typeof MetaTags>[0]> = {}) { | |||
const component = mockComponent({ | |||
configuration: { | |||
showSettings: false, |
@@ -1,84 +0,0 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import isValidUri from '../../../app/utils/isValidUri'; | |||
import { ClearButton } from '../../../components/controls/buttons'; | |||
import ProjectLinkIcon from '../../../components/icons/ProjectLinkIcon'; | |||
import { getLinkName } from '../../../helpers/projectLinks'; | |||
import { ProjectLink } from '../../../types/types'; | |||
interface Props { | |||
iconOnly?: boolean; | |||
link: ProjectLink; | |||
} | |||
interface State { | |||
expanded: boolean; | |||
} | |||
export default class MetaLink extends React.PureComponent<Props, State> { | |||
state = { expanded: false }; | |||
handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
this.setState(({ expanded }) => ({ expanded: !expanded })); | |||
}; | |||
handleCollapse = () => { | |||
this.setState({ expanded: false }); | |||
}; | |||
handleSelect = (event: React.MouseEvent<HTMLInputElement>) => { | |||
event.currentTarget.select(); | |||
}; | |||
render() { | |||
const { iconOnly, link } = this.props; | |||
const linkTitle = getLinkName(link); | |||
const isValid = isValidUri(link.url); | |||
return ( | |||
<li> | |||
<a | |||
className="link-no-underline" | |||
href={isValid ? link.url : undefined} | |||
onClick={isValid ? undefined : this.handleClick} | |||
rel="nofollow noreferrer noopener" | |||
target="_blank" | |||
title={linkTitle} | |||
> | |||
<ProjectLinkIcon className="little-spacer-right" type={link.type} /> | |||
{!iconOnly && linkTitle} | |||
</a> | |||
{this.state.expanded && ( | |||
<div className="little-spacer-top display-flex-center"> | |||
<input | |||
className="overview-key width-80" | |||
onClick={this.handleSelect} | |||
readOnly | |||
type="text" | |||
value={link.url} | |||
/> | |||
<ClearButton className="little-spacer-left" onClick={this.handleCollapse} /> | |||
</div> | |||
)} | |||
</li> | |||
); | |||
} | |||
} |
@@ -1,84 +0,0 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import { getProjectLinks } from '../../../api/projectLinks'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { orderLinks } from '../../../helpers/projectLinks'; | |||
import { LightComponent, ProjectLink } from '../../../types/types'; | |||
import MetaLink from './MetaLink'; | |||
interface Props { | |||
component: LightComponent; | |||
} | |||
interface State { | |||
links?: ProjectLink[]; | |||
} | |||
export default class MetaLinks extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = {}; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.loadLinks(); | |||
} | |||
componentDidUpdate(prevProps: Props) { | |||
if (prevProps.component.key !== this.props.component.key) { | |||
this.loadLinks(); | |||
} | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
loadLinks = () => | |||
getProjectLinks(this.props.component.key).then( | |||
(links) => { | |||
if (this.mounted) { | |||
this.setState({ links }); | |||
} | |||
}, | |||
() => {} | |||
); | |||
render() { | |||
const { links } = this.state; | |||
if (!links || links.length === 0) { | |||
return null; | |||
} | |||
const orderedLinks = orderLinks(links); | |||
return ( | |||
<div className="big-padded bordered-bottom"> | |||
<h3>{translate('overview.external_links')}</h3> | |||
<ul className="project-info-list"> | |||
{orderedLinks.map((link) => ( | |||
<MetaLink key={link.id} link={link} /> | |||
))} | |||
</ul> | |||
</div> | |||
); | |||
} | |||
} |
@@ -1,151 +0,0 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import { searchRules } from '../../../api/rules'; | |||
import withLanguagesContext from '../../../app/components/languages/withLanguagesContext'; | |||
import Link from '../../../components/common/Link'; | |||
import Tooltip from '../../../components/controls/Tooltip'; | |||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | |||
import { getQualityProfileUrl } from '../../../helpers/urls'; | |||
import { Languages } from '../../../types/languages'; | |||
import { ComponentQualityProfile, Dict } from '../../../types/types'; | |||
interface Props { | |||
headerClassName?: string; | |||
languages: Languages; | |||
profiles: ComponentQualityProfile[]; | |||
} | |||
interface State { | |||
deprecatedByKey: Dict<number>; | |||
} | |||
export class MetaQualityProfiles extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { deprecatedByKey: {} }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.loadDeprecatedRules(); | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
loadDeprecatedRules() { | |||
const existingProfiles = this.props.profiles.filter((p) => !p.deleted); | |||
const requests = existingProfiles.map((profile) => | |||
this.loadDeprecatedRulesForProfile(profile.key) | |||
); | |||
Promise.all(requests).then( | |||
(responses) => { | |||
if (this.mounted) { | |||
const deprecatedByKey: Dict<number> = {}; | |||
responses.forEach((count, i) => { | |||
const profileKey = existingProfiles[i].key; | |||
deprecatedByKey[profileKey] = count; | |||
}); | |||
this.setState({ deprecatedByKey }); | |||
} | |||
}, | |||
() => {} | |||
); | |||
} | |||
loadDeprecatedRulesForProfile(profileKey: string) { | |||
const data = { | |||
activation: 'true', | |||
ps: 1, | |||
qprofile: profileKey, | |||
statuses: 'DEPRECATED', | |||
}; | |||
return searchRules(data).then((r) => r.paging.total); | |||
} | |||
getDeprecatedRulesCount(profile: { key: string }) { | |||
const count = this.state.deprecatedByKey[profile.key]; | |||
return count || 0; | |||
} | |||
renderProfile(profile: ComponentQualityProfile) { | |||
const languageFromStore = this.props.languages[profile.language]; | |||
const languageName = languageFromStore ? languageFromStore.name : profile.language; | |||
const inner = ( | |||
<div className="text-ellipsis"> | |||
<span className="spacer-right">({languageName})</span> | |||
{profile.deleted ? ( | |||
profile.name | |||
) : ( | |||
<Link to={getQualityProfileUrl(profile.name, profile.language)}> | |||
<span | |||
aria-label={translateWithParameters( | |||
'overview.link_to_x_profile_y', | |||
languageName, | |||
profile.name | |||
)} | |||
> | |||
{profile.name} | |||
</span> | |||
</Link> | |||
)} | |||
</div> | |||
); | |||
if (profile.deleted) { | |||
const tooltip = translateWithParameters('overview.deleted_profile', profile.name); | |||
return ( | |||
<Tooltip key={profile.key} overlay={tooltip}> | |||
<li className="project-info-deleted-profile">{inner}</li> | |||
</Tooltip> | |||
); | |||
} | |||
const count = this.getDeprecatedRulesCount(profile); | |||
if (count > 0) { | |||
const tooltip = translateWithParameters('overview.deprecated_profile', count); | |||
return ( | |||
<Tooltip key={profile.key} overlay={tooltip}> | |||
<li className="project-info-deprecated-rules">{inner}</li> | |||
</Tooltip> | |||
); | |||
} | |||
return <li key={profile.key}>{inner}</li>; | |||
} | |||
render() { | |||
const { headerClassName, profiles } = this.props; | |||
return ( | |||
<> | |||
<h3 className={headerClassName}>{translate('overview.quality_profiles')}</h3> | |||
<ul className="project-info-list"> | |||
{profiles.map((profile) => this.renderProfile(profile))} | |||
</ul> | |||
</> | |||
); | |||
} | |||
} | |||
export default withLanguagesContext(MetaQualityProfiles); |
@@ -1,93 +0,0 @@ | |||
/* | |||
* 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 * as React from 'react'; | |||
import { setApplicationTags, setProjectTags } from '../../../api/components'; | |||
import Dropdown from '../../../components/controls/Dropdown'; | |||
import { ButtonLink } from '../../../components/controls/buttons'; | |||
import TagsList from '../../../components/tags/TagsList'; | |||
import { PopupPlacement } from '../../../components/ui/popups'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { ComponentQualifier } from '../../../types/component'; | |||
import { Component } from '../../../types/types'; | |||
import MetaTagsSelector from './MetaTagsSelector'; | |||
interface Props { | |||
component: Component; | |||
onComponentChange: (changes: {}) => void; | |||
} | |||
export default class MetaTags extends React.PureComponent<Props> { | |||
canUpdateTags = () => { | |||
const { configuration } = this.props.component; | |||
return configuration && configuration.showSettings; | |||
}; | |||
setTags = (values: string[]) => { | |||
const { component } = this.props; | |||
if (component.qualifier === ComponentQualifier.Application) { | |||
return setApplicationTags({ | |||
application: component.key, | |||
tags: values.join(','), | |||
}); | |||
} | |||
return setProjectTags({ | |||
project: component.key, | |||
tags: values.join(','), | |||
}); | |||
}; | |||
handleSetProjectTags = (values: string[]) => { | |||
this.setTags(values).then( | |||
() => this.props.onComponentChange({ tags: values }), | |||
() => {} | |||
); | |||
}; | |||
render() { | |||
const tags = this.props.component.tags || []; | |||
return this.canUpdateTags() ? ( | |||
<div className="big-spacer-top project-info-tags"> | |||
<Dropdown | |||
closeOnClick={false} | |||
closeOnClickOutside | |||
overlay={ | |||
<MetaTagsSelector selectedTags={tags} setProjectTags={this.handleSetProjectTags} /> | |||
} | |||
overlayPlacement={PopupPlacement.BottomLeft} | |||
> | |||
<ButtonLink stopPropagation> | |||
<TagsList allowUpdate tags={tags.length ? tags : [translate('no_tags')]} /> | |||
</ButtonLink> | |||
</Dropdown> | |||
</div> | |||
) : ( | |||
<div className="big-spacer-top project-info-tags"> | |||
<TagsList | |||
allowUpdate={false} | |||
className="note" | |||
tags={tags.length ? tags : [translate('no_tags')]} | |||
/> | |||
</div> | |||
); | |||
} | |||
} |
@@ -1,83 +0,0 @@ | |||
/* | |||
* 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 { difference, without } from 'lodash'; | |||
import * as React from 'react'; | |||
import { searchProjectTags } from '../../../api/components'; | |||
import TagsSelector from '../../../components/tags/TagsSelector'; | |||
interface Props { | |||
selectedTags: string[]; | |||
setProjectTags: (tags: string[]) => void; | |||
} | |||
interface State { | |||
searchResult: string[]; | |||
} | |||
const LIST_SIZE = 10; | |||
export default class MetaTagsSelector extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { searchResult: [] }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
onSearch = (query: string) => { | |||
return searchProjectTags({ | |||
q: query, | |||
ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100), | |||
}).then( | |||
({ tags }) => { | |||
if (this.mounted) { | |||
this.setState({ searchResult: tags }); | |||
} | |||
}, | |||
() => {} | |||
); | |||
}; | |||
onSelect = (tag: string) => { | |||
this.props.setProjectTags([...this.props.selectedTags, tag]); | |||
}; | |||
onUnselect = (tag: string) => { | |||
this.props.setProjectTags(without(this.props.selectedTags, tag)); | |||
}; | |||
render() { | |||
const availableTags = difference(this.state.searchResult, this.props.selectedTags); | |||
return ( | |||
<TagsSelector | |||
listSize={LIST_SIZE} | |||
onSearch={this.onSearch} | |||
onSelect={this.onSelect} | |||
onUnselect={this.onUnselect} | |||
selectedTags={this.props.selectedTags} | |||
tags={availableTags} | |||
/> | |||
); | |||
} | |||
} |
@@ -0,0 +1,113 @@ | |||
/* | |||
* 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 { CloseIcon, InputField, InteractiveIcon, Link } from 'design-system'; | |||
import React, { useState } from 'react'; | |||
import isValidUri from '../../app/utils/isValidUri'; | |||
import { translate } from '../../helpers/l10n'; | |||
import { getLinkName } from '../../helpers/projectLinks'; | |||
import { ProjectLink } from '../../types/types'; | |||
import { ClearButton } from '../controls/buttons'; | |||
import ProjectLinkIcon from '../icons/ProjectLinkIcon'; | |||
interface Props { | |||
iconOnly?: boolean; | |||
link: ProjectLink; | |||
// TODO Remove this prop when all links are migrated to the new design | |||
miui?: boolean; | |||
} | |||
export default function MetaLink({ iconOnly, link, miui }: Props) { | |||
const [expanded, setExpanded] = useState(false); | |||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
setExpanded((expanded) => !expanded); | |||
}; | |||
const handleCollapse = () => { | |||
setExpanded(false); | |||
}; | |||
const handleSelect = (event: React.MouseEvent<HTMLInputElement>) => { | |||
event.currentTarget.select(); | |||
}; | |||
const linkTitle = getLinkName(link); | |||
const isValid = isValidUri(link.url); | |||
return miui ? ( | |||
<li> | |||
<Link | |||
isExternal | |||
to={link.url} | |||
preventDefault={!isValid} | |||
onClick={isValid ? undefined : handleClick} | |||
rel="nofollow noreferrer noopener" | |||
target="_blank" | |||
icon={<ProjectLinkIcon miui className="little-spacer-right" type={link.type} />} | |||
> | |||
{!iconOnly && linkTitle} | |||
</Link> | |||
{expanded && ( | |||
<div className="little-spacer-top display-flex-center"> | |||
<InputField | |||
className="overview-key width-80" | |||
onClick={handleSelect} | |||
readOnly | |||
type="text" | |||
value={link.url} | |||
/> | |||
<InteractiveIcon | |||
Icon={CloseIcon} | |||
aria-label={translate('hide')} | |||
className="sw-ml-1" | |||
onClick={handleCollapse} | |||
/> | |||
</div> | |||
)} | |||
</li> | |||
) : ( | |||
<li> | |||
<a | |||
className="link-no-underline" | |||
href={isValid ? link.url : undefined} | |||
onClick={isValid ? undefined : handleClick} | |||
rel="nofollow noreferrer noopener" | |||
target="_blank" | |||
title={linkTitle} | |||
> | |||
<ProjectLinkIcon className="little-spacer-right" type={link.type} /> | |||
{!iconOnly && linkTitle} | |||
</a> | |||
{expanded && ( | |||
<div className="little-spacer-top display-flex-center"> | |||
<input | |||
className="overview-key width-80" | |||
onClick={handleSelect} | |||
readOnly | |||
type="text" | |||
value={link.url} | |||
/> | |||
<ClearButton className="little-spacer-left" onClick={handleCollapse} /> | |||
</div> | |||
)} | |||
</li> | |||
); | |||
} |
@@ -17,6 +17,8 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { LinkExternalIcon } from '@primer/octicons-react'; | |||
import { HomeIcon } from 'design-system'; | |||
import * as React from 'react'; | |||
import BugTrackerIcon from './BugTrackerIcon'; | |||
import ContinuousIntegrationIcon from './ContinuousIntegrationIcon'; | |||
@@ -27,19 +29,30 @@ import SCMIcon from './SCMIcon'; | |||
interface ProjectLinkIconProps { | |||
type: string; | |||
miui?: boolean; | |||
} | |||
export default function ProjectLinkIcon({ type, ...iconProps }: IconProps & ProjectLinkIconProps) { | |||
switch (type) { | |||
case 'issue': | |||
return <BugTrackerIcon {...iconProps} />; | |||
case 'homepage': | |||
return <HouseIcon {...iconProps} />; | |||
case 'ci': | |||
return <ContinuousIntegrationIcon {...iconProps} />; | |||
case 'scm': | |||
return <SCMIcon {...iconProps} />; | |||
default: | |||
return <DetachIcon {...iconProps} />; | |||
} | |||
export default function ProjectLinkIcon({ | |||
miui, | |||
type, | |||
...iconProps | |||
}: IconProps & ProjectLinkIconProps) { | |||
const getIcon = (): any => { | |||
switch (type) { | |||
case 'issue': | |||
return BugTrackerIcon; | |||
case 'homepage': | |||
return miui ? HomeIcon : HouseIcon; | |||
case 'ci': | |||
return ContinuousIntegrationIcon; | |||
case 'scm': | |||
return SCMIcon; | |||
default: | |||
return miui ? LinkExternalIcon : DetachIcon; | |||
} | |||
}; | |||
const Icon = getIcon(); | |||
return <Icon {...iconProps} />; | |||
} |
@@ -1825,12 +1825,23 @@ projects_management.filter_by_visibility=Filter by visibility | |||
project.info.title=Project Information | |||
application.info.title=Application Information | |||
project.about.title=About this Project | |||
application.about.title=About this Application | |||
project.info.description=Description | |||
project.info.empty_description=No description added for this project. | |||
application.info.empty_description=No description added for this application. | |||
project.info.quality_gate=Quality Gate used | |||
project.info.to_notifications=Set notifications | |||
project.info.notifications=Notifications | |||
project.info.main_branch=Main branch | |||
project.info.see_more_info_on_x_locs=See more information on your {0} lines of code | |||
project.info.make_home.title=Use as homepage | |||
project.info.make_home.label=Make this project my homepage | |||
application.info.make_home.label=Make this application my homepage | |||
project.info.make_home.tooltip=This means you'll be redirected to this project whenever you log in to sonarqube or click on the top-left SonarQube logo. | |||
application.info.make_home.tooltip=This means you'll be redirected to this application whenever you log in to sonarqube or click on the top-left SonarQube logo. | |||
overview.project_key.tooltip.TRK=Your project key is a unique identifier for your project. If you are using Maven, make sure the key matches the "groupId:artifactId" format. | |||
overview.project_key.tooltip.APP=Your application key is a unique identifier for your application. | |||
#------------------------------------------------------------------------------ | |||
# |