From 7fd01e5845af17d6edef31a2dc05ebb653530c91 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 12 Jul 2019 20:18:21 +0000 Subject: [PATCH] [CORL-444] Stories Tab Adjustments (#2404) * feat: improved stories tab * feat: swapped date sorting with text sorting --- client/coral-admin/src/actions/stories.js | 40 +++-- client/coral-admin/src/constants/stories.js | 6 +- client/coral-admin/src/reducers/stories.js | 67 ++++---- .../src/routes/Stories/components/Stories.css | 9 +- .../src/routes/Stories/components/Stories.js | 96 ++++++------ .../src/routes/Stories/containers/Stories.js | 44 +++--- client/coral-framework/services/bootstrap.js | 6 + routes/api/index.js | 1 + routes/api/v2/index.js | 13 ++ routes/api/v2/stories.js | 145 ++++++++++++++++++ 10 files changed, 319 insertions(+), 108 deletions(-) create mode 100644 routes/api/v2/index.js create mode 100644 routes/api/v2/stories.js 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;