Bladeren bron

Rewrite SelectList component in React on Quality Page (#3152)

tags/7.5
Pascal Mugnier 6 jaren geleden
bovenliggende
commit
af09abd297
29 gewijzigde bestanden met toevoegingen van 1354 en 658 verwijderingen
  1. 14
    2
      server/sonar-web/src/main/js/api/quality-gates.ts
  2. 18
    5
      server/sonar-web/src/main/js/api/quality-profiles.ts
  3. 21
    3
      server/sonar-web/src/main/js/api/user_groups.ts
  4. 20
    1
      server/sonar-web/src/main/js/api/users.ts
  5. 9
    0
      server/sonar-web/src/main/js/app/styles/init/icons.css
  6. 1
    1
      server/sonar-web/src/main/js/app/styles/init/type.css
  7. 1
    1
      server/sonar-web/src/main/js/app/utils/exposeLibraries.ts
  8. 63
    32
      server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx
  9. 7
    2
      server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap
  10. 75
    38
      server/sonar-web/src/main/js/apps/quality-gates/components/Projects.js
  11. 68
    39
      server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx
  12. 61
    32
      server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx
  13. 128
    0
      server/sonar-web/src/main/js/components/SelectList/SelectList.tsx
  14. 71
    0
      server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx
  15. 75
    0
      server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx
  16. 78
    0
      server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx
  17. 49
    0
      server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx
  18. 47
    0
      server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx
  19. 379
    0
      server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap
  20. 88
    0
      server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap
  21. 35
    0
      server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap
  22. 0
    460
      server/sonar-web/src/main/js/components/SelectList/index.js
  23. 5
    0
      server/sonar-web/src/main/js/components/SelectList/styles.css
  24. 0
    2
      server/sonar-web/src/main/js/components/SelectList/templates/item.hbs
  25. 0
    25
      server/sonar-web/src/main/js/components/SelectList/templates/list.hbs
  26. 14
    8
      server/sonar-web/src/main/js/components/controls/Checkbox.tsx
  27. 7
    1
      server/sonar-web/src/main/js/components/controls/RadioToggle.tsx
  28. 17
    5
      server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.tsx
  29. 3
    1
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 14
- 2
server/sonar-web/src/main/js/api/quality-gates.ts Bestand weergeven

@@ -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);
}

+ 18
- 5
server/sonar-web/src/main/js/api/quality-profiles.ts Bestand weergeven

@@ -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 {

+ 21
- 3
server/sonar-web/src/main/js/api/user_groups.ts Bestand weergeven

@@ -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: {

+ 20
- 1
server/sonar-web/src/main/js/api/users.ts Bestand weergeven

@@ -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);
}


+ 9
- 0
server/sonar-web/src/main/js/app/styles/init/icons.css Bestand weergeven

@@ -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;
}

+ 1
- 1
server/sonar-web/src/main/js/app/styles/init/type.css Bestand weergeven

@@ -242,7 +242,7 @@ small,
}

.text-muted {
color: var(--secondFontColor);
color: var(--secondFontColor) !important;
}

.text-muted-2 {

+ 1
- 1
server/sonar-web/src/main/js/app/utils/exposeLibraries.ts Bestand weergeven

@@ -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';

+ 63
- 32
server/sonar-web/src/main/js/apps/groups/components/EditMembers.tsx Bestand weergeven

@@ -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">

+ 7
- 2
server/sonar-web/src/main/js/apps/groups/components/__tests__/__snapshots__/EditMembers-test.tsx.snap Bestand weergeven

@@ -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

+ 75
- 38
server/sonar-web/src/main/js/apps/quality-gates/components/Projects.js Bestand weergeven

@@ -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}
/>
);
}
}

+ 68
- 39
server/sonar-web/src/main/js/apps/quality-profiles/details/ChangeProjectsForm.tsx Bestand weergeven

@@ -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">

+ 61
- 32
server/sonar-web/src/main/js/apps/users/components/GroupsForm.tsx Bestand weergeven

@@ -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">

+ 128
- 0
server/sonar-web/src/main/js/components/SelectList/SelectList.tsx Bestand weergeven

@@ -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>
);
}
}

+ 71
- 0
server/sonar-web/src/main/js/components/SelectList/SelectListListContainer.tsx Bestand weergeven

@@ -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>
);
}
}

+ 75
- 0
server/sonar-web/src/main/js/components/SelectList/SelectListListElement.tsx Bestand weergeven

@@ -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>
);
}
}

+ 78
- 0
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectList-test.tsx Bestand weergeven

@@ -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();
});

+ 49
- 0
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListContainer-test.tsx Bestand weergeven

@@ -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();
});

+ 47
- 0
server/sonar-web/src/main/js/components/SelectList/__tests__/SelectListListElement-test.tsx Bestand weergeven

@@ -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);
});

+ 379
- 0
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectList-test.tsx.snap Bestand weergeven

@@ -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>
`;

+ 88
- 0
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap Bestand weergeven

@@ -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>
`;

+ 35
- 0
server/sonar-web/src/main/js/components/SelectList/__tests__/__snapshots__/SelectListListElement-test.tsx.snap Bestand weergeven

@@ -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>
`;

+ 0
- 460
server/sonar-web/src/main/js/components/SelectList/index.js Bestand weergeven

@@ -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;

+ 5
- 0
server/sonar-web/src/main/js/components/SelectList/styles.css Bestand weergeven

@@ -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 {

+ 0
- 2
server/sonar-web/src/main/js/components/SelectList/templates/item.hbs Bestand weergeven

@@ -1,2 +0,0 @@
<input class="select-list-list-checkbox" type="checkbox">
<div class="select-list-list-item">{{{this}}}</div>

+ 0
- 25
server/sonar-web/src/main/js/components/SelectList/templates/list.hbs Bestand weergeven

@@ -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>

+ 14
- 8
server/sonar-web/src/main/js/components/controls/Checkbox.tsx Bestand weergeven

@@ -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}
/>
);

+ 7
- 1
server/sonar-web/src/main/js/components/controls/RadioToggle.tsx Bestand weergeven

@@ -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>
);
}
}

+ 17
- 5
server/sonar-web/src/main/js/components/controls/__tests__/Checkbox-test.tsx Bestand weergeven

@@ -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);
});

+ 3
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties Bestand weergeven

@@ -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.
webhooks.url.required=URL is required.

Laden…
Annuleren
Opslaan