Editorial workflow Drag'nDrop

This commit is contained in:
Cássio Zen 2016-09-13 16:00:24 -03:00
parent e2b6e140f3
commit 0b447d483d
10 changed files with 237 additions and 61 deletions

View File

@ -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",

View File

@ -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));
});
};
}

View File

@ -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);
}

View File

@ -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',

View File

@ -98,4 +98,8 @@ export default class GitHub {
))[0]
));
}
updateUnpublishedEntryStatus(collection, slug, newStatus) {
return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
}

View File

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

View File

@ -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(
<div className={className}>
<h2>{statusDescriptions.get(status)}</h2>
{children}
</div>
);
}
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(
<div>
<Card className={styles.card}>
{children}
</Card>
</div>
);
}
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]) => (
<div key={currColumn} className={styles.column}>
<h2>{statusDescriptions.get(currColumn)}</h2>
<Column
key={currColumn}
status={currColumn}
onChangeStatus={this.props.handleChangeStatus}
>
{this.renderColumns(currEntries, currColumn)}
</div>
</Column>
));
} else {
return <div>
@ -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 (
<Card key={entry.get('slug')} className={styles.card}>
<EntryCard
key={entry.get('slug')}
slug={entry.get('slug')}
status={entry.getIn(['metaData', 'status'])}
collection={entry.getIn(['metaData', 'collection'])}
>
<h1><Link to={link}>{entry.getIn(['data', 'title'])}</Link> <small>by {author}</small></h1>
<p>Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}</p>
</Card>
</EntryCard>
);
}
)}
@ -40,9 +124,11 @@ export default class UnpublishedListing extends React.Component {
const columns = this.renderColumns(this.props.entries);
return (
<div>
<div className={styles.clear}>
<h1>Editorial Workflow</h1>
<div className={styles.container}>
{columns}
</div>
</div>
);
}
@ -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);

View File

@ -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 (
<div className={styles.alignable}>
<UnpublishedListing entries={unpublishedEntries}/>
<UnpublishedListing entries={unpublishedEntries} handleChangeStatus={this.handleChangeStatus.bind(this)} />
{super.render()}
</div>
);
@ -56,5 +60,5 @@ export default function CollectionPageHOC(CollectionPage) {
return returnObj;
}
return connect(mapStateToProps)(CollectionPageHOC);
return connect(mapStateToProps, { updateUnpublishedEntryStatus })(CollectionPageHOC);
}

View File

@ -37,7 +37,7 @@ export default function EntryPageHOC(EntryPage) {
};
returnObj.persistEntry = (collection, entryDraft) => {
dispatch(persistUnpublishedEntry(collection, status, entryDraft));
dispatch(persistUnpublishedEntry(collection, entryDraft));
};
}
return returnObj;

View File

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