diff options
Diffstat (limited to 'server/sonar-web/src/main/js/apps')
32 files changed, 1189 insertions, 545 deletions
diff --git a/server/sonar-web/src/main/js/apps/project-admin/quality-gate/QualityGate.js b/server/sonar-web/src/main/js/apps/project-admin/quality-gate/QualityGate.js deleted file mode 100644 index 6194c04371a..00000000000 --- a/server/sonar-web/src/main/js/apps/project-admin/quality-gate/QualityGate.js +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 React from 'react'; -import PropTypes from 'prop-types'; -import Helmet from 'react-helmet'; -import { connect } from 'react-redux'; -import Header from './Header'; -import Form from './Form'; -import { fetchProjectGate, setProjectGate } from '../store/actions'; -import { getProjectAdminAllGates, getProjectAdminProjectGate } from '../../../store/rootReducer'; -import { translate } from '../../../helpers/l10n'; - -class QualityGate extends React.PureComponent { - static propTypes = { - component: PropTypes.object, - allGates: PropTypes.array, - gate: PropTypes.object - }; - - componentDidMount() { - this.props.fetchProjectGate(this.props.component.key); - } - - handleChangeGate(oldId, newId) { - this.props.setProjectGate(this.props.component.key, oldId, newId); - } - - render() { - return ( - <div id="project-quality-gate" className="page page-limited"> - <Helmet title={translate('project_quality_gate.page')} /> - <Header /> - <Form - allGates={this.props.allGates} - gate={this.props.gate} - onChange={this.handleChangeGate.bind(this)} - /> - </div> - ); - } -} - -const mapStateToProps = (state, ownProps) => ({ - allGates: getProjectAdminAllGates(state), - gate: getProjectAdminProjectGate(state, ownProps.location.query.id) -}); - -export default connect(mapStateToProps, { fetchProjectGate, setProjectGate })(QualityGate); diff --git a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/QualityProfiles.js b/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/QualityProfiles.js deleted file mode 100644 index 02ff3c3eac5..00000000000 --- a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/QualityProfiles.js +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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. - */ -// @flow -import React from 'react'; -import Helmet from 'react-helmet'; -import { connect } from 'react-redux'; -import Header from './Header'; -import Table from './Table'; -import { fetchProjectProfiles, setProjectProfile } from '../store/actions'; -import { - areThereCustomOrganizations, - getProjectAdminAllProfiles, - getProjectAdminProjectProfiles -} from '../../../store/rootReducer'; -import { translate } from '../../../helpers/l10n'; - -/*:: -type Props = { - allProfiles: Array<{}>, - component: { key: string, organization: string }, - customOrganizations: boolean, - fetchProjectProfiles: (componentKey: string, organization?: string) => void, - profiles: Array<{}>, - setProjectProfile: (string, string, string) => void -}; -*/ - -class QualityProfiles extends React.PureComponent { - /*:: props: Props; */ - - componentDidMount() { - if (this.props.customOrganizations) { - this.props.fetchProjectProfiles(this.props.component.key, this.props.component.organization); - } else { - this.props.fetchProjectProfiles(this.props.component.key); - } - } - - handleChangeProfile = (oldKey, newKey) => { - this.props.setProjectProfile(this.props.component.key, oldKey, newKey); - }; - - render() { - const { allProfiles, profiles } = this.props; - - return ( - <div className="page page-limited"> - <Helmet title={translate('project_quality_profiles.page')} /> - - <Header /> - - {profiles.length > 0 - ? <Table - allProfiles={allProfiles} - profiles={profiles} - onChangeProfile={this.handleChangeProfile} - /> - : <i className="spinner" />} - </div> - ); - } -} - -const mapStateToProps = (state, ownProps) => ({ - customOrganizations: areThereCustomOrganizations(state), - allProfiles: getProjectAdminAllProfiles(state), - profiles: getProjectAdminProjectProfiles(state, ownProps.location.query.id) -}); - -const mapDispatchToProps = { fetchProjectProfiles, setProjectProfile }; - -export default connect(mapStateToProps, mapDispatchToProps)(QualityProfiles); diff --git a/server/sonar-web/src/main/js/apps/project-admin/routes.js b/server/sonar-web/src/main/js/apps/project-admin/routes.js index a7b363c2c7b..f5efaa8e722 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/routes.js +++ b/server/sonar-web/src/main/js/apps/project-admin/routes.js @@ -20,15 +20,11 @@ import React from 'react'; import { Route } from 'react-router'; import Deletion from './deletion/Deletion'; -import QualityProfiles from './quality-profiles/QualityProfiles'; -import QualityGate from './quality-gate/QualityGate'; import Links from './links/Links'; import Key from './key/Key'; export default [ - <Route key="deletion" path="deletion" component={Deletion} />, - <Route key="quality_profiles" path="quality_profiles" component={QualityProfiles} />, - <Route key="quality_gate" path="quality_gate" component={QualityGate} />, - <Route key="links" path="links" component={Links} />, - <Route key="key" path="key" component={Key} /> + <Route key="deletion" path="project/deletion" component={Deletion} />, + <Route key="links" path="project/links" component={Links} />, + <Route key="key" path="project/key" component={Key} /> ]; diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/actions.js b/server/sonar-web/src/main/js/apps/project-admin/store/actions.js index 6701e8f07db..28f7473e260 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/store/actions.js +++ b/server/sonar-web/src/main/js/apps/project-admin/store/actions.js @@ -17,116 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { - searchQualityProfiles, - associateProject, - dissociateProject -} from '../../../api/quality-profiles'; -import { - fetchQualityGates, - getGateForProject, - associateGateWithProject, - dissociateGateWithProject -} from '../../../api/quality-gates'; import { getProjectLinks, createLink } from '../../../api/projectLinks'; import { getTree, changeKey as changeKeyApi } from '../../../api/components'; -import { addGlobalSuccessMessage } from '../../../store/globalMessages/duck'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { getProjectAdminProfileByKey } from '../../../store/rootReducer'; - -export const RECEIVE_PROFILES = 'projectAdmin/RECEIVE_PROFILES'; -export const receiveProfiles = profiles => ({ - type: RECEIVE_PROFILES, - profiles -}); - -export const RECEIVE_PROJECT_PROFILES = 'projectAdmin/RECEIVE_PROJECT_PROFILES'; -export const receiveProjectProfiles = (projectKey, profiles) => ({ - type: RECEIVE_PROJECT_PROFILES, - projectKey, - profiles -}); - -export const fetchProjectProfiles = (projectKey, organization) => dispatch => { - Promise.all([ - organization ? searchQualityProfiles({ organization }) : searchQualityProfiles(), - organization - ? searchQualityProfiles({ organization, projectKey }) - : searchQualityProfiles({ projectKey }) - ]).then(responses => { - const [allProfiles, projectProfiles] = responses; - dispatch(receiveProfiles(allProfiles)); - dispatch(receiveProjectProfiles(projectKey, projectProfiles)); - }); -}; - -export const SET_PROJECT_PROFILE = 'projectAdmin/SET_PROJECT_PROFILE'; -const setProjectProfileAction = (projectKey, oldProfileKey, newProfileKey) => ({ - type: SET_PROJECT_PROFILE, - projectKey, - oldProfileKey, - newProfileKey -}); - -export const setProjectProfile = (projectKey, oldKey, newKey) => (dispatch, getState) => { - const state = getState(); - const newProfile = getProjectAdminProfileByKey(state, newKey); - const request = newProfile.isDefault - ? dissociateProject(oldKey, projectKey) - : associateProject(newKey, projectKey); - - request.then(() => { - dispatch(setProjectProfileAction(projectKey, oldKey, newKey)); - dispatch( - addGlobalSuccessMessage( - translateWithParameters( - 'project_quality_profile.successfully_updated', - newProfile.languageName - ) - ) - ); - }); -}; - -export const RECEIVE_GATES = 'projectAdmin/RECEIVE_GATES'; -export const receiveGates = gates => ({ - type: RECEIVE_GATES, - gates -}); - -export const RECEIVE_PROJECT_GATE = 'projectAdmin/RECEIVE_PROJECT_GATE'; -export const receiveProjectGate = (projectKey, gate) => ({ - type: RECEIVE_PROJECT_GATE, - projectKey, - gate -}); - -export const fetchProjectGate = projectKey => dispatch => { - Promise.all([fetchQualityGates(), getGateForProject(projectKey)]).then(responses => { - const [allGates, projectGate] = responses; - dispatch(receiveGates(allGates)); - dispatch(receiveProjectGate(projectKey, projectGate)); - }); -}; - -export const SET_PROJECT_GATE = 'projectAdmin/SET_PROJECT_GATE'; -const setProjectGateAction = (projectKey, gateId) => ({ - type: SET_PROJECT_GATE, - projectKey, - gateId -}); - -export const setProjectGate = (projectKey, oldId, newId) => dispatch => { - const request = - newId != null - ? associateGateWithProject(newId, projectKey) - : dissociateGateWithProject(oldId, projectKey); - - request.then(() => { - dispatch(setProjectGateAction(projectKey, newId)); - dispatch(addGlobalSuccessMessage(translate('project_quality_gate.successfully_updated'))); - }); -}; export const RECEIVE_PROJECT_LINKS = 'projectAdmin/RECEIVE_PROJECT_LINKS'; export const receiveProjectLinks = (projectKey, links) => ({ diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/gateByProject.js b/server/sonar-web/src/main/js/apps/project-admin/store/gateByProject.js deleted file mode 100644 index 451f531d1ca..00000000000 --- a/server/sonar-web/src/main/js/apps/project-admin/store/gateByProject.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 { RECEIVE_PROJECT_GATE, SET_PROJECT_GATE } from './actions'; - -const gateByProject = (state = {}, action = {}) => { - if (action.type === RECEIVE_PROJECT_GATE) { - const gateId = action.gate ? action.gate.id : null; - return { ...state, [action.projectKey]: gateId }; - } - - if (action.type === SET_PROJECT_GATE) { - return { ...state, [action.projectKey]: action.gateId }; - } - - return state; -}; - -export default gateByProject; - -export const getProjectGate = (state, projectKey) => state[projectKey]; diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/profilesByProject.js b/server/sonar-web/src/main/js/apps/project-admin/store/profilesByProject.js deleted file mode 100644 index afc7ea0143b..00000000000 --- a/server/sonar-web/src/main/js/apps/project-admin/store/profilesByProject.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { without } from 'lodash'; -import { RECEIVE_PROJECT_PROFILES, SET_PROJECT_PROFILE } from './actions'; - -const profilesByProject = (state = {}, action = {}) => { - if (action.type === RECEIVE_PROJECT_PROFILES) { - const profileKeys = action.profiles.map(profile => profile.key); - return { ...state, [action.projectKey]: profileKeys }; - } - - if (action.type === SET_PROJECT_PROFILE) { - const profileKeys = state[action.projectKey]; - const nextProfileKeys = [...without(profileKeys, action.oldProfileKey), action.newProfileKey]; - return { ...state, [action.projectKey]: nextProfileKeys }; - } - - return state; -}; - -export default profilesByProject; - -export const getProfiles = (state, projectKey) => state[projectKey] || []; diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/rootReducer.js b/server/sonar-web/src/main/js/apps/project-admin/store/rootReducer.js index 4eb6dec8e0e..43ab5760cfd 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/store/rootReducer.js +++ b/server/sonar-web/src/main/js/apps/project-admin/store/rootReducer.js @@ -18,10 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { combineReducers } from 'redux'; -import profiles, { getProfile, getAllProfiles as nextGetAllProfiles } from './profiles'; -import profilesByProject, { getProfiles } from './profilesByProject'; -import gates, { getAllGates as nextGetAllGates, getGate } from './gates'; -import gateByProject, { getProjectGate as nextGetProjectGate } from './gateByProject'; import links, { getLink } from './links'; import linksByProject, { getLinks } from './linksByProject'; import components, { getComponentByKey as nextGetComponentByKey } from './components'; @@ -31,10 +27,6 @@ import globalMessages, { } from '../../../store/globalMessages/duck'; const rootReducer = combineReducers({ - profiles, - profilesByProject, - gates, - gateByProject, links, linksByProject, components, @@ -44,22 +36,6 @@ const rootReducer = combineReducers({ export default rootReducer; -export const getProfileByKey = (state, profileKey) => getProfile(state.profiles, profileKey); - -export const getAllProfiles = state => nextGetAllProfiles(state.profiles); - -export const getProjectProfiles = (state, projectKey) => - getProfiles(state.profilesByProject, projectKey).map(profileKey => - getProfileByKey(state, profileKey) - ); - -export const getGateById = (state, gateId) => getGate(state.gates, gateId); - -export const getAllGates = state => nextGetAllGates(state.gates); - -export const getProjectGate = (state, projectKey) => - getGateById(state, nextGetProjectGate(state.gateByProject, projectKey)); - export const getLinkById = (state, linkId) => getLink(state.links, linkId); export const getProjectLinks = (state, projectKey) => diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/App.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/App.tsx new file mode 100644 index 00000000000..51d42a94526 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/App.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 Helmet from 'react-helmet'; +import Header from './Header'; +import Form from './Form'; +import { + fetchQualityGates, + getGateForProject, + associateGateWithProject, + dissociateGateWithProject, + QualityGate +} from '../../api/quality-gates'; +import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage'; +import handleRequiredAuthorization from '../../app/utils/handleRequiredAuthorization'; +import { Component } from '../../app/types'; +import { translate } from '../../helpers/l10n'; + +interface Props { + component: Component; +} + +interface State { + allGates?: QualityGate[]; + gate?: QualityGate; + loading: boolean; +} + +export default class App extends React.PureComponent<Props> { + mounted: boolean; + state: State = { loading: true }; + + componentDidMount() { + this.mounted = true; + if (this.checkPermissions()) { + this.fetchQualityGates(); + } else { + handleRequiredAuthorization(); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + checkPermissions() { + const { configuration } = this.props.component; + const hasPermission = configuration && configuration.showQualityGates; + return !!hasPermission; + } + + fetchQualityGates() { + this.setState({ loading: true }); + Promise.all([fetchQualityGates(), getGateForProject(this.props.component.key)]).then( + ([allGates, gate]) => { + if (this.mounted) { + this.setState({ allGates, gate, loading: false }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + } + + handleChangeGate = (oldId: string | undefined, newId: string | undefined) => { + const { allGates } = this.state; + + if ((!oldId && !newId) || !allGates) { + return Promise.resolve(); + } + + const request = newId + ? associateGateWithProject(newId, this.props.component.key) + : dissociateGateWithProject(oldId!, this.props.component.key); + + return request.then(() => { + if (this.mounted) { + addGlobalSuccessMessage(translate('project_quality_gate.successfully_updated')); + if (newId) { + const newGate = allGates.find(gate => gate.id === newId); + if (newGate) { + this.setState({ gate: newGate }); + } + } else { + this.setState({ gate: undefined }); + } + } + }); + }; + + render() { + if (!this.checkPermissions()) { + return null; + } + + const { allGates, gate, loading } = this.state; + + return ( + <div id="project-quality-gate" className="page page-limited"> + <Helmet title={translate('project_quality_gate.page')} /> + <Header /> + {loading + ? <i className="spinner" /> + : allGates && <Form allGates={allGates} gate={gate} onChange={this.handleChangeGate} />} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/project-admin/quality-gate/Form.js b/server/sonar-web/src/main/js/apps/projectQualityGate/Form.tsx index 69b5fdc2126..2e0e502db14 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/quality-gate/Form.js +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/Form.tsx @@ -17,39 +17,47 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import Select from 'react-select'; +import * as React from 'react'; +import * as Select from 'react-select'; import { some } from 'lodash'; -import { translate } from '../../../helpers/l10n'; +import { QualityGate } from '../../api/quality-gates'; +import { translate } from '../../helpers/l10n'; -export default class Form extends React.PureComponent { - static propTypes = { - allGates: PropTypes.array.isRequired, - gate: PropTypes.object, - onChange: PropTypes.func.isRequired - }; +interface Props { + allGates: QualityGate[]; + gate?: QualityGate; + onChange: (oldGate: string | undefined, newGate: string) => Promise<void>; +} - state = { - loading: false - }; +interface State { + loading: boolean; +} - componentWillMount() { - this.handleChange = this.handleChange.bind(this); - this.renderGateName = this.renderGateName.bind(this); - } +interface Option { + isDefault?: boolean; + label: string; + value: string; +} - componentDidUpdate(prevProps) { - if (prevProps.gate !== this.props.gate) { - this.stopLoading(); - } +export default class Form extends React.PureComponent<Props, State> { + mounted: boolean; + state: State = { loading: false }; + + componentDidMount() { + this.mounted = true; } - stopLoading() { - this.setState({ loading: false }); + componentWillUnmount() { + this.mounted = false; } - handleChange(option) { + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + handleChange = (option: { value: string }) => { const { gate } = this.props; const isSet = gate == null && option.value != null; @@ -59,30 +67,34 @@ export default class Form extends React.PureComponent { if (hasChanged) { this.setState({ loading: true }); - this.props.onChange(gate && gate.id, option.value); + this.props.onChange(gate && gate.id, option.value).then(this.stopLoading, this.stopLoading); } - } + }; - renderGateName(gateOption) { - if (gateOption.isDefault) { + renderGateName = (option: { isDefault?: boolean; label: string }) => { + if (option.isDefault) { return ( <span> <strong> {translate('default')} </strong> {': '} - {gateOption.label} + {option.label} </span> ); } - return gateOption.label; - } + return ( + <span> + {option.label} + </span> + ); + }; renderSelect() { const { gate, allGates } = this.props; - const options = allGates.map(gate => ({ + const options: Option[] = allGates.map(gate => ({ value: gate.id, label: gate.name, isDefault: gate.isDefault @@ -90,23 +102,20 @@ export default class Form extends React.PureComponent { const hasDefault = some(allGates, gate => gate.isDefault); if (!hasDefault) { - options.unshift({ - value: null, - label: translate('none') - }); + options.unshift({ value: '', label: translate('none') }); } return ( <Select - options={options} - valueRenderer={this.renderGateName} - optionRenderer={this.renderGateName} - value={gate && gate.id} clearable={false} + disabled={this.state.loading} + onChange={this.handleChange} + optionRenderer={this.renderGateName} + options={options} placeholder={translate('none')} style={{ width: 300 }} - disabled={this.state.loading} - onChange={this.handleChange.bind(this)} + value={gate && gate.id} + valueRenderer={this.renderGateName} /> ); } diff --git a/server/sonar-web/src/main/js/apps/project-admin/quality-gate/Header.js b/server/sonar-web/src/main/js/apps/projectQualityGate/Header.tsx index fb3dd5ff053..4570192a235 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/quality-gate/Header.js +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/Header.tsx @@ -17,8 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import { translate } from '../../../helpers/l10n'; +import * as React from 'react'; +import { translate } from '../../helpers/l10n'; export default function Header() { return ( diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/App-test.tsx new file mode 100644 index 00000000000..ac0d97d33d7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/App-test.tsx @@ -0,0 +1,123 @@ +/* + * 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/quality-gates', () => ({ + associateGateWithProject: jest.fn(() => Promise.resolve()), + dissociateGateWithProject: jest.fn(() => Promise.resolve()), + fetchQualityGates: jest.fn(), + getGateForProject: jest.fn() +})); + +jest.mock('../../../app/utils/addGlobalSuccessMessage', () => ({ + default: jest.fn() +})); + +jest.mock('../../../app/utils/handleRequiredAuthorization', () => ({ + default: jest.fn() +})); + +import * as React from 'react'; +import { mount } from 'enzyme'; +import App from '../App'; + +const associateGateWithProject = require('../../../api/quality-gates') + .associateGateWithProject as jest.Mock<any>; + +const dissociateGateWithProject = require('../../../api/quality-gates') + .dissociateGateWithProject as jest.Mock<any>; + +const fetchQualityGates = require('../../../api/quality-gates').fetchQualityGates as jest.Mock<any>; + +const getGateForProject = require('../../../api/quality-gates').getGateForProject as jest.Mock<any>; + +const addGlobalSuccessMessage = require('../../../app/utils/addGlobalSuccessMessage') + .default as jest.Mock<any>; + +const handleRequiredAuthorization = require('../../../app/utils/handleRequiredAuthorization') + .default as jest.Mock<any>; + +const component = { + analysisDate: '', + breadcrumbs: [], + configuration: { showQualityGates: true }, + key: 'component', + name: 'component', + organization: 'org', + qualifier: 'TRK', + version: '0.0.1' +}; + +beforeEach(() => { + associateGateWithProject.mockClear(); + dissociateGateWithProject.mockClear(); + addGlobalSuccessMessage.mockClear(); +}); + +it('checks permissions', () => { + handleRequiredAuthorization.mockClear(); + mount(<App component={{ ...component, configuration: undefined }} />); + expect(handleRequiredAuthorization).toBeCalled(); +}); + +it('fetches quality gates', () => { + fetchQualityGates.mockClear(); + getGateForProject.mockClear(); + mount(<App component={component} />); + expect(fetchQualityGates).toBeCalledWith(); + expect(getGateForProject).toBeCalledWith('component'); +}); + +it('changes quality gate from custom to default', () => { + const gate = randomGate('foo'); + const allGates = [gate, randomGate('bar', true), randomGate('baz')]; + const wrapper = mountRender(allGates, gate); + wrapper.find('Form').prop<Function>('onChange')('foo', 'bar'); + expect(associateGateWithProject).toBeCalledWith('bar', 'component'); +}); + +it('changes quality gate from custom to custom', () => { + const allGates = [randomGate('foo'), randomGate('bar', true), randomGate('baz')]; + const wrapper = mountRender(allGates, randomGate('foo')); + wrapper.find('Form').prop<Function>('onChange')('foo', 'baz'); + expect(associateGateWithProject).toBeCalledWith('baz', 'component'); +}); + +it('changes quality gate from custom to none', () => { + const allGates = [randomGate('foo'), randomGate('bar'), randomGate('baz')]; + const wrapper = mountRender(allGates, randomGate('foo')); + wrapper.find('Form').prop<Function>('onChange')('foo', undefined); + expect(dissociateGateWithProject).toBeCalledWith('foo', 'component'); +}); + +it('changes quality gate from none to custom', () => { + const allGates = [randomGate('foo'), randomGate('bar'), randomGate('baz')]; + const wrapper = mountRender(allGates); + wrapper.find('Form').prop<Function>('onChange')(undefined, 'baz'); + expect(associateGateWithProject).toBeCalledWith('baz', 'component'); +}); + +function randomGate(id: string, isDefault = false) { + return { id, isDefault, name: id }; +} + +function mountRender(allGates: any[], gate?: any) { + const wrapper = mount(<App component={component} />); + wrapper.setState({ allGates, loading: false, gate }); + return wrapper; +} diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Form-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Form-test.tsx new file mode 100644 index 00000000000..f06a73eea6d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Form-test.tsx @@ -0,0 +1,48 @@ +/* + * 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 Form from '../Form'; + +it('renders', () => { + const foo = randomGate('foo'); + const allGates = [foo, randomGate('bar')]; + expect(shallow(<Form allGates={allGates} gate={foo} onChange={jest.fn()} />)).toMatchSnapshot(); +}); + +it('changes quality gate', () => { + const allGates = [randomGate('foo'), randomGate('bar')]; + const onChange = jest.fn(() => Promise.resolve()); + const wrapper = shallow(<Form allGates={allGates} onChange={onChange} />); + + wrapper.find('Select').prop<Function>('onChange')({ value: 'bar' }); + expect(onChange).lastCalledWith(undefined, 'bar'); + + wrapper.setProps({ gate: randomGate('foo') }); + wrapper.find('Select').prop<Function>('onChange')({ value: 'bar' }); + expect(onChange).lastCalledWith('foo', 'bar'); +}); + +function randomGate(id: string) { + return { + id, + name: id + }; +} diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Header-test.tsx new file mode 100644 index 00000000000..adeee211e56 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/Header-test.tsx @@ -0,0 +1,26 @@ +/* + * 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 from '../Header'; + +it('renders', () => { + expect(shallow(<Header />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Form-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Form-test.tsx.snap new file mode 100644 index 00000000000..fcbb0ecfff8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Form-test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders 1`] = ` +<div> + <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]} + optionRenderer={[Function]} + options={ + Array [ + Object { + "label": "none", + "value": "", + }, + Object { + "isDefault": undefined, + "label": "foo", + "value": "foo", + }, + Object { + "isDefault": undefined, + "label": "bar", + "value": "bar", + }, + ] + } + pageSize={5} + placeholder="none" + required={false} + scrollMenuIntoView={true} + searchable={true} + simpleValue={false} + style={ + Object { + "width": 300, + } + } + tabSelectsValue={true} + value="foo" + valueComponent={[Function]} + valueKey="value" + valueRenderer={[Function]} + /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Header-test.tsx.snap new file mode 100644 index 00000000000..eaade8b5468 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/__tests__/__snapshots__/Header-test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders 1`] = ` +<header + className="page-header" +> + <h1 + className="page-title" + > + project_quality_gate.page + </h1> + <div + className="page-description" + > + project_quality_gate.page.description + </div> +</header> +`; diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/gates.js b/server/sonar-web/src/main/js/apps/projectQualityGate/routes.ts index d68aae11508..e342e0f4070 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/store/gates.js +++ b/server/sonar-web/src/main/js/apps/projectQualityGate/routes.ts @@ -17,20 +17,14 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { keyBy, values } from 'lodash'; -import { RECEIVE_GATES } from './actions'; +import { RouterState, IndexRouteProps } from 'react-router'; -const gates = (state = {}, action = {}) => { - if (action.type === RECEIVE_GATES) { - const newGatesById = keyBy(action.gates, 'id'); - return { ...state, ...newGatesById }; +const routes = [ + { + getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) { + import('./App').then(i => callback(null, { component: i.default })); + } } +]; - return state; -}; - -export default gates; - -export const getAllGates = state => values(state); - -export const getGate = (state, id) => state[id]; +export default routes; diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/App.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/App.tsx new file mode 100644 index 00000000000..ccc85320a62 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/App.tsx @@ -0,0 +1,139 @@ +/* + * 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 Header from './Header'; +import Table from './Table'; +import { + associateProject, + dissociateProject, + searchQualityProfiles, + Profile +} from '../../api/quality-profiles'; +import { Component } from '../../app/types'; +import addGlobalSuccessMessage from '../../app/utils/addGlobalSuccessMessage'; +import handleRequiredAuthorization from '../../app/utils/handleRequiredAuthorization'; +import { translate, translateWithParameters } from '../../helpers/l10n'; + +interface Props { + component: Component; + customOrganizations: boolean; +} + +interface State { + allProfiles?: Profile[]; + loading: boolean; + profiles?: Profile[]; +} + +export default class QualityProfiles extends React.PureComponent<Props, State> { + mounted: boolean; + state: State = { loading: true }; + + componentDidMount() { + this.mounted = true; + if (this.checkPermissions()) { + this.fetchProfiles(); + } else { + handleRequiredAuthorization(); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + checkPermissions() { + const { configuration } = this.props.component; + const hasPermission = configuration && configuration.showQualityProfiles; + return !!hasPermission; + } + + fetchProfiles() { + const { component } = this.props; + const organization = this.props.customOrganizations ? component.organization : undefined; + Promise.all([ + searchQualityProfiles({ organization }), + searchQualityProfiles({ organization, projectKey: component.key }) + ]).then( + ([allProfiles, profiles]) => { + if (this.mounted) { + this.setState({ loading: false, allProfiles, profiles }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + } + + handleChangeProfile = (oldKey: string, newKey: string) => { + const { component } = this.props; + const { allProfiles, profiles } = this.state; + const newProfile = allProfiles && allProfiles.find(profile => profile.key === newKey); + const request = + newProfile && newProfile.isDefault + ? dissociateProject(oldKey, component.key) + : associateProject(newKey, component.key); + + return request.then(() => { + if (this.mounted && profiles && newProfile) { + // remove old profile, add new one + const nextProfiles = [...profiles.filter(profile => profile.key !== oldKey), newProfile]; + this.setState({ profiles: nextProfiles }); + + addGlobalSuccessMessage( + translateWithParameters( + 'project_quality_profile.successfully_updated', + newProfile.languageName + ) + ); + } + }); + }; + + render() { + if (!this.checkPermissions()) { + return null; + } + + const { allProfiles, loading, profiles } = this.state; + + return ( + <div className="page page-limited"> + <Helmet title={translate('project_quality_profiles.page')} /> + + <Header /> + + {loading + ? <i className="spinner" /> + : allProfiles && + profiles && + <Table + allProfiles={allProfiles} + profiles={profiles} + onChangeProfile={this.handleChangeProfile} + />} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/Header.js b/server/sonar-web/src/main/js/apps/projectQualityProfiles/Header.tsx index 0ecebfdd8cb..a758189099d 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/Header.js +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/Header.tsx @@ -17,8 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import { translate } from '../../../helpers/l10n'; +import * as React from 'react'; +import { translate } from '../../helpers/l10n'; export default function Header() { return ( diff --git a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/ProfileRow.js b/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProfileRow.tsx index 8a9e02e717f..0679b4463b4 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/ProfileRow.js +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/ProfileRow.tsx @@ -17,36 +17,49 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import Select from 'react-select'; -import { translate } from '../../../helpers/l10n'; +import * as React from 'react'; +import * as Select from 'react-select'; +import { Profile } from '../../api/quality-profiles'; +import { translate } from '../../helpers/l10n'; -export default class ProfileRow extends React.PureComponent { - static propTypes = { - profile: PropTypes.object.isRequired, - possibleProfiles: PropTypes.array.isRequired, - onChangeProfile: PropTypes.func.isRequired - }; +interface Props { + onChangeProfile: (oldProfile: string, newProfile: string) => Promise<void>; + possibleProfiles: Profile[]; + profile: Profile; +} - state = { - loading: false - }; +interface State { + loading: boolean; +} + +export default class ProfileRow extends React.PureComponent<Props, State> { + mounted: boolean; + state: State = { loading: false }; + + componentDidMount() { + this.mounted = true; + } - componentWillUpdate(nextProps) { - if (nextProps.profile !== this.props.profile) { + componentWillUnmount() { + this.mounted = false; + } + + stopLoading = () => { + if (this.mounted) { this.setState({ loading: false }); } - } + }; - handleChange(option) { + handleChange = (option: { value: string }) => { if (this.props.profile.key !== option.value) { this.setState({ loading: true }); - this.props.onChangeProfile(this.props.profile.key, option.value); + this.props + .onChangeProfile(this.props.profile.key, option.value) + .then(this.stopLoading, this.stopLoading); } - } + }; - renderProfileName(profileOption) { + renderProfileName = (profileOption: { isDefault: boolean; label: string }) => { if (profileOption.isDefault) { return ( <span> @@ -59,8 +72,12 @@ export default class ProfileRow extends React.PureComponent { ); } - return profileOption.label; - } + return ( + <span> + {profileOption.label} + </span> + ); + }; renderProfileSelect() { const { profile, possibleProfiles } = this.props; @@ -73,14 +90,14 @@ export default class ProfileRow extends React.PureComponent { return ( <Select + clearable={false} + disabled={this.state.loading} + onChange={this.handleChange} + optionRenderer={this.renderProfileName} options={options} + style={{ width: 300 }} valueRenderer={this.renderProfileName} - optionRenderer={this.renderProfileName} value={profile.key} - clearable={false} - style={{ width: 300 }} - disabled={this.state.loading} - onChange={this.handleChange.bind(this)} /> ); } diff --git a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/Table.js b/server/sonar-web/src/main/js/apps/projectQualityProfiles/Table.tsx index 24a099134b2..43fca05f7ae 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/quality-profiles/Table.js +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/Table.tsx @@ -17,22 +17,34 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import * as React from 'react'; import { groupBy, orderBy } from 'lodash'; import ProfileRow from './ProfileRow'; -import { translate } from '../../../helpers/l10n'; +import { Profile } from '../../api/quality-profiles'; +import { translate } from '../../helpers/l10n'; -export default class Table extends React.PureComponent { - static propTypes = { - allProfiles: PropTypes.array.isRequired, - profiles: PropTypes.array.isRequired, - onChangeProfile: PropTypes.func.isRequired - }; +interface Props { + allProfiles: Profile[]; + profiles: Profile[]; + onChangeProfile: (oldProfile: string, newProfile: string) => Promise<void>; +} + +export default function Table(props: Props) { + const profilesByLanguage = groupBy(props.allProfiles, 'language'); + const orderedProfiles = orderBy(props.profiles, 'languageName'); + + // set key to language to avoid destroying of component + const profileRows = orderedProfiles.map(profile => + <ProfileRow + key={profile.language} + profile={profile} + possibleProfiles={profilesByLanguage[profile.language]} + onChangeProfile={props.onChangeProfile} + /> + ); - renderHeader() { - // keep one empty cell for the spinner - return ( + return ( + <table className="data zebra"> <thead> <tr> <th className="thin nowrap"> @@ -41,33 +53,13 @@ export default class Table extends React.PureComponent { <th className="thin nowrap"> {translate('quality_profile')} </th> + {/* keep one empty cell for the spinner */} <th> </th> </tr> </thead> - ); - } - - render() { - const profilesByLanguage = groupBy(this.props.allProfiles, 'language'); - const orderedProfiles = orderBy(this.props.profiles, 'languageName'); - - // set key to language to avoid destroying of component - const profileRows = orderedProfiles.map(profile => - <ProfileRow - key={profile.language} - profile={profile} - possibleProfiles={profilesByLanguage[profile.language]} - onChangeProfile={this.props.onChangeProfile} - /> - ); - - return ( - <table className="data zebra"> - {this.renderHeader()} - <tbody> - {profileRows} - </tbody> - </table> - ); - } + <tbody> + {profileRows} + </tbody> + </table> + ); } diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/App-test.tsx new file mode 100644 index 00000000000..ab1f2ec7071 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/App-test.tsx @@ -0,0 +1,122 @@ +/* + * 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/quality-profiles', () => ({ + associateProject: jest.fn(() => Promise.resolve()), + dissociateProject: jest.fn(() => Promise.resolve()), + searchQualityProfiles: jest.fn() +})); + +jest.mock('../../../app/utils/addGlobalSuccessMessage', () => ({ + default: jest.fn() +})); + +jest.mock('../../../app/utils/handleRequiredAuthorization', () => ({ + default: jest.fn() +})); + +import * as React from 'react'; +import { mount } from 'enzyme'; +import App from '../App'; + +const associateProject = require('../../../api/quality-profiles').associateProject as jest.Mock< + any +>; + +const dissociateProject = require('../../../api/quality-profiles') + .dissociateProject as jest.Mock<any>; + +const searchQualityProfiles = require('../../../api/quality-profiles') + .searchQualityProfiles as jest.Mock<any>; + +const addGlobalSuccessMessage = require('../../../app/utils/addGlobalSuccessMessage') + .default as jest.Mock<any>; + +const handleRequiredAuthorization = require('../../../app/utils/handleRequiredAuthorization') + .default as jest.Mock<any>; + +const component = { + analysisDate: '', + breadcrumbs: [], + configuration: { showQualityProfiles: true }, + key: 'foo', + name: 'foo', + organization: 'org', + qualifier: 'TRK', + version: '0.0.1' +}; + +it('checks permissions', () => { + handleRequiredAuthorization.mockClear(); + mount(<App component={{ ...component, configuration: undefined }} customOrganizations={false} />); + expect(handleRequiredAuthorization).toBeCalled(); +}); + +it('fetches profiles', () => { + searchQualityProfiles.mockClear(); + mount(<App component={component} customOrganizations={false} />); + expect(searchQualityProfiles.mock.calls).toHaveLength(2); + expect(searchQualityProfiles).toBeCalledWith({ organization: undefined }); + expect(searchQualityProfiles).toBeCalledWith({ organization: undefined, projectKey: 'foo' }); +}); + +it('fetches profiles with organization', () => { + searchQualityProfiles.mockClear(); + mount(<App component={component} customOrganizations={true} />); + expect(searchQualityProfiles.mock.calls).toHaveLength(2); + expect(searchQualityProfiles).toBeCalledWith({ organization: 'org' }); + expect(searchQualityProfiles).toBeCalledWith({ organization: 'org', projectKey: 'foo' }); +}); + +it('changes profile', () => { + associateProject.mockClear(); + dissociateProject.mockClear(); + addGlobalSuccessMessage.mockClear(); + const wrapper = mount(<App component={component} customOrganizations={false} />); + + const fooJava = randomProfile('foo-java', 'java'); + const fooJs = randomProfile('foo-js', 'js'); + const allProfiles = [ + fooJava, + randomProfile('bar-java', 'java'), + randomProfile('baz-java', 'java', true), + fooJs + ]; + const profiles = [fooJava, fooJs]; + wrapper.setState({ allProfiles, loading: false, profiles }); + + wrapper.find('Table').prop<Function>('onChangeProfile')('foo-java', 'bar-java'); + expect(associateProject).toBeCalledWith('bar-java', 'foo'); + + wrapper.find('Table').prop<Function>('onChangeProfile')('foo-java', 'baz-java'); + expect(dissociateProject).toBeCalledWith('foo-java', 'foo'); +}); + +function randomProfile(key: string, language: string, isDefault = false) { + return { + activeRuleCount: 17, + activeDeprecatedRuleCount: 0, + isDefault, + key, + name: key, + language: language, + languageName: language, + organization: 'org' + }; +} diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Header-test.tsx new file mode 100644 index 00000000000..adeee211e56 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Header-test.tsx @@ -0,0 +1,26 @@ +/* + * 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 from '../Header'; + +it('renders', () => { + expect(shallow(<Header />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProfileRow-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProfileRow-test.tsx new file mode 100644 index 00000000000..cec351e32db --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/ProfileRow-test.tsx @@ -0,0 +1,65 @@ +/* + * 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 ProfileRow from '../ProfileRow'; +import { doAsync } from '../../../helpers/testUtils'; + +it('renders', () => { + expect( + shallow( + <ProfileRow + onChangeProfile={jest.fn()} + possibleProfiles={[randomProfile('bar'), randomProfile('baz')]} + profile={randomProfile('foo')} + /> + ) + ).toMatchSnapshot(); +}); + +it('changes profile', () => { + const onChangeProfile = jest.fn(() => Promise.resolve()); + const wrapper = shallow( + <ProfileRow + onChangeProfile={onChangeProfile} + possibleProfiles={[randomProfile('bar'), randomProfile('baz')]} + profile={randomProfile('foo')} + /> + ); + (wrapper.instance() as ProfileRow).mounted = true; + wrapper.find('Select').prop<Function>('onChange')({ value: 'baz' }); + expect(onChangeProfile).toBeCalledWith('foo', 'baz'); + expect(wrapper.state().loading).toBeTruthy(); + return doAsync().then(() => { + expect(wrapper.state().loading).toBeFalsy(); + }); +}); + +function randomProfile(key: string) { + return { + activeRuleCount: 17, + activeDeprecatedRuleCount: 0, + key, + name: key, + language: 'xoo', + languageName: 'xoo', + organization: 'org' + }; +} diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Table-test.tsx b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Table-test.tsx new file mode 100644 index 00000000000..84e70359fab --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/Table-test.tsx @@ -0,0 +1,49 @@ +/* + * 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 Table from '../Table'; + +it('renders', () => { + const fooJava = randomProfile('foo-java', 'java'); + const fooJs = randomProfile('foo-js', 'js'); + const allProfiles = [ + fooJava, + randomProfile('bar-java', 'java'), + randomProfile('baz-java', 'java'), + fooJs + ]; + const profiles = [fooJava, fooJs]; + expect( + shallow(<Table allProfiles={allProfiles} onChangeProfile={jest.fn()} profiles={profiles} />) + ).toMatchSnapshot(); +}); + +function randomProfile(key: string, language: string) { + return { + activeRuleCount: 17, + activeDeprecatedRuleCount: 0, + key, + name: key, + language: language, + languageName: language, + organization: 'org' + }; +} diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Header-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Header-test.tsx.snap new file mode 100644 index 00000000000..4f0e35f4a2f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Header-test.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders 1`] = ` +<header + className="page-header" +> + <h1 + className="page-title" + > + project_quality_profiles.page + </h1> + <div + className="page-description" + > + project_quality_profiles.page.description + </div> +</header> +`; diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProfileRow-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProfileRow-test.tsx.snap new file mode 100644 index 00000000000..541a117c889 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/ProfileRow-test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders 1`] = ` +<tr + data-key="xoo" +> + <td + className="thin nowrap" + > + xoo + </td> + <td + className="thin nowrap" + > + <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]} + optionRenderer={[Function]} + options={ + Array [ + Object { + "isDefault": undefined, + "label": "bar", + "value": "bar", + }, + Object { + "isDefault": undefined, + "label": "baz", + "value": "baz", + }, + ] + } + pageSize={5} + placeholder="Select..." + required={false} + scrollMenuIntoView={true} + searchable={true} + simpleValue={false} + style={ + Object { + "width": 300, + } + } + tabSelectsValue={true} + value="foo" + valueComponent={[Function]} + valueKey="value" + valueRenderer={[Function]} + /> + </td> + <td /> +</tr> +`; diff --git a/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Table-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Table-test.tsx.snap new file mode 100644 index 00000000000..b4b991157eb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/__tests__/__snapshots__/Table-test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders 1`] = ` +<table + className="data zebra" +> + <thead> + <tr> + <th + className="thin nowrap" + > + language + </th> + <th + className="thin nowrap" + > + quality_profile + </th> + <th> + + </th> + </tr> + </thead> + <tbody> + <ProfileRow + onChangeProfile={[Function]} + possibleProfiles={ + Array [ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 17, + "key": "foo-java", + "language": "java", + "languageName": "java", + "name": "foo-java", + "organization": "org", + }, + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 17, + "key": "bar-java", + "language": "java", + "languageName": "java", + "name": "bar-java", + "organization": "org", + }, + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 17, + "key": "baz-java", + "language": "java", + "languageName": "java", + "name": "baz-java", + "organization": "org", + }, + ] + } + profile={ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 17, + "key": "foo-java", + "language": "java", + "languageName": "java", + "name": "foo-java", + "organization": "org", + } + } + /> + <ProfileRow + onChangeProfile={[Function]} + possibleProfiles={ + Array [ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 17, + "key": "foo-js", + "language": "js", + "languageName": "js", + "name": "foo-js", + "organization": "org", + }, + ] + } + profile={ + Object { + "activeDeprecatedRuleCount": 0, + "activeRuleCount": 17, + "key": "foo-js", + "language": "js", + "languageName": "js", + "name": "foo-js", + "organization": "org", + } + } + /> + </tbody> +</table> +`; diff --git a/server/sonar-web/src/main/js/apps/project-admin/store/profiles.js b/server/sonar-web/src/main/js/apps/projectQualityProfiles/routes.ts index ed6a8345d0e..e342e0f4070 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/store/profiles.js +++ b/server/sonar-web/src/main/js/apps/projectQualityProfiles/routes.ts @@ -17,20 +17,14 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { keyBy, values } from 'lodash'; -import { RECEIVE_PROFILES } from './actions'; +import { RouterState, IndexRouteProps } from 'react-router'; -const profiles = (state = {}, action = {}) => { - if (action.type === RECEIVE_PROFILES) { - const newProfilesByKey = keyBy(action.profiles, 'key'); - return { ...state, ...newProfilesByKey }; +const routes = [ + { + getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) { + import('./App').then(i => callback(null, { component: i.default })); + } } +]; - return state; -}; - -export default profiles; - -export const getAllProfiles = state => values(state); - -export const getProfile = (state, key) => state[key]; +export default routes; diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileRules-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileRules-test.tsx index 91e5ed96cfc..df6462d30b0 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileRules-test.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/ProfileRules-test.tsx @@ -25,18 +25,19 @@ import * as apiRules from '../../../../api/rules'; import * as apiQP from '../../../../api/quality-profiles'; const PROFILE = { - key: 'foo', - name: 'Foo', + activeRuleCount: 68, + activeDeprecatedRuleCount: 0, + childrenCount: 0, + depth: 0, isBuiltIn: false, isDefault: false, isInherited: false, + key: 'foo', language: 'java', languageName: 'Java', - activeRuleCount: 68, - activeDeprecatedRuleCount: 0, - rulesUpdatedAt: '2017-06-28T12:58:44+0000', - depth: 0, - childrenCount: 0 + name: 'Foo', + organization: 'org', + rulesUpdatedAt: '2017-06-28T12:58:44+0000' }; const apiResponseAll = { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx index 5252d1c1840..957fa4790a2 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx @@ -60,16 +60,17 @@ export default function EvolutionStagnant(props: Props) { {profile.name} </ProfileLink> </div> - <DateFormatter date={profile.rulesUpdatedAt} long={true}> - {formattedDate => - <div className="note"> - {translateWithParameters( - 'quality_profiles.x_updated_on_y', - profile.languageName, - formattedDate - )} - </div>} - </DateFormatter> + {profile.rulesUpdatedAt && + <DateFormatter date={profile.rulesUpdatedAt} long={true}> + {formattedDate => + <div className="note"> + {translateWithParameters( + 'quality_profiles.x_updated_on_y', + profile.languageName, + formattedDate + )} + </div>} + </DateFormatter>} </li> )} </ul> diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/types.ts b/server/sonar-web/src/main/js/apps/quality-profiles/types.ts index 415ab3c03bb..88758d46591 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/types.ts +++ b/server/sonar-web/src/main/js/apps/quality-profiles/types.ts @@ -17,22 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -export interface Profile { - key: string; - name: string; - isBuiltIn: boolean; - isDefault: boolean; - isInherited: boolean; - language: string; - languageName: string; - activeRuleCount: number; - activeDeprecatedRuleCount: number; - projectCount?: number; - parentKey?: string; - parentName?: string; - userUpdatedAt?: string; - lastUsed?: string; - rulesUpdatedAt: string; +import { Profile as BaseProfile } from '../../api/quality-profiles'; + +export interface Profile extends BaseProfile { depth: number; childrenCount: number; } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/utils.ts b/server/sonar-web/src/main/js/apps/quality-profiles/utils.ts index 6d5c9db9d9f..a23ab59a6c6 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/utils.ts +++ b/server/sonar-web/src/main/js/apps/quality-profiles/utils.ts @@ -18,20 +18,21 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { sortBy } from 'lodash'; +import { Profile as BaseProfile } from '../../api/quality-profiles'; import { differenceInYears, isValidDate, parseDate } from '../../helpers/dates'; import { Profile } from './types'; -export function sortProfiles(profiles: Profile[]) { +export function sortProfiles(profiles: BaseProfile[]): Profile[] { const result: Profile[] = []; const sorted = sortBy(profiles, 'name'); - function retrieveChildren(parent: Profile | null) { + function retrieveChildren(parent: BaseProfile | null) { return sorted.filter( p => (parent == null && p.parentKey == null) || (parent != null && p.parentKey === parent.key) ); } - function putProfile(profile: Profile | null = null, depth: number = 1) { + function putProfile(profile: BaseProfile | null = null, depth: number = 1) { const children = retrieveChildren(profile); if (profile != null) { |