diff --git a/package.json b/package.json index 83916a98..a1c42b59 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,9 @@ "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", "selection-position": "^1.0.0", "semaphore": "^1.0.5", "slate": "^0.13.6" 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/implementation.js b/src/backends/github/implementation.js index 2d270261..cdb94c86 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(); @@ -53,12 +53,19 @@ export default class GitHub { })); } - entry(collection, slug) { + + // 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); } @@ -72,7 +79,7 @@ export default class GitHub { 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); + const entry = createEntry('draft', entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), entryPath, { raw: data.file }); entry.metaData = data.metaData; resolve(entry); sem.leave(); 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/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 <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, 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 <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, }; 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..3c161adf 100644 --- a/src/containers/CollectionPage.js +++ b/src/containers/CollectionPage.js @@ -9,6 +9,7 @@ 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, @@ -30,16 +31,21 @@ 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}> {entries ? - <EntryListing collection={collection} entries={entries}/> + <EntryListing collections={collection} entries={entries} page={page} onPaginate={this.handleLoadMore}> + Listing {collection.get('name')} + </EntryListing> : <Loader active>{['Loading Entries', 'Caching Entries', 'This might take several minutes']}</Loader> } @@ -58,9 +64,11 @@ 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 <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); 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 ( <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> ); 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; }