Merge branch 'react-pr' into dashboard-link

This commit is contained in:
Andrey Okonetchnikov 2016-09-15 10:57:01 +02:00
commit 6d0535dc26
37 changed files with 669 additions and 153 deletions

View File

@ -9,8 +9,18 @@
"test:watch": "npm test -- --watch", "test:watch": "npm test -- --watch",
"build": "webpack --config webpack.config.js", "build": "webpack --config webpack.config.js",
"storybook": "start-storybook -p 9001", "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",
"lint:staged": "lint-staged"
}, },
"lint-staged": {
"*.@(js|jsx)": [
"eslint --fix",
"git add"
]
},
"pre-commit": "lint:staged",
"keywords": [ "keywords": [
"netlify", "netlify",
"cms" "cms"
@ -21,7 +31,7 @@
"@kadira/storybook": "^1.36.0", "@kadira/storybook": "^1.36.0",
"autoprefixer": "^6.3.3", "autoprefixer": "^6.3.3",
"babel-core": "^6.5.1", "babel-core": "^6.5.1",
"babel-eslint": "^4.1.8", "babel-eslint": "^6.1.2",
"babel-loader": "^6.2.2", "babel-loader": "^6.2.2",
"babel-plugin-lodash": "^3.2.0", "babel-plugin-lodash": "^3.2.0",
"babel-plugin-transform-class-properties": "^6.5.2", "babel-plugin-transform-class-properties": "^6.5.2",
@ -32,20 +42,21 @@
"babel-register": "^6.5.2", "babel-register": "^6.5.2",
"babel-runtime": "^6.5.0", "babel-runtime": "^6.5.0",
"css-loader": "^0.23.1", "css-loader": "^0.23.1",
"eslint": "^1.10.3", "eslint": "^3.5.0",
"eslint-loader": "^1.2.1",
"eslint-plugin-react": "^5.1.1", "eslint-plugin-react": "^5.1.1",
"exports-loader": "^0.6.3", "exports-loader": "^0.6.3",
"file-loader": "^0.8.5", "file-loader": "^0.8.5",
"immutable": "^3.7.6", "immutable": "^3.7.6",
"imports-loader": "^0.6.5", "imports-loader": "^0.6.5",
"js-yaml": "^3.5.3", "js-yaml": "^3.5.3",
"lint-staged": "^3.0.2",
"mocha": "^2.4.5", "mocha": "^2.4.5",
"moment": "^2.11.2", "moment": "^2.11.2",
"normalizr": "^2.0.0", "normalizr": "^2.0.0",
"postcss-cssnext": "^2.7.0", "postcss-cssnext": "^2.7.0",
"postcss-import": "^8.1.2", "postcss-import": "^8.1.2",
"postcss-loader": "^0.9.1", "postcss-loader": "^0.9.1",
"pre-commit": "^1.1.3",
"react": "^15.1.0", "react": "^15.1.0",
"react-dom": "^15.1.0", "react-dom": "^15.1.0",
"react-hot-loader": "^3.0.0-beta.2", "react-hot-loader": "^3.0.0-beta.2",
@ -75,8 +86,10 @@
"markup-it": "git+https://github.com/cassiozen/markup-it.git", "markup-it": "git+https://github.com/cassiozen/markup-it.git",
"pluralize": "^3.0.0", "pluralize": "^3.0.0",
"prismjs": "^1.5.1", "prismjs": "^1.5.1",
"react-datetime": "^2.6.0",
"react-addons-css-transition-group": "^15.3.1", "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", "react-portal": "^2.2.1",
"selection-position": "^1.0.0", "selection-position": "^1.0.0",
"semaphore": "^1.0.5", "semaphore": "^1.0.5",

View File

@ -1,4 +1,5 @@
import { currentBackend } from '../backends/backend'; import { currentBackend } from '../backends/backend';
import { getMedia } from '../reducers';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
/* /*
* Contant Declarations * Contant Declarations
@ -10,6 +11,14 @@ export const UNPUBLISHED_ENTRIES_REQUEST = 'UNPUBLISHED_ENTRIES_REQUEST';
export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS'; export const UNPUBLISHED_ENTRIES_SUCCESS = 'UNPUBLISHED_ENTRIES_SUCCESS';
export const UNPUBLISHED_ENTRIES_FAILURE = 'UNPUBLISHED_ENTRIES_FAILURE'; 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';
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)
@ -53,6 +62,56 @@ function unpublishedEntriesFailed(error) {
}; };
} }
function unpublishedEntryPersisting(entry) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
payload: { entry }
};
}
function unpublishedEntryPersisted(entry) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
payload: { entry }
};
}
function unpublishedEntryPersistedFail(error) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
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 }
};
}
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
*/ */
@ -79,3 +138,42 @@ export function loadUnpublishedEntries() {
); );
}; };
} }
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(entry));
backend.persistUnpublishedEntry(state.config, collection, entry, MediaProxies.toJS()).then(
() => {
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));
});
};
}
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

@ -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 newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
const parsedData = { const parsedData = {
@ -139,10 +139,23 @@ class Backend {
const collectionName = collection.get('name'); const collectionName = collection.get('name');
return this.implementation.persistEntry(entryObj, MediaFiles, { return this.implementation.persistEntry(entryObj, MediaFiles, {
newEntry, parsedData, commitMessage, collectionName, mode newEntry, parsedData, commitMessage, collectionName, mode, ...options
}); });
} }
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);
}
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);
return format && format.toFile(entry); return format && format.toFile(entry);

View File

@ -1,13 +1,12 @@
import LocalForage from 'localforage'; import LocalForage from 'localforage';
import MediaProxy from '../../valueObjects/MediaProxy'; import MediaProxy from '../../valueObjects/MediaProxy';
import { Base64 } from 'js-base64'; 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'; const API_ROOT = 'https://api.github.com';
export default class API { export default class API {
constructor(token, repo, branch) { constructor(token, repo, branch) {
this.token = token; this.token = token;
this.repo = repo; this.repo = repo;
@ -54,7 +53,8 @@ export default class API {
const headers = this.requestHeaders(options.headers || {}); const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options); const url = this.urlFor(path, options);
return fetch(url, { ...options, headers: headers }).then((response) => { 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); return this.parseJsonResponse(response);
} }
@ -98,17 +98,28 @@ export default class API {
return this.uploadBlob(fileTree[`${key}.json`]) return this.uploadBlob(fileTree[`${key}.json`])
.then(item => this.updateTree(branchData.sha, '/', fileTree)) .then(item => this.updateTree(branchData.sha, '/', fileTree))
.then(changeTree => this.commit(`Updating “${key}” metadata`, changeTree)) .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) { retrieveMetadata(key) {
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`, { return this.request(`${this.repoURL}/contents/${key}.json`, {
params: { ref: 'refs/meta/_netlify_cms' }, params: { ref: 'refs/meta/_netlify_cms' },
headers: { Accept: 'application/vnd.github.VERSION.raw' }, headers: { Accept: 'application/vnd.github.VERSION.raw' },
cache: 'no-store', cache: 'no-store',
}) })
.then(response => JSON.parse(response)); .then(response => JSON.parse(response));
});
} }
readFile(path, sha, branch = this.branch) { readFile(path, sha, branch = this.branch) {
@ -136,10 +147,6 @@ export default class API {
} }
readUnpublishedBranchFile(contentKey) { readUnpublishedBranchFile(contentKey) {
const cache = LocalForage.getItem(`gh.unpublished.${contentKey}`);
return cache.then((cached) => {
if (cached && cached.expires > Date.now()) { return cached.data; }
let metaData; let metaData;
return this.retrieveMetadata(contentKey) return this.retrieveMetadata(contentKey)
.then(data => { .then(data => {
@ -148,14 +155,6 @@ export default class API {
}) })
.then(file => { .then(file => {
return { metaData, file }; return { metaData, file };
})
.then((result) => {
LocalForage.setItem(`gh.unpublished.${contentKey}`, {
expires: Date.now() + 300000, // In 5 minutes
data: result,
});
return result;
});
}); });
} }
@ -183,19 +182,44 @@ export default class API {
subtree[filename] = file; subtree[filename] = file;
file.file = true; file.file = true;
}); });
return Promise.all(uploadPromises) return Promise.all(uploadPromises).then(() => {
.then(() => this.getBranch()) if (!options.mode || (options.mode && options.mode === SIMPLE)) {
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(response => this.patchBranch(this.branch, response.sha));
if (options.mode && options.mode === EDITORIAL_WORKFLOW) { } 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 contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug;
const branchName = `cms/${contentKey}`; 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
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(commitResponse => this.createBranch(branchName, commitResponse.sha))
.then(branchResponse => this.createPR(options.commitMessage, branchName))
.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,
@ -204,16 +228,65 @@ export default class API {
description: options.parsedData && options.parsedData.description, description: options.parsedData && options.parsedData.description,
objects: { objects: {
entry: entry.path, entry: entry.path,
files: mediaFiles.map(file => file.path) files: filesList
}, },
timeStamp: new Date().toISOString() 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);
}
}); });
} 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,
title: options.parsedData && options.parsedData.title,
description: options.parsedData && options.parsedData.description,
objects: {
entry: entry.path,
files: _.uniq(files)
},
timeStamp: new Date().toISOString()
};
})
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata))
.then(this.patchBranch(branchName, response.sha));
});
}
}
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));
}
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);
})
.then(() => this.deleteBranch(`cms/${contentKey}`));
} }
createRef(type, name, sha) { createRef(type, name, sha) {
@ -223,10 +296,6 @@ export default class API {
}); });
} }
createBranch(branchName, sha) {
return this.createRef('heads', branchName, sha);
}
patchRef(type, name, sha) { patchRef(type, name, sha) {
return this.request(`${this.repoURL}/git/refs/${type}/${name}`, { return this.request(`${this.repoURL}/git/refs/${type}/${name}`, {
method: 'PATCH', method: 'PATCH',
@ -234,12 +303,26 @@ export default class API {
}); });
} }
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) { patchBranch(branchName, sha) {
return this.patchRef('heads', branchName, sha); return this.patchRef('heads', branchName, sha);
} }
getBranch() { deleteBranch(branchName) {
return this.request(`${this.repoURL}/branches/${this.branch}`); return this.deleteRef('heads', branchName);
} }
createPR(title, head, base = 'master') { createPR(title, head, base = 'master') {
@ -250,6 +333,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

@ -21,9 +21,9 @@ export default class AuthenticationPage extends React.Component {
auth = new Authenticator(); auth = new Authenticator();
} }
auth.authenticate({provider: 'github', scope: 'repo'}, (err, data) => { auth.authenticate({ provider: 'github', scope: 'repo' }, (err, data) => {
if (err) { if (err) {
this.setState({loginError: err.toString()}); this.setState({ loginError: err.toString() });
return; return;
} }
this.props.onLogin(data); this.props.onLogin(data);

View File

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

View File

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import Authenticator from '../../lib/netlify-auth';
export default class AuthenticationPage extends React.Component { export default class AuthenticationPage extends React.Component {
static propTypes = { static propTypes = {
@ -14,8 +13,8 @@ export default class AuthenticationPage extends React.Component {
handleLogin(e) { handleLogin(e) {
e.preventDefault(); e.preventDefault();
const {email, password} = this.state; const { email, password } = this.state;
this.setState({authenticating: true}); this.setState({ authenticating: true });
fetch(`${AuthenticationPage.url}/token`, { fetch(`${AuthenticationPage.url}/token`, {
method: 'POST', method: 'POST',
body: 'grant_type=client_credentials', body: 'grant_type=client_credentials',
@ -27,18 +26,18 @@ export default class AuthenticationPage extends React.Component {
console.log(response); console.log(response);
if (response.ok) { if (response.ok) {
return response.json().then((data) => { return response.json().then((data) => {
this.props.onLogin(Object.assign({email}, data)); this.props.onLogin(Object.assign({ email }, data));
}); });
} }
response.json().then((data) => { response.json().then((data) => {
this.setState({loginError: data.msg}); this.setState({ loginError: data.msg });
}) });
}) });
} }
handleChange(key) { handleChange(key) {
return (e) => { return (e) => {
this.setState({[key]: e.target.value}); this.setState({ [key]: e.target.value });
}; };
} }

View File

@ -7,7 +7,7 @@ export default class AuthenticationPage extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {email: ''}; this.state = { email: '' };
this.handleLogin = this.handleLogin.bind(this); this.handleLogin = this.handleLogin.bind(this);
this.handleEmailChange = this.handleEmailChange.bind(this); this.handleEmailChange = this.handleEmailChange.bind(this);
} }
@ -18,7 +18,7 @@ export default class AuthenticationPage extends React.Component {
} }
handleEmailChange(e) { handleEmailChange(e) {
this.setState({email: e.target.value}); this.setState({ email: e.target.value });
} }
render() { render() {

View File

@ -12,6 +12,7 @@
height: 45px; height: 45px;
border-top: 1px solid #e8eae8; border-top: 1px solid #e8eae8;
padding: 10px 20px; padding: 10px 20px;
z-index: 10;
} }
.controlPane { .controlPane {
width: 50%; width: 50%;

View File

@ -27,14 +27,14 @@ export default class EntryEditor extends React.Component {
calculateHeight() { calculateHeight() {
const height = window.innerHeight - 54; const height = window.innerHeight - 54;
console.log('setting height to %s', height); console.log('setting height to %s', height);
this.setState({height}); this.setState({ height });
} }
render() { render() {
const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props; const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props;
const {height} = this.state; const { height } = this.state;
return <div className={styles.entryEditor} style={{height}}> return <div className={styles.entryEditor} style={{ height }}>
<div className={styles.container}> <div className={styles.container}>
<div className={styles.controlPane}> <div className={styles.controlPane}>
<ControlPane <ControlPane

View File

@ -1,6 +1,6 @@
.frame { .frame {
width: 100%; width: 100%;
height: 100%; height: 100vh;
border: none; border: none;
background: #fff; background: #fff;
} }

View File

@ -55,7 +55,7 @@ export default class PreviewPane extends React.Component {
} }
renderPreview() { renderPreview() {
const props = Object.assign({}, this.props, {widgetFor: this.widgetFor}); const props = Object.assign({}, this.props, { widgetFor: this.widgetFor });
const component = registry.getPreviewTemplate(props.collection.get('name')) || Preview; const component = registry.getPreviewTemplate(props.collection.get('name')) || Preview;
render(React.createElement(component, props), this.previewEl); render(React.createElement(component, props), this.previewEl);

View File

@ -1,8 +1,7 @@
@import "../theme.css";
.card { .card {
composes: base from "../theme.css"; composes: base container rounded depth;
composes: container from "../theme.css";
composes: rounded from "../theme.css";
composes: depth from "../theme.css";
overflow: hidden; overflow: hidden;
width: 240px; width: 240px;
} }

View File

@ -6,7 +6,7 @@ const availableIcons = [
'bold', 'italic', 'list', 'font', 'text-height', 'text-width', 'align-left', 'align-center', 'align-right', 'bold', 'italic', 'list', 'font', 'text-height', 'text-width', 'align-left', 'align-center', 'align-right',
'align-justify', 'indent-left', 'indent-right', 'list-bullet', 'list-numbered', 'strike', 'underline', 'table', 'align-justify', 'indent-left', 'indent-right', 'list-bullet', 'list-numbered', 'strike', 'underline', 'table',
'superscript', 'subscript', 'header', 'h1', 'h2', 'paragraph', 'link', 'unlink', 'quote-left', 'quote-right', 'code', 'superscript', 'subscript', 'header', 'h1', 'h2', 'paragraph', 'link', 'unlink', 'quote-left', 'quote-right', 'code',
'picture','video', 'picture', 'video',
// Entypo // Entypo
'note', 'note-beamed', 'note', 'note-beamed',
'music', 'music',
@ -199,7 +199,7 @@ const iconPropType = (props, propName) => {
const noop = function() {}; const noop = function() {};
export default function Icon({ style, className = '', type, onClick = noop}) { export default function Icon({ style, className = '', type, onClick = noop }) {
return <span className={`${styles.root} ${styles[type]} ${className}`} style={style} onClick={onClick} />; return <span className={`${styles.root} ${styles[type]} ${className}`} style={style} onClick={onClick} />;
} }

View File

@ -1,3 +1,4 @@
export { default as Card } from './card/Card'; export { default as Card } from './card/Card';
export { default as Loader } from './loader/Loader'; export { default as Loader } from './loader/Loader';
export { default as Icon } from './icon/Icon'; export { default as Icon } from './icon/Icon';
export { default as Toast } from './toast/Toast';

View File

@ -1,5 +1,6 @@
:root { :root {
--defaultColor: #333; --defaultColor: #333;
--defaultColorLight: #eee;
--backgroundColor: #fff; --backgroundColor: #fff;
--shadowColor: rgba(0, 0, 0, 0.117647); --shadowColor: rgba(0, 0, 0, 0.117647);
--successColor: #1c7; --successColor: #1c7;

View File

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

View File

@ -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 = <Icon type={icons[type]} className={styles.icon} />;
}
if (!this.state.shown) {
classes.push(styles.hidden);
}
return (
<div className={classes.join(' ')} style={style}>{icon}{children}</div>
);
}
}
Toast.propTypes = {
style: PropTypes.object,
type: PropTypes.oneOf(['success', 'warning', 'error']).isRequired,
className: PropTypes.string,
show: PropTypes.bool,
children: PropTypes.node
};

View File

@ -1,23 +1,32 @@
.container {
display: table;
width: 100%;
}
.column { .column {
position: relative; display: table-cell;
display: inline-block;
vertical-align: top;
text-align: center; text-align: center;
width: 28%; width: 33%;
height: 100%;
transition: background-color .5s ease;
& h2 { & h2 {
font-size: 16px; font-size: 16px;
} }
} }
.highlighted {
background-color: #e1eeea;
}
.column:not(:last-child) { .column:not(:last-child) {
margin-right: 8%; padding-right: 20px;
} }
.card { .card {
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;
@ -29,4 +38,16 @@
font-size: 12px; font-size: 12px;
margin-top: 5px; margin-top: 5px;
} }
& button {
margin: 10px 10px 0 0;
float: right;
}
}
.clear::after {
content:"";
display:block;
clear:both;
} }

View File

@ -1,21 +1,104 @@
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 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';
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().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({ slug, collection, ownStatus, onRequestPublish, connectDragSource, children }) {
return connectDragSource(
<div>
<Card className={styles.card}>
{children}
{(ownStatus === status.last()) &&
<button onClick={onRequestPublish}>Publish now</button>
}
</Card>
</div>
);
}
const cardDragSpec = {
beginDrag(props) {
return {
slug: props.slug,
collection: props.collection,
ownStatus: props.ownStatus
};
}
};
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);
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) {
if (!entries) return; if (!entries) return;
if (!column) { if (!column) {
return entries.entrySeq().map(([currColumn, currEntries]) => ( return entries.entrySeq().map(([currColumn, currEntries]) => (
<div key={currColumn} className={styles.column}> <Column
<h2>{statusDescriptions.get(currColumn)}</h2> key={currColumn}
status={currColumn}
onChangeStatus={this.props.handleChangeStatus}
>
{this.renderColumns(currEntries, currColumn)} {this.renderColumns(currEntries, currColumn)}
</div> </Column>
)); ));
} else { } else {
return <div> return <div>
@ -24,11 +107,20 @@ export default 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 (
<Card key={entry.get('slug')} className={styles.card}> <EntryCard
<h1><Link to={link}>{entry.getIn(['data', 'title'])}</Link> <small>by {author}</small></h1> key={slug}
slug={slug}
ownStatus={status}
collection={collection}
onRequestPublish={this.requestPublish.bind(this, collection, slug, status)} // eslint-disable-line
>
<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>
</Card> </EntryCard>
); );
} }
)} )}
@ -38,16 +130,21 @@ export default class UnpublishedListing extends React.Component {
render() { render() {
const columns = this.renderColumns(this.props.entries); const columns = this.renderColumns(this.props.entries);
return ( return (
<div> <div className={styles.clear}>
<h1>Editorial Workflow</h1> <h1>Editorial Workflow</h1>
<div className={styles.container}>
{columns} {columns}
</div> </div>
</div>
); );
} }
} }
UnpublishedListing.propTypes = { UnpublishedListing.propTypes = {
entries: ImmutablePropTypes.orderedMap, entries: ImmutablePropTypes.orderedMap,
handleChangeStatus: PropTypes.func.isRequired,
handlePublish: PropTypes.func.isRequired,
}; };
export default DragDropContext(HTML5Backend)(UnpublishedListing);

View File

@ -60,7 +60,7 @@ class MarkdownControl extends React.Component {
return ( return (
<div> <div>
{ this.renderEditor() } {this.renderEditor()}
</div> </div>
); );
} }

View File

@ -62,4 +62,4 @@ export const SCHEMA = {
borderRadius: '4px' borderRadius: '4px'
} }
} }
} };

View File

@ -46,7 +46,7 @@ function processEditorPlugins(plugins) {
<div {...props.attributes} className={className}> <div {...props.attributes} className={className}>
<div className="plugin_icon" contentEditable={false}><Icon type={plugin.icon}/></div> <div className="plugin_icon" contentEditable={false}><Icon type={plugin.icon}/></div>
<div className="plugin_fields" contentEditable={false}> <div className="plugin_fields" contentEditable={false}>
{ plugin.fields.map(field => `${field.label}: “${node.data.get(field.name)}`) } {plugin.fields.map(field => `${field.label}: “${node.data.get(field.name)}`)}
</div> </div>
</div> </div>
); );

View File

@ -39,4 +39,4 @@ storiesOf('Card', module)
<p>header and footer elements are also not subject to margin</p> <p>header and footer elements are also not subject to margin</p>
<footer style={styles.footer}>&copy; Thousand Cats Corp</footer> <footer style={styles.footer}>&copy; Thousand Cats Corp</footer>
</Card> </Card>
)) ));

View File

@ -0,0 +1,19 @@
import React from 'react';
import { Toast } from '../UI';
import { storiesOf } from '@kadira/storybook';
storiesOf('Toast', module)
.add('Success', () => (
<div>
<Toast type='success' show>A Toast Message</Toast>
</div>
)).add('Waring', () => (
<div>
<Toast type='warning' show>A Toast Message</Toast>
</div>
)).add('Error', () => (
<div>
<Toast type='error' show>A Toast Message</Toast>
</div>
));

View File

@ -1,2 +1,3 @@
import './Card'; import './Card';
import './Icon'; import './Icon';
import './Toast';

View File

@ -4,6 +4,7 @@ import { IndexLink } from 'react-router';
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';
@ -27,7 +28,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

@ -13,7 +13,12 @@ class FindBar extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this._compiledCommands = []; 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 = { this.state = {
value: '', value: '',
placeholder: PLACEHOLDER, placeholder: PLACEHOLDER,
@ -68,7 +73,7 @@ class FindBar extends Component {
if (match && match[1]) { if (match && match[1]) {
regexp += '(.*)'; 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, { return Object.assign({}, command, {
@ -144,6 +149,7 @@ class FindBar extends Component {
getSuggestions() { getSuggestions() {
return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands); return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands);
} }
// Memoized version // Memoized version
_getSuggestions(value, scope, commands, defaultCommands) { _getSuggestions(value, scope, commands, defaultCommands) {
if (scope) return []; // No autocomplete for scoped input if (scope) return []; // No autocomplete for scoped input
@ -162,7 +168,7 @@ class FindBar extends Component {
}); });
const returnResults = results.slice(0, 4).map(result => ( 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); returnResults.push(this._searchCommand);
@ -290,7 +296,7 @@ class FindBar extends Component {
let children; let children;
if (!command.search) { if (!command.search) {
children = ( children = (
<span><span dangerouslySetInnerHTML={{__html: command.string}} /></span> <span><span dangerouslySetInnerHTML={{ __html: command.string }}/></span>
); );
} else { } else {
children = ( children = (
@ -299,7 +305,8 @@ class FindBar extends Component {
<span><Icon type="search"/>Search... </span> : <span><Icon type="search"/>Search... </span> :
<span className={styles.faded}><Icon type="search"/>Search for: </span> <span className={styles.faded}><Icon type="search"/>Search for: </span>
} }
<strong>{this.state.value}</strong></span> <strong>{this.state.value}</strong>
</span>
); );
} }
return ( return (
@ -317,7 +324,7 @@ class FindBar extends Component {
return commands.length === 0 ? null : ( return commands.length === 0 ? null : (
<div className={styles.menu}> <div className={styles.menu}>
<div className={styles.suggestions}> <div className={styles.suggestions}>
{ commands } {commands}
</div> </div>
<div className={styles.history}> <div className={styles.history}>
Your past searches and commands Your past searches and commands
@ -328,7 +335,7 @@ class FindBar extends Component {
renderActiveScope() { renderActiveScope() {
if (this.state.activeScope === SEARCH) { if (this.state.activeScope === SEARCH) {
return <div className={styles.inputScope}><Icon type="search"/> </div>; return <div className={styles.inputScope}><Icon type="search"/></div>;
} else { } else {
return <div className={styles.inputScope}>{this.state.activeScope}</div>; return <div className={styles.inputScope}>{this.state.activeScope}</div>;
} }
@ -358,6 +365,7 @@ class FindBar extends Component {
); );
} }
} }
FindBar.propTypes = { FindBar.propTypes = {
commands: PropTypes.arrayOf(PropTypes.shape({ commands: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,

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 } 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';
@ -20,12 +20,16 @@ export default function CollectionPageHOC(CollectionPage) {
} }
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}/> <UnpublishedListing
entries={unpublishedEntries}
handleChangeStatus={updateUnpublishedEntryStatus}
handlePublish={publishUnpublishedEntry}
/>
{super.render()} {super.render()}
</div> </div>
); );
@ -56,5 +60,8 @@ export default function CollectionPageHOC(CollectionPage) {
return returnObj; return returnObj;
} }
return connect(mapStateToProps)(CollectionPageHOC); return connect(mapStateToProps, {
updateUnpublishedEntryStatus,
publishUnpublishedEntry
})(CollectionPageHOC);
} }

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { EDITORIAL_WORKFLOW } from '../../constants/publishModes'; import { EDITORIAL_WORKFLOW } from '../../constants/publishModes';
import { selectUnpublishedEntry } from '../../reducers'; import { selectUnpublishedEntry } from '../../reducers';
import { loadUnpublishedEntry } from '../../actions/editorialWorkflow'; import { loadUnpublishedEntry, persistUnpublishedEntry } from '../../actions/editorialWorkflow';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
export default function EntryPageHOC(EntryPage) { export default function EntryPageHOC(EntryPage) {
@ -22,10 +22,6 @@ export default function EntryPageHOC(EntryPage) {
const slug = ownProps.params.slug; const slug = ownProps.params.slug;
const entry = selectUnpublishedEntry(state, status, slug); const entry = selectUnpublishedEntry(state, status, slug);
returnObj.entry = entry; returnObj.entry = entry;
returnObj.persistEntry = () => {
// TODO - for now, simply ignore
};
} }
return returnObj; return returnObj;
} }
@ -39,6 +35,10 @@ export default function EntryPageHOC(EntryPage) {
returnObj.loadEntry = (collection, slug) => { returnObj.loadEntry = (collection, slug) => {
dispatch(loadUnpublishedEntry(collection, status, slug)); dispatch(loadUnpublishedEntry(collection, status, slug));
}; };
returnObj.persistEntry = (collection, entryDraft) => {
dispatch(persistUnpublishedEntry(collection, entryDraft));
};
} }
return returnObj; return returnObj;
} }

View File

@ -41,6 +41,6 @@ export default class YAML {
} }
toFile(data) { toFile(data) {
return yaml.safeDump(data, {schema: OutputSchema}); return yaml.safeDump(data, { schema: OutputSchema });
} }
} }

View File

@ -1,5 +1,5 @@
import {List} from 'immutable'; import { List } from 'immutable';
import {newEditorPlugin} from '../components/Widgets/MarkdownControlElements/plugins'; import { newEditorPlugin } from '../components/Widgets/MarkdownControlElements/plugins';
const _registry = { const _registry = {
templates: {}, templates: {},

View File

@ -1,7 +1,12 @@
import { Map, List, fromJS } from 'immutable'; import { Map, List, fromJS } from 'immutable';
import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; import { EDITORIAL_WORKFLOW } from '../constants/publishModes';
import { 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,
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS
} from '../actions/editorialWorkflow'; } from '../actions/editorialWorkflow';
import { CONFIG_SUCCESS } from '../actions/config'; import { CONFIG_SUCCESS } from '../actions/config';
@ -39,6 +44,23 @@ const unpublishedEntries = (state = null, action) => {
ids: List(entries.map((entry) => entry.slug)) ids: List(entries.map((entry) => entry.slug))
})); }));
}); });
case UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS:
return state.withMutations((map) => {
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 !== `${action.payload.oldStatus}.${action.payload.slug}`
));
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: default:
return state; return state;
} }

View File

@ -16,15 +16,15 @@ describe('auth', () => {
expect( expect(
auth(undefined, authenticating()) auth(undefined, authenticating())
).toEqual( ).toEqual(
Immutable.Map({isFetching: true}) Immutable.Map({ isFetching: true })
); );
}); });
it('should handle authentication', () => { it('should handle authentication', () => {
expect( expect(
auth(undefined, authenticate({email: 'joe@example.com'})) auth(undefined, authenticate({ email: 'joe@example.com' }))
).toEqual( ).toEqual(
Immutable.fromJS({user: {email: 'joe@example.com'}}) Immutable.fromJS({ user: { email: 'joe@example.com' } })
); );
}); });

View File

@ -15,39 +15,39 @@ describe('collections', () => {
it('should load the collections from the config', () => { it('should load the collections from the config', () => {
expect( expect(
collections(undefined, configLoaded({collections: [ collections(undefined, configLoaded({ collections: [
{name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]} { name: 'posts', folder: '_posts', fields: [{ name: 'title', widget: 'string' }] }
]})) ] }))
).toEqual( ).toEqual(
OrderedMap({ 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', () => { it('should mark entries as loading', () => {
const state = OrderedMap({ const state = OrderedMap({
'posts': Map({name: 'posts'}) 'posts': Map({ name: 'posts' })
}); });
expect( expect(
collections(state, entriesLoading(Map({name: 'posts'}))) collections(state, entriesLoading(Map({ name: 'posts' })))
).toEqual( ).toEqual(
OrderedMap({ OrderedMap({
'posts': Map({name: 'posts', isFetching: true}) 'posts': Map({ name: 'posts', isFetching: true })
}) })
); );
}); });
it('should handle loaded entries', () => { it('should handle loaded entries', () => {
const state = OrderedMap({ 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( expect(
collections(state, entriesLoaded(Map({name: 'posts'}), entries)) collections(state, entriesLoaded(Map({ name: 'posts' }), entries))
).toEqual( ).toEqual(
OrderedMap({ OrderedMap({
'posts': fromJS({name: 'posts', entries: entries}) 'posts': fromJS({ name: 'posts', entries: entries })
}) })
); );
}); });

View File

@ -14,9 +14,9 @@ describe('config', () => {
it('should handle an update', () => { it('should handle an update', () => {
expect( 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( ).toEqual(
Immutable.Map({'a': 'changed', 'e': 'new'}) Immutable.Map({ 'a': 'changed', 'e': 'new' })
); );
}); });
@ -24,15 +24,15 @@ describe('config', () => {
expect( expect(
config(undefined, configLoading()) config(undefined, configLoading())
).toEqual( ).toEqual(
Immutable.Map({isFetching: true}) Immutable.Map({ isFetching: true })
); );
}); });
it('should handle an error', () => { it('should handle an error', () => {
expect( 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( ).toEqual(
Immutable.Map({error: 'Error: Config could not be loaded'}) Immutable.Map({ error: 'Error: Config could not be loaded' })
); );
}); });
}); });