From af09abd297eee6694b088437f7f33bea210b82f0 Mon Sep 17 00:00:00 2001 From: Pascal Mugnier Date: Fri, 16 Mar 2018 16:21:29 +0100 Subject: [PATCH] Rewrite SelectList component in React on Quality Page (#3152) --- .../src/main/js/api/quality-gates.ts | 16 +- .../src/main/js/api/quality-profiles.ts | 23 +- .../sonar-web/src/main/js/api/user_groups.ts | 24 +- server/sonar-web/src/main/js/api/users.ts | 21 +- .../src/main/js/app/styles/init/icons.css | 9 + .../src/main/js/app/styles/init/type.css | 2 +- .../src/main/js/app/utils/exposeLibraries.ts | 2 +- .../js/apps/groups/components/EditMembers.tsx | 95 ++-- .../__snapshots__/EditMembers-test.tsx.snap | 9 +- .../apps/quality-gates/components/Projects.js | 113 +++-- .../details/ChangeProjectsForm.tsx | 107 ++-- .../js/apps/users/components/GroupsForm.tsx | 93 ++-- .../js/components/SelectList/SelectList.tsx | 128 +++++ .../SelectList/SelectListListContainer.tsx | 71 +++ .../SelectList/SelectListListElement.tsx | 75 +++ .../SelectList/__tests__/SelectList-test.tsx | 78 +++ .../SelectListListContainer-test.tsx | 49 ++ .../__tests__/SelectListListElement-test.tsx | 47 ++ .../__snapshots__/SelectList-test.tsx.snap | 379 +++++++++++++++ .../SelectListListContainer-test.tsx.snap | 88 ++++ .../SelectListListElement-test.tsx.snap | 35 ++ .../main/js/components/SelectList/index.js | 460 ------------------ .../main/js/components/SelectList/styles.css | 5 + .../components/SelectList/templates/item.hbs | 2 - .../components/SelectList/templates/list.hbs | 25 - .../main/js/components/controls/Checkbox.tsx | 22 +- .../js/components/controls/RadioToggle.tsx | 8 +- .../controls/__tests__/Checkbox-test.tsx | 22 +- .../resources/org/sonar/l10n/core.properties | 4 +- 29 files changed, 1354 insertions(+), 658 deletions(-) create mode 100644 server/sonar-web/src/main/js/components/SelectList/SelectList.tsx create mode 100644 server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx create mode 100644 server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx create mode 100644 server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx create mode 100644 server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx create mode 100644 server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx create mode 100644 server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap delete mode 100644 server/sonar-web/src/main/js/components/SelectList/index.js delete mode 100644 server/sonar-web/src/main/js/components/SelectList/templates/item.hbs delete mode 100644 server/sonar-web/src/main/js/components/SelectList/templates/list.hbs 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 { + return getJSON('/api/qualitygates/search', data).catch(throwGlobalError); +} + export function associateGateWithProject(data: { gateId: number; organization?: string; - projectKey: string; + projectKey?: string; + projectId?: string; }): Promise { 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 { 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 { .then(parseJSON); } -export function getProfileProjects(data: RequestData): Promise { +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 return getJSON('/api/qualityprofiles/compare', { leftKey, rightKey }); } -export function associateProject(profileKey: string, projectKey: string): Promise { - 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 { - 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 { +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 { 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 { 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 { } }; - 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)}
${escapeHtml(item.login)}`, - 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 {
-
(this.container = node)} /> + user.login)} + onSearch={this.handleSearch} + onSelect={this.handleSelect} + onUnselect={this.handleUnselect} + renderElement={this.renderElement} + selectedElements={this.state.selectedUsers} + />