Przeglądaj źródła

SONAR-14871 Add PR decoration settings validation action

tags/9.0.0.45539
Philippe Perrin 3 lat temu
rodzic
commit
4fdc4da1db

+ 22
- 1
server/sonar-web/src/main/js/api/alm-settings.ts Wyświetl plik

@@ -17,7 +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 { get, getJSON, HttpStatus, parseError, post } from 'sonar-ui-common/helpers/request';
import {
get,
getJSON,
HttpStatus,
parseError,
parseJSON,
post
} from 'sonar-ui-common/helpers/request';
import throwGlobalError from '../app/utils/throwGlobalError';
import {
AlmSettingsBindingDefinitions,
@@ -32,6 +39,7 @@ import {
GithubProjectAlmBindingParams,
GitlabBindingDefinition,
GitlabProjectAlmBindingParams,
ProjectAlmBindingConfigurationErrors,
ProjectAlmBindingResponse
} from '../types/alm-settings';

@@ -142,3 +150,16 @@ export function setProjectGithubBinding(data: GithubProjectAlmBindingParams) {
export function setProjectGitlabBinding(data: GitlabProjectAlmBindingParams) {
return post('/api/alm_settings/set_gitlab_binding', data).catch(throwGlobalError);
}

export function validateProjectAlmBinding(
projectKey: string
): Promise<ProjectAlmBindingConfigurationErrors | undefined> {
return get('/api/alm_settings/validate_binding', { project: projectKey })
.then(() => undefined)
.catch((response: Response) => {
if (response.status === HttpStatus.BadRequest) {
return parseJSON(response);
}
return throwGlobalError(response);
});
}

+ 1
- 1
server/sonar-web/src/main/js/apps/settings/components/__tests__/__snapshots__/AdditionalCategories-test.tsx.snap Wyświetl plik

@@ -87,7 +87,7 @@ exports[`should render additional categories component correctly 4`] = `
`;

exports[`should render additional categories component correctly 5`] = `
<Connect(PRDecorationBinding)
<Connect(Connect(withCurrentUser(PRDecorationBinding)))
component={
Object {
"breadcrumbs": Array [],

+ 7
- 1
server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegration.tsx Wyświetl plik

@@ -58,8 +58,14 @@ export class AlmIntegration extends React.PureComponent<Props, State> {

constructor(props: Props) {
super(props);

let currentAlm = props.location.query.alm || AlmKeys.GitHub;
if (currentAlm === AlmKeys.BitbucketCloud) {
currentAlm = AlmKeys.BitbucketServer;
}

this.state = {
currentAlm: props.location.query.alm || AlmKeys.GitHub,
currentAlm,
definitions: {
[AlmKeys.Azure]: [],
[AlmKeys.BitbucketServer]: [],

+ 10
- 2
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/AlmIntegration-test.tsx Wyświetl plik

@@ -162,8 +162,16 @@ it('should fetch settings', async () => {
expect(wrapper.state().loadingAlmDefinitions).toBe(false);
});

function shallowRender() {
it('should detect the current ALM from the query', () => {
let wrapper = shallowRender({ location: mockLocation() });
expect(wrapper.state().currentAlm).toBe(AlmKeys.GitHub);

wrapper = shallowRender({ location: mockLocation({ query: { alm: AlmKeys.BitbucketCloud } }) });
expect(wrapper.state().currentAlm).toBe(AlmKeys.BitbucketServer);
});

function shallowRender(props: Partial<AlmIntegration['props']> = {}) {
return shallow<AlmIntegration>(
<AlmIntegration appState={{ branchesEnabled: true }} location={mockLocation()} />
<AlmIntegration appState={{ branchesEnabled: true }} location={mockLocation()} {...props} />
);
}

+ 59
- 21
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBinding.tsx Wyświetl plik

@@ -28,16 +28,21 @@ import {
setProjectBitbucketBinding,
setProjectBitbucketCloudBinding,
setProjectGithubBinding,
setProjectGitlabBinding
setProjectGitlabBinding,
validateProjectAlmBinding
} from '../../../../api/alm-settings';
import throwGlobalError from '../../../../app/utils/throwGlobalError';
import { withCurrentUser } from '../../../../components/hoc/withCurrentUser';
import { hasGlobalPermission } from '../../../../helpers/users';
import { getAppState, Store } from '../../../../store/rootReducer';
import {
AlmKeys,
AlmSettingsInstance,
ProjectAlmBindingConfigurationErrors,
ProjectAlmBindingResponse
} from '../../../../types/alm-settings';
import { EditionKey } from '../../../../types/editions';
import { Permissions } from '../../../../types/permissions';
import PRDecorationBindingRenderer from './PRDecorationBindingRenderer';

type FormData = T.Omit<ProjectAlmBindingResponse, 'alm'>;
@@ -48,6 +53,7 @@ interface StateProps {

interface Props {
component: T.Component;
currentUser: T.CurrentUser;
}

interface State {
@@ -57,9 +63,11 @@ interface State {
isConfigured: boolean;
isValid: boolean;
loading: boolean;
orignalData?: FormData;
saving: boolean;
success: boolean;
originalData?: FormData;
updating: boolean;
successfullyUpdated: boolean;
checkingConfiguration: boolean;
configurationErrors?: ProjectAlmBindingConfigurationErrors;
}

const REQUIRED_FIELDS_BY_ALM: {
@@ -81,8 +89,9 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
isConfigured: false,
isValid: false,
loading: true,
saving: false,
success: false
updating: false,
successfullyUpdated: false,
checkingConfiguration: false
};

componentDidMount() {
@@ -108,7 +117,8 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
isConfigured: !!originalData,
isValid: this.validateForm(newFormData),
loading: false,
orignalData: newFormData
originalData: newFormData,
configurationErrors: undefined
};
});
}
@@ -117,7 +127,8 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
if (this.mounted) {
this.setState({ loading: false });
}
});
})
.then(() => this.checkConfiguration());
};

getProjectBinding(project: string): Promise<ProjectAlmBindingResponse | undefined> {
@@ -131,13 +142,13 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,

catchError = () => {
if (this.mounted) {
this.setState({ saving: false });
this.setState({ updating: false });
}
};

handleReset = () => {
const { component } = this.props;
this.setState({ saving: true });
this.setState({ updating: true });
deleteProjectAlmBinding(component.key)
.then(() => {
if (this.mounted) {
@@ -148,11 +159,12 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
slug: '',
monorepo: false
},
orignalData: undefined,
originalData: undefined,
isChanged: false,
isConfigured: false,
saving: false,
success: true
updating: false,
successfullyUpdated: true,
configurationErrors: undefined
});
}
})
@@ -233,8 +245,28 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
}
}

checkConfiguration = async () => {
const {
component: { key: projectKey }
} = this.props;

const { isConfigured } = this.state;

if (!isConfigured) {
return;
}

this.setState({ checkingConfiguration: true, configurationErrors: undefined });

const configurationErrors = await validateProjectAlmBinding(projectKey).catch(error => error);

if (this.mounted) {
this.setState({ checkingConfiguration: false, configurationErrors });
}
};

handleSubmit = () => {
this.setState({ saving: true });
this.setState({ updating: true });
const {
formData: { key, ...additionalFields },
instances
@@ -249,8 +281,8 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
.then(() => {
if (this.mounted) {
this.setState({
saving: false,
success: true
updating: false,
successfullyUpdated: true
});
}
})
@@ -278,7 +310,7 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
}

handleFieldChange = (id: keyof ProjectAlmBindingResponse, value: string | boolean) => {
this.setState(({ formData, orignalData }) => {
this.setState(({ formData, originalData }) => {
const newFormData = {
...formData,
[id]: value
@@ -287,8 +319,8 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
return {
formData: newFormData,
isValid: this.validateForm(newFormData),
isChanged: !this.isDataSame(newFormData, orignalData || { key: '', monorepo: false }),
success: false
isChanged: !this.isDataSame(newFormData, originalData || { key: '', monorepo: false }),
successfullyUpdated: false
};
});
};
@@ -305,15 +337,21 @@ export class PRDecorationBinding extends React.PureComponent<Props & StateProps,
);
};

handleCheckConfiguration = async () => {
await this.checkConfiguration();
};

render() {
const { monorepoEnabled } = this.props;
const { currentUser, monorepoEnabled } = this.props;

return (
<PRDecorationBindingRenderer
onFieldChange={this.handleFieldChange}
onReset={this.handleReset}
onSubmit={this.handleSubmit}
onCheckConfiguration={this.handleCheckConfiguration}
monorepoEnabled={monorepoEnabled}
isSysAdmin={hasGlobalPermission(currentUser, Permissions.Admin)}
{...this.state}
/>
);
@@ -327,4 +365,4 @@ const mapStateToProps = (state: Store): StateProps => ({
)
});

export default connect(mapStateToProps)(PRDecorationBinding);
export default connect(mapStateToProps)(withCurrentUser(PRDecorationBinding));

+ 85
- 16
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBindingRenderer.tsx Wyświetl plik

@@ -28,7 +28,13 @@ import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
import MandatoryFieldMarker from 'sonar-ui-common/components/ui/MandatoryFieldMarker';
import MandatoryFieldsExplanation from 'sonar-ui-common/components/ui/MandatoryFieldsExplanation';
import { translate } from 'sonar-ui-common/helpers/l10n';
import { AlmSettingsInstance, ProjectAlmBindingResponse } from '../../../../types/alm-settings';
import {
AlmSettingsInstance,
ProjectAlmBindingConfigurationErrors,
ProjectAlmBindingConfigurationErrorScope,
ProjectAlmBindingResponse
} from '../../../../types/alm-settings';
import { ALM_INTEGRATION } from '../AdditionalCategoryKeys';
import AlmSpecificForm from './AlmSpecificForm';

export interface PRDecorationBindingRendererProps {
@@ -41,9 +47,13 @@ export interface PRDecorationBindingRendererProps {
onFieldChange: (id: keyof ProjectAlmBindingResponse, value: string | boolean) => void;
onReset: () => void;
onSubmit: () => void;
saving: boolean;
success: boolean;
updating: boolean;
successfullyUpdated: boolean;
monorepoEnabled: boolean;
onCheckConfiguration: () => void;
checkingConfiguration: boolean;
configurationErrors?: ProjectAlmBindingConfigurationErrors;
isSysAdmin: boolean;
}

function optionRenderer(instance: AlmSettingsInstance) {
@@ -65,9 +75,12 @@ export default function PRDecorationBindingRenderer(props: PRDecorationBindingRe
isConfigured,
isValid,
loading,
saving,
success,
monorepoEnabled
updating,
successfullyUpdated,
monorepoEnabled,
checkingConfiguration,
configurationErrors,
isSysAdmin
} = props;

if (loading) {
@@ -155,25 +168,81 @@ export default function PRDecorationBindingRenderer(props: PRDecorationBindingRe
/>
)}

<div className="display-flex-center big-spacer-top">
<DeferredSpinner className="spacer-right" loading={saving} />
<div className="display-flex-center big-spacer-top action-section">
{isChanged && (
<SubmitButton className="spacer-right button-success" disabled={saving || !isValid}>
<SubmitButton className="spacer-right button-success" disabled={updating || !isValid}>
<span data-test="project-settings__alm-save">{translate('save')}</span>
<DeferredSpinner className="spacer-left" loading={updating} />
</SubmitButton>
)}
{isConfigured && (
<Button className="spacer-right" onClick={props.onReset}>
<span data-test="project-settings__alm-reset">{translate('reset_verb')}</span>
</Button>
)}
{!saving && success && (
<span className="text-success">
{!updating && successfullyUpdated && (
<span className="text-success spacer-right">
<AlertSuccessIcon className="spacer-right" />
{translate('settings.state.saved')}
</span>
)}
{isConfigured && (
<>
<Button className="spacer-right" onClick={props.onReset}>
<span data-test="project-settings__alm-reset">{translate('reset_verb')}</span>
</Button>
{!isChanged && (
<Button onClick={props.onCheckConfiguration} disabled={checkingConfiguration}>
{translate('settings.pr_decoration.binding.check_configuration')}
<DeferredSpinner className="spacer-left" loading={checkingConfiguration} />
</Button>
)}
</>
)}
</div>
{!checkingConfiguration && configurationErrors?.errors && (
<Alert variant="error" display="inline" className="big-spacer-top">
<p className="spacer-bottom">
{translate('settings.pr_decoration.binding.check_configuration.failure')}
</p>
<ul className="list-styled">
{configurationErrors.errors.map((error, i) => (
// eslint-disable-next-line react/no-array-index-key
<li key={i}>{error.msg}</li>
))}
</ul>
{configurationErrors.scope === ProjectAlmBindingConfigurationErrorScope.Global && (
<p>
{isSysAdmin ? (
<FormattedMessage
id="settings.pr_decoration.binding.check_configuration.failure.check_global_settings"
defaultMessage={translate(
'settings.pr_decoration.binding.check_configuration.failure.check_global_settings'
)}
values={{
link: (
<Link
to={{
pathname: '/admin/settings',
query: {
category: ALM_INTEGRATION,
alm
}
}}>
{translate(
'settings.pr_decoration.binding.check_configuration.failure.check_global_settings.link'
)}
</Link>
)
}}
/>
) : (
translate('settings.pr_decoration.binding.check_configuration.contact_admin')
)}
</p>
)}
</Alert>
)}
{isConfigured && !isChanged && !checkingConfiguration && !configurationErrors && (
<Alert variant="success" display="inline" className="big-spacer-top">
{translate('settings.pr_decoration.binding.check_configuration.success')}
</Alert>
)}
</form>
</div>
);

+ 78
- 11
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBinding-test.tsx Wyświetl plik

@@ -28,11 +28,22 @@ import {
setProjectBitbucketBinding,
setProjectBitbucketCloudBinding,
setProjectGithubBinding,
setProjectGitlabBinding
setProjectGitlabBinding,
validateProjectAlmBinding
} from '../../../../../api/alm-settings';
import { mockComponent } from '../../../../../helpers/testMocks';
import { AlmKeys, AlmSettingsInstance } from '../../../../../types/alm-settings';
import {
mockAlmSettingsInstance,
mockProjectAlmBindingResponse
} from '../../../../../helpers/mocks/alm-settings';
import { mockComponent, mockCurrentUser } from '../../../../../helpers/testMocks';
import {
AlmKeys,
AlmSettingsInstance,
ProjectAlmBindingConfigurationErrors,
ProjectAlmBindingConfigurationErrorScope
} from '../../../../../types/alm-settings';
import { PRDecorationBinding } from '../PRDecorationBinding';
import PRDecorationBindingRenderer from '../PRDecorationBindingRenderer';

jest.mock('../../../../../api/alm-settings', () => ({
getAlmSettings: jest.fn().mockResolvedValue([]),
@@ -42,7 +53,8 @@ jest.mock('../../../../../api/alm-settings', () => ({
setProjectGithubBinding: jest.fn().mockResolvedValue(undefined),
setProjectGitlabBinding: jest.fn().mockResolvedValue(undefined),
setProjectBitbucketCloudBinding: jest.fn().mockResolvedValue(undefined),
deleteProjectAlmBinding: jest.fn().mockResolvedValue(undefined)
deleteProjectAlmBinding: jest.fn().mockResolvedValue(undefined),
validateProjectAlmBinding: jest.fn().mockResolvedValue(undefined)
}));

const PROJECT_KEY = 'project-key';
@@ -122,7 +134,7 @@ describe('handleSubmit', () => {
summaryCommentEnabled,
monorepo
});
expect(wrapper.state().success).toBe(true);
expect(wrapper.state().successfullyUpdated).toBe(true);
});

it('should work for azure', async () => {
@@ -146,7 +158,7 @@ describe('handleSubmit', () => {
repositoryName: repository,
monorepo
});
expect(wrapper.state().success).toBe(true);
expect(wrapper.state().successfullyUpdated).toBe(true);
});

it('should work for bitbucket', async () => {
@@ -167,7 +179,7 @@ describe('handleSubmit', () => {
slug,
monorepo
});
expect(wrapper.state().success).toBe(true);
expect(wrapper.state().successfullyUpdated).toBe(true);
});

it('should work for gitlab', async () => {
@@ -189,7 +201,7 @@ describe('handleSubmit', () => {
repository,
monorepo
});
expect(wrapper.state().success).toBe(true);
expect(wrapper.state().successfullyUpdated).toBe(true);
});

it('should work for bitbucket cloud', async () => {
@@ -213,7 +225,7 @@ describe('handleSubmit', () => {
repository,
monorepo
});
expect(wrapper.state().success).toBe(true);
expect(wrapper.state().successfullyUpdated).toBe(true);
});
});

@@ -233,12 +245,12 @@ describe.each([[500], [404]])('For status %i', status => {
await waitAndUpdate(wrapper);
wrapper.setState({
formData: newFormData,
orignalData: undefined
originalData: undefined
});

wrapper.instance().handleSubmit();
await waitAndUpdate(wrapper);
expect(wrapper.instance().state.orignalData).toBeUndefined();
expect(wrapper.instance().state.originalData).toBeUndefined();
wrapper.instance().handleReset();
await waitAndUpdate(wrapper);
expect(wrapper.instance().state.formData).toEqual(newFormData);
@@ -355,9 +367,64 @@ it('should validate form', async () => {
});
});

it('should call the validation WS and store errors', async () => {
(getAlmSettings as jest.Mock).mockResolvedValueOnce(mockAlmSettingsInstance());
(getProjectAlmBinding as jest.Mock).mockResolvedValueOnce(
mockProjectAlmBindingResponse({ key: 'key' })
);

const errors: ProjectAlmBindingConfigurationErrors = {
scope: ProjectAlmBindingConfigurationErrorScope.Global,
errors: [{ msg: 'Test' }, { msg: 'tesT' }]
};
(validateProjectAlmBinding as jest.Mock).mockRejectedValueOnce(errors);

const wrapper = shallowRender();

wrapper
.find(PRDecorationBindingRenderer)
.props()
.onCheckConfiguration();

await waitAndUpdate(wrapper);

expect(validateProjectAlmBinding).toHaveBeenCalledWith(PROJECT_KEY);
expect(wrapper.state().configurationErrors).toBe(errors);
});

it('should call the validation WS after loading', async () => {
(getAlmSettings as jest.Mock).mockResolvedValueOnce([mockAlmSettingsInstance()]);
(getProjectAlmBinding as jest.Mock).mockResolvedValueOnce(
mockProjectAlmBindingResponse({ key: 'key ' })
);

const wrapper = shallowRender();

await waitAndUpdate(wrapper);

expect(validateProjectAlmBinding).toHaveBeenCalled();
});

it('should call the validation WS upon saving', async () => {
(getAlmSettings as jest.Mock).mockResolvedValueOnce([mockAlmSettingsInstance()]);
(getProjectAlmBinding as jest.Mock).mockResolvedValueOnce(
mockProjectAlmBindingResponse({ key: 'key ' })
);

const wrapper = shallowRender();

wrapper.instance().handleFieldChange('key', 'key');
wrapper.instance().handleSubmit();

await waitAndUpdate(wrapper);

expect(validateProjectAlmBinding).toHaveBeenCalled();
});

function shallowRender(props: Partial<PRDecorationBinding['props']> = {}) {
return shallow<PRDecorationBinding>(
<PRDecorationBinding
currentUser={mockCurrentUser()}
component={mockComponent({ key: PROJECT_KEY })}
monorepoEnabled={false}
{...props}

+ 94
- 86
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBindingRenderer-test.tsx Wyświetl plik

@@ -21,65 +21,51 @@ import { shallow } from 'enzyme';
import * as React from 'react';
import Select from 'sonar-ui-common/components/controls/Select';
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { AlmKeys } from '../../../../../types/alm-settings';
import {
AlmKeys,
AlmSettingsInstance,
ProjectAlmBindingConfigurationErrors,
ProjectAlmBindingConfigurationErrorScope
} from '../../../../../types/alm-settings';
import PRDecorationBindingRenderer, {
PRDecorationBindingRendererProps
} from '../PRDecorationBindingRenderer';

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot();
expect(shallowRender({ loading: false })).toMatchSnapshot();
});

it('should render single instance correctly', () => {
const singleInstance = {
key: 'single',
url: 'http://single.url',
alm: AlmKeys.GitHub
};
expect(
shallowRender({
loading: false,
instances: [singleInstance]
})
).toMatchSnapshot();
});
const urls = ['http://github.enterprise.com', 'http://bbs.enterprise.com'];
const instances: AlmSettingsInstance[] = [
{
alm: AlmKeys.GitHub,
key: 'i1',
url: urls[0]
},
{
alm: AlmKeys.GitHub,
key: 'i2',
url: urls[0]
},
{
alm: AlmKeys.BitbucketServer,
key: 'i3',
url: urls[1]
},
{
alm: AlmKeys.Azure,
key: 'i4'
}
];
const configurationErrors: ProjectAlmBindingConfigurationErrors = {
scope: ProjectAlmBindingConfigurationErrorScope.Global,
errors: [{ msg: 'Test' }, { msg: 'tesT' }]
};

it('should render multiple instances correctly', () => {
const urls = ['http://github.enterprise.com', 'http://bbs.enterprise.com'];
const instances = [
{
alm: AlmKeys.GitHub,
key: 'i1',
url: urls[0]
},
it.each([
['when loading', { loading: true }],
['with no ALM instances', {}],
['with a single ALM instance', { instances: [instances[0]] }],
['with an empty form', { instances }],
[
'with a valid and saved form',
{
alm: AlmKeys.GitHub,
key: 'i2',
url: urls[0]
},
{
alm: AlmKeys.BitbucketServer,
key: 'i3',
url: urls[1]
},
{
alm: AlmKeys.Azure,
key: 'i4'
}
];

//unfilled
expect(
shallowRender({
instances,
loading: false
})
).toMatchSnapshot();

// filled
expect(
shallowRender({
formData: {
key: 'i1',
repository: 'account/repo',
@@ -87,43 +73,62 @@ it('should render multiple instances correctly', () => {
},
isChanged: false,
isConfigured: true,
instances
}
],
[
'when there are configuration errors (non-admin user)',
{ instances, isConfigured: true, configurationErrors }
],
[
'when there are configuration errors (admin user)',
{
formData: {
key: 'i1',
repository: 'account/repo',
monorepo: false
},
instances,
loading: false
})
).toMatchSnapshot();
});

it('should display action state correctly', () => {
const urls = ['http://url.com'];
const instances = [{ key: 'key', url: urls[0], alm: AlmKeys.GitHub }];

expect(shallowRender({ instances, loading: false, saving: true })).toMatchSnapshot();
expect(shallowRender({ instances, loading: false, success: true })).toMatchSnapshot();
expect(
shallowRender({
isConfigured: true,
configurationErrors,
isSysAdmin: true
}
],
[
'when there are configuration errors (admin user) and error are at PROJECT level',
{
instances,
isValid: true,
loading: false
})
).toMatchSnapshot();
isConfigured: true,
configurationErrors: {
...configurationErrors,
scope: ProjectAlmBindingConfigurationErrorScope.Project
},
isSysAdmin: true
}
]
])('should render correctly', (name: string, props: PRDecorationBindingRendererProps) => {
expect(shallowRender(props)).toMatchSnapshot(name);
});

it.each([
['updating', { updating: true }],
['update is successfull', { successfullyUpdated: true }],
['form is valid', { isValid: true }],
['configuration is saved', { isConfigured: true }],
['configuration check is in progress', { isConfigured: true, checkingConfiguration: true }]
])(
'should display action section correctly when',
(name: string, props: PRDecorationBindingRendererProps) => {
expect(shallowRender({ ...props, instances }).find('.action-section')).toMatchSnapshot(name);
}
);

it('should render select options correctly', async () => {
const instances = [
{
alm: AlmKeys.Azure,
key: 'azure'
},
{
alm: AlmKeys.GitHub,
key: 'github',
url: 'gh.url.com'
}
];
const wrapper = shallowRender({ loading: false, instances });
const wrapper = shallowRender({ instances });

await waitAndUpdate(wrapper);

const optionRenderer = wrapper.find(Select).prop('optionRenderer');
const { optionRenderer } = wrapper.find(Select).props();

expect(optionRenderer!(instances[0])).toMatchSnapshot();

@@ -142,13 +147,16 @@ function shallowRender(props: Partial<PRDecorationBindingRendererProps> = {}) {
isChanged={false}
isConfigured={false}
isValid={false}
loading={true}
loading={false}
onFieldChange={jest.fn()}
onReset={jest.fn()}
onSubmit={jest.fn()}
saving={false}
success={false}
updating={false}
successfullyUpdated={false}
monorepoEnabled={false}
checkingConfiguration={false}
onCheckConfiguration={jest.fn()}
isSysAdmin={false}
{...props}
/>
);

+ 5
- 2
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/__snapshots__/PRDecorationBinding-test.tsx.snap Wyświetl plik

@@ -2,6 +2,7 @@

exports[`should render correctly 1`] = `
<PRDecorationBindingRenderer
checkingConfiguration={false}
formData={
Object {
"key": "",
@@ -11,13 +12,15 @@ exports[`should render correctly 1`] = `
instances={Array []}
isChanged={false}
isConfigured={false}
isSysAdmin={false}
isValid={false}
loading={true}
monorepoEnabled={false}
onCheckConfiguration={[Function]}
onFieldChange={[Function]}
onReset={[Function]}
onSubmit={[Function]}
saving={false}
success={false}
successfullyUpdated={false}
updating={false}
/>
`;

+ 425
- 113
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/__snapshots__/PRDecorationBindingRenderer-test.tsx.snap Wyświetl plik

@@ -1,6 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should display action state correctly 1`] = `
exports[`should display action section correctly when: configuration check is in progress 1`] = `
<div
className="display-flex-center big-spacer-top action-section"
>
<Button
className="spacer-right"
onClick={[MockFunction]}
>
<span
data-test="project-settings__alm-reset"
>
reset_verb
</span>
</Button>
<Button
disabled={true}
onClick={[MockFunction]}
>
settings.pr_decoration.binding.check_configuration
<DeferredSpinner
className="spacer-left"
loading={true}
/>
</Button>
</div>
`;

exports[`should display action section correctly when: configuration is saved 1`] = `
<div
className="display-flex-center big-spacer-top action-section"
>
<Button
className="spacer-right"
onClick={[MockFunction]}
>
<span
data-test="project-settings__alm-reset"
>
reset_verb
</span>
</Button>
<Button
disabled={false}
onClick={[MockFunction]}
>
settings.pr_decoration.binding.check_configuration
<DeferredSpinner
className="spacer-left"
loading={false}
/>
</Button>
</div>
`;

exports[`should display action section correctly when: form is valid 1`] = `
<div
className="display-flex-center big-spacer-top action-section"
/>
`;

exports[`should display action section correctly when: update is successfull 1`] = `
<div
className="display-flex-center big-spacer-top action-section"
>
<span
className="text-success spacer-right"
>
<AlertSuccessIcon
className="spacer-right"
/>
settings.state.saved
</span>
</div>
`;

exports[`should display action section correctly when: updating 1`] = `
<div
className="display-flex-center big-spacer-top action-section"
/>
`;

exports[`should render correctly: when loading 1`] = `<DeferredSpinner />`;

exports[`should render correctly: when there are configuration errors (admin user) 1`] = `
<div>
<header
className="page-header"
@@ -63,31 +146,146 @@ exports[`should display action state correctly 1`] = `
Array [
Object {
"alm": "github",
"key": "key",
"url": "http://url.com",
"key": "i1",
"url": "http://github.enterprise.com",
},
Object {
"alm": "github",
"key": "i2",
"url": "http://github.enterprise.com",
},
Object {
"alm": "bitbucket",
"key": "i3",
"url": "http://bbs.enterprise.com",
},
Object {
"alm": "azure",
"key": "i4",
},
]
}
searchable={false}
value=""
value="i1"
valueKey="key"
valueRenderer={[Function]}
/>
</div>
</div>
<AlmSpecificForm
alm="github"
formData={
Object {
"key": "i1",
"monorepo": false,
"repository": "account/repo",
}
}
instances={
Array [
Object {
"alm": "github",
"key": "i1",
"url": "http://github.enterprise.com",
},
Object {
"alm": "github",
"key": "i2",
"url": "http://github.enterprise.com",
},
Object {
"alm": "bitbucket",
"key": "i3",
"url": "http://bbs.enterprise.com",
},
Object {
"alm": "azure",
"key": "i4",
},
]
}
monorepoEnabled={false}
onFieldChange={[MockFunction]}
/>
<div
className="display-flex-center big-spacer-top"
className="display-flex-center big-spacer-top action-section"
>
<DeferredSpinner
<Button
className="spacer-right"
loading={true}
/>
onClick={[MockFunction]}
>
<span
data-test="project-settings__alm-reset"
>
reset_verb
</span>
</Button>
<Button
disabled={false}
onClick={[MockFunction]}
>
settings.pr_decoration.binding.check_configuration
<DeferredSpinner
className="spacer-left"
loading={false}
/>
</Button>
</div>
<Alert
className="big-spacer-top"
display="inline"
variant="error"
>
<p
className="spacer-bottom"
>
settings.pr_decoration.binding.check_configuration.failure
</p>
<ul
className="list-styled"
>
<li
key="0"
>
Test
</li>
<li
key="1"
>
tesT
</li>
</ul>
<p>
<FormattedMessage
defaultMessage="settings.pr_decoration.binding.check_configuration.failure.check_global_settings"
id="settings.pr_decoration.binding.check_configuration.failure.check_global_settings"
values={
Object {
"link": <Link
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/admin/settings",
"query": Object {
"alm": "github",
"category": "almintegration",
},
}
}
>
settings.pr_decoration.binding.check_configuration.failure.check_global_settings.link
</Link>,
}
}
/>
</p>
</Alert>
</form>
</div>
`;

exports[`should display action state correctly 2`] = `
exports[`should render correctly: when there are configuration errors (admin user) and error are at PROJECT level 1`] = `
<div>
<header
className="page-header"
@@ -150,8 +348,22 @@ exports[`should display action state correctly 2`] = `
Array [
Object {
"alm": "github",
"key": "key",
"url": "http://url.com",
"key": "i1",
"url": "http://github.enterprise.com",
},
Object {
"alm": "github",
"key": "i2",
"url": "http://github.enterprise.com",
},
Object {
"alm": "bitbucket",
"key": "i3",
"url": "http://bbs.enterprise.com",
},
Object {
"alm": "azure",
"key": "i4",
},
]
}
@@ -163,26 +375,59 @@ exports[`should display action state correctly 2`] = `
</div>
</div>
<div
className="display-flex-center big-spacer-top"
className="display-flex-center big-spacer-top action-section"
>
<DeferredSpinner
<Button
className="spacer-right"
loading={false}
/>
<span
className="text-success"
onClick={[MockFunction]}
>
<AlertSuccessIcon
className="spacer-right"
<span
data-test="project-settings__alm-reset"
>
reset_verb
</span>
</Button>
<Button
disabled={false}
onClick={[MockFunction]}
>
settings.pr_decoration.binding.check_configuration
<DeferredSpinner
className="spacer-left"
loading={false}
/>
settings.state.saved
</span>
</Button>
</div>
<Alert
className="big-spacer-top"
display="inline"
variant="error"
>
<p
className="spacer-bottom"
>
settings.pr_decoration.binding.check_configuration.failure
</p>
<ul
className="list-styled"
>
<li
key="0"
>
Test
</li>
<li
key="1"
>
tesT
</li>
</ul>
</Alert>
</form>
</div>
`;

exports[`should display action state correctly 3`] = `
exports[`should render correctly: when there are configuration errors (non-admin user) 1`] = `
<div>
<header
className="page-header"
@@ -245,8 +490,22 @@ exports[`should display action state correctly 3`] = `
Array [
Object {
"alm": "github",
"key": "key",
"url": "http://url.com",
"key": "i1",
"url": "http://github.enterprise.com",
},
Object {
"alm": "github",
"key": "i2",
"url": "http://github.enterprise.com",
},
Object {
"alm": "bitbucket",
"key": "i3",
"url": "http://bbs.enterprise.com",
},
Object {
"alm": "azure",
"key": "i4",
},
]
}
@@ -258,45 +517,62 @@ exports[`should display action state correctly 3`] = `
</div>
</div>
<div
className="display-flex-center big-spacer-top"
className="display-flex-center big-spacer-top action-section"
>
<DeferredSpinner
<Button
className="spacer-right"
loading={false}
/>
onClick={[MockFunction]}
>
<span
data-test="project-settings__alm-reset"
>
reset_verb
</span>
</Button>
<Button
disabled={false}
onClick={[MockFunction]}
>
settings.pr_decoration.binding.check_configuration
<DeferredSpinner
className="spacer-left"
loading={false}
/>
</Button>
</div>
<Alert
className="big-spacer-top"
display="inline"
variant="error"
>
<p
className="spacer-bottom"
>
settings.pr_decoration.binding.check_configuration.failure
</p>
<ul
className="list-styled"
>
<li
key="0"
>
Test
</li>
<li
key="1"
>
tesT
</li>
</ul>
<p>
settings.pr_decoration.binding.check_configuration.contact_admin
</p>
</Alert>
</form>
</div>
`;

exports[`should render correctly 1`] = `<DeferredSpinner />`;

exports[`should render correctly 2`] = `
<div>
<Alert
className="spacer-top huge-spacer-bottom"
variant="info"
>
<FormattedMessage
defaultMessage="settings.pr_decoration.binding.no_bindings"
id="settings.pr_decoration.binding.no_bindings"
values={
Object {
"link": <Link
onlyActiveOnIndex={false}
style={Object {}}
to="/documentation/analysis/pull-request/#pr-decoration"
>
learn_more
</Link>,
}
}
/>
</Alert>
</div>
`;

exports[`should render multiple instances correctly 1`] = `
exports[`should render correctly: with a single ALM instance 1`] = `
<div>
<header
className="page-header"
@@ -362,20 +638,6 @@ exports[`should render multiple instances correctly 1`] = `
"key": "i1",
"url": "http://github.enterprise.com",
},
Object {
"alm": "github",
"key": "i2",
"url": "http://github.enterprise.com",
},
Object {
"alm": "bitbucket",
"key": "i3",
"url": "http://bbs.enterprise.com",
},
Object {
"alm": "azure",
"key": "i4",
},
]
}
searchable={false}
@@ -386,18 +648,13 @@ exports[`should render multiple instances correctly 1`] = `
</div>
</div>
<div
className="display-flex-center big-spacer-top"
>
<DeferredSpinner
className="spacer-right"
loading={false}
/>
</div>
className="display-flex-center big-spacer-top action-section"
/>
</form>
</div>
`;

exports[`should render multiple instances correctly 2`] = `
exports[`should render correctly: with a valid and saved form 1`] = `
<div>
<header
className="page-header"
@@ -522,12 +779,8 @@ exports[`should render multiple instances correctly 2`] = `
onFieldChange={[MockFunction]}
/>
<div
className="display-flex-center big-spacer-top"
className="display-flex-center big-spacer-top action-section"
>
<DeferredSpinner
className="spacer-right"
loading={false}
/>
<Button
className="spacer-right"
onClick={[MockFunction]}
@@ -538,32 +791,29 @@ exports[`should render multiple instances correctly 2`] = `
reset_verb
</span>
</Button>
<Button
disabled={false}
onClick={[MockFunction]}
>
settings.pr_decoration.binding.check_configuration
<DeferredSpinner
className="spacer-left"
loading={false}
/>
</Button>
</div>
<Alert
className="big-spacer-top"
display="inline"
variant="success"
>
settings.pr_decoration.binding.check_configuration.success
</Alert>
</form>
</div>
`;

exports[`should render select options correctly 1`] = `
<span>
azure
</span>
`;

exports[`should render select options correctly 2`] = `
<React.Fragment>
<span>
github
</span>
<span
className="text-muted"
>
gh.url.com
</span>
</React.Fragment>
`;

exports[`should render single instance correctly 1`] = `
exports[`should render correctly: with an empty form 1`] = `
<div>
<header
className="page-header"
@@ -626,8 +876,22 @@ exports[`should render single instance correctly 1`] = `
Array [
Object {
"alm": "github",
"key": "single",
"url": "http://single.url",
"key": "i1",
"url": "http://github.enterprise.com",
},
Object {
"alm": "github",
"key": "i2",
"url": "http://github.enterprise.com",
},
Object {
"alm": "bitbucket",
"key": "i3",
"url": "http://bbs.enterprise.com",
},
Object {
"alm": "azure",
"key": "i4",
},
]
}
@@ -639,13 +903,61 @@ exports[`should render single instance correctly 1`] = `
</div>
</div>
<div
className="display-flex-center big-spacer-top"
>
<DeferredSpinner
className="spacer-right"
loading={false}
/>
</div>
className="display-flex-center big-spacer-top action-section"
/>
</form>
</div>
`;

exports[`should render correctly: with no ALM instances 1`] = `
<div>
<Alert
className="spacer-top huge-spacer-bottom"
variant="info"
>
<FormattedMessage
defaultMessage="settings.pr_decoration.binding.no_bindings"
id="settings.pr_decoration.binding.no_bindings"
values={
Object {
"link": <Link
onlyActiveOnIndex={false}
style={Object {}}
to="/documentation/analysis/pull-request/#pr-decoration"
>
learn_more
</Link>,
}
}
/>
</Alert>
</div>
`;

exports[`should render select options correctly 1`] = `
<React.Fragment>
<span>
i1
</span>
<span
className="text-muted"
>
http://github.enterprise.com
</span>
</React.Fragment>
`;

exports[`should render select options correctly 2`] = `
<React.Fragment>
<span>
i2
</span>
<span
className="text-muted"
>
http://github.enterprise.com
</span>
</React.Fragment>
`;

+ 11
- 0
server/sonar-web/src/main/js/types/alm-settings.ts Wyświetl plik

@@ -153,6 +153,17 @@ export enum AlmSettingsBindingStatusType {
Warning
}

export enum ProjectAlmBindingConfigurationErrorScope {
Global = 'GLOBAL',
Project = 'PROJECT',
Unknown = 'UNKNOWN'
}

export interface ProjectAlmBindingConfigurationErrors {
scope: ProjectAlmBindingConfigurationErrorScope;
errors: { msg: string }[];
}

export function isProjectBitbucketBindingResponse(
binding: ProjectAlmBindingResponse
): binding is ProjectBitbucketBindingResponse {

+ 6
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties Wyświetl plik

@@ -1168,7 +1168,12 @@ settings.pr_decoration.binding.category=DevOps Platform Integration
settings.pr_decoration.binding.no_bindings=This feature must first be enabled in the global settings. {link}
settings.pr_decoration.binding.title=DevOps Platform Integration
settings.pr_decoration.binding.description=Display your Quality Gate status directly in your DevOps Platform.
settings.pr_decoration.binding.form.url=Project location
settings.pr_decoration.binding.check_configuration=Check configuration
settings.pr_decoration.binding.check_configuration.failure=You have the following errors in your configuration:
settings.pr_decoration.binding.check_configuration.failure.check_global_settings=Please check your {link}.
settings.pr_decoration.binding.check_configuration.failure.check_global_settings.link=global settings
settings.pr_decoration.binding.check_configuration.contact_admin=Please contact your system administrator.
settings.pr_decoration.binding.check_configuration.success=Configuration valid.
settings.pr_decoration.binding.form.name=Configuration name
settings.pr_decoration.binding.form.name.help=Each DevOps Platform instance must be configured globally first, and given a unique name. Pick the instance your project is hosted on.
settings.pr_decoration.binding.form.monorepo=Enable mono repository support

Ładowanie…
Anuluj
Zapisz