Add events in the content-type builder

This commit is contained in:
Aurélien Georget 2019-02-20 15:31:07 +01:00
commit cd4313236c
41 changed files with 1267 additions and 64 deletions

View File

@ -0,0 +1,126 @@
/**
*
* 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 {
hiddenPlayer = React.createRef();
player = React.createRef();
componentDidMount() {
this.hiddenPlayer.current.subscribeToStateChange(
this.handleChangeState,
);
}
handleChangeState = (state, prevState) => {
const { duration } = state;
const { id } = this.props;
if (duration !== prevState.duration) {
this.props.setVideoDuration(id, duration);
}
};
handleCurrentTimeChange = (curr) => {
this.props.getVideoCurrentTime(this.props.id, curr);
}
afterOpenModal = () => {
this.player.current.play();
};
onModalClose = () => {
const { player } = this.player.current.getState();
const currTime = player.currentTime;
this.handleCurrentTimeChange(currTime);
};
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} />
<div className={styles.play} />
</div>
<div className={styles.txtWrapper}>
<p className={styles.title}>{video.title}</p>
<p className={styles.time}>{isNaN(video.duration) ? ' ' : `${Math.floor(video.duration / 60)}:${Math.floor(video.duration)%60}`}</p>
</div>
<Modal
isOpen={video.isOpen}
toggle={this.props.onClick}
className={styles.videoModal}
onOpened={this.afterOpenModal}
onClosed={this.onModalClose}
>
<ModalHeader
toggle={this.props.onClick}
className={styles.videoModalHeader}
>
{video.title}
</ModalHeader>
<ModalBody className={styles.modalBodyHelper}>
<div>
<Player
ref={this.player}
poster="/assets/poster.png"
src={video.video}
startTime={video.startTime}
preload="auto"
/>
</div>
</ModalBody>
</Modal>
{!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 = {
currTime: 0,
video: {},
setVideoDuration: () => {},
getVideoCurrentTime: () => {},
};
OnboardingVideo.propTypes = {
currTime: PropTypes.number,
videos: PropTypes.object,
setVideoDuration: PropTypes.func,
getVideoCurrentTime: PropTypes.func,
};
export default OnboardingVideo;

View File

@ -0,0 +1,100 @@
li.listItem {
display: block;
padding: 10px 15px;
cursor: pointer;
&.finished {
.title {
color: #919bae;
}
.thumbWrapper {
.play {
background-color: #5a9e06;
&::before {
content: '\f00c';
font-size: 11px;
}
}
}
}
&:hover {
background-color: #f7f8f8;
.title {
color: #0e7de7;
}
}
.txtWrapper,
.thumbWrapper {
display: inline-block;
vertical-align: middle;
}
.thumbWrapper {
width: 55px;
height: 38px;
background-color: #d8d8d8;
border-radius: 2px;
overflow: hidden;
position: relative;
img {
width: 100%;
height: 100%;
}
.play {
width: 20px;
height: 20px;
line-height: 20px;
border-radius: 50%;
background-color: #0e7de7;
position: absolute;
top: 50%;
margin-top: -10px;
left: 50%;
margin-left: -10px;
text-align: center;
&::before {
content: '\f04b';
font-family: 'FontAwesome';
font-size: 9px;
color: white;
display: inline-block;
vertical-align: middle;
}
}
}
.txtWrapper {
padding: 0 15px;
p {
font-size: 14px;
font-family: Lato-SemiBold;
}
.time {
font-size: 11px;
color: #919bae;
font-family: Lato-Bold;
}
}
}
.modal .modal-dialog {
background: red;
}
.hiddenPlayerWrapper {
display: none;
}
.videoModal {
margin-right: auto !important;
margin-left: auto !important;
.videoModalHeader {
border-bottom: 0;
padding-bottom: 0;
> h5 {
font-family: Lato;
font-weight: bold!important;
font-size: 1.8rem!important;
}
> button {
margin-top: 0;
margin-right: 0;
}
}
}

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(false);
});
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,152 @@
.videosWrapper {
position: fixed;
right: 15px;
bottom: 15px;
button,
button:focus,
a {
cursor: pointer;
outline: 0;
}
p {
margin-bottom: 0;
}
.videosHeader {
padding: 15px 15px 0 15px;
p {
font-family: Lato-Bold;
font-size: 11px;
text-transform: uppercase;
display: inline-block;
width: 50%;
vertical-align: top;
color: #5c5f66;
&:last-of-type {
text-align: right;
color: #5a9e06;
}
}
}
.videosContent {
background-color: white;
margin-bottom: 10px;
margin-right: 15px;
box-shadow: 0 2px 4px 0 #e3e9f3;
border-radius: 3px;
transition: all 0.3s linear;
&.shown {
visibility: visible;
opacity: 1;
}
&.hide {
opacity: 0;
visibility: hidden;
}
ul {
list-style: none;
padding: 10px 0;
li {
&.finished {
.title {
color: #919bae;
}
.thumbWrapper {
.play {
background-color: #5a9e06;
&::before {
content: '\f00c';
font-size: 11px;
}
}
}
}
a {
display: block;
padding: 10px 15px;
&:hover {
background-color: #f7f8f8;
.title {
color: #0e7de7;
}
}
}
.txtWrapper,
.thumbWrapper {
display: inline-block;
vertical-align: middle;
}
.thumbWrapper {
width: 55px;
height: 38px;
background-color: #d8d8d8;
border-radius: 2px;
overflow: hidden;
position: relative;
img {
width: 100%;
height: 100%;
}
.play {
width: 20px;
height: 20px;
line-height: 20px;
border-radius: 50%;
background-color: #0e7de7;
position: absolute;
top: 50%;
margin-top: -10px;
left: 50%;
margin-left: -10px;
text-align: center;
&::before {
content: '\f04b';
font-family: 'FontAwesome';
font-size: 9px;
color: white;
display: inline-block;
vertical-align: middle;
}
}
}
.txtWrapper {
padding: 0 15px;
p {
font-size: 14px;
font-family: Lato-SemiBold;
}
.time {
font-size: 11px;
color: #919bae;
font-family: Lato-Bold;
}
}
}
}
}
.openBtn {
width: 38px;
height: 38px;
float: right;
button {
width: 100%;
height: 100%;
border-radius: 50%;
color: white;
background: #0e7de7;
-webkit-box-shadow: 0px 2px 4px 0px rgba(227, 233, 243, 1);
-moz-box-shadow: 0px 2px 4px 0px rgba(227, 233, 243, 1);
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;
}
}
}
}
}

View File

@ -0,0 +1,67 @@
/**
*
* PopUpWarning
*
*/
import React from 'react';
import PropTypes from 'prop-types';
// modal
import { Button, Modal, ModalHeader, ModalBody } from 'reactstrap';
import { Player } from 'video-react';
import { FormattedMessage } from 'react-intl';
import styles from './styles.scss';
function PopUpVideo({
content,
isOpen,
onConfirm,
onlyConfirmButton,
popUpWarningType,
toggleModal,
video,
}) {
return (
<div className={styles.popUpWarningHelper}>
<Player playsInline poster="/assets/poster.png" src={video} />
{/* <Modal
isOpen={isOpen}
toggle={toggleModal}
className={styles.modalPosition}
>
<ModalHeader toggle={toggleModal} className={styles.popUpWarningHeader}>
<FormattedMessage
id={content.title || 'components.popUpWarning.title'}
/>
</ModalHeader>
<ModalBody className={styles.modalBodyHelper}>
<div>
<video controls autoPlay src={src} />
</div>
</ModalBody>
</Modal> */}
</div>
);
}
PopUpVideo.propTypes = {
content: PropTypes.shape({
cancel: PropTypes.string,
confirm: PropTypes.string,
message: PropTypes.string,
title: PropTypes.string,
}),
};
PopUpVideo.defaultProps = {
content: {
cancel: 'components.popUpWarning.button.cancel',
confirm: 'components.popUpWarning.button.confirm',
message: 'components.popUpWarning.message',
title: 'components.popUpWarning.title',
},
};
export default PopUpVideo;

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

@ -38,6 +38,7 @@ import Content from 'containers/Content';
import LocaleToggle from 'containers/LocaleToggle';
import CTAWrapper from 'components/CtaWrapper';
import Header from 'components/Header/index';
import Onboarding from 'containers/Onboarding';
import HomePage from 'containers/HomePage/Loadable';
import Marketplace from 'containers/Marketplace/Loadable';
import LeftMenu from 'containers/LeftMenu';
@ -52,7 +53,7 @@ import FullStory from 'components/FullStory';
import auth from 'utils/auth';
import injectReducer from 'utils/injectReducer';
import injectSaga from 'utils/injectSaga';
import { getAdminData } from './actions';
import { emitEvent, getAdminData } from './actions';
import reducer from './reducer';
import saga from './saga';
import selectAdminPage from './selectors';
@ -70,6 +71,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,
@ -267,7 +269,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} />
@ -278,6 +284,7 @@ export class AdminPage extends React.Component {
isOpen={this.props.blockApp && this.props.showGlobalAppBlocker}
{...this.props.overlayBlockerData}
/>
<Onboarding />
</div>
);
}
@ -285,6 +292,7 @@ export class AdminPage extends React.Component {
AdminPage.childContextTypes = {
currentEnvironment: PropTypes.string.isRequired,
emitEvent: PropTypes.func,
disableGlobalOverlayBlocker: PropTypes.func,
enableGlobalOverlayBlocker: PropTypes.func,
plugins: PropTypes.object,
@ -336,6 +344,7 @@ function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
disableGlobalOverlayBlocker,
emitEvent,
enableGlobalOverlayBlocker,
getAdminData,
pluginLoaded,

View File

@ -11,11 +11,13 @@ import {
} from './constants';
const initialState = fromJS({
uuid: false,
uuid: true,
currentEnvironment: 'development',
isLoading: true,
layout: Map({}),
strapiVersion: '3',
eventName: '',
shouldEmit: true,
});
function adminPageReducer(state = initialState, action) {

View File

@ -5,7 +5,35 @@ import { makeSelectAppPlugins } from 'containers/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,
requestURL,
{
method: 'POST',
body: JSON.stringify({ event, uuid, properties }),
headers: {
'Content-Type': 'application/json',
},
},
false,
true,
{ noAuth: true },
);
}
} catch(err) {
console.log(err); // eslint-disable-line no-console
}
}
function* getData() {
try {
@ -32,6 +60,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

@ -122,7 +122,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)) {
@ -131,13 +135,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/3.x.x/getting-started/quick-start.html#_3-create-a-content-type',
id: 'app.components.HomePage.button.quickStart',
primary: true,
}
@ -158,7 +167,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: {
@ -177,7 +188,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) => (
@ -191,14 +207,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>
@ -265,10 +288,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,43 @@
/*
*
* Onboarding actions
*
*/
import { GET_VIDEOS, GET_VIDEOS_SUCCEEDED, ON_CLICK, SET_VIDEOS_DURATION, UPDATE_VIDEO_START_TIME } from './constants';
export function getVideos() {
return {
type: GET_VIDEOS,
};
}
export function getVideosSucceeded(videos) {
return {
type: GET_VIDEOS_SUCCEEDED,
videos,
};
}
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),
};
}

View File

@ -0,0 +1,12 @@
/*
*
* Onboarding constants
*
*/
export const GET_VIDEOS = 'StrapiAdmin/Onboarding/GET_VIDEOS';
export const GET_VIDEOS_SUCCEEDED =
'StrapiAdmin/Onboarding/GET_VIDEOS_SUCCEEDED';
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';

View File

@ -0,0 +1,133 @@
/**
*
* 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 { getVideos, onClick, setVideoDuration, updateVideoStartTime } from './actions';
import injectSaga from 'utils/injectSaga';
import injectReducer from 'utils/injectReducer';
import makeSelectOnboarding from './selectors';
import reducer from './reducer';
import saga from './saga';
import OnboardingVideo from 'components/OnboardingVideo';
import styles from './styles.scss';
export class Onboarding extends React.Component {
state = { showVideos: false };
componentWillMount() {
this.setState({ showVideos: true });
this.props.getVideos();
}
toggleVideos = () => {
// Display videos card
this.setState(prevState => ({ showVideos: !prevState.showVideos }));
// EmitEvent
const { showVideos } = this.state;
const eventName = showVideos ? 'didOpenGetStartedVideoContainer' : 'didCloseGetStartedVideoContainer';
this.context.emitEvent(eventName);
};
updateLocalStorage = (index, current) => {
// Update store
this.props.updateVideoStartTime(index, current);
// Update localStorage
let videosTime = JSON.parse(localStorage.getItem('videos'));
videosTime.fill(0);
videosTime[index] = current;
localStorage.setItem('videos', JSON.stringify(videosTime));
};
// eslint-disable-line react/prefer-stateless-function
render() {
const { videos, onClick, setVideoDuration } = this.props;
return (
<div className={styles.videosWrapper}>
<div
className={cn(
styles.videosContent,
this.state.showVideos ? styles.shown : styles.hide,
)}
>
<div className={styles.videosHeader}>
<p>Get started video</p>
<p>25% 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.updateLocalStorage}
/>
);
})}
</ul>
</div>
<div className={styles.openBtn}>
<button
onClick={this.toggleVideos}
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.propTypes = {
getVideos: PropTypes.func.isRequired,
};
const mapStateToProps = makeSelectOnboarding();
function mapDispatchToProps(dispatch) {
return bindActionCreators({ getVideos, onClick, setVideoDuration, updateVideoStartTime }, 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,47 @@
/*
*
* Onboarding reducer
*
*/
import { fromJS } from 'immutable';
import { GET_VIDEOS_SUCCEEDED, ON_CLICK, SET_VIDEOS_DURATION, UPDATE_VIDEO_START_TIME } 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 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:
return state.updateIn(['videos'], list => {
return list.reduce((acc, current, index) => {
if (index === action.index) {
return acc.updateIn([index, 'startTime'], () => action.startTime);
}
return acc.updateIn([index, 'startTime'], () => 0);
}, list);
});
default:
return state;
}
}
export default onboardingReducer;

View File

@ -0,0 +1,50 @@
import { GET_VIDEOS } from './constants';
import { getVideosSucceeded } from './actions';
import request from 'utils/request';
import { all, call, fork, takeLatest, put } from 'redux-saga/effects';
function* getVideos() {
try {
const videos = yield call(request, 'https://strapi.io/videos', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
},
false,
true,
{ noAuth: true },
);
let currTimes = Array.apply(null, Array(videos.length)).map(Number.prototype.valueOf,0);
// Retrieve start time if enable in localStorage
if (localStorage.getItem('videos')) {
currTimes.splice(0, currTimes.length, ...JSON.parse(localStorage.getItem('videos')));
} else {
localStorage.setItem('videos', JSON.stringify(currTimes));
}
yield put(
getVideosSucceeded(
videos.map((video, index) => {
video.isOpen = false;
video.duration = null;
video.startTime = currTimes[index];
return video;
}),
),
);
} catch (err) {
console.log('err');
console.log({ err });
}
}
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,77 @@
.videosWrapper {
position: fixed;
right: 15px;
bottom: 15px;
button,
button:focus,
a {
cursor: pointer;
outline: 0;
}
p {
margin-bottom: 0;
}
.videosHeader {
padding: 15px 15px 0 15px;
p {
font-family: Lato-Bold;
font-size: 11px;
text-transform: uppercase;
display: inline-block;
width: 50%;
vertical-align: top;
color: #5c5f66;
&:last-of-type {
text-align: right;
color: #5a9e06;
}
}
}
.videosContent {
background-color: white;
margin-bottom: 10px;
margin-right: 15px;
box-shadow: 0 2px 4px 0 #e3e9f3;
border-radius: 3px;
transition: all 0.3s linear;
&.shown {
visibility: visible;
opacity: 1;
}
&.hide {
opacity: 0;
visibility: hidden;
}
ul {
list-style: none;
padding: 10px 0;
}
}
.openBtn {
width: 38px;
height: 38px;
float: right;
button {
width: 100%;
height: 100%;
border-radius: 50%;
color: white;
background: #0e7de7;
-webkit-box-shadow: 0px 2px 4px 0px rgba(227, 233, 243, 1);
-moz-box-shadow: 0px 2px 4px 0px rgba(227, 233, 243, 1);
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;
}
}
}
}
}

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(false);
});
});

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(false);
});
});

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(false);
});
});

View File

@ -26,9 +26,13 @@
},
"dependencies": {
"intl": "^1.2.5",
"react": "^16.8.1",
"react-dom": "^16.8.1",
"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

@ -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

@ -73,7 +73,6 @@ const auth = {
return null;
},
setToken(value = '', isLocalStorage = false, tokenKey = TOKEN_KEY) {
return auth.set(value, tokenKey, isLocalStorage);
},

View File

@ -9,6 +9,7 @@ import auth from 'utils/auth';
* @return {object} The parsed JSON from the request
*/
function parseJSON(response) {
// return response;
return response.json ? response.json() : response;
}
@ -41,21 +42,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 +74,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 +95,7 @@ function serverRestartWatcher(response) {
})
.catch(() => {
setTimeout(() => {
return serverRestartWatcher(response)
.then(resolve);
return serverRestartWatcher(response).then(resolve);
}, 100);
});
});
@ -108,22 +109,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
@ -142,7 +159,7 @@ export default function request(url, options = {}, shouldWatchServerRestart = fa
return fetch(url, options)
.then(checkStatus)
.then(parseJSON)
.then((response) => {
.then(response => {
if (shouldWatchServerRestart) {
// Display the global OverlayBlocker
strapi.lockApp(shouldWatchServerRestart);

View File

@ -56,6 +56,7 @@ class AttributeRow extends React.Component {
handleEdit = () => this.props.onEditAttribute(this.props.row.name);
handleDelete = () => {
this.context.emitEvent('willDeleteFieldOfContentType');
this.props.onDelete(this.props.row.name);
this.setState({ showWarning: false });
};
@ -136,6 +137,10 @@ class AttributeRow extends React.Component {
}
}
AttributeRow.contextTypes = {
emitEvent: PropTypes.func,
};
AttributeRow.propTypes = {
onDelete: PropTypes.func.isRequired,
onEditAttribute: PropTypes.func.isRequired,

View File

@ -17,6 +17,9 @@ import styles from './styles.scss';
/* eslint-disable jsx-a11y/click-events-have-key-events */
class ContentHeader extends React.Component { // eslint-disable-line react/prefer-stateless-function
handleEdit = () => {
// Send event.
this.context.emitEvent('willEditNameOfContentType');
// Open modal.
router.push(this.props.editPath);
}
@ -41,6 +44,7 @@ class ContentHeader extends React.Component { // eslint-disable-line react/prefe
renderContentHeader = () => {
const description = isEmpty(this.props.description) ? '' : <FormattedMessage id={this.props.description} defaultMessage='{description}' values={{ description: this.props.description}} />;
const buttons = this.props.addButtons ? this.renderButtonContainer() : '';
return (
<div className={styles.contentHeader} style={this.props.styles}>
<div>
@ -74,6 +78,10 @@ class ContentHeader extends React.Component { // eslint-disable-line react/prefe
}
}
ContentHeader.contextTypes = {
emitEvent: PropTypes.func
};
ContentHeader.propTypes = {
addButtons: PropTypes.bool,
buttonsContent: PropTypes.array,

View File

@ -15,6 +15,12 @@ import styles from './styles.scss';
class PopUpHeaderNavLink extends React.Component { // eslint-disable-line react/prefer-stateless-function
handleGoTo = () => {
if (this.props.routePath.indexOf('#create::contentType') !== -1 && this.props.name === 'advancedSettings') {
this.context.emitEvent('didSelectContentTypeSettings');
} else if (this.props.routePath.indexOf('#create') !== -1 && this.props.routePath.indexOf('::attribute') !== -1 && this.props.name === 'advancedSettings') {
this.context.emitEvent('didSelectContentTypeFieldSettings');
}
router.push(replace(this.props.routePath, this.props.nameToReplace, this.props.name));
}
@ -29,6 +35,10 @@ class PopUpHeaderNavLink extends React.Component { // eslint-disable-line react/
}
}
PopUpHeaderNavLink.contextTypes = {
emitEvent: PropTypes.func
};
PopUpHeaderNavLink.propTypes = {
message: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,

View File

@ -373,6 +373,7 @@ export class Form extends React.Component { // eslint-disable-line react/prefer-
}
goToAttributeTypeView = (attributeType) => {
this.context.emitEvent('didSelectContentTypeFieldType', { type: attributeType });
const settings = attributeType === 'relation' ? 'defineRelation' : 'baseSettings';
router.push(`${this.props.routePath}#create${this.props.modelName}::attribute${attributeType}::${settings}`);
}
@ -461,6 +462,12 @@ export class Form extends React.Component { // eslint-disable-line react/prefer-
let dataSucces = null;
let cbFail;
if (redirectToChoose) {
this.context.emitEvent('willAddMoreFieldToContentType');
} else if (this.props.hash.indexOf('#edit') !== -1 && this.props.hash.indexOf('::attribute') !== -1) {
this.context.emitEvent('willEditFieldOfContentType');
}
switch (true) {
case includes(hashArray[0], '#edit'): {
// Check if the user is editing the attribute
@ -682,6 +689,7 @@ export class Form extends React.Component { // eslint-disable-line react/prefer-
}
Form.contextTypes = {
emitEvent: PropTypes.func,
plugins: PropTypes.object,
updatePlugin: PropTypes.func,
};

View File

@ -39,6 +39,8 @@ export function* editContentType(action) {
const response = yield call(request, requestUrl, opts, true);
if (response.ok) {
action.context.emitEvent('didEditNameOfContentType');
yield put(contentTypeActionSucceeded());
yield put(unsetButtonLoading());

View File

@ -46,6 +46,9 @@ export class HomePage extends React.Component { // eslint-disable-line react/pre
if (storeData.getIsModelTemporary()) {
strapi.notification.info('content-type-builder.notification.info.contentType.creating.notSaved');
} else {
// Send event.
this.context.emitEvent('willCreateContentType');
// Open CT modal.
this.toggleModal();
}
}
@ -108,6 +111,7 @@ export class HomePage extends React.Component { // eslint-disable-line react/pre
}
HomePage.contextTypes = {
emitEvent: PropTypes.func,
plugins: PropTypes.object,
updatePlugin: PropTypes.func,
};

View File

@ -315,6 +315,7 @@ export class ModelPage extends React.Component { // eslint-disable-line react/pr
}
ModelPage.contextTypes = {
emitEvent: PropTypes.func,
plugins: PropTypes.object,
updatePlugin: PropTypes.func,
};

View File

@ -107,15 +107,22 @@ export function* submitChanges(action) {
set(body, 'plugin', pluginModel);
}
const { emitEvent } = action.context;
const method = modelName === body.name ? 'POST' : 'PUT';
const baseUrl = '/content-type-builder/models/';
const requestUrl = method === 'POST' ? baseUrl : `${baseUrl}${body.name}`;
const opts = { method, body };
// Send event.
yield put(emitEvent('willSaveContentType'));
// Send request to save the content type.
const response = yield call(request, requestUrl, opts, true);
if (response.ok) {
if (method === 'POST') {
storeData.clearAppStorage();
yield put(emitEvent('didSaveContentType'));
yield put(temporaryContentTypePosted(size(get(body, 'attributes'))));
yield put(postContentTypeSucceeded());

View File

@ -80,6 +80,8 @@ module.exports = {
if (_.isEmpty(strapi.api)) {
strapi.emit('didCreateFirstContentType');
} else {
strapi.emit('didCreateContentType');
}
ctx.send({ ok: true });