From e852991954f477d6d1a55db29dbdd7c84d2f5f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 12:12:38 -0300 Subject: [PATCH 01/13] Storing more complete commit information on branch metadata --- src/actions/config.js | 5 +++++ src/backends/backend.js | 13 ++++++++++--- src/backends/github/API.js | 25 ++++++++++++++++++------- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/actions/config.js b/src/actions/config.js index 00082c97..57a51510 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -70,6 +70,11 @@ function parseConfig(data) { } } + if (!('publish_mode' in config.backend)) { + // Make sure there is a publish mode + config.backend['publish_mode'] = 'simple'; + } + if (!('public_folder' in config)) { // Make sure there is a public folder config.public_folder = config.media_folder; diff --git a/src/backends/backend.js b/src/backends/backend.js index 43e00126..cafcf668 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -21,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'; } } @@ -103,8 +103,13 @@ class Backend { } 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) { @@ -130,7 +135,9 @@ class Backend { 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/github/API.js b/src/backends/github/API.js index 13025037..7c4de494 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -101,7 +101,7 @@ export default class API { }); return result; }); - }); + }).catch(error => null); } readFile(path, sha) { @@ -132,10 +132,13 @@ export default class API { 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; @@ -146,15 +149,23 @@ 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) { 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.createBranch(branchName, response.sha) + .then(this.storeMetadata(contentKey, { + type: 'PR', + status: 'draft', + branch: branchName, + title: options.parsedData.title, + description: options.parsedData.description, + objects: files.map(file => file.path) + })) .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); } else { return this.patchBranch(this.branch, response.sha); From 7d2ab1a45ebe4099b2352898df915005decdcd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 16:12:37 -0300 Subject: [PATCH 02/13] Use urlfor params --- src/backends/github/API.js | 3 ++- src/backends/netlify-git/API.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index a49a2585..453f3e89 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -105,7 +105,8 @@ export default class API { 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`, { + 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) => { diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index 8c072a30..ef16c56d 100644 --- a/src/backends/netlify-git/API.js +++ b/src/backends/netlify-git/API.js @@ -99,7 +99,8 @@ export default class API { return cache.then((cached) => { if (cached && cached.expires > Date.now()) { return cached.data; } - return this.request(`${this.repoURL}/files/${key}.json?ref=refs/meta/_netlify_cms`, { + return this.request(`${this.repoURL}/files/${key}.json`, { + params: { ref: 'refs/meta/_netlify_cms' }, headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', }).then((result) => { From 626164a2f8ff81c8afb6cc16896f8ebeb0a545c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 16:13:41 -0300 Subject: [PATCH 03/13] Lint --- src/backends/netlify-git/implementation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'; From 1c464743849a40e63add7732ccc9f7bbb4d543bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 16:15:03 -0300 Subject: [PATCH 04/13] Loader component --- src/components/UI/index.js | 1 + src/components/UI/loader/Loader.css | 95 +++++++++++++++++++++++++++++ src/components/UI/loader/Loader.js | 21 +++++++ 3 files changed, 117 insertions(+) create mode 100644 src/components/UI/loader/Loader.css create mode 100644 src/components/UI/loader/Loader.js 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..c46da16c --- /dev/null +++ b/src/components/UI/loader/Loader.css @@ -0,0 +1,95 @@ +.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%; + -webkit-animation: loader 0.6s linear; + animation: loader 0.6s linear; + -webkit-animation-iteration-count: infinite; + 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; +} diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js new file mode 100644 index 00000000..6b7ecb42 --- /dev/null +++ b/src/components/UI/loader/Loader.js @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './Loader.css'; + +export default function Loader({ active, style, className = '', children }) { + // Class names + let classNames = styles.loader; + if (active) { + classNames += ` ${styles.active}`; + } + if (className.length > 0) { + classNames += ` ${className}`; + } + + // Render child text + let child; + if (children) { + child =
{children}
; + } + + return
{child}
; +} From a76aade7c38d58e36f69685ad5d4b70fa096af32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 17:24:24 -0300 Subject: [PATCH 05/13] Loading Animation --- package.json | 1 + src/components/UI/loader/Loader.css | 24 +++++++++- src/components/UI/loader/Loader.js | 74 ++++++++++++++++++++++++----- src/containers/CollectionPage.js | 7 ++- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index c1db9538..0ac77f63 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "markup-it": "git+https://github.com/cassiozen/markup-it.git", "pluralize": "^3.0.0", "prismjs": "^1.5.1", + "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/components/UI/loader/Loader.css b/src/components/UI/loader/Loader.css index c46da16c..69d3b9ad 100644 --- a/src/components/UI/loader/Loader.css +++ b/src/components/UI/loader/Loader.css @@ -33,9 +33,7 @@ left: 50%; width: 100%; height: 100%; - -webkit-animation: loader 0.6s linear; animation: loader 0.6s linear; - -webkit-animation-iteration-count: infinite; animation-iteration-count: infinite; border-radius: 500rem; border-color: #767676 transparent transparent; @@ -93,3 +91,25 @@ .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 index 6b7ecb42..4cd84b14 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -1,21 +1,69 @@ import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import styles from './Loader.css'; -export default function Loader({ active, style, className = '', children }) { - // Class names - let classNames = styles.loader; - if (active) { - classNames += ` ${styles.active}`; - } - if (className.length > 0) { - classNames += ` ${className}`; +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); } - // Render child text - let child; - if (children) { - child =
{children}
; + componengWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } } - return
{child}
; + setAnimation() { + if (this.interval) return; + console.log("Passed"); + 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/containers/CollectionPage.js b/src/containers/CollectionPage.js index 8c10703e..a04a1a21 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -3,6 +3,7 @@ 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'; class DashboardPage extends React.Component { @@ -28,7 +29,11 @@ class DashboardPage extends React.Component { } return
- {entries ? : 'Loading entries...'} + {entries ? + + : + {['Loading Entries', 'Caching Entries', 'This might take several minutes']} + }
; } } From 04db90710bee56d821d7cbfaac845355da097d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 17:24:24 -0300 Subject: [PATCH 06/13] Loading Animation --- package.json | 1 + src/backends/backend.js | 1 - src/components/UI/loader/Loader.css | 24 +++++++++- src/components/UI/loader/Loader.js | 73 ++++++++++++++++++++++++----- src/containers/CollectionPage.js | 7 ++- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index c1db9538..0ac77f63 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "markup-it": "git+https://github.com/cassiozen/markup-it.git", "pluralize": "^3.0.0", "prismjs": "^1.5.1", + "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/backends/backend.js b/src/backends/backend.js index 0104866d..23584799 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -49,7 +49,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)) diff --git a/src/components/UI/loader/Loader.css b/src/components/UI/loader/Loader.css index c46da16c..69d3b9ad 100644 --- a/src/components/UI/loader/Loader.css +++ b/src/components/UI/loader/Loader.css @@ -33,9 +33,7 @@ left: 50%; width: 100%; height: 100%; - -webkit-animation: loader 0.6s linear; animation: loader 0.6s linear; - -webkit-animation-iteration-count: infinite; animation-iteration-count: infinite; border-radius: 500rem; border-color: #767676 transparent transparent; @@ -93,3 +91,25 @@ .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 index 6b7ecb42..b5e4e442 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -1,21 +1,68 @@ import React from 'react'; +import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import styles from './Loader.css'; -export default function Loader({ active, style, className = '', children }) { - // Class names - let classNames = styles.loader; - if (active) { - classNames += ` ${styles.active}`; - } - if (className.length > 0) { - classNames += ` ${className}`; +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); } - // Render child text - let child; - if (children) { - child =
{children}
; + componengWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } } - return
{child}
; + 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/containers/CollectionPage.js b/src/containers/CollectionPage.js index 8c10703e..a04a1a21 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -3,6 +3,7 @@ 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'; class DashboardPage extends React.Component { @@ -28,7 +29,11 @@ class DashboardPage extends React.Component { } return
- {entries ? : 'Loading entries...'} + {entries ? + + : + {['Loading Entries', 'Caching Entries', 'This might take several minutes']} + }
; } } From b0e62d1ca99b21910d1faca431679c75bb53e31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Mon, 5 Sep 2016 18:56:03 -0300 Subject: [PATCH 07/13] Refactor: Publish mode to publish 'workflow' --- src/actions/config.js | 6 +++--- src/backends/backend.js | 8 ++++---- src/backends/constants.js | 4 ++-- src/backends/github/API.js | 4 ++-- src/backends/netlify-git/API.js | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/actions/config.js b/src/actions/config.js index 57a51510..ff6581e9 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -70,9 +70,9 @@ function parseConfig(data) { } } - if (!('publish_mode' in config.backend)) { - // Make sure there is a publish mode - config.backend['publish_mode'] = 'simple'; + if (!('publish_workflow' in config)) { + // Make sure there is a publish workflow mode set + config['publish_workflow'] = 'simple'; } if (!('public_folder' in config)) { diff --git a/src/backends/backend.js b/src/backends/backend.js index 23584799..b734108e 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -3,7 +3,7 @@ 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'; +import { SIMPLE, EDITORIAL } from './constants'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -94,9 +94,9 @@ class Backend { } getPublishMode(config) { - const publish_modes = [SIMPLE, BRANCH]; - const mode = config.getIn(['backend', 'publish_mode']); - if (publish_modes.indexOf(mode) !== -1) { + const publish_workflows = [SIMPLE, EDITORIAL]; + const mode = config.get('publish_workflow'); + if (publish_workflows.indexOf(mode) !== -1) { return mode; } else { return SIMPLE; diff --git a/src/backends/constants.js b/src/backends/constants.js index 6f6c6852..e4203a35 100644 --- a/src/backends/constants.js +++ b/src/backends/constants.js @@ -1,3 +1,3 @@ -// Create/edit modes +// Create/edit workflows export const SIMPLE = 'simple'; -export const BRANCH = 'branch'; +export const EDITORIAL = 'editorial'; diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 453f3e89..2cd4ac3d 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/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 } from '../constants'; const API_ROOT = 'https://api.github.com'; @@ -169,7 +169,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) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; const branchName = `cms/${contentKey}`; return this.createBranch(branchName, response.sha) diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index ef16c56d..e5e8d28c 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 } from '../constants'; export default class API { constructor(token, url, branch) { @@ -161,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) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; return this.createBranch(`cms/${contentKey}`, response.sha) .then(this.storeMetadata(contentKey, { status: 'draft' })) From f0e608a209114d96f1caf902103d5b43e11ae05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 6 Sep 2016 13:04:17 -0300 Subject: [PATCH 08/13] Editorial Workflow skeleton --- src/actions/config.js | 6 ++- src/actions/editorialWorkflow.js | 53 +++++++++++++++++++ src/actions/entries.js | 1 - src/backends/backend.js | 26 +++++---- src/backends/github/API.js | 5 +- src/backends/github/implementation.js | 9 +++- src/backends/netlify-git/API.js | 4 +- src/backends/test-repo/implementation.js | 1 + .../publishModes.js} | 2 +- src/containers/CollectionPage.js | 3 +- src/formats/formats.js | 2 +- src/reducers/editorialWorkflow.js | 37 +++++++++++++ src/reducers/entries.js | 4 ++ src/reducers/index.js | 12 ++++- 14 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 src/actions/editorialWorkflow.js rename src/{backends/constants.js => constants/publishModes.js} (50%) create mode 100644 src/reducers/editorialWorkflow.js diff --git a/src/actions/config.js b/src/actions/config.js index ff6581e9..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,9 +72,9 @@ function parseConfig(data) { } } - if (!('publish_workflow' in config)) { + if (!('publish_mode' in config) || _.values(publishModes).indexOf(config.publish_mode) === -1) { // Make sure there is a publish workflow mode set - config['publish_workflow'] = 'simple'; + config.publish_mode = publishModes.SIMPLE; } if (!('public_folder' in config)) { diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js new file mode 100644 index 00000000..6f478892 --- /dev/null +++ b/src/actions/editorialWorkflow.js @@ -0,0 +1,53 @@ +import { currentBackend } from '../backends/backend'; +import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; +/* + * Contant Declarations + */ +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 Thunk Action Creators + */ +export function loadUnpublishedEntries() { + return (dispatch, getState) => { + const state = getState(); + if (state.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 b734108e..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, EDITORIAL } from './constants'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -65,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); } @@ -75,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) { @@ -93,16 +101,6 @@ class Backend { }); } - getPublishMode(config) { - const publish_workflows = [SIMPLE, EDITORIAL]; - const mode = config.get('publish_workflow'); - if (publish_workflows.indexOf(mode) !== -1) { - return mode; - } else { - return SIMPLE; - } - } - persistEntry(config, collection, entryDraft, MediaFiles) { const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; @@ -132,7 +130,7 @@ 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'); diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 2cd4ac3d..3fe28ea5 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,7 +1,7 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; import { Base64 } from 'js-base64'; -import { EDITORIAL } from '../constants'; +import { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; const API_ROOT = 'https://api.github.com'; @@ -169,7 +169,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 === EDITORIAL) { + if (options.mode && options.mode === EDITORIAL_WORKFLOW) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; const branchName = `cms/${contentKey}`; return this.createBranch(branchName, response.sha) @@ -177,6 +177,7 @@ export default class API { type: 'PR', status: 'draft', branch: branchName, + collection: options.collectionName, title: options.parsedData.title, description: options.parsedData.description, objects: files.map(file => file.path) diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 9cb11f45..d2dd26d1 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,11 @@ export default class GitHub { persistEntry(entry, mediaFiles = [], options = {}) { return this.api.persistFiles(entry, mediaFiles, options); } + + unpublishedEntries() { + return Promise.resolve({ + pagination: {}, + entries: [] + }); + } } diff --git a/src/backends/netlify-git/API.js b/src/backends/netlify-git/API.js index e5e8d28c..25e288c8 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 { EDITORIAL } from '../constants'; +import { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; export default class API { constructor(token, url, branch) { @@ -161,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 === EDITORIAL) { + 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/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/backends/constants.js b/src/constants/publishModes.js similarity index 50% rename from src/backends/constants.js rename to src/constants/publishModes.js index e4203a35..308f5696 100644 --- a/src/backends/constants.js +++ b/src/constants/publishModes.js @@ -1,3 +1,3 @@ // Create/edit workflows export const SIMPLE = 'simple'; -export const EDITORIAL = 'editorial'; +export const EDITORIAL_WORKFLOW = 'editorial_workflow'; diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index a04a1a21..9fe0a622 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -2,6 +2,7 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; +import { loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { selectEntries } from '../reducers'; import { Loader } from '../components/UI'; import EntryListing from '../components/EntryListing'; @@ -9,7 +10,7 @@ import EntryListing from '../components/EntryListing'; class DashboardPage extends React.Component { componentDidMount() { const { collection, dispatch } = this.props; - + dispatch(loadUnpublishedEntries); if (collection) { dispatch(loadEntries(collection)); } diff --git a/src/formats/formats.js b/src/formats/formats.js index 9d6f72ad..854a3505 100644 --- a/src/formats/formats.js +++ b/src/formats/formats.js @@ -1,5 +1,5 @@ import YAMLFrontmatter from './yaml-frontmatter'; -export function resolveFormat(collection, entry) { +export function resolveFormat(collectionOrEntity, entry) { return new YAMLFrontmatter(); } diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js new file mode 100644 index 00000000..23f071f1 --- /dev/null +++ b/src/reducers/editorialWorkflow.js @@ -0,0 +1,37 @@ +import { Map, List, fromJS } from 'immutable'; +import { + UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS +} from '../actions/editorialWorkflow'; + +const unpublishedEntries = (state = Map({ entities: Map(), pages: Map() }), action) => { + switch (action.type) { + 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}`], null) +); + +export const selectUnpublishedEntries = (state, status) => { + const slugs = state.getIn(['pages', 'ids']); + return slugs && slugs.map((slug) => selectUnpublishedEntry(state, status, slug)); +}; + + +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); From 76693c71bd945a7c67c51f54b7b847e70febebee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 6 Sep 2016 15:18:48 -0300 Subject: [PATCH 09/13] typo --- src/components/UI/loader/Loader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/UI/loader/Loader.js b/src/components/UI/loader/Loader.js index b5e4e442..c2b8ec5d 100644 --- a/src/components/UI/loader/Loader.js +++ b/src/components/UI/loader/Loader.js @@ -12,7 +12,7 @@ export default class Loader extends React.Component { this.renderChild = this.renderChild.bind(this); } - componengWillUnmount() { + componentWillUnmount() { if (this.interval) { clearInterval(this.interval); } From 90d4b39fc15a344916b35b575c140375be700cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 6 Sep 2016 17:18:27 -0300 Subject: [PATCH 10/13] Load unpublished entries --- src/actions/editorialWorkflow.js | 3 +-- src/backends/github/API.js | 32 ++++++++++++++++++++++----- src/backends/github/implementation.js | 27 +++++++++++++++++++--- src/containers/CollectionPage.js | 2 +- src/reducers/editorialWorkflow.js | 2 +- src/valueObjects/Entry.js | 1 + 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 6f478892..0c6d6817 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -41,8 +41,7 @@ function unpublishedEntriesFailed(error) { export function loadUnpublishedEntries() { return (dispatch, getState) => { const state = getState(); - if (state.publish_mode !== EDITORIAL_WORKFLOW) return; - + if (state.config.get('publish_mode') !== EDITORIAL_WORKFLOW) return; const backend = currentBackend(state.config); dispatch(unpublishedEntriesLoading()); backend.unpublishedEntries().then( diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 3fe28ea5..c4cbc03c 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -100,7 +100,7 @@ export default class API { }); } - retrieveMetadata(key, data) { + retrieveMetadata(key) { const cache = LocalForage.getItem(`gh.meta.${key}`); return cache.then((cached) => { if (cached && cached.expires > Date.now()) { return cached.data; } @@ -109,7 +109,9 @@ export default class API { params: { ref: 'refs/meta/_netlify_cms' }, headers: { Accept: 'application/vnd.github.VERSION.raw' }, cache: 'no-store', - }).then((result) => { + }) + .then(response => JSON.parse(response)) + .then((result) => { LocalForage.setItem(`gh.meta.${key}`, { expires: Date.now() + 300000, // In 5 minutes data: result, @@ -119,20 +121,19 @@ export default class API { }).catch(error => null); } - 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; }); }); @@ -144,6 +145,22 @@ export default class API { }); } + readUnpublishedBranchFile(contentKey) { + let metaData; + return this.retrieveMetadata(contentKey) + .then(data => { + metaData = data; + return this.readFile(data.objects.entry, null, data.branch); + }) + .then(file => { + return { metaData, file }; + }); + } + + listUnpublishedBranches() { + return this.request(`${this.repoURL}/git/refs/heads/cms`); + } + persistFiles(entry, mediaFiles, options) { let filename, part, parts, subtree; const fileTree = {}; @@ -180,7 +197,10 @@ export default class API { collection: options.collectionName, title: options.parsedData.title, description: options.parsedData.description, - objects: files.map(file => file.path) + objects: { + entry: entry.path, + files: mediaFiles.map(file => file.path) + } })) .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); } else { diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index d2dd26d1..a4540ce1 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -64,9 +64,30 @@ export default class GitHub { } unpublishedEntries() { - return Promise.resolve({ - pagination: {}, - entries: [] + 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/containers/CollectionPage.js b/src/containers/CollectionPage.js index 9fe0a622..cad69722 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -10,7 +10,7 @@ import EntryListing from '../components/EntryListing'; class DashboardPage extends React.Component { componentDidMount() { const { collection, dispatch } = this.props; - dispatch(loadUnpublishedEntries); + dispatch(loadUnpublishedEntries()); if (collection) { dispatch(loadEntries(collection)); } diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index 23f071f1..aef48f0d 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -12,7 +12,7 @@ const unpublishedEntries = (state = Map({ entities: Map(), pages: Map() }), acti 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.setIn(['entities', `${entry.metaData.status}.${entry.slug}`], fromJS(entry).set('isFetching', false)) )); map.set('pages', Map({ ...pages, 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; } From 04c50d8defd1bec8402e8c03ac677f639f74a8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Thu, 8 Sep 2016 16:18:38 -0300 Subject: [PATCH 11/13] editorial workflow HoC --- src/actions/editorialWorkflow.js | 11 ++++++ src/backends/github/API.js | 6 ++- src/constants/publishModes.js | 17 ++++++++- src/containers/CollectionPage.js | 11 ++++-- src/containers/EditorialWorkflowHoC.js | 52 ++++++++++++++++++++++++++ src/reducers/editorialWorkflow.js | 9 +++-- 6 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 src/containers/EditorialWorkflowHoC.js diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 0c6d6817..5c906f1c 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -3,6 +3,7 @@ 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'; @@ -35,6 +36,16 @@ function unpublishedEntriesFailed(error) { }; } +/* + * Exported simple Action Creators + */ +export function init() { + return { + type: INIT + }; +} + + /* * Exported Thunk Action Creators */ diff --git a/src/backends/github/API.js b/src/backends/github/API.js index c4cbc03c..9fe61b71 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 { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; +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; @@ -192,7 +194,7 @@ export default class API { return this.createBranch(branchName, response.sha) .then(this.storeMetadata(contentKey, { type: 'PR', - status: 'draft', + status: status.DRAFT, branch: branchName, collection: options.collectionName, title: options.parsedData.title, diff --git a/src/constants/publishModes.js b/src/constants/publishModes.js index 308f5696..9e7256d9 100644 --- a/src/constants/publishModes.js +++ b/src/constants/publishModes.js @@ -1,3 +1,18 @@ -// Create/edit workflows +import { Map } from 'immutable'; + +// Create/edit workflow modes export const SIMPLE = 'simple'; export const EDITORIAL_WORKFLOW = 'editorial_workflow'; + +// Available status +export const status = { + DRAFT: 'draft', + PENDING_REVIEW: 'pending_review', + PENDING_PUBLISH: 'pending_publish', +}; + +export const statusDescriptions = Map({ + [status.DRAFT]: 'Draft', + [status.PENDING_REVIEW]: 'Waiting for Review', + [status.PENDING_PUBLISH]: 'Waiting to go live', +}); diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index cad69722..5790fbf0 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -2,15 +2,14 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { loadEntries } from '../actions/entries'; -import { loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { selectEntries } from '../reducers'; import { Loader } from '../components/UI'; import EntryListing from '../components/EntryListing'; +import EditorialWorkflow from './EditorialWorkflowHoC'; class DashboardPage extends React.Component { componentDidMount() { const { collection, dispatch } = this.props; - dispatch(loadUnpublishedEntries()); if (collection) { dispatch(loadEntries(collection)); } @@ -38,7 +37,6 @@ class DashboardPage extends React.Component { ; } } - DashboardPage.propTypes = { collection: ImmutablePropTypes.map.isRequired, collections: ImmutablePropTypes.orderedMap.isRequired, @@ -46,6 +44,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..3c626d85 --- /dev/null +++ b/src/containers/EditorialWorkflowHoC.js @@ -0,0 +1,52 @@ +import React, { PropTypes } from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { init, loadUnpublishedEntries } from '../actions/editorialWorkflow'; +import { selectUnpublishedEntries } from '../reducers'; +import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; +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 } = this.props; + if (!isEditorialWorkflow) return super.render(); + + return ( +
+

HOC

+ {super.render()} +
+ ); + } + } + + EditorialWorkflow.propTypes = { + dispatch: PropTypes.func.isRequired, + isEditorialWorkflow: PropTypes.bool.isRequired, + unpublishedEntries: ImmutablePropTypes.list, + }; + + function mapStateToProps(state) { + const publish_mode = state.config.get('publish_mode'); + const isEditorialWorkflow = (publish_mode === EDITORIAL_WORKFLOW); + const returnObj = { isEditorialWorkflow }; + + if (isEditorialWorkflow) { + returnObj.unpublishedEntries = selectUnpublishedEntries(state, 'draft'); + } + + return returnObj; + } + + return connect(mapStateToProps)(EditorialWorkflow); +} diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index aef48f0d..10307bfd 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -1,10 +1,13 @@ import { Map, List, fromJS } from 'immutable'; import { - UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS + INIT, UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS } from '../actions/editorialWorkflow'; -const unpublishedEntries = (state = Map({ entities: Map(), pages: Map() }), action) => { +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); @@ -29,9 +32,9 @@ export const selectUnpublishedEntry = (state, status, slug) => ( ); export const selectUnpublishedEntries = (state, status) => { + if (!state) return; const slugs = state.getIn(['pages', 'ids']); return slugs && slugs.map((slug) => selectUnpublishedEntry(state, status, slug)); }; - export default unpublishedEntries; From b6874152d92a8b4b6865076acd3d2fa78efd9050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Thu, 8 Sep 2016 19:04:54 -0300 Subject: [PATCH 12/13] unpublished items dashboard --- src/components/UnpublishedListing.js | 45 ++++++++++++++++++++++++++ src/containers/EditorialWorkflowHoC.js | 15 ++++++--- src/reducers/editorialWorkflow.js | 13 ++++++-- 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/components/UnpublishedListing.js diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js new file mode 100644 index 00000000..0ef0cdfa --- /dev/null +++ b/src/components/UnpublishedListing.js @@ -0,0 +1,45 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Card } from './UI'; +import { statusDescriptions } from '../constants/publishModes'; + +export default class UnpublishedListing extends React.Component { + renderColumn(entries) { + if (!entries) return; + return ( +
+ {entries.map(entry => { + return

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

; + } + )} +
+ ); + } + + render() { + const { entries } = this.props; + const columns = entries.entrySeq().map(([key, currEntries]) => ( +
+

{statusDescriptions.get(key)}

+ {this.renderColumn(currEntries)} +
+ )); + + return ( +
+ {columns} +
+ ); + } +} + +UnpublishedListing.propTypes = { + entries: ImmutablePropTypes.map, +}; + + + +
+

Drafts

+ Cool Recipe +
diff --git a/src/containers/EditorialWorkflowHoC.js b/src/containers/EditorialWorkflowHoC.js index 3c626d85..a7ffcc74 100644 --- a/src/containers/EditorialWorkflowHoC.js +++ b/src/containers/EditorialWorkflowHoC.js @@ -1,9 +1,12 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Map } from 'immutable'; import { init, loadUnpublishedEntries } from '../actions/editorialWorkflow'; import { selectUnpublishedEntries } from '../reducers'; -import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; +import { EDITORIAL_WORKFLOW, status } from '../constants/publishModes'; +import UnpublishedListing from '../components/UnpublishedListing'; import { connect } from 'react-redux'; +import _ from 'lodash'; export default function EditorialWorkflow(WrappedComponent) { class EditorialWorkflow extends WrappedComponent { @@ -18,12 +21,12 @@ export default function EditorialWorkflow(WrappedComponent) { } render() { - const { isEditorialWorkflow } = this.props; + const { isEditorialWorkflow, unpublishedEntries } = this.props; if (!isEditorialWorkflow) return super.render(); return (
-

HOC

+ {super.render()}
); @@ -33,7 +36,7 @@ export default function EditorialWorkflow(WrappedComponent) { EditorialWorkflow.propTypes = { dispatch: PropTypes.func.isRequired, isEditorialWorkflow: PropTypes.bool.isRequired, - unpublishedEntries: ImmutablePropTypes.list, + unpublishedEntries: ImmutablePropTypes.map, }; function mapStateToProps(state) { @@ -42,7 +45,9 @@ export default function EditorialWorkflow(WrappedComponent) { const returnObj = { isEditorialWorkflow }; if (isEditorialWorkflow) { - returnObj.unpublishedEntries = selectUnpublishedEntries(state, 'draft'); + returnObj.unpublishedEntries = _.reduce(status, (acc, currStatus) => { + return acc.set(currStatus, selectUnpublishedEntries(state, currStatus)); + }, Map()); } return returnObj; diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index 10307bfd..95cf9e28 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -28,13 +28,22 @@ const unpublishedEntries = (state = null, action) => { }; export const selectUnpublishedEntry = (state, status, slug) => ( - state.getIn(['entities', `${status}.${slug}`], null) + state.getIn(['entities', `${status}.${slug}`]) ); export const selectUnpublishedEntries = (state, status) => { if (!state) return; const slugs = state.getIn(['pages', 'ids']); - return slugs && slugs.map((slug) => selectUnpublishedEntry(state, status, slug)); + + 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; From c84d538eb6364e313e4950d5f4d164c9f7eec1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Fri, 9 Sep 2016 17:15:58 -0300 Subject: [PATCH 13/13] editor workflow ui adjustments --- package.json | 1 + src/backends/github/API.js | 69 ++++++++++++++------------ src/components/UnpublishedListing.css | 29 +++++++++++ src/components/UnpublishedListing.js | 47 ++++++++++-------- src/constants/publishModes.js | 12 ++--- src/containers/EditorialWorkflowHoC.js | 13 +++-- 6 files changed, 107 insertions(+), 64 deletions(-) create mode 100644 src/components/UnpublishedListing.css diff --git a/package.json b/package.json index 0ac77f63..c4432c59 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", diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 9fe61b71..210a229f 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -103,24 +103,12 @@ export default class API { } retrieveMetadata(key) { - 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`, { - params: { ref: 'refs/meta/_netlify_cms' }, - headers: { Accept: 'application/vnd.github.VERSION.raw' }, - cache: 'no-store', - }) - .then(response => JSON.parse(response)) - .then((result) => { - LocalForage.setItem(`gh.meta.${key}`, { - expires: Date.now() + 300000, // In 5 minutes - data: result, - }); - return result; - }); - }).catch(error => null); + 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, branch = this.branch) { @@ -148,14 +136,26 @@ export default class API { } readUnpublishedBranchFile(contentKey) { - let metaData; - return this.retrieveMetadata(contentKey) - .then(data => { - metaData = data; - return this.readFile(data.objects.entry, null, data.branch); - }) - .then(file => { - return { metaData, file }; + 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; + }); }); } @@ -191,19 +191,24 @@ export default class API { if (options.mode && options.mode === EDITORIAL_WORKFLOW) { const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; const branchName = `cms/${contentKey}`; - return this.createBranch(branchName, response.sha) - .then(this.storeMetadata(contentKey, { + return this.user().then(user => { + return user.name ? user.name : user.login; + }) + .then(username => this.storeMetadata(contentKey, { type: 'PR', - status: status.DRAFT, + user: username, + status: status.first(), branch: branchName, collection: options.collectionName, - title: options.parsedData.title, - description: options.parsedData.description, + 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/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 index 0ef0cdfa..e43c8b07 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing.js @@ -1,29 +1,41 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import dateFormat from 'dateFormat'; import { Card } from './UI'; import { statusDescriptions } from '../constants/publishModes'; +import styles from './UnpublishedListing.css'; export default class UnpublishedListing extends React.Component { - renderColumn(entries) { + renderColumns(entries, column) { if (!entries) return; - return ( -
+ + if (!column) { + return entries.entrySeq().map(([currColumn, currEntries]) => ( +
+

{statusDescriptions.get(currColumn)}

+ {this.renderColumns(currEntries, currColumn)} +
+ )); + } else { + return
{entries.map(entry => { - return

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

; + // Look for an "author" field. Fallback to username on backend implementation; + const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user'])); + const timeStamp = dateFormat(Date.parse(entry.getIn(['metaData', 'timeStamp'])), 'longDate'); + return ( + +

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

+

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

+
+ ); } )} -
- ); +
; + } } render() { - const { entries } = this.props; - const columns = entries.entrySeq().map(([key, currEntries]) => ( -
-

{statusDescriptions.get(key)}

- {this.renderColumn(currEntries)} -
- )); + const columns = this.renderColumns(this.props.entries); return (
@@ -34,12 +46,5 @@ export default class UnpublishedListing extends React.Component { } UnpublishedListing.propTypes = { - entries: ImmutablePropTypes.map, + entries: ImmutablePropTypes.orderedMap, }; - - - -
-

Drafts

- Cool Recipe -
diff --git a/src/constants/publishModes.js b/src/constants/publishModes.js index 9e7256d9..2cf4e443 100644 --- a/src/constants/publishModes.js +++ b/src/constants/publishModes.js @@ -1,18 +1,18 @@ -import { Map } from 'immutable'; +import { Map, OrderedMap } from 'immutable'; // Create/edit workflow modes export const SIMPLE = 'simple'; export const EDITORIAL_WORKFLOW = 'editorial_workflow'; // Available status -export const status = { +export const status = OrderedMap({ DRAFT: 'draft', PENDING_REVIEW: 'pending_review', PENDING_PUBLISH: 'pending_publish', -}; +}); export const statusDescriptions = Map({ - [status.DRAFT]: 'Draft', - [status.PENDING_REVIEW]: 'Waiting for Review', - [status.PENDING_PUBLISH]: 'Waiting to go live', + [status.get('DRAFT')]: 'Draft', + [status.get('PENDING_REVIEW')]: 'Waiting for Review', + [status.get('PENDING_PUBLISH')]: 'Waiting to go live', }); diff --git a/src/containers/EditorialWorkflowHoC.js b/src/containers/EditorialWorkflowHoC.js index a7ffcc74..603f7819 100644 --- a/src/containers/EditorialWorkflowHoC.js +++ b/src/containers/EditorialWorkflowHoC.js @@ -1,12 +1,11 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Map } from 'immutable'; +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'; -import _ from 'lodash'; export default function EditorialWorkflow(WrappedComponent) { class EditorialWorkflow extends WrappedComponent { @@ -45,11 +44,15 @@ export default function EditorialWorkflow(WrappedComponent) { const returnObj = { isEditorialWorkflow }; if (isEditorialWorkflow) { - returnObj.unpublishedEntries = _.reduce(status, (acc, currStatus) => { + /* + * 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)); - }, Map()); + }, OrderedMap()); } - return returnObj; }