diff --git a/package.json b/package.json index dd503d0d..312193cf 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,18 @@ "test:watch": "npm test -- --watch", "build": "webpack --config webpack.config.js", "storybook": "start-storybook -p 9001", - "storybook-build": "build-storybook -o dist" + "storybook-build": "build-storybook -o dist", + "lint": "eslint .", + "lint:fix": "npm run lint -- --fix", + "lint:staged": "lint-staged" }, + "lint-staged": { + "*.@(js|jsx)": [ + "eslint --fix", + "git add" + ] + }, + "pre-commit": "lint:staged", "keywords": [ "netlify", "cms" @@ -21,7 +31,7 @@ "@kadira/storybook": "^1.36.0", "autoprefixer": "^6.3.3", "babel-core": "^6.5.1", - "babel-eslint": "^4.1.8", + "babel-eslint": "^6.1.2", "babel-loader": "^6.2.2", "babel-plugin-lodash": "^3.2.0", "babel-plugin-transform-class-properties": "^6.5.2", @@ -32,20 +42,21 @@ "babel-register": "^6.5.2", "babel-runtime": "^6.5.0", "css-loader": "^0.23.1", - "eslint": "^1.10.3", - "eslint-loader": "^1.2.1", + "eslint": "^3.5.0", "eslint-plugin-react": "^5.1.1", "exports-loader": "^0.6.3", "file-loader": "^0.8.5", "immutable": "^3.7.6", "imports-loader": "^0.6.5", "js-yaml": "^3.5.3", + "lint-staged": "^3.0.2", "mocha": "^2.4.5", "moment": "^2.11.2", "normalizr": "^2.0.0", "postcss-cssnext": "^2.7.0", "postcss-import": "^8.1.2", "postcss-loader": "^0.9.1", + "pre-commit": "^1.1.3", "react": "^15.1.0", "react-dom": "^15.1.0", "react-hot-loader": "^3.0.0-beta.2", @@ -75,8 +86,10 @@ "markup-it": "git+https://github.com/cassiozen/markup-it.git", "pluralize": "^3.0.0", "prismjs": "^1.5.1", - "react-datetime": "^2.6.0", "react-addons-css-transition-group": "^15.3.1", + "react-datetime": "^2.6.0", + "react-dnd": "^2.1.4", + "react-dnd-html5-backend": "^2.1.2", "react-portal": "^2.2.1", "selection-position": "^1.0.0", "semaphore": "^1.0.5", diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 32322840..9b1cb70b 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -1,4 +1,5 @@ import { currentBackend } from '../backends/backend'; +import { getMedia } from '../reducers'; import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; /* * Contant Declarations @@ -10,6 +11,14 @@ export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST'; export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS'; export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE'; +export const UNPUBLISHED_ENTRY_PERSIST_REQUEST = 'UNPUBLISHED_ENTRY_PERSIST_REQUEST'; +export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCCESS'; + +export const UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST'; +export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS'; + +export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST'; +export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS'; /* * Simple Action Creators (Internal) @@ -53,6 +62,56 @@ function unpublishedEntriesFailed(error) { }; } + +function unpublishedEntryPersisting(entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_REQUEST, + payload: { entry } + }; +} + +function unpublishedEntryPersisted(entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + payload: { entry } + }; +} + +function unpublishedEntryPersistedFail(error) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + payload: { error } + }; +} + +function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, + payload: { collection, slug, oldStatus, newStatus } + }; +} + +function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, + payload: { collection, slug, oldStatus, newStatus } + }; +} + +function unpublishedEntryPublishRequest(collection, slug, status) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST, + payload: { collection, slug, status } + }; +} + +function unpublishedEntryPublished(collection, slug, status) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, + payload: { collection, slug, status } + }; +} + /* * Exported Thunk Action Creators */ @@ -79,3 +138,42 @@ export function loadUnpublishedEntries() { ); }; } + +export function persistUnpublishedEntry(collection, entry) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + const MediaProxies = entry && entry.get('mediaFiles').map(path => getMedia(state, path)); + dispatch(unpublishedEntryPersisting(entry)); + backend.persistUnpublishedEntry(state.config, collection, entry, MediaProxies.toJS()).then( + () => { + dispatch(unpublishedEntryPersisted(entry)); + }, + (error) => dispatch(unpublishedEntryPersistedFail(error)) + ); + }; +} + +export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus)); + backend.updateUnpublishedEntryStatus(collection, slug, newStatus) + .then(() => { + dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus)); + }); + }; +} + +export function publishUnpublishedEntry(collection, slug, status) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + dispatch(unpublishedEntryPublishRequest(collection, slug, status)); + backend.publishUnpublishedEntry(collection, slug, status) + .then(() => { + dispatch(unpublishedEntryPublished(collection, slug, status)); + }); + }; +} diff --git a/src/backends/backend.js b/src/backends/backend.js index c2c4d100..1cb9bc42 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -105,7 +105,7 @@ class Backend { }); } - persistEntry(config, collection, entryDraft, MediaFiles) { + persistEntry(config, collection, entryDraft, MediaFiles, options) { const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; const parsedData = { @@ -139,10 +139,23 @@ class Backend { const collectionName = collection.get('name'); return this.implementation.persistEntry(entryObj, MediaFiles, { - newEntry, parsedData, commitMessage, collectionName, mode + newEntry, parsedData, commitMessage, collectionName, mode, ...options }); } + persistUnpublishedEntry(config, collection, entryDraft, MediaFiles) { + return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true }); + } + + updateUnpublishedEntryStatus(collection, slug, newStatus) { + return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus); + } + + publishUnpublishedEntry(collection, slug, status) { + return this.implementation.publishUnpublishedEntry(collection, slug, status); + } + + entryToRaw(collection, entry) { const format = resolveFormat(collection, entry); return format && format.toFile(entry); diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 210a229f..70770aed 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,13 +1,12 @@ import LocalForage from 'localforage'; import MediaProxy from '../../valueObjects/MediaProxy'; import { Base64 } from 'js-base64'; -import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes'; +import _ from 'lodash'; +import { SIMPLE, 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; @@ -54,7 +53,8 @@ export default class API { const headers = this.requestHeaders(options.headers || {}); const url = this.urlFor(path, options); return fetch(url, { ...options, headers: headers }).then((response) => { - if (response.headers.get('Content-Type').match(/json/)) { + const contentType = response.headers.get('Content-Type'); + if (contentType && contentType.match(/json/)) { return this.parseJsonResponse(response); } @@ -98,17 +98,28 @@ export default class API { return this.uploadBlob(fileTree[`${key}.json`]) .then(item => this.updateTree(branchData.sha, '/', fileTree)) .then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree)) - .then(response => this.patchRef('meta', '_netlify_cms', response.sha)); + .then(response => this.patchRef('meta', '_netlify_cms', response.sha)) + .then(() => { + LocalForage.setItem(`gh.meta.${key}`, { + expires: Date.now() + 300000, // In 5 minutes + data + }); + }); }); } 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)); + 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)); + }); } readFile(path, sha, branch = this.branch) { @@ -136,26 +147,14 @@ 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; - }); + let metaData; + return this.retrieveMetadata(contentKey) + .then(data => { + metaData = data; + return this.readFile(data.objects.entry, null, data.branch); + }) + .then(file => { + return { metaData, file }; }); } @@ -183,37 +182,111 @@ export default class API { subtree[filename] = file; file.file = true; }); - return Promise.all(uploadPromises) - .then(() => this.getBranch()) + return Promise.all(uploadPromises).then(() => { + if (!options.mode || (options.mode && options.mode === SIMPLE)) { + return this.getBranch() + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then(response => this.patchBranch(this.branch, response.sha)); + } else if (options.mode && options.mode === EDITORIAL_WORKFLOW) { + const mediaFilesList = mediaFiles.map(file => file.path); + return this.editorialWorkflowGit(fileTree, entry, mediaFilesList, options); + } + }); + } + + editorialWorkflowGit(fileTree, entry, filesList, options) { + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + const branchName = `cms/${contentKey}`; + const unpublished = options.unpublished || false; + + if (!unpublished) { + // Open new editorial review workflow for this entry - Create new metadata and commit to new branch + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + const branchName = `cms/${contentKey}`; + + return this.getBranch() + .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) + .then(changeTree => this.commit(options.commitMessage, changeTree)) + .then(commitResponse => this.createBranch(branchName, commitResponse.sha)) + .then(branchResponse => this.createPR(options.commitMessage, branchName)) + .then((prResponse) => { + return this.user().then(user => { + return user.name ? user.name : user.login; + }) + .then(username => this.storeMetadata(contentKey, { + type: 'PR', + pr: { + number: prResponse.number, + head: prResponse.head && prResponse.head.sha + }, + 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: filesList + }, + timeStamp: new Date().toISOString() + })); + }); + } else { + // Entry is already on editorial review workflow - just update metadata and commit to existing branch + return this.getBranch(branchName) .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then((response) => { - if (options.mode && options.mode === EDITORIAL_WORKFLOW) { - const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; - 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, + const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; + const branchName = `cms/${contentKey}`; + return this.user().then(user => { + return user.name ? user.name : user.login; + }) + .then(username => this.retrieveMetadata(contentKey)) + .then(metadata => { + let files = metadata.objects && metadata.objects.files || []; + files = files.concat(filesList); + + return { + ...metadata, title: options.parsedData && options.parsedData.title, description: options.parsedData && options.parsedData.description, objects: { entry: entry.path, - files: mediaFiles.map(file => file.path) + files: _.uniq(files) }, 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); - } + }; + }) + .then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata)) + .then(this.patchBranch(branchName, response.sha)); }); + } + } + + updateUnpublishedEntryStatus(collection, slug, status) { + const contentKey = collection ? `${collection}-${slug}` : slug; + return this.retrieveMetadata(contentKey) + .then(metadata => { + return { + ...metadata, + status + }; + }) + .then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata)); + } + + publishUnpublishedEntry(collection, slug, status) { + const contentKey = collection ? `${collection}-${slug}` : slug; + return this.retrieveMetadata(contentKey) + .then(metadata => { + const headSha = metadata.pr && metadata.pr.head; + const number = metadata.pr && metadata.pr.number; + return this.mergePR(headSha, number); + }) + .then(() => this.deleteBranch(`cms/${contentKey}`)); } createRef(type, name, sha) { @@ -223,10 +296,6 @@ export default class API { }); } - createBranch(branchName, sha) { - return this.createRef('heads', branchName, sha); - } - patchRef(type, name, sha) { return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { method: 'PATCH', @@ -234,12 +303,26 @@ export default class API { }); } + deleteRef(type, name, sha) { + return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { + method: 'DELETE', + }); + } + + getBranch(branch = this.branch) { + return this.request(`${this.repoURL}/branches/${branch}`); + } + + createBranch(branchName, sha) { + return this.createRef('heads', branchName, sha); + } + patchBranch(branchName, sha) { return this.patchRef('heads', branchName, sha); } - getBranch() { - return this.request(`${this.repoURL}/branches/${this.branch}`); + deleteBranch(branchName) { + return this.deleteRef('heads', branchName); } createPR(title, head, base = 'master') { @@ -250,6 +333,16 @@ export default class API { }); } + mergePR(headSha, number) { + return this.request(`${this.repoURL}/pulls/${number}/merge`, { + method: 'PUT', + body: JSON.stringify({ + commit_message: 'Automatically generated. Merged on Netlify CMS.', + sha: headSha + }), + }); + } + getTree(sha) { return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); } diff --git a/src/backends/github/AuthenticationPage.js b/src/backends/github/AuthenticationPage.js index f7100604..c85dde2f 100644 --- a/src/backends/github/AuthenticationPage.js +++ b/src/backends/github/AuthenticationPage.js @@ -21,9 +21,9 @@ export default class AuthenticationPage extends React.Component { auth = new Authenticator(); } - auth.authenticate({provider: 'github', scope: 'repo'}, (err, data) => { + auth.authenticate({ provider: 'github', scope: 'repo' }, (err, data) => { if (err) { - this.setState({loginError: err.toString()}); + this.setState({ loginError: err.toString() }); return; } this.props.onLogin(data); diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 6ce1c865..2d270261 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -98,4 +98,12 @@ export default class GitHub { ))[0] )); } + + updateUnpublishedEntryStatus(collection, slug, newStatus) { + return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus); + } + + publishUnpublishedEntry(collection, slug, status) { + return this.api.publishUnpublishedEntry(collection, slug, status); + } } diff --git a/src/backends/netlify-git/AuthenticationPage.js b/src/backends/netlify-git/AuthenticationPage.js index 4503a660..74e508c5 100644 --- a/src/backends/netlify-git/AuthenticationPage.js +++ b/src/backends/netlify-git/AuthenticationPage.js @@ -1,5 +1,4 @@ import React from 'react'; -import Authenticator from '../../lib/netlify-auth'; export default class AuthenticationPage extends React.Component { static propTypes = { @@ -14,8 +13,8 @@ export default class AuthenticationPage extends React.Component { handleLogin(e) { e.preventDefault(); - const {email, password} = this.state; - this.setState({authenticating: true}); + const { email, password } = this.state; + this.setState({ authenticating: true }); fetch(`${AuthenticationPage.url}/token`, { method: 'POST', body: 'grant_type=client_credentials', @@ -27,18 +26,18 @@ export default class AuthenticationPage extends React.Component { console.log(response); if (response.ok) { return response.json().then((data) => { - this.props.onLogin(Object.assign({email}, data)); + this.props.onLogin(Object.assign({ email }, data)); }); } response.json().then((data) => { - this.setState({loginError: data.msg}); - }) - }) + this.setState({ loginError: data.msg }); + }); + }); } handleChange(key) { return (e) => { - this.setState({[key]: e.target.value}); + this.setState({ [key]: e.target.value }); }; } diff --git a/src/backends/test-repo/AuthenticationPage.js b/src/backends/test-repo/AuthenticationPage.js index fd5c3ddc..ce20b0bf 100644 --- a/src/backends/test-repo/AuthenticationPage.js +++ b/src/backends/test-repo/AuthenticationPage.js @@ -7,7 +7,7 @@ export default class AuthenticationPage extends React.Component { constructor(props) { super(props); - this.state = {email: ''}; + this.state = { email: '' }; this.handleLogin = this.handleLogin.bind(this); this.handleEmailChange = this.handleEmailChange.bind(this); } @@ -18,7 +18,7 @@ export default class AuthenticationPage extends React.Component { } handleEmailChange(e) { - this.setState({email: e.target.value}); + this.setState({ email: e.target.value }); } render() { diff --git a/src/components/EntryEditor.css b/src/components/EntryEditor.css index 03c128bd..9cacb875 100644 --- a/src/components/EntryEditor.css +++ b/src/components/EntryEditor.css @@ -12,6 +12,7 @@ height: 45px; border-top: 1px solid #e8eae8; padding: 10px 20px; + z-index: 10; } .controlPane { width: 50%; diff --git a/src/components/EntryEditor.js b/src/components/EntryEditor.js index ea83f7bb..fd308698 100644 --- a/src/components/EntryEditor.js +++ b/src/components/EntryEditor.js @@ -27,14 +27,14 @@ export default class EntryEditor extends React.Component { calculateHeight() { const height = window.innerHeight - 54; console.log('setting height to %s', height); - this.setState({height}); + this.setState({ height }); } render() { const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props; - const {height} = this.state; + const { height } = this.state; - return
+ return
{ const noop = function() {}; -export default function Icon({ style, className = '', type, onClick = noop}) { +export default function Icon({ style, className = '', type, onClick = noop }) { return ; } diff --git a/src/components/UI/index.js b/src/components/UI/index.js index 7f538b2a..87b473a5 100644 --- a/src/components/UI/index.js +++ b/src/components/UI/index.js @@ -1,3 +1,4 @@ export { default as Card } from './card/Card'; export { default as Loader } from './loader/Loader'; export { default as Icon } from './icon/Icon'; +export { default as Toast } from './toast/Toast'; diff --git a/src/components/UI/theme.css b/src/components/UI/theme.css index b8add9c7..87c78c64 100644 --- a/src/components/UI/theme.css +++ b/src/components/UI/theme.css @@ -1,5 +1,6 @@ :root { --defaultColor: #333; + --defaultColorLight: #eee; --backgroundColor: #fff; --shadowColor: rgba(0, 0, 0, 0.117647); --successColor: #1c7; diff --git a/src/components/UI/toast/Toast.css b/src/components/UI/toast/Toast.css new file mode 100644 index 00000000..2c5bb930 --- /dev/null +++ b/src/components/UI/toast/Toast.css @@ -0,0 +1,40 @@ +@import "../theme.css"; + +.toast { + composes: base container rounded depth; + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + width: 350px; + padding: 20px 10px; + font-size: 0.9rem; + text-align: center; + color: var(--defaultColorLight); + overflow: hidden; + opacity: 1; + transition: opacity .3s ease-in; +} + +.hidden { + opacity: 0; +} + +.icon { + position: absolute; + top: calc(50% - 15px); + left: 15px; + font-size: 30px; +} + +.success { + background-color: var(--successColor); +} + +.warning { + background-color: var(--warningColor); +} + +.error { + background-color: var(--errorColor); +} diff --git a/src/components/UI/toast/Toast.js b/src/components/UI/toast/Toast.js new file mode 100644 index 00000000..34671df4 --- /dev/null +++ b/src/components/UI/toast/Toast.js @@ -0,0 +1,74 @@ +import React, { PropTypes } from 'react'; +import { Icon } from '../index'; +import styles from './Toast.css'; + +export default class Toast extends React.Component { + constructor(props) { + super(props); + this.state = { + shown: false + }; + + this.autoHideTimeout = this.autoHideTimeout.bind(this); + } + + componentWillMount() { + if (this.props.show) { + this.autoHideTimeout(); + this.setState({ shown: true }); + } + } + + componentWillReceiveProps(nextProps) { + if (nextProps !== this.props) { + if (nextProps.show) this.autoHideTimeout(); + this.setState({ shown: nextProps.show }); + } + } + + componentWillUnmount() { + if (this.timeOut) { + clearTimeout(this.timeOut); + } + } + + autoHideTimeout() { + clearTimeout(this.timeOut); + this.timeOut = setTimeout(() => { + this.setState({ shown: false }); + }, 4000); + } + + render() { + const { style, type, className, children } = this.props; + const icons = { + success: 'check', + warning: 'attention', + error: 'alert' + }; + const classes = [styles.toast]; + if (className) classes.push(className); + + let icon = ''; + if (type) { + classes.push(styles[type]); + icon = ; + } + + if (!this.state.shown) { + classes.push(styles.hidden); + } + + return ( +
{icon}{children}
+ ); + } +} + +Toast.propTypes = { + style: PropTypes.object, + type: PropTypes.oneOf(['success', 'warning', 'error']).isRequired, + className: PropTypes.string, + show: PropTypes.bool, + children: PropTypes.node +}; diff --git a/src/components/UnpublishedListing.css b/src/components/UnpublishedListing.css index b37d8bf2..ebd7bcd6 100644 --- a/src/components/UnpublishedListing.css +++ b/src/components/UnpublishedListing.css @@ -1,23 +1,32 @@ +.container { + display: table; + width: 100%; +} + .column { - position: relative; - display: inline-block; - vertical-align: top; + display: table-cell; text-align: center; - width: 28%; + width: 33%; + height: 100%; + transition: background-color .5s ease; & h2 { font-size: 16px; } } +.highlighted { + background-color: #e1eeea; +} + .column:not(:last-child) { - margin-right: 8%; + padding-right: 20px; } .card { width: 100% !important; margin: 7px 0; - & h1 { + & h2 { font-size: 17px; & small { font-weight: normal; @@ -29,4 +38,16 @@ font-size: 12px; margin-top: 5px; } + + & button { + margin: 10px 10px 0 0; + float: right; + } +} + + +.clear::after { + content:""; + display:block; + clear:both; } diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js index 97a711f4..9d09b6a0 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing.js @@ -1,21 +1,104 @@ -import React from 'react'; +import React, { PropTypes } from 'react'; +import { DragDropContext, DragSource, DropTarget } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; import ImmutablePropTypes from 'react-immutable-proptypes'; import moment from 'moment'; import { Card } from './UI'; import { Link } from 'react-router'; -import { statusDescriptions } from '../constants/publishModes'; +import { status, statusDescriptions } from '../constants/publishModes'; import styles from './UnpublishedListing.css'; -export default class UnpublishedListing extends React.Component { +const CARD = 'card'; + +/* + * Column DropTarget Component + */ +function Column({ connectDropTarget, status, isOver, children }) { + const className = isOver ? `${styles.column} ${styles.highlighted}` : styles.column; + return connectDropTarget( +
+

{statusDescriptions.get(status)}

+ {children} +
+ ); +} +const columnTargetSpec = { + drop(props, monitor) { + const slug = monitor.getItem().slug; + const collection = monitor.getItem().collection; + const oldStatus = monitor.getItem().ownStatus; + props.onChangeStatus(collection, slug, oldStatus, props.status); + } +}; +function columnCollect(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} +Column = DropTarget(CARD, columnTargetSpec, columnCollect)(Column); + + +/* + * Card DropTarget Component + */ +function EntryCard({ slug, collection, ownStatus, onRequestPublish, connectDragSource, children }) { + return connectDragSource( +
+ + {children} + {(ownStatus === status.last()) && + + } + +
+ ); +} +const cardDragSpec = { + beginDrag(props) { + return { + slug: props.slug, + collection: props.collection, + ownStatus: props.ownStatus + }; + } +}; +function cardCollect(connect, monitor) { + return { + connectDragSource: connect.dragSource() + }; +} +EntryCard = DragSource(CARD, cardDragSpec, cardCollect)(EntryCard); + +/* + * The actual exported component implementation + */ +class UnpublishedListing extends React.Component { + constructor(props) { + super(props); + this.renderColumns = this.renderColumns.bind(this); + this.requestPublish = this.requestPublish.bind(this); + } + + requestPublish(collection, slug, ownStatus) { + if (ownStatus !== status.last()) return; + if (window.confirm('Are you sure you want to publish this entry?')) { + this.props.handlePublish(collection, slug, ownStatus); + } + } + renderColumns(entries, column) { if (!entries) return; if (!column) { return entries.entrySeq().map(([currColumn, currEntries]) => ( -
-

{statusDescriptions.get(currColumn)}

+ {this.renderColumns(currEntries, currColumn)} -
+ )); } else { return
@@ -24,11 +107,20 @@ export default class UnpublishedListing extends React.Component { const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user'])); const timeStamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('llll'); const link = `/editorialworkflow/${entry.getIn(['metaData', 'collection'])}/${entry.getIn(['metaData', 'status'])}/${entry.get('slug')}`; + const slug = entry.get('slug'); + const status = entry.getIn(['metaData', 'status']); + const collection = entry.getIn(['metaData', 'collection']); return ( - -

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

+ +

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

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

-
+ ); } )} @@ -38,11 +130,12 @@ export default class UnpublishedListing extends React.Component { render() { const columns = this.renderColumns(this.props.entries); - return ( -
+

Editorial Workflow

+
{columns} +
); } @@ -50,4 +143,8 @@ export default class UnpublishedListing extends React.Component { UnpublishedListing.propTypes = { entries: ImmutablePropTypes.orderedMap, + handleChangeStatus: PropTypes.func.isRequired, + handlePublish: PropTypes.func.isRequired, }; + +export default DragDropContext(HTML5Backend)(UnpublishedListing); diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index 54abac8b..483be419 100644 --- a/src/components/Widgets/MarkdownControl.js +++ b/src/components/Widgets/MarkdownControl.js @@ -60,7 +60,7 @@ class MarkdownControl extends React.Component { return (
- { this.renderEditor() } + {this.renderEditor()}
); } diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js index ea00ade2..412dae5e 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js @@ -62,4 +62,4 @@ export const SCHEMA = { borderRadius: '4px' } } -} +}; diff --git a/src/components/Widgets/richText.js b/src/components/Widgets/richText.js index b5d4b323..082631e0 100644 --- a/src/components/Widgets/richText.js +++ b/src/components/Widgets/richText.js @@ -46,7 +46,7 @@ function processEditorPlugins(plugins) {
- { plugin.fields.map(field => `${field.label}: “${node.data.get(field.name)}”`) } + {plugin.fields.map(field => `${field.label}: “${node.data.get(field.name)}”`)}
); diff --git a/src/components/stories/Card.js b/src/components/stories/Card.js index 95eeabe7..368a85cf 100644 --- a/src/components/stories/Card.js +++ b/src/components/stories/Card.js @@ -39,4 +39,4 @@ storiesOf('Card', module)

header and footer elements are also not subject to margin

© Thousand Cats Corp
- )) + )); diff --git a/src/components/stories/Toast.js b/src/components/stories/Toast.js new file mode 100644 index 00000000..6ac4b7c6 --- /dev/null +++ b/src/components/stories/Toast.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { Toast } from '../UI'; +import { storiesOf } from '@kadira/storybook'; + + +storiesOf('Toast', module) + .add('Success', () => ( +
+ A Toast Message +
+ )).add('Waring', () => ( +
+ A Toast Message +
+ )).add('Error', () => ( +
+ A Toast Message +
+ )); diff --git a/src/components/stories/index.js b/src/components/stories/index.js index a966ecf4..21f91079 100644 --- a/src/components/stories/index.js +++ b/src/components/stories/index.js @@ -1,2 +1,3 @@ import './Card'; import './Icon'; +import './Toast'; diff --git a/src/containers/App.js b/src/containers/App.js index 5b6af1c9..64a56d4d 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -4,6 +4,7 @@ import { IndexLink } from 'react-router'; import { loadConfig } from '../actions/config'; import { loginUser } from '../actions/auth'; import { currentBackend } from '../backends/backend'; +import { Loader } from '../components/UI'; import { SHOW_COLLECTION, CREATE_COLLECTION, HELP } from '../actions/findbar'; import FindBar from './FindBar'; import styles from './App.css'; @@ -27,7 +28,7 @@ class App extends React.Component { configLoading() { return
-

Loading configuration...

+ Loading configuration...
; } diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index 1693b587..36cfb71a 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -57,7 +57,7 @@ class EntryPage extends React.Component { const { entry, entryDraft, boundGetMedia, collection, changeDraft, addMedia, removeMedia } = this.props; - + if (entryDraft == null || entryDraft.get('entry') == undefined || entry && entry.get('isFetching')) { return
Loading...
; } diff --git a/src/containers/FindBar.js b/src/containers/FindBar.js index ce13254d..f228b824 100644 --- a/src/containers/FindBar.js +++ b/src/containers/FindBar.js @@ -13,7 +13,12 @@ class FindBar extends Component { constructor(props) { super(props); this._compiledCommands = []; - this._searchCommand = { search: true, regexp:`(?:${SEARCH})?(.*)`, param:{ name:'searchTerm', display:'' }, token: SEARCH }; + this._searchCommand = { + search: true, + regexp: `(?:${SEARCH})?(.*)`, + param: { name: 'searchTerm', display: '' }, + token: SEARCH + }; this.state = { value: '', placeholder: PLACEHOLDER, @@ -68,7 +73,7 @@ class FindBar extends Component { if (match && match[1]) { regexp += '(.*)'; - param = { name:match[1], display:match[2] || this._camelCaseToSpace(match[1]) }; + param = { name: match[1], display: match[2] || this._camelCaseToSpace(match[1]) }; } return Object.assign({}, command, { @@ -144,6 +149,7 @@ class FindBar extends Component { getSuggestions() { return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands); } + // Memoized version _getSuggestions(value, scope, commands, defaultCommands) { if (scope) return []; // No autocomplete for scoped input @@ -152,7 +158,7 @@ class FindBar extends Component { .filter(command => defaultCommands.indexOf(command.id) !== -1) .map(result => ( Object.assign({}, result, { string: result.token } - ))); + ))); } const results = fuzzy.filter(value, commands, { @@ -162,8 +168,8 @@ class FindBar extends Component { }); const returnResults = results.slice(0, 4).map(result => ( - Object.assign({}, result.original, { string:result.string } - ))); + Object.assign({}, result.original, { string: result.string } + ))); returnResults.push(this._searchCommand); return returnResults; @@ -178,7 +184,7 @@ class FindBar extends Component { index = ( highlightedIndex === this.getSuggestions().length - 1 || this.state.isOpen === false - ) ? 0 : highlightedIndex + 1; + ) ? 0 : highlightedIndex + 1; this.setState({ highlightedIndex: index, isOpen: true, @@ -290,7 +296,7 @@ class FindBar extends Component { let children; if (!command.search) { children = ( - + ); } else { children = ( @@ -299,7 +305,8 @@ class FindBar extends Component { Search... : Search for: } - {this.state.value} + {this.state.value} + ); } return ( @@ -317,7 +324,7 @@ class FindBar extends Component { return commands.length === 0 ? null : (
- { commands } + {commands}
Your past searches and commands @@ -328,7 +335,7 @@ class FindBar extends Component { renderActiveScope() { if (this.state.activeScope === SEARCH) { - return
; + return
; } else { return
{this.state.activeScope}
; } @@ -358,6 +365,7 @@ class FindBar extends Component { ); } } + FindBar.propTypes = { commands: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, diff --git a/src/containers/editorialWorkflow/CollectionPageHOC.js b/src/containers/editorialWorkflow/CollectionPageHOC.js index bee4a2ac..6aeacb71 100644 --- a/src/containers/editorialWorkflow/CollectionPageHOC.js +++ b/src/containers/editorialWorkflow/CollectionPageHOC.js @@ -1,7 +1,7 @@ import React, { PropTypes } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { OrderedMap } from 'immutable'; -import { loadUnpublishedEntries } from '../../actions/editorialWorkflow'; +import { loadUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } from '../../actions/editorialWorkflow'; import { selectUnpublishedEntries } from '../../reducers'; import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes'; import UnpublishedListing from '../../components/UnpublishedListing'; @@ -20,12 +20,16 @@ export default function CollectionPageHOC(CollectionPage) { } render() { - const { isEditorialWorkflow, unpublishedEntries } = this.props; + const { isEditorialWorkflow, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props; if (!isEditorialWorkflow) return super.render(); return (
- + {super.render()}
); @@ -56,5 +60,8 @@ export default function CollectionPageHOC(CollectionPage) { return returnObj; } - return connect(mapStateToProps)(CollectionPageHOC); + return connect(mapStateToProps, { + updateUnpublishedEntryStatus, + publishUnpublishedEntry + })(CollectionPageHOC); } diff --git a/src/containers/editorialWorkflow/EntryPageHOC.js b/src/containers/editorialWorkflow/EntryPageHOC.js index 1840c210..4f38e293 100644 --- a/src/containers/editorialWorkflow/EntryPageHOC.js +++ b/src/containers/editorialWorkflow/EntryPageHOC.js @@ -1,7 +1,7 @@ import React from 'react'; import { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; import { selectUnpublishedEntry } from '../../reducers'; -import { loadUnpublishedEntry } from '../../actions/editorialWorkflow'; +import { loadUnpublishedEntry, persistUnpublishedEntry } from '../../actions/editorialWorkflow'; import { connect } from 'react-redux'; export default function EntryPageHOC(EntryPage) { @@ -22,10 +22,6 @@ export default function EntryPageHOC(EntryPage) { const slug = ownProps.params.slug; const entry = selectUnpublishedEntry(state, status, slug); returnObj.entry = entry; - - returnObj.persistEntry = () => { - // TODO - for now, simply ignore - }; } return returnObj; } @@ -39,6 +35,10 @@ export default function EntryPageHOC(EntryPage) { returnObj.loadEntry = (collection, slug) => { dispatch(loadUnpublishedEntry(collection, status, slug)); }; + + returnObj.persistEntry = (collection, entryDraft) => { + dispatch(persistUnpublishedEntry(collection, entryDraft)); + }; } return returnObj; } diff --git a/src/formats/yaml.js b/src/formats/yaml.js index 9fd73c07..d939c068 100644 --- a/src/formats/yaml.js +++ b/src/formats/yaml.js @@ -41,6 +41,6 @@ export default class YAML { } toFile(data) { - return yaml.safeDump(data, {schema: OutputSchema}); + return yaml.safeDump(data, { schema: OutputSchema }); } } diff --git a/src/lib/registry.js b/src/lib/registry.js index 4d7f14e9..e4de5183 100644 --- a/src/lib/registry.js +++ b/src/lib/registry.js @@ -1,5 +1,5 @@ -import {List} from 'immutable'; -import {newEditorPlugin} from '../components/Widgets/MarkdownControlElements/plugins'; +import { List } from 'immutable'; +import { newEditorPlugin } from '../components/Widgets/MarkdownControlElements/plugins'; const _registry = { templates: {}, diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index beee2071..246656f0 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -1,7 +1,12 @@ import { Map, List, fromJS } from 'immutable'; import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; import { - UNPUBLISHED_ENTRY_REQUEST, UNPUBLISHED_ENTRY_SUCCESS, UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS + UNPUBLISHED_ENTRY_REQUEST, + UNPUBLISHED_ENTRY_SUCCESS, + UNPUBLISHED_ENTRIES_REQUEST, + UNPUBLISHED_ENTRIES_SUCCESS, + UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, + UNPUBLISHED_ENTRY_PUBLISH_SUCCESS } from '../actions/editorialWorkflow'; import { CONFIG_SUCCESS } from '../actions/config'; @@ -39,6 +44,23 @@ const unpublishedEntries = (state = null, action) => { ids: List(entries.map((entry) => entry.slug)) })); }); + + case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS: + return state.withMutations((map) => { + let entry = map.getIn(['entities', `${action.payload.oldStatus}.${action.payload.slug}`]); + entry = entry.setIn(['metaData', 'status'], action.payload.newStatus); + + let entities = map.get('entities').filter((val, key) => ( + key !== `${action.payload.oldStatus}.${action.payload.slug}` + )); + entities = entities.set(`${action.payload.newStatus}.${action.payload.slug}`, entry); + + map.set('entities', entities); + }); + + case UNPUBLISHED_ENTRY_PUBLISH_SUCCESS: + return state.deleteIn(['entities', `${action.payload.status}.${action.payload.slug}`]); + default: return state; } diff --git a/test/reducers/auth.spec.js b/test/reducers/auth.spec.js index 1d766f53..83830fd5 100644 --- a/test/reducers/auth.spec.js +++ b/test/reducers/auth.spec.js @@ -16,15 +16,15 @@ describe('auth', () => { expect( auth(undefined, authenticating()) ).toEqual( - Immutable.Map({isFetching: true}) + Immutable.Map({ isFetching: true }) ); }); it('should handle authentication', () => { expect( - auth(undefined, authenticate({email: 'joe@example.com'})) + auth(undefined, authenticate({ email: 'joe@example.com' })) ).toEqual( - Immutable.fromJS({user: {email: 'joe@example.com'}}) + Immutable.fromJS({ user: { email: 'joe@example.com' } }) ); }); diff --git a/test/reducers/collections.spec.js b/test/reducers/collections.spec.js index f3f31583..18ba43d0 100644 --- a/test/reducers/collections.spec.js +++ b/test/reducers/collections.spec.js @@ -15,39 +15,39 @@ describe('collections', () => { it('should load the collections from the config', () => { expect( - collections(undefined, configLoaded({collections: [ - {name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]} - ]})) + collections(undefined, configLoaded({ collections: [ + { name: 'posts', folder: '_posts', fields: [{ name: 'title', widget: 'string' }] } + ] })) ).toEqual( OrderedMap({ - posts: fromJS({name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]}) + posts: fromJS({ name: 'posts', folder: '_posts', fields: [{ name: 'title', widget: 'string' }] }) }) ); }); it('should mark entries as loading', () => { const state = OrderedMap({ - 'posts': Map({name: 'posts'}) + 'posts': Map({ name: 'posts' }) }); expect( - collections(state, entriesLoading(Map({name: 'posts'}))) + collections(state, entriesLoading(Map({ name: 'posts' }))) ).toEqual( OrderedMap({ - 'posts': Map({name: 'posts', isFetching: true}) + 'posts': Map({ name: 'posts', isFetching: true }) }) ); }); it('should handle loaded entries', () => { const state = OrderedMap({ - 'posts': Map({name: 'posts'}) + 'posts': Map({ name: 'posts' }) }); - const entries = [{slug: 'a', path: ''}, {slug: 'b', title: 'B'}]; + const entries = [{ slug: 'a', path: '' }, { slug: 'b', title: 'B' }]; expect( - collections(state, entriesLoaded(Map({name: 'posts'}), entries)) + collections(state, entriesLoaded(Map({ name: 'posts' }), entries)) ).toEqual( OrderedMap({ - 'posts': fromJS({name: 'posts', entries: entries}) + 'posts': fromJS({ name: 'posts', entries: entries }) }) ); }); diff --git a/test/reducers/config.spec.js b/test/reducers/config.spec.js index 75aa9aad..9167e542 100644 --- a/test/reducers/config.spec.js +++ b/test/reducers/config.spec.js @@ -14,9 +14,9 @@ describe('config', () => { it('should handle an update', () => { expect( - config(Immutable.Map({'a': 'b', 'c': 'd'}), configLoaded({'a': 'changed', 'e': 'new'})) + config(Immutable.Map({ 'a': 'b', 'c': 'd' }), configLoaded({ 'a': 'changed', 'e': 'new' })) ).toEqual( - Immutable.Map({'a': 'changed', 'e': 'new'}) + Immutable.Map({ 'a': 'changed', 'e': 'new' }) ); }); @@ -24,15 +24,15 @@ describe('config', () => { expect( config(undefined, configLoading()) ).toEqual( - Immutable.Map({isFetching: true}) + Immutable.Map({ isFetching: true }) ); }); it('should handle an error', () => { expect( - config(Immutable.Map({isFetching: true}), configFailed(new Error('Config could not be loaded'))) + config(Immutable.Map({ isFetching: true }), configFailed(new Error('Config could not be loaded'))) ).toEqual( - Immutable.Map({error: 'Error: Config could not be loaded'}) + Immutable.Map({ error: 'Error: Config could not be loaded' }) ); }); });