From ed959ca07a4d7f0bfa2696375a1e525b82ea0ea4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 11 Dec 2017 20:21:27 +0100 Subject: [PATCH 01/22] Integrate react-virtualized --- .../src/components/CommentDetails.js | 6 +- .../routes/Moderation/components/Comment.css | 1 + .../routes/Moderation/components/Comment.js | 6 + .../components/ModerationLayout.css | 3 + .../Moderation/components/ModerationQueue.css | 9 +- .../Moderation/components/ModerationQueue.js | 154 +++++++++++++----- .../Moderation/components/ViewOptions.css | 1 + .../Moderation/containers/Moderation.js | 2 +- package.json | 1 + yarn.lock | 16 +- 10 files changed, 148 insertions(+), 51 deletions(-) create mode 100644 client/coral-admin/src/routes/Moderation/components/ModerationLayout.css diff --git a/client/coral-admin/src/components/CommentDetails.js b/client/coral-admin/src/components/CommentDetails.js index cd504a40f..418be0354 100644 --- a/client/coral-admin/src/components/CommentDetails.js +++ b/client/coral-admin/src/components/CommentDetails.js @@ -21,10 +21,11 @@ class CommentDetails extends Component { this.setState((state) => ({ showDetail: !state.showDetail })); + this.props.clearHeightCache && this.props.clearHeightCache(); } render() { - const {data, root, comment} = this.props; + const {data, root, comment, clearHeightCache} = this.props; const {showDetail} = this.state; const queryData = { root, @@ -44,12 +45,14 @@ class CommentDetails extends Component { {showDetail && } @@ -61,6 +64,7 @@ CommentDetails.propTypes = { data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, comment: PropTypes.object.isRequired, + clearHeightCache: PropTypes.func, }; export default CommentDetails; diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css index 10cc707fb..029a8e5d1 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.css +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -10,6 +10,7 @@ position: relative; transition: all 200ms; padding: 10px 0; + margin-top: 13px; min-height: 0; /* diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index cea49720b..9f913695a 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -65,6 +65,7 @@ class Comment extends React.Component { root: {settings}, currentUserId, currentAsset, + clearHeightCache, } = this.props; const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; @@ -114,6 +115,7 @@ class Comment extends React.Component { @@ -145,6 +147,7 @@ class Comment extends React.Component {
@@ -166,6 +169,7 @@ class Comment extends React.Component {
@@ -176,6 +180,7 @@ class Comment extends React.Component { data={data} root={root} comment={comment} + clearHeightCache={clearHeightCache} /> ); @@ -191,6 +196,7 @@ Comment.propTypes = { showBanUserDialog: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func.isRequired, currentUserId: PropTypes.string.isRequired, + clearHeightCache: PropTypes.func, comment: PropTypes.shape({ id: PropTypes.string.isRequired, status: PropTypes.string.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationLayout.css b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.css new file mode 100644 index 000000000..7214b4550 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ModerationLayout.css @@ -0,0 +1,3 @@ +.root { + height: 100%; +} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css index 6f1d4ae95..f9e3aa59a 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css @@ -2,7 +2,10 @@ padding: 8px 0; list-style: none; display: block; - margin-top: 16px; +} + +:global(html) { + height: inherit; } .list { @@ -15,12 +18,12 @@ } .commentLeaveActive { - opacity: 0; + opacity: 0.1; transition: opacity 800ms; } .commentEnter { - opacity: 0; + opacity: 0.1; } .commentEnterActive { diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index dc4a8c74c..a2c09c5df 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -7,7 +7,8 @@ import EmptyCard from '../../../components/EmptyCard'; import LoadMore from '../../../components/LoadMore'; import ViewMore from './ViewMore'; import t from 'coral-framework/services/i18n'; -import {CSSTransitionGroup} from 'react-transition-group'; +import {WindowScroller, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized'; +import throttle from 'lodash/throttle'; const hasComment = (nodes, id) => nodes.some((node) => node.id === id); @@ -43,14 +44,37 @@ function invalidateCursor(invalidated, state, props) { return {idCursors}; } +let keyMapper = null; + +// In this example, average cell height is assumed to be about 50px. +// This value will be used for the initial `Grid` layout. +// Width is not dynamic. +const cache = new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 250, + keyMapper: (index) => keyMapper(index), +}); + class ModerationQueue extends React.Component { isLoadingMore = false; + cache = cache; + listRef = null; constructor(props) { super(props); this.state = { ...resetCursors(this.state, props), }; + keyMapper = (index) => { + const view = this.getVisibleComments(); + if (index < view.length) { + return view[index].id; + } + else if (index === view.length) { + return 'loadMore'; + } + throw new Error(`unknown index ${index}`); + }; } componentDidUpdate (prev) { @@ -64,6 +88,10 @@ class ModerationQueue extends React.Component { } } + handleListRef = (list) => { + this.listRef = list; + }; + componentWillReceiveProps(next) { const {comments: prevComments} = this.props; const {comments: nextComments} = next; @@ -94,6 +122,11 @@ class ModerationQueue extends React.Component { this.setState(resetCursors); }; + reflowList = throttle(() => { + this.cache.clearAll(); + this.listRef.recomputeRowHeights(); + }, 500); + // getVisibileComments returns a list containing comments // which comes after the `idCursor`. getVisibleComments() { @@ -117,14 +150,68 @@ class ModerationQueue extends React.Component { return view; } + rowRenderer = ({ + index, // Index of row within collection + parent, + style // Style object to be applied to row (to position it) + }) => { + if (index === parent.props.rowCount - 1) { + return ( + +
+ +
+
+ ); + } + const comment = this.props.comments[index]; + return ( + +
+ {this.cache.clear(index); this.listRef.recomputeRowHeights(index); }} + /> +
+
+ ); + }; + render () { const { comments, selectedCommentId, - commentCount, singleView, viewUserDetail, - activeTab, ...props } = this.props; @@ -167,46 +254,27 @@ class ModerationQueue extends React.Component { viewMore={this.viewNewComments} count={comments.length - view.length} /> - - { - view - .map((comment) => { - return ; - }) - } - - - + + {({height, isScrolling, onChildScroll, scrollTop}) => ( + + )} + ); } diff --git a/client/coral-admin/src/routes/Moderation/components/ViewOptions.css b/client/coral-admin/src/routes/Moderation/components/ViewOptions.css index 3e5aae32d..68f089c27 100644 --- a/client/coral-admin/src/routes/Moderation/components/ViewOptions.css +++ b/client/coral-admin/src/routes/Moderation/components/ViewOptions.css @@ -8,6 +8,7 @@ overflow: visible; height: 144px; min-height: auto; + margin-top: 16px; z-index: 10; @media (--tablet) { diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index ca6f0224a..b936b4c30 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -178,7 +178,7 @@ class ModerationContainer extends Component { loadMore = (tab) => { const variables = { - limit: 10, + limit: 50, cursor: this.props.root[tab].endCursor, sortOrder: this.props.data.variables.sortOrder, asset_id: this.props.data.variables.asset_id, diff --git a/package.json b/package.json index 4d556c48b..f16053ab5 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "react-test-renderer": "15.5", "react-toastify": "^1.5.0", "react-transition-group": "^1.1.3", + "react-virtualized": "9.9.0", "recompose": "^0.23.1", "redux": "^3.6.0", "redux-thunk": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index c01e2e85a..81fb50604 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1020,7 +1020,7 @@ babel-register@^6.26.0: mkdirp "^0.5.1" source-map-support "^0.4.15" -babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: +babel-runtime@^6.11.6, babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" dependencies: @@ -2402,7 +2402,7 @@ doctypes@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" -dom-helpers@^3.2.0: +"dom-helpers@^2.4.0 || ^3.0.0", dom-helpers@^3.2.0: version "3.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a" @@ -5451,7 +5451,7 @@ longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -7464,6 +7464,16 @@ react-transition-group@^1.1.2, react-transition-group@^1.1.3: prop-types "^15.5.6" warning "^3.0.0" +react-virtualized@9.9.0: + version "9.9.0" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.9.0.tgz#799a6f23819eeb82860d59b82fad33d1d420325e" + dependencies: + babel-runtime "^6.11.6" + classnames "^2.2.3" + dom-helpers "^2.4.0 || ^3.0.0" + loose-envify "^1.3.0" + prop-types "^15.5.4" + react@^15.3.1, react@^15.4.2: version "15.6.2" resolved "https://registry.yarnpkg.com/react/-/react-15.6.2.tgz#dba0434ab439cfe82f108f0f511663908179aa72" From f32e4523e8eb7108e68a64636beb190e217fcb84 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 12 Dec 2017 12:53:54 +0100 Subject: [PATCH 02/22] Make view more coments work with virtualized --- .../src/routes/Moderation/components/ModerationQueue.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index a2c09c5df..d73cd04a5 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -119,7 +119,7 @@ class ModerationQueue extends React.Component { } viewNewComments = () => { - this.setState(resetCursors); + this.setState(resetCursors, () => this.reflowList()); }; reflowList = throttle(() => { @@ -155,7 +155,9 @@ class ModerationQueue extends React.Component { parent, style // Style object to be applied to row (to position it) }) => { - if (index === parent.props.rowCount - 1) { + const view = this.getVisibleComments(); + const rowCount = view.length + 1; + if (index === rowCount - 1) { return ( ); } - const comment = this.props.comments[index]; + + const comment = view[index]; return ( Date: Tue, 12 Dec 2017 17:19:11 +0100 Subject: [PATCH 03/22] Reimplement keyboard navigation --- client/coral-admin/src/actions/moderation.js | 5 ++ .../coral-admin/src/constants/moderation.js | 1 + client/coral-admin/src/reducers/moderation.js | 6 ++ .../routes/Moderation/components/Comment.css | 1 + .../routes/Moderation/components/Comment.js | 21 +++++ .../Moderation/components/Moderation.js | 90 ++----------------- .../Moderation/components/ModerationQueue.js | 60 ++++++++++++- .../Moderation/containers/Moderation.js | 5 +- 8 files changed, 102 insertions(+), 87 deletions(-) diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index a3ff2ca3e..18e31091f 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -34,3 +34,8 @@ export const storySearchChange = (value) => ({ export const clearState = () => ({ type: actions.MODERATION_CLEAR_STATE }); + +export const selectCommentId = (id) => ({ + type: actions.MODERATION_SELECT_COMMENT, + id, +}); diff --git a/client/coral-admin/src/constants/moderation.js b/client/coral-admin/src/constants/moderation.js index cae13947d..8eb939d76 100644 --- a/client/coral-admin/src/constants/moderation.js +++ b/client/coral-admin/src/constants/moderation.js @@ -6,3 +6,4 @@ export const SHOW_STORY_SEARCH = 'SHOW_STORY_SEARCH'; export const HIDE_STORY_SEARCH = 'HIDE_STORY_SEARCH'; export const STORY_SEARCH_CHANGE_VALUE = 'STORY_SEARCH_CHANGE_VALUE'; export const MODERATION_CLEAR_STATE = 'MODERATION_CLEAR_STATE'; +export const MODERATION_SELECT_COMMENT = 'MODERATION_SELECT_COMMENT'; diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js index 43ae81573..6a64b380a 100644 --- a/client/coral-admin/src/reducers/moderation.js +++ b/client/coral-admin/src/reducers/moderation.js @@ -7,6 +7,7 @@ const initialState = { storySearchString: '', shortcutsNoteVisible: 'show', sortOrder: 'DESC', + selectedCommentId: '', }; export default function moderation (state = initialState, action) { @@ -51,6 +52,11 @@ export default function moderation (state = initialState, action) { ...state, sortOrder: action.order, }; + case actions.MODERATION_SELECT_COMMENT: + return { + ...state, + selectedCommentId: action.id, + }; default: return state; } diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css index 029a8e5d1..708761b95 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.css +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -12,6 +12,7 @@ padding: 10px 0; margin-top: 13px; min-height: 0; + outline: 0; /* Fix rendering issues in Safari by promoting this diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 9f913695a..4c08f1559 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -20,6 +20,16 @@ import t, {timeago} from 'coral-framework/services/i18n'; class Comment extends React.Component { + ref = null; + + handleRef = (ref) => this.ref = ref; + + handleFocusOrClick = () => { + if (!this.props.selected) { + this.props.selectComment(); + } + }; + showSuspendUserDialog = () => { const {comment, showSuspendUserDialog} = this.props; return showSuspendUserDialog({ @@ -55,6 +65,12 @@ class Comment extends React.Component { : this.props.rejectComment({commentId: this.props.comment.id}) ); + componentDidUpdate(prev) { + if (!prev.selected && this.props.selected) { + this.ref.focus(); + } + } + render() { const { comment, @@ -76,6 +92,9 @@ class Comment extends React.Component { tabIndex={0} className={cn(className, 'mdl-card', selectionStateCSS, styles.root, {[styles.selected]: selected}, 'talk-admin-moderate-comment')} id={`comment_${comment.id}`} + onClick={this.handleFocusOrClick} + ref={this.handleRef} + onFocus={this.handleFocusOrClick} >
@@ -190,7 +209,9 @@ class Comment extends React.Component { Comment.propTypes = { viewUserDetail: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, + selectComment: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, + onClick: PropTypes.func, className: PropTypes.string, currentAsset: PropTypes.object, showBanUserDialog: PropTypes.func.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 392db36e3..594d5d57f 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -12,15 +12,8 @@ import Slot from 'coral-framework/components/Slot'; import ViewOptions from './ViewOptions'; class Moderation extends Component { - constructor(props) { - super(props); - const comments = this.getComments(props); - this.state = { - selectedCommentId: comments[0] ? comments[0].id : null, - }; - - } + state = {}; componentWillMount() { const {toggleModal, singleView} = this.props; @@ -30,8 +23,6 @@ class Moderation extends Component { key('esc', () => toggleModal(false)); key('ctrl+f', () => this.openSearch()); key('t', () => this.nextQueue()); - key('j', () => this.select(true)); - key('k', () => this.select(false)); key('f', () => this.moderate(false)); key('d', () => this.moderate(true)); this.getMenuItems() @@ -96,64 +87,6 @@ class Moderation extends Component { return root[activeTab].nodes; } - scrollTo = (toId, smooth = true) => - document.querySelector(`#comment_${toId}`).scrollIntoView(smooth ? {behavior: 'smooth'} : {}); - - select = async (next, props = this.props, selectedCommentId = this.state.selectedCommentId) => { - const comments = this.getComments(props); - - // No comments to be selected. - if (comments.length === 0){ - return; - } - - // Find current index if we have a selected comment. - const index = selectedCommentId - ? comments.findIndex((comment) => comment.id === selectedCommentId) - : null; - - if (next) { - - // Grab first one if we don't have a selected comment yet. - if (!selectedCommentId) { - this.setState({selectedCommentId: comments[0].id}, () => this.scrollTo(comments[0].id)); - return; - } - - // Select next one when we still have more comments left. - if (index < comments.length - 1) { - this.setState({selectedCommentId: comments[index + 1].id}, () => this.scrollTo(comments[index + 1].id)); - return; - } else { - - // We hit the end of the list, load more comments if we have. - if (comments.length < this.getActiveTabCount()) { - const res = await this.loadMore(); - - // If `loadMore` was already in progress, res would be false. - if (res) { - - // Select next comment after loading has completed. - this.select(true); - } - } - return; - } - } else { - - // We have no selected comment, so just skip it. - if (!selectedCommentId) { - return; - } - - // If we still have previous comments take the one before. - if (index > 0) { - this.setState({selectedCommentId: comments[index - 1].id}, () => this.scrollTo(comments[index - 1].id)); - return; - } - } - } - loadMore = async () => { if (!this.isLoadingMore) { this.isLoadingMore = true; @@ -176,8 +109,6 @@ class Moderation extends Component { key.unbind('esc'); key.unbind('ctrl+f'); key.unbind('t'); - key.unbind('j'); - key.unbind('k'); key.unbind('f'); key.unbind('d'); this.getMenuItems() @@ -186,11 +117,8 @@ class Moderation extends Component { componentWillReceiveProps(nextProps) { - if (this.props.activeTab !== nextProps.activeTab) { - - // Reset selection when changing tabs. - this.select(true, nextProps, null); - } else { + // TODO: Adapt to react virtualized. + if (this.props.activeTab === nextProps.activeTab) { // Detect if comment has left the queue and find next or prev selected comment to set it // as the new selectedCommentId. @@ -218,14 +146,6 @@ class Moderation extends Component { } } - componentDidUpdate(prevProps) { - - // Scroll to comment when changing from single wiew to normal view. - if (prevProps.moderation.singleView !== this.props.moderation.singleView && this.state.selectedCommentId) { - this.scrollTo(this.state.selectedCommentId, false); - } - } - render () { const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; const {asset} = root; @@ -268,7 +188,7 @@ class Moderation extends Component { comments={comments.nodes} activeTab={activeTab} singleView={moderation.singleView} - selectedCommentId={this.state.selectedCommentId} + selectedCommentId={moderation.selectedCommentId} showBanUserDialog={props.showBanUserDialog} showSuspendUserDialog={props.showSuspendUserDialog} acceptComment={props.acceptComment} @@ -277,6 +197,7 @@ class Moderation extends Component { commentCount={activeTabCount} currentUserId={this.props.auth.user.id} viewUserDetail={viewUserDetail} + selectCommentId={props.selectCommentId} /> nodes.some((node) => node.id === id); @@ -75,6 +76,46 @@ class ModerationQueue extends React.Component { } throw new Error(`unknown index ${index}`); }; + const view = this.getVisibleComments(); + if (view.length) { + props.selectCommentId(view[0].id); + } + } + + componentDidMount() { + key('j', () => this.selectDown()); + key('k', () => this.selectUp()); + } + + componentWillUnmount() { + key.unbind('j'); + key.unbind('k'); + } + + async selectDown() { + const view = this.getVisibleComments(); + const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + if (index === view.length - 1 && this.props.comments.length !== this.props.commentCount) { + await this.props.loadMore(); + this.selectDown(); + return; + } + if (index < view.length - 1) { + this.props.selectCommentId(view[index + 1].id); + } + } + + selectUp() { + const view = this.getVisibleComments(); + const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + + if (index === 0 && view.length < this.props.comments.length) { + this.viewNewComments(() => this.selectUp()); + return; + } + if (index > 0) { + this.props.selectCommentId(view[index - 1].id); + } } componentDidUpdate (prev) { @@ -86,6 +127,15 @@ class ModerationQueue extends React.Component { if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) { this.props.loadMore(); } + + // Scroll to selected comment. + if (prev.selectedCommentId !== this.props.selectedCommentId) { + + const view = this.getVisibleComments(); + const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + + this.listRef.scrollToRow(index); + } } handleListRef = (list) => { @@ -118,8 +168,11 @@ class ModerationQueue extends React.Component { } } - viewNewComments = () => { - this.setState(resetCursors, () => this.reflowList()); + viewNewComments = (callback) => { + this.setState(resetCursors, () => { + this.reflowList(); + callback && callback(); + }); }; reflowList = throttle(() => { @@ -203,6 +256,7 @@ class ModerationQueue extends React.Component { currentAsset={this.props.currentAsset} currentUserId={this.props.currentUserId} clearHeightCache={() => {this.cache.clear(index); this.listRef.recomputeRowHeights(index); }} + selectComment={() => this.props.selectCommentId(comment.id)} />
@@ -244,6 +298,7 @@ class ModerationQueue extends React.Component { rejectComment={props.rejectComment} currentAsset={props.currentAsset} currentUserId={this.props.currentUserId} + selectComment={() => this.props.selectCommentId(comment.id)} />;
); @@ -287,6 +342,7 @@ ModerationQueue.propTypes = { viewUserDetail: PropTypes.func.isRequired, currentAsset: PropTypes.object, showBanUserDialog: PropTypes.func.isRequired, + selectCommentId: PropTypes.func.isRequired, showSuspendUserDialog: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index b936b4c30..d858cc856 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -23,7 +23,8 @@ import { toggleStorySearch, setSortOrder, storySearchChange, - clearState + clearState, + selectCommentId, } from 'actions/moderation'; import withQueueConfig from '../hoc/withQueueConfig'; import {notify} from 'coral-framework/actions/notification'; @@ -246,6 +247,7 @@ class ModerationContainer extends Component { activeTab={this.activeTab} queueConfig={currentQueueConfig} handleCommentChange={this.handleCommentChange} + selectedCommentId={this.props.selectedCommentId} />; } } @@ -423,6 +425,7 @@ const mapDispatchToProps = (dispatch) => ({ storySearchChange, clearState, notify, + selectCommentId, }, dispatch), }); From 51bd109f13b5afe3f34ca81ae504bc0108e57bb5 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 12 Dec 2017 17:28:38 +0100 Subject: [PATCH 04/22] Fix some bugs --- client/coral-admin/src/routes/Moderation/components/Comment.js | 2 +- .../src/routes/Moderation/components/ModerationQueue.js | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 4c08f1559..0856e4657 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -209,7 +209,7 @@ class Comment extends React.Component { Comment.propTypes = { viewUserDetail: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, - selectComment: PropTypes.func.isRequired, + selectComment: PropTypes.func, rejectComment: PropTypes.func.isRequired, onClick: PropTypes.func, className: PropTypes.string, diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 3963e9c01..87833151a 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -129,7 +129,7 @@ class ModerationQueue extends React.Component { } // Scroll to selected comment. - if (prev.selectedCommentId !== this.props.selectedCommentId) { + if (prev.selectedCommentId !== this.props.selectedCommentId && this.listRef) { const view = this.getVisibleComments(); const index = view.findIndex(({id}) => id === this.props.selectedCommentId); @@ -298,7 +298,6 @@ class ModerationQueue extends React.Component { rejectComment={props.rejectComment} currentAsset={props.currentAsset} currentUserId={this.props.currentUserId} - selectComment={() => this.props.selectCommentId(comment.id)} />; ); From 5548deb6b55fedba3133233a416d88e94ddc4712 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Wed, 13 Dec 2017 15:06:01 +0100 Subject: [PATCH 05/22] Auto Load More and other bug fixes --- .../routes/Moderation/components/AutoLoadMore.js | 16 ++++++++++++++++ .../routes/Moderation/components/Moderation.js | 13 ++++++++----- .../Moderation/components/ModerationQueue.css | 1 + .../Moderation/components/ModerationQueue.js | 11 ++++++----- .../routes/Moderation/containers/Moderation.js | 5 +++-- client/coral-framework/hocs/withQuery.js | 3 +++ package.json | 2 +- yarn.lock | 10 +++++----- 8 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js diff --git a/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js new file mode 100644 index 000000000..d2148f87f --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js @@ -0,0 +1,16 @@ +import React from 'react'; +import {Spinner} from 'coral-ui'; + +class AutoLoadMore extends React.Component { + componentDidMount() { + if(!this.props.loading) { + this.props.loadMore(); + } + } + + render() { + return ; + } +} + +export default AutoLoadMore; diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 594d5d57f..5967ff556 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -13,7 +13,9 @@ import ViewOptions from './ViewOptions'; class Moderation extends Component { - state = {}; + state = { + isLoadingMore: false, + }; componentWillMount() { const {toggleModal, singleView} = this.props; @@ -88,15 +90,15 @@ class Moderation extends Component { } loadMore = async () => { - if (!this.isLoadingMore) { - this.isLoadingMore = true; + if (!this.state.isLoadingMore) { + this.setState({isLoadingMore: true}); try { const result = await this.props.loadMore(this.props.activeTab); - this.isLoadingMore = false; + this.setState({isLoadingMore: false}); return result; } catch (e) { - this.isLoadingMore = false; + this.setState({isLoadingMore: false}); throw e; } } @@ -194,6 +196,7 @@ class Moderation extends Component { acceptComment={props.acceptComment} rejectComment={props.rejectComment} loadMore={this.loadMore} + isLoadingMore={this.state.isLoadingMore} commentCount={activeTabCount} currentUserId={this.props.auth.user.id} viewUserDetail={viewUserDetail} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css index f9e3aa59a..e39bb67fe 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css @@ -2,6 +2,7 @@ padding: 8px 0; list-style: none; display: block; + min-height: 650px; } :global(html) { diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 87833151a..e069c3033 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import Comment from '../containers/Comment'; import styles from './ModerationQueue.css'; import EmptyCard from '../../../components/EmptyCard'; -import LoadMore from '../../../components/LoadMore'; +import AutoLoadMore from './AutoLoadMore'; import ViewMore from './ViewMore'; import t from 'coral-framework/services/i18n'; import {WindowScroller, CellMeasurer, CellMeasurerCache, List} from 'react-virtualized'; @@ -57,7 +57,6 @@ const cache = new CellMeasurerCache({ }); class ModerationQueue extends React.Component { - isLoadingMore = false; cache = cache; listRef = null; @@ -222,9 +221,9 @@ class ModerationQueue extends React.Component {
-
@@ -304,6 +303,7 @@ class ModerationQueue extends React.Component { } const view = this.getVisibleComments(); + const hasMore = this.props.comments.length < this.props.commentCount; return (
@@ -325,7 +325,7 @@ class ModerationQueue extends React.Component { scrollTop={scrollTop} isScrolling={isScrolling} onScroll={onChildScroll} - rowCount={view.length + 1} + rowCount={hasMore ? view.length + 1 : view.length} deferredMeasurementCache={this.cache} rowRenderer={this.rowRenderer} rowHeight={this.cache.rowHeight} @@ -350,6 +350,7 @@ ModerationQueue.propTypes = { loadMore: PropTypes.func.isRequired, selectedCommentId: PropTypes.string, singleView: PropTypes.bool, + isLoadingMore: PropTypes.bool, activeTab: PropTypes.string.isRequired, data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index d858cc856..29b64f0aa 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -179,7 +179,7 @@ class ModerationContainer extends Component { loadMore = (tab) => { const variables = { - limit: 50, + limit: 20, cursor: this.props.root[tab].endCursor, sortOrder: this.props.data.variables.sortOrder, asset_id: this.props.data.variables.asset_id, @@ -363,7 +363,8 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql` ${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''} ${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''} asset_id: $asset_id, - sortOrder: $sortOrder + sortOrder: $sortOrder, + limit: 20, }) { ...CoralAdmin_Moderation_CommentConnection } diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index 280191f89..ffd5a1671 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -83,6 +83,9 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => { // Handle any pending susbcription data in the subscription queue at max once every second. // Updates are batched in written into apollo in one go. processSubscriptionQueue = throttle(() => { + if (!this.subscriptionQueue.length) { + return; + } const variables = typeof this.wrappedOptions === 'function' ? this.wrappedOptions(this.props).variables : this.wrappedOptions.variables; diff --git a/package.json b/package.json index f16053ab5..ab37b62f5 100644 --- a/package.json +++ b/package.json @@ -167,7 +167,7 @@ "react-test-renderer": "15.5", "react-toastify": "^1.5.0", "react-transition-group": "^1.1.3", - "react-virtualized": "9.9.0", + "react-virtualized": "9.13.0", "recompose": "^0.23.1", "redux": "^3.6.0", "redux-thunk": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index 81fb50604..27728e2ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1020,7 +1020,7 @@ babel-register@^6.26.0: mkdirp "^0.5.1" source-map-support "^0.4.15" -babel-runtime@^6.11.6, babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: +babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0, babel-runtime@^6.6.1: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" dependencies: @@ -7464,11 +7464,11 @@ react-transition-group@^1.1.2, react-transition-group@^1.1.3: prop-types "^15.5.6" warning "^3.0.0" -react-virtualized@9.9.0: - version "9.9.0" - resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.9.0.tgz#799a6f23819eeb82860d59b82fad33d1d420325e" +react-virtualized@9.13.0: + version "9.13.0" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.13.0.tgz#83e4d984271a37631225e5fe6faeaeada6e59f53" dependencies: - babel-runtime "^6.11.6" + babel-runtime "^6.23.0" classnames "^2.2.3" dom-helpers "^2.4.0 || ^3.0.0" loose-envify "^1.3.0" From 2779734ec0bfda8278f1bbf483550ebe9dff392c Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Wed, 13 Dec 2017 15:25:47 +0100 Subject: [PATCH 06/22] Workaround --- .../src/routes/Moderation/components/ModerationQueue.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index e069c3033..118d2fea3 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -84,6 +84,9 @@ class ModerationQueue extends React.Component { componentDidMount() { key('j', () => this.selectDown()); key('k', () => this.selectUp()); + + // TODO: Workaround for issue https://github.com/bvaughn/react-virtualized/issues/866 + this.reflowList(); } componentWillUnmount() { From 34289e3340365ce2bf44b4f7f8aadbfcf192bbc9 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 12:35:26 +0100 Subject: [PATCH 07/22] Refactor view --- .../Moderation/components/ModerationQueue.js | 80 ++++++++++--------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 118d2fea3..29aa2c9c3 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -18,14 +18,15 @@ const hasComment = (nodes, id) => nodes.some((node) => node.id === id); // comments to show. The spare cursor functions as a backup in case one // of the comments gets deleted. function resetCursors(state, props) { + let idCursors = []; if (props.comments && props.comments.length) { - const idCursors = [props.comments[0].id]; + idCursors.push(props.comments[0].id); if (props.comments[1]) { idCursors.push(props.comments[1].id); } - return {idCursors}; } - return {idCursors: []}; + const view = getVisibleComments(props.comments, idCursors[0]); + return {idCursors, view}; } // invalidateCursor is called whenever a comment is removed which is referenced @@ -42,7 +43,29 @@ function invalidateCursor(invalidated, state, props) { idCursors.push(nextInLine.id); } } - return {idCursors}; + const view = getVisibleComments(props.comments, idCursors[0]); + return {idCursors, view}; +} + +// getVisibileComments returns a list containing comments +// which comes after the `idCursor`. +function getVisibleComments(comments, idCursor) { + + if (!comments) { + return []; + } + + const view = []; + let pastCursor = false; + comments.forEach((comment) => { + if (comment.id === idCursor) { + pastCursor = true; + } + if (pastCursor) { + view.push(comment); + } + }); + return view; } let keyMapper = null; @@ -66,7 +89,7 @@ class ModerationQueue extends React.Component { ...resetCursors(this.state, props), }; keyMapper = (index) => { - const view = this.getVisibleComments(); + const view = this.state.view; if (index < view.length) { return view[index].id; } @@ -75,9 +98,8 @@ class ModerationQueue extends React.Component { } throw new Error(`unknown index ${index}`); }; - const view = this.getVisibleComments(); - if (view.length) { - props.selectCommentId(view[0].id); + if (this.state.view.length) { + props.selectCommentId(this.state.view[0].id); } } @@ -95,7 +117,7 @@ class ModerationQueue extends React.Component { } async selectDown() { - const view = this.getVisibleComments(); + const view = this.state.view; const index = view.findIndex(({id}) => id === this.props.selectedCommentId); if (index === view.length - 1 && this.props.comments.length !== this.props.commentCount) { await this.props.loadMore(); @@ -108,7 +130,7 @@ class ModerationQueue extends React.Component { } selectUp() { - const view = this.getVisibleComments(); + const view = this.state.view; const index = view.findIndex(({id}) => id === this.props.selectedCommentId); if (index === 0 && view.length < this.props.comments.length) { @@ -133,7 +155,7 @@ class ModerationQueue extends React.Component { // Scroll to selected comment. if (prev.selectedCommentId !== this.props.selectedCommentId && this.listRef) { - const view = this.getVisibleComments(); + const view = this.state.view; const index = view.findIndex(({id}) => id === this.props.selectedCommentId); this.listRef.scrollToRow(index); @@ -155,7 +177,7 @@ class ModerationQueue extends React.Component { if ( prevComments && nextComments && - nextComments.length < prevComments.length + nextComments.length < prevComments.length ) { // Invalidate first cursor if referenced comment was removed. @@ -167,6 +189,11 @@ class ModerationQueue extends React.Component { if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) { this.setState(invalidateCursor(1, this.state, next)); } + return; + } + + if (prevComments !== nextComments) { + this.setState({view: getVisibleComments(nextComments, this.state.idCursors[0])}); } } @@ -182,35 +209,12 @@ class ModerationQueue extends React.Component { this.listRef.recomputeRowHeights(); }, 500); - // getVisibileComments returns a list containing comments - // which comes after the `idCursor`. - getVisibleComments() { - const {comments} = this.props; - const idCursor = this.state.idCursors[0]; - - if (!comments) { - return []; - } - - const view = []; - let pastCursor = false; - comments.forEach((comment) => { - if (comment.id === idCursor) { - pastCursor = true; - } - if (pastCursor) { - view.push(comment); - } - }); - return view; - } - rowRenderer = ({ index, // Index of row within collection parent, style // Style object to be applied to row (to position it) }) => { - const view = this.getVisibleComments(); + const view = this.state.view; const rowCount = view.length + 1; if (index === rowCount - 1) { return ( @@ -305,13 +309,13 @@ class ModerationQueue extends React.Component { ); } - const view = this.getVisibleComments(); + const view = this.state.view; const hasMore = this.props.comments.length < this.props.commentCount; return (
this.viewNewComments()} count={comments.length - view.length} /> From 5e6d22f1afa17d3c07c7cb99bb4c51b95fa569d0 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 13:38:01 +0100 Subject: [PATCH 08/22] Handle removing comments from queue --- .../Moderation/components/Moderation.js | 34 +------------ .../Moderation/components/ModerationQueue.js | 49 +++++++++++++++---- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 5967ff556..e2353a2a2 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -67,8 +67,7 @@ class Moderation extends Component { } moderate = (accept) => { - const {acceptComment, rejectComment} = this.props; - const {selectedCommentId} = this.state; + const {acceptComment, rejectComment, moderation: {selectedCommentId}} = this.props; // Accept or reject only if there's a selected comment if(selectedCommentId != null){ @@ -117,37 +116,6 @@ class Moderation extends Component { .forEach((menuItem, idx) => key.unbind(`${idx + 1}`)); } - componentWillReceiveProps(nextProps) { - - // TODO: Adapt to react virtualized. - if (this.props.activeTab === nextProps.activeTab) { - - // Detect if comment has left the queue and find next or prev selected comment to set it - // as the new selectedCommentId. - const prevComments = this.getComments(this.props); - const nextComments = this.getComments(nextProps); - if (nextComments.length < prevComments.length) { - - // Comments have changed, now check if our selected comment has left the queue. - if ( - this.state.selectedCommentId && - !nextComments.some((comment) => comment.id === this.state.selectedCommentId) - ) { - - // Determine a comment to select. - const prevIndex = prevComments.findIndex((comment) => comment.id === this.state.selectedCommentId); - if (prevIndex !== prevComments.length - 1) { - this.setState({selectedCommentId: prevComments[prevIndex + 1].id}); - } else if(prevIndex > 0) { - this.setState({selectedCommentId: prevComments[prevIndex - 1].id}); - } else { - this.setState({selectedCommentId: null}); - } - } - } - } - } - render () { const {root, data, moderation, viewUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props; const {asset} = root; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 29aa2c9c3..4f2f108b4 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -43,8 +43,7 @@ function invalidateCursor(invalidated, state, props) { idCursors.push(nextInLine.id); } } - const view = getVisibleComments(props.comments, idCursors[0]); - return {idCursors, view}; + return idCursors; } // getVisibileComments returns a list containing comments @@ -175,6 +174,8 @@ class ModerationQueue extends React.Component { return; } + let idCursors = this.state.idCursors; + if ( prevComments && nextComments && nextComments.length < prevComments.length @@ -182,18 +183,40 @@ class ModerationQueue extends React.Component { // Invalidate first cursor if referenced comment was removed. if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) { - this.setState(invalidateCursor(0, this.state, next)); + idCursors = invalidateCursor(0, this.state, next); } // Invalidate second cursor if referenced comment was removed. if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) { - this.setState(invalidateCursor(1, this.state, next)); + idCursors = invalidateCursor(1, this.state, next); + } + + if ( + this.props.selectedCommentId && + !hasComment(nextComments, this.props.selectedCommentId) + ) { + const view = this.state.view; + let nextSelectedCommentId = null; + + // Determine a comment to select. + const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId); + if (prevIndex !== view.length - 1) { + nextSelectedCommentId = view[prevIndex + 1].id; + } else if(prevIndex > 0) { + nextSelectedCommentId = view[prevIndex - 1].id; + } + this.props.selectCommentId(nextSelectedCommentId); } - return; } if (prevComments !== nextComments) { - this.setState({view: getVisibleComments(nextComments, this.state.idCursors[0])}); + const nextView = getVisibleComments(nextComments, idCursors[0]); + this.setState({idCursors, view: nextView}); + + // TODO: removing a comment from the list seems to render incorrect.. + // Find first changed comment and perform a reflow. + const index = this.state.view.findIndex((comment, i) => nextView[i] !== comment); + this.reflowList(index); } } @@ -204,9 +227,15 @@ class ModerationQueue extends React.Component { }); }; - reflowList = throttle(() => { - this.cache.clearAll(); - this.listRef.recomputeRowHeights(); + reflowList = throttle((index) => { + if (index >= 0) { + this.cache.clear(index); + this.listRef.recomputeRowHeights(index); + } + else { + this.cache.clearAll(); + this.listRef.recomputeRowHeights(); + } }, 500); rowRenderer = ({ @@ -261,7 +290,7 @@ class ModerationQueue extends React.Component { rejectComment={this.props.rejectComment} currentAsset={this.props.currentAsset} currentUserId={this.props.currentUserId} - clearHeightCache={() => {this.cache.clear(index); this.listRef.recomputeRowHeights(index); }} + clearHeightCache={() => this.reflowList(index)} selectComment={() => this.props.selectCommentId(comment.id)} />
From f32bb5ce8f80ab62fed1bcea14ca8291f83e30ed Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 13:55:08 +0100 Subject: [PATCH 09/22] Small adjustments --- .../src/routes/Moderation/components/ModerationQueue.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 4f2f108b4..0ae6fc2e4 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -69,9 +69,6 @@ function getVisibleComments(comments, idCursor) { let keyMapper = null; -// In this example, average cell height is assumed to be about 50px. -// This value will be used for the initial `Grid` layout. -// Width is not dynamic. const cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: 250, @@ -213,9 +210,9 @@ class ModerationQueue extends React.Component { const nextView = getVisibleComments(nextComments, idCursors[0]); this.setState({idCursors, view: nextView}); - // TODO: removing a comment from the list seems to render incorrect.. + // TODO: removing or adding a comment from the list seems to render incorrect, is this a bug? // Find first changed comment and perform a reflow. - const index = this.state.view.findIndex((comment, i) => nextView[i] !== comment); + const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id); this.reflowList(index); } } From 62c2d551f80c7fbe4429746859479984bc4e236e Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 15:57:44 +0100 Subject: [PATCH 10/22] Append comments only --- client/coral-admin/src/routes/Moderation/graphql.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 866218d73..8ee728a5e 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -45,17 +45,14 @@ function addCommentToQueue(root, queue, comment, sortOrder) { return root; } - const sortAlgo = sortOrder === 'ASC' ? ascending : descending; const changes = { [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, }; if (shouldCommentBeAdded(root, queue, comment, sortOrder)) { - const nodes = root[queue].nodes.concat(comment).sort(sortAlgo); + const nodes = root[queue].nodes.concat(comment); changes[queue] = { nodes: {$set: nodes}, - startCursor: {$set: nodes[0].created_at}, - endCursor: {$set: nodes[nodes.length - 1].created_at}, }; } From 60f6c63c8a089b15e5f65c48b25c578ade8eaf5d Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 16:29:31 +0100 Subject: [PATCH 11/22] Restrict notifications to comments which are rendered --- .../Moderation/components/ModerationQueue.js | 15 ++++++------ .../src/routes/Moderation/graphql.js | 23 +++++++++++++------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 0ae6fc2e4..13a09e1ed 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -166,7 +166,7 @@ class ModerationQueue extends React.Component { const {comments: prevComments} = this.props; const {comments: nextComments} = next; - if (!prevComments && nextComments) { + if (!prevComments && nextComments || !prevComments.length && nextComments.length) { this.setState(resetCursors); return; } @@ -227,11 +227,11 @@ class ModerationQueue extends React.Component { reflowList = throttle((index) => { if (index >= 0) { this.cache.clear(index); - this.listRef.recomputeRowHeights(index); + this.listRef && this.listRef.recomputeRowHeights(index); } else { this.cache.clearAll(); - this.listRef.recomputeRowHeights(); + this.listRef && this.listRef.recomputeRowHeights(); } }, 500); @@ -243,6 +243,7 @@ class ModerationQueue extends React.Component { const view = this.state.view; const rowCount = view.length + 1; if (index === rowCount - 1) { + const hasMore = this.props.comments.length < this.props.commentCount; return (
- + />}
); @@ -336,7 +338,6 @@ class ModerationQueue extends React.Component { } const view = this.state.view; - const hasMore = this.props.comments.length < this.props.commentCount; return (
@@ -358,7 +359,7 @@ class ModerationQueue extends React.Component { scrollTop={scrollTop} isScrolling={isScrolling} onScroll={onChildScroll} - rowCount={hasMore ? view.length + 1 : view.length} + rowCount={view.length + 1} deferredMeasurementCache={this.cache} rowRenderer={this.rowRenderer} rowHeight={this.cache.rowHeight} diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 8ee728a5e..e99bb4a7e 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -59,6 +59,11 @@ function addCommentToQueue(root, queue, comment, sortOrder) { return update(root, changes); } +export function sortComments(nodes, sortOrder) { + const sortAlgo = sortOrder === 'ASC' ? ascending : descending; + return nodes.sort(sortAlgo); +} + /** * getCommentQueues determines in which queues a comment should be placed. */ @@ -83,6 +88,14 @@ function getCommentQueues(comment, queueConfig) { return queues; } +function isVisible(id) { + return !!document.getElementById(`comment_${id}`); +} + +function isEndOfListVisible(root, queue) { + return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list'); +} + /** * Assimilate comment changes into current store. * @param {Object} root current state of the store @@ -111,22 +124,18 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { next = addCommentToQueue(next, queue, comment, sortOrder); - if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sortOrder)) { + if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) { showNotificationOnce(); } } } else if(queueHasComment(next, queue, comment.id)){ next = removeCommentFromQueue(next, queue, comment.id); - if (notify && activeQueue === queue) { + if (notify && isVisible(comment.id)) { showNotificationOnce(); } } - if ( - notify - && queueHasComment(next, queue, comment.id) - && activeQueue === queue - ) { + if (notify && isVisible(comment.id)) { showNotificationOnce(); } }); From 0bda44e97f504b3f0e260b202856decc940026a0 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 16:51:50 +0100 Subject: [PATCH 12/22] Fix cursor bug --- .../src/routes/Moderation/components/ModerationQueue.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 13a09e1ed..00dddf6d6 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -166,8 +166,9 @@ class ModerationQueue extends React.Component { const {comments: prevComments} = this.props; const {comments: nextComments} = next; - if (!prevComments && nextComments || !prevComments.length && nextComments.length) { - this.setState(resetCursors); + // New comments where added and our cursor list is incomplete. + if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) { + this.setState(resetCursors(this.state, next)); return; } From 0b774402cf4238ede59a7fe284537b08f051da2f Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 18:19:00 +0100 Subject: [PATCH 13/22] Introduce dangling comments and queue cleanup --- .../routes/Moderation/components/Comment.css | 4 + .../routes/Moderation/components/Comment.js | 4 +- .../Moderation/components/Moderation.js | 4 + .../Moderation/components/ModerationQueue.css | 18 ----- .../Moderation/components/ModerationQueue.js | 22 ++++-- .../Moderation/containers/Moderation.js | 19 ++++- .../src/routes/Moderation/graphql.js | 79 ++++++++++++++----- 7 files changed, 105 insertions(+), 45 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css index 708761b95..119688b73 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.css +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -27,6 +27,10 @@ } } +.dangling { + background-color: #efefef; +} + .container { padding: 0 14px; } diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 0856e4657..293071cec 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -82,6 +82,7 @@ class Comment extends React.Component { currentUserId, currentAsset, clearHeightCache, + dangling, } = this.props; const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; @@ -90,7 +91,7 @@ class Comment extends React.Component { return (
  • props.commentBelongToQueue(props.activeTab, comment)).length; } async selectDown() { const view = this.state.view; const index = view.findIndex(({id}) => id === this.props.selectedCommentId); - if (index === view.length - 1 && this.props.comments.length !== this.props.commentCount) { + if (index === view.length - 1 && this.getCommentCountWithoutDagling() !== this.props.commentCount) { await this.props.loadMore(); this.selectDown(); return; @@ -139,20 +145,20 @@ class ModerationQueue extends React.Component { } componentDidUpdate (prev) { - const {comments, commentCount} = this.props; + const {commentCount, selectedCommentId} = this.props; // if the user just moderated the last (visible) comment // AND there are more comments available on the server, // go ahead and load more comments - if (prev.comments.length > 0 && comments.length === 0 && commentCount > 0) { + if (prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0 && commentCount > 0) { this.props.loadMore(); } // Scroll to selected comment. - if (prev.selectedCommentId !== this.props.selectedCommentId && this.listRef) { + if (prev.selectedCommentId !== selectedCommentId && this.listRef) { const view = this.state.view; - const index = view.findIndex(({id}) => id === this.props.selectedCommentId); + const index = view.findIndex(({id}) => id === selectedCommentId); this.listRef.scrollToRow(index); } @@ -244,7 +250,7 @@ class ModerationQueue extends React.Component { const view = this.state.view; const rowCount = view.length + 1; if (index === rowCount - 1) { - const hasMore = this.props.comments.length < this.props.commentCount; + const hasMore = this.getCommentCountWithoutDagling() < this.props.commentCount; return ( { + if (!this.props.data.loading) { + this.props.data.updateQuery((query) => { + return cleanUpQueue(query, queue, this.props.queueConfig); + }); + } + } + acceptComment = ({commentId}) => { return this.props.setCommentStatus({commentId, status: 'ACCEPTED'}); } @@ -177,6 +185,10 @@ class ModerationContainer extends Component { return this.props.setCommentStatus({commentId, status: 'REJECTED'}); } + commentBelongToQueue = (queue, comment) => { + return commentBelongToQueue(queue, comment, this.props.queueConfig); + } + loadMore = (tab) => { const variables = { limit: 20, @@ -248,6 +260,8 @@ class ModerationContainer extends Component { queueConfig={currentQueueConfig} handleCommentChange={this.handleCommentChange} selectedCommentId={this.props.selectedCommentId} + commentBelongToQueue={this.commentBelongToQueue} + cleanUpQueue={this.cleanUpQueue} />; } } @@ -389,6 +403,9 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql` organizationName moderation } + me { + id + } ...${getDefinitionName(Comment.fragments.root)} } ${Comment.fragments.root} diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index e99bb4a7e..ec6bc4f88 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -16,16 +16,21 @@ function queueHasComment(root, queue, id) { return root[queue].nodes.find((c) => c.id === id); } -function removeCommentFromQueue(root, queue, id) { +function removeCommentFromQueue(root, queue, id, dangling = false) { if (!queueHasComment(root, queue, id)) { return root; } - return update(root, { + const changes = { [`${queue}Count`]: {$set: root[`${queue}Count`] - 1}, - [queue]: { + }; + + if (!dangling) { + changes[queue] = { nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)}, - }, - }); + }; + } + + return update(root, changes); } function shouldCommentBeAdded(root, queue, comment, sortOrder) { @@ -70,24 +75,28 @@ export function sortComments(nodes, sortOrder) { function getCommentQueues(comment, queueConfig) { const queues = []; Object.keys(queueConfig).forEach((key) => { - const {action_type, statuses, tags} = queueConfig[key]; - let addToQueues = true; - if (statuses && statuses.indexOf(comment.status) === -1) { - addToQueues = false; - } - if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) { - addToQueues = false; - } - if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) { - addToQueues = false; - } - if (addToQueues) { + if (commentBelongToQueue(key, comment, queueConfig)) { queues.push(key); } }); return queues; } +export function commentBelongToQueue(queue, comment, queueConfig) { + const {action_type, statuses, tags} = queueConfig[queue]; + let belong = true; + if (statuses && statuses.indexOf(comment.status) === -1) { + belong = false; + } + if (tags && (!comment.tags || !comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0))) { + belong = false; + } + if (action_type && (!comment.actions || !comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type.toLowerCase()}action`))) { + belong = false; + } + return belong; +} + function isVisible(id) { return !!document.getElementById(`comment_${id}`); } @@ -96,6 +105,37 @@ function isEndOfListVisible(root, queue) { return root[queue].nodes.length === 0 || !!document.getElementById('end-of-comment-list'); } +function applyCommentChanges(root, comment, queueConfig) { + const queues = Object.keys(queueConfig); + for (let i = 0; i < queues.length; i++) { + const queue = queues[i]; + const index = root[queue].nodes.findIndex(({id}) => id === comment.id); + if (index > -1) { + return update(root, { + [queue]: { + nodes: { + [index]: {$merge: comment}, + }, + }, + }); + } + } + return root; +} + +export function cleanUpQueue(root, queue, queueConfig) { + return update(root, { + [queue]: { + nodes: { + $apply: (nodes) => + sortComments( + nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)) + ).slice(0, 50), + }, + }, + }); +} + /** * Assimilate comment changes into current store. * @param {Object} root current state of the store @@ -129,7 +169,8 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi } } } else if(queueHasComment(next, queue, comment.id)){ - next = removeCommentFromQueue(next, queue, comment.id); + const dangling = activeQueue === queue && comment.status_history[comment.status_history.length - 1].assigned_by.id !== root.me.id; + next = removeCommentFromQueue(next, queue, comment.id, dangling); if (notify && isVisible(comment.id)) { showNotificationOnce(); } @@ -138,6 +179,8 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi if (notify && isVisible(comment.id)) { showNotificationOnce(); } + + next = applyCommentChanges(next, comment, queueConfig); }); return next; } From 243d0c1493a7a13253598ff1ee078e83c1854b05 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 18:33:49 +0100 Subject: [PATCH 14/22] Limit to 100 comments --- client/coral-admin/src/routes/Moderation/graphql.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index ec6bc4f88..f028d9c42 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -130,7 +130,7 @@ export function cleanUpQueue(root, queue, queueConfig) { $apply: (nodes) => sortComments( nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)) - ).slice(0, 50), + ).slice(0, 100), }, }, }); From defc4049cb0cbf3e6c9c0cd44c0d26cb184b3d82 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 19:47:42 +0100 Subject: [PATCH 15/22] Sort correctly + refactors and comments --- .../Moderation/components/AutoLoadMore.js | 9 + .../Moderation/components/ModerationQueue.js | 241 ++++++++++-------- .../Moderation/containers/Moderation.js | 2 +- .../src/routes/Moderation/graphql.js | 16 +- 4 files changed, 153 insertions(+), 115 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js index d2148f87f..4ba7c3298 100644 --- a/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js +++ b/client/coral-admin/src/routes/Moderation/components/AutoLoadMore.js @@ -1,6 +1,10 @@ import React from 'react'; import {Spinner} from 'coral-ui'; +import PropTypes from 'prop-types'; +/** + * AutoLoadMore with call `loadMore` the moment it is rendered and shows a Spinner. + */ class AutoLoadMore extends React.Component { componentDidMount() { if(!this.props.loading) { @@ -13,4 +17,9 @@ class AutoLoadMore extends React.Component { } } +AutoLoadMore.propTypes = { + loading: PropTypes.bool.isRequired, + loadMore: PropTypes.func.isRequired, +}; + export default AutoLoadMore; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index bda02ee10..48ad67f54 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -14,9 +14,9 @@ import key from 'keymaster'; const hasComment = (nodes, id) => nodes.some((node) => node.id === id); // resetCursors will return the id cursors of the first and second comment of -// the current comment list. The cursors are used to dertermine which -// comments to show. The spare cursor functions as a backup in case one -// of the comments gets deleted. +// the current comment The spare cursor functions as a backup in case one +// of the comments gets deleted. Additionally the new view based on the new +// cursors are also returned. function resetCursors(state, props) { let idCursors = []; if (props.comments && props.comments.length) { @@ -67,8 +67,12 @@ function getVisibleComments(comments, idCursor) { return view; } +// Current keymapper to use for the CellMeasurer Cache. let keyMapper = null; +// CellMeasurerCache is used to measure the size of the elements +// of the virtual list. We use a global one with a keyMapper that +// should resolve to a comment id, which is then used to cache the height. const cache = new CellMeasurerCache({ fixedWidth: true, defaultHeight: 250, @@ -76,7 +80,6 @@ const cache = new CellMeasurerCache({ }); class ModerationQueue extends React.Component { - cache = cache; listRef = null; constructor(props) { @@ -84,6 +87,8 @@ class ModerationQueue extends React.Component { this.state = { ...resetCursors(this.state, props), }; + + // Set keyMapper to map to comment ids. keyMapper = (index) => { const view = this.state.view; if (index < view.length) { @@ -94,6 +99,8 @@ class ModerationQueue extends React.Component { } throw new Error(`unknown index ${index}`); }; + + // Select first comment. if (this.state.view.length) { props.selectCommentId(this.state.view[0].id); } @@ -111,9 +118,92 @@ class ModerationQueue extends React.Component { key.unbind('j'); key.unbind('k'); + // When switching queues, clean it up first. + // Removes dangling comments and reduce overly large + // lists and restore chronological order. this.props.cleanUpQueue(this.props.activeTab); } + componentWillReceiveProps(next) { + const {comments: prevComments} = this.props; + const {comments: nextComments} = next; + + // New comments where added and our cursor list is incomplete. + if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) { + this.setState(resetCursors(this.state, next)); + return; + } + + let idCursors = this.state.idCursors; + + // Comments have been removed. + if ( + prevComments && nextComments && + nextComments.length < prevComments.length + ) { + + // Invalidate first cursor if referenced comment was removed. + if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) { + idCursors = invalidateCursor(0, this.state, next); + } + + // Invalidate second cursor if referenced comment was removed. + if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) { + idCursors = invalidateCursor(1, this.state, next); + } + + // Selected comment was removed, determine and set next selected comment. + if ( + this.props.selectedCommentId && + !hasComment(nextComments, this.props.selectedCommentId) + ) { + const view = this.state.view; + let nextSelectedCommentId = null; + + // Determine a comment to select. + const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId); + if (prevIndex !== view.length - 1) { + nextSelectedCommentId = view[prevIndex + 1].id; + } else if(prevIndex > 0) { + nextSelectedCommentId = view[prevIndex - 1].id; + } + this.props.selectCommentId(nextSelectedCommentId); + } + } + + // Comments changed. + if (prevComments !== nextComments) { + const nextView = getVisibleComments(nextComments, idCursors[0]); + this.setState({idCursors, view: nextView}); + + // TODO: removing or adding a comment from the list seems to render incorrect, is this a bug? + // Find first changed comment and perform a reflow. + const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id); + this.reflowList(index); + } + } + + componentDidUpdate (prev) { + const {commentCount, selectedCommentId} = this.props; + + // If the user just moderated the last (visible) comment + // AND there are more comments available on the server, + // go ahead and load more comments + if (prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0 && commentCount > 0) { + this.props.loadMore(); + } + + // Scroll to selected comment. + if (prev.selectedCommentId !== selectedCommentId && this.listRef) { + + const view = this.state.view; + const index = view.findIndex(({id}) => id === selectedCommentId); + + this.listRef.scrollToRow(index); + } + } + + // Returns comment counts without dangling comments. getCommentCountWithoutDagling(props = this.props) { return props.comments.filter((comment) => props.commentBelongToQueue(props.activeTab, comment)).length; } @@ -144,86 +234,10 @@ class ModerationQueue extends React.Component { } } - componentDidUpdate (prev) { - const {commentCount, selectedCommentId} = this.props; - - // if the user just moderated the last (visible) comment - // AND there are more comments available on the server, - // go ahead and load more comments - if (prev.comments.length > 0 && this.getCommentCountWithoutDagling() === 0 && commentCount > 0) { - this.props.loadMore(); - } - - // Scroll to selected comment. - if (prev.selectedCommentId !== selectedCommentId && this.listRef) { - - const view = this.state.view; - const index = view.findIndex(({id}) => id === selectedCommentId); - - this.listRef.scrollToRow(index); - } - } - handleListRef = (list) => { this.listRef = list; }; - componentWillReceiveProps(next) { - const {comments: prevComments} = this.props; - const {comments: nextComments} = next; - - // New comments where added and our cursor list is incomplete. - if (this.state.idCursors.length < 2 && nextComments.length > this.state.idCursors.length) { - this.setState(resetCursors(this.state, next)); - return; - } - - let idCursors = this.state.idCursors; - - if ( - prevComments && nextComments && - nextComments.length < prevComments.length - ) { - - // Invalidate first cursor if referenced comment was removed. - if (this.state.idCursors[0] && !hasComment(nextComments, this.state.idCursors[0])) { - idCursors = invalidateCursor(0, this.state, next); - } - - // Invalidate second cursor if referenced comment was removed. - if (this.state.idCursors[1] && !hasComment(nextComments, this.state.idCursors[1])) { - idCursors = invalidateCursor(1, this.state, next); - } - - if ( - this.props.selectedCommentId && - !hasComment(nextComments, this.props.selectedCommentId) - ) { - const view = this.state.view; - let nextSelectedCommentId = null; - - // Determine a comment to select. - const prevIndex = view.findIndex((comment) => comment.id === this.props.selectedCommentId); - if (prevIndex !== view.length - 1) { - nextSelectedCommentId = view[prevIndex + 1].id; - } else if(prevIndex > 0) { - nextSelectedCommentId = view[prevIndex - 1].id; - } - this.props.selectCommentId(nextSelectedCommentId); - } - } - - if (prevComments !== nextComments) { - const nextView = getVisibleComments(nextComments, idCursors[0]); - this.setState({idCursors, view: nextView}); - - // TODO: removing or adding a comment from the list seems to render incorrect, is this a bug? - // Find first changed comment and perform a reflow. - const index = this.state.view.findIndex((comment, i) => !nextView[i] || nextView[i].id !== comment.id); - this.reflowList(index); - } - } - viewNewComments = (callback) => { this.setState(resetCursors, () => { this.reflowList(); @@ -233,11 +247,11 @@ class ModerationQueue extends React.Component { reflowList = throttle((index) => { if (index >= 0) { - this.cache.clear(index); + cache.clear(index); this.listRef && this.listRef.recomputeRowHeights(index); } else { - this.cache.clearAll(); + cache.clearAll(); this.listRef && this.listRef.recomputeRowHeights(); } }, 500); @@ -249,38 +263,31 @@ class ModerationQueue extends React.Component { }) => { const view = this.state.view; const rowCount = view.length + 1; + + let child = null; + let key = null; + + // Last element of list is our AutoLoadMore component and contains an + // id indicating that this is the last element in list. if (index === rowCount - 1) { const hasMore = this.getCommentCountWithoutDagling() < this.props.commentCount; - return ( - -
    - {hasMore && } -
    -
    + {hasMore && } +
  • ); } - - const comment = view[index]; - return ( - + else { + const comment = view[index]; + key = comment.id; + child = (
    @@ -301,6 +308,18 @@ class ModerationQueue extends React.Component { selectComment={() => this.props.selectCommentId(comment.id)} />
    + ); + } + + return ( + + {child} ); }; @@ -368,9 +387,9 @@ class ModerationQueue extends React.Component { isScrolling={isScrolling} onScroll={onChildScroll} rowCount={view.length + 1} - deferredMeasurementCache={this.cache} + deferredMeasurementCache={cache} rowRenderer={this.rowRenderer} - rowHeight={this.cache.rowHeight} + rowHeight={cache.rowHeight} /> )} diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index e723d4fe0..38b5b67d9 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -172,7 +172,7 @@ class ModerationContainer extends Component { cleanUpQueue = (queue) => { if (!this.props.data.loading) { this.props.data.updateQuery((query) => { - return cleanUpQueue(query, queue, this.props.queueConfig); + return cleanUpQueue(query, queue, this.props.moderation.sortOrder, this.props.queueConfig); }); } } diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index f028d9c42..f76f28563 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -64,7 +64,7 @@ function addCommentToQueue(root, queue, comment, sortOrder) { return update(root, changes); } -export function sortComments(nodes, sortOrder) { +function sortComments(nodes, sortOrder) { const sortAlgo = sortOrder === 'ASC' ? ascending : descending; return nodes.sort(sortAlgo); } @@ -82,6 +82,9 @@ function getCommentQueues(comment, queueConfig) { return queues; } +/** + * Return whether or not the comment belongs to the queue. + */ export function commentBelongToQueue(queue, comment, queueConfig) { const {action_type, statuses, tags} = queueConfig[queue]; let belong = true; @@ -123,13 +126,17 @@ function applyCommentChanges(root, comment, queueConfig) { return root; } -export function cleanUpQueue(root, queue, queueConfig) { +/** + * Remove dangling comments, sort and resize queues. + */ +export function cleanUpQueue(root, queue, sortOrder, queueConfig) { return update(root, { [queue]: { nodes: { $apply: (nodes) => sortComments( - nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)) + nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)), + sortOrder, ).slice(0, 100), }, }, @@ -180,6 +187,9 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi showNotificationOnce(); } + // We need to apply every comment change, because we use + // batched subscription handler which bypasses apollo that would + // have done that for us. next = applyCommentChanges(next, comment, queueConfig); }); return next; From 33781c3d161bd99b9b1ae0be099a9e0dc39ae623 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 20:01:33 +0100 Subject: [PATCH 16/22] Fix view new comments --- client/coral-admin/src/routes/Moderation/graphql.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index f76f28563..13ff2ed5a 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -55,7 +55,17 @@ function addCommentToQueue(root, queue, comment, sortOrder) { }; if (shouldCommentBeAdded(root, queue, comment, sortOrder)) { - const nodes = root[queue].nodes.concat(comment); + const cursor = new Date(root[queue].startCursor); + const date = new Date(comment.created_at); + + let append = sortOrder === 'ASC' + ? date >= cursor + : date <= cursor; + + const nodes = append + ? root[queue].nodes.concat(comment) + : [comment].concat(...root[queue].nodes); + changes[queue] = { nodes: {$set: nodes}, }; From d4051f682814064e1602a5e087f4866514f5b890 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 20:08:09 +0100 Subject: [PATCH 17/22] Fix view more styling --- .../src/routes/Moderation/components/ModerationQueue.css | 3 +-- .../src/routes/Moderation/components/ModerationQueue.js | 2 +- .../coral-admin/src/routes/Moderation/components/ViewMore.css | 3 +++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css index 4110966b8..78959be1b 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css @@ -10,7 +10,6 @@ } .list { - padding: 0; - margin: 0; + outline: none; } diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 48ad67f54..c3977c7bb 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -377,9 +377,9 @@ class ModerationQueue extends React.Component { Date: Thu, 14 Dec 2017 20:27:19 +0100 Subject: [PATCH 18/22] Cache callbacks to preserve immutability --- .../Moderation/components/ModerationQueue.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index c3977c7bb..6abe37d25 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -81,6 +81,10 @@ const cache = new CellMeasurerCache({ class ModerationQueue extends React.Component { listRef = null; + callbackCaches = { + clearHeightCache: {}, + selectCommentId: {}, + }; constructor(props) { super(props); @@ -286,6 +290,16 @@ class ModerationQueue extends React.Component { } else { const comment = view[index]; + + // Use callback cache so not to change the identity of these arrow functions. + // Otherwise shallow compare will fail to optimize. + if (!this.callbackCaches.clearHeightCache[index]) { + this.callbackCaches.clearHeightCache[index] = () => this.reflowList(index); + } + if (!this.callbackCaches.selectCommentId[comment.id]) { + this.callbackCaches.selectCommentId[comment.id] = () => this.props.selectCommentId(comment.id); + } + key = comment.id; child = (
    this.reflowList(index)} - selectComment={() => this.props.selectCommentId(comment.id)} + clearHeightCache={this.callbackCaches.clearHeightCache[index]} + selectComment={this.callbackCaches.selectCommentId[comment.id]} />
    ); From 5446abe3a997a273760aed46822d705815a02493 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 20:37:51 +0100 Subject: [PATCH 19/22] Fix bug with featured comments --- client/coral-admin/src/routes/Moderation/containers/Comment.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/coral-admin/src/routes/Moderation/containers/Comment.js b/client/coral-admin/src/routes/Moderation/containers/Comment.js index 299c7cecb..972d6bd9a 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Comment.js +++ b/client/coral-admin/src/routes/Moderation/containers/Comment.js @@ -48,6 +48,9 @@ export default withFragments({ edited } status_history { + assigned_by { + id + } type } hasParent From 2b312dbf8e2fbc00e87b824ca94eeb6b4448f136 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 20:41:05 +0100 Subject: [PATCH 20/22] Next try --- .../src/routes/Moderation/containers/Comment.js | 3 --- .../client/containers/ModSubscription.js | 8 ++++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/containers/Comment.js b/client/coral-admin/src/routes/Moderation/containers/Comment.js index 972d6bd9a..299c7cecb 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Comment.js +++ b/client/coral-admin/src/routes/Moderation/containers/Comment.js @@ -48,9 +48,6 @@ export default withFragments({ edited } status_history { - assigned_by { - id - } type } hasParent diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js index 0e9fc8c89..7e8d7d426 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js +++ b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js @@ -65,6 +65,14 @@ const COMMENT_FEATURED_SUBSCRIPTION = gql` commentFeatured(asset_id: $assetId) { comment { ...${getDefinitionName(Comment.fragments.comment)} + status_history { + type + created_at + assigned_by { + id + username + } + } } user { id From 19568afde54065c8864f6f45f52480ad7986a023 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 14 Dec 2017 21:08:13 +0100 Subject: [PATCH 21/22] Reduce view more movements --- .../src/routes/Moderation/components/ModerationQueue.css | 1 + .../coral-admin/src/routes/Moderation/components/ViewMore.css | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css index 78959be1b..465c7ccbd 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.css @@ -3,6 +3,7 @@ list-style: none; display: block; min-height: 650px; + margin-top: 16px; } :global(html) { diff --git a/client/coral-admin/src/routes/Moderation/components/ViewMore.css b/client/coral-admin/src/routes/Moderation/components/ViewMore.css index 8e80ea5ec..e5c572ef5 100644 --- a/client/coral-admin/src/routes/Moderation/components/ViewMore.css +++ b/client/coral-admin/src/routes/Moderation/components/ViewMore.css @@ -2,7 +2,6 @@ display: flex; justify-content: center; width: 100%; - margin-bottom: -8px; } .viewMore { @@ -15,7 +14,8 @@ cursor: pointer; text-transform: capitalize; margin: 0; - margin-bottom: 8px; + margin-top: -18px; + margin-bottom: -4px; } .viewMore:hover { From 9a0fb95d0674836afd86be5f1ef80eb9b0c45402 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 18 Dec 2017 15:03:54 +0100 Subject: [PATCH 22/22] Pagination fixes --- .../Moderation/components/Moderation.js | 1 + .../Moderation/components/ModerationQueue.js | 5 +- .../Moderation/containers/Moderation.js | 1 - .../src/routes/Moderation/graphql.js | 72 ++++++++++++------- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 12412b244..da0745acc 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -156,6 +156,7 @@ class Moderation extends Component { root={this.props.root} currentAsset={asset} comments={comments.nodes} + hasNextPage={comments.hasNextPage} activeTab={activeTab} singleView={moderation.singleView} selectedCommentId={moderation.selectedCommentId} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 6abe37d25..fbb116cd7 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -274,14 +274,13 @@ class ModerationQueue extends React.Component { // Last element of list is our AutoLoadMore component and contains an // id indicating that this is the last element in list. if (index === rowCount - 1) { - const hasMore = this.getCommentCountWithoutDagling() < this.props.commentCount; key = 'end-of-comment-list'; child = (
    - {hasMore && } @@ -427,6 +426,8 @@ ModerationQueue.propTypes = { selectedCommentId: PropTypes.string, singleView: PropTypes.bool, isLoadingMore: PropTypes.bool, + hasNextPage: PropTypes.bool, + comments: PropTypes.array, activeTab: PropTypes.string.isRequired, data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 38b5b67d9..9ea3baf00 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -207,7 +207,6 @@ class ModerationContainer extends Component { [tab]: { nodes: {$push: comments.nodes}, hasNextPage: {$set: comments.hasNextPage}, - startCursor: {$set: comments.startCursor}, endCursor: {$set: comments.endCursor}, }, }); diff --git a/client/coral-admin/src/routes/Moderation/graphql.js b/client/coral-admin/src/routes/Moderation/graphql.js index 13ff2ed5a..5758315eb 100644 --- a/client/coral-admin/src/routes/Moderation/graphql.js +++ b/client/coral-admin/src/routes/Moderation/graphql.js @@ -45,7 +45,7 @@ function shouldCommentBeAdded(root, queue, comment, sortOrder) { : new Date(comment.created_at) >= cursor; } -function addCommentToQueue(root, queue, comment, sortOrder) { +function addCommentToQueue(root, queue, comment, sortOrder, cleanup) { if (queueHasComment(root, queue, comment.id)) { return root; } @@ -54,24 +54,32 @@ function addCommentToQueue(root, queue, comment, sortOrder) { [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, }; - if (shouldCommentBeAdded(root, queue, comment, sortOrder)) { - const cursor = new Date(root[queue].startCursor); - const date = new Date(comment.created_at); - - let append = sortOrder === 'ASC' - ? date >= cursor - : date <= cursor; - - const nodes = append - ? root[queue].nodes.concat(comment) - : [comment].concat(...root[queue].nodes); - - changes[queue] = { - nodes: {$set: nodes}, - }; + if (!shouldCommentBeAdded(root, queue, comment, sortOrder)) { + return update(root, changes); } - return update(root, changes); + const cursor = new Date(root[queue].startCursor); + const date = new Date(comment.created_at); + + let append = sortOrder === 'ASC' + ? date >= cursor + : date <= cursor; + + const nodes = append + ? root[queue].nodes.concat(comment) + : [comment].concat(...root[queue].nodes); + + changes[queue] = { + nodes: {$set: nodes}, + }; + + const next = update(root, changes); + + if (!cleanup) { + return next; + } + + return cleanUpQueue(next, queue, sortOrder); } function sortComments(nodes, sortOrder) { @@ -138,17 +146,31 @@ function applyCommentChanges(root, comment, queueConfig) { /** * Remove dangling comments, sort and resize queues. + * If queueConfig is omitted, dangling comments are not removed. */ export function cleanUpQueue(root, queue, sortOrder, queueConfig) { + let nodes = root[queue].nodes; + let hasNextPage = root[queue].hasNextPage; + + if (queueConfig) { + nodes = root[queue].nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)); + } + + nodes = sortComments( + nodes, + sortOrder, + ); + + if (nodes.length > 2) { + nodes = nodes.slice(0, 2); + hasNextPage = true; + } + return update(root, { [queue]: { - nodes: { - $apply: (nodes) => - sortComments( - nodes.filter((comment) => commentBelongToQueue(queue, comment, queueConfig)), - sortOrder, - ).slice(0, 100), - }, + nodes: {$set: nodes}, + endCursor: {$set: nodes[nodes.length - 1].created_at}, + hasNextPage: {$set: hasNextPage}, }, }); } @@ -180,7 +202,7 @@ export function handleCommentChange(root, comment, sortOrder, notify, queueConfi Object.keys(queueConfig).forEach((queue) => { if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { - next = addCommentToQueue(next, queue, comment, sortOrder); + next = addCommentToQueue(next, queue, comment, sortOrder, activeQueue !== queue); if (notify && activeQueue === queue && isEndOfListVisible(root, queue)) { showNotificationOnce(); }