Merge branch 'master' into jest-update

This commit is contained in:
Andrey Okonetchnikov 2016-10-13 13:40:15 +02:00
commit d630463dd5
32 changed files with 698 additions and 277 deletions

View File

@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"start": "webpack-dev-server --config webpack.dev.js",
"test": "NODE_ENV=test npm run lint && jest",
"test": "NODE_ENV=test jest",
"test:watch": "NODE_ENV=test jest --watch",
"build": "webpack --config webpack.config.js",
"storybook": "start-storybook -p 9001",
@ -40,7 +40,6 @@
"license": "MIT",
"devDependencies": {
"@kadira/storybook": "^1.36.0",
"autoprefixer": "^6.3.3",
"babel-core": "^6.5.1",
"babel-loader": "^6.2.2",
"babel-plugin-lodash": "^3.2.0",
@ -56,30 +55,15 @@
"expect": "^1.20.2",
"exports-loader": "^0.6.3",
"file-loader": "^0.8.5",
"immutable": "^3.7.6",
"imports-loader": "^0.6.5",
"jest-cli": "^16.0.1",
"js-yaml": "^3.5.3",
"lint-staged": "^4.0.0-beta-2",
"moment": "^2.11.2",
"lint-staged": "^3.0.2",
"node-sass": "^3.10.0",
"normalizr": "^2.0.0",
"npm-check": "^5.2.3",
"postcss-cssnext": "^2.7.0",
"postcss-import": "^8.1.2",
"postcss-loader": "^0.9.1",
"pre-commit": "^1.1.3",
"react": "^15.1.0",
"react-dom": "^15.1.0",
"react-hot-loader": "^3.0.0-beta.2",
"react-immutable-proptypes": "^1.6.0",
"react-lazy-load": "^3.0.3",
"react-pure-render": "^1.0.2",
"react-redux": "^4.4.0",
"react-router": "^2.5.1",
"react-router-redux": "^4.0.5",
"redux": "^3.3.1",
"redux-thunk": "^1.0.3",
"sass-loader": "^4.0.2",
"style-loader": "^0.13.0",
"stylefmt": "^4.3.1",
@ -92,30 +76,46 @@
"webpack": "^1.13.2",
"webpack-dev-server": "^1.15.1",
"webpack-merge": "^0.14.1",
"webpack-postcss-tools": "^1.1.1",
"whatwg-fetch": "^1.0.0"
"webpack-postcss-tools": "^1.1.1"
},
"dependencies": {
"autoprefixer": "^6.3.3",
"bricks.js": "^1.7.0",
"dateformat": "^1.0.12",
"fuzzy": "^0.1.1",
"immutable": "^3.7.6",
"immutability-helper": "^2.0.0",
"js-base64": "^2.1.9",
"js-yaml": "^3.5.3",
"json-loader": "^0.5.4",
"localforage": "^1.4.2",
"lodash": "^4.13.1",
"markup-it": "git+https://github.com/cassiozen/markup-it.git",
"material-design-icons": "^3.0.1",
"moment": "^2.11.2",
"normalize.css": "^4.2.0",
"pluralize": "^3.0.0",
"prismjs": "^1.5.1",
"react": "^15.1.0",
"react-dom": "^15.1.0",
"react-hot-loader": "^3.0.0-beta.2",
"react-addons-css-transition-group": "^15.3.1",
"react-datetime": "^2.6.0",
"react-portal": "^2.2.1",
"react-toolbox": "^1.2.1",
"react-simple-dnd": "^0.1.2",
"react-toolbox": "^1.2.1",
"react-waypoint": "^3.1.3",
"react-immutable-proptypes": "^1.6.0",
"react-lazy-load": "^3.0.3",
"react-pure-render": "^1.0.2",
"react-redux": "^4.4.0",
"react-router": "^2.5.1",
"react-router-redux": "^4.0.5",
"redux": "^3.3.1",
"redux-thunk": "^1.0.3",
"selection-position": "^1.0.0",
"semaphore": "^1.0.5",
"slate": "^0.13.6"
"slate": "^0.13.6",
"whatwg-fetch": "^1.0.0"
}
}

View File

@ -1,8 +1,6 @@
import yaml from 'js-yaml';
import _ from 'lodash';
import { currentBackend } from '../backends/backend';
import { authenticate } from '../actions/auth';
import * as publishModes from '../constants/publishModes';
import * as MediaProxy from '../valueObjects/MediaProxy';
export const CONFIG_REQUEST = 'CONFIG_REQUEST';
@ -72,19 +70,5 @@ function parseConfig(data) {
}
}
if (!('publish_mode' in config) || _.values(publishModes).indexOf(config.publish_mode) === -1) {
// Make sure there is a publish workflow mode set
config.publish_mode = publishModes.SIMPLE;
}
if (!('public_folder' in config)) {
// Make sure there is a public folder
config.public_folder = config.media_folder;
}
if (config.public_folder.charAt(0) !== '/') {
config.public_folder = '/' + config.public_folder;
}
return config;
}

View File

@ -1,5 +1,6 @@
import { currentBackend } from '../backends/backend';
import { getMedia } from '../reducers';
import { getIntegrationProvider } from '../integrations';
import { getMedia, selectIntegration } from '../reducers';
/*
* Contant Declarations
@ -21,6 +22,9 @@ export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST';
export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS';
export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE';
export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST';
export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS';
export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE';
/*
* Simple Action Creators (Internal)
@ -61,7 +65,7 @@ export function entriesLoaded(collection, entries, pagination) {
payload: {
collection: collection.get('name'),
entries: entries,
pages: pagination
page: pagination
}
};
}
@ -110,6 +114,34 @@ export function emmptyDraftCreated(entry) {
};
}
export function searchingEntries(searchTerm) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm }
};
}
export function SearchSuccess(searchTerm, entries, page) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
searchTerm,
entries,
page
}
};
}
export function SearchFailure(searchTerm, error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: {
searchTerm,
error
}
};
}
/*
* Exported simple Action Creators
*/
@ -136,25 +168,30 @@ export function changeDraft(entry) {
/*
* Exported Thunk Action Creators
*/
export function loadEntry(collection, slug) {
export function loadEntry(entry, collection, slug) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
dispatch(entryLoading(collection, slug));
backend.entry(collection, slug)
.then((entry) => dispatch(entryLoaded(collection, entry)));
let getPromise;
if (entry && entry.get('path')) {
getPromise = backend.getEntry(entry.get('collection'), entry.get('slug'), entry.get('path'));
} else {
getPromise = backend.lookupEntry(collection, slug);
}
return getPromise.then((loadedEntry) => dispatch(entryLoaded(collection, loadedEntry)));
};
}
export function loadEntries(collection) {
export function loadEntries(collection, page = 0) {
return (dispatch, getState) => {
if (collection.get('isFetching')) { return; }
const state = getState();
const backend = currentBackend(state.config);
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config);
dispatch(entriesLoading(collection));
backend.entries(collection).then(
provider.listEntries(collection, page).then(
(response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)),
(error) => dispatch(entriesFailed(collection, error))
);
@ -184,3 +221,19 @@ export function persistEntry(collection, entry) {
);
};
}
export function searchEntries(searchTerm, page = 0) {
return (dispatch, getState) => {
const state = getState();
let collections = state.collections.keySeq().toArray();
collections = collections.filter(collection => selectIntegration(state, collection, 'search'));
const integration = selectIntegration(state, collections[0], 'search');
if (!integration) console.warn('There isn\'t a search integration configured.');
const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config);
dispatch(searchingEntries(searchTerm));
provider.search(collections, searchTerm, page).then(
(response) => dispatch(SearchSuccess(searchTerm, response.entries, response.pagination)),
(error) => dispatch(SearchFailure(searchTerm, error))
);
};
}

View File

@ -31,7 +31,7 @@ export function runCommand(commandName, payload) {
window.alert('Find Bar Help (PLACEHOLDER)\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit.');
break;
case SEARCH:
history.push('/search');
history.push(`/search/${payload.searchTerm}`);
break;
}
dispatch(run(commandName, payload));

View File

@ -17,6 +17,25 @@ class LocalStorageAuthStore {
}
}
const slugFormatter = (template, entryData) => {
var date = new Date();
return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) {
switch (name) {
case 'year':
return date.getFullYear();
case 'month':
return ('0' + (date.getMonth() + 1)).slice(-2);
case 'day':
return ('0' + date.getDate()).slice(-2);
case 'slug':
const identifier = entryData.get('title', entryData.get('path'));
return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-\_]+/gi, '-');
default:
return entryData.get(name);
}
});
};
class Backend {
constructor(implementation, authStore = null) {
this.implementation = implementation;
@ -46,7 +65,7 @@ class Backend {
});
}
entries(collection, page, perPage) {
listEntries(collection, page, perPage) {
return this.implementation.entries(collection, page, perPage).then((response) => {
return {
pagination: response.pagination,
@ -55,8 +74,15 @@ class Backend {
});
}
entry(collection, slug) {
return this.implementation.entry(collection, slug).then(this.entryWithFormat(collection));
// We have the file path. Fetch and parse the file.
getEntry(collection, slug, path) {
return this.implementation.getEntry(collection, slug, path).then(this.entryWithFormat(collection));
}
// Will fetch the whole list of files from GitHub and load each file, then looks up for entry.
// (Files are persisted in local storage - only expensive on the first run for each file).
lookupEntry(collection, slug) {
return this.implementation.lookupEntry(collection, slug).then(this.entryWithFormat(collection));
}
newEntry(collection) {
@ -87,24 +113,6 @@ class Backend {
return this.implementation.unpublishedEntry(collection, slug).then(this.entryWithFormat(collection));
}
slugFormatter(template, entry) {
var date = new Date();
return template.replace(/\{\{([^\}]+)\}\}/g, function(_, name) {
switch (name) {
case 'year':
return date.getFullYear();
case 'month':
return ('0' + (date.getMonth() + 1)).slice(-2);
case 'day':
return ('0' + date.getDate()).slice(-2);
case 'slug':
return entry.getIn(['data', 'title']).trim().toLowerCase().replace(/[^a-z0-9\.\-\_]+/gi, '-');
default:
return entry.getIn(['data', name]);
}
});
}
persistEntry(config, collection, entryDraft, MediaFiles, options) {
const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false;
@ -116,7 +124,7 @@ class Backend {
const entryData = entryDraft.getIn(['entry', 'data']).toJS();
let entryObj;
if (newEntry) {
const slug = this.slugFormatter(collection.get('slug'), entryDraft.get('entry'));
const slug = slugFormatter(collection.get('slug'), entryDraft.getIn(['entry', 'data']));
entryObj = {
path: `${collection.get('folder')}/${slug}.md`,
slug: slug,
@ -172,11 +180,11 @@ export function resolveBackend(config) {
switch (name) {
case 'test-repo':
return new Backend(new TestRepoBackend(config), authStore);
return new Backend(new TestRepoBackend(config, slugFormatter), authStore);
case 'github':
return new Backend(new GitHubBackend(config), authStore);
return new Backend(new GitHubBackend(config, slugFormatter), authStore);
case 'netlify-git':
return new Backend(new NetlifyGitBackend(config), authStore);
return new Backend(new NetlifyGitBackend(config, slugFormatter), authStore);
default:
throw `Backend not found: ${name}`;
}

View File

@ -11,7 +11,7 @@ export default class API {
this.token = token;
this.repo = repo;
this.branch = branch;
this.repoURL = `/repos/${this.repo}`;
this.repoURL = `/repos/${ this.repo }`;
}
user() {
@ -20,9 +20,9 @@ export default class API {
requestHeaders(headers = {}) {
return {
Authorization: `token ${this.token}`,
Authorization: `token ${ this.token }`,
'Content-Type': 'application/json',
...headers
...headers,
};
}
@ -40,11 +40,11 @@ export default class API {
const params = [];
if (options.params) {
for (const key in options.params) {
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
params.push(`${ key }=${ encodeURIComponent(options.params[key]) }`);
}
}
if (params.length) {
path += `?${params.join('&')}`;
path += `?${ params.join('&') }`;
}
return API_ROOT + path;
}
@ -52,7 +52,7 @@ export default class API {
request(path, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
return fetch(url, { ...options, headers: headers }).then((response) => {
return fetch(url, { ...options, headers }).then((response) => {
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
@ -63,20 +63,20 @@ export default class API {
}
checkMetadataRef() {
return this.request(`${this.repoURL}/git/refs/meta/_netlify_cms?${Date.now()}`, {
return this.request(`${ this.repoURL }/git/refs/meta/_netlify_cms?${ Date.now() }`, {
cache: 'no-store',
})
.then(response => response.object)
.catch(error => {
.catch((error) => {
// Meta ref doesn't exist
const readme = {
raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.'
raw: '# Netlify CMS\n\nThis tree is used by the Netlify CMS to store metadata information for specific files and branches.',
};
return this.uploadBlob(readme)
.then(item => this.request(`${this.repoURL}/git/trees`, {
.then(item => this.request(`${ this.repoURL }/git/trees`, {
method: 'POST',
body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] })
body: JSON.stringify({ tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }] }),
}))
.then(tree => this.commit('First Commit', tree))
.then(response => this.createRef('meta', '_netlify_cms', response.sha))
@ -88,32 +88,32 @@ export default class API {
return this.checkMetadataRef()
.then((branchData) => {
const fileTree = {
[`${key}.json`]: {
path: `${key}.json`,
[`${ key }.json`]: {
path: `${ key }.json`,
raw: JSON.stringify(data),
file: true
}
file: true,
},
};
return this.uploadBlob(fileTree[`${key}.json`])
return this.uploadBlob(fileTree[`${ key }.json`])
.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(() => {
LocalForage.setItem(`gh.meta.${key}`, {
LocalForage.setItem(`gh.meta.${ key }`, {
expires: Date.now() + 300000, // In 5 minutes
data
data,
});
});
});
}
retrieveMetadata(key) {
const cache = LocalForage.getItem(`gh.meta.${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' },
headers: { Accept: 'application/vnd.github.VERSION.raw' },
cache: 'no-store',
@ -123,17 +123,17 @@ export default class API {
}
readFile(path, sha, branch = this.branch) {
const cache = sha ? LocalForage.getItem(`gh.${sha}`) : Promise.resolve(null);
const cache = sha ? LocalForage.getItem(`gh.${ sha }`) : Promise.resolve(null);
return cache.then((cached) => {
if (cached) { return cached; }
return this.request(`${this.repoURL}/contents/${path}`, {
return this.request(`${ this.repoURL }/contents/${ path }`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
params: { ref: branch },
cache: false
cache: false,
}).then((result) => {
if (sha) {
LocalForage.setItem(`gh.${sha}`, result);
LocalForage.setItem(`gh.${ sha }`, result);
}
return result;
});
@ -141,25 +141,27 @@ export default class API {
}
listFiles(path) {
return this.request(`${this.repoURL}/contents/${path}`, {
params: { ref: this.branch }
return this.request(`${ this.repoURL }/contents/${ path }`, {
params: { ref: this.branch },
});
}
readUnpublishedBranchFile(contentKey) {
let metaData;
return this.retrieveMetadata(contentKey)
.then(data => {
const unpublishedPromise = this.retrieveMetadata(contentKey)
.then((data) => {
metaData = data;
return this.readFile(data.objects.entry, null, data.branch);
})
.then(file => {
return { metaData, file };
.then(file => ({ metaData, file }))
.catch((error) => {
return null;
});
return unpublishedPromise;
}
listUnpublishedBranches() {
return this.request(`${this.repoURL}/git/refs/heads/cms`);
return this.request(`${ this.repoURL }/git/refs/heads/cms`);
}
persistFiles(entry, mediaFiles, options) {
@ -172,7 +174,7 @@ export default class API {
files.forEach((file) => {
if (file.uploaded) { return; }
uploadPromises.push(this.uploadBlob(file));
parts = file.path.split('/').filter((part) => part);
parts = file.path.split('/').filter(part => part);
filename = parts.pop();
subtree = fileTree;
while (part = parts.shift()) {
@ -196,14 +198,14 @@ export default class API {
}
editorialWorkflowGit(fileTree, entry, filesList, options) {
const contentKey = options.collectionName ? `${options.collectionName}-${entry.slug}` : entry.slug;
const branchName = `cms/${contentKey}`;
const contentKey = options.collectionName ? `${ options.collectionName }-${ entry.slug }` : entry.slug;
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}`;
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))
@ -211,14 +213,14 @@ export default class API {
.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;
})
.then(username => this.storeMetadata(contentKey, {
type: 'PR',
pr: {
number: prResponse.number,
head: prResponse.head && prResponse.head.sha
head: prResponse.head && prResponse.head.sha,
},
user: username,
status: status.first(),
@ -228,9 +230,9 @@ export default class API {
description: options.parsedData && options.parsedData.description,
objects: {
entry: entry.path,
files: filesList
files: filesList,
},
timeStamp: new Date().toISOString()
timeStamp: new Date().toISOString(),
}));
});
} else {
@ -239,13 +241,13 @@ export default class API {
.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 => {
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 => {
.then((metadata) => {
let files = metadata.objects && metadata.objects.files || [];
files = files.concat(filesList);
@ -255,9 +257,9 @@ export default class API {
description: options.parsedData && options.parsedData.description,
objects: {
entry: entry.path,
files: _.uniq(files)
files: _.uniq(files),
},
timeStamp: new Date().toISOString()
timeStamp: new Date().toISOString(),
};
})
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata))
@ -267,50 +269,50 @@ export default class API {
}
updateUnpublishedEntryStatus(collection, slug, status) {
const contentKey = collection ? `${collection}-${slug}` : slug;
const contentKey = collection ? `${ collection }-${ slug }` : slug;
return this.retrieveMetadata(contentKey)
.then(metadata => {
.then((metadata) => {
return {
...metadata,
status
status,
};
})
.then(updatedMetadata => this.storeMetadata(contentKey, updatedMetadata));
}
publishUnpublishedEntry(collection, slug, status) {
const contentKey = collection ? `${collection}-${slug}` : slug;
const contentKey = collection ? `${ collection }-${ slug }` : slug;
return this.retrieveMetadata(contentKey)
.then(metadata => {
.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}`));
.then(() => this.deleteBranch(`cms/${ contentKey }`));
}
createRef(type, name, sha) {
return this.request(`${this.repoURL}/git/refs`, {
return this.request(`${ this.repoURL }/git/refs`, {
method: 'POST',
body: JSON.stringify({ ref: `refs/${type}/${name}`, sha }),
body: JSON.stringify({ ref: `refs/${ 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',
body: JSON.stringify({ sha })
body: JSON.stringify({ sha }),
});
}
deleteRef(type, name, sha) {
return this.request(`${this.repoURL}/git/refs/${type}/${name}`, {
return this.request(`${ this.repoURL }/git/refs/${ type }/${ name }`, {
method: 'DELETE',
});
}
getBranch(branch = this.branch) {
return this.request(`${this.repoURL}/branches/${branch}`);
return this.request(`${ this.repoURL }/branches/${ branch }`);
}
createBranch(branchName, sha) {
@ -327,24 +329,24 @@ export default class API {
createPR(title, head, base = 'master') {
const body = 'Automatically generated by Netlify CMS';
return this.request(`${this.repoURL}/pulls`, {
return this.request(`${ this.repoURL }/pulls`, {
method: 'POST',
body: JSON.stringify({ title, body, head, base }),
});
}
mergePR(headSha, number) {
return this.request(`${this.repoURL}/pulls/${number}/merge`, {
return this.request(`${ this.repoURL }/pulls/${ number }/merge`, {
method: 'PUT',
body: JSON.stringify({
commit_message: 'Automatically generated. Merged on Netlify CMS.',
sha: headSha
sha: headSha,
}),
});
}
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: [] });
}
toBase64(str) {
@ -357,12 +359,12 @@ export default class API {
const content = item instanceof MediaProxy ? item.toBase64() : this.toBase64(item.raw);
return content.then((contentBase64) => {
return this.request(`${this.repoURL}/git/blobs`, {
return this.request(`${ this.repoURL }/git/blobs`, {
method: 'POST',
body: JSON.stringify({
content: contentBase64,
encoding: 'base64'
})
encoding: 'base64',
}),
}).then((response) => {
item.sha = response.sha;
item.uploaded = true;
@ -374,11 +376,11 @@ export default class API {
updateTree(sha, path, fileTree) {
return this.getTree(sha)
.then((tree) => {
var obj, filename, fileOrDir;
var updates = [];
var added = {};
let obj, filename, fileOrDir;
const updates = [];
const added = {};
for (var i = 0, len = tree.tree.length; i < len; i++) {
for (let i = 0, len = tree.tree.length; i < len; i++) {
obj = tree.tree[i];
if (fileOrDir = fileTree[obj.path]) {
added[obj.path] = true;
@ -400,12 +402,12 @@ export default class API {
}
return Promise.all(updates)
.then((updates) => {
return this.request(`${this.repoURL}/git/trees`, {
return this.request(`${ this.repoURL }/git/trees`, {
method: 'POST',
body: JSON.stringify({ base_tree: sha, tree: updates })
body: JSON.stringify({ base_tree: sha, tree: updates }),
});
}).then((response) => {
return { path: path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha };
return { path, mode: '040000', type: 'tree', sha: response.sha, parentSha: sha };
});
});
}
@ -413,9 +415,9 @@ export default class API {
commit(message, changeTree) {
const tree = changeTree.sha;
const parents = changeTree.parentSha ? [changeTree.parentSha] : [];
return this.request(`${this.repoURL}/git/commits`, {
return this.request(`${ this.repoURL }/git/commits`, {
method: 'POST',
body: JSON.stringify({ message, tree, parents })
body: JSON.stringify({ message, tree, parents }),
});
}

View File

@ -38,7 +38,7 @@ export default class GitHub {
files.map((file) => {
promises.push(new Promise((resolve, reject) => {
return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => {
resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data));
resolve(createEntry(collection.get('name'), file.path.split('/').pop().replace(/\.[^\.]+$/, ''), file.path, { raw: data }));
sem.leave();
}).catch((err) => {
sem.leave();
@ -47,18 +47,25 @@ export default class GitHub {
}));
});
return Promise.all(promises);
}).then((entries) => ({
pagination: {},
entries
}).then(entries => ({
pagination: 0,
entries,
}));
}
entry(collection, slug) {
return this.entries(collection).then((response) => (
response.entries.filter((entry) => entry.slug === slug)[0]
// Will fetch the entire list of entries from github.
lookupEntry(collection, slug) {
return this.entries(collection).then(response => (
response.entries.filter(entry => entry.slug === slug)[0]
));
}
// Fetches a single entry.
getEntry(collection, slug, path) {
return this.api.readFile(path).then(data => createEntry(collection, slug, path, { raw: data }));
}
persistEntry(entry, mediaFiles = [], options = {}) {
return this.api.persistFiles(entry, mediaFiles, options);
}
@ -71,11 +78,16 @@ export default class GitHub {
promises.push(new Promise((resolve, reject) => {
const contentKey = branch.ref.split('refs/heads/cms/').pop();
return sem.take(() => this.api.readUnpublishedBranchFile(contentKey).then((data) => {
const entryPath = data.metaData.objects.entry;
const entry = createEntry(entryPath, entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), data.file);
entry.metaData = data.metaData;
resolve(entry);
sem.leave();
if (data === null || data === undefined) {
resolve(null);
sem.leave();
} else {
const entryPath = data.metaData.objects.entry;
const entry = createEntry('draft', entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), entryPath, { raw: data.file });
entry.metaData = data.metaData;
resolve(entry);
sem.leave();
}
}).catch((err) => {
sem.leave();
reject(err);
@ -84,16 +96,17 @@ export default class GitHub {
});
return Promise.all(promises);
}).then((entries) => {
const filteredEntries = entries.filter(entry => entry !== null);
return {
pagination: {},
entries
pagination: 0,
entries: filteredEntries,
};
});
}
unpublishedEntry(collection, slug) {
return this.unpublishedEntries().then((response) => (
response.entries.filter((entry) => (
return this.unpublishedEntries().then(response => (
response.entries.filter(entry => (
entry.metaData && entry.metaData.collection === collection.get('name') && entry.slug === slug
))[0]
));

View File

@ -19,7 +19,6 @@ export default class AuthenticationPage extends React.Component {
'Authorization': 'Basic ' + btoa(`${email}:${password}`)
}
}).then((response) => {
console.log(response);
if (response.ok) {
return response.json().then((data) => {
this.props.onLogin(Object.assign({ email }, data));

View File

@ -35,7 +35,7 @@ export default class NetlifyGit {
files.map((file) => {
promises.push(new Promise((resolve, reject) => {
return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => {
resolve(createEntry(file.path, file.path.split('/').pop().replace(/\.[^\.]+$/, ''), data));
resolve(createEntry(collection.get('name'), file.path.split('/').pop().replace(/\.[^\.]+$/, ''), file.path, { raw: data }));
sem.leave();
}).catch((err) => {
sem.leave();
@ -50,7 +50,7 @@ export default class NetlifyGit {
}));
}
entry(collection, slug) {
lookupEntry(collection, slug) {
return this.entries(collection).then((response) => (
response.entries.filter((entry) => entry.slug === slug)[0]
));

View File

@ -29,7 +29,7 @@ export default class TestRepo {
const folder = collection.get('folder');
if (folder) {
for (var path in window.repoFiles[folder]) {
entries.push(createEntry(folder + '/' + path, getSlug(path), window.repoFiles[folder][path].content));
entries.push(createEntry(collection.get('name'), getSlug(path), folder + '/' + path, { raw: window.repoFiles[folder][path].content }));
}
}
@ -39,7 +39,7 @@ export default class TestRepo {
});
}
entry(collection, slug) {
lookupEntry(collection, slug) {
return this.entries(collection).then((response) => (
response.entries.filter((entry) => entry.slug === slug)[0]
));

View File

@ -9,9 +9,3 @@
.appBar {
background-color: var(--backgroundColor);
}
.createBtn {
position: fixed;
right: 2rem;
top: 3.5rem;
}

View File

@ -1,7 +1,7 @@
import React from 'react';
import pluralize from 'pluralize';
import { IndexLink } from 'react-router';
import { Menu, MenuItem, Button, IconButton } from 'react-toolbox';
import { Menu, MenuItem } from 'react-toolbox';
import AppBar from 'react-toolbox/lib/app_bar';
import FindBar from '../FindBar/FindBar';
import styles from './AppHeader.css';
@ -45,45 +45,37 @@ export default class AppHeader extends React.Component {
<AppBar
fixed
theme={styles}
leftIcon="menu"
rightIcon="create"
onLeftIconClick={toggleNavDrawer}
onRightIconClick={this.handleCreateButtonClick}
>
<IconButton
icon="menu"
inverse
onClick={toggleNavDrawer}
/>
<IndexLink to="/">
Dashboard
</IndexLink>
<FindBar
commands={commands}
defaultCommands={defaultCommands}
runCommand={runCommand}
/>
<Button
className={styles.createBtn}
icon='add'
floating
accent
onClick={this.handleCreateButtonClick}
>
<Menu
active={createMenuActive}
position="topRight"
onHide={this.handleCreateMenuHide}
>
{
collections.valueSeq().map(collection =>
<MenuItem
key={collection.get('name')}
value={collection.get('name')}
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))}
caption={pluralize(collection.get('label'), 1)}
/>
)
}
</Menu>
</Button>
<Menu
active={createMenuActive}
position="topRight"
onHide={this.handleCreateMenuHide}
>
{
collections.valueSeq().map(collection =>
<MenuItem
key={collection.get('name')}
value={collection.get('name')}
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))}
caption={pluralize(collection.get('label'), 1)}
/>
)
}
</Menu>
</AppBar>
);
}

View File

@ -6,11 +6,13 @@ export default class ControlPane extends React.Component {
controlFor(field) {
const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props;
const widget = resolveWidget(field.get('widget'));
const value = entry.getIn(['data', field.get('name')]);
if (!value) return null;
return <div className="cms-control">
<label>{field.get('label')}</label>
{React.createElement(widget.control, {
field: field,
value: entry.getIn(['data', field.get('name')]),
value: value,
onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)),
onAddMedia: onAddMedia,
onRemoveMedia: onRemoveMedia,

View File

@ -1,6 +1,8 @@
import React from 'react';
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Map } from 'immutable';
import Bricks from 'bricks.js';
import Waypoint from 'react-waypoint';
import history from '../routing/history';
import Cards from './Cards';
import _ from 'lodash';
@ -23,6 +25,7 @@ export default class EntryListing extends React.Component {
};
this.updateBricks = _.throttle(this.updateBricks.bind(this), 30);
this.handleLoadMore = this.handleLoadMore.bind(this);
}
componentDidMount() {
@ -58,7 +61,6 @@ export default class EntryListing extends React.Component {
}
cardFor(collection, entry, link) {
//const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props;
const cartType = collection.getIn(['card', 'type']) || 'alltype';
const card = Cards[cartType] || Cards._unknown;
return React.createElement(card, {
@ -72,23 +74,47 @@ export default class EntryListing extends React.Component {
});
}
render() {
const { collection, entries } = this.props;
const name = collection.get('name');
handleLoadMore() {
this.props.onPaginate(this.props.page + 1);
}
renderCards = () => {
const { collections, entries } = this.props;
if (Map.isMap(collections)) {
const collectionName = collections.get('name');
return entries.map((entry) => {
const path = `/collections/${collectionName}/entries/${entry.get('slug')}`;
return this.cardFor(collections, entry, path);
});
} else {
return entries.map((entry) => {
const collection = collections.filter(collection => collection.get('name') === entry.get('collection')).first();
const path = `/collections/${collection.get('name')}/entries/${entry.get('slug')}`;
return this.cardFor(collection, entry, path);
});
}
};
render() {
const { children } = this.props;
const cards = this.renderCards();
return <div>
<h1>Listing {name}</h1>
<h1>{children}</h1>
<div ref={(c) => this._entries = c}>
{entries.map((entry) => {
const path = `/collections/${name}/entries/${entry.get('slug')}`;
return this.cardFor(collection, entry, path);
})}
{cards}
<Waypoint onEnter={this.handleLoadMore} />
</div>
</div>;
}
}
EntryListing.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
children: PropTypes.node.isRequired,
collections: PropTypes.oneOfType([
ImmutablePropTypes.map,
ImmutablePropTypes.iterable
]).isRequired,
entries: ImmutablePropTypes.list,
onPaginate: PropTypes.func.isRequired,
page: PropTypes.number,
};

View File

@ -16,6 +16,9 @@ class UnpublishedListing extends React.Component {
};
requestPublish = (collection, slug, ownStatus) => {
console.log('HERE');
console.log(ownStatus);
console.log(status.last());
if (ownStatus !== status.last()) return;
if (window.confirm('Are you sure you want to publish this entry?')) {
this.props.handlePublish(collection, slug, ownStatus);
@ -39,12 +42,12 @@ class UnpublishedListing extends React.Component {
/* eslint-enable */
));
} else {
return <div>
{entries.map(entry => {
return (<div>
{entries.map((entry) => {
// Look for an "author" field. Fallback to username on backend implementation;
const author = entry.getIn(['data', 'author'], entry.getIn(['metaData', 'user']));
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 ownStatus = entry.getIn(['metaData', 'status']);
const collection = entry.getIn(['metaData', 'collection']);
@ -56,7 +59,7 @@ class UnpublishedListing extends React.Component {
<span className={styles.cardHeading}><Link to={link}>{entry.getIn(['data', 'title'])}</Link> <small>by {author}</small></span>
<p className={styles.cardText}>Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}</p>
{(ownStatus === status.last()) &&
<button className={styles.button} onClick={this.requestPublish.bind(this, collection, slug, status)}>Publish now</button>
<button className={styles.button} onClick={this.requestPublish.bind(this, collection, slug, ownStatus)}>Publish now</button>
}
</Card>
</div>
@ -65,7 +68,7 @@ class UnpublishedListing extends React.Component {
);
}
)}
</div>;
</div>);
}
};
@ -81,7 +84,7 @@ class UnpublishedListing extends React.Component {
<div className={styles.clear}>
<h1>Editorial Workflow</h1>
<div className={styles.container}>
{columns}
{columns}
</div>
</div>
);

View File

@ -2,8 +2,6 @@ import React, { PropTypes } from 'react';
import { Editor, Plain, Mark } from 'slate';
import Prism from 'prismjs';
import marks from './prismMarkdown';
import styles from './index.css';
Prism.languages.markdown = Prism.languages.extend('markup', {});
Prism.languages.insertBefore('markdown', 'prolog', marks);
@ -75,7 +73,6 @@ const SCHEMA = {
class RawEditor extends React.Component {
constructor(props) {
super(props);
const content = props.value ? Plain.deserialize(props.value) : Plain.deserialize('');
this.state = {

View File

@ -8,7 +8,7 @@ import { DEFAULT_NODE, SCHEMA } from './schema';
import { getNodes, getSyntaxes, getPlugins } from '../../richText';
import StylesMenu from './StylesMenu';
import BlockTypesMenu from './BlockTypesMenu';
import styles from './index.css';
//import styles from './index.css';
/**
* Slate Render Configuration

View File

@ -17,7 +17,7 @@ const EditorComponent = Record({
});
class Plugin extends Component {
class Plugin extends Component { // eslint-disable-line
static propTypes = {
children: PropTypes.element.isRequired
};

View File

@ -9,10 +9,12 @@ import styles from './CollectionPage.css';
import CollectionPageHOC from './editorialWorkflow/CollectionPageHOC';
class DashboardPage extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired,
dispatch: PropTypes.func.isRequired,
page: PropTypes.number,
entries: ImmutablePropTypes.list,
};
@ -30,20 +32,30 @@ class DashboardPage extends React.Component {
}
}
handleLoadMore = (page) => {
const { collection, dispatch } = this.props;
dispatch(loadEntries(collection, page));
};
render() {
const { collections, collection, entries } = this.props;
const { collections, collection, page, entries } = this.props;
if (collections == null) {
return <h1>No collections defined in your config.yml</h1>;
}
return <div className={styles.root}>
return (<div className={styles.root}>
{entries ?
<EntryListing collection={collection} entries={entries}/>
<EntryListing
collections={collection}
entries={entries}
page={page}
onPaginate={this.handleLoadMore}
>
{collection.get('name')}
</EntryListing>
:
<Loader active>{['Loading Entries', 'Caching Entries', 'This might take several minutes']}</Loader>
<Loader active>{['Loading Entries', 'Caching Entries', 'This might take several minutes']}</Loader>
}
</div>;
</div>);
}
}
@ -51,16 +63,18 @@ class DashboardPage extends React.Component {
* Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff,
* We delegate it to a Higher Order Component
*/
DashboardPage = CollectionPageHOC(DashboardPage);
DashboardPage = CollectionPageHOC(DashboardPage); // eslint-disable-line
function mapStateToProps(state, ownProps) {
const { collections } = state;
const { name, slug } = ownProps.params;
const collection = name ? collections.get(name) : collections.first();
const page = state.entries.getIn(['pages', collection.get('name'), 'page']);
const entries = selectEntries(state, collection.get('name'));
return { slug, collection, collections, entries };
return { slug, collection, collections, page, entries };
}
export default connect(mapStateToProps)(DashboardPage);

View File

@ -33,18 +33,18 @@ class EntryPage extends React.Component {
};
componentDidMount() {
if (!this.props.newEntry) {
this.props.loadEntry(this.props.collection, this.props.slug);
const { entry, collection, slug } = this.props;
this.createDraft(this.props.entry);
} else {
if (this.props.newEntry) {
this.props.createEmptyDraft(this.props.collection);
} else {
this.props.loadEntry(entry, collection, slug);
this.createDraft(entry);
}
}
componentWillReceiveProps(nextProps) {
if (this.props.entry === nextProps.entry) return;
if (nextProps.entry && !nextProps.entry.get('isFetching')) {
this.createDraft(nextProps.entry);
} else if (nextProps.newEntry) {
@ -86,6 +86,13 @@ class EntryPage extends React.Component {
}
}
/*
* Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff,
* We delegate it to a Higher Order Component
*/
EntryPage = EntryPageHOC(EntryPage);
function mapStateToProps(state, ownProps) {
const { collections, entryDraft } = state;
const collection = collections.get(ownProps.params.name);
@ -96,12 +103,6 @@ function mapStateToProps(state, ownProps) {
return { collection, collections, newEntry, entryDraft, boundGetMedia, slug, entry };
}
/*
* Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff,
* We delegate it to a Higher Order Component
*/
EntryPage = EntryPageHOC(EntryPage);
export default connect(
mapStateToProps,
{

View File

@ -1,12 +1,64 @@
import React from 'react';
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { selectSearchedEntries } from '../reducers';
import { searchEntries } from '../actions/entries';
import { Loader } from '../components/UI';
import EntryListing from '../components/EntryListing';
import styles from './CollectionPage.css';
class SearchPage extends React.Component {
static propTypes = {
isFetching: PropTypes.bool,
searchEntries: PropTypes.func.isRequired,
searchTerm: PropTypes.string.isRequired,
entries: ImmutablePropTypes.list
};
componentDidMount() {
const { searchTerm, searchEntries } = this.props;
searchEntries(searchTerm);
}
componentWillReceiveProps(nextProps) {
if (this.props.searchTerm === nextProps.searchTerm) return;
const { searchEntries } = this.props;
searchEntries(nextProps.searchTerm);
}
handleLoadMore = (page) => {
const { searchTerm, searchEntries } = this.props;
searchEntries(searchTerm, page);
};
render() {
return <div>
<h1>Search</h1>
const { collections, searchTerm, entries, isFetching, page } = this.props;
return <div className={styles.root}>
{(isFetching === true || !entries) ?
<Loader active>{['Loading Entries', 'Caching Entries', 'This might take several minutes']}</Loader>
:
<EntryListing collections={collections} entries={entries} page={page} onPaginate={this.handleLoadMore}>
Results for {searchTerm}
</EntryListing>
}
</div>;
}
}
export default connect()(SearchPage);
function mapStateToProps(state, ownProps) {
const isFetching = state.entries.getIn(['search', 'isFetching']);
const page = state.entries.getIn(['search', 'page']);
const entries = selectSearchedEntries(state);
const collections = state.collections.toIndexedSeq();
const searchTerm = ownProps.params && ownProps.params.searchTerm;
return { isFetching, page, collections, entries, searchTerm };
}
export default connect(
mapStateToProps,
{ searchEntries }
)(SearchPage);

View File

@ -1,12 +1,16 @@
import React from 'react';
import { render } from 'react-dom';
import { AppContainer } from 'react-hot-loader';
import Root from './root';
import registry from './lib/registry';
import 'file?name=index.html!../example/index.html';
import 'react-toolbox/lib/commons.scss';
import Root from './root';
import registry from './lib/registry';
import './index.css';
if (process.env.NODE_ENV !== 'production') {
require('./utils.css'); // eslint-disable-line
}
// Create mount element dynamically
const el = document.createElement('div');
el.id = 'root';

28
src/integrations/index.js Normal file
View File

@ -0,0 +1,28 @@
import Algolia from './providers/algolia/implementation';
import { Map } from 'immutable';
export function resolveIntegrations(interationsConfig) {
let integrationInstances = Map({});
interationsConfig.get('providers').forEach((providerData, providerName) => {
switch (providerName) {
case 'algolia':
integrationInstances = integrationInstances.set('algolia', new Algolia(providerData));
break;
}
});
return integrationInstances;
}
export const getIntegrationProvider = (function() {
let integrations = null;
return (interationsConfig, provider) => {
if (integrations) {
return integrations.get(provider);
} else {
integrations = resolveIntegrations(interationsConfig);
return integrations.get(provider);
}
};
})();

View File

@ -0,0 +1,123 @@
import { createEntry } from '../../../valueObjects/Entry';
import _ from 'lodash';
function getSlug(path) {
const m = path.match(/([^\/]+?)(\.[^\/\.]+)?$/);
return m && m[1];
}
export default class Algolia {
constructor(config) {
this.config = config;
if (config.get('applicationID') == null ||
config.get('apiKey') == null) {
throw 'The Algolia search integration needs the credentials (applicationID and apiKey) in the integration configuration.';
}
this.applicationID = config.get('applicationID');
this.apiKey = config.get('apiKey');
this.searchURL = `https://${this.applicationID}-dsn.algolia.net/1`;
this.entriesCache = {
collection: null,
page: null,
entries: []
};
}
requestHeaders(headers = {}) {
return {
'X-Algolia-API-Key': this.apiKey,
'X-Algolia-Application-Id': this.applicationID,
'Content-Type': 'application/json',
...headers
};
}
parseJsonResponse(response) {
return response.json().then((json) => {
if (!response.ok) {
return Promise.reject(json);
}
return json;
});
}
urlFor(path, options) {
const params = [];
if (options.params) {
for (const key in options.params) {
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
}
}
if (params.length) {
path += `?${params.join('&')}`;
}
return path;
}
request(path, options = {}) {
const headers = this.requestHeaders(options.headers || {});
const url = this.urlFor(path, options);
return fetch(url, { ...options, headers: headers }).then((response) => {
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.match(/json/)) {
return this.parseJsonResponse(response);
}
return response.text();
});
}
search(collections, searchTerm, page) {
const searchCollections = collections.map(collection => (
{ indexName: collection, params: `query=${searchTerm}&page=${page}` }
));
return this.request(`${this.searchURL}/indexes/*/queries`, {
method: 'POST',
body: JSON.stringify({ requests: searchCollections })
}).then(response => {
const entries = response.results.map((result, index) => result.hits.map(hit => {
const slug = hit.slug || getSlug(hit.path);
return createEntry(collections[index], slug, hit.path, { data: hit.data, partial: true });
}));
return { entries: _.flatten(entries), pagination: page };
});
}
searchBy(field, collection, query) {
return this.request(`${this.searchURL}/indexes/${collection}`, {
params: {
restrictSearchableAttributes: field,
query
}
});
}
listEntries(collection, page) {
if (this.entriesCache.collection === collection && this.entriesCache.page === page) {
return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries });
} else {
return this.request(`${this.searchURL}/indexes/${collection.get('name')}`, {
params: { page }
}).then(response => {
const entries = response.hits.map(hit => {
const slug = hit.slug || getSlug(hit.path);
return createEntry(collection.get('name'), slug, hit.path, { data: hit.data, partial: true });
});
this.entriesCache = { collection, page, entries };
return { entries, pagination: response.page };
});
}
}
getEntry(collection, slug) {
return this.searchBy('slug', collection.get('name'), slug).then((response) => {
const entry = response.hits.filter((hit) => hit.slug === slug)[0];
return createEntry(collection.get('name'), slug, entry.path, { data: entry.data, partial: true });
});
}
}

View File

@ -1,12 +1,28 @@
import Immutable from 'immutable';
import _ from 'lodash';
import * as publishModes from '../constants/publishModes';
import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE } from '../actions/config';
const defaults = {
publish_mode: publishModes.SIMPLE
};
const applyDefaults = (config) => {
// Make sure there is a public folder
_.set(defaults,
'public_folder',
config.media_folder.charAt(0) === '/' ? config.media_folder : '/' + config.media_folder);
return _.defaultsDeep(config, defaults);
};
const config = (state = null, action) => {
switch (action.type) {
case CONFIG_REQUEST:
return Immutable.Map({ isFetching: true });
case CONFIG_SUCCESS:
return Immutable.fromJS(action.payload);
const config = applyDefaults(action.payload);
return Immutable.fromJS(config);
case CONFIG_FAILURE:
return Immutable.Map({ error: action.payload.toString() });
default:

View File

@ -1,8 +1,10 @@
import { Map, List, fromJS } from 'immutable';
import {
ENTRY_REQUEST, ENTRY_SUCCESS, ENTRIES_REQUEST, ENTRIES_SUCCESS
ENTRY_REQUEST, ENTRY_SUCCESS, ENTRIES_REQUEST, ENTRIES_SUCCESS, SEARCH_ENTRIES_REQUEST, SEARCH_ENTRIES_SUCCESS
} from '../actions/entries';
let collection, loadedEntries, page, searchTerm;
const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
switch (action.type) {
case ENTRY_REQUEST:
@ -18,14 +20,45 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
return state.setIn(['pages', action.payload.collection, 'isFetching'], true);
case ENTRIES_SUCCESS:
const { collection, entries, pages } = action.payload;
collection = action.payload.collection;
loadedEntries = action.payload.entries;
page = action.payload.page;
return state.withMutations((map) => {
entries.forEach((entry) => (
loadedEntries.forEach((entry) => (
map.setIn(['entities', `${collection}.${entry.slug}`], fromJS(entry).set('isFetching', false))
));
const ids = List(loadedEntries.map((entry) => entry.slug));
map.setIn(['pages', collection], Map({
...pages,
ids: List(entries.map((entry) => entry.slug))
page: page,
ids: page === 0 ? ids : map.getIn(['pages', collection, 'ids'], List()).concat(ids)
}));
});
case SEARCH_ENTRIES_REQUEST:
if (action.payload.searchTerm !== state.getIn(['search', 'term'])) {
return state.withMutations((map) => {
map.setIn(['search', 'isFetching'], true);
map.setIn(['search', 'term'], action.payload.searchTerm);
});
} else {
return state;
}
case SEARCH_ENTRIES_SUCCESS:
loadedEntries = action.payload.entries;
page = action.payload.page;
searchTerm = action.payload.searchTerm;
return state.withMutations((map) => {
loadedEntries.forEach((entry) => (
map.setIn(['entities', `${entry.collection}.${entry.slug}`], fromJS(entry).set('isFetching', false))
));
const ids = List(loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })));
map.set('search', Map({
page: page,
term: searchTerm,
ids: page === 0 ? ids : map.getIn(['search', 'ids'], List()).concat(ids)
}));
});
@ -43,4 +76,9 @@ export const selectEntries = (state, collection) => {
return slugs && slugs.map((slug) => selectEntry(state, collection, slug));
};
export const selectSearchedEntries = (state) => {
const searchItems = state.getIn(['search', 'ids']);
return searchItems && searchItems.map(({ collection, slug }) => selectEntry(state, collection, slug));
};
export default entries;

View File

@ -1,8 +1,9 @@
import auth from './auth';
import config from './config';
import editor from './editor';
import integrations, * as fromIntegrations from './integrations';
import entries, * as fromEntries from './entries';
import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import editorialWorkflow, * as fromEditorialWorkflow from './editorialWorkflow';
import entryDraft from './entryDraft';
import collections from './collections';
import medias, * as fromMedias from './medias';
@ -11,6 +12,7 @@ const reducers = {
auth,
config,
collections,
integrations,
editor,
entries,
editorialWorkflow,
@ -29,11 +31,17 @@ export const selectEntry = (state, collection, slug) =>
export const selectEntries = (state, collection) =>
fromEntries.selectEntries(state.entries, collection);
export const selectSearchedEntries = (state) =>
fromEntries.selectSearchedEntries(state.entries);
export const selectUnpublishedEntry = (state, status, slug) =>
fromEditorialWorkflow.selectUnpublishedEntry(state.editorialWorkflow, status, slug);
export const selectUnpublishedEntries = (state, status) =>
fromEditorialWorkflow.selectUnpublishedEntries(state.editorialWorkflow, status);
export const selectIntegration = (state, collection, hook) =>
fromIntegrations.selectIntegration(state.integrations, collection, hook);
export const getMedia = (state, path) =>
fromMedias.getMedia(state.medias, path);

View File

@ -0,0 +1,29 @@
import { fromJS } from 'immutable';
import { CONFIG_SUCCESS } from '../actions/config';
const integrations = (state = null, action) => {
switch (action.type) {
case CONFIG_SUCCESS:
const integrations = action.payload.integrations || [];
const newState = integrations.reduce((acc, integration) => {
const { hooks, collections, provider, ...providerData } = integration;
acc.providers[provider] = { ...providerData };
collections.forEach(collection => {
hooks.forEach(hook => {
acc.hooks[collection] ? acc.hooks[collection][hook] = provider : acc.hooks[collection] = { [hook]: provider };
});
});
return acc;
}, { providers:{}, hooks: {} });
return fromJS(newState);
default:
return state;
}
};
export const selectIntegration = (state, collection, hook) => {
return state.getIn(['hooks', collection, hook], false);
};
export default integrations;

View File

@ -13,7 +13,7 @@ export default (
<Route path="/collections/:name/entries/new" component={EntryPage} newRecord />
<Route path="/collections/:name/entries/:slug" component={EntryPage} />
<Route path="/editorialworkflow/:name/:status/:slug" component={EntryPage} unpublishedEntry />
<Route path="/search" component={SearchPage}/>
<Route path="/search/:searchTerm" component={SearchPage}/>
<Route path="*" component={NotFoundPage}/>
</Route>
);

25
src/utils.css Normal file
View File

@ -0,0 +1,25 @@
/* stylelint-disable */
/* This is an utility file that should not be included in production build */
:global {
& .undefined {
position: fixed !important;
top: 0 !important;
right: 0 !important;
bottom: 0 !important;
left: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background: red !important;
color: white !important;
font-weight: bold !important;
font-size: 30px !important;
}
& .undefined::after {
display: block !important;
padding: 15px 30px !important;
content: 'ERROR! You are missing a class definition in your css module! Inspect me to find out where.' !important;
}
}
/* stylelint-enable */

View File

@ -1,9 +1,11 @@
export function createEntry(path = '', slug = '', raw = '') {
export function createEntry(collection, slug = '', path = '', options = {}) {
const returnObj = {};
returnObj.path = path;
returnObj.collection = collection;
returnObj.slug = slug;
returnObj.raw = raw;
returnObj.data = {};
returnObj.metaData = {};
returnObj.path = path;
returnObj.partial = options.partial || false;
returnObj.raw = options.raw || '';
returnObj.data = options.data || {};
returnObj.metaData = options.metaData || null;
return returnObj;
}

View File

@ -1,3 +1,5 @@
/* eslint global-require: 0 */
const webpack = require('webpack');
module.exports = {
@ -5,11 +7,11 @@ module.exports = {
loaders: [
{
test: /\.((png)|(eot)|(woff)|(woff2)|(ttf)|(svg)|(gif))(\?v=\d+\.\d+\.\d+)?$/,
loader: 'url-loader?limit=100000'
loader: 'url-loader?limit=100000',
},
{
test: /\.json$/,
loader: 'json-loader'
loader: 'json-loader',
},
{
test: /\.scss$/,
@ -31,15 +33,21 @@ module.exports = {
'transform-object-assign',
'transform-object-rest-spread',
'lodash',
'react-hot-loader/babel'
]
}
}
]
'react-hot-loader/babel',
],
},
},
],
},
postcss: [
require('postcss-import')({ addDependencyTo: webpack }),
require('postcss-cssnext')
require('postcss-cssnext'),
],
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV),
},
}),
],
};