You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Menu.tsx 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2022 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import classNames from 'classnames';
  21. import { LocationDescriptorObject } from 'history';
  22. import { omit } from 'lodash';
  23. import * as React from 'react';
  24. import { Link, LinkProps } from 'react-router';
  25. import Dropdown from '../../../../components/controls/Dropdown';
  26. import Tooltip from '../../../../components/controls/Tooltip';
  27. import BulletListIcon from '../../../../components/icons/BulletListIcon';
  28. import DropdownIcon from '../../../../components/icons/DropdownIcon';
  29. import NavBarTabs from '../../../../components/ui/NavBarTabs';
  30. import { getBranchLikeQuery, isPullRequest } from '../../../../helpers/branch-like';
  31. import { hasMessage, translate, translateWithParameters } from '../../../../helpers/l10n';
  32. import { getPortfolioUrl, getProjectQueryUrl } from '../../../../helpers/urls';
  33. import { AppState } from '../../../../types/appstate';
  34. import { BranchLike, BranchParameters } from '../../../../types/branch-like';
  35. import { ComponentQualifier, isPortfolioLike } from '../../../../types/component';
  36. import { Component, Extension } from '../../../../types/types';
  37. import withAppStateContext from '../../app-state/withAppStateContext';
  38. import './Menu.css';
  39. const SETTINGS_URLS = [
  40. '/project/admin',
  41. '/project/baseline',
  42. '/project/branches',
  43. '/project/settings',
  44. '/project/quality_profiles',
  45. '/project/quality_gate',
  46. '/project/links',
  47. '/project_roles',
  48. '/project/history',
  49. 'background_tasks',
  50. '/project/key',
  51. '/project/deletion',
  52. '/project/webhooks'
  53. ];
  54. interface Props {
  55. appState: AppState;
  56. branchLike: BranchLike | undefined;
  57. branchLikes: BranchLike[] | undefined;
  58. component: Component;
  59. isInProgress?: boolean;
  60. isPending?: boolean;
  61. onToggleProjectInfo: () => void;
  62. }
  63. type Query = BranchParameters & { id: string };
  64. export class Menu extends React.PureComponent<Props> {
  65. hasAnalysis = () => {
  66. const { branchLikes = [], component, isInProgress, isPending } = this.props;
  67. const hasBranches = branchLikes.length > 1;
  68. return hasBranches || isInProgress || isPending || component.analysisDate !== undefined;
  69. };
  70. isProject = () => {
  71. return this.props.component.qualifier === ComponentQualifier.Project;
  72. };
  73. isDeveloper = () => {
  74. return this.props.component.qualifier === ComponentQualifier.Developper;
  75. };
  76. isPortfolio = () => {
  77. const { qualifier } = this.props.component;
  78. return isPortfolioLike(qualifier);
  79. };
  80. isApplication = () => {
  81. return this.props.component.qualifier === ComponentQualifier.Application;
  82. };
  83. isAllChildProjectAccessible = () => {
  84. return Boolean(this.props.component.canBrowseAllChildProjects);
  85. };
  86. isApplicationChildInaccessble = () => {
  87. return this.isApplication() && !this.isAllChildProjectAccessible();
  88. };
  89. isGovernanceEnabled = () => {
  90. const {
  91. component: { extensions }
  92. } = this.props;
  93. return extensions && extensions.some(extension => extension.key.startsWith('governance/'));
  94. };
  95. getConfiguration = () => {
  96. return this.props.component.configuration || {};
  97. };
  98. getQuery = (): Query => {
  99. return { id: this.props.component.key, ...getBranchLikeQuery(this.props.branchLike) };
  100. };
  101. renderLinkWhenInaccessibleChild(label: React.ReactNode) {
  102. return (
  103. <li>
  104. <Tooltip
  105. overlay={translateWithParameters(
  106. 'layout.all_project_must_be_accessible',
  107. translate('qualifier', this.props.component.qualifier)
  108. )}>
  109. <a aria-disabled="true" className="disabled-link">
  110. {label}
  111. </a>
  112. </Tooltip>
  113. </li>
  114. );
  115. }
  116. renderMenuLink = ({
  117. label,
  118. to,
  119. ...props
  120. }: Omit<LinkProps, 'to'> & {
  121. label: React.ReactNode;
  122. to: LocationDescriptorObject;
  123. }) => {
  124. const hasAnalysis = this.hasAnalysis();
  125. const isApplicationChildInaccessble = this.isApplicationChildInaccessble();
  126. const query = this.getQuery();
  127. if (isApplicationChildInaccessble) {
  128. return this.renderLinkWhenInaccessibleChild(label);
  129. }
  130. return (
  131. <li>
  132. {hasAnalysis ? (
  133. <Link
  134. activeClassName="active"
  135. to={{ ...to, query: { ...query, ...to.query } }}
  136. {...omit(props, ['to'])}>
  137. {label}
  138. </Link>
  139. ) : (
  140. <Tooltip overlay={translate('layout.must_be_configured')}>
  141. <a aria-disabled="true" className="disabled-link">
  142. {label}
  143. </a>
  144. </Tooltip>
  145. )}
  146. </li>
  147. );
  148. };
  149. renderDashboardLink = () => {
  150. const { id, ...branchLike } = this.getQuery();
  151. if (this.isPortfolio()) {
  152. return this.isGovernanceEnabled() ? (
  153. <li>
  154. <Link activeClassName="active" to={getPortfolioUrl(id)}>
  155. {translate('overview.page')}
  156. </Link>
  157. </li>
  158. ) : null;
  159. }
  160. const isApplicationChildInaccessble = this.isApplicationChildInaccessble();
  161. if (isApplicationChildInaccessble) {
  162. return this.renderLinkWhenInaccessibleChild(translate('overview.page'));
  163. }
  164. return (
  165. <li>
  166. <Link activeClassName="active" to={getProjectQueryUrl(id, branchLike)}>
  167. {translate('overview.page')}
  168. </Link>
  169. </li>
  170. );
  171. };
  172. renderBreakdownLink = () => {
  173. return this.isPortfolio() && this.isGovernanceEnabled()
  174. ? this.renderMenuLink({
  175. label: translate('portfolio_breakdown.page'),
  176. to: { pathname: '/code' }
  177. })
  178. : null;
  179. };
  180. renderCodeLink = () => {
  181. if (this.isPortfolio() || this.isDeveloper()) {
  182. return null;
  183. }
  184. const label = this.isApplication() ? translate('view_projects.page') : translate('code.page');
  185. return this.renderMenuLink({ label, to: { pathname: '/code' } });
  186. };
  187. renderActivityLink = () => {
  188. const { branchLike } = this.props;
  189. if (isPullRequest(branchLike)) {
  190. return null;
  191. }
  192. return this.renderMenuLink({
  193. label: translate('project_activity.page'),
  194. to: { pathname: '/project/activity' }
  195. });
  196. };
  197. renderIssuesLink = () => {
  198. return this.renderMenuLink({
  199. label: translate('issues.page'),
  200. to: { pathname: '/project/issues', query: { resolved: 'false' } }
  201. });
  202. };
  203. renderComponentMeasuresLink = () => {
  204. return this.renderMenuLink({
  205. label: translate('layout.measures'),
  206. to: { pathname: '/component_measures' }
  207. });
  208. };
  209. renderSecurityHotspotsLink = () => {
  210. const isPortfolio = this.isPortfolio();
  211. return (
  212. !isPortfolio &&
  213. this.renderMenuLink({
  214. label: translate('layout.security_hotspots'),
  215. to: { pathname: '/security_hotspots' }
  216. })
  217. );
  218. };
  219. renderSecurityReports = () => {
  220. const { branchLike, component } = this.props;
  221. const { extensions = [] } = component;
  222. if (isPullRequest(branchLike)) {
  223. return null;
  224. }
  225. const hasSecurityReportsEnabled = extensions.some(extension =>
  226. extension.key.startsWith('securityreport/')
  227. );
  228. if (!hasSecurityReportsEnabled) {
  229. return null;
  230. }
  231. return this.renderMenuLink({
  232. label: translate('layout.security_reports'),
  233. to: { pathname: '/project/extension/securityreport/securityreport' }
  234. });
  235. };
  236. renderAdministration = () => {
  237. const { branchLike, component } = this.props;
  238. const isProject = this.isProject();
  239. const isPortfolio = this.isPortfolio();
  240. const isApplication = this.isApplication();
  241. const query = this.getQuery();
  242. if (!this.getConfiguration().showSettings || isPullRequest(branchLike)) {
  243. return null;
  244. }
  245. const isSettingsActive = SETTINGS_URLS.some(url => window.location.href.indexOf(url) !== -1);
  246. const adminLinks = this.renderAdministrationLinks(query, isProject, isApplication, isPortfolio);
  247. if (!adminLinks.some(link => link != null)) {
  248. return null;
  249. }
  250. return (
  251. <Dropdown
  252. data-test="administration"
  253. overlay={<ul className="menu">{adminLinks}</ul>}
  254. tagName="li">
  255. {({ onToggleClick, open }) => (
  256. <a
  257. aria-expanded={open}
  258. aria-haspopup="menu"
  259. role="button"
  260. className={classNames('dropdown-toggle', { active: isSettingsActive || open })}
  261. href="#"
  262. id="component-navigation-admin"
  263. onClick={onToggleClick}>
  264. {hasMessage('layout.settings', component.qualifier)
  265. ? translate('layout.settings', component.qualifier)
  266. : translate('layout.settings')}
  267. <DropdownIcon className="little-spacer-left" />
  268. </a>
  269. )}
  270. </Dropdown>
  271. );
  272. };
  273. renderAdministrationLinks = (
  274. query: Query,
  275. isProject: boolean,
  276. isApplication: boolean,
  277. isPortfolio: boolean
  278. ) => {
  279. return [
  280. this.renderSettingsLink(query, isApplication, isPortfolio),
  281. this.renderBranchesLink(query, isProject),
  282. this.renderBaselineLink(query, isApplication, isPortfolio),
  283. ...this.renderAdminExtensions(query, isApplication),
  284. this.renderImportExportLink(query, isProject),
  285. this.renderProfilesLink(query),
  286. this.renderQualityGateLink(query),
  287. this.renderLinksLink(query),
  288. this.renderPermissionsLink(query),
  289. this.renderBackgroundTasksLink(query),
  290. this.renderUpdateKeyLink(query),
  291. this.renderWebhooksLink(query, isProject),
  292. this.renderDeletionLink(query)
  293. ];
  294. };
  295. renderProjectInformationButton = () => {
  296. const isProject = this.isProject();
  297. const isApplication = this.isApplication();
  298. const label = translate(isProject ? 'project' : 'application', 'info.title');
  299. const isApplicationChildInaccessble = this.isApplicationChildInaccessble();
  300. if (isPullRequest(this.props.branchLike)) {
  301. return null;
  302. }
  303. if (isApplicationChildInaccessble) {
  304. return this.renderLinkWhenInaccessibleChild(label);
  305. }
  306. return (
  307. (isProject || isApplication) && (
  308. <li>
  309. <a
  310. className="menu-button"
  311. onClick={(e: React.SyntheticEvent<HTMLAnchorElement>) => {
  312. e.preventDefault();
  313. e.currentTarget.blur();
  314. this.props.onToggleProjectInfo();
  315. }}
  316. role="button"
  317. tabIndex={0}>
  318. <BulletListIcon className="little-spacer-right" />
  319. {label}
  320. </a>
  321. </li>
  322. )
  323. );
  324. };
  325. renderSettingsLink = (query: Query, isApplication: boolean, isPortfolio: boolean) => {
  326. if (!this.getConfiguration().showSettings || isApplication || isPortfolio) {
  327. return null;
  328. }
  329. return (
  330. <li key="settings">
  331. <Link activeClassName="active" to={{ pathname: '/project/settings', query }}>
  332. {translate('project_settings.page')}
  333. </Link>
  334. </li>
  335. );
  336. };
  337. renderBranchesLink = (query: Query, isProject: boolean) => {
  338. if (
  339. !this.props.appState.branchesEnabled ||
  340. !isProject ||
  341. !this.getConfiguration().showSettings
  342. ) {
  343. return null;
  344. }
  345. return (
  346. <li key="branches">
  347. <Link activeClassName="active" to={{ pathname: '/project/branches', query }}>
  348. {translate('project_branch_pull_request.page')}
  349. </Link>
  350. </li>
  351. );
  352. };
  353. renderBaselineLink = (query: Query, isApplication: boolean, isPortfolio: boolean) => {
  354. if (!this.getConfiguration().showSettings || isApplication || isPortfolio) {
  355. return null;
  356. }
  357. return (
  358. <li key="baseline">
  359. <Link activeClassName="active" to={{ pathname: '/project/baseline', query }}>
  360. {translate('project_baseline.page')}
  361. </Link>
  362. </li>
  363. );
  364. };
  365. renderImportExportLink = (query: Query, isProject: boolean) => {
  366. if (!isProject) {
  367. return null;
  368. }
  369. return (
  370. <li key="import-export">
  371. <Link activeClassName="active" to={{ pathname: '/project/import_export', query }}>
  372. {translate('project_dump.page')}
  373. </Link>
  374. </li>
  375. );
  376. };
  377. renderProfilesLink = (query: Query) => {
  378. if (!this.getConfiguration().showQualityProfiles) {
  379. return null;
  380. }
  381. return (
  382. <li key="profiles">
  383. <Link activeClassName="active" to={{ pathname: '/project/quality_profiles', query }}>
  384. {translate('project_quality_profiles.page')}
  385. </Link>
  386. </li>
  387. );
  388. };
  389. renderQualityGateLink = (query: Query) => {
  390. if (!this.getConfiguration().showQualityGates) {
  391. return null;
  392. }
  393. return (
  394. <li key="quality_gate">
  395. <Link activeClassName="active" to={{ pathname: '/project/quality_gate', query }}>
  396. {translate('project_quality_gate.page')}
  397. </Link>
  398. </li>
  399. );
  400. };
  401. renderLinksLink = (query: Query) => {
  402. if (!this.getConfiguration().showLinks) {
  403. return null;
  404. }
  405. return (
  406. <li key="links">
  407. <Link activeClassName="active" to={{ pathname: '/project/links', query }}>
  408. {translate('project_links.page')}
  409. </Link>
  410. </li>
  411. );
  412. };
  413. renderPermissionsLink = (query: Query) => {
  414. if (!this.getConfiguration().showPermissions) {
  415. return null;
  416. }
  417. return (
  418. <li key="permissions">
  419. <Link activeClassName="active" to={{ pathname: '/project_roles', query }}>
  420. {translate('permissions.page')}
  421. </Link>
  422. </li>
  423. );
  424. };
  425. renderBackgroundTasksLink = (query: Query) => {
  426. if (!this.getConfiguration().showBackgroundTasks) {
  427. return null;
  428. }
  429. return (
  430. <li key="background_tasks">
  431. <Link activeClassName="active" to={{ pathname: '/project/background_tasks', query }}>
  432. {translate('background_tasks.page')}
  433. </Link>
  434. </li>
  435. );
  436. };
  437. renderUpdateKeyLink = (query: Query) => {
  438. if (!this.getConfiguration().showUpdateKey) {
  439. return null;
  440. }
  441. return (
  442. <li key="update_key">
  443. <Link activeClassName="active" to={{ pathname: '/project/key', query }}>
  444. {translate('update_key.page')}
  445. </Link>
  446. </li>
  447. );
  448. };
  449. renderWebhooksLink = (query: Query, isProject: boolean) => {
  450. if (!this.getConfiguration().showSettings || !isProject) {
  451. return null;
  452. }
  453. return (
  454. <li key="webhooks">
  455. <Link activeClassName="active" to={{ pathname: '/project/webhooks', query }}>
  456. {translate('webhooks.page')}
  457. </Link>
  458. </li>
  459. );
  460. };
  461. renderDeletionLink = (query: Query) => {
  462. const { qualifier } = this.props.component;
  463. if (!this.getConfiguration().showSettings) {
  464. return null;
  465. }
  466. if (
  467. ![
  468. ComponentQualifier.Project,
  469. ComponentQualifier.Portfolio,
  470. ComponentQualifier.Application
  471. ].includes(qualifier as ComponentQualifier)
  472. ) {
  473. return null;
  474. }
  475. return (
  476. <li key="project_delete">
  477. <Link activeClassName="active" to={{ pathname: '/project/deletion', query }}>
  478. {translate('deletion.page')}
  479. </Link>
  480. </li>
  481. );
  482. };
  483. renderExtension = ({ key, name }: Extension, isAdmin: boolean, baseQuery: Query) => {
  484. const pathname = isAdmin ? `/project/admin/extension/${key}` : `/project/extension/${key}`;
  485. const query = { ...baseQuery, qualifier: this.props.component.qualifier };
  486. return (
  487. <li key={key}>
  488. <Link activeClassName="active" to={{ pathname, query }}>
  489. {name}
  490. </Link>
  491. </li>
  492. );
  493. };
  494. renderAdminExtensions = (query: Query, isApplication: boolean) => {
  495. const extensions = this.getConfiguration().extensions || [];
  496. return extensions
  497. .filter(e => !isApplication || e.key !== 'governance/console')
  498. .map(e => this.renderExtension(e, true, query));
  499. };
  500. renderExtensions = () => {
  501. const query = this.getQuery();
  502. const extensions = this.props.component.extensions || [];
  503. const withoutSecurityExtension = extensions.filter(
  504. extension =>
  505. !extension.key.startsWith('securityreport/') && !extension.key.startsWith('governance/')
  506. );
  507. if (withoutSecurityExtension.length === 0) {
  508. return null;
  509. }
  510. return (
  511. <Dropdown
  512. data-test="extensions"
  513. overlay={
  514. <ul className="menu">
  515. {withoutSecurityExtension.map(e => this.renderExtension(e, false, query))}
  516. </ul>
  517. }
  518. tagName="li">
  519. {({ onToggleClick, open }) => (
  520. <a
  521. aria-expanded={open}
  522. aria-haspopup="menu"
  523. role="button"
  524. className={classNames('dropdown-toggle', { active: open })}
  525. href="#"
  526. id="component-navigation-more"
  527. onClick={onToggleClick}>
  528. {translate('more')}
  529. <DropdownIcon className="little-spacer-left" />
  530. </a>
  531. )}
  532. </Dropdown>
  533. );
  534. };
  535. render() {
  536. return (
  537. <div className="display-flex-center display-flex-space-between">
  538. <NavBarTabs>
  539. {this.renderDashboardLink()}
  540. {this.renderBreakdownLink()}
  541. {this.renderIssuesLink()}
  542. {this.renderSecurityHotspotsLink()}
  543. {this.renderSecurityReports()}
  544. {this.renderComponentMeasuresLink()}
  545. {this.renderCodeLink()}
  546. {this.renderActivityLink()}
  547. {this.renderExtensions()}
  548. </NavBarTabs>
  549. <NavBarTabs>
  550. {this.renderAdministration()}
  551. {this.renderProjectInformationButton()}
  552. </NavBarTabs>
  553. </div>
  554. );
  555. }
  556. }
  557. export default withAppStateContext(Menu);