diff options
author | Viktor Vorona <viktor.vorona@sonarsource.com> | 2023-06-23 16:22:45 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-06-26 20:03:55 +0000 |
commit | 4e7d35f61399ea04e151b5c9239982dd499957cf (patch) | |
tree | 2dd1bc54e1f26712e445ac5c9c6c815fbd7f0f11 /server | |
parent | 7199d2e23d9b6976fa03bc8e8af8dbd77b703d0b (diff) | |
download | sonarqube-4e7d35f61399ea04e151b5c9239982dd499957cf.tar.gz sonarqube-4e7d35f61399ea04e151b5c9239982dd499957cf.zip |
SONAR-19613 Migrate About this project to MIUI
Diffstat (limited to 'server')
26 files changed, 887 insertions, 670 deletions
diff --git a/server/sonar-web/design-system/src/components/SizeIndicator.tsx b/server/sonar-web/design-system/src/components/SizeIndicator.tsx index 62cc685c2e7..a5a4d54259b 100644 --- a/server/sonar-web/design-system/src/components/SizeIndicator.tsx +++ b/server/sonar-web/design-system/src/components/SizeIndicator.tsx @@ -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')}; diff --git a/server/sonar-web/design-system/src/components/__tests__/SizeIndicator-test.tsx b/server/sonar-web/design-system/src/components/__tests__/SizeIndicator-test.tsx index bdd8b8c6d85..908197ec19a 100644 --- a/server/sonar-web/design-system/src/components/__tests__/SizeIndicator-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/SizeIndicator-test.tsx @@ -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} />); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx index 35ad1906cb9..57bd0ba3d69 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx @@ -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> = {}) { diff --git a/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx b/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx index 93899de2a3f..dc29d25eaa2 100644 --- a/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx +++ b/server/sonar-web/src/main/js/apps/account/projects/ProjectCard.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projectInformation/ProjectInformationApp.tsx b/server/sonar-web/src/main/js/apps/projectInformation/ProjectInformationApp.tsx index 765e19b795d..223c019c707 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/ProjectInformationApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/ProjectInformationApp.tsx @@ -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> diff --git a/server/sonar-web/src/main/js/apps/projectInformation/__tests__/ProjectInformationApp-it.tsx b/server/sonar-web/src/main/js/apps/projectInformation/__tests__/ProjectInformationApp-it.tsx new file mode 100644 index 00000000000..cebd49ea64c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectInformation/__tests__/ProjectInformationApp-it.tsx @@ -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 } + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/about/AboutProject.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/AboutProject.tsx index 5b2b9a38d50..09dd1ec8bed 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/about/AboutProject.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/AboutProject.tsx @@ -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 />} + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaDescription.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaDescription.tsx new file mode 100644 index 00000000000..9a9e3e5e4a8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaDescription.tsx @@ -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')} /> + )} + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaHome.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaHome.tsx new file mode 100644 index 00000000000..cf79ab1f0b5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaHome.tsx @@ -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> + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaKey.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaKey.tsx new file mode 100644 index 00000000000..f5b023c180a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaKey.tsx @@ -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> + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaLinks.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaLinks.tsx new file mode 100644 index 00000000000..15cc747d867 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaLinks.tsx @@ -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> + </> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaQualityGate.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaQualityGate.tsx index 94719fc965e..745aa85062f 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaQualityGate.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaQualityGate.tsx @@ -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> diff --git a/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaQualityProfiles.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaQualityProfiles.tsx new file mode 100644 index 00000000000..87d7b0488e9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaQualityProfiles.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaSize.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaSize.tsx index 499d3b18492..a015f048d94 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaSize.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaSize.tsx @@ -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> ) : ( diff --git a/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaTags.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaTags.tsx new file mode 100644 index 00000000000..68abe4393c5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaTags.tsx @@ -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} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaKey.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaVisibility.tsx index 8532fac8291..c5fdb3d6b27 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaKey.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaVisibility.tsx @@ -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} /> </> ); } diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/__tests__/MetaKey-test.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/__tests__/MetaKey-test.tsx index 0fd2885b200..17d2a8c315d 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/__tests__/MetaKey-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/__tests__/MetaKey-test.tsx @@ -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} /> ); diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/__tests__/MetaQualityProfiles-test.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/__tests__/MetaQualityProfiles-test.tsx index 842045daff7..8d2f51f585b 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/__tests__/MetaQualityProfiles-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/__tests__/MetaQualityProfiles-test.tsx @@ -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> ); } diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/__tests__/MetaTags-test.tsx b/server/sonar-web/src/main/js/apps/projectInformation/about/components/__tests__/MetaTags-test.tsx index 04b073db83e..88511e5e193 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/__tests__/MetaTags-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/about/components/__tests__/MetaTags-test.tsx @@ -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, diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaLink.tsx b/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaLink.tsx deleted file mode 100644 index e81f53c7554..00000000000 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaLink.tsx +++ /dev/null @@ -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> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaLinks.tsx b/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaLinks.tsx deleted file mode 100644 index c6df449cb8f..00000000000 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaLinks.tsx +++ /dev/null @@ -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> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaQualityProfiles.tsx b/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaQualityProfiles.tsx deleted file mode 100644 index 1788efdad17..00000000000 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaQualityProfiles.tsx +++ /dev/null @@ -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); diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaTags.tsx b/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaTags.tsx deleted file mode 100644 index d12a0bf75ce..00000000000 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaTags.tsx +++ /dev/null @@ -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> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaTagsSelector.tsx b/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaTagsSelector.tsx deleted file mode 100644 index d2602d40f2d..00000000000 --- a/server/sonar-web/src/main/js/apps/projectInformation/meta/MetaTagsSelector.tsx +++ /dev/null @@ -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} - /> - ); - } -} diff --git a/server/sonar-web/src/main/js/components/common/MetaLink.tsx b/server/sonar-web/src/main/js/components/common/MetaLink.tsx new file mode 100644 index 00000000000..c2d73bd9323 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/MetaLink.tsx @@ -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> + ); +} diff --git a/server/sonar-web/src/main/js/components/icons/ProjectLinkIcon.tsx b/server/sonar-web/src/main/js/components/icons/ProjectLinkIcon.tsx index 86606a750d0..9392d6400e9 100644 --- a/server/sonar-web/src/main/js/components/icons/ProjectLinkIcon.tsx +++ b/server/sonar-web/src/main/js/components/icons/ProjectLinkIcon.tsx @@ -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} />; } |