0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Refactered App structure and naming

no issue

- Moved `ParentContainer.js` to redundant empty top level `App.js` to remove extra nesting and improved naming
- Renamed `ParentContext` to `AppContext` and exported as default
- Fixed imports in all the components and test-utils with new structure
- Passed `siteUrl` to api setup from main App to allow easier configuration
- Updated App tests
This commit is contained in:
Rish 2020-06-12 13:51:00 +05:30
parent 1fc8f86507
commit 8df822d317
14 changed files with 332 additions and 317 deletions

View file

@ -1,13 +1,279 @@
import React from 'react';
import TriggerButton from './components/TriggerButton';
import PopupModal from './components/PopupModal';
import setupGhostApi from './utils/api';
import AppContext from './AppContext';
import './App.css';
import ParentContainer from './components/ParentContainer';
function App(props) {
return (
<div className="App">
<ParentContainer data={props.data} />
</div>
);
const React = require('react');
export default class App extends React.Component {
constructor(props) {
super(props);
// Setup custom trigger button handling
this.setupCustomTriggerButton();
// testState is used by App.test to pass custom default state for testing
this.state = props.testState || {
site: null,
member: null,
page: 'loading',
showPopup: false,
action: 'init:running',
initStatus: 'running',
lastPage: null
};
}
componentDidMount() {
this.fetchData();
}
componentDidUpdate(prevProps, prevState) {
if (prevState.showPopup !== this.state.showPopup) {
this.handleCustomTriggerClassUpdate();
}
}
componentWillUnmount() {
this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.addEventListener('click', this.clickHandler);
});
}
handleCustomTriggerClassUpdate() {
const popupOpenClass = 'gh-members-popup-open';
const popupCloseClass = 'gh-members-popup-close';
this.customTriggerButtons.forEach((customButton) => {
const elAddClass = this.state.showPopup ? popupOpenClass : popupCloseClass;
const elRemoveClass = this.state.showPopup ? popupCloseClass : popupOpenClass;
customButton.classList.add(elAddClass);
customButton.classList.remove(elRemoveClass);
});
}
getStripeUrlParam() {
const url = new URL(window.location);
return url.searchParams.get('stripe');
}
getDefaultPage({member = this.state.member, stripeParam} = {}) {
// Loads default page and popup state for local UI testing
if (process.env.NODE_ENV === 'development') {
return {
page: 'signup',
showPopup: true
};
}
if (!member && stripeParam === 'success') {
return {page: 'magiclink', showPopup: true};
}
if (member) {
return {
page: 'accountHome'
};
}
return {
page: 'signup'
};
}
// Fetch site and member session data with Ghost Apis
async fetchData() {
const {siteUrl} = this.props;
try {
this.GhostApi = setupGhostApi({siteUrl});
const {site, member} = await this.GhostApi.init();
site.isStripeConfigured = (site.isStripeConfigured === undefined) || site.isStripeConfigured;
const stripeParam = this.getStripeUrlParam();
const {page, showPopup = false} = this.getDefaultPage({member, stripeParam});
this.setState({
site,
member,
page,
showPopup,
action: 'init:success',
initStatus: 'success'
});
} catch (e) {
/* eslint-disable no-console */
console.error(`[Members.js] Failed to initialize`);
/* eslint-enable no-console */
this.setState({
action: 'init:failed',
initStatus: 'failed'
});
}
}
setupCustomTriggerButton() {
// Handler for custom buttons
this.clickHandler = (event) => {
const target = event.currentTarget;
const page = target && target.dataset.membersTriggerButton;
event.preventDefault();
this.onAction('openPopup', {page});
};
const customTriggerSelector = '[data-members-trigger-button]';
const popupCloseClass = 'gh-members-popup-close';
this.customTriggerButtons = document.querySelectorAll(customTriggerSelector) || [];
this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.classList.add(popupCloseClass);
customTriggerButton.addEventListener('click', this.clickHandler);
});
}
getActionData(action) {
const [type, status, reason] = action.split(':');
return {type, status, reason};
}
getBrandColor() {
return (this.state.site && this.state.site.brand && this.state.site.brand.primaryColor) || '#3db0ef';
}
async onAction(action, data) {
this.setState({
action: `${action}:running`
});
try {
if (action === 'switchPage') {
this.setState({
page: data.page,
lastPage: data.lastPage || null
});
} else if (action === 'togglePopup') {
this.setState({
showPopup: !this.state.showPopup
});
} else if (action === 'openPopup') {
this.setState({
showPopup: true,
page: data.page
});
} else if (action === 'back') {
if (this.state.lastPage) {
this.setState({
page: this.state.lastPage
});
}
} else if (action === 'closePopup') {
const {page: defaultPage} = this.getDefaultPage();
this.setState({
showPopup: false,
page: this.state.page === 'magiclink' ? defaultPage : this.state.page
});
} else if (action === 'signout') {
await this.GhostApi.member.signout();
this.setState({
action: 'signout:success'
});
} else if (action === 'signin') {
await this.GhostApi.member.sendMagicLink(data);
this.setState({
action: 'signin:success',
page: 'magiclink'
});
} else if (action === 'signup') {
const {plan, email, name} = data;
if (plan.toLowerCase() === 'free') {
await this.GhostApi.member.sendMagicLink(data);
} else {
await this.GhostApi.member.checkoutPlan({plan, email, name});
}
this.setState({
action: 'signup:success',
page: 'magiclink'
});
} else if (action === 'updateEmail') {
await this.GhostApi.member.sendMagicLink(data);
this.setState({
action: 'updateEmail:success'
});
} else if (action === 'checkoutPlan') {
const {plan} = data;
await this.GhostApi.member.checkoutPlan({
plan
});
} else if (action === 'updateSubscription') {
const {plan, subscriptionId, cancelAtPeriodEnd} = data;
await this.GhostApi.member.updateSubscription({
planName: plan, subscriptionId, cancelAtPeriodEnd
});
const member = await this.GhostApi.member.sessionData();
this.setState({
action: 'updateSubscription:success',
page: 'accountHome',
member: member
});
} else if (action === 'editBilling') {
await this.GhostApi.member.editBilling();
} else if (action === 'updateMember') {
const {name, subscribed} = data;
const member = await this.GhostApi.member.update({name, subscribed});
if (!member) {
this.setState({
action: 'updateMember:failed'
});
} else {
this.setState({
action: 'updateMember:success',
member: member
});
}
}
setTimeout(() => {
this.setState({
action: ''
});
}, 5000);
} catch (e) {
this.setState({
action: `${action}:failed`
});
}
}
renderPopup() {
if (this.state.showPopup) {
return (
<PopupModal />
);
}
return null;
}
renderTriggerButton() {
if (!this.customTriggerButtons || this.customTriggerButtons.length === 0) {
return (
<TriggerButton
isPopupOpen={this.state.showPopup}
/>
);
}
return null;
}
render() {
if (this.state.initStatus === 'success') {
const {site, member, action, page, lastPage} = this.state;
return (
<AppContext.Provider value={{
site,
member,
action,
brandColor: this.getBrandColor(),
page,
lastPage,
onAction: (_action, data) => this.onAction(_action, data)
}}>
{this.renderPopup()}
{this.renderTriggerButton()}
</AppContext.Provider>
);
}
return null;
}
}
export default App;

View file

@ -1,13 +1,35 @@
import React from 'react';
import {render} from '@testing-library/react';
import {site, member} from './utils/fixtures';
import App from './App';
test('renders App', () => {
const {container} = render(
<App />
const setup = (overrides) => {
const testState = {
site,
member: member.free,
action: 'init:success',
brandColor: site.brand.primaryColor,
page: 'signup',
initStatus: 'success',
showPopup: true
};
const {...utils} = render(
<App testState={testState} />
);
const triggerButtonFrame = utils.getByTitle(/membersjs-trigger/i);
const popupFrame = utils.getByTitle(/membersjs-popup/i);
return {
popupFrame,
triggerButtonFrame,
...utils
};
};
// dashboard component should be rendered on root route
const element = container.querySelector('.App');
expect(element).toBeInTheDocument();
describe('App', () => {
test('renders popup and trigger frames', () => {
const {popupFrame, triggerButtonFrame} = setup();
expect(popupFrame).toBeInTheDocument();
expect(triggerButtonFrame).toBeInTheDocument();
});
});

View file

@ -1,10 +1,12 @@
// Ref: https://reactjs.org/docs/context.html
const React = require('react');
export const ParentContext = React.createContext({
const AppContext = React.createContext({
site: {},
member: {},
action: '',
brandColor: '',
onAction: () => {}
});
});
export default AppContext;

View file

@ -1,275 +0,0 @@
import TriggerButton from './TriggerButton';
import PopupModal from './PopupModal';
import setupGhostApi from '../utils/api';
import {ParentContext} from './ParentContext';
const React = require('react');
export default class ParentContainer extends React.Component {
constructor(props) {
super(props);
// Setup custom trigger button handling
this.setupCustomTriggerButton();
this.state = {
page: 'loading',
showPopup: false,
action: 'init:running',
initStatus: 'running',
lastPage: null
};
}
componentDidMount() {
this.fetchData();
}
componentDidUpdate(prevProps, prevState) {
if (prevState.showPopup !== this.state.showPopup) {
this.handleCustomTriggerClassUpdate();
}
}
componentWillUnmount() {
this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.addEventListener('click', this.clickHandler);
});
}
handleCustomTriggerClassUpdate() {
const popupOpenClass = 'gh-members-popup-open';
const popupCloseClass = 'gh-members-popup-close';
this.customTriggerButtons.forEach((customButton) => {
const elAddClass = this.state.showPopup ? popupOpenClass : popupCloseClass;
const elRemoveClass = this.state.showPopup ? popupCloseClass : popupOpenClass;
customButton.classList.add(elAddClass);
customButton.classList.remove(elRemoveClass);
});
}
getStripeUrlParam() {
const url = new URL(window.location);
return url.searchParams.get('stripe');
}
getDefaultPage({member = this.state.member, stripeParam} = {}) {
// Loads default page and popup state for local UI testing
if (process.env.NODE_ENV === 'development') {
return {
page: 'signup',
showPopup: true
};
}
if (!member && stripeParam === 'success') {
return {page: 'magiclink', showPopup: true};
}
if (member) {
return {
page: 'accountHome'
};
}
return {
page: 'signup'
};
}
// Fetch site and member session data with Ghost Apis
async fetchData() {
try {
this.GhostApi = setupGhostApi();
const {site, member} = await this.GhostApi.init();
site.isStripeConfigured = (site.isStripeConfigured === undefined) || site.isStripeConfigured;
const stripeParam = this.getStripeUrlParam();
const {page, showPopup = false} = this.getDefaultPage({member, stripeParam});
this.setState({
site,
member,
page,
showPopup,
action: 'init:success',
initStatus: 'success'
});
} catch (e) {
/* eslint-disable no-console */
console.error(`[Members.js] Failed to initialize`);
/* eslint-enable no-console */
this.setState({
action: 'init:failed',
initStatus: 'failed'
});
}
}
setupCustomTriggerButton() {
// Handler for custom buttons
this.clickHandler = (event) => {
const target = event.currentTarget;
const page = target && target.dataset.membersTriggerButton;
event.preventDefault();
this.onAction('openPopup', {page});
};
const customTriggerSelector = '[data-members-trigger-button]';
const popupCloseClass = 'gh-members-popup-close';
this.customTriggerButtons = document.querySelectorAll(customTriggerSelector) || [];
this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.classList.add(popupCloseClass);
customTriggerButton.addEventListener('click', this.clickHandler);
});
}
getActionData(action) {
const [type, status, reason] = action.split(':');
return {type, status, reason};
}
getBrandColor() {
return (this.state.site && this.state.site.brand && this.state.site.brand.primaryColor) || '#3db0ef';
}
async onAction(action, data) {
this.setState({
action: `${action}:running`
});
try {
if (action === 'switchPage') {
this.setState({
page: data.page,
lastPage: data.lastPage || null
});
} else if (action === 'togglePopup') {
this.setState({
showPopup: !this.state.showPopup
});
} else if (action === 'openPopup') {
this.setState({
showPopup: true,
page: data.page
});
} else if (action === 'back') {
if (this.state.lastPage) {
this.setState({
page: this.state.lastPage
});
}
} else if (action === 'closePopup') {
const {page: defaultPage} = this.getDefaultPage();
this.setState({
showPopup: false,
page: this.state.page === 'magiclink' ? defaultPage : this.state.page
});
} else if (action === 'signout') {
await this.GhostApi.member.signout();
this.setState({
action: 'signout:success'
});
} else if (action === 'signin') {
await this.GhostApi.member.sendMagicLink(data);
this.setState({
action: 'signin:success',
page: 'magiclink'
});
} else if (action === 'signup') {
const {plan, email, name} = data;
if (plan.toLowerCase() === 'free') {
await this.GhostApi.member.sendMagicLink(data);
} else {
await this.GhostApi.member.checkoutPlan({plan, email, name});
}
this.setState({
action: 'signup:success',
page: 'magiclink'
});
} else if (action === 'updateEmail') {
await this.GhostApi.member.sendMagicLink(data);
this.setState({
action: 'updateEmail:success'
});
} else if (action === 'checkoutPlan') {
const {plan} = data;
await this.GhostApi.member.checkoutPlan({
plan
});
} else if (action === 'updateSubscription') {
const {plan, subscriptionId, cancelAtPeriodEnd} = data;
await this.GhostApi.member.updateSubscription({
planName: plan, subscriptionId, cancelAtPeriodEnd
});
const member = await this.GhostApi.member.sessionData();
this.setState({
action: 'updateSubscription:success',
page: 'accountHome',
member: member
});
} else if (action === 'editBilling') {
await this.GhostApi.member.editBilling();
} else if (action === 'updateMember') {
const {name, subscribed} = data;
const member = await this.GhostApi.member.update({name, subscribed});
if (!member) {
this.setState({
action: 'updateMember:failed'
});
} else {
this.setState({
action: 'updateMember:success',
member: member
});
}
}
setTimeout(() => {
this.setState({
action: ''
});
}, 5000);
} catch (e) {
this.setState({
action: `${action}:failed`
});
}
}
renderPopup() {
if (this.state.showPopup) {
return (
<PopupModal />
);
}
return null;
}
renderTriggerButton() {
if (!this.customTriggerButtons || this.customTriggerButtons.length === 0) {
return (
<TriggerButton
isPopupOpen={this.state.showPopup}
/>
);
}
return null;
}
render() {
if (this.state.initStatus === 'success') {
const {site, member} = this.state;
return (
<ParentContext.Provider value={{
site,
member,
action: this.state.action,
brandColor: this.getBrandColor(),
page: this.state.page,
lastPage: this.state.lastPage,
onAction: (action, data) => this.onAction(action, data)
}}>
{this.renderPopup()}
{this.renderTriggerButton()}
</ParentContext.Provider>
);
}
return null;
}
}

View file

@ -5,7 +5,7 @@ import AccountHomePage from './pages/AccountHomePage';
import MagicLinkPage from './pages/MagicLinkPage';
import LoadingPage from './pages/LoadingPage';
import {ReactComponent as CloseIcon} from '../images/icons/close.svg';
import {ParentContext} from './ParentContext';
import AppContext from '../AppContext';
import FrameStyle from './Frame.styles';
import AccountPlanPage from './pages/AccountPlanPage';
import AccountProfilePage from './pages/AccountProfilePage';
@ -42,6 +42,7 @@ const StylesWrapper = ({member}) => {
width: '100%',
height: '100%',
overflow: 'auto',
textAlign: 'center',
backgroundColor: 'rgba(128,128,128,0.5)'
},
frame: {
@ -123,7 +124,7 @@ const Pages = {
};
export default class PopupModal extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
renderCurrentPage(page) {
const PageComponent = Pages[page];

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import Frame from './Frame';
import MemberGravatar from './common/MemberGravatar';
import {ParentContext} from './ParentContext';
import AppContext from '../AppContext';
import {ReactComponent as UserIcon} from '../images/icons/user.svg';
import {ReactComponent as CloseIcon} from '../images/icons/close.svg';
const React = require('react');
@ -62,7 +62,7 @@ const Styles = ({brandColor}) => {
};
export default class TriggerButton extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
onToggle() {
this.context.onAction('togglePopup');

View file

@ -1,4 +1,4 @@
import {ParentContext} from '../ParentContext';
import AppContext from '../../AppContext';
import MemberAvatar from '../common/MemberGravatar';
import ActionButton from '../common/ActionButton';
import Switch from '../common/Switch';
@ -59,7 +59,7 @@ const UserHeader = ({member}) => {
};
class FreeAccountHomePage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
handleSignout(e) {
e.preventDefault();
@ -120,7 +120,7 @@ class FreeAccountHomePage extends React.Component {
}
class PaidAccountHomePage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
handleSignout(e) {
e.preventDefault();
@ -254,7 +254,7 @@ class PaidAccountHomePage extends React.Component {
}
}
export default class AccountHomePage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
render() {
const {member} = this.context;

View file

@ -1,11 +1,11 @@
import {ParentContext} from '../ParentContext';
import AppContext from '../../AppContext';
import ActionButton from '../common/ActionButton';
import PlansSection from '../common/PlansSection';
const React = require('react');
export default class AccountPlanPage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
constructor(props, context) {
super(props, context);

View file

@ -1,4 +1,4 @@
import {ParentContext} from '../ParentContext';
import AppContext from '../../AppContext';
import MemberAvatar from '../common/MemberGravatar';
import ActionButton from '../common/ActionButton';
import InputField from '../common/InputField';
@ -7,7 +7,7 @@ import Switch from '../common/Switch';
const React = require('react');
export default class AccountProfilePage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
constructor(props, context) {
super(props, context);

View file

@ -1,9 +1,9 @@
import ActionButton from '../common/ActionButton';
import {ParentContext} from '../ParentContext';
import AppContext from '../../AppContext';
const React = require('react');
export default class MagicLinkPage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
renderFormHeader() {
return (

View file

@ -1,11 +1,11 @@
import ActionButton from '../common/ActionButton';
import InputField from '../common/InputField';
import {ParentContext} from '../ParentContext';
import AppContext from '../../AppContext';
const React = require('react');
export default class SigninPage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
constructor(props) {
super(props);

View file

@ -1,12 +1,12 @@
import ActionButton from '../common/ActionButton';
import InputField from '../common/InputField';
import {ParentContext} from '../ParentContext';
import AppContext from '../../AppContext';
import PlansSection from '../common/PlansSection';
const React = require('react');
class SignupPage extends React.Component {
static contextType = ParentContext;
static contextType = AppContext;
constructor(props) {
super(props);

View file

@ -1,12 +1,11 @@
import * as Fixtures from './fixtures';
function setupGhostApi() {
function setupGhostApi({siteUrl = window.location.origin}) {
const apiPath = 'members/api';
const siteUrl = window.location.origin;
function endpointFor({type, resource}) {
if (type === 'members') {
return `${siteUrl}/${apiPath}/${resource}/`;
return `${siteUrl.replace(/\/$/, '')}/${apiPath}/${resource}/`;
}
}

View file

@ -1,15 +1,15 @@
// 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 AppContext from '../AppContext';
import {site, member} from './fixtures';
const setupProvider = (context) => {
return ({children}) => {
return (
<ParentContext.Provider value={context}>
<AppContext.Provider value={context}>
{children}
</ParentContext.Provider>
</AppContext.Provider>
);
};
};