Refactored code structure using react context

ref https://github.com/TryGhost/members.js/issues/5

React context allows us cleaner setup in codebase with shared data and methods across components at different nesting levels. This should allow faster iteration and easier development going forward.

- Uses Parent context for shared data and methods across different components instead of passed down props
- Uses new `init` method in API for data initialization
- Removes `PopupMenu` component in favor of `PopupModal`
- Adds new test util for custom render for easier base setup - https://testing-library.com/docs/react-testing-library/setup#custom-render
- Updates tests to use new test util for easier test setup
This commit is contained in:
Rish 2020-04-28 01:10:08 +05:30
parent 2214a5048b
commit 487bea51b2
14 changed files with 287 additions and 482 deletions

View File

@ -1,11 +1,10 @@
import React from 'react'; import React from 'react';
import {render} from '@testing-library/react'; import {render} from '@testing-library/react';
import App from './App'; import App from './App';
import {site} from './test/fixtures/data';
test('renders App', () => { test('renders App', () => {
const {container} = render( const {container} = render(
<App data={{site}} /> <App data={{adminUrl: 'https://youradminurl.com'}} />
); );
// dashboard component should be rendered on root route // dashboard component should be rendered on root route

View File

@ -1,11 +1,12 @@
import TriggerButton from './TriggerButton'; import TriggerButton from './TriggerButton';
import PopupMenu from './PopupMenu';
import PopupModal from './PopupModal'; import PopupModal from './PopupModal';
import * as Fixtures from '../test/fixtures/data'; import * as Fixtures from '../test/fixtures/data';
import Api from '../utils/api'; import setupGhostApi from '../utils/api';
import {ParentContext} from './ParentContext';
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
export default class ParentContainer extends React.Component { export default class ParentContainer extends React.Component {
static propTypes = { static propTypes = {
data: PropTypes.object.isRequired data: PropTypes.object.isRequired
@ -14,57 +15,64 @@ export default class ParentContainer extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { // Setup custom trigger button handling
page: 'magiclink', this.setupCustomTriggerButton();
showPopup: false,
action: {
name: 'loading'
}
};
this.initialize(); this.state = {
page: 'accountHome',
showPopup: false,
action: 'init:running',
initStatus: 'running'
};
} }
componentDidMount() { componentDidMount() {
// Initialize site and members data
this.loadData();
}
initialize() {
// Setup custom trigger button handling
this.setupCustomTriggerButton();
}
async loadData() {
// Setup Members API with site/admin URLs
const {adminUrl} = this.props.data; const {adminUrl} = this.props.data;
const siteUrl = window.location.origin; if (adminUrl) {
this.MembersAPI = Api({siteUrl, adminUrl}); this.GhostApi = setupGhostApi({adminUrl});
this.fetchData();
} else {
console.error(`[Members.js] Failed to initialize, pass a valid admin url.`);
this.setState({
action: 'init:failed:missingAdminUrl'
});
}
}
// Fetch site and member session data with Ghost Apis
async fetchData() {
const {adminUrl} = this.props.data;
this.GhostApi = setupGhostApi({adminUrl});
try { try {
const [{site}, member] = await Promise.all([this.MembersAPI.site.read(), this.MembersAPI.member.sessionData()]); const {site, member} = await this.GhostApi.init();
console.log('Initialized Members.js with', site, member);
this.setState({ this.setState({
site, site,
member, member,
page: member ? 'accountHome' : 'signup', page: member ? 'accountHome' : 'signup',
action: 'init:success' action: 'init:success',
initStatus: 'success'
}); });
} catch (e) { } catch (e) {
console.log('Failed state fetch', e); console.error(`[Members.js] Failed to fetch site data, please make sure your admin url - ${adminUrl} - is correct.`);
this.setState({ this.setState({
action: { action: 'init:failed:incorrectAdminUrl',
name: 'init:failed' initStatus: 'failed'
}
}); });
} }
} }
getData() { getData() {
const member = process.env.REACT_APP_ADMIN_URL ? Fixtures.member.free : this.state.member; // Load data from fixtures for development mode
const site = process.env.REACT_APP_ADMIN_URL ? Fixtures.site : this.state.site; if (process.env.REACT_APP_ADMIN_URL) {
return {
return {site, member}; site: Fixtures.site,
member: Fixtures.member.free
};
}
return {
site: this.state.site,
member: this.state.member
};
} }
switchPage(page) { switchPage(page) {
@ -74,7 +82,8 @@ export default class ParentContainer extends React.Component {
} }
setupCustomTriggerButton() { setupCustomTriggerButton() {
this.customTriggerButton = document.querySelector('[data-members-trigger-button]'); const customTriggerSelector = '[data-members-trigger-button]';
this.customTriggerButton = document.querySelector(customTriggerSelector);
if (this.customTriggerButton) { if (this.customTriggerButton) {
const clickHandler = (event) => { const clickHandler = (event) => {
@ -83,78 +92,56 @@ export default class ParentContainer extends React.Component {
const elRemoveClass = this.state.showPopup ? 'popup-open' : 'popup-close'; const elRemoveClass = this.state.showPopup ? 'popup-open' : 'popup-close';
this.customTriggerButton.classList.add(elAddClass); this.customTriggerButton.classList.add(elAddClass);
this.customTriggerButton.classList.remove(elRemoveClass); this.customTriggerButton.classList.remove(elRemoveClass);
this.onTriggerToggle(); this.onAction('togglePopup');
}; };
this.customTriggerButton.classList.add('popup-close'); this.customTriggerButton.classList.add('popup-close');
this.customTriggerButton.addEventListener('click', clickHandler); this.customTriggerButton.addEventListener('click', clickHandler);
} }
} }
resetAction() {
this.setState({
action: null
});
}
getBrandColor() { getBrandColor() {
return this.getData().site && this.getData().site.brand && this.getData().site.brand.primaryColor; return (this.getData().site && this.getData().site.brand && this.getData().site.brand.primaryColor) || '#3db0ef';
} }
async onAction(action, data) { async onAction(action, data) {
this.setState({ this.setState({
action: { action: `${action}:running`
name: action,
isRunning: true,
isSuccess: false,
error: null
}
}); });
try { try {
if (action === 'closePopup') { if (action === 'switchPage') {
this.setState({
page: data
});
} else if (action === 'togglePopup') {
this.setState({
showPopup: !this.state.showPopup
});
} else if (action === 'closePopup') {
this.setState({ this.setState({
showPopup: false showPopup: false
}); });
} else if (action === 'signout') { } else if (action === 'signout') {
await this.MembersAPI.member.signout(); await this.GhostApi.member.signout();
this.setState({ this.setState({
action: { action: 'signout:success'
name: action,
isRunning: false,
isSuccess: true
}
}); });
} } else if (action === 'signin') {
await this.GhostApi.member.sendMagicLink(data);
if (action === 'signin') {
await this.MembersAPI.member.sendMagicLink(data);
this.setState({ this.setState({
action: { action: 'signin:success',
name: action,
isRunning: false,
isSuccess: true
},
page: 'magiclink' page: 'magiclink'
}); });
} } else if (action === 'signup') {
await this.GhostApi.member.sendMagicLink(data);
if (action === 'signup') {
await this.MembersAPI.member.sendMagicLink(data);
this.setState({ this.setState({
action: { action: 'signup:success',
name: action,
isRunning: false,
isSuccess: true
},
page: 'magiclink' page: 'magiclink'
}); });
} } else if (action === 'checkoutPlan') {
if (action === 'checkoutPlan') {
const checkoutSuccessUrl = (new URL('/account/?stripe=billing-update-success', window.location.href)).href; const checkoutSuccessUrl = (new URL('/account/?stripe=billing-update-success', window.location.href)).href;
const checkoutCancelUrl = (new URL('/account/?stripe=billing-update-cancel', window.location.href)).href; const checkoutCancelUrl = (new URL('/account/?stripe=billing-update-cancel', window.location.href)).href;
const {plan} = data; const {plan} = data;
await this.MembersAPI.member.checkoutPlan({ await this.GhostApi.member.checkoutPlan({
plan, plan,
checkoutSuccessUrl, checkoutSuccessUrl,
checkoutCancelUrl checkoutCancelUrl
@ -162,47 +149,15 @@ export default class ParentContainer extends React.Component {
} }
} catch (e) { } catch (e) {
this.setState({ this.setState({
action: { action: `${action}:failed`
name: action,
isRunning: false,
error: e
}
}); });
} }
} }
onTriggerToggle() {
let showPopup = !this.state.showPopup;
this.setState({
showPopup
});
}
renderPopupMenu() { renderPopupMenu() {
if (this.state.showPopup) { if (this.state.showPopup) {
if (this.state.page === 'accountHome') {
return (
<PopupMenu
data={this.getData()}
action={this.state.action}
onToggle= {e => this.onTriggerToggle()}
page={this.state.page}
switchPage={page => this.switchPage(page)}
onAction={(action, data) => this.onAction(action, data)}
brandColor = {this.getBrandColor()}
/>
);
}
return ( return (
<PopupModal <PopupModal />
data={this.getData()}
action={this.state.action}
onToggle= {e => this.onTriggerToggle()}
page={this.state.page}
switchPage={page => this.switchPage(page)}
onAction={(action, data) => this.onAction(action, data)}
brandColor = {this.getBrandColor()}
/>
); );
} }
return null; return null;
@ -212,11 +167,7 @@ export default class ParentContainer extends React.Component {
if (!this.customTriggerButton) { if (!this.customTriggerButton) {
return ( return (
<TriggerButton <TriggerButton
name={this.props.name}
onToggle= {e => this.onTriggerToggle()}
isPopupOpen={this.state.showPopup} isPopupOpen={this.state.showPopup}
data={this.getData()}
brandColor = {this.getBrandColor()}
/> />
); );
} }
@ -224,12 +175,29 @@ export default class ParentContainer extends React.Component {
return null; return null;
} }
getActionData(action) {
const [type, status, reason] = action.split(':');
return {type, status, reason};
}
render() { render() {
return ( if (this.state.initStatus === 'success' || process.env.REACT_APP_ADMIN_URL) {
<> const {site, member} = this.getData();
{this.renderPopupMenu()}
{this.renderTriggerButton()} return (
</> <ParentContext.Provider value={{
); site,
member,
action: this.state.action,
brandColor: this.getBrandColor(),
page: this.state.page,
onAction: (action, data) => this.onAction(action, data)
}}>
{this.renderPopupMenu()}
{this.renderTriggerButton()}
</ParentContext.Provider>
);
}
return null;
} }
} }

View File

@ -1,144 +0,0 @@
import Frame from './Frame';
import SigninPage from './pages/SigninPage';
import SignupPage from './pages/SignupPage';
import AccountHomePage from './pages/AccountHomePage';
import MagicLinkPage from './pages/MagicLinkPage';
import LoadingPage from './pages/LoadingPage';
const React = require('react');
const PropTypes = require('prop-types');
const Styles = {
frame: {
common: {
zIndex: '2147483000',
position: 'fixed',
bottom: '100px',
right: '20px',
width: '350px',
minHeight: '350px',
maxHeight: '410px',
boxShadow: 'rgba(0, 0, 0, 0.16) 0px 5px 40px',
opacity: '1',
height: 'calc(100% - 120px)',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'white'
},
signin: {
width: '400px',
minHeight: '200px',
maxHeight: '240px'
},
signup: {
width: '450px',
minHeight: '400px',
maxHeight: '460px'
},
accountHome: {
width: '280px',
minHeight: '200px',
maxHeight: '240px'
},
magiclink: {
width: '400px',
minHeight: '130px',
maxHeight: '130px'
},
loading: {
width: '250px',
minHeight: '130px',
maxHeight: '130px'
}
},
popup: {
parent: {
width: '100%',
height: '100%',
position: 'absolute',
letterSpacing: '0',
textRendering: 'optimizeLegibility',
fontSize: '1.5rem'
},
container: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
position: 'absolute',
top: '0px',
bottom: '0px',
left: '0px',
right: '0px',
overflow: 'hidden',
paddingTop: '18px',
paddingBottom: '18px',
textAlign: 'left'
}
}
};
const Pages = {
signin: SigninPage,
signup: SignupPage,
accountHome: AccountHomePage,
magiclink: MagicLinkPage,
loading: LoadingPage
};
export default class PopupMenu extends React.Component {
static propTypes = {
data: PropTypes.shape({
site: PropTypes.shape({
title: PropTypes.string,
description: PropTypes.string
}).isRequired,
member: PropTypes.shape({
email: PropTypes.string
})
}).isRequired,
action: PropTypes.object,
page: PropTypes.string.isRequired,
onAction: PropTypes.func.isRequired
};
renderCurrentPage(page) {
const PageComponent = Pages[page];
return (
<PageComponent
data={this.props.data}
action={this.props.action}
onAction={(action, data) => this.props.onAction(action, data)}
switchPage={page => this.props.switchPage(page)}
/>
);
}
renderPopupContent() {
return (
<div style={Styles.popup.parent}>
<div style={Styles.popup.container}>
{this.renderCurrentPage(this.props.page)}
</div>
</div>
);
}
renderFrameContainer() {
const page = this.props.page;
const frameStyle = {
...Styles.frame.common,
...Styles.frame[page]
};
return (
<Frame style={frameStyle} title="membersjs-popup">
{this.renderPopupContent()}
</Frame>
);
}
render() {
return this.renderFrameContainer();
}
}

View File

@ -1,15 +0,0 @@
import React from 'react';
import {render} from '@testing-library/react';
import PopupMenu from './PopupMenu';
import {site} from '../test/fixtures/data';
describe('Popup Menu', () => {
test('renders', () => {
const {getByTitle} = render(
<PopupMenu data={{site}} page='signin' action={{}} onAction={() => {}} />
);
const popupFrame = getByTitle('membersjs-popup');
expect(popupFrame).toBeInTheDocument();
});
});

View File

@ -5,9 +5,9 @@ import AccountHomePage from './pages/AccountHomePage';
import MagicLinkPage from './pages/MagicLinkPage'; import MagicLinkPage from './pages/MagicLinkPage';
import LoadingPage from './pages/LoadingPage'; import LoadingPage from './pages/LoadingPage';
import {ReactComponent as CloseIcon} from '../images/icons/close.svg'; import {ReactComponent as CloseIcon} from '../images/icons/close.svg';
import {ParentContext} from './ParentContext';
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types');
const Styles = { const Styles = {
modalContainer: { modalContainer: {
@ -35,6 +35,19 @@ const Styles = {
height: '60%', height: '60%',
backgroundColor: 'white' backgroundColor: 'white'
}, },
menu: {
position: 'fixed',
padding: '0',
outline: '0',
bottom: '100px',
right: '20px',
borderRadius: '8px',
boxShadow: 'rgba(0, 0, 0, 0.16) 0px 5px 40px',
opacity: '1',
overflow: 'hidden',
height: '60%',
backgroundColor: 'white'
},
signin: { signin: {
minHeight: '200px', minHeight: '200px',
maxHeight: '330px' maxHeight: '330px'
@ -44,8 +57,9 @@ const Styles = {
maxHeight: '620px' maxHeight: '620px'
}, },
accountHome: { accountHome: {
minHeight: '350px', width: '280px',
maxHeight: '510px' minHeight: '200px',
maxHeight: '240px'
}, },
magiclink: { magiclink: {
minHeight: '230px', minHeight: '230px',
@ -94,39 +108,20 @@ const Pages = {
}; };
export default class PopupModal extends React.Component { export default class PopupModal extends React.Component {
static propTypes = { static contextType = ParentContext;
data: PropTypes.shape({
site: PropTypes.shape({
title: PropTypes.string,
description: PropTypes.string
}).isRequired,
member: PropTypes.shape({
email: PropTypes.string
})
}).isRequired,
action: PropTypes.object,
page: PropTypes.string.isRequired,
onAction: PropTypes.func.isRequired
};
renderCurrentPage(page) { renderCurrentPage(page) {
const PageComponent = Pages[page]; const PageComponent = Pages[page];
return ( return (
<PageComponent <PageComponent />
data={this.props.data}
action={this.props.action}
onAction={(action, data) => this.props.onAction(action, data)}
brandColor={this.props.brandColor}
switchPage={page => this.props.switchPage(page)}
/>
); );
} }
renderPopupClose() { renderPopupClose() {
return ( return (
<div style={{display: 'flex', justifyContent: 'flex-end', padding: '0 20px'}}> <div style={{display: 'flex', justifyContent: 'flex-end', padding: '0 20px'}}>
<CloseIcon style={Styles.popup.closeIcon} onClick = {() => this.props.onToggle()} /> <CloseIcon style={Styles.popup.closeIcon} onClick = {() => this.context.onAction('closePopup')} />
</div> </div>
); );
} }
@ -135,7 +130,7 @@ export default class PopupModal extends React.Component {
return ( return (
<div style={Styles.popup.container}> <div style={Styles.popup.container}>
{this.renderPopupClose()} {this.renderPopupClose()}
{this.renderCurrentPage(this.props.page)} {this.renderCurrentPage(this.context.page)}
</div> </div>
); );
} }
@ -143,14 +138,15 @@ export default class PopupModal extends React.Component {
handlePopupClose(e) { handlePopupClose(e) {
e.preventDefault(); e.preventDefault();
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
this.props.onToggle(); this.context.onAction('closePopup');
} }
} }
renderFrameContainer() { renderFrameContainer() {
const page = this.props.page; const page = this.context.page;
const commonStyle = this.context.page === 'accountHome' ? Styles.frame.menu : Styles.frame.common;
const frameStyle = { const frameStyle = {
...Styles.frame.common, ...commonStyle,
...Styles.frame[page] ...Styles.frame[page]
}; };
return ( return (

View File

@ -1,94 +1,93 @@
import Frame from './Frame'; import Frame from './Frame';
import {ParentContext} from './ParentContext';
import {ReactComponent as UserIcon} from '../images/icons/user.svg'; import {ReactComponent as UserIcon} from '../images/icons/user.svg';
import {ReactComponent as CloseIcon} from '../images/icons/close.svg'; import {ReactComponent as CloseIcon} from '../images/icons/close.svg';
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types');
const Styles = { const Styles = ({brandColor}) => {
frame: { return {
zIndex: '2147483000', frame: {
position: 'fixed', zIndex: '2147483000',
bottom: '20px', position: 'fixed',
right: '20px', bottom: '20px',
width: '60px', right: '20px',
height: '60px', width: '60px',
boxShadow: 'rgba(0, 0, 0, 0.06) 0px 1px 6px 0px, rgba(0, 0, 0, 0.16) 0px 2px 32px 0px', height: '60px',
borderRadius: '50%', boxShadow: 'rgba(0, 0, 0, 0.06) 0px 1px 6px 0px, rgba(0, 0, 0, 0.16) 0px 2px 32px 0px',
backgroundColor: '#3EB0EF', borderRadius: '50%',
animation: '250ms ease 0s 1 normal none running animation-bhegco', backgroundColor: brandColor,
transition: 'opacity 0.3s ease 0s' animation: '250ms ease 0s 1 normal none running animation-bhegco',
}, transition: 'opacity 0.3s ease 0s'
launcher: { },
position: 'absolute', launcher: {
top: '0px', position: 'absolute',
left: '0px', top: '0px',
width: '60px', left: '0px',
height: '60px', width: '60px',
cursor: 'pointer', height: '60px',
transformOrigin: 'center center', cursor: 'pointer',
backfaceVisibility: 'hidden', transformOrigin: 'center center',
WebkitFontSmoothing: 'antialiased', backfaceVisibility: 'hidden',
borderRadius: '50%', WebkitFontSmoothing: 'antialiased',
overflow: 'hidden' borderRadius: '50%',
}, overflow: 'hidden'
button: { },
display: 'flex', button: {
WebkitBoxAlign: 'center', display: 'flex',
alignItems: 'center', WebkitBoxAlign: 'center',
WebkitBoxPack: 'center', alignItems: 'center',
justifyContent: 'center', WebkitBoxPack: 'center',
position: 'absolute', justifyContent: 'center',
top: '0px', position: 'absolute',
bottom: '0px', top: '0px',
width: '100%', bottom: '0px',
opacity: '1', width: '100%',
transform: 'rotate(0deg) scale(1)', opacity: '1',
transition: 'transform 0.16s linear 0s, opacity 0.08s linear 0s' transform: 'rotate(0deg) scale(1)',
}, transition: 'transform 0.16s linear 0s, opacity 0.08s linear 0s'
userIcon: { },
width: '20px', userIcon: {
height: '20px', width: '20px',
color: '#fff' height: '20px',
}, color: '#fff'
},
closeIcon: { closeIcon: {
width: '20px', width: '20px',
height: '20px', height: '20px',
color: '#fff' color: '#fff'
} }
};
}; };
export default class TriggerButton extends React.Component { export default class TriggerButton extends React.Component {
static propTypes = { static contextType = ParentContext;
name: PropTypes.string
};
onToggle() { onToggle() {
this.props.onToggle(); this.context.onAction('togglePopup');
} }
renderTriggerIcon() { renderTriggerIcon() {
const Style = Styles({brandColor: this.context.brandColor});
if (this.props.isPopupOpen) { if (this.props.isPopupOpen) {
return ( return (
<CloseIcon style={Styles.closeIcon} /> <CloseIcon style={Style.closeIcon} />
); );
} }
return ( return (
<UserIcon style={Styles.userIcon} /> <UserIcon style={Style.userIcon} />
); );
} }
render() { render() {
const frameStyle = { const Style = Styles({brandColor: this.context.brandColor});
...Styles.frame,
backgroundColor: this.props.brandColor || '#3EB0EF'
};
return ( return (
<Frame style={frameStyle} title="membersjs-trigger"> <Frame style={Style.frame} title="membersjs-trigger">
<div style={Styles.launcher} onClick={e => this.onToggle(e)}> <div style={Style.launcher} onClick={e => this.onToggle(e)}>
<div style={Styles.button}> <div style={Style.button}>
{this.renderTriggerIcon()} {this.renderTriggerIcon()}
</div> </div>
</div> </div>

View File

@ -1,34 +1,20 @@
import {ParentContext} from '../ParentContext';
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types');
export default class AccountHomePage extends React.Component { export default class AccountHomePage extends React.Component {
static propTypes = { static contextType = ParentContext;
data: PropTypes.shape({
site: PropTypes.shape({
title: PropTypes.string,
description: PropTypes.string
}).isRequired,
member: PropTypes.shape({
email: PropTypes.string
}).isRequired
}).isRequired,
onAction: PropTypes.func
};
handleSignout(e) { handleSignout(e) {
e.preventDefault(); e.preventDefault();
this.props.onAction('signout'); this.context.onAction('signout');
} }
handlePlanCheckout(e) { handlePlanCheckout(e) {
e.preventDefault(); e.preventDefault();
const plan = e.target.name; const plan = e.target.name;
const email = this.getMemberEmail(); const email = this.context.member.email;
this.props.onAction('checkoutPlan', {email, plan}); this.context.onAction('checkoutPlan', {email, plan});
}
getMemberEmail() {
return this.props.data.member.email;
} }
renderPlanSelectButton({name}) { renderPlanSelectButton({name}) {
@ -45,7 +31,7 @@ export default class AccountHomePage extends React.Component {
cursor: 'pointer', cursor: 'pointer',
transition: '.4s ease', transition: '.4s ease',
color: '#fff', color: '#fff',
backgroundColor: this.props.brandColor || '#3eb0ef', backgroundColor: this.context.brandColor,
boxShadow: 'none', boxShadow: 'none',
userSelect: 'none', userSelect: 'none',
width: '90px', width: '90px',
@ -110,8 +96,8 @@ export default class AccountHomePage extends React.Component {
border: '1px solid black', border: '1px solid black',
marginBottom: '12px' marginBottom: '12px'
}; };
const siteTitle = this.props.data.site && this.props.data.site.title; const siteTitle = this.context.site.title;
const plans = this.props.data.site && this.props.data.site.plans; const plans = this.context.site.plans;
return ( return (
<div style={{padding: '12px 12px'}}> <div style={{padding: '12px 12px'}}>
<div style={{marginBottom: '12px', fontSize: '14px'}}> <div style={{marginBottom: '12px', fontSize: '14px'}}>
@ -127,7 +113,7 @@ export default class AccountHomePage extends React.Component {
} }
renderHeader() { renderHeader() {
const memberEmail = this.getMemberEmail(); const memberEmail = this.context.member.email;
return ( return (
<> <>
@ -142,7 +128,7 @@ export default class AccountHomePage extends React.Component {
} }
renderUserAvatar() { renderUserAvatar() {
const avatarImg = (this.props.data.member && this.props.data.member.avatar_image); const avatarImg = (this.context.member && this.context.member.avatar_image);
const logoStyle = { const logoStyle = {
position: 'relative', position: 'relative',
@ -167,8 +153,8 @@ export default class AccountHomePage extends React.Component {
} }
renderUserHeader() { renderUserHeader() {
const memberEmail = this.getMemberEmail(); const memberEmail = this.context.member.email;
const memberName = this.props.data.member.name; const memberName = this.context.member.name;
return ( return (
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '24px'}}> <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '24px'}}>

View File

@ -1,14 +1,12 @@
import React from 'react'; import React from 'react';
import {render, fireEvent} from '@testing-library/react'; import {render, fireEvent} from '../../utils/tests';
import AccountHomePage from './AccountHomePage'; import AccountHomePage from './AccountHomePage';
import {site, member} from '../../test/fixtures/data'; import {member} from '../../test/fixtures/data';
const setup = (overrides) => { const setup = (overrides) => {
const mockOnActionFn = jest.fn();
const mockSwitchPageFn = jest.fn();
const freeMember = member.free; const freeMember = member.free;
const utils = render( const {mockOnActionFn, ...utils} = render(
<AccountHomePage data={{site, member: freeMember}} onAction={mockOnActionFn} switchPage={mockSwitchPageFn} /> <AccountHomePage />
); );
const memberEmail = utils.getByText(freeMember.email); const memberEmail = utils.getByText(freeMember.email);
const logoutButton = utils.queryByRole('button', {name: 'Log out'}); const logoutButton = utils.queryByRole('button', {name: 'Log out'});
@ -16,7 +14,6 @@ const setup = (overrides) => {
memberEmail, memberEmail,
logoutButton, logoutButton,
mockOnActionFn, mockOnActionFn,
mockSwitchPageFn,
...utils ...utils
}; };
}; };

View File

@ -1,7 +1,10 @@
import ActionButton from '../common/ActionButton'; import ActionButton from '../common/ActionButton';
import {ParentContext} from '../ParentContext';
const React = require('react'); const React = require('react');
export default class MagicLinkPage extends React.Component { export default class MagicLinkPage extends React.Component {
static contextType = ParentContext;
renderFormHeader() { renderFormHeader() {
return ( return (
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '18px'}}> <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '18px'}}>
@ -14,13 +17,13 @@ export default class MagicLinkPage extends React.Component {
renderLoginMessage() { renderLoginMessage() {
return ( return (
<div style={{display: 'flex', justifyContent: 'center'}}> <div style={{display: 'flex', justifyContent: 'center'}}>
<div style={{color: '#3db0ef', fontWeight: 'bold', cursor: 'pointer'}} onClick={() => this.props.switchPage('signin')}> Back to Log in </div> <div style={{color: '#3db0ef', fontWeight: 'bold', cursor: 'pointer'}} onClick={() => this.context.onAction('switchPage', 'signin')}> Back to Log in </div>
</div> </div>
); );
} }
handleClose(e) { handleClose(e) {
this.props.onAction('closePopup'); this.context.onAction('closePopup');
} }
renderCloseButton() { renderCloseButton() {
@ -28,7 +31,7 @@ export default class MagicLinkPage extends React.Component {
return ( return (
<ActionButton <ActionButton
onClick={e => this.handleSignin(e)} onClick={e => this.handleSignin(e)}
brandColor={this.props.brandColor} brandColor={this.context.brandColor}
label={label} label={label}
/> />
); );

View File

@ -1,19 +1,11 @@
import ActionButton from '../common/ActionButton'; import ActionButton from '../common/ActionButton';
import InputField from '../common/InputField'; import InputField from '../common/InputField';
import {ParentContext} from '../ParentContext';
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types');
export default class SigninPage extends React.Component { export default class SigninPage extends React.Component {
static propTypes = { static contextType = ParentContext;
data: PropTypes.shape({
site: PropTypes.shape({
title: PropTypes.string,
description: PropTypes.string
}).isRequired
}).isRequired,
onAction: PropTypes.func.isRequired
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -26,7 +18,7 @@ export default class SigninPage extends React.Component {
e.preventDefault(); e.preventDefault();
const email = this.state.email; const email = this.state.email;
this.props.onAction('signin', {email}); this.context.onAction('signin', {email});
} }
handleInput(e, field) { handleInput(e, field) {
@ -38,14 +30,14 @@ export default class SigninPage extends React.Component {
} }
renderSubmitButton() { renderSubmitButton() {
const isRunning = this.props.action && this.props.action.name === 'signin' && this.props.action.isRunning; const isRunning = (this.context.action === 'signin:running');
const label = this.state.isLoading ? 'Sending' : 'Send Login Link'; const label = isRunning ? 'Sending' : 'Send Login Link';
const disabled = isRunning ? true : false; const disabled = isRunning ? true : false;
return ( return (
<ActionButton <ActionButton
onClick={e => this.handleSignin(e)} onClick={e => this.handleSignin(e)}
disabled={disabled} disabled={disabled}
brandColor={this.props.brandColor} brandColor={this.context.brandColor}
label={label} label={label}
/> />
); );
@ -75,11 +67,11 @@ export default class SigninPage extends React.Component {
} }
renderSignupMessage() { renderSignupMessage() {
const color = this.props.brandColor || '#3db0ef'; const brandColor = this.context.brandColor;
return ( return (
<div style={{display: 'flex', justifyContent: 'center'}}> <div style={{display: 'flex', justifyContent: 'center'}}>
<div style={{marginRight: '6px', color: '#929292'}}> Don't have an account ? </div> <div style={{marginRight: '6px', color: '#929292'}}> Don't have an account ? </div>
<div style={{color, fontWeight: 'bold', cursor: 'pointer'}} role="button" onClick={() => this.props.switchPage('signup')}> Subscribe </div> <div style={{color: brandColor, fontWeight: 'bold', cursor: 'pointer'}} role="button" onClick={() => this.context.onAction('switchPage', 'signup')}> Subscribe </div>
</div> </div>
); );
} }
@ -95,7 +87,7 @@ export default class SigninPage extends React.Component {
} }
renderSiteLogo() { renderSiteLogo() {
const siteLogo = (this.props.data.site && this.props.data.site.logo); const siteLogo = this.context.site.logo;
const logoStyle = { const logoStyle = {
position: 'relative', position: 'relative',
@ -119,7 +111,7 @@ export default class SigninPage extends React.Component {
} }
renderFormHeader() { renderFormHeader() {
const siteTitle = (this.props.data.site && this.props.data.site.title) || 'Site Title'; const siteTitle = this.context.site.title || 'Site Title';
return ( return (
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '18px'}}> <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '18px'}}>

View File

@ -1,14 +1,10 @@
import React from 'react'; import React from 'react';
import {render, fireEvent} from '@testing-library/react'; import {render, fireEvent} from '../../utils/tests';
import SigninPage from './SigninPage'; import SigninPage from './SigninPage';
import {site} from '../../test/fixtures/data';
const setup = (overrides) => { const setup = (overrides) => {
const mockOnActionFn = jest.fn(); const {mockOnActionFn, ...utils} = render(
const mockSwitchPageFn = jest.fn(); <SigninPage />
const utils = render(
<SigninPage data={{site}} onAction={mockOnActionFn} switchPage={mockSwitchPageFn} />
); );
const emailInput = utils.getByLabelText(/email/i); const emailInput = utils.getByLabelText(/email/i);
const submitButton = utils.queryByRole('button', {name: 'Send Login Link'}); const submitButton = utils.queryByRole('button', {name: 'Send Login Link'});
@ -18,7 +14,6 @@ const setup = (overrides) => {
submitButton, submitButton,
signupButton, signupButton,
mockOnActionFn, mockOnActionFn,
mockSwitchPageFn,
...utils ...utils
}; };
}; };
@ -43,9 +38,9 @@ describe('SigninPage', () => {
}); });
test('can call swithPage for signup', () => { test('can call swithPage for signup', () => {
const {signupButton, mockSwitchPageFn} = setup(); const {signupButton, mockOnActionFn} = setup();
fireEvent.click(signupButton); fireEvent.click(signupButton);
expect(mockSwitchPageFn).toHaveBeenCalledWith('signup'); expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', 'signup');
}); });
}); });

View File

@ -1,57 +1,46 @@
import ActionButton from '../common/ActionButton'; import ActionButton from '../common/ActionButton';
import InputField from '../common/InputField'; import InputField from '../common/InputField';
import {ParentContext} from '../ParentContext';
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types');
export default class SignupPage extends React.Component { class SignupPage extends React.Component {
static propTypes = { static contextType = ParentContext;
data: PropTypes.shape({
site: PropTypes.shape({
title: PropTypes.string,
description: PropTypes.string
}).isRequired
}).isRequired,
onAction: PropTypes.func.isRequired,
switchPage: PropTypes.func.isRequired
};
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
name: '', name: '',
email: '', email: '',
plan: 'FREE', plan: 'FREE'
isLoading: false,
showSuccess: false
}; };
} }
handleSignup(e) { handleSignup(e) {
e.preventDefault(); e.preventDefault();
const {onAction} = this.context;
const email = this.state.email; const email = this.state.email;
const name = this.state.name; const name = this.state.name;
const plan = this.state.plan; const plan = this.state.plan;
this.props.onAction('signup', {name, email, plan}); onAction('signup', {name, email, plan});
} }
handleInput(e, field) { handleInput(e, field) {
this.setState({ this.setState({
[field]: e.target.value, [field]: e.target.value
showSuccess: false,
isLoading: false
}); });
} }
renderSubmitButton() { renderSubmitButton() {
const isRunning = this.props.action && this.props.action.name === 'signup' && this.props.action.isRunning; const {action, brandColor} = this.context;
const label = this.state.isLoading ? 'Sending...' : 'Continue';
const disabled = isRunning ? true : false; const label = (action === 'signup:running') ? 'Sending...' : 'Continue';
const disabled = (action === 'signup:running') ? true : false;
return ( return (
<ActionButton <ActionButton
onClick={e => this.handleSignup(e)} onClick={e => this.handleSignup(e)}
disabled={disabled} disabled={disabled}
brandColor={this.props.brandColor} brandColor={brandColor}
label={label} label={label}
/> />
); );
@ -161,7 +150,8 @@ export default class SignupPage extends React.Component {
borderRadius: '9px', borderRadius: '9px',
marginBottom: '12px' marginBottom: '12px'
}; };
const plans = this.props.data.site && this.props.data.site.plans; let {site} = this.context;
const plans = site.plans;
if (!plans) { if (!plans) {
return null; return null;
} }
@ -210,11 +200,11 @@ export default class SignupPage extends React.Component {
} }
renderLoginMessage() { renderLoginMessage() {
const color = this.props.brandColor || '#3db0ef'; const {brandColor, onAction} = this.context;
return ( return (
<div style={{display: 'flex', justifyContent: 'center'}}> <div style={{display: 'flex', justifyContent: 'center'}}>
<div style={{marginRight: '6px', color: '#929292'}}> Already a member ? </div> <div style={{marginRight: '6px', color: '#929292'}}> Already a member ? </div>
<div style={{color, fontWeight: 'bold', cursor: 'pointer'}} role="button" onClick={() => this.props.switchPage('signin')}> Log in </div> <div style={{color: brandColor, fontWeight: 'bold', cursor: 'pointer'}} role="button" onClick={() => onAction('switchPage', 'signin')}> Log in </div>
</div> </div>
); );
} }
@ -232,7 +222,8 @@ export default class SignupPage extends React.Component {
} }
renderSiteLogo() { renderSiteLogo() {
const siteLogo = (this.props.data.site && this.props.data.site.logo); const {site} = this.context;
const siteLogo = site.logo;
const logoStyle = { const logoStyle = {
position: 'relative', position: 'relative',
@ -256,7 +247,8 @@ export default class SignupPage extends React.Component {
} }
renderFormHeader() { renderFormHeader() {
const siteTitle = (this.props.data.site && this.props.data.site.title) || 'Site Title'; const {site} = this.context;
const siteTitle = site.title || 'Site Title';
return ( return (
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '18px'}}> <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '18px'}}>
@ -277,3 +269,5 @@ export default class SignupPage extends React.Component {
); );
} }
} }
export default SignupPage;

View File

@ -1,14 +1,10 @@
import React from 'react'; import React from 'react';
import {render, fireEvent} from '@testing-library/react';
import SignupPage from './SignupPage'; import SignupPage from './SignupPage';
import {site} from '../../test/fixtures/data'; import {render, fireEvent} from '../../utils/tests';
const setup = (overrides) => { const setup = (overrides) => {
const mockOnActionFn = jest.fn(); const {mockOnActionFn, ...utils} = render(
const mockSwitchPageFn = jest.fn(); <SignupPage />
const utils = render(
<SignupPage data={{site}} onAction={mockOnActionFn} switchPage={mockSwitchPageFn} />
); );
const emailInput = utils.getByLabelText(/email/i); const emailInput = utils.getByLabelText(/email/i);
const nameInput = utils.getByLabelText(/name/i); const nameInput = utils.getByLabelText(/name/i);
@ -20,7 +16,6 @@ const setup = (overrides) => {
submitButton, submitButton,
signinButton, signinButton,
mockOnActionFn, mockOnActionFn,
mockSwitchPageFn,
...utils ...utils
}; };
}; };
@ -51,9 +46,9 @@ describe('SignupPage', () => {
}); });
test('can call swithPage for signin', () => { test('can call swithPage for signin', () => {
const {signinButton, mockSwitchPageFn} = setup(); const {signinButton, mockOnActionFn} = setup();
fireEvent.click(signinButton); fireEvent.click(signinButton);
expect(mockSwitchPageFn).toHaveBeenCalledWith('signin'); expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', 'signin');
}); });
}); });

View File

@ -0,0 +1,40 @@
// Common test setup util - Ref: https://testing-library.com/docs/react-testing-library/setup#custom-render
import React from 'react';
import {render} from '@testing-library/react';
import {ParentContext} from '../components/ParentContext';
import {site, member} from '../test/fixtures/data';
const setupProvider = (context) => {
return ({children}) => {
return (
<ParentContext.Provider value={context}>
{children}
</ParentContext.Provider>
);
};
};
const customRender = (ui, {options = {}, overrideContext = {}} = {}) => {
const mockOnActionFn = jest.fn();
const context = {
site,
member: member.free,
action: 'init:success',
brandColor: site.brand.primaryColor,
page: 'signup',
onAction: mockOnActionFn,
...overrideContext
};
const utils = render(ui, {wrapper: setupProvider(context), ...options});
return {
...utils,
mockOnActionFn
};
};
// re-export everything
export * from '@testing-library/react';
// override render method
export {customRender as render};