aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/apps/projectsManagement
diff options
context:
space:
mode:
authorStas Vilchik <stas.vilchik@sonarsource.com>2017-09-05 11:00:00 +0200
committerStas Vilchik <stas.vilchik@sonarsource.com>2017-09-11 11:28:29 +0200
commit71fec25c4056c1dcfe75769c2041b1d56a89a2e5 (patch)
treee640a76709b242652d3cc274a9d0a98f720ae768 /server/sonar-web/src/main/js/apps/projectsManagement
parent0926670e79d919e0afa3f0a2e11f656bdcd05916 (diff)
downloadsonarqube-71fec25c4056c1dcfe75769c2041b1d56a89a2e5.tar.gz
sonarqube-71fec25c4056c1dcfe75769c2041b1d56a89a2e5.zip
SONAR-9784 rewrite projects management page
Diffstat (limited to 'server/sonar-web/src/main/js/apps/projectsManagement')
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/App.tsx246
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx99
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx217
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/ChangeVisibilityForm.tsx128
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx226
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx105
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx92
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx96
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx68
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx228
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx148
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/BulkApplyTemplateModal-test.tsx128
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeVisibilityForm-test.tsx73
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx72
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/DeleteModal-test.tsx63
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx64
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectRow-test.tsx61
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Projects-test.tsx67
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx107
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/BulkApplyTemplateModal-test.tsx.snap643
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeVisibilityForm-test.tsx.snap308
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap468
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/DeleteModal-test.tsx.snap98
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap56
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ProjectRow-test.tsx.snap101
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Projects-test.tsx.snap37
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap234
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/routes.ts35
-rw-r--r--server/sonar-web/src/main/js/apps/projectsManagement/utils.ts40
29 files changed, 4308 insertions, 0 deletions
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx
new file mode 100644
index 00000000000..5a860abb5f7
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/App.tsx
@@ -0,0 +1,246 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 Helmet from 'react-helmet';
+import { debounce, uniq, without } from 'lodash';
+import Header from './Header';
+import Search from './Search';
+import Projects from './Projects';
+import CreateProjectForm from './CreateProjectForm';
+import ListFooter from '../../components/controls/ListFooter';
+import { PAGE_SIZE, Type, Project } from './utils';
+import { getComponents, getProvisioned, getGhosts } from '../../api/components';
+import { Organization } from '../../app/types';
+import { translate } from '../../helpers/l10n';
+
+export interface Props {
+ hasProvisionPermission?: boolean;
+ onVisibilityChange: (visibility: string) => void;
+ organization: Organization;
+ topLevelQualifiers: string[];
+}
+
+interface State {
+ createProjectForm: boolean;
+ page: number;
+ projects: Project[];
+ qualifiers: string;
+ query: string;
+ ready: boolean;
+ selection: string[];
+ total: number;
+ type: Type;
+}
+
+export default class App extends React.PureComponent<Props, State> {
+ mounted: boolean;
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ createProjectForm: false,
+ ready: false,
+ projects: [],
+ total: 0,
+ page: 1,
+ query: '',
+ qualifiers: 'TRK',
+ type: Type.All,
+ selection: []
+ };
+ this.requestProjects = debounce(this.requestProjects, 250);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.requestProjects();
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ getFilters = () => ({
+ organization: this.props.organization.key,
+ p: this.state.page !== 1 ? this.state.page : undefined,
+ ps: PAGE_SIZE,
+ q: this.state.query ? this.state.query : undefined
+ });
+
+ requestProjects = () => {
+ switch (this.state.type) {
+ case Type.All:
+ this.requestAllProjects();
+ break;
+ case Type.Provisioned:
+ this.requestProvisioned();
+ break;
+ case Type.Ghosts:
+ this.requestGhosts();
+ break;
+ }
+ };
+
+ requestGhosts = () => {
+ const data = this.getFilters();
+ getGhosts(data).then(r => {
+ if (this.mounted) {
+ let projects: Project[] = r.projects.map((project: any) => ({
+ ...project,
+ id: project.uuid,
+ qualifier: 'TRK'
+ }));
+ if (this.state.page > 1) {
+ projects = [...this.state.projects, ...projects];
+ }
+ this.setState({ ready: true, projects, selection: [], total: r.total });
+ }
+ });
+ };
+
+ requestProvisioned = () => {
+ const data = this.getFilters();
+ getProvisioned(data).then(r => {
+ if (this.mounted) {
+ let projects: Project[] = r.projects.map((project: any) => ({
+ ...project,
+ id: project.uuid,
+ qualifier: 'TRK'
+ }));
+ if (this.state.page > 1) {
+ projects = [...this.state.projects, ...projects];
+ }
+ this.setState({ ready: true, projects, selection: [], total: r.paging.total });
+ }
+ });
+ };
+
+ requestAllProjects = () => {
+ const data = this.getFilters();
+ Object.assign(data, { qualifiers: this.state.qualifiers });
+ getComponents(data).then(r => {
+ if (this.mounted) {
+ let projects: Project[] = r.components;
+ if (this.state.page > 1) {
+ projects = [...this.state.projects, ...projects];
+ }
+ this.setState({ ready: true, projects, selection: [], total: r.paging.total });
+ }
+ });
+ };
+
+ loadMore = () => {
+ this.setState({ ready: false, page: this.state.page + 1 }, this.requestProjects);
+ };
+
+ onSearch = (query: string) => {
+ this.setState({ ready: false, page: 1, query, selection: [] }, this.requestProjects);
+ };
+
+ onTypeChanged = (newType: Type) => {
+ this.setState(
+ { ready: false, page: 1, query: '', type: newType, qualifiers: 'TRK', selection: [] },
+ this.requestProjects
+ );
+ };
+
+ onQualifierChanged = (newQualifier: string) => {
+ this.setState(
+ { ready: false, page: 1, query: '', type: Type.All, qualifiers: newQualifier, selection: [] },
+ this.requestProjects
+ );
+ };
+
+ onProjectSelected = (project: string) => {
+ const newSelection = uniq([...this.state.selection, project]);
+ this.setState({ selection: newSelection });
+ };
+
+ onProjectDeselected = (project: string) => {
+ const newSelection = without(this.state.selection, project);
+ this.setState({ selection: newSelection });
+ };
+
+ onAllSelected = () => {
+ const newSelection = this.state.projects.map(project => project.key);
+ this.setState({ selection: newSelection });
+ };
+
+ onAllDeselected = () => {
+ this.setState({ selection: [] });
+ };
+
+ openCreateProjectForm = () => {
+ this.setState({ createProjectForm: true });
+ };
+
+ closeCreateProjectForm = () => {
+ this.setState({ createProjectForm: false });
+ };
+
+ render() {
+ return (
+ <div className="page page-limited" id="projects-management-page">
+ <Helmet title={translate('projects_management')} />
+
+ <Header
+ hasProvisionPermission={this.props.hasProvisionPermission}
+ onProjectCreate={this.openCreateProjectForm}
+ onVisibilityChange={this.props.onVisibilityChange}
+ organization={this.props.organization}
+ />
+
+ <Search
+ {...this.props}
+ {...this.state}
+ onAllSelected={this.onAllSelected}
+ onAllDeselected={this.onAllDeselected}
+ onDeleteProjects={this.requestProjects}
+ onQualifierChanged={this.onQualifierChanged}
+ onSearch={this.onSearch}
+ onTypeChanged={this.onTypeChanged}
+ />
+
+ <Projects
+ ready={this.state.ready}
+ projects={this.state.projects}
+ selection={this.state.selection}
+ onProjectSelected={this.onProjectSelected}
+ onProjectDeselected={this.onProjectDeselected}
+ organization={this.props.organization}
+ />
+
+ <ListFooter
+ ready={this.state.ready}
+ count={this.state.projects.length}
+ total={this.state.total}
+ loadMore={this.loadMore}
+ />
+
+ {this.state.createProjectForm &&
+ <CreateProjectForm
+ onClose={this.closeCreateProjectForm}
+ onProjectCreated={this.requestProjects}
+ organization={this.props.organization}
+ />}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx
new file mode 100644
index 00000000000..0b00f3eba76
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/AppContainer.tsx
@@ -0,0 +1,99 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 { connect } from 'react-redux';
+import App from './App';
+import { Organization } from '../../app/types';
+import { onFail } from '../../store/rootActions';
+import { getAppState, getOrganizationByKey } from '../../store/rootReducer';
+import { receiveOrganizations } from '../../store/organizations/duck';
+import { changeProjectVisibility } from '../../api/organizations';
+import { fetchOrganization } from '../../apps/organizations/actions';
+
+interface Props {
+ appState: {
+ defaultOrganization: string;
+ qualifiers: string[];
+ };
+ fetchOrganization: (organization: string) => void;
+ onVisibilityChange: (organization: Organization, visibility: string) => void;
+ onRequestFail: (error: any) => void;
+ organization?: Organization;
+}
+
+class AppContainer extends React.PureComponent<Props> {
+ componentDidMount() {
+ // if there is no organization, that means we are in the global scope
+ // let's fetch defails for the default organization in this case
+ if (!this.props.organization || !this.props.organization.projectVisibility) {
+ this.props.fetchOrganization(this.props.appState.defaultOrganization);
+ }
+ }
+
+ handleVisibilityChange = (visibility: string) => {
+ if (this.props.organization) {
+ this.props.onVisibilityChange(this.props.organization, visibility);
+ }
+ };
+
+ render() {
+ const { organization } = this.props;
+
+ if (!organization) {
+ return null;
+ }
+
+ const topLevelQualifiers = organization.isDefault ? this.props.appState.qualifiers : ['TRK'];
+
+ return (
+ <App
+ hasProvisionPermission={organization.canProvisionProjects}
+ onVisibilityChange={this.handleVisibilityChange}
+ organization={organization}
+ topLevelQualifiers={topLevelQualifiers}
+ />
+ );
+ }
+}
+
+const mapStateToProps = (state: any, ownProps: Props) => ({
+ appState: getAppState(state),
+ organization:
+ ownProps.organization || getOrganizationByKey(state, getAppState(state).defaultOrganization)
+});
+
+const onVisibilityChange = (organization: Organization, visibility: string) => (
+ dispatch: Function
+) => {
+ const currentVisibility = organization.projectVisibility;
+ dispatch(receiveOrganizations([{ ...organization, projectVisibility: visibility }]));
+ changeProjectVisibility(organization.key, visibility).catch(error => {
+ onFail(dispatch)(error);
+ dispatch(receiveOrganizations([{ ...organization, projectVisibility: currentVisibility }]));
+ });
+};
+
+const mapDispatchToProps = (dispatch: Function) => ({
+ fetchOrganization: (key: string) => dispatch(fetchOrganization(key)),
+ onVisibilityChange: (organization: Organization, visibility: string) =>
+ dispatch(onVisibilityChange(organization, visibility))
+});
+
+export default connect<any, any, any>(mapStateToProps, mapDispatchToProps)(AppContainer);
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx
new file mode 100644
index 00000000000..a5f967fd04a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/BulkApplyTemplateModal.tsx
@@ -0,0 +1,217 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 Modal from 'react-modal';
+import * as Select from 'react-select';
+import { Type } from './utils';
+import {
+ getPermissionTemplates,
+ PermissionTemplate,
+ bulkApplyTemplate,
+ applyTemplateToProject
+} from '../../api/permissions';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+
+export interface Props {
+ onClose: () => void;
+ organization: string;
+ qualifier: string;
+ query: string;
+ selection: string[];
+ total: number;
+ type: Type;
+}
+
+interface State {
+ done: boolean;
+ loading: boolean;
+ permissionTemplate?: string;
+ permissionTemplates?: PermissionTemplate[];
+ submitting: boolean;
+}
+
+export default class BulkApplyTemplateModal extends React.PureComponent<Props, State> {
+ mounted: boolean;
+ state: State = { done: false, loading: true, submitting: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ this.loadPermissionTemplates();
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ loadPermissionTemplates() {
+ this.setState({ loading: true });
+ getPermissionTemplates(this.props.organization).then(
+ ({ permissionTemplates }) => {
+ if (this.mounted) {
+ this.setState({
+ loading: false,
+ permissionTemplate:
+ permissionTemplates.length > 0 ? permissionTemplates[0].id : undefined,
+ permissionTemplates: permissionTemplates
+ });
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ }
+ );
+ }
+
+ bulkApplyToAll = (permissionTemplate: string) => {
+ const data = {
+ organization: this.props.organization,
+ q: this.props.query ? this.props.query : undefined,
+ qualifier: this.props.qualifier,
+ templateId: permissionTemplate
+ };
+ return bulkApplyTemplate(data);
+ };
+
+ bulkApplyToSelected = (permissionTemplate: string) => {
+ const { selection } = this.props;
+ let lastRequest = Promise.resolve();
+
+ selection.forEach(projectKey => {
+ const data = {
+ organization: this.props.organization,
+ projectKey,
+ templateId: permissionTemplate
+ };
+ lastRequest = lastRequest.then(() => applyTemplateToProject(data));
+ });
+
+ return lastRequest;
+ };
+
+ handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.props.onClose();
+ };
+
+ handleConfirmClick = () => {
+ const { permissionTemplate } = this.state;
+ if (permissionTemplate) {
+ this.setState({ submitting: true });
+ const request = this.props.selection.length
+ ? this.bulkApplyToSelected(permissionTemplate)
+ : this.bulkApplyToAll(permissionTemplate);
+ request.then(
+ () => {
+ if (this.mounted) {
+ this.setState({ done: true, submitting: false });
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ submitting: false });
+ }
+ }
+ );
+ }
+ };
+
+ handlePermissionTemplateChange = ({ value }: { value: string }) => {
+ this.setState({ permissionTemplate: value });
+ };
+
+ renderWarning = () => {
+ return this.props.selection.length
+ ? <div className="alert alert-info">
+ {translateWithParameters(
+ 'permission_templates.bulk_apply_permission_template.apply_to_selected',
+ this.props.selection.length
+ )}
+ </div>
+ : <div className="alert alert-warning">
+ {translateWithParameters(
+ 'permission_templates.bulk_apply_permission_template.apply_to_all',
+ this.props.total
+ )}
+ </div>;
+ };
+
+ renderSelect = () =>
+ <div className="modal-field">
+ <label>
+ {translate('template')}
+ <em className="mandatory">*</em>
+ </label>
+ <Select
+ clearable={false}
+ disabled={this.state.submitting}
+ onChange={this.handlePermissionTemplateChange}
+ options={this.state.permissionTemplates!.map(t => ({ label: t.name, value: t.id }))}
+ searchable={false}
+ value={this.state.permissionTemplate}
+ />
+ </div>;
+
+ render() {
+ const { done, loading, permissionTemplates, submitting } = this.state;
+ const header = translate('permission_templates.bulk_apply_permission_template');
+
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel={header}
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.props.onClose}>
+ <header className="modal-head">
+ <h2>
+ {header}
+ </h2>
+ </header>
+
+ <div className="modal-body">
+ {done &&
+ <div className="alert alert-success">
+ {translate('projects_role.apply_template.success')}
+ </div>}
+
+ {loading && <i className="spinner" />}
+
+ {!loading && !done && permissionTemplates && this.renderWarning()}
+ {!loading && !done && permissionTemplates && this.renderSelect()}
+ </div>
+
+ <footer className="modal-foot">
+ {submitting && <i className="spinner spacer-right" />}
+ {!loading &&
+ !done &&
+ permissionTemplates &&
+ <button disabled={submitting} onClick={this.handleConfirmClick}>
+ {translate('apply')}
+ </button>}
+ <a className="js-modal-close" href="#" onClick={this.handleCancelClick}>
+ {done ? translate('close') : translate('cancel')}
+ </a>
+ </footer>
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ChangeVisibilityForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeVisibilityForm.tsx
new file mode 100644
index 00000000000..9c04349c688
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/ChangeVisibilityForm.tsx
@@ -0,0 +1,128 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 Modal from 'react-modal';
+import * as classNames from 'classnames';
+import { Organization } from '../../app/types';
+import UpgradeOrganizationBox from '../../components/common/UpgradeOrganizationBox';
+import { translate } from '../../helpers/l10n';
+import { Visibility } from './utils';
+
+export interface Props {
+ onClose: () => void;
+ onConfirm: (visiblity: Visibility) => void;
+ organization: Organization;
+}
+
+interface State {
+ visibility: Visibility;
+}
+
+export default class ChangeVisibilityForm extends React.PureComponent<Props, State> {
+ constructor(props: Props) {
+ super(props);
+ this.state = { visibility: props.organization.projectVisibility as Visibility };
+ }
+
+ handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.props.onClose();
+ };
+
+ handleConfirmClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+ event.preventDefault();
+ this.props.onConfirm(this.state.visibility);
+ this.props.onClose();
+ };
+
+ handleVisibilityClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ const visibility = event.currentTarget.dataset.visibility as Visibility;
+ this.setState({ visibility });
+ };
+
+ render() {
+ const { canUpdateProjectsVisibilityToPrivate } = this.props.organization;
+
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel="modal form"
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.props.onClose}>
+ <header className="modal-head">
+ <h2>
+ {translate('organization.change_visibility_form.header')}
+ </h2>
+ </header>
+
+ <div className="modal-body">
+ {[Visibility.Public, Visibility.Private].map(visibility =>
+ <div className="big-spacer-bottom" key={visibility}>
+ <p>
+ {visibility === Visibility.Private && !canUpdateProjectsVisibilityToPrivate
+ ? <span className="text-muted cursor-not-allowed">
+ <i
+ className={classNames('icon-radio', 'spacer-right', {
+ 'is-checked': this.state.visibility === visibility
+ })}
+ />
+ {translate('visibility', visibility)}
+ </span>
+ : <a
+ className="link-base-color link-no-underline"
+ data-visibility={visibility}
+ href="#"
+ onClick={this.handleVisibilityClick}>
+ <i
+ className={classNames('icon-radio', 'spacer-right', {
+ 'is-checked': this.state.visibility === visibility
+ })}
+ />
+ {translate('visibility', visibility)}
+ </a>}
+ </p>
+ <p className="text-muted spacer-top" style={{ paddingLeft: 22 }}>
+ {translate('visibility', visibility, 'description.short')}
+ </p>
+ </div>
+ )}
+
+ {canUpdateProjectsVisibilityToPrivate
+ ? <div className="alert alert-warning">
+ {translate('organization.change_visibility_form.warning')}
+ </div>
+ : <UpgradeOrganizationBox organization={this.props.organization.key} />}
+ </div>
+
+ <footer className="modal-foot">
+ <button className="js-confirm" onClick={this.handleConfirmClick}>
+ {translate('organization.change_visibility_form.submit')}
+ </button>
+ <a className="js-modal-close" href="#" onClick={this.handleCancelClick}>
+ {translate('cancel')}
+ </a>
+ </footer>
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx
new file mode 100644
index 00000000000..22a81acf160
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/CreateProjectForm.tsx
@@ -0,0 +1,226 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 Modal from 'react-modal';
+import { Link } from 'react-router';
+import { Organization } from '../../app/types';
+import UpgradeOrganizationBox from '../../components/common/UpgradeOrganizationBox';
+import VisibilitySelector from '../../components/common/VisibilitySelector';
+import { createProject } from '../../api/components';
+import { translate } from '../../helpers/l10n';
+import { getProjectUrl } from '../../helpers/urls';
+
+interface Props {
+ onClose: () => void;
+ onProjectCreated: () => void;
+ organization: Organization;
+}
+
+interface State {
+ branch: string;
+ createdProject?: { key: string; name: string };
+ key: string;
+ loading: boolean;
+ name: string;
+ visibility: string;
+ // add index declaration to be able to do `this.setState({ [name]: value });`
+ [x: string]: any;
+}
+
+export default class CreateProjectForm extends React.PureComponent<Props, State> {
+ mounted: boolean;
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ branch: '',
+ key: '',
+ loading: false,
+ name: '',
+ visibility: props.organization.projectVisibility
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.props.onClose();
+ };
+
+ handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+ const { name, value } = event.currentTarget;
+ this.setState({ [name]: value });
+ };
+
+ handleVisibilityChange = (visibility: string) => {
+ this.setState({ visibility });
+ };
+
+ handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+ event.preventDefault();
+
+ const data = {
+ name: this.state.name,
+ branch: this.state.branch,
+ organization: this.props.organization && this.props.organization.key,
+ project: this.state.key,
+ visibility: this.state.visibility
+ };
+
+ this.setState({ loading: true });
+ createProject(data).then(
+ response => {
+ if (this.mounted) {
+ this.setState({ createdProject: response.project, loading: false });
+ this.props.onProjectCreated();
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ }
+ );
+ };
+
+ render() {
+ const { organization } = this.props;
+ const { createdProject } = this.state;
+
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel="modal form"
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.props.onClose}>
+ {createdProject
+ ? <div>
+ <header className="modal-head">
+ <h2>
+ {translate('qualifiers.create.TRK')}
+ </h2>
+ </header>
+
+ <div className="modal-body">
+ <div className="alert alert-success">
+ Project <Link to={getProjectUrl(createdProject.key)}>
+ {createdProject.name}
+ </Link>{' '}
+ has been successfully created.
+ </div>
+ </div>
+
+ <footer className="modal-foot">
+ <a href="#" id="create-project-close" onClick={this.handleCancelClick}>
+ {translate('close')}
+ </a>
+ </footer>
+ </div>
+ : <form id="create-project-form" onSubmit={this.handleFormSubmit}>
+ <header className="modal-head">
+ <h2>
+ {translate('qualifiers.create.TRK')}
+ </h2>
+ </header>
+
+ <div className="modal-body">
+ <div className="modal-field">
+ <label htmlFor="create-project-name">
+ {translate('name')}
+ <em className="mandatory">*</em>
+ </label>
+ <input
+ autoFocus={true}
+ id="create-project-name"
+ maxLength={2000}
+ name="name"
+ onChange={this.handleInputChange}
+ required={true}
+ type="text"
+ value={this.state.name}
+ />
+ </div>
+ <div className="modal-field">
+ <label htmlFor="create-project-branch">
+ {translate('branch')}
+ </label>
+ <input
+ id="create-project-branch"
+ maxLength={200}
+ name="branch"
+ onChange={this.handleInputChange}
+ type="text"
+ value={this.state.branch}
+ />
+ </div>
+ <div className="modal-field">
+ <label htmlFor="create-project-key">
+ {translate('key')}
+ <em className="mandatory">*</em>
+ </label>
+ <input
+ id="create-project-key"
+ maxLength={400}
+ name="key"
+ onChange={this.handleInputChange}
+ required={true}
+ type="text"
+ value={this.state.key}
+ />
+ </div>
+ <div className="modal-field">
+ <label>
+ {translate('visibility')}
+ </label>
+ <VisibilitySelector
+ canTurnToPrivate={organization.canUpdateProjectsVisibilityToPrivate}
+ className="little-spacer-top"
+ onChange={this.handleVisibilityChange}
+ visibility={this.state.visibility}
+ />
+ {!organization.canUpdateProjectsVisibilityToPrivate &&
+ <div className="spacer-top">
+ <UpgradeOrganizationBox organization={organization.key} />
+ </div>}
+ </div>
+ </div>
+
+ <footer className="modal-foot">
+ {this.state.loading && <i className="spinner spacer-right" />}
+ <button disabled={this.state.loading} id="create-project-submit" type="submit">
+ {translate('create')}
+ </button>
+ <a href="#" id="create-project-cancel" onClick={this.handleCancelClick}>
+ {translate('cancel')}
+ </a>
+ </footer>
+ </form>}
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx
new file mode 100644
index 00000000000..7b4a45fc61b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/DeleteModal.tsx
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 Modal from 'react-modal';
+import { deleteComponents } from '../../api/components';
+import { translate } from '../../helpers/l10n';
+
+export interface Props {
+ onClose: () => void;
+ onConfirm: () => void;
+ organization: string;
+ qualifier: string;
+ selection: string[];
+}
+
+interface State {
+ loading: boolean;
+}
+
+export default class DeleteModal extends React.PureComponent<Props, State> {
+ mounted: boolean;
+ state: State = { loading: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.props.onClose();
+ };
+
+ handleConfirmClick = () => {
+ this.setState({ loading: true });
+ deleteComponents(this.props.selection, this.props.organization).then(
+ () => {
+ if (this.mounted) {
+ this.props.onConfirm();
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ }
+ );
+ };
+
+ render() {
+ const header = translate('qualifiers.delete', this.props.qualifier);
+
+ return (
+ <Modal
+ isOpen={true}
+ contentLabel={header}
+ className="modal"
+ overlayClassName="modal-overlay"
+ onRequestClose={this.props.onClose}>
+ <header className="modal-head">
+ <h2>
+ {header}
+ </h2>
+ </header>
+
+ <div className="modal-body">
+ {translate('qualifiers.delete_confirm', this.props.qualifier)}
+ </div>
+
+ <footer className="modal-foot">
+ {this.state.loading && <i className="spinner spacer-right" />}
+ <button
+ className="button-red"
+ disabled={this.state.loading}
+ onClick={this.handleConfirmClick}>
+ {translate('delete')}
+ </button>
+ <a className="js-modal-close" href="#" onClick={this.handleCancelClick}>
+ {translate('cancel')}
+ </a>
+ </footer>
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx
new file mode 100644
index 00000000000..ef77181c740
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/Header.tsx
@@ -0,0 +1,92 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 ChangeVisibilityForm from './ChangeVisibilityForm';
+import { Visibility } from './utils';
+import { Organization } from '../../app/types';
+import { translate } from '../../helpers/l10n';
+
+export interface Props {
+ hasProvisionPermission?: boolean;
+ onProjectCreate: () => void;
+ onVisibilityChange: (visibility: Visibility) => void;
+ organization: Organization;
+}
+
+interface State {
+ visibilityForm: boolean;
+}
+
+export default class Header extends React.PureComponent<Props, State> {
+ state: State = { visibilityForm: false };
+
+ handleCreateProjectClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+ event.preventDefault();
+ this.props.onProjectCreate();
+ };
+
+ handleChangeVisibilityClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ this.setState({ visibilityForm: true });
+ };
+
+ closeVisiblityForm = () => {
+ this.setState({ visibilityForm: false });
+ };
+
+ render() {
+ const { organization } = this.props;
+
+ return (
+ <header className="page-header">
+ <h1 className="page-title">
+ {translate('projects_management')}
+ </h1>
+
+ <div className="page-actions">
+ <span className="big-spacer-right">
+ {translate('organization.default_visibility_of_new_projects')}{' '}
+ <strong>{translate('visibility', organization.projectVisibility)}</strong>
+ <a
+ className="js-change-visibility spacer-left icon-edit"
+ href="#"
+ onClick={this.handleChangeVisibilityClick}
+ />
+ </span>
+ {this.props.hasProvisionPermission &&
+ <button id="create-project" onClick={this.handleCreateProjectClick}>
+ {translate('qualifiers.create.TRK')}
+ </button>}
+ </div>
+
+ <p className="page-description">
+ {translate('projects_management.page.description')}
+ </p>
+
+ {this.state.visibilityForm &&
+ <ChangeVisibilityForm
+ onClose={this.closeVisiblityForm}
+ onConfirm={this.props.onVisibilityChange}
+ organization={organization}
+ />}
+ </header>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx
new file mode 100644
index 00000000000..60f951dd32c
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/ProjectRow.tsx
@@ -0,0 +1,96 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 { Link } from 'react-router';
+import { Project, Visibility } from './utils';
+import PrivateBadge from '../../components/common/PrivateBadge';
+import Checkbox from '../../components/controls/Checkbox';
+import QualifierIcon from '../../components/shared/QualifierIcon';
+import { translate } from '../../helpers/l10n';
+import { getComponentPermissionsUrl } from '../../helpers/urls';
+
+interface Props {
+ onApplyTemplateClick: (project: Project) => void;
+ onProjectCheck: (project: Project, checked: boolean) => void;
+ project: Project;
+ selected: boolean;
+}
+
+export default class ProjectRow extends React.PureComponent<Props> {
+ handleProjectCheck = (checked: boolean) => {
+ this.props.onProjectCheck(this.props.project, checked);
+ };
+
+ handleApplyTemplateClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.props.onApplyTemplateClick(this.props.project);
+ };
+
+ render() {
+ const { project, selected } = this.props;
+
+ return (
+ <tr>
+ <td className="thin">
+ <Checkbox checked={selected} onCheck={this.handleProjectCheck} />
+ </td>
+
+ <td className="nowrap">
+ <Link
+ to={{ pathname: '/dashboard', query: { id: project.key } }}
+ className="link-with-icon">
+ <QualifierIcon qualifier={project.qualifier} /> <span>{project.name}</span>
+ </Link>
+ </td>
+
+ <td className="nowrap">
+ <span className="note">
+ {project.key}
+ </span>
+ </td>
+
+ <td className="width-20">
+ {project.visibility === Visibility.Private && <PrivateBadge />}
+ </td>
+
+ <td className="thin nowrap">
+ <div className="dropdown">
+ <button className="dropdown-toggle" data-toggle="dropdown">
+ {translate('actions')} <i className="icon-dropdown" />
+ </button>
+ <ul className="dropdown-menu dropdown-menu-right">
+ <li>
+ <Link to={getComponentPermissionsUrl(project.key)}>
+ {translate('edit_permissions')}
+ </Link>
+ </li>
+ <li>
+ <a className="js-apply-template" href="#" onClick={this.handleApplyTemplateClick}>
+ {translate('projects_role.apply_template')}
+ </a>
+ </li>
+ </ul>
+ </div>
+ </td>
+ </tr>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx
new file mode 100644
index 00000000000..ff6264dce90
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/Projects.tsx
@@ -0,0 +1,68 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 ProjectRow from './ProjectRow';
+import { Project } from './utils';
+import ApplyTemplateView from '../permissions/project/views/ApplyTemplateView';
+import { Organization } from '../../app/types';
+
+interface Props {
+ onProjectDeselected: (project: string) => void;
+ onProjectSelected: (project: string) => void;
+ organization: Organization;
+ projects: Project[];
+ ready?: boolean;
+ selection: string[];
+}
+
+export default class Projects extends React.PureComponent<Props> {
+ onProjectCheck = (project: Project, checked: boolean) => {
+ if (checked) {
+ this.props.onProjectSelected(project.key);
+ } else {
+ this.props.onProjectDeselected(project.key);
+ }
+ };
+
+ onApplyTemplateClick = (project: Project) => {
+ new ApplyTemplateView({ project, organization: this.props.organization }).render();
+ };
+
+ render() {
+ return (
+ <table
+ className={classNames('data', 'zebra', { 'new-loading': !this.props.ready })}
+ id="projects-management-page-projects">
+ <tbody>
+ {this.props.projects.map(project =>
+ <ProjectRow
+ key={project.key}
+ onApplyTemplateClick={this.onApplyTemplateClick}
+ onProjectCheck={this.onProjectCheck}
+ project={project}
+ selected={this.props.selection.includes(project.key)}
+ />
+ )}
+ </tbody>
+ </table>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx
new file mode 100644
index 00000000000..914883f8359
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx
@@ -0,0 +1,228 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 { sortBy } from 'lodash';
+import BulkApplyTemplateModal from './BulkApplyTemplateModal';
+import DeleteModal from './DeleteModal';
+import { Type, QUALIFIERS_ORDER } from './utils';
+import { Project } from './utils';
+import { Organization } from '../../app/types';
+import RadioToggle from '../../components/controls/RadioToggle';
+import Checkbox from '../../components/controls/Checkbox';
+import { translate } from '../../helpers/l10n';
+
+export interface Props {
+ onAllDeselected: () => void;
+ onAllSelected: () => void;
+ onDeleteProjects: () => void;
+ onQualifierChanged: (qualifier: string) => void;
+ onSearch: (query: string) => void;
+ onTypeChanged: (type: Type) => void;
+ organization: Organization;
+ projects: Project[];
+ qualifiers: string;
+ query: string;
+ ready: boolean;
+ selection: any[];
+ topLevelQualifiers: string[];
+ total: number;
+ type: Type;
+}
+
+interface State {
+ bulkApplyTemplateModal: boolean;
+ deleteModal: boolean;
+}
+
+export default class Search extends React.PureComponent<Props, State> {
+ input: HTMLInputElement;
+ mounted: boolean;
+ state: State = { bulkApplyTemplateModal: false, deleteModal: false };
+
+ onSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ this.search();
+ };
+
+ search = (event?: React.SyntheticEvent<HTMLInputElement>) => {
+ const q = event ? event.currentTarget.value : this.input.value;
+ this.props.onSearch(q);
+ };
+
+ getTypeOptions = () => [
+ { value: Type.All, label: 'All' },
+ { value: Type.Provisioned, label: 'Provisioned' },
+ { value: Type.Ghosts, label: 'Ghosts' }
+ ];
+
+ getQualifierOptions = () => {
+ const options = this.props.topLevelQualifiers.map(q => {
+ return { value: q, label: translate('qualifiers', q) };
+ });
+ return sortBy(options, option => QUALIFIERS_ORDER.indexOf(option.value));
+ };
+
+ onCheck = (checked: boolean) => {
+ if (checked) {
+ this.props.onAllSelected();
+ } else {
+ this.props.onAllDeselected();
+ }
+ };
+
+ handleDeleteClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.setState({ deleteModal: true });
+ };
+
+ closeDeleteModal = () => {
+ this.setState({ deleteModal: false });
+ };
+
+ handleDeleteConfirm = () => {
+ this.closeDeleteModal();
+ this.props.onDeleteProjects();
+ };
+
+ handleBulkApplyTemplateClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.setState({ bulkApplyTemplateModal: true });
+ };
+
+ closeBulkApplyTemplateModal = () => {
+ this.setState({ bulkApplyTemplateModal: false });
+ };
+
+ renderCheckbox = () => {
+ const isAllChecked =
+ this.props.projects.length > 0 && this.props.selection.length === this.props.projects.length;
+ const thirdState =
+ this.props.projects.length > 0 &&
+ this.props.selection.length > 0 &&
+ this.props.selection.length < this.props.projects.length;
+ const checked = isAllChecked || thirdState;
+ return <Checkbox checked={checked} thirdState={thirdState} onCheck={this.onCheck} />;
+ };
+
+ renderGhostsDescription = () => {
+ if (this.props.type !== Type.Ghosts || !this.props.ready) {
+ return null;
+ }
+ return (
+ <div className="spacer-top alert alert-info">
+ {translate('bulk_deletion.ghosts.description')}
+ </div>
+ );
+ };
+
+ renderQualifierFilter = () => {
+ const options = this.getQualifierOptions();
+ if (options.length < 2) {
+ return null;
+ }
+ return (
+ <td className="thin nowrap text-middle">
+ <RadioToggle
+ options={this.getQualifierOptions()}
+ value={this.props.qualifiers}
+ name="projects-qualifier"
+ onCheck={this.props.onQualifierChanged}
+ />
+ </td>
+ );
+ };
+
+ render() {
+ const isSomethingSelected = this.props.projects.length > 0 && this.props.selection.length > 0;
+ return (
+ <div className="panel panel-vertical bordered-bottom spacer-bottom">
+ <table className="data">
+ <tbody>
+ <tr>
+ <td className="thin text-middle">
+ {this.props.ready ? this.renderCheckbox() : <i className="spinner" />}
+ </td>
+ {this.renderQualifierFilter()}
+ <td className="thin nowrap text-middle">
+ <RadioToggle
+ options={this.getTypeOptions()}
+ value={this.props.type}
+ name="projects-type"
+ onCheck={this.props.onTypeChanged}
+ />
+ </td>
+ <td className="text-middle">
+ <form onSubmit={this.onSubmit} className="search-box">
+ <button className="search-box-submit button-clean">
+ <i className="icon-search" />
+ </button>
+ <input
+ onChange={this.search}
+ value={this.props.query}
+ ref={node => (this.input = node!)}
+ className="search-box-input input-medium"
+ type="search"
+ placeholder="Search"
+ />
+ </form>
+ </td>
+ <td className="thin nowrap text-middle">
+ <button
+ className="spacer-right js-bulk-apply-permission-template"
+ onClick={this.handleBulkApplyTemplateClick}>
+ {translate('permission_templates.bulk_apply_permission_template')}
+ </button>
+ <button
+ onClick={this.handleDeleteClick}
+ className="js-delete button-red"
+ disabled={!isSomethingSelected}>
+ {translate('delete')}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ {this.renderGhostsDescription()}
+
+ {this.state.bulkApplyTemplateModal &&
+ <BulkApplyTemplateModal
+ onClose={this.closeBulkApplyTemplateModal}
+ organization={this.props.organization.key}
+ qualifier={this.props.qualifiers}
+ query={this.props.query}
+ selection={this.props.selection}
+ total={this.props.total}
+ type={this.props.type}
+ />}
+
+ {this.state.deleteModal &&
+ <DeleteModal
+ onClose={this.closeDeleteModal}
+ onConfirm={this.handleDeleteConfirm}
+ organization={this.props.organization.key}
+ qualifier={this.props.qualifiers}
+ selection={this.props.selection}
+ />}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx
new file mode 100644
index 00000000000..1c9569d5700
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/App-test.tsx
@@ -0,0 +1,148 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+jest.mock('lodash', () => {
+ const lodash = require.requireActual('lodash');
+ lodash.debounce = (fn: Function) => (...args: any[]) => fn(args);
+ return lodash;
+});
+
+jest.mock('../../../api/components', () => ({
+ getComponents: jest.fn(),
+ getProvisioned: jest.fn(() => Promise.resolve({ paging: { total: 0 }, projects: [] })),
+ getGhosts: jest.fn(() => Promise.resolve({ projects: [], total: 0 }))
+}));
+
+import * as React from 'react';
+import { mount } from 'enzyme';
+import App, { Props } from '../App';
+import { Type } from '../utils';
+
+const getComponents = require('../../../api/components').getComponents as jest.Mock<any>;
+const getProvisioned = require('../../../api/components').getProvisioned as jest.Mock<any>;
+const getGhosts = require('../../../api/components').getGhosts as jest.Mock<any>;
+
+const organization = { key: 'org', name: 'org', projectVisibility: 'public' };
+
+const defaultSearchParameters = {
+ organization: 'org',
+ p: undefined,
+ ps: 50,
+ q: undefined
+};
+
+beforeEach(() => {
+ getComponents
+ .mockImplementation(() => Promise.resolve({ paging: { total: 0 }, components: [] }))
+ .mockClear();
+ getProvisioned.mockClear();
+ getGhosts.mockClear();
+});
+
+it('fetches all projects on mount', () => {
+ mountRender();
+ expect(getComponents).lastCalledWith({ ...defaultSearchParameters, qualifiers: 'TRK' });
+});
+
+it('changes type', () => {
+ const wrapper = mountRender();
+ wrapper.find('Search').prop<Function>('onTypeChanged')(Type.Provisioned);
+ expect(getProvisioned).lastCalledWith(defaultSearchParameters);
+ wrapper.find('Search').prop<Function>('onTypeChanged')(Type.Ghosts);
+ expect(getGhosts).lastCalledWith(defaultSearchParameters);
+});
+
+it('changes qualifier and resets type', () => {
+ const wrapper = mountRender();
+ wrapper.setState({ type: Type.Provisioned });
+ wrapper.find('Search').prop<Function>('onQualifierChanged')('VW');
+ expect(getComponents).lastCalledWith({ ...defaultSearchParameters, qualifiers: 'VW' });
+});
+
+it('searches', () => {
+ const wrapper = mountRender();
+ wrapper.find('Search').prop<Function>('onSearch')('foo');
+ expect(getComponents).lastCalledWith({ ...defaultSearchParameters, q: 'foo', qualifiers: 'TRK' });
+});
+
+it('loads more', async () => {
+ const wrapper = mountRender();
+ wrapper.find('ListFooter').prop<Function>('loadMore')();
+ expect(getComponents).lastCalledWith({ ...defaultSearchParameters, p: 2, qualifiers: 'TRK' });
+});
+
+it('selects and deselects projects', async () => {
+ getComponents.mockImplementation(() =>
+ Promise.resolve({ paging: { total: 2 }, components: [{ key: 'foo' }, { key: 'bar' }] })
+ );
+ const wrapper = mountRender();
+ await new Promise(setImmediate);
+
+ wrapper.find('Projects').prop<Function>('onProjectSelected')('foo');
+ expect(wrapper.state('selection')).toEqual(['foo']);
+
+ wrapper.find('Projects').prop<Function>('onProjectSelected')('bar');
+ expect(wrapper.state('selection')).toEqual(['foo', 'bar']);
+
+ // should not select already selected project
+ wrapper.find('Projects').prop<Function>('onProjectSelected')('bar');
+ expect(wrapper.state('selection')).toEqual(['foo', 'bar']);
+
+ wrapper.find('Projects').prop<Function>('onProjectDeselected')('foo');
+ expect(wrapper.state('selection')).toEqual(['bar']);
+
+ wrapper.find('Search').prop<Function>('onAllDeselected')();
+ expect(wrapper.state('selection')).toEqual([]);
+
+ wrapper.find('Search').prop<Function>('onAllSelected')();
+ expect(wrapper.state('selection')).toEqual(['foo', 'bar']);
+});
+
+it('creates project', () => {
+ const wrapper = mountRender();
+ expect(wrapper.find('CreateProjectForm').exists()).toBeFalsy();
+
+ wrapper.find('Header').prop<Function>('onProjectCreate')();
+ expect(wrapper.find('CreateProjectForm').exists()).toBeTruthy();
+
+ wrapper.find('CreateProjectForm').prop<Function>('onProjectCreated')();
+ expect(getComponents.mock.calls).toHaveLength(2);
+
+ wrapper.find('CreateProjectForm').prop<Function>('onClose')();
+ expect(wrapper.find('CreateProjectForm').exists()).toBeFalsy();
+});
+
+it('changes default project visibility', () => {
+ const onVisibilityChange = jest.fn();
+ const wrapper = mountRender({ onVisibilityChange });
+ wrapper.find('Header').prop<Function>('onVisibilityChange')('private');
+ expect(onVisibilityChange).toBeCalledWith('private');
+});
+
+function mountRender(props?: { [P in keyof Props]?: Props[P] }) {
+ return mount(
+ <App
+ hasProvisionPermission={true}
+ onVisibilityChange={jest.fn()}
+ organization={organization}
+ topLevelQualifiers={['TRK', 'VW', 'APP']}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/BulkApplyTemplateModal-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/BulkApplyTemplateModal-test.tsx
new file mode 100644
index 00000000000..1c8399e6eda
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/BulkApplyTemplateModal-test.tsx
@@ -0,0 +1,128 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+jest.mock('../../../api/permissions', () => ({
+ applyTemplateToProject: jest.fn(),
+ bulkApplyTemplate: jest.fn(),
+ getPermissionTemplates: jest.fn()
+}));
+
+import * as React from 'react';
+import { mount, shallow } from 'enzyme';
+import BulkApplyTemplateModal, { Props } from '../BulkApplyTemplateModal';
+import { Type } from '../utils';
+import { click } from '../../../helpers/testUtils';
+
+const applyTemplateToProject = require('../../../api/permissions')
+ .applyTemplateToProject as jest.Mock<any>;
+const bulkApplyTemplate = require('../../../api/permissions').bulkApplyTemplate as jest.Mock<any>;
+const getPermissionTemplates = require('../../../api/permissions')
+ .getPermissionTemplates as jest.Mock<any>;
+
+beforeEach(() => {
+ applyTemplateToProject.mockImplementation(() => Promise.resolve()).mockClear();
+ bulkApplyTemplate.mockImplementation(() => Promise.resolve()).mockClear();
+ getPermissionTemplates
+ .mockImplementation(() => Promise.resolve({ permissionTemplates: [] }))
+ .mockClear();
+});
+
+it('fetches permission templates on mount', () => {
+ mount(render());
+ expect(getPermissionTemplates).toBeCalledWith('org');
+});
+
+it('bulk applies template to all results', async () => {
+ const wrapper = shallow(render());
+ (wrapper.instance() as BulkApplyTemplateModal).mounted = true;
+ expect(wrapper).toMatchSnapshot();
+
+ wrapper.setState({
+ loading: false,
+ permissionTemplate: 'foo',
+ permissionTemplates: [{ id: 'foo', name: 'Foo' }, { id: 'bar', name: 'Bar' }]
+ });
+ expect(wrapper).toMatchSnapshot();
+
+ click(wrapper.find('button'));
+ expect(bulkApplyTemplate).toBeCalledWith({
+ organization: 'org',
+ q: 'bla',
+ qualifier: 'TRK',
+ templateId: 'foo'
+ });
+ expect(wrapper).toMatchSnapshot();
+
+ await new Promise(setImmediate);
+ wrapper.update();
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('bulk applies template to selected results', async () => {
+ const wrapper = shallow(render({ selection: ['proj1', 'proj2'] }));
+ (wrapper.instance() as BulkApplyTemplateModal).mounted = true;
+ expect(wrapper).toMatchSnapshot();
+
+ wrapper.setState({
+ loading: false,
+ permissionTemplate: 'foo',
+ permissionTemplates: [{ id: 'foo', name: 'Foo' }, { id: 'bar', name: 'Bar' }]
+ });
+ expect(wrapper).toMatchSnapshot();
+
+ click(wrapper.find('button'));
+ expect(wrapper).toMatchSnapshot();
+ await new Promise(setImmediate);
+ expect(applyTemplateToProject.mock.calls).toHaveLength(2);
+ expect(applyTemplateToProject).toBeCalledWith({
+ organization: 'org',
+ projectKey: 'proj1',
+ templateId: 'foo'
+ });
+ expect(applyTemplateToProject).toBeCalledWith({
+ organization: 'org',
+ projectKey: 'proj2',
+ templateId: 'foo'
+ });
+
+ wrapper.update();
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('closes', () => {
+ const onClose = jest.fn();
+ const wrapper = shallow(render({ onClose }));
+ click(wrapper.find('.js-modal-close'));
+ expect(onClose).toBeCalled();
+});
+
+function render(props?: { [P in keyof Props]?: Props[P] }) {
+ return (
+ <BulkApplyTemplateModal
+ onClose={jest.fn()}
+ organization="org"
+ qualifier="TRK"
+ query="bla"
+ selection={[]}
+ total={17}
+ type={Type.All}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeVisibilityForm-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeVisibilityForm-test.tsx
new file mode 100644
index 00000000000..61dce6dbfe4
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ChangeVisibilityForm-test.tsx
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 ChangeVisibilityForm, { Props } from '../ChangeVisibilityForm';
+import { click } from '../../../helpers/testUtils';
+
+const organization = {
+ canUpdateProjectsVisibilityToPrivate: true,
+ key: 'org',
+ name: 'org',
+ projectVisibility: 'public'
+};
+
+it('renders disabled', () => {
+ expect(
+ shallowRender({
+ organization: { ...organization, canUpdateProjectsVisibilityToPrivate: false }
+ })
+ ).toMatchSnapshot();
+});
+
+it('closes', () => {
+ const onClose = jest.fn();
+ const wrapper = shallowRender({ onClose });
+ click(wrapper.find('.js-modal-close'));
+ expect(onClose).toBeCalled();
+});
+
+it('changes visibility', () => {
+ const onConfirm = jest.fn();
+ const wrapper = shallowRender({ onConfirm });
+ expect(wrapper).toMatchSnapshot();
+
+ click(wrapper.find('a[data-visibility="private"]'), {
+ currentTarget: {
+ blur() {},
+ dataset: { visibility: 'private' }
+ }
+ });
+ expect(wrapper).toMatchSnapshot();
+
+ click(wrapper.find('.js-confirm'));
+ expect(onConfirm).toBeCalledWith('private');
+});
+
+function shallowRender(props?: { [P in keyof Props]?: Props[P] }) {
+ return shallow(
+ <ChangeVisibilityForm
+ onClose={jest.fn()}
+ onConfirm={jest.fn()}
+ organization={organization}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx
new file mode 100644
index 00000000000..0212d094e8e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/CreateProjectForm-test.tsx
@@ -0,0 +1,72 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+jest.mock('../../../api/components', () => ({
+ createProject: jest.fn(({ name }: { name: string }) =>
+ Promise.resolve({ project: { key: name, name } })
+ )
+}));
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import CreateProjectForm from '../CreateProjectForm';
+import { change, submit } from '../../../helpers/testUtils';
+
+const createProject = require('../../../api/components').createProject as jest.Mock<any>;
+
+const organization = { key: 'org', name: 'org', projectVisibility: 'public' };
+
+it('creates project', async () => {
+ const wrapper = shallow(
+ <CreateProjectForm
+ onClose={jest.fn()}
+ onProjectCreated={jest.fn()}
+ organization={organization}
+ />
+ );
+ (wrapper.instance() as CreateProjectForm).mounted = true;
+ expect(wrapper).toMatchSnapshot();
+
+ change(wrapper.find('input[name="name"]'), 'name', {
+ currentTarget: { name: 'name', value: 'name' }
+ });
+ change(wrapper.find('input[name="branch"]'), 'branch', {
+ currentTarget: { name: 'branch', value: 'branch' }
+ });
+ change(wrapper.find('input[name="key"]'), 'key', {
+ currentTarget: { name: 'key', value: 'key' }
+ });
+ wrapper.find('VisibilitySelector').prop<Function>('onChange')('private');
+ wrapper.update();
+ expect(wrapper).toMatchSnapshot();
+
+ submit(wrapper.find('form'));
+ expect(createProject).toBeCalledWith({
+ branch: 'branch',
+ name: 'name',
+ organization: 'org',
+ project: 'key',
+ visibility: 'private'
+ });
+ expect(wrapper).toMatchSnapshot();
+
+ await new Promise(resolve => setImmediate(resolve));
+ wrapper.update();
+ expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/DeleteModal-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/DeleteModal-test.tsx
new file mode 100644
index 00000000000..d24f30e9cdf
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/DeleteModal-test.tsx
@@ -0,0 +1,63 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.
+ */
+jest.mock('../../../api/components', () => ({
+ deleteComponents: jest.fn(() => Promise.resolve())
+}));
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import DeleteModal, { Props } from '../DeleteModal';
+import { click } from '../../../helpers/testUtils';
+
+const deleteComponents = require('../../../api/components').deleteComponents as jest.Mock<any>;
+
+it('deletes projects', async () => {
+ const onConfirm = jest.fn();
+ const wrapper = shallowRender({ onConfirm });
+ (wrapper.instance() as DeleteModal).mounted = true;
+ expect(wrapper).toMatchSnapshot();
+
+ click(wrapper.find('button'));
+ expect(wrapper).toMatchSnapshot();
+ expect(deleteComponents).toBeCalledWith(['foo', 'bar'], 'org');
+
+ await new Promise(setImmediate);
+ expect(onConfirm).toBeCalled();
+});
+
+it('closes', () => {
+ const onClose = jest.fn();
+ const wrapper = shallowRender({ onClose });
+ click(wrapper.find('.js-modal-close'));
+ expect(onClose).toBeCalled();
+});
+
+function shallowRender(props?: { [P in keyof Props]?: Props[P] }) {
+ return shallow(
+ <DeleteModal
+ onClose={jest.fn()}
+ onConfirm={jest.fn()}
+ organization="org"
+ qualifier="TRK"
+ selection={['foo', 'bar']}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx
new file mode 100644
index 00000000000..726c6b186cb
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Header-test.tsx
@@ -0,0 +1,64 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 Header, { Props } from '../Header';
+import { Visibility } from '../utils';
+import { click } from '../../../helpers/testUtils';
+
+const organization = { key: 'org', name: 'org', projectVisibility: 'public' };
+
+it('renders', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('creates project', () => {
+ const onProjectCreate = jest.fn();
+ const wrapper = shallowRender({ onProjectCreate });
+ click(wrapper.find('#create-project'));
+ expect(onProjectCreate).toBeCalledWith();
+});
+
+it('changes default visibility', () => {
+ const onVisibilityChange = jest.fn();
+ const wrapper = shallowRender({ onVisibilityChange });
+
+ click(wrapper.find('.js-change-visibility'));
+
+ const modalWrapper = wrapper.find('ChangeVisibilityForm');
+ expect(modalWrapper).toMatchSnapshot();
+ modalWrapper.prop<Function>('onConfirm')(Visibility.Private);
+ expect(onVisibilityChange).toBeCalledWith(Visibility.Private);
+
+ modalWrapper.prop<Function>('onClose')();
+ expect(wrapper.find('ChangeVisibilityForm').exists()).toBeFalsy();
+});
+
+function shallowRender(props?: { [P in keyof Props]?: Props[P] }) {
+ return shallow(
+ <Header
+ hasProvisionPermission={true}
+ onProjectCreate={jest.fn()}
+ onVisibilityChange={jest.fn()}
+ organization={organization}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectRow-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectRow-test.tsx
new file mode 100644
index 00000000000..7b7bb09d5ae
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/ProjectRow-test.tsx
@@ -0,0 +1,61 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 ProjectRow from '../ProjectRow';
+import { Visibility } from '../utils';
+import { click } from '../../../helpers/testUtils';
+
+const project = {
+ key: 'project',
+ name: 'Project',
+ qualifier: 'TRK',
+ visibility: Visibility.Private
+};
+
+it('renders', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('checks project', () => {
+ const onProjectCheck = jest.fn();
+ const wrapper = shallowRender({ onProjectCheck });
+ wrapper.find('Checkbox').prop<Function>('onCheck')(false);
+ expect(onProjectCheck).toBeCalledWith(project, false);
+});
+
+it('applies permission template', () => {
+ const onApplyTemplateClick = jest.fn();
+ const wrapper = shallowRender({ onApplyTemplateClick });
+ click(wrapper.find('.js-apply-template'));
+ expect(onApplyTemplateClick).toBeCalledWith(project);
+});
+
+function shallowRender(props?: any) {
+ return shallow(
+ <ProjectRow
+ onApplyTemplateClick={jest.fn()}
+ onProjectCheck={jest.fn()}
+ project={project}
+ selected={true}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Projects-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Projects-test.tsx
new file mode 100644
index 00000000000..b1f165cde7a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Projects-test.tsx
@@ -0,0 +1,67 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+jest.mock('../../permissions/project/views/ApplyTemplateView');
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import Projects from '../Projects';
+import { Visibility } from '../utils';
+import ApplyTemplateView from '../../permissions/project/views/ApplyTemplateView';
+
+const organization = { key: 'org', name: 'org', projectVisibility: 'public' };
+const projects = [
+ { key: 'a', name: 'A', qualifier: 'TRK', visibility: Visibility.Public },
+ { key: 'b', name: 'B', qualifier: 'TRK', visibility: Visibility.Public }
+];
+const selection = ['a'];
+
+it('renders list of projects', () => {
+ expect(shallowRender({ projects, selection })).toMatchSnapshot();
+});
+
+it('selects and deselects project', () => {
+ const onProjectDeselected = jest.fn();
+ const onProjectSelected = jest.fn();
+ const wrapper = shallowRender({ onProjectDeselected, onProjectSelected, projects });
+
+ wrapper.find('ProjectRow').first().prop<Function>('onProjectCheck')(projects[0], true);
+ expect(onProjectSelected).toBeCalledWith('a');
+
+ wrapper.find('ProjectRow').first().prop<Function>('onProjectCheck')(projects[0], false);
+ expect(onProjectDeselected).toBeCalledWith('a');
+});
+
+it('opens modal to apply permission template', () => {
+ const wrapper = shallowRender({ projects });
+ wrapper.find('ProjectRow').first().prop<Function>('onApplyTemplateClick')(projects[0]);
+ expect(ApplyTemplateView).toBeCalledWith({ organization, project: projects[0] });
+});
+
+function shallowRender(props?: any) {
+ return shallow(
+ <Projects
+ onProjectDeselected={jest.fn()}
+ onProjectSelected={jest.fn()}
+ organization={organization}
+ selection={[]}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx
new file mode 100644
index 00000000000..950b78e1624
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx
@@ -0,0 +1,107 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 Search, { Props } from '../Search';
+import { Type } from '../utils';
+import { change, click } from '../../../helpers/testUtils';
+
+const organization = { key: 'org', name: 'org', projectVisibility: 'public' };
+
+it('renders', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('render qualifiers filter', () => {
+ expect(shallowRender({ topLevelQualifiers: ['TRK', 'VW', 'APP'] })).toMatchSnapshot();
+});
+
+it('updates qualifier', () => {
+ const onQualifierChanged = jest.fn();
+ const wrapper = shallowRender({ onQualifierChanged, topLevelQualifiers: ['TRK', 'VW', 'APP'] });
+ wrapper.find('RadioToggle[name="projects-qualifier"]').prop<Function>('onCheck')('VW');
+ expect(onQualifierChanged).toBeCalledWith('VW');
+});
+
+it('updates type', () => {
+ const onTypeChanged = jest.fn();
+ const wrapper = shallowRender({ onTypeChanged });
+ wrapper.find('RadioToggle[name="projects-type"]').prop<Function>('onCheck')(Type.Provisioned);
+ expect(onTypeChanged).toBeCalledWith(Type.Provisioned);
+});
+
+it('searches', () => {
+ const onSearch = jest.fn();
+ const wrapper = shallowRender({ onSearch });
+ change(wrapper.find('input[type="search"]'), 'foo');
+ expect(onSearch).toBeCalledWith('foo');
+});
+
+it('checks all or none projects', () => {
+ const onAllDeselected = jest.fn();
+ const onAllSelected = jest.fn();
+ const wrapper = shallowRender({ onAllDeselected, onAllSelected });
+
+ wrapper.find('Checkbox').prop<Function>('onCheck')(true);
+ expect(onAllSelected).toBeCalled();
+
+ wrapper.find('Checkbox').prop<Function>('onCheck')(false);
+ expect(onAllDeselected).toBeCalled();
+});
+
+it('deletes projects', () => {
+ const onDeleteProjects = jest.fn();
+ const wrapper = shallowRender({ onDeleteProjects, selection: ['foo', 'bar'] });
+ click(wrapper.find('.js-delete'));
+ expect(wrapper.find('DeleteModal')).toMatchSnapshot();
+ wrapper.find('DeleteModal').prop<Function>('onConfirm')();
+ expect(onDeleteProjects).toBeCalled();
+});
+
+it('bulk applies permission template', () => {
+ const wrapper = shallowRender({});
+ click(wrapper.find('.js-bulk-apply-permission-template'));
+ expect(wrapper.find('BulkApplyTemplateModal')).toMatchSnapshot();
+ wrapper.find('BulkApplyTemplateModal').prop<Function>('onClose')();
+ expect(wrapper.find('BulkApplyTemplateModal').exists()).toBeFalsy();
+});
+
+function shallowRender(props?: { [P in keyof Props]?: Props[P] }) {
+ return shallow(
+ <Search
+ onAllDeselected={jest.fn()}
+ onAllSelected={jest.fn()}
+ onDeleteProjects={jest.fn()}
+ onQualifierChanged={jest.fn()}
+ onSearch={jest.fn()}
+ onTypeChanged={jest.fn()}
+ organization={organization}
+ projects={[]}
+ qualifiers="TRK"
+ query=""
+ ready={true}
+ selection={[]}
+ topLevelQualifiers={['TRK']}
+ total={0}
+ type={Type.All}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/BulkApplyTemplateModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/BulkApplyTemplateModal-test.tsx.snap
new file mode 100644
index 00000000000..bc3e39d7f64
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/BulkApplyTemplateModal-test.tsx.snap
@@ -0,0 +1,643 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`bulk applies template to all results 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <i
+ className="spinner"
+ />
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`bulk applies template to all results 2`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="alert alert-warning"
+ >
+ permission_templates.bulk_apply_permission_template.apply_to_all.17
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label>
+ template
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <Select
+ addLabelText="Add \\"{label}\\"?"
+ arrowRenderer={[Function]}
+ autosize={true}
+ backspaceRemoves={true}
+ backspaceToRemoveMessage="Press backspace to remove {label}"
+ clearAllText="Clear all"
+ clearRenderer={[Function]}
+ clearValueText="Clear value"
+ clearable={false}
+ deleteRemoves={true}
+ delimiter=","
+ disabled={false}
+ escapeClearsValue={true}
+ filterOptions={[Function]}
+ ignoreAccents={true}
+ ignoreCase={true}
+ inputProps={Object {}}
+ isLoading={false}
+ joinValues={false}
+ labelKey="label"
+ matchPos="any"
+ matchProp="any"
+ menuBuffer={0}
+ menuRenderer={[Function]}
+ multi={false}
+ noResultsText="No results found"
+ onBlurResetsInput={true}
+ onChange={[Function]}
+ onCloseResetsInput={true}
+ optionComponent={[Function]}
+ options={
+ Array [
+ Object {
+ "label": "Foo",
+ "value": "foo",
+ },
+ Object {
+ "label": "Bar",
+ "value": "bar",
+ },
+ ]
+ }
+ pageSize={5}
+ placeholder="Select..."
+ required={false}
+ scrollMenuIntoView={true}
+ searchable={false}
+ simpleValue={false}
+ tabSelectsValue={true}
+ value="foo"
+ valueComponent={[Function]}
+ valueKey="value"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ disabled={false}
+ onClick={[Function]}
+ >
+ apply
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`bulk applies template to all results 3`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="alert alert-warning"
+ >
+ permission_templates.bulk_apply_permission_template.apply_to_all.17
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label>
+ template
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <Select
+ addLabelText="Add \\"{label}\\"?"
+ arrowRenderer={[Function]}
+ autosize={true}
+ backspaceRemoves={true}
+ backspaceToRemoveMessage="Press backspace to remove {label}"
+ clearAllText="Clear all"
+ clearRenderer={[Function]}
+ clearValueText="Clear value"
+ clearable={false}
+ deleteRemoves={true}
+ delimiter=","
+ disabled={true}
+ escapeClearsValue={true}
+ filterOptions={[Function]}
+ ignoreAccents={true}
+ ignoreCase={true}
+ inputProps={Object {}}
+ isLoading={false}
+ joinValues={false}
+ labelKey="label"
+ matchPos="any"
+ matchProp="any"
+ menuBuffer={0}
+ menuRenderer={[Function]}
+ multi={false}
+ noResultsText="No results found"
+ onBlurResetsInput={true}
+ onChange={[Function]}
+ onCloseResetsInput={true}
+ optionComponent={[Function]}
+ options={
+ Array [
+ Object {
+ "label": "Foo",
+ "value": "foo",
+ },
+ Object {
+ "label": "Bar",
+ "value": "bar",
+ },
+ ]
+ }
+ pageSize={5}
+ placeholder="Select..."
+ required={false}
+ scrollMenuIntoView={true}
+ searchable={false}
+ simpleValue={false}
+ tabSelectsValue={true}
+ value="foo"
+ valueComponent={[Function]}
+ valueKey="value"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <i
+ className="spinner spacer-right"
+ />
+ <button
+ disabled={true}
+ onClick={[Function]}
+ >
+ apply
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`bulk applies template to all results 4`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="alert alert-success"
+ >
+ projects_role.apply_template.success
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ close
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`bulk applies template to selected results 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <i
+ className="spinner"
+ />
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`bulk applies template to selected results 2`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="alert alert-info"
+ >
+ permission_templates.bulk_apply_permission_template.apply_to_selected.2
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label>
+ template
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <Select
+ addLabelText="Add \\"{label}\\"?"
+ arrowRenderer={[Function]}
+ autosize={true}
+ backspaceRemoves={true}
+ backspaceToRemoveMessage="Press backspace to remove {label}"
+ clearAllText="Clear all"
+ clearRenderer={[Function]}
+ clearValueText="Clear value"
+ clearable={false}
+ deleteRemoves={true}
+ delimiter=","
+ disabled={false}
+ escapeClearsValue={true}
+ filterOptions={[Function]}
+ ignoreAccents={true}
+ ignoreCase={true}
+ inputProps={Object {}}
+ isLoading={false}
+ joinValues={false}
+ labelKey="label"
+ matchPos="any"
+ matchProp="any"
+ menuBuffer={0}
+ menuRenderer={[Function]}
+ multi={false}
+ noResultsText="No results found"
+ onBlurResetsInput={true}
+ onChange={[Function]}
+ onCloseResetsInput={true}
+ optionComponent={[Function]}
+ options={
+ Array [
+ Object {
+ "label": "Foo",
+ "value": "foo",
+ },
+ Object {
+ "label": "Bar",
+ "value": "bar",
+ },
+ ]
+ }
+ pageSize={5}
+ placeholder="Select..."
+ required={false}
+ scrollMenuIntoView={true}
+ searchable={false}
+ simpleValue={false}
+ tabSelectsValue={true}
+ value="foo"
+ valueComponent={[Function]}
+ valueKey="value"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ disabled={false}
+ onClick={[Function]}
+ >
+ apply
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`bulk applies template to selected results 3`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="alert alert-info"
+ >
+ permission_templates.bulk_apply_permission_template.apply_to_selected.2
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label>
+ template
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <Select
+ addLabelText="Add \\"{label}\\"?"
+ arrowRenderer={[Function]}
+ autosize={true}
+ backspaceRemoves={true}
+ backspaceToRemoveMessage="Press backspace to remove {label}"
+ clearAllText="Clear all"
+ clearRenderer={[Function]}
+ clearValueText="Clear value"
+ clearable={false}
+ deleteRemoves={true}
+ delimiter=","
+ disabled={true}
+ escapeClearsValue={true}
+ filterOptions={[Function]}
+ ignoreAccents={true}
+ ignoreCase={true}
+ inputProps={Object {}}
+ isLoading={false}
+ joinValues={false}
+ labelKey="label"
+ matchPos="any"
+ matchProp="any"
+ menuBuffer={0}
+ menuRenderer={[Function]}
+ multi={false}
+ noResultsText="No results found"
+ onBlurResetsInput={true}
+ onChange={[Function]}
+ onCloseResetsInput={true}
+ optionComponent={[Function]}
+ options={
+ Array [
+ Object {
+ "label": "Foo",
+ "value": "foo",
+ },
+ Object {
+ "label": "Bar",
+ "value": "bar",
+ },
+ ]
+ }
+ pageSize={5}
+ placeholder="Select..."
+ required={false}
+ scrollMenuIntoView={true}
+ searchable={false}
+ simpleValue={false}
+ tabSelectsValue={true}
+ value="foo"
+ valueComponent={[Function]}
+ valueKey="value"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <i
+ className="spinner spacer-right"
+ />
+ <button
+ disabled={true}
+ onClick={[Function]}
+ >
+ apply
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`bulk applies template to selected results 4`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="permission_templates.bulk_apply_permission_template"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ permission_templates.bulk_apply_permission_template
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="alert alert-success"
+ >
+ projects_role.apply_template.success
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ close
+ </a>
+ </footer>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeVisibilityForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeVisibilityForm-test.tsx.snap
new file mode 100644
index 00000000000..d46e88f161f
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ChangeVisibilityForm-test.tsx.snap
@@ -0,0 +1,308 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`changes visibility 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ organization.change_visibility_form.header
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="big-spacer-bottom"
+ >
+ <p>
+ <a
+ className="link-base-color link-no-underline"
+ data-visibility="public"
+ href="#"
+ onClick={[Function]}
+ >
+ <i
+ className="icon-radio spacer-right is-checked"
+ />
+ visibility.public
+ </a>
+ </p>
+ <p
+ className="text-muted spacer-top"
+ style={
+ Object {
+ "paddingLeft": 22,
+ }
+ }
+ >
+ visibility.public.description.short
+ </p>
+ </div>
+ <div
+ className="big-spacer-bottom"
+ >
+ <p>
+ <a
+ className="link-base-color link-no-underline"
+ data-visibility="private"
+ href="#"
+ onClick={[Function]}
+ >
+ <i
+ className="icon-radio spacer-right"
+ />
+ visibility.private
+ </a>
+ </p>
+ <p
+ className="text-muted spacer-top"
+ style={
+ Object {
+ "paddingLeft": 22,
+ }
+ }
+ >
+ visibility.private.description.short
+ </p>
+ </div>
+ <div
+ className="alert alert-warning"
+ >
+ organization.change_visibility_form.warning
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ className="js-confirm"
+ onClick={[Function]}
+ >
+ organization.change_visibility_form.submit
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`changes visibility 2`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ organization.change_visibility_form.header
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="big-spacer-bottom"
+ >
+ <p>
+ <a
+ className="link-base-color link-no-underline"
+ data-visibility="public"
+ href="#"
+ onClick={[Function]}
+ >
+ <i
+ className="icon-radio spacer-right"
+ />
+ visibility.public
+ </a>
+ </p>
+ <p
+ className="text-muted spacer-top"
+ style={
+ Object {
+ "paddingLeft": 22,
+ }
+ }
+ >
+ visibility.public.description.short
+ </p>
+ </div>
+ <div
+ className="big-spacer-bottom"
+ >
+ <p>
+ <a
+ className="link-base-color link-no-underline"
+ data-visibility="private"
+ href="#"
+ onClick={[Function]}
+ >
+ <i
+ className="icon-radio spacer-right is-checked"
+ />
+ visibility.private
+ </a>
+ </p>
+ <p
+ className="text-muted spacer-top"
+ style={
+ Object {
+ "paddingLeft": 22,
+ }
+ }
+ >
+ visibility.private.description.short
+ </p>
+ </div>
+ <div
+ className="alert alert-warning"
+ >
+ organization.change_visibility_form.warning
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ className="js-confirm"
+ onClick={[Function]}
+ >
+ organization.change_visibility_form.submit
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`renders disabled 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ organization.change_visibility_form.header
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="big-spacer-bottom"
+ >
+ <p>
+ <a
+ className="link-base-color link-no-underline"
+ data-visibility="public"
+ href="#"
+ onClick={[Function]}
+ >
+ <i
+ className="icon-radio spacer-right is-checked"
+ />
+ visibility.public
+ </a>
+ </p>
+ <p
+ className="text-muted spacer-top"
+ style={
+ Object {
+ "paddingLeft": 22,
+ }
+ }
+ >
+ visibility.public.description.short
+ </p>
+ </div>
+ <div
+ className="big-spacer-bottom"
+ >
+ <p>
+ <span
+ className="text-muted cursor-not-allowed"
+ >
+ <i
+ className="icon-radio spacer-right"
+ />
+ visibility.private
+ </span>
+ </p>
+ <p
+ className="text-muted spacer-top"
+ style={
+ Object {
+ "paddingLeft": 22,
+ }
+ }
+ >
+ visibility.private.description.short
+ </p>
+ </div>
+ <UpgradeOrganizationBox
+ organization="org"
+ />
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ className="js-confirm"
+ onClick={[Function]}
+ >
+ organization.change_visibility_form.submit
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap
new file mode 100644
index 00000000000..fad19337758
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/CreateProjectForm-test.tsx.snap
@@ -0,0 +1,468 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`creates project 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <form
+ id="create-project-form"
+ onSubmit={[Function]}
+ >
+ <header
+ className="modal-head"
+ >
+ <h2>
+ qualifiers.create.TRK
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-name"
+ >
+ name
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ autoFocus={true}
+ id="create-project-name"
+ maxLength={2000}
+ name="name"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value=""
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-branch"
+ >
+ branch
+ </label>
+ <input
+ id="create-project-branch"
+ maxLength={200}
+ name="branch"
+ onChange={[Function]}
+ type="text"
+ value=""
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-key"
+ >
+ key
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ id="create-project-key"
+ maxLength={400}
+ name="key"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value=""
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label>
+ visibility
+ </label>
+ <VisibilitySelector
+ className="little-spacer-top"
+ onChange={[Function]}
+ visibility="public"
+ />
+ <div
+ className="spacer-top"
+ >
+ <UpgradeOrganizationBox
+ organization="org"
+ />
+ </div>
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ disabled={false}
+ id="create-project-submit"
+ type="submit"
+ >
+ create
+ </button>
+ <a
+ href="#"
+ id="create-project-cancel"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
+
+exports[`creates project 2`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <form
+ id="create-project-form"
+ onSubmit={[Function]}
+ >
+ <header
+ className="modal-head"
+ >
+ <h2>
+ qualifiers.create.TRK
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-name"
+ >
+ name
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ autoFocus={true}
+ id="create-project-name"
+ maxLength={2000}
+ name="name"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value="name"
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-branch"
+ >
+ branch
+ </label>
+ <input
+ id="create-project-branch"
+ maxLength={200}
+ name="branch"
+ onChange={[Function]}
+ type="text"
+ value="branch"
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-key"
+ >
+ key
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ id="create-project-key"
+ maxLength={400}
+ name="key"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value="key"
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label>
+ visibility
+ </label>
+ <VisibilitySelector
+ className="little-spacer-top"
+ onChange={[Function]}
+ visibility="private"
+ />
+ <div
+ className="spacer-top"
+ >
+ <UpgradeOrganizationBox
+ organization="org"
+ />
+ </div>
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ disabled={false}
+ id="create-project-submit"
+ type="submit"
+ >
+ create
+ </button>
+ <a
+ href="#"
+ id="create-project-cancel"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
+
+exports[`creates project 3`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <form
+ id="create-project-form"
+ onSubmit={[Function]}
+ >
+ <header
+ className="modal-head"
+ >
+ <h2>
+ qualifiers.create.TRK
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-name"
+ >
+ name
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ autoFocus={true}
+ id="create-project-name"
+ maxLength={2000}
+ name="name"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value="name"
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-branch"
+ >
+ branch
+ </label>
+ <input
+ id="create-project-branch"
+ maxLength={200}
+ name="branch"
+ onChange={[Function]}
+ type="text"
+ value="branch"
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label
+ htmlFor="create-project-key"
+ >
+ key
+ <em
+ className="mandatory"
+ >
+ *
+ </em>
+ </label>
+ <input
+ id="create-project-key"
+ maxLength={400}
+ name="key"
+ onChange={[Function]}
+ required={true}
+ type="text"
+ value="key"
+ />
+ </div>
+ <div
+ className="modal-field"
+ >
+ <label>
+ visibility
+ </label>
+ <VisibilitySelector
+ className="little-spacer-top"
+ onChange={[Function]}
+ visibility="private"
+ />
+ <div
+ className="spacer-top"
+ >
+ <UpgradeOrganizationBox
+ organization="org"
+ />
+ </div>
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <i
+ className="spinner spacer-right"
+ />
+ <button
+ disabled={true}
+ id="create-project-submit"
+ type="submit"
+ >
+ create
+ </button>
+ <a
+ href="#"
+ id="create-project-cancel"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+ </form>
+</Modal>
+`;
+
+exports[`creates project 4`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="modal form"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <div>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ qualifiers.create.TRK
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="alert alert-success"
+ >
+ Project
+ <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/dashboard",
+ "query": Object {
+ "id": "name",
+ },
+ }
+ }
+ >
+ name
+ </Link>
+
+ has been successfully created.
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <a
+ href="#"
+ id="create-project-close"
+ onClick={[Function]}
+ >
+ close
+ </a>
+ </footer>
+ </div>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/DeleteModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/DeleteModal-test.tsx.snap
new file mode 100644
index 00000000000..e57788fbc92
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/DeleteModal-test.tsx.snap
@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`deletes projects 1`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="qualifiers.delete.TRK"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ qualifiers.delete.TRK
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ qualifiers.delete_confirm.TRK
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <button
+ className="button-red"
+ disabled={false}
+ onClick={[Function]}
+ >
+ delete
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
+
+exports[`deletes projects 2`] = `
+<Modal
+ ariaHideApp={true}
+ bodyOpenClassName="ReactModal__Body--open"
+ className="modal"
+ closeTimeoutMS={0}
+ contentLabel="qualifiers.delete.TRK"
+ isOpen={true}
+ onRequestClose={[Function]}
+ overlayClassName="modal-overlay"
+ parentSelector={[Function]}
+ portalClassName="ReactModalPortal"
+ shouldCloseOnOverlayClick={true}
+>
+ <header
+ className="modal-head"
+ >
+ <h2>
+ qualifiers.delete.TRK
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ qualifiers.delete_confirm.TRK
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <i
+ className="spinner spacer-right"
+ />
+ <button
+ className="button-red"
+ disabled={true}
+ onClick={[Function]}
+ >
+ delete
+ </button>
+ <a
+ className="js-modal-close"
+ href="#"
+ onClick={[Function]}
+ >
+ cancel
+ </a>
+ </footer>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap
new file mode 100644
index 00000000000..9fb16cfbed5
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Header-test.tsx.snap
@@ -0,0 +1,56 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`changes default visibility 1`] = `
+<ChangeVisibilityForm
+ onClose={[Function]}
+ onConfirm={[Function]}
+ organization={
+ Object {
+ "key": "org",
+ "name": "org",
+ "projectVisibility": "public",
+ }
+ }
+/>
+`;
+
+exports[`renders 1`] = `
+<header
+ className="page-header"
+>
+ <h1
+ className="page-title"
+ >
+ projects_management
+ </h1>
+ <div
+ className="page-actions"
+ >
+ <span
+ className="big-spacer-right"
+ >
+ organization.default_visibility_of_new_projects
+
+ <strong>
+ visibility.public
+ </strong>
+ <a
+ className="js-change-visibility spacer-left icon-edit"
+ href="#"
+ onClick={[Function]}
+ />
+ </span>
+ <button
+ id="create-project"
+ onClick={[Function]}
+ >
+ qualifiers.create.TRK
+ </button>
+ </div>
+ <p
+ className="page-description"
+ >
+ projects_management.page.description
+ </p>
+</header>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ProjectRow-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ProjectRow-test.tsx.snap
new file mode 100644
index 00000000000..b306b2fe020
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/ProjectRow-test.tsx.snap
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<tr>
+ <td
+ className="thin"
+ >
+ <Checkbox
+ checked={true}
+ onCheck={[Function]}
+ thirdState={false}
+ />
+ </td>
+ <td
+ className="nowrap"
+ >
+ <Link
+ className="link-with-icon"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/dashboard",
+ "query": Object {
+ "id": "project",
+ },
+ }
+ }
+ >
+ <QualifierIcon
+ qualifier="TRK"
+ />
+
+ <span>
+ Project
+ </span>
+ </Link>
+ </td>
+ <td
+ className="nowrap"
+ >
+ <span
+ className="note"
+ >
+ project
+ </span>
+ </td>
+ <td
+ className="width-20"
+ >
+ <PrivateBadge />
+ </td>
+ <td
+ className="thin nowrap"
+ >
+ <div
+ className="dropdown"
+ >
+ <button
+ className="dropdown-toggle"
+ data-toggle="dropdown"
+ >
+ actions
+
+ <i
+ className="icon-dropdown"
+ />
+ </button>
+ <ul
+ className="dropdown-menu dropdown-menu-right"
+ >
+ <li>
+ <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/project_roles",
+ "query": Object {
+ "id": "project",
+ },
+ }
+ }
+ >
+ edit_permissions
+ </Link>
+ </li>
+ <li>
+ <a
+ className="js-apply-template"
+ href="#"
+ onClick={[Function]}
+ >
+ projects_role.apply_template
+ </a>
+ </li>
+ </ul>
+ </div>
+ </td>
+</tr>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Projects-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Projects-test.tsx.snap
new file mode 100644
index 00000000000..14bb03d1ec6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Projects-test.tsx.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders list of projects 1`] = `
+<table
+ className="data zebra new-loading"
+ id="projects-management-page-projects"
+>
+ <tbody>
+ <ProjectRow
+ onApplyTemplateClick={[Function]}
+ onProjectCheck={[Function]}
+ project={
+ Object {
+ "key": "a",
+ "name": "A",
+ "qualifier": "TRK",
+ "visibility": "public",
+ }
+ }
+ selected={true}
+ />
+ <ProjectRow
+ onApplyTemplateClick={[Function]}
+ onProjectCheck={[Function]}
+ project={
+ Object {
+ "key": "b",
+ "name": "B",
+ "qualifier": "TRK",
+ "visibility": "public",
+ }
+ }
+ selected={false}
+ />
+ </tbody>
+</table>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap
new file mode 100644
index 00000000000..84838c7bc2e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap
@@ -0,0 +1,234 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`bulk applies permission template 1`] = `
+<BulkApplyTemplateModal
+ onClose={[Function]}
+ organization="org"
+ qualifier="TRK"
+ query=""
+ selection={Array []}
+ total={0}
+ type="ALL"
+/>
+`;
+
+exports[`deletes projects 1`] = `
+<DeleteModal
+ onClose={[Function]}
+ onConfirm={[Function]}
+ organization="org"
+ qualifier="TRK"
+ selection={
+ Array [
+ "foo",
+ "bar",
+ ]
+ }
+/>
+`;
+
+exports[`render qualifiers filter 1`] = `
+<div
+ className="panel panel-vertical bordered-bottom spacer-bottom"
+>
+ <table
+ className="data"
+ >
+ <tbody>
+ <tr>
+ <td
+ className="thin text-middle"
+ >
+ <Checkbox
+ checked={false}
+ onCheck={[Function]}
+ thirdState={false}
+ />
+ </td>
+ <td
+ className="thin nowrap text-middle"
+ >
+ <RadioToggle
+ disabled={false}
+ name="projects-qualifier"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "label": "qualifiers.TRK",
+ "value": "TRK",
+ },
+ Object {
+ "label": "qualifiers.VW",
+ "value": "VW",
+ },
+ Object {
+ "label": "qualifiers.APP",
+ "value": "APP",
+ },
+ ]
+ }
+ value="TRK"
+ />
+ </td>
+ <td
+ className="thin nowrap text-middle"
+ >
+ <RadioToggle
+ disabled={false}
+ name="projects-type"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "label": "All",
+ "value": "ALL",
+ },
+ Object {
+ "label": "Provisioned",
+ "value": "PROVISIONED",
+ },
+ Object {
+ "label": "Ghosts",
+ "value": "GHOSTS",
+ },
+ ]
+ }
+ value="ALL"
+ />
+ </td>
+ <td
+ className="text-middle"
+ >
+ <form
+ className="search-box"
+ onSubmit={[Function]}
+ >
+ <button
+ className="search-box-submit button-clean"
+ >
+ <i
+ className="icon-search"
+ />
+ </button>
+ <input
+ className="search-box-input input-medium"
+ onChange={[Function]}
+ placeholder="Search"
+ type="search"
+ value=""
+ />
+ </form>
+ </td>
+ <td
+ className="thin nowrap text-middle"
+ >
+ <button
+ className="spacer-right js-bulk-apply-permission-template"
+ onClick={[Function]}
+ >
+ permission_templates.bulk_apply_permission_template
+ </button>
+ <button
+ className="js-delete button-red"
+ disabled={true}
+ onClick={[Function]}
+ >
+ delete
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+`;
+
+exports[`renders 1`] = `
+<div
+ className="panel panel-vertical bordered-bottom spacer-bottom"
+>
+ <table
+ className="data"
+ >
+ <tbody>
+ <tr>
+ <td
+ className="thin text-middle"
+ >
+ <Checkbox
+ checked={false}
+ onCheck={[Function]}
+ thirdState={false}
+ />
+ </td>
+ <td
+ className="thin nowrap text-middle"
+ >
+ <RadioToggle
+ disabled={false}
+ name="projects-type"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "label": "All",
+ "value": "ALL",
+ },
+ Object {
+ "label": "Provisioned",
+ "value": "PROVISIONED",
+ },
+ Object {
+ "label": "Ghosts",
+ "value": "GHOSTS",
+ },
+ ]
+ }
+ value="ALL"
+ />
+ </td>
+ <td
+ className="text-middle"
+ >
+ <form
+ className="search-box"
+ onSubmit={[Function]}
+ >
+ <button
+ className="search-box-submit button-clean"
+ >
+ <i
+ className="icon-search"
+ />
+ </button>
+ <input
+ className="search-box-input input-medium"
+ onChange={[Function]}
+ placeholder="Search"
+ type="search"
+ value=""
+ />
+ </form>
+ </td>
+ <td
+ className="thin nowrap text-middle"
+ >
+ <button
+ className="spacer-right js-bulk-apply-permission-template"
+ onClick={[Function]}
+ >
+ permission_templates.bulk_apply_permission_template
+ </button>
+ <button
+ className="js-delete button-red"
+ disabled={true}
+ onClick={[Function]}
+ >
+ delete
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/routes.ts b/server/sonar-web/src/main/js/apps/projectsManagement/routes.ts
new file mode 100644
index 00000000000..447c6ae73dd
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/routes.ts
@@ -0,0 +1,35 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 { RouterState, IndexRouteProps } from 'react-router';
+
+const routes = [
+ {
+ getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) {
+ Promise.all([
+ import('./AppContainer').then(i => i.default),
+ import('../organizations/forSingleOrganization').then(i => i.default)
+ ]).then(([AppContainer, forSingleOrganization]) =>
+ callback(null, { component: forSingleOrganization(AppContainer) })
+ );
+ }
+ }
+];
+
+export default routes;
diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/utils.ts b/server/sonar-web/src/main/js/apps/projectsManagement/utils.ts
new file mode 100644
index 00000000000..4e3f01888b3
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectsManagement/utils.ts
@@ -0,0 +1,40 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+export const PAGE_SIZE = 50;
+
+export const QUALIFIERS_ORDER = ['TRK', 'VW', 'APP', 'DEV'];
+
+export enum Type {
+ All = 'ALL',
+ Provisioned = 'PROVISIONED',
+ Ghosts = 'GHOSTS'
+}
+
+export interface Project {
+ key: string;
+ name: string;
+ qualifier: string;
+ visibility: Visibility;
+}
+
+export enum Visibility {
+ Public = 'public',
+ Private = 'private'
+}