Skip to content

Commit

Permalink
chore: Use redux to manage UI state (ref #1250)
Browse files Browse the repository at this point in the history
  • Loading branch information
tofumatt committed Sep 23, 2017
1 parent f091728 commit 3f5e0aa
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 57 deletions.
45 changes: 26 additions & 19 deletions src/amo/components/ReportAbuseButton/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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) => {
Expand All @@ -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 })

Expand All @@ -88,7 +94,7 @@ export class ReportAbuseButtonBase extends React.Component {
return null;
}

if (abuseReport) {
if (abuseReport && abuseReport.message) {
return (
<div className="ReportAbuseButton ReportAbuseButton--report-sent">
<h3 className="ReportAbuseButton-header">
Expand All @@ -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
Expand All @@ -128,7 +134,7 @@ export class ReportAbuseButtonBase extends React.Component {
return (
<div
className={classNames('ReportAbuseButton', {
'ReportAbuseButton--is-expanded': this.state.expanded,
'ReportAbuseButton--is-expanded': abuseReport.uiVisible,
})}
>
<div className="ReportAbuseButton--preview">
Expand Down Expand Up @@ -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,
};
};
Expand Down
127 changes: 121 additions & 6 deletions src/core/reducers/abuse.js
Original file line number Diff line number Diff line change
@@ -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: {|
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -56,14 +107,34 @@ 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,
};

type ReducerState = {|
bySlug: {
[addonSlug: string]: {| message: string, reporter: Object | null |},
[addonSlug: string]: {|
buttonEnabled?: bool,
message: string,
reporter: Object | null,
uiVisible?: bool,
|},
},
loading: bool,
|};
Expand All @@ -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;
}
Expand Down
57 changes: 25 additions & 32 deletions tests/unit/amo/components/TestReportAbuseButton.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,38 +18,18 @@ import {

describe(__filename, () => {
function renderMount({
addon = { ...fakeAddon, slug: 'my-addon' },
store = dispatchClientMetadata().store,
...props
} = {}) {
return mount(
<ReportAbuseButton
addon={addon}
debounce={(callback) => (...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(
<ReportAbuseButtonBase
<ReportAbuseButton
addon={addon}
debounce={(callback) => (...args) => callback(...args)}
errorHandler={errorHandler}
i18n={getFakeI18nInst()}
store={store}
{...mapStateToProps(store.getState(), { addon })}
{...props}
/>
);
Expand Down Expand Up @@ -200,17 +178,18 @@ 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');
textarea.node.value = 'Opera did it first!';
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!',
Expand All @@ -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);
});
});

0 comments on commit 3f5e0aa

Please sign in to comment.