merge button for editorial workflow

This commit is contained in:
Cássio Zen 2016-09-14 18:25:45 -03:00
parent 91846cdbc5
commit 71b5b0bde9
9 changed files with 121 additions and 40 deletions

View File

@ -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_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST';
export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS'; 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) * Simple Action Creators (Internal)
*/ */
@ -81,7 +84,6 @@ function unpublishedEntryPersistedFail(error) {
}; };
} }
function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus) { function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus) {
return { return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, 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 * 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));
});
};
}

View File

@ -151,6 +151,10 @@ class Backend {
return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus); return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus);
} }
publishUnpublishedEntry(collection, slug, status) {
return this.implementation.publishUnpublishedEntry(collection, slug, status);
}
entryToRaw(collection, entry) { entryToRaw(collection, entry) {
const format = resolveFormat(collection, entry); const format = resolveFormat(collection, entry);

View File

@ -201,17 +201,24 @@ export default class API {
if (!unpublished) { if (!unpublished) {
// Open new editorial review workflow for this entry - Create new metadata and commit to new branch // 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() return this.getBranch()
.then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree)) .then(branchData => this.updateTree(branchData.commit.sha, '/', fileTree))
.then(changeTree => this.commit(options.commitMessage, changeTree)) .then(changeTree => this.commit(options.commitMessage, changeTree))
.then((response) => { .then(commitResponse => this.createBranch(branchName, commitResponse.sha))
const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug; .then(branchResponse => this.createPR(options.commitMessage, branchName))
const branchName = `cms/${contentKey}`; .then((prResponse) => {
return this.user().then(user => { return this.user().then(user => {
return user.name ? user.name : user.login; return user.name ? user.name : user.login;
}) })
.then(username => this.storeMetadata(contentKey, { .then(username => this.storeMetadata(contentKey, {
type: 'PR', type: 'PR',
pr: {
number: prResponse.number,
head: prResponse.head && prResponse.head.sha
},
user: username, user: username,
status: status.first(), status: status.first(),
branch: branchName, branch: branchName,
@ -223,9 +230,7 @@ export default class API {
files: filesList files: filesList
}, },
timeStamp: new Date().toISOString() timeStamp: new Date().toISOString()
})) }));
.then(this.createBranch(branchName, response.sha))
.then(this.createPR(options.commitMessage, `cms/${contentKey}`));
}); });
} else { } else {
// Entry is already on editorial review workflow - just update metadata and commit to existing branch // 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)); .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) { createRef(type, name, sha) {
return this.request(`${this.repoURL}/git/refs`, { return this.request(`${this.repoURL}/git/refs`, {
method: 'POST', 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) { getTree(sha) {
return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] }); return sha ? this.request(`${this.repoURL}/git/trees/${sha}`) : Promise.resolve({ tree: [] });
} }

View File

@ -102,4 +102,8 @@ export default class GitHub {
updateUnpublishedEntryStatus(collection, slug, newStatus) { updateUnpublishedEntryStatus(collection, slug, newStatus) {
return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus); return this.api.updateUnpublishedEntryStatus(collection, slug, newStatus);
} }
publishUnpublishedEntry(collection, slug, status) {
return this.api.publishUnpublishedEntry(collection, slug, status);
}
} }

View File

@ -26,7 +26,7 @@
width: 100% !important; width: 100% !important;
margin: 7px 0; margin: 7px 0;
& h1 { & h2 {
font-size: 17px; font-size: 17px;
& small { & small {
font-weight: normal; font-weight: normal;
@ -38,6 +38,11 @@
font-size: 12px; font-size: 12px;
margin-top: 5px; margin-top: 5px;
} }
& button {
margin: 10px 10px 0 0;
float: right;
}
} }

View File

@ -5,7 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import moment from 'moment'; import moment from 'moment';
import { Card } from './UI'; import { Card } from './UI';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { statusDescriptions } from '../constants/publishModes'; import { status, statusDescriptions } from '../constants/publishModes';
import styles from './UnpublishedListing.css'; import styles from './UnpublishedListing.css';
const CARD = 'card'; const CARD = 'card';
@ -22,56 +22,52 @@ function Column({ connectDropTarget, status, isOver, children }) {
</div> </div>
); );
} }
const columnTargetSpec = { const columnTargetSpec = {
drop(props, monitor) { drop(props, monitor) {
const slug = monitor.getItem().slug; const slug = monitor.getItem().slug;
const collection = monitor.getItem().collection; const collection = monitor.getItem().collection;
const oldStatus = monitor.getItem().currentStatus; const oldStatus = monitor.getItem().ownStatus;
props.onChangeStatus(collection, slug, oldStatus, props.status); props.onChangeStatus(collection, slug, oldStatus, props.status);
} }
}; };
function columnCollect(connect, monitor) { function columnCollect(connect, monitor) {
return { return {
connectDropTarget: connect.dropTarget(), connectDropTarget: connect.dropTarget(),
isOver: monitor.isOver() isOver: monitor.isOver()
}; };
} }
Column = DropTarget(CARD, columnTargetSpec, columnCollect)(Column); Column = DropTarget(CARD, columnTargetSpec, columnCollect)(Column);
/* /*
* Card DropTarget Component * Card DropTarget Component
*/ */
function EntryCard({ connectDragSource, children }) { function EntryCard({ slug, collection, ownStatus, onRequestPublish, connectDragSource, children }) {
return connectDragSource( return connectDragSource(
<div> <div>
<Card className={styles.card}> <Card className={styles.card}>
{children} {children}
{(ownStatus === status.last()) &&
<button onClick={onRequestPublish}>Publish now</button>
}
</Card> </Card>
</div> </div>
); );
} }
const cardDragSpec = { const cardDragSpec = {
beginDrag(props) { beginDrag(props) {
return { return {
slug: props.slug, slug: props.slug,
collection: props.collection, collection: props.collection,
currentStatus: props.status ownStatus: props.ownStatus
}; };
} }
}; };
function cardCollect(connect, monitor) { function cardCollect(connect, monitor) {
return { return {
connectDragSource: connect.dragSource() connectDragSource: connect.dragSource()
}; };
} }
EntryCard = DragSource(CARD, cardDragSpec, cardCollect)(EntryCard); EntryCard = DragSource(CARD, cardDragSpec, cardCollect)(EntryCard);
/* /*
@ -81,6 +77,14 @@ class UnpublishedListing extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.renderColumns = this.renderColumns.bind(this); 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) { renderColumns(entries, column) {
@ -103,14 +107,18 @@ class UnpublishedListing extends React.Component {
const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user'])); const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user']));
const timeStamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('llll'); const timeStamp = moment(entry.getIn(['metaData', 'timeStamp'])).format('llll');
const link = `/editorialworkflow/${entry.getIn(['metaData', 'collection'])}/${entry.getIn(['metaData', 'status'])}/${entry.get('slug')}`; 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 ( return (
<EntryCard <EntryCard
key={entry.get('slug')} key={slug}
slug={entry.get('slug')} slug={slug}
status={entry.getIn(['metaData', 'status'])} ownStatus={status}
collection={entry.getIn(['metaData', 'collection'])} collection={collection}
onRequestPublish={this.requestPublish.bind(this, collection, slug, status)} // eslint-disable-line
> >
<h1><Link to={link}>{entry.getIn(['data', 'title'])}</Link> <small>by {author}</small></h1> <h2><Link to={link}>{entry.getIn(['data', 'title'])}</Link> <small>by {author}</small></h2>
<p>Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}</p> <p>Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}</p>
</EntryCard> </EntryCard>
); );
@ -122,7 +130,6 @@ class UnpublishedListing extends React.Component {
render() { render() {
const columns = this.renderColumns(this.props.entries); const columns = this.renderColumns(this.props.entries);
return ( return (
<div className={styles.clear}> <div className={styles.clear}>
<h1>Editorial Workflow</h1> <h1>Editorial Workflow</h1>
@ -137,6 +144,7 @@ class UnpublishedListing extends React.Component {
UnpublishedListing.propTypes = { UnpublishedListing.propTypes = {
entries: ImmutablePropTypes.orderedMap, entries: ImmutablePropTypes.orderedMap,
handleChangeStatus: PropTypes.func.isRequired, handleChangeStatus: PropTypes.func.isRequired,
handlePublish: PropTypes.func.isRequired,
}; };
export default DragDropContext(HTML5Backend)(UnpublishedListing); export default DragDropContext(HTML5Backend)(UnpublishedListing);

View File

@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import { loadConfig } from '../actions/config'; import { loadConfig } from '../actions/config';
import { loginUser } from '../actions/auth'; import { loginUser } from '../actions/auth';
import { currentBackend } from '../backends/backend'; import { currentBackend } from '../backends/backend';
import { Loader } from '../components/UI';
import { SHOW_COLLECTION, CREATE_COLLECTION, HELP } from '../actions/findbar'; import { SHOW_COLLECTION, CREATE_COLLECTION, HELP } from '../actions/findbar';
import FindBar from './FindBar'; import FindBar from './FindBar';
import styles from './App.css'; import styles from './App.css';
@ -26,7 +27,7 @@ class App extends React.Component {
configLoading() { configLoading() {
return <div> return <div>
<h1>Loading configuration...</h1> <Loader active>Loading configuration...</Loader>
</div>; </div>;
} }

View File

@ -1,7 +1,7 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { OrderedMap } from 'immutable'; import { OrderedMap } from 'immutable';
import { loadUnpublishedEntries, updateUnpublishedEntryStatus } from '../../actions/editorialWorkflow'; import { loadUnpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } from '../../actions/editorialWorkflow';
import { selectUnpublishedEntries } from '../../reducers'; import { selectUnpublishedEntries } from '../../reducers';
import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes'; import { EDITORIAL_WORKFLOW, status } from '../../constants/publishModes';
import UnpublishedListing from '../../components/UnpublishedListing'; import UnpublishedListing from '../../components/UnpublishedListing';
@ -19,17 +19,17 @@ export default function CollectionPageHOC(CollectionPage) {
super.componentDidMount(); super.componentDidMount();
} }
handleChangeStatus(collection, slug, oldStatus, newStatus) {
this.props.updateUnpublishedEntryStatus(collection, slug, oldStatus, newStatus);
}
render() { render() {
const { isEditorialWorkflow, unpublishedEntries } = this.props; const { isEditorialWorkflow, unpublishedEntries, updateUnpublishedEntryStatus, publishUnpublishedEntry } = this.props;
if (!isEditorialWorkflow) return super.render(); if (!isEditorialWorkflow) return super.render();
return ( return (
<div className={styles.alignable}> <div className={styles.alignable}>
<UnpublishedListing entries={unpublishedEntries} handleChangeStatus={this.handleChangeStatus.bind(this)} /> <UnpublishedListing
entries={unpublishedEntries}
handleChangeStatus={updateUnpublishedEntryStatus}
handlePublish={publishUnpublishedEntry}
/>
{super.render()} {super.render()}
</div> </div>
); );
@ -60,5 +60,8 @@ export default function CollectionPageHOC(CollectionPage) {
return returnObj; return returnObj;
} }
return connect(mapStateToProps, { updateUnpublishedEntryStatus })(CollectionPageHOC); return connect(mapStateToProps, {
updateUnpublishedEntryStatus,
publishUnpublishedEntry
})(CollectionPageHOC);
} }

View File

@ -5,7 +5,8 @@ import {
UNPUBLISHED_ENTRY_SUCCESS, UNPUBLISHED_ENTRY_SUCCESS,
UNPUBLISHED_ENTRIES_REQUEST, UNPUBLISHED_ENTRIES_REQUEST,
UNPUBLISHED_ENTRIES_SUCCESS, UNPUBLISHED_ENTRIES_SUCCESS,
UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS
} from '../actions/editorialWorkflow'; } from '../actions/editorialWorkflow';
import { CONFIG_SUCCESS } from '../actions/config'; import { CONFIG_SUCCESS } from '../actions/config';
@ -45,19 +46,21 @@ const unpublishedEntries = (state = null, action) => {
}); });
case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS: case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS:
const { slug, oldStatus, newStatus } = action.payload;
return state.withMutations((map) => { 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) => ( let entities = map.get('entities').filter((val, key) => (
key !== `${oldStatus}.${slug}` key !== `${action.payload.oldStatus}.${action.payload.slug}`
)); ));
entities = entities.set(`${action.payload.newStatus}.${action.payload.slug}`, entry);
entities = entities.set(`${newStatus}.${slug}`, entry);
map.set('entities', entities); map.set('entities', entities);
}); });
case UNPUBLISHED_ENTRY_PUBLISH_SUCCESS:
return state.deleteIn(['entities', `${action.payload.status}.${action.payload.slug}`]);
default: default:
return state; return state;
} }