diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2017-09-05 11:00:00 +0200 |
---|---|---|
committer | Stas Vilchik <stas.vilchik@sonarsource.com> | 2017-09-11 11:28:29 +0200 |
commit | 71fec25c4056c1dcfe75769c2041b1d56a89a2e5 (patch) | |
tree | e640a76709b242652d3cc274a9d0a98f720ae768 /server/sonar-web/src/main/js/apps/projectsManagement | |
parent | 0926670e79d919e0afa3f0a2e11f656bdcd05916 (diff) | |
download | sonarqube-71fec25c4056c1dcfe75769c2041b1d56a89a2e5.tar.gz sonarqube-71fec25c4056c1dcfe75769c2041b1d56a89a2e5.zip |
SONAR-9784 rewrite projects management page
Diffstat (limited to 'server/sonar-web/src/main/js/apps/projectsManagement')
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' +} |