Search integration (React Version) (#84)
* algolia integration skeleton * Configuration Defaults * Implemented partial entries with lazy loading of complete file * Moved backend selection logic to actioncreators * basic pagination for entries * general search skeleton * Basic search result listing * Redo search for different search terms * search results pagination * Changing integration config & handling * Changing integration config & handling * new integration config model
This commit is contained in:
parent
45d810a25f
commit
2815a86e0c
@ -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"
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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));
|
||||
|
@ -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]
|
||||
));
|
||||
|
@ -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]
|
||||
));
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -17,7 +17,7 @@ const EditorComponent = Record({
|
||||
});
|
||||
|
||||
|
||||
class Plugin extends Component {
|
||||
class Plugin extends Component { // eslint-disable-line
|
||||
static propTypes = {
|
||||
children: PropTypes.element.isRequired
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
{
|
||||
|
@ -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);
|
||||
|
28
src/integrations/index.js
Normal file
28
src/integrations/index.js
Normal file
@ -0,0 +1,28 @@
|
||||
import Algolia from './providers/algolia/implementation';
|
||||
import { Map } from 'immutable';
|
||||
|
||||
export function resolveIntegrations(interationsConfig) {
|
||||
let integrationInstances = Map({});
|
||||
interationsConfig.get('providers').forEach((providerData, providerName) => {
|
||||
switch (providerName) {
|
||||
case 'algolia':
|
||||
integrationInstances = integrationInstances.set('algolia', new Algolia(providerData));
|
||||
break;
|
||||
}
|
||||
});
|
||||
return integrationInstances;
|
||||
}
|
||||
|
||||
|
||||
export const getIntegrationProvider = (function() {
|
||||
let integrations = null;
|
||||
|
||||
return (interationsConfig, provider) => {
|
||||
if (integrations) {
|
||||
return integrations.get(provider);
|
||||
} else {
|
||||
integrations = resolveIntegrations(interationsConfig);
|
||||
return integrations.get(provider);
|
||||
}
|
||||
};
|
||||
})();
|
123
src/integrations/providers/algolia/implementation.js
Normal file
123
src/integrations/providers/algolia/implementation.js
Normal file
@ -0,0 +1,123 @@
|
||||
import { createEntry } from '../../../valueObjects/Entry';
|
||||
import _ from 'lodash';
|
||||
|
||||
function getSlug(path) {
|
||||
const m = path.match(/([^\/]+?)(\.[^\/\.]+)?$/);
|
||||
return m && m[1];
|
||||
}
|
||||
|
||||
export default class Algolia {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
if (config.get('applicationID') == null ||
|
||||
config.get('apiKey') == null) {
|
||||
throw 'The Algolia search integration needs the credentials (applicationID and apiKey) in the integration configuration.';
|
||||
}
|
||||
|
||||
this.applicationID = config.get('applicationID');
|
||||
this.apiKey = config.get('apiKey');
|
||||
this.searchURL = `https://${this.applicationID}-dsn.algolia.net/1`;
|
||||
|
||||
this.entriesCache = {
|
||||
collection: null,
|
||||
page: null,
|
||||
entries: []
|
||||
};
|
||||
}
|
||||
|
||||
requestHeaders(headers = {}) {
|
||||
return {
|
||||
'X-Algolia-API-Key': this.apiKey,
|
||||
'X-Algolia-Application-Id': this.applicationID,
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
};
|
||||
}
|
||||
|
||||
parseJsonResponse(response) {
|
||||
return response.json().then((json) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(json);
|
||||
}
|
||||
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
urlFor(path, options) {
|
||||
const params = [];
|
||||
if (options.params) {
|
||||
for (const key in options.params) {
|
||||
params.push(`${key}=${encodeURIComponent(options.params[key])}`);
|
||||
}
|
||||
}
|
||||
if (params.length) {
|
||||
path += `?${params.join('&')}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
request(path, options = {}) {
|
||||
const headers = this.requestHeaders(options.headers || {});
|
||||
const url = this.urlFor(path, options);
|
||||
return fetch(url, { ...options, headers: headers }).then((response) => {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType && contentType.match(/json/)) {
|
||||
return this.parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
});
|
||||
}
|
||||
|
||||
search(collections, searchTerm, page) {
|
||||
const searchCollections = collections.map(collection => (
|
||||
{ indexName: collection, params: `query=${searchTerm}&page=${page}` }
|
||||
));
|
||||
|
||||
return this.request(`${this.searchURL}/indexes/*/queries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ requests: searchCollections })
|
||||
}).then(response => {
|
||||
const entries = response.results.map((result, index) => result.hits.map(hit => {
|
||||
const slug = hit.slug || getSlug(hit.path);
|
||||
return createEntry(collections[index], slug, hit.path, { data: hit.data, partial: true });
|
||||
}));
|
||||
|
||||
return { entries: _.flatten(entries), pagination: page };
|
||||
});
|
||||
}
|
||||
|
||||
searchBy(field, collection, query) {
|
||||
return this.request(`${this.searchURL}/indexes/${collection}`, {
|
||||
params: {
|
||||
restrictSearchableAttributes: field,
|
||||
query
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
listEntries(collection, page) {
|
||||
if (this.entriesCache.collection === collection && this.entriesCache.page === page) {
|
||||
return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries });
|
||||
} else {
|
||||
return this.request(`${this.searchURL}/indexes/${collection.get('name')}`, {
|
||||
params: { page }
|
||||
}).then(response => {
|
||||
const entries = response.hits.map(hit => {
|
||||
const slug = hit.slug || getSlug(hit.path);
|
||||
return createEntry(collection.get('name'), slug, hit.path, { data: hit.data, partial: true });
|
||||
});
|
||||
this.entriesCache = { collection, page, entries };
|
||||
return { entries, pagination: response.page };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getEntry(collection, slug) {
|
||||
return this.searchBy('slug', collection.get('name'), slug).then((response) => {
|
||||
const entry = response.hits.filter((hit) => hit.slug === slug)[0];
|
||||
return createEntry(collection.get('name'), slug, entry.path, { data: entry.data, partial: true });
|
||||
});
|
||||
}
|
||||
}
|
@ -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:
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
29
src/reducers/integrations.js
Normal file
29
src/reducers/integrations.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { fromJS } from 'immutable';
|
||||
import { CONFIG_SUCCESS } from '../actions/config';
|
||||
|
||||
const integrations = (state = null, action) => {
|
||||
switch (action.type) {
|
||||
case CONFIG_SUCCESS:
|
||||
const integrations = action.payload.integrations || [];
|
||||
const newState = integrations.reduce((acc, integration) => {
|
||||
const { hooks, collections, provider, ...providerData } = integration;
|
||||
acc.providers[provider] = { ...providerData };
|
||||
collections.forEach(collection => {
|
||||
hooks.forEach(hook => {
|
||||
acc.hooks[collection] ? acc.hooks[collection][hook] = provider : acc.hooks[collection] = { [hook]: provider };
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, { providers:{}, hooks: {} });
|
||||
return fromJS(newState);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const selectIntegration = (state, collection, hook) => {
|
||||
return state.getIn(['hooks', collection, hook], false);
|
||||
};
|
||||
|
||||
|
||||
export default integrations;
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user