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
Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}
+