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] 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; }