@@ -24,9 +24,9 @@ import React from 'react'; | |||
import tw, { theme } from 'twin.macro'; | |||
import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; | |||
import { isDefined } from '../helpers/types'; | |||
import { ChevronDownIcon } from './icons/ChevronDownIcon'; | |||
import NavLink, { NavLinkProps } from './NavLink'; | |||
import Tooltip from './Tooltip'; | |||
import { ChevronDownIcon } from './icons/ChevronDownIcon'; | |||
interface Props extends React.HTMLAttributes<HTMLUListElement> { | |||
children?: React.ReactNode; | |||
@@ -126,7 +126,7 @@ const NavBarTabLinkWrapper = styled.li` | |||
&:has(a.disabled-link) > a:hover, | |||
&:has(a.disabled-link) > a.hover, | |||
&:has(a.disabled-link)[aria-expanded='true'] { | |||
${tw`sw-cursor-not-allowed`}; | |||
${tw`sw-cursor-default`}; | |||
border-bottom: ${themeBorder('xsActive', 'transparent', 1)}; | |||
color: ${themeContrast('subnavigationDisabled')}; | |||
} |
@@ -17,26 +17,16 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { FlagMessage, Link } from 'design-system'; | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import withIndexationContext, { | |||
WithIndexationContextProps, | |||
} from '../../../components/hoc/withIndexationContext'; | |||
import { Alert } from '../../../components/ui/Alert'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import { Component } from '../../../types/types'; | |||
interface Props extends WithIndexationContextProps { | |||
pageContext?: PageContext; | |||
component?: Pick<Component, 'qualifier' | 'name'>; | |||
} | |||
export enum PageContext { | |||
Issues = 'issues', | |||
Portfolios = 'portfolios', | |||
} | |||
export class PageUnavailableDueToIndexation extends React.PureComponent<Props> { | |||
export class PageUnavailableDueToIndexation extends React.PureComponent<WithIndexationContextProps> { | |||
componentDidUpdate() { | |||
if ( | |||
this.props.indexationContext.status.isCompleted && | |||
@@ -47,33 +37,28 @@ export class PageUnavailableDueToIndexation extends React.PureComponent<Props> { | |||
} | |||
render() { | |||
const { pageContext, component } = this.props; | |||
let messageKey = 'indexation.page_unavailable.title'; | |||
if (pageContext) { | |||
messageKey = `${messageKey}.${pageContext}`; | |||
} | |||
return ( | |||
<div className="page-wrapper-simple"> | |||
<div className="page-simple"> | |||
<h1 className="big-spacer-bottom"> | |||
<FormattedMessage | |||
id={messageKey} | |||
defaultMessage={translate(messageKey)} | |||
values={{ | |||
componentQualifier: translate('qualifier', component?.qualifier ?? ''), | |||
componentName: <em>{component?.name}</em>, | |||
}} | |||
/> | |||
</h1> | |||
<Alert variant="info"> | |||
<p>{translate('indexation.page_unavailable.description')}</p> | |||
<p className="spacer-top"> | |||
{translate('indexation.page_unavailable.description.additional_information')} | |||
</p> | |||
</Alert> | |||
</div> | |||
<FlagMessage className="sw-m-10" variant="info"> | |||
{translate('indexation.page_unavailable.description')} | |||
<br /> | |||
<FormattedMessage | |||
defaultMessage={translate( | |||
'indexation.page_unavailable.description.additional_information' | |||
)} | |||
id="indexation.page_unavailable.description.additional_information" | |||
values={{ | |||
link: ( | |||
<Link | |||
className="sw-ml-4" | |||
to="https://docs.sonarqube.org/latest/instance-administration/reindexing/" | |||
> | |||
{translate('learn_more')} | |||
</Link> | |||
), | |||
}} | |||
/> | |||
</FlagMessage> | |||
</div> | |||
); | |||
} |
@@ -17,13 +17,14 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { PageContext, PageUnavailableDueToIndexation } from '../PageUnavailableDueToIndexation'; | |||
import { PageUnavailableDueToIndexation } from '../PageUnavailableDueToIndexation'; | |||
it('should render correctly', () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
@@ -42,6 +43,7 @@ it('should not refresh the page once the indexation is complete if there were fa | |||
wrapper.setProps({ | |||
indexationContext: { status: { isCompleted: true, percentCompleted: 100, hasFailures: true } }, | |||
}); | |||
wrapper.update(); | |||
expect(reload).not.toHaveBeenCalled(); | |||
@@ -62,20 +64,18 @@ it('should refresh the page once the indexation is complete if there were NO fai | |||
wrapper.setProps({ | |||
indexationContext: { status: { isCompleted: true, percentCompleted: 100, hasFailures: false } }, | |||
}); | |||
wrapper.update(); | |||
expect(reload).toHaveBeenCalled(); | |||
}); | |||
function shallowRender(props?: PageUnavailableDueToIndexation['props']) { | |||
function shallowRender() { | |||
return shallow<PageUnavailableDueToIndexation>( | |||
<PageUnavailableDueToIndexation | |||
indexationContext={{ | |||
status: { isCompleted: false, percentCompleted: 23, hasFailures: false }, | |||
}} | |||
pageContext={PageContext.Issues} | |||
component={{ qualifier: ComponentQualifier.Portfolio, name: 'test-portfolio' }} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -4,37 +4,26 @@ exports[`should render correctly 1`] = ` | |||
<div | |||
className="page-wrapper-simple" | |||
> | |||
<div | |||
className="page-simple" | |||
<FlagMessage | |||
className="sw-m-10" | |||
variant="info" | |||
> | |||
<h1 | |||
className="big-spacer-bottom" | |||
> | |||
<FormattedMessage | |||
defaultMessage="indexation.page_unavailable.title.issues" | |||
id="indexation.page_unavailable.title.issues" | |||
values={ | |||
{ | |||
"componentName": <em> | |||
test-portfolio | |||
</em>, | |||
"componentQualifier": "qualifier.VW", | |||
} | |||
indexation.page_unavailable.description | |||
<br /> | |||
<FormattedMessage | |||
defaultMessage="indexation.page_unavailable.description.additional_information" | |||
id="indexation.page_unavailable.description.additional_information" | |||
values={ | |||
{ | |||
"link": <StandoutLink | |||
className="sw-ml-4" | |||
to="https://docs.sonarqube.org/latest/instance-administration/reindexing/" | |||
> | |||
learn_more | |||
</StandoutLink>, | |||
} | |||
/> | |||
</h1> | |||
<Alert | |||
variant="info" | |||
> | |||
<p> | |||
indexation.page_unavailable.description | |||
</p> | |||
<p | |||
className="spacer-top" | |||
> | |||
indexation.page_unavailable.description.additional_information | |||
</p> | |||
</Alert> | |||
</div> | |||
} | |||
/> | |||
</FlagMessage> | |||
</div> | |||
`; |
@@ -35,16 +35,18 @@ import { keyBy, omit, without } from 'lodash'; | |||
import * as React from 'react'; | |||
import { Helmet } from 'react-helmet-async'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { searchIssues } from '../../../api/issues'; | |||
import { listIssues, searchIssues } from '../../../api/issues'; | |||
import { getRuleDetails } from '../../../api/rules'; | |||
import withComponentContext from '../../../app/components/componentContext/withComponentContext'; | |||
import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; | |||
import { PageContext } from '../../../app/components/indexation/PageUnavailableDueToIndexation'; | |||
import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; | |||
import EmptySearch from '../../../components/common/EmptySearch'; | |||
import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; | |||
import ListFooter from '../../../components/controls/ListFooter'; | |||
import Suggestions from '../../../components/embed-docs-modal/Suggestions'; | |||
import withIndexationContext, { | |||
WithIndexationContextProps, | |||
} from '../../../components/hoc/withIndexationContext'; | |||
import withIndexationGuard from '../../../components/hoc/withIndexationGuard'; | |||
import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; | |||
import IssueTabViewer from '../../../components/rules/IssueTabViewer'; | |||
@@ -106,7 +108,7 @@ import NoMyIssues from './NoMyIssues'; | |||
import PageActions from './PageActions'; | |||
import StyledHeader, { PSEUDO_SHADOW_HEIGHT } from './StyledHeader'; | |||
interface Props { | |||
interface Props extends WithIndexationContextProps { | |||
branchLike?: BranchLike; | |||
component?: Component; | |||
currentUser: CurrentUser; | |||
@@ -147,6 +149,7 @@ export interface State { | |||
const DEFAULT_QUERY = { resolved: 'false' }; | |||
const MAX_INITAL_FETCH = 1000; | |||
const VARIANTS_FACET = 'codeVariants'; | |||
const ISSUES_PAGE_SIZE = 100; | |||
export class App extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
@@ -456,6 +459,19 @@ export class App extends React.PureComponent<Props, State> { | |||
createdAfterIncludesTime = () => Boolean(this.props.location.query.createdAfter?.includes('T')); | |||
fetchIssuesHelper = (query: RawQuery) => { | |||
if (this.props.component?.needIssueSync) { | |||
return listIssues({ | |||
...query, | |||
}).then((response) => { | |||
const { components, issues, rules } = response; | |||
const parsedIssues = issues.map((issue) => | |||
parseIssueFromResponse(issue, components, undefined, rules) | |||
); | |||
return { ...response, issues: parsedIssues } as FetchIssuesPromise; | |||
}); | |||
} | |||
return searchIssues({ | |||
...query, | |||
additionalFields: '_all', | |||
@@ -487,15 +503,23 @@ export class App extends React.PureComponent<Props, State> { | |||
facets = facets ? `${facets},${VARIANTS_FACET}` : VARIANTS_FACET; | |||
} | |||
const parameters: Dict<string | undefined> = { | |||
...getBranchLikeQuery(this.props.branchLike), | |||
componentKeys: component?.key, | |||
s: 'FILE_LINE', | |||
...serializeQuery(query), | |||
ps: '100', | |||
facets, | |||
...additional, | |||
}; | |||
const parameters: Dict<string | undefined> = component?.needIssueSync | |||
? { | |||
...getBranchLikeQuery(this.props.branchLike, true), | |||
project: component?.key, | |||
...serializeQuery(query), | |||
ps: `${ISSUES_PAGE_SIZE}`, | |||
...additional, | |||
} | |||
: { | |||
...getBranchLikeQuery(this.props.branchLike), | |||
componentKeys: component?.key, | |||
s: 'FILE_LINE', | |||
...serializeQuery(query), | |||
ps: `${ISSUES_PAGE_SIZE}`, | |||
facets, | |||
...additional, | |||
}; | |||
if (query.createdAfter !== undefined && this.createdAfterIncludesTime()) { | |||
parameters.createdAfter = serializeDate(query.createdAfter); | |||
@@ -535,50 +559,51 @@ export class App extends React.PureComponent<Props, State> { | |||
fetchPromise = this.fetchIssues({}, true, firstRequest); | |||
} | |||
return fetchPromise.then( | |||
({ effortTotal, facets, issues, paging, ...other }) => { | |||
if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) { | |||
const openIssue = getOpenIssue(this.props, issues); | |||
let selected: string | undefined = undefined; | |||
if (issues.length > 0) { | |||
selected = openIssue ? openIssue.key : issues[0].key; | |||
} | |||
this.setState(({ showVariantsFilter }) => ({ | |||
cannotShowOpenIssue: Boolean(openIssueKey && !openIssue), | |||
effortTotal, | |||
facets: parseFacets(facets), | |||
showVariantsFilter: firstRequest | |||
? Boolean(facets.find((f) => f.property === VARIANTS_FACET)?.values.length) | |||
: showVariantsFilter, | |||
loading: false, | |||
locationsNavigator: true, | |||
issues, | |||
openIssue, | |||
paging, | |||
referencedComponentsById: keyBy(other.components, 'uuid'), | |||
referencedComponentsByKey: keyBy(other.components, 'key'), | |||
referencedLanguages: keyBy(other.languages, 'key'), | |||
referencedRules: keyBy(other.rules, 'key'), | |||
referencedUsers: keyBy(other.users, 'login'), | |||
selected, | |||
selectedFlowIndex: 0, | |||
selectedLocationIndex: undefined, | |||
})); | |||
} | |||
return fetchPromise.then(this.parseFirstIssues(firstRequest, openIssueKey, prevQuery), () => { | |||
if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) { | |||
this.setState({ loading: false }); | |||
} | |||
return issues; | |||
}, | |||
() => { | |||
if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) { | |||
this.setState({ loading: false }); | |||
return []; | |||
}); | |||
} | |||
parseFirstIssues = | |||
(firstRequest: boolean, openIssueKey: string | undefined, prevQuery: RawQuery) => | |||
({ effortTotal, facets, issues, paging, ...other }: FetchIssuesPromise) => { | |||
if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) { | |||
const openIssue = getOpenIssue(this.props, issues); | |||
let selected: string | undefined = undefined; | |||
if (issues.length > 0) { | |||
selected = openIssue ? openIssue.key : issues[0].key; | |||
} | |||
return []; | |||
this.setState(({ showVariantsFilter }) => ({ | |||
cannotShowOpenIssue: Boolean(openIssueKey && !openIssue), | |||
effortTotal, | |||
facets: parseFacets(facets), | |||
showVariantsFilter: firstRequest | |||
? Boolean(facets?.find((f) => f.property === VARIANTS_FACET)?.values.length) | |||
: showVariantsFilter, | |||
loading: false, | |||
locationsNavigator: true, | |||
issues, | |||
openIssue, | |||
paging, | |||
referencedComponentsById: keyBy(other.components, 'uuid'), | |||
referencedComponentsByKey: keyBy(other.components, 'key'), | |||
referencedLanguages: keyBy(other.languages, 'key'), | |||
referencedRules: keyBy(other.rules, 'key'), | |||
referencedUsers: keyBy(other.users, 'login'), | |||
selected, | |||
selectedFlowIndex: 0, | |||
selectedLocationIndex: undefined, | |||
})); | |||
} | |||
); | |||
} | |||
return issues; | |||
}; | |||
fetchIssuesPage = (p: number) => { | |||
return this.fetchIssues({ p }); | |||
@@ -966,7 +991,7 @@ export class App extends React.PureComponent<Props, State> { | |||
> | |||
{warning && <div className="sw-pb-6">{warning}</div>} | |||
{currentUser.isLoggedIn && ( | |||
{currentUser.isLoggedIn && !component?.needIssueSync && ( | |||
<div className="sw-flex sw-justify-start sw-mb-8"> | |||
<ToggleButton | |||
onChange={this.handleMyIssuesChange} | |||
@@ -1089,7 +1114,7 @@ export class App extends React.PureComponent<Props, State> { | |||
let noIssuesMessage = null; | |||
if (paging.total === 0 && !loading) { | |||
if (issues.length === 0 && !loading) { | |||
if (this.isFiltered()) { | |||
noIssuesMessage = <EmptySearch />; | |||
} else if (this.state.myIssues) { | |||
@@ -1103,7 +1128,7 @@ export class App extends React.PureComponent<Props, State> { | |||
<div> | |||
<h2 className="a11y-hidden">{translate('list_of_issues')}</h2> | |||
{paging.total > 0 && ( | |||
{issues.length > 0 && ( | |||
<IssuesList | |||
branchLike={branchLike} | |||
checked={this.state.checked} | |||
@@ -1120,13 +1145,14 @@ export class App extends React.PureComponent<Props, State> { | |||
/> | |||
)} | |||
{paging.total > 0 && ( | |||
{issues.length > 0 && ( | |||
<ListFooter | |||
count={issues.length} | |||
loadMore={() => { | |||
this.fetchMoreIssues().catch(() => undefined); | |||
}} | |||
loading={loadingMore} | |||
pageSize={ISSUES_PAGE_SIZE} | |||
total={paging.total} | |||
useMIUIButtons | |||
/> | |||
@@ -1158,7 +1184,7 @@ export class App extends React.PureComponent<Props, State> { | |||
<PageActions | |||
canSetHome={!this.props.component} | |||
effortTotal={this.state.effortTotal} | |||
paging={paging} | |||
paging={this.props.component?.needIssueSync ? undefined : paging} | |||
selectedIndex={selectedIndex} | |||
/> | |||
</div> | |||
@@ -1302,9 +1328,22 @@ export class App extends React.PureComponent<Props, State> { | |||
} | |||
} | |||
export default withIndexationGuard( | |||
withRouter(withComponentContext(withCurrentUserContext(withBranchLikes(App)))), | |||
PageContext.Issues | |||
export default withRouter( | |||
withComponentContext( | |||
withCurrentUserContext( | |||
withBranchLikes( | |||
withIndexationContext( | |||
withIndexationGuard<Props & WithIndexationContextProps>({ | |||
Component: App, | |||
showIndexationMessage: ({ component, indexationContext }) => | |||
(!component && indexationContext.status.isCompleted === false) || | |||
(component?.qualifier !== ComponentQualifier.Project && | |||
component?.needIssueSync === true), | |||
}) | |||
) | |||
) | |||
) | |||
) | |||
); | |||
const PageWrapperStyle = styled.div` |
@@ -20,7 +20,6 @@ | |||
import { mount } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { IndexationContext } from '../../../app/components/indexation/IndexationContext'; | |||
import { PageContext } from '../../../app/components/indexation/PageUnavailableDueToIndexation'; | |||
import { IndexationContextInterface } from '../../../types/indexation'; | |||
import withIndexationGuard from '../withIndexationGuard'; | |||
@@ -62,4 +61,4 @@ class TestComponent extends React.PureComponent { | |||
} | |||
} | |||
const TestComponentWithGuard = withIndexationGuard(TestComponent, PageContext.Issues); | |||
const TestComponentWithGuard = withIndexationGuard(TestComponent); |
@@ -17,29 +17,23 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { IndexationContext } from '../../app/components/indexation/IndexationContext'; | |||
import PageUnavailableDueToIndexation, { | |||
PageContext, | |||
} from '../../app/components/indexation/PageUnavailableDueToIndexation'; | |||
import PageUnavailableDueToIndexation from '../../app/components/indexation/PageUnavailableDueToIndexation'; | |||
export default function withIndexationGuard<P>( | |||
WrappedComponent: React.ComponentType<P>, | |||
pageContext: PageContext | |||
) { | |||
return class WithIndexationGuard extends React.PureComponent<P> { | |||
render() { | |||
return ( | |||
<IndexationContext.Consumer> | |||
{(context) => | |||
context?.status.isCompleted && !context?.status.hasFailures ? ( | |||
<WrappedComponent {...this.props} /> | |||
) : ( | |||
<PageUnavailableDueToIndexation pageContext={pageContext} /> | |||
) | |||
} | |||
</IndexationContext.Consumer> | |||
); | |||
} | |||
export default function withIndexationGuard<P>(WrappedComponent: React.ComponentType<P>) { | |||
return function WithIndexationGuard(props: React.PropsWithChildren<P>) { | |||
return ( | |||
<IndexationContext.Consumer> | |||
{(context) => | |||
context?.status.isCompleted && !context?.status.hasFailures ? ( | |||
<WrappedComponent {...props} /> | |||
) : ( | |||
<PageUnavailableDueToIndexation /> | |||
) | |||
} | |||
</IndexationContext.Consumer> | |||
); | |||
}; | |||
} |
@@ -544,7 +544,6 @@ layout.security_reports=Security Reports | |||
layout.nav.home_logo_alt=Logo, link to homepage | |||
layout.must_be_configured=This will be available once your project is configured and analyzed. | |||
layout.all_project_must_be_accessible=You need access to all projects within this {0} to access it. | |||
layout.component_must_be_reindexed=This will be available once the reindexing has completed. | |||
sidebar.projects=Projects | |||
sidebar.project_settings=Configuration | |||
@@ -4734,8 +4733,8 @@ indexation.admin_link=See {link} for more information. | |||
indexation.page_unavailable.title.issues=Issues page is temporarily unavailable | |||
indexation.page_unavailable.title.portfolios=Portfolios page is temporarily unavailable | |||
indexation.page_unavailable.title={componentQualifier} {componentName} is temporarily unavailable | |||
indexation.page_unavailable.description=This page will be available after the data is reloaded. This might take a while depending on the amount of projects and issues in your SonarQube instance. | |||
indexation.page_unavailable.description.additional_information=You can keep analyzing your projects during this process. | |||
indexation.page_unavailable.description=SonarQube is reindexing project data. | |||
indexation.page_unavailable.description.additional_information=This page is unavailable until this process is complete. {link} | |||
#------------------------------------------------------------------------------ |