@@ -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'; |
@@ -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 && ( |
@@ -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); | |||
}); | |||
@@ -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", |
@@ -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') | |||
} | |||
/> | |||
); | |||
} | |||
} |
@@ -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); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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(); | |||
}); |
@@ -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]} | |||
/> | |||
`; |
@@ -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={ |
@@ -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> | |||
</> |
@@ -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>) => { |
@@ -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) { |
@@ -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( |
@@ -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", | |||
} | |||
} |
@@ -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", | |||
}, | |||
] |
@@ -65,6 +65,7 @@ exports[`should render correctly 1`] = ` | |||
Array [ | |||
Object { | |||
"isMain": true, | |||
"label": "master", | |||
"value": "master", | |||
}, | |||
] |
@@ -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} | |||
/> | |||
); | |||
} | |||
} |
@@ -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, |
@@ -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'); | |||
}); |
@@ -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(); | |||
}); |
@@ -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} | |||
/> | |||
`; |
@@ -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} | |||
/> | |||
`; |
@@ -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>; | |||
} |
@@ -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 |