diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2018-12-19 15:28:21 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-01-18 20:21:02 +0100 |
commit | 5c110414621fcfb2db1083f76ff09c4b6364757f (patch) | |
tree | e4093ebbf1a8402f4e60c4c1eb8839b2a70a5f4a | |
parent | 0d3ba8c8aa4c29e46e9a687f42590ffda3fb348f (diff) | |
download | sonarqube-5c110414621fcfb2db1083f76ff09c4b6364757f.tar.gz sonarqube-5c110414621fcfb2db1083f76ff09c4b6364757f.zip |
SONARCLOUD-329 Create downgrade reason page
14 files changed, 553 insertions, 16 deletions
diff --git a/server/sonar-web/src/main/js/api/billing.ts b/server/sonar-web/src/main/js/api/billing.ts index b3634ee0430..62a8fe17a2e 100644 --- a/server/sonar-web/src/main/js/api/billing.ts +++ b/server/sonar-web/src/main/js/api/billing.ts @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { getJSON } from '../helpers/request'; +import { getJSON, post } from '../helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; export function getSubscriptionPlans(): Promise<T.SubscriptionPlan[]> { @@ -26,3 +26,11 @@ export function getSubscriptionPlans(): Promise<T.SubscriptionPlan[]> { throwGlobalError ); } + +export function giveDowngradeFeedback(data: { + organization: string; + feedback: string; + additionalFeedback?: string; +}): Promise<void> { + return post('/api/billing/send_feedback', data); +} diff --git a/server/sonar-web/src/main/js/app/styles/components/boxed-group.css b/server/sonar-web/src/main/js/app/styles/components/boxed-group.css index f1feb720ab8..6912e432353 100644 --- a/server/sonar-web/src/main/js/app/styles/components/boxed-group.css +++ b/server/sonar-web/src/main/js/app/styles/components/boxed-group.css @@ -24,6 +24,12 @@ background-color: #fff; } +.boxed-group-centered { + margin-left: auto; + margin-right: auto; + max-width: 500px; +} + .boxed-group > h2 { line-height: var(--controlHeight); padding: calc(2 * var(--gridSize)) 20px 0; diff --git a/server/sonar-web/src/main/js/app/styles/init/forms.css b/server/sonar-web/src/main/js/app/styles/init/forms.css index 2db2d3eac0f..e270290c3a5 100644 --- a/server/sonar-web/src/main/js/app/styles/init/forms.css +++ b/server/sonar-web/src/main/js/app/styles/init/forms.css @@ -69,7 +69,8 @@ select:invalid { outline: none; } -input::placeholder { +input::placeholder, +textarea::placeholder { color: var(--secondFontColor); } diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx index dca733a3587..9af3dc05144 100644 --- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx +++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx @@ -183,12 +183,20 @@ export default function startReactApp( <Route path="issues" component={IssuesPageSelector} /> <RouteWithChildRoutes path="onboarding" childRoutes={onboardingRoutes} /> {isSonarCloud() && ( - <Route - path="create-organization" - component={lazyLoad(() => - import('../../apps/create/organization/CreateOrganization') - )} - /> + <> + <Route + path="create-organization" + component={lazyLoad(() => + import('../../apps/create/organization/CreateOrganization') + )} + /> + <Route + path="feedback/downgrade" + component={lazyLoad(() => + import('../../apps/feedback/downgrade/DowngradeFeedback') + )} + /> + </> )} <RouteWithChildRoutes path="organizations" childRoutes={organizationsRoutes} /> <RouteWithChildRoutes path="projects" childRoutes={projectsRoutes} /> diff --git a/server/sonar-web/src/main/js/apps/feedback/downgrade/DowngradeFeedback.css b/server/sonar-web/src/main/js/apps/feedback/downgrade/DowngradeFeedback.css new file mode 100644 index 00000000000..c8432987d42 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/feedback/downgrade/DowngradeFeedback.css @@ -0,0 +1,80 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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. + */ +.billing-downgrade-feedback > .alert { + max-width: 400px; + margin: 30px auto 50px auto; +} + +.billing-downgrade-feedback .boxed-group { + margin-top: 5vh; +} + +.billing-downgrade-feedback .boxed-group-header { + padding-top: 40px; + text-align: center; +} + +.billing-downgrade-feedback .boxed-group-inner { + padding: 50px 50px 40px 50px; +} + +.billing-downgrade-feedback h2 { + font-weight: bold; + font-size: 18px; + margin-bottom: 20px; +} + +.billing-downgrade-feedback .boxed-group-list li { + margin-left: 0; + padding-left: 0; +} + +.billing-downgrade-feedback input[type='radio'] { + margin-right: 1em; +} + +.billing-downgrade-feedback-form-actions { + margin-top: 36px; +} + +.billing-downgrade-feedback-explain-wrapper { + margin-left: 25px; + padding-top: 20px; +} + +.billing-downgrade-feedback-explain-wrapper label { + display: block; + margin-bottom: 4px; + font-weight: bold; +} + +.billing-downgrade-feedback-explain-wrapper label .note { + font-weight: normal; + margin-left: 0.5em; +} + +.billing-downgrade-feedback-explain-wrapper textarea { + display: block; + min-height: 70px; + width: 100%; + padding: 6px 8px; + border: 1px solid #cdcdcd; + border-radius: 2px; +} diff --git a/server/sonar-web/src/main/js/apps/feedback/downgrade/DowngradeFeedback.tsx b/server/sonar-web/src/main/js/apps/feedback/downgrade/DowngradeFeedback.tsx new file mode 100644 index 00000000000..75b570a57be --- /dev/null +++ b/server/sonar-web/src/main/js/apps/feedback/downgrade/DowngradeFeedback.tsx @@ -0,0 +1,170 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 { WithRouterProps } from 'react-router'; +import { Alert } from '../../../components/ui/Alert'; +import { SubmitButton } from '../../../components/ui/buttons'; +import { translate } from '../../../helpers/l10n'; +import { giveDowngradeFeedback } from '../../../api/billing'; +import { getBaseUrl } from '../../../helpers/urls'; +import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage'; +import './DowngradeFeedback.css'; +import Radio from '../../../components/controls/Radio'; + +enum Reason { + doesntWork = 'did_not_work', + doesntMeetExpectations = 'did_not_match_expectations', + doesntMeetCompanyPolicy = 'company_policy', + onlyTesting = 'just_testing', + other = 'other' +} + +interface State { + additionalFeedback: string; + feedback?: Reason; +} + +export interface LocationState { + confirmationMessage?: string; + organization?: T.Organization; + returnTo?: string; + title: string; +} + +export default class DowngradeFeedback extends React.PureComponent<WithRouterProps, State> { + state: State = { + additionalFeedback: '' + }; + + handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { + this.setState({ + additionalFeedback: event.currentTarget.value + }); + }; + + handleChoice = (value: string) => { + this.setState({ + feedback: value as Reason + }); + }; + + handleSkip = (event: React.MouseEvent<HTMLAnchorElement>) => { + event.preventDefault(); + this.navigateAway(); + }; + + handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const { organization } = this.getLocationState(); + if (!organization) { + return; + } + + giveDowngradeFeedback({ + organization: organization.key, + feedback: this.state.feedback as string, + additionalFeedback: this.state.additionalFeedback + }); + + this.navigateAway(translate('billing.downgrade.thankyou_for_feedback')); + }; + + getLocationState = (): LocationState => { + const { location } = this.props; + return location.state || {}; + }; + + navigateAway = (message?: string) => { + if (message) { + addGlobalSuccessMessage(message); + } + + const { returnTo } = this.getLocationState(); + this.props.router.replace({ + pathname: returnTo || getBaseUrl() + }); + }; + + render() { + const { organization, confirmationMessage, title } = this.getLocationState(); + if (!organization) { + return null; + } + + return ( + <div className="billing-downgrade-feedback"> + {confirmationMessage && <Alert variant="success">{confirmationMessage}</Alert>} + <div className="boxed-group boxed-group-centered"> + <div className="boxed-group-header"> + <h2>{title}</h2> + <div>{translate('billing.downgrade.reason.explanation')}</div> + </div> + <div className="boxed-group-inner"> + <form className="billing-downgrade-feedback-form" onSubmit={this.handleSubmit}> + <ul className="boxed-group-list"> + {Object.keys(Reason).map(key => { + const reason = Reason[key as any]; + return ( + <li key={reason}> + <Radio + checked={this.state.feedback === reason} + onCheck={this.handleChoice} + value={reason}> + {translate('billing.downgrade.reason.option', reason, 'label')} + </Radio> + {this.state.feedback === reason && ( + <div className="billing-downgrade-feedback-explain-wrapper"> + <label htmlFor={`reason_text_${reason}`}> + {translate('billing.why')} + <span className="note">{translate('billing.optional')}</span> + </label> + <textarea + id={`reason_text_${reason}`} + name={`reason_text_${reason}`} + onChange={this.handleChange} + placeholder={translate( + 'billing.downgrade.reason.option', + reason, + 'placeholder' + )} + value={this.state.additionalFeedback} + /> + </div> + )} + </li> + ); + })} + </ul> + <div className="billing-downgrade-feedback-form-actions"> + <SubmitButton className="spacer-right" disabled={!this.state.feedback}> + {translate('billing.send')} + </SubmitButton> + <a href="#" onClick={this.handleSkip}> + {translate('billing.skip')} + </a> + </div> + </form> + </div> + </div> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/feedback/downgrade/__tests__/DowngradeFeedback-test.tsx b/server/sonar-web/src/main/js/apps/feedback/downgrade/__tests__/DowngradeFeedback-test.tsx new file mode 100644 index 00000000000..df1946a7f98 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/feedback/downgrade/__tests__/DowngradeFeedback-test.tsx @@ -0,0 +1,103 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import DowngradeFeedback, { LocationState } from '../DowngradeFeedback'; +import { giveDowngradeFeedback } from '../../../../api/billing'; +import { mockRouter, mockLocation } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/billing', () => ({ + giveDowngradeFeedback: jest.fn() +})); + +const mockRouterReplace = jest.fn(); + +const org: T.Organization = { + key: 'myorg', + name: 'My Org' +}; + +const returnPath = '/path?with=query'; + +beforeEach(() => { + (mockRouterReplace as jest.Mock).mockClear(); + (giveDowngradeFeedback as jest.Mock).mockClear(); +}); + +it('should render correctly', () => { + const wrapper = getWrapper(); + expect(wrapper).toMatchSnapshot(); +}); + +it('should enforce choosing a reason, and show an extra textarea if a reason was chosen', () => { + const wrapper = getWrapper(); + expect(wrapper.find('SubmitButton')).toMatchSnapshot(); + + wrapper.setState({ feedback: 'other' }); + expect(wrapper.find('[name="reason_text_other"]').exists()).toBe(true); + expect(wrapper.find('SubmitButton')).toMatchSnapshot(); +}); + +it('should submit the data to the webservice', () => { + const wrapper = getWrapper(); + const feedback = 'other'; + const additionalFeedback = 'Additional feedback'; + wrapper.setState({ feedback, additionalFeedback }); + wrapper + .find('form') + .at(0) + .simulate('submit', { + preventDefault: jest.fn() + }); + expect(giveDowngradeFeedback).toBeCalledWith({ + organization: org.key, + feedback, + additionalFeedback + }); + expect(mockRouterReplace).toBeCalledWith({ + pathname: returnPath + }); +}); + +function mockLocationState(overrides = {}): LocationState { + return { + confirmationMessage: 'Downgrade successful', + returnTo: returnPath, + organization: org, + title: 'Title', + ...overrides + }; +} + +function getWrapper(props = {}, locationState = {}) { + return shallow( + <DowngradeFeedback + location={mockLocation({ + state: mockLocationState(locationState) + })} + params={{}} + router={mockRouter({ + replace: mockRouterReplace + })} + routes={[]} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/feedback/downgrade/__tests__/__snapshots__/DowngradeFeedback-test.tsx.snap b/server/sonar-web/src/main/js/apps/feedback/downgrade/__tests__/__snapshots__/DowngradeFeedback-test.tsx.snap new file mode 100644 index 00000000000..5a2a16d4dff --- /dev/null +++ b/server/sonar-web/src/main/js/apps/feedback/downgrade/__tests__/__snapshots__/DowngradeFeedback-test.tsx.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should enforce choosing a reason, and show an extra textarea if a reason was chosen 1`] = ` +<SubmitButton + className="spacer-right" + disabled={true} +> + billing.send +</SubmitButton> +`; + +exports[`should enforce choosing a reason, and show an extra textarea if a reason was chosen 2`] = ` +<SubmitButton + className="spacer-right" + disabled={false} +> + billing.send +</SubmitButton> +`; + +exports[`should render correctly 1`] = ` +<div + className="billing-downgrade-feedback" +> + <Alert + variant="success" + > + Downgrade successful + </Alert> + <div + className="boxed-group boxed-group-centered" + > + <div + className="boxed-group-header" + > + <h2> + Title + </h2> + <div> + billing.downgrade.reason.explanation + </div> + </div> + <div + className="boxed-group-inner" + > + <form + className="billing-downgrade-feedback-form" + onSubmit={[Function]} + > + <ul + className="boxed-group-list" + > + <li + key="did_not_work" + > + <Radio + checked={false} + onCheck={[Function]} + value="did_not_work" + > + billing.downgrade.reason.option.did_not_work.label + </Radio> + </li> + <li + key="did_not_match_expectations" + > + <Radio + checked={false} + onCheck={[Function]} + value="did_not_match_expectations" + > + billing.downgrade.reason.option.did_not_match_expectations.label + </Radio> + </li> + <li + key="company_policy" + > + <Radio + checked={false} + onCheck={[Function]} + value="company_policy" + > + billing.downgrade.reason.option.company_policy.label + </Radio> + </li> + <li + key="just_testing" + > + <Radio + checked={false} + onCheck={[Function]} + value="just_testing" + > + billing.downgrade.reason.option.just_testing.label + </Radio> + </li> + <li + key="other" + > + <Radio + checked={false} + onCheck={[Function]} + value="other" + > + billing.downgrade.reason.option.other.label + </Radio> + </li> + </ul> + <div + className="billing-downgrade-feedback-form-actions" + > + <SubmitButton + className="spacer-right" + disabled={true} + > + billing.send + </SubmitButton> + <a + href="#" + onClick={[Function]} + > + billing.skip + </a> + </div> + </form> + </div> + </div> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/organizations/actions.ts b/server/sonar-web/src/main/js/apps/organizations/actions.ts index 0bd460161e2..cda9b50e9e4 100644 --- a/server/sonar-web/src/main/js/apps/organizations/actions.ts +++ b/server/sonar-web/src/main/js/apps/organizations/actions.ts @@ -47,6 +47,5 @@ export const updateOrganization = (key: string, changes: T.OrganizationBase) => export const deleteOrganization = (key: string) => (dispatch: Dispatch<any>) => { return api.deleteOrganization(key).then(() => { dispatch(actions.deleteOrganization(key)); - dispatch(addGlobalSuccessMessage(translate('organization.deleted'))); }); }; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx index 2df8f24417b..6954e3bf7e7 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx @@ -29,6 +29,7 @@ import { getOrganizationBilling } from '../../../api/organizations'; import { isSonarCloud } from '../../../helpers/system'; import { Alert } from '../../../components/ui/Alert'; import { withRouter, Router } from '../../../components/hoc/withRouter'; +import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage'; interface DispatchToProps { deleteOrganization: (key: string) => Promise<void>; @@ -78,7 +79,7 @@ export class OrganizationDelete extends React.PureComponent<Props, State> { } }; - handleInput = (event: React.SyntheticEvent<HTMLInputElement>) => { + handleInput = (event: React.ChangeEvent<HTMLInputElement>) => { this.setState({ verify: event.currentTarget.value }); }; @@ -87,8 +88,24 @@ export class OrganizationDelete extends React.PureComponent<Props, State> { }; onDelete = () => { - return this.props.deleteOrganization(this.props.organization.key).then(() => { - this.props.router.replace('/'); + const { organization } = this.props; + return this.props.deleteOrganization(organization.key).then(() => { + if (this.state.hasPaidPlan) { + this.props.router.replace({ + pathname: '/feedback/downgrade', + state: { + confirmationMessage: translateWithParameters( + 'organization.deleted_x', + organization.name + ), + organization, + title: translate('billing.downgrade.reason.title_deleted') + } + }); + } else { + addGlobalSuccessMessage(translate('organization.deleted')); + this.props.router.replace('/'); + } }); }; diff --git a/server/sonar-web/src/main/js/components/controls/Radio.tsx b/server/sonar-web/src/main/js/components/controls/Radio.tsx index 5c690bdaba4..e389c71d664 100644 --- a/server/sonar-web/src/main/js/components/controls/Radio.tsx +++ b/server/sonar-web/src/main/js/components/controls/Radio.tsx @@ -23,14 +23,15 @@ import * as classNames from 'classnames'; interface Props { checked: boolean; className?: string; - onCheck: () => void; + onCheck: (value: string) => void; + value: string; } export default class Radio extends React.PureComponent<Props> { handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); event.currentTarget.blur(); - this.props.onCheck(); + this.props.onCheck(this.props.value); }; render() { diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Radio-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Radio-test.tsx index f3224f519c5..a0f9eabdc8d 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/Radio-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/Radio-test.tsx @@ -24,11 +24,12 @@ import { click } from '../../../helpers/testUtils'; it('should render and check', () => { const onCheck = jest.fn(); - const wrapper = shallow(<Radio checked={false} onCheck={onCheck} />); + const value = 'value'; + const wrapper = shallow(<Radio checked={false} onCheck={onCheck} value={value} />); expect(wrapper).toMatchSnapshot(); click(wrapper); - expect(onCheck).toBeCalled(); + expect(onCheck).toBeCalledWith(value); wrapper.setProps({ checked: true }); expect(wrapper).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/helpers/testUtils.ts b/server/sonar-web/src/main/js/helpers/testUtils.ts index fdf8bf0728b..210bd412192 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.ts +++ b/server/sonar-web/src/main/js/helpers/testUtils.ts @@ -19,6 +19,7 @@ */ import { ShallowWrapper, ReactWrapper } from 'enzyme'; import { InjectedRouter } from 'react-router'; +import { Location } from 'history'; import { EditionKey } from '../apps/marketplace/utils'; export const mockEvent = { @@ -133,6 +134,18 @@ export async function waitAndUpdate(wrapper: ShallowWrapper<any, any> | ReactWra wrapper.update(); } +export function mockLocation(overrides = {}): Location { + return { + action: 'PUSH', + key: 'key', + pathname: '/path', + query: {}, + search: '', + state: {}, + ...overrides + }; +} + export function mockRouter(overrides: { push?: Function; replace?: Function } = {}) { return { createHref: jest.fn(), diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 606e8f82fbb..76a3cc099ff 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2643,6 +2643,7 @@ organization.delete.description=Delete this organization from {instance}. All pr organization.delete.sonarcloud.paid_plan_info=Your current paid plan subscription will stop and you won't be charged anymore. organization.delete.question=Are you sure you want to delete this organization? organization.deleted=Organization has been deleted. +organization.deleted_x=Organization "{0}" has been deleted. organization.description=Description organization.description.description=Description of the organization. organization.edit=Edit Organization |