Merge branch 'master' into fix/#2783

This commit is contained in:
Jim LAURIE 2019-02-28 18:58:51 +01:00 committed by GitHub
commit 68ea58d8c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1116 additions and 73 deletions

View File

@ -0,0 +1,170 @@
/**
*
* OnboardingList
*
*/
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Modal, ModalHeader, ModalBody } from 'reactstrap';
import { Player } from 'video-react';
import '../../../../node_modules/video-react/dist/video-react.css';
import styles from './styles.scss';
class OnboardingVideo extends React.Component {
componentDidMount() {
this.hiddenPlayer.current.subscribeToStateChange(
this.handleChangeState,
);
}
hiddenPlayer = React.createRef();
player = React.createRef();
handleChangeState = (state, prevState) => {
const { duration } = state;
const { id } = this.props;
if (duration !== prevState.duration) {
this.props.setVideoDuration(id, duration);
}
};
handleChangeIsPlayingState = (state, prevState) => {
const { isActive } = state;
const { id } = this.props;
if (isActive !== prevState.isActive && isActive) {
this.props.didPlayVideo(id, this.props.video.startTime);
}
};
handleCurrentTimeChange = (curr) => {
this.props.getVideoCurrentTime(this.props.id, curr, this.props.video.duration);
}
handleModalOpen = () => {
this.player.current.subscribeToStateChange(
this.handleChangeIsPlayingState,
);
this.player.current.play();
if (this.props.video.startTime === 0) {
const { player } = this.player.current.getState();
player.isActive = true;
this.props.didPlayVideo(this.props.id, this.props.video.startTime);
} else {
this.player.current.pause();
}
};
handleVideoPause = () => {
const { player } = this.player.current.getState();
const currTime = player.currentTime;
this.handleCurrentTimeChange(currTime);
this.props.didStopVideo(this.props.id, currTime);
};
handleModalClose = () => {
const { player } = this.player.current.getState();
const paused = player.paused;
if (!paused) {
this.handleVideoPause();
}
};
render() {
const { video } = this.props;
return (
<li
key={this.props.id}
onClick={this.props.onClick}
id={this.props.id}
className={cn(styles.listItem, video.end && (styles.finished))}
>
<div className={styles.thumbWrapper}>
<img src={video.preview} alt="preview" />
<div className={styles.overlay} />
<div className={styles.play} />
</div>
<div className={styles.txtWrapper}>
<p className={styles.title}>{video.title}</p>
<p className={styles.time}>{isNaN(video.duration) ? '\xA0' : `${Math.floor(video.duration / 60)}:${Math.floor(video.duration)%60}`}</p>
</div>
<Modal
isOpen={video.isOpen}
toggle={this.props.onClick} // eslint-disable-line react/jsx-handler-names
className={styles.videoModal}
onOpened={this.handleModalOpen}
onClosed={this.handleModalClose}
>
<ModalHeader
toggle={this.props.onClick} // eslint-disable-line react/jsx-handler-names
className={styles.videoModalHeader}
>
{video.title}
</ModalHeader>
<ModalBody className={styles.modalBodyHelper}>
<div>
<Player
ref={this.player}
className={styles.videoPlayer}
poster="/assets/poster.png"
src={video.video}
startTime={video.startTime}
preload="auto"
onPause={this.handleVideoPause}
onplay={this.videoStart}
subscribeToStateChange={this.subscribeToStateChange}
/>
</div>
</ModalBody>
</Modal>
{!this.props.video.duration && (
<div className={cn(styles.hiddenPlayerWrapper)}>
<Player
ref={this.hiddenPlayer}
poster="/assets/poster.png"
src={video.video}
preload="auto"
subscribeToStateChange={this.subscribeToStateChange}
/>
</div>
)}
</li>
);
}
}
OnboardingVideo.defaultProps = {
didPlayVideo: () => {},
didStopVideo: () => {},
getVideoCurrentTime: () => {},
id: 0,
onClick: () => {},
setVideoDuration: () => {},
video: {},
};
OnboardingVideo.propTypes = {
didPlayVideo: PropTypes.func,
didStopVideo: PropTypes.func,
getVideoCurrentTime: PropTypes.func,
id: PropTypes.number,
onClick: PropTypes.func,
setVideoDuration: PropTypes.func,
video: PropTypes.object,
};
export default OnboardingVideo;

View File

@ -0,0 +1,144 @@
li.listItem {
display: block;
padding: 17px 15px;
cursor: pointer;
&:hover {
background-color: #f7f8f8;
.title {
color: #0e7de7;
}
}
.txtWrapper,
.thumbWrapper {
display: inline-block;
vertical-align: middle;
}
.thumbWrapper {
position: relative;
width: 55px;
height: 38px;
background-color: #d8d8d8;
border-radius: 2px;
overflow: hidden;
.overlay {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background-color: rgba(#0E7DE7, .8);
}
img {
position: relative;
z-index: 0;
width: 100%;
height: 100%;
}
.play {
position: absolute;
top: calc(50% - 10px);
left: calc(50% - 10px);
width: 20px;
height: 20px;
background-color: #0e7de7;
border: 1px solid white;
text-align: center;
line-height: 20px;
border-radius: 50%;
z-index: 2;
&::before {
content: '\f04b';
display: inline-block;
vertical-align: top;
height: 100%;
font-family: 'FontAwesome';
color: white;
font-size: 10px;
margin-left: 3px;
line-height: 18px;
}
}
}
&.finished {
.title {
color: #919bae;
}
.thumbWrapper {
.overlay {
background-color: transparent;
}
img {
opacity: 0.6;
}
.play {
background-color: #5a9e06;
border-color: #5a9e06;
&::before {
content: '\f00c';
margin-left: 0;
font-size: 11px;
line-height: 20px;
}
}
}
}
.txtWrapper {
padding: 0 15px;
p {
font-size: 14px;
line-height: 24px;
font-family: Lato;
font-weight: 600;
}
.time {
color: #919bae;
font-family: Lato;
font-weight: bold;
font-size: 11px;
line-height: 11px;
}
}
}
.hiddenPlayerWrapper {
display: none;
}
.videoModal {
margin-right: auto !important;
margin-left: auto !important;
.videoModalHeader {
padding-bottom: 0;
border-bottom: 0;
> h5 {
font-family: Lato;
font-weight: bold!important;
font-size: 1.8rem!important;
line-height: 3.1rem;
color: #333740;
}
> button {
display: flex;
position: absolute;
right: 0;
top: 0;
margin-top: 0;
margin-right: 0;
padding: 10px;
cursor: pointer;
span {
line-height: 0.6em;
}
}
}
.videoPlayer {
> button {
top: 50%;
margin-top: -0.75em;
left: 50%;
margin-left: -1.5em;
}
}
}

View File

@ -0,0 +1,11 @@
// import OnboardingList from '../index';
import expect from 'expect';
// import { shallow } from 'enzyme';
// import React from 'react';
describe('<OnboardingList />', () => {
it('Expect to have unit tests specified', () => {
expect(true).toEqual(true);
});
});

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@
import {
GET_ADMIN_DATA,
GET_ADMIN_DATA_SUCCEEDED,
EMIT_EVENT,
} from './constants';
export function getAdminData() {
@ -19,4 +20,12 @@ export function getAdminDataSucceeded(data) {
type: GET_ADMIN_DATA_SUCCEEDED,
data,
};
}
export function emitEvent(event, properties) {
return {
type: EMIT_EVENT,
event,
properties,
};
}

View File

@ -5,4 +5,5 @@
*/
export const GET_ADMIN_DATA = 'app/Admin/GET_ADMIN_DATA';
export const GET_ADMIN_DATA_SUCCEEDED = 'app/Admin/GET_ADMIN_DATA_SUCCEEDED';
export const GET_ADMIN_DATA_SUCCEEDED = 'app/Admin/GET_ADMIN_DATA_SUCCEEDED';
export const EMIT_EVENT = 'app/Admin/EMIT_EVENT';

View File

@ -29,6 +29,7 @@ import OverlayBlocker from 'components/OverlayBlocker';
import auth from 'utils/auth';
import { pluginLoaded, updatePlugin } from '../App/actions';
import {
makeSelectAppPlugins,
makeSelectBlockApp,
@ -54,10 +55,11 @@ import HomePage from '../HomePage/Loadable';
import Marketplace from '../Marketplace/Loadable';
import LeftMenu from '../LeftMenu';
import ListPluginsPage from '../ListPluginsPage/Loadable';
import Onboarding from '../Onboarding';
import NotFoundPage from '../NotFoundPage/Loadable';
import PluginPage from '../PluginPage';
import { getAdminData } from './actions';
import { emitEvent, getAdminData } from './actions';
import reducer from './reducer';
import saga from './saga';
import selectAdminPage from './selectors';
@ -75,6 +77,7 @@ export class AdminPage extends React.Component {
getChildContext = () => ({
currentEnvironment: this.props.adminPage.currentEnvironment,
disableGlobalOverlayBlocker: this.props.disableGlobalOverlayBlocker,
emitEvent: this.props.emitEvent,
enableGlobalOverlayBlocker: this.props.enableGlobalOverlayBlocker,
plugins: this.props.plugins,
updatePlugin: this.props.updatePlugin,
@ -85,7 +88,6 @@ export class AdminPage extends React.Component {
this.checkLogin(this.props);
ReactGA.initialize('UA-54313258-9');
}
componentDidUpdate(prevProps) {
const {
adminPage: { uuid },
@ -100,7 +102,7 @@ export class AdminPage extends React.Component {
ReactGA.pageview(pathname);
}
}
const hasAdminPath = ['users-permissions', 'hasAdminUser'];
if (
@ -272,7 +274,11 @@ export class AdminPage extends React.Component {
<Route path="/plugins/:pluginId" component={PluginPage} />
<Route path="/plugins" component={ComingSoonPage} />
<Route path="/list-plugins" component={ListPluginsPage} exact />
<Route path="/marketplace" render={this.renderMarketPlace} exact />
<Route
path="/marketplace"
render={this.renderMarketPlace}
exact
/>
<Route path="/configuration" component={ComingSoonPage} exact />
<Route path="" component={NotFoundPage} />
<Route path="404" component={NotFoundPage} />
@ -283,12 +289,14 @@ export class AdminPage extends React.Component {
isOpen={this.props.blockApp && this.props.showGlobalAppBlocker}
{...this.props.overlayBlockerData}
/>
{this.shouldDisplayLogout() && <Onboarding />}
</div>
);
}
}
AdminPage.childContextTypes = {
emitEvent: PropTypes.func,
currentEnvironment: PropTypes.string.isRequired,
disableGlobalOverlayBlocker: PropTypes.func,
enableGlobalOverlayBlocker: PropTypes.func,
@ -313,6 +321,7 @@ AdminPage.propTypes = {
appPlugins: PropTypes.array,
blockApp: PropTypes.bool.isRequired,
disableGlobalOverlayBlocker: PropTypes.func.isRequired,
emitEvent: PropTypes.func.isRequired,
enableGlobalOverlayBlocker: PropTypes.func.isRequired,
getAdminData: PropTypes.func.isRequired,
hasUserPlugin: PropTypes.bool,
@ -341,6 +350,7 @@ function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
disableGlobalOverlayBlocker,
emitEvent,
enableGlobalOverlayBlocker,
getAdminData,
pluginLoaded,

View File

@ -16,6 +16,8 @@ const initialState = fromJS({
isLoading: true,
layout: Map({}),
strapiVersion: '3',
eventName: '',
shouldEmit: true,
});
function adminPageReducer(state = initialState, action) {

View File

@ -5,7 +5,32 @@ import { makeSelectAppPlugins } from '../App/selectors';
import {
getAdminDataSucceeded,
} from './actions';
import { GET_ADMIN_DATA } from './constants';
import { makeSelectUuid } from './selectors';
import { EMIT_EVENT, GET_ADMIN_DATA } from './constants';
function* emitter(action) {
try {
const requestURL = 'https://analytics.strapi.io/track';
const uuid = yield select(makeSelectUuid());
const { event, properties } = action;
if (uuid) {
yield call(
fetch, // eslint-disable-line no-undef
requestURL,
{
method: 'POST',
body: JSON.stringify({ event, uuid, properties }),
headers: {
'Content-Type': 'application/json',
},
},
);
}
} catch(err) {
console.log(err); // eslint-disable-line no-console
}
}
function* getData() {
try {
@ -32,6 +57,7 @@ function* getData() {
function* defaultSaga() {
yield all([
fork(takeLatest, GET_ADMIN_DATA, getData),
fork(takeLatest, EMIT_EVENT, emitter),
]);
}

View File

@ -14,9 +14,15 @@ const selectAdminPageDomain = () => state => state.get('adminPage');
* Default selector used by HomePage
*/
const makeSelectUuid = () => createSelector(
selectAdminPageDomain(),
substate => substate.get('uuid'),
);
const selectAdminPage = () => createSelector(
selectAdminPageDomain(),
(substate) => substate.toJS(),
);
export default selectAdminPage;
export { makeSelectUuid };

View File

@ -123,7 +123,11 @@ export class HomePage extends React.PureComponent {
handleSubmit = e => {
e.preventDefault();
const errors = validateInput(this.props.homePage.body.email, { required: true }, 'email');
const errors = validateInput(
this.props.homePage.body.email,
{ required: true },
'email',
);
this.setState({ errors });
if (isEmpty(errors)) {
@ -132,13 +136,18 @@ export class HomePage extends React.PureComponent {
};
showFirstBlock = () =>
get(this.props.plugins.toJS(), 'content-manager.leftMenuSections.0.links', []).length === 0;
get(
this.props.plugins.toJS(),
'content-manager.leftMenuSections.0.links',
[],
).length === 0;
renderButton = () => {
const data = this.showFirstBlock()
? {
className: styles.homePageTutorialButton,
href: 'https://strapi.io/documentation/3.x.x/getting-started/quick-start.html#_3-create-a-content-type',
href:
'https://strapi.io/documentation/getting-started/quick-start.html#_3-create-a-content-type',
id: 'app.components.HomePage.button.quickStart',
primary: true,
}
@ -159,7 +168,9 @@ export class HomePage extends React.PureComponent {
};
render() {
const { homePage: { articles, body } } = this.props;
const {
homePage: { articles, body },
} = this.props;
const WELCOME_AGAIN_BLOCK = [
{
title: {
@ -178,7 +189,12 @@ export class HomePage extends React.PureComponent {
<Block>
{this.showFirstBlock() &&
FIRST_BLOCK.map((value, key) => (
<Sub key={key} {...value} underline={key === 0} bordered={key === 0} />
<Sub
key={key}
{...value}
underline={key === 0}
bordered={key === 0}
/>
))}
{!this.showFirstBlock() &&
WELCOME_AGAIN_BLOCK.concat(articles).map((value, key) => (
@ -192,14 +208,21 @@ export class HomePage extends React.PureComponent {
))}
{this.renderButton()}
<div className={styles.homePageFlex}>
{FIRST_BLOCK_LINKS.map((value, key) => <BlockLink {...value} key={key} />)}
{FIRST_BLOCK_LINKS.map((value, key) => (
<BlockLink {...value} key={key} />
))}
</div>
</Block>
<Block>
<Sub {...SECOND_BLOCK} />
<div className={styles.homePageFlex}>
<div className="row" style={{ width: '100%', marginRight: '0' }}>
{SOCIAL_LINKS.map((value, key) => <SocialLink key={key} {...value} />)}
<div
className="row"
style={{ width: '100%', marginRight: '0' }}
>
{SOCIAL_LINKS.map((value, key) => (
<SocialLink key={key} {...value} />
))}
</div>
<div className={styles.newsLetterWrapper}>
<div>
@ -266,10 +289,17 @@ function mapDispatchToProps(dispatch) {
);
}
const withConnect = connect(mapStateToProps, mapDispatchToProps);
const withConnect = connect(
mapStateToProps,
mapDispatchToProps,
);
const withReducer = injectReducer({ key: 'homePage', reducer });
const withSaga = injectSaga({ key: 'homePage', saga });
// export default connect(mapDispatchToProps)(HomePage);
export default compose(withReducer, withSaga, withConnect)(HomePage);
export default compose(
withReducer,
withSaga,
withConnect,
)(HomePage);

View File

@ -1,14 +1,7 @@
import 'whatwg-fetch';
import { dropRight, take } from 'lodash';
import removeMd from 'remove-markdown';
import {
all,
call,
fork,
put,
select,
takeLatest,
} from 'redux-saga/effects';
import { all, call, fork, put, select, takeLatest } from 'redux-saga/effects';
import request from 'utils/request';
import { getArticlesSucceeded, submitSucceeded } from './actions';
import { GET_ARTICLES, SUBMIT } from './constants';
@ -19,7 +12,11 @@ function* getArticles() {
const articles = yield call(fetchArticles);
const posts = articles.posts.reduce((acc, curr) => {
// Limit to 200 characters and remove last word.
const content = dropRight(take(removeMd(curr.markdown), 250).join('').split(' ')).join(' ');
const content = dropRight(
take(removeMd(curr.markdown), 250)
.join('')
.split(' '),
).join(' ');
acc.push({
title: curr.title,
@ -31,17 +28,19 @@ function* getArticles() {
}, []);
yield put(getArticlesSucceeded(posts));
} catch(err) {
} catch (err) {
// Silent
}
}
function* submit() {
try {
const body = yield select(makeSelectBody());
yield call(request, 'https://analytics.strapi.io/register', { method: 'POST', body });
} catch(err) {
yield call(request, 'https://analytics.strapi.io/register', {
method: 'POST',
body,
});
} catch (err) {
// silent
} finally {
strapi.notification.success('HomePage.notification.newsLetter.success');
@ -56,11 +55,12 @@ function* defaultSaga() {
]);
}
function fetchArticles() {
return fetch('https://blog.strapi.io/ghost/api/v0.1/posts/?client_id=ghost-frontend&client_secret=1f260788b4ec&limit=2', {})
.then(resp => {
return resp.json ? resp.json() : resp;
});
return fetch(
'https://blog.strapi.io/ghost/api/v0.1/posts/?client_id=ghost-frontend&client_secret=1f260788b4ec&limit=2',
{},
).then(resp => {
return resp.json ? resp.json() : resp;
});
}
export default defaultSaga;

View File

@ -0,0 +1,64 @@
/*
*
* Onboarding actions
*
*/
import { GET_VIDEOS, GET_VIDEOS_SUCCEEDED, SHOULD_OPEN_MODAL, ON_CLICK, SET_VIDEOS_DURATION, UPDATE_VIDEO_START_TIME, SET_VIDEO_END, REMOVE_VIDEOS } from './constants';
export function getVideos() {
return {
type: GET_VIDEOS,
};
}
export function getVideosSucceeded(videos) {
return {
type: GET_VIDEOS_SUCCEEDED,
videos,
};
}
export function shouldOpenModal(opened) {
return {
type: SHOULD_OPEN_MODAL,
opened,
};
}
export function onClick(e) {
return {
type: ON_CLICK,
index: parseInt(e.currentTarget.id, 10),
};
}
export function setVideoDuration(index, duration) {
return {
type: SET_VIDEOS_DURATION,
index: parseInt(index, 10),
duration: parseFloat(duration, 10),
};
}
export function updateVideoStartTime(index, startTime) {
return {
type: UPDATE_VIDEO_START_TIME,
index: parseInt(index, 10),
startTime: parseFloat(startTime, 10),
};
}
export function setVideoEnd(index, end) {
return {
type: SET_VIDEO_END,
index: parseInt(index, 10),
end,
};
}
export function removeVideos() {
return {
type: REMOVE_VIDEOS,
};
}

View File

@ -0,0 +1,15 @@
/*
*
* Onboarding constants
*
*/
export const GET_VIDEOS = 'StrapiAdmin/Onboarding/GET_VIDEOS';
export const GET_VIDEOS_SUCCEEDED =
'StrapiAdmin/Onboarding/GET_VIDEOS_SUCCEEDED';
export const SHOULD_OPEN_MODAL = 'StrapiAdmin/Onboarding/SHOULD_OPEN_MODAL';
export const ON_CLICK = 'StrapiAdmin/Onboarding/ON_CLICK';
export const SET_VIDEOS_DURATION = 'StrapiAdmin/Onboarding/SET_VIDEOS_DURATION';
export const UPDATE_VIDEO_START_TIME = 'StrapiAdmin/Onboarding/UPDATE_VIDEO_START_TIME';
export const SET_VIDEO_END = 'StrapiAdmin/Onboarding/SET_VIDEO_END';
export const REMOVE_VIDEOS = 'StrapiAdmin/Onboarding/REMOVE_VIDEOS';

View File

@ -0,0 +1,182 @@
/**
*
* Onboarding
*
*/
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { connect } from 'react-redux';
import { bindActionCreators, compose } from 'redux';
import { FormattedMessage } from 'react-intl';
import injectSaga from 'utils/injectSaga';
import injectReducer from 'utils/injectReducer';
import OnboardingVideo from 'components/OnboardingVideo';
import { getVideos, onClick, removeVideos, setVideoDuration, setVideoEnd, updateVideoStartTime } from './actions';
import makeSelectOnboarding from './selectors';
import reducer from './reducer';
import saga from './saga';
import styles from './styles.scss';
export class Onboarding extends React.Component {
state = { showVideos: false };
componentDidMount() {
this.props.getVideos();
}
componentDidUpdate(prevProps) {
const { shouldOpenModal } = this.props;
if (shouldOpenModal !== prevProps.shouldOpenModal && shouldOpenModal) {
this.handleOpenModal();
}
}
componentWillUnmount() {
this.props.removeVideos();
}
setVideoEnd = () => {
this.setVideoEnd();
}
didPlayVideo = (index, currTime) => {
const eventName = `didPlay${index}GetStartedVideo`;
this.context.emitEvent(eventName, {timestamp: currTime});
}
didStopVideo = (index, currTime) => {
const eventName = `didStop${index}Video`;
this.context.emitEvent(eventName, {timestamp: currTime});
}
handleOpenModal = () => this.setState({ showVideos: true });
handleVideosToggle = () => {
this.setState(prevState => ({ showVideos: !prevState.showVideos }));
const { showVideos } = this.state;
const eventName = showVideos ? 'didOpenGetStartedVideoContainer' : 'didCloseGetStartedVideoContainer';
this.context.emitEvent(eventName);
};
updateCurrentTime = (index, current, duration) => {
this.props.updateVideoStartTime(index, current);
const percent = current * 100 / duration;
const video = this.props.videos[index];
if (percent >= 80) {
if (video.end === false) {
this.updateEnd(index);
}
}
};
updateEnd = (index) => {
this.props.setVideoEnd(index, true);
};
// eslint-disable-line jsx-handler-names
render() {
const { videos, onClick, setVideoDuration } = this.props;
return (
<div className={cn(styles.videosWrapper, videos.length > 0 ? styles.visible : styles.hidden)}>
<div className={cn(styles.videosContent, this.state.showVideos ? styles.shown : styles.hide)}>
<div className={styles.videosHeader}>
<p><FormattedMessage id="app.components.Onboarding.title" /></p>
{videos.length && (
<p>{Math.floor((videos.filter(v => v.end).length)*100/videos.length)}<FormattedMessage id="app.components.Onboarding.label.completed" /></p>
)}
</div>
<ul className={styles.onboardingList}>
{videos.map((video, i) => {
return (
<OnboardingVideo
key={i}
id={i}
video={video}
onClick={onClick}
setVideoDuration={setVideoDuration}
getVideoCurrentTime={this.updateCurrentTime}
didPlayVideo={this.didPlayVideo}
didStopVideo={this.didStopVideo}
/>
);
})}
</ul>
</div>
<div className={styles.openBtn}>
<button
onClick={this.handleVideosToggle}
className={this.state.showVideos ? styles.active : ''}
>
<i className="fa fa-question" />
<i className="fa fa-times" />
<span />
</button>
</div>
</div>
);
}
}
Onboarding.contextTypes = {
emitEvent: PropTypes.func,
};
Onboarding.defaultProps = {
onClick: () => {},
removeVideos: () => {},
setVideoDuration: () => {},
setVideoEnd: () => {},
shouldOpenModal: false,
videos: [],
updateVideoStartTime: () => {},
};
Onboarding.propTypes = {
getVideos: PropTypes.func.isRequired,
onClick: PropTypes.func,
removeVideos: PropTypes.func,
setVideoDuration: PropTypes.func,
setVideoEnd: PropTypes.func,
shouldOpenModal: PropTypes.bool,
updateVideoStartTime: PropTypes.func,
videos: PropTypes.array,
};
const mapStateToProps = makeSelectOnboarding();
function mapDispatchToProps(dispatch) {
return bindActionCreators({ getVideos, onClick, setVideoDuration, updateVideoStartTime, setVideoEnd, removeVideos }, dispatch);
}
const withConnect = connect(
mapStateToProps,
mapDispatchToProps,
);
/* Remove this line if the container doesn't have a route and
* check the documentation to see how to create the container's store
*/
const withReducer = injectReducer({ key: 'onboarding', reducer });
/* Remove the line below the container doesn't have a route and
* check the documentation to see how to create the container's store
*/
const withSaga = injectSaga({ key: 'onboarding', saga });
export default compose(
withReducer,
withSaga,
withConnect,
)(Onboarding);

View File

@ -0,0 +1,69 @@
/*
*
* Onboarding reducer
*
*/
import { fromJS } from 'immutable';
import { GET_VIDEOS_SUCCEEDED, SHOULD_OPEN_MODAL, ON_CLICK, SET_VIDEOS_DURATION, UPDATE_VIDEO_START_TIME, SET_VIDEO_END, REMOVE_VIDEOS } from './constants';
const initialState = fromJS({
videos: fromJS([]),
});
function onboardingReducer(state = initialState, action) {
switch (action.type) {
case GET_VIDEOS_SUCCEEDED:
return state.update('videos', () => fromJS(action.videos));
case SHOULD_OPEN_MODAL:
return state.update('shouldOpenModal', () => action.opened);
case ON_CLICK:
return state.updateIn(['videos'], list => {
return list.reduce((acc, current, index) => {
if (index === action.index) {
return acc.updateIn([index, 'isOpen'], v => !v);
}
return acc.updateIn([index, 'isOpen'], () => false);
}, list);
});
case SET_VIDEOS_DURATION:
return state.updateIn(['videos', action.index, 'duration'], () => action.duration);
case UPDATE_VIDEO_START_TIME: {
const storedVideos = JSON.parse(localStorage.getItem('videos'));
const videos = state.updateIn(['videos'], list => {
return list.reduce((acc, current, index) => {
if (index === action.index) {
storedVideos[index].startTime = action.startTime;
return acc.updateIn([index, 'startTime'], () => action.startTime);
}
storedVideos[index].startTime = 0;
return acc.updateIn([index, 'startTime'], () => 0);
}, list);
});
localStorage.setItem('videos', JSON.stringify(storedVideos));
return videos;
}
case SET_VIDEO_END: {
const storedVideos = JSON.parse(localStorage.getItem('videos'));
storedVideos[action.index].end = action.end;
localStorage.setItem('videos', JSON.stringify(storedVideos));
return state.updateIn(['videos', action.index, 'end'], () => action.end);
}
case REMOVE_VIDEOS:
return initialState;
default:
return state;
}
}
export default onboardingReducer;

View File

@ -0,0 +1,66 @@
import request from 'utils/request';
import { all, call, fork, takeLatest, put } from 'redux-saga/effects';
import { GET_VIDEOS } from './constants';
import { getVideosSucceeded, shouldOpenModal } from './actions';
function* getVideos() {
try {
const data = yield call(request, 'https://strapi.io/videos', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
},
false,
true,
{ noAuth: true },
);
const storedVideo = JSON.parse(localStorage.getItem('videos')) || null;
const videos = data.map(video => {
const { end, startTime } = storedVideo ? storedVideo.find(v => v.order === video.order) : { end: false, startTime: 0};
return {
...video,
duration: null,
end,
isOpen: false,
key: video.order,
startTime,
};
}).sort((a,b) => (a.order - b.order));
localStorage.setItem('videos', JSON.stringify(videos));
yield put(
getVideosSucceeded(videos),
);
const isFirstTime = JSON.parse(localStorage.getItem('onboarding')) || null;
if (isFirstTime === null) {
yield new Promise(resolve => {
setTimeout(() => {
resolve();
}, 500);
});
yield put(
shouldOpenModal(true),
);
localStorage.setItem('onboarding', true);
}
} catch (err) {
console.log(err); // eslint-disable-line no-console
}
}
function* defaultSaga() {
yield all([fork(takeLatest, GET_VIDEOS, getVideos)]);
}
export default defaultSaga;

View File

@ -0,0 +1,25 @@
import { createSelector } from 'reselect';
/**
* Direct selector to the onboarding state domain
*/
const selectOnboardingDomain = () => (state) => state.get('onboarding');
/**
* Other specific selectors
*/
/**
* Default selector used by Onboarding
*/
const makeSelectOnboarding = () => createSelector(
selectOnboardingDomain(),
(substate) => substate.toJS()
);
export default makeSelectOnboarding;
export {
selectOnboardingDomain,
};

View File

@ -0,0 +1,116 @@
.videosWrapper {
position: fixed;
right: 15px;
bottom: 15px;
button,
button:focus,
a {
cursor: pointer;
outline: 0;
}
p {
margin-bottom: 0;
}
.videosHeader {
padding: 25px 15px 0 15px;
p {
display: inline-block;
vertical-align: top;
width: 50%;
font-family: Lato;
font-weight: bold;
font-size: 11px;
color: #5c5f66;
letter-spacing: 0.5px;
text-transform: uppercase;
&:last-of-type {
color: #5a9e06;
text-align: right;
}
}
}
&.visible {
opacity: 1;
}
&.hidden {
opacity: 0;
}
.videosContent {
min-width: 320px;
margin-bottom: 10px;
margin-right: 15px;
background-color: white;
box-shadow: 0 2px 4px 0 #e3e9f3;
border-radius: 3px;
overflow: hidden;
&.shown {
animation: fadeIn 0.5s forwards;
}
&.hide {
animation: fadeOut 0.5s forwards;
}
ul {
padding: 10px 0;
margin-bottom: 0;
list-style: none;
}
}
.openBtn {
float: right;
width: 38px;
height: 38px;
button {
width: 100%;
height: 100%;
border-radius: 50%;
color: white;
background: #0e7de7;
box-shadow: 0px 2px 4px 0px rgba(227, 233, 243, 1);
i:last-of-type {
display: none;
}
&.active {
i:first-of-type {
display: none;
}
i:last-of-type {
display: block;
}
}
}
}
}
@keyframes fadeIn {
0% {
width: auto;
height: auto;
opacity: 0;
}
5% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
width: 0;
height: 0;
}
}

View File

@ -0,0 +1,18 @@
import {
defaultAction,
} from '../actions';
import {
DEFAULT_ACTION,
} from '../constants';
describe('Onboarding actions', () => {
describe('Default Action', () => {
it('has a type of DEFAULT_ACTION', () => {
const expected = {
type: DEFAULT_ACTION,
};
expect(defaultAction()).toEqual(expected);
});
});
});

View File

@ -0,0 +1,10 @@
// import React from 'react';
// import { shallow } from 'enzyme';
// import { Onboarding } from '../index';
describe('<Onboarding />', () => {
it('Expect to have unit tests specified', () => {
expect(true).toEqual(true);
});
});

View File

@ -0,0 +1,9 @@
import { fromJS } from 'immutable';
import onboardingReducer from '../reducer';
describe('onboardingReducer', () => {
it('returns the initial state', () => {
expect(onboardingReducer(undefined, [])).toEqual(fromJS([]));
});
});

View File

@ -0,0 +1,15 @@
/**
* Test sagas
*/
/* eslint-disable redux-saga/yield-effects */
// import { take, call, put, select } from 'redux-saga/effects';
// import { defaultSaga } from '../saga';
// const generator = defaultSaga();
describe('defaultSaga Saga', () => {
it('Expect to have unit tests specified', () => {
expect(true).toEqual(true);
});
});

View File

@ -0,0 +1,10 @@
// import { fromJS } from 'immutable';
// import { makeSelectOnboardingDomain } from '../selectors';
// const selector = makeSelectOnboardingDomain();
describe('makeSelectOnboardingDomain', () => {
it('Expect to have unit tests specified', () => {
expect(true).toEqual(true);
});
});

View File

@ -84,6 +84,8 @@
"app.components.NotFoundPage.back": "Back to homepage",
"app.components.NotFoundPage.description": "Not Found",
"app.components.Official": "Official",
"app.components.Onboarding.label.completed": "% completed",
"app.components.Onboarding.title": "Get Started Videos",
"app.components.PluginCard.Button.label.download": "Download",
"app.components.PluginCard.Button.label.install": "Already installed",
"app.components.PluginCard.Button.label.support": "Support us",
@ -145,4 +147,4 @@
"notification.error.layout": "Couldn't retrieve the layout",
"request.error.model.unknown": "This model doesn't exist",
"app.utils.delete": "Delete"
}
}

View File

@ -85,6 +85,8 @@
"app.components.NotFoundPage.back": "Retourner à la page d'accueil",
"app.components.NotFoundPage.description": "Page introuvable",
"app.components.Official": "Officiel",
"app.components.Onboarding.label.completed": "% complétées",
"app.components.Onboarding.title": "Démarrons ensemble",
"app.components.PluginCard.Button.label.download": "Télécharger",
"app.components.PluginCard.Button.label.install": "Déjà installé",
"app.components.PluginCard.Button.label.support": "Nous soutenir",
@ -146,4 +148,4 @@
"notification.error.layout": "Impossible de récupérer le layout de l'admin",
"request.error.model.unknown": "Le model n'existe pas",
"app.utils.delete": "Supprimer"
}
}

View File

@ -27,8 +27,10 @@
"dependencies": {
"intl": "^1.2.5",
"react-ga": "^2.4.1",
"redux": "^4.0.1",
"remove-markdown": "^0.2.2",
"shelljs": "^0.7.8"
"shelljs": "^0.7.8",
"video-react": "^0.13.2"
},
"devDependencies": {
"cross-env": "^5.0.5",

View File

@ -16,9 +16,13 @@ if (!isSetup) {
strapi.log.level = 'silent';
(async () => {
await strapi.load({
environment: process.env.NODE_ENV,
});
try {
await strapi.load({
environment: process.env.NODE_ENV,
});
} catch (e) {
// console.log(e);
}
// Force exit process if an other process doen't exit during Strapi load.
process.exit();

View File

@ -47,7 +47,7 @@ const addDevMiddlewares = (app, webpackConfig) => {
/**
* Front-end middleware
*/
module.exports = (app) => {
module.exports = app => {
const webpackConfig = require('../../internals/webpack/webpack.dev.babel');
// const webpackConfig = require(path.resolve(process.cwd(), 'node_modules', 'strapi-helper-plugin', 'internals', 'webpack', 'webpack.dev.babel'));

View File

@ -21,7 +21,12 @@ const auth = {
clearAppStorage() {
if (localStorage) {
const videos = auth.get('videos');
const onboarding = auth.get('onboarding');
localStorage.clear();
localStorage.setItem('videos', JSON.stringify(videos));
localStorage.setItem('onboarding', onboarding);
}
if (sessionStorage) {
@ -73,7 +78,6 @@ const auth = {
return null;
},
setToken(value = '', isLocalStorage = false, tokenKey = TOKEN_KEY) {
return auth.set(value, tokenKey, isLocalStorage);
},

View File

@ -20,7 +20,7 @@ function parseJSON(response) {
* @return {object|undefined} Returns either the response, or throws an error
*/
function checkStatus(response, checkToken = true) {
if (response.status >= 200 && response.status < 300) {
if ((response.status >= 200 && response.status < 300) || response.status === 0) {
return response;
}
@ -41,21 +41,22 @@ function checkTokenValidity(response) {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${auth.getToken()}`,
Authorization: `Bearer ${auth.getToken()}`,
},
};
if (auth.getToken()) {
return fetch(`${strapi.backendURL}/user/me`, options)
.then(() => {
if (response.status === 401) {
window.location = `${strapi.remoteURL}/plugins/users-permissions/auth/login`;
return fetch(`${strapi.backendURL}/user/me`, options).then(() => {
if (response.status === 401) {
window.location = `${
strapi.remoteURL
}/plugins/users-permissions/auth/login`;
auth.clearAppStorage();
}
auth.clearAppStorage();
}
return checkStatus(response, false);
});
return checkStatus(response, false);
});
}
}
@ -72,12 +73,12 @@ function formatQueryParams(params) {
}
/**
* Server restart watcher
* @param response
* @returns {object} the response data
*/
* Server restart watcher
* @param response
* @returns {object} the response data
*/
function serverRestartWatcher(response) {
return new Promise((resolve) => {
return new Promise(resolve => {
fetch(`${strapi.backendURL}/_health`, {
method: 'HEAD',
mode: 'no-cors',
@ -93,8 +94,7 @@ function serverRestartWatcher(response) {
})
.catch(() => {
setTimeout(() => {
return serverRestartWatcher(response)
.then(resolve);
return serverRestartWatcher(response).then(resolve);
}, 100);
});
});
@ -108,22 +108,38 @@ function serverRestartWatcher(response) {
*
* @return {object} The response data
*/
export default function request(url, options = {}, shouldWatchServerRestart = false, stringify = true ) {
export default function request(...args) {
let [url, options = {}, shouldWatchServerRestart, stringify = true, ...rest] = args;
let noAuth;
try {
[{ noAuth }] = rest;
} catch(err) {
noAuth = false;
}
// Set headers
if (!options.headers) {
options.headers = Object.assign({
'Content-Type': 'application/json',
}, options.headers, {
'X-Forwarded-Host': 'strapi',
});
options.headers = Object.assign(
{
'Content-Type': 'application/json',
},
options.headers,
{
'X-Forwarded-Host': 'strapi',
},
);
}
const token = auth.getToken();
if (token) {
options.headers = Object.assign({
'Authorization': `Bearer ${token}`,
}, options.headers);
if (token && !noAuth) {
options.headers = Object.assign(
{
Authorization: `Bearer ${token}`,
},
options.headers,
);
}
// Add parameters to url
@ -138,11 +154,11 @@ export default function request(url, options = {}, shouldWatchServerRestart = fa
if (options && options.body && stringify) {
options.body = JSON.stringify(options.body);
}
return fetch(url, options)
.then(checkStatus)
.then(parseJSON)
.then((response) => {
.then(response => {
if (shouldWatchServerRestart) {
// Display the global OverlayBlocker
strapi.lockApp(shouldWatchServerRestart);

View File

@ -155,6 +155,10 @@ module.exports = strapi => {
} catch (err) {
fs.mkdirSync(fileDirectory);
}
// Force base directory.
// Note: it removes the warning logs when starting the administration in development mode.
options.connection.filename = path.resolve(strapi.config.appPath, options.connection.filename);
// Disable warn log
// .returning() is not supported by sqlite3 and will not have any effect.

View File

@ -55,7 +55,8 @@ module.exports = {
to: options.to,
subject: options.subject,
text: options.text,
html: options.html
html: options.html,
...(options.attachment && { attachment: options.attachment })
};
msg['h:Reply-To'] = options.replyTo;