diff --git a/package.json b/package.json index a3c0dd05..9f48f4d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/actions/config.js b/src/actions/config.js index 00082c97..8b286142 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -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; diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js new file mode 100644 index 00000000..5c906f1c --- /dev/null +++ b/src/actions/editorialWorkflow.js @@ -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)) + ); + }; +} diff --git a/src/actions/entries.js b/src/actions/entries.js index 4f239d2f..9c9cffd6 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -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'; diff --git a/src/backends/backend.js b/src/backends/backend.js index d7d52a15..66fbd33c 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -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) { diff --git a/src/backends/constants.js b/src/backends/constants.js deleted file mode 100644 index 6f6c6852..00000000 --- a/src/backends/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -// Create/edit modes -export const SIMPLE = 'simple'; -export const BRANCH = 'branch'; diff --git a/src/backends/github/API.js b/src/backends/github/API.js index fa165b9a..210a229f 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -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`, { - 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; - }); - }); + 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(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); diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 9cb11f45..a4540ce1 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,5 +1,5 @@ import semaphore from 'semaphore'; -import {createEntry} from '../../valueObjects/Entry'; +import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; @@ -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 + }; + }); + } } diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index d0bad38c..cbe9e8ff 100644 --- a/src/backends/netlify-git/API.js +++ b/src/backends/netlify-git/API.js @@ -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' })) diff --git a/src/backends/netlify-git/implementation.js b/src/backends/netlify-git/implementation.js index cd52f5ff..cf7f21ab 100644 --- a/src/backends/netlify-git/implementation.js +++ b/src/backends/netlify-git/implementation.js @@ -1,5 +1,5 @@ import semaphore from 'semaphore'; -import {createEntry} from '../../valueObjects/Entry'; +import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index fce8d3d8..b8cf56cc 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -57,4 +57,5 @@ export default class TestRepo { mediaFiles.forEach(media => media.uploaded = true); return Promise.resolve(); } + } diff --git a/src/components/UI/index.js b/src/components/UI/index.js index fae9d19b..7f538b2a 100644 --- a/src/components/UI/index.js +++ b/src/components/UI/index.js @@ -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'; diff --git a/src/components/UI/loader/Loader.css b/src/components/UI/loader/Loader.css new file mode 100644 index 00000000..69d3b9ad --- /dev/null +++ b/src/components/UI/loader/Loader.css @@ -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; +} diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js new file mode 100644 index 00000000..c2b8ec5d --- /dev/null +++ b/src/components/UI/loader/Loader.js @@ -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
{children}
; + } else if (Array.isArray(children)) { + this.setAnimation(); + return
+ +
{children[currentItem]}
+
+
; + } + } + + 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
{this.renderChild()}
; + + } +} diff --git a/src/components/UnpublishedListing.css b/src/components/UnpublishedListing.css new file mode 100644 index 00000000..a4b97b67 --- /dev/null +++ b/src/components/UnpublishedListing.css @@ -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; + } +} diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js new file mode 100644 index 00000000..85fce44e --- /dev/null +++ b/src/components/UnpublishedListing.js @@ -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]) => ( +
+

{statusDescriptions.get(currColumn)}

+ {this.renderColumns(currEntries, currColumn)} +
+ )); + } else { + return
+ {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 ( + +

{entry.getIn(['data', 'title'])} by {author}

+

Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}

+
+ ); + } + )} +
; + } + } + + render() { + const columns = this.renderColumns(this.props.entries); + + return ( +
+ {columns} +
+ ); + } +} + +UnpublishedListing.propTypes = { + entries: ImmutablePropTypes.orderedMap, +}; diff --git a/src/constants/publishModes.js b/src/constants/publishModes.js new file mode 100644 index 00000000..2cf4e443 --- /dev/null +++ b/src/constants/publishModes.js @@ -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', +}); diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index 671cc4ce..ec2486d8 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -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

No collections defined in your config.yml

; } + return
- {entries ? : 'Loading entries...'} + {entries ? + + : + {['Loading Entries', 'Caching Entries', 'This might take several minutes']} + }
; } } - 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; diff --git a/src/containers/EditorialWorkflowHoC.js b/src/containers/EditorialWorkflowHoC.js new file mode 100644 index 00000000..603f7819 --- /dev/null +++ b/src/containers/EditorialWorkflowHoC.js @@ -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 ( +
+ + {super.render()} +
+ ); + } + } + + 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); +} diff --git a/src/formats/formats.js b/src/formats/formats.js index 8e3679b5..42b1b7b1 100644 --- a/src/formats/formats.js +++ b/src/formats/formats.js @@ -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': diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js new file mode 100644 index 00000000..95cf9e28 --- /dev/null +++ b/src/reducers/editorialWorkflow.js @@ -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; diff --git a/src/reducers/entries.js b/src/reducers/entries.js index 70e73cd2..6ae5e554 100644 --- a/src/reducers/entries.js +++ b/src/reducers/entries.js @@ -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; } diff --git a/src/reducers/index.js b/src/reducers/index.js index f7199111..62615bdc 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -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); diff --git a/src/valueObjects/Entry.js b/src/valueObjects/Entry.js index 36ce0de0..ab247a6c 100644 --- a/src/valueObjects/Entry.js +++ b/src/valueObjects/Entry.js @@ -4,5 +4,6 @@ export function createEntry(path = '', slug = '', raw = '') { returnObj.slug = slug; returnObj.raw = raw; returnObj.data = {}; + returnObj.metaData = {}; return returnObj; }