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:
commit
7cd5d84fa5
@ -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",
|
||||
|
@ -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;
|
||||
|
63
src/actions/editorialWorkflow.js
Normal file
63
src/actions/editorialWorkflow.js
Normal 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))
|
||||
);
|
||||
};
|
||||
}
|
@ -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';
|
||||
|
@ -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) {
|
||||
|
@ -1,3 +0,0 @@
|
||||
// Create/edit modes
|
||||
export const SIMPLE = 'simple';
|
||||
export const BRANCH = 'branch';
|
@ -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);
|
||||
|
@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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' }))
|
||||
|
@ -57,4 +57,5 @@ export default class TestRepo {
|
||||
mediaFiles.forEach(media => media.uploaded = true);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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';
|
||||
|
115
src/components/UI/loader/Loader.css
Normal file
115
src/components/UI/loader/Loader.css
Normal 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;
|
||||
}
|
68
src/components/UI/loader/Loader.js
Normal file
68
src/components/UI/loader/Loader.js
Normal 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>;
|
||||
|
||||
}
|
||||
}
|
29
src/components/UnpublishedListing.css
Normal file
29
src/components/UnpublishedListing.css
Normal 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;
|
||||
}
|
||||
}
|
50
src/components/UnpublishedListing.js
Normal file
50
src/components/UnpublishedListing.js
Normal 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,
|
||||
};
|
18
src/constants/publishModes.js
Normal file
18
src/constants/publishModes.js
Normal 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',
|
||||
});
|
@ -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;
|
||||
|
60
src/containers/EditorialWorkflowHoC.js
Normal file
60
src/containers/EditorialWorkflowHoC.js
Normal 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);
|
||||
}
|
@ -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':
|
||||
|
49
src/reducers/editorialWorkflow.js
Normal file
49
src/reducers/editorialWorkflow.js
Normal 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;
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -4,5 +4,6 @@ export function createEntry(path = '', slug = '', raw = '') {
|
||||
returnObj.slug = slug;
|
||||
returnObj.raw = raw;
|
||||
returnObj.data = {};
|
||||
returnObj.metaData = {};
|
||||
return returnObj;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user