Merge remote-tracking branch 'origin/react-pr' into react-ui-updates

Replaced dateFormat with moment

Conflicts:
	package.json
	src/backends/netlify-git/API.js
	src/containers/CollectionPage.js
	src/formats/formats.js
This commit is contained in:
Mathias Biilmann Christensen 2016-09-12 15:35:56 +02:00
commit 7cd5d84fa5
24 changed files with 617 additions and 58 deletions

View File

@ -67,6 +67,7 @@
},
"dependencies": {
"bricks.js": "^1.7.0",
"dateformat": "^1.0.12",
"fuzzy": "^0.1.1",
"js-base64": "^2.1.9",
"json-loader": "^0.5.4",
@ -76,6 +77,7 @@
"pluralize": "^3.0.0",
"prismjs": "^1.5.1",
"react-datetime": "^2.6.0",
"react-addons-css-transition-group": "^15.3.1",
"react-portal": "^2.2.1",
"selection-position": "^1.0.0",
"semaphore": "^1.0.5",

View File

@ -1,6 +1,8 @@
import yaml from 'js-yaml';
import _ from 'lodash';
import { currentBackend } from '../backends/backend';
import { authenticate } from '../actions/auth';
import * as publishModes from '../constants/publishModes';
import * as MediaProxy from '../valueObjects/MediaProxy';
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
@ -70,6 +72,11 @@ function parseConfig(data) {
}
}
if (!('publish_mode' in config) || _.values(publishModes).indexOf(config.publish_mode) === -1) {
// Make sure there is a publish workflow mode set
config.publish_mode = publishModes.SIMPLE;
}
if (!('public_folder' in config)) {
// Make sure there is a public folder
config.public_folder = config.media_folder;

View File

@ -0,0 +1,63 @@
import { currentBackend } from '../backends/backend';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
/*
* Contant Declarations
*/
export const INIT = 'init';
export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST';
export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS';
export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE';
/*
* Simple Action Creators (Internal)
*/
function unpublishedEntriesLoading() {
return {
type: UNPUBLISHED_ENTRIES_REQUEST
};
}
function unpublishedEntriesLoaded(entries, pagination) {
return {
type: UNPUBLISHED_ENTRIES_SUCCESS,
payload: {
entries: entries,
pages: pagination
}
};
}
function unpublishedEntriesFailed(error) {
return {
type: UNPUBLISHED_ENTRIES_FAILURE,
error: 'Failed to load entries',
payload: error.toString(),
};
}
/*
* Exported simple Action Creators
*/
export function init() {
return {
type: INIT
};
}
/*
* Exported Thunk Action Creators
*/
export function loadUnpublishedEntries() {
return (dispatch, getState) => {
const state = getState();
if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return;
const backend = currentBackend(state.config);
dispatch(unpublishedEntriesLoading());
backend.unpublishedEntries().then(
(response) => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)),
(error) => dispatch(unpublishedEntriesFailed(error))
);
};
}

View File

@ -17,7 +17,6 @@ export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY';
export const DRAFT_DISCARD = 'DRAFT_DISCARD';
export const DRAFT_CHANGE = 'DRAFT_CHANGE';
export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE';

View File

@ -3,7 +3,6 @@ import GitHubBackend from './github/implementation';
import NetlifyGitBackend from './netlify-git/implementation';
import { resolveFormat } from '../formats/formats';
import { createEntry } from '../valueObjects/Entry';
import { SIMPLE, BRANCH } from './constants';
class LocalStorageAuthStore {
storageKey = 'nf-cms-user';
@ -22,7 +21,7 @@ class Backend {
constructor(implementation, authStore = null) {
this.implementation = implementation;
this.authStore = authStore;
if (this.implementation == null) {
if (this.implementation === null) {
throw 'Cannot instantiate a Backend with no implementation';
}
}
@ -49,7 +48,6 @@ class Backend {
entries(collection, page, perPage) {
return this.implementation.entries(collection, page, perPage).then((response) => {
console.log("Got %s entries", response.entries.length);
return {
pagination: response.pagination,
entries: response.entries.map(this.entryWithFormat(collection))
@ -66,9 +64,9 @@ class Backend {
return this.entryWithFormat(collection)(newEntry);
}
entryWithFormat(collection) {
entryWithFormat(collectionOrEntity) {
return (entry) => {
const format = resolveFormat(collection, entry);
const format = resolveFormat(collectionOrEntity, entry);
if (entry && entry.raw) {
entry.data = format && format.fromFile(entry.raw);
}
@ -76,6 +74,15 @@ class Backend {
};
}
unpublishedEntries(page, perPage) {
return this.implementation.unpublishedEntries(page, perPage).then((response) => {
return {
pagination: response.pagination,
entries: response.entries.map(this.entryWithFormat('editorialWorkflow'))
};
});
}
slugFormatter(template, entry) {
var date = new Date();
return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) {
@ -94,19 +101,14 @@ class Backend {
});
}
getPublishMode(config) {
const publish_modes = [SIMPLE, BRANCH];
const mode = config.getIn(['backend', 'publish_mode']);
if (publish_modes.indexOf(mode) !== -1) {
return mode;
} else {
return SIMPLE;
}
}
persistEntry(config, collection, entryDraft, MediaFiles) {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const parsedData = {
title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'),
description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description'),
};
const entryData = entryDraft.getIn(['entry', 'data']).toJS();
let entryObj;
if (newEntry) {
@ -128,11 +130,13 @@ class Backend {
collection.get('label') + ' “' +
entryDraft.getIn(['entry', 'data', 'title']) + '”';
const mode = this.getPublishMode(config);
const mode = config.get('publish_mode');
const collectionName = collection.get('name');
return this.implementation.persistEntry(entryObj, MediaFiles, { newEntry, commitMessage, collectionName, mode });
return this.implementation.persistEntry(entryObj, MediaFiles, {
newEntry, parsedData, commitMessage, collectionName, mode
});
}
entryToRaw(collection, entry) {

View File

@ -1,3 +0,0 @@
// Create/edit modes
export const SIMPLE = 'simple';
export const BRANCH = 'branch';

View File

@ -1,11 +1,13 @@
import LocalForage from 'localforage';
import MediaProxy from '../../valueObjects/MediaProxy';
import { Base64 } from 'js-base64';
import { BRANCH } from '../constants';
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
const API_ROOT = 'https://api.github.com';
export default class API {
constructor(token, repo, branch) {
this.token = token;
this.repo = repo;
@ -100,38 +102,28 @@ export default class API {
});
}
retrieveMetadata(key, data) {
const cache = LocalForage.getItem(`gh.meta.${key}`);
return cache.then((cached) => {
if (cached && cached.expires > Date.now()) { return cached.data; }
return this.request(`${this.repoURL}/contents/${key}.json?ref=refs/meta/_netlify_cms`, {
retrieveMetadata(key) {
return this.request(`${this.repoURL}/contents/${key}.json`, {
params: { ref: 'refs/meta/_netlify_cms' },
headers: { Accept: 'application/vnd.github.VERSION.raw' },
cache: 'no-store',
}).then((result) => {
LocalForage.setItem(`gh.meta.${key}`, {
expires: Date.now() + 300000, // In 5 minutes
data: result,
});
return result;
});
});
})
.then(response => JSON.parse(response));
}
readFile(path, sha) {
readFile(path, sha, branch = this.branch) {
const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null);
return cache.then((cached) => {
if (cached) { return cached; }
return this.request(`${this.repoURL}/contents/${path}`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
params: { ref: this.branch },
params: { ref: branch },
cache: false
}).then((result) => {
if (sha) {
LocalForage.setItem(`gh.${sha}`, result);
}
return result;
});
});
@ -143,13 +135,44 @@ export default class API {
});
}
readUnpublishedBranchFile(contentKey) {
const cache = LocalForage.getItem(`gh.unpublished.${contentKey}`);
return cache.then((cached) => {
if (cached && cached.expires > Date.now()) { return cached.data; }
let metaData;
return this.retrieveMetadata(contentKey)
.then(data => {
metaData = data;
return this.readFile(data.objects.entry, null, data.branch);
})
.then(file => {
return { metaData, file };
})
.then((result) => {
LocalForage.setItem(`gh.unpublished.${contentKey}`, {
expires: Date.now() + 300000, // In 5 minutes
data: result,
});
return result;
});
});
}
listUnpublishedBranches() {
return this.request(`${this.repoURL}/git/refs/heads/cms`);
}
persistFiles(entry, mediaFiles, options) {
let filename, part, parts, subtree;
const fileTree = {};
const files = [];
mediaFiles.concat(entry).forEach((file) => {
const uploadPromises = [];
const files = mediaFiles.concat(entry);
files.forEach((file) => {
if (file.uploaded) { return; }
files.push(this.uploadBlob(file));
uploadPromises.push(this.uploadBlob(file));
parts = file.path.split('/').filter((part) => part);
filename = parts.pop();
subtree = fileTree;
@ -160,15 +183,32 @@ export default class API {
subtree[filename] = file;
file.file = true;
});
return Promise.all(files)
return Promise.all(uploadPromises)
.then(() => this.getBranch())
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then((response) => {
if (options.mode && options.mode === BRANCH) {
if (options.mode && options.mode === EDITORIAL_WORKFLOW) {
const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug;
return this.createBranch(`cms/${contentKey}`, response.sha)
.then(this.storeMetadata(contentKey, { status: 'draft' }))
const branchName = `cms/${contentKey}`;
return this.user().then(user => {
return user.name ? user.name : user.login;
})
.then(username => this.storeMetadata(contentKey, {
type: 'PR',
user: username,
status: status.first(),
branch: branchName,
collection: options.collectionName,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
objects: {
entry: entry.path,
files: mediaFiles.map(file => file.path)
},
timeStamp: new Date().toISOString()
}))
.then(this.createBranch(branchName, response.sha))
.then(this.createPR(options.commitMessage, `cms/${contentKey}`));
} else {
return this.patchBranch(this.branch, response.sha);

View File

@ -62,4 +62,32 @@ export default class GitHub {
persistEntry(entry, mediaFiles = [], options = {}) {
return this.api.persistFiles(entry, mediaFiles, options);
}
unpublishedEntries() {
return this.api.listUnpublishedBranches().then((branches) => {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
const promises = [];
branches.map((branch) => {
promises.push(new Promise((resolve, reject) => {
const contentKey = branch.ref.split('refs/heads/cms/').pop();
return sem.take(() => this.api.readUnpublishedBranchFile(contentKey).then((data) => {
const entryPath = data.metaData.objects.entry;
const entry = createEntry(entryPath, entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), data.file);
entry.metaData = data.metaData;
resolve(entry);
sem.leave();
}).catch((err) => {
sem.leave();
reject(err);
}));
}));
});
return Promise.all(promises);
}).then((entries) => {
return {
pagination: {},
entries
};
});
}
}

View File

@ -1,7 +1,7 @@
import LocalForage from 'localforage';
import MediaProxy from '../../valueObjects/MediaProxy';
import { Base64 } from 'js-base64';
import { BRANCH } from '../constants';
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
export default class API {
constructor(token, url, branch) {
@ -100,6 +100,7 @@ export default class API {
if (cached && cached.expires > Date.now()) { return cached.data; }
return this.request(`${this.repoURL}/files/${key}.json?ref=refs/meta/_netlify_cms`, {
params: { ref: 'refs/meta/_netlify_cms' },
headers: { 'Content-Type': 'application/vnd.netlify.raw' },
cache: 'no-store',
}).then((result) => {
@ -160,7 +161,7 @@ export default class API {
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then((response) => {
if (options.mode && options.mode === BRANCH) {
if (options.mode && options.mode === EDITORIAL_WORKFLOW) {
const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug;
return this.createBranch(`cms/${contentKey}`, response.sha)
.then(this.storeMetadata(contentKey, { status: 'draft' }))

View File

@ -57,4 +57,5 @@ export default class TestRepo {
mediaFiles.forEach(media => media.uploaded = true);
return Promise.resolve();
}
}

View File

@ -1,2 +1,3 @@
export { default as Card } from './card/Card';
export { default as Loader } from './loader/Loader';
export { default as Icon } from './icon/Icon';

View File

@ -0,0 +1,115 @@
.loader {
display: none;
position: absolute;
top: 50%;
left: 50%;
margin: 0px;
text-align: center;
z-index: 1000;
-webkit-transform: translateX(-50%) translateY(-50%);
-ms-transform: translateX(-50%) translateY(-50%);
transform: translateX(-50%) translateY(-50%);
}
/* Static Shape */
.loader:before {
position: absolute;
content: '';
top: 0%;
left: 50%;
width: 100%;
height: 100%;
border-radius: 500rem;
border: 0.2em solid rgba(0, 0, 0, 0.1);
}
/* Active Shape */
.loader:after {
position: absolute;
content: '';
top: 0%;
left: 50%;
width: 100%;
height: 100%;
animation: loader 0.6s linear;
animation-iteration-count: infinite;
border-radius: 500rem;
border-color: #767676 transparent transparent;
border-style: solid;
border-width: 0.2em;
box-shadow: 0px 0px 0px 1px transparent;
}
/* Active Animation */
@-webkit-keyframes loader {
from {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes loader {
from {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.loader:before,
.loader:after {
width: 2.28571429rem;
height: 2.28571429rem;
margin: 0em 0em 0em -1.14285714rem;
}
.text {
width: auto !important;
height: auto !important;
text-align: center;
color: #767676;
margin-top: 35px;
}
.active {
display: block;
}
.disabled {
display: none;
}
/*Animations*/
.animateItem{
position: absolute;
white-space: nowrap;
transform: translateX(-50%);
}
.enter {
opacity: 0.01;
}
.enter.enterActive {
opacity: 1;
transition: opacity 500ms ease-in;
}
.leave {
opacity: 1;
}
.leave.leaveActive {
opacity: 0.01;
transition: opacity 300ms ease-in;
}

View File

@ -0,0 +1,68 @@
import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import styles from './Loader.css';
export default class Loader extends React.Component {
constructor(props) {
super(props);
this.state = {
currentItem: 0,
};
this.setAnimation = this.setAnimation.bind(this);
this.renderChild = this.renderChild.bind(this);
}
componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval);
}
}
setAnimation() {
if (this.interval) return;
const { children } = this.props;
this.interval = setInterval(() => {
const nextItem = (this.state.currentItem === children.length - 1) ? 0 : this.state.currentItem + 1;
this.setState({ currentItem: nextItem });
}, 5000);
}
renderChild() {
const { children } = this.props;
const { currentItem } = this.state;
if (!children) {
return null;
} else if (typeof children == 'string') {
return <div className={styles.text}>{children}</div>;
} else if (Array.isArray(children)) {
this.setAnimation();
return <div className={styles.text}>
<ReactCSSTransitionGroup
transitionName={styles}
transitionEnterTimeout={500}
transitionLeaveTimeout={500}
>
<div key={currentItem} className={styles.animateItem}>{children[currentItem]}</div>
</ReactCSSTransitionGroup>
</div>;
}
}
render() {
const { active, style, className = '' } = this.props;
// Class names
let classNames = styles.loader;
if (active) {
classNames += ` ${styles.active}`;
}
if (className.length > 0) {
classNames += ` ${className}`;
}
return <div className={classNames} style={style}>{this.renderChild()}</div>;
}
}

View File

@ -0,0 +1,29 @@
.column {
position: relative;
display: inline-block;
vertical-align: top;
text-align: center;
width: 28%;
}
.column:not(:last-child) {
margin-right: 8%;
}
.card {
width: 100% !important;
margin: 7px 0;
& h1 {
font-size: 17px;
& small {
font-weight: normal;
}
}
& p {
color: #555;
font-size: 12px;
margin-top: 5px;
}
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import moment from 'moment';
import { Card } from './UI';
import { statusDescriptions } from '../constants/publishModes';
import styles from './UnpublishedListing.css';
export default class UnpublishedListing extends React.Component {
renderColumns(entries, column) {
if (!entries) return;
if (!column) {
return entries.entrySeq().map(([currColumn, currEntries]) => (
<div key={currColumn} className={styles.column}>
<h3>{statusDescriptions.get(currColumn)}</h3>
{this.renderColumns(currEntries, currColumn)}
</div>
));
} else {
return <div>
{entries.map(entry => {
// Look for an "author" field. Fallback to username on backend implementation;
const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user']));
const timeStamp = moment(entry.getIn(['metaData', 'timeStamp'])).formate('llll');
return (
<Card key={entry.get('slug')} className={styles.card}>
<h1>{entry.getIn(['data', 'title'])} <small>by {author}</small></h1>
<p>Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}</p>
</Card>
);
}
)}
</div>;
}
}
render() {
const columns = this.renderColumns(this.props.entries);
return (
<div>
{columns}
</div>
);
}
}
UnpublishedListing.propTypes = {
entries: ImmutablePropTypes.orderedMap,
};

View File

@ -0,0 +1,18 @@
import { Map, OrderedMap } from 'immutable';
// Create/edit workflow modes
export const SIMPLE = 'simple';
export const EDITORIAL_WORKFLOW = 'editorial_workflow';
// Available status
export const status = OrderedMap({
DRAFT: 'draft',
PENDING_REVIEW: 'pending_review',
PENDING_PUBLISH: 'pending_publish',
});
export const statusDescriptions = Map({
[status.get('DRAFT')]: 'Draft',
[status.get('PENDING_REVIEW')]: 'Waiting for Review',
[status.get('PENDING_PUBLISH')]: 'Waiting to go live',
});

View File

@ -3,13 +3,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { loadEntries } from '../actions/entries';
import { selectEntries } from '../reducers';
import { Loader } from '../components/UI';
import EntryListing from '../components/EntryListing';
import styles from './CollectionPage.css';
import EditorialWorkflow from './EditorialWorkflowHoC';
class DashboardPage extends React.Component {
componentDidMount() {
const { collection, dispatch } = this.props;
if (collection) {
dispatch(loadEntries(collection));
}
@ -28,12 +29,16 @@ class DashboardPage extends React.Component {
return <h1>No collections defined in your config.yml</h1>;
}
return <div className={styles.alignable}>
{entries ? <EntryListing collection={collection} entries={entries}/> : 'Loading entries...'}
{entries ?
<EntryListing collection={collection} entries={entries}/>
:
<Loader active>{['Loading Entries', 'Caching Entries', 'This might take several minutes']}</Loader>
}
</div>;
}
}
DashboardPage.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired,
@ -41,6 +46,13 @@ DashboardPage.propTypes = {
entries: ImmutablePropTypes.list,
};
/*
* Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff,
* We delegate it to a Higher Order Component
*/
DashboardPage = EditorialWorkflow(DashboardPage);
function mapStateToProps(state, ownProps) {
const { collections } = state;
const { name, slug } = ownProps.params;

View File

@ -0,0 +1,60 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { OrderedMap } from 'immutable';
import { init, loadUnpublishedEntries } from '../actions/editorialWorkflow';
import { selectUnpublishedEntries } from '../reducers';
import { EDITORIAL_WORKFLOW, status } from '../constants/publishModes';
import UnpublishedListing from '../components/UnpublishedListing';
import { connect } from 'react-redux';
export default function EditorialWorkflow(WrappedComponent) {
class EditorialWorkflow extends WrappedComponent {
componentDidMount() {
const { dispatch, isEditorialWorkflow } = this.props;
if (isEditorialWorkflow) {
dispatch(init());
dispatch(loadUnpublishedEntries());
}
super.componentDidMount();
}
render() {
const { isEditorialWorkflow, unpublishedEntries } = this.props;
if (!isEditorialWorkflow) return super.render();
return (
<div>
<UnpublishedListing entries={unpublishedEntries}/>
{super.render()}
</div>
);
}
}
EditorialWorkflow.propTypes = {
dispatch: PropTypes.func.isRequired,
isEditorialWorkflow: PropTypes.bool.isRequired,
unpublishedEntries: ImmutablePropTypes.map,
};
function mapStateToProps(state) {
const publish_mode = state.config.get('publish_mode');
const isEditorialWorkflow = (publish_mode === EDITORIAL_WORKFLOW);
const returnObj = { isEditorialWorkflow };
if (isEditorialWorkflow) {
/*
* Generates an ordered Map of the available status as keys.
* Each key containing a List of available unpubhlished entries
* Eg.: OrderedMap{'draft':List(), 'pending_review':List(), 'pending_publish':List()}
*/
returnObj.unpublishedEntries = status.reduce((acc, currStatus) => {
return acc.set(currStatus, selectUnpublishedEntries(state, currStatus));
}, OrderedMap());
}
return returnObj;
}
return connect(mapStateToProps)(EditorialWorkflow);
}

View File

@ -4,7 +4,7 @@ import YAMLFrontmatter from './yaml-frontmatter';
const yamlFormatter = new YAML();
const YamlFrontmatterFormatter = new YAMLFrontmatter();
export function resolveFormat(collection, entry) {
export function resolveFormat(collectionOrEntity, entry) {
const extension = entry.path.split('.').pop();
switch (extension) {
case 'yml':

View File

@ -0,0 +1,49 @@
import { Map, List, fromJS } from 'immutable';
import {
INIT, UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS
} from '../actions/editorialWorkflow';
const unpublishedEntries = (state = null, action) => {
switch (action.type) {
case INIT:
// Editorial workflow must be explicitly initiated.
return Map({ entities: Map(), pages: Map() });
case UNPUBLISHED_ENTRIES_REQUEST:
return state.setIn(['pages', 'isFetching'], true);
case UNPUBLISHED_ENTRIES_SUCCESS:
const { entries, pages } = action.payload;
return state.withMutations((map) => {
entries.forEach((entry) => (
map.setIn(['entities', `${entry.metaData.status}.${entry.slug}`], fromJS(entry).set('isFetching', false))
));
map.set('pages', Map({
...pages,
ids: List(entries.map((entry) => entry.slug))
}));
});
default:
return state;
}
};
export const selectUnpublishedEntry = (state, status, slug) => (
state.getIn(['entities', `${status}.${slug}`])
);
export const selectUnpublishedEntries = (state, status) => {
if (!state) return;
const slugs = state.getIn(['pages', 'ids']);
return slugs && slugs.reduce((acc, slug) => {
const entry = selectUnpublishedEntry(state, status, slug);
if (entry) {
return acc.push(entry);
} else {
return acc;
}
}, List());
};
export default unpublishedEntries;

View File

@ -7,13 +7,16 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
switch (action.type) {
case ENTRY_REQUEST:
return state.setIn(['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'], true);
case ENTRY_SUCCESS:
return state.setIn(
['entities', `${action.payload.collection}.${action.payload.entry.slug}`],
fromJS(action.payload.entry)
);
case ENTRIES_REQUEST:
return state.setIn(['pages', action.payload.collection, 'isFetching'], true);
case ENTRIES_SUCCESS:
const { collection, entries, pages } = action.payload;
return state.withMutations((map) => {
@ -25,6 +28,7 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
ids: List(entries.map((entry) => entry.slug))
}));
});
default:
return state;
}

View File

@ -2,6 +2,7 @@ import auth from './auth';
import config from './config';
import editor from './editor';
import entries, * as fromEntries from './entries';
import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import entryDraft from './entryDraft';
import collections from './collections';
import medias, * as fromMedias from './medias';
@ -12,18 +13,27 @@ const reducers = {
collections,
editor,
entries,
editorialWorkflow,
entryDraft,
medias
};
export default reducers;
/*
* Selectors
*/
export const selectEntry = (state, collection, slug) =>
fromEntries.selectEntry(state.entries, collection, slug);
export const selectEntries = (state, collection) =>
fromEntries.selectEntries(state.entries, collection);
export const selectUnpublishedEntry = (state, status, slug) =>
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, status, slug);
export const selectUnpublishedEntries = (state, status) =>
fromEditorialWorkflow.selectUnpublishedEntries(state.editorialWorkflow, status);
export const getMedia = (state, path) =>
fromMedias.getMedia(state.medias, path);

View File

@ -4,5 +4,6 @@ export function createEntry(path = '', slug = '', raw = '') {
returnObj.slug = slug;
returnObj.raw = raw;
returnObj.data = {};
returnObj.metaData = {};
return returnObj;
}