diff options
author | Jeremy Davis <jeremy.davis@sonarsource.com> | 2022-04-01 15:48:02 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-04-05 20:03:16 +0000 |
commit | 58533b7fcf8a2089df72928888fe3102b782fa77 (patch) | |
tree | 95e3af283956331a48a30594c6dd3b83a378363f /server/sonar-web/src | |
parent | ca68dabbefbad5122b57d57174130b33b2e93d22 (diff) | |
download | sonarqube-58533b7fcf8a2089df72928888fe3102b782fa77.tar.gz sonarqube-58533b7fcf8a2089df72928888fe3102b782fa77.zip |
SONAR-16221 Add select variants (creatable and async) and replace SelectLegacy
Diffstat (limited to 'server/sonar-web/src')
24 files changed, 932 insertions, 563 deletions
diff --git a/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts b/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts index ab9847c117b..429cb1ae374 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts +++ b/server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts @@ -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'; diff --git a/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx index 7b26bf5d4b4..ffde9cf777e 100644 --- a/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx @@ -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 && ( diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx index 2f37b51928b..380170f9a01 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx @@ -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); }); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap index ea876a7498e..56300a11343 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap @@ -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", diff --git a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx new file mode 100644 index 00000000000..987c4cb2039 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx @@ -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') + } + /> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx index 7754785e2af..78e5b564bee 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx @@ -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); } diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx new file mode 100644 index 00000000000..211010e483e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx @@ -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} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx index 6e91c7258cb..19c592dbb47 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx @@ -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(); }); diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap new file mode 100644 index 00000000000..9d4eaf6ca61 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap @@ -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]} +/> +`; diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap index d79ff90dddb..444ba88d326 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap @@ -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={ diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingReferenceBranch.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingReferenceBranch.tsx index cfe46334afe..12e94c24f5e 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingReferenceBranch.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingReferenceBranch.tsx @@ -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> </> diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx index 7b2d84aa6e6..b3b074509f4 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx @@ -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>) => { diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx index 0fec9735c38..190f93fcb13 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx @@ -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) { diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BaselineSettingReferenceBranch-test.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BaselineSettingReferenceBranch-test.tsx index 8d73da812a5..7ac3867884a 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BaselineSettingReferenceBranch-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BaselineSettingReferenceBranch-test.tsx @@ -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( diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BaselineSettingReferenceBranch-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BaselineSettingReferenceBranch-test.tsx.snap index 7ebf3e41e4f..d4f3b6d5534 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BaselineSettingReferenceBranch-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BaselineSettingReferenceBranch-test.tsx.snap @@ -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", } } diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BranchBaselineSettingModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BranchBaselineSettingModal-test.tsx.snap index faeab231d18..03eb20e011e 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BranchBaselineSettingModal-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BranchBaselineSettingModal-test.tsx.snap @@ -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", }, ] diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/ProjectBaselineSelector-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/ProjectBaselineSelector-test.tsx.snap index a0fe096a3b6..bb2a408d107 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/ProjectBaselineSelector-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/ProjectBaselineSelector-test.tsx.snap @@ -65,6 +65,7 @@ exports[`should render correctly 1`] = ` Array [ Object { "isMain": true, + "label": "master", "value": "master", }, ] diff --git a/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx b/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx deleted file mode 100644 index c89bd94f372..00000000000 --- a/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx +++ /dev/null @@ -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} - /> - ); - } -} diff --git a/server/sonar-web/src/main/js/components/controls/Select.tsx b/server/sonar-web/src/main/js/components/controls/Select.tsx index 4273c2fbbb0..42862806a66 100644 --- a/server/sonar-web/src/main/js/components/controls/Select.tsx +++ b/server/sonar-web/src/main/js/components/controls/Select.tsx @@ -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, diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx deleted file mode 100644 index b43492863b0..00000000000 --- a/server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx +++ /dev/null @@ -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'); -}); diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Select-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Select-test.tsx index 57a924e18bb..840cef0a826 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/Select-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/Select-test.tsx @@ -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(); +}); diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap deleted file mode 100644 index 9ee6e2df213..00000000000 --- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap +++ /dev/null @@ -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} -/> -`; diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Select-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Select-test.tsx.snap index a8ecb8b67ff..eff8131e9a9 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Select-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Select-test.tsx.snap @@ -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} +/> +`; diff --git a/server/sonar-web/src/main/js/helpers/mocks/react-select.ts b/server/sonar-web/src/main/js/helpers/mocks/react-select.ts index 05f49106ed4..d25e6abf791 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/react-select.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/react-select.ts @@ -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>; } |