diff --git a/client/coral-admin/src/actions/stories.js b/client/coral-admin/src/actions/stories.js
index 9927f3cf0..643ccd9a0 100644
--- a/client/coral-admin/src/actions/stories.js
+++ b/client/coral-admin/src/actions/stories.js
@@ -4,13 +4,15 @@ import {
FETCH_ASSETS_REQUEST,
FETCH_ASSETS_SUCCESS,
FETCH_ASSETS_FAILURE,
+ LOAD_MORE_ASSETS_REQUEST,
+ LOAD_MORE_ASSETS_SUCCESS,
+ LOAD_MORE_ASSETS_FAILURE,
SET_PAGE,
SET_SEARCH_VALUE,
SET_CRITERIA,
UPDATE_ASSET_STATE_REQUEST,
UPDATE_ASSET_STATE_SUCCESS,
UPDATE_ASSET_STATE_FAILURE,
- UPDATE_ASSETS,
} from '../constants/stories';
import t from 'coral-framework/services/i18n';
@@ -21,17 +23,14 @@ import t from 'coral-framework/services/i18n';
// Fetch a page of assets
// Get comments to fill each of the three lists on the mod queue
-export const fetchAssets = (query = {}) => (dispatch, _, { rest }) => {
+export const fetchAssets = (query = {}) => (dispatch, _, { rest2 }) => {
dispatch({ type: FETCH_ASSETS_REQUEST });
- return rest(`/assets?${queryString.stringify(query)}`)
- .then(({ result, page, count, limit, totalPages }) =>
+ return rest2(`/stories?${queryString.stringify(query)}`)
+ .then(({ edges, pageInfo }) =>
dispatch({
type: FETCH_ASSETS_SUCCESS,
- assets: result,
- page,
- count,
- limit,
- totalPages,
+ edges,
+ pageInfo,
})
)
.catch(error => {
@@ -43,6 +42,25 @@ export const fetchAssets = (query = {}) => (dispatch, _, { rest }) => {
});
};
+export const loadMoreAssets = (query = {}) => (dispatch, _l, { rest2 }) => {
+ dispatch({ type: LOAD_MORE_ASSETS_REQUEST });
+ return rest2(`/stories?${queryString.stringify(query)}`)
+ .then(({ edges, pageInfo }) =>
+ dispatch({
+ type: LOAD_MORE_ASSETS_SUCCESS,
+ edges,
+ pageInfo,
+ })
+ )
+ .catch(error => {
+ console.error(error);
+ const errorMessage = error.translation_key
+ ? t(`error.${error.translation_key}`)
+ : error.toString();
+ dispatch({ type: LOAD_MORE_ASSETS_FAILURE, error: errorMessage });
+ });
+};
+
// Update an asset state
// Get comments to fill each of the three lists on the mod queue
export const updateAssetState = (id, closedAt) => (dispatch, _, { rest }) => {
@@ -58,10 +76,6 @@ export const updateAssetState = (id, closedAt) => (dispatch, _, { rest }) => {
});
};
-export const updateAssets = assets => dispatch => {
- dispatch({ type: UPDATE_ASSETS, assets });
-};
-
export const setPage = page => ({
type: SET_PAGE,
page,
diff --git a/client/coral-admin/src/constants/stories.js b/client/coral-admin/src/constants/stories.js
index d1994e19a..978979f62 100644
--- a/client/coral-admin/src/constants/stories.js
+++ b/client/coral-admin/src/constants/stories.js
@@ -4,12 +4,14 @@ export const FETCH_ASSETS_REQUEST = `${prefix}_FETCH_ASSETS_REQUEST`;
export const FETCH_ASSETS_SUCCESS = `${prefix}_FETCH_ASSETS_SUCCESS`;
export const FETCH_ASSETS_FAILURE = `${prefix}_FETCH_ASSETS_FAILURE`;
+export const LOAD_MORE_ASSETS_REQUEST = `${prefix}_LOAD_MORE_ASSETS_REQUEST`;
+export const LOAD_MORE_ASSETS_SUCCESS = `${prefix}_LOAD_MORE_ASSETS_SUCCESS`;
+export const LOAD_MORE_ASSETS_FAILURE = `${prefix}_LOAD_MORE_ASSETS_FAILURE`;
+
export const UPDATE_ASSET_STATE_REQUEST = `${prefix}_UPDATE_ASSET_STATE_REQUEST`;
export const UPDATE_ASSET_STATE_SUCCESS = `${prefix}_UPDATE_ASSET_STATE_SUCCESS`;
export const UPDATE_ASSET_STATE_FAILURE = `${prefix}_UPDATE_ASSET_STATE_FAILURE`;
-export const UPDATE_ASSETS = `${prefix}_UPDATE_ASSETS`;
-
export const SET_PAGE = `${prefix}_SET_PAGE`;
export const SET_SEARCH_VALUE = `${prefix}_SET_SEARCH_VALUE`;
export const SET_CRITERIA = `${prefix}_SET_CRITERIA`;
diff --git a/client/coral-admin/src/reducers/stories.js b/client/coral-admin/src/reducers/stories.js
index 4a069c82e..67f3c9f00 100644
--- a/client/coral-admin/src/reducers/stories.js
+++ b/client/coral-admin/src/reducers/stories.js
@@ -3,33 +3,57 @@ import update from 'immutability-helper';
const initialState = {
assets: {
- byId: {},
- ids: [],
- assets: [],
+ edges: [],
+ pageInfo: {},
},
searchValue: '',
criteria: {
- asc: 'false',
filter: 'all',
- limit: 20,
},
+ loading: true,
+ loadingMore: false,
};
export default function assets(state = initialState, action) {
switch (action.type) {
- case actions.FETCH_ASSETS_SUCCESS: {
- const assets = action.assets.reduce((prev, curr) => {
- prev[curr.id] = curr;
- return prev;
- }, {});
-
+ case actions.FETCH_ASSETS_REQUEST: {
return update(state, {
+ loading: { $set: true },
+ });
+ }
+ case actions.FETCH_ASSETS_FAILURE: {
+ return update(state, {
+ loading: { $set: false },
+ });
+ }
+ case actions.FETCH_ASSETS_SUCCESS: {
+ return update(state, {
+ loading: { $set: false },
assets: {
- totalPages: { $set: action.totalPages },
- page: { $set: action.page },
- byId: { $set: assets },
- count: { $set: action.count },
- ids: { $set: Object.keys(assets) },
+ edges: { $set: action.edges },
+ pageInfo: { $set: action.pageInfo },
+ },
+ });
+ }
+ case actions.LOAD_MORE_ASSETS_REQUEST: {
+ return update(state, {
+ loadingMore: { $set: true },
+ });
+ }
+ case actions.LOAD_MORE_ASSETS_FAILURE: {
+ return update(state, {
+ loadingMore: { $set: false },
+ });
+ }
+ case actions.LOAD_MORE_ASSETS_SUCCESS: {
+ return update(state, {
+ loadingMore: { $set: false },
+ assets: {
+ edges: { $push: action.edges },
+ pageInfo: {
+ endCursor: { $set: action.pageInfo.endCursor },
+ hasNextPage: { $set: action.pageInfo.hasNextPage },
+ },
},
});
}
@@ -43,17 +67,6 @@ export default function assets(state = initialState, action) {
},
},
});
- case actions.UPDATE_ASSETS:
- return update(state, {
- assets: {
- assets: { $set: action.assets },
- },
- });
- case actions.SET_PAGE:
- return {
- ...state,
- page: action.page,
- };
case actions.SET_SEARCH_VALUE:
return {
...state,
diff --git a/client/coral-admin/src/routes/Stories/components/Stories.css b/client/coral-admin/src/routes/Stories/components/Stories.css
index 76c3e8e7e..5d642358f 100644
--- a/client/coral-admin/src/routes/Stories/components/Stories.css
+++ b/client/coral-admin/src/routes/Stories/components/Stories.css
@@ -77,7 +77,6 @@
display: block;
}
-
.hidden {
display: none;
}
@@ -95,3 +94,11 @@
text-align: left;
}
+.loadMore {
+ margin: 20px auto;
+ text-align: center;
+}
+
+.loadMoreSpinner {
+ margin: 20px auto 50px;
+}
diff --git a/client/coral-admin/src/routes/Stories/components/Stories.js b/client/coral-admin/src/routes/Stories/components/Stories.js
index fa0e33db5..a396c9d66 100644
--- a/client/coral-admin/src/routes/Stories/components/Stories.js
+++ b/client/coral-admin/src/routes/Stories/components/Stories.js
@@ -2,11 +2,14 @@ import React, { Component } from 'react';
import cn from 'classnames';
import { Link } from 'react-router';
import PropTypes from 'prop-types';
-import { Dropdown, Option, Paginate, Icon } from 'coral-ui';
import { DataTable, TableHeader, RadioGroup, Radio } from 'react-mdl';
+
import t from 'coral-framework/services/i18n';
-import styles from './Stories.css';
+import { Dropdown, Option, Icon, Spinner } from 'coral-ui';
import EmptyCard from 'coral-admin/src/components/EmptyCard';
+import LoadMore from 'coral-admin/src/components/LoadMore';
+
+import styles from './Stories.css';
class Stories extends Component {
renderDate = date => {
@@ -39,10 +42,11 @@ class Stories extends Component {
filter,
onSearchChange,
onSettingChange,
- onPageChange,
- asc,
+ onLoadMore,
+ loading,
+ loadingMore,
} = this.props;
- const rows = assets.ids.map(id => assets.byId[id]);
+ const rows = assets.edges.map(({ node }) => node);
return (
@@ -74,60 +78,58 @@ class Stories extends Component {
{t('streams.open')}
{t('streams.closed')}
-
{t('streams.sort_by')}
-
- {t('streams.newest')}
- {t('streams.oldest')}
-
- {rows.length ? (
-
-
-
- {t('streams.article')}
-
-
- {t('streams.pubdate')}
-
-
- {t('streams.status')}
-
-
-
-
- ) : (
- {t('streams.empty_result')}
- )}
+
+ {loading ? (
+
+ ) : rows.length ? (
+
+
+
+ {t('streams.article')}
+
+
+ {t('streams.pubdate')}
+
+
+ {t('streams.status')}
+
+
+ {loadingMore ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
{t('streams.empty_result')}
+ )}
+
);
}
}
Stories.propTypes = {
+ loading: PropTypes.bool,
+ loadingMore: PropTypes.bool,
assets: PropTypes.object,
searchValue: PropTypes.string,
- asc: PropTypes.string,
filter: PropTypes.string,
+ onLoadMore: PropTypes.func.isRequired,
onStatusChange: PropTypes.func.isRequired,
onSearchChange: PropTypes.func.isRequired,
- onPageChange: PropTypes.func.isRequired,
onSettingChange: PropTypes.func.isRequired,
};
diff --git a/client/coral-admin/src/routes/Stories/containers/Stories.js b/client/coral-admin/src/routes/Stories/containers/Stories.js
index 7549855c0..88b126f16 100644
--- a/client/coral-admin/src/routes/Stories/containers/Stories.js
+++ b/client/coral-admin/src/routes/Stories/containers/Stories.js
@@ -5,9 +5,9 @@ import { bindActionCreators } from 'redux';
import {
fetchAssets,
updateAssetState,
- setPage,
setSearchValue,
setCriteria,
+ loadMoreAssets,
} from 'coral-admin/src/actions/stories';
import Stories from '../components/Stories';
@@ -35,13 +35,11 @@ class StoriesContainer extends Component {
};
fetchAssets = query => {
- const { searchValue, asc, filter, limit } = this.props;
+ const { searchValue: value, filter } = this.props;
this.props.fetchAssets({
- value: searchValue,
- asc,
+ value,
filter,
- limit,
...query,
});
};
@@ -57,24 +55,34 @@ class StoriesContainer extends Component {
}
};
- onPageChange = ({ selected }) => {
- const page = selected + 1;
- this.props.setPage(page);
- this.fetchAssets({ page });
+ onLoadMore = async () => {
+ const {
+ searchValue: value,
+ filter,
+ assets: {
+ pageInfo: { endCursor: cursor },
+ },
+ loadMoreAssets,
+ } = this.props;
+ try {
+ await loadMoreAssets({ cursor, value, filter });
+ } catch (err) {
+ console.error(err);
+ }
};
render() {
return (
);
}
@@ -82,34 +90,34 @@ class StoriesContainer extends Component {
const mapStateToProps = ({ stories }) => ({
assets: stories.assets,
+ loading: stories.loading,
+ loadingMore: stories.loadingMore,
searchValue: stories.searchValue,
- asc: stories.criteria.asc,
filter: stories.criteria.filter,
- limit: stories.criteria.limit,
});
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
- setPage,
setCriteria,
setSearchValue,
fetchAssets,
updateAssetState,
+ loadMoreAssets,
},
dispatch
);
StoriesContainer.propTypes = {
+ loading: PropTypes.bool,
+ loadingMore: PropTypes.bool,
assets: PropTypes.object,
searchValue: PropTypes.string,
- asc: PropTypes.string,
filter: PropTypes.string,
- limit: PropTypes.number,
- setPage: PropTypes.func.isRequired,
setCriteria: PropTypes.func.isRequired,
setSearchValue: PropTypes.func.isRequired,
fetchAssets: PropTypes.func.isRequired,
+ loadMoreAssets: PropTypes.func.isRequired,
updateAssetState: PropTypes.func.isRequired,
};
diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js
index 300cd3e70..d0469d6d4 100644
--- a/client/coral-framework/services/bootstrap.js
+++ b/client/coral-framework/services/bootstrap.js
@@ -159,6 +159,11 @@ export async function createContext({
token,
});
+ const rest2 = createRestClient({
+ uri: `${BASE_PATH}api/v2`,
+ token,
+ });
+
const staticConfig = getStaticConfiguration();
let { LIVE_URI: liveUri, BASE_ORIGIN: origin } = staticConfig;
if (liveUri == null) {
@@ -193,6 +198,7 @@ export async function createContext({
plugins,
eventEmitter,
rest,
+ rest2,
graphql,
notification,
localStorage,
diff --git a/routes/api/index.js b/routes/api/index.js
index f6651a4f5..29a78e887 100644
--- a/routes/api/index.js
+++ b/routes/api/index.js
@@ -3,5 +3,6 @@ const router = express.Router();
// Return the current version.
router.use('/v1', require('./v1'));
+router.use('/v2', require('./v2'));
module.exports = router;
diff --git a/routes/api/v2/index.js b/routes/api/v2/index.js
new file mode 100644
index 000000000..ce40fe276
--- /dev/null
+++ b/routes/api/v2/index.js
@@ -0,0 +1,13 @@
+const express = require('express');
+const { version } = require('../../../package.json');
+const { REVISION_HASH } = require('../../../config');
+const router = express.Router();
+
+// Return the current version.
+router.get('/', (req, res) => {
+ res.json({ version, revision: REVISION_HASH });
+});
+
+router.use('/stories', require('./stories'));
+
+module.exports = router;
diff --git a/routes/api/v2/stories.js b/routes/api/v2/stories.js
new file mode 100644
index 000000000..05ae09d48
--- /dev/null
+++ b/routes/api/v2/stories.js
@@ -0,0 +1,145 @@
+const express = require('express');
+const Joi = require('joi');
+const { get, first, last } = require('lodash');
+
+const authorization = require('../../../middleware/authorization');
+const AssetModel = require('../../../models/asset');
+
+const router = express.Router();
+
+const ListStorySchema = Joi.object({
+ value: Joi.string()
+ .empty('')
+ .default(''),
+ filter: Joi.string()
+ .empty('')
+ .valid(['all', 'open', 'closed'])
+ .default('all'),
+ limit: Joi.number()
+ .empty('')
+ .default(20)
+ .max(500)
+ .min(0),
+ cursor: Joi.string()
+ .empty('')
+ .default(''),
+});
+
+function validate(query) {
+ const { value, error } = Joi.validate(query, ListStorySchema, {
+ presence: 'optional',
+ });
+ if (error) {
+ throw error;
+ }
+
+ return value;
+}
+
+router.get(
+ '/',
+ authorization.needed('ADMIN', 'MODERATOR'),
+ async (req, res, next) => {
+ try {
+ // Validate and extract the query arguments.
+ let { cursor, value, filter, limit } = validate(req.query);
+ const isTextBasedSearch = value.length > 0;
+
+ // The cursor can be a date or a number based on the style of search being
+ // performed.
+ cursor = Joi.attempt(
+ cursor,
+ isTextBasedSearch
+ ? Joi.number()
+ .empty('')
+ .min(0)
+ .default(0)
+ : Joi.date()
+ .empty('')
+ .default(null)
+ );
+
+ // Create a new query to begin adding conditions.
+ let query = AssetModel.find(
+ {},
+ isTextBasedSearch ? { score: { $meta: 'textScore' } } : {}
+ );
+
+ if (filter === 'open') {
+ // Filter by open stories.
+ query.merge({
+ $or: [{ closedAt: null }, { closedAt: { $gt: Date.now() } }],
+ });
+ } else if (filter === 'closed') {
+ // Filter by closed stories.
+ query.merge({
+ closedAt: {
+ $lt: Date.now(),
+ },
+ });
+ }
+
+ if (isTextBasedSearch) {
+ // This is a text based search, so search by the value.
+ query.merge({
+ $text: {
+ $search: value,
+ },
+ });
+
+ // Sort by text search score.
+ query.sort({ score: { $meta: 'textScore' } });
+
+ if (cursor) {
+ // We are paginating, so we should skip stories based on the cursor.
+ query.skip(cursor);
+ }
+ } else {
+ // This is not a text based search, so sort by the created timestamp.
+ query.sort({ created_at: -1 });
+
+ if (cursor) {
+ // We are paginating, so we should sort based on the created
+ // timestamp.
+ query.merge({
+ created_at: {
+ $lt: cursor,
+ },
+ });
+ }
+ }
+
+ // Execute the query.
+ const results = await query.limit(limit + 1);
+
+ const textTransformer = (node, idx) => ({
+ node,
+ cursor: idx + cursor + 1,
+ });
+
+ const dateTransformer = node => ({
+ node,
+ cursor: node.created_at,
+ });
+
+ // Slice the nodes to get only the requested number of elements.
+ const edges = results
+ .slice(0, limit)
+ .map(isTextBasedSearch ? textTransformer : dateTransformer);
+
+ // Generate the pageInfo.
+ const pageInfo = {
+ startCursor: get(first(edges), 'cursor', null),
+ endCursor: get(last(edges), 'cursor', null),
+ hasNextPage: results.length > limit,
+ };
+
+ // Send back the asset data.
+ return res.json({ edges, pageInfo });
+ } catch (err) {
+ return next(err);
+ }
+ }
+);
+
+module.exports = router;