]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19345 Layout changes for new MIUI issue pages
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Thu, 8 Jun 2023 08:42:35 +0000 (10:42 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 9 Jun 2023 20:03:09 +0000 (20:03 +0000)
14 files changed:
server/sonar-web/design-system/src/components/FacetItem.tsx
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/components/PageActions.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
server/sonar-web/src/main/js/apps/issues/issues-subnavigation/IssueLocation.tsx
server/sonar-web/src/main/js/apps/issues/issues-subnavigation/SubnavigationIssue.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
server/sonar-web/src/main/js/components/issue/components/IssueView.tsx
server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx [new file with mode: 0644]

index 62d5e38b150d24fa525a7e8386b394fa8b7d489a..9fbeff5e730aa0a34ca344a70f45bfd77eec103a 100644 (file)
@@ -113,6 +113,7 @@ const StyledButton = styled(ButtonSecondary)<{ active?: boolean; small?: boolean
 
       & mark {
         background-color: ${themeColor('searchHighlight')};
+        font-weight: 400;
       }
     }
 
index 85fe17a7f6f85e8462301c113451b7beccd35c90..f0ca8055c1f2e2a7ca0b863843d2a71088be8174 100644 (file)
@@ -42,6 +42,7 @@ const TEMP_PAGELIST_WITH_NEW_BACKGROUND = [
   '/dashboard',
   '/security_hotspots',
   '/component_measures',
+  '/project/issues',
 ];
 
 export default function GlobalContainer() {
index dc72fc139bbff3f487b021bbace9e13374a79e80..f2cf204eb5e24b43cb795208d480247b07ffc3b7 100644 (file)
@@ -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 () => {
index de50f0504631735aaa8a76d1863df34e8aa070fd..c6beaa5f4d14dfaecfe44de06b9f45f404cd5631 100644 (file)
@@ -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}>
index 2e578793b04361bf014c80d40017c93453c2d35e..ef342cf6da9675a4ee03a66e3c057b91f1eeb0c9 100644 (file)
  * 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')};
+`;
index e37cdc3444c93dc5f80234c46ddae85a6d7a282b..e5ffb1979623f70abbbd90d7e250176542e13ade 100644 (file)
@@ -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" />
 
index 799ec264bc2d02ffaa26330633fe560ac102eed1..413b72c56893d268b9819ab9c32605e63643f39f 100644 (file)
  */
 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';
index 88cee15e0b7d31a5e322b59e51a757c9efd01042..fa12fafb98ecbf530f7c33e776ebf5a3dafd59f8 100644 (file)
@@ -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';
index e4ed91a30d3618b728d6a32581a5ca2724c85586..7571092038cabde2bab384c1a469bed7773733a4 100644 (file)
@@ -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')};
   }
index 721d3ce48357b906dac1483561e4a09eeed4800d..52c90cc0b23f158f7f04560c88f21719c1ebf5ea 100644 (file)
@@ -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) {
index 159b29209ed41cc4a6b634a1c35d0565ef428da5..1e469313e339600a11a8a891e89daa73b534781c 100644 (file)
@@ -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>
   );
index 7fb1d3d6ccb49fb635972e3f32191fde608f0fd8..1e996ba29e7f66a1caf4950b635183dd93272516 100644 (file)
@@ -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>
index 96713af892a1f1cb8d095f6c4c788ac84526ef01..ca7250d3af17f3e520f48b1b61f9ee42ce476842 100644 (file)
@@ -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 (file)
index 0000000..952979c
--- /dev/null
@@ -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));