diff options
29 files changed, 1354 insertions, 658 deletions
diff --git a/server/sonar-web/src/main/js/api/quality-gates.ts b/server/sonar-web/src/main/js/api/quality-gates.ts index b40e6130b28..fc72234b27e 100644 --- a/server/sonar-web/src/main/js/api/quality-gates.ts +++ b/server/sonar-web/src/main/js/api/quality-gates.ts @@ -133,10 +133,21 @@ export function getGateForProject(data: { ); } +export function searchGates(data: { + gateId: number; + organization?: string; + page: number; + pageSize: number; + selected: string; +}): Promise<void | Response> { + return getJSON('/api/qualitygates/search', data).catch(throwGlobalError); +} + export function associateGateWithProject(data: { gateId: number; organization?: string; - projectKey: string; + projectKey?: string; + projectId?: string; }): Promise<void | Response> { return post('/api/qualitygates/select', data).catch(throwGlobalError); } @@ -144,7 +155,8 @@ export function associateGateWithProject(data: { export function dissociateGateWithProject(data: { gateId: number; organization?: string; - projectKey: string; + projectKey?: string; + projectId?: string; }): Promise<void | Response> { return post('/api/qualitygates/deselect', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/api/quality-profiles.ts b/server/sonar-web/src/main/js/api/quality-profiles.ts index e589638ac76..bf8fc2b3eeb 100644 --- a/server/sonar-web/src/main/js/api/quality-profiles.ts +++ b/server/sonar-web/src/main/js/api/quality-profiles.ts @@ -107,7 +107,16 @@ export function restoreQualityProfile(data: RequestData): Promise<any> { .then(parseJSON); } -export function getProfileProjects(data: RequestData): Promise<any> { +export interface ProfileProject { + id: number; + key: string; + name: string; + selected: boolean; +} + +export function getProfileProjects( + data: RequestData +): Promise<{ more: boolean; results: ProfileProject[] }> { return getJSON('/api/qualityprofiles/projects', data).catch(throwGlobalError); } @@ -153,12 +162,16 @@ export function compareProfiles(leftKey: string, rightKey: string): Promise<any> return getJSON('/api/qualityprofiles/compare', { leftKey, rightKey }); } -export function associateProject(profileKey: string, projectKey: string): Promise<void> { - return post('/api/qualityprofiles/add_project', { profileKey, projectKey }); +export function associateProject(profileKey: string, projectKey: string) { + return post('/api/qualityprofiles/add_project', { profileKey, projectKey }).catch( + throwGlobalError + ); } -export function dissociateProject(profileKey: string, projectKey: string): Promise<void> { - return post('/api/qualityprofiles/remove_project', { profileKey, projectKey }); +export function dissociateProject(profileKey: string, projectKey: string) { + return post('/api/qualityprofiles/remove_project', { profileKey, projectKey }).catch( + throwGlobalError + ); } export interface SearchUsersGroupsParameters { diff --git a/server/sonar-web/src/main/js/api/user_groups.ts b/server/sonar-web/src/main/js/api/user_groups.ts index 2e1b85c5cdf..eadd920a5f0 100644 --- a/server/sonar-web/src/main/js/api/user_groups.ts +++ b/server/sonar-web/src/main/js/api/user_groups.ts @@ -28,7 +28,25 @@ export function searchUsersGroups(data: { ps?: number; q?: string; }): Promise<{ groups: Group[]; paging: Paging }> { - return getJSON('/api/user_groups/search', data); + return getJSON('/api/user_groups/search', data).catch(throwGlobalError); +} + +export interface GroupUser { + login: string; + name: string; + selected: boolean; +} + +export function getUsersInGroup(data: { + id?: number; + name?: string; + organization?: string; + p?: number; + ps?: number; + q?: string; + selected?: string; +}): Promise<{ paging: Paging; users: GroupUser[] }> { + return getJSON('/api/user_groups/users', data).catch(throwGlobalError); } export function addUserToGroup(data: { @@ -37,7 +55,7 @@ export function addUserToGroup(data: { login?: string; organization?: string; }) { - return post('/api/user_groups/add_user', data); + return post('/api/user_groups/add_user', data).catch(throwGlobalError); } export function removeUserFromGroup(data: { @@ -46,7 +64,7 @@ export function removeUserFromGroup(data: { login?: string; organization?: string; }) { - return post('/api/user_groups/remove_user', data); + return post('/api/user_groups/remove_user', data).catch(throwGlobalError); } export function createGroup(data: { diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index b8667f54e38..055be31081f 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -33,11 +33,30 @@ export function changePassword(data: { return post('/api/users/change_password', data); } -export function getUserGroups(login: string, organization?: string): Promise<any> { +export interface UserGroup { + default: boolean; + description: string; + id: number; + name: string; + selected: boolean; +} + +export function getUserGroups( + login: string, + organization?: string, + query?: string, + selected?: string +): Promise<{ paging: Paging; groups: UserGroup[] }> { const data: RequestData = { login }; if (organization) { data.organization = organization; } + if (query) { + data.q = query; + } + if (selected) { + data.selected = selected; + } return getJSON('/api/users/groups', data); } diff --git a/server/sonar-web/src/main/js/app/styles/init/icons.css b/server/sonar-web/src/main/js/app/styles/init/icons.css index 9955f3fef6e..b6c9cdb078b 100644 --- a/server/sonar-web/src/main/js/app/styles/init/icons.css +++ b/server/sonar-web/src/main/js/app/styles/init/icons.css @@ -310,6 +310,15 @@ a[class*=' icon-'] { background-image: url('data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M10%204.698C10%204.312%209.688%204%209.302%204H4.698C4.312%204%204%204.312%204%204.698v4.604c0%20.386.312.698.698.698h4.604c.386%200%20.698-.312.698-.698V4.698z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E'); } +.icon-checkbox-disabled:before { + border: 1px solid #bbb; + cursor: not-allowed; +} + +.icon-checkbox-disabled.icon-checkbox-checked:before { + background-color: #bbb; +} + .icon-checkbox-invisible { visibility: hidden; } diff --git a/server/sonar-web/src/main/js/app/styles/init/type.css b/server/sonar-web/src/main/js/app/styles/init/type.css index 28ab828201f..943ee5497a8 100644 --- a/server/sonar-web/src/main/js/app/styles/init/type.css +++ b/server/sonar-web/src/main/js/app/styles/init/type.css @@ -242,7 +242,7 @@ small, } .text-muted { - color: var(--secondFontColor); + color: var(--secondFontColor) !important; } .text-muted-2 { diff --git a/server/sonar-web/src/main/js/app/utils/exposeLibraries.ts b/server/sonar-web/src/main/js/app/utils/exposeLibraries.ts index 1cd899f9064..001ed45dd06 100644 --- a/server/sonar-web/src/main/js/app/utils/exposeLibraries.ts +++ b/server/sonar-web/src/main/js/app/utils/exposeLibraries.ts @@ -34,7 +34,7 @@ import Modal from '../../components/controls/Modal'; import SearchBox from '../../components/controls/SearchBox'; import Select from '../../components/controls/Select'; import Tooltip from '../../components/controls/Tooltip'; -import SelectList from '../../components/SelectList'; +import SelectList from '../../components/SelectList/SelectList'; import CoverageRating from '../../components/ui/CoverageRating'; import DuplicationsRating from '../../components/ui/DuplicationsRating'; import Level from '../../components/ui/Level'; diff --git a/server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx b/server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx index 9b62320e86b..ee0fc20aacc 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx @@ -18,14 +18,19 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import * as escapeHtml from 'escape-html'; +import { find, without } from 'lodash'; import { Group } from '../../../app/types'; import Modal from '../../../components/controls/Modal'; import BulletListIcon from '../../../components/icons-components/BulletListIcon'; -import SelectList from '../../../components/SelectList'; +import SelectList, { Filter } from '../../../components/SelectList/SelectList'; import { ButtonIcon, ResetButtonLink } from '../../../components/ui/buttons'; import { translate } from '../../../helpers/l10n'; -import { getBaseUrl } from '../../../helpers/urls'; +import { + getUsersInGroup, + addUserToGroup, + removeUserFromGroup, + GroupUser +} from '../../../api/user_groups'; interface Props { group: Group; @@ -35,14 +40,17 @@ interface Props { interface State { modal: boolean; + users: GroupUser[]; + selectedUsers: string[]; } export default class EditMembers extends React.PureComponent<Props, State> { container?: HTMLElement | null; mounted = false; - state: State = { modal: false }; + state: State = { modal: false, users: [], selectedUsers: [] }; componentDidMount() { + this.handleSearch('', Filter.Selected); this.mounted = true; } @@ -50,13 +58,49 @@ export default class EditMembers extends React.PureComponent<Props, State> { this.mounted = false; } - handleMembersClick = () => { - this.setState({ modal: true }, () => { - // defer rendering of the SelectList to make sure we have `ref` assigned - setTimeout(this.renderSelectList, 0); + handleSearch = (query: string, selected: Filter) => { + return getUsersInGroup({ + id: this.props.group.id, + organization: this.props.organization, + ps: 100, + q: query !== '' ? query : undefined, + selected + }).then(data => { + this.setState({ + users: data.users, + selectedUsers: data.users.filter(user => user.selected).map(user => user.login) + }); + }); + }; + + handleSelect = (login: string) => { + return addUserToGroup({ + name: this.props.group.name, + login, + organization: this.props.organization + }).then(() => { + this.setState((state: State) => ({ + selectedUsers: [...state.selectedUsers, login] + })); + }); + }; + + handleUnselect = (login: string) => { + return removeUserFromGroup({ + name: this.props.group.name, + login, + organization: this.props.organization + }).then(() => { + this.setState((state: State) => ({ + selectedUsers: without(state.selectedUsers, login) + })); }); }; + handleMembersClick = () => { + this.setState({ modal: true }); + }; + handleModalClose = () => { if (this.mounted) { this.setState({ modal: false }); @@ -64,29 +108,9 @@ export default class EditMembers extends React.PureComponent<Props, State> { } }; - renderSelectList = () => { - if (this.container) { - const extra = { name: this.props.group.name, organization: this.props.organization }; - - /* eslint-disable no-new */ - new SelectList({ - el: this.container, - width: '100%', - readOnly: false, - focusSearch: false, - dangerouslyUnescapedHtmlFormat: (item: { login: string; name: string }) => - `${escapeHtml(item.name)}<br><span class="note">${escapeHtml(item.login)}</span>`, - queryParam: 'q', - searchUrl: getBaseUrl() + '/api/user_groups/users?ps=100&id=' + this.props.group.id, - selectUrl: getBaseUrl() + '/api/user_groups/add_user', - deselectUrl: getBaseUrl() + '/api/user_groups/remove_user', - extra, - selectParameter: 'login', - selectParameterValue: 'login', - parse: (r: any) => r.users - }); - /* eslint-enable no-new */ - } + renderElement = (login: string): React.ReactNode => { + const user = find(this.state.users, { login }); + return user === undefined ? login : user.login; }; render() { @@ -104,7 +128,14 @@ export default class EditMembers extends React.PureComponent<Props, State> { </header> <div className="modal-body"> - <div id="groups-users" ref={node => (this.container = node)} /> + <SelectList + elements={this.state.users.map(user => user.login)} + onSearch={this.handleSearch} + onSelect={this.handleSelect} + onUnselect={this.handleUnselect} + renderElement={this.renderElement} + selectedElements={this.state.selectedUsers} + /> </div> <footer className="modal-foot"> diff --git a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap index 230836640f5..a0b1f679d14 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap @@ -33,8 +33,13 @@ exports[`should edit members 2`] = ` <div className="modal-body" > - <div - id="groups-users" + <SelectList + elements={Array []} + onSearch={[Function]} + onSelect={[Function]} + onUnselect={[Function]} + renderElement={[Function]} + selectedElements={Array []} /> </div> <footer diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.js b/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.js index 09d8baf401d..b51bed9bd11 100644 --- a/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.js +++ b/server/sonar-web/src/main/js/apps/quality-gates/components/Projects.js @@ -18,55 +18,92 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import React from 'react'; -import escapeHtml from 'escape-html'; -import SelectList from '../../../components/SelectList'; +import { find, without } from 'lodash'; +import SelectList, { Filter } from '../../../components/SelectList/SelectList'; import { translate } from '../../../helpers/l10n'; -import { getBaseUrl } from '../../../helpers/urls'; +import { + searchGates, + associateGateWithProject, + dissociateGateWithProject +} from '../../../api/quality-gates'; +/*:: import { Project } from '../../projects/types'; */ + +/*:: +type State = { + projects: Projects[], + selectedProjects: string[] +}; +*/ export default class Projects extends React.PureComponent { + state /*: State */ = { projects: [], selectedProjects: [] }; + componentDidMount() { - this.renderSelectList(); + this.handleSearch('', Filter.Selected); } - renderSelectList = () => { - if (!this.container) return; - - const { qualityGate, edit, organization } = this.props; + handleSearch = (query /*: string*/, selected /*: string */) => { + return searchGates({ + gateId: this.props.qualityGate.id, + organization: this.props.organization, + pageSize: 100, + query: query !== '' ? query : undefined, + selected + }).then(data => { + this.setState({ + projects: data.results, + selectedProjects: data.results + .filter(project => project.selected) + .map(project => project.id) + }); + }); + }; - const extra = { gateId: qualityGate.id }; - let orgQuery = ''; - if (organization) { - extra.organization = organization; - orgQuery = '&organization=' + organization; - } + handleSelect = (id /*: string*/) => { + return associateGateWithProject({ + gateId: this.props.qualityGate.id, + organization: this.props.organization, + projectId: id + }).then(() => { + this.setState((state /*: State*/) => ({ + selectedProjects: [...state.selectedProjects, id] + })); + }); + }; - // eslint-disable-next-line no-new - new SelectList({ - el: this.container, - width: '100%', - readOnly: !edit, - focusSearch: false, - dangerouslyUnescapedHtmlFormat: item => escapeHtml(item.name), - searchUrl: getBaseUrl() + `/api/qualitygates/search?gateId=${qualityGate.id}${orgQuery}`, - selectUrl: getBaseUrl() + '/api/qualitygates/select', - deselectUrl: getBaseUrl() + '/api/qualitygates/deselect', - extra, - selectParameter: 'projectId', - selectParameterValue: 'id', - labels: { - selected: translate('quality_gates.projects.with'), - deselected: translate('quality_gates.projects.without'), - all: translate('quality_gates.projects.all'), - noResults: translate('quality_gates.projects.noResults') + handleUnselect = (id /*: string*/) => { + return dissociateGateWithProject({ + gateId: this.props.qualityGate.id, + organization: this.props.organization, + projectId: id + }).then( + () => { + this.setState((state /*: State*/) => ({ + selectedProjects: without(state.selectedProjects, id) + })); }, - tooltips: { - select: translate('quality_gates.projects.select_hint'), - deselect: translate('quality_gates.projects.deselect_hint') - } - }); + () => {} + ); + }; + + renderElement = (id /*: string*/) /*: React.ReactNode*/ => { + const project = find(this.state.projects, { id }); + return project === undefined ? id : project.name; }; render() { - return <div ref={node => (this.container = node)} />; + return ( + <SelectList + elements={this.state.projects.map(project => project.id)} + labelAll={translate('quality_gates.projects.all')} + labelSelected={translate('quality_gates.projects.with')} + labelUnselected={translate('quality_gates.projects.without')} + onSearch={this.handleSearch} + onSelect={this.handleSelect} + onUnselect={this.handleUnselect} + renderElement={this.renderElement} + selectedElements={this.state.selectedProjects} + /> + ); } } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx index 8eb09c52072..408c5afa85d 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx @@ -18,11 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import * as escapeHtml from 'escape-html'; +import { find, without } from 'lodash'; import Modal from '../../../components/controls/Modal'; -import SelectList from '../../../components/SelectList'; +import SelectList, { Filter } from '../../../components/SelectList/SelectList'; import { translate } from '../../../helpers/l10n'; import { Profile } from '../types'; +import { + getProfileProjects, + associateProject, + dissociateProject, + ProfileProject +} from '../../../api/quality-profiles'; interface Props { onClose: () => void; @@ -30,61 +36,84 @@ interface Props { profile: Profile; } +interface State { + projects: ProfileProject[]; + selectedProjects: string[]; +} + export default class ChangeProjectsForm extends React.PureComponent<Props> { container?: HTMLElement | null; + state: State = { projects: [], selectedProjects: [] }; + + componentDidMount() { + this.handleSearch('', Filter.Selected); + } + + handleSearch = (query: string, selected: Filter) => { + return getProfileProjects({ + key: this.props.profile.key, + organization: this.props.organization, + pageSize: 100, + query: query !== '' ? query : undefined, + selected + }).then( + data => { + this.setState({ + projects: data.results, + selectedProjects: data.results + .filter(project => project.selected) + .map(project => project.key) + }); + }, + () => {} + ); + }; + + handleSelect = (key: string) => { + return associateProject(this.props.profile.key, key).then(() => { + this.setState((state: State) => ({ + selectedProjects: [...state.selectedProjects, key] + })); + }); + }; + + handleUnselect = (key: string) => { + return dissociateProject(this.props.profile.key, key).then(() => { + this.setState((state: State) => ({ selectedProjects: without(state.selectedProjects, key) })); + }); + }; handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => { event.preventDefault(); this.props.onClose(); }; - renderSelectList = () => { - if (this.container) { - const { key } = this.props.profile; - - const searchUrl = - (window as any).baseUrl + '/api/qualityprofiles/projects?key=' + encodeURIComponent(key); - - new SelectList({ - searchUrl, - el: this.container, - width: '100%', - readOnly: false, - focusSearch: false, - dangerouslyUnescapedHtmlFormat: (item: { name: string }) => escapeHtml(item.name), - selectUrl: (window as any).baseUrl + '/api/qualityprofiles/add_project', - deselectUrl: (window as any).baseUrl + '/api/qualityprofiles/remove_project', - extra: { profileKey: key }, - selectParameter: 'projectUuid', - selectParameterValue: 'uuid', - labels: { - selected: translate('quality_gates.projects.with'), - deselected: translate('quality_gates.projects.without'), - all: translate('quality_gates.projects.all'), - noResults: translate('quality_gates.projects.noResults') - }, - tooltips: { - select: translate('quality_profiles.projects.select_hint'), - deselect: translate('quality_profiles.projects.deselect_hint') - } - }); - } + renderElement = (key: string): React.ReactNode => { + const project = find(this.state.projects, { key }); + return project === undefined ? key : project.name; }; render() { const header = translate('projects'); return ( - <Modal - contentLabel={header} - onAfterOpen={this.renderSelectList} - onRequestClose={this.props.onClose}> + <Modal contentLabel={header} onRequestClose={this.props.onClose}> <div className="modal-head"> <h2>{header}</h2> </div> - <div className="modal-body"> - <div id="profile-projects" ref={node => (this.container = node)} /> + <div className="modal-body" id="profile-projects"> + <SelectList + elements={this.state.projects.map(project => project.key)} + labelAll={translate('quality_gates.projects.all')} + labelSelected={translate('quality_gates.projects.with')} + labelUnselected={translate('quality_gates.projects.without')} + onSearch={this.handleSearch} + onSelect={this.handleSelect} + onUnselect={this.handleUnselect} + renderElement={this.renderElement} + selectedElements={this.state.selectedProjects} + /> </div> <div className="modal-foot"> diff --git a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx index a15eac7f596..6bb46a38335 100644 --- a/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx @@ -18,12 +18,13 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import * as escapeHtml from 'escape-html'; +import { find, without } from 'lodash'; import { User } from '../../../app/types'; import Modal from '../../../components/controls/Modal'; -import SelectList from '../../../components/SelectList'; +import SelectList, { Filter } from '../../../components/SelectList/SelectList'; import { translate } from '../../../helpers/l10n'; -import { getBaseUrl } from '../../../helpers/urls'; +import { getUserGroups, UserGroup } from '../../../api/users'; +import { addUserToGroup, removeUserFromGroup } from '../../../api/user_groups'; interface Props { onClose: () => void; @@ -31,8 +32,48 @@ interface Props { user: User; } +interface State { + error: string; + groups: UserGroup[]; + selectedGroups: string[]; +} + export default class GroupsForm extends React.PureComponent<Props> { container?: HTMLDivElement | null; + state: State = { error: '', groups: [], selectedGroups: [] }; + + componentDidMount() { + this.handleSearch('', Filter.Selected); + } + + handleSearch = (query: string, selected: Filter) => { + return getUserGroups(this.props.user.login, undefined, query, selected).then(data => { + this.setState({ + groups: data.groups, + selectedGroups: data.groups.filter(group => group.selected).map(group => group.name) + }); + }); + }; + + handleSelect = (name: string) => { + return addUserToGroup({ + name, + login: this.props.user.login + }).then(() => { + this.setState((state: State) => ({ selectedGroups: [...state.selectedGroups, name] })); + }); + }; + + handleUnselect = (name: string) => { + return removeUserFromGroup({ + name, + login: this.props.user.login + }).then(() => { + this.setState((state: State) => ({ + selectedGroups: without(state.selectedGroups, name) + })); + }); + }; handleCloseClick = (event: React.SyntheticEvent<HTMLElement>) => { event.preventDefault(); @@ -44,46 +85,34 @@ export default class GroupsForm extends React.PureComponent<Props> { this.props.onClose(); }; - renderSelectList = () => { - const searchUrl = `${getBaseUrl()}/api/users/groups?ps=100&login=${encodeURIComponent( - this.props.user.login - )}`; - - new (SelectList as any)({ - el: this.container, - width: '100%', - readOnly: false, - focusSearch: false, - dangerouslyUnescapedHtmlFormat: (item: { name: string; description: string }) => - `${escapeHtml(item.name)}<br><span class="note">${escapeHtml(item.description)}</span>`, - queryParam: 'q', - searchUrl, - selectUrl: getBaseUrl() + '/api/user_groups/add_user', - deselectUrl: getBaseUrl() + '/api/user_groups/remove_user', - extra: { login: this.props.user.login }, - selectParameter: 'id', - selectParameterValue: 'id', - parse(r: any) { - this.more = false; - return r.groups; - } - }); + renderElement = (name: string): React.ReactNode => { + const group = find(this.state.groups, { name }); + return group === undefined ? name : group.name; }; render() { const header = translate('users.update_groups'); return ( - <Modal - contentLabel={header} - onAfterOpen={this.renderSelectList} - onRequestClose={this.handleClose}> + <Modal contentLabel={header} onRequestClose={this.handleClose}> <div className="modal-head"> <h2>{header}</h2> </div> <div className="modal-body"> - <div id="user-groups" ref={node => (this.container = node)} /> + {this.state.error !== '' && ( + <div className="alert alert-danger"> + <p>{this.state.error}</p> + </div> + )} + <SelectList + elements={this.state.groups.map(group => group.name)} + onSearch={this.handleSearch} + onSelect={this.handleSelect} + onUnselect={this.handleUnselect} + renderElement={this.renderElement} + selectedElements={this.state.selectedGroups} + /> </div> <footer className="modal-foot"> diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx new file mode 100644 index 00000000000..c57fc9a7029 --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/SelectList.tsx @@ -0,0 +1,128 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import SelectListListContainer from './SelectListListContainer'; +import { translate } from '../../helpers/l10n'; +import SearchBox from '../controls/SearchBox'; +import RadioToggle from '../controls/RadioToggle'; +import './styles.css'; + +export enum Filter { + All = 'all', + Selected = 'selected', + Unselected = 'deselected' +} + +interface Props { + elements: string[]; + disabledElements?: string[]; + labelSelected?: string; + labelUnselected?: string; + labelAll?: string; + onSearch: (query: string, tab: Filter) => Promise<void>; + onSelect: (element: string) => Promise<void>; + onUnselect: (element: string) => Promise<void>; + renderElement: (element: string) => React.ReactNode; + selectedElements: string[]; +} + +interface State { + filter: Filter; + loading: boolean; + query: string; +} + +export default class SelectList extends React.PureComponent<Props, State> { + mounted = false; + state: State = { filter: Filter.Selected, loading: false, query: '' }; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + changeFilter = (filter: Filter) => { + this.setState({ filter, loading: true }); + this.props.onSearch(this.state.query, filter).then(this.stopLoading, this.stopLoading); + }; + + handleQueryChange = (query: string) => { + this.setState({ loading: true, query }); + this.props.onSearch(query, this.getFilter()).then(this.stopLoading, this.stopLoading); + }; + + getFilter = () => { + return this.state.query === '' ? this.state.filter : Filter.All; + }; + + render() { + const { + labelSelected = translate('selected'), + labelUnselected = translate('unselected'), + labelAll = translate('all') + } = this.props; + const { filter } = this.state; + + const disabled = this.state.query !== ''; + + return ( + <div className="select-list"> + <div className="display-flex-center"> + <RadioToggle + className="spacer-right" + name="filter" + onCheck={this.changeFilter} + options={[ + { disabled, label: labelSelected, value: Filter.Selected }, + { disabled, label: labelUnselected, value: Filter.Unselected }, + { disabled, label: labelAll, value: Filter.All } + ]} + value={filter} + /> + <SearchBox + autoFocus={true} + loading={this.state.loading} + onChange={this.handleQueryChange} + placeholder={translate('search_verb')} + value={this.state.query} + /> + </div> + <SelectListListContainer + disabledElements={this.props.disabledElements || []} + elements={this.props.elements} + filter={this.getFilter()} + onSelect={this.props.onSelect} + onUnselect={this.props.onUnselect} + renderElement={this.props.renderElement} + selectedElements={this.props.selectedElements} + /> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx new file mode 100644 index 00000000000..ac54e39d417 --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx @@ -0,0 +1,71 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { Filter } from './SelectList'; +import SelectListListElement from './SelectListListElement'; + +interface Props { + elements: string[]; + disabledElements: string[]; + filter: Filter; + onSelect: (element: string) => Promise<void>; + onUnselect: (element: string) => Promise<void>; + renderElement: (element: string) => React.ReactNode; + selectedElements: string[]; +} + +export default class SelectListListContainer extends React.PureComponent<Props> { + isDisabled = (element: string): boolean => { + return this.props.disabledElements.includes(element); + }; + + isSelected = (element: string): boolean => { + return this.props.selectedElements.includes(element); + }; + + render() { + const { elements, filter } = this.props; + const filteredElements = elements.filter(element => { + if (filter === Filter.All) { + return true; + } + const isSelected = this.isSelected(element); + return filter === Filter.Selected ? isSelected : !isSelected; + }); + + return ( + <div className="select-list-list-container spacer-top"> + <ul className="menu"> + {filteredElements.map(element => ( + <SelectListListElement + disabled={this.isDisabled(element)} + element={element} + key={element} + onSelect={this.props.onSelect} + onUnselect={this.props.onUnselect} + renderElement={this.props.renderElement} + selected={this.isSelected(element)} + /> + ))} + </ul> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx b/server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx new file mode 100644 index 00000000000..a0a7afd7a0d --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx @@ -0,0 +1,75 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import Checkbox from '../controls/Checkbox'; + +interface Props { + active?: boolean; + disabled?: boolean; + element: string; + onSelect: (element: string) => Promise<void>; + onUnselect: (element: string) => Promise<void>; + renderElement: (element: string) => React.ReactNode; + selected: boolean; +} + +interface State { + loading: boolean; +} + +export default class SelectListListElement extends React.PureComponent<Props, State> { + mounted = false; + state: State = { loading: false }; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + handleCheck = (checked: boolean) => { + this.setState({ loading: true }); + const request = checked ? this.props.onSelect : this.props.onUnselect; + request(this.props.element).then(this.stopLoading, this.stopLoading); + }; + + render() { + return ( + <li> + <Checkbox + checked={this.props.selected} + className={classNames({ active: this.props.active })} + disabled={this.props.disabled} + onCheck={this.handleCheck}> + <span className="little-spacer-left">{this.props.renderElement(this.props.element)}</span> + </Checkbox> + </li> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx new file mode 100644 index 00000000000..0e27e47f406 --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx @@ -0,0 +1,78 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import SelectList, { Filter } from '../SelectList'; +import { waitAndUpdate } from '../../../helpers/testUtils'; + +const selectList = ( + <SelectList + elements={['foo', 'bar', 'baz']} + onSearch={jest.fn(() => Promise.resolve())} + onSelect={jest.fn(() => Promise.resolve())} + onUnselect={jest.fn(() => Promise.resolve())} + renderElement={(foo: string) => foo} + selectedElements={['foo']} + /> +); + +it('should display selected elements only by default', () => { + const wrapper = shallow(selectList); + expect(wrapper.state().filter).toBe(Filter.Selected); +}); + +it('should display a loader when searching', async () => { + const wrapper = shallow(selectList); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.state().loading).toBe(false); + + (wrapper.instance() as SelectList).handleQueryChange(''); + expect(wrapper.state().loading).toBe(true); + expect(wrapper).toMatchSnapshot(); + + await waitAndUpdate(wrapper); + expect(wrapper.state().loading).toBe(false); +}); + +it('should display a loader when updating filter', async () => { + const wrapper = shallow(selectList); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.state().loading).toBe(false); + + (wrapper.instance() as SelectList).changeFilter(Filter.Unselected); + expect(wrapper.state().loading).toBe(true); + expect(wrapper).toMatchSnapshot(); + + await waitAndUpdate(wrapper); + expect(wrapper.state().filter).toBe(Filter.Unselected); + expect(wrapper.state().loading).toBe(false); +}); + +it('should cancel filter selection when search is active', async () => { + const wrapper = shallow(selectList); + + wrapper.setState({ filter: Filter.Selected }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); + + wrapper.setState({ query: 'foobar' }); + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx new file mode 100644 index 00000000000..ef8440f052e --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import SelectListListContainer from '../SelectListListContainer'; +import { Filter } from '../SelectList'; + +const elementsContainer = ( + <SelectListListContainer + disabledElements={[]} + elements={['foo', 'bar', 'baz']} + filter={Filter.All} + onSelect={jest.fn(() => Promise.resolve())} + onUnselect={jest.fn(() => Promise.resolve())} + renderElement={(foo: string) => foo} + selectedElements={['foo']} + /> +); + +it('should display elements based on filters', () => { + const wrapper = shallow(elementsContainer); + expect(wrapper.find('SelectListListElement')).toHaveLength(3); + expect(wrapper).toMatchSnapshot(); + + wrapper.setProps({ filter: Filter.Unselected }); + expect(wrapper.find('SelectListListElement')).toHaveLength(2); + expect(wrapper).toMatchSnapshot(); + + wrapper.setProps({ filter: Filter.Selected }); + expect(wrapper.find('SelectListListElement')).toHaveLength(1); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx new file mode 100644 index 00000000000..d059fd5e848 --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx @@ -0,0 +1,47 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import SelectListListElement from '../SelectListListElement'; +import { waitAndUpdate } from '../../../helpers/testUtils'; + +const listElement = ( + <SelectListListElement + element={'foo'} + key={'foo'} + onSelect={jest.fn(() => Promise.resolve())} + onUnselect={jest.fn(() => Promise.resolve())} + renderElement={(foo: string) => foo} + selected={false} + /> +); + +it('should display a loader when checking', async () => { + const wrapper = shallow(listElement); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.state().loading).toBe(false); + + (wrapper.instance() as SelectListListElement).handleCheck(true); + expect(wrapper.state().loading).toBe(true); + expect(wrapper).toMatchSnapshot(); + + await waitAndUpdate(wrapper); + expect(wrapper.state().loading).toBe(false); +}); diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap new file mode 100644 index 00000000000..1ed4160821d --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap @@ -0,0 +1,379 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should cancel filter selection when search is active 1`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": false, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": false, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": false, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="selected" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> +</div> +`; + +exports[`should cancel filter selection when search is active 2`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": true, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": true, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": true, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="foobar" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="all" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> +</div> +`; + +exports[`should display a loader when searching 1`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": false, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": false, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": false, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="selected" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> +</div> +`; + +exports[`should display a loader when searching 2`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": false, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": false, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": false, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="selected" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> +</div> +`; + +exports[`should display a loader when updating filter 1`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": false, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": false, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": false, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="selected" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> +</div> +`; + +exports[`should display a loader when updating filter 2`] = ` +<div + className="select-list" +> + <div + className="display-flex-center" + > + <RadioToggle + className="spacer-right" + disabled={false} + name="filter" + onCheck={[Function]} + options={ + Array [ + Object { + "disabled": false, + "label": "selected", + "value": "selected", + }, + Object { + "disabled": false, + "label": "unselected", + "value": "deselected", + }, + Object { + "disabled": false, + "label": "all", + "value": "all", + }, + ] + } + value="selected" + /> + <SearchBox + autoFocus={true} + loading={false} + onChange={[Function]} + placeholder="search_verb" + value="" + /> + </div> + <SelectListListContainer + disabledElements={Array []} + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + filter="selected" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selectedElements={ + Array [ + "foo", + ] + } + /> +</div> +`; diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap new file mode 100644 index 00000000000..9f946d3e41b --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should display elements based on filters 1`] = ` +<div + className="select-list-list-container spacer-top" +> + <ul + className="menu" + > + <SelectListListElement + disabled={false} + element="foo" + key="foo" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selected={true} + /> + <SelectListListElement + disabled={false} + element="bar" + key="bar" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selected={false} + /> + <SelectListListElement + disabled={false} + element="baz" + key="baz" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selected={false} + /> + </ul> +</div> +`; + +exports[`should display elements based on filters 2`] = ` +<div + className="select-list-list-container spacer-top" +> + <ul + className="menu" + > + <SelectListListElement + disabled={false} + element="bar" + key="bar" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selected={false} + /> + <SelectListListElement + disabled={false} + element="baz" + key="baz" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selected={false} + /> + </ul> +</div> +`; + +exports[`should display elements based on filters 3`] = ` +<div + className="select-list-list-container spacer-top" +> + <ul + className="menu" + > + <SelectListListElement + disabled={false} + element="foo" + key="foo" + onSelect={[MockFunction]} + onUnselect={[MockFunction]} + renderElement={[Function]} + selected={true} + /> + </ul> +</div> +`; diff --git a/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap new file mode 100644 index 00000000000..28b25b2a3db --- /dev/null +++ b/server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should display a loader when checking 1`] = ` +<li> + <Checkbox + checked={false} + className="" + onCheck={[Function]} + thirdState={false} + > + <span + className="little-spacer-left" + > + foo + </span> + </Checkbox> +</li> +`; + +exports[`should display a loader when checking 2`] = ` +<li> + <Checkbox + checked={false} + className="" + onCheck={[Function]} + thirdState={false} + > + <span + className="little-spacer-left" + > + foo + </span> + </Checkbox> +</li> +`; diff --git a/server/sonar-web/src/main/js/components/SelectList/index.js b/server/sonar-web/src/main/js/components/SelectList/index.js deleted file mode 100644 index f8b47f5aeb8..00000000000 --- a/server/sonar-web/src/main/js/components/SelectList/index.js +++ /dev/null @@ -1,460 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 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 $ from 'jquery'; -import Backbone from 'backbone'; -import { debounce, throttle } from 'lodash'; -import escapeHtml from 'escape-html'; -import ItemTemplate from './templates/item.hbs'; -import ListTemplate from './templates/list.hbs'; -import { translate } from '../../helpers/l10n'; -import './styles.css'; -import '../controls/SearchBox.css'; - -let showError = null; - -/* - * SelectList Collection - */ - -const SelectListCollection = Backbone.Collection.extend({ - initialize(options) { - this.options = options; - }, - - parse(r) { - return this.options.parse.call(this, r); - }, - - fetch(options) { - const data = $.extend( - { - page: 1, - pageSize: 100 - }, - options.data || {} - ); - const settings = $.extend({}, options, { data }); - - this.settings = { - url: settings.url, - data - }; - - Backbone.Collection.prototype.fetch.call(this, settings); - }, - - fetchNextPage(options) { - if (this.more) { - const nextPage = this.settings.data.page + 1; - const settings = $.extend(this.settings, options); - - settings.data.page = nextPage; - settings.remove = false; - this.fetch(settings); - } else { - options.error(); - } - } -}); - -/* - * SelectList Item View - */ - -const SelectListItemView = Backbone.View.extend({ - tagName: 'li', - template: ItemTemplate, - - events: { - 'change .select-list-list-checkbox': 'toggle' - }, - - initialize(options) { - this.listenTo(this.model, 'change', this.render); - this.settings = options.settings; - }, - - render() { - this.$el.html(this.template(this.settings.dangerouslyUnescapedHtmlFormat(this.model.toJSON()))); - this.$('input').prop('name', this.model.get('name')); - this.$el.toggleClass('selected', this.model.get('selected')); - this.$('.select-list-list-checkbox') - .prop( - 'title', - this.model.get('selected') ? this.settings.tooltips.deselect : this.settings.tooltips.select - ) - .prop('checked', this.model.get('selected')); - - if (this.settings.readOnly) { - this.$('.select-list-list-checkbox').prop('disabled', true); - } - }, - - remove(postpone) { - if (postpone) { - this.$el.addClass(this.model.get('selected') ? 'added' : 'removed'); - setTimeout(() => { - Backbone.View.prototype.remove.call(this, arguments); - }, 500); - } else { - Backbone.View.prototype.remove.call(this, arguments); - } - }, - - toggle() { - const selected = this.model.get('selected'); - const that = this; - const url = selected ? this.settings.deselectUrl : this.settings.selectUrl; - const data = $.extend({}, this.settings.extra || {}); - - data[this.settings.selectParameter] = this.model.get(this.settings.selectParameterValue); - - that.$el.addClass('progress'); - $.ajax({ - url, - data, - type: 'POST', - statusCode: { - // do not show global error - 400: null, - 403: null, - 500: null - } - }) - .done(() => { - that.model.set('selected', !selected); - }) - .fail(jqXHR => { - that.render(); - showError(jqXHR); - }) - .always(() => { - that.$el.removeClass('progress'); - }); - } -}); - -/* - * SelectList View - */ - -const SelectListView = Backbone.View.extend({ - template: ListTemplate, - - events: { - 'click .select-list-control-button[name=selected]': 'showSelected', - 'click .select-list-control-button[name=deselected]': 'showDeselected', - 'click .select-list-control-button[name=all]': 'showAll', - 'click .js-reset': 'onResetClick' - }, - - initialize(options) { - this.listenTo(this.collection, 'add', this.renderListItem); - this.listenTo(this.collection, 'reset', this.renderList); - this.listenTo(this.collection, 'remove', this.removeModel); - this.listenTo(this.collection, 'change:selected', this.confirmFilter); - this.settings = options.settings; - - const that = this; - this.showFetchSpinner = function() { - that.$listContainer.addClass('loading'); - }; - this.hideFetchSpinner = function() { - that.$listContainer.removeClass('loading'); - }; - - const onScroll = function() { - that.showFetchSpinner(); - - that.collection.fetchNextPage({ - success() { - that.hideFetchSpinner(); - }, - error() { - that.hideFetchSpinner(); - } - }); - }; - this.onScroll = throttle(onScroll, 1000); - }, - - render() { - const that = this; - const keyup = function() { - that.search(); - }; - - this.$el.html(this.template(this.settings.labels)).width(this.settings.width); - - this.$listContainer = this.$('.select-list-list-container'); - if (!this.settings.readOnly) { - this.$listContainer - .height(this.settings.height) - .css('overflow', 'auto') - .on('scroll', () => { - that.scroll(); - }); - } else { - this.$listContainer.addClass('select-list-list-container-readonly'); - } - - this.$list = this.$('.select-list-list'); - - const searchInput = this.$('.select-list-search-control input') - .on('keyup', debounce(keyup, 250)) - .on('search', debounce(keyup, 250)); - - if (this.settings.focusSearch) { - setTimeout(() => { - searchInput.focus(); - }, 250); - } - - this.listItemViews = []; - - showError = function(jqXHR) { - let message = translate('default_error_message'); - if (jqXHR != null && jqXHR.responseJSON != null && jqXHR.responseJSON.errors != null) { - message = jqXHR.responseJSON.errors.map(e => e.msg).join('. '); - } - - that.$el.prevAll('.alert').remove(); - $('<div>') - .addClass('alert alert-danger') - .text(message) - .insertBefore(that.$el); - }; - - if (this.settings.readOnly) { - this.$('.select-list-control').remove(); - } - }, - - renderList() { - this.listItemViews.forEach(view => { - view.remove(); - }); - this.listItemViews = []; - if (this.collection.length > 0) { - this.collection.each(this.renderListItem, this); - } else if (this.settings.readOnly) { - this.renderEmpty(); - } - this.$listContainer.scrollTop(0); - }, - - renderListItem(item) { - const itemView = new SelectListItemView({ - model: item, - settings: this.settings - }); - this.listItemViews.push(itemView); - this.$list.append(itemView.el); - itemView.render(); - }, - - renderEmpty() { - this.$list.append(`<li class="empty-message">${this.settings.labels.noResults}</li>`); - }, - - confirmFilter(model) { - if (this.currentFilter !== 'all') { - this.collection.remove(model); - } - }, - - removeModel(model, collection, options) { - this.listItemViews[options.index].remove(true); - this.listItemViews.splice(options.index, 1); - }, - - filterBySelection(filter) { - const that = this; - filter = this.currentFilter = filter || this.currentFilter; - - if (filter != null) { - this.$('.select-list-check-control').toggleClass('disabled', false); - this.$('.select-list-search-control').toggleClass('disabled', true); - this.$('.select-list-search-control input').val(''); - - this.$('.select-list-control-button') - .removeClass('active') - .filter(`[name=${filter}]`) - .addClass('active'); - - this.showFetchSpinner(); - - this.collection.fetch({ - url: this.settings.searchUrl, - reset: true, - data: { selected: filter }, - success() { - that.hideFetchSpinner(); - }, - error: showError - }); - } - }, - - showSelected() { - this.filterBySelection('selected'); - }, - - showDeselected() { - this.filterBySelection('deselected'); - }, - - showAll() { - this.filterBySelection('all'); - }, - - search() { - const query = this.$('.select-list-search-control input').val(); - const hasQuery = query.length > 0; - const that = this; - const data = {}; - - this.$('.select-list-check-control').toggleClass('disabled', hasQuery); - this.$('.select-list-search-control').toggleClass('disabled', !hasQuery); - this.$('.js-reset').toggleClass('hidden', !hasQuery); - - if (hasQuery) { - this.showFetchSpinner(); - this.currentFilter = 'all'; - - data[this.settings.queryParam] = query; - data.selected = 'all'; - this.collection.fetch({ - data, - url: this.settings.searchUrl, - reset: true, - success() { - that.hideFetchSpinner(); - }, - error: showError - }); - } else { - this.filterBySelection(); - } - }, - - onResetClick(e) { - e.preventDefault(); - e.currentTarget.blur(); - this.$('.select-list-search-control input') - .val('') - .focus() - .trigger('search'); - }, - - searchByQuery(query) { - this.$('.select-list-search-control input').val(query); - this.search(); - }, - - clearSearch() { - this.filterBySelection(); - }, - - scroll() { - const scrollBottom = - this.$listContainer.scrollTop() >= - this.$list[0].scrollHeight - this.$listContainer.outerHeight(); - - if (scrollBottom && this.collection.more) { - this.onScroll(); - } - } -}); - -/* - * SelectList Entry Point - */ - -const SelectList = function(options) { - this.settings = $.extend(this.defaults, options); - - this.collection = new SelectListCollection({ - parse: this.settings.parse - }); - - this.view = new SelectListView({ - el: this.settings.el, - collection: this.collection, - settings: this.settings - }); - - this.view.render(); - this.filter('selected'); - return this; -}; - -/* - * SelectList API Methods - */ - -SelectList.prototype.filter = function(filter) { - this.view.filterBySelection(filter); - return this; -}; - -SelectList.prototype.search = function(query) { - this.view.searchByQuery(query); - return this; -}; - -/* - * SelectList Defaults - */ - -SelectList.prototype.defaults = { - width: '50%', - height: 400, - - readOnly: false, - focusSearch: true, - - dangerouslyUnescapedHtmlFormat(item) { - return escapeHtml(item.value); - }, - - parse(r) { - this.more = r.more; - return r.results; - }, - - queryParam: 'query', - - labels: { - selected: 'Selected', - deselected: 'Deselected', - all: 'All', - noResults: '' - }, - - tooltips: { - select: 'Click this to select item', - deselect: 'Click this to deselect item' - }, - - errorMessage: 'Something gone wrong, try to reload the page and try again.' -}; - -export default SelectList; diff --git a/server/sonar-web/src/main/js/components/SelectList/styles.css b/server/sonar-web/src/main/js/components/SelectList/styles.css index 89aa7d6e551..8f44b2e61ae 100644 --- a/server/sonar-web/src/main/js/components/SelectList/styles.css +++ b/server/sonar-web/src/main/js/components/SelectList/styles.css @@ -17,6 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +.select-list { +} + .select-list-container { min-width: 500px; box-sizing: border-box; @@ -30,6 +33,8 @@ .select-list-list-container { border: 1px solid #bfbfbf; box-sizing: border-box; + height: 400px; + overflow: auto; } .select-list-list-container.loading .select-list-list { diff --git a/server/sonar-web/src/main/js/components/SelectList/templates/item.hbs b/server/sonar-web/src/main/js/components/SelectList/templates/item.hbs deleted file mode 100644 index 95655f62b7d..00000000000 --- a/server/sonar-web/src/main/js/components/SelectList/templates/item.hbs +++ /dev/null @@ -1,2 +0,0 @@ -<input class="select-list-list-checkbox" type="checkbox"> -<div class="select-list-list-item">{{{this}}}</div> diff --git a/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs b/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs deleted file mode 100644 index fe9379484ea..00000000000 --- a/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs +++ /dev/null @@ -1,25 +0,0 @@ -<div class="select-list-container"> - <div class="select-list-control"> - <div class="select-list-check-control"> - <a class="select-list-control-button" name="selected">{{this.selected}}</a><a class="select-list-control-button" name="deselected">{{this.deselected}}</a><a class="select-list-control-button" name="all">{{this.all}}</a> - </div> - <div class="select-list-search-control"> - <div class="search-box"> - <input class="search-box-input" type="text" name="q" placeholder="{{t 'search_verb'}}" maxlength="100" autocomplete="off"> - <svg class="search-box-magnifier" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"> - <g transform="matrix(0.0288462,0,0,0.0288462,2,1.07692)"> - <path d="M288,208C288,177.167 277.042,150.792 255.125,128.875C233.208,106.958 206.833,96 176,96C145.167,96 118.792,106.958 96.875,128.875C74.958,150.792 64,177.167 64,208C64,238.833 74.958,265.208 96.875,287.125C118.792,309.042 145.167,320 176,320C206.833,320 233.208,309.042 255.125,287.125C277.042,265.208 288,238.833 288,208ZM416,416C416,424.667 412.833,432.167 406.5,438.5C400.167,444.833 392.667,448 384,448C375,448 367.5,444.833 361.5,438.5L275.75,353C245.917,373.667 212.667,384 176,384C152.167,384 129.375,379.375 107.625,370.125C85.875,360.875 67.125,348.375 51.375,332.625C35.625,316.875 23.125,298.125 13.875,276.375C4.625,254.625 0,231.833 0,208C0,184.167 4.625,161.375 13.875,139.625C23.125,117.875 35.625,99.125 51.375,83.375C67.125,67.625 85.875,55.125 107.625,45.875C129.375,36.625 152.167,32 176,32C199.833,32 222.625,36.625 244.375,45.875C266.125,55.125 284.875,67.625 300.625,83.375C316.375,99.125 328.875,117.875 338.125,139.625C347.375,161.375 352,184.167 352,208C352,244.667 341.667,277.917 321,307.75L406.75,393.5C412.917,399.667 416,407.167 416,416Z" style="fill:currentColor;fill-rule:nonzero;"/> - </g> - </svg> - <button class="js-reset hidden button-tiny search-box-clear button-icon" style="color: rgb(153, 153, 153);" type="reset"> - <svg width="12" height="12" viewBox="0 0 16 16" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"> - <path d="M14 4.242L11.758 2l-3.76 3.76L4.242 2 2 4.242l3.756 3.756L2 11.758 4.242 14l3.756-3.76 3.76 3.76L14 11.758l-3.76-3.76L14 4.242z" style="fill: currentcolor;"/> - </svg> - </button> - </div> - </div> - </div> - <div class="select-list-list-container"> - <ul class="select-list-list"></ul> - </div> -</div> diff --git a/server/sonar-web/src/main/js/components/controls/Checkbox.tsx b/server/sonar-web/src/main/js/components/controls/Checkbox.tsx index cb5513b2b39..2c174010ac9 100644 --- a/server/sonar-web/src/main/js/components/controls/Checkbox.tsx +++ b/server/sonar-web/src/main/js/components/controls/Checkbox.tsx @@ -22,6 +22,7 @@ import * as classNames from 'classnames'; interface Props { checked: boolean; + disabled?: boolean; children?: React.ReactNode; className?: string; id?: string; @@ -34,24 +35,29 @@ export default class Checkbox extends React.PureComponent<Props> { thirdState: false }; - handleClick = (e: React.SyntheticEvent<HTMLElement>) => { - e.preventDefault(); - e.currentTarget.blur(); - this.props.onCheck(!this.props.checked, this.props.id); + handleClick = (event: React.SyntheticEvent<HTMLElement>) => { + event.preventDefault(); + event.currentTarget.blur(); + if (!this.props.disabled) { + this.props.onCheck(!this.props.checked, this.props.id); + } }; render() { const className = classNames('icon-checkbox', { 'icon-checkbox-checked': this.props.checked, - 'icon-checkbox-single': this.props.thirdState + 'icon-checkbox-single': this.props.thirdState, + 'icon-checkbox-disabled': this.props.disabled }); if (this.props.children) { return ( <a - id={this.props.id} - className={classNames('link-checkbox', this.props.className)} + className={classNames('link-checkbox', this.props.className, { + 'text-muted': this.props.disabled + })} href="#" + id={this.props.id} onClick={this.handleClick}> <i className={className} /> {this.props.children} @@ -61,9 +67,9 @@ export default class Checkbox extends React.PureComponent<Props> { return ( <a - id={this.props.id} className={classNames(className, this.props.className)} href="#" + id={this.props.id} onClick={this.handleClick} /> ); diff --git a/server/sonar-web/src/main/js/components/controls/RadioToggle.tsx b/server/sonar-web/src/main/js/components/controls/RadioToggle.tsx index 69ceedbeeb8..21ff253d898 100644 --- a/server/sonar-web/src/main/js/components/controls/RadioToggle.tsx +++ b/server/sonar-web/src/main/js/components/controls/RadioToggle.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import * as classNames from 'classnames'; import Tooltip from './Tooltip'; interface Option { @@ -28,6 +29,7 @@ interface Option { } interface Props { + className?: string; name: string; onCheck: (value: string) => void; options: Option[]; @@ -71,6 +73,10 @@ export default class RadioToggle extends React.PureComponent<Props> { }; render() { - return <ul className="radio-toggle">{this.props.options.map(this.renderOption)}</ul>; + return ( + <ul className={classNames('radio-toggle', this.props.className)}> + {this.props.options.map(this.renderOption)} + </ul> + ); } } diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.tsx index f34d80e83fc..3804325fe13 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.tsx @@ -32,14 +32,19 @@ it('should render checked', () => { expect(checkbox.is('.icon-checkbox-checked')).toBe(true); }); +it('should render disabled', () => { + const checkbox = shallow(<Checkbox checked={true} disabled={true} onCheck={() => true} />); + expect(checkbox.is('.icon-checkbox-disabled')).toBe(true); +}); + it('should render unchecked third state', () => { - const checkbox = shallow(<Checkbox checked={false} thirdState={true} onCheck={() => true} />); + const checkbox = shallow(<Checkbox checked={false} onCheck={() => true} thirdState={true} />); expect(checkbox.is('.icon-checkbox-single')).toBe(true); expect(checkbox.is('.icon-checkbox-checked')).toBe(false); }); -it('should render checked third state', () => { - const checkbox = shallow(<Checkbox checked={true} thirdState={true} onCheck={() => true} />); +it('should render checked third state', () => { + const checkbox = shallow(<Checkbox checked={true} onCheck={() => true} thirdState={true} />); expect(checkbox.is('.icon-checkbox-single')).toBe(true); expect(checkbox.is('.icon-checkbox-checked')).toBe(true); }); @@ -61,16 +66,23 @@ it('should call onCheck', () => { expect(onCheck).toBeCalledWith(true, undefined); }); +it('should not call onCheck when disabled', () => { + const onCheck = jest.fn(); + const checkbox = shallow(<Checkbox checked={false} disabled={true} onCheck={onCheck} />); + click(checkbox); + expect(onCheck).toHaveBeenCalledTimes(0); +}); + it('should call onCheck with id as second parameter', () => { const onCheck = jest.fn(); - const checkbox = shallow(<Checkbox id="foo" checked={false} onCheck={onCheck} />); + const checkbox = shallow(<Checkbox checked={false} id="foo" onCheck={onCheck} />); click(checkbox); expect(onCheck).toBeCalledWith(true, 'foo'); }); it('should apply custom class', () => { const checkbox = shallow( - <Checkbox className="customclass" checked={true} onCheck={() => true} /> + <Checkbox checked={true} className="customclass" onCheck={() => true} /> ); expect(checkbox.is('.customclass')).toBe(true); }); diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index caf4db69304..3610dd3c107 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -145,6 +145,7 @@ save=Save search_verb=Search see_all=See All select_verb=Select +selected=Selected set=Set severity=Severity shared=Shared @@ -173,6 +174,7 @@ updated=Updated updated_on=Updated on update_verb=Update updating=Updating +unselected=Unselected user=User value=Value variation=Variation @@ -2844,4 +2846,4 @@ webhooks.url=URL webhooks.url.bad_format=Bad format of URL. webhooks.url.bad_protocol=URL must start with "http://" or "https://". webhooks.url.description=Server endpoint that will receive the webhook payload, for example: "http://my_server/foo". If HTTP Basic authentication is used, HTTPS is recommended to avoid man in the middle attacks. Example: "https://myLogin:myPassword@my_server/foo" -webhooks.url.required=URL is required.
\ No newline at end of file +webhooks.url.required=URL is required. |