@@ -20,11 +20,13 @@ | |||
import { differenceBy } from 'lodash'; | |||
import * as React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import { getProjectAlmBinding } from '../../api/alm-settings'; | |||
import { HttpStatus } from 'sonar-ui-common/helpers/request'; | |||
import { getProjectAlmBinding, validateProjectAlmBinding } from '../../api/alm-settings'; | |||
import { getBranches, getPullRequests } from '../../api/branches'; | |||
import { getAnalysisStatus, getTasksForComponent } from '../../api/ce'; | |||
import { getComponentData } from '../../api/components'; | |||
import { getComponentNavigation } from '../../api/nav'; | |||
import { withAppState } from '../../components/hoc/withAppState'; | |||
import { Location, Router, withRouter } from '../../components/hoc/withRouter'; | |||
import { | |||
getBranchLikeQuery, | |||
@@ -34,9 +36,12 @@ import { | |||
} from '../../helpers/branch-like'; | |||
import { getPortfolioUrl } from '../../helpers/urls'; | |||
import { registerBranchStatus, requireAuthorization } from '../../store/rootActions'; | |||
import { ProjectAlmBindingResponse } from '../../types/alm-settings'; | |||
import { | |||
ProjectAlmBindingConfigurationErrors, | |||
ProjectAlmBindingResponse | |||
} from '../../types/alm-settings'; | |||
import { BranchLike } from '../../types/branch-like'; | |||
import { isPortfolioLike } from '../../types/component'; | |||
import { ComponentQualifier, isPortfolioLike } from '../../types/component'; | |||
import { Task, TaskStatuses, TaskWarning } from '../../types/tasks'; | |||
import ComponentContainerNotFound from './ComponentContainerNotFound'; | |||
import { ComponentContext } from './ComponentContext'; | |||
@@ -44,6 +49,7 @@ import PageUnavailableDueToIndexation from './indexation/PageUnavailableDueToInd | |||
import ComponentNav from './nav/component/ComponentNav'; | |||
interface Props { | |||
appState: Pick<T.AppState, 'branchesEnabled'>; | |||
children: React.ReactElement; | |||
location: Pick<Location, 'query' | 'pathname'>; | |||
registerBranchStatus: (branchLike: BranchLike, component: string, status: T.Status) => void; | |||
@@ -59,6 +65,7 @@ interface State { | |||
isPending: boolean; | |||
loading: boolean; | |||
projectBinding?: ProjectAlmBindingResponse; | |||
projectBindingErrors?: ProjectAlmBindingConfigurationErrors; | |||
tasksInProgress?: Task[]; | |||
warnings: TaskWarning[]; | |||
} | |||
@@ -90,96 +97,85 @@ export class ComponentContainer extends React.PureComponent<Props, State> { | |||
window.clearTimeout(this.watchStatusTimer); | |||
} | |||
addQualifier = (component: T.Component) => ({ | |||
...component, | |||
qualifier: component.breadcrumbs[component.breadcrumbs.length - 1].qualifier | |||
}); | |||
fetchComponent() { | |||
fetchComponent = async () => { | |||
const { branch, id: key, pullRequest } = this.props.location.query; | |||
this.setState({ loading: true }); | |||
const onError = (response?: Response) => { | |||
let componentWithQualifier; | |||
try { | |||
const [nav, { component }] = await Promise.all([ | |||
getComponentNavigation({ component: key, branch, pullRequest }), | |||
getComponentData({ component: key, branch, pullRequest }) | |||
]); | |||
componentWithQualifier = this.addQualifier({ ...nav, ...component }); | |||
} catch (e) { | |||
if (this.mounted) { | |||
if (response && response.status === 403) { | |||
if (e && e.status === HttpStatus.Forbidden) { | |||
this.props.requireAuthorization(this.props.router); | |||
} else { | |||
this.setState({ component: undefined, loading: false }); | |||
} | |||
} | |||
}; | |||
Promise.all([ | |||
getComponentNavigation({ component: key, branch, pullRequest }), | |||
getComponentData({ component: key, branch, pullRequest }), | |||
getProjectAlmBinding(key).catch(() => undefined) | |||
]) | |||
.then(([nav, { component }, projectBinding]) => { | |||
const componentWithQualifier = this.addQualifier({ ...nav, ...component }); | |||
/* | |||
* There used to be a redirect from /dashboard to /portfolio which caused issues. | |||
* Links should be fixed to not rely on this redirect, but: | |||
* This is a fail-safe in case there are still some faulty links remaining. | |||
*/ | |||
if ( | |||
this.props.location.pathname.match('dashboard') && | |||
isPortfolioLike(componentWithQualifier.qualifier) | |||
) { | |||
this.props.router.replace(getPortfolioUrl(component.key)); | |||
} | |||
return; | |||
} | |||
if (this.mounted) { | |||
this.setState({ projectBinding }); | |||
} | |||
/* | |||
* There used to be a redirect from /dashboard to /portfolio which caused issues. | |||
* Links should be fixed to not rely on this redirect, but: | |||
* This is a fail-safe in case there are still some faulty links remaining. | |||
*/ | |||
if ( | |||
this.props.location.pathname.match('dashboard') && | |||
isPortfolioLike(componentWithQualifier.qualifier) | |||
) { | |||
this.props.router.replace(getPortfolioUrl(componentWithQualifier.key)); | |||
} | |||
return componentWithQualifier; | |||
}, onError) | |||
.then(this.fetchBranches) | |||
.then( | |||
({ branchLike, branchLikes, component }) => { | |||
if (this.mounted) { | |||
this.setState({ | |||
branchLike, | |||
branchLikes, | |||
component, | |||
loading: false | |||
}); | |||
this.fetchStatus(component); | |||
this.fetchWarnings(component, branchLike); | |||
} | |||
}, | |||
() => {} | |||
); | |||
} | |||
const { branchLike, branchLikes } = await this.fetchBranches(componentWithQualifier); | |||
const projectBinding = await getProjectAlmBinding(key).catch(() => undefined); | |||
fetchBranches = ( | |||
component: T.Component | |||
): Promise<{ | |||
branchLike?: BranchLike; | |||
branchLikes: BranchLike[]; | |||
component: T.Component; | |||
}> => { | |||
const breadcrumb = component.breadcrumbs.find(({ qualifier }) => { | |||
return ['APP', 'TRK'].includes(qualifier); | |||
if (this.mounted) { | |||
this.setState({ | |||
branchLike, | |||
branchLikes, | |||
component: componentWithQualifier, | |||
projectBinding, | |||
loading: false | |||
}); | |||
this.fetchStatus(componentWithQualifier); | |||
this.fetchWarnings(componentWithQualifier, branchLike); | |||
this.fetchProjectBindingErrors(componentWithQualifier); | |||
} | |||
}; | |||
fetchBranches = async (componentWithQualifier: T.Component) => { | |||
const breadcrumb = componentWithQualifier.breadcrumbs.find(({ qualifier }) => { | |||
return ([ComponentQualifier.Application, ComponentQualifier.Project] as string[]).includes( | |||
qualifier | |||
); | |||
}); | |||
let branchLike = undefined; | |||
let branchLikes: BranchLike[] = []; | |||
if (breadcrumb) { | |||
const { key } = breadcrumb; | |||
return Promise.all([ | |||
const [branches, pullRequests] = await Promise.all([ | |||
getBranches(key), | |||
breadcrumb.qualifier === 'APP' ? Promise.resolve([]) : getPullRequests(key) | |||
]).then(([branches, pullRequests]) => { | |||
const branchLikes = [...branches, ...pullRequests]; | |||
const branchLike = this.getCurrentBranchLike(branchLikes); | |||
breadcrumb.qualifier === ComponentQualifier.Application | |||
? Promise.resolve([]) | |||
: getPullRequests(key) | |||
]); | |||
this.registerBranchStatuses(branchLikes, component); | |||
branchLikes = [...branches, ...pullRequests]; | |||
branchLike = this.getCurrentBranchLike(branchLikes); | |||
return { branchLike, branchLikes, component }; | |||
}); | |||
} else { | |||
return Promise.resolve({ branchLikes: [], component }); | |||
this.registerBranchStatuses(branchLikes, componentWithQualifier); | |||
} | |||
return { branchLike, branchLikes }; | |||
}; | |||
fetchStatus = (component: T.Component) => { | |||
@@ -237,7 +233,7 @@ export class ComponentContainer extends React.PureComponent<Props, State> { | |||
}; | |||
fetchWarnings = (component: T.Component, branchLike?: BranchLike) => { | |||
if (component.qualifier === 'TRK') { | |||
if (component.qualifier === ComponentQualifier.Project) { | |||
getAnalysisStatus({ | |||
component: component.key, | |||
...getBranchLikeQuery(branchLike) | |||
@@ -250,6 +246,22 @@ export class ComponentContainer extends React.PureComponent<Props, State> { | |||
} | |||
}; | |||
fetchProjectBindingErrors = async (component: T.Component) => { | |||
if (component.analysisDate === undefined && this.props.appState.branchesEnabled) { | |||
const projectBindingErrors = await validateProjectAlmBinding(component.key).catch( | |||
() => undefined | |||
); | |||
if (this.mounted) { | |||
this.setState({ projectBindingErrors }); | |||
} | |||
} | |||
}; | |||
addQualifier = (component: T.Component) => ({ | |||
...component, | |||
qualifier: component.breadcrumbs[component.breadcrumbs.length - 1].qualifier | |||
}); | |||
getCurrentBranchLike = (branchLikes: BranchLike[]) => { | |||
const { query } = this.props.location; | |||
return query.pullRequest | |||
@@ -347,27 +359,32 @@ export class ComponentContainer extends React.PureComponent<Props, State> { | |||
currentTask, | |||
isPending, | |||
projectBinding, | |||
projectBindingErrors, | |||
tasksInProgress | |||
} = this.state; | |||
const isInProgress = tasksInProgress && tasksInProgress.length > 0; | |||
return ( | |||
<div> | |||
{component && !['FIL', 'UTS'].includes(component.qualifier) && ( | |||
<ComponentNav | |||
branchLikes={branchLikes} | |||
component={component} | |||
currentBranchLike={branchLike} | |||
currentTask={currentTask} | |||
currentTaskOnSameBranch={currentTask && this.isSameBranch(currentTask, branchLike)} | |||
isInProgress={isInProgress} | |||
isPending={isPending} | |||
onComponentChange={this.handleComponentChange} | |||
onWarningDismiss={this.handleWarningDismiss} | |||
projectBinding={projectBinding} | |||
warnings={this.state.warnings} | |||
/> | |||
)} | |||
{component && | |||
!([ComponentQualifier.File, ComponentQualifier.TestFile] as string[]).includes( | |||
component.qualifier | |||
) && ( | |||
<ComponentNav | |||
branchLikes={branchLikes} | |||
component={component} | |||
currentBranchLike={branchLike} | |||
currentTask={currentTask} | |||
currentTaskOnSameBranch={currentTask && this.isSameBranch(currentTask, branchLike)} | |||
isInProgress={isInProgress} | |||
isPending={isPending} | |||
onComponentChange={this.handleComponentChange} | |||
onWarningDismiss={this.handleWarningDismiss} | |||
projectBinding={projectBinding} | |||
projectBindingErrors={projectBindingErrors} | |||
warnings={this.state.warnings} | |||
/> | |||
)} | |||
{loading ? ( | |||
<div className="page page-limited"> | |||
<i className="spinner" /> | |||
@@ -393,4 +410,4 @@ export class ComponentContainer extends React.PureComponent<Props, State> { | |||
const mapDispatchToProps = { registerBranchStatus, requireAuthorization }; | |||
export default withRouter(connect(null, mapDispatchToProps)(ComponentContainer)); | |||
export default withAppState(withRouter(connect(null, mapDispatchToProps)(ComponentContainer))); |
@@ -20,14 +20,15 @@ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; | |||
import { getProjectAlmBinding } from '../../../api/alm-settings'; | |||
import { getProjectAlmBinding, validateProjectAlmBinding } from '../../../api/alm-settings'; | |||
import { getBranches, getPullRequests } from '../../../api/branches'; | |||
import { getAnalysisStatus, getTasksForComponent } from '../../../api/ce'; | |||
import { getComponentData } from '../../../api/components'; | |||
import { getComponentNavigation } from '../../../api/nav'; | |||
import { mockProjectAlmBindingConfigurationErrors } from '../../../helpers/mocks/alm-settings'; | |||
import { mockBranch, mockMainBranch, mockPullRequest } from '../../../helpers/mocks/branch-like'; | |||
import { mockTask } from '../../../helpers/mocks/tasks'; | |||
import { mockComponent, mockLocation, mockRouter } from '../../../helpers/testMocks'; | |||
import { mockAppState, mockComponent, mockLocation, mockRouter } from '../../../helpers/testMocks'; | |||
import { AlmKeys } from '../../../types/alm-settings'; | |||
import { ComponentQualifier } from '../../../types/component'; | |||
import { TaskStatuses } from '../../../types/tasks'; | |||
@@ -68,7 +69,8 @@ jest.mock('../../../api/nav', () => ({ | |||
})); | |||
jest.mock('../../../api/alm-settings', () => ({ | |||
getProjectAlmBinding: jest.fn().mockResolvedValue(undefined) | |||
getProjectAlmBinding: jest.fn().mockResolvedValue(undefined), | |||
validateProjectAlmBinding: jest.fn().mockResolvedValue(undefined) | |||
})); | |||
// mock this, because some of its children are using redux store | |||
@@ -312,9 +314,36 @@ it('should correctly reload last task warnings if anything got dismissed', async | |||
expect(getAnalysisStatus).toBeCalledTimes(1); | |||
}); | |||
describe('should correctly validate the project binding depending on the context', () => { | |||
const COMPONENT = mockComponent({ | |||
breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: ComponentQualifier.Project }] | |||
}); | |||
const PROJECT_BINDING_ERRORS = mockProjectAlmBindingConfigurationErrors(); | |||
it.each([ | |||
["has an analysis; won't perform any check", { ...COMPONENT, analysisDate: '2020-01' }], | |||
['has a project binding; check is OK', COMPONENT, undefined, 1], | |||
['has a project binding; check is not OK', COMPONENT, PROJECT_BINDING_ERRORS, 1] | |||
])('%s', async (_, component, projectBindingErrors = undefined, n = 0) => { | |||
(getComponentNavigation as jest.Mock).mockResolvedValueOnce({}); | |||
(getComponentData as jest.Mock<any>).mockResolvedValueOnce({ component }); | |||
if (n > 0) { | |||
(validateProjectAlmBinding as jest.Mock).mockResolvedValueOnce(projectBindingErrors); | |||
} | |||
const wrapper = shallowRender({ appState: mockAppState({ branchesEnabled: true }) }); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.state().projectBindingErrors).toBe(projectBindingErrors); | |||
expect(validateProjectAlmBinding).toBeCalledTimes(n); | |||
}); | |||
}); | |||
function shallowRender(props: Partial<ComponentContainer['props']> = {}) { | |||
return shallow<ComponentContainer>( | |||
<ComponentContainer | |||
appState={mockAppState()} | |||
location={mockLocation({ query: { id: 'foo' } })} | |||
registerBranchStatus={jest.fn()} | |||
requireAuthorization={jest.fn()} |
@@ -20,13 +20,17 @@ | |||
import * as classNames from 'classnames'; | |||
import * as React from 'react'; | |||
import ContextNavBar from 'sonar-ui-common/components/ui/ContextNavBar'; | |||
import { ProjectAlmBindingResponse } from '../../../../types/alm-settings'; | |||
import { | |||
ProjectAlmBindingConfigurationErrors, | |||
ProjectAlmBindingResponse | |||
} from '../../../../types/alm-settings'; | |||
import { BranchLike } from '../../../../types/branch-like'; | |||
import { ComponentQualifier } from '../../../../types/component'; | |||
import { Task, TaskStatuses, TaskWarning } from '../../../../types/tasks'; | |||
import { rawSizes } from '../../../theme'; | |||
import RecentHistory from '../../RecentHistory'; | |||
import ComponentNavBgTaskNotif from './ComponentNavBgTaskNotif'; | |||
import ComponentNavProjectBindingErrorNotif from './ComponentNavProjectBindingErrorNotif'; | |||
import Header from './Header'; | |||
import HeaderMeta from './HeaderMeta'; | |||
import Menu from './Menu'; | |||
@@ -44,9 +48,12 @@ export interface ComponentNavProps { | |||
onComponentChange: (changes: Partial<T.Component>) => void; | |||
onWarningDismiss: () => void; | |||
projectBinding?: ProjectAlmBindingResponse; | |||
projectBindingErrors?: ProjectAlmBindingConfigurationErrors; | |||
warnings: TaskWarning[]; | |||
} | |||
const ALERT_HEIGHT = 30; | |||
export default function ComponentNav(props: ComponentNavProps) { | |||
const { | |||
branchLikes, | |||
@@ -57,6 +64,7 @@ export default function ComponentNav(props: ComponentNavProps) { | |||
isInProgress, | |||
isPending, | |||
projectBinding, | |||
projectBindingErrors, | |||
warnings | |||
} = props; | |||
const { contextNavHeightRaw, globalNavHeightRaw } = rawSizes; | |||
@@ -78,9 +86,11 @@ export default function ComponentNav(props: ComponentNavProps) { | |||
} | |||
}, [component, component.key]); | |||
let notifComponent; | |||
let contextNavHeight = contextNavHeightRaw; | |||
let bgTaskNotifComponent; | |||
if (isInProgress || isPending || (currentTask && currentTask.status === TaskStatuses.Failed)) { | |||
notifComponent = ( | |||
bgTaskNotifComponent = ( | |||
<ComponentNavBgTaskNotif | |||
component={component} | |||
currentTask={currentTask} | |||
@@ -89,12 +99,31 @@ export default function ComponentNav(props: ComponentNavProps) { | |||
isPending={isPending} | |||
/> | |||
); | |||
contextNavHeight += ALERT_HEIGHT; | |||
} | |||
const contextNavHeight = notifComponent ? contextNavHeightRaw + 30 : contextNavHeightRaw; | |||
let prDecoNotifComponent; | |||
if (projectBindingErrors !== undefined) { | |||
prDecoNotifComponent = ( | |||
<ComponentNavProjectBindingErrorNotif | |||
alm={projectBinding?.alm} | |||
component={component} | |||
projectBindingErrors={projectBindingErrors} | |||
/> | |||
); | |||
contextNavHeight += ALERT_HEIGHT; | |||
} | |||
return ( | |||
<ContextNavBar height={contextNavHeight} id="context-navigation" notif={notifComponent}> | |||
<ContextNavBar | |||
height={contextNavHeight} | |||
id="context-navigation" | |||
notif={ | |||
<> | |||
{bgTaskNotifComponent} | |||
{prDecoNotifComponent} | |||
</> | |||
}> | |||
<div | |||
className={classNames('display-flex-center display-flex-space-between little-padded-top', { | |||
'padded-bottom': warnings.length === 0 |
@@ -111,7 +111,7 @@ export class ComponentNavBgTaskNotif extends React.PureComponent<Props> { | |||
} | |||
return ( | |||
<Alert display="banner" variant="error"> | |||
<Alert className="null-spacer-bottom" display="banner" variant="error"> | |||
{message} | |||
</Alert> | |||
); |
@@ -0,0 +1,96 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2021 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import { Link } from 'react-router'; | |||
import { Alert } from 'sonar-ui-common/components/ui/Alert'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import { | |||
ALM_INTEGRATION, | |||
PULL_REQUEST_DECORATION_BINDING_CATEGORY | |||
} from '../../../../apps/settings/components/AdditionalCategoryKeys'; | |||
import { withCurrentUser } from '../../../../components/hoc/withCurrentUser'; | |||
import { hasGlobalPermission } from '../../../../helpers/users'; | |||
import { | |||
AlmKeys, | |||
ProjectAlmBindingConfigurationErrors, | |||
ProjectAlmBindingConfigurationErrorScope | |||
} from '../../../../types/alm-settings'; | |||
import { Permissions } from '../../../../types/permissions'; | |||
export interface ComponentNavProjectBindingErrorNotifProps { | |||
alm?: AlmKeys; | |||
component: T.Component; | |||
currentUser: T.CurrentUser; | |||
projectBindingErrors: ProjectAlmBindingConfigurationErrors; | |||
} | |||
export function ComponentNavProjectBindingErrorNotif( | |||
props: ComponentNavProjectBindingErrorNotifProps | |||
) { | |||
const { alm, component, currentUser, projectBindingErrors } = props; | |||
const isSysadmin = hasGlobalPermission(currentUser, Permissions.Admin); | |||
let action; | |||
if (projectBindingErrors.scope === ProjectAlmBindingConfigurationErrorScope.Global) { | |||
if (isSysadmin) { | |||
action = ( | |||
<Link | |||
to={{ | |||
pathname: '/admin/settings', | |||
query: { | |||
category: ALM_INTEGRATION, | |||
alm | |||
} | |||
}}> | |||
{translate('component_navigation.pr_deco.action.check_global_settings')} | |||
</Link> | |||
); | |||
} else { | |||
action = translate('component_navigation.pr_deco.action.contact_sys_admin'); | |||
} | |||
} else if (projectBindingErrors.scope === ProjectAlmBindingConfigurationErrorScope.Project) { | |||
if (component.configuration?.showSettings) { | |||
action = ( | |||
<Link | |||
to={{ | |||
pathname: '/project/settings', | |||
query: { category: PULL_REQUEST_DECORATION_BINDING_CATEGORY, id: component.key } | |||
}}> | |||
{translate('component_navigation.pr_deco.action.check_project_settings')} | |||
</Link> | |||
); | |||
} else { | |||
action = translate('component_navigation.pr_deco.action.contact_project_admin'); | |||
} | |||
} | |||
return ( | |||
<Alert display="banner" variant="warning"> | |||
<FormattedMessage | |||
defaultMessage={translate('component_navigation.pr_deco.error_detected_X')} | |||
id="component_navigation.pr_deco.error_detected_X" | |||
values={{ action }} | |||
/> | |||
</Alert> | |||
); | |||
} | |||
export default withCurrentUser(ComponentNavProjectBindingErrorNotif); |
@@ -19,6 +19,7 @@ | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { mockProjectAlmBindingConfigurationErrors } from '../../../../../helpers/mocks/alm-settings'; | |||
import { mockTask, mockTaskWarning } from '../../../../../helpers/mocks/tasks'; | |||
import { mockComponent } from '../../../../../helpers/testMocks'; | |||
import { ComponentQualifier } from '../../../../../types/component'; | |||
@@ -41,6 +42,11 @@ it('renders correctly', () => { | |||
expect(shallowRender({ currentTask: mockTask({ status: TaskStatuses.Failed }) })).toMatchSnapshot( | |||
'has failed notification' | |||
); | |||
expect( | |||
shallowRender({ | |||
projectBindingErrors: mockProjectAlmBindingConfigurationErrors() | |||
}) | |||
).toMatchSnapshot('has failed project binding'); | |||
}); | |||
it('correctly adds data to the history if there are breadcrumbs', () => { |
@@ -0,0 +1,76 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2021 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 { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { mockProjectAlmBindingConfigurationErrors } from '../../../../../helpers/mocks/alm-settings'; | |||
import { mockComponent, mockCurrentUser, mockLoggedInUser } from '../../../../../helpers/testMocks'; | |||
import { | |||
AlmKeys, | |||
ProjectAlmBindingConfigurationErrorScope | |||
} from '../../../../../types/alm-settings'; | |||
import { Permissions } from '../../../../../types/permissions'; | |||
import { | |||
ComponentNavProjectBindingErrorNotif, | |||
ComponentNavProjectBindingErrorNotifProps | |||
} from '../ComponentNavProjectBindingErrorNotif'; | |||
it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot('global, no admin'); | |||
expect( | |||
shallowRender({ | |||
currentUser: mockLoggedInUser({ permissions: { global: [Permissions.Admin] } }) | |||
}) | |||
).toMatchSnapshot('global, admin'); | |||
expect( | |||
shallowRender({ | |||
projectBindingErrors: mockProjectAlmBindingConfigurationErrors({ | |||
scope: ProjectAlmBindingConfigurationErrorScope.Project | |||
}) | |||
}) | |||
).toMatchSnapshot('project, no admin'); | |||
expect( | |||
shallowRender({ | |||
component: mockComponent({ configuration: { showSettings: true } }), | |||
projectBindingErrors: mockProjectAlmBindingConfigurationErrors({ | |||
scope: ProjectAlmBindingConfigurationErrorScope.Project | |||
}) | |||
}) | |||
).toMatchSnapshot('project, admin'); | |||
expect( | |||
shallowRender({ | |||
projectBindingErrors: mockProjectAlmBindingConfigurationErrors({ | |||
scope: ProjectAlmBindingConfigurationErrorScope.Unknown | |||
}) | |||
}) | |||
).toMatchSnapshot('unknown'); | |||
}); | |||
function shallowRender(props: Partial<ComponentNavProjectBindingErrorNotifProps> = {}) { | |||
return shallow<ComponentNavProjectBindingErrorNotifProps>( | |||
<ComponentNavProjectBindingErrorNotif | |||
alm={AlmKeys.GitHub} | |||
component={mockComponent()} | |||
currentUser={mockCurrentUser()} | |||
projectBindingErrors={mockProjectAlmBindingConfigurationErrors()} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -4,6 +4,7 @@ exports[`renders correctly: default 1`] = ` | |||
<ContextNavBar | |||
height={72} | |||
id="context-navigation" | |||
notif={<React.Fragment />} | |||
> | |||
<div | |||
className="display-flex-center display-flex-space-between little-padded-top padded-bottom" | |||
@@ -151,7 +152,59 @@ exports[`renders correctly: has failed notification 1`] = ` | |||
height={102} | |||
id="context-navigation" | |||
notif={ | |||
<withRouter(ComponentNavBgTaskNotif) | |||
<React.Fragment> | |||
<withRouter(ComponentNavBgTaskNotif) | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
currentTask={ | |||
Object { | |||
"analysisId": "x123", | |||
"componentKey": "foo", | |||
"componentName": "Foo", | |||
"componentQualifier": "TRK", | |||
"id": "AXR8jg_0mF2ZsYr8Wzs2", | |||
"status": "FAILED", | |||
"submittedAt": "2020-09-11T11:45:35+0200", | |||
"type": "REPORT", | |||
} | |||
} | |||
isInProgress={false} | |||
isPending={false} | |||
/> | |||
</React.Fragment> | |||
} | |||
> | |||
<div | |||
className="display-flex-center display-flex-space-between little-padded-top padded-bottom" | |||
> | |||
<Connect(Component) | |||
branchLikes={Array []} | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
@@ -180,21 +233,164 @@ exports[`renders correctly: has failed notification 1`] = ` | |||
"tags": Array [], | |||
} | |||
} | |||
currentTask={ | |||
/> | |||
<Connect(HeaderMeta) | |||
component={ | |||
Object { | |||
"analysisId": "x123", | |||
"componentKey": "foo", | |||
"componentName": "Foo", | |||
"componentQualifier": "TRK", | |||
"id": "AXR8jg_0mF2ZsYr8Wzs2", | |||
"status": "FAILED", | |||
"submittedAt": "2020-09-11T11:45:35+0200", | |||
"type": "REPORT", | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
isInProgress={false} | |||
isPending={false} | |||
onWarningDismiss={[MockFunction]} | |||
warnings={Array []} | |||
/> | |||
</div> | |||
<Connect(withAppState(Menu)) | |||
branchLikes={Array []} | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
isInProgress={false} | |||
isPending={false} | |||
onToggleProjectInfo={[Function]} | |||
/> | |||
<InfoDrawer | |||
displayed={false} | |||
onClose={[Function]} | |||
top={120} | |||
> | |||
<Connect(ProjectInformation) | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
onComponentChange={[MockFunction]} | |||
/> | |||
</InfoDrawer> | |||
</ContextNavBar> | |||
`; | |||
exports[`renders correctly: has failed project binding 1`] = ` | |||
<ContextNavBar | |||
height={102} | |||
id="context-navigation" | |||
notif={ | |||
<React.Fragment> | |||
<Connect(withCurrentUser(ComponentNavProjectBindingErrorNotif)) | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
projectBindingErrors={ | |||
Object { | |||
"errors": Array [ | |||
Object { | |||
"msg": "Foo bar is not correct", | |||
}, | |||
Object { | |||
"msg": "Bar baz has no permissions here", | |||
}, | |||
], | |||
"scope": "GLOBAL", | |||
} | |||
} | |||
/> | |||
</React.Fragment> | |||
} | |||
> | |||
<div | |||
@@ -343,38 +539,40 @@ exports[`renders correctly: has in progress notification 1`] = ` | |||
height={102} | |||
id="context-navigation" | |||
notif={ | |||
<withRouter(ComponentNavBgTaskNotif) | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
<React.Fragment> | |||
<withRouter(ComponentNavBgTaskNotif) | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
} | |||
isInProgress={true} | |||
isPending={false} | |||
/> | |||
isInProgress={true} | |||
isPending={false} | |||
/> | |||
</React.Fragment> | |||
} | |||
> | |||
<div | |||
@@ -523,38 +721,40 @@ exports[`renders correctly: has pending notification 1`] = ` | |||
height={102} | |||
id="context-navigation" | |||
notif={ | |||
<withRouter(ComponentNavBgTaskNotif) | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
<React.Fragment> | |||
<withRouter(ComponentNavBgTaskNotif) | |||
component={ | |||
Object { | |||
"breadcrumbs": Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
"qualifier": "TRK", | |||
}, | |||
], | |||
"key": "my-project", | |||
"name": "MyProject", | |||
"qualifier": "TRK", | |||
"qualityGate": Object { | |||
"isDefault": true, | |||
"key": "30", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
"qualityProfiles": Array [ | |||
Object { | |||
"deleted": false, | |||
"key": "my-qp", | |||
"language": "ts", | |||
"name": "Sonar way", | |||
}, | |||
], | |||
"tags": Array [], | |||
} | |||
} | |||
} | |||
isInProgress={false} | |||
isPending={true} | |||
/> | |||
isInProgress={false} | |||
isPending={true} | |||
/> | |||
</React.Fragment> | |||
} | |||
> | |||
<div | |||
@@ -702,6 +902,7 @@ exports[`renders correctly: has warnings 1`] = ` | |||
<ContextNavBar | |||
height={72} | |||
id="context-navigation" | |||
notif={<React.Fragment />} | |||
> | |||
<div | |||
className="display-flex-center display-flex-space-between little-padded-top" |
@@ -2,6 +2,7 @@ | |||
exports[`renders correctly: default 1`] = ` | |||
<Alert | |||
className="null-spacer-bottom" | |||
display="banner" | |||
variant="error" | |||
> |
@@ -0,0 +1,114 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly: global, admin 1`] = ` | |||
<Alert | |||
display="banner" | |||
variant="warning" | |||
> | |||
<FormattedMessage | |||
defaultMessage="component_navigation.pr_deco.error_detected_X" | |||
id="component_navigation.pr_deco.error_detected_X" | |||
values={ | |||
Object { | |||
"action": <Link | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/admin/settings", | |||
"query": Object { | |||
"alm": "github", | |||
"category": "almintegration", | |||
}, | |||
} | |||
} | |||
> | |||
component_navigation.pr_deco.action.check_global_settings | |||
</Link>, | |||
} | |||
} | |||
/> | |||
</Alert> | |||
`; | |||
exports[`should render correctly: global, no admin 1`] = ` | |||
<Alert | |||
display="banner" | |||
variant="warning" | |||
> | |||
<FormattedMessage | |||
defaultMessage="component_navigation.pr_deco.error_detected_X" | |||
id="component_navigation.pr_deco.error_detected_X" | |||
values={ | |||
Object { | |||
"action": "component_navigation.pr_deco.action.contact_sys_admin", | |||
} | |||
} | |||
/> | |||
</Alert> | |||
`; | |||
exports[`should render correctly: project, admin 1`] = ` | |||
<Alert | |||
display="banner" | |||
variant="warning" | |||
> | |||
<FormattedMessage | |||
defaultMessage="component_navigation.pr_deco.error_detected_X" | |||
id="component_navigation.pr_deco.error_detected_X" | |||
values={ | |||
Object { | |||
"action": <Link | |||
onlyActiveOnIndex={false} | |||
style={Object {}} | |||
to={ | |||
Object { | |||
"pathname": "/project/settings", | |||
"query": Object { | |||
"category": "pull_request_decoration_binding", | |||
"id": "my-project", | |||
}, | |||
} | |||
} | |||
> | |||
component_navigation.pr_deco.action.check_project_settings | |||
</Link>, | |||
} | |||
} | |||
/> | |||
</Alert> | |||
`; | |||
exports[`should render correctly: project, no admin 1`] = ` | |||
<Alert | |||
display="banner" | |||
variant="warning" | |||
> | |||
<FormattedMessage | |||
defaultMessage="component_navigation.pr_deco.error_detected_X" | |||
id="component_navigation.pr_deco.error_detected_X" | |||
values={ | |||
Object { | |||
"action": "component_navigation.pr_deco.action.contact_project_admin", | |||
} | |||
} | |||
/> | |||
</Alert> | |||
`; | |||
exports[`should render correctly: unknown 1`] = ` | |||
<Alert | |||
display="banner" | |||
variant="warning" | |||
> | |||
<FormattedMessage | |||
defaultMessage="component_navigation.pr_deco.error_detected_X" | |||
id="component_navigation.pr_deco.error_detected_X" | |||
values={ | |||
Object { | |||
"action": undefined, | |||
} | |||
} | |||
/> | |||
</Alert> | |||
`; |
@@ -69,6 +69,10 @@ th.hide-overflow { | |||
margin-top: 0 !important; | |||
} | |||
.null-spacer-bottom { | |||
margin-bottom: 0 !important; | |||
} | |||
.spacer { | |||
margin: 8px !important; | |||
} |
@@ -33,15 +33,11 @@ import { | |||
} from '../../../../../api/alm-settings'; | |||
import { | |||
mockAlmSettingsInstance, | |||
mockProjectAlmBindingConfigurationErrors, | |||
mockProjectAlmBindingResponse | |||
} from '../../../../../helpers/mocks/alm-settings'; | |||
import { mockComponent, mockCurrentUser } from '../../../../../helpers/testMocks'; | |||
import { | |||
AlmKeys, | |||
AlmSettingsInstance, | |||
ProjectAlmBindingConfigurationErrors, | |||
ProjectAlmBindingConfigurationErrorScope | |||
} from '../../../../../types/alm-settings'; | |||
import { AlmKeys, AlmSettingsInstance } from '../../../../../types/alm-settings'; | |||
import { PRDecorationBinding } from '../PRDecorationBinding'; | |||
import PRDecorationBindingRenderer from '../PRDecorationBindingRenderer'; | |||
@@ -373,10 +369,7 @@ it('should call the validation WS and store errors', async () => { | |||
mockProjectAlmBindingResponse({ key: 'key' }) | |||
); | |||
const errors: ProjectAlmBindingConfigurationErrors = { | |||
scope: ProjectAlmBindingConfigurationErrorScope.Global, | |||
errors: [{ msg: 'Test' }, { msg: 'tesT' }] | |||
}; | |||
const errors = mockProjectAlmBindingConfigurationErrors(); | |||
(validateProjectAlmBinding as jest.Mock).mockRejectedValueOnce(errors); | |||
const wrapper = shallowRender(); |
@@ -27,6 +27,8 @@ import { | |||
BitbucketCloudBindingDefinition, | |||
GithubBindingDefinition, | |||
GitlabBindingDefinition, | |||
ProjectAlmBindingConfigurationErrors, | |||
ProjectAlmBindingConfigurationErrorScope, | |||
ProjectAlmBindingResponse, | |||
ProjectAzureBindingResponse, | |||
ProjectBitbucketBindingResponse, | |||
@@ -198,3 +200,13 @@ export function mockAlmSettingsBindingStatus( | |||
...overrides | |||
}; | |||
} | |||
export function mockProjectAlmBindingConfigurationErrors( | |||
overrides: Partial<ProjectAlmBindingConfigurationErrors> = {} | |||
): ProjectAlmBindingConfigurationErrors { | |||
return { | |||
scope: ProjectAlmBindingConfigurationErrorScope.Global, | |||
errors: [{ msg: 'Foo bar is not correct' }, { msg: 'Bar baz has no permissions here' }], | |||
...overrides | |||
}; | |||
} |
@@ -2802,6 +2802,11 @@ component_navigation.status.last_blocked_due_to_bad_license_X=Last analysis bloc | |||
component_navigation.last_analysis_had_warnings=Last analysis had {warnings} | |||
component_navigation.x_warnings={warningsCount} {warningsCount, plural, one {warning} other {warnings}} | |||
component_navigation.pr_deco.error_detected_X=We've detected an issue with your configuration. Your SonarQube instance won't be able to perform any pull request decoration. {action} | |||
component_navigation.pr_deco.action.check_global_settings=Please check your global settings. | |||
component_navigation.pr_deco.action.contact_sys_admin=Please contact your system administrator. | |||
component_navigation.pr_deco.action.check_project_settings=Please check your project settings. | |||
component_navigation.pr_deco.action.contact_project_admin=Please contact your project administrator. | |||
background_task.status.ALL=All | |||
background_task.status.PENDING=Pending |