Browse Source

Improve test coverage

tags/8.2.0.32929
Wouter Admiraal 4 years ago
parent
commit
ccfd204b05
34 changed files with 2225 additions and 828 deletions
  1. 8
    4
      server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx
  2. 48
    0
      server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.tsx
  3. 51
    0
      server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/ProjectAdminPageExtension-test.tsx.snap
  4. 8
    3
      server/sonar-web/src/main/js/apps/coding-rules/components/BulkChangeModal.tsx
  5. 12
    8
      server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetails.tsx
  6. 93
    15
      server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/BulkChangeModal-test.tsx
  7. 106
    31
      server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleDetails-test.tsx
  8. 52
    10
      server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleListItem-test.tsx
  9. 252
    1
      server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap
  10. 9
    9
      server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/RuleDetails-test.tsx.snap
  11. 122
    23
      server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/RuleListItem-test.tsx.snap
  12. 42
    42
      server/sonar-web/src/main/js/apps/quality-gates/components/DetailsContent.tsx
  13. 131
    0
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/Details-test.tsx
  14. 21
    43
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/DetailsContent-test.tsx
  15. 88
    0
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/DetailsHeader-test.tsx
  16. 53
    0
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/Details-test.tsx.snap
  17. 83
    0
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/DetailsContent-test.tsx.snap
  18. 112
    0
      server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/DetailsHeader-test.tsx.snap
  19. 18
    8
      server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx
  20. 71
    52
      server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsForm-test.tsx
  21. 16
    113
      server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsForm-test.tsx.snap
  22. 81
    96
      server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListRow.tsx
  23. 47
    0
      server/sonar-web/src/main/js/apps/quality-profiles/home/__tests__/ProfilesListRow-test.tsx
  24. 3
    3
      server/sonar-web/src/main/js/apps/quality-profiles/home/__tests__/__snapshots__/ProfilesList-test.tsx.snap
  25. 465
    0
      server/sonar-web/src/main/js/apps/quality-profiles/home/__tests__/__snapshots__/ProfilesListRow-test.tsx.snap
  26. 6
    21
      server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx
  27. 0
    109
      server/sonar-web/src/main/js/apps/sessions/components/LoginSonarCloud.tsx
  28. 66
    0
      server/sonar-web/src/main/js/apps/sessions/components/__tests__/LoginContainer-test.tsx
  29. 0
    64
      server/sonar-web/src/main/js/apps/sessions/components/__tests__/LoginSonarCloud-test.tsx
  30. 18
    0
      server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/LoginContainer-test.tsx.snap
  31. 0
    170
      server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/LoginSonarCloud-test.tsx.snap
  32. 21
    0
      server/sonar-web/src/main/js/helpers/testMocks.ts
  33. 119
    0
      server/sonar-web/src/main/js/store/__tests__/users-test.tsx
  34. 3
    3
      server/sonar-web/src/main/js/store/users.ts

+ 8
- 4
server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx View File

@@ -24,18 +24,22 @@ import { addGlobalErrorMessage } from '../../../store/globalMessages';
import NotFound from '../NotFound';
import Extension from './Extension';

interface Props {
export interface ProjectAdminPageExtensionProps {
component: T.Component;
location: Location;
params: { extensionKey: string; pluginKey: string };
}

function ProjectAdminPageExtension(props: Props) {
const { extensionKey, pluginKey } = props.params;
const { component } = props;
export function ProjectAdminPageExtension(props: ProjectAdminPageExtensionProps) {
const {
component,
params: { extensionKey, pluginKey }
} = props;

const extension =
component.configuration &&
(component.configuration.extensions || []).find(p => p.key === `${pluginKey}/${extensionKey}`);

return extension ? (
<Extension extension={extension} options={{ component }} />
) : (

+ 48
- 0
server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.tsx View File

@@ -0,0 +1,48 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 { mockComponent, mockLocation } from '../../../../helpers/testMocks';
import {
ProjectAdminPageExtension,
ProjectAdminPageExtensionProps
} from '../ProjectAdminPageExtension';

it('should render correctly', () => {
expect(
shallowRender({
component: mockComponent({
configuration: { extensions: [{ key: 'foo/bar', name: 'Foo Bar' }] }
})
})
).toMatchSnapshot('extension exists');
expect(shallowRender()).toMatchSnapshot('extension not found');
});

function shallowRender(props: Partial<ProjectAdminPageExtensionProps> = {}) {
return shallow(
<ProjectAdminPageExtension
component={mockComponent()}
location={mockLocation()}
params={{ extensionKey: 'bar', pluginKey: 'foo' }}
{...props}
/>
);
}

+ 51
- 0
server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/ProjectAdminPageExtension-test.tsx.snap View File

@@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly: extension exists 1`] = `
<InjectIntl(withRouter(Connect(Extension)))
extension={
Object {
"key": "foo/bar",
"name": "Foo Bar",
}
}
options={
Object {
"component": Object {
"breadcrumbs": Array [],
"configuration": Object {
"extensions": Array [
Object {
"key": "foo/bar",
"name": "Foo Bar",
},
],
},
"key": "my-project",
"name": "MyProject",
"organization": "foo",
"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 [],
},
}
}
/>
`;

exports[`should render correctly: extension not found 1`] = `
<NotFound
withContainer={false}
/>
`;

+ 8
- 3
server/sonar-web/src/main/js/apps/coding-rules/components/BulkChangeModal.tsx View File

@@ -194,10 +194,15 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
render() {
const { action, profile, total } = this.props;
const header =
// prettier-ignore
action === 'activate'
? `${translate('coding_rules.activate_in_quality_profile')} (${formatMeasure(total, 'INT')} ${translate('coding_rules._rules')})`
: `${translate('coding_rules.deactivate_in_quality_profile')} (${formatMeasure(total, 'INT')} ${translate('coding_rules._rules')})`;
? `${translate('coding_rules.activate_in_quality_profile')} (${formatMeasure(
total,
'INT'
)} ${translate('coding_rules._rules')})`
: `${translate('coding_rules.deactivate_in_quality_profile')} (${formatMeasure(
total,
'INT'
)} ${translate('coding_rules._rules')})`;

return (
<Modal contentLabel={header} onRequestClose={this.props.onClose} size="small">

+ 12
- 8
server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetails.tsx View File

@@ -76,8 +76,8 @@ export default class RuleDetails extends React.PureComponent<Props, State> {
this.mounted = false;
}

fetchRuleDetails = () =>
getRuleDetails({
fetchRuleDetails = () => {
return getRuleDetails({
actives: true,
key: this.props.ruleKey,
organization: this.props.organization
@@ -93,6 +93,7 @@ export default class RuleDetails extends React.PureComponent<Props, State> {
}
}
);
};

handleRuleChange = (ruleDetails: T.RuleDetails) => {
if (this.mounted) {
@@ -119,8 +120,8 @@ export default class RuleDetails extends React.PureComponent<Props, State> {
});
};

handleActivate = () =>
this.fetchRuleDetails().then(() => {
handleActivate = () => {
return this.fetchRuleDetails().then(() => {
const { ruleKey, selectedProfile } = this.props;
if (selectedProfile && this.state.actives) {
const active = this.state.actives.find(active => active.qProfile === selectedProfile.key);
@@ -129,9 +130,10 @@ export default class RuleDetails extends React.PureComponent<Props, State> {
}
}
});
};

handleDeactivate = () =>
this.fetchRuleDetails().then(() => {
handleDeactivate = () => {
return this.fetchRuleDetails().then(() => {
const { ruleKey, selectedProfile } = this.props;
if (
selectedProfile &&
@@ -141,11 +143,13 @@ export default class RuleDetails extends React.PureComponent<Props, State> {
this.props.onDeactivate(selectedProfile.key, ruleKey);
}
});
};

handleDelete = () =>
deleteRule({ key: this.props.ruleKey, organization: this.props.organization }).then(() =>
handleDelete = () => {
return deleteRule({ key: this.props.ruleKey, organization: this.props.organization }).then(() =>
this.props.onDelete(this.props.ruleKey)
);
};

render() {
const { ruleDetails } = this.state;

+ 93
- 15
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/BulkChangeModal-test.tsx View File

@@ -19,23 +19,101 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { mockQualityProfile } from '../../../../helpers/testMocks';
import { submit, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { bulkActivateRules, bulkDeactivateRules } from '../../../../api/quality-profiles';
import { mockLanguage, mockQualityProfile } from '../../../../helpers/testMocks';
import { Query } from '../../query';
import BulkChangeModal from '../BulkChangeModal';

it('render correctly', () => {
jest.mock('../../../../api/quality-profiles', () => ({
bulkActivateRules: jest.fn().mockResolvedValue({ failed: 0, succeeded: 2 }),
bulkDeactivateRules: jest.fn().mockResolvedValue({ failed: 2, succeeded: 0 })
}));

beforeEach(jest.clearAllMocks);

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('default');
expect(shallowRender({ profile: undefined })).toMatchSnapshot('no profile pre-selected');
expect(shallowRender({ action: 'deactivate' })).toMatchSnapshot('deactivate action');
expect(
shallow(
<BulkChangeModal
action="activate"
languages={{ js: { key: 'js', name: 'JavaScript' } }}
onClose={jest.fn()}
organization="foo"
profile={mockQualityProfile()}
query={{ languages: ['js'] } as Query}
referencedProfiles={{ foo: mockQualityProfile() }}
total={42}
/>
)
).toMatchSnapshot();
shallowRender().setState({
results: [
{ failed: 2, profile: 'foo', succeeded: 0 },
{ failed: 0, profile: 'bar', succeeded: 2 }
]
})
).toMatchSnapshot('results');
expect(shallowRender().setState({ submitting: true })).toMatchSnapshot('submitting');
expect(shallowRender().setState({ finished: true })).toMatchSnapshot('finished');
});

it('should pre-select a profile if only 1 is available', () => {
const profile = mockQualityProfile({
actions: { edit: true },
isBuiltIn: false,
key: 'foo',
language: 'js'
});
const wrapper = shallowRender({ profile: undefined, referencedProfiles: { foo: profile } });
expect(wrapper.state().selectedProfiles).toEqual(['foo']);
});

it('should handle profile selection', () => {
const wrapper = shallowRender();
wrapper.instance().handleProfileSelect([{ value: 'foo' }, { value: 'bar' }]);
expect(wrapper.state().selectedProfiles).toEqual(['foo', 'bar']);
});

it('should handle form submission', async () => {
const wrapper = shallowRender({ profile: undefined });
wrapper.setState({ selectedProfiles: ['foo', 'bar'] });

// Activate.
submit(wrapper.find('form'));
await waitAndUpdate(wrapper);
expect(bulkActivateRules).toBeCalledWith(expect.objectContaining({ targetKey: 'foo' }));

await waitAndUpdate(wrapper);
expect(bulkActivateRules).toBeCalledWith(expect.objectContaining({ targetKey: 'bar' }));

await waitAndUpdate(wrapper);
expect(wrapper.state().results).toEqual([
{ failed: 0, profile: 'foo', succeeded: 2 },
{ failed: 0, profile: 'bar', succeeded: 2 }
]);

// Deactivate.
wrapper.setProps({ action: 'deactivate' }).setState({ results: [] });
submit(wrapper.find('form'));
await waitAndUpdate(wrapper);
expect(bulkDeactivateRules).toBeCalledWith(expect.objectContaining({ targetKey: 'foo' }));

await waitAndUpdate(wrapper);
expect(bulkDeactivateRules).toBeCalledWith(expect.objectContaining({ targetKey: 'bar' }));

await waitAndUpdate(wrapper);
expect(wrapper.state().results).toEqual([
{ failed: 2, profile: 'foo', succeeded: 0 },
{ failed: 2, profile: 'bar', succeeded: 0 }
]);
});

function shallowRender(props: Partial<BulkChangeModal['props']> = {}) {
return shallow<BulkChangeModal>(
<BulkChangeModal
action="activate"
languages={{ js: mockLanguage() }}
onClose={jest.fn()}
organization={undefined}
profile={mockQualityProfile()}
query={{ languages: ['js'] } as Query}
referencedProfiles={{
foo: mockQualityProfile({ key: 'foo' }),
bar: mockQualityProfile({ key: 'bar' })
}}
total={42}
{...props}
/>
);
}

+ 106
- 31
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleDetails-test.tsx View File

@@ -17,32 +17,34 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
/* eslint-disable sonarjs/no-duplicate-string */
import { shallow } from 'enzyme';
import * as React from 'react';
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { updateRule } from '../../../../api/rules';
import { deleteRule, getRuleDetails, updateRule } from '../../../../api/rules';
import { mockQualityProfile } from '../../../../helpers/testMocks';
import RuleDetails from '../RuleDetails';

jest.mock('../../../../api/rules', () => ({
deleteRule: jest.fn(),
getRuleDetails: jest.fn().mockResolvedValue({
rule: getMockHelpers().mockRuleDetails(),
actives: [
{
qProfile: 'key',
inherit: 'NONE',
severity: 'MAJOR',
params: [],
createdAt: '2017-06-16T16:13:38+0200',
updatedAt: '2017-06-16T16:13:38+0200'
}
]
}),
updateRule: jest.fn().mockResolvedValue({})
}));
const { mockQualityProfile } = getMockHelpers();
const profile = mockQualityProfile();
jest.mock('../../../../api/rules', () => {
const { mockRuleDetails } = jest.requireActual('../../../../helpers/testMocks');
return {
deleteRule: jest.fn().mockResolvedValue(null),
getRuleDetails: jest.fn().mockResolvedValue({
rule: mockRuleDetails(),
actives: [
{
qProfile: 'foo',
inherit: 'NONE',
severity: 'MAJOR',
params: [],
createdAt: '2017-06-16T16:13:38+0200',
updatedAt: '2017-06-16T16:13:38+0200'
}
]
}),
updateRule: jest.fn().mockResolvedValue(null)
};
});

beforeEach(() => {
jest.clearAllMocks();
@@ -50,18 +52,43 @@ beforeEach(() => {

it('should render correctly', async () => {
const wrapper = shallowRender();
expect(wrapper).toMatchSnapshot();
expect(wrapper).toMatchSnapshot('loading');

await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
expect(wrapper).toMatchSnapshot('loaded');

expect(getRuleDetails).toBeCalledWith(
expect.objectContaining({
actives: true,
key: 'squid:S1337'
})
);
});

it('should correctly handle prop changes', async () => {
const ruleKey = 'foo:bar';
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
jest.clearAllMocks();

wrapper.setProps({ ruleKey });
expect(getRuleDetails).toBeCalledWith(
expect.objectContaining({
actives: true,
key: ruleKey
})
);
});

it('should correctly handle tag changes', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);

wrapper.instance().handleTagsChange(['foo', 'bar']);
const ruleDetails = wrapper.state('ruleDetails');
expect(ruleDetails && ruleDetails.tags).toEqual(['foo', 'bar']);
await waitAndUpdate(wrapper);

expect(updateRule).toHaveBeenCalledWith({
key: 'squid:S1337',
organization: undefined,
@@ -69,16 +96,64 @@ it('should correctly handle tag changes', async () => {
});
});

function getMockHelpers() {
// We use this little "force-requiring" instead of an import statement in
// order to prevent a hoisting race condition while mocking. If we want to use
// a mock helper in a Jest mock, we have to require it like this. Otherwise,
// we get errors like:
// ReferenceError: testMocks_1 is not defined
return require.requireActual('../../../../helpers/testMocks');
}
it('should correctly handle rule changes', () => {
const wrapper = shallowRender();
const ruleChange = {
createdAt: '2019-02-01',
key: 'foo',
name: 'Foo',
repo: 'bar',
severity: 'MAJOR',
status: 'READY',
type: 'BUG' as T.RuleType
};

wrapper.instance().handleRuleChange(ruleChange);
expect(wrapper.state().ruleDetails).toBe(ruleChange);
});

it('should correctly handle activation', async () => {
const onActivate = jest.fn();
const wrapper = shallowRender({ onActivate });
await waitAndUpdate(wrapper);

wrapper.instance().handleActivate();
await waitAndUpdate(wrapper);
expect(onActivate).toBeCalledWith(
'foo',
'squid:S1337',
expect.objectContaining({
inherit: 'NONE',
severity: 'MAJOR'
})
);
});

it('should correctly handle deactivation', async () => {
const onDeactivate = jest.fn();
const selectedProfile = mockQualityProfile({ key: 'bar' });
const wrapper = shallowRender({ onDeactivate, selectedProfile });
await waitAndUpdate(wrapper);

wrapper.instance().handleDeactivate();
await waitAndUpdate(wrapper);
expect(onDeactivate).toBeCalledWith(selectedProfile.key, 'squid:S1337');
});

it('should correctly handle deletion', async () => {
const onDelete = jest.fn();
const wrapper = shallowRender({ onDelete });
await waitAndUpdate(wrapper);

wrapper.instance().handleDelete();
await waitAndUpdate(wrapper);
expect(deleteRule).toBeCalledWith(expect.objectContaining({ key: 'squid:S1337' }));
expect(onDelete).toBeCalledWith('squid:S1337');
});

function shallowRender(props: Partial<RuleDetails['props']> = {}) {
const profile = mockQualityProfile({ key: 'foo' });

return shallow<RuleDetails>(
<RuleDetails
onActivate={jest.fn()}

+ 52
- 10
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/RuleListItem-test.tsx View File

@@ -19,26 +19,68 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { Link } from 'react-router';
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { deactivateRule } from '../../../../api/quality-profiles';
import { mockEvent, mockQualityProfile, mockRule } from '../../../../helpers/testMocks';
import RuleListItem from '../RuleListItem';

it('should render', () => {
expect(shallowRender()).toMatchSnapshot();
jest.mock('../../../../api/quality-profiles', () => ({
deactivateRule: jest.fn().mockResolvedValue(null)
}));

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('default');
expect(shallowRender({})).toMatchSnapshot('with activation');
});

it('should open rule', () => {
const onOpen = jest.fn();
const wrapper = shallowRender({ onOpen });
wrapper.find('Link').prop<Function>('onClick')(mockEvent({ button: 0 }));
wrapper.find(Link).simulate('click', mockEvent({ button: 0 }));
expect(onOpen).toBeCalledWith('javascript:S1067');
});

it('should render deactivate button', () => {
const wrapper = shallowRender();
const instance = wrapper.instance();
it('handle activation', () => {
const profile = mockQualityProfile();
const rule = mockRule();
const onActivate = jest.fn();
const wrapper = shallowRender({ onActivate, rule, selectedProfile: profile });

expect(instance.renderDeactivateButton('NONE')).toMatchSnapshot();
expect(instance.renderDeactivateButton('', 'coding_rules.need_extend_or_copy')).toMatchSnapshot();
wrapper.instance().handleActivate('MAJOR');
expect(onActivate).toBeCalledWith(profile.key, rule.key, {
severity: 'MAJOR',
inherit: 'NONE'
});
});

it('handle deactivation', async () => {
const profile = mockQualityProfile();
const rule = mockRule();
const onDeactivate = jest.fn();
const wrapper = shallowRender({ onDeactivate, rule, selectedProfile: profile });

wrapper.instance().handleDeactivate();
expect(deactivateRule).toBeCalledWith(
expect.objectContaining({
key: profile.key,
rule: rule.key
})
);
await waitAndUpdate(wrapper);
expect(onDeactivate).toBeCalledWith(profile.key, rule.key);
});

describe('#renderDeactivateButton', () => {
it('should render correctly', () => {
const wrapper = shallowRender();
const instance = wrapper.instance();

expect(instance.renderDeactivateButton('NONE')).toMatchSnapshot();
expect(
instance.renderDeactivateButton('', 'coding_rules.need_extend_or_copy')
).toMatchSnapshot();
});
});

describe('renderActions', () => {
@@ -123,8 +165,8 @@ function shallowRender(props?: Partial<RuleListItem['props']>) {
onDeactivate={jest.fn()}
onFilterChange={jest.fn()}
onOpen={jest.fn()}
organization="org"
rule={mockRule()}
organization={undefined}
rule={mockRule({ key: 'javascript:S1067' })}
selected={false}
{...props}
/>

+ 252
- 1
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap View File

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

exports[`render correctly 1`] = `
exports[`should render correctly: deactivate action 1`] = `
<Modal
contentLabel="coding_rules.deactivate_in_quality_profile (42 coding_rules._rules)"
onRequestClose={[MockFunction]}
size="small"
>
<form
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
coding_rules.deactivate_in_quality_profile (42 coding_rules._rules)
</h2>
</header>
<div
className="modal-body"
>
<div
className="modal-field"
>
<h3>
<label
htmlFor="coding-rules-bulk-change-profile"
>
coding_rules.deactivate_in
</label>
</h3>
<span>
name
are_you_sure
</span>
</div>
</div>
<footer
className="modal-foot"
>
<SubmitButton
disabled={false}
id="coding-rules-submit-bulk-change"
>
apply
</SubmitButton>
<ResetButtonLink
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`should render correctly: default 1`] = `
<Modal
contentLabel="coding_rules.activate_in_quality_profile (42 coding_rules._rules)"
onRequestClose={[MockFunction]}
size="small"
>
<form
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
coding_rules.activate_in_quality_profile (42 coding_rules._rules)
</h2>
</header>
<div
className="modal-body"
>
<div
className="modal-field"
>
<h3>
<label
htmlFor="coding-rules-bulk-change-profile"
>
coding_rules.activate_in
</label>
</h3>
<span>
name
are_you_sure
</span>
</div>
</div>
<footer
className="modal-foot"
>
<SubmitButton
disabled={false}
id="coding-rules-submit-bulk-change"
>
apply
</SubmitButton>
<ResetButtonLink
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`should render correctly: finished 1`] = `
<Modal
contentLabel="coding_rules.activate_in_quality_profile (42 coding_rules._rules)"
onRequestClose={[MockFunction]}
size="small"
>
<form
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
coding_rules.activate_in_quality_profile (42 coding_rules._rules)
</h2>
</header>
<div
className="modal-body"
/>
<footer
className="modal-foot"
>
<ResetButtonLink
onClick={[MockFunction]}
>
close
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`should render correctly: no profile pre-selected 1`] = `
<Modal
contentLabel="coding_rules.activate_in_quality_profile (42 coding_rules._rules)"
onRequestClose={[MockFunction]}
size="small"
>
<form
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
coding_rules.activate_in_quality_profile (42 coding_rules._rules)
</h2>
</header>
<div
className="modal-body"
>
<div
className="modal-field"
>
<h3>
<label
htmlFor="coding-rules-bulk-change-profile"
>
coding_rules.activate_in
</label>
</h3>
<Select
multi={true}
onChange={[Function]}
options={Array []}
value={Array []}
/>
</div>
</div>
<footer
className="modal-foot"
>
<SubmitButton
disabled={false}
id="coding-rules-submit-bulk-change"
>
apply
</SubmitButton>
<ResetButtonLink
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`should render correctly: results 1`] = `
<Modal
contentLabel="coding_rules.activate_in_quality_profile (42 coding_rules._rules)"
onRequestClose={[MockFunction]}
@@ -19,6 +217,18 @@ exports[`render correctly 1`] = `
<div
className="modal-body"
>
<Alert
key="foo"
variant="warning"
>
coding_rules.bulk_change.warning.name.CSS.0.2
</Alert>
<Alert
key="bar"
variant="success"
>
coding_rules.bulk_change.success.name.CSS.2
</Alert>
<div
className="modal-field"
>
@@ -54,3 +264,44 @@ exports[`render correctly 1`] = `
</form>
</Modal>
`;

exports[`should render correctly: submitting 1`] = `
<Modal
contentLabel="coding_rules.activate_in_quality_profile (42 coding_rules._rules)"
onRequestClose={[MockFunction]}
size="small"
>
<form
onSubmit={[Function]}
>
<header
className="modal-head"
>
<h2>
coding_rules.activate_in_quality_profile (42 coding_rules._rules)
</h2>
</header>
<div
className="modal-body"
/>
<footer
className="modal-foot"
>
<i
className="spinner spacer-right"
/>
<SubmitButton
disabled={true}
id="coding-rules-submit-bulk-change"
>
apply
</SubmitButton>
<ResetButtonLink
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

+ 9
- 9
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/RuleDetails-test.tsx.snap View File

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

exports[`should render correctly 1`] = `
<div
className="coding-rule-details"
/>
`;

exports[`should render correctly 2`] = `
exports[`should render correctly: loaded 1`] = `
<div
className="coding-rule-details"
>
@@ -103,7 +97,7 @@ exports[`should render correctly 2`] = `
"createdAt": "2017-06-16T16:13:38+0200",
"inherit": "NONE",
"params": Array [],
"qProfile": "key",
"qProfile": "foo",
"severity": "MAJOR",
"updatedAt": "2017-06-16T16:13:38+0200",
},
@@ -121,7 +115,7 @@ exports[`should render correctly 2`] = `
"isBuiltIn": false,
"isDefault": false,
"isInherited": false,
"key": "key",
"key": "foo",
"language": "js",
"languageName": "JavaScript",
"name": "name",
@@ -202,3 +196,9 @@ exports[`should render correctly 2`] = `
</DeferredSpinner>
</div>
`;

exports[`should render correctly: loading 1`] = `
<div
className="coding-rule-details"
/>
`;

+ 122
- 23
server/sonar-web/src/main/js/apps/coding-rules/components/__tests__/__snapshots__/RuleListItem-test.tsx.snap View File

@@ -1,5 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`#renderDeactivateButton should render correctly 1`] = `
<ConfirmButton
confirmButtonText="yes"
modalBody="coding_rules.deactivate.confirm"
modalHeader="coding_rules.deactivate"
onConfirm={[Function]}
>
[Function]
</ConfirmButton>
`;

exports[`#renderDeactivateButton should render correctly 2`] = `
<Tooltip
overlay="coding_rules.need_extend_or_copy"
>
<Button
className="coding-rules-detail-quality-profile-deactivate button-red"
disabled={true}
>
coding_rules.deactivate
</Button>
</Tooltip>
`;

exports[`renderActions should disable the button when I am on a built-in profile 1`] = `
<td
className="coding-rule-table-meta-cell coding-rule-activation-actions"
@@ -26,7 +50,6 @@ exports[`renderActions should render the activate button 1`] = `
className="coding-rules-detail-quality-profile-activate"
modalHeader="coding_rules.activate_in_quality_profile"
onDone={[Function]}
organization="org"
profiles={
Array [
Object {
@@ -87,7 +110,7 @@ exports[`renderActions should render the deactivate button 1`] = `
</td>
`;

exports[`should render 1`] = `
exports[`should render correctly: default 1`] = `
<div
className="coding-rule"
data-rule="javascript:S1067"
@@ -108,7 +131,7 @@ exports[`should render 1`] = `
style={Object {}}
to={
Object {
"pathname": "/organizations/org/rules",
"pathname": "/coding_rules",
"query": Object {
"open": "javascript:S1067",
"rule_key": "javascript:S1067",
@@ -187,26 +210,102 @@ exports[`should render 1`] = `
</div>
`;

exports[`should render deactivate button 1`] = `
<ConfirmButton
confirmButtonText="yes"
modalBody="coding_rules.deactivate.confirm"
modalHeader="coding_rules.deactivate"
onConfirm={[Function]}
>
[Function]
</ConfirmButton>
`;

exports[`should render deactivate button 2`] = `
<Tooltip
overlay="coding_rules.need_extend_or_copy"
exports[`should render correctly: with activation 1`] = `
<div
className="coding-rule"
data-rule="javascript:S1067"
>
<Button
className="coding-rules-detail-quality-profile-deactivate button-red"
disabled={true}
<table
className="coding-rule-table"
>
coding_rules.deactivate
</Button>
</Tooltip>
<tbody>
<tr>
<td>
<div
className="coding-rule-title"
>
<Link
className="link-no-underline"
onClick={[Function]}
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"open": "javascript:S1067",
"rule_key": "javascript:S1067",
},
}
}
>
Use foo
</Link>
</div>
</td>
<td
className="coding-rule-table-meta-cell"
>
<div
className="display-flex-center coding-rule-meta"
>
<span
className="display-inline-flex-center spacer-left note"
>
JavaScript
</span>
<Tooltip
overlay="coding_rules.type.tooltip.CODE_SMELL"
>
<span
className="display-inline-flex-center spacer-left note"
>
<IssueTypeIcon
query="CODE_SMELL"
/>
<span
className="little-spacer-left text-middle"
>
issue.type.CODE_SMELL
</span>
</span>
</Tooltip>
<TagsList
allowUpdate={false}
className="display-inline-flex-center note spacer-left"
tags={
Array [
"x",
"a",
"b",
]
}
/>
<SimilarRulesFilter
onFilterChange={[MockFunction]}
rule={
Object {
"key": "javascript:S1067",
"lang": "js",
"langName": "JavaScript",
"name": "Use foo",
"severity": "MAJOR",
"status": "READY",
"sysTags": Array [
"a",
"b",
],
"tags": Array [
"x",
],
"type": "CODE_SMELL",
}
}
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
`;

+ 42
- 42
server/sonar-web/src/main/js/apps/quality-gates/components/DetailsContent.tsx View File

@@ -23,7 +23,7 @@ import DocTooltip from '../../../components/docs/DocTooltip';
import Conditions from './Conditions';
import Projects from './Projects';

interface Props {
export interface DetailsContentProps {
isDefault?: boolean;
metrics: T.Dict<T.Metric>;
organization?: string;
@@ -33,48 +33,48 @@ interface Props {
qualityGate: T.QualityGate;
}

export default class DetailsContent extends React.PureComponent<Props> {
render() {
const { isDefault, metrics, organization, qualityGate } = this.props;
const conditions = qualityGate.conditions || [];
const actions = qualityGate.actions || ({} as any);
export function DetailsContent(props: DetailsContentProps) {
const { isDefault, metrics, organization, qualityGate } = props;
const conditions = qualityGate.conditions || [];
const actions = qualityGate.actions || ({} as any);

return (
<div className="layout-page-main-inner">
<Conditions
canEdit={actions.manageConditions}
conditions={conditions}
metrics={metrics}
onAddCondition={this.props.onAddCondition}
onRemoveCondition={this.props.onRemoveCondition}
onSaveCondition={this.props.onSaveCondition}
organization={organization}
qualityGate={qualityGate}
/>
return (
<div className="layout-page-main-inner">
<Conditions
canEdit={actions.manageConditions}
conditions={conditions}
metrics={metrics}
onAddCondition={props.onAddCondition}
onRemoveCondition={props.onRemoveCondition}
onSaveCondition={props.onSaveCondition}
organization={organization}
qualityGate={qualityGate}
/>

<div className="quality-gate-section" id="quality-gate-projects">
<header className="display-flex-center spacer-bottom">
<h3>{translate('quality_gates.projects')}</h3>
<DocTooltip
className="spacer-left"
doc={import(
/* webpackMode: "eager" */ 'Docs/tooltips/quality-gates/quality-gate-projects.md'
)}
/>
</header>
{isDefault ? (
translate('quality_gates.projects_for_default')
) : (
<Projects
canEdit={actions.associateProjects}
// pass unique key to re-mount the component when the quality gate changes
key={qualityGate.id}
organization={organization}
qualityGate={qualityGate}
/>
)}
</div>
<div className="quality-gate-section" id="quality-gate-projects">
<header className="display-flex-center spacer-bottom">
<h3>{translate('quality_gates.projects')}</h3>
<DocTooltip
className="spacer-left"
doc={import(
/* webpackMode: "eager" */ 'Docs/tooltips/quality-gates/quality-gate-projects.md'
)}
/>
</header>
{isDefault ? (
translate('quality_gates.projects_for_default')
) : (
<Projects
canEdit={actions.associateProjects}
// pass unique key to re-mount the component when the quality gate changes
key={qualityGate.id}
organization={organization}
qualityGate={qualityGate}
/>
)}
</div>
);
}
</div>
);
}

export default React.memo(DetailsContent);

+ 131
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/Details-test.tsx View File

@@ -0,0 +1,131 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { fetchQualityGate } from '../../../../api/quality-gates';
import { mockCondition, mockQualityGate } from '../../../../helpers/testMocks';
import { addCondition, deleteCondition, replaceCondition } from '../../utils';
import { Details } from '../Details';

jest.mock('../../../../api/quality-gates', () => {
const { mockQualityGate } = jest.requireActual('../../../../helpers/testMocks');
return {
fetchQualityGate: jest.fn().mockResolvedValue(mockQualityGate())
};
});

jest.mock('../../utils', () => ({
checkIfDefault: jest.fn(() => false),
addCondition: jest.fn(qg => qg),
deleteCondition: jest.fn(qg => qg),
replaceCondition: jest.fn(qg => qg)
}));

beforeEach(jest.clearAllMocks);

it('should render correctly', async () => {
const wrapper = shallowRender();
expect(wrapper).toMatchSnapshot('loading');

await waitAndUpdate(wrapper);
expect(fetchQualityGate).toBeCalledWith({ id: '1' });
expect(wrapper).toMatchSnapshot('loaded');
});

it('should refresh if the QG id changes', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
jest.clearAllMocks();

wrapper.setProps({ id: '2' });
expect(fetchQualityGate).toBeCalledWith({ id: '2' });
});

it('should fetch metrics on mount', () => {
const fetchMetrics = jest.fn();
shallowRender({ fetchMetrics });
expect(fetchMetrics).toBeCalled();
});

it('should correctly add/replace/remove conditions', async () => {
const qualityGate = mockQualityGate();
(fetchQualityGate as jest.Mock).mockResolvedValue(qualityGate);

const wrapper = shallowRender();
const instance = wrapper.instance();

instance.handleAddCondition(mockCondition());
expect(wrapper.state().qualityGate).toBeUndefined();

instance.handleSaveCondition(mockCondition(), mockCondition());
expect(wrapper.state().qualityGate).toBeUndefined();

instance.handleRemoveCondition(mockCondition());
expect(wrapper.state().qualityGate).toBeUndefined();

await waitAndUpdate(wrapper);

const newCondition = mockCondition({ metric: 'bugs', id: 2 });
instance.handleAddCondition(newCondition);
expect(addCondition).toBeCalledWith(qualityGate, newCondition);

const updatedCondition = mockCondition({ metric: 'new_bugs' });
instance.handleSaveCondition(newCondition, updatedCondition);
expect(replaceCondition).toBeCalledWith(qualityGate, newCondition, updatedCondition);

instance.handleRemoveCondition(newCondition);
expect(deleteCondition).toBeCalledWith(qualityGate, newCondition);
});

it('should correctly handle setting default', async () => {
const qualityGate = mockQualityGate();
(fetchQualityGate as jest.Mock).mockResolvedValue(qualityGate);

const onSetDefault = jest.fn();
const wrapper = shallowRender({ onSetDefault });

wrapper.instance().handleSetDefault();
expect(wrapper.state().qualityGate).toBeUndefined();

await waitAndUpdate(wrapper);

wrapper.instance().handleSetDefault();
expect(wrapper.state().qualityGate).toEqual(
expect.objectContaining({
id: qualityGate.id,
actions: { delete: false, setAsDefault: false }
})
);
});

function shallowRender(props: Partial<Details['props']> = {}) {
return shallow<Details>(
<Details
fetchMetrics={jest.fn()}
id="1"
metrics={{}}
onSetDefault={jest.fn()}
qualityGates={[mockQualityGate()]}
refreshQualityGates={jest.fn()}
{...props}
/>
);
}

server/sonar-web/src/main/js/apps/sessions/components/LoginSonarCloud.css → server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/DetailsContent-test.tsx View File

@@ -17,47 +17,25 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
.sonarcloud-login-alert {
margin: 10vh auto 5vh auto;
width: 256px;
}

.sonarcloud-login-page {
margin-top: 15vh;
width: 216px;
margin-left: auto;
margin-right: auto;
padding: calc(4 * var(--gridSize)) 20px;
}

.sonarcloud-login-alert ~ .sonarcloud-login-page {
margin-top: 0;
}

.sonarcloud-login-page-large {
width: 300px;
}

.sonarcloud-login-title {
line-height: 1.5;
font-size: var(--bigFontSize);
font-weight: 300;
width: 135px;
margin: var(--gridSize) auto calc(3 * var(--gridSize));
}

.sonarcloud-oauth-providers.oauth-providers > ul {
width: 186px;
}

.sonarcloud-oauth-providers.oauth-providers > ul > li {
margin-bottom: var(--gridSize);
}

.sonarcloud-oauth-providers.oauth-providers .oauth-providers-help {
right: -22px;
}

.sonarcloud-login-cancel {
text-align: center;
import { shallow } from 'enzyme';
import * as React from 'react';
import { mockQualityGate } from '../../../../helpers/testMocks';
import { DetailsContent, DetailsContentProps } from '../DetailsContent';

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('is not default');
expect(shallowRender({ isDefault: true })).toMatchSnapshot('is default');
});

function shallowRender(props: Partial<DetailsContentProps> = {}) {
return shallow(
<DetailsContent
metrics={{}}
onAddCondition={jest.fn()}
onRemoveCondition={jest.fn()}
onSaveCondition={jest.fn()}
qualityGate={mockQualityGate()}
{...props}
/>
);
}

+ 88
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/DetailsHeader-test.tsx View File

@@ -0,0 +1,88 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 { click, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { setQualityGateAsDefault } from '../../../../api/quality-gates';
import { mockQualityGate } from '../../../../helpers/testMocks';
import DetailsHeader from '../DetailsHeader';

jest.mock('../../../../api/quality-gates', () => ({
setQualityGateAsDefault: jest.fn().mockResolvedValue(null)
}));

beforeEach(jest.clearAllMocks);

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('default');
expect(shallowRender({ qualityGate: mockQualityGate({ isBuiltIn: true }) })).toMatchSnapshot(
'built-in'
);
expect(
shallowRender({
qualityGate: mockQualityGate({
actions: {
copy: true,
delete: true,
rename: true,
setAsDefault: true
}
})
})
).toMatchSnapshot('admin actions');
});

it('should allow the QG to be set as the default', async () => {
const onSetDefault = jest.fn();
const refreshItem = jest.fn();
const refreshList = jest.fn();

const qualityGate = mockQualityGate({ id: 1, actions: { setAsDefault: true } });
const wrapper = shallowRender({ onSetDefault, qualityGate, refreshItem, refreshList });

click(wrapper.find('Button#quality-gate-toggle-default'));
expect(setQualityGateAsDefault).toBeCalledWith({ id: 1 });
expect(onSetDefault).toBeCalled();
await waitAndUpdate(wrapper);
expect(refreshItem).toBeCalled();
expect(refreshList).toBeCalled();

jest.clearAllMocks();

wrapper.setProps({ qualityGate: mockQualityGate({ ...qualityGate, isDefault: true }) });
click(wrapper.find('Button#quality-gate-toggle-default'));
expect(setQualityGateAsDefault).not.toBeCalled();
expect(onSetDefault).not.toBeCalled();
await waitAndUpdate(wrapper);
expect(refreshItem).not.toBeCalled();
expect(refreshList).not.toBeCalled();
});

function shallowRender(props: Partial<DetailsHeader['props']> = {}) {
return shallow<DetailsHeader>(
<DetailsHeader
onSetDefault={jest.fn()}
qualityGate={mockQualityGate()}
refreshItem={jest.fn().mockResolvedValue(null)}
refreshList={jest.fn().mockResolvedValue(null)}
{...props}
/>
);
}

+ 53
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/Details-test.tsx.snap View File

@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly: loaded 1`] = `
<div
className="layout-page-main"
>
<DeferredSpinner
loading={false}
timeout={200}
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
title="qualitygate"
/>
<DetailsHeader
onSetDefault={[Function]}
qualityGate={
Object {
"id": 1,
"name": "qualitygate",
}
}
refreshItem={[Function]}
refreshList={[MockFunction]}
/>
<Memo(DetailsContent)
isDefault={false}
metrics={Object {}}
onAddCondition={[Function]}
onRemoveCondition={[Function]}
onSaveCondition={[Function]}
qualityGate={
Object {
"id": 1,
"name": "qualitygate",
}
}
/>
</DeferredSpinner>
</div>
`;

exports[`should render correctly: loading 1`] = `
<div
className="layout-page-main"
>
<DeferredSpinner
loading={true}
timeout={200}
/>
</div>
`;

+ 83
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/DetailsContent-test.tsx.snap View File

@@ -0,0 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly: is default 1`] = `
<div
className="layout-page-main-inner"
>
<Connect(withAppState(Conditions))
conditions={Array []}
metrics={Object {}}
onAddCondition={[MockFunction]}
onRemoveCondition={[MockFunction]}
onSaveCondition={[MockFunction]}
qualityGate={
Object {
"id": 1,
"name": "qualitygate",
}
}
/>
<div
className="quality-gate-section"
id="quality-gate-projects"
>
<header
className="display-flex-center spacer-bottom"
>
<h3>
quality_gates.projects
</h3>
<DocTooltip
className="spacer-left"
doc={Promise {}}
/>
</header>
quality_gates.projects_for_default
</div>
</div>
`;

exports[`should render correctly: is not default 1`] = `
<div
className="layout-page-main-inner"
>
<Connect(withAppState(Conditions))
conditions={Array []}
metrics={Object {}}
onAddCondition={[MockFunction]}
onRemoveCondition={[MockFunction]}
onSaveCondition={[MockFunction]}
qualityGate={
Object {
"id": 1,
"name": "qualitygate",
}
}
/>
<div
className="quality-gate-section"
id="quality-gate-projects"
>
<header
className="display-flex-center spacer-bottom"
>
<h3>
quality_gates.projects
</h3>
<DocTooltip
className="spacer-left"
doc={Promise {}}
/>
</header>
<Projects
key="1"
qualityGate={
Object {
"id": 1,
"name": "qualitygate",
}
}
/>
</div>
</div>
`;

+ 112
- 0
server/sonar-web/src/main/js/apps/quality-gates/components/__tests__/__snapshots__/DetailsHeader-test.tsx.snap View File

@@ -0,0 +1,112 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly: admin actions 1`] = `
<div
className="layout-page-header-panel layout-page-main-header issues-main-header"
>
<div
className="layout-page-header-panel-inner layout-page-main-header-inner"
>
<div
className="layout-page-main-inner"
>
<div
className="pull-left display-flex-center"
>
<h2>
qualitygate
</h2>
</div>
<div
className="pull-right"
>
<ModalButton
modal={[Function]}
>
<Component />
</ModalButton>
<ModalButton
modal={[Function]}
>
<Component />
</ModalButton>
<Button
className="little-spacer-left"
id="quality-gate-toggle-default"
onClick={[Function]}
>
set_as_default
</Button>
<withRouter(DeleteQualityGateForm)
onDelete={[MockFunction]}
qualityGate={
Object {
"actions": Object {
"copy": true,
"delete": true,
"rename": true,
"setAsDefault": true,
},
"id": 1,
"name": "qualitygate",
}
}
/>
</div>
</div>
</div>
</div>
`;

exports[`should render correctly: built-in 1`] = `
<div
className="layout-page-header-panel layout-page-main-header issues-main-header"
>
<div
className="layout-page-header-panel-inner layout-page-main-header-inner"
>
<div
className="layout-page-main-inner"
>
<div
className="pull-left display-flex-center"
>
<h2>
qualitygate
</h2>
<BuiltInQualityGateBadge
className="spacer-left"
/>
</div>
<div
className="pull-right"
/>
</div>
</div>
</div>
`;

exports[`should render correctly: default 1`] = `
<div
className="layout-page-header-panel layout-page-main-header issues-main-header"
>
<div
className="layout-page-header-panel-inner layout-page-main-header-inner"
>
<div
className="layout-page-main-inner"
>
<div
className="pull-left display-flex-center"
>
<h2>
qualitygate
</h2>
</div>
<div
className="pull-right"
/>
</div>
</div>
</div>
`;

+ 18
- 8
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsForm.tsx View File

@@ -62,21 +62,31 @@ export default class ProfilePermissionsForm extends React.PureComponent<Props, S
}
};

handleUserAdd = (user: T.UserSelected) =>
handleUserAdd = (user: T.UserSelected) => {
const {
profile: { language, name },
organization
} = this.props;
addUser({
language: this.props.profile.language,
language,
login: user.login,
organization: this.props.organization,
qualityProfile: this.props.profile.name
organization,
qualityProfile: name
}).then(() => this.props.onUserAdd(user), this.stopSubmitting);
};

handleGroupAdd = (group: Group) =>
handleGroupAdd = (group: Group) => {
const {
profile: { language, name },
organization
} = this.props;
addGroup({
group: group.name,
language: this.props.profile.language,
organization: this.props.organization,
qualityProfile: this.props.profile.name
language,
organization,
qualityProfile: name
}).then(() => this.props.onGroupAdd(group), this.stopSubmitting);
};

handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();

+ 71
- 52
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfilePermissionsForm-test.tsx View File

@@ -17,76 +17,95 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
/* eslint-disable import/first */
jest.mock('../../../../api/quality-profiles', () => ({
addUser: jest.fn(() => Promise.resolve()),
addGroup: jest.fn(() => Promise.resolve())
}));

import { shallow } from 'enzyme';
import * as React from 'react';
import { submit } from 'sonar-ui-common/helpers/testUtils';
import { submit, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { addGroup, addUser, searchGroups, searchUsers } from '../../../../api/quality-profiles';
import { mockGroup, mockUser } from '../../../../helpers/testMocks';
import ProfilePermissionsForm from '../ProfilePermissionsForm';
import ProfilePermissionsFormSelect from '../ProfilePermissionsFormSelect';

const addUser = require('../../../../api/quality-profiles').addUser as jest.Mock<any>;
const addGroup = require('../../../../api/quality-profiles').addGroup as jest.Mock<any>;
jest.mock('../../../../api/quality-profiles', () => ({
addUser: jest.fn().mockResolvedValue(null),
addGroup: jest.fn().mockResolvedValue(null),
searchGroups: jest.fn().mockResolvedValue({ groups: [] }),
searchUsers: jest.fn().mockResolvedValue({ users: [] })
}));

const profile = { language: 'js', name: 'Sonar way' };
const PROFILE = { language: 'js', name: 'Sonar way' };

it('adds user', async () => {
it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('default');
expect(shallowRender().setState({ submitting: true })).toMatchSnapshot('submitting');
});

it('correctly adds users', async () => {
const onUserAdd = jest.fn();
const wrapper = shallow(
<ProfilePermissionsForm
onClose={jest.fn()}
onGroupAdd={jest.fn()}
onUserAdd={onUserAdd}
organization="org"
profile={profile}
/>
);
expect(wrapper).toMatchSnapshot();
const wrapper = shallowRender({ onUserAdd });

wrapper.setState({ selected: { login: 'luke' } });
expect(wrapper).toMatchSnapshot();
const user: T.UserSelected = { ...mockUser(), name: 'John doe', active: true, selected: true };
wrapper.instance().handleValueChange(user);
expect(wrapper.find(ProfilePermissionsFormSelect).prop('selected')).toBe(user);

submit(wrapper.find('form'));
expect(wrapper).toMatchSnapshot();
expect(addUser).toBeCalledWith({
language: 'js',
login: 'luke',
organization: 'org',
qualityProfile: 'Sonar way'
});
expect(addUser).toBeCalledWith(
expect.objectContaining({
language: PROFILE.language,
qualityProfile: PROFILE.name,
login: user.login
})
);

await new Promise(setImmediate);
expect(onUserAdd).toBeCalledWith({ login: 'luke' });
await waitAndUpdate(wrapper);
expect(onUserAdd).toBeCalledWith(user);
});

it('adds group', async () => {
it('correctly adds groups', async () => {
const onGroupAdd = jest.fn();
const wrapper = shallow(
<ProfilePermissionsForm
onClose={jest.fn()}
onGroupAdd={onGroupAdd}
onUserAdd={jest.fn()}
organization="org"
profile={profile}
/>
);
expect(wrapper).toMatchSnapshot();
const wrapper = shallowRender({ onGroupAdd });

wrapper.setState({ selected: { name: 'lambda' } });
expect(wrapper).toMatchSnapshot();
const group = mockGroup();
wrapper.instance().handleValueChange(group);
expect(wrapper.find(ProfilePermissionsFormSelect).prop('selected')).toBe(group);

submit(wrapper.find('form'));
expect(wrapper).toMatchSnapshot();
expect(addGroup).toBeCalledWith({
group: 'lambda',
language: 'js',
organization: 'org',
qualityProfile: 'Sonar way'
});
expect(addGroup).toBeCalledWith(
expect.objectContaining({
language: PROFILE.language,
qualityProfile: PROFILE.name,
group: group.name
})
);

await new Promise(setImmediate);
expect(onGroupAdd).toBeCalledWith({ name: 'lambda' });
await waitAndUpdate(wrapper);
expect(onGroupAdd).toBeCalledWith(group);
});

it('correctly handles search', () => {
const wrapper = shallowRender();
wrapper.instance().handleSearch('foo');

const parameters = {
language: PROFILE.language,
q: 'foo',
qualityProfile: PROFILE.name,
selected: 'deselected'
};

expect(searchUsers).toBeCalledWith(parameters);
expect(searchGroups).toBeCalledWith(parameters);
});

function shallowRender(props: Partial<ProfilePermissionsForm['props']> = {}) {
return shallow<ProfilePermissionsForm>(
<ProfilePermissionsForm
onClose={jest.fn()}
onGroupAdd={jest.fn()}
onUserAdd={jest.fn()}
profile={PROFILE}
{...props}
/>
);
}

+ 16
- 113
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsForm-test.tsx.snap View File

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

exports[`adds group 1`] = `
<Modal
contentLabel="quality_profiles.grant_permissions_to_user_or_group"
onRequestClose={[MockFunction]}
>
<header
className="modal-head"
>
<h2>
quality_profiles.grant_permissions_to_user_or_group
</h2>
</header>
<form
onSubmit={[Function]}
>
<div
className="modal-body"
>
<div
className="modal-field"
>
<label>
quality_profiles.search_description
</label>
<ProfilePermissionsFormSelect
onChange={[Function]}
onSearch={[Function]}
/>
</div>
</div>
<footer
className="modal-foot"
>
<SubmitButton
disabled={true}
>
add_verb
</SubmitButton>
<ResetButtonLink
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`adds group 2`] = `
exports[`correctly adds groups 1`] = `
<Modal
contentLabel="quality_profiles.grant_permissions_to_user_or_group"
onRequestClose={[MockFunction]}
@@ -77,7 +29,9 @@ exports[`adds group 2`] = `
onSearch={[Function]}
selected={
Object {
"name": "lambda",
"id": 1,
"membersCount": 1,
"name": "Foo",
}
}
/>
@@ -86,8 +40,11 @@ exports[`adds group 2`] = `
<footer
className="modal-foot"
>
<i
className="spinner spacer-right"
/>
<SubmitButton
disabled={false}
disabled={true}
>
add_verb
</SubmitButton>
@@ -101,7 +58,7 @@ exports[`adds group 2`] = `
</Modal>
`;

exports[`adds group 3`] = `
exports[`correctly adds users 1`] = `
<Modal
contentLabel="quality_profiles.grant_permissions_to_user_or_group"
onRequestClose={[MockFunction]}
@@ -130,7 +87,11 @@ exports[`adds group 3`] = `
onSearch={[Function]}
selected={
Object {
"name": "lambda",
"active": true,
"local": true,
"login": "john.doe",
"name": "John doe",
"selected": true,
}
}
/>
@@ -157,7 +118,7 @@ exports[`adds group 3`] = `
</Modal>
`;

exports[`adds user 1`] = `
exports[`should render correctly: default 1`] = `
<Modal
contentLabel="quality_profiles.grant_permissions_to_user_or_group"
onRequestClose={[MockFunction]}
@@ -205,7 +166,7 @@ exports[`adds user 1`] = `
</Modal>
`;

exports[`adds user 2`] = `
exports[`should render correctly: submitting 1`] = `
<Modal
contentLabel="quality_profiles.grant_permissions_to_user_or_group"
onRequestClose={[MockFunction]}
@@ -232,64 +193,6 @@ exports[`adds user 2`] = `
<ProfilePermissionsFormSelect
onChange={[Function]}
onSearch={[Function]}
selected={
Object {
"login": "luke",
}
}
/>
</div>
</div>
<footer
className="modal-foot"
>
<SubmitButton
disabled={false}
>
add_verb
</SubmitButton>
<ResetButtonLink
onClick={[MockFunction]}
>
cancel
</ResetButtonLink>
</footer>
</form>
</Modal>
`;

exports[`adds user 3`] = `
<Modal
contentLabel="quality_profiles.grant_permissions_to_user_or_group"
onRequestClose={[MockFunction]}
>
<header
className="modal-head"
>
<h2>
quality_profiles.grant_permissions_to_user_or_group
</h2>
</header>
<form
onSubmit={[Function]}
>
<div
className="modal-body"
>
<div
className="modal-field"
>
<label>
quality_profiles.search_description
</label>
<ProfilePermissionsFormSelect
onChange={[Function]}
onSearch={[Function]}
selected={
Object {
"login": "luke",
}
}
/>
</div>
</div>

+ 81
- 96
server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListRow.tsx View File

@@ -29,113 +29,98 @@ import ProfileDate from '../components/ProfileDate';
import ProfileLink from '../components/ProfileLink';
import { Profile } from '../types';

interface Props {
export interface ProfilesListRowProps {
organization: string | null;
profile: Profile;
updateProfiles: () => Promise<void>;
}

export default class ProfilesListRow extends React.PureComponent<Props> {
renderName() {
const { profile } = this.props;
const offset = 25 * (profile.depth - 1);
return (
<div className="display-flex-center" style={{ paddingLeft: offset }}>
<div>
<ProfileLink
language={profile.language}
name={profile.name}
organization={this.props.organization}>
{profile.name}
</ProfileLink>
</div>
{profile.isBuiltIn && <BuiltInQualityProfileBadge className="spacer-left" />}
</div>
);
}

renderProjects() {
const { profile } = this.props;
export function ProfilesListRow(props: ProfilesListRowProps) {
const { organization, profile } = props;

if (profile.isDefault) {
return (
<DocTooltip
doc={import(
/* webpackMode: "eager" */ 'Docs/tooltips/quality-profiles/default-quality-profile.md'
)}>
<span className="badge">{translate('default')}</span>
</DocTooltip>
);
}
const offset = 25 * (profile.depth - 1);
const activeRulesUrl = getRulesUrl(
{
qprofile: profile.key,
activation: 'true'
},
organization
);
const deprecatedRulesUrl = getRulesUrl(
{
qprofile: profile.key,
activation: 'true',
statuses: 'DEPRECATED'
},
organization
);

return <span>{profile.projectCount}</span>;
}
return (
<tr
className="quality-profiles-table-row text-middle"
data-key={profile.key}
data-name={profile.name}>
<td className="quality-profiles-table-name text-middle">
<div className="display-flex-center" style={{ paddingLeft: offset }}>
<div>
<ProfileLink
language={profile.language}
name={profile.name}
organization={organization}>
{profile.name}
</ProfileLink>
</div>
{profile.isBuiltIn && <BuiltInQualityProfileBadge className="spacer-left" />}
</div>
</td>

renderRules() {
const { profile } = this.props;
<td className="quality-profiles-table-projects thin nowrap text-middle text-right">
{profile.isDefault ? (
<DocTooltip
doc={import(
/* webpackMode: "eager" */ 'Docs/tooltips/quality-profiles/default-quality-profile.md'
)}>
<span className="badge">{translate('default')}</span>
</DocTooltip>
) : (
<span>{profile.projectCount}</span>
)}
</td>

const activeRulesUrl = getRulesUrl(
{
qprofile: profile.key,
activation: 'true'
},
this.props.organization
);
<td className="quality-profiles-table-rules thin nowrap text-middle text-right">
<div>
{profile.activeDeprecatedRuleCount > 0 && (
<span className="spacer-right">
<Tooltip overlay={translate('quality_profiles.deprecated_rules')}>
<Link className="badge badge-error" to={deprecatedRulesUrl}>
{profile.activeDeprecatedRuleCount}
</Link>
</Tooltip>
</span>
)}

const deprecatedRulesUrl = getRulesUrl(
{
qprofile: profile.key,
activation: 'true',
statuses: 'DEPRECATED'
},
this.props.organization
);
<Link to={activeRulesUrl}>{profile.activeRuleCount}</Link>
</div>
</td>

return (
<div>
{profile.activeDeprecatedRuleCount > 0 && (
<span className="spacer-right">
<Tooltip overlay={translate('quality_profiles.deprecated_rules')}>
<Link className="badge badge-error" to={deprecatedRulesUrl}>
{profile.activeDeprecatedRuleCount}
</Link>
</Tooltip>
</span>
)}
<td className="quality-profiles-table-date thin nowrap text-middle text-right">
<ProfileDate date={profile.rulesUpdatedAt} />
</td>

<Link to={activeRulesUrl}>{profile.activeRuleCount}</Link>
</div>
);
}
<td className="quality-profiles-table-date thin nowrap text-middle text-right">
<ProfileDate date={profile.lastUsed} />
</td>

render() {
return (
<tr
className="quality-profiles-table-row text-middle"
data-key={this.props.profile.key}
data-name={this.props.profile.name}>
<td className="quality-profiles-table-name text-middle">{this.renderName()}</td>
<td className="quality-profiles-table-projects thin nowrap text-middle text-right">
{this.renderProjects()}
</td>
<td className="quality-profiles-table-rules thin nowrap text-middle text-right">
{this.renderRules()}
</td>
<td className="quality-profiles-table-date thin nowrap text-middle text-right">
<ProfileDate date={this.props.profile.rulesUpdatedAt} />
</td>
<td className="quality-profiles-table-date thin nowrap text-middle text-right">
<ProfileDate date={this.props.profile.lastUsed} />
</td>
<td className="quality-profiles-table-actions thin nowrap text-middle text-right">
<ProfileActions
fromList={true}
organization={this.props.organization}
profile={this.props.profile}
updateProfiles={this.props.updateProfiles}
/>
</td>
</tr>
);
}
<td className="quality-profiles-table-actions thin nowrap text-middle text-right">
<ProfileActions
fromList={true}
organization={organization}
profile={profile}
updateProfiles={props.updateProfiles}
/>
</td>
</tr>
);
}

export default React.memo(ProfilesListRow);

+ 47
- 0
server/sonar-web/src/main/js/apps/quality-profiles/home/__tests__/ProfilesListRow-test.tsx View File

@@ -0,0 +1,47 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 { mockQualityProfile } from '../../../../helpers/testMocks';
import { ProfilesListRow, ProfilesListRowProps } from '../ProfilesListRow';

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('default');
expect(shallowRender({ profile: mockQualityProfile({ isBuiltIn: true }) })).toMatchSnapshot(
'built-in profile'
);
expect(shallowRender({ profile: mockQualityProfile({ isDefault: true }) })).toMatchSnapshot(
'default profile'
);
expect(
shallowRender({ profile: mockQualityProfile({ activeDeprecatedRuleCount: 10 }) })
).toMatchSnapshot('with deprecated rules');
});

function shallowRender(props: Partial<ProfilesListRowProps> = {}) {
return shallow(
<ProfilesListRow
organization={null}
profile={mockQualityProfile({ activeDeprecatedRuleCount: 0 })}
updateProfiles={jest.fn()}
{...props}
/>
);
}

+ 3
- 3
server/sonar-web/src/main/js/apps/quality-profiles/home/__tests__/__snapshots__/ProfilesList-test.tsx.snap View File

@@ -62,7 +62,7 @@ exports[`should render correctly 1`] = `
</tr>
</thead>
<tbody>
<ProfilesListRow
<Memo(ProfilesListRow)
key="key"
organization="foo"
profile={
@@ -132,7 +132,7 @@ exports[`should render correctly 1`] = `
</tr>
</thead>
<tbody>
<ProfilesListRow
<Memo(ProfilesListRow)
key="key"
organization="foo"
profile={
@@ -223,7 +223,7 @@ exports[`should render correctly 2`] = `
</tr>
</thead>
<tbody>
<ProfilesListRow
<Memo(ProfilesListRow)
key="key"
organization="foo"
profile={

+ 465
- 0
server/sonar-web/src/main/js/apps/quality-profiles/home/__tests__/__snapshots__/ProfilesListRow-test.tsx.snap View File

@@ -0,0 +1,465 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly: built-in profile 1`] = `
<tr
className="quality-profiles-table-row text-middle"
data-key="key"
data-name="name"
>
<td
className="quality-profiles-table-name text-middle"
>
<div
className="display-flex-center"
style={
Object {
"paddingLeft": 0,
}
}
>
<div>
<ProfileLink
language="js"
name="name"
organization={null}
>
name
</ProfileLink>
</div>
<BuiltInQualityProfileBadge
className="spacer-left"
/>
</div>
</td>
<td
className="quality-profiles-table-projects thin nowrap text-middle text-right"
>
<span>
3
</span>
</td>
<td
className="quality-profiles-table-rules thin nowrap text-middle text-right"
>
<div>
<span
className="spacer-right"
>
<Tooltip
overlay="quality_profiles.deprecated_rules"
>
<Link
className="badge badge-error"
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"activation": "true",
"qprofile": "key",
"statuses": "DEPRECATED",
},
}
}
>
2
</Link>
</Tooltip>
</span>
<Link
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"activation": "true",
"qprofile": "key",
},
}
}
>
10
</Link>
</div>
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-actions thin nowrap text-middle text-right"
>
<withRouter(ProfileActions)
fromList={true}
organization={null}
profile={
Object {
"activeDeprecatedRuleCount": 2,
"activeRuleCount": 10,
"childrenCount": 0,
"depth": 1,
"isBuiltIn": true,
"isDefault": false,
"isInherited": false,
"key": "key",
"language": "js",
"languageName": "JavaScript",
"name": "name",
"organization": "foo",
"projectCount": 3,
}
}
updateProfiles={[MockFunction]}
/>
</td>
</tr>
`;

exports[`should render correctly: default 1`] = `
<tr
className="quality-profiles-table-row text-middle"
data-key="key"
data-name="name"
>
<td
className="quality-profiles-table-name text-middle"
>
<div
className="display-flex-center"
style={
Object {
"paddingLeft": 0,
}
}
>
<div>
<ProfileLink
language="js"
name="name"
organization={null}
>
name
</ProfileLink>
</div>
</div>
</td>
<td
className="quality-profiles-table-projects thin nowrap text-middle text-right"
>
<span>
3
</span>
</td>
<td
className="quality-profiles-table-rules thin nowrap text-middle text-right"
>
<div>
<Link
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"activation": "true",
"qprofile": "key",
},
}
}
>
10
</Link>
</div>
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-actions thin nowrap text-middle text-right"
>
<withRouter(ProfileActions)
fromList={true}
organization={null}
profile={
Object {
"activeDeprecatedRuleCount": 0,
"activeRuleCount": 10,
"childrenCount": 0,
"depth": 1,
"isBuiltIn": false,
"isDefault": false,
"isInherited": false,
"key": "key",
"language": "js",
"languageName": "JavaScript",
"name": "name",
"organization": "foo",
"projectCount": 3,
}
}
updateProfiles={[MockFunction]}
/>
</td>
</tr>
`;

exports[`should render correctly: default profile 1`] = `
<tr
className="quality-profiles-table-row text-middle"
data-key="key"
data-name="name"
>
<td
className="quality-profiles-table-name text-middle"
>
<div
className="display-flex-center"
style={
Object {
"paddingLeft": 0,
}
}
>
<div>
<ProfileLink
language="js"
name="name"
organization={null}
>
name
</ProfileLink>
</div>
</div>
</td>
<td
className="quality-profiles-table-projects thin nowrap text-middle text-right"
>
<DocTooltip
doc={Promise {}}
>
<span
className="badge"
>
default
</span>
</DocTooltip>
</td>
<td
className="quality-profiles-table-rules thin nowrap text-middle text-right"
>
<div>
<span
className="spacer-right"
>
<Tooltip
overlay="quality_profiles.deprecated_rules"
>
<Link
className="badge badge-error"
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"activation": "true",
"qprofile": "key",
"statuses": "DEPRECATED",
},
}
}
>
2
</Link>
</Tooltip>
</span>
<Link
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"activation": "true",
"qprofile": "key",
},
}
}
>
10
</Link>
</div>
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-actions thin nowrap text-middle text-right"
>
<withRouter(ProfileActions)
fromList={true}
organization={null}
profile={
Object {
"activeDeprecatedRuleCount": 2,
"activeRuleCount": 10,
"childrenCount": 0,
"depth": 1,
"isBuiltIn": false,
"isDefault": true,
"isInherited": false,
"key": "key",
"language": "js",
"languageName": "JavaScript",
"name": "name",
"organization": "foo",
"projectCount": 3,
}
}
updateProfiles={[MockFunction]}
/>
</td>
</tr>
`;

exports[`should render correctly: with deprecated rules 1`] = `
<tr
className="quality-profiles-table-row text-middle"
data-key="key"
data-name="name"
>
<td
className="quality-profiles-table-name text-middle"
>
<div
className="display-flex-center"
style={
Object {
"paddingLeft": 0,
}
}
>
<div>
<ProfileLink
language="js"
name="name"
organization={null}
>
name
</ProfileLink>
</div>
</div>
</td>
<td
className="quality-profiles-table-projects thin nowrap text-middle text-right"
>
<span>
3
</span>
</td>
<td
className="quality-profiles-table-rules thin nowrap text-middle text-right"
>
<div>
<span
className="spacer-right"
>
<Tooltip
overlay="quality_profiles.deprecated_rules"
>
<Link
className="badge badge-error"
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"activation": "true",
"qprofile": "key",
"statuses": "DEPRECATED",
},
}
}
>
10
</Link>
</Tooltip>
</span>
<Link
onlyActiveOnIndex={false}
style={Object {}}
to={
Object {
"pathname": "/coding_rules",
"query": Object {
"activation": "true",
"qprofile": "key",
},
}
}
>
10
</Link>
</div>
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-date thin nowrap text-middle text-right"
>
<ProfileDate />
</td>
<td
className="quality-profiles-table-actions thin nowrap text-middle text-right"
>
<withRouter(ProfileActions)
fromList={true}
organization={null}
profile={
Object {
"activeDeprecatedRuleCount": 10,
"activeRuleCount": 10,
"childrenCount": 0,
"depth": 1,
"isBuiltIn": false,
"isDefault": false,
"isInherited": false,
"key": "key",
"language": "js",
"languageName": "JavaScript",
"name": "name",
"organization": "foo",
"projectCount": 3,
}
}
updateProfiles={[MockFunction]}
/>
</td>
</tr>
`;

+ 6
- 21
server/sonar-web/src/main/js/apps/sessions/components/LoginContainer.tsx View File

@@ -17,19 +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 { Location } from 'history';
import * as React from 'react';
import { connect } from 'react-redux';
import { getReturnUrl } from 'sonar-ui-common/helpers/urls';
import { getIdentityProviders } from '../../../api/users';
import { isSonarCloud } from '../../../helpers/system';
import { doLogin } from '../../../store/rootActions';
import Login from './Login';
import LoginSonarCloud from './LoginSonarCloud';

interface OwnProps {
location: {
hash?: string;
pathName: string;
location: Pick<Location, 'hash' | 'pathname' | 'query'> & {
query: { advanced?: string; return_to?: string };
};
}
@@ -44,7 +41,7 @@ interface State {
identityProviders?: T.IdentityProvider[];
}

class LoginContainer extends React.PureComponent<Props, State> {
export class LoginContainer extends React.PureComponent<Props, State> {
mounted = false;

state: State = {};
@@ -52,11 +49,9 @@ class LoginContainer extends React.PureComponent<Props, State> {
componentDidMount() {
this.mounted = true;
getIdentityProviders().then(
identityProvidersResponse => {
({ identityProviders }) => {
if (this.mounted) {
this.setState({
identityProviders: identityProvidersResponse.identityProviders
});
this.setState({ identityProviders });
}
},
() => {}
@@ -78,21 +73,11 @@ class LoginContainer extends React.PureComponent<Props, State> {
render() {
const { location } = this.props;
const { identityProviders } = this.state;

if (!identityProviders) {
return null;
}

if (isSonarCloud()) {
return (
<LoginSonarCloud
identityProviders={identityProviders}
onSubmit={this.handleSubmit}
returnTo={getReturnUrl(location)}
showForm={location.query['advanced'] !== undefined}
/>
);
}

return (
<Login
identityProviders={identityProviders}

+ 0
- 109
server/sonar-web/src/main/js/apps/sessions/components/LoginSonarCloud.tsx View File

@@ -1,109 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 classNames from 'classnames';
import * as React from 'react';
import { connect } from 'react-redux';
import { Alert } from 'sonar-ui-common/components/ui/Alert';
import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n';
import { getBaseUrl } from 'sonar-ui-common/helpers/urls';
import { Store } from '../../../store/rootReducer';
import LoginForm from './LoginForm';
import './LoginSonarCloud.css';
import OAuthProviders from './OAuthProviders';

interface Props {
identityProviders: T.IdentityProvider[];
onSubmit: (login: string, password: string) => Promise<void>;
returnTo: string;
showForm?: boolean;
authorizationError?: boolean;
authenticationError?: boolean;
}

function formatLabel(name: string) {
return translateWithParameters('login.with_x', name);
}

export function LoginSonarCloud({
showForm,
identityProviders,
returnTo,
onSubmit,
authorizationError,
authenticationError
}: Props) {
const displayForm = showForm || identityProviders.length <= 0;
const displayErrorAction = authorizationError || authenticationError;
return (
<>
{displayErrorAction && (
<Alert className="sonarcloud-login-alert" display="block" variant="warning">
{translate('login.unauthorized_access_alert')}
</Alert>
)}
<div
className={classNames('sonarcloud-login-page boxed-group boxed-group-inner', {
'sonarcloud-login-page-large': displayForm
})}
id="login_form">
<div className="text-center">
<img
alt="SonarCloud logo"
height={36}
src={`${getBaseUrl()}/images/sonarcloud-square-logo.svg`}
width={36}
/>
<h1 className="sonarcloud-login-title">
{translate('login.login_or_signup_to_sonarcloud')}
</h1>
</div>

{displayForm ? (
<LoginForm onSubmit={onSubmit} returnTo={returnTo} />
) : (
<OAuthProviders
className="sonarcloud-oauth-providers"
formatLabel={formatLabel}
identityProviders={identityProviders}
returnTo={returnTo}
/>
)}

{displayErrorAction && (
<div className="sonarcloud-login-cancel">
<div className="horizontal-pipe-separator">
<div className="horizontal-separator" />
<span className="note">{translate('or')}</span>
<div className="horizontal-separator" />
</div>
<a href={`${getBaseUrl()}/`}>{translate('go_back_to_homepage')}</a>
</div>
)}
</div>
</>
);
}

const mapStateToProps = (state: Store) => ({
authorizationError: state.appState.authorizationError,
authenticationError: state.appState.authenticationError
});

export default connect(mapStateToProps)(LoginSonarCloud);

+ 66
- 0
server/sonar-web/src/main/js/apps/sessions/components/__tests__/LoginContainer-test.tsx View File

@@ -0,0 +1,66 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import { getIdentityProviders } from '../../../../api/users';
import { mockLocation } from '../../../../helpers/testMocks';
import { LoginContainer } from '../LoginContainer';

jest.mock('../../../../api/users', () => {
const { mockIdentityProvider } = jest.requireActual('../../../../helpers/testMocks');
return {
getIdentityProviders: jest
.fn()
.mockResolvedValue({ identityProviders: [mockIdentityProvider()] })
};
});

beforeEach(jest.clearAllMocks);

it('should render correctly', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);

expect(wrapper).toMatchSnapshot();
expect(getIdentityProviders).toBeCalled();
});

it('should not provide any options if no IdPs are present', async () => {
(getIdentityProviders as jest.Mock).mockResolvedValue({});
const wrapper = shallowRender();
await waitAndUpdate(wrapper);

expect(wrapper.type()).toBeNull();
expect(getIdentityProviders).toBeCalled();
});

it('should handle submission', () => {
const doLogin = jest.fn().mockResolvedValue(null);
const wrapper = shallowRender({ doLogin });
wrapper.instance().handleSubmit('user', 'pass');
expect(doLogin).toBeCalledWith('user', 'pass');
});

function shallowRender(props: Partial<LoginContainer['props']> = {}) {
return shallow<LoginContainer>(
<LoginContainer doLogin={jest.fn()} location={mockLocation()} {...props} />
);
}

+ 0
- 64
server/sonar-web/src/main/js/apps/sessions/components/__tests__/LoginSonarCloud-test.tsx View File

@@ -1,64 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 { LoginSonarCloud } from '../LoginSonarCloud';

const identityProvider = {
backgroundColor: '#000',
iconPath: '/some/path',
key: 'foo',
name: 'foo'
};

it('logs in with identity provider', () => {
const wrapper = shallow(
<LoginSonarCloud identityProviders={[identityProvider]} onSubmit={jest.fn()} returnTo="" />
);
expect(wrapper).toMatchSnapshot();
});

it('logs in with simple form', () => {
expect(
shallow(
<LoginSonarCloud
identityProviders={[identityProvider]}
onSubmit={jest.fn()}
returnTo=""
showForm={true}
/>
)
).toMatchSnapshot();
expect(
shallow(<LoginSonarCloud identityProviders={[]} onSubmit={jest.fn()} returnTo="" />)
).toMatchSnapshot();
});

it("shows an warning message if there's an authorization error", () => {
const wrapper = shallow(
<LoginSonarCloud
authorizationError={true}
identityProviders={[identityProvider]}
onSubmit={jest.fn()}
returnTo=""
/>
);
expect(wrapper).toMatchSnapshot();
});

+ 18
- 0
server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/LoginContainer-test.tsx.snap View File

@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
<Login
identityProviders={
Array [
Object {
"backgroundColor": "#000000",
"iconPath": "/path/icon.svg",
"key": "github",
"name": "Github",
},
]
}
onSubmit={[Function]}
returnTo="/"
/>
`;

+ 0
- 170
server/sonar-web/src/main/js/apps/sessions/components/__tests__/__snapshots__/LoginSonarCloud-test.tsx.snap View File

@@ -1,170 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`logs in with identity provider 1`] = `
<Fragment>
<div
className="sonarcloud-login-page boxed-group boxed-group-inner"
id="login_form"
>
<div
className="text-center"
>
<img
alt="SonarCloud logo"
height={36}
src="/images/sonarcloud-square-logo.svg"
width={36}
/>
<h1
className="sonarcloud-login-title"
>
login.login_or_signup_to_sonarcloud
</h1>
</div>
<OAuthProviders
className="sonarcloud-oauth-providers"
formatLabel={[Function]}
identityProviders={
Array [
Object {
"backgroundColor": "#000",
"iconPath": "/some/path",
"key": "foo",
"name": "foo",
},
]
}
returnTo=""
/>
</div>
</Fragment>
`;

exports[`logs in with simple form 1`] = `
<Fragment>
<div
className="sonarcloud-login-page boxed-group boxed-group-inner sonarcloud-login-page-large"
id="login_form"
>
<div
className="text-center"
>
<img
alt="SonarCloud logo"
height={36}
src="/images/sonarcloud-square-logo.svg"
width={36}
/>
<h1
className="sonarcloud-login-title"
>
login.login_or_signup_to_sonarcloud
</h1>
</div>
<LoginForm
onSubmit={[MockFunction]}
returnTo=""
/>
</div>
</Fragment>
`;

exports[`logs in with simple form 2`] = `
<Fragment>
<div
className="sonarcloud-login-page boxed-group boxed-group-inner sonarcloud-login-page-large"
id="login_form"
>
<div
className="text-center"
>
<img
alt="SonarCloud logo"
height={36}
src="/images/sonarcloud-square-logo.svg"
width={36}
/>
<h1
className="sonarcloud-login-title"
>
login.login_or_signup_to_sonarcloud
</h1>
</div>
<LoginForm
onSubmit={[MockFunction]}
returnTo=""
/>
</div>
</Fragment>
`;

exports[`shows an warning message if there's an authorization error 1`] = `
<Fragment>
<Alert
className="sonarcloud-login-alert"
display="block"
variant="warning"
>
login.unauthorized_access_alert
</Alert>
<div
className="sonarcloud-login-page boxed-group boxed-group-inner"
id="login_form"
>
<div
className="text-center"
>
<img
alt="SonarCloud logo"
height={36}
src="/images/sonarcloud-square-logo.svg"
width={36}
/>
<h1
className="sonarcloud-login-title"
>
login.login_or_signup_to_sonarcloud
</h1>
</div>
<OAuthProviders
className="sonarcloud-oauth-providers"
formatLabel={[Function]}
identityProviders={
Array [
Object {
"backgroundColor": "#000",
"iconPath": "/some/path",
"key": "foo",
"name": "foo",
},
]
}
returnTo=""
/>
<div
className="sonarcloud-login-cancel"
>
<div
className="horizontal-pipe-separator"
>
<div
className="horizontal-separator"
/>
<span
className="note"
>
or
</span>
<div
className="horizontal-separator"
/>
</div>
<a
href="/"
>
go_back_to_homepage
</a>
</div>
</div>
</Fragment>
`;

+ 21
- 0
server/sonar-web/src/main/js/helpers/testMocks.ts View File

@@ -388,6 +388,15 @@ export function mockLoggedInUser(overrides: Partial<T.LoggedInUser> = {}): T.Log
};
}

export function mockGroup(overrides: Partial<T.Group> = {}): T.Group {
return {
id: 1,
membersCount: 1,
name: 'Foo',
...overrides
};
}

export function mockEvent(overrides = {}) {
return {
target: { blur() {} },
@@ -831,3 +840,15 @@ export function mockFlowLocation(overrides: Partial<T.FlowLocation> = {}): T.Flo
...overrides
};
}

export function mockIdentityProvider(
overrides: Partial<T.IdentityProvider> = {}
): T.IdentityProvider {
return {
backgroundColor: '#000000',
iconPath: '/path/icon.svg',
key: 'github',
name: 'Github',
...overrides
};
}

+ 119
- 0
server/sonar-web/src/main/js/store/__tests__/users-test.tsx View File

@@ -0,0 +1,119 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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.
*/
/* eslint-disable sonarjs/no-duplicate-string */
import { mockCurrentUser, mockLoggedInUser, mockUser } from '../../helpers/testMocks';
import reducer, {
getCurrentUser,
getCurrentUserSetting,
getUserByLogin,
getUsersByLogins,
receiveCurrentUser,
setCurrentUserSettingAction,
setHomePageAction,
skipOnboardingAction,
State
} from '../users';

describe('reducer and actions', () => {
it('should allow to receive the current user', () => {
const initialState: State = createState();

const currentUser = mockCurrentUser();
const newState = reducer(initialState, receiveCurrentUser(currentUser));
expect(newState).toEqual(createState({ currentUser }));
});

it('should allow to skip the onboarding tutorials', () => {
const currentUser = mockLoggedInUser({ showOnboardingTutorial: true });
const initialState: State = createState({ currentUser });

const newState = reducer(initialState, skipOnboardingAction());
expect(newState).toEqual(
createState({ currentUser: { ...currentUser, showOnboardingTutorial: false } })
);
});

it('should allow to set the homepage', () => {
const homepage: T.HomePage = { type: 'PROJECTS' };
const currentUser = mockLoggedInUser({ homepage: undefined });
const initialState: State = createState({ currentUser });

const newState = reducer(initialState, setHomePageAction(homepage));
expect(newState).toEqual(
createState({ currentUser: { ...currentUser, homepage } as T.LoggedInUser })
);
});

it('should allow to set a user setting', () => {
const setting1: T.CurrentUserSetting = { key: 'notifications.optOut', value: '1' };
const setting2: T.CurrentUserSetting = { key: 'notifications.readDate', value: '2' };
const setting3: T.CurrentUserSetting = { key: 'notifications.optOut', value: '2' };
const currentUser = mockLoggedInUser();
const initialState: State = createState({ currentUser });

const newState = reducer(initialState, setCurrentUserSettingAction(setting1));
expect(newState).toEqual(
createState({ currentUser: { ...currentUser, settings: [setting1] } as T.LoggedInUser })
);

const newerState = reducer(newState, setCurrentUserSettingAction(setting2));
expect(newerState).toEqual(
createState({
currentUser: { ...currentUser, settings: [setting1, setting2] } as T.LoggedInUser
})
);

const newestState = reducer(newerState, setCurrentUserSettingAction(setting3));
expect(newestState).toEqual(
createState({
currentUser: { ...currentUser, settings: [setting3, setting2] } as T.LoggedInUser
})
);
});
});

describe('getters', () => {
const currentUser = mockLoggedInUser({ settings: [{ key: 'notifications.optOut', value: '1' }] });
const jane = mockUser({ login: 'jane', name: 'Jane Doe' });
const john = mockUser({ login: 'john' });
const state = createState({ currentUser, usersByLogin: { jane, john } });

test('getCurrentUser', () => {
expect(getCurrentUser(state)).toBe(currentUser);
});

test('getCurrentUserSetting', () => {
expect(getCurrentUserSetting(state, 'notifications.optOut')).toBe('1');
expect(getCurrentUserSetting(state, 'notifications.readDate')).toBeUndefined();
});

test('getUserByLogin', () => {
expect(getUserByLogin(state, 'jane')).toBe(jane);
expect(getUserByLogin(state, 'steve')).toBeUndefined();
});

test('getUsersByLogins', () => {
expect(getUsersByLogins(state, ['jane', 'john'])).toEqual([jane, john]);
});
});

function createState(overrides: Partial<State> = {}): State {
return { usersByLogin: {}, userLogins: [], currentUser: mockCurrentUser(), ...overrides };
}

+ 3
- 3
server/sonar-web/src/main/js/store/users.ts View File

@@ -46,7 +46,7 @@ export function receiveCurrentUser(user: T.CurrentUser) {
return { type: Actions.ReceiveCurrentUser, user };
}

function skipOnboardingAction() {
export function skipOnboardingAction() {
return { type: Actions.SkipOnboardingAction };
}

@@ -58,11 +58,11 @@ export function skipOnboarding() {
);
}

function setHomePageAction(homepage: T.HomePage) {
export function setHomePageAction(homepage: T.HomePage) {
return { type: Actions.SetHomePageAction, homepage };
}

function setCurrentUserSettingAction(setting: T.CurrentUserSetting) {
export function setCurrentUserSettingAction(setting: T.CurrentUserSetting) {
return { type: Actions.SetCurrentUserSetting, setting };
}


Loading…
Cancel
Save