From ba7e99212d7b80cd6e34a7d880f408098bd54f0c Mon Sep 17 00:00:00 2001 From: Andrey Okonetchnikov Date: Tue, 13 Sep 2016 13:16:10 +0200 Subject: [PATCH 01/12] Removed second defualt export that caused an error in the bundle compilation. --- src/components/Widgets/MarkdownControl.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index a315cb80..13cc4759 100644 --- a/src/components/Widgets/MarkdownControl.js +++ b/src/components/Widgets/MarkdownControl.js @@ -64,8 +64,6 @@ class MarkdownControl extends React.Component { } } -export default MarkdownControl; - MarkdownControl.propTypes = { editor: PropTypes.object.isRequired, onChange: PropTypes.func.isRequired, From 26dab74439748c938fd28b0aa80c95fe178100ae Mon Sep 17 00:00:00 2001 From: Andrey Okonetchnikov Date: Tue, 13 Sep 2016 15:23:17 +0200 Subject: [PATCH 02/12] Updated eslint Updated ESLint dependency and removed unused eslint-loader. Also added 2 tasks to package.json: 'lint' and 'lint:fix'. --- package.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9f48f4d9..1d335ab9 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "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" }, "keywords": [ "netlify", @@ -21,7 +23,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,12 +34,11 @@ "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", - "extract-text-webpack-plugin": "^1.0.1", "express": "^4.13.4", + "extract-text-webpack-plugin": "^1.0.1", "file-loader": "^0.8.5", "immutable": "^3.7.6", "imports-loader": "^0.6.5", From 09841c05e2954eb271766d4801729e5ef9962d1d Mon Sep 17 00:00:00 2001 From: Andrey Okonetchnikov Date: Tue, 13 Sep 2016 15:27:29 +0200 Subject: [PATCH 03/12] Added lint-staged with the eslint autofix config. --- package.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1d335ab9..a30f1cf0 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,16 @@ "storybook": "start-storybook -p 9001", "storybook-build": "build-storybook -o dist", "lint": "eslint .", - "lint:fix": "npm run lint -- --fix" + "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" @@ -43,12 +51,14 @@ "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-immutable-proptypes": "^1.6.0", From eb14200643b638c0a140e65678096b9324809c34 Mon Sep 17 00:00:00 2001 From: Andrey Okonetchnikov Date: Tue, 13 Sep 2016 15:30:58 +0200 Subject: [PATCH 04/12] Fixed ESLint errors and warning that could be fixed with eslint --fix --- src/backends/github/AuthenticationPage.js | 4 ++-- .../netlify-git/AuthenticationPage.js | 14 ++++++------ src/backends/test-repo/AuthenticationPage.js | 4 ++-- src/components/EntryEditor.js | 6 ++--- src/components/PreviewPane.js | 2 +- src/components/UI/icon/Icon.js | 4 ++-- src/components/UnpublishedListing.js | 2 +- src/components/Widgets/MarkdownControl.js | 2 +- .../VisualEditor/schema.js | 2 +- src/components/Widgets/richText.js | 2 +- src/components/stories/Card.js | 2 +- src/containers/EntryPage.js | 2 +- src/containers/FindBar.js | 4 ++-- src/formats/yaml.js | 2 +- src/lib/registry.js | 4 ++-- test/reducers/auth.spec.js | 6 ++--- test/reducers/collections.spec.js | 22 +++++++++---------- test/reducers/config.spec.js | 10 ++++----- webpack.config.js | 2 +- 19 files changed, 48 insertions(+), 48 deletions(-) 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/netlify-git/AuthenticationPage.js b/src/backends/netlify-git/AuthenticationPage.js index 4503a660..3b4f1bf3 100644 --- a/src/backends/netlify-git/AuthenticationPage.js +++ b/src/backends/netlify-git/AuthenticationPage.js @@ -14,8 +14,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 +27,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.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/UnpublishedListing.js b/src/components/UnpublishedListing.js index b57eebd9..01b35dcb 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import moment from 'moment'; import { Card } from './UI'; -import { Link } from 'react-router' +import { Link } from 'react-router'; import { statusDescriptions } from '../constants/publishModes'; import styles from './UnpublishedListing.css'; 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/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..33f98441 100644 --- a/src/containers/FindBar.js +++ b/src/containers/FindBar.js @@ -290,7 +290,7 @@ class FindBar extends Component { let children; if (!command.search) { children = ( - + ); } else { children = ( @@ -317,7 +317,7 @@ class FindBar extends Component { return commands.length === 0 ? null : (
- { commands } + {commands}
Your past searches and commands 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/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' }) ); }); }); diff --git a/webpack.config.js b/webpack.config.js index b5dbcf83..32e97dd8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,7 +13,7 @@ module.exports = { { test: /\.json$/, loader: 'json-loader' }, { test: /\.css$/, - loader: ExtractTextPlugin.extract("style", "css?modules&importLoaders=1&&localIdentName=cms__[name]__[local]!postcss"), + loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&&localIdentName=cms__[name]__[local]!postcss'), }, { loader: 'babel', From a1a68408261e4261e79c0807882c606baafd3112 Mon Sep 17 00:00:00 2001 From: Andrey Okonetchnikov Date: Tue, 13 Sep 2016 15:35:38 +0200 Subject: [PATCH 05/12] Fixed formatting of the FindBar.js --- src/containers/FindBar.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/containers/FindBar.js b/src/containers/FindBar.js index 33f98441..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 ( @@ -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, From 056a14425f118a4e4071648a7e69c082ff6a0a50 Mon Sep 17 00:00:00 2001 From: Andrey Okonetchnikov Date: Tue, 13 Sep 2016 15:38:34 +0200 Subject: [PATCH 06/12] Removed unused import --- src/backends/netlify-git/AuthenticationPage.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backends/netlify-git/AuthenticationPage.js b/src/backends/netlify-git/AuthenticationPage.js index 3b4f1bf3..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 = { From 686dd51410bb5164bb23343645e5d71ac1f295e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 13 Sep 2016 14:31:18 -0300 Subject: [PATCH 07/12] Commiting unpublished branches on pre existing branch (updates PR) --- src/actions/editorialWorkflow.js | 40 ++++++++ src/backends/backend.js | 9 +- src/backends/github/API.js | 96 ++++++++++++++----- .../editorialWorkflow/EntryPageHOC.js | 10 +- 4 files changed, 124 insertions(+), 31 deletions(-) diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 32322840..73586cb3 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,8 @@ 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'; /* * Simple Action Creators (Internal) @@ -53,6 +56,28 @@ function unpublishedEntriesFailed(error) { }; } + +function unpublishedEntryPersisting(status, entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_REQUEST, + payload: { status, entry } + }; +} + +function unpublishedEntryPersisted(status, entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + payload: { status, entry } + }; +} + +function unpublishedEntryPersistedFail(status, entry) { + return { + type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, + payload: { status, entry } + }; +} + /* * Exported Thunk Action Creators */ @@ -79,3 +104,18 @@ export function loadUnpublishedEntries() { ); }; } + +export function persistUnpublishedEntry(collection, status, 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(status, entry)); + backend.persistUnpublishedEntry(state.config, collection, status, entry, MediaProxies.toJS()).then( + () => { + dispatch(unpublishedEntryPersisted(status, entry)); + }, + (error) => dispatch(unpublishedEntryPersistedFail(error)) + ); + }; +} diff --git a/src/backends/backend.js b/src/backends/backend.js index c2c4d100..b09d1467 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,15 @@ 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, status, entryDraft, MediaFiles) { + return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true, 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..bd3690a9 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -1,7 +1,8 @@ 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'; @@ -183,37 +184,84 @@ 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 + return this.getBranch() .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.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: filesList + }, + timeStamp: new Date().toISOString() + })) + .then(this.createBranch(branchName, response.sha)) + .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); + }); + } 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) => { + 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, + status: options.status, 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)); }); + } } createRef(type, name, sha) { @@ -238,8 +286,8 @@ export default class API { return this.patchRef('heads', branchName, sha); } - getBranch() { - return this.request(`${this.repoURL}/branches/${this.branch}`); + getBranch(branch = this.branch) { + return this.request(`${this.repoURL}/branches/${branch}`); } createPR(title, head, base = 'master') { diff --git a/src/containers/editorialWorkflow/EntryPageHOC.js b/src/containers/editorialWorkflow/EntryPageHOC.js index 1840c210..a05127c8 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, status, entryDraft)); + }; } return returnObj; } From f4a6929d17a1fc3f2b2d32545ffda86654b91f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 13 Sep 2016 15:10:56 -0300 Subject: [PATCH 08/12] minor css adjustments --- src/components/EntryEditor.css | 1 + src/components/PreviewPane.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/PreviewPane.css b/src/components/PreviewPane.css index 6bf62a0a..af8c2001 100644 --- a/src/components/PreviewPane.css +++ b/src/components/PreviewPane.css @@ -1,6 +1,6 @@ .frame { width: 100%; - height: 100%; + height: 100vh; border: none; background: #fff; } From 0b447d483dfc12ca6c83d902790c52b6b85a0ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Tue, 13 Sep 2016 16:00:24 -0300 Subject: [PATCH 09/12] Editorial workflow Drag'nDrop --- package.json | 4 +- src/actions/editorialWorkflow.js | 50 +++++++-- src/backends/backend.js | 8 +- src/backends/github/API.js | 68 +++++++----- src/backends/github/implementation.js | 4 + src/components/UnpublishedListing.css | 26 ++++- src/components/UnpublishedListing.js | 105 ++++++++++++++++-- .../editorialWorkflow/CollectionPageHOC.js | 10 +- .../editorialWorkflow/EntryPageHOC.js | 2 +- src/reducers/editorialWorkflow.js | 21 +++- 10 files changed, 237 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 2314178d..312193cf 100644 --- a/package.json +++ b/package.json @@ -86,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 73586cb3..c7e73082 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -14,6 +14,9 @@ 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'; + /* * Simple Action Creators (Internal) */ @@ -57,24 +60,39 @@ function unpublishedEntriesFailed(error) { } -function unpublishedEntryPersisting(status, entry) { +function unpublishedEntryPersisting(entry) { return { type: UNPUBLISHED_ENTRY_PERSIST_REQUEST, - payload: { status, entry } + payload: { entry } }; } -function unpublishedEntryPersisted(status, entry) { +function unpublishedEntryPersisted(entry) { return { type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, - payload: { status, entry } + payload: { entry } }; } -function unpublishedEntryPersistedFail(status, entry) { +function unpublishedEntryPersistedFail(error) { return { type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, - payload: { status, entry } + 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 } }; } @@ -105,17 +123,29 @@ export function loadUnpublishedEntries() { }; } -export function persistUnpublishedEntry(collection, status, entry) { +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(status, entry)); - backend.persistUnpublishedEntry(state.config, collection, status, entry, MediaProxies.toJS()).then( + dispatch(unpublishedEntryPersisting(entry)); + backend.persistUnpublishedEntry(state.config, collection, entry, MediaProxies.toJS()).then( () => { - dispatch(unpublishedEntryPersisted(status, entry)); + 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)); + }); + }; +} diff --git a/src/backends/backend.js b/src/backends/backend.js index b09d1467..db163b74 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -143,8 +143,12 @@ class Backend { }); } - persistUnpublishedEntry(config, collection, status, entryDraft, MediaFiles) { - return this.persistEntry(config, collection, entryDraft, MediaFiles, { unpublished: true, status }); + 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); } diff --git a/src/backends/github/API.js b/src/backends/github/API.js index bd3690a9..196150e8 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -7,8 +7,6 @@ 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; @@ -99,17 +97,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) { @@ -137,26 +146,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 }; }); } @@ -248,7 +245,6 @@ export default class API { return { ...metadata, - status: options.status, title: options.parsedData && options.parsedData.title, description: options.parsedData && options.parsedData.description, objects: { @@ -264,6 +260,18 @@ export default class API { } } + 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)); + } + createRef(type, name, sha) { return this.request(`${this.repoURL}/git/refs`, { method: 'POST', diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 6ce1c865..c744bc46 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -98,4 +98,8 @@ export default class GitHub { ))[0] )); } + + updateUnpublishedEntryStatus(collection, slug, newStatus) { + return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus); + } } diff --git a/src/components/UnpublishedListing.css b/src/components/UnpublishedListing.css index b37d8bf2..db202e36 100644 --- a/src/components/UnpublishedListing.css +++ b/src/components/UnpublishedListing.css @@ -1,16 +1,25 @@ +.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 { @@ -30,3 +39,10 @@ margin-top: 5px; } } + + +.clear::after { + content:""; + display:block; + clear:both; +} diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js index 97a711f4..8fa90bf2 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing.js @@ -1,4 +1,6 @@ -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'; @@ -6,16 +8,93 @@ import { Link } from 'react-router'; import { 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().currentStatus; + 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({ connectDragSource, children }) { + return connectDragSource( +
+ + {children} + +
+ ); +} + +const cardDragSpec = { + beginDrag(props) { + return { + slug: props.slug, + collection: props.collection, + currentStatus: props.status + }; + } +}; + +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); + } + renderColumns(entries, column) { if (!entries) return; if (!column) { return entries.entrySeq().map(([currColumn, currEntries]) => ( -
-

{statusDescriptions.get(currColumn)}

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

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

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

-
+ ); } )} @@ -40,9 +124,11 @@ export default class UnpublishedListing extends React.Component { const columns = this.renderColumns(this.props.entries); return ( -
+

Editorial Workflow

+
{columns} +
); } @@ -50,4 +136,7 @@ export default class UnpublishedListing extends React.Component { UnpublishedListing.propTypes = { entries: ImmutablePropTypes.orderedMap, + handleChangeStatus: PropTypes.func.isRequired, }; + +export default DragDropContext(HTML5Backend)(UnpublishedListing); diff --git a/src/containers/editorialWorkflow/CollectionPageHOC.js b/src/containers/editorialWorkflow/CollectionPageHOC.js index bee4a2ac..b5e14d08 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 } from '../../actions/editorialWorkflow'; import { selectUnpublishedEntries } from '../../reducers'; import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes'; import UnpublishedListing from '../../components/UnpublishedListing'; @@ -19,13 +19,17 @@ export default function CollectionPageHOC(CollectionPage) { super.componentDidMount(); } + handleChangeStatus(collection, slug, oldStatus, newStatus) { + this.props.updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus); + } + render() { const { isEditorialWorkflow, unpublishedEntries } = this.props; if (!isEditorialWorkflow) return super.render(); return (
- + {super.render()}
); @@ -56,5 +60,5 @@ export default function CollectionPageHOC(CollectionPage) { return returnObj; } - return connect(mapStateToProps)(CollectionPageHOC); + return connect(mapStateToProps, { updateUnpublishedEntryStatus })(CollectionPageHOC); } diff --git a/src/containers/editorialWorkflow/EntryPageHOC.js b/src/containers/editorialWorkflow/EntryPageHOC.js index a05127c8..4f38e293 100644 --- a/src/containers/editorialWorkflow/EntryPageHOC.js +++ b/src/containers/editorialWorkflow/EntryPageHOC.js @@ -37,7 +37,7 @@ export default function EntryPageHOC(EntryPage) { }; returnObj.persistEntry = (collection, entryDraft) => { - dispatch(persistUnpublishedEntry(collection, status, entryDraft)); + dispatch(persistUnpublishedEntry(collection, entryDraft)); }; } return returnObj; diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index beee2071..e5dcfd2c 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -1,7 +1,11 @@ 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 } from '../actions/editorialWorkflow'; import { CONFIG_SUCCESS } from '../actions/config'; @@ -39,6 +43,21 @@ const unpublishedEntries = (state = null, action) => { ids: List(entries.map((entry) => entry.slug)) })); }); + + case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS: + const { slug, oldStatus, newStatus } = action.payload; + return state.withMutations((map) => { + const entry = map.getIn(['entities', `${oldStatus}.${slug}`]); + + let entities = map.get('entities').filter((val, key) => ( + key !== `${oldStatus}.${slug}` + )); + + entities = entities.set(`${newStatus}.${slug}`, entry); + + map.set('entities', entities); + }); + default: return state; } From 91846cdbc5228bcb8b1fa76a9254bffd847141b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 14 Sep 2016 11:59:59 -0300 Subject: [PATCH 10/12] Toast component --- src/components/UI/card/Card.css | 7 ++- src/components/UI/index.js | 1 + src/components/UI/theme.css | 1 + src/components/UI/toast/Toast.css | 40 +++++++++++++++++ src/components/UI/toast/Toast.js | 74 +++++++++++++++++++++++++++++++ src/components/stories/Toast.js | 19 ++++++++ src/components/stories/index.js | 1 + 7 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/components/UI/toast/Toast.css create mode 100644 src/components/UI/toast/Toast.js create mode 100644 src/components/stories/Toast.js diff --git a/src/components/UI/card/Card.css b/src/components/UI/card/Card.css index 592cfd1e..43d7392e 100644 --- a/src/components/UI/card/Card.css +++ b/src/components/UI/card/Card.css @@ -1,8 +1,7 @@ +@import "../theme.css"; + .card { - composes: base from "../theme.css"; - composes: container from "../theme.css"; - composes: rounded from "../theme.css"; - composes: depth from "../theme.css"; + composes: base container rounded depth; overflow: hidden; width: 240px; } 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/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'; From 71b5b0bde94f76e4dd23e3ba6ca68da4b6b9fc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 14 Sep 2016 18:25:45 -0300 Subject: [PATCH 11/12] merge button for editorial workflow --- src/actions/editorialWorkflow.js | 30 ++++++++++++- src/backends/backend.js | 4 ++ src/backends/github/API.js | 37 +++++++++++++--- src/backends/github/implementation.js | 4 ++ src/components/UnpublishedListing.css | 7 +++- src/components/UnpublishedListing.js | 42 +++++++++++-------- src/containers/App.js | 3 +- .../editorialWorkflow/CollectionPageHOC.js | 19 +++++---- src/reducers/editorialWorkflow.js | 15 ++++--- 9 files changed, 121 insertions(+), 40 deletions(-) diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index c7e73082..9b1cb70b 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -17,6 +17,9 @@ export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCC 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) */ @@ -81,7 +84,6 @@ function unpublishedEntryPersistedFail(error) { }; } - function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus) { return { type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, @@ -96,6 +98,20 @@ function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newS }; } +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 */ @@ -149,3 +165,15 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta }); }; } + +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 db163b74..1cb9bc42 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -151,6 +151,10 @@ class Backend { 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); diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 196150e8..fe2dc273 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -201,17 +201,24 @@ export default class API { 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((response) => { - const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; - const branchName = `cms/${contentKey}`; + .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, @@ -223,9 +230,7 @@ export default class API { files: filesList }, timeStamp: new Date().toISOString() - })) - .then(this.createBranch(branchName, response.sha)) - .then(this.createPR(options.commitMessage, `cms/${contentKey}`)); + })); }); } else { // Entry is already on editorial review workflow - just update metadata and commit to existing branch @@ -272,6 +277,16 @@ export default class API { .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); + }); + } + createRef(type, name, sha) { return this.request(`${this.repoURL}/git/refs`, { method: 'POST', @@ -306,6 +321,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/implementation.js b/src/backends/github/implementation.js index c744bc46..2d270261 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -102,4 +102,8 @@ export default class GitHub { 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/components/UnpublishedListing.css b/src/components/UnpublishedListing.css index db202e36..ebd7bcd6 100644 --- a/src/components/UnpublishedListing.css +++ b/src/components/UnpublishedListing.css @@ -26,7 +26,7 @@ width: 100% !important; margin: 7px 0; - & h1 { + & h2 { font-size: 17px; & small { font-weight: normal; @@ -38,6 +38,11 @@ font-size: 12px; margin-top: 5px; } + + & button { + margin: 10px 10px 0 0; + float: right; + } } diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js index 8fa90bf2..9d09b6a0 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing.js @@ -5,7 +5,7 @@ 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'; const CARD = 'card'; @@ -22,56 +22,52 @@ function Column({ connectDropTarget, status, isOver, children }) {
); } - const columnTargetSpec = { drop(props, monitor) { const slug = monitor.getItem().slug; const collection = monitor.getItem().collection; - const oldStatus = monitor.getItem().currentStatus; + 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({ connectDragSource, children }) { +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, - currentStatus: props.status + ownStatus: props.ownStatus }; } }; - function cardCollect(connect, monitor) { return { connectDragSource: connect.dragSource() }; } - EntryCard = DragSource(CARD, cardDragSpec, cardCollect)(EntryCard); /* @@ -81,6 +77,14 @@ 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) { @@ -103,14 +107,18 @@ 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'])}

); @@ -122,7 +130,6 @@ class UnpublishedListing extends React.Component { render() { const columns = this.renderColumns(this.props.entries); - return (

Editorial Workflow

@@ -137,6 +144,7 @@ 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/containers/App.js b/src/containers/App.js index 052af2c1..0cfec9fa 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; 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'; @@ -26,7 +27,7 @@ class App extends React.Component { configLoading() { return
-

Loading configuration...

+ Loading configuration...
; } diff --git a/src/containers/editorialWorkflow/CollectionPageHOC.js b/src/containers/editorialWorkflow/CollectionPageHOC.js index b5e14d08..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, updateUnpublishedEntryStatus } 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'; @@ -19,17 +19,17 @@ export default function CollectionPageHOC(CollectionPage) { super.componentDidMount(); } - handleChangeStatus(collection, slug, oldStatus, newStatus) { - this.props.updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus); - } - render() { - const { isEditorialWorkflow, unpublishedEntries } = this.props; + const { isEditorialWorkflow, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props; if (!isEditorialWorkflow) return super.render(); return (
- + {super.render()}
); @@ -60,5 +60,8 @@ export default function CollectionPageHOC(CollectionPage) { return returnObj; } - return connect(mapStateToProps, { updateUnpublishedEntryStatus })(CollectionPageHOC); + return connect(mapStateToProps, { + updateUnpublishedEntryStatus, + publishUnpublishedEntry + })(CollectionPageHOC); } diff --git a/src/reducers/editorialWorkflow.js b/src/reducers/editorialWorkflow.js index e5dcfd2c..246656f0 100644 --- a/src/reducers/editorialWorkflow.js +++ b/src/reducers/editorialWorkflow.js @@ -5,7 +5,8 @@ import { UNPUBLISHED_ENTRY_SUCCESS, UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_SUCCESS, - UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS + UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, + UNPUBLISHED_ENTRY_PUBLISH_SUCCESS } from '../actions/editorialWorkflow'; import { CONFIG_SUCCESS } from '../actions/config'; @@ -45,19 +46,21 @@ const unpublishedEntries = (state = null, action) => { }); case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS: - const { slug, oldStatus, newStatus } = action.payload; return state.withMutations((map) => { - const entry = map.getIn(['entities', `${oldStatus}.${slug}`]); + 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 !== `${oldStatus}.${slug}` + key !== `${action.payload.oldStatus}.${action.payload.slug}` )); - - entities = entities.set(`${newStatus}.${slug}`, entry); + 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; } From ae64ce73ea6bdbee8fe313985f961c234e24a600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 14 Sep 2016 18:55:42 -0300 Subject: [PATCH 12/12] Delete branch after PR merge --- src/backends/github/API.js | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/backends/github/API.js b/src/backends/github/API.js index fe2dc273..70770aed 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -53,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); } @@ -284,7 +285,8 @@ export default class API { 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) { @@ -294,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', @@ -305,14 +303,28 @@ export default class API { }); } - patchBranch(branchName, sha) { - return this.patchRef('heads', branchName, sha); + 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); + } + + deleteBranch(branchName) { + return this.deleteRef('heads', branchName); + } + createPR(title, head, base = 'master') { const body = 'Automatically generated by Netlify CMS'; return this.request(`${this.repoURL}/pulls`, {