diff --git a/src/amo/components/ReportAbuseButton/index.js b/src/amo/components/ReportAbuseButton/index.js index d2c8279a895..5c3dba16f90 100644 --- a/src/amo/components/ReportAbuseButton/index.js +++ b/src/amo/components/ReportAbuseButton/index.js @@ -7,7 +7,13 @@ import { compose } from 'redux'; import { withErrorHandler } from 'core/errorHandler'; import translate from 'core/i18n/translate'; -import { sendAddonAbuseReport } from 'core/reducers/abuse'; +import { + disableAbuseButtonUI, + enableAbuseButtonUI, + hideAddonAbuseReportUI, + sendAddonAbuseReport, + showAddonAbuseReportUI, +} from 'core/reducers/abuse'; import { sanitizeHTML } from 'core/utils'; import Button from 'ui/components/Button'; @@ -29,20 +35,16 @@ export class ReportAbuseButtonBase extends React.Component { debounce: defaultDebounce, }; - constructor(props: Object) { - super(props); - - this.state = { buttonEnabled: false, expanded: false }; - } - cancelReport = (event) => { event.preventDefault(); - if (this.props.loading) { + const { addon, dispatch, loading } = this.props; + + if (loading) { return; } - this.setState({ expanded: false }); + dispatch(hideAddonAbuseReportUI({ addon })); } sendReport = (event) => { @@ -66,16 +68,20 @@ export class ReportAbuseButtonBase extends React.Component { showMore = (event) => { event.preventDefault(); - this.setState({ expanded: true }, function focusTextarea() { - this.textarea.focus(); - }); + const { addon, dispatch } = this.props; + + dispatch(showAddonAbuseReportUI({ addon })); + this.textarea.focus(); } textareaChange = this.props.debounce(() => { - if (this.textarea.value.length) { - this.setState({ buttonEnabled: true }); + const { abuseReport, addon, dispatch } = this.props; + + // Don't dispatch the UI update if the button is already visible. + if (this.textarea.value.length && !abuseReport.uiVisible) { + dispatch(enableAbuseButtonUI({ addon })); } else { - this.setState({ buttonEnabled: false }); + dispatch(disableAbuseButtonUI({ addon })); } }, 100, { trailing: true }) @@ -88,7 +94,7 @@ export class ReportAbuseButtonBase extends React.Component { return null; } - if (abuseReport) { + if (abuseReport && abuseReport.message) { return (

@@ -112,7 +118,7 @@ export class ReportAbuseButtonBase extends React.Component { ); } - const sendButtonIsDisabled = loading || !this.state.buttonEnabled; + const sendButtonIsDisabled = loading || !abuseReport.buttonEnabled; const prefaceText = i18n.sprintf(i18n.gettext( `If you think this add-on violates @@ -128,7 +134,7 @@ export class ReportAbuseButtonBase extends React.Component { return (
@@ -196,7 +202,8 @@ export const mapStateToProps = (state, ownProps) => { const addon = ownProps.addon; return { - abuseReport: addon ? state.abuse.bySlug[addon.slug] : null, + abuseReport: addon && state.abuse.bySlug[addon.slug] ? + state.abuse.bySlug[addon.slug] : {}, loading: state.abuse.loading, }; }; diff --git a/src/core/reducers/abuse.js b/src/core/reducers/abuse.js index 69efbe250e8..e7a8a92f04b 100644 --- a/src/core/reducers/abuse.js +++ b/src/core/reducers/abuse.js @@ -1,6 +1,57 @@ /* @flow */ +import type { AddonType } from 'core/types/addons'; + +export const DISABLE_ADDON_ABUSE_BUTTON_UI = 'DISABLE_ADDON_ABUSE_BUTTON_UI'; +export const ENABLE_ADDON_ABUSE_BUTTON_UI = 'ENABLE_ADDON_ABUSE_BUTTON_UI'; +export const HIDE_ADDON_ABUSE_REPORT_UI = 'HIDE_ADDON_ABUSE_REPORT_UI'; export const LOAD_ADDON_ABUSE_REPORT = 'LOAD_ADDON_ABUSE_REPORT'; export const SEND_ADDON_ABUSE_REPORT = 'SEND_ADDON_ABUSE_REPORT'; +export const SHOW_ADDON_ABUSE_REPORT_UI = 'SHOW_ADDON_ABUSE_REPORT_UI'; + +type DisableAddonAbuseButtonUIType = { addon: AddonType }; + +export function disableAbuseButtonUI( + { addon }: DisableAddonAbuseButtonUIType = {} +) { + if (!addon) { + throw new Error('addon is required'); + } + + return { + type: DISABLE_ADDON_ABUSE_BUTTON_UI, + payload: { addon }, + }; +} + +type EnableAddonAbuseButtonUIType = { addon: AddonType }; + +export function enableAbuseButtonUI( + { addon }: EnableAddonAbuseButtonUIType = {} +) { + if (!addon) { + throw new Error('addon is required'); + } + + return { + type: ENABLE_ADDON_ABUSE_BUTTON_UI, + payload: { addon }, + }; +} + +type HideAddonAbuseReportUIType = { addon: AddonType }; + +export function hideAddonAbuseReportUI( + { addon }: HideAddonAbuseReportUIType = {} +) { + if (!addon) { + throw new Error('addon is required'); + } + + return { + type: HIDE_ADDON_ABUSE_REPORT_UI, + payload: { addon }, + }; +} type LoadAddonAbuseReportType = { addon: {| @@ -13,7 +64,7 @@ type LoadAddonAbuseReportType = { }; export function loadAddonAbuseReport( - { addon, message, reporter }: LoadAddonAbuseReportType + { addon, message, reporter }: LoadAddonAbuseReportType = {} ) { if (!addon) { throw new Error('addon is required'); @@ -38,7 +89,7 @@ type SendAddonAbuseReportAction = {| |}; export function sendAddonAbuseReport( - { addonSlug, errorHandlerId, message }: SendAddonAbuseReportAction + { addonSlug, errorHandlerId, message }: SendAddonAbuseReportAction = {} ) { if (!addonSlug) { throw new Error('addonSlug is required'); @@ -56,6 +107,21 @@ export function sendAddonAbuseReport( }; } +type ShowAddonAbuseReportUIType = { addon: AddonType }; + +export function showAddonAbuseReportUI( + { addon }: ShowAddonAbuseReportUIType = {} +) { + if (!addon) { + throw new Error('addon is required'); + } + + return { + type: SHOW_ADDON_ABUSE_REPORT_UI, + payload: { addon }, + }; +} + export const initialState = { bySlug: {}, loading: false, @@ -63,7 +129,12 @@ export const initialState = { type ReducerState = {| bySlug: { - [addonSlug: string]: {| message: string, reporter: Object | null |}, + [addonSlug: string]: {| + buttonEnabled?: bool, + message: string, + reporter: Object | null, + uiVisible?: bool, + |}, }, loading: bool, |}; @@ -73,19 +144,63 @@ export default function abuseReducer( action: Object ) { switch (action.type) { - case SEND_ADDON_ABUSE_REPORT: - return { ...state, loading: true }; + case DISABLE_ADDON_ABUSE_BUTTON_UI: { + const { addon } = action.payload; + + return { + ...state, + bySlug: { + ...state.bySlug, + [addon.slug]: { ...state.bySlug[addon.slug], buttonEnabled: false }, + }, + }; + } + case ENABLE_ADDON_ABUSE_BUTTON_UI: { + const { addon } = action.payload; + + return { + ...state, + bySlug: { + ...state.bySlug, + [addon.slug]: { ...state.bySlug[addon.slug], buttonEnabled: true }, + }, + }; + } + case HIDE_ADDON_ABUSE_REPORT_UI: { + const { addon } = action.payload; + + return { + ...state, + bySlug: { + ...state.bySlug, + [addon.slug]: { ...state.bySlug[addon.slug], uiVisible: false }, + }, + }; + } case LOAD_ADDON_ABUSE_REPORT: { const { addon, message, reporter } = action.payload; return { ...state, bySlug: { ...state.bySlug, - [addon.slug]: { message, reporter }, + [addon.slug]: { message, reporter, uiVisible: false }, }, loading: false, }; } + case SEND_ADDON_ABUSE_REPORT: + return { ...state, loading: true }; + case SHOW_ADDON_ABUSE_REPORT_UI: { + const { addon } = action.payload; + + return { + ...state, + bySlug: { + ...state.bySlug, + [addon.slug]: { ...state.bySlug[addon.slug], uiVisible: true }, + }, + }; + } default: return state; } diff --git a/tests/unit/amo/components/TestReportAbuseButton.js b/tests/unit/amo/components/TestReportAbuseButton.js index 656da187e1c..e85873fe647 100644 --- a/tests/unit/amo/components/TestReportAbuseButton.js +++ b/tests/unit/amo/components/TestReportAbuseButton.js @@ -1,11 +1,9 @@ import { mount } from 'enzyme'; import React from 'react'; -import ReportAbuseButton, { - ReportAbuseButtonBase, - mapStateToProps, -} from 'amo/components/ReportAbuseButton'; +import ReportAbuseButton from 'amo/components/ReportAbuseButton'; import { + enableAbuseButtonUI, loadAddonAbuseReport, sendAddonAbuseReport, } from 'core/reducers/abuse'; @@ -20,38 +18,18 @@ import { describe(__filename, () => { function renderMount({ - addon = { ...fakeAddon, slug: 'my-addon' }, - store = dispatchClientMetadata().store, - ...props - } = {}) { - return mount( - (...args) => callback(...args)} - i18n={getFakeI18nInst()} - store={store} - {...props} - /> - ); - } - - // We use `mount` and the base version of this component for these tests - // because we need to check the state of the component and call methods - // directly. The only way to do that is to mount it directly without HOC. - function mountBaseComponent({ addon = { ...fakeAddon, slug: 'my-addon' }, errorHandler = createStubErrorHandler(), store = dispatchClientMetadata().store, ...props } = {}) { return mount( - (...args) => callback(...args)} errorHandler={errorHandler} i18n={getFakeI18nInst()} store={store} - {...mapStateToProps(store.getState(), { addon })} {...props} /> ); @@ -200,9 +178,10 @@ describe(__filename, () => { it('dispatches when the send button is clicked if textarea has text', () => { const addon = { ...fakeAddon, slug: 'which-browser' }; - const fakeDispatch = sinon.stub(); const fakeEvent = createFakeEvent(); - const root = mountBaseComponent({ addon, dispatch: fakeDispatch }); + const { store } = dispatchClientMetadata(); + const dispatchSpy = sinon.spy(store, 'dispatch'); + const root = renderMount({ addon, store }); // This simulates entering text into the textarea. const textarea = root.find('.ReportAbuseButton-textarea textarea'); @@ -210,7 +189,7 @@ describe(__filename, () => { textarea.simulate('change'); root.find('.ReportAbuseButton-send-report').simulate('click', fakeEvent); - sinon.assert.calledWith(fakeDispatch, sendAddonAbuseReport({ + sinon.assert.calledWith(dispatchSpy, sendAddonAbuseReport({ addonSlug: addon.slug, errorHandlerId: 'create-stub-error-handler-id', message: 'Opera did it first!', @@ -224,12 +203,26 @@ describe(__filename, () => { // be called if the textarea is empty but this function manages to be // called. it('does not allow dispatch if there is no content in the textarea', () => { - const fakeDispatch = sinon.stub(); + const addon = { ...fakeAddon, slug: 'this-should-not-happen' }; const fakeEvent = createFakeEvent(); - const root = mountBaseComponent({ dispatch: fakeDispatch }); + const { store } = dispatchClientMetadata(); + const dispatchSpy = sinon.spy(store, 'dispatch'); + const root = renderMount({ addon, store }); + + // We enable the button with an empty textarea; this never happens + // normally but we can force it here for testing. + store.dispatch(enableAbuseButtonUI({ addon })); + dispatchSpy.reset(); + fakeEvent.preventDefault.reset(); + + // Make sure the button isn't disabled. + expect(root.find('.ReportAbuseButton-send-report').prop('disabled')) + .toEqual(false); + root.find('.ReportAbuseButton-send-report').simulate('click', fakeEvent); - root.instance().sendReport(fakeEvent); - sinon.assert.notCalled(fakeDispatch); + sinon.assert.notCalled(dispatchSpy); + // Make sure preventDefault was called; we then know the sendReport() + // method was called. sinon.assert.called(fakeEvent.preventDefault); }); }); diff --git a/tests/unit/core/reducers/test_abuse.js b/tests/unit/core/reducers/test_abuse.js index 8b5c9ec1942..398a3ac2855 100644 --- a/tests/unit/core/reducers/test_abuse.js +++ b/tests/unit/core/reducers/test_abuse.js @@ -1,8 +1,12 @@ import abuseReducer, { SEND_ADDON_ABUSE_REPORT, + disableAbuseButtonUI, + enableAbuseButtonUI, + hideAddonAbuseReportUI, initialState, loadAddonAbuseReport, sendAddonAbuseReport, + showAddonAbuseReportUI, } from 'core/reducers/abuse'; import { dispatchClientMetadata, fakeAddon } from 'tests/unit/amo/helpers'; import { createFakeAddonAbuseReport } from 'tests/unit/helpers'; @@ -41,6 +45,86 @@ describe(__filename, () => { }); }); + describe('disableAbuseButtonUI', () => { + it('sets the buttonEnabled state to false', () => { + const state = abuseReducer( + initialState, disableAbuseButtonUI({ addon: fakeAddon })); + + expect(state).toEqual({ + bySlug: { + [fakeAddon.slug]: { buttonEnabled: false }, + }, + loading: false, + }); + }); + + it('requires an addon param', () => { + expect(() => { + disableAbuseButtonUI(); + }).toThrow('addon is required'); + }); + }); + + describe('enableAbuseButtonUI', () => { + it('sets the buttonEnabled state to true', () => { + const state = abuseReducer( + initialState, enableAbuseButtonUI({ addon: fakeAddon })); + + expect(state).toEqual({ + bySlug: { + [fakeAddon.slug]: { buttonEnabled: true }, + }, + loading: false, + }); + }); + + it('requires an addon param', () => { + expect(() => { + enableAbuseButtonUI(); + }).toThrow('addon is required'); + }); + }); + + describe('hideAddonAbuseReportUI', () => { + it('sets the uiVisible state to false', () => { + const state = abuseReducer( + initialState, hideAddonAbuseReportUI({ addon: fakeAddon })); + + expect(state).toEqual({ + bySlug: { + [fakeAddon.slug]: { uiVisible: false }, + }, + loading: false, + }); + }); + + it('requires an addon param', () => { + expect(() => { + hideAddonAbuseReportUI(); + }).toThrow('addon is required'); + }); + }); + + describe('showAddonAbuseReportUI', () => { + it('sets the uiVisible state to true', () => { + const state = abuseReducer( + initialState, showAddonAbuseReportUI({ addon: fakeAddon })); + + expect(state).toEqual({ + bySlug: { + [fakeAddon.slug]: { uiVisible: true }, + }, + loading: false, + }); + }); + + it('requires an addon param', () => { + expect(() => { + showAddonAbuseReportUI(); + }).toThrow('addon is required'); + }); + }); + describe('sendAddonAbuseReport', () => { let defaultParams;