Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Editorial workflow dependency tracking #243

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 230 additions & 1 deletion src/actions/editorialWorkflow.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import uuid from 'uuid';
import Immutable from 'immutable';
import { actions as notifActions } from 'redux-notifications';
import { closeEntry } from './editor';
import { BEGIN, COMMIT, REVERT } from 'redux-optimist';
Expand Down Expand Up @@ -33,6 +34,17 @@ export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQU
export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS';
export const UNPUBLISHED_ENTRY_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE';

export const UNPUBLISHED_ENTRIES_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRIES_PUBLISH_REQUEST';
export const UNPUBLISHED_ENTRIES_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRIES_PUBLISH_SUCCESS';
export const UNPUBLISHED_ENTRIES_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRIES_PUBLISH_FAILURE';

export const UNPUBLISHED_ENTRY_REGISTER_DEPENDENCY = 'UNPUBLISHED_ENTRY_REGISTER_DEPENDENCY';
export const UNPUBLISHED_ENTRY_UNREGISTER_DEPENDENCY = 'UNPUBLISHED_ENTRY_UNREGISTER_DEPENDENCY';

export const UNPUBLISHED_ENTRY_DEPENDENCIES_REQUEST = 'UNPUBLISHED_ENTRY_DEPENDENCIES_REQUEST';
export const UNPUBLISHED_ENTRY_DEPENDENCIES_SUCCESS = 'UNPUBLISHED_ENTRY_DEPENDENCIES_SUCCESS';
export const UNPUBLISHED_ENTRY_DEPENDENCIES_FAILURE = 'UNPUBLISHED_ENTRY_DEPENDENCIES_FAILURE';

/*
* Simple Action Creators (Internal)
*/
Expand Down Expand Up @@ -180,16 +192,78 @@ function unpublishedEntryPublishError(collection, slug, transactionID) {
};
}

function unpublishedEntriesPublishRequest(entries, transactionID) {
return {
type: UNPUBLISHED_ENTRIES_PUBLISH_REQUEST,
payload: { entries },
optimist: { type: BEGIN, id: transactionID },
};
}

function unpublishedEntriesPublished(entries, transactionID) {
return {
type: UNPUBLISHED_ENTRIES_PUBLISH_SUCCESS,
payload: { entries },
optimist: { type: COMMIT, id: transactionID },
};
}

function unpublishedEntriesPublishError(entries, transactionID) {
return {
type: UNPUBLISHED_ENTRIES_PUBLISH_FAILURE,
payload: { entries },
optimist: { type: REVERT, id: transactionID },
};
}

function unpublishedEntryRegisterDependency(field, collection, slug) {
return {
type: UNPUBLISHED_ENTRY_REGISTER_DEPENDENCY,
payload: { field, collection, slug },
};
}

function unpublishedEntryUnregisterDependency(field) {
return {
type: UNPUBLISHED_ENTRY_UNREGISTER_DEPENDENCY,
payload: { field },
};
}

function unpublishedEntryDependenciesRequest(collection, slug) {
return {
type: UNPUBLISHED_ENTRY_DEPENDENCIES_REQUEST,
payload: { collection, slug },
};
}

function unpublishedEntryDependenciesSuccess(collection, slug, dependencies) {
return {
type: UNPUBLISHED_ENTRY_DEPENDENCIES_SUCCESS,
payload: { collection, slug, dependencies },
};
}

function unpublishedEntryDependenciesError(collection, slug, error) {
return {
type: UNPUBLISHED_ENTRY_DEPENDENCIES_FAILURE,
payload: { collection, slug, error },
};
}

/*
* Exported Thunk Action Creators
*/

export const registerUnpublishedEntryDependency = unpublishedEntryRegisterDependency;
export const unregisterUnpublishedEntryDependency = unpublishedEntryUnregisterDependency;

export function loadUnpublishedEntry(collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(unpublishedEntryLoading(collection, slug));
backend.unpublishedEntry(collection, slug)
return backend.unpublishedEntry(collection, slug)
.then(entry => dispatch(unpublishedEntryLoaded(collection, entry)))
.catch((error) => {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
Expand Down Expand Up @@ -312,3 +386,158 @@ export function publishUnpublishedEntry(collection, slug) {
});
};
}

export function publishUnpublishedEntries(entries) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const transactionID = uuid.v4();

dispatch(unpublishedEntriesPublishRequest(entries, transactionID));
backend.publishUnpublishedEntries(entries)
.then(() => {
dispatch(unpublishedEntriesPublished(entries, transactionID));
})
.catch((error) => {
dispatch(notifSend({
message: `Failed to merge: ${ error }`,
kind: 'danger',
dismissAfter: 8000,
}));
dispatch(unpublishedEntriesPublishError(entries, transactionID));
});
};
}

const getDepsPath = dep => [
"entities",
dep,
"metaData",
"dependencies",
];

const getEventualDependencies = (paths, loadedDeps, state, dispatch) =>
// Filter paths to remove those we've already checked. This
// prevents traverse from loading posts we don't need or looping
// infinitely over cyclic dependencies.
paths.filter(path => !loadedDeps.includes(path)).map((path) => {
const [pathCollectionName, pathSlug] = path.split(".");
const pathCollection = state.collections.get(pathCollectionName);
// Wait for the entry to load
return dispatch(loadUnpublishedEntry(pathCollection, pathSlug))
// Return the path at the end so we can use it in .thens later
.then(() => path);
});

const pathHasDependencies = (state, path) => {
if (!state.editorialWorkflow.hasIn(path) ||
state.editorialWorkflow.getIn(path) === null) {
return false;
}

if (state.editorialWorkflow.getIn(path).size === 0) {
return false;
}

return true;
};

const reducePromises = (promises, fn, initPromise) => {
// If the array is empty and we aren't given an init value, we don't
// have anything to return.
if (promises.length === 0 && initPromise === undefined) {
throw new Error("Reduce of empty promise array with no initial value.");
}

// If we weren't given an init value, then the init value should be
// the first item in `promises`
const [initValue, skipFirstPromise] = (initPromise !== undefined)
? [initPromise, false]
: [promises[0], true];

// If we are using the first promise as our init value, we need to
// remove it from the promises we'll reduce over.
const promisesToReduce = skipFirstPromise
? promises.slice(1)
: promises;

return promisesToReduce.reduce((accumulatedPromises, currentPromise) =>
Promise.all([accumulatedPromises, currentPromise]).then(
(([accumulated, current]) => fn(accumulated, current))),
initValue);
};

const traverse = (collectedDeps, path, getState, dispatch) => {
const state = getState();

if (collectedDeps.get(path) === true) {
return Promise.resolve(collectedDeps);
}

// Add this entry to the dependency list
const newDeps = collectedDeps.set(path, true);
const newDepsPromise = Promise.resolve(newDeps);

// Get the full state path to this entries dependencies
const depsPath = getDepsPath(path);

// If the entry has no dependencies, return the collected dependency
// list (including the current entry)
if (!pathHasDependencies(state, depsPath)) {
return newDepsPromise;
}

const theseDependencies = state.editorialWorkflow.getIn(depsPath);

// Gets a list of promises for all unrecorded dependencies. Each
// promise resolves once its entry is loaded.
const eventualDeps = getEventualDependencies(theseDependencies, collectedDeps, state, dispatch);

// Reduce over the list of dependency promises. allDepsPromise is
// the accumulation value. Each time we reduce, we call traverse to
// get the dependencies of the current dependency, then continue to
// the next one. This makes traversal recurse until all the
// dependencies are collected.
return reducePromises(
eventualDeps,
(deps, dep) => traverse(deps, dep, getState, dispatch),
newDepsPromise
);
};

export function getUnpublishedEntryDependencies(collection, slug) {
return (dispatch, getState) => {
dispatch(unpublishedEntryDependenciesRequest(collection, slug));

// Begin traversal
return traverse(new Immutable.Map(), `${ collection }.${ slug }`, getState, dispatch)
.then((dependencyMap) => {
const state = getState();
const dependencies = dependencyMap.keySeq().toList();

// Remove any dependencies which are already published
const filteredDependencies = dependencies.filter(
dep => state.editorialWorkflow.hasIn(["entities", dep]));
return dispatch(unpublishedEntryDependenciesSuccess(collection, slug, filteredDependencies));
})
.catch(err => dispatch(unpublishedEntryDependenciesError(collection, slug, err)));
};
}

export function publishUnpublishedEntryAndDependencies(collection, slug) {
return (dispatch, getState) => dispatch(getUnpublishedEntryDependencies(collection, slug))
.then(({ payload }) => {
if (payload.dependencies.size === 1) {
return dispatch(publishUnpublishedEntry(collection, slug));
}

const confirmationMessage = `\
This entry has dependencies, and cannot be published on its own. Publish all the following posts?
${ payload.dependencies.join("\n") }`;

if (window.confirm(confirmationMessage)) {
return dispatch(publishUnpublishedEntries(payload.dependencies.map(
dep => dep.split("."))));
}
});
}
9 changes: 8 additions & 1 deletion src/backends/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ class Backend {
persistEntry(config, collection, entryDraft, MediaFiles, options) {
const newEntry = entryDraft.getIn(["entry", "newRecord"]) || false;

const maybeDeps = entryDraft.getIn(["entry", "dependencies"]);
const dependencies = (maybeDeps !== undefined) ? maybeDeps.toJS() : null;

const parsedData = {
title: entryDraft.getIn(["entry", "data", "title"], "No Title"),
description: entryDraft.getIn(["entry", "data", "description"], "No Description!"),
Expand Down Expand Up @@ -184,7 +187,7 @@ class Backend {
const collectionName = collection.get("name");

return this.implementation.persistEntry(entryObj, MediaFiles, {
newEntry, parsedData, commitMessage, collectionName, mode, ...options,
newEntry, parsedData, commitMessage, collectionName, mode, dependencies, ...options,
});
}

Expand All @@ -200,6 +203,10 @@ class Backend {
return this.implementation.publishUnpublishedEntry(collection, slug);
}

publishUnpublishedEntries(entries) {
return this.implementation.publishUnpublishedEntries(entries);
}

deleteUnpublishedEntry(collection, slug) {
return this.implementation.deleteUnpublishedEntry(collection, slug);
}
Expand Down
64 changes: 62 additions & 2 deletions src/backends/github/API.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import LocalForage from "localforage";
import { Base64 } from "js-base64";
import Immutable from "immutable";
import _ from "lodash";
import AssetProxy from "../../valueObjects/AssetProxy";
import { SIMPLE, EDITORIAL_WORKFLOW, status } from "../../constants/publishModes";
Expand Down Expand Up @@ -258,6 +259,7 @@ export default class API {
collection: options.collectionName,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
dependencies: options.dependencies,
objects: {
entry: {
path: entry.path,
Expand Down Expand Up @@ -288,6 +290,7 @@ export default class API {
pr: updatedPR,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
dependencies: options.dependencies,
objects: {
entry: {
path: entry.path,
Expand Down Expand Up @@ -330,6 +333,61 @@ export default class API {
.then(() => this.deleteBranch(`cms/${ contentKey }`));
}

publishUnpublishedEntries(entries) {
const entriesMetadataPromise = Promise.all(entries.map(([collection, slug]) => {
const contentKey = slug;
return this.retrieveMetadata(contentKey);
}));

const entryObjectsPromise = entriesMetadataPromise
.then(metadata =>
metadata.map(m => [
{
...m.objects.entry,
mode: "100644",
type: "blob",
},
...(m.objects.files ? m.objects.files.map(file => ({
sha: file.sha,
path: (file.path[0] === "/" ? file.path.slice(1) : file.path),
mode: "100644",
type: "blob",
})) : []),
]))
// Flatten list of objects
.then(objectCollections => [].concat.apply([], objectCollections));

const entryPrsPromise = entriesMetadataPromise
.then(metadata => metadata.map(m => m.pr.head));

const baseShaPromise = this.getBranch("master")
.then(({ commit }) => commit.sha);

const treePromise = Promise.all([entryObjectsPromise, baseShaPromise])
.then(([entryObjects, baseSha]) => this.createTree(baseSha, entryObjects));

const commitPromise = Promise.all([baseShaPromise, treePromise, entryPrsPromise])
.then(([baseSha, tree, entryPrs]) =>
this.commit("Publish posts", { sha: tree.sha }, entryPrs.concat([baseSha])));

const pushCommitPromise = commitPromise
.then(commit => this.patchRef("heads", "master", commit.sha));

const deletePrBranchesPromise = pushCommitPromise
.then(pushCommit => Promise.all(entries.map(entry => this.deleteBranch(`cms/${ entry[1] }`))));

return deletePrBranchesPromise;
}

createTree(baseSha, treeObjects) {
return this.request(`${ this.repoURL }/git/trees`, {
method: "POST",
body: JSON.stringify({
base_tree: baseSha,
tree: treeObjects,
}),
});
}

createRef(type, name, sha) {
return this.request(`${ this.repoURL }/git/refs`, {
Expand Down Expand Up @@ -484,9 +542,11 @@ export default class API {
});
}

commit(message, changeTree) {
commit(message, changeTree, parentArray=[]) {
const tree = changeTree.sha;
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
const parentSha = changeTree.parentSha ? [changeTree.parentSha] : [];
const parents = (parentSha !== [] && parentArray.indexOf(parentSha[0] !== 0))
? parentArray.concat(parentSha) : parentArray;
return this.request(`${ this.repoURL }/git/commits`, {
method: "POST",
body: JSON.stringify({ message, tree, parents }),
Expand Down
Loading