diff --git a/package.json b/package.json index 31551034..74dbb6f4 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/actions/config.js b/src/actions/config.js index 8b286142..9500df2f 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -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; } diff --git a/src/actions/entries.js b/src/actions/entries.js index 83cb5c65..bf5d5b46 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -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)) + ); + }; +} diff --git a/src/actions/findbar.js b/src/actions/findbar.js index b726ec58..4abfd3db 100644 --- a/src/actions/findbar.js +++ b/src/actions/findbar.js @@ -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)); diff --git a/src/backends/backend.js b/src/backends/backend.js index 1cb9bc42..6c6bb778 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -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}`; } diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 70770aed..71818899 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -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 }), }); } diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 2d270261..5168edd1 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -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] )); diff --git a/src/backends/netlify-git/AuthenticationPage.js b/src/backends/netlify-git/AuthenticationPage.js index 28333d33..0840a332 100644 --- a/src/backends/netlify-git/AuthenticationPage.js +++ b/src/backends/netlify-git/AuthenticationPage.js @@ -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)); diff --git a/src/backends/netlify-git/implementation.js b/src/backends/netlify-git/implementation.js index cf7f21ab..589b1b83 100644 --- a/src/backends/netlify-git/implementation.js +++ b/src/backends/netlify-git/implementation.js @@ -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] )); diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index b8cf56cc..fc3462d7 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -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] )); diff --git a/src/components/AppHeader/AppHeader.css b/src/components/AppHeader/AppHeader.css index c1406f5d..55b25a48 100644 --- a/src/components/AppHeader/AppHeader.css +++ b/src/components/AppHeader/AppHeader.css @@ -9,9 +9,3 @@ .appBar { background-color: var(--backgroundColor); } - -.createBtn { - position: fixed; - right: 2rem; - top: 3.5rem; -} diff --git a/src/components/AppHeader/AppHeader.js b/src/components/AppHeader/AppHeader.js index c8c7363c..54ae8a3b 100644 --- a/src/components/AppHeader/AppHeader.js +++ b/src/components/AppHeader/AppHeader.js @@ -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 { - Dashboard + - + + { + collections.valueSeq().map(collection => + + ) + } + ); } diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js index fc39041e..ef764b0a 100644 --- a/src/components/ControlPane.js +++ b/src/components/ControlPane.js @@ -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
{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, diff --git a/src/components/EntryListing.js b/src/components/EntryListing.js index 91239dc3..09fe5407 100644 --- a/src/components/EntryListing.js +++ b/src/components/EntryListing.js @@ -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
-

Listing {name}

+

{children}

this._entries = c}> - {entries.map((entry) => { - const path = `/collections/${name}/entries/${entry.get('slug')}`; - return this.cardFor(collection, entry, path); - })} + {cards} +
; } } 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, }; diff --git a/src/components/UnpublishedListing.js b/src/components/UnpublishedListing.js index 3da8ac52..e7df60d2 100644 --- a/src/components/UnpublishedListing.js +++ b/src/components/UnpublishedListing.js @@ -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
- {entries.map(entry => { + return (
+ {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 { {entry.getIn(['data', 'title'])} by {author}

Last updated: {timeStamp} by {entry.getIn(['metaData', 'user'])}

{(ownStatus === status.last()) && - + }
@@ -65,7 +68,7 @@ class UnpublishedListing extends React.Component { ); } )} -
; +
); } }; @@ -81,7 +84,7 @@ class UnpublishedListing extends React.Component {

Editorial Workflow

- {columns} + {columns}
); diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js index 6aad3750..e8cacbcb 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js @@ -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 = { diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index d7af9d66..c285ed61 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -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 diff --git a/src/components/Widgets/MarkdownControlElements/plugins.js b/src/components/Widgets/MarkdownControlElements/plugins.js index 86c9a111..b8e4da6a 100644 --- a/src/components/Widgets/MarkdownControlElements/plugins.js +++ b/src/components/Widgets/MarkdownControlElements/plugins.js @@ -17,7 +17,7 @@ const EditorComponent = Record({ }); -class Plugin extends Component { +class Plugin extends Component { // eslint-disable-line static propTypes = { children: PropTypes.element.isRequired }; diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js index 7a218866..07ab62e8 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -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

No collections defined in your config.yml

; } - - - return
+ return (
{entries ? - + + {collection.get('name')} + : - {['Loading Entries', 'Caching Entries', 'This might take several minutes']} + {['Loading Entries', 'Caching Entries', 'This might take several minutes']} } -
; +
); } } @@ -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); diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index dd79f2f0..dd903b2e 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -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, { diff --git a/src/containers/SearchPage.js b/src/containers/SearchPage.js index db042948..7d503991 100644 --- a/src/containers/SearchPage.js +++ b/src/containers/SearchPage.js @@ -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
-

Search

+ const { collections, searchTerm, entries, isFetching, page } = this.props; + return
+ {(isFetching === true || !entries) ? + {['Loading Entries', 'Caching Entries', 'This might take several minutes']} + : + + Results for “{searchTerm}” + + }
; } } -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); diff --git a/src/index.js b/src/index.js index e7c8ffe1..ba980758 100644 --- a/src/index.js +++ b/src/index.js @@ -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'; diff --git a/src/integrations/index.js b/src/integrations/index.js new file mode 100644 index 00000000..8a5b604e --- /dev/null +++ b/src/integrations/index.js @@ -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); + } + }; +})(); diff --git a/src/integrations/providers/algolia/implementation.js b/src/integrations/providers/algolia/implementation.js new file mode 100644 index 00000000..0fd7f517 --- /dev/null +++ b/src/integrations/providers/algolia/implementation.js @@ -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 }); + }); + } +} diff --git a/src/reducers/config.js b/src/reducers/config.js index 088b014c..15985a75 100644 --- a/src/reducers/config.js +++ b/src/reducers/config.js @@ -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: diff --git a/src/reducers/entries.js b/src/reducers/entries.js index 6ae5e554..a609fa80 100644 --- a/src/reducers/entries.js +++ b/src/reducers/entries.js @@ -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; diff --git a/src/reducers/index.js b/src/reducers/index.js index 62615bdc..1430bc13 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -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); diff --git a/src/reducers/integrations.js b/src/reducers/integrations.js new file mode 100644 index 00000000..efff00b6 --- /dev/null +++ b/src/reducers/integrations.js @@ -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; diff --git a/src/routing/routes.js b/src/routing/routes.js index 6dc9f55e..9c83a2a0 100644 --- a/src/routing/routes.js +++ b/src/routing/routes.js @@ -13,7 +13,7 @@ export default ( - + ); diff --git a/src/utils.css b/src/utils.css new file mode 100644 index 00000000..85da4449 --- /dev/null +++ b/src/utils.css @@ -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 */ diff --git a/src/valueObjects/Entry.js b/src/valueObjects/Entry.js index ab247a6c..ba000b24 100644 --- a/src/valueObjects/Entry.js +++ b/src/valueObjects/Entry.js @@ -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; } diff --git a/webpack.base.js b/webpack.base.js index 80a24194..2052d935 100644 --- a/webpack.base.js +++ b/webpack.base.js @@ -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), + }, + }), ], };