diff options
author | Revanshu Paliwal <revanshu.paliwal@sonarsource.com> | 2023-06-08 10:42:35 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-06-09 20:03:09 +0000 |
commit | 3356d0014fd32650a783f1aa3344c55c6a0d6012 (patch) | |
tree | 47745b379c247fcecb4bb8f4b86c945beab10c78 | |
parent | cbd8e9fb6445a14cc0689e862c3a942f08a0ada7 (diff) | |
download | sonarqube-3356d0014fd32650a783f1aa3344c55c6a0d6012.tar.gz sonarqube-3356d0014fd32650a783f1aa3344c55c6a0d6012.zip |
SONAR-19345 Layout changes for new MIUI issue pages
14 files changed, 555 insertions, 145 deletions
diff --git a/server/sonar-web/design-system/src/components/FacetItem.tsx b/server/sonar-web/design-system/src/components/FacetItem.tsx index 62d5e38b150..9fbeff5e730 100644 --- a/server/sonar-web/design-system/src/components/FacetItem.tsx +++ b/server/sonar-web/design-system/src/components/FacetItem.tsx @@ -113,6 +113,7 @@ const StyledButton = styled(ButtonSecondary)<{ active?: boolean; small?: boolean & mark { background-color: ${themeColor('searchHighlight')}; + font-weight: 400; } } diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index 85fe17a7f6f..f0ca8055c1f 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -42,6 +42,7 @@ const TEMP_PAGELIST_WITH_NEW_BACKGROUND = [ '/dashboard', '/security_hotspots', '/component_measures', + '/project/issues', ]; export default function GlobalContainer() { diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx index dc72fc139bb..f2cf204eb5e 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx @@ -544,9 +544,8 @@ describe('issues item', () => { expect( await screen.findByRole('tab', { name: `coding_rules.description_section.title.root_cause`, - selected: true, }) - ).toBeInTheDocument(); + ).toHaveAttribute('aria-current', 'true'); }); it('should interact with flows and locations', async () => { @@ -823,17 +822,15 @@ describe('issues item', () => { expect( screen.queryByRole('tab', { name: `issue.tabs.${TabKeys.Code}`, - selected: true, }) - ).not.toBeInTheDocument(); + ).toHaveAttribute('aria-current', 'false'); await user.click(screen.getByRole('link', { name: 'location 1' })); expect( - screen.getByRole('tab', { + screen.queryByRole('tab', { name: `issue.tabs.${TabKeys.Code}`, - selected: true, }) - ).toBeInTheDocument(); + ).toHaveAttribute('aria-current', 'true'); // Select the same selected hotspot location should also navigate back to code page await user.click( @@ -842,17 +839,15 @@ describe('issues item', () => { expect( screen.queryByRole('tab', { name: `issue.tabs.${TabKeys.Code}`, - selected: true, }) - ).not.toBeInTheDocument(); + ).toHaveAttribute('aria-current', 'false'); await user.click(screen.getByRole('link', { name: 'location 1' })); expect( - screen.getByRole('tab', { + screen.queryByRole('tab', { name: `issue.tabs.${TabKeys.Code}`, - selected: true, }) - ).toBeInTheDocument(); + ).toHaveAttribute('aria-current', 'true'); }); it('should show issue tags if applicable', async () => { diff --git a/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx b/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx index de50f050463..c6beaa5f4d1 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx @@ -58,7 +58,7 @@ export default function ComponentBreadcrumbs({ 'issues.on_file_x', `${displayProject ? issue.projectName + ', ' : ''}${componentName}` )} - className="sw-flex sw-box-border sw-body-sm sw-w-full sw-pb-1 sw-pt-6 sw-truncate" + className="sw-flex sw-box-border sw-body-sm sw-w-full sw-pb-2 sw-pt-4 sw-truncate" > {displayProject && ( <span title={projectName}> diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index 2e578793b04..ef342cf6da9 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -18,8 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import classNames from 'classnames'; -import { ButtonSecondary, Checkbox, FlagMessage, ToggleButton } from 'design-system'; +import styled from '@emotion/styled'; +import { + ButtonSecondary, + Checkbox, + FlagMessage, + LargeCenteredLayout, + PageContentFontWrapper, + ToggleButton, + themeBorder, + themeColor, +} from 'design-system'; import { debounce, keyBy, omit, without } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; @@ -37,7 +46,7 @@ import ListFooter from '../../../components/controls/ListFooter'; import Suggestions from '../../../components/embed-docs-modal/Suggestions'; import withIndexationGuard from '../../../components/hoc/withIndexationGuard'; import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; -import RuleTabViewer from '../../../components/rules/RuleTabViewer'; +import IssueTabViewer from '../../../components/rules/IssueTabViewer'; import '../../../components/search-navigator.css'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import { @@ -963,11 +972,10 @@ export class App extends React.PureComponent<Props, State> { <div className={ 'it__layout-page-filters sw-bg-white sw-box-border sw-h-full sw-overflow-y-auto ' + - 'sw-pt-6 sw-pl-3 sw-pr-4 sw-w-[300px] lg:sw-w-[390px]' + 'sw-py-6 sw-pl-3 sw-pr-4 sw-w-[300px] lg:sw-w-[390px]' } - style={{ borderLeft: '1px solid #dddddd', borderTop: '1px solid #dddddd' }} > - {warning} + <div className="sw-pb-6">{warning}</div> {currentUser.isLoggedIn && ( <div className="sw-flex sw-justify-start sw-mb-8"> @@ -1024,7 +1032,7 @@ export class App extends React.PureComponent<Props, State> { const warning = !canBrowseAllChildProjects && isPortfolioLike(qualifier) && ( <FlagMessage ariaLabel={translate('issues.not_all_issue_show')} - className="it__portfolio_warning sw-flex sw-my-4" + className="it__portfolio_warning sw-flex" title={translate('issues.not_all_issue_show_why')} variant="warning" > @@ -1033,14 +1041,14 @@ export class App extends React.PureComponent<Props, State> { ); return ( - <ScreenPositionHelper className="layout-page-side-outer"> + <ScreenPositionHelper className="sw-z-filterbar"> {({ top }) => ( <nav aria-label={openIssue ? translate('list_of_issues') : translate('filters')} - className="layout-page-side" - style={{ top }} + className="it__issues-nav-bar sw-overflow-scroll" + style={{ height: `calc((100vh - ${top}px) - 60px)` }} // 60px (footer) > - <div className="sw-flex sw-h-full sw-justify-end"> + <SideBarStyle className="sw-w-[300px] lg:sw-w-[390px]"> <A11ySkipTarget anchor="issues_sidebar" label={ @@ -1051,9 +1059,7 @@ export class App extends React.PureComponent<Props, State> { {openIssue ? ( <div> - <div className={classNames('not-all-issue-warning', 'open-issue-list')}> - {warning} - </div> + {warning && <div className="sw-py-4">{warning}</div>} <SubnavigationIssuesList fetchMoreIssues={this.fetchMoreIssues} @@ -1072,7 +1078,7 @@ export class App extends React.PureComponent<Props, State> { ) : ( this.renderFacets(warning) )} - </div> + </SideBarStyle> </nav> )} </ScreenPositionHelper> @@ -1150,11 +1156,10 @@ export class App extends React.PureComponent<Props, State> { return openIssue ? ( <A11ySkipTarget anchor="issues_main" /> ) : ( - <div className="layout-page-header-panel layout-page-main-header issues-main-header"> - <div className="layout-page-header-panel-inner layout-page-main-header-inner"> - <div className="layout-page-main-inner"> - <A11ySkipTarget anchor="issues_main" /> - + <> + <A11ySkipTarget anchor="issues_main" /> + <div className="sw-flex sw-mb-6 sw-gap-4"> + <div className="sw-flex sw-w-full sw-items-center sw-justify-between"> {this.renderBulkChange()} <PageActions @@ -1165,7 +1170,7 @@ export class App extends React.PureComponent<Props, State> { /> </div> </div> - </div> + </> ); } @@ -1181,121 +1186,131 @@ export class App extends React.PureComponent<Props, State> { loadingRule, } = this.state; return ( - <div className="layout-page-main-inner"> - <DeferredSpinner loading={loadingRule}> - {/* eslint-disable-next-line local-rules/no-conditional-rendering-of-deferredspinner */} - {openIssue && openRuleDetails ? ( - <> - <IssueHeader - issue={openIssue} - ruleDetails={openRuleDetails} - branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)} - onIssueChange={this.handleIssueChange} - /> - - <RuleTabViewer - ruleDetails={openRuleDetails} - extendedDescription={openRuleDetails.htmlNote} - ruleDescriptionContextKey={openIssue.ruleDescriptionContextKey} - issue={openIssue} - selectedFlowIndex={this.state.selectedFlowIndex} - selectedLocationIndex={this.state.selectedLocationIndex} - codeTabContent={ - <IssuesSourceViewer + <ScreenPositionHelper> + {({ top }) => ( + <div + className="it__layout-page-main-inner sw-overflow-scroll" + style={{ height: `calc((100vh - ${top}px) - 120px)` }} // 120px = 60px (footer) + 60px (bulk change bar) + > + <DeferredSpinner loading={loadingRule}> + {/* eslint-disable-next-line local-rules/no-conditional-rendering-of-deferredspinner */} + {openIssue && openRuleDetails ? ( + <> + <IssueHeader + issue={openIssue} + ruleDetails={openRuleDetails} branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)} - issues={issues} - locationsNavigator={this.state.locationsNavigator} - onIssueSelect={this.openIssue} - onLocationSelect={this.selectLocation} - openIssue={openIssue} - selectedFlowIndex={this.state.selectedFlowIndex} - selectedLocationIndex={this.state.selectedLocationIndex} + onIssueChange={this.handleIssueChange} /> - } - scrollInTab - activityTabContent={ - <IssueReviewHistoryAndComments + + <IssueTabViewer + ruleDetails={openRuleDetails} + extendedDescription={openRuleDetails.htmlNote} + ruleDescriptionContextKey={openIssue.ruleDescriptionContextKey} issue={openIssue} - onChange={this.handleIssueChange} - /> - } - /> - </> - ) : ( - <DeferredSpinner loading={loading} ariaLabel={translate('issues.loading_issues')}> - {checkAll && paging && paging.total > MAX_PAGE_SIZE && ( - <FlagMessage - ariaLabel={translate('issue_bulk_change.max_issues_reached')} - className="sw-mb-4" - variant="warning" - > - <FormattedMessage - defaultMessage={translate('issue_bulk_change.max_issues_reached')} - id="issue_bulk_change.max_issues_reached" - values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }} + selectedFlowIndex={this.state.selectedFlowIndex} + selectedLocationIndex={this.state.selectedLocationIndex} + codeTabContent={ + <IssuesSourceViewer + branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)} + issues={issues} + locationsNavigator={this.state.locationsNavigator} + onIssueSelect={this.openIssue} + onLocationSelect={this.selectLocation} + openIssue={openIssue} + selectedFlowIndex={this.state.selectedFlowIndex} + selectedLocationIndex={this.state.selectedLocationIndex} + /> + } + scrollInTab + activityTabContent={ + <IssueReviewHistoryAndComments + issue={openIssue} + onChange={this.handleIssueChange} + /> + } /> - </FlagMessage> - )} - - {cannotShowOpenIssue && (!paging || paging.total > 0) && ( - <FlagMessage - ariaLabel={translateWithParameters( - 'issues.cannot_open_issue_max_initial_X_fetched', - MAX_INITAL_FETCH + </> + ) : ( + <DeferredSpinner loading={loading} ariaLabel={translate('issues.loading_issues')}> + {checkAll && paging && paging.total > MAX_PAGE_SIZE && ( + <FlagMessage + ariaLabel={translate('issue_bulk_change.max_issues_reached')} + variant="warning" + > + <span> + <FormattedMessage + defaultMessage={translate('issue_bulk_change.max_issues_reached')} + id="issue_bulk_change.max_issues_reached" + values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }} + /> + </span> + </FlagMessage> )} - className="sw-mb-4" - variant="warning" - > - {translateWithParameters( - 'issues.cannot_open_issue_max_initial_X_fetched', - MAX_INITAL_FETCH + + {cannotShowOpenIssue && (!paging || paging.total > 0) && ( + <FlagMessage + ariaLabel={translateWithParameters( + 'issues.cannot_open_issue_max_initial_X_fetched', + MAX_INITAL_FETCH + )} + className="sw-mb-4" + variant="warning" + > + {translateWithParameters( + 'issues.cannot_open_issue_max_initial_X_fetched', + MAX_INITAL_FETCH + )} + </FlagMessage> )} - </FlagMessage> - )} - {this.renderList()} + {this.renderList()} + </DeferredSpinner> + )} </DeferredSpinner> - )} - </DeferredSpinner> - </div> + </div> + )} + </ScreenPositionHelper> ); } render() { - const { component } = this.props; const { openIssue, paging } = this.state; const selectedIndex = this.getSelectedIndex(); return ( - <div - className={classNames('layout-page issues', { 'project-level': component !== undefined })} - id="issues-page" - > - <Suggestions suggestions="issues" /> - - {openIssue ? ( - <Helmet - defer={false} - title={openIssue.message} - titleTemplate={translateWithParameters( - 'page_title.template.with_category', - translate('issues.page') - )} - /> - ) : ( - <Helmet defer={false} title={translate('issues.page')} /> - )} + <PageWrapperStyle id="issues-page"> + <LargeCenteredLayout> + <PageContentFontWrapper className="sw-body-sm"> + <div className="sw-w-full sw-flex" id="issues-page"> + <Suggestions suggestions="issues" /> - <h1 className="a11y-hidden">{translate('issues.page')}</h1> + {openIssue ? ( + <Helmet + defer={false} + title={openIssue.message} + titleTemplate={translateWithParameters( + 'page_title.template.with_category', + translate('issues.page') + )} + /> + ) : ( + <Helmet defer={false} title={translate('issues.page')} /> + )} - {this.renderSide(openIssue)} + <h1 className="a11y-hidden">{translate('issues.page')}</h1> - <div role="main" className={classNames('layout-page-main', { 'open-issue': !!openIssue })}> - {this.renderHeader({ openIssue, paging, selectedIndex })} + {this.renderSide(openIssue)} - {this.renderPage()} - </div> - </div> + <MainContentStyle role="main" className="sw-relative sw-ml-2 sw-p-6 sw-flex-1"> + {this.renderHeader({ openIssue, paging, selectedIndex })} + + {this.renderPage()} + </MainContentStyle> + </div> + </PageContentFontWrapper> + </LargeCenteredLayout> + </PageWrapperStyle> ); } } @@ -1304,3 +1319,19 @@ export default withIndexationGuard( withRouter(withCurrentUserContext(withBranchStatusActions(withComponentContext(App)))), PageContext.Issues ); + +const PageWrapperStyle = styled.div` + background-color: ${themeColor('backgroundPrimary')}; +`; + +const MainContentStyle = styled.div` + background-color: ${themeColor('subnavigation')}; + border-left: ${themeBorder('default', 'filterbarBorder')}; + border-right: ${themeBorder('default', 'filterbarBorder')}; +`; + +const SideBarStyle = styled.div` + border-left: ${themeBorder('default', 'filterbarBorder')}; + border-right: ${themeBorder('default', 'filterbarBorder')}; + background-color: ${themeColor('backgroundSecondary')}; +`; diff --git a/server/sonar-web/src/main/js/apps/issues/components/PageActions.tsx b/server/sonar-web/src/main/js/apps/issues/components/PageActions.tsx index e37cdc3444c..e5ffb197962 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/PageActions.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/PageActions.tsx @@ -36,7 +36,7 @@ export default function PageActions(props: PageActionsProps) { const { canSetHome, effortTotal, paging, selectedIndex } = props; return ( - <div className="sw-body-sm sw-flex sw-gap-6 sw-justify-end"> + <div className="sw-body-sm sw-flex sw-items-center sw-gap-6 sw-justify-end"> <KeyboardHint title={translate('issues.to_select_issues')} command="ArrowUp ArrowDown" /> <KeyboardHint title={translate('issues.to_navigate')} command="ArrowLeft ArrowRight" /> diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx index 799ec264bc2..413b72c5689 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx @@ -19,13 +19,12 @@ */ import styled from '@emotion/styled'; import classNames from 'classnames'; -import { FlagMessage, LineFinding, themeColor } from 'design-system'; +import { FlagMessage, IssueMessageHighlighting, LineFinding, themeColor } from 'design-system'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; import { getSources } from '../../../api/components'; import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus'; import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing'; -import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting'; import { getBranchLikeQuery } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; import { BranchLike } from '../../../types/branch-like'; diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx index 88cee15e0b7..fa12fafb98e 100644 --- a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx +++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx @@ -71,7 +71,9 @@ export default function IssueLocation(props: Props) { text={locationType.toUpperCase()} /> )} - <span>{normalizedMessage ?? translate('issue.unnamed_location')}</span> + <StyledLocationName> + {normalizedMessage ?? translate('issue.unnamed_location')} + </StyledLocationName> </span> </StyledLocation> </StyledLink> @@ -94,6 +96,10 @@ const StyledLink = styled(BaseLink)` border: none; `; +const StyledLocationName = styled.span` + word-break: break-word; +`; + function getLocationType(message?: string) { if (message?.toLowerCase().startsWith('source')) { return 'source'; diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx index e4ed91a30d3..7571092038c 100644 --- a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx +++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx @@ -44,7 +44,7 @@ export default function SubnavigationIssue(props: ConciseIssueProps) { React.useEffect(() => { if (selected && element.current) { - const parent = document.querySelector('.layout-page-side') as HTMLMenuElement; + const parent = document.querySelector('nav.it__issues-nav-bar') as HTMLMenuElement; const rect = parent.getBoundingClientRect(); const offset = element.current.offsetTop - rect.height / HALF_DIVIDER + rect.top / HALF_DIVIDER; @@ -92,6 +92,7 @@ const IssueInfo = styled.div` `; const StyledIssueTitle = styled(BareButton)` + word-break: break-word; &:focus { background-color: ${themeColor('subnavigationSelected')}; } diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx index 721d3ce4835..52c90cc0b23 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx @@ -367,7 +367,7 @@ export class ListStyleFacet<S> extends React.Component<Props<S>, State<S>> { const { searching, searchMaxResults, searchResults, searchPaging } = this.state; if (!searching && !searchResults?.length) { - return <div className="note spacer-bottom">{translate('no_results')}</div>; + return <div className="sw-mb-2">{translate('no_results')}</div>; } if (!searchResults) { diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx index 159b29209ed..1e469313e33 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx @@ -19,7 +19,7 @@ */ import { useTheme } from '@emotion/react'; -import { BaseLink, Theme, themeColor } from 'design-system'; +import { DiscreetLink, Theme, themeColor } from 'design-system'; import * as React from 'react'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { formatMeasure } from '../../../helpers/measures'; @@ -49,13 +49,13 @@ export function ListStyleFacetFooter({ return ( <div - className="sw-body-xs sw-mb-2 sw-mt-2 sw-text-center" + className="sw-mb-2 sw-mt-2 sw-text-center" style={{ color: themeColor('graphCursorLineColor')({ theme }) }} > {translateWithParameters('x_show', formatMeasure(nbShown, MetricType.Integer))} {hasMore && ( - <BaseLink + <DiscreetLink aria-label={showMoreAriaLabel} className="sw-ml-2" onClick={(e) => { @@ -65,11 +65,11 @@ export function ListStyleFacetFooter({ to="#" > {translate('show_more')} - </BaseLink> + </DiscreetLink> )} {showLess && allShown && ( - <BaseLink + <DiscreetLink aria-label={showLessAriaLabel} className="sw-ml-2" onClick={(e) => { @@ -79,7 +79,7 @@ export function ListStyleFacetFooter({ to="#" > {translate('show_less')} - </BaseLink> + </DiscreetLink> )} </div> ); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap index 7fb1d3d6ccb..1e996ba29e7 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap @@ -412,7 +412,7 @@ exports[`should search 4`] = ` value="blabla" /> <div - className="note spacer-bottom" + className="sw-mb-2" > no_results </div> @@ -451,7 +451,7 @@ exports[`should search 5`] = ` value="blabla" /> <div - className="note spacer-bottom" + className="sw-mb-2" > no_results </div> diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx b/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx index 96713af892a..ca7250d3af1 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx +++ b/server/sonar-web/src/main/js/components/issue/components/IssueView.tsx @@ -162,8 +162,6 @@ const IssueItem = styled.li` &.selected { border: ${themeBorder('default', 'tableRowSelected')}; - &:last-child { - } } &:hover { diff --git a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx new file mode 100644 index 00000000000..952979cba94 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx @@ -0,0 +1,378 @@ +/* + * 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 classNames from 'classnames'; +import { ToggleButton } from 'design-system'; +import { cloneDeep, debounce, groupBy } from 'lodash'; +import * as React from 'react'; +import { Location } from 'react-router-dom'; +import { dismissNotice } from '../../api/users'; +import { CurrentUserContextInterface } from '../../app/components/current-user/CurrentUserContext'; +import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; +import { RuleDescriptionSections } from '../../apps/coding-rules/rule'; +import { translate } from '../../helpers/l10n'; +import { Issue, RuleDetails } from '../../types/types'; +import { NoticeType } from '../../types/users'; +import ScreenPositionHelper from '../common/ScreenPositionHelper'; +import withLocation from '../hoc/withLocation'; +import MoreInfoRuleDescription from './MoreInfoRuleDescription'; +import RuleDescription from './RuleDescription'; + +import './style.css'; + +interface IssueTabViewerProps extends CurrentUserContextInterface { + ruleDetails: RuleDetails; + extendedDescription?: string; + ruleDescriptionContextKey?: string; + codeTabContent?: React.ReactNode; + activityTabContent?: React.ReactNode; + scrollInTab?: boolean; + location: Location; + selectedFlowIndex?: number; + selectedLocationIndex?: number; + issue?: Issue; +} + +interface State { + tabs: Tab[]; + selectedTab?: Tab; + displayEducationalPrinciplesNotification?: boolean; + educationalPrinciplesNotificationHasBeenDismissed?: boolean; +} + +export interface Tab { + value: TabKeys; + key: TabKeys; + label: string; + content: React.ReactNode; + counter?: number; +} + +export enum TabKeys { + Code = 'code', + WhyIsThisAnIssue = 'why', + HowToFixIt = 'how_to_fix', + AssessTheIssue = 'assess_the_problem', + Activity = 'activity', + MoreInfo = 'more_info', +} + +const DEBOUNCE_FOR_SCROLL = 250; + +export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, State> { + state: State = { + tabs: [], + }; + + educationPrinciplesRef: React.RefObject<HTMLDivElement>; + + constructor(props: IssueTabViewerProps) { + super(props); + this.educationPrinciplesRef = React.createRef(); + this.checkIfEducationPrinciplesAreVisible = debounce( + this.checkIfEducationPrinciplesAreVisible, + DEBOUNCE_FOR_SCROLL + ); + } + + componentDidMount() { + this.setState((prevState) => this.computeState(prevState)); + this.attachScrollEvent(); + + const tabs = this.computeTabs(Boolean(this.state.displayEducationalPrinciplesNotification)); + + const query = new URLSearchParams(this.props.location.search); + if (query.has('why')) { + this.setState({ + selectedTab: tabs.find((tab) => tab.key === TabKeys.WhyIsThisAnIssue) || tabs[0], + }); + } + } + + componentDidUpdate(prevProps: IssueTabViewerProps, prevState: State) { + const { + ruleDetails, + ruleDescriptionContextKey, + currentUser, + issue, + selectedFlowIndex, + selectedLocationIndex, + } = this.props; + const { selectedTab } = this.state; + + if ( + prevProps.ruleDetails.key !== ruleDetails.key || + prevProps.ruleDescriptionContextKey !== ruleDescriptionContextKey || + prevProps.issue !== issue || + prevProps.selectedFlowIndex !== selectedFlowIndex || + prevProps.selectedLocationIndex !== selectedLocationIndex || + prevProps.currentUser !== currentUser + ) { + this.setState((pState) => + this.computeState( + pState, + prevProps.ruleDetails !== ruleDetails || + (prevProps.issue && issue && prevProps.issue.key !== issue.key) || + prevProps.selectedFlowIndex !== selectedFlowIndex || + prevProps.selectedLocationIndex !== selectedLocationIndex + ) + ); + } + + if (selectedTab?.key === TabKeys.MoreInfo) { + this.checkIfEducationPrinciplesAreVisible(); + } + + if ( + prevState.selectedTab?.key === TabKeys.MoreInfo && + prevState.displayEducationalPrinciplesNotification && + prevState.educationalPrinciplesNotificationHasBeenDismissed + ) { + this.props.updateDismissedNotices(NoticeType.EDUCATION_PRINCIPLES, true); + } + } + + componentWillUnmount() { + this.detachScrollEvent(); + } + + computeState = (prevState: State, resetSelectedTab = false) => { + const { + ruleDetails, + currentUser: { isLoggedIn, dismissedNotices }, + } = this.props; + + const displayEducationalPrinciplesNotification = + !!ruleDetails.educationPrinciples && + ruleDetails.educationPrinciples.length > 0 && + isLoggedIn && + !dismissedNotices[NoticeType.EDUCATION_PRINCIPLES]; + const tabs = this.computeTabs(displayEducationalPrinciplesNotification); + + return { + tabs, + selectedTab: resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab, + displayEducationalPrinciplesNotification, + }; + }; + + computeTabs = (displayEducationalPrinciplesNotification: boolean) => { + const { + codeTabContent, + ruleDetails: { descriptionSections, educationPrinciples, type: ruleType }, + ruleDescriptionContextKey, + extendedDescription, + activityTabContent, + issue, + } = this.props; + + // As we might tamper with the description later on, we clone to avoid any side effect + const descriptionSectionsByKey = cloneDeep( + groupBy(descriptionSections, (section) => section.key) + ); + + if (extendedDescription) { + if (descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]?.length > 0) { + // We add the extended description (htmlNote) in the first context, in case there are contexts + // Extended description will get reworked in future + descriptionSectionsByKey[RuleDescriptionSections.RESOURCES][0].content += + '<br/>' + extendedDescription; + } else { + descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] = [ + { + key: RuleDescriptionSections.RESOURCES, + content: extendedDescription, + }, + ]; + } + } + + const tabs: Tab[] = [ + { + value: TabKeys.WhyIsThisAnIssue, + key: TabKeys.WhyIsThisAnIssue, + label: + ruleType === 'SECURITY_HOTSPOT' + ? translate('coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT') + : translate('coding_rules.description_section.title.root_cause'), + content: (descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || + descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]) && ( + <RuleDescription + sections={ + descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || + descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE] + } + defaultContextKey={ruleDescriptionContextKey} + /> + ), + }, + { + value: TabKeys.AssessTheIssue, + key: TabKeys.AssessTheIssue, + label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue), + content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( + <RuleDescription + sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]} + /> + ), + }, + { + value: TabKeys.HowToFixIt, + key: TabKeys.HowToFixIt, + label: translate('coding_rules.description_section.title', TabKeys.HowToFixIt), + content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( + <RuleDescription + sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} + defaultContextKey={ruleDescriptionContextKey} + /> + ), + }, + { + value: TabKeys.Activity, + key: TabKeys.Activity, + label: translate('coding_rules.description_section.title', TabKeys.Activity), + content: activityTabContent, + counter: issue?.comments?.length, + }, + { + value: TabKeys.MoreInfo, + key: TabKeys.MoreInfo, + label: translate('coding_rules.description_section.title', TabKeys.MoreInfo), + content: ((educationPrinciples && educationPrinciples.length > 0) || + descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && ( + <MoreInfoRuleDescription + educationPrinciples={educationPrinciples} + sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]} + displayEducationalPrinciplesNotification={displayEducationalPrinciplesNotification} + educationPrinciplesRef={this.educationPrinciplesRef} + /> + ), + counter: displayEducationalPrinciplesNotification ? 1 : undefined, + }, + ]; + + if (codeTabContent !== undefined) { + tabs.unshift({ + value: TabKeys.Code, + key: TabKeys.Code, + label: translate('issue.tabs', TabKeys.Code), + content: codeTabContent, + }); + } + + return tabs.filter((tab) => tab.content); + }; + + attachScrollEvent = () => { + document.addEventListener('scroll', this.checkIfEducationPrinciplesAreVisible, { + capture: true, + }); + }; + + detachScrollEvent = () => { + document.removeEventListener('scroll', this.checkIfEducationPrinciplesAreVisible, { + capture: true, + }); + }; + + checkIfEducationPrinciplesAreVisible = () => { + const { + displayEducationalPrinciplesNotification, + educationalPrinciplesNotificationHasBeenDismissed, + } = this.state; + + if (this.educationPrinciplesRef.current) { + const rect = this.educationPrinciplesRef.current.getBoundingClientRect(); + const isVisible = rect.top <= (window.innerHeight || document.documentElement.clientHeight); + + if ( + isVisible && + displayEducationalPrinciplesNotification && + !educationalPrinciplesNotificationHasBeenDismissed + ) { + dismissNotice(NoticeType.EDUCATION_PRINCIPLES) + .then(() => { + this.detachScrollEvent(); + this.setState({ educationalPrinciplesNotificationHasBeenDismissed: true }); + }) + .catch(() => { + /* noop */ + }); + } + } + }; + + handleSelectTabs = (currentTabKey: TabKeys) => { + this.setState(({ tabs }) => ({ + selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0], + })); + }; + + render() { + const { scrollInTab } = this.props; + const { tabs, selectedTab } = this.state; + + if (!tabs || tabs.length === 0 || !selectedTab) { + return null; + } + + return ( + <> + <div className="sw-mb-4"> + <ToggleButton + role="tablist" + value={selectedTab.key} + options={tabs} + onChange={this.handleSelectTabs} + /> + </div> + <ScreenPositionHelper> + {({ top }) => ( + <div + style={{ + // We substract the footer height with padding (80) and the main layout padding (20) and the tabs padding (20) + maxHeight: scrollInTab ? `calc(100vh - ${top + 120}px)` : 'initial', + }} + className="sw-flex sw-flex-col" + role="tabpanel" + aria-labelledby={`tab-${selectedTab.key}`} + id={`tabpanel-${selectedTab.key}`} + > + { + // Preserve tabs state by always rendering all of them. Only hide them when not selected + tabs.map((tab) => ( + <div + className={classNames('sw-overflow-y-auto', { + hidden: tab.key !== selectedTab.key, + })} + key={tab.key} + > + {tab.content} + </div> + )) + } + </div> + )} + </ScreenPositionHelper> + </> + ); + } +} + +export default withCurrentUserContext(withLocation(IssueTabViewer)); |