Przeglądaj źródła

SONAR-16221 Add select variants (creatable and async) and replace SelectLegacy

tags/9.5.0.56709
Jeremy Davis 2 lat temu
rodzic
commit
58533b7fcf
25 zmienionych plików z 933 dodań i 563 usunięć
  1. 1
    2
      server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts
  2. 6
    10
      server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx
  3. 2
    2
      server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx
  4. 13
    15
      server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap
  5. 139
    0
      server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx
  6. 38
    105
      server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
  7. 149
    0
      server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx
  8. 17
    46
      server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx
  9. 84
    0
      server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap
  10. 0
    20
      server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap
  11. 42
    27
      server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingReferenceBranch.tsx
  12. 2
    1
      server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx
  13. 1
    1
      server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx
  14. 32
    19
      server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BaselineSettingReferenceBranch-test.tsx
  15. 103
    48
      server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BaselineSettingReferenceBranch-test.tsx.snap
  16. 6
    3
      server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BranchBaselineSettingModal-test.tsx.snap
  17. 1
    0
      server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/ProjectBaselineSelector-test.tsx.snap
  18. 0
    154
      server/sonar-web/src/main/js/components/controls/SearchSelect.tsx
  19. 96
    22
      server/sonar-web/src/main/js/components/controls/Select.tsx
  20. 0
    50
      server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx
  21. 48
    20
      server/sonar-web/src/main/js/components/controls/__tests__/Select-test.tsx
  22. 0
    17
      server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap
  23. 151
    0
      server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Select-test.tsx.snap
  24. 1
    1
      server/sonar-web/src/main/js/helpers/mocks/react-select.ts
  25. 1
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 1
- 2
server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts Wyświetl plik

@@ -41,8 +41,7 @@ import Radio from '../../../components/controls/Radio';
import RadioToggle from '../../../components/controls/RadioToggle';
import ReloadButton from '../../../components/controls/ReloadButton';
import SearchBox from '../../../components/controls/SearchBox';
import SearchSelect from '../../../components/controls/SearchSelect';
import Select from '../../../components/controls/Select';
import Select, { SearchSelect } from '../../../components/controls/Select';
import SelectLegacy from '../../../components/controls/SelectLegacy';
import SelectList, { SelectListFilter } from '../../../components/controls/SelectList';
import SimpleModal from '../../../components/controls/SimpleModal';

+ 6
- 10
server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx Wyświetl plik

@@ -25,7 +25,7 @@ import { Button } from '../../../components/controls/buttons';
import ListFooter from '../../../components/controls/ListFooter';
import Radio from '../../../components/controls/Radio';
import SearchBox from '../../../components/controls/SearchBox';
import SearchSelect from '../../../components/controls/SearchSelect';
import Select, { BasicSelectOption } from '../../../components/controls/Select';
import CheckIcon from '../../../components/icons/CheckIcon';
import QualifierIcon from '../../../components/icons/QualifierIcon';
import { Alert } from '../../../components/ui/Alert';
@@ -62,9 +62,6 @@ function orgToOption({ key, name }: GithubOrganization) {
return { value: key, label: name };
}

const handleSearch = (organizations: GithubOrganization[]) => (q: string) =>
Promise.resolve(organizations.filter(o => !q || o.name.includes(q)).map(orgToOption));

function renderRepositoryList(props: GitHubProjectCreateRendererProps) {
const {
importing,
@@ -240,12 +237,11 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
<div className="form-field">
<label>{translate('onboarding.create_project.github.choose_organization')}</label>
{organizations.length > 0 ? (
<SearchSelect
defaultOptions={organizations.map(orgToOption)}
onSearch={handleSearch(organizations)}
minimumQueryLength={0}
onSelect={({ value }) => props.onSelectOrganization(value)}
value={selectedOrganization && orgToOption(selectedOrganization)}
<Select
className="input-super-large"
options={organizations.map(orgToOption)}
onChange={({ value }: BasicSelectOption) => props.onSelectOrganization(value)}
value={selectedOrganization ? orgToOption(selectedOrganization) : null}
/>
) : (
!loadingOrganizations && (

+ 2
- 2
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx Wyświetl plik

@@ -21,7 +21,7 @@ import { shallow } from 'enzyme';
import * as React from 'react';
import Radio from '../../../../components/controls/Radio';
import SearchBox from '../../../../components/controls/SearchBox';
import SearchSelect from '../../../../components/controls/SearchSelect';
import Select from '../../../../components/controls/Select';
import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations';
import { GithubOrganization } from '../../../../types/alm-integration';
import GitHubProjectCreateRenderer, {
@@ -85,7 +85,7 @@ describe('callback', () => {

it('should be called when org is selected', () => {
const value = 'o1';
wrapper.find(SearchSelect).simulate('select', { value });
wrapper.find(Select).simulate('change', { value });
expect(onSelectOrganization).toBeCalledWith(value);
});


+ 13
- 15
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap Wyświetl plik

@@ -172,8 +172,10 @@ exports[`should render correctly: no repositories 1`] = `
<label>
onboarding.create_project.github.choose_organization
</label>
<SearchSelect
defaultOptions={
<Select
className="input-super-large"
onChange={[Function]}
options={
Array [
Object {
"label": "org1",
@@ -185,9 +187,6 @@ exports[`should render correctly: no repositories 1`] = `
},
]
}
minimumQueryLength={0}
onSearch={[Function]}
onSelect={[Function]}
value={
Object {
"label": "org2",
@@ -226,8 +225,10 @@ exports[`should render correctly: organizations 1`] = `
<label>
onboarding.create_project.github.choose_organization
</label>
<SearchSelect
defaultOptions={
<Select
className="input-super-large"
onChange={[Function]}
options={
Array [
Object {
"label": "org1",
@@ -239,9 +240,7 @@ exports[`should render correctly: organizations 1`] = `
},
]
}
minimumQueryLength={0}
onSearch={[Function]}
onSelect={[Function]}
value={null}
/>
</div>
</DeferredSpinner>
@@ -291,8 +290,10 @@ exports[`should render correctly: repositories 1`] = `
<label>
onboarding.create_project.github.choose_organization
</label>
<SearchSelect
defaultOptions={
<Select
className="input-super-large"
onChange={[Function]}
options={
Array [
Object {
"label": "org1",
@@ -304,9 +305,6 @@ exports[`should render correctly: repositories 1`] = `
},
]
}
minimumQueryLength={0}
onSearch={[Function]}
onSelect={[Function]}
value={
Object {
"label": "org2",

+ 139
- 0
server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx Wyświetl plik

@@ -0,0 +1,139 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { debounce } from 'lodash';
import * as React from 'react';
import { components, OptionProps, SingleValueProps } from 'react-select';
import { BasicSelectOption, SearchSelect } from '../../../components/controls/Select';
import Avatar from '../../../components/ui/Avatar';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Issue } from '../../../types/types';
import { CurrentUser, isLoggedIn, isUserActive } from '../../../types/users';
import { searchAssignees } from '../utils';

const DEBOUNCE_DELAY = 250;
// exported for test
export const MIN_QUERY_LENGTH = 2;

export interface AssigneeOption extends BasicSelectOption {
avatar?: string;
email?: string;
label: string;
value: string;
}

export interface AssigneeSelectProps {
currentUser: CurrentUser;
issues: Issue[];
onAssigneeSelect: (assignee: AssigneeOption) => void;
}

export default class AssigneeSelect extends React.Component<AssigneeSelectProps> {
constructor(props: AssigneeSelectProps) {
super(props);

this.handleAssigneeSearch = debounce(this.handleAssigneeSearch, DEBOUNCE_DELAY);
}

getDefaultAssignee = () => {
const { currentUser, issues } = this.props;
const options = [];

if (isLoggedIn(currentUser)) {
const canBeAssignedToMe =
issues.filter(issue => issue.assignee !== currentUser.login).length > 0;
if (canBeAssignedToMe) {
options.push({
avatar: currentUser.avatar,
label: currentUser.name,
value: currentUser.login
});
}
}

const canBeUnassigned = issues.filter(issue => issue.assignee).length > 0;
if (canBeUnassigned) {
options.push({ label: translate('unassigned'), value: '' });
}

return options;
};

handleAssigneeSearch = (query: string, resolve: (options: AssigneeOption[]) => void) => {
if (query.length < MIN_QUERY_LENGTH) {
resolve([]);
return;
}

searchAssignees(query)
.then(({ results }) =>
results.map(r => {
const userInfo = r.name || r.login;

return {
avatar: r.avatar,
label: isUserActive(r) ? userInfo : translateWithParameters('user.x_deleted', userInfo),
value: r.login
};
})
)
.then(resolve)
.catch(() => resolve([]));
};

renderAssignee = (option: AssigneeOption) => {
return (
<div className="display-flex-center">
{option.avatar !== undefined && (
<Avatar className="spacer-right" hash={option.avatar} name={option.label} size={16} />
)}
{option.label}
</div>
);
};

renderAssigneeOption = (props: OptionProps<AssigneeOption, false>) => (
<components.Option {...props}>{this.renderAssignee(props.data)}</components.Option>
);

renderSingleAssignee = (props: SingleValueProps<AssigneeOption>) => (
<components.SingleValue {...props}>{this.renderAssignee(props.data)}</components.SingleValue>
);

render() {
return (
<SearchSelect
className="input-super-large"
components={{
Option: this.renderAssigneeOption,
SingleValue: this.renderSingleAssignee
}}
isClearable={true}
defaultOptions={this.getDefaultAssignee()}
loadOptions={this.handleAssigneeSearch}
onChange={this.props.onAssigneeSelect}
noOptionsMessage={({ inputValue }) =>
inputValue.length < MIN_QUERY_LENGTH
? translateWithParameters('select2.tooShort', MIN_QUERY_LENGTH)
: translate('select2.noMatches')
}
/>
);
}
}

+ 38
- 105
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx Wyświetl plik

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { pickBy, sortBy } from 'lodash';
import { debounce, pickBy, sortBy } from 'lodash';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { components, OptionProps, SingleValueProps } from 'react-select';
@@ -28,27 +28,24 @@ import Checkbox from '../../../components/controls/Checkbox';
import HelpTooltip from '../../../components/controls/HelpTooltip';
import Modal from '../../../components/controls/Modal';
import Radio from '../../../components/controls/Radio';
import SearchSelect from '../../../components/controls/SearchSelect';
import Select, { BasicSelectOption } from '../../../components/controls/Select';
import Select, {
BasicSelectOption,
CreatableSelect,
SearchSelect
} from '../../../components/controls/Select';
import Tooltip from '../../../components/controls/Tooltip';
import IssueTypeIcon from '../../../components/icons/IssueTypeIcon';
import SeverityHelper from '../../../components/shared/SeverityHelper';
import { Alert } from '../../../components/ui/Alert';
import Avatar from '../../../components/ui/Avatar';
import { throwGlobalError } from '../../../helpers/error';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { Component, Dict, Issue, IssueType, Paging } from '../../../types/types';
import { CurrentUser, isLoggedIn, isUserActive } from '../../../types/users';
import { searchAssignees } from '../utils';
import { CurrentUser } from '../../../types/users';
import AssigneeSelect, { AssigneeOption } from './AssigneeSelect';

interface AssigneeOption {
avatar?: string;
email?: string;
label: string;
value: string;
}
const DEBOUNCE_DELAY = 250;

interface TagOption {
interface TagOption extends BasicSelectOption {
label: string;
value: string;
}
@@ -82,12 +79,6 @@ interface State extends FormFields {
submitting: boolean;
}

type AssigneeSelectType = new () => SearchSelect<AssigneeOption>;
const AssigneeSelect = SearchSelect as AssigneeSelectType;

type TagSelectType = new () => SearchSelect<TagOption>;
const TagSelect = SearchSelect as TagSelectType;

export const MAX_PAGE_SIZE = 500;

export default class BulkChangeModal extends React.PureComponent<Props, State> {
@@ -96,6 +87,8 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { initialTags: [], issues: [], loading: true, submitting: false };

this.handleTagsSearch = debounce(this.handleTagsSearch, DEBOUNCE_DELAY);
}

componentDidMount() {
@@ -128,58 +121,18 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE });
};

getDefaultAssignee = () => {
const { currentUser } = this.props;
const { issues } = this.state;
const options = [];

if (isLoggedIn(currentUser)) {
const canBeAssignedToMe =
issues.filter(issue => issue.assignee !== currentUser.login).length > 0;
if (canBeAssignedToMe) {
options.push({
avatar: currentUser.avatar,
label: currentUser.name,
value: currentUser.login
});
}
}

const canBeUnassigned = issues.filter(issue => issue.assignee).length > 0;
if (canBeUnassigned) {
options.push({ label: translate('unassigned'), value: '' });
}

return options;
};

handleAssigneeSearch = (query: string) => {
return searchAssignees(query).then(({ results }) =>
results.map(r => {
const userInfo = r.name || r.login;

return {
avatar: r.avatar,
label: isUserActive(r) ? userInfo : translateWithParameters('user.x_deleted', userInfo),
value: r.login
};
})
);
};

handleAssigneeSelect = (assignee: AssigneeOption) => {
this.setState({ assignee });
};

handleTagsSearch = (query: string) => {
return searchIssueTags({ q: query }).then(tags =>
tags.map(tag => ({ label: tag, value: tag }))
);
handleTagsSearch = (query: string, resolve: (option: TagOption[]) => void) => {
searchIssueTags({ q: query })
.then(tags => tags.map(tag => ({ label: tag, value: tag })))
.then(resolve)
.catch(() => resolve([]));
};

handleTagsSelect = (field: 'addTags' | 'removeTags') => (
options: Array<{ label: string; value: string }>
) => {
handleTagsSelect = (field: 'addTags' | 'removeTags') => (options: TagOption[]) => {
this.setState<keyof FormFields>({ [field]: options });
};

@@ -306,18 +259,9 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
</div>
);

renderAssigneeOption = (option: AssigneeOption) => {
return (
<span>
{option.avatar !== undefined && (
<Avatar className="spacer-right" hash={option.avatar} name={option.label} size={16} />
)}
{option.label}
</span>
);
};

renderAssigneeField = () => {
const { currentUser } = this.props;
const { issues } = this.state;
const affected = this.state.issues.filter(hasAction('assign')).length;

if (affected === 0) {
@@ -326,14 +270,9 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {

const input = (
<AssigneeSelect
className="input-super-large"
clearable={true}
defaultOptions={this.getDefaultAssignee()}
onSearch={this.handleAssigneeSearch}
onSelect={this.handleAssigneeSelect}
renderOption={this.renderAssigneeOption}
resetOnBlur={false}
value={this.state.assignee}
currentUser={currentUser}
issues={issues}
onAssigneeSelect={this.handleAssigneeSelect}
/>
);

@@ -419,10 +358,6 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
return this.renderField('severity', 'issue.set_severity', affected, input);
};

renderTagOption = (option: TagOption) => {
return <span>{option.label}</span>;
};

renderTagsField = (field: 'addTags' | 'removeTags', label: string, allowCreate: boolean) => {
const { initialTags } = this.state;
const affected = this.state.issues.filter(hasAction('set_tags')).length;
@@ -431,21 +366,19 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
return null;
}

const input = (
<TagSelect
canCreate={allowCreate}
className="input-super-large"
clearable={true}
defaultOptions={this.state.initialTags}
minimumQueryLength={0}
multi={true}
onMultiSelect={this.handleTagsSelect(field)}
onSearch={this.handleTagsSearch}
promptTextCreator={promptCreateTag}
renderOption={this.renderTagOption}
resetOnBlur={false}
value={this.state[field]}
/>
const props = {
className: 'input-super-large',
isClearable: true,
defaultOptions: this.state.initialTags,
isMulti: true,
onChange: this.handleTagsSelect(field),
loadOptions: this.handleTagsSearch
};

const input = allowCreate ? (
<CreatableSelect {...props} formatCreateLabel={createTagPrompt} />
) : (
<SearchSelect {...props} />
);

return this.renderField(field, label, affected, input);
@@ -580,6 +513,6 @@ function hasAction(action: string) {
return (issue: Issue) => issue.actions && issue.actions.includes(action);
}

function promptCreateTag(label: string) {
return `+ ${label}`;
function createTagPrompt(label: string) {
return translateWithParameters('issue.create_tag_x', label);
}

+ 149
- 0
server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx Wyświetl plik

@@ -0,0 +1,149 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { Props as ReactSelectAsyncProps } from 'react-select/async';
import { SearchSelect } from '../../../../components/controls/Select';
import Avatar from '../../../../components/ui/Avatar';
import { mockCurrentUser, mockIssue, mockLoggedInUser } from '../../../../helpers/testMocks';
import { searchAssignees } from '../../utils';
import AssigneeSelect, {
AssigneeOption,
AssigneeSelectProps,
MIN_QUERY_LENGTH
} from '../AssigneeSelect';

jest.mock('../../utils', () => ({
searchAssignees: jest.fn().mockResolvedValue({
results: [
{
active: true,
avatar: '##avatar1',
login: 'toto@toto',
name: 'toto'
},
{
active: false,
avatar: '##avatar2',
login: 'tata@tata',
name: 'tata'
},
{
active: true,
avatar: '##avatar3',
login: 'titi@titi'
}
]
})
}));

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot('default');
expect(shallowRender({ currentUser: mockLoggedInUser(), issues: [mockIssue()] })).toMatchSnapshot(
'logged in & assignable issues'
);
expect(shallowRender({ currentUser: mockLoggedInUser() })).toMatchSnapshot(
'logged in & no assignable issues'
);
expect(shallowRender({ issues: [mockIssue(false, { assignee: 'someone' })] })).toMatchSnapshot(
'unassignable issues'
);
});

it('should render options correctly', () => {
const wrapper = shallowRender();

expect(
shallow(
wrapper.instance().renderAssignee({
avatar: '##avatar1',
value: 'toto@toto',
label: 'toto'
})
)
.find(Avatar)
.exists()
).toBe(true);

expect(
shallow(
wrapper.instance().renderAssignee({
value: 'toto@toto',
label: 'toto'
})
)
.find(Avatar)
.exists()
).toBe(false);
});

it('should render noOptionsMessage correctly', () => {
const wrapper = shallowRender();
expect(
wrapper.find<ReactSelectAsyncProps<AssigneeOption, false>>(SearchSelect).props()
.noOptionsMessage!({ inputValue: 'a' })
).toBe(`select2.tooShort.${MIN_QUERY_LENGTH}`);

expect(
wrapper.find<ReactSelectAsyncProps<AssigneeOption, false>>(SearchSelect).props()
.noOptionsMessage!({ inputValue: 'droids' })
).toBe('select2.noMatches');
});

it('should handle assignee search', async () => {
const onAssigneeSelect = jest.fn();
const wrapper = shallowRender({ onAssigneeSelect });

wrapper.instance().handleAssigneeSearch('a', jest.fn());
expect(searchAssignees).not.toBeCalled();

const result = await new Promise((resolve: (opts: AssigneeOption[]) => void) => {
wrapper.instance().handleAssigneeSearch('someone', resolve);
});

expect(result).toEqual([
{
avatar: '##avatar1',
value: 'toto@toto',
label: 'toto'
},
{
avatar: '##avatar2',
value: 'tata@tata',
label: 'user.x_deleted.tata'
},
{
avatar: '##avatar3',
value: 'titi@titi',
label: 'user.x_deleted.titi@titi'
}
]);
});

function shallowRender(overrides: Partial<AssigneeSelectProps> = {}) {
return shallow<AssigneeSelect>(
<AssigneeSelect
currentUser={mockCurrentUser()}
issues={[]}
onAssigneeSelect={jest.fn()}
{...overrides}
/>
);
}

+ 17
- 46
server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx Wyświetl plik

@@ -19,10 +19,11 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { Props as ReactSelectProps } from 'react-select';
import { SelectComponentsProps } from 'react-select/src/Select';
import { searchIssueTags } from '../../../../api/issues';
import { SubmitButton } from '../../../../components/controls/buttons';
import Select from '../../../../components/controls/Select';
import Select, { CreatableSelect, SearchSelect } from '../../../../components/controls/Select';
import { mockIssue } from '../../../../helpers/testMocks';
import { change, waitAndUpdate } from '../../../../helpers/testUtils';
import { Issue } from '../../../../types/types';
@@ -32,30 +33,6 @@ jest.mock('../../../../api/issues', () => ({
searchIssueTags: jest.fn().mockResolvedValue([undefined, []])
}));

jest.mock('../../utils', () => ({
searchAssignees: jest.fn().mockResolvedValue({
results: [
{
active: true,
avatar: '##toto',
login: 'toto@toto',
name: 'toto'
},
{
active: false,
avatar: '##toto',
login: 'login@login',
name: 'toto'
},
{
active: true,
avatar: '##toto',
login: 'login@login'
}
]
})
}));

it('should display error message when no issues available', async () => {
const wrapper = getWrapper([]);
await waitAndUpdate(wrapper);
@@ -80,20 +57,11 @@ it('should display warning when too many issues are passed', async () => {
expect(wrapper.find('Alert')).toMatchSnapshot();
});

it('should properly handle the search for assignee', async () => {
const issues: Issue[] = [];
for (let i = MAX_PAGE_SIZE + 1; i > 0; i--) {
issues.push(mockIssue());
}

const wrapper = getWrapper(issues);
const result = await wrapper.instance().handleAssigneeSearch('toto');
expect(result).toMatchSnapshot();
});

it('should properly handle the search for tags', async () => {
const wrapper = getWrapper([]);
await wrapper.instance().handleTagsSearch('query');
await new Promise(resolve => {
wrapper.instance().handleTagsSearch('query', resolve);
});
expect(searchIssueTags).toBeCalled();
});

@@ -110,27 +78,30 @@ it.each([
expect(SingleValue({ data: { label: 'label', value: 'value' } })).toMatchSnapshot('SingleValue');
});

it('should render tags correctly', async () => {
const wrapper = getWrapper([mockIssue(false, { actions: ['set_tags'] })]);
await waitAndUpdate(wrapper);

expect(wrapper.find(CreatableSelect).exists()).toBe(true);
expect(wrapper.find(SearchSelect).exists()).toBe(true);
});

it('should disable the submit button unless some change is configured', async () => {
const wrapper = getWrapper([mockIssue(false, { actions: ['set_severity', 'comment'] })]);
await waitAndUpdate(wrapper);

return new Promise<void>((resolve, reject) => {
return new Promise<void>(resolve => {
expect(wrapper.find(SubmitButton).props().disabled).toBe(true);

// Setting a comment is not sufficient; some other change must occur.
change(wrapper.find('#comment'), 'Some comment');
expect(wrapper.find(SubmitButton).props().disabled).toBe(true);

const { onChange } = wrapper
.find(Select)
wrapper
.find<ReactSelectProps>(Select)
.at(0)
.props();
if (!onChange) {
reject();
return;
}
.simulate('change', { value: 'foo' });

onChange({ value: 'foo' });
expect(wrapper.find(SubmitButton).props().disabled).toBe(false);
resolve();
});

+ 84
- 0
server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap Wyświetl plik

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

exports[`should render correctly: default 1`] = `
<SearchSelect
className="input-super-large"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
defaultOptions={Array []}
isClearable={true}
loadOptions={[Function]}
noOptionsMessage={[Function]}
onChange={[MockFunction]}
/>
`;

exports[`should render correctly: logged in & assignable issues 1`] = `
<SearchSelect
className="input-super-large"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
defaultOptions={
Array [
Object {
"avatar": undefined,
"label": "Skywalker",
"value": "luke",
},
]
}
isClearable={true}
loadOptions={[Function]}
noOptionsMessage={[Function]}
onChange={[MockFunction]}
/>
`;

exports[`should render correctly: logged in & no assignable issues 1`] = `
<SearchSelect
className="input-super-large"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
defaultOptions={Array []}
isClearable={true}
loadOptions={[Function]}
noOptionsMessage={[Function]}
onChange={[MockFunction]}
/>
`;

exports[`should render correctly: unassignable issues 1`] = `
<SearchSelect
className="input-super-large"
components={
Object {
"Option": [Function],
"SingleValue": [Function],
}
}
defaultOptions={
Array [
Object {
"label": "unassigned",
"value": "",
},
]
}
isClearable={true}
loadOptions={[Function]}
noOptionsMessage={[Function]}
onChange={[MockFunction]}
/>
`;

+ 0
- 20
server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap Wyświetl plik

@@ -131,26 +131,6 @@ exports[`should display warning when too many issues are passed 2`] = `
</Alert>
`;

exports[`should properly handle the search for assignee 1`] = `
Array [
Object {
"avatar": "##toto",
"label": "toto",
"value": "toto@toto",
},
Object {
"avatar": "##toto",
"label": "user.x_deleted.toto",
"value": "login@login",
},
Object {
"avatar": "##toto",
"label": "user.x_deleted.login@login",
"value": "login@login",
},
]
`;

exports[`should render select for severity: Option 1`] = `
<Option
data={

+ 42
- 27
server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingReferenceBranch.tsx Wyświetl plik

@@ -18,8 +18,9 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { components, OptionProps } from 'react-select';
import RadioCard from '../../../components/controls/RadioCard';
import SearchSelect from '../../../components/controls/SearchSelect';
import Select from '../../../components/controls/Select';
import Tooltip from '../../../components/controls/Tooltip';
import AlertErrorIcon from '../../../components/icons/AlertErrorIcon';
import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
@@ -40,32 +41,45 @@ export interface BaselineSettingReferenceBranchProps {
}

export interface BranchOption {
disabled?: boolean;
isDisabled?: boolean;
isInvalid?: boolean;
isMain: boolean;
label: string;
value: string;
}

function renderBranchOption(option: BranchOption) {
return option.isInvalid ? (
<Tooltip
overlay={translateWithParameters('baseline.reference_branch.does_not_exist', option.value)}>
<span>
{option.value} <AlertErrorIcon />
</span>
</Tooltip>
) : (
<>
<span
title={
option.disabled ? translate('baseline.reference_branch.cannot_be_itself') : undefined
}>
{option.value}
</span>
{option.isMain && (
<div className="badge spacer-left">{translate('branches.main_branch')}</div>
/* Export for testing */
export function renderBranchOption(props: OptionProps<BranchOption, false>) {
const { data: option } = props;

return (
<components.Option {...props}>
{option.isInvalid ? (
<Tooltip
overlay={translateWithParameters(
'baseline.reference_branch.does_not_exist',
option.value
)}>
<span>
{option.value} <AlertErrorIcon />
</span>
</Tooltip>
) : (
<>
<span
title={
option.isDisabled
? translate('baseline.reference_branch.cannot_be_itself')
: undefined
}>
{option.value}
</span>
{option.isMain && (
<div className="badge spacer-left">{translate('branches.main_branch')}</div>
)}
</>
)}
</>
</components.Option>
);
}

@@ -73,6 +87,7 @@ export default function BaselineSettingReferenceBranch(props: BaselineSettingRef
const { branchList, className, disabled, referenceBranch, selected, settingLevel } = props;

const currentBranch = branchList.find(b => b.value === referenceBranch) || {
label: referenceBranch,
value: referenceBranch,
isMain: false,
isInvalid: true
@@ -98,15 +113,15 @@ export default function BaselineSettingReferenceBranch(props: BaselineSettingRef
<strong>{translate('baseline.reference_branch.choose')}</strong>
<MandatoryFieldMarker />
</label>
<SearchSelect<BranchOption>
<Select<BranchOption>
autofocus={false}
className="little-spacer-top spacer-bottom"
defaultOptions={branchList}
minimumQueryLength={1}
onSearch={q => Promise.resolve(branchList.filter(b => b.value.includes(q)))}
onSelect={option => props.onChangeReferenceBranch(option.value)}
renderOption={renderBranchOption}
options={branchList}
onChange={(option: BranchOption) => props.onChangeReferenceBranch(option.value)}
value={currentBranch}
components={{
Option: renderBranchOption
}}
/>
</div>
</>

+ 2
- 1
server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx Wyświetl plik

@@ -82,9 +82,10 @@ export default class BranchBaselineSettingModal extends React.PureComponent<Prop
}

branchToOption = (b: Branch) => ({
label: b.name,
value: b.name,
isMain: b.isMain,
disabled: b.name === this.props.branch.name // cannot itself be used as a reference branch
isDisabled: b.name === this.props.branch.name // cannot itself be used as a reference branch
});

handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {

+ 1
- 1
server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx Wyświetl plik

@@ -78,7 +78,7 @@ function renderGeneralSetting(generalSetting: NewCodePeriod) {
}

function branchToOption(b: Branch) {
return { value: b.name, isMain: b.isMain };
return { label: b.name, value: b.name, isMain: b.isMain };
}

export default function ProjectBaselineSelector(props: ProjectBaselineSelectorProps) {

+ 32
- 19
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BaselineSettingReferenceBranch-test.tsx Wyświetl plik

@@ -19,11 +19,13 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { OptionProps, Props as ReactSelectProps } from 'react-select';
import RadioCard from '../../../../components/controls/RadioCard';
import SearchSelect from '../../../../components/controls/SearchSelect';
import Select from '../../../../components/controls/Select';
import BaselineSettingReferenceBranch, {
BaselineSettingReferenceBranchProps,
BranchOption
BranchOption,
renderBranchOption
} from '../BaselineSettingReferenceBranch';

it('should render correctly', () => {
@@ -33,7 +35,7 @@ it('should render correctly', () => {
);
expect(
shallowRender({
branchList: [{ value: 'master', isMain: true }],
branchList: [{ label: 'master', value: 'master', isMain: true }],
settingLevel: 'branch',
configuredBranchName: 'master'
})
@@ -61,9 +63,9 @@ it('should callback when changing selection', () => {
const wrapper = shallowRender({ onChangeReferenceBranch });

wrapper
.find(SearchSelect)
.find(Select)
.first()
.simulate('select', { value: 'branch-6.9' });
.simulate('change', { value: 'branch-6.9' });
expect(onChangeReferenceBranch).toHaveBeenCalledWith('branch-6.9');
});

@@ -73,34 +75,45 @@ it('should handle an invalid branch', () => {

expect(
wrapper
.find(SearchSelect)
.find<ReactSelectProps>(Select)
.first()
.props().value
).toEqual({ value: unknownBranchName, isMain: false, isInvalid: true });
).toEqual({ label: unknownBranchName, value: unknownBranchName, isMain: false, isInvalid: true });
});

describe('renderOption', () => {
const select = shallowRender()
.find(SearchSelect)
.first();
const renderFunction = select.props().renderOption as (option: BranchOption) => JSX.Element;
// fake props injected by the Select itself
const props = {} as OptionProps<BranchOption, false>;

it('should render correctly', () => {
expect(renderFunction({ value: 'master', isMain: true })).toMatchSnapshot('main');
expect(renderFunction({ value: 'branch-7.4', isMain: false })).toMatchSnapshot('branch');
expect(renderFunction({ value: 'disabled', isMain: false, disabled: true })).toMatchSnapshot(
'disabled'
);
expect(
renderFunction({ value: 'branch-nope', isMain: false, isInvalid: true })
renderBranchOption({ ...props, data: { label: 'master', value: 'master', isMain: true } })
).toMatchSnapshot('main');
expect(
renderBranchOption({
...props,
data: { label: 'branch-7.4', value: 'branch-7.4', isMain: false }
})
).toMatchSnapshot('branch');
expect(
renderBranchOption({
...props,
data: { label: 'disabled', value: 'disabled', isMain: false, isDisabled: true }
})
).toMatchSnapshot('disabled');
expect(
renderBranchOption({
...props,
data: { value: 'branch-nope', isMain: false, isInvalid: true }
})
).toMatchSnapshot("branch doesn't exist");
});
});

function shallowRender(props: Partial<BaselineSettingReferenceBranchProps> = {}) {
const branchOptions = [
{ value: 'master', isMain: true },
{ value: 'branch-7.9', isMain: false }
{ label: 'master', value: 'master', isMain: true },
{ label: 'branch-7.9', value: 'branch-7.9', isMain: false }
];

return shallow(

+ 103
- 48
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BaselineSettingReferenceBranch-test.tsx.snap Wyświetl plik

@@ -1,46 +1,87 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renderOption should render correctly: branch 1`] = `
<React.Fragment>
<span>
branch-7.4
</span>
</React.Fragment>
<Option
data={
Object {
"isMain": false,
"label": "branch-7.4",
"value": "branch-7.4",
}
}
>
<React.Fragment>
<span>
branch-7.4
</span>
</React.Fragment>
</Option>
`;

exports[`renderOption should render correctly: branch doesn't exist 1`] = `
<Tooltip
overlay="baseline.reference_branch.does_not_exist.branch-nope"
<Option
data={
Object {
"isInvalid": true,
"isMain": false,
"value": "branch-nope",
}
}
>
<span>
branch-nope
<AlertErrorIcon />
</span>
</Tooltip>
<Tooltip
overlay="baseline.reference_branch.does_not_exist.branch-nope"
>
<span>
branch-nope
<AlertErrorIcon />
</span>
</Tooltip>
</Option>
`;

exports[`renderOption should render correctly: disabled 1`] = `
<React.Fragment>
<span
title="baseline.reference_branch.cannot_be_itself"
>
disabled
</span>
</React.Fragment>
<Option
data={
Object {
"isDisabled": true,
"isMain": false,
"label": "disabled",
"value": "disabled",
}
}
>
<React.Fragment>
<span
title="baseline.reference_branch.cannot_be_itself"
>
disabled
</span>
</React.Fragment>
</Option>
`;

exports[`renderOption should render correctly: main 1`] = `
<React.Fragment>
<span>
master
</span>
<div
className="badge spacer-left"
>
branches.main_branch
</div>
</React.Fragment>
<Option
data={
Object {
"isMain": true,
"label": "master",
"value": "master",
}
}
>
<React.Fragment>
<span>
master
</span>
<div
className="badge spacer-left"
>
branches.main_branch
</div>
</React.Fragment>
</Option>
`;

exports[`should render correctly: Branch level - no other branches 1`] = `
@@ -67,24 +108,28 @@ exports[`should render correctly: Branch level - no other branches 1`] = `
</strong>
<MandatoryFieldMarker />
</label>
<SearchSelect
<Select
autofocus={false}
className="little-spacer-top spacer-bottom"
defaultOptions={
components={
Object {
"Option": [Function],
}
}
onChange={[Function]}
options={
Array [
Object {
"isMain": true,
"label": "master",
"value": "master",
},
]
}
minimumQueryLength={1}
onSearch={[Function]}
onSelect={[Function]}
renderOption={[Function]}
value={
Object {
"isMain": true,
"label": "master",
"value": "master",
}
}
@@ -117,28 +162,33 @@ exports[`should render correctly: Branch level 1`] = `
</strong>
<MandatoryFieldMarker />
</label>
<SearchSelect
<Select
autofocus={false}
className="little-spacer-top spacer-bottom"
defaultOptions={
components={
Object {
"Option": [Function],
}
}
onChange={[Function]}
options={
Array [
Object {
"isMain": true,
"label": "master",
"value": "master",
},
Object {
"isMain": false,
"label": "branch-7.9",
"value": "branch-7.9",
},
]
}
minimumQueryLength={1}
onSearch={[Function]}
onSelect={[Function]}
renderOption={[Function]}
value={
Object {
"isMain": true,
"label": "master",
"value": "master",
}
}
@@ -176,28 +226,33 @@ exports[`should render correctly: Project level 1`] = `
</strong>
<MandatoryFieldMarker />
</label>
<SearchSelect
<Select
autofocus={false}
className="little-spacer-top spacer-bottom"
defaultOptions={
components={
Object {
"Option": [Function],
}
}
onChange={[Function]}
options={
Array [
Object {
"isMain": true,
"label": "master",
"value": "master",
},
Object {
"isMain": false,
"label": "branch-7.9",
"value": "branch-7.9",
},
]
}
minimumQueryLength={1}
onSearch={[Function]}
onSelect={[Function]}
renderOption={[Function]}
value={
Object {
"isMain": true,
"label": "master",
"value": "master",
}
}

+ 6
- 3
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BranchBaselineSettingModal-test.tsx.snap Wyświetl plik

@@ -44,13 +44,15 @@ exports[`should render correctly: multiple branches 1`] = `
branchList={
Array [
Object {
"disabled": true,
"isDisabled": true,
"isMain": true,
"label": "master",
"value": "master",
},
Object {
"disabled": false,
"isDisabled": false,
"isMain": false,
"label": "branch-6.7",
"value": "branch-6.7",
},
]
@@ -129,8 +131,9 @@ exports[`should render correctly: only one branch 1`] = `
branchList={
Array [
Object {
"disabled": true,
"isDisabled": true,
"isMain": true,
"label": "master",
"value": "master",
},
]

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

@@ -65,6 +65,7 @@ exports[`should render correctly 1`] = `
Array [
Object {
"isMain": true,
"label": "master",
"value": "master",
},
]

+ 0
- 154
server/sonar-web/src/main/js/components/controls/SearchSelect.tsx Wyświetl plik

@@ -1,154 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { debounce } from 'lodash';
import * as React from 'react';
import { translate, translateWithParameters } from '../../helpers/l10n';
import SelectLegacy, { CreatableLegacy } from './SelectLegacy';

interface Props<T> {
autofocus?: boolean;
canCreate?: boolean;
className?: string;
clearable?: boolean;
defaultOptions?: T[];
minimumQueryLength?: number;
multi?: boolean;
onSearch: (query: string) => Promise<T[]>;
onSelect?: (option: T) => void;
onMultiSelect?: (options: T[]) => void;
promptTextCreator?: (label: string) => string;
renderOption?: (option: T) => JSX.Element;
resetOnBlur?: boolean;
value?: T | T[];
}

interface State<T> {
loading: boolean;
options: T[];
query: string;
}

export default class SearchSelect<T extends { value: string }> extends React.PureComponent<
Props<T>,
State<T>
> {
mounted = false;

constructor(props: Props<T>) {
super(props);
this.state = { loading: false, options: props.defaultOptions || [], query: '' };
this.handleSearch = debounce(this.handleSearch, 250);
}

componentDidMount() {
this.mounted = true;
}

componentWillUnmount() {
this.mounted = false;
}

get autofocus() {
return this.props.autofocus !== undefined ? this.props.autofocus : true;
}

get minimumQueryLength() {
return this.props.minimumQueryLength !== undefined ? this.props.minimumQueryLength : 2;
}

get resetOnBlur() {
return this.props.resetOnBlur !== undefined ? this.props.resetOnBlur : true;
}

handleSearch = (query: string) => {
// Ignore the result if the query changed
const currentQuery = query;
this.props.onSearch(currentQuery).then(
options => {
if (this.mounted) {
this.setState(state => ({
loading: false,
options: state.query === currentQuery ? options : state.options
}));
}
},
() => {
if (this.mounted) {
this.setState({ loading: false });
}
}
);
};

handleChange = (option: T | T[]) => {
if (Array.isArray(option)) {
if (this.props.onMultiSelect) {
this.props.onMultiSelect(option);
}
} else if (this.props.onSelect) {
this.props.onSelect(option);
}
};

handleInputChange = (query: string) => {
if (query.length >= this.minimumQueryLength) {
this.setState({ loading: true, query });
this.handleSearch(query);
} else {
// `onInputChange` is called with an empty string after a user selects a value
// in this case we shouldn't reset `options`, because it also resets select value :(
const options = (query.length === 0 && this.props.defaultOptions) || [];
this.setState({ options, query });
}
};

// disable internal filtering
handleFilterOption = () => true;

render() {
const Component = this.props.canCreate ? CreatableLegacy : SelectLegacy;
return (
<Component
autoFocus={this.autofocus}
className={this.props.className}
clearable={this.props.clearable}
escapeClearsValue={false}
filterOption={this.handleFilterOption}
isLoading={this.state.loading}
multi={this.props.multi}
noResultsText={
this.state.query.length < this.minimumQueryLength
? translateWithParameters('select2.tooShort', this.minimumQueryLength)
: translate('select2.noMatches')
}
onBlurResetsInput={this.resetOnBlur}
onChange={this.handleChange}
onInputChange={this.handleInputChange}
optionRenderer={this.props.renderOption}
options={this.state.options}
placeholder={translate('search_verb')}
promptTextCreator={this.props.promptTextCreator}
searchable={true}
value={this.props.value}
valueRenderer={this.props.renderOption}
/>
);
}
}

+ 96
- 22
server/sonar-web/src/main/js/components/controls/Select.tsx Wyświetl plik

@@ -18,8 +18,20 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import styled from '@emotion/styled';
import classNames from 'classnames';
import * as React from 'react';
import ReactSelect, { GroupTypeBase, IndicatorProps, Props, StylesConfig } from 'react-select';
import ReactSelect, {
GroupTypeBase,
IndicatorProps,
OptionTypeBase,
Props,
StylesConfig
} from 'react-select';
import AsyncReactSelect, { AsyncProps } from 'react-select/async';
import AsyncCreatableReactSelect, {
Props as AsyncCreatableProps
} from 'react-select/async-creatable';
import { LoadingIndicatorProps } from 'react-select/src/components/indicators';
import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
import { colors, others, sizes, zIndexes } from '../../app/theme';
import { ClearButton } from './buttons';
@@ -38,29 +50,51 @@ export interface BasicSelectOption {
value: string;
}

export default class Select<
Option,
export function dropdownIndicator<
Option extends OptionTypeBase,
IsMulti extends boolean = false,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
> extends React.PureComponent<Props<Option, IsMulti, Group>> {
dropdownIndicator({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
return <ArrowSpan {...innerProps} />;
}
>({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
return <ArrowSpan {...innerProps} />;
}

clearIndicator({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
return (
<ClearButton
className="button-tiny spacer-left spacer-right text-middle"
iconProps={{ size: 12 }}
{...innerProps}
/>
);
}
export function clearIndicator<
Option extends OptionTypeBase,
IsMulti extends boolean = false,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
>({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
return (
<ClearButton
className="button-tiny spacer-left spacer-right text-middle"
iconProps={{ size: 12 }}
{...innerProps}
/>
);
}

multiValueRemove(props: MultiValueRemoveProps<Option, Group>) {
return <div {...props.innerProps}>×</div>;
}
export function loadingIndicator<
Option extends OptionTypeBase,
IsMulti extends boolean,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
>({ innerProps }: LoadingIndicatorProps<Option, IsMulti, Group>) {
return (
<i className={classNames('deferred-spinner spacer-left spacer-right', innerProps.className)} />
);
}

export function multiValueRemove<
Option extends OptionTypeBase,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
>(props: MultiValueRemoveProps<Option, Group>) {
return <div {...props.innerProps}>×</div>;
}

/* Keeping it as a class to simplify a dozen tests */
export default class Select<
Option,
IsMulti extends boolean = false,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
> extends React.Component<Props<Option, IsMulti, Group>> {
render() {
return (
<ReactSelect
@@ -68,15 +102,55 @@ export default class Select<
styles={selectStyle<Option, IsMulti, Group>()}
components={{
...this.props.components,
DropdownIndicator: this.dropdownIndicator,
ClearIndicator: this.clearIndicator,
MultiValueRemove: this.multiValueRemove
DropdownIndicator: dropdownIndicator,
ClearIndicator: clearIndicator,
MultiValueRemove: multiValueRemove
}}
/>
);
}
}

export function CreatableSelect<
Option,
isMulti extends boolean,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
>(props: AsyncCreatableProps<Option, isMulti, Group>) {
return (
<AsyncCreatableReactSelect
{...props}
styles={selectStyle<Option, isMulti, Group>()}
components={{
...props.components,
DropdownIndicator: dropdownIndicator,
ClearIndicator: clearIndicator,
MultiValueRemove: multiValueRemove,
LoadingIndicator: loadingIndicator
}}
/>
);
}

export function SearchSelect<
Option,
isMulti extends boolean,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
>(props: Props<Option, isMulti, Group> & AsyncProps<Option, Group>) {
return (
<AsyncReactSelect
{...props}
styles={selectStyle<Option, isMulti, Group>()}
components={{
...props.components,
DropdownIndicator: dropdownIndicator,
ClearIndicator: clearIndicator,
MultiValueRemove: multiValueRemove,
LoadingIndicator: loadingIndicator
}}
/>
);
}

export function selectStyle<
Option,
IsMulti extends boolean,

+ 0
- 50
server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx Wyświetl plik

@@ -1,50 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 SearchSelect from '../SearchSelect';

jest.mock('lodash', () => {
const lodash = jest.requireActual('lodash');
lodash.debounce = jest.fn(fn => fn);
return lodash;
});

it('should render Select', () => {
expect(shallow(<SearchSelect onSearch={jest.fn()} onSelect={jest.fn()} />)).toMatchSnapshot();
});

it('should call onSelect', () => {
const onSelect = jest.fn();
const wrapper = shallow(<SearchSelect onSearch={jest.fn()} onSelect={onSelect} />);
wrapper.prop('onChange')({ value: 'foo' });
expect(onSelect).lastCalledWith({ value: 'foo' });
});

it('should call onSearch', () => {
const onSearch = jest.fn().mockReturnValue(Promise.resolve([]));
const wrapper = shallow(
<SearchSelect minimumQueryLength={2} onSearch={onSearch} onSelect={jest.fn()} />
);
wrapper.prop('onInputChange')('f');
expect(onSearch).not.toHaveBeenCalled();
wrapper.prop('onInputChange')('foo');
expect(onSearch).lastCalledWith('foo');
});

+ 48
- 20
server/sonar-web/src/main/js/components/controls/__tests__/Select-test.tsx Wyświetl plik

@@ -19,9 +19,18 @@
*/
import { shallow } from 'enzyme';
import * as React from 'react';
import { components, GroupTypeBase, InputProps, Props } from 'react-select';
import { components, GroupTypeBase, InputProps, Props as ReactSelectProps } from 'react-select';
import { LoadingIndicatorProps } from 'react-select/src/components/indicators';
import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
import { mockReactSelectIndicatorProps } from '../../../helpers/mocks/react-select';
import Select from '../Select';
import Select, {
clearIndicator,
CreatableSelect,
dropdownIndicator,
loadingIndicator,
multiValueRemove,
SearchSelect
} from '../Select';

describe('Select', () => {
it('should render correctly', () => {
@@ -33,35 +42,54 @@ describe('Select', () => {
<components.Input {...props} className={`little-spacer-top ${props.className}`} />
);

const props = {
isClearable: true,
isLoading: true,
components: {
Input: inputRenderer
}
};
expect(shallowRender(props)).toMatchSnapshot('other props');
expect(
shallowRender({
isClearable: true,
isLoading: true,
components: {
Input: inputRenderer
}
})
).toMatchSnapshot('other props');
});

it('should render clearIndicator correctly', () => {
const wrapper = shallowRender();
const ClearIndicator = wrapper.instance().clearIndicator;
const clearIndicator = shallow(<ClearIndicator {...mockReactSelectIndicatorProps()} />);
expect(clearIndicator).toBeDefined();
expect(clearIndicator(mockReactSelectIndicatorProps({ value: '' }))).toMatchSnapshot();
});

it('should render dropdownIndicator correctly', () => {
const wrapper = shallowRender();
const DropdownIndicator = wrapper.instance().dropdownIndicator;
const clearIndicator = shallow(<DropdownIndicator {...mockReactSelectIndicatorProps()} />);
expect(clearIndicator).toBeDefined();
expect(dropdownIndicator(mockReactSelectIndicatorProps({ value: '' }))).toMatchSnapshot();
});

it('should render loadingIndicator correctly', () => {
expect(
loadingIndicator({ innerProps: { className: 'additional-class' } } as LoadingIndicatorProps<
{},
false
>)
).toMatchSnapshot();
});

it('should render multiValueRemove correctly', () => {
expect(multiValueRemove({ innerProps: {} } as MultiValueRemoveProps<{}>)).toMatchSnapshot();
});

function shallowRender<
Option,
IsMulti extends boolean = false,
Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
>(props: Partial<Props<Option, IsMulti, Group>> = {}) {
return shallow<Select<Option, IsMulti, Group>>(<Select {...props} />);
>(props: Partial<ReactSelectProps<Option, IsMulti, Group>> = {}) {
return shallow<ReactSelectProps<Option, IsMulti, Group>>(<Select {...props} />);
}
});

it.each([
['CreatableSelect', CreatableSelect],
['SearchSelect', SearchSelect]
])('should render %s correctly', (_name, Component) => {
expect(
shallow(<Component />)
.dive()
.dive()
).toMatchSnapshot();
});

+ 0
- 17
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap Wyświetl plik

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

exports[`should render Select 1`] = `
<SelectLegacy
autoFocus={true}
escapeClearsValue={false}
filterOption={[Function]}
isLoading={false}
noResultsText="select2.tooShort.2"
onBlurResetsInput={true}
onChange={[Function]}
onInputChange={[Function]}
options={Array []}
placeholder="search_verb"
searchable={true}
/>
`;

+ 151
- 0
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Select-test.tsx.snap Wyświetl plik

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

exports[`Select should render clearIndicator correctly 1`] = `
<ClearButton
className="button-tiny spacer-left spacer-right text-middle"
iconProps={
Object {
"size": 12,
}
}
/>
`;

exports[`Select should render complex select component: other props 1`] = `
<StateManager
components={
@@ -70,3 +81,143 @@ exports[`Select should render correctly: default 1`] = `
}
/>
`;

exports[`Select should render dropdownIndicator correctly 1`] = `<Styled(span) />`;

exports[`Select should render loadingIndicator correctly 1`] = `
<i
className="deferred-spinner spacer-left spacer-right additional-class"
/>
`;

exports[`Select should render multiValueRemove correctly 1`] = `
<div>
×
</div>
`;

exports[`should render CreatableSelect correctly 1`] = `
<Creatable
allowCreateWhileLoading={false}
cacheOptions={false}
components={
Object {
"ClearIndicator": [Function],
"DropdownIndicator": [Function],
"LoadingIndicator": [Function],
"MultiValueRemove": [Function],
}
}
createOptionPosition="last"
defaultOptions={false}
filterOption={null}
formatCreateLabel={[Function]}
getNewOptionData={[Function]}
getOptionLabel={[Function]}
getOptionValue={[Function]}
inputValue=""
isLoading={false}
isValidNewOption={[Function]}
menuIsOpen={false}
onChange={[Function]}
onInputChange={[Function]}
onMenuClose={[Function]}
onMenuOpen={[Function]}
options={Array []}
styles={
Object {
"container": [Function],
"control": [Function],
"indicatorsContainer": [Function],
"input": [Function],
"loadingIndicator": [Function],
"menu": [Function],
"menuList": [Function],
"multiValue": [Function],
"multiValueLabel": [Function],
"multiValueRemove": [Function],
"noOptionsMessage": [Function],
"option": [Function],
"placeholder": [Function],
"singleValue": [Function],
"valueContainer": [Function],
}
}
value={null}
/>
`;

exports[`should render SearchSelect correctly 1`] = `
<Select
aria-live="polite"
backspaceRemovesValue={true}
blurInputOnSelect={true}
cacheOptions={false}
captureMenuScroll={false}
closeMenuOnScroll={false}
closeMenuOnSelect={true}
components={
Object {
"ClearIndicator": [Function],
"DropdownIndicator": [Function],
"LoadingIndicator": [Function],
"MultiValueRemove": [Function],
}
}
controlShouldRenderValue={true}
defaultOptions={false}
escapeClearsValue={false}
filterOption={null}
formatGroupLabel={[Function]}
getOptionLabel={[Function]}
getOptionValue={[Function]}
inputValue=""
isDisabled={false}
isLoading={false}
isMulti={false}
isOptionDisabled={[Function]}
isRtl={false}
isSearchable={true}
loadingMessage={[Function]}
maxMenuHeight={300}
menuIsOpen={false}
menuPlacement="bottom"
menuPosition="absolute"
menuShouldBlockScroll={false}
menuShouldScrollIntoView={true}
minMenuHeight={140}
noOptionsMessage={[Function]}
onChange={[Function]}
onInputChange={[Function]}
onMenuClose={[Function]}
onMenuOpen={[Function]}
openMenuOnClick={true}
openMenuOnFocus={false}
options={Array []}
pageSize={5}
placeholder="Select..."
screenReaderStatus={[Function]}
styles={
Object {
"container": [Function],
"control": [Function],
"indicatorsContainer": [Function],
"input": [Function],
"loadingIndicator": [Function],
"menu": [Function],
"menuList": [Function],
"multiValue": [Function],
"multiValueLabel": [Function],
"multiValueRemove": [Function],
"noOptionsMessage": [Function],
"option": [Function],
"placeholder": [Function],
"singleValue": [Function],
"valueContainer": [Function],
}
}
tabIndex="0"
tabSelectsValue={true}
value={null}
/>
`;

+ 1
- 1
server/sonar-web/src/main/js/helpers/mocks/react-select.ts Wyświetl plik

@@ -49,6 +49,6 @@ export function mockReactSelectIndicatorProps<
OptionType,
IsMulti extends boolean,
GroupType extends GroupTypeBase<OptionType> = GroupTypeBase<OptionType>
>(): IndicatorProps<OptionType, IsMulti, GroupType> {
>(_option: OptionType): IndicatorProps<OptionType, IsMulti, GroupType> {
return {} as IndicatorProps<OptionType, IsMulti, GroupType>;
}

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

@@ -802,6 +802,7 @@ issues.on_file_x=Issues on file {0}
issue.add_tags=Add Tags
issue.remove_tags=Remove Tags
issue.no_tag=No tags
issue.create_tag_x=Create Tag '{0}'
issue.assign.assigned_to_x_click_to_change=Assigned to {0}, click to change
issue.assign.unassigned_click_to_assign=Unassigned, click to assign issue
issue.assign.formlink=Assign

Ładowanie…
Anuluj
Zapisz